ng-form-foundry-transformers 0.5.1 → 0.5.3

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,14 @@ 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
+
158
166
  **The schema may cover any slice of the document.** Keys it does not mention
159
167
  are governed by `unknownKeys` (YAML, JSON, and libconfig alike):
160
168
  `'preserve'` (default) keeps them verbatim on save — a partial schema edits
@@ -301,6 +309,19 @@ npm test # node:test on the compiled output (no Python needed)
301
309
 
302
310
  ## Status
303
311
 
312
+ `0.5.3` — version-alignment release with the workspace (the paired
313
+ `ng-form-foundry` 0.5.3 ships hover-revealed field actions and indexed
314
+ group-list add labels); no transformer changes.
315
+
316
+ `0.5.2` — **shape safety and operator-friendly display**: a container-shape
317
+ disagreement between document and schema (array declared where the document
318
+ holds an object, or scalar where it holds a collection — inside choices
319
+ included) throws a `SchemaShapeError` naming the path instead of producing
320
+ an empty form whose save would erase the section; a type-changing integral
321
+ edit writes an integer literal, never a float; and schema-driven fields
322
+ display hex/octal/binary values in the base the document wrote them, in
323
+ every `unknownKeys` mode. Adversarially proof-tested before release.
324
+
304
325
  `0.5.1` — **partial JSON Schemas edit safely**: the new `unknownKeys` option
305
326
  (YAML, JSON, libconfig) governs keys the schema does not cover — `'preserve'`
306
327
  (default) keeps them verbatim on save, `'drop'` deletes them for
@@ -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; } });
@@ -10,7 +10,7 @@
10
10
  * each document level, mirroring the wire encoding of
11
11
  * ng-form-foundry's `serializeForm`/`toWireValue`.
12
12
  */
13
- import type { NodeGroup, NodeType } from './schema';
13
+ import type { Choice, NodeGroup, NodeType } from './schema';
14
14
  /**
15
15
  * The schema-born key set of one document level, as `name → child schema`
16
16
  * for recursion. `undefined` means "no schema in play": every key is
@@ -31,3 +31,10 @@ export type SchemaKeys = Map<string, NodeType | undefined> | undefined;
31
31
  export declare function childrenOf(node: NodeType): SchemaKeys;
32
32
  /** The per-item schema when `node` describes a list of groups, else undefined. */
33
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;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.childrenOf = childrenOf;
4
4
  exports.itemSchemaOf = itemSchemaOf;
5
+ exports.isSingleNodeBody = isSingleNodeBody;
5
6
  /**
6
7
  * The keys `serializeForm` can emit for a schema node standing at a document
7
8
  * object/group, with each key's schema for recursion. A nodeGroup
@@ -32,10 +33,19 @@ function childrenOf(node) {
32
33
  function itemSchemaOf(node) {
33
34
  return node?.kind === 'nodeGroupList' ? node.type : undefined;
34
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
+ }
35
45
  /** Field name → schema across every case of a choice (leaf-bodied cases too). */
36
46
  function* caseEntries(choice) {
37
47
  for (const body of Object.values(choice.cases)) {
38
- if ('kind' in body)
48
+ if (isSingleNodeBody(body))
39
49
  yield [body.name, body];
40
50
  else
41
51
  for (const [name, field] of Object.entries(body))
@@ -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; } });
@@ -7,6 +7,7 @@ const json_schema_1 = require("../../core/json-schema");
7
7
  const bigint_1 = require("../../core/bigint");
8
8
  const schema_keys_1 = require("../../core/schema-keys");
9
9
  const merge_inferred_1 = require("../../core/merge-inferred");
10
+ const shape_check_1 = require("../../core/shape-check");
10
11
  /**
11
12
  * A JSON {@link Transformer}: turn a JSON config document into a form and write
12
13
  * the edited value back to JSON. The form is built by the same format-agnostic
@@ -40,6 +41,10 @@ exports.jsonTransformer = {
40
41
  const schema = fromJsonSchema && unknownKeys === 'edit'
41
42
  ? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, infer_1.inferNodeGroup)(data, options?.rootName))
42
43
  : fromJsonSchema ?? (0, infer_1.inferNodeGroup)(data, options?.rootName);
44
+ // A container-shape disagreement between document and schema would erase
45
+ // the section on save: refuse up front (see assertSchemaShapes).
46
+ if (fromJsonSchema)
47
+ (0, shape_check_1.assertSchemaShapes)(data, fromJsonSchema);
43
48
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
44
49
  // 'preserve' gates the revert on the JSON Schema's NodeGroup; 'edit' on
45
50
  // the merged one (schema-born ≈ everything, but keys no form field can
@@ -1,2 +1,3 @@
1
1
  export { libconfigTransformer, type LibconfigBinding, type LibconfigOptions, } from './libconfig-transformer';
2
2
  export { parseLibconfig, LibconfigParseError } from './parser';
3
+ export { SchemaShapeError } from '../../core/shape-check';
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LibconfigParseError = exports.parseLibconfig = exports.libconfigTransformer = void 0;
3
+ exports.SchemaShapeError = 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
6
  var parser_1 = require("./parser");
7
7
  Object.defineProperty(exports, "parseLibconfig", { enumerable: true, get: function () { return parser_1.parseLibconfig; } });
8
8
  Object.defineProperty(exports, "LibconfigParseError", { enumerable: true, get: function () { return parser_1.LibconfigParseError; } });
9
+ // Thrown by schema-driven toSchema; re-exported so browser consumers of this
10
+ // subpath can catch it by instance.
11
+ var shape_check_1 = require("../../core/shape-check");
12
+ Object.defineProperty(exports, "SchemaShapeError", { enumerable: true, get: function () { return shape_check_1.SchemaShapeError; } });
@@ -5,6 +5,7 @@ const thesaurus_1 = require("../../core/thesaurus");
5
5
  const json_schema_1 = require("../../core/json-schema");
6
6
  const merge_inferred_1 = require("../../core/merge-inferred");
7
7
  const schema_keys_1 = require("../../core/schema-keys");
8
+ const shape_check_1 = require("../../core/shape-check");
8
9
  const parser_1 = require("./parser");
9
10
  const schema_1 = require("./schema");
10
11
  const revert_1 = require("./revert");
@@ -19,8 +20,17 @@ exports.libconfigTransformer = {
19
20
  const schema = fromJsonSchema && unknownKeys === 'edit'
20
21
  ? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__'))
21
22
  : fromJsonSchema ?? (0, schema_1.libconfigToNodeGroup)(root, source, options?.rootName ?? '__root__');
23
+ // Schema-driven leaves display in the base the document wrote them in —
24
+ // the JSON Schema cannot know it, the document does (see annotateSchemaRadix).
25
+ if (fromJsonSchema)
26
+ (0, schema_1.annotateSchemaRadix)(root, schema);
22
27
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
23
28
  const initialValue = (0, schema_1.extractValue)(root, source, options?.schema != null);
29
+ // A container-shape disagreement (group vs list vs scalar) produces a
30
+ // form that cannot carry the section and would erase it on save: refuse
31
+ // up front so the consumer can fall back to inferred editing.
32
+ if (fromJsonSchema)
33
+ (0, shape_check_1.assertSchemaShapes)(initialValue, fromJsonSchema);
24
34
  if (fromJsonSchema && unknownKeys === 'edit') {
25
35
  // Uncovered empty collections merged as read-only carries: their value
26
36
  // must be the verbatim slice, not the typed [] the extraction produced.
@@ -197,7 +197,10 @@ function emitScalar(value, node) {
197
197
  if ((node.type === 'int' || node.type === 'int64') && Number.isInteger(value)) {
198
198
  return intLiteral(value, node.int);
199
199
  }
200
- return floatLiteral(value); // a fractional edit into an int slot: emit honestly
200
+ // A type-changing edit (e.g. a quoted "0xe00" slot edited to a number
201
+ // under an integer schema): integral values emit an int literal —
202
+ // float form is reserved for genuinely fractional values.
203
+ return Number.isInteger(value) ? String(value) : floatLiteral(value);
201
204
  }
202
205
  return serialize(value, '');
203
206
  }
@@ -18,7 +18,7 @@
18
18
  * transformer options) replaces this inference entirely and makes typed
19
19
  * empty collections editable.
20
20
  */
21
- import type { NodeGroup } from '../../core/schema';
21
+ import type { NodeGroup, NodeType } from '../../core/schema';
22
22
  import { type SchemaKeys } from '../../core/schema-keys';
23
23
  import { type CfgGroup, type CfgList, type CfgValue } from './parser';
24
24
  /** Infer the root NodeGroup for a parsed document. */
@@ -37,6 +37,19 @@ type ListShape = 'groups' | 'scalars' | 'empty' | 'heterogeneous';
37
37
  export declare function listShape(list: CfgList): ListShape;
38
38
  /** The verbatim source slice of a node. */
39
39
  export declare function raw(value: CfgValue, source: string): string;
40
+ /**
41
+ * Stamp the document's non-decimal integer presentation onto a
42
+ * **schema-driven** NodeGroup as `radix` display hints — the JSON Schema has
43
+ * no radix vocabulary, so the document is the only authority on how a value
44
+ * is written. Covered number/string leaves whose literal is hex/octal/binary
45
+ * (and homogeneous collections thereof) display in that base in every
46
+ * `unknownKeys` mode, matching what inference does for uncovered fields.
47
+ * Fills gaps only: a `radix` already on the leaf (e.g. carried by
48
+ * `mergeInferred` under `'edit'`) wins. Keyed containers — groups, choices,
49
+ * maps — resolve through {@link childrenOf}, so case fields and map entries
50
+ * annotate too.
51
+ */
52
+ export declare function annotateSchemaRadix(node: CfgValue, schema: NodeType): void;
40
53
  /**
41
54
  * `unknownKeys: 'edit'` extraction fixup. The whole-document extraction runs
42
55
  * with `emptyAsArrays` (schema-covered empty collections are typed and
@@ -4,6 +4,7 @@ exports.libconfigToNodeGroup = libconfigToNodeGroup;
4
4
  exports.extractValue = extractValue;
5
5
  exports.listShape = listShape;
6
6
  exports.raw = raw;
7
+ exports.annotateSchemaRadix = annotateSchemaRadix;
7
8
  exports.carryUncoveredEmpties = carryUncoveredEmpties;
8
9
  const schema_keys_1 = require("../../core/schema-keys");
9
10
  const parser_1 = require("./parser");
@@ -186,6 +187,63 @@ function rawLeaf(name, why) {
186
187
  function raw(value, source) {
187
188
  return source.slice(value.span.start, value.span.end);
188
189
  }
190
+ /**
191
+ * Stamp the document's non-decimal integer presentation onto a
192
+ * **schema-driven** NodeGroup as `radix` display hints — the JSON Schema has
193
+ * no radix vocabulary, so the document is the only authority on how a value
194
+ * is written. Covered number/string leaves whose literal is hex/octal/binary
195
+ * (and homogeneous collections thereof) display in that base in every
196
+ * `unknownKeys` mode, matching what inference does for uncovered fields.
197
+ * Fills gaps only: a `radix` already on the leaf (e.g. carried by
198
+ * `mergeInferred` under `'edit'`) wins. Keyed containers — groups, choices,
199
+ * maps — resolve through {@link childrenOf}, so case fields and map entries
200
+ * annotate too.
201
+ */
202
+ function annotateSchemaRadix(node, schema) {
203
+ switch (schema.kind) {
204
+ case 'leaf': {
205
+ // Only number/string leaves render radix; a boolean/enum leaf covering
206
+ // an int literal is a scalar-type mismatch the form handles otherwise.
207
+ if (schema.type !== 'number' && schema.type !== 'string')
208
+ return;
209
+ if (node.kind === 'scalar' && node.int && node.int.radix !== 10 && !schema.radix) {
210
+ schema.radix = node.int.radix;
211
+ }
212
+ return;
213
+ }
214
+ case 'leafList': {
215
+ if (schema.radix)
216
+ return;
217
+ if ((node.kind === 'array' || node.kind === 'list') && node.elements.every((e) => e.kind === 'scalar')) {
218
+ const radix = sharedRadix(node.elements);
219
+ if (radix)
220
+ schema.radix = radix;
221
+ }
222
+ return;
223
+ }
224
+ case 'nodeGroupList': {
225
+ if (node.kind !== 'list')
226
+ return;
227
+ for (const el of node.elements)
228
+ annotateSchemaRadix(el, schema.type);
229
+ return;
230
+ }
231
+ default: {
232
+ // nodeGroup / choice / map — keyed containers over a document group.
233
+ if (node.kind !== 'group')
234
+ return;
235
+ const keys = (0, schema_keys_1.childrenOf)(schema);
236
+ if (!keys)
237
+ return;
238
+ for (const setting of node.settings) {
239
+ const child = keys.get(setting.name);
240
+ if (child)
241
+ annotateSchemaRadix(setting.value, child);
242
+ }
243
+ return;
244
+ }
245
+ }
246
+ }
189
247
  /**
190
248
  * `unknownKeys: 'edit'` extraction fixup. The whole-document extraction runs
191
249
  * with `emptyAsArrays` (schema-covered empty collections are typed and
@@ -9,3 +9,4 @@
9
9
  export { yamlTransformer } from './yaml-transformer';
10
10
  export type { YamlOptions, YamlBinding } from './yaml-transformer';
11
11
  export { applyValueToDocument } from './revert';
12
+ export { SchemaShapeError } from '../../core/shape-check';
@@ -8,8 +8,12 @@
8
8
  * `core` — they are format-agnostic and shared with the JSON transformer.
9
9
  */
10
10
  Object.defineProperty(exports, "__esModule", { value: true });
11
- exports.applyValueToDocument = exports.yamlTransformer = void 0;
11
+ exports.SchemaShapeError = exports.applyValueToDocument = exports.yamlTransformer = void 0;
12
12
  var yaml_transformer_1 = require("./yaml-transformer");
13
13
  Object.defineProperty(exports, "yamlTransformer", { enumerable: true, get: function () { return yaml_transformer_1.yamlTransformer; } });
14
14
  var revert_1 = require("./revert");
15
15
  Object.defineProperty(exports, "applyValueToDocument", { enumerable: true, get: function () { return revert_1.applyValueToDocument; } });
16
+ // Thrown by schema-driven toSchema; re-exported so subpath consumers can
17
+ // catch it by instance.
18
+ var shape_check_1 = require("../../core/shape-check");
19
+ Object.defineProperty(exports, "SchemaShapeError", { enumerable: true, get: function () { return shape_check_1.SchemaShapeError; } });
@@ -6,6 +6,8 @@ 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
8
  const merge_inferred_1 = require("../../core/merge-inferred");
9
+ const schema_keys_1 = require("../../core/schema-keys");
10
+ const shape_check_1 = require("../../core/shape-check");
9
11
  const bigint_1 = require("../../core/bigint");
10
12
  const revert_1 = require("./revert");
11
13
  /**
@@ -36,9 +38,14 @@ exports.yamlTransformer = {
36
38
  const schema = fromJsonSchema && unknownKeys === 'edit'
37
39
  ? (0, merge_inferred_1.mergeInferred)(fromJsonSchema, (0, infer_1.inferNodeGroup)(data, options?.rootName))
38
40
  : 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)
41
+ // A container-shape disagreement between document and schema would erase
42
+ // the section on save: refuse up front (see assertSchemaShapes).
43
+ if (fromJsonSchema)
44
+ (0, shape_check_1.assertSchemaShapes)(data, fromJsonSchema);
45
+ // Every mode picks up the document's hex/octal presentation: inference
46
+ // for its own leaves, schema-driven leaves too — the JSON Schema cannot
47
+ // know the base a value was written in, the document does.
48
+ if (doc.contents)
42
49
  annotateRadix(doc.contents, schema);
43
50
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
44
51
  return {
@@ -62,17 +69,23 @@ exports.yamlTransformer = {
62
69
  const RADIX_BY_FORMAT = { BIN: 2, OCT: 8, HEX: 16 };
63
70
  /**
64
71
  * Copy non-decimal integer presentation (`0x`/`0o`/`0b` literals) from the
65
- * parsed document onto the inferred schema as leaf/leafList `radix` display
66
- * hints — the plain-data inference cannot see them, because `toJS()` drops the
67
- * scalar format. The revert needs nothing: scalars are mutated in place, so an
68
- * edited value re-emits in its literal's own base. Across group-list items the
69
- * first non-decimal occurrence wins one shared item schema cannot vary the
70
- * display per item. Schema-driven mode is untouched (JSON Schema has no radix
71
- * vocabulary).
72
+ * parsed document onto the schema as leaf/leafList `radix` display hints —
73
+ * neither plain-data inference (`toJS()` drops the scalar format) nor a JSON
74
+ * Schema (no radix vocabulary) can know the base a value was written in; the
75
+ * document is the only authority. Runs in every mode and fills gaps only, so
76
+ * a `radix` already on a leaf wins. The revert needs nothing: scalars are
77
+ * mutated in place, so an edited value re-emits in its literal's own base.
78
+ * Across group-list items the first non-decimal occurrence wins — one shared
79
+ * item schema cannot vary the display per item. Keyed containers — groups,
80
+ * choices, maps — resolve their children through {@link childrenOf}, so case
81
+ * fields and map entries annotate too.
72
82
  */
73
83
  function annotateRadix(node, schema) {
74
84
  switch (schema.kind) {
75
85
  case 'leaf': {
86
+ // Only number/string leaves render radix.
87
+ if (schema.type !== 'number' && schema.type !== 'string')
88
+ return;
76
89
  const radix = scalarRadix(node);
77
90
  if (radix && !schema.radix)
78
91
  schema.radix = radix;
@@ -86,25 +99,27 @@ function annotateRadix(node, schema) {
86
99
  schema.radix = radixes[0];
87
100
  return;
88
101
  }
89
- case 'nodeGroup': {
102
+ case 'nodeGroupList': {
103
+ if (!(0, yaml_1.isSeq)(node))
104
+ return;
105
+ for (const item of node.items)
106
+ annotateRadix(item, schema.type);
107
+ return;
108
+ }
109
+ default: {
110
+ // nodeGroup / choice / map — keyed containers over a document mapping.
90
111
  if (!(0, yaml_1.isMap)(node))
91
112
  return;
113
+ const keys = (0, schema_keys_1.childrenOf)(schema);
114
+ if (!keys)
115
+ return;
92
116
  for (const pair of node.items) {
93
- const child = schema.children[pairKey(pair.key)];
117
+ const child = keys.get(pairKey(pair.key));
94
118
  if (child)
95
119
  annotateRadix(pair.value, child);
96
120
  }
97
121
  return;
98
122
  }
99
- case 'nodeGroupList': {
100
- if (!(0, yaml_1.isSeq)(node))
101
- return;
102
- for (const item of node.items)
103
- annotateRadix(item, schema.type);
104
- return;
105
- }
106
- default:
107
- return; // choice/map never come out of plain-data inference
108
123
  }
109
124
  }
110
125
  /** The display radix of a non-decimal integer scalar node, else undefined. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ng-form-foundry-transformers",
3
- "version": "0.5.1",
3
+ "version": "0.5.3",
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",