@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -90,7 +90,7 @@
90
90
  * certainty, because R4 has it REPLACE the context rather than extend it.
91
91
  */
92
92
 
93
- import { scopeBlocks, scopeBodies } from "./blocks.ts";
93
+ import { type ScopeBlock, scopeBlocksIn } from "./blocks.ts";
94
94
  import { commandVerbIndex } from "./catalog.ts";
95
95
  import {
96
96
  type Defect,
@@ -101,13 +101,19 @@ import {
101
101
  import { isKnownMenuPath } from "./is-known-menu.ts";
102
102
  import {
103
103
  braceStartsStatements,
104
+ canRetainStatementIndex,
104
105
  hashStartsHardError,
105
106
  scopeNameFromMasked,
106
107
  } from "./scope-brace.ts";
107
108
  import {
108
- maskComments,
109
+ documentRange,
110
+ type MaskedRange,
111
+ nestedRange,
112
+ rangeAt,
109
113
  type SegmentResult,
110
114
  scanQuotedString,
115
+ scanStringInterpolation,
116
+ segmentScopeBody,
111
117
  segmentStatements,
112
118
  } from "./segment.ts";
113
119
  import { VERBS } from "./verbs.ts";
@@ -159,6 +165,29 @@ function asciiWords(text: string): string[] {
159
165
  return trimmed.length === 0 ? [] : trimmed.split(ASCII_WHITESPACE);
160
166
  }
161
167
 
168
+ /**
169
+ * The same words, yielded one at a time.
170
+ *
171
+ * For the readers that STOP at the first word which is not a path segment — the
172
+ * leading run, and the unreadable-menu test. A statement's text carries its
173
+ * whole nested `do={…}` subtree, so splitting all of it to look at the first
174
+ * two words was work repeated once per enclosing level (#322). Equivalent to
175
+ * {@link asciiWords} by construction: a trim followed by a split on runs of
176
+ * ASCII whitespace is exactly the maximal non-whitespace runs.
177
+ */
178
+ function* asciiWordsIter(text: string): Generator<string> {
179
+ let i = 0;
180
+ while (i < text.length) {
181
+ if (isAsciiWhitespace(text[i])) {
182
+ i++;
183
+ continue;
184
+ }
185
+ const start = i;
186
+ while (i < text.length && !isAsciiWhitespace(text[i])) i++;
187
+ yield text.slice(start, i);
188
+ }
189
+ }
190
+
162
191
  function isAbsolutePathToken(token: string): boolean {
163
192
  if (!token.startsWith("/")) return false;
164
193
  const parts = token.split("/").filter(Boolean);
@@ -182,16 +211,87 @@ const MAX_DEPTH = 256;
182
211
  * statement must never yield a confident command, so the resolver degrades it
183
212
  * to `unresolved` and does not descend into it. Statement-local; the
184
213
  * segmenter's document-level defects are surfaced separately on the envelope.
214
+ *
215
+ * Reads a RANGE of the document's own comment mask rather than masking the
216
+ * statement's text, which carries its whole nested subtree (#322).
185
217
  */
186
- function structuralDefect(text: string): string | null {
187
- // Mask comments so a `#`-comment `}`/`)` is not counted as a real delimiter.
188
- const masked = maskComments(text);
218
+ interface StructuralIndex {
219
+ wellNested: boolean;
220
+ hardHashes: number[];
221
+ }
222
+
223
+ let recentStructuralText: string | undefined;
224
+ let recentStructuralIndex: StructuralIndex | undefined;
225
+
226
+ function structuralIndex(masked: string): StructuralIndex {
227
+ if (recentStructuralText === masked && recentStructuralIndex !== undefined)
228
+ return recentStructuralIndex;
229
+
230
+ let wellNested = true;
231
+ const hardHashes: number[] = [];
189
232
  const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
190
233
  const stack: { char: string; statements: boolean }[] = [];
191
234
  for (let i = 0; i < masked.length; i++) {
192
235
  const c = masked[i];
193
236
  if (c === '"') {
194
237
  const str = scanQuotedString(masked, i);
238
+ if (!str.closed) wellNested = false;
239
+ i = str.end - 1;
240
+ continue;
241
+ }
242
+ if (
243
+ c === "#" &&
244
+ hashStartsHardError(masked, i, stack.at(-1)?.statements === false, 0)
245
+ )
246
+ hardHashes.push(i);
247
+ if (c === "(" || c === "[" || c === "{")
248
+ stack.push({
249
+ char: c,
250
+ statements:
251
+ c === "[" ||
252
+ (c === "{" &&
253
+ (stack.at(-1)?.statements ?? true) &&
254
+ braceStartsStatements(masked, i, 0)),
255
+ });
256
+ else if (c === ")" || c === "]" || c === "}") {
257
+ if (stack.pop()?.char !== openOf[c]) wellNested = false;
258
+ }
259
+ }
260
+ if (stack.length > 0) wellNested = false;
261
+ const index = { wellNested, hardHashes };
262
+ if (canRetainStatementIndex(masked)) {
263
+ recentStructuralText = masked;
264
+ recentStructuralIndex = index;
265
+ }
266
+ return index;
267
+ }
268
+
269
+ function structuralDefectIn(range: MaskedRange): string | null {
270
+ // The document mask makes comments opaque before this shared topology pass.
271
+ const index = structuralIndex(range.masked);
272
+ if (!index.wellNested) return structuralDefectByScan(range);
273
+ const defects = index.hardHashes;
274
+ let lo = 0;
275
+ let hi = defects.length;
276
+ while (lo < hi) {
277
+ const mid = (lo + hi) >>> 1;
278
+ if ((defects[mid] as number) < range.start) lo = mid + 1;
279
+ else hi = mid;
280
+ }
281
+ const defect = defects[lo];
282
+ return defect !== undefined && defect < range.end
283
+ ? "structural defect: invalid unquoted hash"
284
+ : null;
285
+ }
286
+
287
+ function structuralDefectByScan(range: MaskedRange): string | null {
288
+ const { masked, start: lo, end: hi } = range;
289
+ const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
290
+ const stack: { char: string; statements: boolean }[] = [];
291
+ for (let i = lo; i < hi; i++) {
292
+ const c = masked[i];
293
+ if (c === '"') {
294
+ const str = scanQuotedString(masked, i, hi);
195
295
  if (!str.closed)
196
296
  return "structural defect: unbalanced delimiter or string";
197
297
  i = str.end - 1;
@@ -199,7 +299,7 @@ function structuralDefect(text: string): string | null {
199
299
  }
200
300
  if (
201
301
  c === "#" &&
202
- hashStartsHardError(masked, i, stack.at(-1)?.statements === false)
302
+ hashStartsHardError(masked, i, stack.at(-1)?.statements === false, lo)
203
303
  )
204
304
  return "structural defect: invalid unquoted hash";
205
305
  if (c === "(" || c === "[" || c === "{")
@@ -209,7 +309,7 @@ function structuralDefect(text: string): string | null {
209
309
  c === "[" ||
210
310
  (c === "{" &&
211
311
  (stack.at(-1)?.statements ?? true) &&
212
- braceStartsStatements(masked, i)),
312
+ braceStartsStatements(masked, i, lo)),
213
313
  });
214
314
  else if (c === ")" || c === "]" || c === "}") {
215
315
  if (stack.pop()?.char !== openOf[c])
@@ -229,11 +329,14 @@ function structuralDefect(text: string): string | null {
229
329
  const CONTEXT_LOST = "context lost to an earlier unreadable statement";
230
330
 
231
331
  /** R11 — a directive written without its colon, told by its scope block. */
232
- function isBareDirective(trimmed: string): boolean {
332
+ function isBareDirective(text: string, range: MaskedRange): boolean {
333
+ const start = trimmedStart(text, 0, text.length);
334
+ const end = trimmedEnd(text, start, text.length);
335
+ const trimmed = text.slice(start, end);
233
336
  return (
234
337
  !trimmed.startsWith(":") &&
235
338
  !trimmed.startsWith("/") &&
236
- scopeBodies(trimmed).length > 0
339
+ scopeBlocksIn(nestedRange(range, start, end)).length > 0
237
340
  );
238
341
  }
239
342
 
@@ -242,9 +345,9 @@ function isBareDirective(trimmed: string): boolean {
242
345
  * context? Mirrors `canonicalPath`'s `base` exactly — absolute, `:` directive,
243
346
  * or bare directive (R11) all resolve at the root.
244
347
  */
245
- function isContextIndependent(text: string): boolean {
348
+ function isContextIndependent(text: string, range: MaskedRange): boolean {
246
349
  const t = trimAscii(text);
247
- return t.startsWith(":") || t.startsWith("/") || isBareDirective(t);
350
+ return t.startsWith(":") || t.startsWith("/") || isBareDirective(text, range);
248
351
  }
249
352
 
250
353
  /**
@@ -254,9 +357,9 @@ function isContextIndependent(text: string): boolean {
254
357
  * at the root but hands its body the context in force (R5), so `:foreach … do={
255
358
  * add … }` still depends on an upstream context its own path does not.
256
359
  */
257
- function isBodyContextIndependent(text: string): boolean {
360
+ function isBodyContextIndependent(text: string, range: MaskedRange): boolean {
258
361
  const t = trimAscii(text);
259
- return t.startsWith("/") || isBareDirective(t);
362
+ return t.startsWith("/") || isBareDirective(text, range);
260
363
  }
261
364
 
262
365
  /**
@@ -287,7 +390,7 @@ function isUnreadablePath(text: string): boolean {
287
390
  if (t.length === 0) return false;
288
391
  // A head that is not path-shaped names no menu.
289
392
  if (/^[$[("':]/.test(t)) return false;
290
- for (const word of asciiWords(t)) {
393
+ for (const word of asciiWordsIter(t)) {
291
394
  // Arguments begin: everything before this was readable. A `$` word
292
395
  // STANDING ALONE is a positional operand (`get $item time`), not a path
293
396
  // segment — only a `$` slash-joined inside a word is a menu segment.
@@ -340,12 +443,16 @@ function isUnreadableAbsolute(text: string): boolean {
340
443
  * anchors nothing, in EITHER spelling: `statementPath` derives a base from a
341
444
  * relative one too (`route/$verb remove [find]` yields `<ctx>/route/$verb`).
342
445
  */
343
- function handsKnownContext(text: string, contextCertain: boolean): boolean {
446
+ function handsKnownContext(
447
+ text: string,
448
+ contextCertain: boolean,
449
+ range: MaskedRange,
450
+ ): boolean {
344
451
  // Either spelling of an unreadable menu anchors nothing. `statementPath`
345
452
  // derives a base from a RELATIVE one too (`route/$verb remove [find]` yields
346
453
  // `<ctx>/route/$verb`), so this cannot test only the absolute form.
347
454
  if (isUnreadableAbsolute(text) || isUnreadablePath(text)) return false;
348
- return contextCertain || isBodyContextIndependent(text);
455
+ return contextCertain || isBodyContextIndependent(text, range);
349
456
  }
350
457
 
351
458
  /** A re-constituted `[…]` command substitution (Q3). */
@@ -381,6 +488,30 @@ export interface Resolution {
381
488
  * {@link Loc}.
382
489
  */
383
490
  span: Span;
491
+ /**
492
+ * Where {@link inner} itself sits, in the same document byte space.
493
+ *
494
+ * Not derivable from `span`: the two bracket spellings do not agree on what
495
+ * `span` covers. `collectBrackets` spans `[…]`, so `inner` starts one byte
496
+ * in; `scanInterpolations` spans `$[…]` **from the sigil** (that `$` is part
497
+ * of the substitution), so it starts two. `inner` is also trimmed, so any
498
+ * leading whitespace shifts it further right again. A consumer that guessed
499
+ * `span.start + 1` therefore read one byte early on every interpolated
500
+ * substitution and, finding the text did not match, silently dropped the
501
+ * resolution — which is how a `$[…]` inside a string lost its path tokens to
502
+ * the string fill (#325). Widens with `span` when interior offsets cannot be
503
+ * mapped, so a consumer must still check the slice against `inner`.
504
+ */
505
+ innerSpan: Span;
506
+ }
507
+
508
+ const resolutionRanges = new WeakMap<Resolution, MaskedRange>();
509
+
510
+ /** The exact masked view used to read one resolution's inner text. */
511
+ export function rangeForResolution(
512
+ resolution: Resolution,
513
+ ): MaskedRange | undefined {
514
+ return resolutionRanges.get(resolution);
384
515
  }
385
516
 
386
517
  /** The canonical path of one statement in source order (Q4). */
@@ -505,6 +636,12 @@ interface Located {
505
636
  text: string;
506
637
  span: Span;
507
638
  loc: Loc;
639
+ /**
640
+ * This statement as a range of the document, with the document's comment
641
+ * mask. Every reader that would otherwise re-mask `text` — which carries the
642
+ * whole nested subtree — takes this instead (#322).
643
+ */
644
+ range: MaskedRange;
508
645
  }
509
646
 
510
647
  /**
@@ -517,6 +654,7 @@ interface Located {
517
654
  function locate(
518
655
  segmented: SegmentResult,
519
656
  loc: Loc,
657
+ parent: MaskedRange | null,
520
658
  ): { units: Located[]; defects: Defect[] } {
521
659
  const units = segmented.segments.map((s) => {
522
660
  const span = spanIn(loc, s.start, s.end);
@@ -531,6 +669,15 @@ function locate(
531
669
  loc.base < 0 || !asciiOnly
532
670
  ? { base: -1, fallback: span }
533
671
  : { base: loc.base + s.start, fallback: span },
672
+ // A statement is a range of its parent whenever the parent offered one
673
+ // AND the text just segmented was pure ASCII — the only case in which
674
+ // `s.start`/`s.end`, which are analyzed BYTES, are also JS string
675
+ // indices into it. Decided per level, so one non-ASCII body drops just
676
+ // its own subtree back to a mask of its own, exactly as before (#322).
677
+ range:
678
+ parent !== null && segmented.ascii
679
+ ? nestedRange(parent, s.start, s.end)
680
+ : documentRange(s.text),
534
681
  };
535
682
  });
536
683
  // STRUCTURAL defects only. `segmentStatements` merges `coordinateDefects`
@@ -553,6 +700,30 @@ function locate(
553
700
  */
554
701
  const DOCUMENT_LOC: Loc = { base: 0, fallback: { start: 0, end: 0 } };
555
702
 
703
+ /**
704
+ * The document-wide masked range a walk descends through, or null.
705
+ *
706
+ * Null for a non-ASCII document: the walk's offsets are analyzed BYTES, and
707
+ * only in an all-ASCII document is such an offset also a JS string index. There
708
+ * every level re-derives its own mask, which is what every level did before
709
+ * (#322) — the anchored reading is a fast path, never a second answer.
710
+ */
711
+ function rootRange(text: string, segmented: SegmentResult): MaskedRange | null {
712
+ return segmented.ascii ? documentRange(text, true) : null;
713
+ }
714
+
715
+ /**
716
+ * A scope body as a range of the statement that carries it.
717
+ *
718
+ * `ScopeBlock.start` is a JS index into the statement's text by construction —
719
+ * `scopeBlocksIn` walks the mask, which is code-unit-for-code-unit with it — so
720
+ * this needs no ASCII test of its own. Whether the body's own STATEMENTS may
721
+ * then be nested is decided by `locate`, against that body's segmentation.
722
+ */
723
+ function bodyRange(range: MaskedRange, block: ScopeBlock): MaskedRange {
724
+ return nestedRange(range, block.start, block.start + block.body.length);
725
+ }
726
+
556
727
  /** Q3 — resolve every `[…]` command substitution in `text`. */
557
728
  export function resolveDocument(text: string): DocumentAnalysis {
558
729
  // Segment ONCE and hand the result to `locate` — `containsWrite` calls both
@@ -564,7 +735,7 @@ export function resolveDocument(text: string): DocumentAnalysis {
564
735
  // substring's coordinate space.
565
736
  const defects: Defect[] = [...segmented.defects];
566
737
  const resolutions: Resolution[] = [];
567
- const top = locate(segmented, DOCUMENT_LOC);
738
+ const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
568
739
  defects.push(...top.defects);
569
740
  walk(top.units, "/", resolutions, 0, defects, true);
570
741
  return { resolutions, defects: mergeDefects(defects) };
@@ -581,9 +752,9 @@ function walk(
581
752
  let ctx = context;
582
753
  let certain = contextCertain;
583
754
  for (const unit of units) {
584
- const { text, loc } = unit;
755
+ const { text, loc, range } = unit;
585
756
  // R4 — a menu-navigation statement moves the document context.
586
- const nav = menuNavPath(text, ctx);
757
+ const nav = menuNavPath(text, ctx, range);
587
758
  if (nav !== null) {
588
759
  const relative = !trimAscii(text).startsWith("/");
589
760
  // A `..` read against an unknown context stays unknown; an absolute
@@ -597,26 +768,26 @@ function walk(
597
768
  // bracket path and is not descended. It may have been a navigation, so the
598
769
  // context after it is unknown (Q14 C3b, the same contract the statement
599
770
  // walk applies — these two walks must move in lockstep).
600
- if (structuralDefect(text) !== null) {
771
+ if (structuralDefectIn(range) !== null) {
601
772
  certain = false;
602
773
  continue;
603
774
  }
604
775
  // #235 — a relative bare-word that abstained (neither table) poisons
605
776
  // downstream context, so the next relative statement cannot fabricate.
606
- const poisonAbstention = isAbstainedRelativeNav(text, ctx);
777
+ const poisonAbstention = isAbstainedRelativeNav(text, ctx, range);
607
778
  // R3 — the statement's own path is the context its brackets see.
608
- const stmtCtx = statementPath(text, ctx);
779
+ const stmtCtx = statementPath(text, ctx, range);
609
780
  // Derived BEFORE the poison, from the certainty in force at this statement:
610
781
  // `handsKnownContext` answers about the context this statement HANDS DOWN,
611
782
  // which its own unreadability already governs. An abstained nav poisons
612
783
  // what follows, so its own body must already be uncertain.
613
784
  const stmtCertain = poisonAbstention
614
785
  ? false
615
- : handsKnownContext(text, certain);
786
+ : handsKnownContext(text, certain, range);
616
787
  if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
617
- collectBrackets(text, stmtCtx, 0, out, defects, stmtCertain, loc);
788
+ collectBrackets(range, stmtCtx, 0, out, defects, stmtCertain, loc);
618
789
  // R5 — block bodies inherit the context in force here.
619
- for (const block of scopeBlocks(text)) {
790
+ for (const block of scopeBlocksIn(range)) {
620
791
  if (blockDepth >= MAX_DEPTH) {
621
792
  // The BLOCK that exceeded the limit, not the whole statement —
622
793
  // narrowing regions is what this channel is for.
@@ -627,7 +798,11 @@ function walk(
627
798
  continue;
628
799
  }
629
800
  const bodyLoc = nest(loc, block.start, unit.span);
630
- const body = locate(segmentStatements(block.body), bodyLoc);
801
+ const body = locate(
802
+ segmentScopeBody(block.body, bodyRange(range, block)),
803
+ bodyLoc,
804
+ bodyRange(range, block),
805
+ );
631
806
  defects.push(...body.defects);
632
807
  walk(body.units, stmtCtx, out, blockDepth + 1, defects, stmtCertain);
633
808
  }
@@ -640,7 +815,7 @@ export function resolveStatements(text: string): StatementAnalysis {
640
815
  // As `resolveDocument`: coordinate facts come from the root only.
641
816
  const defects: Defect[] = [...segmented.defects];
642
817
  const statements: StatementResolution[] = [];
643
- const top = locate(segmented, DOCUMENT_LOC);
818
+ const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
644
819
  defects.push(...top.defects);
645
820
  walkStatements(top.units, "/", statements, 0, defects, true);
646
821
  return { statements, defects: mergeDefects(defects) };
@@ -657,8 +832,8 @@ function walkStatements(
657
832
  let ctx = context;
658
833
  let certain = contextCertain;
659
834
  for (const unit of units) {
660
- const { text, span } = unit;
661
- const nav = menuNavPath(text, ctx);
835
+ const { text, span, range } = unit;
836
+ const nav = menuNavPath(text, ctx, range);
662
837
  if (nav !== null) {
663
838
  // `..` ascends FROM the context, so it cannot be read while the context
664
839
  // is unknown. A `/`-led navigation (and a bare `/`) REPLACES it (R4) and
@@ -691,7 +866,7 @@ function walkStatements(
691
866
  // Q14 fail-closed — a malformed statement degrades to unresolved and is
692
867
  // not descended; it does not move the context either. It may however have
693
868
  // BEEN a navigation, so what the context now is becomes unknown.
694
- const defectReason = structuralDefect(text);
869
+ const defectReason = structuralDefectIn(range);
695
870
  if (defectReason !== null) {
696
871
  out.push({
697
872
  text,
@@ -708,17 +883,19 @@ function walkStatements(
708
883
  // #235 — a relative bare-word that abstained (neither table) poisons
709
884
  // downstream context. Capture before the push so the current statement's
710
885
  // own `contextCertain` stays true while the following statements lose it.
711
- const poisonAbstention = isAbstainedRelativeNav(text, ctx);
886
+ const poisonAbstention = isAbstainedRelativeNav(text, ctx, range);
712
887
  // Q14 C3b — the cascade. A statement that CONSUMES the context cannot be
713
888
  // resolved against a value the resolver has already admitted it lost.
714
889
  // Applied to the RESOLUTION, not ahead of it, so a statement that would
715
890
  // have refused on its own terms keeps its own — more specific — reason.
716
- const resolved = canonicalPath(text, ctx);
891
+ const resolved = canonicalPath(text, ctx, range);
717
892
  out.push({
718
893
  text,
719
894
  isNav: false,
720
895
  context: ctx,
721
- ...(resolved.path !== null && !certain && !isContextIndependent(text)
896
+ ...(resolved.path !== null &&
897
+ !certain &&
898
+ !isContextIndependent(text, range)
722
899
  ? { path: null, unresolved: CONTEXT_LOST }
723
900
  : resolved),
724
901
  contextCertain: certain,
@@ -729,12 +906,12 @@ function walkStatements(
729
906
  // knowable, when the statement spells its path out or is a bare directive,
730
907
  // and unknowable when the statement's own path could not be read. Same
731
908
  // predicate and same order as the bracket walk, per the lockstep rule.
732
- const stmtCtx = statementPath(text, ctx);
909
+ const stmtCtx = statementPath(text, ctx, range);
733
910
  const bodyCertain = poisonAbstention
734
911
  ? false
735
- : handsKnownContext(text, certain);
912
+ : handsKnownContext(text, certain, range);
736
913
  if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
737
- for (const block of scopeBlocks(text)) {
914
+ for (const block of scopeBlocksIn(range)) {
738
915
  if (blockDepth >= MAX_DEPTH) {
739
916
  defects.push({
740
917
  code: "over-depth",
@@ -743,7 +920,11 @@ function walkStatements(
743
920
  continue;
744
921
  }
745
922
  const bodyLoc = nest(unit.loc, block.start, span);
746
- const body = locate(segmentStatements(block.body), bodyLoc);
923
+ const body = locate(
924
+ segmentScopeBody(block.body, bodyRange(range, block)),
925
+ bodyLoc,
926
+ bodyRange(range, block),
927
+ );
747
928
  defects.push(...body.defects);
748
929
  walkStatements(
749
930
  body.units,
@@ -801,7 +982,11 @@ function walkStatements(
801
982
  * now disagree CONFIDENTLY rather than one of them refusing. The same test pins
802
983
  * the invariant for both rules.
803
984
  */
804
- function menuNavPath(text: string, ctx: string): string | null {
985
+ function menuNavPath(
986
+ text: string,
987
+ ctx: string,
988
+ range: MaskedRange,
989
+ ): string | null {
805
990
  const trimmed = trimAscii(text);
806
991
  if (trimmed === "/") return "/";
807
992
  if (/^\.\.(?:[ \t\r\n]|$)/.test(trimmed)) {
@@ -811,7 +996,7 @@ function menuNavPath(text: string, ctx: string): string | null {
811
996
  }
812
997
  if (!trimmed.startsWith("/")) {
813
998
  // #235 R13 — relative bare-word menu navigation.
814
- const reading = readRelativeBare(text, ctx);
999
+ const reading = readRelativeBare(text, ctx, range);
815
1000
  return reading?.kind === "nav" ? reading.path : null;
816
1001
  }
817
1002
  if (/[=[({"$]/.test(trimmed)) return null;
@@ -856,6 +1041,7 @@ type RelativeBareReading = { kind: "nav"; path: string } | { kind: "abstain" };
856
1041
  function readRelativeBare(
857
1042
  text: string,
858
1043
  ctx: string,
1044
+ range: MaskedRange,
859
1045
  ): RelativeBareReading | null {
860
1046
  const trimmed = trimAscii(text);
861
1047
  if (
@@ -865,7 +1051,7 @@ function readRelativeBare(
865
1051
  trimmed.startsWith("$") ||
866
1052
  trimmed.startsWith("[") ||
867
1053
  trimmed.startsWith("(") ||
868
- isBareDirective(trimmed)
1054
+ isBareDirective(text, range)
869
1055
  )
870
1056
  return null;
871
1057
  // Whitespace and `/` are interchangeable on the device, so the run is read
@@ -893,13 +1079,18 @@ function readRelativeBare(
893
1079
  * Presence in a table → nav; absence → abstain → poison downstream, so the
894
1080
  * next relative statement cannot resolve against a context that may have moved.
895
1081
  */
896
- function isAbstainedRelativeNav(text: string, ctx: string): boolean {
897
- return readRelativeBare(text, ctx)?.kind === "abstain";
1082
+ function isAbstainedRelativeNav(
1083
+ text: string,
1084
+ ctx: string,
1085
+ range: MaskedRange,
1086
+ ): boolean {
1087
+ return readRelativeBare(text, ctx, range)?.kind === "abstain";
898
1088
  }
899
1089
 
900
1090
  function canonicalPath(
901
1091
  text: string,
902
1092
  ctx: string,
1093
+ range: MaskedRange,
903
1094
  ): { path: string | null; candidates?: string[]; unresolved?: string } {
904
1095
  const t = trimAscii(text);
905
1096
  if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
@@ -913,10 +1104,11 @@ function canonicalPath(
913
1104
  // R11 — a scripting directive written WITHOUT its colon is still at the root.
914
1105
  // A statement that carries a SCOPE-valued block is a directive, because no
915
1106
  // menu command takes one — a schema-free tell.
916
- const bareDirective = isBareDirective(t);
1107
+ const bareDirective = isBareDirective(text, range);
917
1108
  // The cascade contract reads this same split to decide which statements may
918
1109
  // keep resolving once the context is lost, so both must come from one place.
919
- const base = isContextIndependent(t) ? "/" : ctx;
1110
+ const base =
1111
+ t.startsWith(":") || t.startsWith("/") || bareDirective ? "/" : ctx;
920
1112
  // R7 — a variable segment anywhere in the leading run leaves the path
921
1113
  // unresolved, INCLUDING after a static word. `statementRun` drops the whole
922
1114
  // word carrying the variable, so `/ip route/$menu/remove` would otherwise
@@ -950,7 +1142,7 @@ function canonicalPath(
950
1142
  */
951
1143
  function statementRun(text: string): string[] {
952
1144
  const out: string[] = [];
953
- for (const t of asciiWords(text)) {
1145
+ for (const t of asciiWordsIter(text)) {
954
1146
  if (t.includes("=") || /^[[({"$:]/.test(t)) break;
955
1147
  const parts = t.split("/").filter((p) => p.length > 0);
956
1148
  if (parts.length === 0) continue;
@@ -965,22 +1157,22 @@ function statementRun(text: string): string[] {
965
1157
  * [find …]` that is `/ip/route`: the leading path run minus its last token,
966
1158
  * which is the verb (Q6's reading, right ~93.7% of the time).
967
1159
  */
968
- function statementPath(text: string, ctx: string): string {
1160
+ function statementPath(text: string, ctx: string, range: MaskedRange): string {
969
1161
  const t = trimAsciiStart(text);
970
1162
  // R11 — a bare directive is at the root, so the context it hands its body is
971
1163
  // the root too, not `<ctx>/while`.
972
- if (isBareDirective(trimAscii(t))) return "/";
1164
+ if (isBareDirective(text, range)) return "/";
973
1165
  const lead = leadingRun(text);
974
1166
  if (lead.length === 0) return ctx;
975
1167
  // Same split `isBodyContextIndependent` reports to the cascade contract.
976
- const base = isBodyContextIndependent(t) ? "/" : ctx;
1168
+ const base = t.startsWith("/") ? "/" : ctx;
977
1169
  return joinPath(base, lead.slice(0, -1).join("/"));
978
1170
  }
979
1171
 
980
1172
  /** Leading bare/path tokens, stopping at the first argument or group. */
981
1173
  function leadingRun(text: string): string[] {
982
1174
  const out: string[] = [];
983
- for (const t of asciiWords(text)) {
1175
+ for (const t of asciiWordsIter(text)) {
984
1176
  if (t.includes("=") || /^[[({"$:]/.test(t)) break;
985
1177
  for (const part of t.split("/")) if (part.length > 0) out.push(part);
986
1178
  if (out.length === 0 && t === "/") out.push("");
@@ -1001,8 +1193,21 @@ function joinPath(base: string, rest: string): string {
1001
1193
  return `/${stack.join("/")}`;
1002
1194
  }
1003
1195
 
1196
+ /**
1197
+ * Q3's bracket scan over one range of a document.
1198
+ *
1199
+ * Reads `range.masked` and never `range.text`: comments hold no valid inner
1200
+ * command (`#` mid-`[…]` is not statement-leading), so an inner command is the
1201
+ * same text in either view, and the recursion already descended into masked
1202
+ * slices before this took a range. What changed is who masks — the caller hands
1203
+ * down the document's one mask instead of each level re-deriving its own over a
1204
+ * subtree an inner level has already read (#322).
1205
+ *
1206
+ * Offsets are absolute, while `loc` stays anchored at the range start, so every
1207
+ * `nest`/`spanIn` here passes a range-relative index.
1208
+ */
1004
1209
  function collectBrackets(
1005
- text: string,
1210
+ range: MaskedRange,
1006
1211
  ctx: string,
1007
1212
  depth: number,
1008
1213
  out: Resolution[],
@@ -1011,33 +1216,30 @@ function collectBrackets(
1011
1216
  loc: Loc,
1012
1217
  inLiteral = false,
1013
1218
  ): void {
1219
+ const { masked, start: lo, end: hi } = range;
1014
1220
  // Bound the recursion (bracket nesting AND literal-brace descent) so
1015
1221
  // untrusted deeply nested input abstains instead of overflowing the stack.
1016
1222
  if (depth >= MAX_DEPTH) {
1017
- // `text` is empty when the nesting bottoms out on an empty body (`[]`),
1018
- // which `regionIn` widens rather than emitting a zero-width region.
1019
- defects.push({ code: "over-depth", ...regionIn(loc, 0, text.length) });
1223
+ // The region is empty when the nesting bottoms out on an empty body
1224
+ // (`[]`), which `regionIn` widens rather than emitting a zero-width one.
1225
+ defects.push({ code: "over-depth", ...regionIn(loc, 0, hi - lo) });
1020
1226
  return;
1021
1227
  }
1022
- // Scan a comment-masked copy so a `#`-comment `[`/`{` is not treated as a
1023
- // substitution or scope. Comments never hold valid inner commands (`#` mid
1024
- // `[…]` is not statement-leading), so masked slices equal the originals.
1025
- const masked = maskComments(text);
1026
- for (let i = 0; i < masked.length; i++) {
1228
+ for (let i = lo; i < hi; i++) {
1027
1229
  const c = masked[i];
1028
1230
  if (c === '"') {
1029
1231
  // A string is opaque EXCEPT for `$[ … ]` interpolation, a real command
1030
1232
  // substitution: `:put "$[:pick $ip 0 $n]"` lowers with `/pick` inside
1031
1233
  // (topic-…/post-0003-snippet-01 @ 7.22.1).
1032
- const strEnd = stringEnd(masked, i);
1234
+ const strEnd = stringEnd(masked, i, hi);
1033
1235
  scanInterpolations(
1034
- masked.slice(i + 1, strEnd),
1236
+ rangeAt(range, i + 1, strEnd),
1035
1237
  ctx,
1036
1238
  depth,
1037
1239
  out,
1038
1240
  defects,
1039
1241
  contextCertain,
1040
- nest(loc, i + 1, loc.fallback),
1242
+ nest(loc, i + 1 - lo, loc.fallback),
1041
1243
  );
1042
1244
  i = strEnd;
1043
1245
  continue;
@@ -1046,69 +1248,98 @@ function collectBrackets(
1046
1248
  // A literal body is a value (Q2), but can still contain command
1047
1249
  // substitutions: `{[/terminal/inkey]}` lowers the bracket. Descend for
1048
1250
  // brackets only; scope bodies are walked by walk().
1049
- const end = matchDelim(masked, i, "{", "}");
1050
- const body = masked.slice(i + 1, end);
1051
- const isScope = !inLiteral && scopeNameFromMasked(masked, i) !== null;
1052
- if (!isScope && (body.includes("[") || body.includes("{")))
1251
+ const end = matchDelim(masked, i, "{", "}", hi);
1252
+ const isScope = !inLiteral && scopeNameFromMasked(masked, i, lo) !== null;
1253
+ if (!isScope && hasBracketOrBrace(masked, i + 1, end))
1053
1254
  collectBrackets(
1054
- body,
1255
+ rangeAt(range, i + 1, end),
1055
1256
  ctx,
1056
1257
  depth + 1,
1057
1258
  out,
1058
1259
  defects,
1059
1260
  contextCertain,
1060
- nest(loc, i + 1, loc.fallback),
1261
+ nest(loc, i + 1 - lo, loc.fallback),
1061
1262
  true,
1062
1263
  );
1063
1264
  i = end;
1064
1265
  continue;
1065
1266
  }
1066
1267
  if (c !== "[") continue;
1067
- const end = matchDelim(masked, i, "[", "]");
1068
- const inner = trimAscii(masked.slice(i + 1, end));
1268
+ const end = matchDelim(masked, i, "[", "]", hi);
1269
+ // `inner` is the trimmed region of `masked` starting at i+1; the leading
1270
+ // whitespace trim shifts it further right.
1271
+ const innerAt = trimmedStart(masked, i + 1, end);
1272
+ const innerEnd = trimmedEnd(masked, innerAt, end);
1273
+ const inner = masked.slice(innerAt, innerEnd);
1069
1274
  // The span covers the whole `[…]`, closing bracket included.
1070
- out.push({
1275
+ const resolution: Resolution = {
1071
1276
  ...resolveInner(inner, ctx, depth, contextCertain),
1072
- span: spanIn(loc, i, Math.min(end + 1, text.length)),
1073
- });
1277
+ span: spanIn(loc, i - lo, Math.min(end + 1, hi) - lo),
1278
+ innerSpan: spanIn(loc, innerAt - lo, innerEnd - lo),
1279
+ };
1280
+ out.push(resolution);
1281
+ resolutionRanges.set(resolution, rangeAt(range, innerAt, innerEnd));
1074
1282
  // R6 — nested brackets inherit from this one's resolution.
1075
- const nestedCtx = out[out.length - 1]?.path ?? ctx;
1283
+ const nestedCtx = resolution.path ?? ctx;
1076
1284
  collectBrackets(
1077
- inner,
1285
+ rangeAt(range, innerAt, innerEnd),
1078
1286
  nestedCtx,
1079
1287
  depth + 1,
1080
1288
  out,
1081
1289
  defects,
1082
- nestedCertainty(out[out.length - 1], contextCertain),
1083
- // `inner` is the trimmed slice of `masked` starting at i+1; the leading
1084
- // whitespace trim shifts it further right.
1085
- nest(
1086
- loc,
1087
- i +
1088
- 1 +
1089
- (masked.slice(i + 1, end).length -
1090
- trimAsciiStart(masked.slice(i + 1, end)).length),
1091
- loc.fallback,
1092
- ),
1290
+ nestedCertainty(resolution, contextCertain),
1291
+ nest(loc, innerAt - lo, loc.fallback),
1093
1292
  );
1094
1293
  i = end;
1095
1294
  }
1096
1295
  }
1097
1296
 
1297
+ /** First non-ASCII-whitespace index in `[start, end)`, or `end`. */
1298
+ function trimmedStart(text: string, start: number, end: number): number {
1299
+ let i = start;
1300
+ while (i < end && isAsciiWhitespace(text[i])) i++;
1301
+ return i;
1302
+ }
1303
+
1304
+ /** One past the last non-ASCII-whitespace index in `[start, end)`. */
1305
+ function trimmedEnd(text: string, start: number, end: number): number {
1306
+ let i = end;
1307
+ while (i > start && isAsciiWhitespace(text[i - 1])) i--;
1308
+ return i;
1309
+ }
1310
+
1311
+ /** Whether `[start, end)` holds a `[` or a `{` — the literal-descent test. */
1312
+ function hasBracketOrBrace(text: string, start: number, end: number): boolean {
1313
+ for (let i = start; i < end; i++) {
1314
+ const c = text[i];
1315
+ if (c === "[" || c === "{") return true;
1316
+ }
1317
+ return false;
1318
+ }
1319
+
1098
1320
  /**
1099
1321
  * Index of the closing quote of the string starting at `open`. Delegates to the
1100
1322
  * shared `scanQuotedString`, so a `$[…]` substitution carrying its own string
1101
1323
  * does not end the outer one early and cut `scanInterpolations`' body short
1102
1324
  * (#199).
1103
1325
  */
1104
- function stringEnd(text: string, open: number): number {
1105
- const str = scanQuotedString(text, open);
1106
- return str.closed ? str.end - 1 : text.length;
1326
+ function stringEnd(
1327
+ text: string,
1328
+ open: number,
1329
+ limit: number = text.length,
1330
+ ): number {
1331
+ const str = scanQuotedString(text, open, limit);
1332
+ return str.closed ? str.end - 1 : limit;
1107
1333
  }
1108
1334
 
1109
- /** `$[ … ]` command substitutions inside a string body. */
1335
+ /**
1336
+ * `$[ … ]` command substitutions inside the string body `[lo, hi)`.
1337
+ *
1338
+ * As {@link collectBrackets}: offsets are absolute into the document mask and
1339
+ * `loc` is anchored at `lo`.
1340
+ */
1110
1341
  function scanInterpolations(
1111
- body: string,
1342
+ range: MaskedRange,
1112
1343
  ctx: string,
1113
1344
  depth: number,
1114
1345
  out: Resolution[],
@@ -1116,28 +1347,41 @@ function scanInterpolations(
1116
1347
  contextCertain: boolean,
1117
1348
  loc: Loc,
1118
1349
  ): void {
1119
- for (let i = 0; i < body.length - 1; i++) {
1120
- if (body[i] !== "$" || body[i + 1] !== "[") continue;
1121
- const end = matchDelim(body, i + 1, "[", "]");
1122
- const raw = body.slice(i + 2, end);
1123
- const inner = trimAscii(raw);
1124
- // Span the `$[…]` from its sigil through the closing bracket.
1125
- out.push({
1126
- ...resolveInner(inner, ctx, depth, contextCertain),
1127
- span: spanIn(loc, i, Math.min(end + 1, body.length)),
1128
- });
1129
- collectBrackets(
1350
+ const { text, start: lo, end: hi } = range;
1351
+ for (let i = lo; i < hi - 1; i++) {
1352
+ if (text[i] !== "$" || text[i + 1] !== "[") continue;
1353
+ const scanned = scanStringInterpolation(text, i + 1, hi);
1354
+ const end = scanned.end;
1355
+ const innerAt = trimmedStart(text, i + 2, end);
1356
+ const innerEnd = trimmedEnd(text, innerAt, end);
1357
+ const inner = text.slice(innerAt, innerEnd);
1358
+ const innerRange = rangeAt(
1359
+ scanned.range,
1360
+ innerAt - (i + 1),
1361
+ innerEnd - (i + 1),
1362
+ );
1363
+ const maskedInner = innerRange.masked.slice(
1364
+ innerRange.start,
1365
+ innerRange.end,
1366
+ );
1367
+ // Span the `$[…]` from its sigil through the closing bracket; `innerSpan`
1368
+ // is the two-byte-in start that difference makes non-derivable.
1369
+ const resolution: Resolution = {
1370
+ ...resolveInner(maskedInner, ctx, depth, contextCertain),
1130
1371
  inner,
1131
- out[out.length - 1]?.path ?? ctx,
1372
+ span: spanIn(loc, i - lo, Math.min(end + 1, hi) - lo),
1373
+ innerSpan: spanIn(loc, innerAt - lo, innerEnd - lo),
1374
+ };
1375
+ out.push(resolution);
1376
+ resolutionRanges.set(resolution, innerRange);
1377
+ collectBrackets(
1378
+ innerRange,
1379
+ resolution.path ?? ctx,
1132
1380
  depth + 1,
1133
1381
  out,
1134
1382
  defects,
1135
- nestedCertainty(out[out.length - 1], contextCertain),
1136
- nest(
1137
- loc,
1138
- i + 2 + (raw.length - trimAsciiStart(raw).length),
1139
- loc.fallback,
1140
- ),
1383
+ nestedCertainty(resolution, contextCertain),
1384
+ nest(loc, innerAt - lo, loc.fallback),
1141
1385
  );
1142
1386
  i = end;
1143
1387
  }
@@ -1191,7 +1435,7 @@ function resolveInner(
1191
1435
  ctx: string,
1192
1436
  depth: number,
1193
1437
  contextCertain: boolean,
1194
- ): Omit<Resolution, "span"> {
1438
+ ): Omit<Resolution, "span" | "innerSpan"> {
1195
1439
  const absolute = inner.startsWith("/");
1196
1440
  const klass = classify(inner, absolute, depth);
1197
1441
  if (klass === "cli-prompt-artifact")
@@ -1294,7 +1538,7 @@ function classify(inner: string, absolute: boolean, depth: number): string {
1294
1538
  if (CLI_PROMPT_RE.test(trimAscii(inner))) return "cli-prompt-artifact";
1295
1539
  if (depth > 0) return "nested-bracket";
1296
1540
  if (absolute) return "absolute-inner-path";
1297
- const head = (asciiWords(inner)[0] ?? "").toLowerCase();
1541
+ const head = (asciiWordsIter(inner).next().value ?? "").toLowerCase();
1298
1542
  if (head.startsWith("$")) return "dynamic-invocation";
1299
1543
  if (head.startsWith(":")) return "scripting-directive";
1300
1544
  if (head === "find") return "bare-find";
@@ -1306,12 +1550,13 @@ function matchDelim(
1306
1550
  start: number,
1307
1551
  open: string,
1308
1552
  close: string,
1553
+ limit: number = text.length,
1309
1554
  ): number {
1310
1555
  let depth = 0;
1311
- for (let i = start; i < text.length; i++) {
1556
+ for (let i = start; i < limit; i++) {
1312
1557
  const c = text[i];
1313
1558
  if (c === '"') {
1314
- i = scanQuotedString(text, i).end - 1;
1559
+ i = scanQuotedString(text, i, limit).end - 1;
1315
1560
  continue;
1316
1561
  }
1317
1562
  if (c === open) depth++;
@@ -1320,5 +1565,5 @@ function matchDelim(
1320
1565
  if (depth === 0) return i;
1321
1566
  }
1322
1567
  }
1323
- return text.length;
1568
+ return limit;
1324
1569
  }