@tikoci/centrs 0.1.3 → 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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,1569 @@
1
+ /**
2
+ * Offline path resolution for `explain` (centrs canonicalizer).
3
+ *
4
+ * Ratified by the phase-0 lab, questions Q3 and Q4 (#185), promoted from the
5
+ * throwaway probe `.scratch/explain-lab-pathresolve.ts`. Two surfaces:
6
+ *
7
+ * resolveStatements (Q4) — the fully-qualified canonical path of every
8
+ * statement in source order, tracking the persistent menu
9
+ * context across statements.
10
+ * resolveDocument (Q3) — the re-constituted path of every `[…]` command
11
+ * substitution, the spec's flagship example
12
+ * (`remove [find …]` inherits the statement's own menu).
13
+ *
14
+ * The lab priced three context models; only the ratified `stateful` rule set is
15
+ * promoted (`stateless`/`stateful-append` were A/B arms to justify it, left in
16
+ * the lab). The rules:
17
+ * R1 An inner command starting with `/` is ABSOLUTE; context is ignored.
18
+ * R2 Otherwise it inherits the nearest enclosing MENU CONTEXT.
19
+ * R3 The nearest enclosing context of a `[…]` inside a statement is that
20
+ * STATEMENT's own path, not the document's.
21
+ * R4 Document context is set by a menu-navigation statement and by a
22
+ * menu-block prefix, and persists to following statements. An absolute
23
+ * NAVIGATION replaces context; an absolute COMMAND does not move it (the
24
+ * CHR-confirmed cascade — see the Q4 corners).
25
+ * R5 A block body inherits the context in force where the block appears.
26
+ * R6 Nested `[…]` inherit from the bracket that encloses them.
27
+ * R7 A variable (`$x`) path segment makes the path UNRESOLVED — offline says
28
+ * so rather than guessing.
29
+ * R8 `..` ascends and a bare `/` resets; a bare word alone is navigation only
30
+ * when a shipped structure table confirms the context-applied path (R13).
31
+ * R9 A `/`-led bare path whose run carries a FROZEN-VOCABULARY VERB is a
32
+ * command, not navigation (#211). Q6 already decides these, so without it
33
+ * this module and `verbsplit` returned contradictory answers for the same
34
+ * text — and this one INVENTED a menu (`/ip address print` navigating to
35
+ * `/ip/address/print`). See `menuNavPath`.
36
+ * R10 A `:` scripting directive takes its operand POSITIONALLY (`:global x`
37
+ * is `/global`, not `/global/x`); a menu command spells its menu out.
38
+ * R11 A bare directive (`while (…) do={…}`) is at the root, and hands its
39
+ * body the root context too. The schema-free tell is the scope block: no
40
+ * menu command takes `do=`.
41
+ * R12 A `/`-led bare path the published CATALOG names as a command is not
42
+ * navigation either (#228). The verb-free half R9 left open (#211 B2):
43
+ * `/system/reboot` is a command, so it must not move the context and the
44
+ * statement after it must not resolve against `/system/reboot`.
45
+ * R13 A relative bare path that a shipped table names as a menu advances the
46
+ * context (#235). Its run is read exactly as R9 reads an absolute one —
47
+ * whitespace and `/` are interchangeable on the device, so `firewall
48
+ * filter` and `firewall/filter` under `/ip` are one form. If neither table
49
+ * knows the joined path, offline abstains and the following relative
50
+ * statements lose context certainty.
51
+ *
52
+ * Verb-vs-menu identification is deliberately NOT decided here — offline cannot
53
+ * tell `find` (verb) from a deeper menu without a schema (that is Q6). A
54
+ * resolution therefore reports a best-guess `path` AND the full `candidates`
55
+ * set (context, then context extended by each leading token), so a consumer can
56
+ * present alternatives rather than assert a coin flip.
57
+ *
58
+ * ## Context certainty (Q14 C3b — the cascade, #192)
59
+ *
60
+ * The Q14 floor shipped in #191 degrades a MALFORMED statement to `unresolved`
61
+ * and does not descend into it. That is statement-local, and the lab measured a
62
+ * second failure on top of it: the defect also destroys the DOCUMENT CONTEXT,
63
+ * and every later RELATIVE statement was still resolved confidently against the
64
+ * stale value. `/ip) address` + `add address=…` reported a confident `/add` —
65
+ * a path no device would accept, invented from a context the resolver had
66
+ * already admitted it could not read.
67
+ *
68
+ * So each statement now also reports `contextCertain`: whether the menu context
69
+ * in force BEFORE it is known. A statement that CONSUMES an unknown context
70
+ * degrades to `unresolved` instead of resolving; one that does not consume it
71
+ * is unaffected. Which is which is exactly the `base` split already used for
72
+ * path resolution:
73
+ * - context-INDEPENDENT — an absolute (`/`-led) statement, a `:` directive,
74
+ * and a bare directive (R11) all resolve against the root. They keep
75
+ * resolving confidently while context is lost.
76
+ * - context-DEPENDENT — a relative statement (`add address=…`) resolves
77
+ * against the context, so it degrades.
78
+ *
79
+ * Three things make the context unknown, all cases where the statement might
80
+ * have been navigation and offline cannot read where to:
81
+ * - a structural defect (the text is unreadable, so it may have been a nav),
82
+ * - a `/`-led statement whose leading run is unreadable (`/ip/$menu`, and
83
+ * equally the mixed spelling `/ip route/$menu`), which is a navigation to a
84
+ * computed menu as easily as it is a command,
85
+ * - a relative bare path that neither shipped structure table knows (#235).
86
+ * A dynamic-headed statement (`$x`, `[…]`, `(…)`) is context-NEUTRAL — it
87
+ * evaluates a value and does not navigate — so it must NOT poison; that
88
+ * distinction is the whole reason this lives here rather than in a caller,
89
+ * which sees only flattened statements. An ABSOLUTE navigation re-establishes
90
+ * certainty, because R4 has it REPLACE the context rather than extend it.
91
+ */
92
+
93
+ import { type ScopeBlock, scopeBlocksIn } from "./blocks.ts";
94
+ import { commandVerbIndex } from "./catalog.ts";
95
+ import {
96
+ type Defect,
97
+ isPositionalFact,
98
+ mergeDefects,
99
+ rebaseDefects,
100
+ } from "./defects.ts";
101
+ import { isKnownMenuPath } from "./is-known-menu.ts";
102
+ import {
103
+ braceStartsStatements,
104
+ canRetainStatementIndex,
105
+ hashStartsHardError,
106
+ scopeNameFromMasked,
107
+ } from "./scope-brace.ts";
108
+ import {
109
+ documentRange,
110
+ type MaskedRange,
111
+ nestedRange,
112
+ rangeAt,
113
+ type SegmentResult,
114
+ scanQuotedString,
115
+ scanStringInterpolation,
116
+ segmentScopeBody,
117
+ segmentStatements,
118
+ } from "./segment.ts";
119
+ import { VERBS } from "./verbs.ts";
120
+
121
+ const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
122
+ /**
123
+ * A whole path segment that is a variable reference (`$menu` in `/ip/$menu`).
124
+ *
125
+ * Deliberately "begins with `$`" and NOT an enumeration of name spellings. This
126
+ * makes NO claim about which spellings RouterOS accepts — the point is the
127
+ * opposite: every `$`-headed segment is unreadable HERE, whether it is a valid
128
+ * reference (`$menu`, `$"quoted name"`) or something the device would itself
129
+ * reject (`${braced}` is rejected at the `{` on 7.24rc1; a bare `$`). Offline
130
+ * cannot resolve either kind, so both must abstain.
131
+ *
132
+ * A pattern like `\$[A-Za-z_]…` instead recognizes only the shapes it lists and
133
+ * lets every other one through as an ordinary readable menu segment, which
134
+ * fails OPEN. That is the same trap as the unanchored-regex and reason-prefix
135
+ * bugs earlier in this module: a guard that decides whether to ABSTAIN must
136
+ * match the general shape and let the unknown case block, never enumerate the
137
+ * known ones. `write.ts` reaches the same conclusion at head position, where
138
+ * `isDynamicForm` tests a bare `text.startsWith("$")`.
139
+ */
140
+ function isVariableSegment(part: string): boolean {
141
+ return part.startsWith("$");
142
+ }
143
+ const ASCII_WHITESPACE = /[ \t\r\n]+/;
144
+
145
+ function isAsciiWhitespace(char: string | undefined): boolean {
146
+ return char === " " || char === "\t" || char === "\r" || char === "\n";
147
+ }
148
+
149
+ function trimAscii(text: string): string {
150
+ let start = 0;
151
+ let end = text.length;
152
+ while (start < end && isAsciiWhitespace(text[start])) start++;
153
+ while (end > start && isAsciiWhitespace(text[end - 1])) end--;
154
+ return text.slice(start, end);
155
+ }
156
+
157
+ function trimAsciiStart(text: string): string {
158
+ let start = 0;
159
+ while (start < text.length && isAsciiWhitespace(text[start])) start++;
160
+ return text.slice(start);
161
+ }
162
+
163
+ function asciiWords(text: string): string[] {
164
+ const trimmed = trimAscii(text);
165
+ return trimmed.length === 0 ? [] : trimmed.split(ASCII_WHITESPACE);
166
+ }
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
+
191
+ function isAbsolutePathToken(token: string): boolean {
192
+ if (!token.startsWith("/")) return false;
193
+ const parts = token.split("/").filter(Boolean);
194
+ return parts.length > 0 && parts.every((part) => BARE_WORD.test(part));
195
+ }
196
+
197
+ /**
198
+ * Defensive recursion bound. `explain` accepts untrusted editor/MCP input, so
199
+ * deeply nested substitutions/blocks must make the analysis ABSTAIN (an
200
+ * `over-depth` note) rather than consume the JS stack. Matches the segmenter's
201
+ * `MAX_CONTAINER_DEPTH`; far beyond any real script. Unlike the segmenter's H7
202
+ * flattening — now a single left-to-right pass (#190) — this resolver's descent
203
+ * is a genuine tree walk (block topology + bracket sub-command path
204
+ * re-constitution), so it stays recursive under this bound.
205
+ */
206
+ const MAX_DEPTH = 256;
207
+
208
+ /**
209
+ * True when a statement's own delimiters are not well-formed — unbalanced
210
+ * `()`/`[]`/`{}` or an unterminated string. Q14 fail-closed floor: a malformed
211
+ * statement must never yield a confident command, so the resolver degrades it
212
+ * to `unresolved` and does not descend into it. Statement-local; the
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).
217
+ */
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[] = [];
232
+ const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
233
+ const stack: { char: string; statements: boolean }[] = [];
234
+ for (let i = 0; i < masked.length; i++) {
235
+ const c = masked[i];
236
+ if (c === '"') {
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);
295
+ if (!str.closed)
296
+ return "structural defect: unbalanced delimiter or string";
297
+ i = str.end - 1;
298
+ continue;
299
+ }
300
+ if (
301
+ c === "#" &&
302
+ hashStartsHardError(masked, i, stack.at(-1)?.statements === false, lo)
303
+ )
304
+ return "structural defect: invalid unquoted hash";
305
+ if (c === "(" || c === "[" || c === "{")
306
+ stack.push({
307
+ char: c,
308
+ statements:
309
+ c === "[" ||
310
+ (c === "{" &&
311
+ (stack.at(-1)?.statements ?? true) &&
312
+ braceStartsStatements(masked, i, lo)),
313
+ });
314
+ else if (c === ")" || c === "]" || c === "}") {
315
+ if (stack.pop()?.char !== openOf[c])
316
+ return "structural defect: unbalanced delimiter or string";
317
+ }
318
+ }
319
+ return stack.length > 0
320
+ ? "structural defect: unbalanced delimiter or string"
321
+ : null;
322
+ }
323
+
324
+ /**
325
+ * The `unresolved` reason of a statement (or bracket) that needed the document
326
+ * context and could not have it. Distinct from every other reason: the
327
+ * statement itself is perfectly readable — what failed is upstream.
328
+ */
329
+ const CONTEXT_LOST = "context lost to an earlier unreadable statement";
330
+
331
+ /** R11 — a directive written without its colon, told by its scope block. */
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);
336
+ return (
337
+ !trimmed.startsWith(":") &&
338
+ !trimmed.startsWith("/") &&
339
+ scopeBlocksIn(nestedRange(range, start, end)).length > 0
340
+ );
341
+ }
342
+
343
+ /**
344
+ * Does this statement resolve its OWN path without consulting the document
345
+ * context? Mirrors `canonicalPath`'s `base` exactly — absolute, `:` directive,
346
+ * or bare directive (R11) all resolve at the root.
347
+ */
348
+ function isContextIndependent(text: string, range: MaskedRange): boolean {
349
+ const t = trimAscii(text);
350
+ return t.startsWith(":") || t.startsWith("/") || isBareDirective(text, range);
351
+ }
352
+
353
+ /**
354
+ * Does the context this statement hands its BLOCK BODIES (and its own brackets,
355
+ * R3) ignore the document context? Mirrors `statementPath`'s base, which is NOT
356
+ * the same predicate as `isContextIndependent`: a `:` directive resolves itself
357
+ * at the root but hands its body the context in force (R5), so `:foreach … do={
358
+ * add … }` still depends on an upstream context its own path does not.
359
+ */
360
+ function isBodyContextIndependent(text: string, range: MaskedRange): boolean {
361
+ const t = trimAscii(text);
362
+ return t.startsWith("/") || isBareDirective(text, range);
363
+ }
364
+
365
+ /**
366
+ * Does this text NAME a menu path that offline could not read?
367
+ *
368
+ * True when the leading run starts out path-shaped and is then TRUNCATED by a
369
+ * variable segment: `/ip/$menu/remove`, `/ip route/$menu/remove`, and the
370
+ * relative `route/$verb`. R7 says such a path is unresolved, so nothing may
371
+ * inherit a base derived from it.
372
+ *
373
+ * Two traps this exists to avoid, both found in review:
374
+ * - `statementRun` DISCARDS the whole word carrying the variable, so after a
375
+ * static word (`/ip route/$menu/…`) its output is a clean, variable-free
376
+ * prefix and the statement reads as perfectly resolvable. The signal has to
377
+ * be taken from the raw words, not from the collected run.
378
+ * - Spelling is not the discriminator. Mixed space/slash menu spelling is a
379
+ * supported contract, and a RELATIVE head is just as much a menu name, so
380
+ * this must not test for a leading `/`.
381
+ *
382
+ * What it deliberately does NOT match is text that names no menu at all — a
383
+ * head that is a variable, group, string or directive (`$fn [get …]`,
384
+ * `-z "$URL"`, `iter(hip)`), and a `$` word in ARGUMENT position
385
+ * (`get $item time`), which is a positional operand rather than a path
386
+ * segment. Those establish no menu, so the ambient context still governs.
387
+ */
388
+ function isUnreadablePath(text: string): boolean {
389
+ const t = trimAscii(text);
390
+ if (t.length === 0) return false;
391
+ // A head that is not path-shaped names no menu.
392
+ if (/^[$[("':]/.test(t)) return false;
393
+ for (const word of asciiWordsIter(t)) {
394
+ // Arguments begin: everything before this was readable. A `$` word
395
+ // STANDING ALONE is a positional operand (`get $item time`), not a path
396
+ // segment — only a `$` slash-joined inside a word is a menu segment.
397
+ if (word.includes("=") || /^[[({"$:]/.test(word)) return false;
398
+ const parts = word.split("/").filter((p) => p.length > 0);
399
+ if (parts.length === 0) continue;
400
+ for (const part of parts) {
401
+ if (BARE_WORD.test(part)) continue;
402
+ // A whole segment that is a variable is the unreadable-menu case.
403
+ if (isVariableSegment(part)) return true;
404
+ // Anything else is not a path segment at all, so the run ended here and
405
+ // everything before it was readable. This is why the test cannot simply
406
+ // look for a `$` anywhere: `print where comment~[$strfind …]` and
407
+ // `/ppp secret print where comment~"\\$SECRET"` carry a `$` inside an
408
+ // ARGUMENT — the `~` means no `=` ends the run first — and both name a
409
+ // perfectly readable menu.
410
+ return false;
411
+ }
412
+ }
413
+ return false;
414
+ }
415
+
416
+ /**
417
+ * A `/`-led statement whose leading run offline cannot read — a variable
418
+ * segment truncates it (`/ip/$menu`, `/interface/$type/print`). It is a
419
+ * navigation to a computed menu exactly as plausibly as it is a command, so the
420
+ * context after it is unknown. `menuNavPath` has already claimed every readable
421
+ * absolute navigation before this is consulted.
422
+ */
423
+ function isUnreadableAbsolute(text: string): boolean {
424
+ const t = trimAscii(text);
425
+ if (!t.startsWith("/")) return false;
426
+ if (isUnreadablePath(t)) return true;
427
+ const tokens = statementRun(t);
428
+ return tokens.length === 0 || tokens.some((x) => x.startsWith("$"));
429
+ }
430
+
431
+ /**
432
+ * Is the context this statement hands its own brackets (R3) and its block
433
+ * bodies (R5) KNOWN?
434
+ *
435
+ * Two conditions, and BOTH are needed. The base must be knowable — the
436
+ * statement is `/`-led or a bare directive, or the document context is certain.
437
+ * And the statement's own leading run must be READABLE: `/ip/$menu/remove` is
438
+ * `/`-led, so its base does not depend on the document context, but `R7` says a
439
+ * variable path segment leaves the path unresolved, and `statementPath` derives
440
+ * a menu from it anyway (`/ip/$menu`). Anchoring brackets to that hands them a
441
+ * fabricated base — `[find]` at a "path" containing a literal `$menu` — while
442
+ * `resolveStatements` refuses the very same statement. So an unreadable menu
443
+ * anchors nothing, in EITHER spelling: `statementPath` derives a base from a
444
+ * relative one too (`route/$verb remove [find]` yields `<ctx>/route/$verb`).
445
+ */
446
+ function handsKnownContext(
447
+ text: string,
448
+ contextCertain: boolean,
449
+ range: MaskedRange,
450
+ ): boolean {
451
+ // Either spelling of an unreadable menu anchors nothing. `statementPath`
452
+ // derives a base from a RELATIVE one too (`route/$verb remove [find]` yields
453
+ // `<ctx>/route/$verb`), so this cannot test only the absolute form.
454
+ if (isUnreadableAbsolute(text) || isUnreadablePath(text)) return false;
455
+ return contextCertain || isBodyContextIndependent(text, range);
456
+ }
457
+
458
+ /** A re-constituted `[…]` command substitution (Q3). */
459
+ export interface Resolution {
460
+ /** Raw text inside the brackets. */
461
+ inner: string;
462
+ /** Context in force at the bracket, `/` when at document root. */
463
+ context: string;
464
+ /** Leading `/`-path token run of the inner command, context applied. */
465
+ tokens: string[];
466
+ /**
467
+ * Every path the inner command could resolve to: the context alone, then the
468
+ * context extended by each leading token in turn. Offline cannot pick one
469
+ * without knowing which token is the verb (Q6); `path` is the best guess.
470
+ */
471
+ candidates: string[];
472
+ /** Best single guess: the first bare token is the verb (Q1's rule). */
473
+ path: string | null;
474
+ unresolved?: string;
475
+ /**
476
+ * Was the menu context this bracket inherited (R3) known? False once an
477
+ * earlier unreadable statement destroyed it — a RELATIVE inner command is
478
+ * then `unresolved` rather than resolved against a stale value.
479
+ */
480
+ contextCertain: boolean;
481
+ /** Nesting depth of this bracket (0 = directly in a statement). */
482
+ depth: number;
483
+ /** Feature class, for per-class reporting. */
484
+ klass: string;
485
+ /**
486
+ * The `[…]` in DOCUMENT analyzed-byte space, closing bracket included. Widens
487
+ * to the enclosing statement where interior offsets cannot be mapped — see
488
+ * {@link Loc}.
489
+ */
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);
515
+ }
516
+
517
+ /** The canonical path of one statement in source order (Q4). */
518
+ export interface StatementResolution {
519
+ text: string;
520
+ /** Offline read this statement as menu navigation, not a command. */
521
+ isNav: boolean;
522
+ /** Context in force BEFORE this statement. */
523
+ context: string;
524
+ /**
525
+ * Fully-qualified canonical path, menu AND verb (`/ip/route/add`). Where the
526
+ * path stops and POSITIONAL arguments begin is still a Q6 boundary: `path` is
527
+ * the greedy full-run reading and `candidates` carries the shorter readings.
528
+ */
529
+ path: string | null;
530
+ /** Context extended by each prefix of the leading run, shortest first. */
531
+ candidates?: string[];
532
+ unresolved?: string;
533
+ /**
534
+ * Was the menu context in force BEFORE this statement known? False once an
535
+ * earlier unreadable statement destroyed it (Q14 C3b). A context-DEPENDENT
536
+ * statement is then `unresolved`; a context-INDEPENDENT one still resolves,
537
+ * so a `false` here does not by itself invalidate `path`.
538
+ */
539
+ contextCertain: boolean;
540
+ /** This statement in DOCUMENT analyzed-byte space. */
541
+ span: Span;
542
+ }
543
+
544
+ /** Q3 result: bracket resolutions plus any structural / over-depth defects. */
545
+ export interface DocumentAnalysis {
546
+ resolutions: Resolution[];
547
+ /**
548
+ * Located defects surfaced from the segmenter (`unclosed`, `unbalanced-close`,
549
+ * `unterminated-string`) plus `over-depth` when a bounded traversal abstained,
550
+ * and defects raised inside a block body. A caller must not treat resolutions
551
+ * as confident while a NON-POSITIONAL defect is present — `isPositionalFact`
552
+ * in `defects.ts` is that test.
553
+ */
554
+ defects: Defect[];
555
+ }
556
+
557
+ /** Q4 result: per-statement resolutions plus structural / over-depth defects. */
558
+ export interface StatementAnalysis {
559
+ statements: StatementResolution[];
560
+ /** As {@link DocumentAnalysis.defects}. */
561
+ defects: Defect[];
562
+ }
563
+
564
+ /** A half-open analyzed-byte span. */
565
+ export interface Span {
566
+ start: number;
567
+ end: number;
568
+ }
569
+
570
+ /**
571
+ * Where the text a walker is scanning sits in the DOCUMENT.
572
+ *
573
+ * `base` is the document analyzed-byte offset that index 0 of that text maps to,
574
+ * or -1 when the mapping is unavailable. It goes to -1 for exactly one reason:
575
+ * a statement's `text` is the ORIGINAL substring (UTF-16 code units), so an
576
+ * index into it is an analyzed-byte offset only while the statement is pure
577
+ * ASCII. Once -1 it stays -1 for everything nested inside.
578
+ *
579
+ * The check for that is a length comparison, and it is exact rather than a
580
+ * heuristic: every non-ASCII code point has a UTF-8 length strictly greater than
581
+ * its UTF-16 length (2>1, 3>1, 4>2), so the two lengths agree if and only if
582
+ * every character is ASCII.
583
+ *
584
+ * `fallback` is the enclosing statement's span, used when `base` is -1. Widening
585
+ * a defect to its statement is the fail-closed choice in the Q14 sense: a region
586
+ * that is too WIDE still points the reader at real text, while one computed from
587
+ * a mismatched index space points confidently at the wrong byte.
588
+ */
589
+ interface Loc {
590
+ base: number;
591
+ fallback: Span;
592
+ }
593
+
594
+ /** A `[start, end)` of the scanned text, in document space. */
595
+ function spanIn(loc: Loc, start: number, end: number): Span {
596
+ return loc.base < 0
597
+ ? { start: loc.fallback.start, end: loc.fallback.end }
598
+ : { start: loc.base + start, end: loc.base + end };
599
+ }
600
+
601
+ /**
602
+ * A defect region for `[start, end)` of the scanned text, never zero-width.
603
+ *
604
+ * An empty extent is reachable — `do={}` at the block-depth limit, `[]` at the
605
+ * bracket limit — and `Defect` contracts `end > start`, so an empty one falls
606
+ * back to the enclosing statement rather than emitting `start === end`.
607
+ */
608
+ function regionIn(loc: Loc, start: number, end: number): Span {
609
+ return end > start
610
+ ? spanIn(loc, start, end)
611
+ : { start: loc.fallback.start, end: loc.fallback.end };
612
+ }
613
+
614
+ /** Move `loc` to a nested text beginning at index `start` of the current one. */
615
+ function nest(loc: Loc, start: number, fallback: Span): Loc {
616
+ return { base: loc.base < 0 ? -1 : loc.base + start, fallback };
617
+ }
618
+
619
+ /** Carry `defects` (offsets into the scanned text) into document space. */
620
+ function attribute(defects: readonly Defect[], loc: Loc): Defect[] {
621
+ if (loc.base >= 0) return rebaseDefects(defects, loc.base);
622
+ return defects.map((d) =>
623
+ d.detail === undefined
624
+ ? { code: d.code, start: loc.fallback.start, end: loc.fallback.end }
625
+ : {
626
+ code: d.code,
627
+ start: loc.fallback.start,
628
+ end: loc.fallback.end,
629
+ detail: d.detail,
630
+ },
631
+ );
632
+ }
633
+
634
+ /** One statement to walk, already located in document space. */
635
+ interface Located {
636
+ text: string;
637
+ span: Span;
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;
645
+ }
646
+
647
+ /**
648
+ * Segment `text` and locate every statement in document space, carrying the
649
+ * segmenter's own defects out with them.
650
+ *
651
+ * The offsets and defects a plain `segments.map((s) => s.text)` would discard
652
+ * are what every span and region downstream is built from.
653
+ */
654
+ function locate(
655
+ segmented: SegmentResult,
656
+ loc: Loc,
657
+ parent: MaskedRange | null,
658
+ ): { units: Located[]; defects: Defect[] } {
659
+ const units = segmented.segments.map((s) => {
660
+ const span = spanIn(loc, s.start, s.end);
661
+ // `s.start`/`s.end` are analyzed bytes of THIS text; `s.text` is the
662
+ // original substring. Interior indices into `s.text` are analyzed-byte
663
+ // offsets only when the two lengths agree (see `Loc`).
664
+ const asciiOnly = s.end - s.start === s.text.length;
665
+ return {
666
+ text: s.text,
667
+ span,
668
+ loc:
669
+ loc.base < 0 || !asciiOnly
670
+ ? { base: -1, fallback: span }
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),
681
+ };
682
+ });
683
+ // STRUCTURAL defects only. `segmentStatements` merges `coordinateDefects`
684
+ // into every result, so a nested call on a block body would re-run the
685
+ // coordinate pass over that SUBSTRING — and a coordinate fact is a property
686
+ // of the ROOT input, not of any slice of it. Two things went wrong when this
687
+ // filter was missing: a non-ASCII run already reported by the document pass
688
+ // came back a second time widened to its whole statement, and a U+FEFF in
689
+ // document-MIDDLE position was re-classified `bom` because it happens to be
690
+ // leading within the body — contradicting `coordinateDefects`' own contract.
691
+ // The entry points seed these once from the root `SegmentResult`.
692
+ const structural = segmented.defects.filter((d) => !isPositionalFact(d.code));
693
+ return { units, defects: attribute(structural, loc) };
694
+ }
695
+
696
+ /**
697
+ * The whole-document starting location. `base` is 0 and always valid — the
698
+ * segmenter's offsets are already document analyzed bytes — so the fallback is
699
+ * unreachable here and every statement gets its own from {@link locate}.
700
+ */
701
+ const DOCUMENT_LOC: Loc = { base: 0, fallback: { start: 0, end: 0 } };
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
+
727
+ /** Q3 — resolve every `[…]` command substitution in `text`. */
728
+ export function resolveDocument(text: string): DocumentAnalysis {
729
+ // Segment ONCE and hand the result to `locate` — `containsWrite` calls both
730
+ // entry points, and re-segmenting here would put a document through the
731
+ // segmenter four times on input its own comment calls "not cheap".
732
+ const segmented = segmentStatements(text);
733
+ // The ROOT segmentation is the only source of coordinate facts (`bom`,
734
+ // `non-ascii`); `locate` strips them so recursion cannot re-derive them in a
735
+ // substring's coordinate space.
736
+ const defects: Defect[] = [...segmented.defects];
737
+ const resolutions: Resolution[] = [];
738
+ const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
739
+ defects.push(...top.defects);
740
+ walk(top.units, "/", resolutions, 0, defects, true);
741
+ return { resolutions, defects: mergeDefects(defects) };
742
+ }
743
+
744
+ function walk(
745
+ units: Located[],
746
+ context: string,
747
+ out: Resolution[],
748
+ blockDepth: number,
749
+ defects: Defect[],
750
+ contextCertain: boolean,
751
+ ): void {
752
+ let ctx = context;
753
+ let certain = contextCertain;
754
+ for (const unit of units) {
755
+ const { text, loc, range } = unit;
756
+ // R4 — a menu-navigation statement moves the document context.
757
+ const nav = menuNavPath(text, ctx, range);
758
+ if (nav !== null) {
759
+ const relative = !trimAscii(text).startsWith("/");
760
+ // A `..` read against an unknown context stays unknown; an absolute
761
+ // navigation replaces it and re-establishes certainty.
762
+ if (relative && !certain) continue;
763
+ ctx = nav;
764
+ if (!relative) certain = true;
765
+ continue;
766
+ }
767
+ // Q14 fail-closed — a structurally malformed statement yields no confident
768
+ // bracket path and is not descended. It may have been a navigation, so the
769
+ // context after it is unknown (Q14 C3b, the same contract the statement
770
+ // walk applies — these two walks must move in lockstep).
771
+ if (structuralDefectIn(range) !== null) {
772
+ certain = false;
773
+ continue;
774
+ }
775
+ // #235 — a relative bare-word that abstained (neither table) poisons
776
+ // downstream context, so the next relative statement cannot fabricate.
777
+ const poisonAbstention = isAbstainedRelativeNav(text, ctx, range);
778
+ // R3 — the statement's own path is the context its brackets see.
779
+ const stmtCtx = statementPath(text, ctx, range);
780
+ // Derived BEFORE the poison, from the certainty in force at this statement:
781
+ // `handsKnownContext` answers about the context this statement HANDS DOWN,
782
+ // which its own unreadability already governs. An abstained nav poisons
783
+ // what follows, so its own body must already be uncertain.
784
+ const stmtCertain = poisonAbstention
785
+ ? false
786
+ : handsKnownContext(text, certain, range);
787
+ if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
788
+ collectBrackets(range, stmtCtx, 0, out, defects, stmtCertain, loc);
789
+ // R5 — block bodies inherit the context in force here.
790
+ for (const block of scopeBlocksIn(range)) {
791
+ if (blockDepth >= MAX_DEPTH) {
792
+ // The BLOCK that exceeded the limit, not the whole statement —
793
+ // narrowing regions is what this channel is for.
794
+ defects.push({
795
+ code: "over-depth",
796
+ ...regionIn(loc, block.start, block.start + block.body.length),
797
+ });
798
+ continue;
799
+ }
800
+ const bodyLoc = nest(loc, block.start, unit.span);
801
+ const body = locate(
802
+ segmentScopeBody(block.body, bodyRange(range, block)),
803
+ bodyLoc,
804
+ bodyRange(range, block),
805
+ );
806
+ defects.push(...body.defects);
807
+ walk(body.units, stmtCtx, out, blockDepth + 1, defects, stmtCertain);
808
+ }
809
+ }
810
+ }
811
+
812
+ /** Q4 — canonical path of every statement in source order. */
813
+ export function resolveStatements(text: string): StatementAnalysis {
814
+ const segmented = segmentStatements(text);
815
+ // As `resolveDocument`: coordinate facts come from the root only.
816
+ const defects: Defect[] = [...segmented.defects];
817
+ const statements: StatementResolution[] = [];
818
+ const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
819
+ defects.push(...top.defects);
820
+ walkStatements(top.units, "/", statements, 0, defects, true);
821
+ return { statements, defects: mergeDefects(defects) };
822
+ }
823
+
824
+ function walkStatements(
825
+ units: Located[],
826
+ context: string,
827
+ out: StatementResolution[],
828
+ blockDepth: number,
829
+ defects: Defect[],
830
+ contextCertain: boolean,
831
+ ): void {
832
+ let ctx = context;
833
+ let certain = contextCertain;
834
+ for (const unit of units) {
835
+ const { text, span, range } = unit;
836
+ const nav = menuNavPath(text, ctx, range);
837
+ if (nav !== null) {
838
+ // `..` ascends FROM the context, so it cannot be read while the context
839
+ // is unknown. A `/`-led navigation (and a bare `/`) REPLACES it (R4) and
840
+ // therefore re-establishes certainty.
841
+ const relative = !trimAscii(text).startsWith("/");
842
+ if (relative && !certain) {
843
+ out.push({
844
+ text,
845
+ isNav: true,
846
+ context: ctx,
847
+ path: null,
848
+ unresolved: CONTEXT_LOST,
849
+ contextCertain: false,
850
+ span,
851
+ });
852
+ continue;
853
+ }
854
+ out.push({
855
+ text,
856
+ isNav: true,
857
+ context: ctx,
858
+ path: nav,
859
+ contextCertain: certain,
860
+ span,
861
+ });
862
+ ctx = nav;
863
+ if (!relative) certain = true;
864
+ continue;
865
+ }
866
+ // Q14 fail-closed — a malformed statement degrades to unresolved and is
867
+ // not descended; it does not move the context either. It may however have
868
+ // BEEN a navigation, so what the context now is becomes unknown.
869
+ const defectReason = structuralDefectIn(range);
870
+ if (defectReason !== null) {
871
+ out.push({
872
+ text,
873
+ isNav: false,
874
+ context: ctx,
875
+ path: null,
876
+ unresolved: defectReason,
877
+ contextCertain: certain,
878
+ span,
879
+ });
880
+ certain = false;
881
+ continue;
882
+ }
883
+ // #235 — a relative bare-word that abstained (neither table) poisons
884
+ // downstream context. Capture before the push so the current statement's
885
+ // own `contextCertain` stays true while the following statements lose it.
886
+ const poisonAbstention = isAbstainedRelativeNav(text, ctx, range);
887
+ // Q14 C3b — the cascade. A statement that CONSUMES the context cannot be
888
+ // resolved against a value the resolver has already admitted it lost.
889
+ // Applied to the RESOLUTION, not ahead of it, so a statement that would
890
+ // have refused on its own terms keeps its own — more specific — reason.
891
+ const resolved = canonicalPath(text, ctx, range);
892
+ out.push({
893
+ text,
894
+ isNav: false,
895
+ context: ctx,
896
+ ...(resolved.path !== null &&
897
+ !certain &&
898
+ !isContextIndependent(text, range)
899
+ ? { path: null, unresolved: CONTEXT_LOST }
900
+ : resolved),
901
+ contextCertain: certain,
902
+ span,
903
+ });
904
+ // A block body's statements are the parent's siblings after flattening,
905
+ // and R5 gives them the context in force here — which is the ROOT, hence
906
+ // knowable, when the statement spells its path out or is a bare directive,
907
+ // and unknowable when the statement's own path could not be read. Same
908
+ // predicate and same order as the bracket walk, per the lockstep rule.
909
+ const stmtCtx = statementPath(text, ctx, range);
910
+ const bodyCertain = poisonAbstention
911
+ ? false
912
+ : handsKnownContext(text, certain, range);
913
+ if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
914
+ for (const block of scopeBlocksIn(range)) {
915
+ if (blockDepth >= MAX_DEPTH) {
916
+ defects.push({
917
+ code: "over-depth",
918
+ ...regionIn(unit.loc, block.start, block.start + block.body.length),
919
+ });
920
+ continue;
921
+ }
922
+ const bodyLoc = nest(unit.loc, block.start, span);
923
+ const body = locate(
924
+ segmentScopeBody(block.body, bodyRange(range, block)),
925
+ bodyLoc,
926
+ bodyRange(range, block),
927
+ );
928
+ defects.push(...body.defects);
929
+ walkStatements(
930
+ body.units,
931
+ stmtCtx,
932
+ out,
933
+ blockDepth + 1,
934
+ defects,
935
+ bodyCertain,
936
+ );
937
+ }
938
+ }
939
+ }
940
+
941
+ /**
942
+ * `/ip address` alone on a statement → the new context. Otherwise null.
943
+ *
944
+ * R8/R13 — `..` ascends, a bare `/` resets, and a relative bare path advances
945
+ * only when a shipped structure table confirms the context-applied menu
946
+ * ({@link readRelativeBare}). An absolute navigation REPLACES the context
947
+ * rather than extending it; a relative one EXTENDS it.
948
+ *
949
+ * R9 (#211 B1) — a `/`-led bare path whose run carries a FROZEN-VOCABULARY VERB
950
+ * is a command, not navigation. Without this the shape test alone claimed
951
+ * `/ip address print` as a navigation to `/ip/address/print` — a menu that does
952
+ * not exist — and then resolved the rest of the document against it. Q6 already
953
+ * decides these statements: `verbsplit` reads the very same text as verb `print`
954
+ * at `/ip/address`, so the two promoted modules returned contradictory answers
955
+ * for 34 dev / 13 holdout statements, `verbsplit` right in every one. The verb
956
+ * set is deliberately the SAME `VERBS` object and the same case-sensitive
957
+ * lookup, so the modules cannot drift apart; `explain-pathresolve.test.ts` pins
958
+ * the invariant that a `verbsplit`-`resolved` statement is never `isNav`.
959
+ *
960
+ * This costs no abstention — the statements it removes from navigation were
961
+ * already decided, just decided wrongly. It also does not collide with any menu
962
+ * the generated table knows: no path in `MENU_PATHS` carries a verb segment,
963
+ * asserted in the same test so a regeneration that introduced one fails. That
964
+ * is a claim about the TABLE, not about RouterOS — `MENU_PATHS` is a floor
965
+ * (#207), so a real menu absent from it and named `.../print` would still be
966
+ * read as a command here. Nothing in the corpus or the four pinned trees looks
967
+ * like that, and such a menu would be indistinguishable from `print` at its
968
+ * parent anyway.
969
+ *
970
+ * R12 (#228 step 2) — a `/`-led bare path the published CATALOG names as a
971
+ * command is not navigation either. This is the harder half R9 could not reach
972
+ * (#211 B2): `/system/reboot`, `/quit` and `/tool/speed-test` carry no
973
+ * vocabulary verb, and #211 could only have refused them by reading ABSENCE from
974
+ * `MENU_PATHS` as evidence of a command — which inverts the floor contract
975
+ * (#207) and is why B2 was parked rather than guessed at. The command axis is
976
+ * the missing POSITIVE evidence, so nothing about the floor changes: presence in
977
+ * either table decides, absence from both still abstains.
978
+ *
979
+ * It is also no longer optional. `verbsplit` reads `/system reboot` as verb
980
+ * `reboot` at `/system`, so leaving it navigation here would re-open exactly the
981
+ * contradiction R9 closed — and worse than before, since the two modules would
982
+ * now disagree CONFIDENTLY rather than one of them refusing. The same test pins
983
+ * the invariant for both rules.
984
+ */
985
+ function menuNavPath(
986
+ text: string,
987
+ ctx: string,
988
+ range: MaskedRange,
989
+ ): string | null {
990
+ const trimmed = trimAscii(text);
991
+ if (trimmed === "/") return "/";
992
+ if (/^\.\.(?:[ \t\r\n]|$)/.test(trimmed)) {
993
+ const tokens = asciiWords(trimmed);
994
+ if (!tokens.every((token) => token === "..")) return null;
995
+ return joinPath(ctx, tokens.join("/"));
996
+ }
997
+ if (!trimmed.startsWith("/")) {
998
+ // #235 R13 — relative bare-word menu navigation.
999
+ const reading = readRelativeBare(text, ctx, range);
1000
+ return reading?.kind === "nav" ? reading.path : null;
1001
+ }
1002
+ if (/[=[({"$]/.test(trimmed)) return null;
1003
+ const tokens = asciiWords(trimmed);
1004
+ if (
1005
+ !tokens.every((token, i) =>
1006
+ i === 0 ? isAbsolutePathToken(token) : BARE_WORD.test(token),
1007
+ )
1008
+ )
1009
+ return null;
1010
+ // R9 — the run is verb-free, or this is not navigation. Tokens may be
1011
+ // slash-joined (`/ip/address/print` is one token), so the verb test has to run
1012
+ // over path SEGMENTS, exactly as `verbsplit`'s `runTokens` splits them.
1013
+ const segments = tokens.flatMap((token) =>
1014
+ token.split("/").filter((part) => part.length > 0),
1015
+ );
1016
+ if (segments.some((segment) => VERBS.has(segment))) return null;
1017
+ // R12 — the same segments, against the published command axis.
1018
+ if (commandVerbIndex(segments) !== null) return null;
1019
+ return joinPath("/", tokens.join("/"));
1020
+ }
1021
+
1022
+ /**
1023
+ * How a RELATIVE bare-path statement reads against `ctx` (#235, R13).
1024
+ *
1025
+ * - `nav` — a shipped table confirms the context-applied path, so it navigates.
1026
+ * - `abstain` — the statement HAS the shape of relative navigation but neither
1027
+ * table knows the joined path. Offline cannot say which it was, so the caller
1028
+ * poisons what follows rather than resolve it against a context that may
1029
+ * already have moved.
1030
+ * - `null` — not a relative bare path at all: absolute, dynamic, a directive,
1031
+ * or a run carrying a vocabulary verb / published command, which is a
1032
+ * COMMAND and therefore not a maybe-navigation either.
1033
+ *
1034
+ * ONE classifier rather than a shape test per question. The nav reading and the
1035
+ * poison test must agree about which statements are in scope; written twice
1036
+ * they can drift, and a drift there is silently either a lost navigation or a
1037
+ * poisoned document.
1038
+ */
1039
+ type RelativeBareReading = { kind: "nav"; path: string } | { kind: "abstain" };
1040
+
1041
+ function readRelativeBare(
1042
+ text: string,
1043
+ ctx: string,
1044
+ range: MaskedRange,
1045
+ ): RelativeBareReading | null {
1046
+ const trimmed = trimAscii(text);
1047
+ if (
1048
+ trimmed.length === 0 ||
1049
+ trimmed.startsWith("/") ||
1050
+ trimmed.startsWith(":") ||
1051
+ trimmed.startsWith("$") ||
1052
+ trimmed.startsWith("[") ||
1053
+ trimmed.startsWith("(") ||
1054
+ isBareDirective(text, range)
1055
+ )
1056
+ return null;
1057
+ // Whitespace and `/` are interchangeable on the device, so the run is read
1058
+ // over path SEGMENTS exactly as R9 reads an absolute one: `firewall filter`
1059
+ // and `firewall/filter` under `/ip` are the same navigation (CHR 7.24rc1 —
1060
+ // see the `pathresolve.json` corner). A `..`/`.` segment fails `BARE_WORD`,
1061
+ // so the `..` form above stays the only ascent this function admits.
1062
+ const parts = asciiWords(trimmed).flatMap((word) => word.split("/"));
1063
+ if (!parts.every((part) => BARE_WORD.test(part))) return null;
1064
+ const path = joinPath(ctx, parts.join("/"));
1065
+ const segments = path.split("/").filter(Boolean);
1066
+ // R9/R12 on the JOINED path — a verb-bearing or published-command path is a
1067
+ // command, not navigation, and not an abstention either: `address print`
1068
+ // under `/ip` is verb `print` at `/ip/address`, which `verbsplit` decides.
1069
+ if (segments.some((segment) => VERBS.has(segment))) return null;
1070
+ if (commandVerbIndex(segments) !== null) return null;
1071
+ return isKnownMenuPath(segments)
1072
+ ? { kind: "nav", path }
1073
+ : { kind: "abstain" };
1074
+ }
1075
+
1076
+ /**
1077
+ * #235 — does `text` look like a relative menu navigation that ABSTAINED?
1078
+ *
1079
+ * Presence in a table → nav; absence → abstain → poison downstream, so the
1080
+ * next relative statement cannot resolve against a context that may have moved.
1081
+ */
1082
+ function isAbstainedRelativeNav(
1083
+ text: string,
1084
+ ctx: string,
1085
+ range: MaskedRange,
1086
+ ): boolean {
1087
+ return readRelativeBare(text, ctx, range)?.kind === "abstain";
1088
+ }
1089
+
1090
+ function canonicalPath(
1091
+ text: string,
1092
+ ctx: string,
1093
+ range: MaskedRange,
1094
+ ): { path: string | null; candidates?: string[]; unresolved?: string } {
1095
+ const t = trimAscii(text);
1096
+ if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
1097
+ return {
1098
+ path: null,
1099
+ unresolved: "dynamic or substitution-headed statement",
1100
+ };
1101
+ // A `:` scripting directive is always at the ROOT, whatever the context —
1102
+ // but it is not necessarily one segment: `:log info "x"` is `/log/info`.
1103
+ const body = t.startsWith(":") ? t.slice(1) : t;
1104
+ // R11 — a scripting directive written WITHOUT its colon is still at the root.
1105
+ // A statement that carries a SCOPE-valued block is a directive, because no
1106
+ // menu command takes one — a schema-free tell.
1107
+ const bareDirective = isBareDirective(text, range);
1108
+ // The cascade contract reads this same split to decide which statements may
1109
+ // keep resolving once the context is lost, so both must come from one place.
1110
+ const base =
1111
+ t.startsWith(":") || t.startsWith("/") || bareDirective ? "/" : ctx;
1112
+ // R7 — a variable segment anywhere in the leading run leaves the path
1113
+ // unresolved, INCLUDING after a static word. `statementRun` drops the whole
1114
+ // word carrying the variable, so `/ip route/$menu/remove` would otherwise
1115
+ // report a confident, truncated `/ip`.
1116
+ if (isUnreadablePath(t))
1117
+ return { path: null, unresolved: "variable path segment" };
1118
+ const tokens = statementRun(body);
1119
+ if (tokens.length === 0)
1120
+ return { path: null, unresolved: "no leading path token" };
1121
+ if (tokens.some((x) => x.startsWith("$")))
1122
+ return { path: null, unresolved: "variable path segment" };
1123
+ const candidates: string[] = [];
1124
+ for (let k = 1; k <= tokens.length; k++)
1125
+ candidates.push(joinPath(base, tokens.slice(0, k).join("/")));
1126
+ // R10 — the path/positional boundary, split by statement kind: a `:` (or
1127
+ // bare) directive takes its operand positionally, so its path is the
1128
+ // directive alone; a menu command spells its whole menu out.
1129
+ const path =
1130
+ t.startsWith(":") || bareDirective
1131
+ ? (candidates[0] as string)
1132
+ : joinPath(base, tokens.join("/"));
1133
+ return { path, candidates };
1134
+ }
1135
+
1136
+ /**
1137
+ * Leading path-token run of a statement. Stops at the first token that cannot
1138
+ * be a path segment: an argument (`x=1`), a group, a quoted string, or anything
1139
+ * not shaped like an identifier — a bare `9` is a positional argument, never a
1140
+ * menu. It does NOT stop at identifier-shaped positional arguments (`enable
1141
+ * www-ssl`); nothing offline can, and that residue is Q6.
1142
+ */
1143
+ function statementRun(text: string): string[] {
1144
+ const out: string[] = [];
1145
+ for (const t of asciiWordsIter(text)) {
1146
+ if (t.includes("=") || /^[[({"$:]/.test(t)) break;
1147
+ const parts = t.split("/").filter((p) => p.length > 0);
1148
+ if (parts.length === 0) continue;
1149
+ if (!parts.every((p) => BARE_WORD.test(p))) break;
1150
+ out.push(...parts);
1151
+ }
1152
+ return out;
1153
+ }
1154
+
1155
+ /**
1156
+ * The menu path a statement's own brackets inherit. For `/ip route remove
1157
+ * [find …]` that is `/ip/route`: the leading path run minus its last token,
1158
+ * which is the verb (Q6's reading, right ~93.7% of the time).
1159
+ */
1160
+ function statementPath(text: string, ctx: string, range: MaskedRange): string {
1161
+ const t = trimAsciiStart(text);
1162
+ // R11 — a bare directive is at the root, so the context it hands its body is
1163
+ // the root too, not `<ctx>/while`.
1164
+ if (isBareDirective(text, range)) return "/";
1165
+ const lead = leadingRun(text);
1166
+ if (lead.length === 0) return ctx;
1167
+ // Same split `isBodyContextIndependent` reports to the cascade contract.
1168
+ const base = t.startsWith("/") ? "/" : ctx;
1169
+ return joinPath(base, lead.slice(0, -1).join("/"));
1170
+ }
1171
+
1172
+ /** Leading bare/path tokens, stopping at the first argument or group. */
1173
+ function leadingRun(text: string): string[] {
1174
+ const out: string[] = [];
1175
+ for (const t of asciiWordsIter(text)) {
1176
+ if (t.includes("=") || /^[[({"$:]/.test(t)) break;
1177
+ for (const part of t.split("/")) if (part.length > 0) out.push(part);
1178
+ if (out.length === 0 && t === "/") out.push("");
1179
+ }
1180
+ return out;
1181
+ }
1182
+
1183
+ function joinPath(base: string, rest: string): string {
1184
+ const parts = [
1185
+ ...base.split("/").filter(Boolean),
1186
+ ...rest.split("/").filter((p) => p.length > 0),
1187
+ ];
1188
+ const stack: string[] = [];
1189
+ for (const p of parts) {
1190
+ if (p === "..") stack.pop();
1191
+ else if (p !== ".") stack.push(p);
1192
+ }
1193
+ return `/${stack.join("/")}`;
1194
+ }
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
+ */
1209
+ function collectBrackets(
1210
+ range: MaskedRange,
1211
+ ctx: string,
1212
+ depth: number,
1213
+ out: Resolution[],
1214
+ defects: Defect[],
1215
+ contextCertain: boolean,
1216
+ loc: Loc,
1217
+ inLiteral = false,
1218
+ ): void {
1219
+ const { masked, start: lo, end: hi } = range;
1220
+ // Bound the recursion (bracket nesting AND literal-brace descent) so
1221
+ // untrusted deeply nested input abstains instead of overflowing the stack.
1222
+ if (depth >= MAX_DEPTH) {
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) });
1226
+ return;
1227
+ }
1228
+ for (let i = lo; i < hi; i++) {
1229
+ const c = masked[i];
1230
+ if (c === '"') {
1231
+ // A string is opaque EXCEPT for `$[ … ]` interpolation, a real command
1232
+ // substitution: `:put "$[:pick $ip 0 $n]"` lowers with `/pick` inside
1233
+ // (topic-…/post-0003-snippet-01 @ 7.22.1).
1234
+ const strEnd = stringEnd(masked, i, hi);
1235
+ scanInterpolations(
1236
+ rangeAt(range, i + 1, strEnd),
1237
+ ctx,
1238
+ depth,
1239
+ out,
1240
+ defects,
1241
+ contextCertain,
1242
+ nest(loc, i + 1 - lo, loc.fallback),
1243
+ );
1244
+ i = strEnd;
1245
+ continue;
1246
+ }
1247
+ if (c === "{") {
1248
+ // A literal body is a value (Q2), but can still contain command
1249
+ // substitutions: `{[/terminal/inkey]}` lowers the bracket. Descend for
1250
+ // brackets only; scope bodies are walked by walk().
1251
+ const end = matchDelim(masked, i, "{", "}", hi);
1252
+ const isScope = !inLiteral && scopeNameFromMasked(masked, i, lo) !== null;
1253
+ if (!isScope && hasBracketOrBrace(masked, i + 1, end))
1254
+ collectBrackets(
1255
+ rangeAt(range, i + 1, end),
1256
+ ctx,
1257
+ depth + 1,
1258
+ out,
1259
+ defects,
1260
+ contextCertain,
1261
+ nest(loc, i + 1 - lo, loc.fallback),
1262
+ true,
1263
+ );
1264
+ i = end;
1265
+ continue;
1266
+ }
1267
+ if (c !== "[") continue;
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);
1274
+ // The span covers the whole `[…]`, closing bracket included.
1275
+ const resolution: Resolution = {
1276
+ ...resolveInner(inner, ctx, depth, contextCertain),
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));
1282
+ // R6 — nested brackets inherit from this one's resolution.
1283
+ const nestedCtx = resolution.path ?? ctx;
1284
+ collectBrackets(
1285
+ rangeAt(range, innerAt, innerEnd),
1286
+ nestedCtx,
1287
+ depth + 1,
1288
+ out,
1289
+ defects,
1290
+ nestedCertainty(resolution, contextCertain),
1291
+ nest(loc, innerAt - lo, loc.fallback),
1292
+ );
1293
+ i = end;
1294
+ }
1295
+ }
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
+
1320
+ /**
1321
+ * Index of the closing quote of the string starting at `open`. Delegates to the
1322
+ * shared `scanQuotedString`, so a `$[…]` substitution carrying its own string
1323
+ * does not end the outer one early and cut `scanInterpolations`' body short
1324
+ * (#199).
1325
+ */
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;
1333
+ }
1334
+
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
+ */
1341
+ function scanInterpolations(
1342
+ range: MaskedRange,
1343
+ ctx: string,
1344
+ depth: number,
1345
+ out: Resolution[],
1346
+ defects: Defect[],
1347
+ contextCertain: boolean,
1348
+ loc: Loc,
1349
+ ): void {
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),
1371
+ inner,
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,
1380
+ depth + 1,
1381
+ out,
1382
+ defects,
1383
+ nestedCertainty(resolution, contextCertain),
1384
+ nest(loc, innerAt - lo, loc.fallback),
1385
+ );
1386
+ i = end;
1387
+ }
1388
+ }
1389
+
1390
+ /**
1391
+ * Certainty a NESTED bracket inherits (R6). Three cases, and the middle one is
1392
+ * the whole subtlety:
1393
+ *
1394
+ * 1. The enclosing bracket RESOLVED — its path is the nested bracket's base,
1395
+ * so the nested one is anchored even while the document context is lost
1396
+ * (`[/ip/route/get [find …] gateway]` is absolute, hence independent).
1397
+ * 2. The enclosing bracket is a MENU COMMAND WE COULD NOT READ — then the
1398
+ * real base is a menu we cannot name, and `collectBrackets` falls back to
1399
+ * the STATEMENT's context, which is a different menu. In `/ip route` +
1400
+ * `remove [/interface/$type/get [find]]` that fallback reports the nested
1401
+ * `[find]` at `/ip/route`, a context it never had. It must abstain — and
1402
+ * equally for a RELATIVE unreadable menu (`[route/$verb [find]]`), because
1403
+ * spelling is not what makes a menu unreadable.
1404
+ * 3. The enclosing bracket is NOT A MENU COMMAND AT ALL — a user function
1405
+ * (`$formatDate`), a value expression, a `:` directive. It
1406
+ * establishes no menu, so the AMBIENT context still governs its arguments
1407
+ * and the fallback is correct. Corpus evidence for this being the common
1408
+ * shape rather than a corner: under `/log`,
1409
+ * `:set x "$[$formatDate [get $item time]]"` must keep resolving
1410
+ * `[get …]` at `/log` (rextended/topic-166898). Refusing case 3 as well
1411
+ * costs 12 further brackets and one document's `containsWrite` verdict,
1412
+ * all of them correct answers thrown away.
1413
+ *
1414
+ * Case 2 is told from case 3 by `isUnreadablePath`, the same test the statement
1415
+ * walk uses: the inner NAMES a menu (a path-shaped head) whose leading run is
1416
+ * truncated by a variable segment, in either the absolute or the relative
1417
+ * spelling. Case 3 is text that names no menu at all.
1418
+ */
1419
+ function nestedCertainty(
1420
+ enclosing: Pick<Resolution, "path" | "inner"> | undefined,
1421
+ contextCertain: boolean,
1422
+ ): boolean {
1423
+ if (enclosing === undefined) return contextCertain;
1424
+ if (enclosing.path !== null) return true;
1425
+ if (isUnreadablePath(enclosing.inner)) return false;
1426
+ return contextCertain;
1427
+ }
1428
+
1429
+ /**
1430
+ * Resolve one bracket body. The caller owns the `span`, because only it knows
1431
+ * where the bracket sat in the text it was scanning.
1432
+ */
1433
+ function resolveInner(
1434
+ inner: string,
1435
+ ctx: string,
1436
+ depth: number,
1437
+ contextCertain: boolean,
1438
+ ): Omit<Resolution, "span" | "innerSpan"> {
1439
+ const absolute = inner.startsWith("/");
1440
+ const klass = classify(inner, absolute, depth);
1441
+ if (klass === "cli-prompt-artifact")
1442
+ return {
1443
+ inner,
1444
+ context: ctx,
1445
+ tokens: [],
1446
+ candidates: [],
1447
+ path: null,
1448
+ unresolved: "looks like a pasted CLI prompt, not a substitution",
1449
+ contextCertain,
1450
+ depth,
1451
+ klass,
1452
+ };
1453
+ // A `:` scripting directive is ALWAYS at the root — `[:tostr $x]` lowers to
1454
+ // `(evl (evl /tostrvalue=$x))`, path `/`, whatever context is in force.
1455
+ if (trimAsciiStart(inner).startsWith(":"))
1456
+ return {
1457
+ inner,
1458
+ context: ctx,
1459
+ tokens: [],
1460
+ candidates: ["/"],
1461
+ path: "/",
1462
+ contextCertain,
1463
+ depth,
1464
+ klass,
1465
+ };
1466
+ const tokens = leadingRun(inner);
1467
+ if (tokens.length === 0)
1468
+ return {
1469
+ inner,
1470
+ context: ctx,
1471
+ tokens,
1472
+ candidates: [],
1473
+ path: null,
1474
+ unresolved: "no leading path token",
1475
+ contextCertain,
1476
+ depth,
1477
+ klass,
1478
+ };
1479
+ // R7 — a `$var` anywhere in the leading run makes the path unknowable.
1480
+ if (tokens.some((t) => t.startsWith("$")))
1481
+ return {
1482
+ inner,
1483
+ context: ctx,
1484
+ tokens,
1485
+ candidates: [],
1486
+ path: null,
1487
+ unresolved: "variable path segment",
1488
+ contextCertain,
1489
+ depth,
1490
+ klass,
1491
+ };
1492
+ // Q14 C3b — a RELATIVE inner command inherits the statement's menu (R2/R3),
1493
+ // so it cannot be re-constituted once that context is lost. Checked only once
1494
+ // the inner command has been read, so a bracket that refuses on its own terms
1495
+ // keeps its own reason; an absolute inner path and the `:` directive above are
1496
+ // context-independent and still resolve.
1497
+ if (!contextCertain && !absolute)
1498
+ return {
1499
+ inner,
1500
+ context: ctx,
1501
+ tokens,
1502
+ candidates: [],
1503
+ path: null,
1504
+ unresolved: CONTEXT_LOST,
1505
+ contextCertain,
1506
+ depth,
1507
+ klass,
1508
+ };
1509
+ const base = absolute ? "/" : ctx; // R1 / R2
1510
+ const candidates: string[] = [joinPath(base, "")];
1511
+ for (let k = 1; k < tokens.length; k++)
1512
+ candidates.push(joinPath(base, tokens.slice(0, k).join("/")));
1513
+ return {
1514
+ inner,
1515
+ context: ctx,
1516
+ tokens,
1517
+ candidates,
1518
+ // Absolute inner paths spell their menu out, so the verb is the last
1519
+ // segment of the slash-joined head; relative ones lead with the verb.
1520
+ path: absolute
1521
+ ? joinPath(base, tokens.slice(0, -1).join("/"))
1522
+ : joinPath(base, ""),
1523
+ contextCertain,
1524
+ depth,
1525
+ klass,
1526
+ };
1527
+ }
1528
+
1529
+ // `[admin@Router] > /ip address print` — a pasted terminal transcript. The
1530
+ // prompt is bracketed exactly like a command substitution, and offline has no
1531
+ // way to tell them apart by shape alone. Left unguarded it manufactures phantom
1532
+ // inner commands from any pasted session, which matters because `explain` takes
1533
+ // editor and MCP input. `user@host` with no command shape is the tell; the
1534
+ // resolver abstains rather than inventing a path.
1535
+ const CLI_PROMPT_RE = /^[^ \t\r\n@/[\]]+@[^ \t\r\n@/[\]]+$/;
1536
+
1537
+ function classify(inner: string, absolute: boolean, depth: number): string {
1538
+ if (CLI_PROMPT_RE.test(trimAscii(inner))) return "cli-prompt-artifact";
1539
+ if (depth > 0) return "nested-bracket";
1540
+ if (absolute) return "absolute-inner-path";
1541
+ const head = (asciiWordsIter(inner).next().value ?? "").toLowerCase();
1542
+ if (head.startsWith("$")) return "dynamic-invocation";
1543
+ if (head.startsWith(":")) return "scripting-directive";
1544
+ if (head === "find") return "bare-find";
1545
+ return "bare-inner-command";
1546
+ }
1547
+
1548
+ function matchDelim(
1549
+ text: string,
1550
+ start: number,
1551
+ open: string,
1552
+ close: string,
1553
+ limit: number = text.length,
1554
+ ): number {
1555
+ let depth = 0;
1556
+ for (let i = start; i < limit; i++) {
1557
+ const c = text[i];
1558
+ if (c === '"') {
1559
+ i = scanQuotedString(text, i, limit).end - 1;
1560
+ continue;
1561
+ }
1562
+ if (c === open) depth++;
1563
+ else if (c === close) {
1564
+ depth--;
1565
+ if (depth === 0) return i;
1566
+ }
1567
+ }
1568
+ return limit;
1569
+ }