ng-form-foundry-transformers 0.4.1 → 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
@@ -16,7 +16,7 @@ or a CLI just as easily.
16
16
  | `yang` | YANG model (via an engine) | RFC 7951 instance data | available |
17
17
  | `yaml` | YAML config (optionally a JSON Schema) | YAML (comments preserved) | available |
18
18
  | `json` | JSON config (optionally a JSON Schema) | JSON (indent preserved) | available |
19
- | `libconfig` | libconfig document (optionally a JSON Schema) | libconfig (comments **and scalar types** preserved) | **beta** — warns on first use |
19
+ | `libconfig` | libconfig document (optionally a JSON Schema) | libconfig (comments **and scalar types** preserved) | available |
20
20
 
21
21
  The YAML and JSON transformers share the same format-agnostic form builders in
22
22
  `core` (`inferNodeGroup`, `jsonSchemaToNodeGroup`) — a JSON Schema is an *option*
@@ -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? })`
@@ -169,12 +178,10 @@ const { schema, binding, initialValue } = jsonTransformer.toSchema(configText);
169
178
  const out = jsonTransformer.toSource({ ...initialValue, replicas: 5 }, binding);
170
179
  ```
171
180
 
172
- ## libconfig transformer (beta)
181
+ ## libconfig transformer
173
182
 
174
183
  Edit a **libconfig document** — the `.cfg`/`.conf` format of srsRAN, OAI, and
175
184
  other C/C++ software using [libconfig](https://hyperrealm.github.io/libconfig/).
176
- **Beta**: it logs a one-time warning on first use; diff `toSource` output
177
- against the original before deploying a write-back.
178
185
 
179
186
  ```ts
180
187
  import { libconfigTransformer } from 'ng-form-foundry-transformers';
@@ -294,6 +301,22 @@ npm test # node:test on the compiled output (no Python needed)
294
301
 
295
302
  ## Status
296
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
+
312
+ `0.5.0` — the **libconfig transformer is stable**: the one-time beta warning
313
+ is gone (and with it the `resetLibconfigBetaWarning` helper), and the format's
314
+ guarantees and known limitations are documented in the transformers guide.
315
+ Non-decimal integer literals (`0x`, `0b`, `0o`/`0q`) now set a `radix` display
316
+ hint on the generated schema — the paired `ng-form-foundry` release renders
317
+ those fields as hex/octal/binary editors, and the YAML transformer does the
318
+ same for its hex and octal literals.
319
+
297
320
  `0.4.1` — libconfig hardening after a conformance battle test against the C
298
321
  library's own scanner, test corpus, and real srsRAN/OAI configs: `0q`/`0Q`
299
322
  octal, verbatim radix-prefix preservation on edits, sign accepted on decimal
@@ -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
+ }
@@ -32,6 +32,13 @@ export interface Leaf {
32
32
  min?: number;
33
33
  max?: number;
34
34
  multipleOf?: number;
35
+ /**
36
+ * Present the value in this base (16 hex, 8 octal, 2 binary) instead of
37
+ * decimal — set when the source document wrote the literal that way. Purely
38
+ * a display hint: the value itself stays a plain number (or, on a string
39
+ * leaf, the exact decimal-digit carry of an integer beyond ±2^53).
40
+ */
41
+ radix?: 2 | 8 | 16;
35
42
  /** The value may be `null` (JSON Schema `type: [T, 'null']`). */
36
43
  nullable?: boolean;
37
44
  /** Optional scalar whose presence is itself data (on/off toggle). */
@@ -47,6 +54,8 @@ export interface LeafList {
47
54
  label?: string;
48
55
  minItems?: number;
49
56
  maxItems?: number;
57
+ /** Present every item in this base — see {@link Leaf.radix}. */
58
+ radix?: 2 | 8 | 16;
50
59
  }
51
60
  export interface NodeGroup {
52
61
  kind: 'nodeGroup';
package/dist/index.d.ts CHANGED
@@ -13,8 +13,8 @@
13
13
  * - `json` — JSON config → form → JSON ({@link jsonTransformer}); same builders
14
14
  * as `yaml`, indent preserved.
15
15
  * - `libconfig` — libconfig document (srsRAN/OAI-style `.cfg`/`.conf`) → form →
16
- * libconfig ({@link libconfigTransformer}); **BETA**, warns once on
17
- * first use; comment- and type-preserving span splicing on revert.
16
+ * libconfig ({@link libconfigTransformer}); comment- and
17
+ * type-preserving span splicing on revert.
18
18
  *
19
19
  * Look transformers up at runtime through {@link TransformerRegistry}, or import
20
20
  * the one you need directly.
package/dist/index.js CHANGED
@@ -14,8 +14,8 @@
14
14
  * - `json` — JSON config → form → JSON ({@link jsonTransformer}); same builders
15
15
  * as `yaml`, indent preserved.
16
16
  * - `libconfig` — libconfig document (srsRAN/OAI-style `.cfg`/`.conf`) → form →
17
- * libconfig ({@link libconfigTransformer}); **BETA**, warns once on
18
- * first use; comment- and type-preserving span splicing on revert.
17
+ * libconfig ({@link libconfigTransformer}); comment- and
18
+ * type-preserving span splicing on revert.
19
19
  *
20
20
  * Look transformers up at runtime through {@link TransformerRegistry}, or import
21
21
  * the one you need directly.
@@ -41,4 +41,4 @@ __exportStar(require("./core"), exports);
41
41
  __exportStar(require("./transformers/yang"), exports);
42
42
  __exportStar(require("./transformers/yaml"), exports);
43
43
  __exportStar(require("./transformers/json"), exports);
44
- __exportStar(require("./transformers/libconfig"), exports); // BETA — warns once on first use
44
+ __exportStar(require("./transformers/libconfig"), exports);
@@ -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
+ }
@@ -1,2 +1,2 @@
1
- export { libconfigTransformer, resetLibconfigBetaWarning, type LibconfigBinding, type LibconfigOptions, } from './libconfig-transformer';
1
+ export { libconfigTransformer, type LibconfigBinding, type LibconfigOptions, } from './libconfig-transformer';
2
2
  export { parseLibconfig, LibconfigParseError } from './parser';
@@ -1,9 +1,8 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LibconfigParseError = exports.parseLibconfig = exports.resetLibconfigBetaWarning = exports.libconfigTransformer = void 0;
3
+ exports.LibconfigParseError = exports.parseLibconfig = exports.libconfigTransformer = void 0;
4
4
  var libconfig_transformer_1 = require("./libconfig-transformer");
5
5
  Object.defineProperty(exports, "libconfigTransformer", { enumerable: true, get: function () { return libconfig_transformer_1.libconfigTransformer; } });
6
- Object.defineProperty(exports, "resetLibconfigBetaWarning", { enumerable: true, get: function () { return libconfig_transformer_1.resetLibconfigBetaWarning; } });
7
6
  var parser_1 = require("./parser");
8
7
  Object.defineProperty(exports, "parseLibconfig", { enumerable: true, get: function () { return parser_1.parseLibconfig; } });
9
8
  Object.defineProperty(exports, "LibconfigParseError", { enumerable: true, get: function () { return parser_1.LibconfigParseError; } });
@@ -1,5 +1,5 @@
1
1
  /**
2
- * A libconfig {@link Transformer} (BETA): turn a libconfig document
2
+ * A libconfig {@link Transformer}: turn a libconfig document
3
3
  * (srsRAN/OAI-style `.cfg`/`.conf`) into a form and write the edited value
4
4
  * back with comments, formatting, and — critically — scalar *types*
5
5
  * preserved (libconfig is statically typed; see {@link import('./revert')}).
@@ -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,13 +56,23 @@ 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
- /** Test seam: makes the one-time beta warning observable per test. */
51
- export declare function resetLibconfigBetaWarning(): void;
52
76
  export declare const libconfigTransformer: {
53
77
  id: string;
54
78
  toSchema(source: string, options?: LibconfigOptions): TransformResult<LibconfigBinding>;