@tikoci/centrs 0.1.3 → 0.1.4

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 (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/GLOSSARY.txt +213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +36 -8
  5. package/commands/AGENTS.md +7 -1
  6. package/commands/api/README.md +19 -18
  7. package/commands/api/examples.md +15 -0
  8. package/commands/btest/README.md +21 -32
  9. package/commands/check/README.md +6 -1
  10. package/commands/discover/README.md +5 -11
  11. package/commands/execute/README.md +41 -29
  12. package/commands/execute/examples.md +41 -3
  13. package/commands/explain/README.md +1716 -0
  14. package/commands/explain/examples.md +606 -0
  15. package/commands/retrieve/README.md +25 -27
  16. package/commands/retrieve/examples.md +16 -0
  17. package/commands/transfer/README.md +28 -20
  18. package/commands/transfer/examples.md +29 -0
  19. package/docs/CLI.md +353 -0
  20. package/docs/CONSTITUTION.md +66 -15
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -0
  23. package/docs/errors/quickchr/machine-not-found.md +22 -0
  24. package/docs/errors/quickchr/machine-stopped.md +20 -0
  25. package/docs/errors/quickchr/package-unavailable.md +27 -0
  26. package/docs/errors/quickchr/unsupported-via.md +35 -0
  27. package/docs/errors/quickchr/unsupported.md +35 -0
  28. package/docs/errors/routeros/version-unsupported.md +26 -0
  29. package/docs/errors/transport/provider-trust.md +29 -0
  30. package/docs/errors/usage/stdin-ignored.md +28 -0
  31. package/docs/errors/validation/unknown-attribute.md +37 -4
  32. package/package.json +45 -10
  33. package/src/api-fanout.ts +29 -14
  34. package/src/api.ts +97 -54
  35. package/src/cli/api.ts +25 -6
  36. package/src/cli/btest.ts +18 -2
  37. package/src/cli/execute.ts +25 -6
  38. package/src/cli/explain.ts +503 -0
  39. package/src/cli/retrieve.ts +33 -1
  40. package/src/cli/selection.ts +71 -11
  41. package/src/cli/settings.ts +2 -2
  42. package/src/cli/transfer.ts +23 -6
  43. package/src/cli.ts +15 -2
  44. package/src/core/envelope.ts +5 -2
  45. package/src/core/error-catalog.ts +40 -0
  46. package/src/core/preflight.ts +306 -0
  47. package/src/core/routeros-string.ts +24 -0
  48. package/src/errors.ts +1 -0
  49. package/src/execute-fanout.ts +29 -14
  50. package/src/execute.ts +114 -63
  51. package/src/explain/arg-tokens.ts +114 -0
  52. package/src/explain/args.ts +1403 -0
  53. package/src/explain/blocks.ts +132 -0
  54. package/src/explain/brace-slots.ts +143 -0
  55. package/src/explain/brace-tokens.ts +38 -0
  56. package/src/explain/catalog.ts +1345 -0
  57. package/src/explain/coordinates.ts +329 -0
  58. package/src/explain/defects.ts +217 -0
  59. package/src/explain/is-known-menu.ts +17 -0
  60. package/src/explain/menus.ts +664 -0
  61. package/src/explain/operator-tokens.ts +310 -0
  62. package/src/explain/operators.ts +458 -0
  63. package/src/explain/path-tokens.ts +150 -0
  64. package/src/explain/pathresolve.ts +1324 -0
  65. package/src/explain/quoted-string.ts +241 -0
  66. package/src/explain/scope-brace.ts +308 -0
  67. package/src/explain/segment.ts +757 -0
  68. package/src/explain/string-tokens.ts +67 -0
  69. package/src/explain/symbol-values.ts +520 -0
  70. package/src/explain/symbols.ts +1597 -0
  71. package/src/explain/token-ranges.ts +72 -0
  72. package/src/explain/transport.ts +497 -0
  73. package/src/explain/value-tokens.ts +80 -0
  74. package/src/explain/values.ts +357 -0
  75. package/src/explain/verbs.ts +64 -0
  76. package/src/explain/verbsplit.ts +813 -0
  77. package/src/explain/write.ts +815 -0
  78. package/src/explain.ts +2152 -0
  79. package/src/index.ts +206 -4
  80. package/src/protocols/mac-telnet-console.ts +61 -17
  81. package/src/resolver/facts.ts +1 -1
  82. package/src/resolver/index.ts +23 -4
  83. package/src/resolver/quickchr-provider.ts +597 -0
  84. package/src/resolver/selection.ts +99 -12
  85. package/src/resolver/service-endpoint.ts +155 -0
  86. package/src/resolver/target.ts +1 -1
  87. package/src/retrieve-fanout.ts +26 -12
  88. package/src/retrieve.ts +81 -18
  89. package/src/transfer-fanout.ts +42 -25
  90. package/src/transfer.ts +87 -44
@@ -0,0 +1,1324 @@
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 { scopeBlocks, scopeBodies } 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
+ hashStartsHardError,
105
+ scopeNameFromMasked,
106
+ } from "./scope-brace.ts";
107
+ import {
108
+ maskComments,
109
+ type SegmentResult,
110
+ scanQuotedString,
111
+ segmentStatements,
112
+ } from "./segment.ts";
113
+ import { VERBS } from "./verbs.ts";
114
+
115
+ const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
116
+ /**
117
+ * A whole path segment that is a variable reference (`$menu` in `/ip/$menu`).
118
+ *
119
+ * Deliberately "begins with `$`" and NOT an enumeration of name spellings. This
120
+ * makes NO claim about which spellings RouterOS accepts — the point is the
121
+ * opposite: every `$`-headed segment is unreadable HERE, whether it is a valid
122
+ * reference (`$menu`, `$"quoted name"`) or something the device would itself
123
+ * reject (`${braced}` is rejected at the `{` on 7.24rc1; a bare `$`). Offline
124
+ * cannot resolve either kind, so both must abstain.
125
+ *
126
+ * A pattern like `\$[A-Za-z_]…` instead recognizes only the shapes it lists and
127
+ * lets every other one through as an ordinary readable menu segment, which
128
+ * fails OPEN. That is the same trap as the unanchored-regex and reason-prefix
129
+ * bugs earlier in this module: a guard that decides whether to ABSTAIN must
130
+ * match the general shape and let the unknown case block, never enumerate the
131
+ * known ones. `write.ts` reaches the same conclusion at head position, where
132
+ * `isDynamicForm` tests a bare `text.startsWith("$")`.
133
+ */
134
+ function isVariableSegment(part: string): boolean {
135
+ return part.startsWith("$");
136
+ }
137
+ const ASCII_WHITESPACE = /[ \t\r\n]+/;
138
+
139
+ function isAsciiWhitespace(char: string | undefined): boolean {
140
+ return char === " " || char === "\t" || char === "\r" || char === "\n";
141
+ }
142
+
143
+ function trimAscii(text: string): string {
144
+ let start = 0;
145
+ let end = text.length;
146
+ while (start < end && isAsciiWhitespace(text[start])) start++;
147
+ while (end > start && isAsciiWhitespace(text[end - 1])) end--;
148
+ return text.slice(start, end);
149
+ }
150
+
151
+ function trimAsciiStart(text: string): string {
152
+ let start = 0;
153
+ while (start < text.length && isAsciiWhitespace(text[start])) start++;
154
+ return text.slice(start);
155
+ }
156
+
157
+ function asciiWords(text: string): string[] {
158
+ const trimmed = trimAscii(text);
159
+ return trimmed.length === 0 ? [] : trimmed.split(ASCII_WHITESPACE);
160
+ }
161
+
162
+ function isAbsolutePathToken(token: string): boolean {
163
+ if (!token.startsWith("/")) return false;
164
+ const parts = token.split("/").filter(Boolean);
165
+ return parts.length > 0 && parts.every((part) => BARE_WORD.test(part));
166
+ }
167
+
168
+ /**
169
+ * Defensive recursion bound. `explain` accepts untrusted editor/MCP input, so
170
+ * deeply nested substitutions/blocks must make the analysis ABSTAIN (an
171
+ * `over-depth` note) rather than consume the JS stack. Matches the segmenter's
172
+ * `MAX_CONTAINER_DEPTH`; far beyond any real script. Unlike the segmenter's H7
173
+ * flattening — now a single left-to-right pass (#190) — this resolver's descent
174
+ * is a genuine tree walk (block topology + bracket sub-command path
175
+ * re-constitution), so it stays recursive under this bound.
176
+ */
177
+ const MAX_DEPTH = 256;
178
+
179
+ /**
180
+ * True when a statement's own delimiters are not well-formed — unbalanced
181
+ * `()`/`[]`/`{}` or an unterminated string. Q14 fail-closed floor: a malformed
182
+ * statement must never yield a confident command, so the resolver degrades it
183
+ * to `unresolved` and does not descend into it. Statement-local; the
184
+ * segmenter's document-level defects are surfaced separately on the envelope.
185
+ */
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);
189
+ const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
190
+ const stack: { char: string; statements: boolean }[] = [];
191
+ for (let i = 0; i < masked.length; i++) {
192
+ const c = masked[i];
193
+ if (c === '"') {
194
+ const str = scanQuotedString(masked, i);
195
+ if (!str.closed)
196
+ return "structural defect: unbalanced delimiter or string";
197
+ i = str.end - 1;
198
+ continue;
199
+ }
200
+ if (
201
+ c === "#" &&
202
+ hashStartsHardError(masked, i, stack.at(-1)?.statements === false)
203
+ )
204
+ return "structural defect: invalid unquoted hash";
205
+ if (c === "(" || c === "[" || c === "{")
206
+ stack.push({
207
+ char: c,
208
+ statements:
209
+ c === "[" ||
210
+ (c === "{" &&
211
+ (stack.at(-1)?.statements ?? true) &&
212
+ braceStartsStatements(masked, i)),
213
+ });
214
+ else if (c === ")" || c === "]" || c === "}") {
215
+ if (stack.pop()?.char !== openOf[c])
216
+ return "structural defect: unbalanced delimiter or string";
217
+ }
218
+ }
219
+ return stack.length > 0
220
+ ? "structural defect: unbalanced delimiter or string"
221
+ : null;
222
+ }
223
+
224
+ /**
225
+ * The `unresolved` reason of a statement (or bracket) that needed the document
226
+ * context and could not have it. Distinct from every other reason: the
227
+ * statement itself is perfectly readable — what failed is upstream.
228
+ */
229
+ const CONTEXT_LOST = "context lost to an earlier unreadable statement";
230
+
231
+ /** R11 — a directive written without its colon, told by its scope block. */
232
+ function isBareDirective(trimmed: string): boolean {
233
+ return (
234
+ !trimmed.startsWith(":") &&
235
+ !trimmed.startsWith("/") &&
236
+ scopeBodies(trimmed).length > 0
237
+ );
238
+ }
239
+
240
+ /**
241
+ * Does this statement resolve its OWN path without consulting the document
242
+ * context? Mirrors `canonicalPath`'s `base` exactly — absolute, `:` directive,
243
+ * or bare directive (R11) all resolve at the root.
244
+ */
245
+ function isContextIndependent(text: string): boolean {
246
+ const t = trimAscii(text);
247
+ return t.startsWith(":") || t.startsWith("/") || isBareDirective(t);
248
+ }
249
+
250
+ /**
251
+ * Does the context this statement hands its BLOCK BODIES (and its own brackets,
252
+ * R3) ignore the document context? Mirrors `statementPath`'s base, which is NOT
253
+ * the same predicate as `isContextIndependent`: a `:` directive resolves itself
254
+ * at the root but hands its body the context in force (R5), so `:foreach … do={
255
+ * add … }` still depends on an upstream context its own path does not.
256
+ */
257
+ function isBodyContextIndependent(text: string): boolean {
258
+ const t = trimAscii(text);
259
+ return t.startsWith("/") || isBareDirective(t);
260
+ }
261
+
262
+ /**
263
+ * Does this text NAME a menu path that offline could not read?
264
+ *
265
+ * True when the leading run starts out path-shaped and is then TRUNCATED by a
266
+ * variable segment: `/ip/$menu/remove`, `/ip route/$menu/remove`, and the
267
+ * relative `route/$verb`. R7 says such a path is unresolved, so nothing may
268
+ * inherit a base derived from it.
269
+ *
270
+ * Two traps this exists to avoid, both found in review:
271
+ * - `statementRun` DISCARDS the whole word carrying the variable, so after a
272
+ * static word (`/ip route/$menu/…`) its output is a clean, variable-free
273
+ * prefix and the statement reads as perfectly resolvable. The signal has to
274
+ * be taken from the raw words, not from the collected run.
275
+ * - Spelling is not the discriminator. Mixed space/slash menu spelling is a
276
+ * supported contract, and a RELATIVE head is just as much a menu name, so
277
+ * this must not test for a leading `/`.
278
+ *
279
+ * What it deliberately does NOT match is text that names no menu at all — a
280
+ * head that is a variable, group, string or directive (`$fn [get …]`,
281
+ * `-z "$URL"`, `iter(hip)`), and a `$` word in ARGUMENT position
282
+ * (`get $item time`), which is a positional operand rather than a path
283
+ * segment. Those establish no menu, so the ambient context still governs.
284
+ */
285
+ function isUnreadablePath(text: string): boolean {
286
+ const t = trimAscii(text);
287
+ if (t.length === 0) return false;
288
+ // A head that is not path-shaped names no menu.
289
+ if (/^[$[("':]/.test(t)) return false;
290
+ for (const word of asciiWords(t)) {
291
+ // Arguments begin: everything before this was readable. A `$` word
292
+ // STANDING ALONE is a positional operand (`get $item time`), not a path
293
+ // segment — only a `$` slash-joined inside a word is a menu segment.
294
+ if (word.includes("=") || /^[[({"$:]/.test(word)) return false;
295
+ const parts = word.split("/").filter((p) => p.length > 0);
296
+ if (parts.length === 0) continue;
297
+ for (const part of parts) {
298
+ if (BARE_WORD.test(part)) continue;
299
+ // A whole segment that is a variable is the unreadable-menu case.
300
+ if (isVariableSegment(part)) return true;
301
+ // Anything else is not a path segment at all, so the run ended here and
302
+ // everything before it was readable. This is why the test cannot simply
303
+ // look for a `$` anywhere: `print where comment~[$strfind …]` and
304
+ // `/ppp secret print where comment~"\\$SECRET"` carry a `$` inside an
305
+ // ARGUMENT — the `~` means no `=` ends the run first — and both name a
306
+ // perfectly readable menu.
307
+ return false;
308
+ }
309
+ }
310
+ return false;
311
+ }
312
+
313
+ /**
314
+ * A `/`-led statement whose leading run offline cannot read — a variable
315
+ * segment truncates it (`/ip/$menu`, `/interface/$type/print`). It is a
316
+ * navigation to a computed menu exactly as plausibly as it is a command, so the
317
+ * context after it is unknown. `menuNavPath` has already claimed every readable
318
+ * absolute navigation before this is consulted.
319
+ */
320
+ function isUnreadableAbsolute(text: string): boolean {
321
+ const t = trimAscii(text);
322
+ if (!t.startsWith("/")) return false;
323
+ if (isUnreadablePath(t)) return true;
324
+ const tokens = statementRun(t);
325
+ return tokens.length === 0 || tokens.some((x) => x.startsWith("$"));
326
+ }
327
+
328
+ /**
329
+ * Is the context this statement hands its own brackets (R3) and its block
330
+ * bodies (R5) KNOWN?
331
+ *
332
+ * Two conditions, and BOTH are needed. The base must be knowable — the
333
+ * statement is `/`-led or a bare directive, or the document context is certain.
334
+ * And the statement's own leading run must be READABLE: `/ip/$menu/remove` is
335
+ * `/`-led, so its base does not depend on the document context, but `R7` says a
336
+ * variable path segment leaves the path unresolved, and `statementPath` derives
337
+ * a menu from it anyway (`/ip/$menu`). Anchoring brackets to that hands them a
338
+ * fabricated base — `[find]` at a "path" containing a literal `$menu` — while
339
+ * `resolveStatements` refuses the very same statement. So an unreadable menu
340
+ * anchors nothing, in EITHER spelling: `statementPath` derives a base from a
341
+ * relative one too (`route/$verb remove [find]` yields `<ctx>/route/$verb`).
342
+ */
343
+ function handsKnownContext(text: string, contextCertain: boolean): boolean {
344
+ // Either spelling of an unreadable menu anchors nothing. `statementPath`
345
+ // derives a base from a RELATIVE one too (`route/$verb remove [find]` yields
346
+ // `<ctx>/route/$verb`), so this cannot test only the absolute form.
347
+ if (isUnreadableAbsolute(text) || isUnreadablePath(text)) return false;
348
+ return contextCertain || isBodyContextIndependent(text);
349
+ }
350
+
351
+ /** A re-constituted `[…]` command substitution (Q3). */
352
+ export interface Resolution {
353
+ /** Raw text inside the brackets. */
354
+ inner: string;
355
+ /** Context in force at the bracket, `/` when at document root. */
356
+ context: string;
357
+ /** Leading `/`-path token run of the inner command, context applied. */
358
+ tokens: string[];
359
+ /**
360
+ * Every path the inner command could resolve to: the context alone, then the
361
+ * context extended by each leading token in turn. Offline cannot pick one
362
+ * without knowing which token is the verb (Q6); `path` is the best guess.
363
+ */
364
+ candidates: string[];
365
+ /** Best single guess: the first bare token is the verb (Q1's rule). */
366
+ path: string | null;
367
+ unresolved?: string;
368
+ /**
369
+ * Was the menu context this bracket inherited (R3) known? False once an
370
+ * earlier unreadable statement destroyed it — a RELATIVE inner command is
371
+ * then `unresolved` rather than resolved against a stale value.
372
+ */
373
+ contextCertain: boolean;
374
+ /** Nesting depth of this bracket (0 = directly in a statement). */
375
+ depth: number;
376
+ /** Feature class, for per-class reporting. */
377
+ klass: string;
378
+ /**
379
+ * The `[…]` in DOCUMENT analyzed-byte space, closing bracket included. Widens
380
+ * to the enclosing statement where interior offsets cannot be mapped — see
381
+ * {@link Loc}.
382
+ */
383
+ span: Span;
384
+ }
385
+
386
+ /** The canonical path of one statement in source order (Q4). */
387
+ export interface StatementResolution {
388
+ text: string;
389
+ /** Offline read this statement as menu navigation, not a command. */
390
+ isNav: boolean;
391
+ /** Context in force BEFORE this statement. */
392
+ context: string;
393
+ /**
394
+ * Fully-qualified canonical path, menu AND verb (`/ip/route/add`). Where the
395
+ * path stops and POSITIONAL arguments begin is still a Q6 boundary: `path` is
396
+ * the greedy full-run reading and `candidates` carries the shorter readings.
397
+ */
398
+ path: string | null;
399
+ /** Context extended by each prefix of the leading run, shortest first. */
400
+ candidates?: string[];
401
+ unresolved?: string;
402
+ /**
403
+ * Was the menu context in force BEFORE this statement known? False once an
404
+ * earlier unreadable statement destroyed it (Q14 C3b). A context-DEPENDENT
405
+ * statement is then `unresolved`; a context-INDEPENDENT one still resolves,
406
+ * so a `false` here does not by itself invalidate `path`.
407
+ */
408
+ contextCertain: boolean;
409
+ /** This statement in DOCUMENT analyzed-byte space. */
410
+ span: Span;
411
+ }
412
+
413
+ /** Q3 result: bracket resolutions plus any structural / over-depth defects. */
414
+ export interface DocumentAnalysis {
415
+ resolutions: Resolution[];
416
+ /**
417
+ * Located defects surfaced from the segmenter (`unclosed`, `unbalanced-close`,
418
+ * `unterminated-string`) plus `over-depth` when a bounded traversal abstained,
419
+ * and defects raised inside a block body. A caller must not treat resolutions
420
+ * as confident while a NON-POSITIONAL defect is present — `isPositionalFact`
421
+ * in `defects.ts` is that test.
422
+ */
423
+ defects: Defect[];
424
+ }
425
+
426
+ /** Q4 result: per-statement resolutions plus structural / over-depth defects. */
427
+ export interface StatementAnalysis {
428
+ statements: StatementResolution[];
429
+ /** As {@link DocumentAnalysis.defects}. */
430
+ defects: Defect[];
431
+ }
432
+
433
+ /** A half-open analyzed-byte span. */
434
+ export interface Span {
435
+ start: number;
436
+ end: number;
437
+ }
438
+
439
+ /**
440
+ * Where the text a walker is scanning sits in the DOCUMENT.
441
+ *
442
+ * `base` is the document analyzed-byte offset that index 0 of that text maps to,
443
+ * or -1 when the mapping is unavailable. It goes to -1 for exactly one reason:
444
+ * a statement's `text` is the ORIGINAL substring (UTF-16 code units), so an
445
+ * index into it is an analyzed-byte offset only while the statement is pure
446
+ * ASCII. Once -1 it stays -1 for everything nested inside.
447
+ *
448
+ * The check for that is a length comparison, and it is exact rather than a
449
+ * heuristic: every non-ASCII code point has a UTF-8 length strictly greater than
450
+ * its UTF-16 length (2>1, 3>1, 4>2), so the two lengths agree if and only if
451
+ * every character is ASCII.
452
+ *
453
+ * `fallback` is the enclosing statement's span, used when `base` is -1. Widening
454
+ * a defect to its statement is the fail-closed choice in the Q14 sense: a region
455
+ * that is too WIDE still points the reader at real text, while one computed from
456
+ * a mismatched index space points confidently at the wrong byte.
457
+ */
458
+ interface Loc {
459
+ base: number;
460
+ fallback: Span;
461
+ }
462
+
463
+ /** A `[start, end)` of the scanned text, in document space. */
464
+ function spanIn(loc: Loc, start: number, end: number): Span {
465
+ return loc.base < 0
466
+ ? { start: loc.fallback.start, end: loc.fallback.end }
467
+ : { start: loc.base + start, end: loc.base + end };
468
+ }
469
+
470
+ /**
471
+ * A defect region for `[start, end)` of the scanned text, never zero-width.
472
+ *
473
+ * An empty extent is reachable — `do={}` at the block-depth limit, `[]` at the
474
+ * bracket limit — and `Defect` contracts `end > start`, so an empty one falls
475
+ * back to the enclosing statement rather than emitting `start === end`.
476
+ */
477
+ function regionIn(loc: Loc, start: number, end: number): Span {
478
+ return end > start
479
+ ? spanIn(loc, start, end)
480
+ : { start: loc.fallback.start, end: loc.fallback.end };
481
+ }
482
+
483
+ /** Move `loc` to a nested text beginning at index `start` of the current one. */
484
+ function nest(loc: Loc, start: number, fallback: Span): Loc {
485
+ return { base: loc.base < 0 ? -1 : loc.base + start, fallback };
486
+ }
487
+
488
+ /** Carry `defects` (offsets into the scanned text) into document space. */
489
+ function attribute(defects: readonly Defect[], loc: Loc): Defect[] {
490
+ if (loc.base >= 0) return rebaseDefects(defects, loc.base);
491
+ return defects.map((d) =>
492
+ d.detail === undefined
493
+ ? { code: d.code, start: loc.fallback.start, end: loc.fallback.end }
494
+ : {
495
+ code: d.code,
496
+ start: loc.fallback.start,
497
+ end: loc.fallback.end,
498
+ detail: d.detail,
499
+ },
500
+ );
501
+ }
502
+
503
+ /** One statement to walk, already located in document space. */
504
+ interface Located {
505
+ text: string;
506
+ span: Span;
507
+ loc: Loc;
508
+ }
509
+
510
+ /**
511
+ * Segment `text` and locate every statement in document space, carrying the
512
+ * segmenter's own defects out with them.
513
+ *
514
+ * The offsets and defects a plain `segments.map((s) => s.text)` would discard
515
+ * are what every span and region downstream is built from.
516
+ */
517
+ function locate(
518
+ segmented: SegmentResult,
519
+ loc: Loc,
520
+ ): { units: Located[]; defects: Defect[] } {
521
+ const units = segmented.segments.map((s) => {
522
+ const span = spanIn(loc, s.start, s.end);
523
+ // `s.start`/`s.end` are analyzed bytes of THIS text; `s.text` is the
524
+ // original substring. Interior indices into `s.text` are analyzed-byte
525
+ // offsets only when the two lengths agree (see `Loc`).
526
+ const asciiOnly = s.end - s.start === s.text.length;
527
+ return {
528
+ text: s.text,
529
+ span,
530
+ loc:
531
+ loc.base < 0 || !asciiOnly
532
+ ? { base: -1, fallback: span }
533
+ : { base: loc.base + s.start, fallback: span },
534
+ };
535
+ });
536
+ // STRUCTURAL defects only. `segmentStatements` merges `coordinateDefects`
537
+ // into every result, so a nested call on a block body would re-run the
538
+ // coordinate pass over that SUBSTRING — and a coordinate fact is a property
539
+ // of the ROOT input, not of any slice of it. Two things went wrong when this
540
+ // filter was missing: a non-ASCII run already reported by the document pass
541
+ // came back a second time widened to its whole statement, and a U+FEFF in
542
+ // document-MIDDLE position was re-classified `bom` because it happens to be
543
+ // leading within the body — contradicting `coordinateDefects`' own contract.
544
+ // The entry points seed these once from the root `SegmentResult`.
545
+ const structural = segmented.defects.filter((d) => !isPositionalFact(d.code));
546
+ return { units, defects: attribute(structural, loc) };
547
+ }
548
+
549
+ /**
550
+ * The whole-document starting location. `base` is 0 and always valid — the
551
+ * segmenter's offsets are already document analyzed bytes — so the fallback is
552
+ * unreachable here and every statement gets its own from {@link locate}.
553
+ */
554
+ const DOCUMENT_LOC: Loc = { base: 0, fallback: { start: 0, end: 0 } };
555
+
556
+ /** Q3 — resolve every `[…]` command substitution in `text`. */
557
+ export function resolveDocument(text: string): DocumentAnalysis {
558
+ // Segment ONCE and hand the result to `locate` — `containsWrite` calls both
559
+ // entry points, and re-segmenting here would put a document through the
560
+ // segmenter four times on input its own comment calls "not cheap".
561
+ const segmented = segmentStatements(text);
562
+ // The ROOT segmentation is the only source of coordinate facts (`bom`,
563
+ // `non-ascii`); `locate` strips them so recursion cannot re-derive them in a
564
+ // substring's coordinate space.
565
+ const defects: Defect[] = [...segmented.defects];
566
+ const resolutions: Resolution[] = [];
567
+ const top = locate(segmented, DOCUMENT_LOC);
568
+ defects.push(...top.defects);
569
+ walk(top.units, "/", resolutions, 0, defects, true);
570
+ return { resolutions, defects: mergeDefects(defects) };
571
+ }
572
+
573
+ function walk(
574
+ units: Located[],
575
+ context: string,
576
+ out: Resolution[],
577
+ blockDepth: number,
578
+ defects: Defect[],
579
+ contextCertain: boolean,
580
+ ): void {
581
+ let ctx = context;
582
+ let certain = contextCertain;
583
+ for (const unit of units) {
584
+ const { text, loc } = unit;
585
+ // R4 — a menu-navigation statement moves the document context.
586
+ const nav = menuNavPath(text, ctx);
587
+ if (nav !== null) {
588
+ const relative = !trimAscii(text).startsWith("/");
589
+ // A `..` read against an unknown context stays unknown; an absolute
590
+ // navigation replaces it and re-establishes certainty.
591
+ if (relative && !certain) continue;
592
+ ctx = nav;
593
+ if (!relative) certain = true;
594
+ continue;
595
+ }
596
+ // Q14 fail-closed — a structurally malformed statement yields no confident
597
+ // bracket path and is not descended. It may have been a navigation, so the
598
+ // context after it is unknown (Q14 C3b, the same contract the statement
599
+ // walk applies — these two walks must move in lockstep).
600
+ if (structuralDefect(text) !== null) {
601
+ certain = false;
602
+ continue;
603
+ }
604
+ // #235 — a relative bare-word that abstained (neither table) poisons
605
+ // downstream context, so the next relative statement cannot fabricate.
606
+ const poisonAbstention = isAbstainedRelativeNav(text, ctx);
607
+ // R3 — the statement's own path is the context its brackets see.
608
+ const stmtCtx = statementPath(text, ctx);
609
+ // Derived BEFORE the poison, from the certainty in force at this statement:
610
+ // `handsKnownContext` answers about the context this statement HANDS DOWN,
611
+ // which its own unreadability already governs. An abstained nav poisons
612
+ // what follows, so its own body must already be uncertain.
613
+ const stmtCertain = poisonAbstention
614
+ ? false
615
+ : handsKnownContext(text, certain);
616
+ if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
617
+ collectBrackets(text, stmtCtx, 0, out, defects, stmtCertain, loc);
618
+ // R5 — block bodies inherit the context in force here.
619
+ for (const block of scopeBlocks(text)) {
620
+ if (blockDepth >= MAX_DEPTH) {
621
+ // The BLOCK that exceeded the limit, not the whole statement —
622
+ // narrowing regions is what this channel is for.
623
+ defects.push({
624
+ code: "over-depth",
625
+ ...regionIn(loc, block.start, block.start + block.body.length),
626
+ });
627
+ continue;
628
+ }
629
+ const bodyLoc = nest(loc, block.start, unit.span);
630
+ const body = locate(segmentStatements(block.body), bodyLoc);
631
+ defects.push(...body.defects);
632
+ walk(body.units, stmtCtx, out, blockDepth + 1, defects, stmtCertain);
633
+ }
634
+ }
635
+ }
636
+
637
+ /** Q4 — canonical path of every statement in source order. */
638
+ export function resolveStatements(text: string): StatementAnalysis {
639
+ const segmented = segmentStatements(text);
640
+ // As `resolveDocument`: coordinate facts come from the root only.
641
+ const defects: Defect[] = [...segmented.defects];
642
+ const statements: StatementResolution[] = [];
643
+ const top = locate(segmented, DOCUMENT_LOC);
644
+ defects.push(...top.defects);
645
+ walkStatements(top.units, "/", statements, 0, defects, true);
646
+ return { statements, defects: mergeDefects(defects) };
647
+ }
648
+
649
+ function walkStatements(
650
+ units: Located[],
651
+ context: string,
652
+ out: StatementResolution[],
653
+ blockDepth: number,
654
+ defects: Defect[],
655
+ contextCertain: boolean,
656
+ ): void {
657
+ let ctx = context;
658
+ let certain = contextCertain;
659
+ for (const unit of units) {
660
+ const { text, span } = unit;
661
+ const nav = menuNavPath(text, ctx);
662
+ if (nav !== null) {
663
+ // `..` ascends FROM the context, so it cannot be read while the context
664
+ // is unknown. A `/`-led navigation (and a bare `/`) REPLACES it (R4) and
665
+ // therefore re-establishes certainty.
666
+ const relative = !trimAscii(text).startsWith("/");
667
+ if (relative && !certain) {
668
+ out.push({
669
+ text,
670
+ isNav: true,
671
+ context: ctx,
672
+ path: null,
673
+ unresolved: CONTEXT_LOST,
674
+ contextCertain: false,
675
+ span,
676
+ });
677
+ continue;
678
+ }
679
+ out.push({
680
+ text,
681
+ isNav: true,
682
+ context: ctx,
683
+ path: nav,
684
+ contextCertain: certain,
685
+ span,
686
+ });
687
+ ctx = nav;
688
+ if (!relative) certain = true;
689
+ continue;
690
+ }
691
+ // Q14 fail-closed — a malformed statement degrades to unresolved and is
692
+ // not descended; it does not move the context either. It may however have
693
+ // BEEN a navigation, so what the context now is becomes unknown.
694
+ const defectReason = structuralDefect(text);
695
+ if (defectReason !== null) {
696
+ out.push({
697
+ text,
698
+ isNav: false,
699
+ context: ctx,
700
+ path: null,
701
+ unresolved: defectReason,
702
+ contextCertain: certain,
703
+ span,
704
+ });
705
+ certain = false;
706
+ continue;
707
+ }
708
+ // #235 — a relative bare-word that abstained (neither table) poisons
709
+ // downstream context. Capture before the push so the current statement's
710
+ // own `contextCertain` stays true while the following statements lose it.
711
+ const poisonAbstention = isAbstainedRelativeNav(text, ctx);
712
+ // Q14 C3b — the cascade. A statement that CONSUMES the context cannot be
713
+ // resolved against a value the resolver has already admitted it lost.
714
+ // Applied to the RESOLUTION, not ahead of it, so a statement that would
715
+ // have refused on its own terms keeps its own — more specific — reason.
716
+ const resolved = canonicalPath(text, ctx);
717
+ out.push({
718
+ text,
719
+ isNav: false,
720
+ context: ctx,
721
+ ...(resolved.path !== null && !certain && !isContextIndependent(text)
722
+ ? { path: null, unresolved: CONTEXT_LOST }
723
+ : resolved),
724
+ contextCertain: certain,
725
+ span,
726
+ });
727
+ // A block body's statements are the parent's siblings after flattening,
728
+ // and R5 gives them the context in force here — which is the ROOT, hence
729
+ // knowable, when the statement spells its path out or is a bare directive,
730
+ // and unknowable when the statement's own path could not be read. Same
731
+ // predicate and same order as the bracket walk, per the lockstep rule.
732
+ const stmtCtx = statementPath(text, ctx);
733
+ const bodyCertain = poisonAbstention
734
+ ? false
735
+ : handsKnownContext(text, certain);
736
+ if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
737
+ for (const block of scopeBlocks(text)) {
738
+ if (blockDepth >= MAX_DEPTH) {
739
+ defects.push({
740
+ code: "over-depth",
741
+ ...regionIn(unit.loc, block.start, block.start + block.body.length),
742
+ });
743
+ continue;
744
+ }
745
+ const bodyLoc = nest(unit.loc, block.start, span);
746
+ const body = locate(segmentStatements(block.body), bodyLoc);
747
+ defects.push(...body.defects);
748
+ walkStatements(
749
+ body.units,
750
+ stmtCtx,
751
+ out,
752
+ blockDepth + 1,
753
+ defects,
754
+ bodyCertain,
755
+ );
756
+ }
757
+ }
758
+ }
759
+
760
+ /**
761
+ * `/ip address` alone on a statement → the new context. Otherwise null.
762
+ *
763
+ * R8/R13 — `..` ascends, a bare `/` resets, and a relative bare path advances
764
+ * only when a shipped structure table confirms the context-applied menu
765
+ * ({@link readRelativeBare}). An absolute navigation REPLACES the context
766
+ * rather than extending it; a relative one EXTENDS it.
767
+ *
768
+ * R9 (#211 B1) — a `/`-led bare path whose run carries a FROZEN-VOCABULARY VERB
769
+ * is a command, not navigation. Without this the shape test alone claimed
770
+ * `/ip address print` as a navigation to `/ip/address/print` — a menu that does
771
+ * not exist — and then resolved the rest of the document against it. Q6 already
772
+ * decides these statements: `verbsplit` reads the very same text as verb `print`
773
+ * at `/ip/address`, so the two promoted modules returned contradictory answers
774
+ * for 34 dev / 13 holdout statements, `verbsplit` right in every one. The verb
775
+ * set is deliberately the SAME `VERBS` object and the same case-sensitive
776
+ * lookup, so the modules cannot drift apart; `explain-pathresolve.test.ts` pins
777
+ * the invariant that a `verbsplit`-`resolved` statement is never `isNav`.
778
+ *
779
+ * This costs no abstention — the statements it removes from navigation were
780
+ * already decided, just decided wrongly. It also does not collide with any menu
781
+ * the generated table knows: no path in `MENU_PATHS` carries a verb segment,
782
+ * asserted in the same test so a regeneration that introduced one fails. That
783
+ * is a claim about the TABLE, not about RouterOS — `MENU_PATHS` is a floor
784
+ * (#207), so a real menu absent from it and named `.../print` would still be
785
+ * read as a command here. Nothing in the corpus or the four pinned trees looks
786
+ * like that, and such a menu would be indistinguishable from `print` at its
787
+ * parent anyway.
788
+ *
789
+ * R12 (#228 step 2) — a `/`-led bare path the published CATALOG names as a
790
+ * command is not navigation either. This is the harder half R9 could not reach
791
+ * (#211 B2): `/system/reboot`, `/quit` and `/tool/speed-test` carry no
792
+ * vocabulary verb, and #211 could only have refused them by reading ABSENCE from
793
+ * `MENU_PATHS` as evidence of a command — which inverts the floor contract
794
+ * (#207) and is why B2 was parked rather than guessed at. The command axis is
795
+ * the missing POSITIVE evidence, so nothing about the floor changes: presence in
796
+ * either table decides, absence from both still abstains.
797
+ *
798
+ * It is also no longer optional. `verbsplit` reads `/system reboot` as verb
799
+ * `reboot` at `/system`, so leaving it navigation here would re-open exactly the
800
+ * contradiction R9 closed — and worse than before, since the two modules would
801
+ * now disagree CONFIDENTLY rather than one of them refusing. The same test pins
802
+ * the invariant for both rules.
803
+ */
804
+ function menuNavPath(text: string, ctx: string): string | null {
805
+ const trimmed = trimAscii(text);
806
+ if (trimmed === "/") return "/";
807
+ if (/^\.\.(?:[ \t\r\n]|$)/.test(trimmed)) {
808
+ const tokens = asciiWords(trimmed);
809
+ if (!tokens.every((token) => token === "..")) return null;
810
+ return joinPath(ctx, tokens.join("/"));
811
+ }
812
+ if (!trimmed.startsWith("/")) {
813
+ // #235 R13 — relative bare-word menu navigation.
814
+ const reading = readRelativeBare(text, ctx);
815
+ return reading?.kind === "nav" ? reading.path : null;
816
+ }
817
+ if (/[=[({"$]/.test(trimmed)) return null;
818
+ const tokens = asciiWords(trimmed);
819
+ if (
820
+ !tokens.every((token, i) =>
821
+ i === 0 ? isAbsolutePathToken(token) : BARE_WORD.test(token),
822
+ )
823
+ )
824
+ return null;
825
+ // R9 — the run is verb-free, or this is not navigation. Tokens may be
826
+ // slash-joined (`/ip/address/print` is one token), so the verb test has to run
827
+ // over path SEGMENTS, exactly as `verbsplit`'s `runTokens` splits them.
828
+ const segments = tokens.flatMap((token) =>
829
+ token.split("/").filter((part) => part.length > 0),
830
+ );
831
+ if (segments.some((segment) => VERBS.has(segment))) return null;
832
+ // R12 — the same segments, against the published command axis.
833
+ if (commandVerbIndex(segments) !== null) return null;
834
+ return joinPath("/", tokens.join("/"));
835
+ }
836
+
837
+ /**
838
+ * How a RELATIVE bare-path statement reads against `ctx` (#235, R13).
839
+ *
840
+ * - `nav` — a shipped table confirms the context-applied path, so it navigates.
841
+ * - `abstain` — the statement HAS the shape of relative navigation but neither
842
+ * table knows the joined path. Offline cannot say which it was, so the caller
843
+ * poisons what follows rather than resolve it against a context that may
844
+ * already have moved.
845
+ * - `null` — not a relative bare path at all: absolute, dynamic, a directive,
846
+ * or a run carrying a vocabulary verb / published command, which is a
847
+ * COMMAND and therefore not a maybe-navigation either.
848
+ *
849
+ * ONE classifier rather than a shape test per question. The nav reading and the
850
+ * poison test must agree about which statements are in scope; written twice
851
+ * they can drift, and a drift there is silently either a lost navigation or a
852
+ * poisoned document.
853
+ */
854
+ type RelativeBareReading = { kind: "nav"; path: string } | { kind: "abstain" };
855
+
856
+ function readRelativeBare(
857
+ text: string,
858
+ ctx: string,
859
+ ): RelativeBareReading | null {
860
+ const trimmed = trimAscii(text);
861
+ if (
862
+ trimmed.length === 0 ||
863
+ trimmed.startsWith("/") ||
864
+ trimmed.startsWith(":") ||
865
+ trimmed.startsWith("$") ||
866
+ trimmed.startsWith("[") ||
867
+ trimmed.startsWith("(") ||
868
+ isBareDirective(trimmed)
869
+ )
870
+ return null;
871
+ // Whitespace and `/` are interchangeable on the device, so the run is read
872
+ // over path SEGMENTS exactly as R9 reads an absolute one: `firewall filter`
873
+ // and `firewall/filter` under `/ip` are the same navigation (CHR 7.24rc1 —
874
+ // see the `pathresolve.json` corner). A `..`/`.` segment fails `BARE_WORD`,
875
+ // so the `..` form above stays the only ascent this function admits.
876
+ const parts = asciiWords(trimmed).flatMap((word) => word.split("/"));
877
+ if (!parts.every((part) => BARE_WORD.test(part))) return null;
878
+ const path = joinPath(ctx, parts.join("/"));
879
+ const segments = path.split("/").filter(Boolean);
880
+ // R9/R12 on the JOINED path — a verb-bearing or published-command path is a
881
+ // command, not navigation, and not an abstention either: `address print`
882
+ // under `/ip` is verb `print` at `/ip/address`, which `verbsplit` decides.
883
+ if (segments.some((segment) => VERBS.has(segment))) return null;
884
+ if (commandVerbIndex(segments) !== null) return null;
885
+ return isKnownMenuPath(segments)
886
+ ? { kind: "nav", path }
887
+ : { kind: "abstain" };
888
+ }
889
+
890
+ /**
891
+ * #235 — does `text` look like a relative menu navigation that ABSTAINED?
892
+ *
893
+ * Presence in a table → nav; absence → abstain → poison downstream, so the
894
+ * next relative statement cannot resolve against a context that may have moved.
895
+ */
896
+ function isAbstainedRelativeNav(text: string, ctx: string): boolean {
897
+ return readRelativeBare(text, ctx)?.kind === "abstain";
898
+ }
899
+
900
+ function canonicalPath(
901
+ text: string,
902
+ ctx: string,
903
+ ): { path: string | null; candidates?: string[]; unresolved?: string } {
904
+ const t = trimAscii(text);
905
+ if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
906
+ return {
907
+ path: null,
908
+ unresolved: "dynamic or substitution-headed statement",
909
+ };
910
+ // A `:` scripting directive is always at the ROOT, whatever the context —
911
+ // but it is not necessarily one segment: `:log info "x"` is `/log/info`.
912
+ const body = t.startsWith(":") ? t.slice(1) : t;
913
+ // R11 — a scripting directive written WITHOUT its colon is still at the root.
914
+ // A statement that carries a SCOPE-valued block is a directive, because no
915
+ // menu command takes one — a schema-free tell.
916
+ const bareDirective = isBareDirective(t);
917
+ // The cascade contract reads this same split to decide which statements may
918
+ // keep resolving once the context is lost, so both must come from one place.
919
+ const base = isContextIndependent(t) ? "/" : ctx;
920
+ // R7 — a variable segment anywhere in the leading run leaves the path
921
+ // unresolved, INCLUDING after a static word. `statementRun` drops the whole
922
+ // word carrying the variable, so `/ip route/$menu/remove` would otherwise
923
+ // report a confident, truncated `/ip`.
924
+ if (isUnreadablePath(t))
925
+ return { path: null, unresolved: "variable path segment" };
926
+ const tokens = statementRun(body);
927
+ if (tokens.length === 0)
928
+ return { path: null, unresolved: "no leading path token" };
929
+ if (tokens.some((x) => x.startsWith("$")))
930
+ return { path: null, unresolved: "variable path segment" };
931
+ const candidates: string[] = [];
932
+ for (let k = 1; k <= tokens.length; k++)
933
+ candidates.push(joinPath(base, tokens.slice(0, k).join("/")));
934
+ // R10 — the path/positional boundary, split by statement kind: a `:` (or
935
+ // bare) directive takes its operand positionally, so its path is the
936
+ // directive alone; a menu command spells its whole menu out.
937
+ const path =
938
+ t.startsWith(":") || bareDirective
939
+ ? (candidates[0] as string)
940
+ : joinPath(base, tokens.join("/"));
941
+ return { path, candidates };
942
+ }
943
+
944
+ /**
945
+ * Leading path-token run of a statement. Stops at the first token that cannot
946
+ * be a path segment: an argument (`x=1`), a group, a quoted string, or anything
947
+ * not shaped like an identifier — a bare `9` is a positional argument, never a
948
+ * menu. It does NOT stop at identifier-shaped positional arguments (`enable
949
+ * www-ssl`); nothing offline can, and that residue is Q6.
950
+ */
951
+ function statementRun(text: string): string[] {
952
+ const out: string[] = [];
953
+ for (const t of asciiWords(text)) {
954
+ if (t.includes("=") || /^[[({"$:]/.test(t)) break;
955
+ const parts = t.split("/").filter((p) => p.length > 0);
956
+ if (parts.length === 0) continue;
957
+ if (!parts.every((p) => BARE_WORD.test(p))) break;
958
+ out.push(...parts);
959
+ }
960
+ return out;
961
+ }
962
+
963
+ /**
964
+ * The menu path a statement's own brackets inherit. For `/ip route remove
965
+ * [find …]` that is `/ip/route`: the leading path run minus its last token,
966
+ * which is the verb (Q6's reading, right ~93.7% of the time).
967
+ */
968
+ function statementPath(text: string, ctx: string): string {
969
+ const t = trimAsciiStart(text);
970
+ // R11 — a bare directive is at the root, so the context it hands its body is
971
+ // the root too, not `<ctx>/while`.
972
+ if (isBareDirective(trimAscii(t))) return "/";
973
+ const lead = leadingRun(text);
974
+ if (lead.length === 0) return ctx;
975
+ // Same split `isBodyContextIndependent` reports to the cascade contract.
976
+ const base = isBodyContextIndependent(t) ? "/" : ctx;
977
+ return joinPath(base, lead.slice(0, -1).join("/"));
978
+ }
979
+
980
+ /** Leading bare/path tokens, stopping at the first argument or group. */
981
+ function leadingRun(text: string): string[] {
982
+ const out: string[] = [];
983
+ for (const t of asciiWords(text)) {
984
+ if (t.includes("=") || /^[[({"$:]/.test(t)) break;
985
+ for (const part of t.split("/")) if (part.length > 0) out.push(part);
986
+ if (out.length === 0 && t === "/") out.push("");
987
+ }
988
+ return out;
989
+ }
990
+
991
+ function joinPath(base: string, rest: string): string {
992
+ const parts = [
993
+ ...base.split("/").filter(Boolean),
994
+ ...rest.split("/").filter((p) => p.length > 0),
995
+ ];
996
+ const stack: string[] = [];
997
+ for (const p of parts) {
998
+ if (p === "..") stack.pop();
999
+ else if (p !== ".") stack.push(p);
1000
+ }
1001
+ return `/${stack.join("/")}`;
1002
+ }
1003
+
1004
+ function collectBrackets(
1005
+ text: string,
1006
+ ctx: string,
1007
+ depth: number,
1008
+ out: Resolution[],
1009
+ defects: Defect[],
1010
+ contextCertain: boolean,
1011
+ loc: Loc,
1012
+ inLiteral = false,
1013
+ ): void {
1014
+ // Bound the recursion (bracket nesting AND literal-brace descent) so
1015
+ // untrusted deeply nested input abstains instead of overflowing the stack.
1016
+ 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) });
1020
+ return;
1021
+ }
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++) {
1027
+ const c = masked[i];
1028
+ if (c === '"') {
1029
+ // A string is opaque EXCEPT for `$[ … ]` interpolation, a real command
1030
+ // substitution: `:put "$[:pick $ip 0 $n]"` lowers with `/pick` inside
1031
+ // (topic-…/post-0003-snippet-01 @ 7.22.1).
1032
+ const strEnd = stringEnd(masked, i);
1033
+ scanInterpolations(
1034
+ masked.slice(i + 1, strEnd),
1035
+ ctx,
1036
+ depth,
1037
+ out,
1038
+ defects,
1039
+ contextCertain,
1040
+ nest(loc, i + 1, loc.fallback),
1041
+ );
1042
+ i = strEnd;
1043
+ continue;
1044
+ }
1045
+ if (c === "{") {
1046
+ // A literal body is a value (Q2), but can still contain command
1047
+ // substitutions: `{[/terminal/inkey]}` lowers the bracket. Descend for
1048
+ // 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("{")))
1053
+ collectBrackets(
1054
+ body,
1055
+ ctx,
1056
+ depth + 1,
1057
+ out,
1058
+ defects,
1059
+ contextCertain,
1060
+ nest(loc, i + 1, loc.fallback),
1061
+ true,
1062
+ );
1063
+ i = end;
1064
+ continue;
1065
+ }
1066
+ if (c !== "[") continue;
1067
+ const end = matchDelim(masked, i, "[", "]");
1068
+ const inner = trimAscii(masked.slice(i + 1, end));
1069
+ // The span covers the whole `[…]`, closing bracket included.
1070
+ out.push({
1071
+ ...resolveInner(inner, ctx, depth, contextCertain),
1072
+ span: spanIn(loc, i, Math.min(end + 1, text.length)),
1073
+ });
1074
+ // R6 — nested brackets inherit from this one's resolution.
1075
+ const nestedCtx = out[out.length - 1]?.path ?? ctx;
1076
+ collectBrackets(
1077
+ inner,
1078
+ nestedCtx,
1079
+ depth + 1,
1080
+ out,
1081
+ 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
+ ),
1093
+ );
1094
+ i = end;
1095
+ }
1096
+ }
1097
+
1098
+ /**
1099
+ * Index of the closing quote of the string starting at `open`. Delegates to the
1100
+ * shared `scanQuotedString`, so a `$[…]` substitution carrying its own string
1101
+ * does not end the outer one early and cut `scanInterpolations`' body short
1102
+ * (#199).
1103
+ */
1104
+ function stringEnd(text: string, open: number): number {
1105
+ const str = scanQuotedString(text, open);
1106
+ return str.closed ? str.end - 1 : text.length;
1107
+ }
1108
+
1109
+ /** `$[ … ]` command substitutions inside a string body. */
1110
+ function scanInterpolations(
1111
+ body: string,
1112
+ ctx: string,
1113
+ depth: number,
1114
+ out: Resolution[],
1115
+ defects: Defect[],
1116
+ contextCertain: boolean,
1117
+ loc: Loc,
1118
+ ): 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(
1130
+ inner,
1131
+ out[out.length - 1]?.path ?? ctx,
1132
+ depth + 1,
1133
+ out,
1134
+ defects,
1135
+ nestedCertainty(out[out.length - 1], contextCertain),
1136
+ nest(
1137
+ loc,
1138
+ i + 2 + (raw.length - trimAsciiStart(raw).length),
1139
+ loc.fallback,
1140
+ ),
1141
+ );
1142
+ i = end;
1143
+ }
1144
+ }
1145
+
1146
+ /**
1147
+ * Certainty a NESTED bracket inherits (R6). Three cases, and the middle one is
1148
+ * the whole subtlety:
1149
+ *
1150
+ * 1. The enclosing bracket RESOLVED — its path is the nested bracket's base,
1151
+ * so the nested one is anchored even while the document context is lost
1152
+ * (`[/ip/route/get [find …] gateway]` is absolute, hence independent).
1153
+ * 2. The enclosing bracket is a MENU COMMAND WE COULD NOT READ — then the
1154
+ * real base is a menu we cannot name, and `collectBrackets` falls back to
1155
+ * the STATEMENT's context, which is a different menu. In `/ip route` +
1156
+ * `remove [/interface/$type/get [find]]` that fallback reports the nested
1157
+ * `[find]` at `/ip/route`, a context it never had. It must abstain — and
1158
+ * equally for a RELATIVE unreadable menu (`[route/$verb [find]]`), because
1159
+ * spelling is not what makes a menu unreadable.
1160
+ * 3. The enclosing bracket is NOT A MENU COMMAND AT ALL — a user function
1161
+ * (`$formatDate`), a value expression, a `:` directive. It
1162
+ * establishes no menu, so the AMBIENT context still governs its arguments
1163
+ * and the fallback is correct. Corpus evidence for this being the common
1164
+ * shape rather than a corner: under `/log`,
1165
+ * `:set x "$[$formatDate [get $item time]]"` must keep resolving
1166
+ * `[get …]` at `/log` (rextended/topic-166898). Refusing case 3 as well
1167
+ * costs 12 further brackets and one document's `containsWrite` verdict,
1168
+ * all of them correct answers thrown away.
1169
+ *
1170
+ * Case 2 is told from case 3 by `isUnreadablePath`, the same test the statement
1171
+ * walk uses: the inner NAMES a menu (a path-shaped head) whose leading run is
1172
+ * truncated by a variable segment, in either the absolute or the relative
1173
+ * spelling. Case 3 is text that names no menu at all.
1174
+ */
1175
+ function nestedCertainty(
1176
+ enclosing: Pick<Resolution, "path" | "inner"> | undefined,
1177
+ contextCertain: boolean,
1178
+ ): boolean {
1179
+ if (enclosing === undefined) return contextCertain;
1180
+ if (enclosing.path !== null) return true;
1181
+ if (isUnreadablePath(enclosing.inner)) return false;
1182
+ return contextCertain;
1183
+ }
1184
+
1185
+ /**
1186
+ * Resolve one bracket body. The caller owns the `span`, because only it knows
1187
+ * where the bracket sat in the text it was scanning.
1188
+ */
1189
+ function resolveInner(
1190
+ inner: string,
1191
+ ctx: string,
1192
+ depth: number,
1193
+ contextCertain: boolean,
1194
+ ): Omit<Resolution, "span"> {
1195
+ const absolute = inner.startsWith("/");
1196
+ const klass = classify(inner, absolute, depth);
1197
+ if (klass === "cli-prompt-artifact")
1198
+ return {
1199
+ inner,
1200
+ context: ctx,
1201
+ tokens: [],
1202
+ candidates: [],
1203
+ path: null,
1204
+ unresolved: "looks like a pasted CLI prompt, not a substitution",
1205
+ contextCertain,
1206
+ depth,
1207
+ klass,
1208
+ };
1209
+ // A `:` scripting directive is ALWAYS at the root — `[:tostr $x]` lowers to
1210
+ // `(evl (evl /tostrvalue=$x))`, path `/`, whatever context is in force.
1211
+ if (trimAsciiStart(inner).startsWith(":"))
1212
+ return {
1213
+ inner,
1214
+ context: ctx,
1215
+ tokens: [],
1216
+ candidates: ["/"],
1217
+ path: "/",
1218
+ contextCertain,
1219
+ depth,
1220
+ klass,
1221
+ };
1222
+ const tokens = leadingRun(inner);
1223
+ if (tokens.length === 0)
1224
+ return {
1225
+ inner,
1226
+ context: ctx,
1227
+ tokens,
1228
+ candidates: [],
1229
+ path: null,
1230
+ unresolved: "no leading path token",
1231
+ contextCertain,
1232
+ depth,
1233
+ klass,
1234
+ };
1235
+ // R7 — a `$var` anywhere in the leading run makes the path unknowable.
1236
+ if (tokens.some((t) => t.startsWith("$")))
1237
+ return {
1238
+ inner,
1239
+ context: ctx,
1240
+ tokens,
1241
+ candidates: [],
1242
+ path: null,
1243
+ unresolved: "variable path segment",
1244
+ contextCertain,
1245
+ depth,
1246
+ klass,
1247
+ };
1248
+ // Q14 C3b — a RELATIVE inner command inherits the statement's menu (R2/R3),
1249
+ // so it cannot be re-constituted once that context is lost. Checked only once
1250
+ // the inner command has been read, so a bracket that refuses on its own terms
1251
+ // keeps its own reason; an absolute inner path and the `:` directive above are
1252
+ // context-independent and still resolve.
1253
+ if (!contextCertain && !absolute)
1254
+ return {
1255
+ inner,
1256
+ context: ctx,
1257
+ tokens,
1258
+ candidates: [],
1259
+ path: null,
1260
+ unresolved: CONTEXT_LOST,
1261
+ contextCertain,
1262
+ depth,
1263
+ klass,
1264
+ };
1265
+ const base = absolute ? "/" : ctx; // R1 / R2
1266
+ const candidates: string[] = [joinPath(base, "")];
1267
+ for (let k = 1; k < tokens.length; k++)
1268
+ candidates.push(joinPath(base, tokens.slice(0, k).join("/")));
1269
+ return {
1270
+ inner,
1271
+ context: ctx,
1272
+ tokens,
1273
+ candidates,
1274
+ // Absolute inner paths spell their menu out, so the verb is the last
1275
+ // segment of the slash-joined head; relative ones lead with the verb.
1276
+ path: absolute
1277
+ ? joinPath(base, tokens.slice(0, -1).join("/"))
1278
+ : joinPath(base, ""),
1279
+ contextCertain,
1280
+ depth,
1281
+ klass,
1282
+ };
1283
+ }
1284
+
1285
+ // `[admin@Router] > /ip address print` — a pasted terminal transcript. The
1286
+ // prompt is bracketed exactly like a command substitution, and offline has no
1287
+ // way to tell them apart by shape alone. Left unguarded it manufactures phantom
1288
+ // inner commands from any pasted session, which matters because `explain` takes
1289
+ // editor and MCP input. `user@host` with no command shape is the tell; the
1290
+ // resolver abstains rather than inventing a path.
1291
+ const CLI_PROMPT_RE = /^[^ \t\r\n@/[\]]+@[^ \t\r\n@/[\]]+$/;
1292
+
1293
+ function classify(inner: string, absolute: boolean, depth: number): string {
1294
+ if (CLI_PROMPT_RE.test(trimAscii(inner))) return "cli-prompt-artifact";
1295
+ if (depth > 0) return "nested-bracket";
1296
+ if (absolute) return "absolute-inner-path";
1297
+ const head = (asciiWords(inner)[0] ?? "").toLowerCase();
1298
+ if (head.startsWith("$")) return "dynamic-invocation";
1299
+ if (head.startsWith(":")) return "scripting-directive";
1300
+ if (head === "find") return "bare-find";
1301
+ return "bare-inner-command";
1302
+ }
1303
+
1304
+ function matchDelim(
1305
+ text: string,
1306
+ start: number,
1307
+ open: string,
1308
+ close: string,
1309
+ ): number {
1310
+ let depth = 0;
1311
+ for (let i = start; i < text.length; i++) {
1312
+ const c = text[i];
1313
+ if (c === '"') {
1314
+ i = scanQuotedString(text, i).end - 1;
1315
+ continue;
1316
+ }
1317
+ if (c === open) depth++;
1318
+ else if (c === close) {
1319
+ depth--;
1320
+ if (depth === 0) return i;
1321
+ }
1322
+ }
1323
+ return text.length;
1324
+ }