ng-form-foundry-transformers 0.2.1 → 0.3.0

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
@@ -69,9 +69,14 @@ const out = yamlTransformer.toSource({ ...initialValue, port: 9090 }, binding);
69
69
  // port: 9090
70
70
  ```
71
71
 
72
- With a JSON Schema: `yamlTransformer.toSchema(yaml, { schema })` `object` →
73
- nodeGroup, `array` of objects nodeGroupList, scalar `enum` → enum leaf,
74
- `required`/`title`/`default` carried onto the leaves.
72
+ With a JSON Schema: `yamlTransformer.toSchema(yaml, { schema })`. The mapping
73
+ covers **draft 2020-12** (back-compatible with draft-07): `object` → nodeGroup (or
74
+ a `map` for `additionalProperties`/`patternProperties`), `array` nodeGroupList /
75
+ leafList, `anyOf`/`oneOf` → choice (or a nullable leaf for `[T, null]`), `$ref` →
76
+ `$defs`/`definitions` resolved inline (local **or cross-file** via
77
+ `options.refDocuments`, matched by `$id`), `const` → a read-only leaf, and the string /
78
+ number constraints (`pattern`, `minLength`, `minimum`, `multipleOf`, `format`, …)
79
+ carried onto the leaves as validators.
75
80
 
76
81
  ## JSON transformer
77
82
 
@@ -176,10 +181,21 @@ npm test # node:test on the compiled output (no Python needed)
176
181
 
177
182
  ## Status
178
183
 
184
+ `0.3.0` — data-integrity release plus JSON Schema draft 2020-12. Fixes three
185
+ round-trip corruption bugs shipped in 0.2.1 (YAML maps with non-string keys
186
+ duplicated entries; integers beyond 2^53 lost precision — now carried
187
+ losslessly as strings, mirroring the YANG uint64 strategy; YANG identityref
188
+ resolved to the wrong module on a local-name collision). `jsonSchemaToNodeGroup`
189
+ grew from a draft-07 subset to draft 2020-12: `$ref`/`$defs`, cross-file `$ref`
190
+ by `$id` (`refDocuments` option), `anyOf`/`oneOf` → choice, `type: [T, 'null']`
191
+ → nullable, `const` → read-only, `additionalProperties`/`patternProperties` →
192
+ map, and string/number constraints as validators.
193
+
179
194
  `0.2.1` — first release under this name (formerly `yang-form-foundry`),
180
195
  restructured into a transformer catalog. YANG, YAML, and JSON transformers are
181
196
  complete; YAML and JSON share the format-agnostic form builders in `core` and are
182
- JSON-Schema-driven or inferred.
197
+ JSON-Schema-driven or inferred. **Deprecated: contains the round-trip corruption
198
+ bugs fixed in 0.3.0.**
183
199
 
184
200
  ## License
185
201
 
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Big-integer handling shared by the data transformers.
3
+ *
4
+ * JSON and YAML both write integers as bare numeric literals, but a JavaScript
5
+ * `number` only holds integers up to 2^53 − 1 exactly — parse `9007199254740993`
6
+ * and you get `…992` back. To avoid silently corrupting such values, the
7
+ * transformers carry any out-of-range integer as a **string** in the form value
8
+ * (the same strategy the YANG adapter uses for `int64`/`uint64`/`decimal64`) and
9
+ * re-emit it verbatim as an unquoted number. These helpers are the shared
10
+ * predicates for that path.
11
+ */
12
+ /** A string that is a base-10 integer literal (optionally signed), safe or not. */
13
+ export declare function isIntegerString(s: string): boolean;
14
+ /** A BigInt whose magnitude exceeds the safe range, so a `number` can't hold it. */
15
+ export declare function isUnsafeBigInt(b: bigint): boolean;
16
+ /** An integer literal whose magnitude a `number` can't hold without loss. */
17
+ export declare function isUnsafeIntegerString(s: string): boolean;
@@ -0,0 +1,30 @@
1
+ "use strict";
2
+ /**
3
+ * Big-integer handling shared by the data transformers.
4
+ *
5
+ * JSON and YAML both write integers as bare numeric literals, but a JavaScript
6
+ * `number` only holds integers up to 2^53 − 1 exactly — parse `9007199254740993`
7
+ * and you get `…992` back. To avoid silently corrupting such values, the
8
+ * transformers carry any out-of-range integer as a **string** in the form value
9
+ * (the same strategy the YANG adapter uses for `int64`/`uint64`/`decimal64`) and
10
+ * re-emit it verbatim as an unquoted number. These helpers are the shared
11
+ * predicates for that path.
12
+ */
13
+ Object.defineProperty(exports, "__esModule", { value: true });
14
+ exports.isIntegerString = isIntegerString;
15
+ exports.isUnsafeBigInt = isUnsafeBigInt;
16
+ exports.isUnsafeIntegerString = isUnsafeIntegerString;
17
+ /** Largest integer a `number` represents exactly (2^53 − 1), as a BigInt. */
18
+ const MAX_SAFE = BigInt(Number.MAX_SAFE_INTEGER);
19
+ /** A string that is a base-10 integer literal (optionally signed), safe or not. */
20
+ function isIntegerString(s) {
21
+ return /^-?\d+$/.test(s);
22
+ }
23
+ /** A BigInt whose magnitude exceeds the safe range, so a `number` can't hold it. */
24
+ function isUnsafeBigInt(b) {
25
+ return (b < 0n ? -b : b) > MAX_SAFE;
26
+ }
27
+ /** An integer literal whose magnitude a `number` can't hold without loss. */
28
+ function isUnsafeIntegerString(s) {
29
+ return isIntegerString(s) && isUnsafeBigInt(BigInt(s));
30
+ }
@@ -9,6 +9,6 @@ export type { Transformer, TransformResult, BindingMap } from './transformer';
9
9
  export { TransformerRegistry } from './registry';
10
10
  export { inferNodeGroup } from './infer';
11
11
  export { jsonSchemaToNodeGroup } from './json-schema';
12
- export type { JsonSchema } from './json-schema';
13
- export type { NodeGroup, NodeType, Leaf, LeafList, NodeGroupList, Choice, LeafType, FormValue, } from './schema';
12
+ export type { JsonSchema, JsonSchemaOptions } from './json-schema';
13
+ export type { NodeGroup, NodeType, Leaf, LeafList, NodeGroupList, Choice, ChoiceCase, NodeMap, LeafType, FormValue, } from './schema';
14
14
  export { CASE_KEY } from './schema';
@@ -1,26 +1,65 @@
1
1
  import type { NodeGroup } from './schema';
2
2
  /**
3
- * The subset of JSON Schema (draft-07 style) this transformer maps to a form.
4
- * Only the keywords that shape a form are read; anything else is ignored. No
5
- * validation is performed the schema drives *structure and types*, not
6
- * constraint checking (that stays server-side).
3
+ * The subset of JSON Schema (draft 2020-12, back-compatible with draft-07) this
4
+ * transformer maps to a form. Only the keywords that shape a form are read;
5
+ * anything else is ignored. The mapping drives *structure, types, and
6
+ * constraints*; the library turns the constraints into validators, but no
7
+ * validation happens here.
7
8
  */
8
9
  export interface JsonSchema {
10
+ $schema?: string;
11
+ $id?: string;
12
+ $ref?: string;
13
+ /** Draft 2020-12 reusable subschemas (draft-07 used `definitions`). */
14
+ $defs?: Record<string, JsonSchema>;
15
+ definitions?: Record<string, JsonSchema>;
9
16
  type?: JsonSchemaType | JsonSchemaType[];
10
17
  properties?: Record<string, JsonSchema>;
11
18
  required?: string[];
19
+ additionalProperties?: boolean | JsonSchema;
20
+ patternProperties?: Record<string, JsonSchema>;
12
21
  items?: JsonSchema;
22
+ anyOf?: JsonSchema[];
23
+ oneOf?: JsonSchema[];
13
24
  enum?: unknown[];
25
+ const?: unknown;
14
26
  title?: string;
27
+ description?: string;
15
28
  default?: unknown;
29
+ pattern?: string;
30
+ minLength?: number;
31
+ maxLength?: number;
32
+ format?: string;
33
+ minimum?: number;
34
+ maximum?: number;
35
+ multipleOf?: number;
36
+ minProperties?: number;
37
+ maxProperties?: number;
38
+ minItems?: number;
39
+ maxItems?: number;
40
+ }
41
+ /** Options for {@link jsonSchemaToNodeGroup}. */
42
+ export interface JsonSchemaOptions {
43
+ /**
44
+ * Other schema documents a `$ref` may point at, matched by their `$id`. A
45
+ * cross-file ref like `/jsonschemas/common#/$defs/UeId` resolves into the
46
+ * document whose `$id` ends with `/jsonschemas/common`; refs *within* that
47
+ * document then resolve against it.
48
+ */
49
+ refDocuments?: JsonSchema[];
16
50
  }
17
51
  type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';
18
52
  /**
19
53
  * Map a JSON Schema whose top level is an object into a root {@link NodeGroup}.
20
- * `object` → nodeGroup, `array` of objects → nodeGroupList, `array` of scalars →
21
- * leafList, a scalar with `enum` enum leaf, other scalars → typed leaf.
22
- * `required` marks child leaves, `title` becomes the label, `default` carries a
23
- * scalar default.
54
+ *
55
+ * Supports draft 2020-12: local and cross-document `$ref` (see
56
+ * {@link JsonSchemaOptions.refDocuments}) are inline-resolved; `object`
57
+ * nodeGroup (or a {@link NodeMap} when the keys are open via
58
+ * `additionalProperties`/`patternProperties`); `anyOf`/`oneOf` → a {@link Choice}
59
+ * (or a nullable leaf for the `[T, null]` shape); `array` → nodeGroupList or
60
+ * leafList; a scalar (with `enum`, `const`, `type: [T, 'null']`, and the string /
61
+ * number constraints) → a typed leaf. `required` marks child leaves, `title`
62
+ * becomes the label, `default`/`const` carry a scalar value.
24
63
  */
25
- export declare function jsonSchemaToNodeGroup(schema: JsonSchema, name?: string): NodeGroup;
64
+ export declare function jsonSchemaToNodeGroup(schema: JsonSchema, name?: string, options?: JsonSchemaOptions): NodeGroup;
26
65
  export {};
@@ -4,54 +4,212 @@ exports.jsonSchemaToNodeGroup = jsonSchemaToNodeGroup;
4
4
  const ROOT = '__root__';
5
5
  /**
6
6
  * Map a JSON Schema whose top level is an object into a root {@link NodeGroup}.
7
- * `object` → nodeGroup, `array` of objects → nodeGroupList, `array` of scalars →
8
- * leafList, a scalar with `enum` enum leaf, other scalars → typed leaf.
9
- * `required` marks child leaves, `title` becomes the label, `default` carries a
10
- * scalar default.
7
+ *
8
+ * Supports draft 2020-12: local and cross-document `$ref` (see
9
+ * {@link JsonSchemaOptions.refDocuments}) are inline-resolved; `object`
10
+ * nodeGroup (or a {@link NodeMap} when the keys are open via
11
+ * `additionalProperties`/`patternProperties`); `anyOf`/`oneOf` → a {@link Choice}
12
+ * (or a nullable leaf for the `[T, null]` shape); `array` → nodeGroupList or
13
+ * leafList; a scalar (with `enum`, `const`, `type: [T, 'null']`, and the string /
14
+ * number constraints) → a typed leaf. `required` marks child leaves, `title`
15
+ * becomes the label, `default`/`const` carry a scalar value.
11
16
  */
12
- function jsonSchemaToNodeGroup(schema, name = ROOT) {
13
- const group = objectToNodeGroup(schema, name, schema.title);
17
+ function jsonSchemaToNodeGroup(schema, name = ROOT, options) {
18
+ const documents = [schema, ...(options?.refDocuments ?? [])];
19
+ const resolver = new RefResolver(schema, documents);
20
+ const { schema: root, scope } = resolver.resolve(schema);
21
+ const group = objectToNodeGroup(root, name, scope, root.title);
14
22
  group.root = true;
15
23
  return group;
16
24
  }
17
- function objectToNodeGroup(schema, name, label) {
25
+ /**
26
+ * Resolves `$ref` chains, following them across documents. A resolver is bound to
27
+ * one document (for `#/…` local refs); crossing into another document via a
28
+ * `<path>#/…` ref yields a resolver bound to *that* document, so the resolved
29
+ * schema's own local refs resolve correctly.
30
+ */
31
+ class RefResolver {
32
+ constructor(doc, documents) {
33
+ this.doc = doc;
34
+ this.documents = documents;
35
+ }
36
+ resolve(schema) {
37
+ let current = schema;
38
+ let doc = this.doc;
39
+ const seen = new Set();
40
+ while (current && typeof current.$ref === 'string') {
41
+ const key = `${current.$ref}@${doc.$id ?? ''}`;
42
+ if (seen.has(key))
43
+ break; // cycle guard
44
+ seen.add(key);
45
+ const [docPath, fragment] = splitRef(current.$ref);
46
+ const targetDoc = docPath ? findDocument(this.documents, docPath) ?? doc : doc;
47
+ const target = resolveFragment(targetDoc, fragment);
48
+ if (!target)
49
+ break;
50
+ current = target;
51
+ doc = targetDoc;
52
+ }
53
+ return { schema: current, scope: doc === this.doc ? this : new RefResolver(doc, this.documents) };
54
+ }
55
+ }
56
+ /** Split a `$ref` into its document part (URI without fragment) and its fragment. */
57
+ function splitRef(ref) {
58
+ const hash = ref.indexOf('#');
59
+ if (hash === -1)
60
+ return [ref || undefined, ''];
61
+ return [ref.slice(0, hash) || undefined, ref.slice(hash + 1)];
62
+ }
63
+ /** The document whose `$id` equals or ends with the ref's document path. */
64
+ function findDocument(documents, docPath) {
65
+ return documents.find((d) => typeof d.$id === 'string' && (d.$id === docPath || d.$id.endsWith(docPath)));
66
+ }
67
+ /** Walk a JSON Pointer fragment (`/$defs/UeId`) within a document. */
68
+ function resolveFragment(doc, fragment) {
69
+ if (!fragment || fragment === '/')
70
+ return doc;
71
+ let node = doc;
72
+ for (const raw of fragment.split('/')) {
73
+ if (raw === '')
74
+ continue;
75
+ const segment = raw.replace(/~1/g, '/').replace(/~0/g, '~');
76
+ if (node == null || typeof node !== 'object')
77
+ return undefined;
78
+ node = node[segment];
79
+ }
80
+ return node;
81
+ }
82
+ // --- objects / maps -----------------------------------------------------------
83
+ function objectToNodeGroup(schema, name, resolver, label) {
18
84
  const required = new Set(schema.required ?? []);
19
85
  const children = {};
20
86
  for (const [key, propSchema] of Object.entries(schema.properties ?? {})) {
21
- children[key] = schemaToNode(key, propSchema, required.has(key));
87
+ children[key] = schemaToNode(key, propSchema, required.has(key), resolver);
22
88
  }
23
89
  const group = { kind: 'nodeGroup', name, children };
24
90
  if (label)
25
91
  group.label = label;
26
92
  return group;
27
93
  }
28
- function schemaToNode(name, schema, required) {
94
+ /** An `object` with open keys — `additionalProperties: <schema>` or `patternProperties`, and no fixed `properties`. */
95
+ function isOpenMap(schema) {
96
+ if (schema.properties && Object.keys(schema.properties).length)
97
+ return false;
98
+ const ap = schema.additionalProperties;
99
+ const additionalIsSchema = ap != null && typeof ap === 'object';
100
+ const hasPattern = !!schema.patternProperties && Object.keys(schema.patternProperties).length > 0;
101
+ return additionalIsSchema || hasPattern;
102
+ }
103
+ function objectToMap(name, schema, resolver) {
104
+ let valueSchema = {};
105
+ let keyPattern;
106
+ const pattern = schema.patternProperties && Object.entries(schema.patternProperties)[0];
107
+ if (pattern) {
108
+ [keyPattern, valueSchema] = pattern;
109
+ }
110
+ else if (schema.additionalProperties && typeof schema.additionalProperties === 'object') {
111
+ valueSchema = schema.additionalProperties;
112
+ }
113
+ const node = { kind: 'map', name, value: schemaToNode('value', valueSchema, false, resolver) };
114
+ if (schema.title)
115
+ node.label = schema.title;
116
+ if (keyPattern)
117
+ node.keyPattern = keyPattern;
118
+ if (typeof schema.minProperties === 'number')
119
+ node.minEntries = schema.minProperties;
120
+ if (typeof schema.maxProperties === 'number')
121
+ node.maxEntries = schema.maxProperties;
122
+ return node;
123
+ }
124
+ // --- anyOf / oneOf → choice ---------------------------------------------------
125
+ /** The `anyOf`/`oneOf` `[T, null]` shape collapses to a nullable leaf; anything else is a choice. */
126
+ function branchesToNode(name, schema, rawBranches, required, resolver) {
127
+ const branches = rawBranches.map((b) => resolver.resolve(b));
128
+ const nonNull = branches.filter((b) => b.schema.type !== 'null');
129
+ const sole = nonNull.length === 1 ? nonNull[0] : undefined;
130
+ if (sole && nonNull.length < branches.length && isScalarSchema(sole.schema)) {
131
+ const leaf = scalarLeaf(name, sole.schema, required);
132
+ leaf.nullable = true;
133
+ return leaf;
134
+ }
135
+ const cases = {};
136
+ const caseLabels = {};
137
+ branches.forEach(({ schema: branch, scope }, index) => {
138
+ const caseName = `case${index}`;
139
+ cases[caseName] = branchToCase(branch, scope);
140
+ if (branch.title)
141
+ caseLabels[caseName] = branch.title;
142
+ });
143
+ const choice = { kind: 'choice', name, cases };
144
+ if (schema.title)
145
+ choice.label = schema.title;
146
+ if (Object.keys(caseLabels).length)
147
+ choice.caseLabels = caseLabels;
148
+ return choice;
149
+ }
150
+ /** A resolved branch → a case body: a field record for an object branch, else a single node. */
151
+ function branchToCase(branch, scope) {
152
+ if (primaryType(branch) === 'object' && branch.properties) {
153
+ const required = new Set(branch.required ?? []);
154
+ const fields = {};
155
+ for (const [key, propSchema] of Object.entries(branch.properties)) {
156
+ fields[key] = schemaToNode(key, propSchema, required.has(key), scope);
157
+ }
158
+ return fields;
159
+ }
160
+ return schemaToNode('value', branch, false, scope);
161
+ }
162
+ // --- dispatch -----------------------------------------------------------------
163
+ function schemaToNode(name, rawSchema, required, resolver) {
164
+ const { schema, scope } = resolver.resolve(rawSchema);
165
+ const branches = schema.anyOf ?? schema.oneOf;
166
+ if (branches && branches.length) {
167
+ return branchesToNode(name, schema, branches, required, scope);
168
+ }
29
169
  const type = primaryType(schema);
30
- if (type === 'object') {
31
- return objectToNodeGroup(schema, name, schema.title);
170
+ if (type === 'object' || (type === undefined && (schema.properties || isOpenMap(schema)))) {
171
+ if (isOpenMap(schema))
172
+ return objectToMap(name, schema, scope);
173
+ return objectToNodeGroup(schema, name, scope, schema.title);
32
174
  }
33
175
  if (type === 'array') {
34
- const items = schema.items ?? {};
35
- if (primaryType(items) === 'object') {
176
+ const { schema: items, scope: itemScope } = scope.resolve(schema.items ?? {});
177
+ if (primaryType(items) === 'object' && items.properties) {
36
178
  const node = {
37
179
  kind: 'nodeGroupList',
38
180
  name,
39
- type: objectToNodeGroup(items, name, items.title),
181
+ type: objectToNodeGroup(items, name, itemScope, items.title),
40
182
  };
41
183
  if (schema.title)
42
184
  node.label = schema.title;
185
+ if (typeof schema.minItems === 'number')
186
+ node.minItems = schema.minItems;
187
+ if (typeof schema.maxItems === 'number')
188
+ node.maxItems = schema.maxItems;
43
189
  return node;
44
190
  }
45
191
  const list = { kind: 'leafList', name, type: scalarType(items) };
46
192
  if (schema.title)
47
193
  list.label = schema.title;
194
+ if (typeof schema.minItems === 'number')
195
+ list.minItems = schema.minItems;
196
+ if (typeof schema.maxItems === 'number')
197
+ list.maxItems = schema.maxItems;
48
198
  return list;
49
199
  }
50
- // scalar leaf (possibly an enum)
200
+ return scalarLeaf(name, schema, required);
201
+ }
202
+ // --- scalar leaves ------------------------------------------------------------
203
+ function scalarLeaf(name, schema, required) {
51
204
  const leaf = { kind: 'leaf', name, type: scalarType(schema) };
52
205
  if (schema.enum && schema.enum.length) {
53
206
  leaf.type = 'enum';
54
- leaf.enum = schema.enum.filter((v) => typeof v === 'string' || typeof v === 'number');
207
+ leaf.enum = schema.enum.filter(isEnumMember);
208
+ }
209
+ // `const` — a fixed, read-only value.
210
+ if ('const' in schema && isScalar(schema.const)) {
211
+ leaf.default = schema.const;
212
+ leaf.readOnly = true;
55
213
  }
56
214
  if (required)
57
215
  leaf.required = true;
@@ -59,13 +217,41 @@ function schemaToNode(name, schema, required) {
59
217
  leaf.default = schema.default;
60
218
  if (schema.title)
61
219
  leaf.label = schema.title;
220
+ if (schema.description)
221
+ leaf.description = schema.description;
222
+ if (isNullable(schema))
223
+ leaf.nullable = true;
224
+ if (leaf.type === 'string') {
225
+ if (typeof schema.pattern === 'string')
226
+ leaf.pattern = schema.pattern;
227
+ if (typeof schema.minLength === 'number')
228
+ leaf.minLength = schema.minLength;
229
+ if (typeof schema.maxLength === 'number')
230
+ leaf.maxLength = schema.maxLength;
231
+ const format = mapFormat(schema.format);
232
+ if (format)
233
+ leaf.format = format;
234
+ }
235
+ else if (leaf.type === 'number') {
236
+ if (primaryType(schema) === 'integer')
237
+ leaf.integer = true;
238
+ if (typeof schema.minimum === 'number')
239
+ leaf.min = schema.minimum;
240
+ if (typeof schema.maximum === 'number')
241
+ leaf.max = schema.maximum;
242
+ if (typeof schema.multipleOf === 'number')
243
+ leaf.multipleOf = schema.multipleOf;
244
+ }
62
245
  return leaf;
63
246
  }
247
+ // --- helpers ------------------------------------------------------------------
64
248
  /** The first declared type, ignoring a `null` companion in a `[T, 'null']` union. */
65
249
  function primaryType(schema) {
66
250
  const t = schema.type;
67
251
  if (Array.isArray(t))
68
252
  return t.find((x) => x !== 'null');
253
+ if (t === undefined && schema.const !== undefined)
254
+ return constType(schema.const);
69
255
  return t;
70
256
  }
71
257
  /** Map a JSON Schema scalar type to a form {@link LeafType} (default `string`). */
@@ -82,6 +268,33 @@ function scalarType(schema) {
82
268
  return 'string';
83
269
  }
84
270
  }
271
+ function isScalarSchema(schema) {
272
+ const t = primaryType(schema);
273
+ return t === 'string' || t === 'number' || t === 'integer' || t === 'boolean' || (t === undefined && schema.enum != null);
274
+ }
275
+ function isNullable(schema) {
276
+ return Array.isArray(schema.type) && schema.type.includes('null');
277
+ }
278
+ function constType(value) {
279
+ if (typeof value === 'boolean')
280
+ return 'boolean';
281
+ if (typeof value === 'number')
282
+ return 'number';
283
+ if (typeof value === 'string')
284
+ return 'string';
285
+ return undefined;
286
+ }
287
+ /** Map a JSON Schema `format` to the leaf formats the library validates. */
288
+ function mapFormat(format) {
289
+ if (format === 'email' || format === 'idn-email')
290
+ return 'email';
291
+ if (format === 'uri' || format === 'iri' || format === 'url')
292
+ return 'uri';
293
+ return undefined;
294
+ }
295
+ function isEnumMember(v) {
296
+ return typeof v === 'string' || typeof v === 'number';
297
+ }
85
298
  function isScalar(v) {
86
299
  return typeof v === 'string' || typeof v === 'number' || typeof v === 'boolean';
87
300
  }
@@ -18,6 +18,27 @@ export interface Leaf {
18
18
  default?: string | number | boolean;
19
19
  /** Present when `type` is `'enum'`. */
20
20
  enum?: (string | number)[];
21
+ /** Display labels for the enum options, positionally aligned with `enum`. */
22
+ enumLabel?: string[];
23
+ /** Reject values not matching this regex (JSON Schema `pattern`; unanchored). */
24
+ pattern?: string;
25
+ minLength?: number;
26
+ maxLength?: number;
27
+ /** Semantic string format (JSON Schema `format`). */
28
+ format?: 'email' | 'uri' | 'url';
29
+ /** Require a whole-number value (JSON Schema `type: 'integer'`). */
30
+ integer?: boolean;
31
+ /** Inclusive bounds (JSON Schema `minimum`/`maximum`). */
32
+ min?: number;
33
+ max?: number;
34
+ multipleOf?: number;
35
+ /** The value may be `null` (JSON Schema `type: [T, 'null']`). */
36
+ nullable?: boolean;
37
+ /** Optional scalar whose presence is itself data (on/off toggle). */
38
+ presence?: boolean;
39
+ /** Render read-only; with `default`, expresses a JSON Schema `const`. */
40
+ readOnly?: boolean;
41
+ description?: string;
21
42
  }
22
43
  export interface LeafList {
23
44
  kind: 'leafList';
@@ -48,21 +69,45 @@ export interface NodeGroupList {
48
69
  minItems?: number;
49
70
  maxItems?: number;
50
71
  }
72
+ /** One case body: a record of named fields, or a single node (a leaf-bodied case). */
73
+ export type ChoiceCase = Record<string, NodeType> | NodeType;
51
74
  /**
52
75
  * A discriminated selection: the user picks one `case`, and only that case's
53
76
  * fields are present. In the form value a choice is an object
54
77
  * `{ __case: <caseName>, ...that case's fields }`; the adapter flattens it to the
55
- * inline YANG encoding on write-back.
78
+ * inline YANG encoding on write-back. Cases may be anonymous/auto-named (from a
79
+ * JSON Schema `anyOf`/`oneOf`); the active case is inferred from seed data.
56
80
  */
57
81
  export interface Choice {
58
82
  kind: 'choice';
59
83
  name: string;
60
84
  label?: string;
61
- cases: Record<string, Record<string, NodeType>>;
85
+ cases: Record<string, ChoiceCase>;
86
+ /** Display labels for cases, keyed by case name. */
87
+ caseLabels?: Record<string, string>;
62
88
  default?: string;
63
89
  mandatory?: boolean;
64
90
  }
65
- export type NodeType = Leaf | LeafList | NodeGroup | NodeGroupList | Choice;
91
+ /**
92
+ * An open, arbitrary-keyed record: unlike {@link NodeGroup} (a fixed key set), a
93
+ * map's keys are runtime data and every value conforms to one `value` schema.
94
+ * Maps JSON Schema `additionalProperties: <schema>` / `patternProperties`.
95
+ */
96
+ export interface NodeMap {
97
+ kind: 'map';
98
+ name: string;
99
+ label?: string;
100
+ description?: string;
101
+ /** The schema every entry's value conforms to. */
102
+ value: NodeType;
103
+ keyLabel?: string;
104
+ /** `patternProperties`: entry keys must match this regex. */
105
+ keyPattern?: string;
106
+ minEntries?: number;
107
+ maxEntries?: number;
108
+ presence?: boolean;
109
+ }
110
+ export type NodeType = Leaf | LeafList | NodeGroup | NodeGroupList | Choice | NodeMap;
66
111
  /** The form-value key that records which case of a {@link Choice} is active. */
67
112
  export declare const CASE_KEY = "__case";
68
113
  /** A plain, nested value object produced/consumed by a rendered form. */
@@ -17,6 +17,12 @@ export interface JsonFormat {
17
17
  indent: number;
18
18
  /** Whether the source ended with a trailing newline. */
19
19
  trailingNewline: boolean;
20
+ /**
21
+ * Paths (as `JSON.stringify`'d key/index arrays) that held an integer literal
22
+ * too large for a JS `number`. Their form value is a string; `toSource` emits
23
+ * them back as unquoted numbers so precision survives the round-trip.
24
+ */
25
+ bigInts: string[];
20
26
  }
21
27
  /**
22
28
  * A JSON {@link Transformer}: turn a JSON config document into a form and write
@@ -30,6 +36,10 @@ export interface JsonFormat {
30
36
  * preserved for untouched keys. For JSON **with comments** (JSONC), parse and
31
37
  * edit it as YAML with {@link import('../yaml/yaml-transformer').yamlTransformer}
32
38
  * instead — `JSON.parse` rejects comments.
39
+ *
40
+ * Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
41
+ * as strings in the form value and re-emitted verbatim as unquoted numbers (the
42
+ * same strategy the YANG adapter uses for `uint64`).
33
43
  */
34
44
  export declare const jsonTransformer: {
35
45
  id: string;
@@ -3,6 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.jsonTransformer = void 0;
4
4
  const infer_1 = require("../../core/infer");
5
5
  const json_schema_1 = require("../../core/json-schema");
6
+ const bigint_1 = require("../../core/bigint");
6
7
  /**
7
8
  * A JSON {@link Transformer}: turn a JSON config document into a form and write
8
9
  * the edited value back to JSON. The form is built by the same format-agnostic
@@ -15,22 +16,33 @@ const json_schema_1 = require("../../core/json-schema");
15
16
  * preserved for untouched keys. For JSON **with comments** (JSONC), parse and
16
17
  * edit it as YAML with {@link import('../yaml/yaml-transformer').yamlTransformer}
17
18
  * instead — `JSON.parse` rejects comments.
19
+ *
20
+ * Integers beyond 2^53 can't survive a `number` round-trip, so they are carried
21
+ * as strings in the form value and re-emitted verbatim as unquoted numbers (the
22
+ * same strategy the YANG adapter uses for `uint64`).
18
23
  */
19
24
  exports.jsonTransformer = {
20
25
  id: 'json',
21
26
  toSchema(source, options) {
22
- const data = (JSON.parse(source) ?? {});
27
+ const bigInts = [];
28
+ const parsed = JSON.parse(source, bigIntReviver);
29
+ const data = collectBigInts(parsed ?? {}, [], bigInts);
23
30
  const schema = options?.schema
24
31
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName)
25
32
  : (0, infer_1.inferNodeGroup)(data, options?.rootName);
26
33
  const binding = {
27
34
  indent: detectIndent(source),
28
35
  trailingNewline: source.endsWith('\n'),
36
+ bigInts,
29
37
  };
30
38
  return { schema, binding, initialValue: data };
31
39
  },
32
40
  toSource(value, binding) {
33
- const text = JSON.stringify(value, null, binding.indent);
41
+ const paths = new Set(binding.bigInts);
42
+ const prepared = paths.size ? markBigInts(value, [], paths) : value;
43
+ let text = JSON.stringify(prepared, null, binding.indent);
44
+ if (paths.size)
45
+ text = text.replace(BIGINT_MARK_RE, '$1');
34
46
  return binding.trailingNewline ? text + '\n' : text;
35
47
  },
36
48
  };
@@ -39,3 +51,81 @@ function detectIndent(source) {
39
51
  const match = source.match(/\n( +)\S/);
40
52
  return match && match[1] ? match[1].length : 2;
41
53
  }
54
+ // --- big-integer preservation -------------------------------------------------
55
+ /** A key/index path key, collision-free across separator characters. */
56
+ function pathKey(path) {
57
+ return JSON.stringify(path);
58
+ }
59
+ /** Placeholder wrapping a big-int value through `JSON.stringify`, stripped after. */
60
+ const BIGINT_MARK = '@@nff-bigint@@';
61
+ const BIGINT_MARK_RE = new RegExp(`"${BIGINT_MARK}(-?\\d+)${BIGINT_MARK}"`, 'g');
62
+ /** An out-of-range integer literal, captured verbatim from the source. */
63
+ class BigIntLiteral {
64
+ constructor(digits) {
65
+ this.digits = digits;
66
+ }
67
+ }
68
+ /**
69
+ * A `JSON.parse` reviver that captures integer literals too large for a `number`.
70
+ * The parsed `value` is already lossy, so the exact digits come from the reviver
71
+ * `context.source` (the raw literal text; available on Node 21+). Genuine strings
72
+ * never reach here as numbers, so a quoted big-digit value stays a plain string.
73
+ *
74
+ * On a runtime without `context.source` (Node < 21) the exact digits can't be
75
+ * recovered, so an out-of-range integer throws with an actionable message rather
76
+ * than being silently rounded. Safe integers and floats are unaffected there.
77
+ */
78
+ function bigIntReviver(_key, value, context) {
79
+ if (typeof value !== 'number')
80
+ return value;
81
+ if (context && typeof context.source === 'string') {
82
+ return (0, bigint_1.isUnsafeIntegerString)(context.source) ? new BigIntLiteral(context.source) : value;
83
+ }
84
+ if (Number.isInteger(value) && !Number.isSafeInteger(value)) {
85
+ throw new RangeError(`JSON integer ${value} exceeds the safe range (2^53) and its exact value cannot be ` +
86
+ `recovered on this runtime. Use Node ≥ 21 for lossless big-integer support, or ` +
87
+ `quote the value as a string in the source.`);
88
+ }
89
+ return value;
90
+ }
91
+ /**
92
+ * Replace each {@link BigIntLiteral} with its digit string in the form value, and
93
+ * record its path so `toSource` can re-emit it unquoted. Walks objects and arrays.
94
+ */
95
+ function collectBigInts(node, path, out) {
96
+ if (node instanceof BigIntLiteral) {
97
+ out.push(pathKey(path));
98
+ return node.digits;
99
+ }
100
+ if (Array.isArray(node))
101
+ return node.map((v, i) => collectBigInts(v, [...path, i], out));
102
+ if (isPlainObject(node)) {
103
+ const res = {};
104
+ for (const key of Object.keys(node))
105
+ res[key] = collectBigInts(node[key], [...path, key], out);
106
+ return res;
107
+ }
108
+ return node;
109
+ }
110
+ /**
111
+ * Wrap the string value at each recorded big-int path in a placeholder so
112
+ * `JSON.stringify` emits it as a (quoted) string that {@link BIGINT_MARK_RE} then
113
+ * unquotes. A value the user has edited to a non-integer stays a normal string.
114
+ */
115
+ function markBigInts(node, path, paths) {
116
+ if (typeof node === 'string') {
117
+ return paths.has(pathKey(path)) && (0, bigint_1.isIntegerString)(node) ? `${BIGINT_MARK}${node}${BIGINT_MARK}` : node;
118
+ }
119
+ if (Array.isArray(node))
120
+ return node.map((v, i) => markBigInts(v, [...path, i], paths));
121
+ if (isPlainObject(node)) {
122
+ const res = {};
123
+ for (const key of Object.keys(node))
124
+ res[key] = markBigInts(node[key], [...path, key], paths);
125
+ return res;
126
+ }
127
+ return node;
128
+ }
129
+ function isPlainObject(v) {
130
+ return typeof v === 'object' && v !== null && !Array.isArray(v);
131
+ }
@@ -9,5 +9,10 @@ import { type Document } from 'yaml';
9
9
  * comments. Counterpart of the schema/inference in {@link import('./infer')} and
10
10
  * {@link import('./json-schema')}. Callers should clone the document first if the
11
11
  * original must be preserved.
12
+ *
13
+ * Map keys are matched by the *same string form* `doc.toJS()` produced when the
14
+ * form value was built, so non-string keys (a `80:` port map, a `true:` flag)
15
+ * reconcile against their original typed key nodes instead of being appended as
16
+ * duplicate string-keyed pairs.
12
17
  */
13
18
  export declare function applyValueToDocument(doc: Document, value: unknown): void;
@@ -2,6 +2,7 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.applyValueToDocument = applyValueToDocument;
4
4
  const yaml_1 = require("yaml");
5
+ const bigint_1 = require("../../core/bigint");
5
6
  /**
6
7
  * Apply an edited form value onto a parsed YAML {@link Document} in place,
7
8
  * preserving comments and formatting on every node that survives the edit.
@@ -12,62 +13,97 @@ const yaml_1 = require("yaml");
12
13
  * comments. Counterpart of the schema/inference in {@link import('./infer')} and
13
14
  * {@link import('./json-schema')}. Callers should clone the document first if the
14
15
  * original must be preserved.
16
+ *
17
+ * Map keys are matched by the *same string form* `doc.toJS()` produced when the
18
+ * form value was built, so non-string keys (a `80:` port map, a `true:` flag)
19
+ * reconcile against their original typed key nodes instead of being appended as
20
+ * duplicate string-keyed pairs.
15
21
  */
16
22
  function applyValueToDocument(doc, value) {
17
23
  if (doc.contents == null) {
18
24
  doc.contents = doc.createNode(value);
19
25
  return;
20
26
  }
21
- applyAt(doc, [], value);
27
+ doc.contents = applyToNode(doc, doc.contents, value);
22
28
  }
23
- function applyAt(doc, path, value) {
24
- const node = nodeAt(doc, path);
29
+ /**
30
+ * Reconcile the parsed `node` at one position with its edited `value`, returning
31
+ * the node to store there. A scalar is mutated in place so its comments survive;
32
+ * a map/sequence is reconciled child-by-child; a shape change (or a brand-new
33
+ * position) allocates a fresh node.
34
+ */
35
+ function applyToNode(doc, node, value) {
25
36
  if (isPlainObject(value)) {
26
37
  if (!(0, yaml_1.isMap)(node))
27
- return replaceAt(doc, path, value);
28
- const keys = new Set(Object.keys(value));
29
- for (const key of mapKeyStrings(node)) {
30
- if (!keys.has(key))
31
- node.delete(key);
32
- }
33
- for (const key of keys)
34
- applyAt(doc, [...path, key], value[key]);
35
- return;
38
+ return doc.createNode(value);
39
+ reconcileMap(doc, node, value);
40
+ return node;
36
41
  }
37
42
  if (Array.isArray(value)) {
38
43
  if (!(0, yaml_1.isSeq)(node))
39
- return replaceAt(doc, path, value);
40
- while (node.items.length > value.length)
41
- node.items.pop();
42
- for (let i = 0; i < value.length; i++) {
43
- if (i < node.items.length)
44
- applyAt(doc, [...path, i], value[i]);
45
- else
46
- node.items.push(doc.createNode(value[i]));
47
- }
48
- return;
44
+ return doc.createNode(value);
45
+ reconcileSeq(doc, node, value);
46
+ return node;
49
47
  }
50
48
  // scalar (string / number / boolean / null)
51
- if ((0, yaml_1.isScalar)(node))
52
- node.value = value;
53
- else
54
- replaceAt(doc, path, value);
49
+ if ((0, yaml_1.isScalar)(node)) {
50
+ node.value = coerceScalarValue(value, node.value);
51
+ return node;
52
+ }
53
+ return doc.createNode(value);
55
54
  }
56
- /** Set the whole subtree at `path` to a fresh node (used when the shape changed). */
57
- function replaceAt(doc, path, value) {
58
- if (path.length === 0) {
59
- doc.contents = doc.createNode(value);
55
+ /**
56
+ * Update a map node against the value object: drop pairs whose key is gone,
57
+ * recurse into the ones that remain (matched by their `toJS` key string, so
58
+ * typed keys line up), and append a fresh pair for each genuinely new key.
59
+ */
60
+ function reconcileMap(doc, node, value) {
61
+ node.items = node.items.filter((pair) => keyString(pair.key) in value);
62
+ for (const key of Object.keys(value)) {
63
+ const pair = node.items.find((p) => keyString(p.key) === key);
64
+ if (pair) {
65
+ pair.value = applyToNode(doc, pair.value, value[key]);
66
+ }
67
+ else {
68
+ node.items.push(doc.createPair(key, value[key]));
69
+ }
60
70
  }
61
- else {
62
- doc.setIn(path, doc.createNode(value));
71
+ }
72
+ /** Update a sequence node: shrink/grow to the value's length, recurse per item. */
73
+ function reconcileSeq(doc, node, value) {
74
+ while (node.items.length > value.length)
75
+ node.items.pop();
76
+ for (let i = 0; i < value.length; i++) {
77
+ if (i < node.items.length) {
78
+ node.items[i] = applyToNode(doc, node.items[i], value[i]);
79
+ }
80
+ else {
81
+ node.items.push(doc.createNode(value[i]));
82
+ }
63
83
  }
64
84
  }
65
- function nodeAt(doc, path) {
66
- return path.length === 0 ? doc.contents : doc.getIn(path, true);
85
+ /**
86
+ * The value to store on a scalar node. An out-of-range integer is carried in the
87
+ * form value as a string (a `number` would lose precision); when the node it
88
+ * lands on already held a BigInt — i.e. the source had an unquoted integer there
89
+ * — restore the BigInt so it re-emits as a bare number rather than a quoted
90
+ * string. Everything else passes through unchanged (a genuine string key stays a
91
+ * string node, so it keeps its quotes).
92
+ */
93
+ function coerceScalarValue(value, current) {
94
+ if (typeof current === 'bigint' && typeof value === 'string' && (0, bigint_1.isIntegerString)(value)) {
95
+ return BigInt(value);
96
+ }
97
+ return value;
67
98
  }
68
- /** The string keys currently present in a YAML map node. */
69
- function mapKeyStrings(node) {
70
- return node.items.map((pair) => ((0, yaml_1.isScalar)(pair.key) ? String(pair.key.value) : String(pair.key)));
99
+ /**
100
+ * The string a key node takes as a JS object key, matching `doc.toJS()`: the
101
+ * scalar value stringified, with `null`/`undefined` collapsing to `''` (the
102
+ * empty key `toJS` uses for a `null:` key).
103
+ */
104
+ function keyString(key) {
105
+ const v = (0, yaml_1.isScalar)(key) ? key.value : key;
106
+ return v == null ? '' : String(v);
71
107
  }
72
108
  function isPlainObject(v) {
73
109
  return typeof v === 'object' && v !== null && !Array.isArray(v);
@@ -4,6 +4,7 @@ exports.yamlTransformer = void 0;
4
4
  const yaml_1 = require("yaml");
5
5
  const infer_1 = require("../../core/infer");
6
6
  const json_schema_1 = require("../../core/json-schema");
7
+ const bigint_1 = require("../../core/bigint");
7
8
  const revert_1 = require("./revert");
8
9
  /**
9
10
  * A YAML {@link Transformer}: turn a YAML config document into a form and write
@@ -20,8 +21,11 @@ const revert_1 = require("./revert");
20
21
  exports.yamlTransformer = {
21
22
  id: 'yaml',
22
23
  toSchema(source, options) {
23
- const doc = (0, yaml_1.parseDocument)(source);
24
- const data = (doc.toJS() ?? {});
24
+ // Parse integers as BigInt so the document nodes keep full precision; the
25
+ // revert re-emits them verbatim. The form value can't carry a BigInt, so
26
+ // out-of-range integers become strings there (safe ones become plain numbers).
27
+ const doc = (0, yaml_1.parseDocument)(source, { intAsBigInt: true });
28
+ const data = (normalizeBigInts(doc.toJS()) ?? {});
25
29
  const schema = options?.schema
26
30
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName)
27
31
  : (0, infer_1.inferNodeGroup)(data, options?.rootName);
@@ -35,3 +39,21 @@ exports.yamlTransformer = {
35
39
  // `satisfies` verifies conformance to the catalog contract while keeping the
36
40
  // concrete sync return types, so direct callers need no `await`.
37
41
  };
42
+ /**
43
+ * Replace every BigInt from an `intAsBigInt` parse with a form-value scalar: a
44
+ * plain `number` when it fits the safe range, otherwise its decimal string (so
45
+ * precision survives). Walks maps and sequences; leaves other values untouched.
46
+ */
47
+ function normalizeBigInts(value) {
48
+ if (typeof value === 'bigint')
49
+ return (0, bigint_1.isUnsafeBigInt)(value) ? value.toString() : Number(value);
50
+ if (Array.isArray(value))
51
+ return value.map(normalizeBigInts);
52
+ if (value && typeof value === 'object') {
53
+ const out = {};
54
+ for (const key of Object.keys(value))
55
+ out[key] = normalizeBigInts(value[key]);
56
+ return out;
57
+ }
58
+ return value;
59
+ }
@@ -42,7 +42,8 @@ function mapNode(node) {
42
42
  leaf.required = true;
43
43
  if (node.default !== undefined)
44
44
  leaf.default = node.default;
45
- const options = node.type.enums ?? node.type.identities?.map((i) => i.name);
45
+ const identities = node.type.identities;
46
+ const options = node.type.enums ?? identities?.map((i) => (0, rfc7951_1.identityToken)(i.module, i.name, identities));
46
47
  if (options)
47
48
  leaf.enum = [...options];
48
49
  return leaf;
@@ -88,7 +88,7 @@ function decodeLeaf(node, raw) {
88
88
  return group;
89
89
  }
90
90
  case 'identityref':
91
- return typeof raw === 'string' ? (0, rfc7951_1.bareIdentity)(raw) : raw;
91
+ return typeof raw === 'string' ? (0, rfc7951_1.wireToFormIdentity)(raw, node.module, node.type) : raw;
92
92
  default:
93
93
  return raw;
94
94
  }
@@ -17,14 +17,32 @@ export declare function isStringEncoded(base: YangBase): boolean;
17
17
  * so it never reaches this function.
18
18
  */
19
19
  export declare function toFormLeafType(t: YangType): LeafType;
20
+ type Identity = {
21
+ name: string;
22
+ module: string;
23
+ };
24
+ /**
25
+ * The token an identityref takes in the *form* (dropdown option and form value):
26
+ * the bare identity name, or a fully-qualified `module:name` when the bare name
27
+ * is ambiguous — the same local name is derived from more than one module.
28
+ * Keeping ambiguous identities qualified lets {@link qualifyIdentity} recover the
29
+ * exact identity on write-back; unambiguous ones stay bare for a clean UI.
30
+ */
31
+ export declare function identityToken(module: string, name: string, identities: Identity[]): string;
32
+ /**
33
+ * Resolve an RFC 7951 identityref *wire* value to its form {@link identityToken}.
34
+ * A qualified `module:name` names that exact identity; a bare name is an identity
35
+ * in the leaf's own module (RFC 7951 §6.8).
36
+ */
37
+ export declare function wireToFormIdentity(wire: string, leafModule: string, t: YangType): string;
20
38
  /**
21
39
  * RFC 7951 identityref value (§6.8): `module:identity` when the identity is
22
40
  * defined in a different module than the referencing leaf, otherwise the bare
23
- * identity name.
41
+ * identity name. The incoming `token` is the form value — bare, or `module:name`
42
+ * when the local name is ambiguous (see {@link identityToken}) — so the exact
43
+ * identity is recovered even when two modules derive the same name.
24
44
  */
25
- export declare function qualifyIdentity(name: string, leafModule: string, t: YangType): string;
26
- /** The local identity name, dropping any `module:` prefix. */
27
- export declare function bareIdentity(value: string): string;
45
+ export declare function qualifyIdentity(token: string, leafModule: string, t: YangType): string;
28
46
  /**
29
47
  * RFC 7951 member name for a node (§4). A name is qualified `module:name` when
30
48
  * the node is at the top level (no parent module) or its module differs from its
@@ -36,3 +54,4 @@ export declare function splitQualified(member: string): {
36
54
  module?: string;
37
55
  name: string;
38
56
  };
57
+ export {};
@@ -2,8 +2,9 @@
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isStringEncoded = isStringEncoded;
4
4
  exports.toFormLeafType = toFormLeafType;
5
+ exports.identityToken = identityToken;
6
+ exports.wireToFormIdentity = wireToFormIdentity;
5
7
  exports.qualifyIdentity = qualifyIdentity;
6
- exports.bareIdentity = bareIdentity;
7
8
  exports.qualifiedName = qualifiedName;
8
9
  exports.splitQualified = splitQualified;
9
10
  /**
@@ -51,19 +52,54 @@ function toFormLeafType(t) {
51
52
  return 'number';
52
53
  return 'string';
53
54
  }
55
+ /** True when more than one derived identity shares this local name. */
56
+ function nameIsAmbiguous(identities, name) {
57
+ return identities.filter((i) => i.name === name).length > 1;
58
+ }
59
+ /**
60
+ * The token an identityref takes in the *form* (dropdown option and form value):
61
+ * the bare identity name, or a fully-qualified `module:name` when the bare name
62
+ * is ambiguous — the same local name is derived from more than one module.
63
+ * Keeping ambiguous identities qualified lets {@link qualifyIdentity} recover the
64
+ * exact identity on write-back; unambiguous ones stay bare for a clean UI.
65
+ */
66
+ function identityToken(module, name, identities) {
67
+ return nameIsAmbiguous(identities, name) ? `${module}:${name}` : name;
68
+ }
69
+ /**
70
+ * Resolve an RFC 7951 identityref *wire* value to its form {@link identityToken}.
71
+ * A qualified `module:name` names that exact identity; a bare name is an identity
72
+ * in the leaf's own module (RFC 7951 §6.8).
73
+ */
74
+ function wireToFormIdentity(wire, leafModule, t) {
75
+ const identities = t.identities ?? [];
76
+ const id = resolveIdentity(wire, leafModule, identities);
77
+ return id ? identityToken(id.module, id.name, identities) : splitQualified(wire).name;
78
+ }
54
79
  /**
55
80
  * RFC 7951 identityref value (§6.8): `module:identity` when the identity is
56
81
  * defined in a different module than the referencing leaf, otherwise the bare
57
- * identity name.
82
+ * identity name. The incoming `token` is the form value — bare, or `module:name`
83
+ * when the local name is ambiguous (see {@link identityToken}) — so the exact
84
+ * identity is recovered even when two modules derive the same name.
58
85
  */
59
- function qualifyIdentity(name, leafModule, t) {
60
- const id = t.identities?.find((i) => i.name === name);
61
- return id && id.module !== leafModule ? `${id.module}:${name}` : name;
86
+ function qualifyIdentity(token, leafModule, t) {
87
+ const identities = t.identities ?? [];
88
+ const { module, name } = splitQualified(token);
89
+ const id = resolveIdentity(token, leafModule, identities);
90
+ const idModule = id ? id.module : module ?? leafModule;
91
+ return idModule !== leafModule ? `${idModule}:${name}` : name;
62
92
  }
63
- /** The local identity name, dropping any `module:` prefix. */
64
- function bareIdentity(value) {
65
- const i = value.indexOf(':');
66
- return i === -1 ? value : value.slice(i + 1);
93
+ /**
94
+ * The specific identity a bare-or-qualified reference points at: matched on
95
+ * module + name when qualified (or when the leaf's own module has that name),
96
+ * else the first identity with that local name.
97
+ */
98
+ function resolveIdentity(ref, leafModule, identities) {
99
+ const { module, name } = splitQualified(ref);
100
+ const owner = module ?? leafModule;
101
+ return identities.find((i) => i.name === name && i.module === owner)
102
+ ?? identities.find((i) => i.name === name);
67
103
  }
68
104
  /**
69
105
  * RFC 7951 member name for a node (§4). A name is qualified `module:name` when
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ng-form-foundry-transformers",
3
- "version": "0.2.1",
3
+ "version": "0.3.0",
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",