ng-form-foundry-transformers 0.5.0 → 0.5.1

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,15 @@ 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 may cover any slice of the document.** Keys it does not mention
159
+ are governed by `unknownKeys` (YAML, JSON, and libconfig alike):
160
+ `'preserve'` (default) keeps them verbatim on save — a partial schema edits
161
+ its fields without erasing the rest of a real-world config; `'drop'` deletes
162
+ them — for an intentionally complete schema (sanitizing, template
163
+ enforcement); `'edit'` surfaces them as editable fields typed by the
164
+ document's own values, merged under the JSON Schema in document order, so
165
+ nothing in the file is invisible.
166
+
158
167
  ## JSON transformer
159
168
 
160
169
  Same as YAML, for **JSON config files** — `jsonTransformer.toSchema(json, { schema? })`
@@ -292,6 +301,14 @@ npm test # node:test on the compiled output (no Python needed)
292
301
 
293
302
  ## Status
294
303
 
304
+ `0.5.1` — **partial JSON Schemas edit safely**: the new `unknownKeys` option
305
+ (YAML, JSON, libconfig) governs keys the schema does not cover — `'preserve'`
306
+ (default) keeps them verbatim on save, `'drop'` deletes them for
307
+ intentionally complete schemas, `'edit'` surfaces them as editable fields
308
+ typed by the document's own values. The YAML binding is now
309
+ `YamlBinding { doc, schema? }` instead of the bare parsed document (bindings
310
+ are opaque; code that hands them straight back to `toSource` is unaffected).
311
+
295
312
  `0.5.0` — the **libconfig transformer is stable**: the one-time beta warning
296
313
  is gone (and with it the `resetLibconfigBetaWarning` helper), and the format's
297
314
  guarantees and known limitations are documented in the transformers guide.
@@ -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,33 @@
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 { 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;
@@ -0,0 +1,57 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.childrenOf = childrenOf;
4
+ exports.itemSchemaOf = itemSchemaOf;
5
+ /**
6
+ * The keys `serializeForm` can emit for a schema node standing at a document
7
+ * object/group, with each key's schema for recursion. A nodeGroup
8
+ * contributes its children by key — a choice child included, since the wire
9
+ * value keeps a choice at its own key (`toWireValue` strips only the case
10
+ * discriminator). Standing at a choice, the keys are the union of every
11
+ * case's fields: the form only emits the active case, so the other cases'
12
+ * keys behave like any non-emitted schema key. A map is open-keyed — every
13
+ * key is schema-born and entries recurse with the map's value schema.
14
+ */
15
+ function childrenOf(node) {
16
+ switch (node.kind) {
17
+ case 'nodeGroup':
18
+ return new Map(Object.entries(node.children));
19
+ case 'choice': {
20
+ const out = new Map();
21
+ for (const [name, field] of caseEntries(node))
22
+ out.set(name, field);
23
+ return out;
24
+ }
25
+ case 'map':
26
+ return new OpenKeys(node.value);
27
+ default:
28
+ return undefined; // leaf/leafList/list shapes carry no key semantics
29
+ }
30
+ }
31
+ /** The per-item schema when `node` describes a list of groups, else undefined. */
32
+ function itemSchemaOf(node) {
33
+ return node?.kind === 'nodeGroupList' ? node.type : undefined;
34
+ }
35
+ /** Field name → schema across every case of a choice (leaf-bodied cases too). */
36
+ function* caseEntries(choice) {
37
+ for (const body of Object.values(choice.cases)) {
38
+ if ('kind' in body)
39
+ yield [body.name, body];
40
+ else
41
+ for (const [name, field] of Object.entries(body))
42
+ yield [name, field];
43
+ }
44
+ }
45
+ /** A `SchemaKeys` map matching every key — a map node's open dictionary. */
46
+ class OpenKeys extends Map {
47
+ constructor(valueSchema) {
48
+ super();
49
+ this.valueSchema = valueSchema;
50
+ }
51
+ has(_key) {
52
+ return true;
53
+ }
54
+ get(_key) {
55
+ return this.valueSchema;
56
+ }
57
+ }
@@ -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
@@ -5,18 +5,23 @@ const thesaurus_1 = require("../../core/thesaurus");
5
5
  const infer_1 = require("../../core/infer");
6
6
  const json_schema_1 = require("../../core/json-schema");
7
7
  const bigint_1 = require("../../core/bigint");
8
+ const schema_keys_1 = require("../../core/schema-keys");
9
+ const merge_inferred_1 = require("../../core/merge-inferred");
8
10
  /**
9
11
  * A JSON {@link Transformer}: turn a JSON config document into a form and write
10
12
  * the edited value back to JSON. The form is built by the same format-agnostic
11
13
  * `core` builders the YAML transformer uses — only parsing (`JSON.parse`) and
12
14
  * serialization (`JSON.stringify`) are JSON-specific.
13
15
  *
14
- * Standard JSON has no comments, so revert re-serializes the edited value,
15
- * preserving the source's indent width and trailing newline. Key order follows
16
- * the form value (which follows the schema, derived from the original), so it is
17
- * preserved for untouched keys. For JSON **with comments** (JSONC), parse and
18
- * edit it as YAML with {@link import('../yaml/yaml-transformer').yamlTransformer}
19
- * instead `JSON.parse` rejects comments.
16
+ * Standard JSON has no comments, so revert re-serializes, preserving the
17
+ * source's indent width and trailing newline. In schema-driven mode with
18
+ * `unknownKeys: 'preserve'` or `'edit'` the value is merged over the original
19
+ * data at schema-born paths ({@link mergeSchemaBorn}), so uncovered keys and
20
+ * the original key order survive; otherwise the edited value is serialized
21
+ * as-is, key order following the form value. For JSON **with comments**
22
+ * (JSONC), parse and edit it as YAML with
23
+ * {@link import('../yaml/yaml-transformer').yamlTransformer} instead —
24
+ * `JSON.parse` rejects comments.
20
25
  *
21
26
  * Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
22
27
  * as strings in the form value and re-emitted verbatim as unquoted numbers (the
@@ -28,26 +33,80 @@ exports.jsonTransformer = {
28
33
  const bigInts = [];
29
34
  const parsed = JSON.parse(source, bigIntReviver);
30
35
  const data = collectBigInts(parsed ?? {}, [], bigInts);
31
- const schema = options?.schema
36
+ const unknownKeys = options?.unknownKeys ?? 'preserve';
37
+ const fromJsonSchema = options?.schema
32
38
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
33
- : (0, infer_1.inferNodeGroup)(data, options?.rootName);
39
+ : undefined;
40
+ const schema = fromJsonSchema && unknownKeys === 'edit'
41
+ ? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, infer_1.inferNodeGroup)(data, options?.rootName))
42
+ : fromJsonSchema ?? (0, infer_1.inferNodeGroup)(data, options?.rootName);
34
43
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
44
+ // 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit' on
45
+ // the merged one (schema-born ≈ everything, but keys no form field can
46
+ // carry stay protected); 'drop' and inferred mode leave the value
47
+ // authoritative for the whole document.
48
+ const preserveUnknown = fromJsonSchema != null && unknownKeys !== 'drop';
35
49
  const binding = {
36
50
  indent: detectIndent(source),
37
51
  trailingNewline: source.endsWith('\n'),
38
52
  bigInts,
53
+ original: preserveUnknown ? data : undefined,
54
+ schema: preserveUnknown ? schema : undefined,
39
55
  };
40
56
  return { schema: labeled, binding, initialValue: data };
41
57
  },
42
58
  toSource(value, binding) {
59
+ const merged = binding.schema && binding.original !== undefined
60
+ ? mergeSchemaBorn(binding.original, value, (0, schema_keys_1.childrenOf)(binding.schema))
61
+ : value;
43
62
  const paths = new Set(binding.bigInts);
44
- const prepared = paths.size ? markBigInts(value, [], paths) : value;
63
+ const prepared = paths.size ? markBigInts(merged, [], paths) : merged;
45
64
  let text = JSON.stringify(prepared, null, binding.indent);
46
65
  if (paths.size)
47
66
  text = text.replace(BIGINT_MARK_RE, '$1');
48
67
  return binding.trailingNewline ? text + '\n' : text;
49
68
  },
50
69
  };
70
+ /**
71
+ * Merge the edited value over the original data, value-authoritative for
72
+ * schema-born keys only: an uncovered key keeps its original value and its
73
+ * position in the key order; a covered key takes the edited value (recursing
74
+ * where both sides are objects, and per index into arrays of groups); a
75
+ * covered key absent from the value is omitted (a presence toggle off);
76
+ * covered keys new to the document append after the originals.
77
+ */
78
+ function mergeSchemaBorn(original, value, schema) {
79
+ if (!isPlainObject(original) || !isPlainObject(value) || !schema)
80
+ return value;
81
+ // Null-prototype output and own-key checks: document keys are arbitrary, so
82
+ // `__proto__` must stay a data key and `toString`/`constructor` must not
83
+ // resolve through the prototype chain (which would drop or fabricate keys).
84
+ const out = Object.create(null);
85
+ for (const key of Object.keys(original)) {
86
+ if (!schema.has(key)) {
87
+ out[key] = original[key]; // not schema-born: verbatim, in place
88
+ }
89
+ else if (hasOwn(value, key)) {
90
+ out[key] = mergeChild(original[key], value[key], schema.get(key));
91
+ } // covered + absent: deleted
92
+ }
93
+ for (const key of Object.keys(value)) {
94
+ if (!(key in out) && !hasOwn(original, key) && schema.has(key))
95
+ out[key] = value[key];
96
+ }
97
+ return out;
98
+ }
99
+ /** Per-key recursion: objects merge by their child schema, group arrays per index. */
100
+ function mergeChild(original, value, schema) {
101
+ if (schema && isPlainObject(original) && isPlainObject(value)) {
102
+ return mergeSchemaBorn(original, value, (0, schema_keys_1.childrenOf)(schema));
103
+ }
104
+ const itemSchema = (0, schema_keys_1.itemSchemaOf)(schema);
105
+ if (itemSchema && Array.isArray(original) && Array.isArray(value)) {
106
+ return value.map((item, i) => i < original.length ? mergeChild(original[i], item, itemSchema) : item);
107
+ }
108
+ return value;
109
+ }
51
110
  /** Indent width (in spaces) of the first indented line, or 2 if none/tabs. */
52
111
  function detectIndent(source) {
53
112
  const match = source.match(/\n( +)\S/);
@@ -102,7 +161,8 @@ function collectBigInts(node, path, out) {
102
161
  if (Array.isArray(node))
103
162
  return node.map((v, i) => collectBigInts(v, [...path, i], out));
104
163
  if (isPlainObject(node)) {
105
- const res = {};
164
+ // Null prototype: a `__proto__` document key must stay a data key.
165
+ const res = Object.create(null);
106
166
  for (const key of Object.keys(node))
107
167
  res[key] = collectBigInts(node[key], [...path, key], out);
108
168
  return res;
@@ -121,7 +181,7 @@ function markBigInts(node, path, paths) {
121
181
  if (Array.isArray(node))
122
182
  return node.map((v, i) => markBigInts(v, [...path, i], paths));
123
183
  if (isPlainObject(node)) {
124
- const res = {};
184
+ const res = Object.create(null);
125
185
  for (const key of Object.keys(node))
126
186
  res[key] = markBigInts(node[key], [...path, key], paths);
127
187
  return res;
@@ -131,3 +191,7 @@ function markBigInts(node, path, paths) {
131
191
  function isPlainObject(v) {
132
192
  return typeof v === 'object' && v !== null && !Array.isArray(v);
133
193
  }
194
+ /** Own-key membership, immune to `Object.prototype` members and null-prototype objects. */
195
+ function hasOwn(obj, key) {
196
+ return Object.prototype.hasOwnProperty.call(obj, key);
197
+ }
@@ -12,7 +12,7 @@
12
12
  * are then shown read-only. A JSON Schema in the options unlocks typed empty
13
13
  * collections, presence toggles for optional settings, enums, and ranges.
14
14
  */
15
- import type { FormValue, Thesaurus } from '../../core/schema';
15
+ import type { FormValue, NodeGroup, Thesaurus } from '../../core/schema';
16
16
  import type { TransformResult } from '../../core/transformer';
17
17
  import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
18
18
  import { type CfgGroup } from './parser';
@@ -34,6 +34,20 @@ export interface LibconfigOptions {
34
34
  * line verbatim and edits only this file's own settings.
35
35
  */
36
36
  includes?: 'reject' | 'opaque';
37
+ /**
38
+ * Schema-driven mode only: what happens to settings the JSON Schema does
39
+ * not cover. `'preserve'` (default) keeps them byte-verbatim in place —
40
+ * the form never carried them, so a partial schema edits its slice without
41
+ * erasing the rest. `'drop'` makes the edited value authoritative for the
42
+ * whole document, deleting uncovered settings — for consumers whose schema
43
+ * is intentionally complete (sanitizing a config, enforcing a strict
44
+ * template). `'edit'` surfaces them instead: uncovered settings render as
45
+ * editable fields typed by the document's own literals (the inferred
46
+ * schema merged under the JSON Schema — see `mergeInferred`), so nothing
47
+ * is invisible and the value covers the whole document. Ignored without a
48
+ * `schema`, where the inferred value covers every setting anyway.
49
+ */
50
+ unknownKeys?: 'preserve' | 'drop' | 'edit';
37
51
  /**
38
52
  * Display metadata (`label`/`description`/choice `caseLabels`) injected into
39
53
  * the produced schema, schema-driven or inferred alike — see
@@ -42,10 +56,22 @@ export interface LibconfigOptions {
42
56
  */
43
57
  thesaurus?: Thesaurus;
44
58
  }
45
- /** The revert context: the original text and its positioned AST. */
59
+ /**
60
+ * The revert context: the original text, its positioned AST, and — in
61
+ * schema-driven mode with `unknownKeys: 'preserve'` or `'edit'` — the
62
+ * NodeGroup the form was built from (the JSON Schema's own under
63
+ * `'preserve'`, the inferred-merged one under `'edit'`), so `toSource` knows
64
+ * which paths are schema-born. Settings outside those paths were never
65
+ * carried by the form and survive verbatim; under `'edit'` that is only the
66
+ * keys no form field can carry (e.g. a key inside a covered choice's group
67
+ * that no case names). Absent `schema` (inferred mode, or
68
+ * `unknownKeys: 'drop'`), the value covers the whole document and is
69
+ * authoritative everywhere.
70
+ */
46
71
  export interface LibconfigBinding {
47
72
  source: string;
48
73
  root: CfgGroup;
74
+ schema?: NodeGroup;
49
75
  }
50
76
  export declare const libconfigTransformer: {
51
77
  id: string;
@@ -3,6 +3,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.libconfigTransformer = void 0;
4
4
  const thesaurus_1 = require("../../core/thesaurus");
5
5
  const json_schema_1 = require("../../core/json-schema");
6
+ const merge_inferred_1 = require("../../core/merge-inferred");
7
+ const schema_keys_1 = require("../../core/schema-keys");
6
8
  const parser_1 = require("./parser");
7
9
  const schema_1 = require("./schema");
8
10
  const revert_1 = require("./revert");
@@ -10,14 +12,35 @@ exports.libconfigTransformer = {
10
12
  id: 'libconfig',
11
13
  toSchema(source, options) {
12
14
  const root = (0, parser_1.parseLibconfig)(source, { includes: options?.includes });
13
- const schema = options?.schema
15
+ const unknownKeys = options?.unknownKeys ?? 'preserve';
16
+ const fromJsonSchema = options?.schema
14
17
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
15
- : (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__');
18
+ : undefined;
19
+ const schema = fromJsonSchema && unknownKeys === 'edit'
20
+ ? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__'))
21
+ : fromJsonSchema ?? (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__');
16
22
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
17
23
  const initialValue = (0, schema_1.extractValue)(root, source, options?.schema != null);
18
- return { schema: labeled, binding: { source, root }, initialValue };
24
+ if (fromJsonSchema && unknownKeys === 'edit') {
25
+ // Uncovered empty collections merged as read-only carries: their value
26
+ // must be the verbatim slice, not the typed [] the extraction produced.
27
+ (0, schema_1.carryUncoveredEmpties)(root, source, initialValue, (0, schema_keys_1.childrenOf)(fromJsonSchema));
28
+ }
29
+ return {
30
+ schema: labeled,
31
+ binding: {
32
+ source,
33
+ root,
34
+ // 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit'
35
+ // on the merged one (schema-born ≈ everything, but settings no form
36
+ // field can carry stay protected); 'drop' and inferred mode leave
37
+ // the value authoritative for the whole document.
38
+ schema: fromJsonSchema && unknownKeys !== 'drop' ? schema : undefined,
39
+ },
40
+ initialValue,
41
+ };
19
42
  },
20
43
  toSource(value, binding) {
21
- return (0, revert_1.applyValueToSource)(binding.source, binding.root, value);
44
+ return (0, revert_1.applyValueToSource)(binding.source, binding.root, value, binding.schema);
22
45
  },
23
46
  };
@@ -13,6 +13,18 @@
13
13
  * literal re-emits in hex at its original digit width, an int64 keeps its
14
14
  * `L`/`LL` suffix, and values beyond 2^53 travel as exact decimal strings.
15
15
  */
16
+ import type { NodeGroup } from '../../core/schema';
16
17
  import type { CfgGroup } from './parser';
17
- /** Apply `value` onto the parsed `root` of `source`, returning the new text. */
18
- export declare function applyValueToSource(source: string, root: CfgGroup, value: Record<string, unknown>): string;
18
+ /**
19
+ * Apply `value` onto the parsed `root` of `source`, returning the new text.
20
+ *
21
+ * Without `schema` (inferred mode) the value is authoritative for the whole
22
+ * document: it was extracted from every setting, so a key it lacks is a
23
+ * deletion. With `schema` (the NodeGroup the form was built from) the value
24
+ * is authoritative for **schema-born paths only** — the form never carried
25
+ * the other settings, so they survive byte-verbatim in their original
26
+ * positions. A partial schema thus edits its slice of an OAI/srsRAN config
27
+ * without erasing keys the schema does not enumerate; absence of a
28
+ * schema-born key still deletes (a presence toggle turned off).
29
+ */
30
+ export declare function applyValueToSource(source: string, root: CfgGroup, value: Record<string, unknown>, schema?: NodeGroup): string;
@@ -1,33 +1,50 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyValueToSource = applyValueToSource;
4
+ const schema_keys_1 = require("../../core/schema-keys");
4
5
  const schema_1 = require("./schema");
5
- /** Apply `value` onto the parsed `root` of `source`, returning the new text. */
6
- function applyValueToSource(source, root, value) {
6
+ /**
7
+ * Apply `value` onto the parsed `root` of `source`, returning the new text.
8
+ *
9
+ * Without `schema` (inferred mode) the value is authoritative for the whole
10
+ * document: it was extracted from every setting, so a key it lacks is a
11
+ * deletion. With `schema` (the NodeGroup the form was built from) the value
12
+ * is authoritative for **schema-born paths only** — the form never carried
13
+ * the other settings, so they survive byte-verbatim in their original
14
+ * positions. A partial schema thus edits its slice of an OAI/srsRAN config
15
+ * without erasing keys the schema does not enumerate; absence of a
16
+ * schema-born key still deletes (a presence toggle turned off).
17
+ */
18
+ function applyValueToSource(source, root, value, schema) {
7
19
  const edits = [];
8
- patchGroup(source, root, value, edits);
20
+ patchGroup(source, root, value, edits, schema && (0, schema_keys_1.childrenOf)(schema));
9
21
  edits.sort((a, b) => b.start - a.start);
10
22
  let out = source;
11
23
  for (const e of edits)
12
24
  out = out.slice(0, e.start) + e.text + out.slice(e.end);
13
25
  return out;
14
26
  }
15
- function patchGroup(src, group, value, edits) {
27
+ function patchGroup(src, group, value, edits, schema) {
16
28
  for (const setting of group.settings) {
17
- if (!value || !(setting.name in value)) {
29
+ if (schema && !schema.has(setting.name))
30
+ continue; // not schema-born: verbatim
31
+ // Own-key check: `in` would resolve setting names like `toString` through
32
+ // the prototype chain, making them undeletable (and their "value" a function).
33
+ if (!value || !Object.prototype.hasOwnProperty.call(value, setting.name)) {
18
34
  edits.push(deleteSetting(src, setting));
19
35
  continue;
20
36
  }
21
- patchValue(src, setting.value, value[setting.name], edits);
37
+ patchValue(src, setting.value, value[setting.name], edits, schema?.get(setting.name));
22
38
  }
23
39
  const known = new Set(group.settings.map((s) => s.name));
24
- // null/undefined means "absent", never a value: such keys are not added.
25
- const added = Object.keys(value ?? {}).filter((k) => !known.has(k) && value[k] != null);
40
+ // null/undefined means "absent", never a value: such keys are not added
41
+ // and under a schema, only schema-born keys may be added at all.
42
+ const added = Object.keys(value ?? {}).filter((k) => !known.has(k) && value[k] != null && (!schema || schema.has(k)));
26
43
  if (added.length) {
27
44
  edits.push(insertionEdit(src, group, added.map((k) => `${k} = ${serialize(value[k], '')};`)));
28
45
  }
29
46
  }
30
- function patchValue(src, node, value, edits) {
47
+ function patchValue(src, node, value, edits, schema) {
31
48
  switch (node.kind) {
32
49
  case 'scalar': {
33
50
  if (node.value === value)
@@ -41,7 +58,7 @@ function patchValue(src, node, value, edits) {
41
58
  }
42
59
  case 'group': {
43
60
  if (isRecord(value))
44
- return patchGroup(src, node, value, edits);
61
+ return patchGroup(src, node, value, edits, schema && (0, schema_keys_1.childrenOf)(schema));
45
62
  edits.push({ ...node.span, text: serialize(value, '') }); // shape change: regenerate
46
63
  return;
47
64
  }
@@ -61,7 +78,7 @@ function patchValue(src, node, value, edits) {
61
78
  case 'list': {
62
79
  const shape = (0, schema_1.listShape)(node);
63
80
  if (shape === 'groups' && Array.isArray(value)) {
64
- return patchElements(src, node.elements, node, value, edits);
81
+ return patchElements(src, node.elements, node, value, edits, (0, schema_keys_1.itemSchemaOf)(schema));
65
82
  }
66
83
  if (shape === 'scalars' && Array.isArray(value)) {
67
84
  return patchElements(src, node.elements, node, value, edits);
@@ -89,10 +106,10 @@ function patchValue(src, node, value, edits) {
89
106
  }
90
107
  }
91
108
  /** Element-wise patch of an array/list: edit in place, then grow or shrink. */
92
- function patchElements(src, elements, collection, value, edits) {
109
+ function patchElements(src, elements, collection, value, edits, itemSchema) {
93
110
  const shared = Math.min(elements.length, value.length);
94
111
  for (let i = 0; i < shared; i++)
95
- patchValue(src, elements[i], value[i], edits);
112
+ patchValue(src, elements[i], value[i], edits, itemSchema);
96
113
  if (value.length > elements.length) {
97
114
  const items = value.slice(elements.length).map((v) => serialize(v, ''));
98
115
  const at = elements.length ? elements[elements.length - 1].span.end : collection.innerSpan.start;
@@ -19,6 +19,7 @@
19
19
  * empty collections editable.
20
20
  */
21
21
  import type { NodeGroup } from '../../core/schema';
22
+ import { type SchemaKeys } from '../../core/schema-keys';
22
23
  import { type CfgGroup, type CfgList, type CfgValue } from './parser';
23
24
  /** Infer the root NodeGroup for a parsed document. */
24
25
  export declare function libconfigToNodeGroup(root: CfgGroup, source: string, name: string): NodeGroup;
@@ -36,4 +37,14 @@ type ListShape = 'groups' | 'scalars' | 'empty' | 'heterogeneous';
36
37
  export declare function listShape(list: CfgList): ListShape;
37
38
  /** The verbatim source slice of a node. */
38
39
  export declare function raw(value: CfgValue, source: string): string;
40
+ /**
41
+ * `unknownKeys: 'edit'` extraction fixup. The whole-document extraction runs
42
+ * with `emptyAsArrays` (schema-covered empty collections are typed and
43
+ * editable), but collections the JSON Schema does **not** cover merge as
44
+ * read-only raw-carry leaves — their value must be the verbatim source
45
+ * slice, not `[]`. Walks the document alongside the original (pre-merge)
46
+ * schema coverage and restores the carry on every uncovered empty
47
+ * collection, at any depth.
48
+ */
49
+ export declare function carryUncoveredEmpties(group: CfgGroup, source: string, value: unknown, keys: SchemaKeys): void;
39
50
  export {};
@@ -4,6 +4,8 @@ exports.libconfigToNodeGroup = libconfigToNodeGroup;
4
4
  exports.extractValue = extractValue;
5
5
  exports.listShape = listShape;
6
6
  exports.raw = raw;
7
+ exports.carryUncoveredEmpties = carryUncoveredEmpties;
8
+ const schema_keys_1 = require("../../core/schema-keys");
7
9
  const parser_1 = require("./parser");
8
10
  /** Digits only — the constraint for int64 values carried as strings. */
9
11
  const INTEGER_STRING_PATTERN = '^[-+]?[0-9]+$';
@@ -184,3 +186,61 @@ function rawLeaf(name, why) {
184
186
  function raw(value, source) {
185
187
  return source.slice(value.span.start, value.span.end);
186
188
  }
189
+ /**
190
+ * `unknownKeys: 'edit'` extraction fixup. The whole-document extraction runs
191
+ * with `emptyAsArrays` (schema-covered empty collections are typed and
192
+ * editable), but collections the JSON Schema does **not** cover merge as
193
+ * read-only raw-carry leaves — their value must be the verbatim source
194
+ * slice, not `[]`. Walks the document alongside the original (pre-merge)
195
+ * schema coverage and restores the carry on every uncovered empty
196
+ * collection, at any depth.
197
+ */
198
+ function carryUncoveredEmpties(group, source, value, keys) {
199
+ if (!isRecord(value))
200
+ return;
201
+ for (const setting of group.settings) {
202
+ if (keys && !keys.has(setting.name)) {
203
+ restoreEmptyCarries(setting.value, source, value, setting.name);
204
+ continue;
205
+ }
206
+ const child = keys?.get(setting.name);
207
+ const v = value[setting.name];
208
+ if (setting.value.kind === 'group' && child) {
209
+ carryUncoveredEmpties(setting.value, source, v, (0, schema_keys_1.childrenOf)(child));
210
+ }
211
+ else if (setting.value.kind === 'list' && Array.isArray(v)) {
212
+ const itemSchema = (0, schema_keys_1.itemSchemaOf)(child);
213
+ if (!itemSchema)
214
+ continue;
215
+ const itemKeys = (0, schema_keys_1.childrenOf)(itemSchema);
216
+ setting.value.elements.forEach((el, i) => {
217
+ if (el.kind === 'group')
218
+ carryUncoveredEmpties(el, source, v[i], itemKeys);
219
+ });
220
+ }
221
+ }
222
+ }
223
+ /** In a fully uncovered subtree, every empty collection reverts to its carry. */
224
+ function restoreEmptyCarries(node, source, parent, key) {
225
+ if ((node.kind === 'array' || node.kind === 'list') && node.elements.length === 0) {
226
+ parent[key] = raw(node, source);
227
+ return;
228
+ }
229
+ if (node.kind === 'group') {
230
+ const v = parent[key];
231
+ if (!isRecord(v))
232
+ return;
233
+ for (const s of node.settings)
234
+ restoreEmptyCarries(s.value, source, v, s.name);
235
+ return;
236
+ }
237
+ if (node.kind === 'list') {
238
+ const v = parent[key];
239
+ if (!Array.isArray(v))
240
+ return;
241
+ node.elements.forEach((el, i) => restoreEmptyCarries(el, source, v, i));
242
+ }
243
+ }
244
+ function isRecord(v) {
245
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
246
+ }
@@ -7,5 +7,5 @@
7
7
  * `core` — they are format-agnostic and shared with the JSON transformer.
8
8
  */
9
9
  export { yamlTransformer } from './yaml-transformer';
10
- export type { YamlOptions } from './yaml-transformer';
10
+ export type { YamlOptions, YamlBinding } from './yaml-transformer';
11
11
  export { applyValueToDocument } from './revert';
@@ -1,4 +1,5 @@
1
1
  import { type Document } from 'yaml';
2
+ import type { NodeGroup } from '../../core/schema';
2
3
  /**
3
4
  * Apply an edited form value onto a parsed YAML {@link Document} in place,
4
5
  * preserving comments and formatting on every node that survives the edit.
@@ -10,9 +11,15 @@ import { type Document } from 'yaml';
10
11
  * {@link import('./json-schema')}. Callers should clone the document first if the
11
12
  * original must be preserved.
12
13
  *
14
+ * With `schema` (the NodeGroup the form was built from) the
15
+ * value is authoritative for **schema-born paths only**: keys the schema does
16
+ * not cover — at any depth — are preserved verbatim, their comments included.
17
+ * Without it the value covers the whole document and a key it lacks is a
18
+ * deletion.
19
+ *
13
20
  * Map keys are matched by the *same string form* `doc.toJS()` produced when the
14
21
  * form value was built, so non-string keys (a `80:` port map, a `true:` flag)
15
22
  * reconcile against their original typed key nodes instead of being appended as
16
23
  * duplicate string-keyed pairs.
17
24
  */
18
- export declare function applyValueToDocument(doc: Document, value: unknown): void;
25
+ export declare function applyValueToDocument(doc: Document, value: unknown, schema?: NodeGroup): void;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyValueToDocument = applyValueToDocument;
4
4
  const yaml_1 = require("yaml");
5
5
  const bigint_1 = require("../../core/bigint");
6
+ const schema_keys_1 = require("../../core/schema-keys");
6
7
  /**
7
8
  * Apply an edited form value onto a parsed YAML {@link Document} in place,
8
9
  * preserving comments and formatting on every node that survives the edit.
@@ -14,17 +15,23 @@ const bigint_1 = require("../../core/bigint");
14
15
  * {@link import('./json-schema')}. Callers should clone the document first if the
15
16
  * original must be preserved.
16
17
  *
18
+ * With `schema` (the NodeGroup the form was built from) the
19
+ * value is authoritative for **schema-born paths only**: keys the schema does
20
+ * not cover — at any depth — are preserved verbatim, their comments included.
21
+ * Without it the value covers the whole document and a key it lacks is a
22
+ * deletion.
23
+ *
17
24
  * Map keys are matched by the *same string form* `doc.toJS()` produced when the
18
25
  * form value was built, so non-string keys (a `80:` port map, a `true:` flag)
19
26
  * reconcile against their original typed key nodes instead of being appended as
20
27
  * duplicate string-keyed pairs.
21
28
  */
22
- function applyValueToDocument(doc, value) {
29
+ function applyValueToDocument(doc, value, schema) {
23
30
  if (doc.contents == null) {
24
31
  doc.contents = doc.createNode(value);
25
32
  return;
26
33
  }
27
- doc.contents = applyToNode(doc, doc.contents, value);
34
+ doc.contents = applyToNode(doc, doc.contents, value, schema);
28
35
  }
29
36
  /**
30
37
  * Reconcile the parsed `node` at one position with its edited `value`, returning
@@ -32,22 +39,27 @@ function applyValueToDocument(doc, value) {
32
39
  * a map/sequence is reconciled child-by-child; a shape change (or a brand-new
33
40
  * position) allocates a fresh node.
34
41
  */
35
- function applyToNode(doc, node, value) {
42
+ function applyToNode(doc, node, value, schema) {
36
43
  if (isPlainObject(value)) {
37
44
  if (!(0, yaml_1.isMap)(node))
38
45
  return doc.createNode(value);
39
- reconcileMap(doc, node, value);
46
+ reconcileMap(doc, node, value, schema && (0, schema_keys_1.childrenOf)(schema));
40
47
  return node;
41
48
  }
42
49
  if (Array.isArray(value)) {
43
50
  if (!(0, yaml_1.isSeq)(node))
44
51
  return doc.createNode(value);
45
- reconcileSeq(doc, node, value);
52
+ reconcileSeq(doc, node, value, (0, schema_keys_1.itemSchemaOf)(schema));
46
53
  return node;
47
54
  }
48
55
  // scalar (string / number / boolean / null)
49
56
  if ((0, yaml_1.isScalar)(node)) {
50
- node.value = coerceScalarValue(value, node.value);
57
+ const next = coerceScalarValue(value, node.value);
58
+ // An unchanged value keeps the node untouched — the parsed BigInt and the
59
+ // form's number compare by numeric value, so an identity pass never
60
+ // downgrades a BigInt-held integer to a lossy JS number.
61
+ if (!sameScalar(node.value, next))
62
+ node.value = next;
51
63
  return node;
52
64
  }
53
65
  return doc.createNode(value);
@@ -56,13 +68,24 @@ function applyToNode(doc, node, value) {
56
68
  * Update a map node against the value object: drop pairs whose key is gone,
57
69
  * recurse into the ones that remain (matched by their `toJS` key string, so
58
70
  * typed keys line up), and append a fresh pair for each genuinely new key.
71
+ * Under `schema`, keys that are not schema-born are exempt from all three —
72
+ * never dropped, never recursed into, never addable.
59
73
  */
60
- function reconcileMap(doc, node, value) {
61
- node.items = node.items.filter((pair) => keyString(pair.key) in value);
74
+ function reconcileMap(doc, node, value, schema) {
75
+ node.items = node.items.filter((pair) => {
76
+ const key = keyString(pair.key);
77
+ if (schema && !schema.has(key))
78
+ return true; // not schema-born: verbatim
79
+ // Own-key check: `in` would resolve doc keys like `toString` through the
80
+ // prototype chain and make them undeletable.
81
+ return Object.prototype.hasOwnProperty.call(value, key);
82
+ });
62
83
  for (const key of Object.keys(value)) {
84
+ if (schema && !schema.has(key))
85
+ continue; // not schema-born: never written
63
86
  const pair = node.items.find((p) => keyString(p.key) === key);
64
87
  if (pair) {
65
- pair.value = applyToNode(doc, pair.value, value[key]);
88
+ pair.value = applyToNode(doc, pair.value, value[key], schema?.get(key));
66
89
  }
67
90
  else {
68
91
  node.items.push(doc.createPair(key, value[key]));
@@ -70,12 +93,12 @@ function reconcileMap(doc, node, value) {
70
93
  }
71
94
  }
72
95
  /** Update a sequence node: shrink/grow to the value's length, recurse per item. */
73
- function reconcileSeq(doc, node, value) {
96
+ function reconcileSeq(doc, node, value, itemSchema) {
74
97
  while (node.items.length > value.length)
75
98
  node.items.pop();
76
99
  for (let i = 0; i < value.length; i++) {
77
100
  if (i < node.items.length) {
78
- node.items[i] = applyToNode(doc, node.items[i], value[i]);
101
+ node.items[i] = applyToNode(doc, node.items[i], value[i], itemSchema);
79
102
  }
80
103
  else {
81
104
  node.items.push(doc.createNode(value[i]));
@@ -96,6 +119,15 @@ function coerceScalarValue(value, current) {
96
119
  }
97
120
  return value;
98
121
  }
122
+ /** Numeric equality across the BigInt/number divide, `===` otherwise. */
123
+ function sameScalar(current, next) {
124
+ if (current === next)
125
+ return true;
126
+ if (typeof current === 'bigint' && typeof next === 'number' && Number.isInteger(next)) {
127
+ return current === BigInt(next);
128
+ }
129
+ return false;
130
+ }
99
131
  /**
100
132
  * The string a key node takes as a JS object key, matching `doc.toJS()`: the
101
133
  * scalar value stringified, with `null`/`undefined` collapsing to `''` (the
@@ -1,5 +1,5 @@
1
1
  import { type Document } from 'yaml';
2
- import type { FormValue, Thesaurus } from '../../core/schema';
2
+ import type { FormValue, NodeGroup, Thesaurus } from '../../core/schema';
3
3
  import type { TransformResult } from '../../core/transformer';
4
4
  import { type JsonSchema, type JsonSchemaOptions } from '../../core/json-schema';
5
5
  /** Options for {@link yamlTransformer}'s `toSchema`. */
@@ -21,6 +21,32 @@ export interface YamlOptions {
21
21
  * case-insensitively; never paths.
22
22
  */
23
23
  thesaurus?: Thesaurus;
24
+ /**
25
+ * Schema-driven mode only: what happens to keys the JSON Schema does not
26
+ * cover. `'preserve'` (default) keeps them verbatim — the form never
27
+ * carried them, so a partial schema edits its slice without erasing the
28
+ * rest. `'drop'` makes the edited value authoritative for the whole
29
+ * document, deleting uncovered keys — for consumers whose schema is
30
+ * intentionally complete. `'edit'` surfaces them instead: uncovered keys
31
+ * render as editable fields typed by the data (the inferred schema merged
32
+ * under the JSON Schema), so nothing is invisible and the value covers the
33
+ * whole document. Ignored without a `schema`.
34
+ */
35
+ unknownKeys?: 'preserve' | 'drop' | 'edit';
36
+ }
37
+ /**
38
+ * The revert context: the parsed {@link Document} and — in schema-driven
39
+ * mode with `unknownKeys: 'preserve'` or `'edit'` — the NodeGroup the form
40
+ * was built from (the JSON Schema's own under `'preserve'`, the
41
+ * inferred-merged one under `'edit'`), so `toSource` treats the value as
42
+ * authoritative for schema-born paths only. Under `'edit'` that is almost
43
+ * the whole document; the gate still protects the keys no form field can
44
+ * carry (e.g. a key inside a covered choice's object that no case names).
45
+ * Treat it as opaque: build it with `toSchema`, hand it back to `toSource`.
46
+ */
47
+ export interface YamlBinding {
48
+ doc: Document;
49
+ schema?: NodeGroup;
24
50
  }
25
51
  /**
26
52
  * A YAML {@link Transformer}: turn a YAML config document into a form and write
@@ -28,14 +54,15 @@ export interface YamlOptions {
28
54
  * preserves comments, key order, and formatting by applying edits onto the parsed
29
55
  * document (see {@link applyValueToDocument}).
30
56
  *
31
- * The `binding` it round-trips is the parsed {@link Document}; `toSource` clones
32
- * it before applying, so a single `toSchema` result can serve many edits.
57
+ * The `binding` it round-trips is a {@link YamlBinding}; `toSource` clones the
58
+ * document inside it before applying, so a single `toSchema` result can serve
59
+ * many edits.
33
60
  *
34
61
  * With a JSON Schema in {@link YamlOptions}, the form is schema-driven; without
35
62
  * one it is inferred from the data.
36
63
  */
37
64
  export declare const yamlTransformer: {
38
65
  id: string;
39
- toSchema(source: string, options?: YamlOptions): TransformResult<Document>;
40
- toSource(value: FormValue, binding: Document): string;
66
+ toSchema(source: string, options?: YamlOptions): TransformResult<YamlBinding>;
67
+ toSource(value: FormValue, binding: YamlBinding): string;
41
68
  };
@@ -5,6 +5,7 @@ const yaml_1 = require("yaml");
5
5
  const thesaurus_1 = require("../../core/thesaurus");
6
6
  const infer_1 = require("../../core/infer");
7
7
  const json_schema_1 = require("../../core/json-schema");
8
+ const merge_inferred_1 = require("../../core/merge-inferred");
8
9
  const bigint_1 = require("../../core/bigint");
9
10
  const revert_1 = require("./revert");
10
11
  /**
@@ -13,8 +14,9 @@ const revert_1 = require("./revert");
13
14
  * preserves comments, key order, and formatting by applying edits onto the parsed
14
15
  * document (see {@link applyValueToDocument}).
15
16
  *
16
- * The `binding` it round-trips is the parsed {@link Document}; `toSource` clones
17
- * it before applying, so a single `toSchema` result can serve many edits.
17
+ * The `binding` it round-trips is a {@link YamlBinding}; `toSource` clones the
18
+ * document inside it before applying, so a single `toSchema` result can serve
19
+ * many edits.
18
20
  *
19
21
  * With a JSON Schema in {@link YamlOptions}, the form is schema-driven; without
20
22
  * one it is inferred from the data.
@@ -27,17 +29,31 @@ exports.yamlTransformer = {
27
29
  // out-of-range integers become strings there (safe ones become plain numbers).
28
30
  const doc = (0, yaml_1.parseDocument)(source, { intAsBigInt: true });
29
31
  const data = (normalizeBigInts(doc.toJS()) ?? {});
30
- const schema = options?.schema
32
+ const unknownKeys = options?.unknownKeys ?? 'preserve';
33
+ const fromJsonSchema = options?.schema
31
34
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
32
- : (0, infer_1.inferNodeGroup)(data, options?.rootName);
33
- if (!options?.schema && doc.contents)
35
+ : undefined;
36
+ const schema = fromJsonSchema && unknownKeys === 'edit'
37
+ ? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, infer_1.inferNodeGroup)(data, options?.rootName))
38
+ : fromJsonSchema ?? (0, infer_1.inferNodeGroup)(data, options?.rootName);
39
+ // Inferred leaves (the whole schema, or the uncovered slice under 'edit')
40
+ // pick up the document's hex/octal presentation.
41
+ if ((!fromJsonSchema || unknownKeys === 'edit') && doc.contents)
34
42
  annotateRadix(doc.contents, schema);
35
43
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
36
- return { schema: labeled, binding: doc, initialValue: data };
44
+ return {
45
+ schema: labeled,
46
+ // 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit' on
47
+ // the merged one (schema-born ≈ everything, but keys no form field can
48
+ // carry stay protected); 'drop' and inferred mode leave the value
49
+ // authoritative for the whole document.
50
+ binding: { doc, schema: fromJsonSchema && unknownKeys !== 'drop' ? schema : undefined },
51
+ initialValue: data,
52
+ };
37
53
  },
38
54
  toSource(value, binding) {
39
- const doc = binding.clone();
40
- (0, revert_1.applyValueToDocument)(doc, value);
55
+ const doc = binding.doc.clone();
56
+ (0, revert_1.applyValueToDocument)(doc, value, binding.schema);
41
57
  return String(doc);
42
58
  },
43
59
  // `satisfies` verifies conformance to the catalog contract while keeping the
@@ -115,7 +131,8 @@ function normalizeBigInts(value) {
115
131
  if (Array.isArray(value))
116
132
  return value.map(normalizeBigInts);
117
133
  if (value && typeof value === 'object') {
118
- const out = {};
134
+ // Null prototype: a `__proto__` document key must stay a data key.
135
+ const out = Object.create(null);
119
136
  for (const key of Object.keys(value))
120
137
  out[key] = normalizeBigInts(value[key]);
121
138
  return out;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ng-form-foundry-transformers",
3
- "version": "0.5.0",
3
+ "version": "0.5.1",
4
4
  "description": "Framework-agnostic Node + TypeScript catalog of source-format transformers: turn a YANG model (and more) into an ng-form-foundry schema and revert the edited form value back to the source format.",
5
5
  "keywords": [
6
6
  "ng-form-foundry",