@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,813 @@
1
+ /**
2
+ * Verb / menu boundary resolution for `explain` (centrs canonicalizer).
3
+ *
4
+ * Ratified by the phase-0 lab, question Q6 (#185) and promoted from the
5
+ * throwaway probe `.scratch/explain-lab-verbsplit.ts`. Q6 asks the residual the
6
+ * path resolver (Q3/Q4) deliberately left open: within a statement's leading
7
+ * path run, where does the MENU end and the VERB begin — and when must offline
8
+ * refuse to guess? `pathresolve.ts` reports a greedy full-run `path` plus the
9
+ * shorter `candidates`; this module decides the boundary and, crucially, emits
10
+ * the ratified **`ambiguous`** verdict where a schema-free reading genuinely
11
+ * cannot.
12
+ *
13
+ * The lab priced seven boundary policies (`minimal`, `greedy`, `last`,
14
+ * `punctuation`, `vocab`, `abstain`, `proposed`) against the device command
15
+ * tree as oracle. Only the ratified `proposed` arm is promoted — it scored
16
+ * ~99.9% precision on decided statements at ~3% abstention and is entirely
17
+ * version-independent (no schema snapshot, per decision 3). The other six arms
18
+ * existed only to price it and stayed in the lab.
19
+ *
20
+ * The signals, all schema-free and read off the statement's own text:
21
+ * V1 Every run token carries a separator: `start`, `slash`, `space`.
22
+ * RouterOS console convention writes menu segments slash-joined and the
23
+ * verb space-separated, so the writer's punctuation is a boundary signal
24
+ * that Q3/Q4's flattened run threw away.
25
+ * V2 A token containing `=` is an ARGUMENT and ends the run. The only
26
+ * certain verb/arg tell offline has.
27
+ * V3 A `[`, `(`, `"`, `$` token ends the run.
28
+ * V4 Whether anything FOLLOWS the run is itself a signal. A statement that
29
+ * is nothing but a path (`/ip/address` vs `/system/reboot`) is textually
30
+ * identical whether it navigates to a directory or invokes a no-argument
31
+ * command — Q4's cascade residual. This is the ONLY case `proposed`
32
+ * abstains on, and it carries no length floor.
33
+ *
34
+ * The `proposed` rule, in order: a scripting directive's operand is positional
35
+ * (R10) with the one measured sub-menu exception (`:log <level>`); then a
36
+ * FROZEN 13-verb CRUD vocabulary; then punctuation (the maximal slash-joined
37
+ * prefix is menu, the first space token is the verb); and abstention ONLY at
38
+ * V4's bare path.
39
+ *
40
+ * **V4 is now decidable where the baked container table reaches it (#210).**
41
+ * `splitRun` below still implements `proposed` exactly as ratified — it is the
42
+ * schema-free rule, and it stays that way so its abstention rate remains an
43
+ * honest price for decision 3. `resolveVerb` then reads that `ambiguous`
44
+ * verdict and, for the bare-path case ONLY, asks `menus.ts` whether the path
45
+ * the statement names is a known RouterOS container; if it is, the statement is
46
+ * `navigation` rather than a refusal. `/ip/address` is in the table and
47
+ * `/system/reboot` is not, which is precisely the twin V4 could not separate.
48
+ * A path the table does not carry stays `ambiguous` — the table is a floor, so
49
+ * absence is not evidence of a command (see `menus.ts`).
50
+ *
51
+ * That makes confident menu navigation this module's surface after all, but on
52
+ * a firmer basis than `pathresolve.ts`'s `isNav`, which claims navigation for
53
+ * every `/`-led bare path unconditionally and so reports `/system/reboot` as a
54
+ * menu (#211). Where the two disagree, this module is the narrower answer.
55
+ *
56
+ * **V4's other half is decidable too (#228).** `catalog.ts` carries a published
57
+ * COMMAND axis beside the container table, so the twin `splitRun` cannot
58
+ * separate is now separated from both ends: `/ip/address` is a known menu and
59
+ * `/system/reboot` is a known command. See {@link catalogVerbAt} for the rule,
60
+ * its precedence over the punctuation guess, and the two places it deliberately
61
+ * does not apply. `splitRun` is again untouched — both tables are consumed here.
62
+ *
63
+ * Like `blocks.ts`, this ships only the boundary primitives. Argument parsing
64
+ * (`k=v` / `?query` after the verb) belongs to the phase-1 canonical assembly,
65
+ * not to the Q6 question. `resolveVerb(text, context)` takes the enclosing menu
66
+ * context explicitly; `resolveVerbs(text)` walks a whole document on top of the
67
+ * resolver's context-certainty contract (#192).
68
+ */
69
+
70
+ import { scopeBodies } from "./blocks.ts";
71
+ import { commandVerbIndex } from "./catalog.ts";
72
+ import type { Defect } from "./defects.ts";
73
+ import { isKnownMenuPath } from "./is-known-menu.ts";
74
+ import {
75
+ resolveStatements,
76
+ type Span,
77
+ type StatementAnalysis,
78
+ } from "./pathresolve.ts";
79
+ import { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
80
+
81
+ const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
82
+ /**
83
+ * A word that ends the leading run: it carries `=` (V2) or opens a group /
84
+ * string / variable (V3). `:` is deliberately NOT here — RouterOS uses it only
85
+ * to start a scripting directive, not as a mid-statement opener, and a
86
+ * `:`-prefixed token is already rejected by `BARE_WORD` below, so it ends the
87
+ * run regardless.
88
+ */
89
+ const RUN_TERMINATOR = /^[[({"$]/;
90
+
91
+ function isAsciiWhitespace(char: string | undefined): boolean {
92
+ return char === " " || char === "\t" || char === "\r" || char === "\n";
93
+ }
94
+
95
+ function trimAscii(text: string): string {
96
+ let start = 0;
97
+ let end = text.length;
98
+ while (start < end && isAsciiWhitespace(text[start])) start++;
99
+ while (end > start && isAsciiWhitespace(text[end - 1])) end--;
100
+ return text.slice(start, end);
101
+ }
102
+
103
+ /** Leading trim only — the half `describeStatement` has to count to rebase offsets. */
104
+ function trimAsciiStart(text: string): string {
105
+ let start = 0;
106
+ while (start < text.length && isAsciiWhitespace(text[start])) start++;
107
+ return text.slice(start);
108
+ }
109
+
110
+ /** One whitespace-separated word, and where it sits in the text it came from. */
111
+ interface Word {
112
+ name: string;
113
+ /** offset of the word's first character, inclusive. */
114
+ start: number;
115
+ /** offset just past its last character, exclusive. */
116
+ end: number;
117
+ /** Source span of each character retained in `name`. */
118
+ chars: Span[];
119
+ }
120
+
121
+ /**
122
+ * The words of a statement, WITH their offsets (#202c).
123
+ *
124
+ * The one word scanner this module has. It replaced a
125
+ * `replace(/\\\r?\n/g,"").split(whitespace)` pair whose output could not be
126
+ * mapped back to the text it came from — and a second scanner written beside it
127
+ * for the offsets would be free to disagree with the boundary rule, which is the
128
+ * failure #202b hit three times in argument handling.
129
+ *
130
+ * H5 from the Q1 segmenter: a `\<newline>` is removed before RouterOS parses the
131
+ * continued line, so it JOINS the words either side of it rather than separating
132
+ * them (`na\<nl>me` is the single word `name`) and contributes no character to
133
+ * the name. That is why a word's `end - start` is not its `name.length`: the span
134
+ * covers the continuation bytes, the name does not. A caller taking an interior
135
+ * offset from a name is therefore wrong on continued statements, and
136
+ * {@link describeStatement} only ever publishes the run's END.
137
+ */
138
+ function asciiWordSpans(text: string): Word[] {
139
+ const out: Word[] = [];
140
+ let current: Word | null = null;
141
+ let i = 0;
142
+ while (i < text.length) {
143
+ const c = text[i] as string;
144
+ if (
145
+ c === "\\" &&
146
+ (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
147
+ ) {
148
+ // Inside a word this joins; between words it is whitespace that opens
149
+ // none — matching the removal-then-split the rule replaced.
150
+ i += text[i + 1] === "\r" ? 3 : 2;
151
+ if (current !== null) current.end = i;
152
+ continue;
153
+ }
154
+ if (isAsciiWhitespace(c)) {
155
+ current = null;
156
+ i++;
157
+ continue;
158
+ }
159
+ if (current === null) {
160
+ current = {
161
+ name: c,
162
+ start: i,
163
+ end: i + 1,
164
+ chars: [{ start: i, end: i + 1 }],
165
+ };
166
+ out.push(current);
167
+ } else {
168
+ current.name += c;
169
+ current.end = i + 1;
170
+ current.chars.push({ start: i, end: i + 1 });
171
+ }
172
+ i++;
173
+ }
174
+ return out;
175
+ }
176
+
177
+ /**
178
+ * The frozen vocabulary lives in the leaf `verbs.ts` because `pathresolve.ts`
179
+ * needs it too (R9, #211) and this module already imports that one. Re-exported
180
+ * here so the vocabulary still reads as part of the Q6 surface.
181
+ */
182
+ export { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
183
+
184
+ /** Separator that precedes a run token (V1). */
185
+ export type Sep = "start" | "slash" | "space";
186
+
187
+ /** One token of the leading path run, with the punctuation that introduced it. */
188
+ export interface RunToken {
189
+ name: string;
190
+ sep: Sep;
191
+ }
192
+
193
+ /** A run token mapped back to the statement bytes that spell it. */
194
+ export interface LocatedRunToken extends RunToken {
195
+ /** Name bytes, split where a `\<newline>` continuation interrupts them. */
196
+ nameSpans: Span[];
197
+ /** One valid `/` immediately before this token, if present. */
198
+ slashBefore?: Span;
199
+ /** One trailing `/` after this token, if present. */
200
+ slashAfter?: Span;
201
+ }
202
+
203
+ function coalesceChars(chars: readonly Span[]): Span[] {
204
+ const out: Span[] = [];
205
+ for (const char of chars) {
206
+ const last = out.at(-1);
207
+ if (last !== undefined && last.end === char.start) last.end = char.end;
208
+ else out.push({ start: char.start, end: char.end });
209
+ }
210
+ return out;
211
+ }
212
+
213
+ /**
214
+ * The leading path-token run WITH separators preserved (V1). Same stopping
215
+ * rules as `pathresolve`'s run (V2/V3), so the two stay comparable: a slash-
216
+ * joined word expands to several `slash`-separated tokens; a space-separated
217
+ * word is one `space` token. Stops at the first `=`/group/string/var word, and
218
+ * at any word carrying a non-`BARE_WORD` segment (a variable segment truncates
219
+ * the run before the verb).
220
+ */
221
+ export function locatedRunTokens(text: string): LocatedRunToken[] {
222
+ const out: LocatedRunToken[] = [];
223
+ for (const word of asciiWordSpans(text)) {
224
+ if (word.name.includes("=") || RUN_TERMINATOR.test(word.name)) break;
225
+ const parts: {
226
+ name: string;
227
+ start: number;
228
+ end: number;
229
+ slashBefore?: Span;
230
+ }[] = [];
231
+ let start = 0;
232
+ for (let i = 0; i <= word.name.length; i++) {
233
+ if (i < word.name.length && word.name[i] !== "/") continue;
234
+ if (start < i) {
235
+ const slashRunStart = start;
236
+ let slashCount = 0;
237
+ while (
238
+ slashRunStart - slashCount - 1 >= 0 &&
239
+ word.name[slashRunStart - slashCount - 1] === "/"
240
+ )
241
+ slashCount++;
242
+ const slashIndex = slashCount === 1 ? slashRunStart - 1 : -1;
243
+ parts.push({
244
+ name: word.name.slice(start, i),
245
+ start,
246
+ end: i,
247
+ ...(slashIndex < 0 ? {} : { slashBefore: word.chars[slashIndex] }),
248
+ });
249
+ }
250
+ start = i + 1;
251
+ }
252
+ if (parts.length === 0) continue;
253
+ if (!parts.every((p) => BARE_WORD.test(p.name))) break;
254
+ for (let i = 0; i < parts.length; i++) {
255
+ const part = parts[i] as (typeof parts)[number];
256
+ const leadingSlash = word.name.startsWith("/") && i === 0;
257
+ const trailingSlash =
258
+ i === parts.length - 1 &&
259
+ word.name.endsWith("/") &&
260
+ !word.name.endsWith("//")
261
+ ? word.chars.at(-1)
262
+ : undefined;
263
+ out.push({
264
+ name: part.name,
265
+ sep:
266
+ out.length === 0
267
+ ? "start"
268
+ : i > 0 || leadingSlash
269
+ ? "slash"
270
+ : "space",
271
+ nameSpans: coalesceChars(word.chars.slice(part.start, part.end)),
272
+ ...(part.slashBefore === undefined
273
+ ? {}
274
+ : { slashBefore: part.slashBefore }),
275
+ ...(trailingSlash === undefined ? {} : { slashAfter: trailingSlash }),
276
+ });
277
+ }
278
+ }
279
+ return out;
280
+ }
281
+
282
+ export function runTokens(text: string): RunToken[] {
283
+ return locatedRunTokens(text).map(({ name, sep }) => ({ name, sep }));
284
+ }
285
+
286
+ /** True when the statement is a scripting directive (`:x`, or a bare `x do={…}`). */
287
+ export function isDirective(text: string): boolean {
288
+ const t = trimAscii(text);
289
+ if (t.startsWith(":")) return true;
290
+ return !t.startsWith("/") && scopeBodies(t).length > 0;
291
+ }
292
+
293
+ /** Everything the boundary rule needs from one statement's text. */
294
+ export function describeStatement(text: string): {
295
+ run: RunToken[];
296
+ directive: boolean;
297
+ whole: boolean;
298
+ /**
299
+ * For each run token, the offset in `text` just past the WORD it came from —
300
+ * where the arguments of a command whose verb is that token begin (#202c) —
301
+ * or null where that offset would not mean that.
302
+ *
303
+ * Per RUN TOKEN rather than one run-wide offset, because the run does not
304
+ * stop at the verb: `runTokens` consumes every leading bare word, so
305
+ * `/interface print detail` puts `detail` IN the run. Once the boundary rule
306
+ * has decided the verb, everything after it is by construction an argument —
307
+ * measured at 60% of the corpus's CRUD-verb commands, so ending the argument
308
+ * list at the run instead of at the verb would abstain on most of them.
309
+ *
310
+ * Null in two cases, both fail-closed:
311
+ * - the token is not the LAST part of its word (`/ip/route/add/x` splitting
312
+ * its verb mid-word): the following parts are neither path nor arguments
313
+ * of this reading;
314
+ * - the word walk stopped before reaching that token (the run and the words
315
+ * disagree), so no offset was established for it at all.
316
+ *
317
+ * A word END is the only offset that survives a `\<newline>` continuation —
318
+ * the joined bytes are interior to the word (see {@link asciiWordSpans}),
319
+ * which is why no per-token START is published.
320
+ */
321
+ runEnds: (number | null)[];
322
+ } {
323
+ const lead = text.length - trimAsciiStart(text).length;
324
+ const t = trimAscii(text);
325
+ const directiveColon = t.startsWith(":") ? 1 : 0;
326
+ const body = t.slice(directiveColon);
327
+ // Body offsets rebase onto `text` by this much: the whitespace `trimAscii`
328
+ // dropped in front, plus a `:` the directive form strips.
329
+ const base = lead + directiveColon;
330
+ const run = runTokens(body);
331
+ // V4 — did the run consume every whitespace-separated word? A slash-joined
332
+ // word expands to several run tokens, so compare against the SOURCE words the
333
+ // run covers, not the run length.
334
+ const words = asciiWordSpans(body);
335
+ let covered = 0;
336
+ let index = 0;
337
+ const runEnds: (number | null)[] = run.map(() => null);
338
+ for (const word of words) {
339
+ const parts = word.name.split("/").filter((p) => p.length > 0);
340
+ if (parts.length === 0) continue;
341
+ if (index + parts.length > run.length) break;
342
+ if (
343
+ !parts.every(
344
+ (p, i) => (run[index + i] as RunToken | undefined)?.name === p,
345
+ )
346
+ )
347
+ break;
348
+ // Only the word's LAST part ends where the word does; an earlier part is
349
+ // followed by more path inside the same word.
350
+ runEnds[index + parts.length - 1] = base + word.end;
351
+ index += parts.length;
352
+ covered++;
353
+ }
354
+ return {
355
+ run,
356
+ directive: isDirective(text),
357
+ whole: covered === words.length && run.length > 0,
358
+ runEnds,
359
+ };
360
+ }
361
+
362
+ /** The verb/menu boundary decision for one run. */
363
+ export interface Split {
364
+ /** Index of the verb inside the run, or null for "no verb decided". */
365
+ verbAt: number | null;
366
+ /**
367
+ * True when the schema-free rule cannot decide the run (V4's bare path).
368
+ * `resolveVerb` upgrades this to `navigation` when the container table
369
+ * recognizes the path; here it stays a refusal, so `splitRun`'s abstention
370
+ * rate keeps pricing `proposed` on its own terms.
371
+ */
372
+ ambiguous: boolean;
373
+ /** The rule that fired, for provenance. */
374
+ why: string;
375
+ }
376
+
377
+ /** The maximal slash-joined prefix length: run[0..k-1] are all `start`/`slash`. */
378
+ function slashPrefixLen(run: RunToken[]): number {
379
+ let k = 0;
380
+ while (k < run.length && (run[k] as RunToken).sep !== "space") k++;
381
+ return k;
382
+ }
383
+
384
+ /**
385
+ * The ratified `proposed` boundary rule. `directive` marks a `:`/bare scripting
386
+ * directive; `whole` is V4 (the run is the entire statement — nothing follows).
387
+ */
388
+ export function splitRun(
389
+ run: RunToken[],
390
+ opts: { directive: boolean; whole: boolean },
391
+ ): Split {
392
+ if (run.length === 0)
393
+ return { verbAt: null, ambiguous: false, why: "empty run" };
394
+
395
+ if (opts.directive) {
396
+ const head = (run[0] as RunToken).name;
397
+ if (SUBMENU_DIRECTIVES.has(head) && run.length >= 2)
398
+ return {
399
+ verbAt: 1,
400
+ ambiguous: false,
401
+ why: "`:log <level>` — the one sub-menu directive",
402
+ };
403
+ return {
404
+ verbAt: 0,
405
+ ambiguous: false,
406
+ why: "R10: a directive's operand is positional",
407
+ };
408
+ }
409
+
410
+ const vocab = run.findIndex((t) => VERBS.has(t.name));
411
+ if (vocab >= 0)
412
+ return {
413
+ verbAt: vocab,
414
+ ambiguous: false,
415
+ why: `vocabulary hit \`${run[vocab]?.name}\``,
416
+ };
417
+
418
+ // V4 — a statement that is nothing but a path carries no verb signal at all:
419
+ // `/ip/address` (dir) and `/system/reboot` (no-arg cmd) are the same text.
420
+ if (opts.whole)
421
+ return {
422
+ verbAt: null,
423
+ ambiguous: true,
424
+ why: "bare path, nothing follows — navigation or no-arg command?",
425
+ };
426
+
427
+ // Punctuation: the maximal slash-joined prefix is menu; the first space token
428
+ // is the verb; with no space token but arguments following (V4 false), the
429
+ // last run token is the verb (`/system/identity/print value=…`).
430
+ const k = slashPrefixLen(run);
431
+ return k < run.length
432
+ ? {
433
+ verbAt: k,
434
+ ambiguous: false,
435
+ why: `first space token after ${k} slash-joined`,
436
+ }
437
+ : {
438
+ verbAt: run.length - 1,
439
+ ambiguous: false,
440
+ why: "no space token, arguments follow — last segment",
441
+ };
442
+ }
443
+
444
+ /**
445
+ * The verb index the published command catalog decides for a run, or null.
446
+ *
447
+ * The command half of V4 (#228 step 2). `splitRun` above stays the ratified
448
+ * schema-free rule — its abstention rate is what prices decision 3 — so the
449
+ * baked tables are layered by the CONSUMERS, exactly as `menus.ts` has been
450
+ * since #210. This is the other table and the other direction: `menus.ts` says
451
+ * a bare path is a menu, `commandVerbIndex` says a run prefix is a command.
452
+ *
453
+ * Where `splitRun` guesses from punctuation, this is first-order evidence and
454
+ * wins. `/system/gps/monitor once` has one space token, so the punctuation rule
455
+ * calls `once` the verb and `/system/gps/monitor` the menu; the publication says
456
+ * `/system/gps/monitor` IS the command, which makes `once` its argument. The
457
+ * frozen vocabulary is never overruled in practice — all four catalog commands
458
+ * carrying a `VERBS` segment carry it as the LEAF, so the two rules name the
459
+ * same token (pinned by a test) — but where they could differ, the published
460
+ * path is about that exact path and the vocabulary is a curated heuristic.
461
+ *
462
+ * Directives are left alone. R10 ("a directive's operand is positional") was
463
+ * measured at 2,088 against 18 over the dev split, and the seven root-level
464
+ * catalog commands (`/beep`, `/import`, `/password`, …) are all at index 0,
465
+ * where R10 already puts them. Consulting the table there could only move a
466
+ * case the measurement never saw.
467
+ *
468
+ * `base` is the enclosing menu the run extends, so the lookup is over the same
469
+ * absolute path a reader would type. A match falling entirely inside `base`
470
+ * means the CONTEXT is a command and the run continues past it — no reading of
471
+ * this statement follows from that, so it returns null rather than guessing.
472
+ */
473
+ export function catalogVerbAt(
474
+ run: readonly RunToken[],
475
+ base: string,
476
+ ): number | null {
477
+ const prefix = base.split("/").filter(Boolean);
478
+ const at = commandVerbIndex([...prefix, ...run.map((token) => token.name)]);
479
+ return at !== null && at >= prefix.length ? at - prefix.length : null;
480
+ }
481
+
482
+ /** What every split carries, whatever it decided. */
483
+ interface VerbSplitCommon {
484
+ /** Context extended by each run prefix — the path readings. */
485
+ candidates: string[];
486
+ /** The rule that fired, for provenance. */
487
+ why: string;
488
+ }
489
+
490
+ /**
491
+ * A decided verb: the menu is the context plus the run before the verb.
492
+ *
493
+ * `kind` is **fully derived** from `resolution` and carries no independent
494
+ * information: `resolution` says what the analyzer did, `kind` says what the
495
+ * statement is. It is kept because it is the ratified envelope's vocabulary
496
+ * (`commands/explain/README.md`) and the field phase 1 renders, so this module
497
+ * does not invent a third word for the same concept; the mapping is pinned in
498
+ * both directions by a test so the two can never drift apart.
499
+ */
500
+ export interface VerbSplitCommandReading extends VerbSplitCommon {
501
+ resolution: "resolved";
502
+ kind: "command";
503
+ path: string;
504
+ verb: string;
505
+ /** Index of the verb within the run. */
506
+ verbAt: number;
507
+ /**
508
+ * Offset in the statement text where this command's ARGUMENTS begin, or
509
+ * `null` when no such offset exists (#202c).
510
+ *
511
+ * It is the end of the VERB's word, so a bare-word argument the leading run
512
+ * swallowed (`/interface print detail` puts `detail` in the run) is still
513
+ * inside the argument list rather than lost above it.
514
+ *
515
+ * `null` when no such offset exists: the verb is not the last part of its
516
+ * word (`/ip/route/add/x`), or the word walk never reached it. A caller that
517
+ * lexed from a guessed offset would attribute bytes to the wrong command, so
518
+ * the offset is withheld rather than approximated.
519
+ */
520
+ argsAt: number | null;
521
+ }
522
+
523
+ /** A bare path the container table confirms: it names a menu and nothing else. */
524
+ export interface VerbSplitMenuReading extends VerbSplitCommon {
525
+ resolution: "navigation";
526
+ kind: "menu";
527
+ path: string;
528
+ verb: null;
529
+ verbAt: null;
530
+ /** Navigation names a menu and nothing else — there is no argument list. */
531
+ argsAt: null;
532
+ }
533
+
534
+ /** A refusal — the schema-free rule could not decide, or would have to guess. */
535
+ export interface VerbSplitRefusal extends VerbSplitCommon {
536
+ resolution: "ambiguous" | "unknown";
537
+ kind: null;
538
+ path: null;
539
+ verb: null;
540
+ verbAt: null;
541
+ /** No command was read, so nothing here is an argument OF one. */
542
+ argsAt: null;
543
+ }
544
+
545
+ /**
546
+ * The verb/menu split of one statement, context applied.
547
+ *
548
+ * A union rather than one shape with four nullable fields, because the nulls
549
+ * are not independent: a `resolved` split always has a path AND a verb, a
550
+ * `navigation` split always has a path and never a verb, and a refusal has
551
+ * neither. Stating that in the type means a consumer narrowing on `resolution`
552
+ * gets the fields that exist, and the envelope's fold (`src/explain.ts`) has no
553
+ * unreachable null branch to write. The runtime shape is unchanged — every
554
+ * variant carries every key, so a null is still readable without narrowing.
555
+ */
556
+ export type VerbSplit =
557
+ | VerbSplitCommandReading
558
+ | VerbSplitMenuReading
559
+ | VerbSplitRefusal;
560
+
561
+ /**
562
+ * Whether a reading was decided, or refused.
563
+ *
564
+ * Derived from the union rather than declared beside it, so the vocabulary and
565
+ * the shapes cannot drift: a new resolution word has to arrive as a variant
566
+ * that says what it carries.
567
+ */
568
+ export type VerbResolution = VerbSplit["resolution"];
569
+
570
+ /** Join a base menu with bare run segments (run tokens are `BARE_WORD`, no `..`/`.`). */
571
+ function joinBase(base: string, names: string[]): string {
572
+ const parts = [...base.split("/").filter(Boolean), ...names];
573
+ return `/${parts.join("/")}`;
574
+ }
575
+
576
+ function unknownSplit(why: string): VerbSplitRefusal {
577
+ return {
578
+ resolution: "unknown",
579
+ kind: null,
580
+ path: null,
581
+ verb: null,
582
+ verbAt: null,
583
+ argsAt: null,
584
+ candidates: [],
585
+ why,
586
+ };
587
+ }
588
+
589
+ /**
590
+ * Resolve one statement's verb/menu boundary, applying the enclosing menu
591
+ * `context`. Mirrors `pathresolve`'s base rule: a `:`/bare directive and an
592
+ * absolute (`/`) statement resolve at the root; everything else inherits
593
+ * `context`. Substitution-headed and variable-segment statements are `unknown`
594
+ * — offline refuses rather than guessing (the Q3/Q4/Q14 fail-closed floor).
595
+ */
596
+ export function resolveVerb(text: string, context: string): VerbSplit {
597
+ const t = trimAscii(text);
598
+ if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
599
+ return unknownSplit("dynamic or substitution-headed statement");
600
+
601
+ const { run, directive, whole, runEnds } = describeStatement(text);
602
+ if (run.length === 0) return unknownSplit("no leading path token");
603
+
604
+ // `directive` already folds in the bare-directive case (`isDirective`), so it
605
+ // stands in for the `:`-prefixed and `x do={…}` readings — no second
606
+ // `scopeBodies` scan. A directive or an absolute (`/`) statement resolves at
607
+ // the root; everything else inherits `context`.
608
+ const base = directive || t.startsWith("/") ? "/" : context;
609
+ const catalogAt = directive ? null : catalogVerbAt(run, base);
610
+
611
+ // A bare-word head is not RouterOS navigation (Q4's CHR round), so offline
612
+ // cannot tell a relative command from prose. An INHERITED menu context lifts
613
+ // that where the catalog confirms the result: `reset-counters` under
614
+ // `/interface/ethernet` is `/interface/ethernet/reset-counters`, and refusing
615
+ // it costs a curated WRITE verb the tristate exists to catch.
616
+ //
617
+ // At the ROOT context a bare-word COMMAND stays a refusal, and that boundary
618
+ // is not cosmetic. Seven catalog commands sit at the root (`/import`,
619
+ // `/quit`, `/beep`, …) and every one is an ordinary English word: the corpus
620
+ // carries `import serial`, `import io`, `import os` — Python pasted into a
621
+ // `.rsc` snippet — which the table would happily confirm as RouterOS
622
+ // `/import`. A non-root context is itself the evidence that the surrounding
623
+ // text is RouterOS at all; nothing supplies that at the root, so the ratified
624
+ // refusal stands there.
625
+ //
626
+ // #235 lifts the refusal — at any context, root included — when the head
627
+ // joins to a known MENU rather than a command: `address` under `/ip` and
628
+ // `ip` at `/` are both navigation on the device (CHR 7.24rc1 `:parse` IL).
629
+ // Only the menu axis lifts, so root prose like `import` is still refused.
630
+ if (
631
+ !directive &&
632
+ !t.startsWith("/") &&
633
+ !t.startsWith(":") &&
634
+ !VERBS.has((run[0] as RunToken).name) &&
635
+ (catalogAt === null || base === "/") &&
636
+ !isKnownMenuPath([
637
+ ...base.split("/").filter(Boolean),
638
+ (run[0] as RunToken).name,
639
+ ])
640
+ )
641
+ return unknownSplit("bare-word head is not a known verb");
642
+
643
+ const candidates: string[] = [];
644
+ for (let k = 1; k <= run.length; k++)
645
+ candidates.push(
646
+ joinBase(
647
+ base,
648
+ run.slice(0, k).map((r) => r.name),
649
+ ),
650
+ );
651
+
652
+ const split =
653
+ catalogAt === null
654
+ ? splitRun(run, { directive, whole })
655
+ : {
656
+ verbAt: catalogAt,
657
+ ambiguous: false,
658
+ why: `published command \`${candidates[catalogAt]}\``,
659
+ };
660
+ if (split.ambiguous) {
661
+ // V4's bare path, the one case `proposed` refuses — and the one the baked
662
+ // container table can answer (#210). The lookup reads the LAST candidate,
663
+ // which is the whole run with `base` already applied, so it stays correct
664
+ // under a non-root context instead of relying on the (currently true)
665
+ // property that only `/`-led statements reach here.
666
+ const full = candidates[candidates.length - 1] as string;
667
+ if (isKnownMenuPath(full.split("/").filter(Boolean)))
668
+ return {
669
+ resolution: "navigation",
670
+ kind: "menu",
671
+ path: full,
672
+ verb: null,
673
+ verbAt: null,
674
+ argsAt: null,
675
+ candidates,
676
+ why: "bare path, and a known RouterOS menu — navigation",
677
+ };
678
+ return {
679
+ resolution: "ambiguous",
680
+ kind: null,
681
+ path: null,
682
+ verb: null,
683
+ verbAt: null,
684
+ argsAt: null,
685
+ candidates,
686
+ why: split.why,
687
+ };
688
+ }
689
+ if (split.verbAt === null) return unknownSplit(split.why);
690
+
691
+ const j = split.verbAt;
692
+ return {
693
+ resolution: "resolved",
694
+ kind: "command",
695
+ path: joinBase(
696
+ base,
697
+ run.slice(0, j).map((r) => r.name),
698
+ ),
699
+ verb: (run[j] as RunToken).name,
700
+ verbAt: j,
701
+ // Arguments begin after the VERB's word. Run tokens following the verb are
702
+ // bare-word arguments (`print detail`), which the lexer reads from here as
703
+ // positionals rather than abstaining on.
704
+ argsAt: runEnds[j] ?? null,
705
+ candidates,
706
+ why: split.why,
707
+ };
708
+ }
709
+
710
+ /**
711
+ * One statement's split, as seen by the DOCUMENT walker.
712
+ *
713
+ * The two extra fields are the difference between the entry points, and both are
714
+ * required rather than optional on purpose. `resolveVerb` is handed one
715
+ * statement and an explicit context, so it has nothing to say about where that
716
+ * statement sits or whether the context was knowable; `resolveVerbs` walks the
717
+ * document and always does. An optional flag would make "absent" mean both
718
+ * "single-statement call" and "certain", and the whole point of the field is
719
+ * that silence must not read as certainty.
720
+ */
721
+ export type DocumentVerbSplit = VerbSplit & {
722
+ /**
723
+ * This statement in DOCUMENT analyzed-byte space, straight from the resolver.
724
+ *
725
+ * Carried because the walk FLATTENS: a block body's statements are appended
726
+ * after their parent, so `splits` is longer than the top-level segmentation
727
+ * and a caller pairing the two by index silently attaches the wrong span to every
728
+ * statement after the first `do={…}`. With the span here there is nothing to
729
+ * pair — the location travels with the reading.
730
+ */
731
+ span: Span;
732
+ /**
733
+ * The statement's own text, as the resolver segmented it (#202c).
734
+ *
735
+ * Carried for the same reason {@link DocumentVerbSplit.span} is: the caller
736
+ * that lexes ARGUMENTS from {@link VerbSplitCommandReading.argsAt} needs the
737
+ * text those offsets index, and re-slicing the document by `span` to recover
738
+ * it is exactly the step that silently succeeds when the span is a widened
739
+ * FALLBACK (`pathresolve.ts` → `Loc`) rather than this statement's own bytes.
740
+ * With both here the caller can CHECK that they agree instead of assuming it.
741
+ */
742
+ text: string;
743
+ /**
744
+ * Was the menu context in force BEFORE this statement known? (Q14 C3b, #192.)
745
+ *
746
+ * `false` does NOT invalidate `path`: the resolver already degrades every
747
+ * context-DEPENDENT statement to `unresolved` when context is lost, so a split
748
+ * that still says `resolved` here resolved WITHOUT consuming the context — an
749
+ * absolute path, a `:` directive, or a bare directive. What this carries is
750
+ * the thing that was otherwise dropped on the floor: `/ip) address` followed
751
+ * by `/ip route print` yields a perfectly correct `resolved` split whose
752
+ * document had already lost its context, and a consumer ranking readings or
753
+ * offering completions needs to know that.
754
+ */
755
+ contextCertain: boolean;
756
+ };
757
+
758
+ /** A document's per-statement verb/menu splits, plus structural defects. */
759
+ export interface VerbAnalysis {
760
+ splits: DocumentVerbSplit[];
761
+ /** Located structural surprises; see `defects.ts`. */
762
+ defects: Defect[];
763
+ }
764
+
765
+ /**
766
+ * Verb/menu split of every statement in source order, tracking the persistent
767
+ * menu context via the shipped Q4 resolver (`resolveStatements`).
768
+ *
769
+ * This walker was pulled from the Q6 PR (#193) because it fabricated on the
770
+ * Q14 C3b cascade: after an upstream defect destroyed the document context, a
771
+ * following relative statement headed by a known CRUD verb still resolved
772
+ * confidently against the stale value. It returns unchanged in shape, because
773
+ * the fix belonged in the resolver, not here — `resolveStatements` now degrades
774
+ * exactly the context-DEPENDENT statements to `unresolved` when the context is
775
+ * lost, while leaving a context-NEUTRAL dynamic head (`$x`) alone. So the
776
+ * single rule below — a statement the resolver refused stays `unknown` here —
777
+ * fails closed on the cascade without a local taint that would over-fire on
778
+ * benign statements. The distinction is only visible to the resolver; this
779
+ * module sees flattened statements.
780
+ *
781
+ * What the walker does add is {@link DocumentVerbSplit.contextCertain} and
782
+ * {@link DocumentVerbSplit.span}: the resolver's per-statement certainty and
783
+ * location, carried through instead of dropped. The certainty was the last of
784
+ * #192's three bullets — the cascade FIX shipped in #197, but the signal it
785
+ * produced stopped here.
786
+ *
787
+ * The returned array is the RESOLVER's statement list, which is longer than the
788
+ * top-level segmentation: a `do={…}` body's statements are flattened in after
789
+ * their parent (matching what IL does to them), so a body statement's span is
790
+ * CONTAINED by its parent's rather than following it. Read the spans; do not
791
+ * pair this array with `segmentStatements` by index.
792
+ */
793
+ export function resolveVerbs(text: string): VerbAnalysis {
794
+ return resolveVerbsFromStatements(resolveStatements(text));
795
+ }
796
+
797
+ /** Reuse an existing Q4 walk when a composed analysis already performed it. */
798
+ export function resolveVerbsFromStatements({
799
+ statements,
800
+ defects,
801
+ }: StatementAnalysis): VerbAnalysis {
802
+ return {
803
+ splits: statements.map((s) => ({
804
+ ...(s.unresolved
805
+ ? unknownSplit(s.unresolved)
806
+ : resolveVerb(s.text, s.context)),
807
+ span: s.span,
808
+ text: s.text,
809
+ contextCertain: s.contextCertain,
810
+ })),
811
+ defects,
812
+ };
813
+ }