ng-form-foundry-transformers 0.4.0 → 0.5.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
@@ -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*
@@ -169,12 +169,10 @@ const { schema, binding, initialValue } = jsonTransformer.toSchema(configText);
169
169
  const out = jsonTransformer.toSource({ ...initialValue, replicas: 5 }, binding);
170
170
  ```
171
171
 
172
- ## libconfig transformer (beta)
172
+ ## libconfig transformer
173
173
 
174
174
  Edit a **libconfig document** — the `.cfg`/`.conf` format of srsRAN, OAI, and
175
175
  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
176
 
179
177
  ```ts
180
178
  import { libconfigTransformer } from 'ng-form-foundry-transformers';
@@ -294,6 +292,23 @@ npm test # node:test on the compiled output (no Python needed)
294
292
 
295
293
  ## Status
296
294
 
295
+ `0.5.0` — the **libconfig transformer is stable**: the one-time beta warning
296
+ is gone (and with it the `resetLibconfigBetaWarning` helper), and the format's
297
+ guarantees and known limitations are documented in the transformers guide.
298
+ Non-decimal integer literals (`0x`, `0b`, `0o`/`0q`) now set a `radix` display
299
+ hint on the generated schema — the paired `ng-form-foundry` release renders
300
+ those fields as hex/octal/binary editors, and the YAML transformer does the
301
+ same for its hex and octal literals.
302
+
303
+ `0.4.1` — libconfig hardening after a conformance battle test against the C
304
+ library's own scanner, test corpus, and real srsRAN/OAI configs: `0q`/`0Q`
305
+ octal, verbatim radix-prefix preservation on edits, sign accepted on decimal
306
+ literals only, negative edits into hex/bin/oct slots emit decimal, exact
307
+ round-trips for collections mixing safe and beyond-2^53 integers, `null`
308
+ rejected instead of written as `true`, a 256-level nesting cap, strict `\x`
309
+ escapes, `@include` legal in value positions, and modified read-only carries
310
+ throw instead of corrupting.
311
+
297
312
  `0.4.0` — version-alignment release with the workspace (the paired
298
313
  `ng-form-foundry` 0.4.0 adds appearance-driven field layout); no transformer
299
314
  changes.
@@ -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,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')}).
@@ -47,8 +47,6 @@ export interface LibconfigBinding {
47
47
  source: string;
48
48
  root: CfgGroup;
49
49
  }
50
- /** Test seam: makes the one-time beta warning observable per test. */
51
- export declare function resetLibconfigBetaWarning(): void;
52
50
  export declare const libconfigTransformer: {
53
51
  id: string;
54
52
  toSchema(source: string, options?: LibconfigOptions): TransformResult<LibconfigBinding>;
@@ -1,28 +1,14 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.libconfigTransformer = void 0;
4
- exports.resetLibconfigBetaWarning = resetLibconfigBetaWarning;
5
4
  const thesaurus_1 = require("../../core/thesaurus");
6
5
  const json_schema_1 = require("../../core/json-schema");
7
6
  const parser_1 = require("./parser");
8
7
  const schema_1 = require("./schema");
9
8
  const revert_1 = require("./revert");
10
- let warnedBeta = false;
11
- function warnBeta() {
12
- if (warnedBeta)
13
- return;
14
- warnedBeta = true;
15
- console.warn('[ng-form-foundry-transformers] The libconfig transformer is a BETA feature. ' +
16
- 'Verify every write-back (diff toSource output against the original file) before deploying it.');
17
- }
18
- /** Test seam: makes the one-time beta warning observable per test. */
19
- function resetLibconfigBetaWarning() {
20
- warnedBeta = false;
21
- }
22
9
  exports.libconfigTransformer = {
23
10
  id: 'libconfig',
24
11
  toSchema(source, options) {
25
- warnBeta();
26
12
  const root = (0, parser_1.parseLibconfig)(source, { includes: options?.includes });
27
13
  const schema = options?.schema
28
14
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
@@ -32,7 +18,6 @@ exports.libconfigTransformer = {
32
18
  return { schema: labeled, binding: { source, root }, initialValue };
33
19
  },
34
20
  toSource(value, binding) {
35
- warnBeta();
36
21
  return (0, revert_1.applyValueToSource)(binding.source, binding.root, value);
37
22
  },
38
23
  };
@@ -13,9 +13,13 @@
13
13
  * (https://hyperrealm.github.io/libconfig/libconfig_manual.html); the grammar
14
14
  * covers: groups `{}`, arrays `[]` (homogeneous scalars), lists `()`
15
15
  * (heterogeneous), settings `name = value;` / `name : value` with an optional
16
- * `;`/`,` terminator, int (decimal/hex/binary/octal), int64 (`L`/`LL`
16
+ * `;`/`,` terminator, int (decimal, `0x` hex, `0b` binary, `0o`/`0q` octal —
17
+ * a sign is legal on decimal only, as in the C scanner), int64 (`L`/`LL`
17
18
  * suffix), float, bool (case-insensitive), strings with escapes and adjacent
18
- * literal concatenation, `#`/`//`/`/* *​/` comments, and `@include` lines.
19
+ * literal concatenation, `#`/`//`/`/* *​/` comments, and `@include` lines
20
+ * (handled as trivia, so they are legal anywhere the C scanner accepts them).
21
+ * Nesting is capped at {@link MAX_DEPTH} levels so hostile documents fail
22
+ * with a parse error instead of exhausting the JS stack.
19
23
  */
20
24
  /** Half-open source range of a node, in code-unit offsets. */
21
25
  export interface Span {
@@ -29,6 +33,8 @@ export interface IntMeta {
29
33
  suffix: string;
30
34
  /** Hex/binary/octal digit count of the literal, for width-preserving edits. */
31
35
  digits: number;
36
+ /** Radix prefix verbatim (`0x`, `0B`, `0q`, …), empty for decimal. */
37
+ prefix: string;
32
38
  }
33
39
  export type CfgValue = CfgScalar | CfgGroup | CfgArray | CfgList;
34
40
  export interface CfgScalar {
@@ -14,9 +14,13 @@
14
14
  * (https://hyperrealm.github.io/libconfig/libconfig_manual.html); the grammar
15
15
  * covers: groups `{}`, arrays `[]` (homogeneous scalars), lists `()`
16
16
  * (heterogeneous), settings `name = value;` / `name : value` with an optional
17
- * `;`/`,` terminator, int (decimal/hex/binary/octal), int64 (`L`/`LL`
17
+ * `;`/`,` terminator, int (decimal, `0x` hex, `0b` binary, `0o`/`0q` octal —
18
+ * a sign is legal on decimal only, as in the C scanner), int64 (`L`/`LL`
18
19
  * suffix), float, bool (case-insensitive), strings with escapes and adjacent
19
- * literal concatenation, `#`/`//`/`/* *​/` comments, and `@include` lines.
20
+ * literal concatenation, `#`/`//`/`/* *​/` comments, and `@include` lines
21
+ * (handled as trivia, so they are legal anywhere the C scanner accepts them).
22
+ * Nesting is capped at {@link MAX_DEPTH} levels so hostile documents fail
23
+ * with a parse error instead of exhausting the JS stack.
20
24
  */
21
25
  Object.defineProperty(exports, "__esModule", { value: true });
22
26
  exports.LibconfigParseError = void 0;
@@ -33,18 +37,22 @@ class LibconfigParseError extends Error {
33
37
  }
34
38
  exports.LibconfigParseError = LibconfigParseError;
35
39
  const NAME_RE = /^[A-Za-z*][-A-Za-z0-9_*.]*/;
36
- const INT_RE = /^[-+]?(0[xX][0-9A-Fa-f]+|0[bB][01]+|0[oO][0-7]+|[0-9]+)(LL?)?/;
40
+ // Like the C scanner: octal is `0o` or `0q`, and only decimal takes a sign.
41
+ const INT_RE = /^(0[xX][0-9A-Fa-f]+|0[bB][01]+|0[oOqQ][0-7]+|[-+]?[0-9]+)(LL?)?/;
37
42
  const FLOAT_RE = /^[-+]?(([0-9]*\.[0-9]+|[0-9]+\.[0-9]*)([eE][-+]?[0-9]+)?|[0-9]+[eE][-+]?[0-9]+)/;
38
43
  const BOOL_RE = /^(true|false)(?![-A-Za-z0-9_*.])/i;
39
44
  /** Parse a libconfig document into its root group (spans index into `source`). */
40
45
  function parseLibconfig(source, options) {
41
46
  return new Parser(source, options?.includes ?? 'reject').parseRoot();
42
47
  }
48
+ /** Deepest legal value nesting — far past real configs, well inside JS stack. */
49
+ const MAX_DEPTH = 256;
43
50
  class Parser {
44
51
  constructor(src, includes) {
45
52
  this.src = src;
46
53
  this.includes = includes;
47
54
  this.pos = 0;
55
+ this.depth = 0;
48
56
  }
49
57
  parseRoot() {
50
58
  const settings = this.parseSettings(undefined);
@@ -67,10 +75,6 @@ class Parser {
67
75
  }
68
76
  if (closer && this.src[this.pos] === closer)
69
77
  return settings;
70
- if (this.src[this.pos] === '@') {
71
- this.handleInclude();
72
- continue;
73
- }
74
78
  settings.push(this.parseSetting(settings));
75
79
  }
76
80
  }
@@ -94,14 +98,21 @@ class Parser {
94
98
  return { name, span: { start, end: this.pos }, value };
95
99
  }
96
100
  parseValue() {
97
- const c = this.src[this.pos];
98
- if (c === '{')
99
- return this.parseGroup();
100
- if (c === '[')
101
- return this.parseCollection('[', ']', 'array');
102
- if (c === '(')
103
- return this.parseCollection('(', ')', 'list');
104
- return this.parseScalar();
101
+ if (++this.depth > MAX_DEPTH)
102
+ this.fail(`nesting deeper than ${MAX_DEPTH} levels`);
103
+ try {
104
+ const c = this.src[this.pos];
105
+ if (c === '{')
106
+ return this.parseGroup();
107
+ if (c === '[')
108
+ return this.parseCollection('[', ']', 'array');
109
+ if (c === '(')
110
+ return this.parseCollection('(', ')', 'list');
111
+ return this.parseScalar();
112
+ }
113
+ finally {
114
+ this.depth--;
115
+ }
105
116
  }
106
117
  parseGroup() {
107
118
  const start = this.pos++;
@@ -181,15 +192,16 @@ class Parser {
181
192
  return this.fail('expected a value');
182
193
  }
183
194
  intScalar(lexeme, span) {
184
- const sign = lexeme[0] === '-' ? '-' : '';
185
- const unsigned = lexeme.replace(/^[-+]/, '');
186
- const suffix = /LL?$/i.exec(unsigned)?.[0] ?? '';
187
- const body = suffix ? unsigned.slice(0, -suffix.length) : unsigned;
188
- const prefix = body.slice(0, 2).toLowerCase();
189
- const radix = prefix === '0x' ? 16 : prefix === '0b' ? 2 : prefix === '0o' ? 8 : 10;
190
- const digits = radix === 10 ? body : body.slice(2);
191
- const big = BigInt(sign + (radix === 10 ? digits : body.slice(0, 2) + digits));
192
- const meta = { radix, suffix, digits: digits.length };
195
+ const suffix = /LL?$/i.exec(lexeme)?.[0] ?? '';
196
+ const body = suffix ? lexeme.slice(0, -suffix.length) : lexeme;
197
+ const prefix = /^0[xXbBoOqQ]/.test(body) ? body.slice(0, 2) : '';
198
+ const p = prefix.toLowerCase();
199
+ const radix = p === '0x' ? 16 : p === '0b' ? 2 : p === '' ? 10 : 8;
200
+ const digits = prefix ? body.slice(2) : body.replace(/^[-+]/, '');
201
+ const sign = body[0] === '-' ? '-' : '';
202
+ // `0q`/`0O` normalize to a prefix BigInt understands; decimal keeps its sign.
203
+ const big = prefix ? BigInt((radix === 8 ? '0o' : p) + digits) : BigInt(sign + digits);
204
+ const meta = { radix, suffix, digits: digits.length, prefix };
193
205
  // Values past 2^53 ride as exact decimal strings (the core bigint strategy);
194
206
  // the suffix and radix stay in the metadata for the write-back.
195
207
  const safe = big >= BigInt(Number.MIN_SAFE_INTEGER) && big <= BigInt(Number.MAX_SAFE_INTEGER);
@@ -228,8 +240,11 @@ class Parser {
228
240
  }
229
241
  if (c === '\\') {
230
242
  const esc = this.src[this.pos + 1];
231
- if (esc === 'x') {
232
- out += String.fromCharCode(parseInt(this.src.slice(this.pos + 2, this.pos + 4), 16));
243
+ if (esc === 'x' || esc === 'X') {
244
+ const hex = this.src.slice(this.pos + 2, this.pos + 4);
245
+ if (!/^[0-9A-Fa-f]{2}$/.test(hex))
246
+ this.fail('\\x escape needs two hex digits');
247
+ out += String.fromCharCode(parseInt(hex, 16));
233
248
  this.pos += 4;
234
249
  continue;
235
250
  }
@@ -253,7 +268,7 @@ class Parser {
253
268
  }
254
269
  this.pos = lineEnd === -1 ? this.src.length : lineEnd + 1;
255
270
  }
256
- /** Skip whitespace and all three comment forms. */
271
+ /** Skip whitespace, all three comment forms, and `@include` lines. */
257
272
  skipTrivia() {
258
273
  for (;;) {
259
274
  const c = this.src[this.pos];
@@ -261,6 +276,12 @@ class Parser {
261
276
  this.pos++;
262
277
  continue;
263
278
  }
279
+ // The C scanner handles `@include` at token level, so it is legal in any
280
+ // trivia position (even between list elements), not just between settings.
281
+ if (c === '@' && this.src.startsWith('@include', this.pos)) {
282
+ this.handleInclude();
283
+ continue;
284
+ }
264
285
  if (c === '#' || (c === '/' && this.src[this.pos + 1] === '/')) {
265
286
  const nl = this.src.indexOf('\n', this.pos);
266
287
  this.pos = nl === -1 ? this.src.length : nl;
@@ -21,7 +21,8 @@ function patchGroup(src, group, value, edits) {
21
21
  patchValue(src, setting.value, value[setting.name], edits);
22
22
  }
23
23
  const known = new Set(group.settings.map((s) => s.name));
24
- const added = Object.keys(value ?? {}).filter((k) => !known.has(k));
24
+ // null/undefined means "absent", never a value: such keys are not added.
25
+ const added = Object.keys(value ?? {}).filter((k) => !known.has(k) && value[k] != null);
25
26
  if (added.length) {
26
27
  edits.push(insertionEdit(src, group, added.map((k) => `${k} = ${serialize(value[k], '')};`)));
27
28
  }
@@ -31,6 +32,10 @@ function patchValue(src, node, value, edits) {
31
32
  case 'scalar': {
32
33
  if (node.value === value)
33
34
  return;
35
+ // The collection-wide bigint carry hands safe elements back as digit
36
+ // strings; a string spelling the same value is not an edit.
37
+ if (typeof value === 'string' && typeof node.value === 'number' && value === String(node.value))
38
+ return;
34
39
  edits.push({ ...node.span, text: emitScalar(value, node) });
35
40
  return;
36
41
  }
@@ -43,9 +48,13 @@ function patchValue(src, node, value, edits) {
43
48
  case 'array': {
44
49
  if (Array.isArray(value))
45
50
  return patchElements(src, node.elements, node, value, edits);
46
- // The read-only raw carry of an untyped empty array: leave verbatim.
47
- if (typeof value === 'string' && value === (0, schema_1.raw)(node, src))
48
- return;
51
+ // A string here is the read-only raw carry of an untyped empty array:
52
+ // unchanged it stays verbatim, edited it is an error, never a splice.
53
+ if (typeof value === 'string') {
54
+ if (value === (0, schema_1.raw)(node, src))
55
+ return;
56
+ throw new Error('this collection is read-only (no element type to edit by): the edited text was not applied');
57
+ }
49
58
  edits.push({ ...node.span, text: serialize(value, '') });
50
59
  return;
51
60
  }
@@ -64,11 +73,15 @@ function patchValue(src, node, value, edits) {
64
73
  }
65
74
  return;
66
75
  }
67
- // Heterogeneous (read-only raw carry) or unchanged empty: only replace on
68
- // a genuine mismatch with a non-raw value shape — otherwise leave verbatim.
69
- if (typeof value === 'string' && value === (0, schema_1.raw)(node, src))
70
- return;
71
- if (value !== undefined && typeof value !== 'string') {
76
+ // Heterogeneous (read-only raw carry) or unchanged empty: the carry
77
+ // stays verbatim, an edited carry is an error, a non-string value is a
78
+ // genuine shape change and regenerates.
79
+ if (typeof value === 'string') {
80
+ if (value === (0, schema_1.raw)(node, src))
81
+ return;
82
+ throw new Error('this list is read-only (heterogeneous): the edited text was not applied');
83
+ }
84
+ if (value !== undefined) {
72
85
  edits.push({ ...node.span, text: serialize(value, '') });
73
86
  }
74
87
  return;
@@ -146,11 +159,17 @@ function emitScalar(value, node) {
146
159
  if (typeof value === 'boolean')
147
160
  return value ? 'true' : 'false';
148
161
  if (typeof value === 'string') {
149
- // The int64 string carry: exact digits back, with the original suffix.
150
- if (node.type === 'int64' && typeof node.value === 'string') {
162
+ if (node.type === 'int' || node.type === 'int64') {
151
163
  if (!/^[-+]?[0-9]+$/.test(value)) {
152
- throw new Error(`'${value}' is not an integer: this setting carries a 64-bit integer as a string`);
164
+ throw new Error(`'${value}' is not an integer: this setting is integer-typed`);
153
165
  }
166
+ // The int64 string carry: exact digits back, with the original suffix.
167
+ if (typeof node.value === 'string')
168
+ return value + (node.int?.suffix ?? '');
169
+ // A safe element of a carried collection: back to its own literal style.
170
+ const n = Number(value);
171
+ if (Number.isSafeInteger(n))
172
+ return intLiteral(n, node.int);
154
173
  return value + (node.int?.suffix ?? '');
155
174
  }
156
175
  return quote(value);
@@ -165,13 +184,17 @@ function emitScalar(value, node) {
165
184
  }
166
185
  return serialize(value, '');
167
186
  }
168
- /** Integer in the source's radix and width, suffix preserved. */
187
+ /**
188
+ * Integer in the source's radix, width, and prefix spelling, suffix preserved.
189
+ * Negatives always emit decimal: the C scanner accepts no sign on a
190
+ * hex/binary/octal literal, so `-0x1A` would not load.
191
+ */
169
192
  function intLiteral(value, meta) {
170
- if (!meta || meta.radix === 10)
193
+ if (!meta || meta.radix === 10 || value < 0)
171
194
  return String(value) + (meta?.suffix ?? '');
172
- const prefix = meta.radix === 16 ? '0x' : meta.radix === 2 ? '0b' : '0o';
173
- const digits = Math.abs(value).toString(meta.radix).toUpperCase().padStart(meta.digits, '0');
174
- return (value < 0 ? '-' : '') + prefix + digits + meta.suffix;
195
+ const prefix = meta.prefix || (meta.radix === 16 ? '0x' : meta.radix === 2 ? '0b' : '0o');
196
+ const digits = value.toString(meta.radix).toUpperCase().padStart(meta.digits, '0');
197
+ return prefix + digits + meta.suffix;
175
198
  }
176
199
  /** A float literal that stays a float: integral values gain `.0`. */
177
200
  function floatLiteral(value) {
@@ -206,9 +229,13 @@ function quote(value) {
206
229
  * grown collection). Typing is value-driven here: integral numbers emit as
207
230
  * ints, fractional as floats — a host that needs a float-typed `21.0` for a
208
231
  * fresh setting should send `21.0`-producing edits through an existing float
209
- * slot or accept the int typing (documented beta limitation).
232
+ * slot or accept the int typing. null/undefined have no libconfig literal
233
+ * and throw.
210
234
  */
211
235
  function serialize(value, indent) {
236
+ if (value == null) {
237
+ throw new Error('null/undefined has no libconfig representation: omit the setting instead');
238
+ }
212
239
  if (typeof value === 'boolean')
213
240
  return value ? 'true' : 'false';
214
241
  if (typeof value === 'number')
@@ -226,7 +253,7 @@ function serialize(value, indent) {
226
253
  .join(' ');
227
254
  return `{ ${body} }`;
228
255
  }
229
- return 'true'; // null/undefined have no libconfig literal; unreachable via typed forms
256
+ throw new Error(`a ${typeof value} value has no libconfig representation`);
230
257
  }
231
258
  /** An integer string that exceeds the double-safe range (the bigint carry). */
232
259
  function isBigIntString(value) {
@@ -80,14 +80,21 @@ function scalarLeaf(name, scalar) {
80
80
  case 'float':
81
81
  return { kind: 'leaf', name, type: 'number' };
82
82
  case 'int':
83
- return { kind: 'leaf', name, type: 'number', integer: true };
83
+ return withRadix({ kind: 'leaf', name, type: 'number', integer: true }, scalar);
84
84
  case 'int64':
85
85
  // Beyond 2^53 the value rides as an exact decimal string.
86
86
  return typeof scalar.value === 'string'
87
- ? { kind: 'leaf', name, type: 'string', pattern: INTEGER_STRING_PATTERN }
88
- : { kind: 'leaf', name, type: 'number', integer: true };
87
+ ? withRadix({ kind: 'leaf', name, type: 'string', pattern: INTEGER_STRING_PATTERN }, scalar)
88
+ : withRadix({ kind: 'leaf', name, type: 'number', integer: true }, scalar);
89
89
  }
90
90
  }
91
+ /** Carry a non-decimal literal's base onto the leaf as its display radix. */
92
+ function withRadix(leaf, scalar) {
93
+ const radix = scalar.int?.radix;
94
+ if (radix && radix !== 10)
95
+ leaf.radix = radix;
96
+ return leaf;
97
+ }
91
98
  /**
92
99
  * A homogeneous scalar collection → leafList. One int64 element beyond 2^53
93
100
  * degrades the whole list to string carry: a leafList holds one scalar type,
@@ -97,10 +104,23 @@ function listLeaf(name, elements) {
97
104
  if (elements.length === 0)
98
105
  return rawLeaf(name, 'empty collection');
99
106
  const f = (0, parser_1.family)(elements[0].type);
107
+ const radix = f === 'integer' ? sharedRadix(elements) : undefined;
100
108
  if (f === 'integer' && elements.some((e) => typeof e.value === 'string')) {
101
- return { kind: 'leafList', name, type: 'string' };
109
+ return { kind: 'leafList', name, type: 'string', ...(radix && { radix }) };
102
110
  }
103
- return { kind: 'leafList', name, type: f === 'integer' || f === 'float' ? 'number' : f === 'bool' ? 'boolean' : 'string' };
111
+ return {
112
+ kind: 'leafList',
113
+ name,
114
+ type: f === 'integer' || f === 'float' ? 'number' : f === 'bool' ? 'boolean' : 'string',
115
+ ...(radix && { radix }),
116
+ };
117
+ }
118
+ /** The display radix shared by every element, when uniform and non-decimal. */
119
+ function sharedRadix(elements) {
120
+ const first = elements[0]?.int?.radix ?? 10;
121
+ if (first === 10)
122
+ return undefined;
123
+ return elements.every((e) => (e.int?.radix ?? 10) === first) ? first : undefined;
104
124
  }
105
125
  function arrayValue(elements) {
106
126
  const stringCarry = elements.length > 0 &&
@@ -149,12 +169,15 @@ function listShape(list) {
149
169
  }
150
170
  /** Read-only leaf carrying the collection's verbatim source text. */
151
171
  function rawLeaf(name, why) {
172
+ const hint = why === 'empty collection'
173
+ ? 'Provide a JSON Schema to type its elements and make it editable.'
174
+ : 'It stays read-only: no single element type exists to edit by.';
152
175
  return {
153
176
  kind: 'leaf',
154
177
  name,
155
178
  type: 'string',
156
179
  readOnly: true,
157
- description: `Shown verbatim (${why}): libconfig gives no element type to edit by. Provide a JSON Schema to make it editable.`,
180
+ description: `Shown verbatim (${why}). ${hint}`,
158
181
  };
159
182
  }
160
183
  /** The verbatim source slice of a node. */
@@ -30,6 +30,8 @@ exports.yamlTransformer = {
30
30
  const schema = options?.schema
31
31
  ? (0, json_schema_1.jsonSchemaToNodeGroup)(options.schema, options.rootName, options.schemaOptions)
32
32
  : (0, infer_1.inferNodeGroup)(data, options?.rootName);
33
+ if (!options?.schema && doc.contents)
34
+ annotateRadix(doc.contents, schema);
33
35
  const labeled = options?.thesaurus ? (0, thesaurus_1.applyThesaurus)(schema, options.thesaurus) : schema;
34
36
  return { schema: labeled, binding: doc, initialValue: data };
35
37
  },
@@ -41,6 +43,67 @@ exports.yamlTransformer = {
41
43
  // `satisfies` verifies conformance to the catalog contract while keeping the
42
44
  // concrete sync return types, so direct callers need no `await`.
43
45
  };
46
+ const RADIX_BY_FORMAT = { BIN: 2, OCT: 8, HEX: 16 };
47
+ /**
48
+ * Copy non-decimal integer presentation (`0x`/`0o`/`0b` literals) from the
49
+ * parsed document onto the inferred schema as leaf/leafList `radix` display
50
+ * hints — the plain-data inference cannot see them, because `toJS()` drops the
51
+ * scalar format. The revert needs nothing: scalars are mutated in place, so an
52
+ * edited value re-emits in its literal's own base. Across group-list items the
53
+ * first non-decimal occurrence wins — one shared item schema cannot vary the
54
+ * display per item. Schema-driven mode is untouched (JSON Schema has no radix
55
+ * vocabulary).
56
+ */
57
+ function annotateRadix(node, schema) {
58
+ switch (schema.kind) {
59
+ case 'leaf': {
60
+ const radix = scalarRadix(node);
61
+ if (radix && !schema.radix)
62
+ schema.radix = radix;
63
+ return;
64
+ }
65
+ case 'leafList': {
66
+ if (!(0, yaml_1.isSeq)(node) || schema.radix)
67
+ return;
68
+ const radixes = node.items.map(scalarRadix);
69
+ if (radixes[0] && radixes.every((r) => r === radixes[0]))
70
+ schema.radix = radixes[0];
71
+ return;
72
+ }
73
+ case 'nodeGroup': {
74
+ if (!(0, yaml_1.isMap)(node))
75
+ return;
76
+ for (const pair of node.items) {
77
+ const child = schema.children[pairKey(pair.key)];
78
+ if (child)
79
+ annotateRadix(pair.value, child);
80
+ }
81
+ return;
82
+ }
83
+ case 'nodeGroupList': {
84
+ if (!(0, yaml_1.isSeq)(node))
85
+ return;
86
+ for (const item of node.items)
87
+ annotateRadix(item, schema.type);
88
+ return;
89
+ }
90
+ default:
91
+ return; // choice/map never come out of plain-data inference
92
+ }
93
+ }
94
+ /** The display radix of a non-decimal integer scalar node, else undefined. */
95
+ function scalarRadix(node) {
96
+ if (!(0, yaml_1.isScalar)(node))
97
+ return undefined;
98
+ if (typeof node.value !== 'number' && typeof node.value !== 'bigint')
99
+ return undefined;
100
+ return RADIX_BY_FORMAT[node.format ?? ''];
101
+ }
102
+ /** The string a key node takes as a JS object key, matching `doc.toJS()`. */
103
+ function pairKey(key) {
104
+ const v = (0, yaml_1.isScalar)(key) ? key.value : key;
105
+ return v == null ? '' : String(v);
106
+ }
44
107
  /**
45
108
  * Replace every BigInt from an `intAsBigInt` parse with a form-value scalar: a
46
109
  * plain `number` when it fits the safe range, otherwise its decimal string (so
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ng-form-foundry-transformers",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",