@tikoci/centrs 0.1.3 → 0.1.6

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