@tikoci/centrs 0.1.4 → 0.1.6

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.
Files changed (68) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +8 -4
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +37 -8
  7. package/commands/api/examples.md +31 -1
  8. package/commands/execute/README.md +39 -10
  9. package/commands/execute/examples.md +79 -7
  10. package/commands/explain/README.md +1195 -155
  11. package/commands/explain/examples.md +99 -3
  12. package/commands/mcp/README.md +7 -4
  13. package/commands/mcp/examples.md +2 -2
  14. package/commands/terminal/examples.md +7 -1
  15. package/docs/CLI.md +2 -2
  16. package/docs/CONSTITUTION.md +72 -15
  17. package/docs/MATRIX.md +89 -52
  18. package/docs/errors/validation/syntax.md +29 -4
  19. package/package.json +13 -2
  20. package/src/api-fanout.ts +17 -4
  21. package/src/api.ts +254 -67
  22. package/src/cli/api.ts +1 -1
  23. package/src/cli/btest.ts +1 -1
  24. package/src/cli/explain.ts +1 -1
  25. package/src/cli/terminal.ts +1 -1
  26. package/src/core/envelope.ts +53 -0
  27. package/src/core/error-catalog.ts +2 -1
  28. package/src/core/execute-command.ts +148 -0
  29. package/src/core/inspect.ts +36 -4
  30. package/src/core/version.ts +4 -0
  31. package/src/core/yaml.ts +86 -0
  32. package/src/execute-fanout.ts +18 -4
  33. package/src/execute.ts +225 -244
  34. package/src/explain/arg-tokens.ts +62 -25
  35. package/src/explain/args.ts +398 -52
  36. package/src/explain/blocks.ts +54 -11
  37. package/src/explain/brace-tokens.ts +4 -3
  38. package/src/explain/catalog.ts +42 -44
  39. package/src/explain/coordinates.ts +159 -0
  40. package/src/explain/defects.ts +2 -1
  41. package/src/explain/escape-tokens.ts +77 -0
  42. package/src/explain/highlight-projection.ts +308 -0
  43. package/src/explain/menus.ts +9 -5
  44. package/src/explain/operator-tokens.ts +13 -7
  45. package/src/explain/operators.ts +27 -20
  46. package/src/explain/path-tokens.ts +1 -1
  47. package/src/explain/pathresolve.ts +358 -113
  48. package/src/explain/quoted-string.ts +110 -20
  49. package/src/explain/scope-brace.ts +175 -22
  50. package/src/explain/segment.ts +429 -21
  51. package/src/explain/separator.ts +187 -0
  52. package/src/explain/split-owner.ts +194 -0
  53. package/src/explain/string-tokens.ts +14 -4
  54. package/src/explain/symbol-values.ts +209 -173
  55. package/src/explain/symbols.ts +21 -1
  56. package/src/explain/transport.ts +17 -0
  57. package/src/explain/value-tokens.ts +15 -12
  58. package/src/explain/verbsplit.ts +165 -23
  59. package/src/explain/write.ts +47 -6
  60. package/src/explain.ts +522 -72
  61. package/src/index.ts +19 -2
  62. package/src/mcp/server.ts +2 -1
  63. package/src/offline-gate.ts +148 -0
  64. package/src/retrieve-fanout.ts +1 -1
  65. package/src/retrieve.ts +1 -59
  66. package/src/settings.ts +2 -1
  67. package/src/transfer-fanout.ts +1 -1
  68. package/src/transfer.ts +1 -1
@@ -9,14 +9,37 @@
9
9
  * `[…]` selectors and `?` queries at statement scope, and it was parked until
10
10
  * `--curl` made it load-bearing. This is that lexer.
11
11
  *
12
- * ## It abstains for the WHOLE statement, never per token
12
+ * ## Three readings, one walk
13
13
  *
14
- * The consumer is a REST mapping that renders a runnable `curl` body. A
15
- * partially-read argument list is worse there than no list at all: dropping one
16
- * token silently changes what the rendered command DOES, which is the
17
- * fabrication the fail-closed floor exists to prevent. So every token must be
18
- * decided, or {@link lexArguments} decides nothing and says why. The price is
19
- * measured rather than assumed — see the abstention rates in the #202c PR body.
14
+ * `walkArguments` owns the boundary rules — where a token starts, where it ends,
15
+ * and where it stops being decodable — and three exported readings differ only
16
+ * in what they do at a refusal. They can never disagree about a boundary,
17
+ * because there is only one of them.
18
+ *
19
+ * - {@link lexArguments} — **strict, all-or-nothing.** The consumer is a REST
20
+ * mapping that renders a runnable `curl` body. A partially-read argument
21
+ * list is worse there than no list at all: dropping one token silently
22
+ * changes what the rendered command DOES, which is the fabrication the
23
+ * fail-closed floor exists to prevent. So every token must be decided, or
24
+ * this decides nothing and says why. The price is measured rather than
25
+ * assumed — see the abstention rates in the #202c PR body.
26
+ * - {@link lexValueAnchors} — **prefix-safe.** An unreadable later expression
27
+ * must not erase an earlier, independently bounded literal, so it keeps
28
+ * every anchor found before the first refusal and stops there (#225 V1).
29
+ * - {@link lexArgumentTokens} — **skip-tolerant.** The reading a token-level
30
+ * RULE wants (#316). A refusal whose token boundary is still knowable is
31
+ * published as a located token with `undecided` set and no `value`, and the
32
+ * walk carries on. It reaches 100% of the corpus's argument-bearing
33
+ * statements against the strict reading's 51.8%; the skip-vs-prefix fork is
34
+ * priced by `bun run explain:arg-reach` and decided in
35
+ * `commands/explain/README.md` → *The token stream a rule reads*.
36
+ *
37
+ * A refusal reason is a fact about the input, not a reading-specific message:
38
+ * all three name the same one for the same byte. The one place they differ is
39
+ * where the tolerant walk needs something the strict one never asked for — an
40
+ * unclosed `[…]` is `a substitution or expression value` to a reading that
41
+ * refuses it on sight, and `an unclosed structured argument value` to the one
42
+ * that went looking for the boundary it would have resumed from.
20
43
  *
21
44
  * ## What it refuses, and why each refusal is a fact rather than a gap
22
45
  *
@@ -38,6 +61,10 @@
38
61
  * word scanner handles continuations for the run END; a VALUE needs its
39
62
  * interior, which is exactly what a continuation breaks.
40
63
  *
64
+ * Each of those is a refusal of the VALUE, and the tolerant reading publishes
65
+ * the token anyway with the same reason attached — which is sound precisely
66
+ * because `Argument.value` stays absent. None of them becomes renderable.
67
+ *
41
68
  * ## It emits no defects
42
69
  *
43
70
  * Every defect class this scan could raise (`unterminated-string`,
@@ -49,10 +76,15 @@
49
76
  * re-announce; the abstention reason names it instead.
50
77
  */
51
78
 
52
- import { isScopeBrace } from "./blocks.ts";
79
+ import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
53
80
  import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
54
81
  import { braceStartsStatements } from "./scope-brace.ts";
55
- import { maskComments, scanQuotedString } from "./segment.ts";
82
+ import {
83
+ type MaskedRange,
84
+ maskComments,
85
+ maskOf,
86
+ scanQuotedString,
87
+ } from "./segment.ts";
56
88
 
57
89
  /** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
58
90
  const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
@@ -88,10 +120,30 @@ export interface Argument {
88
120
  * lexer's claim to make.
89
121
  */
90
122
  value?: string;
91
- /** Where the value sits, quotes INCLUDED. Absent whenever `value` is. */
123
+ /**
124
+ * Where the value sits, quotes INCLUDED.
125
+ *
126
+ * In the strict {@link lexArguments} reading this is absent whenever `value`
127
+ * is. The tolerant {@link lexArgumentTokens} reading also publishes it for a
128
+ * value whose BYTES are located but whose literal is unknown
129
+ * (`interface=$x`), which is what lets a positional fill claim the `name=`
130
+ * run of a token no consumer may render. `undecided` is the field that says
131
+ * which of the two this is.
132
+ */
92
133
  valueSpan?: { start: number; end: number };
93
134
  /** The token verbatim, for a positional and for rendering. */
94
135
  text: string;
136
+ /**
137
+ * Why this token's value or shape was not decoded — tolerant reading only.
138
+ *
139
+ * Never set by {@link lexArguments}: the strict reading publishes no token it
140
+ * could not decide, so a consumer holding an {@link ArgumentsRead} never sees
141
+ * this field. {@link lexArgumentTokens} sets it on exactly the tokens the
142
+ * strict walk would have aborted at, carrying the same reason string the
143
+ * strict refusal would have carried. A token with `undecided` set has no
144
+ * `value` and must never be rendered into a runnable command.
145
+ */
146
+ undecided?: string;
95
147
  }
96
148
 
97
149
  /** Internal token classification; quote state is stripped from the REST view. */
@@ -176,6 +228,14 @@ export interface ValueAnchor {
176
228
  parent?: number;
177
229
  }
178
230
 
231
+ /**
232
+ * Skip-tolerant argument tokens; `complete: false` explains where the walk
233
+ * stopped, which is a rarer event than in the strict reading.
234
+ */
235
+ export type ArgumentTokenReading =
236
+ | { complete: true; tokens: Argument[] }
237
+ | { complete: false; tokens: Argument[]; why: string };
238
+
179
239
  /** Prefix-safe value anchoring; `complete: false` explains where scanning stopped. */
180
240
  export type ValueAnchorReading =
181
241
  | { complete: true; anchors: ValueAnchor[] }
@@ -186,9 +246,68 @@ function unread(why: string): ArgumentsUnread {
186
246
  return { read: false, why };
187
247
  }
188
248
 
249
+ /** Where a token ends, and — tolerant walk only — why it was not decoded. */
250
+ interface ScanToken {
251
+ end: number;
252
+ why?: string;
253
+ }
254
+
255
+ /**
256
+ * Whether the `{` at `at` opens a scope, reading the document's mask when the
257
+ * caller anchored this statement and masking `text` itself otherwise.
258
+ *
259
+ * `at` is relative to `text` either way, so callers need no offset arithmetic.
260
+ */
261
+ function scopeBraceAt(
262
+ text: string,
263
+ at: number,
264
+ range: MaskedRange | undefined,
265
+ ): boolean {
266
+ return range === undefined
267
+ ? isScopeBrace(text, at)
268
+ : isScopeBraceIn(range, at);
269
+ }
270
+
271
+ /**
272
+ * `{ range }` when the caller supplied one, otherwise nothing — so an omitted
273
+ * range stays absent rather than becoming an explicit `undefined` under
274
+ * `exactOptionalPropertyTypes`.
275
+ */
276
+ function rangeOption(range: MaskedRange | undefined): { range?: MaskedRange } {
277
+ return range === undefined ? {} : { range };
278
+ }
279
+
189
280
  interface WalkOptions {
190
281
  /** Locate `{…}`/`(…)` literals instead of refusing the statement at one. */
191
282
  allowArrayValues?: boolean;
283
+ /**
284
+ * Where `text` sits in its document, with that document's comment mask.
285
+ *
286
+ * A statement's text carries its whole nested `do={…}` subtree, and every
287
+ * pass over the document asks a different one of these readers for the same
288
+ * statement — so each re-derived a mask over bytes the last one had already
289
+ * read, once per pass and once per enclosing level (#322). A caller holding
290
+ * the document's mask passes the statement's range of it instead; the mask of
291
+ * a statement IS the document's mask restricted to that statement, because a
292
+ * statement boundary begins a comment context (see `segment.ts` →
293
+ * `MaskedRange`). Omitted, this reader masks `text` itself as before.
294
+ *
295
+ * Offsets in and out of these readers stay relative to `text`. The range is
296
+ * read for the mask, and for the questions whose answer depends on the
297
+ * DOCUMENT's statement index rather than this statement's.
298
+ */
299
+ range?: MaskedRange;
300
+ /**
301
+ * Publish a token the walk cannot DECODE instead of aborting at it (#316).
302
+ *
303
+ * Off, every refusal stops the walk — the reading the REST/`curl` consumer
304
+ * needs. On, a refusal whose token boundary is still knowable becomes an
305
+ * {@link Argument} with `undecided` set and no `value`, and the walk carries
306
+ * on to the next token. The boundary rules do not move: both walks agree
307
+ * about where every token starts and ends, and the tolerant one only differs
308
+ * in whether it returns there.
309
+ */
310
+ tolerant?: boolean;
192
311
  /**
193
312
  * The root scripting directive whose slots decide whether a `{…}` here is an
194
313
  * array literal, or undefined when this statement has none.
@@ -267,7 +386,7 @@ function* walkArguments(
267
386
  from: number,
268
387
  options: WalkOptions = {},
269
388
  ): Generator<ReadArgument | string> {
270
- const structural = maskComments(text);
389
+ const structural = maskOf(options.range) ?? maskComments(text);
271
390
  let i = Math.max(0, from);
272
391
  // How many positionals have been read, which is the NEXT positional's index.
273
392
  let positionals = 0;
@@ -298,11 +417,34 @@ function* walkArguments(
298
417
  yield token;
299
418
  return;
300
419
  }
301
- const read = readToken(text, structural, i, token.end, options);
302
- if (typeof read === "string") {
303
- yield read;
420
+ // A refusal whose extent collapsed to nothing leaves no boundary to resume
421
+ // from — resuming at `i` would scan the same byte forever. The tolerant
422
+ // walk stops there exactly as the strict one does.
423
+ if (token.why !== undefined && token.end <= i) {
424
+ yield token.why;
304
425
  return;
305
426
  }
427
+ // `readToken` is skipped entirely for an undecodable token rather than
428
+ // having its result stripped: `literalValue` would read `$x` as the bare
429
+ // run `$x` and publish it as a literal, which is precisely the fabrication
430
+ // the refusal exists to prevent. Shape only, never a value.
431
+ const read =
432
+ token.why === undefined
433
+ ? readToken(text, structural, i, token.end, options)
434
+ : undecidedToken(text, i, token.end, token.why);
435
+ if (typeof read === "string") {
436
+ if (options.tolerant !== true) {
437
+ yield read;
438
+ return;
439
+ }
440
+ // A `readToken` refusal already knows the token's extent — `scanToken`
441
+ // found it — so the tolerant walk publishes the shape and moves on.
442
+ const located = undecidedToken(text, i, token.end, read);
443
+ if (located.kind === "positional") positionals++;
444
+ yield located;
445
+ i = token.end;
446
+ continue;
447
+ }
306
448
  if (read.kind === "positional") positionals++;
307
449
  yield read;
308
450
  i = token.end;
@@ -321,9 +463,13 @@ function* walkArguments(
321
463
  * that hands over the ORIGINAL text of a non-ASCII statement would get spans
322
464
  * that do not map back — `src/explain.ts` verifies the two agree before calling.
323
465
  */
324
- export function lexArguments(text: string, from: number): ArgumentReading {
466
+ export function lexArguments(
467
+ text: string,
468
+ from: number,
469
+ range?: MaskedRange,
470
+ ): ArgumentReading {
325
471
  const tokens: Argument[] = [];
326
- for (const step of walkArguments(text, from)) {
472
+ for (const step of walkArguments(text, from, rangeOption(range))) {
327
473
  if (typeof step === "string") return unread(step);
328
474
  const publicToken = { ...step };
329
475
  delete publicToken.literalQuoted;
@@ -342,6 +488,54 @@ export function lexArguments(text: string, from: number): ArgumentReading {
342
488
  return { read: true, tokens, args, queries, positional };
343
489
  }
344
490
 
491
+ /**
492
+ * Lex the arguments of ONE statement as a TOKEN STREAM, skipping what it cannot
493
+ * decode (#316).
494
+ *
495
+ * Same text, same `from`, same boundary rules and the same reason strings as
496
+ * {@link lexArguments} — the difference is only what a refusal does. Where the
497
+ * strict reading discards the whole list, this one publishes the token it could
498
+ * not decode with `undecided` set, no `value`, and its bytes located, then
499
+ * carries on to the next token. It is the reading every TOKEN-LEVEL rule wants
500
+ * (`findMissingSeparators`, the `arg` fill), and the one no RENDERING consumer
501
+ * may have: a token with `undecided` set has no value to send.
502
+ *
503
+ * ## Skip-and-continue, not stop-at-refusal
504
+ *
505
+ * The cheaper design — publish the prefix decoded before the first refusal,
506
+ * which is what {@link lexValueAnchors} does for values — was measured and
507
+ * rejected. On the 948-script pinned corpus the prefix reading recovers 3,841
508
+ * argument tokens and finds **zero** additional missing-separator runs, because
509
+ * a refusal is usually the FIRST interesting token in the statement
510
+ * (`… interface=$x /ip/route add …` has an empty prefix). Skipping past it is
511
+ * what reaches the rest of the operand stream, and the corpus numbers for both
512
+ * designs are in `commands/explain/README.md` → *The token stream a rule reads*.
513
+ *
514
+ * ## Where it still stops
515
+ *
516
+ * Two constructs have no knowable end, so neither walk can resume past them: an
517
+ * unterminated string and an unbalanced `(`/`[`/`{`. A `;` is a third, and
518
+ * deliberately so — it is a statement boundary the segmenter owns, and resuming
519
+ * after it would read the NEXT statement's bytes as this one's arguments.
520
+ */
521
+ export function lexArgumentTokens(
522
+ text: string,
523
+ from: number,
524
+ range?: MaskedRange,
525
+ ): ArgumentTokenReading {
526
+ const tokens: Argument[] = [];
527
+ for (const step of walkArguments(text, from, {
528
+ tolerant: true,
529
+ ...rangeOption(range),
530
+ })) {
531
+ if (typeof step === "string") return { complete: false, tokens, why: step };
532
+ const publicToken = { ...step };
533
+ delete publicToken.literalQuoted;
534
+ tokens.push(publicToken);
535
+ }
536
+ return { complete: true, tokens };
537
+ }
538
+
345
539
  /**
346
540
  * Length of the `\<newline>` continuation at `at`, or 0 — the ONE boundary rule
347
541
  * this module and `verbsplit.ts`'s word scanner share.
@@ -374,54 +568,135 @@ function scanToken(
374
568
  start: number,
375
569
  positionalIndex: number,
376
570
  options: WalkOptions,
377
- ): { end: number } | string {
571
+ ): ScanToken | string {
378
572
  let i = start;
573
+ // The first refusal seen while still able to find the token's END. Only the
574
+ // tolerant walk ever sets it; the strict walk returns on the first refusal,
575
+ // so `refuse` hands the reason straight back to be returned (#316).
576
+ let why: string | undefined;
577
+ function refuse(reason: string): string | undefined {
578
+ if (options.tolerant !== true) return reason;
579
+ if (why === undefined) why = reason;
580
+ return undefined;
581
+ }
582
+ // A construct whose END is not knowable. The token stops in FRONT of it,
583
+ // keeping the reason its own bytes earned, and the walk resumes at the
584
+ // construct — where this runs again with nothing scanned (`i === start`) and
585
+ // hands back the unbounded reason, which is why the walk stops. Reporting the
586
+ // recorded reason there instead would say the walk stopped at a value it
587
+ // normally skips, when it stopped because there was no boundary to skip to.
588
+ function unbounded(reason: string): ScanToken | string {
589
+ return why === undefined || i <= start ? reason : { end: i, why };
590
+ }
379
591
  while (i < text.length) {
380
592
  const c = structural[i] as string;
381
593
  if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
382
594
  if (c === '"') {
383
595
  const scan = scanQuotedString(structural, i);
384
- if (!scan.closed) return "unterminated string in an argument";
596
+ if (!scan.closed) return unbounded("unterminated string in an argument");
385
597
  i = scan.end;
386
598
  continue;
387
599
  }
388
- if (c === "[") return "a substitution or expression value";
389
- if (c === "(" || c === "{") {
390
- if (!options.allowArrayValues)
391
- return c === "("
392
- ? "a substitution or expression value"
393
- : "an array or block value";
394
- if (c === "{" && isScopeBrace(text, i)) return "a scope block value";
395
- if (
396
- c === "{" &&
397
- !braceOpensArray(text, start, i, positionalIndex, options)
398
- )
399
- return "a brace value RouterOS does not read as an array here";
600
+ if (c === "[" || c === "(" || c === "{") {
601
+ const declined = structuredRefusal(
602
+ text,
603
+ c,
604
+ start,
605
+ i,
606
+ positionalIndex,
607
+ options,
608
+ );
609
+ if (declined !== undefined) {
610
+ const abort = refuse(declined);
611
+ if (abort !== undefined) return abort;
612
+ // The reason is recorded; the extent is still the balanced run, which
613
+ // is what lets the walk carry on past a value it cannot decode.
614
+ const skip = delimitedEnd(structural, i);
615
+ if (skip === null)
616
+ return unbounded("an unclosed structured argument value");
617
+ i = skip;
618
+ continue;
619
+ }
400
620
  const end = delimitedEnd(structural, i);
401
- if (end === null) return "an unclosed structured argument value";
402
- if (hasUnquotedHash(structural, i, end))
403
- return "an invalid hash in a structured argument value";
621
+ if (end === null)
622
+ return unbounded("an unclosed structured argument value");
623
+ if (hasUnquotedHash(structural, i, end, options.range)) {
624
+ const abort = refuse("an invalid hash in a structured argument value");
625
+ if (abort !== undefined) return abort;
626
+ }
404
627
  i = end;
405
628
  const next = nextNonWhitespace(structural, i);
406
- if (continuesExpression(structural, i, next))
407
- return "an expression continuing after a structured value";
629
+ if (continuesExpression(structural, i, next)) {
630
+ const abort = refuse(
631
+ "an expression continuing after a structured value",
632
+ );
633
+ if (abort !== undefined) return abort;
634
+ }
635
+ continue;
636
+ }
637
+ if (c === "$") {
638
+ const abort = refuse("a variable value");
639
+ if (abort !== undefined) return abort;
640
+ i++;
408
641
  continue;
409
642
  }
410
- if (c === "$") return "a variable value";
411
643
  if (c === "\\") {
412
- if (continuationLength(text, i) > 0)
413
- return "a line continuation inside an argument";
644
+ if (continuationLength(text, i) > 0) {
645
+ const abort = refuse("a line continuation inside an argument");
646
+ if (abort !== undefined) return abort;
647
+ // The token's BYTES stay contiguous across the continuation even
648
+ // though its value does not, and `verbsplit.ts`'s word scanner already
649
+ // reads them as one word for the run end. So the span is honest while
650
+ // the value stays absent — which is the whole tolerant contract.
651
+ i += continuationLength(text, i);
652
+ continue;
653
+ }
414
654
  // The #201 rule: in code a backslash is valid only before whitespace.
415
655
  // `symbols.ts` already located the defect; refusing is enough here.
416
- if (text[i + 1] !== " " && text[i + 1] !== "\t")
417
- return "an invalid escape in an argument";
656
+ if (text[i + 1] !== " " && text[i + 1] !== "\t") {
657
+ const abort = refuse("an invalid escape in an argument");
658
+ if (abort !== undefined) return abort;
659
+ i++;
660
+ continue;
661
+ }
418
662
  i += 2;
419
663
  continue;
420
664
  }
421
- if (c === ";") return "a statement separator inside an argument";
665
+ // A `;` is a BOUNDARY the segmenter should already have split on, not a
666
+ // value this walk may skip: resuming after it would read the next
667
+ // statement's bytes as this one's arguments. Both walks stop.
668
+ if (c === ";") return unbounded("a statement separator inside an argument");
422
669
  i++;
423
670
  }
424
- return { end: i };
671
+ return why === undefined ? { end: i } : { end: i, why };
672
+ }
673
+
674
+ /**
675
+ * Why a `[`/`(`/`{` at `at` is not a value this lexer reads, or `undefined`
676
+ * when {@link WalkOptions.allowArrayValues} accepts it as an array literal.
677
+ *
678
+ * Split out of {@link scanToken} so the strict and tolerant walks choose the
679
+ * same reason for the same byte — the tolerant walk then skips the balanced run
680
+ * instead of returning, which is the only difference between them.
681
+ */
682
+ function structuredRefusal(
683
+ text: string,
684
+ c: string,
685
+ start: number,
686
+ at: number,
687
+ positionalIndex: number,
688
+ options: WalkOptions,
689
+ ): string | undefined {
690
+ if (c === "[") return "a substitution or expression value";
691
+ if (!options.allowArrayValues)
692
+ return c === "("
693
+ ? "a substitution or expression value"
694
+ : "an array or block value";
695
+ if (c === "{" && scopeBraceAt(text, at, options.range))
696
+ return "a scope block value";
697
+ if (c === "{" && !braceOpensArray(text, start, at, positionalIndex, options))
698
+ return "a brace value RouterOS does not read as an array here";
699
+ return undefined;
425
700
  }
426
701
 
427
702
  /**
@@ -441,7 +716,12 @@ function scanToken(
441
716
  *
442
717
  * Skipping whole `[…]` regions instead would wrongly accept rows 3 and 4.
443
718
  */
444
- function hasUnquotedHash(text: string, start: number, end: number): boolean {
719
+ function hasUnquotedHash(
720
+ text: string,
721
+ start: number,
722
+ end: number,
723
+ range?: MaskedRange,
724
+ ): boolean {
445
725
  // `start` is the `{`/`(` of an array-or-group value: not a statement context.
446
726
  const statements: boolean[] = [false];
447
727
  for (let i = start + 1; i < end - 1; i++) {
@@ -457,7 +737,14 @@ function hasUnquotedHash(text: string, start: number, end: number): boolean {
457
737
  // scope; nested in an array it is another array, so the role is known
458
738
  // without the reverse-prefix scan. The short-circuit is what keeps this
459
739
  // O(1) per brace on the deep-nesting inputs `explain-write` and Q17 pin.
460
- statements.push(enclosing && braceStartsStatements(text, i));
740
+ statements.push(
741
+ enclosing &&
742
+ braceStartsStatements(
743
+ range?.masked ?? text,
744
+ (range?.start ?? 0) + i,
745
+ range?.start ?? 0,
746
+ ),
747
+ );
461
748
  else if (c === "(") statements.push(false);
462
749
  else if (c === "]" || c === "}" || c === ")") {
463
750
  if (statements.length > 1) statements.pop();
@@ -527,13 +814,14 @@ function isArraySource(
527
814
  structural: string,
528
815
  start: number,
529
816
  end: number,
817
+ range: MaskedRange | undefined,
530
818
  ): boolean {
531
819
  const open = text[start];
532
820
  if (open !== "(" && open !== "{") return false;
533
821
  if (delimitedEnd(structural, start) !== end) return false;
534
822
  if (open === "{")
535
823
  return (
536
- !isScopeBrace(text, start) &&
824
+ !scopeBraceAt(text, start, range) &&
537
825
  structural.slice(start + 1, end - 1).trim().length > 0
538
826
  );
539
827
 
@@ -576,7 +864,7 @@ function readToken(
576
864
  return divergent ?? { kind: "query", span, name: raw.slice(1), text: raw };
577
865
  }
578
866
  if (options.allowArrayValues && startsStructuredSource(text, start)) {
579
- if (isArraySource(text, structural, start, end))
867
+ if (isArraySource(text, structural, start, end, options.range))
580
868
  return {
581
869
  kind: "positional",
582
870
  span,
@@ -613,7 +901,7 @@ function readToken(
613
901
  if (!ARGUMENT_NAME.test(name))
614
902
  return `\`${name}=\` is not a RouterOS argument name`;
615
903
  if (options.allowArrayValues && startsStructuredSource(text, eq + 1)) {
616
- if (isArraySource(text, structural, eq + 1, end))
904
+ if (isArraySource(text, structural, eq + 1, end, options.range))
617
905
  return {
618
906
  kind: "attribute",
619
907
  span,
@@ -641,6 +929,51 @@ function readToken(
641
929
  };
642
930
  }
643
931
 
932
+ /**
933
+ * One located token whose value this phase does not decode (#316).
934
+ *
935
+ * Shape only: the `?`/`name=`/bare split comes from bytes the scanner already
936
+ * bounded, and NOTHING here reads a value — `undecided` is set and `value` stays
937
+ * absent, whatever the source spells. A left side that is not a RouterOS name is
938
+ * not promoted to an attribute (`{a=1;b=2}` stays one bare token), which is the
939
+ * same call {@link readToken} makes when it refuses that shape outright.
940
+ */
941
+ function undecidedToken(
942
+ text: string,
943
+ start: number,
944
+ end: number,
945
+ why: string,
946
+ ): ReadArgument {
947
+ const raw = text.slice(start, end);
948
+ const span = { start, end };
949
+ if (raw.startsWith("?"))
950
+ return {
951
+ kind: "query",
952
+ span,
953
+ name: raw.slice(1),
954
+ text: raw,
955
+ undecided: why,
956
+ };
957
+ const eq = unquotedEquals(text, start, end);
958
+ const name = eq === null ? null : text.slice(start, eq);
959
+ if (eq !== null && name !== null && ARGUMENT_NAME.test(name))
960
+ return {
961
+ kind: "attribute",
962
+ span,
963
+ name,
964
+ valueSpan: { start: eq + 1, end },
965
+ text: raw,
966
+ undecided: why,
967
+ };
968
+ return {
969
+ kind: "positional",
970
+ span,
971
+ valueSpan: span,
972
+ text: raw,
973
+ undecided: why,
974
+ };
975
+ }
976
+
644
977
  /** Offset of the first `=` outside a quoted run, or null. */
645
978
  function unquotedEquals(
646
979
  text: string,
@@ -1065,6 +1398,7 @@ function pushArrayMembers(
1065
1398
  contentEnd: number,
1066
1399
  separator: ";" | ",",
1067
1400
  depth: number,
1401
+ range: MaskedRange | undefined,
1068
1402
  ): string | null {
1069
1403
  // Past the bound the interior is UNVERIFIED, and an unverified interior can
1070
1404
  // hold a fault that makes the whole statement a syntax error — `:parse`
@@ -1206,7 +1540,7 @@ function pushArrayMembers(
1206
1540
  }
1207
1541
  const fault = parenMemberFault(text, structural, valueStart, member.end);
1208
1542
  if (fault !== null) return fault;
1209
- if (isArraySource(text, structural, valueStart, member.end)) {
1543
+ if (isArraySource(text, structural, valueStart, member.end, range)) {
1210
1544
  const at = anchors.length;
1211
1545
  anchors.push({ ...anchor, sourceShape: "array" });
1212
1546
  const nested = pushArrayMembers(
@@ -1218,6 +1552,7 @@ function pushArrayMembers(
1218
1552
  member.end - 1,
1219
1553
  text[valueStart] === "{" ? ";" : ",",
1220
1554
  depth + 1,
1555
+ range,
1221
1556
  );
1222
1557
  if (nested !== null) return nested;
1223
1558
  continue;
@@ -1239,6 +1574,7 @@ function pushArrayMembers(
1239
1574
  member.end,
1240
1575
  ",",
1241
1576
  depth + 1,
1577
+ range,
1242
1578
  );
1243
1579
  if (nested !== null) return nested;
1244
1580
  continue;
@@ -1269,12 +1605,13 @@ function pushArrayMembers(
1269
1605
  export function lexValueAnchors(
1270
1606
  text: string,
1271
1607
  from: number,
1272
- options: { directiveVerb?: string } = {},
1608
+ options: { directiveVerb?: string; range?: MaskedRange } = {},
1273
1609
  ): ValueAnchorReading {
1274
1610
  const anchors: ValueAnchor[] = [];
1275
- const structural = maskComments(text);
1611
+ const structural = maskOf(options.range) ?? maskComments(text);
1276
1612
  for (const read of walkArguments(text, from, {
1277
1613
  allowArrayValues: true,
1614
+ ...rangeOption(options.range),
1278
1615
  ...(options.directiveVerb === undefined
1279
1616
  ? {}
1280
1617
  : { directiveVerb: options.directiveVerb }),
@@ -1302,6 +1639,7 @@ export function lexValueAnchors(
1302
1639
  read.valueSpan.end - 1,
1303
1640
  text[read.valueSpan.start] === "{" ? ";" : ",",
1304
1641
  0,
1642
+ options.range,
1305
1643
  );
1306
1644
  if (unparsed !== null) {
1307
1645
  anchors.length = at;
@@ -1335,8 +1673,9 @@ export function invalidCommandBraceOffsets(
1335
1673
  text: string,
1336
1674
  from: number,
1337
1675
  directiveVerb?: string,
1676
+ range?: MaskedRange,
1338
1677
  ): number[] {
1339
- const structural = maskComments(text);
1678
+ const structural = maskOf(range) ?? maskComments(text);
1340
1679
  const offsets: number[] = [];
1341
1680
  const stack: string[] = [];
1342
1681
  let i = Math.max(0, from);
@@ -1388,7 +1727,14 @@ export function invalidCommandBraceOffsets(
1388
1727
  : braceValueOwner(structural, tokenStart, i);
1389
1728
  const slot = owner === null ? `#${positionals}` : owner;
1390
1729
  if (
1391
- braceStartsStatements(structural, i) ||
1730
+ // Asked of the DOCUMENT when the caller anchored this statement, so the
1731
+ // statement index behind it is the document's one index rather than a
1732
+ // fresh one over this statement's text — which carries its whole nested
1733
+ // subtree, and so was rebuilt once per enclosing level (#322). The floor
1734
+ // makes the two readings the same answer (`scope-brace.ts`).
1735
+ (range === undefined
1736
+ ? braceStartsStatements(structural, i)
1737
+ : braceStartsStatements(range.masked, range.start + i, range.start)) ||
1392
1738
  (directiveVerb !== undefined &&
1393
1739
  slot !== undefined &&
1394
1740
  braceSlotAcceptsBrace(directiveVerb, slot))