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