ng-form-foundry-transformers 0.5.0 → 0.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -155,6 +155,23 @@ enabled, so the output validates against the source schema (opt out with
155
155
  `schemaOptions: { optionalPresence: false }`); a required property mapping to a
156
156
  choice is marked `mandatory`.
157
157
 
158
+ **The schema must agree with the document on container shapes.** A covered
159
+ key whose schema says array where the document holds an object (or scalar
160
+ where it holds a collection) makes `toSchema` throw a `SchemaShapeError`
161
+ naming the path — such a form could not carry the section and saving would
162
+ erase it. Catch it to fall back to inferred editing. Scalar-vs-scalar
163
+ differences (a quoted `"0xe00"` under an `integer` schema) stay editable,
164
+ and an integral edit writes back an integer literal.
165
+
166
+ **The schema may cover any slice of the document.** Keys it does not mention
167
+ are governed by `unknownKeys` (YAML, JSON, and libconfig alike):
168
+ `'preserve'` (default) keeps them verbatim on save — a partial schema edits
169
+ its fields without erasing the rest of a real-world config; `'drop'` deletes
170
+ them — for an intentionally complete schema (sanitizing, template
171
+ enforcement); `'edit'` surfaces them as editable fields typed by the
172
+ document's own values, merged under the JSON Schema in document order, so
173
+ nothing in the file is invisible.
174
+
158
175
  ## JSON transformer
159
176
 
160
177
  Same as YAML, for **JSON config files** — `jsonTransformer.toSchema(json, { schema? })`
@@ -292,6 +309,23 @@ npm test # node:test on the compiled output (no Python needed)
292
309
 
293
310
  ## Status
294
311
 
312
+ `0.5.2` — **shape safety and operator-friendly display**: a container-shape
313
+ disagreement between document and schema (array declared where the document
314
+ holds an object, or scalar where it holds a collection — inside choices
315
+ included) throws a `SchemaShapeError` naming the path instead of producing
316
+ an empty form whose save would erase the section; a type-changing integral
317
+ edit writes an integer literal, never a float; and schema-driven fields
318
+ display hex/octal/binary values in the base the document wrote them, in
319
+ every `unknownKeys` mode. Adversarially proof-tested before release.
320
+
321
+ `0.5.1` — **partial JSON Schemas edit safely**: the new `unknownKeys` option
322
+ (YAML, JSON, libconfig) governs keys the schema does not cover — `'preserve'`
323
+ (default) keeps them verbatim on save, `'drop'` deletes them for
324
+ intentionally complete schemas, `'edit'` surfaces them as editable fields
325
+ typed by the document's own values. The YAML binding is now
326
+ `YamlBinding { doc, schema? }` instead of the bare parsed document (bindings
327
+ are opaque; code that hands them straight back to `toSource` is unaffected).
328
+
295
329
  `0.5.0` — the **libconfig transformer is stable**: the one-time beta warning
296
330
  is gone (and with it the `resetLibconfigBetaWarning` helper), and the format's
297
331
  guarantees and known limitations are documented in the transformers guide.
@@ -14,3 +14,4 @@ export type { NodeGroup, NodeType, Leaf, LeafList, NodeGroupList, Choice, Choice
14
14
  export { CASE_KEY } from './schema';
15
15
  export type { Thesaurus, ThesaurusEntry } from './schema';
16
16
  export { applyThesaurus } from './thesaurus';
17
+ export { SchemaShapeError } from './shape-check';
@@ -7,7 +7,7 @@
7
7
  * map a JSON Schema) that any data-oriented transformer reuses.
8
8
  */
9
9
  Object.defineProperty(exports, "__esModule", { value: true });
10
- exports.applyThesaurus = exports.CASE_KEY = exports.jsonSchemaToNodeGroup = exports.inferNodeGroup = exports.TransformerRegistry = void 0;
10
+ exports.SchemaShapeError = exports.applyThesaurus = exports.CASE_KEY = exports.jsonSchemaToNodeGroup = exports.inferNodeGroup = exports.TransformerRegistry = void 0;
11
11
  var registry_1 = require("./registry");
12
12
  Object.defineProperty(exports, "TransformerRegistry", { enumerable: true, get: function () { return registry_1.TransformerRegistry; } });
13
13
  // Shared schema builders — used by the YAML and JSON transformers alike.
@@ -19,3 +19,5 @@ var schema_1 = require("./schema");
19
19
  Object.defineProperty(exports, "CASE_KEY", { enumerable: true, get: function () { return schema_1.CASE_KEY; } });
20
20
  var thesaurus_1 = require("./thesaurus");
21
21
  Object.defineProperty(exports, "applyThesaurus", { enumerable: true, get: function () { return thesaurus_1.applyThesaurus; } });
22
+ var shape_check_1 = require("./shape-check");
23
+ Object.defineProperty(exports, "SchemaShapeError", { enumerable: true, get: function () { return shape_check_1.SchemaShapeError; } });
@@ -1,7 +1,8 @@
1
1
  import type { NodeGroup } from './schema';
2
2
  /**
3
- * Infer a root {@link NodeGroup} from a parsed data object when no schema is
4
- * given. Structure and types come from the values themselves:
3
+ * Infer a root {@link NodeGroup} from a parsed data object the whole form
4
+ * when no JSON Schema is given, and the layer `mergeInferred` overlays under
5
+ * `unknownKeys: 'edit'`. Structure and types come from the values themselves:
5
6
  *
6
7
  * - object → nodeGroup (recursed)
7
8
  * - array of objects → nodeGroupList (item schema = union of the items' keys)
@@ -3,8 +3,9 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.inferNodeGroup = inferNodeGroup;
4
4
  const ROOT = '__root__';
5
5
  /**
6
- * Infer a root {@link NodeGroup} from a parsed data object when no schema is
7
- * given. Structure and types come from the values themselves:
6
+ * Infer a root {@link NodeGroup} from a parsed data object the whole form
7
+ * when no JSON Schema is given, and the layer `mergeInferred` overlays under
8
+ * `unknownKeys: 'edit'`. Structure and types come from the values themselves:
8
9
  *
9
10
  * - object → nodeGroup (recursed)
10
11
  * - array of objects → nodeGroupList (item schema = union of the items' keys)
@@ -21,7 +22,9 @@ function inferNodeGroup(data, name = ROOT) {
21
22
  return group;
22
23
  }
23
24
  function objectToNodeGroup(data, name) {
24
- const children = {};
25
+ // Null prototype: data keys are arbitrary, and assigning a `__proto__` key
26
+ // onto a plain object would silently set the record's prototype instead.
27
+ const children = Object.create(null);
25
28
  for (const [key, value] of Object.entries(data)) {
26
29
  children[key] = inferNode(key, value);
27
30
  }
@@ -45,7 +48,7 @@ function inferArray(name, items) {
45
48
  }
46
49
  /** A representative object with every key seen across `items`, each mapped to the first non-null sample. */
47
50
  function unionKeys(items) {
48
- const merged = {};
51
+ const merged = Object.create(null);
49
52
  for (const item of items) {
50
53
  for (const [key, value] of Object.entries(item)) {
51
54
  if (!(key in merged) || (merged[key] == null && value != null))
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Schema union for `unknownKeys: 'edit'`: overlay a user-supplied JSON
3
+ * Schema's {@link NodeGroup} onto the schema *inferred from the document
4
+ * itself*, so covered keys keep their typed, validated, labeled nodes while
5
+ * every key the JSON Schema does not mention still renders — editable, typed
6
+ * by inference (see `core/infer.ts` and the per-format inference in the
7
+ * transformers). Counterpart of `core/schema-keys.ts`, which handles the
8
+ * *preserve-invisibly* answer to the same question.
9
+ */
10
+ import type { NodeGroup } from './schema';
11
+ /**
12
+ * Merge per key, recursively. Document order wins for placement: the
13
+ * inferred children mirror the source document, so present keys render in
14
+ * file order with schema-only keys (e.g. presence-able additions) appended
15
+ * after, in schema order. Where both sides describe a key, the schema wins —
16
+ * except that groups and group-lists merge structurally, so a partially
17
+ * covered subtree keeps its uncovered fields at any depth. Group metadata
18
+ * (name, label, presence, bounds) comes from the schema side.
19
+ */
20
+ export declare function mergeInferred(schema: NodeGroup, inferred: NodeGroup): NodeGroup;
@@ -0,0 +1,46 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.mergeInferred = mergeInferred;
4
+ /**
5
+ * Merge per key, recursively. Document order wins for placement: the
6
+ * inferred children mirror the source document, so present keys render in
7
+ * file order with schema-only keys (e.g. presence-able additions) appended
8
+ * after, in schema order. Where both sides describe a key, the schema wins —
9
+ * except that groups and group-lists merge structurally, so a partially
10
+ * covered subtree keeps its uncovered fields at any depth. Group metadata
11
+ * (name, label, presence, bounds) comes from the schema side.
12
+ */
13
+ function mergeInferred(schema, inferred) {
14
+ const children = Object.create(null);
15
+ for (const [name, inferredChild] of Object.entries(inferred.children)) {
16
+ const schemaChild = schema.children[name];
17
+ children[name] = schemaChild ? mergeNode(schemaChild, inferredChild) : inferredChild;
18
+ }
19
+ for (const [name, schemaChild] of Object.entries(schema.children)) {
20
+ if (!(name in children))
21
+ children[name] = schemaChild;
22
+ }
23
+ return { ...schema, children };
24
+ }
25
+ /**
26
+ * One key described by both sides: groups and group-lists merge recursively;
27
+ * everything else — leaves, choices, maps, and shape disagreements — takes
28
+ * the schema node, which is authoritative wherever it speaks. One inferred
29
+ * fact does carry over: a leaf's `radix` display hint, which only the
30
+ * document knows (JSON Schema has no radix vocabulary).
31
+ */
32
+ function mergeNode(schema, inferred) {
33
+ if (schema.kind === 'nodeGroup' && inferred.kind === 'nodeGroup') {
34
+ return mergeInferred(schema, inferred);
35
+ }
36
+ if (schema.kind === 'nodeGroupList' && inferred.kind === 'nodeGroupList') {
37
+ return { ...schema, type: mergeInferred(schema.type, inferred.type) };
38
+ }
39
+ if ((schema.kind === 'leaf' || schema.kind === 'leafList') &&
40
+ schema.kind === inferred.kind &&
41
+ inferred.radix &&
42
+ !schema.radix) {
43
+ return { ...schema, radix: inferred.radix };
44
+ }
45
+ return schema;
46
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Schema-born key resolution for partial-schema reverts.
3
+ *
4
+ * A transformer given a JSON Schema builds its form from the produced
5
+ * {@link NodeGroup} — so `serializeForm` can only ever emit keys that schema
6
+ * describes. When the edited value is written back onto the original
7
+ * document, the value is authoritative for those **schema-born** paths only;
8
+ * every other key was never carried by the form and must be preserved.
9
+ * These helpers answer "which keys can the value legitimately speak for?" at
10
+ * each document level, mirroring the wire encoding of
11
+ * ng-form-foundry's `serializeForm`/`toWireValue`.
12
+ */
13
+ import type { Choice, NodeGroup, NodeType } from './schema';
14
+ /**
15
+ * The schema-born key set of one document level, as `name → child schema`
16
+ * for recursion. `undefined` means "no schema in play": every key is
17
+ * value-authoritative. A key absent from a defined map is not schema-born
18
+ * and must be preserved verbatim.
19
+ */
20
+ export type SchemaKeys = Map<string, NodeType | undefined> | undefined;
21
+ /**
22
+ * The keys `serializeForm` can emit for a schema node standing at a document
23
+ * object/group, with each key's schema for recursion. A nodeGroup
24
+ * contributes its children by key — a choice child included, since the wire
25
+ * value keeps a choice at its own key (`toWireValue` strips only the case
26
+ * discriminator). Standing at a choice, the keys are the union of every
27
+ * case's fields: the form only emits the active case, so the other cases'
28
+ * keys behave like any non-emitted schema key. A map is open-keyed — every
29
+ * key is schema-born and entries recurse with the map's value schema.
30
+ */
31
+ export declare function childrenOf(node: NodeType): SchemaKeys;
32
+ /** The per-item schema when `node` describes a list of groups, else undefined. */
33
+ export declare function itemSchemaOf(node: NodeType | undefined): NodeGroup | undefined;
34
+ /**
35
+ * A case body is a single node exactly when its `kind` is a *string* — a
36
+ * record body whose fields include one literally named `kind` (k8s-style
37
+ * schemas) holds a NodeType there, not a string. The `in` operator cannot
38
+ * tell the two apart.
39
+ */
40
+ export declare function isSingleNodeBody(body: Choice['cases'][string]): body is NodeType;
@@ -0,0 +1,67 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.childrenOf = childrenOf;
4
+ exports.itemSchemaOf = itemSchemaOf;
5
+ exports.isSingleNodeBody = isSingleNodeBody;
6
+ /**
7
+ * The keys `serializeForm` can emit for a schema node standing at a document
8
+ * object/group, with each key's schema for recursion. A nodeGroup
9
+ * contributes its children by key — a choice child included, since the wire
10
+ * value keeps a choice at its own key (`toWireValue` strips only the case
11
+ * discriminator). Standing at a choice, the keys are the union of every
12
+ * case's fields: the form only emits the active case, so the other cases'
13
+ * keys behave like any non-emitted schema key. A map is open-keyed — every
14
+ * key is schema-born and entries recurse with the map's value schema.
15
+ */
16
+ function childrenOf(node) {
17
+ switch (node.kind) {
18
+ case 'nodeGroup':
19
+ return new Map(Object.entries(node.children));
20
+ case 'choice': {
21
+ const out = new Map();
22
+ for (const [name, field] of caseEntries(node))
23
+ out.set(name, field);
24
+ return out;
25
+ }
26
+ case 'map':
27
+ return new OpenKeys(node.value);
28
+ default:
29
+ return undefined; // leaf/leafList/list shapes carry no key semantics
30
+ }
31
+ }
32
+ /** The per-item schema when `node` describes a list of groups, else undefined. */
33
+ function itemSchemaOf(node) {
34
+ return node?.kind === 'nodeGroupList' ? node.type : undefined;
35
+ }
36
+ /**
37
+ * A case body is a single node exactly when its `kind` is a *string* — a
38
+ * record body whose fields include one literally named `kind` (k8s-style
39
+ * schemas) holds a NodeType there, not a string. The `in` operator cannot
40
+ * tell the two apart.
41
+ */
42
+ function isSingleNodeBody(body) {
43
+ return typeof body.kind === 'string';
44
+ }
45
+ /** Field name → schema across every case of a choice (leaf-bodied cases too). */
46
+ function* caseEntries(choice) {
47
+ for (const body of Object.values(choice.cases)) {
48
+ if (isSingleNodeBody(body))
49
+ yield [body.name, body];
50
+ else
51
+ for (const [name, field] of Object.entries(body))
52
+ yield [name, field];
53
+ }
54
+ }
55
+ /** A `SchemaKeys` map matching every key — a map node's open dictionary. */
56
+ class OpenKeys extends Map {
57
+ constructor(valueSchema) {
58
+ super();
59
+ this.valueSchema = valueSchema;
60
+ }
61
+ has(_key) {
62
+ return true;
63
+ }
64
+ get(_key) {
65
+ return this.valueSchema;
66
+ }
67
+ }
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Container-shape validation between a parsed document and a user-supplied
3
+ * JSON Schema's {@link NodeGroup}.
4
+ *
5
+ * A schema that declares a section a list while the document holds a group
6
+ * (or scalar, or vice versa) produces a form that cannot carry the section's
7
+ * contents — the form builder coerces the unusable initial value to an empty
8
+ * control, the form looks valid, and a save erases the section. For config
9
+ * files that program live systems, that silent path is the worst failure
10
+ * mode, so schema-driven `toSchema` refuses up front: the consumer catches
11
+ * {@link SchemaShapeError} and falls back to inferred editing or fixes the
12
+ * schema. Scalar-vs-scalar differences are *not* errors — a quoted `"0xe00"`
13
+ * under an `integer` schema is a legitimate wild-file shape the form can
14
+ * carry and edit.
15
+ */
16
+ import type { NodeGroup } from './schema';
17
+ /** A document/schema container-shape disagreement, pointing at the key path. */
18
+ export declare class SchemaShapeError extends Error {
19
+ /** Path of the offending key, e.g. `gNBs` or `cells[0].id`. */
20
+ readonly path: string;
21
+ /** What the document holds there. */
22
+ readonly found: 'object' | 'array' | 'scalar';
23
+ /** What the schema declares there. */
24
+ readonly expected: 'object' | 'array' | 'scalar';
25
+ constructor(
26
+ /** Path of the offending key, e.g. `gNBs` or `cells[0].id`. */
27
+ path: string,
28
+ /** What the document holds there. */
29
+ found: 'object' | 'array' | 'scalar',
30
+ /** What the schema declares there. */
31
+ expected: 'object' | 'array' | 'scalar');
32
+ }
33
+ /**
34
+ * Walk the extracted document value against the JSON-Schema-derived schema
35
+ * and throw {@link SchemaShapeError} on the first container-kind
36
+ * disagreement. Only schema-covered keys are checked (absent keys are
37
+ * presence, uncovered keys are inference's business, and inference always
38
+ * matches the document by construction).
39
+ */
40
+ export declare function assertSchemaShapes(data: unknown, schema: NodeGroup): void;
@@ -0,0 +1,147 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SchemaShapeError = void 0;
4
+ exports.assertSchemaShapes = assertSchemaShapes;
5
+ const schema_keys_1 = require("./schema-keys");
6
+ /** A document/schema container-shape disagreement, pointing at the key path. */
7
+ class SchemaShapeError extends Error {
8
+ constructor(
9
+ /** Path of the offending key, e.g. `gNBs` or `cells[0].id`. */
10
+ path,
11
+ /** What the document holds there. */
12
+ found,
13
+ /** What the schema declares there. */
14
+ expected) {
15
+ super(`'${path}': the document holds ${article(found)} here, but the schema expects ` +
16
+ `${article(expected)} — the form cannot carry its contents, and saving would erase them. ` +
17
+ `Align the schema with the document, or leave this key out of the schema.`);
18
+ this.path = path;
19
+ this.found = found;
20
+ this.expected = expected;
21
+ this.name = 'SchemaShapeError';
22
+ }
23
+ }
24
+ exports.SchemaShapeError = SchemaShapeError;
25
+ function article(shape) {
26
+ return shape === 'array' ? 'an array' : shape === 'object' ? 'an object' : 'a scalar';
27
+ }
28
+ /**
29
+ * Walk the extracted document value against the JSON-Schema-derived schema
30
+ * and throw {@link SchemaShapeError} on the first container-kind
31
+ * disagreement. Only schema-covered keys are checked (absent keys are
32
+ * presence, uncovered keys are inference's business, and inference always
33
+ * matches the document by construction).
34
+ */
35
+ function assertSchemaShapes(data, schema) {
36
+ checkNode(data, schema, '');
37
+ }
38
+ function checkNode(value, node, path) {
39
+ // Absent is presence semantics; null is either a nullable leaf's value or
40
+ // the empty-container idiom (YAML `section:`) — nothing exists to erase.
41
+ if (value == null)
42
+ return;
43
+ switch (node.kind) {
44
+ case 'leaf':
45
+ // Any primitive is carryable (string carries, quoted ints). An *empty*
46
+ // collection is too: the libconfig empty-collection carry under a
47
+ // string leaf round-trips as a no-op — only content can be erased.
48
+ if (isRecord(value))
49
+ throw new SchemaShapeError(path, 'object', 'scalar');
50
+ if (Array.isArray(value) && value.length > 0)
51
+ throw new SchemaShapeError(path, 'array', 'scalar');
52
+ return;
53
+ case 'leafList':
54
+ if (!Array.isArray(value))
55
+ throw new SchemaShapeError(path, shapeOf(value), 'array');
56
+ value.forEach((item, i) => {
57
+ if (isRecord(item) || Array.isArray(item)) {
58
+ throw new SchemaShapeError(`${path}[${i}]`, shapeOf(item), 'scalar');
59
+ }
60
+ });
61
+ return;
62
+ case 'nodeGroupList':
63
+ if (!Array.isArray(value))
64
+ throw new SchemaShapeError(path, shapeOf(value), 'array');
65
+ value.forEach((item, i) => {
66
+ if (!isRecord(item))
67
+ throw new SchemaShapeError(`${path}[${i}]`, shapeOf(item), 'object');
68
+ checkChildren(item, node.type, `${path}[${i}]`);
69
+ });
70
+ return;
71
+ case 'nodeGroup':
72
+ case 'map':
73
+ if (!isRecord(value))
74
+ throw new SchemaShapeError(path, shapeOf(value), 'object');
75
+ checkChildren(value, node, path);
76
+ return;
77
+ case 'choice': {
78
+ // The document's shape must be one some case can carry: a record for
79
+ // record-bodied cases, an array for a collection-bodied case, a scalar
80
+ // for a leaf-bodied case. An array under object-only cases (or a
81
+ // record under scalar-only cases) is exactly the uncarryable-section
82
+ // erasure this check exists to refuse.
83
+ const allowed = caseShapes(node);
84
+ const found = shapeOf(value);
85
+ if (!allowed.has(found)) {
86
+ throw new SchemaShapeError(path, found, preferredShape(allowed));
87
+ }
88
+ if (isRecord(value))
89
+ checkChildren(value, node, path);
90
+ return;
91
+ }
92
+ }
93
+ }
94
+ /** The container shapes a choice's cases can carry, unioned across cases. */
95
+ function caseShapes(choice) {
96
+ const shapes = new Set();
97
+ for (const body of Object.values(choice.cases)) {
98
+ if (!(0, schema_keys_1.isSingleNodeBody)(body)) {
99
+ shapes.add('object'); // a record of named fields
100
+ continue;
101
+ }
102
+ switch (body.kind) {
103
+ case 'nodeGroup':
104
+ case 'map':
105
+ shapes.add('object');
106
+ break;
107
+ case 'nodeGroupList':
108
+ case 'leafList':
109
+ shapes.add('array');
110
+ break;
111
+ case 'leaf':
112
+ shapes.add('scalar');
113
+ break;
114
+ case 'choice': // a nested choice's own cases are unknowable here: permissive
115
+ shapes.add('object').add('array').add('scalar');
116
+ break;
117
+ }
118
+ }
119
+ return shapes;
120
+ }
121
+ /** A single representative shape for the error message, objects first. */
122
+ function preferredShape(allowed) {
123
+ if (allowed.has('object'))
124
+ return 'object';
125
+ if (allowed.has('array'))
126
+ return 'array';
127
+ return 'scalar';
128
+ }
129
+ /** Recurse a record's own keys through the node's schema-born children. */
130
+ function checkChildren(record, node, path) {
131
+ const keys = (0, schema_keys_1.childrenOf)(node);
132
+ if (!keys)
133
+ return;
134
+ for (const key of Object.keys(record)) {
135
+ const child = keys.get(key);
136
+ if (child)
137
+ checkNode(record[key], child, path ? `${path}.${key}` : key);
138
+ }
139
+ }
140
+ function shapeOf(value) {
141
+ if (Array.isArray(value))
142
+ return 'array';
143
+ return isRecord(value) ? 'object' : 'scalar';
144
+ }
145
+ function isRecord(v) {
146
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
147
+ }
@@ -6,3 +6,4 @@
6
6
  */
7
7
  export { jsonTransformer } from './json-transformer';
8
8
  export type { JsonOptions, JsonFormat } from './json-transformer';
9
+ export { SchemaShapeError } from '../../core/shape-check';
@@ -6,6 +6,10 @@
6
6
  * schema builders in `core`.
7
7
  */
8
8
  Object.defineProperty(exports, "__esModule", { value: true });
9
- exports.jsonTransformer = void 0;
9
+ exports.SchemaShapeError = exports.jsonTransformer = void 0;
10
10
  var json_transformer_1 = require("./json-transformer");
11
11
  Object.defineProperty(exports, "jsonTransformer", { enumerable: true, get: function () { return json_transformer_1.jsonTransformer; } });
12
+ // Thrown by schema-driven toSchema; re-exported so subpath consumers can
13
+ // catch it by instance.
14
+ var shape_check_1 = require("../../core/shape-check");
15
+ Object.defineProperty(exports, "SchemaShapeError", { enumerable: true, get: function () { return shape_check_1.SchemaShapeError; } });
@@ -1,4 +1,4 @@
1
- import type { FormValue, Thesaurus } from '../../core/schema';
1
+ import type { FormValue, NodeGroup, Thesaurus } from '../../core/schema';
2
2
  import type { TransformResult } from '../../core/transformer';
3
3
  import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
4
4
  /** Options for {@link jsonTransformer}'s `toSchema`. */
@@ -19,6 +19,18 @@ export interface JsonOptions {
19
19
  * case-insensitively; never paths.
20
20
  */
21
21
  thesaurus?: Thesaurus;
22
+ /**
23
+ * Schema-driven mode only: what happens to keys the JSON Schema does not
24
+ * cover. `'preserve'` (default) keeps them — the form never carried them,
25
+ * so a partial schema edits its slice without erasing the rest. `'drop'`
26
+ * makes the edited value authoritative for the whole document, deleting
27
+ * uncovered keys — for consumers whose schema is intentionally complete.
28
+ * `'edit'` surfaces them instead: uncovered keys render as editable fields
29
+ * typed by the data (the inferred schema merged under the JSON Schema), so
30
+ * nothing is invisible and the value covers the whole document. Ignored
31
+ * without a `schema`.
32
+ */
33
+ unknownKeys?: 'preserve' | 'drop' | 'edit';
22
34
  }
23
35
  /** How the source was formatted, so `toSource` re-emits it the same way. */
24
36
  export interface JsonFormat {
@@ -32,6 +44,19 @@ export interface JsonFormat {
32
44
  * them back as unquoted numbers so precision survives the round-trip.
33
45
  */
34
46
  bigInts: string[];
47
+ /**
48
+ * Present in schema-driven mode with `unknownKeys: 'preserve'` (the
49
+ * default) or `'edit'`: the original parsed data and the NodeGroup the
50
+ * form was built from (the JSON Schema's own under `'preserve'`, the
51
+ * inferred-merged one under `'edit'`). `toSource` then treats the value as
52
+ * authoritative for schema-born paths only and merges it over the
53
+ * original, so uncovered keys survive in their original key order — under
54
+ * `'edit'` that protection is left covering only the keys no form field
55
+ * can carry (e.g. a key inside a covered choice's object that no case
56
+ * names).
57
+ */
58
+ original?: FormValue;
59
+ schema?: NodeGroup;
35
60
  }
36
61
  /**
37
62
  * A JSON {@link Transformer}: turn a JSON config document into a form and write
@@ -39,12 +64,15 @@ export interface JsonFormat {
39
64
  * `core` builders the YAML transformer uses — only parsing (`JSON.parse`) and
40
65
  * serialization (`JSON.stringify`) are JSON-specific.
41
66
  *
42
- * Standard JSON has no comments, so revert re-serializes the edited value,
43
- * preserving the source's indent width and trailing newline. Key order follows
44
- * the form value (which follows the schema, derived from the original), so it is
45
- * preserved for untouched keys. For JSON **with comments** (JSONC), parse and
46
- * edit it as YAML with {@link import('../yaml/yaml-transformer').yamlTransformer}
47
- * instead `JSON.parse` rejects comments.
67
+ * Standard JSON has no comments, so revert re-serializes, preserving the
68
+ * source's indent width and trailing newline. In schema-driven mode with
69
+ * `unknownKeys: 'preserve'` or `'edit'` the value is merged over the original
70
+ * data at schema-born paths ({@link mergeSchemaBorn}), so uncovered keys and
71
+ * the original key order survive; otherwise the edited value is serialized
72
+ * as-is, key order following the form value. For JSON **with comments**
73
+ * (JSONC), parse and edit it as YAML with
74
+ * {@link import('../yaml/yaml-transformer').yamlTransformer} instead —
75
+ * `JSON.parse` rejects comments.
48
76
  *
49
77
  * Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
50
78
  * as strings in the form value and re-emitted verbatim as unquoted numbers (the