@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,856 @@
1
+ /**
2
+ * Offline write-shape inference for `explain` — `structure.containsWrite`.
3
+ *
4
+ * Ratified by the phase-0 lab, question Q16 (#185) and promoted from the
5
+ * throwaway probe `.scratch/explain-lab-q16-write.ts`. Q16 asked what shape the
6
+ * field should have and answered that the spec's sketched **boolean is unsafe**:
7
+ * a fail-closed `true | false | unknown` reaches 100% precision on what it
8
+ * decides (226/226 holdout, 469/469 dev) with zero false negatives, abstaining
9
+ * on ~35%, while a two-valued field would have to call `/disk format-drive` a
10
+ * non-write. The tristate is the ratified amendment; this module implements it.
11
+ *
12
+ * **This is not the execute gate.** `canonicalizeExecuteCommand`'s `mode` /
13
+ * `writeShaped` verdict is reproduced verbatim elsewhere and is unchanged by
14
+ * anything here (constitution: canonicalizer ownership). `containsWrite` is a
15
+ * distinct explain-only inference with basis `heuristic`, so an agent can never
16
+ * mistake it for a guard `execute` actually applies.
17
+ *
18
+ * **Write-ness is not device-published.** No schema artifact carries mutation
19
+ * semantics: `inspect.json` tags nodes `path|dir|cmd|arg` and nothing more, and
20
+ * restraml's `openapi.json` method assignment is rule-derived (2,844 of 3,649
21
+ * endpoints are a `post` catch-all), so reading it back would be circular. The
22
+ * table below is therefore CURATED, which makes its edges the whole question —
23
+ * hence `unknown` as a first-class outcome rather than a synonym for "no". The
24
+ * gap is standing, not staleness: a 17-name table covers 93.1% of cmd nodes on
25
+ * 7.24beta1 and 93.8% on 7.9.2 (flat to 0.7pp across all of 7.x, ~0.07% churn
26
+ * per minor version), yet 56 of the 225 uncovered verb names on 7.24beta1 are
27
+ * write-shaped (`reboot`, `format`, `delete`, `reset-configuration`, `sign`,
28
+ * `flush`, …).
29
+ *
30
+ * **Two vocabularies, measured not guessed.** Menu verbs are the CRUD verbs —
31
+ * every entry appears under 45+ menus in all 52 trees 7.9→7.24beta1, with none
32
+ * added or removed. Root cmds are RouterOS scripting directives, a CLOSED list
33
+ * enumerated from the tree (41 names on 7.9.2, 55 on 7.24beta1, +15/−1 over the
34
+ * whole 7.x series). The root list is load-bearing: `set`, `find`, `export`,
35
+ * `import`, `password`, `undo` and `redo` are ALL root cmds as well as menu
36
+ * verbs and do not mean the same thing in both places — `:set x 5` assigns a
37
+ * script variable, `/ip/route set …` writes the device. Write-ness genuinely
38
+ * depends on the menu, but only across that one boundary, which is exactly the
39
+ * boundary Q6 measured at 99.9% precision. Q16 composes with Q6 rather than
40
+ * needing a schema of its own (decision 3 stays closed).
41
+ *
42
+ * **What #207 changed, and what it did NOT.** This module now reads one baked
43
+ * artifact, `menus.ts` — the `dir`/`path` node set, i.e. exactly the
44
+ * `path|dir|cmd|arg` tagging named above as carrying no mutation semantics. It
45
+ * still carries none, and nothing here infers write-ness from it. It answers a
46
+ * strictly narrower question that Q6 leaves open: whether a bare path is a
47
+ * MENU or a no-argument command. Decision 3 stays closed — the two vocabularies
48
+ * above are unchanged, still curated, still frozen against tuning — and the
49
+ * module stays offline, because a baked constant is not a lookup.
50
+ *
51
+ * **#228 added the second baked artifact, and the same caveat applies twice.**
52
+ * `catalog.ts`'s command axis says which token of a run is the VERB; it says
53
+ * nothing about whether that verb mutates, and the publication carries no
54
+ * mutation semantics either. So the tables above are still the only thing that
55
+ * decides `write` vs `read`, and a published command whose verb they do not list
56
+ * abstains exactly as before (`/system/reboot` is `unknown`, not `false`).
57
+ *
58
+ * What it does change is which token gets looked up. The punctuation rule reads
59
+ * the first SPACE token as the verb, so `/interface ethernet reset-counters
60
+ * ether1` looked up `ethernet` — a menu segment — and abstained on a curated
61
+ * WRITE verb; `/system/gps/monitor once` looked up `once`. Over the frozen
62
+ * corpus plus the export stratum, 171 readings move and every one names a better
63
+ * token: abstention 44.8% -> 44.6% dev, 46.0% holdout and 0.0% export unchanged,
64
+ * and exactly one document changes verdict.
65
+ *
66
+ * **Fail-closed is enforced where input is DISCARDED, not where it is
67
+ * classified** (Q16 finding 2, the part worth carrying past this module). The
68
+ * lab priced three navigation arms; only the ratified `failclosed` one is
69
+ * promoted. The two naive arms are not actually fail-closed whatever the rollup
70
+ * rule says: `/interface ethernet reset-counters` is bare-word-shaped, so Q4's
71
+ * nav rule swallows the statement, AND carries a verb outside Q6's frozen
72
+ * table, so the verb rule cannot rescue it — and a swallowed statement produces
73
+ * no occurrence, so nothing is left to abstain about and the document
74
+ * confidently reports "no write". The two abstention mechanisms cancelled
75
+ * instead of composing. A classifier can only be conservative about what it is
76
+ * given, so every statement that cannot be CONFIRMED as navigation emits an
77
+ * occurrence here. Cost on holdout: +0.8pp abstention, and the overconfident
78
+ * bin halves.
79
+ *
80
+ * That rule binds the BRACKET walk exactly as it binds the statement walk, and
81
+ * the two must be kept in step. Both fail-closed guards below — the Q14 defect
82
+ * floor and the path-shaped-but-unread abstention — were originally applied to
83
+ * statements only, so `/ip/$menu/remove 0` abstained while `:put [/ip/$menu/remove
84
+ * 0]` cleared the document to a confident `false`. Discarding input inside a
85
+ * bracket discards it just as thoroughly. Cost of closing it over the frozen
86
+ * 911-script corpus: +0.8pp abstention on both splits (7 documents, all
87
+ * `false` -> `unknown`, none off `true`), and two of the seven are pasted CLI
88
+ * transcripts that hide a real write behind the `[user@device]` prompt — i.e.
89
+ * genuine false negatives the tristate exists to prevent.
90
+ */
91
+
92
+ import { type Defect, hasStructuralDefect, mergeDefects } from "./defects.ts";
93
+ import { isMenuPath } from "./menus.ts";
94
+ import {
95
+ type DocumentAnalysis,
96
+ rangeForResolution,
97
+ resolveDocument,
98
+ resolveStatements,
99
+ type StatementAnalysis,
100
+ type StatementResolution,
101
+ } from "./pathresolve.ts";
102
+ import { documentRange, type MaskedRange, rangeAt } from "./segment.ts";
103
+ import {
104
+ catalogVerbAt,
105
+ describeStatement,
106
+ type RunToken,
107
+ splitRun,
108
+ } from "./verbsplit.ts";
109
+
110
+ /** One statement's run, directive flag and V4 flag — parsed once, read by every rule. */
111
+ type Described = ReturnType<typeof describeStatement>;
112
+
113
+ /**
114
+ * Menu verbs that mutate persistent device state. Frequency-justified against
115
+ * the command trees, and frozen against tuning per the Q6 vocabulary discipline
116
+ * — this is a curated list, not a schema, and its honesty depends on it not
117
+ * being fitted to scoring output.
118
+ */
119
+ export const WRITE_VERBS: ReadonlySet<string> = new Set([
120
+ "add",
121
+ "remove",
122
+ "set",
123
+ "unset",
124
+ "enable",
125
+ "disable",
126
+ "comment",
127
+ "move",
128
+ "edit",
129
+ "reset",
130
+ // Counter resets mutate device state (statistics), not configuration. They
131
+ // are included because this field is a SAFETY signal, and the same frequency
132
+ // justification applies: `reset-counters` under 29 menus and
133
+ // `reset-counters-all` under 18, in every tree 7.9→7.24beta1.
134
+ "reset-counters",
135
+ "reset-counters-all",
136
+ ]);
137
+
138
+ /** Menu verbs that read only. `export` emits config, it does not apply it. */
139
+ export const READ_VERBS: ReadonlySet<string> = new Set([
140
+ "print",
141
+ "get",
142
+ "find",
143
+ "export",
144
+ "monitor",
145
+ ]);
146
+
147
+ /**
148
+ * Root cmds (scripting directives) that DO mutate device state. Four names, and
149
+ * the argument for each is that its documented effect is a configuration
150
+ * change, not that its name merely looked mutating:
151
+ * `password` changes the logged-in user's password
152
+ * `undo` reverts the previous configuration change
153
+ * `redo` re-applies it
154
+ * `import` applies an .rsc file to the configuration
155
+ * Every other root cmd is control flow, a pure function, or output.
156
+ */
157
+ export const ROOT_WRITE: ReadonlySet<string> = new Set([
158
+ "password",
159
+ "undo",
160
+ "redo",
161
+ "import",
162
+ ]);
163
+
164
+ /**
165
+ * Root cmds that run a command built at runtime. Offline cannot see what they
166
+ * will do, so they force `unknown` — never `false`.
167
+ */
168
+ export const ROOT_DYNAMIC: ReadonlySet<string> = new Set(["execute", "parse"]);
169
+
170
+ /**
171
+ * The closed root-cmd vocabulary, union across the 7.9.2 / 7.20.8 / 7.23rc1 /
172
+ * 7.24beta1 trees. Membership is what makes a root-level command classifiable
173
+ * at all: a root cmd NOT in this set is a directive this table has not seen, so
174
+ * it abstains rather than being read as harmless control flow.
175
+ */
176
+ export const ROOT_CMDS: ReadonlySet<string> = new Set([
177
+ "beep",
178
+ "break",
179
+ "continue",
180
+ "convert",
181
+ "delay",
182
+ "deserialize",
183
+ "do",
184
+ "error",
185
+ "execute",
186
+ "exit",
187
+ "export",
188
+ "find",
189
+ "for",
190
+ "foreach",
191
+ "global",
192
+ "grep",
193
+ "if",
194
+ "import",
195
+ "jobname",
196
+ "len",
197
+ "local",
198
+ "lock",
199
+ "nothing",
200
+ "onerror",
201
+ "parse",
202
+ "password",
203
+ "pick",
204
+ "ping",
205
+ "put",
206
+ "quit",
207
+ "range",
208
+ "recursive-print",
209
+ "redo",
210
+ "resolve",
211
+ "retry",
212
+ "return",
213
+ "rndnum",
214
+ "rndstr",
215
+ "serialize",
216
+ "set",
217
+ "time",
218
+ "timestamp",
219
+ "toarray",
220
+ "tobool",
221
+ "tocrlf",
222
+ "toid",
223
+ "toip",
224
+ "toip6",
225
+ "tolf",
226
+ "tonsec",
227
+ "tonum",
228
+ "tostr",
229
+ "totime",
230
+ "typeof",
231
+ "undo",
232
+ "while",
233
+ ]);
234
+
235
+ /**
236
+ * The `pathresolve` refusal reasons that name no command at this level, so the
237
+ * statement may be classified normally instead of blocking. Deliberately an
238
+ * ALLOW list: an unrecognized reason is treated as a defect, so this composition
239
+ * fails closed when the resolver grows a new one.
240
+ *
241
+ * `no leading path token` is clearable only because `classifyStatement`'s
242
+ * `isPathShapedButUnread` guard catches the dangerous half of it — the run of a
243
+ * `/`-led statement being TRUNCATED by a variable segment (`/ip/$menu/remove`)
244
+ * rather than genuinely absent (`"a string"`, `=`).
245
+ */
246
+ const CLEARABLE_UNRESOLVED: ReadonlySet<string> = new Set([
247
+ "dynamic or substitution-headed statement",
248
+ "no leading path token",
249
+ ]);
250
+
251
+ /**
252
+ * Whether the command itself is not statically knowable, so the verdict must
253
+ * degrade to `unknown` rather than `false`. Checked BEFORE the navigation gate:
254
+ * `/system/script run myscript` is entirely bare words, so the nav rule would
255
+ * otherwise drop the one form that is unknowable by definition.
256
+ *
257
+ * Read the leading run rather than searching the raw statement. A raw substring
258
+ * probe masks known writes whose argument merely contains `/system script run`,
259
+ * and separate slash/space regexes miss valid mixed spellings such as
260
+ * `/system/script run myscript`.
261
+ */
262
+ function isDynamicForm(text: string, described: Described): boolean {
263
+ if (text.startsWith("$") || /^\[\s*\$/.test(text)) return true;
264
+ if (!text.startsWith("/") || described.run.length < 3) return false;
265
+ const [system, script, run] = described.run;
266
+ return (
267
+ system?.name.toLowerCase() === "system" &&
268
+ script?.name.toLowerCase() === "script" &&
269
+ run?.name.toLowerCase() === "run"
270
+ );
271
+ }
272
+
273
+ /**
274
+ * The tristate. Rendered to the `structure.containsWrite` JSON field as
275
+ * `true | false | "unknown"`; kept as a string union here so `unknown` can
276
+ * never be reached by boolean coercion.
277
+ */
278
+ export type WriteVerdict = "true" | "false" | "unknown";
279
+
280
+ /** How one classified command occurrence bears on the rollup. */
281
+ export type OccurrenceClass =
282
+ /** A curated write verb, in the vocabulary that matches its position. */
283
+ | "write"
284
+ /** A curated read verb. */
285
+ | "read"
286
+ /** A decided verb outside both curated tables — abstains. */
287
+ | "unknown-verb"
288
+ /**
289
+ * A path-shaped statement whose verb/menu boundary Q6 refused — abstains.
290
+ * `/system/reboot` and `/ip/address` are the same text, so a bare path may be
291
+ * a no-argument WRITE. See the note on `classifyStatement`.
292
+ */
293
+ | "ambiguous"
294
+ /** The command is built at runtime; offline cannot read it — abstains. */
295
+ | "dynamic"
296
+ /** A statement the path resolver refused (Q14 structural defect) — abstains. */
297
+ | "defect"
298
+ /**
299
+ * The statement has no leading path run at all (it opens with `[`, `(` or a
300
+ * string), so it names no command. Carries no write signal either way — an
301
+ * inner `[…]` command is reached by the bracket walk instead.
302
+ */
303
+ | "no-verb";
304
+
305
+ /** One command offline can see, at any block or bracket depth. */
306
+ export interface Occurrence {
307
+ kind: "statement" | "bracket";
308
+ /** Trimmed source text of the statement or bracket inner. */
309
+ text: string;
310
+ /** Menu context in force, `/` at document root. */
311
+ context: string;
312
+ /** The verb the Q6 boundary decided, or null. */
313
+ verb: string | null;
314
+ /** Root-level (`:foo` or a root cmd) rather than a menu command. */
315
+ directive: boolean;
316
+ klass: OccurrenceClass;
317
+ }
318
+
319
+ /** Classify one decided verb in its position. The (menu, verb) dependency. */
320
+ export function classifyVerb(
321
+ verb: string | null,
322
+ directive: boolean,
323
+ ): OccurrenceClass {
324
+ if (verb === null) return "no-verb";
325
+ const v = verb.toLowerCase();
326
+ if (directive) {
327
+ if (ROOT_DYNAMIC.has(v)) return "dynamic";
328
+ if (ROOT_WRITE.has(v)) return "write";
329
+ if (ROOT_CMDS.has(v)) return "read";
330
+ return "unknown-verb";
331
+ }
332
+ if (WRITE_VERBS.has(v)) return "write";
333
+ if (READ_VERBS.has(v)) return "read";
334
+ return "unknown-verb";
335
+ }
336
+
337
+ /** The verb Q6's ratified boundary decides for a run, or null if it refuses. */
338
+ function verbOfRun(
339
+ run: RunToken[],
340
+ directive: boolean,
341
+ whole: boolean,
342
+ ): string | null {
343
+ const split = splitRun(run, { directive, whole });
344
+ if (split.ambiguous || split.verbAt === null) return null;
345
+ return (run[split.verbAt] as RunToken | undefined)?.name ?? null;
346
+ }
347
+
348
+ /**
349
+ * Can this statement be CONFIRMED as pure menu navigation? Only when the path it
350
+ * names is a known menu — which is now decidable offline, against the baked
351
+ * container table in `menus.ts`.
352
+ *
353
+ * **What this replaced, and why (#207).** The original rule read token SHAPE: a
354
+ * hyphenated word anywhere after the first token is verb-shaped
355
+ * (`reset-counters`, `format-drive`, `firmware-upgrade`), and testing only the
356
+ * final token would miss `/disk format-drive disk1`, where the verb carries a
357
+ * positional operand. The reasoning was sound; the premise was not. Hyphenated
358
+ * MENU segments are ordinary on a real device, so the rule fired on genuine
359
+ * navigation constantly — measured on the #203 export stratum (120 captures,
360
+ * pinned 7.23.2 and 7.24rc2), **every Q16 abstention in the entire stratum was
361
+ * this one rule**, across 25 distinct menus, at a 100% false-positive rate.
362
+ *
363
+ * The corpus could not have shown it: the rule fires on 2.97% of documents and
364
+ * changes the verdict of three, because the corpus is 96.8% two forum authors
365
+ * writing scripts, and bare-path navigation into a hyphenated menu is an
366
+ * *export* idiom. Corpus-green was not evidence the rule was right.
367
+ *
368
+ * **Why shape cannot be fixed.** `/interface wireless reset-configuration wlan1`
369
+ * is a command, `/ip dhcp-server network` and `/tool mac-server mac-winbox` are
370
+ * menus, and no token-shape rule separates them — the distinction is semantic.
371
+ * `menus.ts` supplies it as a baked constant rather than a live lookup, so this
372
+ * module stays offline. See that file for the source pin.
373
+ *
374
+ * **Still fail-closed, and in both directions.** A path the table does not carry
375
+ * is not confirmed navigation, so it emits an occurrence and abstains — the
376
+ * pre-#207 behaviour for an unrecognized menu, and the reason a pinned,
377
+ * deliberately incomplete table is safe. Going the other way, a bare-path
378
+ * COMMAND is no longer confirmed as navigation at all: `/system reboot` used to
379
+ * pass this rule (no hyphen) and be dropped mid-document, clearing the document
380
+ * to `false` on a statically obvious write. That was the exact false negative
381
+ * Q16's hard threshold forbids, and only `isDanglingBarePath` below caught the
382
+ * end-of-document case. It is closed here.
383
+ *
384
+ * Measured over the frozen 911-script corpus: abstention **unchanged** at 44.8%
385
+ * dev / 46.0% holdout, and exactly two documents move, in opposite directions.
386
+ * One gains an abstention (a pasted `/system/gps/monitor once` transcript, where
387
+ * the abstention is correct). One loses one — a rextended snippet whose elided
388
+ * `... script=… ...` line names no command, so its `unknown` was reached only
389
+ * *via* this bug misreading `/ip dhcp-client`; the residual question of how
390
+ * elided pastes should classify belongs to #192, not here. On the export stratum
391
+ * abstention drops from 3.8% compact / 11.2–11.6% verbose to **0.0% on every
392
+ * stratum**, with no document changing verdict.
393
+ */
394
+ function isConfirmedNav(described: Described): boolean {
395
+ // Only reached for a statement the Q4 resolver already read as navigation, so
396
+ // an empty run means the bare `/` or `..` forms — the two Q4's CHR round
397
+ // confirmed as valid RouterOS navigation. They name no command at all.
398
+ if (described.run.length === 0) return true;
399
+ // Argument-looking text after the run means this was never navigation.
400
+ if (!described.whole) return false;
401
+ return isMenuPath(described.run.map((t) => t.name));
402
+ }
403
+
404
+ /**
405
+ * A bare path with NOTHING after it in the whole document. Q6 ratified that such
406
+ * a statement is `ambiguous` — `/ip/route` is a directory and `/system/reboot` is
407
+ * a no-argument command, and the text is identical — but `isConfirmedNav` above
408
+ * decides at STATEMENT scale and would drop both as navigation, so a lone or
409
+ * trailing `/system/reboot` cleared the document to `false`. That is a false
410
+ * negative on a statically obvious write, the one thing Q16's hard threshold
411
+ * forbids, and it reproduces inside the tristate exactly the failure the tristate
412
+ * was ratified to prevent.
413
+ *
414
+ * Position was the one schema-free confirmation available at document scale:
415
+ * navigating and then ending the script is a no-op, while a no-argument command
416
+ * is meaningful. This is deliberately the WEAKEST rule that closes the reported
417
+ * case — measured over the frozen 911-script corpus it moves **zero** documents,
418
+ * so the ratified navigation arm's abstention (44.0% dev / 45.2% holdout) is
419
+ * unchanged. Every corpus bare path sits mid-document, followed by further
420
+ * statements; working scripts do not end on a menu path.
421
+ *
422
+ * **#207 narrowed what this rule is for.** `isConfirmedNav` now knows whether the
423
+ * path is a menu, so `/system/reboot` is refused there — at any position, and
424
+ * without the +1.4pp / +1.9pp abstention that closing it positionally would have
425
+ * cost. What is left here is a document-scale hedge over paths the table DOES
426
+ * carry: a known menu that ends the document is still suspicious, since a
427
+ * trailing no-op is not what working scripts do, and the table is version-less
428
+ * so a name can be a menu on one RouterOS and a command on another the pinned
429
+ * trees never saw. Retained on that basis; it fires on no corpus document and no
430
+ * export capture.
431
+ *
432
+ * The bare `/` and `..` forms are exempt: they carry no run, name no command, and
433
+ * Q4's CHR round confirmed them as navigation outright.
434
+ *
435
+ * `lastNonEmpty` is the index of the document's last non-blank statement, walked
436
+ * once by `collect`; a statement at or past it is followed only by blanks.
437
+ */
438
+ function isDanglingBarePath(
439
+ described: Described,
440
+ lastNonEmpty: number,
441
+ index: number,
442
+ ): boolean {
443
+ if (described.run.length === 0) return false;
444
+ return index >= lastNonEmpty;
445
+ }
446
+
447
+ /**
448
+ * Does the run carry a token Q6's boundary — or the published command catalog —
449
+ * would call the verb?
450
+ *
451
+ * The catalog arm (#228 step 2) is what routes a bare `/system/gps/monitor`
452
+ * away from the unconfirmed-nav fallback, where the last token was only ever a
453
+ * GUESS and even a recognized READ verb had to be downgraded, and into
454
+ * {@link classifyStatement}, where it is a decided verb read against the
455
+ * vocabulary that matches its position.
456
+ */
457
+ function isCommandShaped(
458
+ described: Described,
459
+ catalogAt: number | null,
460
+ ): boolean {
461
+ if (described.run.length === 0) return false;
462
+ if (catalogAt !== null) return true;
463
+ const split = splitRun(described.run, {
464
+ directive: described.directive,
465
+ whole: described.whole,
466
+ });
467
+ return !split.ambiguous && split.verbAt !== null;
468
+ }
469
+
470
+ /**
471
+ * Classify one statement the Q4 walker read as navigation but Q6 could not
472
+ * confirm as a command. Reached only when every other rule has already
473
+ * abstained, so it falls back to Q6's `last`-token reading purely to obtain a
474
+ * verb CANDIDATE — and is never allowed to produce `false`: an unrecognized
475
+ * candidate stays `unknown-verb`, and even a recognized READ verb is downgraded
476
+ * to `unknown-verb`, because the reading that produced it is not trusted enough
477
+ * to clear the document.
478
+ */
479
+ function classifyUnconfirmedNav(
480
+ statement: StatementResolution,
481
+ text: string,
482
+ described: Described,
483
+ ): Occurrence {
484
+ const run = described.run;
485
+ const candidate = (run[run.length - 1] as RunToken | undefined)?.name ?? null;
486
+ const klass =
487
+ candidate === null ? "unknown-verb" : classifyVerb(candidate, false);
488
+ return {
489
+ kind: "statement",
490
+ text,
491
+ context: statement.context,
492
+ verb: candidate,
493
+ directive: false,
494
+ klass: klass === "read" ? "unknown-verb" : klass,
495
+ };
496
+ }
497
+
498
+ /**
499
+ * Classify a statement that reached neither the dynamic, defect, nor
500
+ * navigation branch: read Q6's boundary and look the verb up in the vocabulary
501
+ * that matches its position.
502
+ *
503
+ * The `ambiguous` arm is where this composition departs from the lab SUT, and
504
+ * it is a fail-closed correction rather than a port. The lab's path resolver
505
+ * called any all-bare-word statement navigation, so a bare-word head reached
506
+ * the unconfirmed-nav fallback and was classified from its last token. The
507
+ * production resolver does not: `menuNavPath` requires a leading `/`, because
508
+ * Q4's CHR round confirmed bare-word navigation is not valid RouterOS. That
509
+ * correction means a statement like `reset-counters` (relative to an
510
+ * `/interface` context) now arrives here with V4 ambiguity and no decided verb
511
+ * — and clearing it would be a false negative on a curated write verb, the one
512
+ * thing Q16's hard threshold forbids. So an undecided boundary over a non-empty
513
+ * run abstains, which is Q14's rule (b) — a bare-word-headed statement degrades
514
+ * to `unknown` — enforced at the rollup.
515
+ */
516
+ function classifyStatement(
517
+ statement: StatementResolution,
518
+ text: string,
519
+ described: Described,
520
+ catalogAt: number | null,
521
+ ): Occurrence {
522
+ const verb =
523
+ catalogAt === null
524
+ ? verbOfRun(described.run, described.directive, described.whole)
525
+ : ((described.run[catalogAt] as RunToken).name ?? null);
526
+ // A single-token command at document root is a root cmd written without its
527
+ // `:` sigil (`/import file-name=…`), so it reads against the root vocabulary
528
+ // rather than the menu one.
529
+ const directive =
530
+ described.directive ||
531
+ (verb !== null && statement.context === "/" && described.run.length === 1);
532
+ const klass =
533
+ verb === null && isPathShapedButUnread(text, described)
534
+ ? "ambiguous"
535
+ : classifyVerb(verb, directive);
536
+ return {
537
+ kind: "statement",
538
+ text,
539
+ context: statement.context,
540
+ verb,
541
+ directive,
542
+ klass,
543
+ };
544
+ }
545
+
546
+ /**
547
+ * Does this text name a command the run could not read? Used to decide whether
548
+ * a `null` verb may clear the document or must abstain.
549
+ *
550
+ * A NON-EMPTY run with no decided verb is V4's bare path — Q6's ratified
551
+ * `ambiguous`. An EMPTY run usually means the text names no command at this
552
+ * level (`[find]`, `(1)`, `"a string"`, `=`) and is safely `no-verb`: an inner
553
+ * `[…]` command is reached by the bracket walk instead.
554
+ *
555
+ * The exception is a `/`-led statement, where an empty run means the run was
556
+ * TRUNCATED rather than absent — `runTokens` stops at the first segment that is
557
+ * not a bare word, so `/ip/$menu/remove 0` and `/ip/$m/add address=…` yield an
558
+ * empty run while plainly naming a write. Clearing those is a false negative on
559
+ * a curated write verb, so a path-shaped statement whose path could not be read
560
+ * abstains.
561
+ */
562
+ function isPathShapedButUnread(text: string, described: Described): boolean {
563
+ return described.run.length > 0 || text.startsWith("/");
564
+ }
565
+
566
+ /**
567
+ * The published catalog's verb index for one statement or bracket inner, or
568
+ * null (#228 step 2).
569
+ *
570
+ * Applies `resolveVerb`'s base rule locally rather than calling it: this module
571
+ * reads `splitRun` directly, and threading the whole resolver in to get one
572
+ * index would put the R9 `bare-word head is not a known verb` refusal in front
573
+ * of the classification, where a refusal means "unknown" instead of "abstain".
574
+ * The two must agree on the BASE, though — a lookup rooted differently would ask
575
+ * about a different path — so the rule is stated the same way in both places: a
576
+ * directive or a `/`-led statement resolves at the root, everything else
577
+ * inherits its context.
578
+ *
579
+ * Including the bare-word rule `resolveVerb` states beside its own refusal: a
580
+ * relative statement is only looked up under an INHERITED menu context, never at
581
+ * the root, because the seven root-level catalog commands are all ordinary
582
+ * English words and the corpus's pasted `import serial` would otherwise read as
583
+ * RouterOS `/import`.
584
+ */
585
+ function catalogVerbOf(
586
+ text: string,
587
+ described: Described,
588
+ context: string,
589
+ ): number | null {
590
+ if (described.directive) return null;
591
+ if (text.startsWith("/")) return catalogVerbAt(described.run, "/");
592
+ if (context === "/") return null;
593
+ return catalogVerbAt(described.run, context);
594
+ }
595
+
596
+ /**
597
+ * Every command occurrence offline can see: statements at every block depth
598
+ * (Q4's stateful walker) plus bracket-inner commands at every nesting depth
599
+ * (Q3's resolver).
600
+ *
601
+ * The ORDER carries no meaning, even though the list is emitted in source order
602
+ * and repeats are significant (`writes` counts multiplicity — do not dedupe).
603
+ * Both lab harness bugs corrected during phase 0 were alignment bugs, where a
604
+ * positional correspondence silently lined the wrong pair up; `containsWrite`
605
+ * consumes this only as a rollup, so it cannot repeat that failure.
606
+ */
607
+ export function occurrences(text: string): Occurrence[] {
608
+ const statements = resolveStatements(text);
609
+ return collect(
610
+ statements,
611
+ resolveDocument(text),
612
+ sourceRange(text, statements),
613
+ );
614
+ }
615
+
616
+ function sourceRange(
617
+ text: string,
618
+ analysis: StatementAnalysis,
619
+ ): MaskedRange | undefined {
620
+ return analysis.defects.some(
621
+ (d) => d.code === "bom" || d.code === "non-ascii",
622
+ )
623
+ ? undefined
624
+ : documentRange(text, true);
625
+ }
626
+
627
+ function collect(
628
+ statements: StatementAnalysis,
629
+ brackets: DocumentAnalysis,
630
+ range?: MaskedRange,
631
+ ): Occurrence[] {
632
+ const out: Occurrence[] = [];
633
+ const list = statements.statements;
634
+ // Walked once here rather than per statement, so the dangling-bare-path rule
635
+ // stays O(n) on nav-heavy documents.
636
+ let lastNonEmpty = -1;
637
+ for (let i = list.length - 1; i >= 0; i--)
638
+ if ((list[i] as StatementResolution).text.trim().length > 0) {
639
+ lastNonEmpty = i;
640
+ break;
641
+ }
642
+
643
+ for (let index = 0; index < list.length; index++) {
644
+ const statement = list[index] as StatementResolution;
645
+ const t = statement.text.trim();
646
+ if (t.length === 0) continue;
647
+ // One parse per statement, threaded through every rule below: the Q6
648
+ // boundary reads the same run three or four times otherwise.
649
+ const trimStart = statement.text.length - statement.text.trimStart().length;
650
+ const described = describeStatement(
651
+ t,
652
+ range !== undefined &&
653
+ statement.span.start >= range.start &&
654
+ statement.span.end <= range.end &&
655
+ range.text.slice(statement.span.start, statement.span.end) ===
656
+ statement.text
657
+ ? rangeAt(
658
+ range,
659
+ statement.span.start + trimStart,
660
+ statement.span.start + trimStart + t.length,
661
+ )
662
+ : undefined,
663
+ );
664
+ const catalogAt = catalogVerbOf(t, described, statement.context);
665
+ if (isDynamicForm(t, described)) {
666
+ out.push({
667
+ kind: "statement",
668
+ text: t,
669
+ context: statement.context,
670
+ verb: null,
671
+ directive: false,
672
+ klass: "dynamic",
673
+ });
674
+ continue;
675
+ }
676
+ // Q14 floor — the resolver refused this statement and did not descend, so
677
+ // a write may be hiding in a body that was never walked. Only the reasons
678
+ // on the safe list clear: those name no command at this level, and their
679
+ // inner brackets are classified by the bracket walk below. Any OTHER
680
+ // reason blocks, including one this list has not seen — so rewording a
681
+ // reason in `pathresolve` degrades to over-abstention rather than
682
+ // silently clearing a hidden write.
683
+ if (
684
+ statement.unresolved !== undefined &&
685
+ !CLEARABLE_UNRESOLVED.has(statement.unresolved)
686
+ ) {
687
+ out.push({
688
+ kind: "statement",
689
+ text: t,
690
+ context: statement.context,
691
+ verb: null,
692
+ directive: false,
693
+ klass: "defect",
694
+ });
695
+ continue;
696
+ }
697
+ if (statement.isNav && !isCommandShaped(described, catalogAt)) {
698
+ if (
699
+ isConfirmedNav(described) &&
700
+ !isDanglingBarePath(described, lastNonEmpty, index)
701
+ )
702
+ continue;
703
+ out.push(classifyUnconfirmedNav(statement, t, described));
704
+ continue;
705
+ }
706
+ out.push(classifyStatement(statement, t, described, catalogAt));
707
+ }
708
+
709
+ for (const bracket of brackets.resolutions) {
710
+ const inner = bracket.inner.trim();
711
+ if (inner.length === 0) continue;
712
+ const bracketRange = rangeForResolution(bracket) ?? documentRange(inner);
713
+ const described = describeStatement(inner, bracketRange);
714
+ if (isDynamicForm(inner, described)) {
715
+ out.push({
716
+ kind: "bracket",
717
+ text: inner,
718
+ context: bracket.context,
719
+ verb: null,
720
+ directive: false,
721
+ klass: "dynamic",
722
+ });
723
+ continue;
724
+ }
725
+ // The same Q14 defect floor the statement walk applies above. A bracket
726
+ // carries its own refusal reasons and its own vocabulary — `variable path
727
+ // segment` is reachable here but not at statement scale — so the ALLOW
728
+ // list does the same work: an unrecognized reason blocks.
729
+ if (
730
+ bracket.unresolved !== undefined &&
731
+ !CLEARABLE_UNRESOLVED.has(bracket.unresolved)
732
+ ) {
733
+ out.push({
734
+ kind: "bracket",
735
+ text: inner,
736
+ context: bracket.context,
737
+ verb: null,
738
+ directive: false,
739
+ klass: "defect",
740
+ });
741
+ continue;
742
+ }
743
+ const catalogAt = catalogVerbOf(inner, described, bracket.context);
744
+ const verb =
745
+ catalogAt === null
746
+ ? verbOfRun(described.run, described.directive, described.whole)
747
+ : ((described.run[catalogAt] as RunToken).name ?? null);
748
+ const klass =
749
+ verb === null && isPathShapedButUnread(inner, described)
750
+ ? "ambiguous"
751
+ : classifyVerb(verb, described.directive);
752
+ out.push({
753
+ kind: "bracket",
754
+ text: inner,
755
+ context: bracket.context,
756
+ verb,
757
+ directive: described.directive,
758
+ klass,
759
+ });
760
+ }
761
+
762
+ return out;
763
+ }
764
+
765
+ /** Q16 result: the tristate plus everything the caller needs to see why. */
766
+ export interface WriteAnalysis {
767
+ verdict: WriteVerdict;
768
+ /** How many occurrences were proven writes. Agreed with IL 100% on both splits. */
769
+ writes: number;
770
+ /**
771
+ * Unclassifiable occurrences. These force `unknown` only when no proven write
772
+ * exists; rule 1 may therefore return `true` with blockers still reported.
773
+ */
774
+ blockers: Occurrence[];
775
+ occurrences: Occurrence[];
776
+ /**
777
+ * Located structural surprises from the segmenter / bounded traversal, plus
778
+ * the `bom`/`non-ascii` positional facts. Any NON-POSITIONAL defect abstains
779
+ * (see {@link hasStructuralDefect}); the positional two never do.
780
+ */
781
+ defects: Defect[];
782
+ }
783
+
784
+ const BLOCKING: ReadonlySet<OccurrenceClass> = new Set([
785
+ "unknown-verb",
786
+ "ambiguous",
787
+ "dynamic",
788
+ "defect",
789
+ ]);
790
+
791
+ /**
792
+ * Fail-closed rollup, per the four rules ratified with the tristate:
793
+ *
794
+ * 1. A proven write wins outright — an `unknown` elsewhere cannot make a
795
+ * document that definitely writes stop writing.
796
+ * 2. With no proven write, ANY unclassifiable occurrence yields `unknown`,
797
+ * never `false`. That is the spec's zero-false-negative requirement
798
+ * expressed as a rule rather than a hope.
799
+ * 3. Statements the parser cannot confirm as navigation emit an occurrence
800
+ * rather than being dropped (enforced in `occurrences`).
801
+ * 4. `unknown` is reported with its blockers, so the caller can see why.
802
+ *
803
+ * A document-level structural defect (unterminated string, over-depth nesting)
804
+ * means part of the input was never walked, so it abstains under rule 2 for the
805
+ * same reason a `defect` occurrence does.
806
+ */
807
+ export function containsWrite(text: string): WriteAnalysis {
808
+ // One resolution pass each for the statement walk (Q4) and the bracket walk
809
+ // (Q3); both are re-entrant over the same segmentation and neither is cheap
810
+ // on adversarial input, so they are not re-run for the defects.
811
+ const statements = resolveStatements(text);
812
+ return containsWriteFromAnalyses(
813
+ statements,
814
+ resolveDocument(text),
815
+ sourceRange(text, statements),
816
+ );
817
+ }
818
+
819
+ /**
820
+ * Reuse the Q3/Q4 walks when a composed analysis already performed them.
821
+ * `range`, if supplied, belongs to the same ASCII document as the analyses;
822
+ * statements outside that range keep their standalone reading.
823
+ */
824
+ export function containsWriteFromAnalyses(
825
+ statements: StatementAnalysis,
826
+ brackets: DocumentAnalysis,
827
+ range?: MaskedRange,
828
+ ): WriteAnalysis {
829
+ const found = collect(statements, brackets, range);
830
+ const defects = mergeDefects(statements.defects, brackets.defects);
831
+ const writes = found.filter((o) => o.klass === "write").length;
832
+ const blockers = found.filter((o) => BLOCKING.has(o.klass));
833
+ if (writes > 0)
834
+ return {
835
+ verdict: "true",
836
+ writes,
837
+ blockers,
838
+ occurrences: found,
839
+ defects,
840
+ };
841
+ if (blockers.length > 0 || hasStructuralDefect(defects))
842
+ return {
843
+ verdict: "unknown",
844
+ writes: 0,
845
+ blockers,
846
+ occurrences: found,
847
+ defects,
848
+ };
849
+ return {
850
+ verdict: "false",
851
+ writes: 0,
852
+ blockers: [],
853
+ occurrences: found,
854
+ defects,
855
+ };
856
+ }