@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
package/src/explain.ts ADDED
@@ -0,0 +1,2602 @@
1
+ /**
2
+ * `explain` — the offline analysis, composed.
3
+ *
4
+ * Phase 0.5 promoted nine ratified analyzers into `src/explain/` with **no
5
+ * caller**: segment, blocks, coordinates, pathresolve, verbsplit, write,
6
+ * symbols, plus the internal menus/verbs tables. Each answers one lab question
7
+ * and none of them knows the others exist. This module is the composition
8
+ * (#202): one pass over one input producing the result shape
9
+ * `commands/explain/README.md` → *Result shape* specifies, so a caller sequences
10
+ * nothing and every fact carries its provenance.
11
+ *
12
+ * ## The two verdicts are different questions
13
+ *
14
+ * `data.verdict` (`pass`/`warn`/`fail`) is the maximum DIAGNOSTIC SEVERITY. It
15
+ * exists to drive `--fail-on` and nothing else.
16
+ * `structure.statements[].resolution` (`resolved`/`ambiguous`/`unknown`) is
17
+ * what the CANONICALIZER decided about one statement.
18
+ *
19
+ * They are deliberately not collapsed: a document can be perfectly well-formed
20
+ * (`verdict: "pass"`) and still be unreadable offline (`/system/reboot` is
21
+ * `ambiguous` — a menu and a no-argument command are the same text without a
22
+ * schema), and a document riddled with defects still returns `ok: true`
23
+ * because the diagnostics ARE the data.
24
+ *
25
+ * ## What the gate is, and what it is not
26
+ *
27
+ * `data.canonical` reproduces `canonicalizeExecuteCommand` field for field.
28
+ * That function is centrs's script-vs-structured EXECUTION gate and widening
29
+ * it is a product regression (`docs/CONSTITUTION.md`); the richer analysis
30
+ * sits BESIDE it and never feeds back into it. Two consequences a reader will
31
+ * otherwise trip on:
32
+ *
33
+ * - The gate wants the verb IN the path. `/ip/route/add dst-address=…` is
34
+ * `structured`; the CLI spelling `/ip/route add dst-address=…` is `script`,
35
+ * because `add` is a bare token with no `=`. `structure.statements[]` reads
36
+ * BOTH as `/ip/route` + `add`, and that disagreement is not a bug — the gate
37
+ * is answering "may this be sent as a structured REST call", the analysis is
38
+ * answering "what did the human write".
39
+ * - The gate reads a bare `/ip/route` as path `/ip` + verb `route`, while the
40
+ * analysis reads it as menu navigation (the #207 table confirms it). Again
41
+ * two different questions over the same bytes.
42
+ *
43
+ * ## Declared gaps (phase 1 offline)
44
+ *
45
+ * - **Per-statement arguments are read where they are LITERAL** (#202c).
46
+ * `structure.statements[].command.args` and the ordered `arguments.tokens`
47
+ * beside it come from `explain/args.ts`, which decides every token of a
48
+ * statement or refuses the whole list with a reason — a partially-read list
49
+ * would silently change what a rendered command does. On the frozen corpus
50
+ * 40% of CRUD-verb commands are read; the refusals are overwhelmingly a
51
+ * value only the device can compute (`[…]`, `$x`, `{…}`). The gate's
52
+ * `canonical.args` and a read list never contradict each other (measured:
53
+ * 83 corpus statements where both decided, 0 contradictions), though the
54
+ * analysis may abstain where the gate decided.
55
+ * - **Transport classification is fail-closed.** `api-candidate` is emitted
56
+ * only for the nine Q8 shapes runtime-exercised on two CHR versions;
57
+ * script-shaped input routes to `execute`, and every other resolved command
58
+ * is `unknown` with a reason. Curl is rendered only when requested.
59
+ * - **`spans` covers what offline can PROVE**: comment runs and resolved
60
+ * variable occurrences (Q13 scored 100% precision on resolved bindings, and
61
+ * abstentions are omitted rather than guessed). The full Q12 vocabulary over
62
+ * path/verb/argument bytes needs device `highlight` as its oracle and is
63
+ * phase 2. A subset is not a claim that the vocabulary is closed.
64
+ * - **Value facts have three axes, and highlight supplies none of them.**
65
+ * Offline analysis now publishes non-authoritative lexical SHAPE hints. The
66
+ * hint list may carry several shapes, but it borrows RouterOS's own type
67
+ * names, so it must never spell one the device contradicts: `2.2` is an
68
+ * IPv4 shortcut (`2.0.0.2`), not a decimal, because RouterOS numbers are
69
+ * integers. The type OBSERVED from a live reading and the SCHEMA type have
70
+ * separate optional homes but no offline producer. Each fact has its own
71
+ * provenance; no hint validates a value or becomes a diagnostic. The
72
+ * decision and the probe matrix are #225; `op` is deliberately not a shape
73
+ * hint — `(> …)` abstains offline and is reported only as a live
74
+ * `observedType` because identical syntax can be `op` or `array` and
75
+ * `:parse` IL cannot distinguish them (#288). This module is deliberately
76
+ * type-blind, which is why a verdict that looks wrong around a value is a
77
+ * type-axis question before it is a lexical one.
78
+ * - **`runtimeAcceptance` is always `"not-proven"`**, offline and live alike.
79
+ * It is the inspect-vs-runtime gap made machine-readable, not a placeholder.
80
+ *
81
+ * ## This module is the public offline entry — its imports are a contract
82
+ *
83
+ * `@tikoci/centrs/explain` resolves here, and the claim that offline analysis is a
84
+ * LIBRARY capability rests on what this file imports: nothing in the graph below
85
+ * it may open a connection, read CDB, or touch the filesystem, so the module
86
+ * bundles and runs in a browser, a Worker, or an editor host (#312).
87
+ *
88
+ * That is easy to break with one line, and it was: `toYaml` and
89
+ * `canonicalizeExecuteCommand` are pure, but importing them from `retrieve.ts`
90
+ * and `execute.ts` pulled mac-telnet (`node:dgram`), native-api (`node:crypto`)
91
+ * and ssh in behind them. Both now live in `src/core/`. **Before adding an
92
+ * import here, check it is pure** — `bun run explain:browser-consumer` walks the
93
+ * graph and names the offending edge, and
94
+ * `test/unit/explain-browser-entry.test.ts` gates it. A pure helper that lives
95
+ * in a transport-reaching module belongs in `src/core/`, not in an import here.
96
+ */
97
+
98
+ import type {
99
+ CentrsErrorEnvelope,
100
+ CentrsSuccessEnvelope,
101
+ CommonSettingsMeta,
102
+ EnvelopeMeta,
103
+ } from "./core/envelope.ts";
104
+ import { buildTip, type Tip, type Warning } from "./core/envelope.ts";
105
+ import {
106
+ type CanonicalExecuteCommand,
107
+ canonicalizeExecuteCommand,
108
+ isWriteShaped,
109
+ } from "./core/execute-command.ts";
110
+ import { toYaml } from "./core/yaml.ts";
111
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
112
+ import { argSpans } from "./explain/arg-tokens.ts";
113
+ import {
114
+ type ArgumentKind,
115
+ type Argument as ExplainArgument,
116
+ invalidCommandBraceOffsets,
117
+ lexArguments,
118
+ lexArgumentTokens,
119
+ lexValueAnchors,
120
+ type ValueAnchorKind,
121
+ } from "./explain/args.ts";
122
+ import { scopeBlocks } from "./explain/blocks.ts";
123
+ import { braceSpans } from "./explain/brace-tokens.ts";
124
+ import { lookupPath } from "./explain/catalog.ts";
125
+ import {
126
+ analyzeCoordinates,
127
+ type CoordinateAnalysis,
128
+ } from "./explain/coordinates.ts";
129
+ import {
130
+ type Defect,
131
+ type DefectCode,
132
+ isPositionalFact,
133
+ } from "./explain/defects.ts";
134
+ import { escapeSpans } from "./explain/escape-tokens.ts";
135
+ import { isKnownMenuPath } from "./explain/is-known-menu.ts";
136
+ import { operatorSpans } from "./explain/operator-tokens.ts";
137
+ import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
138
+ import {
139
+ type Resolution,
140
+ rangeForResolution,
141
+ resolveDocument,
142
+ resolveStatements,
143
+ } from "./explain/pathresolve.ts";
144
+ import { walkStringEscapes } from "./explain/quoted-string.ts";
145
+ import {
146
+ documentRange,
147
+ type MaskedRange,
148
+ nestedRange,
149
+ rangeMask,
150
+ segmentStatements,
151
+ } from "./explain/segment.ts";
152
+ import {
153
+ findMissingSeparators,
154
+ type MissingSeparator,
155
+ } from "./explain/separator.ts";
156
+ import { stringSpans } from "./explain/string-tokens.ts";
157
+ import { augmentSymbolOccurrences } from "./explain/symbol-values.ts";
158
+ import {
159
+ resolveSymbols,
160
+ type SymbolClass,
161
+ type SymbolRole,
162
+ } from "./explain/symbols.ts";
163
+ import {
164
+ classifyExplainTransport,
165
+ type ExplainTransport,
166
+ } from "./explain/transport.ts";
167
+ import { valueSpans } from "./explain/value-tokens.ts";
168
+ import { type ValueShape, valueShapeHints } from "./explain/values.ts";
169
+ import {
170
+ type DocumentVerbSplit,
171
+ resolveVerb,
172
+ resolveVerbsFromStatements,
173
+ VERBS,
174
+ type VerbSplit,
175
+ } from "./explain/verbsplit.ts";
176
+ import {
177
+ containsWriteFromAnalyses,
178
+ type WriteVerdict,
179
+ } from "./explain/write.ts";
180
+ import {
181
+ type ResolvedSetting,
182
+ resolveStringSetting,
183
+ toCoreSource,
184
+ } from "./resolver/settings.ts";
185
+
186
+ /** A half-open analyzed-byte span, the coordinate contract of `coordinates.ts`. */
187
+ export interface ExplainSpanRange {
188
+ start: number;
189
+ end: number;
190
+ }
191
+
192
+ /**
193
+ * One run of the analyzed input mapped back to the original.
194
+ *
195
+ * Identity runs are retained (the spec's requirement) so a consumer needs no
196
+ * second coordinate contract for un-normalized input. A run is coalesced only
197
+ * while the mapping inside it is 1 byte : 1 UTF-16 unit; every non-ASCII code
198
+ * point gets its own entry, because the mapping across a 4-byte/2-unit
199
+ * character is not linear and a coalesced run could not be indexed into.
200
+ */
201
+ export interface ExplainPositionMapEntry {
202
+ analyzed: ExplainSpanRange;
203
+ originalUtf16: ExplainSpanRange;
204
+ }
205
+
206
+ export interface ExplainInput {
207
+ /** Length of the analyzed surface, in bytes. Equals the UTF-8 byte length. */
208
+ bytes: number;
209
+ /** True when normalization stood in for at least one non-ASCII byte. */
210
+ normalized: boolean;
211
+ /**
212
+ * Whether the ANALYZED INPUT was cut short. Always `false` in phase 1: there
213
+ * is no input size cap yet. It is not the `--full` smart-sizing flag, which
214
+ * bounds the RESULT.
215
+ */
216
+ truncated: boolean;
217
+ positionMap: ExplainPositionMapEntry[];
218
+ }
219
+
220
+ /** Maximum diagnostic severity. Drives `--fail-on`; nothing else. */
221
+ export type ExplainVerdict = "pass" | "warn" | "fail";
222
+
223
+ /** The whole-input execute gate, reproduced. */
224
+ export interface ExplainCanonical {
225
+ mode: CanonicalExecuteCommand["mode"];
226
+ path: string;
227
+ verb: string;
228
+ /** `CanonicalExecuteCommand.attributes` under the ratified envelope name. */
229
+ args: Record<string, string>;
230
+ queries: string[];
231
+ /** `isWriteShaped(canonical)` — the write-confirmation predicate, verbatim. */
232
+ writeShaped: boolean;
233
+ }
234
+
235
+ /** What the canonicalizer decided about one statement. */
236
+ export type ExplainResolution = "resolved" | "ambiguous" | "unknown";
237
+
238
+ /**
239
+ * A resolved menu. Navigation names a menu and nothing else, so there is no
240
+ * verb — not an absent one.
241
+ */
242
+ export interface ExplainMenuReading {
243
+ resolution: "resolved";
244
+ kind: "menu";
245
+ command: { path: string };
246
+ /** Navigation names a menu and nothing else — there is no argument list. */
247
+ arguments?: undefined;
248
+ /** A menu is navigation, not a transportable command. */
249
+ transport?: undefined;
250
+ unresolved?: undefined;
251
+ }
252
+
253
+ /**
254
+ * One argument token, located in DOCUMENT analyzed-byte space.
255
+ *
256
+ * The ordered list is the primary shape and {@link ExplainCommandReading.command}
257
+ * `.args` is the object VIEW derived from it — the phase-0 normal form, which
258
+ * requires order and multiplicity to survive somewhere a consumer can read them.
259
+ */
260
+ export interface ExplainArgumentToken {
261
+ kind: ArgumentKind;
262
+ span: ExplainSpanRange;
263
+ /** Attribute name / query word; absent on a positional. */
264
+ name?: string;
265
+ /**
266
+ * The token's LITERAL value, quotes removed — for an attribute the part after
267
+ * `=`, for a positional the whole token.
268
+ *
269
+ * **Absent means there is no literal value**, whatever the kind: the source
270
+ * spells a substitution, an expression, or an escape this phase does not
271
+ * decode. A consumer rendering a runnable command reads this, never `text`,
272
+ * and treats absence as "not renderable". A query is always absent.
273
+ */
274
+ value?: string;
275
+ /** Where the value sits, quotes INCLUDED. Absent whenever `value` is. */
276
+ valueSpan?: ExplainSpanRange;
277
+ /** The token verbatim. */
278
+ text: string;
279
+ }
280
+
281
+ /**
282
+ * What the statement-scope lexer decided about one command's arguments.
283
+ *
284
+ * All-or-nothing by construction (`src/explain/args.ts`): the consumer is a REST
285
+ * rendering, and a partially-read argument list changes what the rendered
286
+ * command DOES. `read: false` carries the reason, which the transport basis then
287
+ * quotes rather than reporting a bare `unknown`.
288
+ */
289
+ export type ExplainArguments =
290
+ | {
291
+ read: true;
292
+ tokens: ExplainArgumentToken[];
293
+ /** `?`-prefixed query words, in order. */
294
+ queries: string[];
295
+ /** Bare tokens, in order — an id (`*1`), a `where`, a `find` operand. */
296
+ positional: string[];
297
+ }
298
+ | { read: false; why: string };
299
+
300
+ /** A resolved command: a path AND the verb that was decided on it. */
301
+ export interface ExplainCommandReading {
302
+ resolution: "resolved";
303
+ kind: "command";
304
+ /**
305
+ * `args` is present exactly when {@link arguments} read: it is that reading's
306
+ * object view (last occurrence wins), in the ratified sketch's place
307
+ * (`commands/explain/README.md` → *Result shape*). Absent is not `{}` — a
308
+ * command with no arguments reads as `{}`.
309
+ */
310
+ command: { path: string; verb: string; args?: Record<string, string> };
311
+ /**
312
+ * ABSENT rather than refused where no attempt was made — the same convention
313
+ * transport follows. Statements always carry it; a `[…]` subcommand does not,
314
+ * because its inner text has its own coordinate space that phase 1 does not
315
+ * rebase (see {@link ExplainSubcommand}).
316
+ */
317
+ arguments?: ExplainArguments;
318
+ /**
319
+ * Present on document statements after the #202c-2 transport pass. Absent on
320
+ * subcommands, whose inner argument coordinates phase 1 does not rebase.
321
+ */
322
+ transport?: ExplainTransport;
323
+ unresolved?: undefined;
324
+ }
325
+
326
+ /** A refusal. It carries its reason and nothing that would read as a reading. */
327
+ export interface ExplainRefusal {
328
+ resolution: "ambiguous" | "unknown";
329
+ kind?: undefined;
330
+ command?: undefined;
331
+ /** No command was read, so nothing here is an argument OF one. */
332
+ arguments?: undefined;
333
+ /** No command was read, so no transport can be classified. */
334
+ transport?: undefined;
335
+ /** Why the analyzer refused. */
336
+ unresolved: string;
337
+ }
338
+
339
+ /**
340
+ * What the analyzer read, as a discriminated union.
341
+ *
342
+ * `verbsplit` reports menu navigation as its own `navigation` resolution so
343
+ * that `resolved` keeps meaning "a verb was decided" for its callers; the
344
+ * envelope folds the two into `resolved` + `kind`, which is the ratified
345
+ * vocabulary. The union rather than four independent optionals, because these
346
+ * fields co-vary and the intended consumers (LSP, MCP) narrow on them:
347
+ * `resolution === "resolved"` must yield a `command`, and `kind === "command"`
348
+ * must yield a `verb`, at compile time. Independently optional fields also
349
+ * describe three shapes that cannot occur — a resolved statement with no
350
+ * command, a menu with a verb, a refusal with a command.
351
+ *
352
+ * Every variant declares the other variants' discriminants as `?: undefined`,
353
+ * so `s.kind` and `s.command` stay readable without narrowing first.
354
+ */
355
+ export type ExplainReading =
356
+ | ExplainMenuReading
357
+ | ExplainCommandReading
358
+ | ExplainRefusal;
359
+
360
+ /** Where a statement is, and how much the analyzer knew when it read it. */
361
+ export interface ExplainStatementFacts {
362
+ span: ExplainSpanRange;
363
+ /**
364
+ * Was the menu context in force BEFORE this statement known? A `false` here
365
+ * does not invalidate a `resolved` reading — the resolver already degrades
366
+ * every context-DEPENDENT statement when context is lost, so a statement that
367
+ * still resolved did not consume it (#192, #197).
368
+ */
369
+ contextCertain: boolean;
370
+ ev: string;
371
+ }
372
+
373
+ export type ExplainStatement = ExplainStatementFacts & ExplainReading;
374
+
375
+ /**
376
+ * A `[…]` command substitution, re-constituted against its enclosing menu.
377
+ *
378
+ * Same vocabulary as {@link ExplainStatement}, deliberately: an inner command is
379
+ * a command, and a caller should not have to learn a second shape to read one.
380
+ *
381
+ * That means the verb boundary here is Q6's answer (`resolveVerb` over the
382
+ * bracket's inner text), not Q3's `Resolution.path`. The two are not always the
383
+ * same — Q3's `path` is documented as the greedy leading run, its "best guess",
384
+ * and it reads `[/system/identity/get name]` as the menu
385
+ * `/system/identity/get` where Q6 reads menu `/system/identity` + verb `get`.
386
+ * Q6 is the module that decides verbs (and #211's R9 exists precisely so these
387
+ * two stop contradicting each other), so it is what the envelope presents; Q3's
388
+ * alternative readings survive as {@link candidates}. Where Q3 REFUSED, this
389
+ * refuses too, whatever Q6 says — the fail-closed floor is not up for a second
390
+ * opinion.
391
+ */
392
+ export interface ExplainSubcommandFacts extends ExplainStatementFacts {
393
+ /** Menu context in force at the bracket, `/` at document root. */
394
+ context: string;
395
+ /** Every path the inner command could resolve to, shortest first. */
396
+ candidates: string[];
397
+ /** Bracket nesting depth; 0 is directly inside a statement. */
398
+ depth: number;
399
+ }
400
+
401
+ export type ExplainSubcommand = ExplainSubcommandFacts & ExplainReading;
402
+
403
+ /** A `{…}` scope at document level. */
404
+ export interface ExplainBlock {
405
+ /** The argument or directive that opened it (`do`, `on-error`, …). */
406
+ name: string;
407
+ /** The BODY's span, braces excluded. */
408
+ span: ExplainSpanRange;
409
+ ev: string;
410
+ }
411
+
412
+ export interface ExplainStructure {
413
+ /**
414
+ * `statements.length`, which counts BLOCK-BODY statements too — see
415
+ * {@link ExplainStructure.statements}. Not the number of top-level statements.
416
+ */
417
+ statementCount: number;
418
+ /**
419
+ * Every statement the analyzer read, in discovery order.
420
+ *
421
+ * A `do={…}` body's statements are flattened in after their parent (which is
422
+ * what RouterOS IL does to them), so a body statement's span is CONTAINED by
423
+ * its parent's rather than following it, and spans may overlap. A consumer
424
+ * that wants only the top level filters on containment; one that wants "every
425
+ * command in this script" — the common case, and the reason to flatten — reads
426
+ * the list as it stands.
427
+ */
428
+ statements: ExplainStatement[];
429
+ blocks: ExplainBlock[];
430
+ /**
431
+ * The Q16 tristate. `"unknown"` is a real value, not a missing one: a
432
+ * write-shaped command outside the small version-stable write table
433
+ * (`/disk format-drive`) must never report `false`.
434
+ */
435
+ containsWrite: boolean | "unknown";
436
+ subcommands: ExplainSubcommand[];
437
+ ev: string;
438
+ }
439
+
440
+ /**
441
+ * A classified byte run.
442
+ *
443
+ * The vocabulary is centrs-owned (the ratified decision): consumers depend on
444
+ * it rather than on raw RouterOS `highlight` classes, so cross-version drift in
445
+ * those classes is centrs's problem to absorb. For these five the two names
446
+ * currently coincide, which is a convenience, not the contract.
447
+ */
448
+ export type ExplainSpanClass =
449
+ | "comment"
450
+ | "variable-local"
451
+ | "variable-global"
452
+ | "variable-auto"
453
+ | "variable-parameter";
454
+
455
+ export interface ExplainSpan extends ExplainSpanRange {
456
+ class: ExplainSpanClass;
457
+ ev: string;
458
+ }
459
+
460
+ /**
461
+ * One analyzer's contribution to the token partition.
462
+ *
463
+ * A fill is already-sorted, non-overlapping spans in document (analyzed-byte)
464
+ * space. `buildTokens` takes fills **in order** — fill 0 claims first, fill 1
465
+ * sees only the residual, and so on — so the argument order IS the fill order
466
+ * (#290 design decision 1) and a structural ambiguity (`/` path sep vs `division`)
467
+ * resolves by which analyzer came first rather than by a smarter byte scanner.
468
+ *
469
+ * A fill is typed as `ExplainToken[]` because B2 fills (path, argument, value,
470
+ * then operator) introduce classes outside `ExplainSpanClass`; the proof-only
471
+ * `spans[]` (ExplainSpan[]) remains a subtype and so fits the same slot.
472
+ */
473
+ export type TokenFill = readonly ExplainToken[];
474
+
475
+ /**
476
+ * A token in the total, gapless byte partition (B1).
477
+ *
478
+ * Every byte of the analyzed input belongs to exactly one token — a
479
+ * contiguous, non-overlapping partition of `[0, input.bytes)` — sorted by
480
+ * `start`, with no gaps and `tokens.map(t => slice).join("") === input`.
481
+ * Byte-exact, inheriting the #215/#252 offset discipline. Offsets are on the
482
+ * analyzed text; `input.positionMap` applies when normalized.
483
+ *
484
+ * `class` is the vocabulary #264 B5 settled (see below); it is not an LSP/SCIP
485
+ * legend and does not try to be one. Every byte no analyzer claims becomes
486
+ * `unclassified`, which is a first-class answer, not a placeholder to be
487
+ * avoided.
488
+ *
489
+ * B2 so far: `dir` / `cmd` (resolved path and verb bytes, including valid path
490
+ * slashes), `operator` (26 spellings + 2 aliases, `syntax-meta` is a residual
491
+ * merge, never a source, #255), `arg` / `arg-sep` (argument names and the `=`
492
+ * that binds each one to its value, both located by `args.ts`), and `value`
493
+ * (argument value bytes and leaf array-literal members from
494
+ * `data.values.occurrences`, quotes included, #295).
495
+ *
496
+ * **B5 settled which merges get their own name** (`commands/explain/README.md`
497
+ * -> *The vocabulary, and what earns a class*, where the figures live beside
498
+ * the generated blocks that keep them honest): a class is split only where a
499
+ * consumer holding the whole envelope could not recover the distinction by
500
+ * joining `tokens[]` to another published surface on byte offsets. `arg-sep` is
501
+ * the one split that test forced, because a large minority of the `=` runs the
502
+ * arg fill paints belong to a statement whose strict `arguments` reading
503
+ * refused, so there is no `ExplainArgumentToken.valueSpan` to subtract one
504
+ * from. The per-spelling `operator`, per-shape `value`, and scope-versus-array
505
+ * `brace` merges all stay merged — the bytes, `data.values.occurrences[]`, and
506
+ * `structure.blocks[]` already carry those distinctions.
507
+ */
508
+ export type ExplainTokenClass =
509
+ | ExplainSpanClass
510
+ | "dir"
511
+ | "cmd"
512
+ | "operator"
513
+ | "arg"
514
+ /**
515
+ * The single `=` byte binding one argument name to its value (#264 B5).
516
+ *
517
+ * Never a comparison `=`, and the separation is fill order rather than a
518
+ * byte scan: the `arg` fill offers only the `=` its own located token names,
519
+ * so a comparison inside a `( )` group falls through to `operator`, and one
520
+ * inside a `[ … ]` substitution stays `unclassified` where the operator fill
521
+ * abstains (`src/explain/operator-tokens.ts`).
522
+ */
523
+ | "arg-sep"
524
+ | "value"
525
+ | "string"
526
+ /**
527
+ * One valid string-internal escape sequence — the `\` and the bytes it
528
+ * escapes (#264).
529
+ *
530
+ * The one split B5's rule admits that naming the `=` did not make. A valid
531
+ * escape is recoverable from no other published surface: only INVALID
532
+ * escapes appear elsewhere, as `bad-string-escape` diagnostics. Both come
533
+ * from the same walk (`walkStringEscapes`), so a token and a diagnostic
534
+ * cannot disagree about which bytes are an escape, and past the first
535
+ * invalid escape the walk stops claiming rather than guess.
536
+ */
537
+ | "escaped"
538
+ | "brace"
539
+ | "unclassified";
540
+
541
+ export interface ExplainToken extends ExplainSpanRange {
542
+ /**
543
+ * The vocabulary #264 B5 settled — see {@link ExplainTokenClass} for what
544
+ * earns a class and why the remaining merges are deliberate.
545
+ * `unclassified` means no analyzer claimed this byte.
546
+ */
547
+ class: ExplainTokenClass;
548
+ ev: string;
549
+ }
550
+
551
+ /** The Q13 class, or `null` where offline analysis must abstain. */
552
+ export type ExplainSymbolClass = Exclude<SymbolClass, "undefined"> | null;
553
+
554
+ export interface ExplainSymbolOccurrence {
555
+ name: string;
556
+ span: ExplainSpanRange;
557
+ class: ExplainSymbolClass;
558
+ role: SymbolRole;
559
+ /** Stable only within this result; empty when no visible binding was decided. */
560
+ bindingIds: string[];
561
+ /** Whether the source used a `$`-sigilled reference spelling. */
562
+ sigil: boolean;
563
+ /** The resolver's reason for an abstention or special reading. */
564
+ note?: string;
565
+ ev: string;
566
+ /**
567
+ * For a declaration/binding/assignment with a literal RHS in the same
568
+ * statement, the `ExplainValueOccurrence.id` that spells it. Absent when
569
+ * the RHS is a variable, substitution, expression, or absent.
570
+ *
571
+ * No RHS evaluation (#239 S2): only a directly literal positional value
572
+ * in the same statement is linked.
573
+ */
574
+ valueId?: string;
575
+ /**
576
+ * For a reference, the set of literal `valueId`s that may reach this
577
+ * program point (flow-sensitive, conservative). Linear code is
578
+ * last-write-wins (single element); a branch merge is a set; a loop merge
579
+ * or a non-literal assignment is `unknown`. Branch/loop bodies are scope
580
+ * blocks; an optional branch unions the pre-block and block-final values,
581
+ * while an exhaustive `if`/`else` unions the two arm-final values.
582
+ * `reachingUnknown` true means the set may be incomplete; callers should
583
+ * treat the shape as `unknown` rather than the listed ids.
584
+ */
585
+ reachingValueIds?: string[];
586
+ /** True when the reaching set is incomplete or otherwise unknown. */
587
+ reachingUnknown?: boolean;
588
+ }
589
+
590
+ /** Semantic symbol facts; `spans` remains the token/LSP-oriented projection. */
591
+ export interface ExplainSymbols {
592
+ occurrences: ExplainSymbolOccurrence[];
593
+ }
594
+
595
+ export interface ExplainValueShapeFact {
596
+ /**
597
+ * A list because the axis admits overlap; the 7.23.3/7.24rc3-grounded V1
598
+ * lexicon happens to assign at most one shape per spelling, and abstains
599
+ * where it has no grounded member (#243) rather than widening a near miss.
600
+ */
601
+ values: ValueShape[];
602
+ ev: string;
603
+ }
604
+
605
+ export interface ExplainValueTypeFact {
606
+ value: string;
607
+ ev: string;
608
+ }
609
+
610
+ /** The three #225 axes; live producers add the latter two without reshaping. */
611
+ export interface ExplainValueFacts {
612
+ shapeHints?: ExplainValueShapeFact;
613
+ observedType?: ExplainValueTypeFact;
614
+ schemaType?: ExplainValueTypeFact;
615
+ }
616
+
617
+ export interface ExplainValueOccurrence {
618
+ /** Result-local identity for #239's later flow-sensitive references. */
619
+ id: string;
620
+ /** Literal source bytes, quotes included. */
621
+ span: ExplainSpanRange;
622
+ tokenSpan: ExplainSpanRange;
623
+ /** `element` is one member of an array literal, located inside its container. */
624
+ kind: ValueAnchorKind;
625
+ /** An attribute's name, or an array member's key. */
626
+ name?: string;
627
+ quoted: boolean;
628
+ /**
629
+ * The `id` of the array literal this value is a member of.
630
+ *
631
+ * Present only on `element` rows. Containment is a reference rather than
632
+ * nesting so the list stays flat and ordered, and so #239 can point a symbol
633
+ * at one member without re-walking a tree.
634
+ */
635
+ parent?: string;
636
+ facts: ExplainValueFacts;
637
+ }
638
+
639
+ export interface ExplainValues {
640
+ occurrences: ExplainValueOccurrence[];
641
+ }
642
+
643
+ export type ExplainSeverity = "error" | "warning" | "info";
644
+
645
+ export interface ExplainDiagnostic {
646
+ /** Slash-namespaced `explain/<source>/<slug>`. */
647
+ code: string;
648
+ severity: ExplainSeverity;
649
+ message: string;
650
+ span: ExplainSpanRange;
651
+ ev: string;
652
+ }
653
+
654
+ /**
655
+ * Where a derived fact came from — the OFFLINE SUBSET of the contract.
656
+ *
657
+ * The spec's evidence entry also carries `source: "live-inspect"` and a
658
+ * RouterOS version stamp per probe, neither of which phase 1 can produce: no
659
+ * probe runs and there is no device to stamp. The type says only what offline
660
+ * emits rather than pre-declaring variants with no producer, so a consumer
661
+ * cannot narrow on a `source` that never appears; phase 2 widens the union and
662
+ * adds the stamp, which is an ADDITION to the shape a caller reads today.
663
+ */
664
+ export interface ExplainEvidence {
665
+ id: string;
666
+ source: "canonicalizer";
667
+ /** The analyzer that produced the facts citing this entry. */
668
+ probe: string;
669
+ /**
670
+ * `direct` — a deterministic reading of the bytes (the gate, the coordinate
671
+ * pass). `heuristic` — a ratified offline rule that a live probe could
672
+ * overturn (path resolution, the verb boundary, the write tristate, symbol
673
+ * classes). No offline fact is `derived`, which is reserved for a conclusion
674
+ * drawn from live responses.
675
+ */
676
+ basis: "direct" | "heuristic" | "derived";
677
+ outcome: "ok" | "empty" | "timeout" | "transport-error";
678
+ }
679
+
680
+ export interface ExplainData {
681
+ input: ExplainInput;
682
+ verdict: ExplainVerdict;
683
+ canonical: ExplainCanonical;
684
+ structure: ExplainStructure;
685
+ symbols: ExplainSymbols;
686
+ values: ExplainValues;
687
+ spans: ExplainSpan[];
688
+ /**
689
+ * Total, gapless token partition of `[0, input.bytes)`.
690
+ *
691
+ * Present only when `--tokens` is requested, matching the `--complete` /
692
+ * `--schema` / `--curl` facet pattern. Every byte belongs to exactly one
693
+ * token, sorted by `start`, with no gaps and no overlaps:
694
+ * `tokens.map(t => input.slice(t.start, t.end)).join("") === input`.
695
+ * `class` is the vocabulary #264 B5 settled ({@link ExplainTokenClass});
696
+ * every byte no analyzer claims is `unclassified`.
697
+ */
698
+ tokens?: ExplainToken[];
699
+ diagnostics: ExplainDiagnostic[];
700
+ evidence: ExplainEvidence[];
701
+ /**
702
+ * Always `"not-proven"`. `/console/inspect` accepts forms the runtime
703
+ * rejects, so no amount of offline or probe evidence proves acceptance.
704
+ */
705
+ runtimeAcceptance: "not-proven";
706
+ }
707
+
708
+ export interface ExplainOperationMeta {
709
+ command: "explain";
710
+ /** `offline` is the only mode phase 1 has; live evidence is phase 2. */
711
+ mode: "offline";
712
+ statementCount: number;
713
+ verdict: ExplainVerdict;
714
+ }
715
+
716
+ export type ExplainEnvelope = CentrsSuccessEnvelope<
717
+ ExplainData,
718
+ ExplainOperationMeta
719
+ >;
720
+
721
+ /**
722
+ * Stable evidence ids, one per analysis PASS.
723
+ *
724
+ * A pass is the finest distinction that is REAL offline: every fact a pass
725
+ * produces has the same provenance, and a per-fact id would imply a granularity
726
+ * the analyzers do not have. But it has to be the pass that actually produced
727
+ * the fact — `statements` and `subcommands` are separate entries because they
728
+ * come from different walks, and a defect is tagged where it is RAISED (below),
729
+ * not with whichever analyzer happens to be merged first.
730
+ */
731
+ const EV = {
732
+ canonical: "e0",
733
+ coordinates: "e1",
734
+ segment: "e2",
735
+ blocks: "e3",
736
+ statements: "e4",
737
+ subcommands: "e5",
738
+ write: "e6",
739
+ symbols: "e7",
740
+ transport: "e8",
741
+ values: "e9",
742
+ operators: "e10",
743
+ args: "e11",
744
+ paths: "e12",
745
+ strings: "e13",
746
+ braces: "e14",
747
+ separators: "e15",
748
+ escapes: "e16",
749
+ } as const;
750
+
751
+ type EvidenceKey = keyof typeof EV;
752
+
753
+ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
754
+ canonical: {
755
+ id: EV.canonical,
756
+ source: "canonicalizer",
757
+ probe: "canonicalizeExecuteCommand",
758
+ basis: "direct",
759
+ outcome: "ok",
760
+ },
761
+ coordinates: {
762
+ id: EV.coordinates,
763
+ source: "canonicalizer",
764
+ probe: "analyzeCoordinates",
765
+ basis: "direct",
766
+ outcome: "ok",
767
+ },
768
+ segment: {
769
+ id: EV.segment,
770
+ source: "canonicalizer",
771
+ probe: "segmentStatements",
772
+ basis: "direct",
773
+ outcome: "ok",
774
+ },
775
+ blocks: {
776
+ id: EV.blocks,
777
+ source: "canonicalizer",
778
+ probe: "scopeBlocks",
779
+ basis: "direct",
780
+ outcome: "ok",
781
+ },
782
+ statements: {
783
+ id: EV.statements,
784
+ source: "canonicalizer",
785
+ probe: "resolveVerbs",
786
+ basis: "heuristic",
787
+ outcome: "ok",
788
+ },
789
+ subcommands: {
790
+ id: EV.subcommands,
791
+ source: "canonicalizer",
792
+ probe: "resolveDocument + resolveVerb",
793
+ basis: "heuristic",
794
+ outcome: "ok",
795
+ },
796
+ write: {
797
+ id: EV.write,
798
+ source: "canonicalizer",
799
+ probe: "containsWrite",
800
+ basis: "heuristic",
801
+ outcome: "ok",
802
+ },
803
+ symbols: {
804
+ id: EV.symbols,
805
+ source: "canonicalizer",
806
+ probe: "resolveSymbols",
807
+ basis: "heuristic",
808
+ outcome: "ok",
809
+ },
810
+ transport: {
811
+ id: EV.transport,
812
+ source: "canonicalizer",
813
+ probe: "classifyExplainTransport",
814
+ basis: "heuristic",
815
+ outcome: "ok",
816
+ },
817
+ values: {
818
+ id: EV.values,
819
+ source: "canonicalizer",
820
+ probe: "valueShapeHints",
821
+ basis: "heuristic",
822
+ outcome: "ok",
823
+ },
824
+ operators: {
825
+ id: EV.operators,
826
+ source: "canonicalizer",
827
+ probe: "operatorSpans",
828
+ basis: "heuristic",
829
+ outcome: "ok",
830
+ },
831
+ /**
832
+ * `heuristic`, not `direct`, even though the span arithmetic is exact.
833
+ *
834
+ * `basis` describes the FACT, not the arithmetic. The fact here is "these
835
+ * bytes are an argument name and its `=`", and it rests on `argsAt` — the
836
+ * verb boundary, which `EV.statements` already records as `heuristic` and
837
+ * which this type's own doc names as the example of a rule a live probe
838
+ * could overturn. A misread boundary shifts every token this fill emits.
839
+ * `operatorSpans` sits at `heuristic` on identical grounds: deterministic
840
+ * byte arithmetic over a grounded table, downstream of an offline rule.
841
+ */
842
+ args: {
843
+ id: EV.args,
844
+ source: "canonicalizer",
845
+ probe: "argSpans",
846
+ basis: "heuristic",
847
+ outcome: "ok",
848
+ },
849
+ paths: {
850
+ id: EV.paths,
851
+ source: "canonicalizer",
852
+ probe: "pathSpans",
853
+ basis: "heuristic",
854
+ outcome: "ok",
855
+ },
856
+ strings: {
857
+ id: EV.strings,
858
+ source: "canonicalizer",
859
+ probe: "stringSpans",
860
+ basis: "heuristic",
861
+ outcome: "ok",
862
+ },
863
+ /**
864
+ * `heuristic`, and #252 is the reason rather than caution: the accepted
865
+ * escape set is a ratified offline rule that a live probe CAN overturn, and
866
+ * once did. The manual's "Constant Escape Sequences" table turned out to be
867
+ * a lower bound — the CHR byte sweep found `\?` missing from it — so the
868
+ * set pinned in `src/explain/quoted-string.ts` is the best current reading
869
+ * of a device's answer, not a deterministic reading of the input bytes.
870
+ * A future RouterOS could accept one more.
871
+ *
872
+ * Cited by the `escaped` tokens AND by `bad-string-escape`: one walk, one
873
+ * evidence entry. That defect used to cite `resolveSymbols`, which never
874
+ * produced it — `symbols.ts` raises the different `bad-escape` code.
875
+ */
876
+ escapes: {
877
+ id: EV.escapes,
878
+ source: "canonicalizer",
879
+ probe: "walkStringEscapes",
880
+ basis: "heuristic",
881
+ outcome: "ok",
882
+ },
883
+ braces: {
884
+ id: EV.braces,
885
+ source: "canonicalizer",
886
+ probe: "braceSpans",
887
+ basis: "heuristic",
888
+ outcome: "ok",
889
+ },
890
+ /**
891
+ * `heuristic`: the run is decided from the frozen verb vocabulary and the
892
+ * shipped structure table, both of which a live probe could overturn on a
893
+ * menu the table does not list. The rejection it reports is device-grounded
894
+ * (`src/explain/separator.ts`); which byte runs are that shape is not.
895
+ */
896
+ separators: {
897
+ id: EV.separators,
898
+ source: "canonicalizer",
899
+ probe: "findMissingSeparators",
900
+ basis: "heuristic",
901
+ outcome: "ok",
902
+ },
903
+ };
904
+
905
+ /** Diagnostic rendering for each defect class. */
906
+ const DEFECT_DIAGNOSTICS: Record<
907
+ DefectCode,
908
+ { severity: ExplainSeverity; message: (d: Defect) => string }
909
+ > = {
910
+ unclosed: {
911
+ severity: "error",
912
+ message: (d) => `unclosed \`${d.detail ?? "delimiter"}\` — never closed`,
913
+ },
914
+ "unbalanced-close": {
915
+ severity: "error",
916
+ message: (d) => `stray \`${d.detail ?? "close"}\` closes nothing`,
917
+ },
918
+ "unterminated-string": {
919
+ severity: "error",
920
+ message: () => "unterminated string — no closing quote",
921
+ },
922
+ "bad-escape": {
923
+ severity: "error",
924
+ message: () =>
925
+ "invalid escape: a backslash in code is valid only before whitespace",
926
+ },
927
+ "bad-string-escape": {
928
+ severity: "error",
929
+ message: () =>
930
+ 'invalid escape in string: unknown escape, truncated hex, or lowercase hex digit — use \\n \\r \\t \\" \\\\ \\$ \\_ \\? \\a \\b \\f \\v, \\XX with uppercase hex, or \\ before whitespace to continue the line',
931
+ },
932
+ "bad-sigil": {
933
+ severity: "error",
934
+ message: () => "invalid sigil: the run must be zero or one character",
935
+ },
936
+ "invalid-hash": {
937
+ severity: "error",
938
+ message: () =>
939
+ "unquoted `#` is a syntax error here — quote it where a value is accepted, or move it to statement-leading position to start a comment",
940
+ },
941
+ // #355. Not a quote-balance rule: `name='abc'` and `:put 'abc'` are rejected
942
+ // too. `'` simply is not a RouterOS token, so the remedy is the double quote,
943
+ // never a matching apostrophe.
944
+ "invalid-apostrophe": {
945
+ severity: "error",
946
+ message: () =>
947
+ '`\'` is not a RouterOS token — only `"` opens a string; use `"` here, or put the apostrophe inside a `"`-string',
948
+ },
949
+ // centrs's own resource bound, not a RouterOS rule. The input may be entirely
950
+ // legal; what is reported is that the analyzer stopped descending, so the
951
+ // honest severity is a warning about incomplete analysis.
952
+ "over-depth": {
953
+ severity: "warning",
954
+ message: () => "nesting past the analyzer's depth bound — not descended",
955
+ },
956
+ // Positional facts. `isPositionalFact` exists so these can never be given
957
+ // error severity: `name="router-🚀"` is a legal command and non-ASCII occurs
958
+ // in ~12% of the phase-0 corpus.
959
+ bom: {
960
+ severity: "info",
961
+ message: () => "byte-order mark",
962
+ },
963
+ "non-ascii": {
964
+ severity: "info",
965
+ message: () => "non-ASCII bytes, normalized one-for-one for analysis",
966
+ },
967
+ };
968
+
969
+ /**
970
+ * Keep only the EARLIEST `invalid-apostrophe`, document-wide (#355).
971
+ *
972
+ * The rule suppresses repeats within one scan, but a nested scope body is a
973
+ * SEPARATE scan — `segmentScopeBody` is called per block and its defects are
974
+ * rebased in — so `:put 'a'; :if (true) do={ :put 'b' }` produced one from each.
975
+ * The device reports one error and stops, so a second occurrence is not an
976
+ * independent finding no matter which scope it sits in, and the contract is
977
+ * stated document-wide.
978
+ *
979
+ * Collapsing HERE rather than threading a flag through the walkers is the point:
980
+ * `segmentStatements` and `segmentScopeBody` stay pure per call, which is what
981
+ * lets a body be segmented on its own. The other classes are deliberately
982
+ * untouched — two `unclosed` delimiters ARE two findings.
983
+ */
984
+ function collapseToFirstApostrophe(
985
+ defects: readonly AttributedDefect[],
986
+ ): AttributedDefect[] {
987
+ let seen = false;
988
+ const out: AttributedDefect[] = [];
989
+ for (const entry of [...defects].sort(
990
+ (a, b) => a.defect.start - b.defect.start,
991
+ )) {
992
+ if (entry.defect.code === "invalid-apostrophe") {
993
+ if (seen) continue;
994
+ seen = true;
995
+ }
996
+ out.push(entry);
997
+ }
998
+ return out;
999
+ }
1000
+
1001
+ /** A defect plus the analysis pass that raised it. */
1002
+ interface AttributedDefect {
1003
+ defect: Defect;
1004
+ ev: string;
1005
+ }
1006
+
1007
+ /**
1008
+ * De-duplicate the analyzers' defect lists, keeping each region's FIRST
1009
+ * attribution.
1010
+ *
1011
+ * `mergeDefects` answers "which regions are defective"; this also answers "who
1012
+ * said so", which the evidence table needs and cannot recover afterwards — once
1013
+ * the lists are concatenated a `bad-sigil` is indistinguishable from an
1014
+ * `unclosed`, and guessing by class is wrong for the three classes two analyzers
1015
+ * both raise.
1016
+ *
1017
+ * FIRST wins rather than "all who saw it", because a fact carries ONE `ev`. The
1018
+ * order is the argument order, and the coordinate pass is not in it at all: its
1019
+ * two classes are raised inside `segmentStatements`' result, so they are
1020
+ * re-tagged here by class — they are readings of the input as received, not of
1021
+ * any structural walk.
1022
+ *
1023
+ * Identity matches `mergeDefects`: the whole region plus `detail`, never the
1024
+ * code alone, so two `over-depth` events at different offsets stay two defects.
1025
+ */
1026
+ function attributeDefects(
1027
+ sources: readonly (readonly [string, readonly Defect[]])[],
1028
+ ): AttributedDefect[] {
1029
+ const seen = new Map<string, AttributedDefect>();
1030
+ for (const [ev, list] of sources)
1031
+ for (const defect of list) {
1032
+ const key = JSON.stringify([
1033
+ defect.code,
1034
+ defect.start,
1035
+ defect.end,
1036
+ defect.detail,
1037
+ ]);
1038
+ if (seen.has(key)) continue;
1039
+ seen.set(key, {
1040
+ defect,
1041
+ ev: isPositionalFact(defect.code) ? EV.coordinates : ev,
1042
+ });
1043
+ }
1044
+ return [...seen.values()].sort(
1045
+ (a, b) =>
1046
+ a.defect.start - b.defect.start ||
1047
+ a.defect.end - b.defect.end ||
1048
+ a.defect.code.localeCompare(b.defect.code),
1049
+ );
1050
+ }
1051
+
1052
+ /** Build `input.positionMap`: coalesced identity runs, one entry per non-ASCII code point. */
1053
+ function positionMap(a: CoordinateAnalysis): ExplainPositionMapEntry[] {
1054
+ // An all-ASCII input coalesces to the single identity run the loop below
1055
+ // would build, so say it rather than walk one object per character (#322).
1056
+ if (a.ascii)
1057
+ return a.original.length === 0
1058
+ ? []
1059
+ : [
1060
+ {
1061
+ analyzed: { start: 0, end: a.original.length },
1062
+ originalUtf16: { start: 0, end: a.original.length },
1063
+ },
1064
+ ];
1065
+ const out: ExplainPositionMapEntry[] = [];
1066
+ let run: ExplainPositionMapEntry | null = null;
1067
+ for (const r of a.runs) {
1068
+ const identity = r.ascii;
1069
+ if (
1070
+ identity &&
1071
+ run !== null &&
1072
+ run.analyzed.end === r.byteStart &&
1073
+ run.originalUtf16.end === r.utf16Start
1074
+ ) {
1075
+ run.analyzed.end = r.byteStart + r.byteLen;
1076
+ run.originalUtf16.end = r.utf16Start + r.utf16Len;
1077
+ continue;
1078
+ }
1079
+ run = {
1080
+ analyzed: { start: r.byteStart, end: r.byteStart + r.byteLen },
1081
+ originalUtf16: { start: r.utf16Start, end: r.utf16Start + r.utf16Len },
1082
+ };
1083
+ out.push(run);
1084
+ if (!identity) run = null;
1085
+ }
1086
+ return out;
1087
+ }
1088
+
1089
+ const SPAN_CLASS_OF_SYMBOL: Record<string, ExplainSpanClass> = {
1090
+ local: "variable-local",
1091
+ global: "variable-global",
1092
+ auto: "variable-auto",
1093
+ parameter: "variable-parameter",
1094
+ };
1095
+
1096
+ /**
1097
+ * Complement of claimed spans within `[0, len)` — the residual runs a fill
1098
+ * sees.
1099
+ *
1100
+ * Factored out of `buildTokens` so every fill can compute it and so tests can
1101
+ * assert it directly. `claimed` need not be sorted; the result is sorted and
1102
+ * coalesced with no gaps or overlaps relative to the claimed set.
1103
+ */
1104
+ export function residualRanges(
1105
+ len: number,
1106
+ claimed: readonly ExplainSpanRange[],
1107
+ ): { start: number; end: number }[] {
1108
+ if (len === 0) return [];
1109
+ const sorted = [...claimed].sort(
1110
+ (a, b) => a.start - b.start || a.end - b.end,
1111
+ );
1112
+ let cursor = 0;
1113
+ const out: { start: number; end: number }[] = [];
1114
+ for (const r of sorted) {
1115
+ if (cursor < r.start) out.push({ start: cursor, end: r.start });
1116
+ cursor = Math.max(cursor, r.end);
1117
+ }
1118
+ if (cursor < len) out.push({ start: cursor, end: len });
1119
+ return out;
1120
+ }
1121
+
1122
+ /**
1123
+ * Build the total, gapless token partition (B1).
1124
+ *
1125
+ * Every byte of `[0, bytes)` belongs to exactly one token: `spans` are
1126
+ * placed sorted, no gaps become `unclassified`, and the result is sorted by
1127
+ * `start` with no overlaps and `join(slice) === input`. See
1128
+ * {@link ExplainTokenClass} for what `class` may be and why.
1129
+ *
1130
+ * B2: `buildTokens` now takes an **ordered list of fills**. Fill 0 claims
1131
+ * first, fill 1 sees only the residual of fill 0, and so on — the argument
1132
+ * order IS the fill order (#290 design decision 1). An overlap across fills is
1133
+ * a hard throw (structural impossibility is achieved by callers offering only
1134
+ * residual; the throw is the safety net). For backward compatibility a single
1135
+ * fill may be passed as a flat span array.
1136
+ */
1137
+ export function buildTokens(
1138
+ analyzed: string,
1139
+ spansOrFills: readonly ExplainSpan[] | readonly TokenFill[],
1140
+ ): ExplainToken[] {
1141
+ const len = analyzed.length;
1142
+ // Normalize the overload: a flat span array is one fill.
1143
+ let fills: readonly TokenFill[];
1144
+ if (spansOrFills.length === 0) {
1145
+ fills = [];
1146
+ } else {
1147
+ const first = spansOrFills[0] as unknown as Record<string, unknown>;
1148
+ const isFlatSpan =
1149
+ first !== null &&
1150
+ typeof first === "object" &&
1151
+ "class" in first &&
1152
+ "ev" in first &&
1153
+ "start" in first;
1154
+ fills = isFlatSpan
1155
+ ? ([spansOrFills] as unknown as readonly TokenFill[])
1156
+ : (spansOrFills as readonly TokenFill[]);
1157
+ }
1158
+ // Validate each fill internally (non-integer, bounds, overlap within fill).
1159
+ for (const fill of fills) {
1160
+ const sorted = [...fill].sort((a, b) => a.start - b.start || a.end - b.end);
1161
+ let prev = 0;
1162
+ for (const s of sorted) {
1163
+ if (!Number.isInteger(s.start) || !Number.isInteger(s.end))
1164
+ throw new Error(
1165
+ `buildTokens: non-integer span [${s.start},${s.end}) for length ${len}`,
1166
+ );
1167
+ if (s.start < 0 || s.end <= s.start || s.end > len)
1168
+ throw new Error(
1169
+ `buildTokens: span out of bounds [${s.start},${s.end}) for length ${len}`,
1170
+ );
1171
+ if (s.start < prev)
1172
+ throw new Error(
1173
+ `buildTokens: overlapping spans at [${s.start},${s.end})`,
1174
+ );
1175
+ prev = Math.max(prev, s.end);
1176
+ }
1177
+ }
1178
+ const flat: ExplainToken[] = fills.flatMap((fill) => [...fill]);
1179
+ const sorted = [...flat].sort((a, b) => a.start - b.start || a.end - b.end);
1180
+ // Cross-fill overlap is also a hard throw — callers achieve impossibility by
1181
+ // offering only residual; this is the safety net.
1182
+ let prev = 0;
1183
+ for (const s of sorted) {
1184
+ if (s.start < prev)
1185
+ throw new Error(
1186
+ `buildTokens: overlapping spans at [${s.start},${s.end})`,
1187
+ );
1188
+ prev = Math.max(prev, s.end);
1189
+ }
1190
+ if (len === 0) return [];
1191
+ const out: ExplainToken[] = [];
1192
+ let cursor = 0;
1193
+ for (const s of sorted) {
1194
+ if (cursor < s.start) {
1195
+ out.push({
1196
+ start: cursor,
1197
+ end: s.start,
1198
+ class: "unclassified",
1199
+ // The pass that produced an UNCLAIMED byte is the one that produced
1200
+ // the analyzed surface it sits on — `analyzeCoordinates`, not the
1201
+ // execute canonicalizer. A B2 fill replaces this with the pass that
1202
+ // claimed the byte.
1203
+ ev: EV.coordinates,
1204
+ });
1205
+ }
1206
+ out.push({ start: s.start, end: s.end, class: s.class, ev: s.ev });
1207
+ cursor = s.end;
1208
+ }
1209
+ if (cursor < len) {
1210
+ out.push({
1211
+ start: cursor,
1212
+ end: len,
1213
+ class: "unclassified",
1214
+ ev: EV.coordinates, // see above
1215
+ });
1216
+ }
1217
+ return out;
1218
+ }
1219
+
1220
+ function severityRank(s: ExplainSeverity): number {
1221
+ return s === "error" ? 2 : s === "warning" ? 1 : 0;
1222
+ }
1223
+
1224
+ function verdictOf(diagnostics: readonly ExplainDiagnostic[]): ExplainVerdict {
1225
+ let worst = 0;
1226
+ for (const d of diagnostics)
1227
+ worst = Math.max(worst, severityRank(d.severity));
1228
+ return worst === 2 ? "fail" : worst === 1 ? "warn" : "pass";
1229
+ }
1230
+
1231
+ /**
1232
+ * Extensible analysis options.
1233
+ *
1234
+ * ONE additive bag, which is why the previous no-options signature could be
1235
+ * widened without a second shape: phase 2 puts `target` and the live facets
1236
+ * here, and `curl` stays orthogonal to both — it asks for a rendering of a
1237
+ * result, not for more evidence. A caller can still tell an honored option from
1238
+ * an ignored one, because every field here changes the result.
1239
+ */
1240
+ export interface ExplainCommandOptions {
1241
+ /** Include a ready-to-edit REST curl on API-candidate statements. */
1242
+ curl?: boolean;
1243
+ /**
1244
+ * Emit the total, gapless token partition behind `data.tokens[]`.
1245
+ *
1246
+ * Mirrors `--complete` / `--schema` / `--curl`: an opt-in facet whose
1247
+ * presence changes the result. See {@link ExplainTokenClass} for the
1248
+ * vocabulary `class` draws on (#264 B5).
1249
+ */
1250
+ tokens?: boolean;
1251
+ }
1252
+
1253
+ /**
1254
+ * Analyze one RouterOS input offline. Never throws, never contacts a device.
1255
+ *
1256
+ * Curl rendering is an opt-in presentation concern. It adds a shell-safe view
1257
+ * of an already-classified request and does not alter canonical analysis.
1258
+ */
1259
+ export function explainCommand(
1260
+ input: string,
1261
+ options: ExplainCommandOptions = {},
1262
+ ): ExplainData {
1263
+ const coordinates = analyzeCoordinates(input);
1264
+ // The ANALYZED text is what arguments are lexed from: it is pure ASCII with
1265
+ // one byte standing in for every non-ASCII one, so an index into it IS a
1266
+ // document byte offset. Decoding it once here keeps `statementOf` and the
1267
+ // string-escape walk from re-deriving the same string.
1268
+ const analyzed = coordinates.ascii
1269
+ ? input
1270
+ : new TextDecoder().decode(coordinates.analyzed);
1271
+ // The whole document as a masked range, in `analyzed` coordinates, derived
1272
+ // ONCE.
1273
+ //
1274
+ // Four passes below read the same statement — the strict argument reading,
1275
+ // the tolerant token stream, the brace diagnostic and the value anchors —
1276
+ // and each masked that statement's text for itself. A statement's text
1277
+ // carries its whole nested `do={…}` subtree, so on a 60 KiB document nested
1278
+ // 64 deep those four passes masked 15.8 MB between them. A statement's mask
1279
+ // is this mask restricted to its span (see `segment.ts` → `MaskedRange`), and
1280
+ // `analyzed` is ASCII by construction, so the offsets need no coordinate test.
1281
+ const analyzedRange = documentRange(analyzed, coordinates.ascii);
1282
+ const segmented = segmentStatements(input);
1283
+ const statementAnalysis = resolveStatements(input);
1284
+ const sourceRange = coordinates.ascii ? analyzedRange : undefined;
1285
+ const verbs = resolveVerbsFromStatements(statementAnalysis, sourceRange);
1286
+ const brackets = resolveDocument(input);
1287
+ const write = containsWriteFromAnalyses(
1288
+ statementAnalysis,
1289
+ brackets,
1290
+ sourceRange,
1291
+ );
1292
+ const symbols = resolveSymbols(input);
1293
+
1294
+ // ONE escape walk, read twice (#264): its invalid half is a
1295
+ // `bad-string-escape` diagnostic and its valid half is the `escaped` token
1296
+ // fill further down. Walking separately for each would let the diagnostics
1297
+ // and the token stream disagree about which bytes are an escape.
1298
+ //
1299
+ // The third argument only decides whether the valid spans are RETAINED, not
1300
+ // whether they are validated, so gating it on the token facet cannot change
1301
+ // a diagnostic — it just stops a caller who never asked for `data.tokens`
1302
+ // from paying for a span object per escape.
1303
+ const stringEscapes = walkStringEscapes(
1304
+ analyzed,
1305
+ segmented.comments,
1306
+ options.tokens === true,
1307
+ );
1308
+
1309
+ // Every analyzer re-derives the document's defects from its own walk, so a
1310
+ // defect two analyzers both see must be reported once — and tagged with the
1311
+ // analyzer that RAISED it, not with whichever list was merged first. The
1312
+ // classes do not partition: `unclosed`/`unbalanced-close`/`unterminated-string`
1313
+ // come from the segmenter AND the symbol scan, while `bad-escape`/`bad-sigil`
1314
+ // are the symbol scan alone (they are lexical rules, hence `heuristic`, where
1315
+ // the segmenter's delimiter stack is a `direct` reading). Attributing all of
1316
+ // them to the segmenter said "direct/segmentStatements" about a fact the
1317
+ // segmenter cannot produce.
1318
+ //
1319
+ // `bad-string-escape` is the same mistake one module over, and #264 is what
1320
+ // made it fixable: it cited `resolveSymbols`, which never produced it —
1321
+ // `symbols.ts` raises the DIFFERENT `bad-escape` code. Until the escape walk
1322
+ // had an evidence entry of its own there was nothing truer to cite; now
1323
+ // there is, and the token and the diagnostic cite the same one.
1324
+ const defects = collapseToFirstApostrophe(
1325
+ attributeDefects([
1326
+ [EV.segment, segmented.defects],
1327
+ [EV.statements, verbs.defects],
1328
+ [EV.subcommands, brackets.defects],
1329
+ [EV.write, write.defects],
1330
+ [EV.symbols, symbols.defects],
1331
+ [EV.escapes, stringEscapes.defects],
1332
+ ]),
1333
+ );
1334
+
1335
+ // From the SPLITS, not from the segmentation. The resolver flattens `do={…}`
1336
+ // bodies in after their parent, so its list is longer than the top-level
1337
+ // segments and pairing the two by index attaches the wrong span to every statement after
1338
+ // the first block. Each split carries its own document-space span.
1339
+ //
1340
+ const readStatements: ExplainStatement[] = verbs.splits.map((split) =>
1341
+ statementOf(split, analyzed, analyzedRange),
1342
+ );
1343
+ const canonical = canonicalizeExecuteCommand(input);
1344
+ // The skip-tolerant token stream every TOKEN-LEVEL rule reads (#316), one
1345
+ // list per split. It is not `statement.arguments`: that reading stays
1346
+ // all-or-nothing because its consumer renders a runnable request, and a token
1347
+ // carrying `undecided` has no value to render. These two never disagree about
1348
+ // where a token starts or ends — `args.ts` owns the one boundary walk.
1349
+ const argumentTokens = readStatements.map((statement, index) => {
1350
+ const split = verbs.splits[index];
1351
+ return split === undefined || statement.kind !== "command"
1352
+ ? []
1353
+ : argumentTokensOf(split, analyzed, analyzedRange);
1354
+ });
1355
+ // #311: the second command-shaped run is found on the statement's OWN
1356
+ // argument tokens, then the reading is degraded — in that order, because the
1357
+ // tokens are the evidence for the diagnostic and the refusal both. Transport
1358
+ // runs after, so it classifies the degraded reading rather than a list the
1359
+ // device never accepted.
1360
+ const separators = readStatements.map((statement, index) =>
1361
+ statement.resolution === "resolved"
1362
+ ? findMissingSeparators(argumentTokens[index] ?? [])
1363
+ : [],
1364
+ );
1365
+ const gated = enforceGateParity(readStatements, canonical);
1366
+ const statements = gated.map((statement, index) => {
1367
+ const runs = separators[index] ?? [];
1368
+ return withStatementTransport(
1369
+ withoutSeparatedArguments(statement, runs),
1370
+ verbs.splits[index]?.text ?? "",
1371
+ options,
1372
+ runs.length === 0 ? undefined : rejectedBySeparator(runs),
1373
+ );
1374
+ });
1375
+
1376
+ const diagnostics: ExplainDiagnostic[] = [
1377
+ ...defects.map(({ defect: d, ev }) => {
1378
+ const render = DEFECT_DIAGNOSTICS[d.code];
1379
+ return {
1380
+ code: `explain/canonicalizer/${d.code}`,
1381
+ severity: render.severity,
1382
+ message: render.message(d),
1383
+ span: { start: d.start, end: d.end },
1384
+ ev,
1385
+ };
1386
+ }),
1387
+ ...invalidCommandBraceDiagnostics(verbs.splits, analyzed, analyzedRange),
1388
+ ...separators.flatMap((runs) => runs.map(missingSeparatorDiagnostic)),
1389
+ ...statements.flatMap((s) => diagnosticsForStatement(s)),
1390
+ ].sort(
1391
+ (a, b) =>
1392
+ a.span.start - b.span.start ||
1393
+ severityRank(b.severity) - severityRank(a.severity),
1394
+ );
1395
+
1396
+ const valueFacts = valuesOf(
1397
+ verbs.splits,
1398
+ analyzed,
1399
+ analyzedRange,
1400
+ separators,
1401
+ );
1402
+ const spans: ExplainSpan[] = [
1403
+ ...segmented.comments.map((c) => ({
1404
+ start: c.start,
1405
+ end: c.end,
1406
+ class: "comment" as const,
1407
+ ev: EV.segment,
1408
+ })),
1409
+ ...symbols.occurrences.flatMap((o) => {
1410
+ const cls = o.cls === null ? undefined : SPAN_CLASS_OF_SYMBOL[o.cls];
1411
+ // An abstention (`cls: null`) and the device-only `undefined` class are
1412
+ // omitted rather than rendered as a guess.
1413
+ return cls === undefined
1414
+ ? []
1415
+ : [{ start: o.start, end: o.end, class: cls, ev: EV.symbols }];
1416
+ }),
1417
+ ].sort((a, b) => a.start - b.start || a.end - b.end);
1418
+ const symbolFacts: ExplainSymbols = {
1419
+ occurrences: augmentSymbolOccurrences(
1420
+ analyzed,
1421
+ symbols.occurrences,
1422
+ valueFacts.occurrences,
1423
+ verbs.splits,
1424
+ ),
1425
+ };
1426
+
1427
+ const structure: ExplainStructure = {
1428
+ statementCount: statements.length,
1429
+ statements,
1430
+ blocks: scopeBlocks(input).map((b) => ({
1431
+ name: b.name,
1432
+ span: { start: b.start, end: b.start + b.body.length },
1433
+ ev: EV.blocks,
1434
+ })),
1435
+ containsWrite: renderWriteVerdict(write.verdict),
1436
+ subcommands: brackets.resolutions.map(subcommandOf),
1437
+ // `containsWrite` is the fact `structure` itself asserts; the statements
1438
+ // and subcommands beside it carry their own.
1439
+ ev: EV.write,
1440
+ };
1441
+
1442
+ // B2/B3 fill order — the argument order IS the order (#290 design decision 1).
1443
+ // `spans` (proof-only: comment + variables) claims first; path, argument,
1444
+ // escape, value, then string/brace, then operator fills each see only the
1445
+ // residual left before them. Structural ambiguity (`/` path vs division, `=` arg sep
1446
+ // vs comparison) therefore resolves by the analyzer that already proved
1447
+ // ownership, not by a wider byte scanner. The operator scanner keeps its
1448
+ // conservative guards for bytes no earlier fill can decide.
1449
+ //
1450
+ // Gate the scan on `options.tokens` — no residual work when the caller
1451
+ // did not ask for `data.tokens`. Future fills belong inside this branch.
1452
+ let tokens: ExplainToken[] | undefined;
1453
+ if (options.tokens === true) {
1454
+ const residual0 = residualRanges(analyzed.length, spans);
1455
+ const pathCandidates: PathTokenCandidate[] = [
1456
+ ...verbs.splits.map((split) => ({
1457
+ text: split.text,
1458
+ span: split.span,
1459
+ split,
1460
+ ev: EV.paths,
1461
+ })),
1462
+ ...brackets.resolutions.flatMap((resolution) => {
1463
+ if (resolution.unresolved !== undefined || resolution.path === null)
1464
+ return [];
1465
+ return [
1466
+ {
1467
+ text: resolution.inner,
1468
+ // `innerSpan`, never `span ± 1`: `span` covers `[…]` for a bare
1469
+ // substitution but `$[…]` for an interpolated one, so the
1470
+ // arithmetic was one byte early inside a string and `pathSpans`
1471
+ // dropped every such candidate on its text check — leaving a
1472
+ // resolved command to the string fill (#325).
1473
+ span: resolution.innerSpan,
1474
+ split: resolveVerb(
1475
+ resolution.inner,
1476
+ resolution.context,
1477
+ rangeForResolution(resolution) ?? documentRange(resolution.inner),
1478
+ ),
1479
+ ev: EV.paths,
1480
+ },
1481
+ ];
1482
+ }),
1483
+ ];
1484
+ const pathSpansList = pathSpans(analyzed, residual0, pathCandidates);
1485
+ const residual1 = residualRanges(analyzed.length, [
1486
+ ...spans,
1487
+ ...pathSpansList,
1488
+ ]);
1489
+ // Every WITHDRAWN reading contributes nothing, exactly as before #316: a
1490
+ // second command-shaped run (`withoutSeparatedArguments`) and a gate-parity
1491
+ // disagreement (`enforceGateParity`) both mean centrs cannot say which
1492
+ // bytes are this command's arguments, and painting `name=` runs inside one
1493
+ // would be that claim in lexical clothing. Everything else now paints from
1494
+ // the tolerant stream, so `gateway=$g` gets its name and `=` claimed where
1495
+ // the strict reading left the whole statement `unclassified`.
1496
+ const argCandidates = gated.flatMap((statement, index) =>
1497
+ (separators[index]?.length ?? 0) > 0 ||
1498
+ (readStatements[index]?.arguments?.read === true &&
1499
+ statement.arguments?.read !== true)
1500
+ ? []
1501
+ : (argumentTokens[index] ?? []),
1502
+ );
1503
+ const argSpansList = argSpans(analyzed, residual1, argCandidates);
1504
+ const residual2 = residualRanges(analyzed.length, [
1505
+ ...spans,
1506
+ ...pathSpansList,
1507
+ ...argSpansList,
1508
+ ]);
1509
+ // An escape is interior to a quoted run, so it claims BEFORE the two
1510
+ // fills that would otherwise swallow it whole (#264) — it takes the
1511
+ // residual `value` used to see, and `value` and `string` then fragment
1512
+ // around what it claimed.
1513
+ const escapeSpansList = escapeSpans(
1514
+ analyzed,
1515
+ residual2,
1516
+ stringEscapes.escapes,
1517
+ );
1518
+ const residual3 = residualRanges(analyzed.length, [
1519
+ ...spans,
1520
+ ...pathSpansList,
1521
+ ...argSpansList,
1522
+ ...escapeSpansList,
1523
+ ]);
1524
+ const valueSpansList = valueSpans(
1525
+ analyzed,
1526
+ residual3,
1527
+ valueFacts.occurrences,
1528
+ );
1529
+ const residual4 = residualRanges(analyzed.length, [
1530
+ ...spans,
1531
+ ...pathSpansList,
1532
+ ...argSpansList,
1533
+ ...escapeSpansList,
1534
+ ...valueSpansList,
1535
+ ]);
1536
+ const stringSpansList = stringSpans(analyzed, residual4);
1537
+ const residual5 = residualRanges(analyzed.length, [
1538
+ ...spans,
1539
+ ...pathSpansList,
1540
+ ...argSpansList,
1541
+ ...escapeSpansList,
1542
+ ...valueSpansList,
1543
+ ...stringSpansList,
1544
+ ]);
1545
+ const braceSpansList = braceSpans(analyzed, residual5);
1546
+ const residual6 = residualRanges(analyzed.length, [
1547
+ ...spans,
1548
+ ...pathSpansList,
1549
+ ...argSpansList,
1550
+ ...escapeSpansList,
1551
+ ...valueSpansList,
1552
+ ...stringSpansList,
1553
+ ...braceSpansList,
1554
+ ]);
1555
+ const opSpans = operatorSpans(analyzed, residual6);
1556
+ const fills: TokenFill[] = [
1557
+ spans,
1558
+ pathSpansList,
1559
+ argSpansList,
1560
+ escapeSpansList,
1561
+ valueSpansList,
1562
+ stringSpansList,
1563
+ braceSpansList,
1564
+ opSpans,
1565
+ ];
1566
+ tokens = buildTokens(analyzed, fills);
1567
+ }
1568
+
1569
+ return {
1570
+ input: {
1571
+ bytes: coordinates.analyzed.length,
1572
+ normalized: !coordinates.ascii,
1573
+ truncated: false,
1574
+ positionMap: positionMap(coordinates),
1575
+ },
1576
+ verdict: verdictOf(diagnostics),
1577
+ canonical: {
1578
+ mode: canonical.mode,
1579
+ path: canonical.path,
1580
+ verb: canonical.verb,
1581
+ args: canonical.attributes,
1582
+ queries: canonical.queries,
1583
+ writeShaped: isWriteShaped(canonical),
1584
+ },
1585
+ structure,
1586
+ symbols: symbolFacts,
1587
+ values: valueFacts,
1588
+ spans,
1589
+ ...(tokens === undefined ? {} : { tokens }),
1590
+ diagnostics,
1591
+ evidence: citedEvidence(
1592
+ structure,
1593
+ diagnostics,
1594
+ spans,
1595
+ symbolFacts,
1596
+ valueFacts,
1597
+ tokens,
1598
+ ),
1599
+ runtimeAcceptance: "not-proven",
1600
+ };
1601
+ }
1602
+
1603
+ /** Attach transport only after the gate-parity guard has had the final word. */
1604
+ function withStatementTransport(
1605
+ statement: ExplainStatement,
1606
+ source: string,
1607
+ options: ExplainCommandOptions,
1608
+ rejected?: string,
1609
+ ): ExplainStatement {
1610
+ if (statement.kind !== "command" || statement.arguments === undefined)
1611
+ return statement;
1612
+ return {
1613
+ ...statement,
1614
+ transport: classifyExplainTransport(
1615
+ {
1616
+ command: statement.command,
1617
+ arguments: statement.arguments,
1618
+ source,
1619
+ ...(rejected === undefined ? {} : { rejected }),
1620
+ },
1621
+ { renderCurl: options.curl, evidenceId: EV.transport },
1622
+ ),
1623
+ };
1624
+ }
1625
+
1626
+ /**
1627
+ * One statement, in the envelope's vocabulary.
1628
+ *
1629
+ * The fold that happens here: `verbsplit` reports menu navigation as its own
1630
+ * `navigation` resolution, because for ITS callers `resolved` has to keep
1631
+ * meaning "a verb was decided". The envelope's ratified vocabulary is
1632
+ * `resolved` + `kind`, and the mapping is total — `navigation` → menu,
1633
+ * `resolved` → command, and the two refusals carry no kind at all.
1634
+ *
1635
+ * The span comes from the split itself, because the resolver FLATTENS block
1636
+ * bodies into its statement list — a body statement's span is contained by its
1637
+ * parent's rather than following it, and the list is longer than the top-level
1638
+ * segmentation. That is why `statements[]` may overlap, and why the count is not
1639
+ * the number of top-level statements.
1640
+ */
1641
+ function statementOf(
1642
+ split: DocumentVerbSplit,
1643
+ analyzed: string,
1644
+ analyzedRange: MaskedRange,
1645
+ ): ExplainStatement {
1646
+ const reading = readingOf(split);
1647
+ return {
1648
+ span: { start: split.span.start, end: split.span.end },
1649
+ ...(reading.kind === "command"
1650
+ ? withArguments(reading, split, analyzed, analyzedRange)
1651
+ : reading),
1652
+ contextCertain: split.contextCertain,
1653
+ ev: EV.statements,
1654
+ };
1655
+ }
1656
+
1657
+ /**
1658
+ * Attach the statement's argument reading, rebased into document space.
1659
+ *
1660
+ * The **span check** is the load-bearing line. `argsAt` indexes the statement's
1661
+ * OWN text, and the document offsets come from `span` — so the two are only
1662
+ * compatible when `span` really is where that text sits. It is not always:
1663
+ * `pathresolve.ts`'s `Loc` widens a nested body statement's span to the
1664
+ * enclosing statement when interior offsets cannot be mapped, and a non-ASCII
1665
+ * statement's original text is shorter than its analyzed byte range. Both would
1666
+ * lex the wrong bytes and report confident arguments for them, which is the one
1667
+ * failure a fail-closed lexer must not have. Comparing the analyzed slice with
1668
+ * the resolver's own text catches both at once and costs one string compare.
1669
+ */
1670
+ function withArguments(
1671
+ reading: ExplainCommandReading,
1672
+ split: DocumentVerbSplit,
1673
+ analyzed: string,
1674
+ analyzedRange: MaskedRange,
1675
+ ): ExplainCommandReading {
1676
+ const read = argumentsOf(split, analyzed, analyzedRange);
1677
+ return {
1678
+ ...reading,
1679
+ command:
1680
+ read.args === undefined
1681
+ ? reading.command
1682
+ : { ...reading.command, args: read.args },
1683
+ arguments: read.arguments,
1684
+ };
1685
+ }
1686
+
1687
+ /**
1688
+ * The statement's argument reading, plus the object view that goes on
1689
+ * `command.args`.
1690
+ *
1691
+ * The two travel together because the LEXER owns the object view: it builds a
1692
+ * last-wins map while it walks the tokens, and recomputing that here from the
1693
+ * rebased tokens would be a second implementation of one rule — free to diverge
1694
+ * from the lexer's, and so from `canonicalizeExecuteCommand`'s, which is the
1695
+ * duplicate-name agreement this module's header asserts. Raised in review of
1696
+ * #202c-1.
1697
+ */
1698
+ function argumentsOf(
1699
+ split: DocumentVerbSplit,
1700
+ analyzed: string,
1701
+ analyzedRange: MaskedRange,
1702
+ ): { arguments: ExplainArguments; args?: Record<string, string> } {
1703
+ if (split.argsAt === null)
1704
+ return {
1705
+ arguments: {
1706
+ read: false,
1707
+ why: "the verb is not the last token of the leading path run, so what follows the run is not this command's argument list",
1708
+ },
1709
+ };
1710
+ const { start, end } = split.span;
1711
+ const text = analyzed.slice(start, end);
1712
+ if (text !== split.text)
1713
+ return {
1714
+ arguments: {
1715
+ read: false,
1716
+ why: "this statement's bytes are not addressable: its text was normalized (non-ASCII), or its span was widened to the enclosing statement",
1717
+ },
1718
+ };
1719
+ const lexed = lexArguments(
1720
+ text,
1721
+ split.argsAt,
1722
+ nestedRange(analyzedRange, start, end),
1723
+ );
1724
+ if (!lexed.read) return { arguments: lexed };
1725
+ return {
1726
+ args: lexed.args,
1727
+ arguments: {
1728
+ read: true,
1729
+ tokens: lexed.tokens.map((token) => ({
1730
+ ...token,
1731
+ span: { start: start + token.span.start, end: start + token.span.end },
1732
+ ...(token.valueSpan === undefined
1733
+ ? {}
1734
+ : {
1735
+ valueSpan: {
1736
+ start: start + token.valueSpan.start,
1737
+ end: start + token.valueSpan.end,
1738
+ },
1739
+ }),
1740
+ })),
1741
+ queries: lexed.queries,
1742
+ positional: lexed.positional,
1743
+ },
1744
+ };
1745
+ }
1746
+
1747
+ /**
1748
+ * The statement's skip-tolerant argument token stream, rebased into document
1749
+ * space (#316).
1750
+ *
1751
+ * Same two guards as {@link argumentsOf}, for the same reason: `argsAt` indexes
1752
+ * the statement's own text, so offsets are only rebased when the analyzed
1753
+ * slice really is that text. What differs is only the reading — the tokens come
1754
+ * back with the undecodable ones located rather than the whole list discarded.
1755
+ * A statement that is not addressable, or has no verb, yields no tokens at all, which is the
1756
+ * same silence the strict reading gives it.
1757
+ */
1758
+ function argumentTokensOf(
1759
+ split: DocumentVerbSplit,
1760
+ analyzed: string,
1761
+ analyzedRange: MaskedRange,
1762
+ ): ExplainArgument[] {
1763
+ if (split.argsAt === null) return [];
1764
+ const { start, end } = split.span;
1765
+ const text = analyzed.slice(start, end);
1766
+ if (text !== split.text) return [];
1767
+ return lexArgumentTokens(
1768
+ text,
1769
+ split.argsAt,
1770
+ nestedRange(analyzedRange, start, end),
1771
+ ).tokens.map((token) => ({
1772
+ ...token,
1773
+ span: { start: start + token.span.start, end: start + token.span.end },
1774
+ ...(token.valueSpan === undefined
1775
+ ? {}
1776
+ : {
1777
+ valueSpan: {
1778
+ start: start + token.valueSpan.start,
1779
+ end: start + token.valueSpan.end,
1780
+ },
1781
+ }),
1782
+ }));
1783
+ }
1784
+
1785
+ /**
1786
+ * A second command-shaped run, said out loud (#311).
1787
+ *
1788
+ * `error`, not `warning`: the abstention vocabulary is for input with several
1789
+ * valid schema-free readings, and this one has none — RouterOS answers every
1790
+ * probed spelling of this shape with `bad parameter <token>` or
1791
+ * `expected end of command` (`src/explain/separator.ts`). What the message may
1792
+ * claim is bounded by the same head-dependence: it says the bytes READ as a
1793
+ * second command, which is centrs's own heuristic reading, and that RouterOS
1794
+ * rejects the statement, which is the grounded part. It does not say which byte
1795
+ * the device will name, because that is per-menu schema — on a head whose value
1796
+ * slot takes a bare positional the path is absorbed into it and the VERB is
1797
+ * refused instead (`:put /ip/route print` → `/putmessage=/ip/route` plus
1798
+ * `bad parameter print`, CHR 7.24.2). The span is the whole second run so an
1799
+ * editor can highlight what has to move; `insertAt` is where the separator
1800
+ * goes, which is that span's start.
1801
+ */
1802
+ function missingSeparatorDiagnostic(run: MissingSeparator): ExplainDiagnostic {
1803
+ return {
1804
+ code: "explain/canonicalizer/missing-statement-separator",
1805
+ severity: "error",
1806
+ message: `missing statement separator: \`${run.path}\` is a menu path and \`${run.verb}\` a command verb, so this reads as a second command with no separator before it, and RouterOS rejects the statement (\`bad parameter\` or \`expected end of command\`, on a token that depends on the head's own argument names) — insert \`;\` or a newline before \`${run.path}\``,
1807
+ span: { start: run.span.start, end: run.span.end },
1808
+ ev: EV.separators,
1809
+ };
1810
+ }
1811
+
1812
+ /**
1813
+ * Withdraw an argument reading the second run contradicts (#311).
1814
+ *
1815
+ * Same move `enforceGateParity` makes, for the same reason: the argument list is
1816
+ * all-or-nothing because a partially-read one changes what the rendered command
1817
+ * means. Reporting `gateway=` as an attribute of `/ip/address add` is the false
1818
+ * confident reading #311 names, and the device says the withdrawal is right
1819
+ * rather than merely cautious — `/ip/address add interface=ether1 /ip/route add
1820
+ * placeholder` lowers to `address=placeholder;;interface=ether1` on CHR 7.24.2, a phantom
1821
+ * attribute the source never wrote, because the second run's words can
1822
+ * abbreviate the HEAD's argument names. Which head arguments exist is schema,
1823
+ * so offline cannot predict when that happens and must not publish either
1824
+ * reading.
1825
+ *
1826
+ * The statement itself stays `resolved`: `/ip/address add` really is the head,
1827
+ * the device highlight agrees, and the source is one invalid statement rather
1828
+ * than two fabricated valid ones.
1829
+ */
1830
+ function withoutSeparatedArguments(
1831
+ statement: ExplainStatement,
1832
+ runs: readonly MissingSeparator[],
1833
+ ): ExplainStatement {
1834
+ if (runs.length === 0 || statement.kind !== "command") return statement;
1835
+ if (statement.arguments?.read !== true) return statement;
1836
+ const { args: _dropped, ...command } = statement.command;
1837
+ return {
1838
+ ...statement,
1839
+ command,
1840
+ arguments: { read: false, why: rejectedBySeparator(runs) },
1841
+ };
1842
+ }
1843
+
1844
+ /** The one sentence both the withdrawn argument list and transport refuse on. */
1845
+ function rejectedBySeparator(runs: readonly MissingSeparator[]): string {
1846
+ const first = runs[0];
1847
+ return `a second command-shaped run (\`${first?.path} ${first?.verb}\`) sits in this operand stream with no \`;\` or newline before it, so this is not one accepted statement`;
1848
+ }
1849
+
1850
+ function invalidCommandBraceDiagnostics(
1851
+ splits: readonly DocumentVerbSplit[],
1852
+ analyzed: string,
1853
+ analyzedRange: MaskedRange,
1854
+ ): ExplainDiagnostic[] {
1855
+ const out: ExplainDiagnostic[] = [];
1856
+ for (const split of splits) {
1857
+ if (split.resolution !== "resolved" || split.argsAt === null) continue;
1858
+ const { start, end } = split.span;
1859
+ const text = analyzed.slice(start, end);
1860
+ if (text !== split.text) continue;
1861
+ // The verb splitter cannot yet flatten every relative nested menu block.
1862
+ // Do not reinterpret a known submenu's leading `{` as an argument value,
1863
+ // but retain diagnostics for rejected braces later in the same split.
1864
+ // Require evidence that the brace actually opens a menu container
1865
+ // (body starts with a submenu name), so `/ip/route/print {1;2}` and
1866
+ // `/ip { firewall/filter/print {1;2} }` still diagnose the `{1;2}` array.
1867
+ const argsText = text.slice(split.argsAt);
1868
+ const leadingBraceAt = argsText.search(/\S/);
1869
+ let menuBraceAt: number | null = null;
1870
+ if (
1871
+ leadingBraceAt >= 0 &&
1872
+ argsText[leadingBraceAt] === "{" &&
1873
+ split.candidates.some((candidate) =>
1874
+ isKnownMenuPath(candidate.split("/").filter(Boolean)),
1875
+ )
1876
+ ) {
1877
+ const braceAt = split.argsAt + leadingBraceAt;
1878
+ const structural = rangeMask(nestedRange(analyzedRange, start, end));
1879
+ const trimmed = structural.slice(braceAt + 1).trimStart();
1880
+ if (trimmed.length === 0 || trimmed[0] === "}") {
1881
+ menuBraceAt = braceAt;
1882
+ } else if (trimmed[0] === ":" || trimmed[0] === "/") {
1883
+ // A menu block body is a statement list, and a statement may be a
1884
+ // scripting directive or an ABSOLUTE command — neither of which the
1885
+ // enclosing menu can vouch for, and neither of which any array literal
1886
+ // can start with. `/interface { ethernet { :put x } }` is corpus 521,
1887
+ // accepted on 7.21.5/7.23.5/7.24.2/7.25beta3 as
1888
+ // `(evl /interface/ethernet/ (evl /putmessage=…))` — #348.
1889
+ menuBraceAt = braceAt;
1890
+ } else {
1891
+ const match = /^([A-Za-z][A-Za-z0-9._-]*)/.exec(trimmed);
1892
+ if (match) {
1893
+ const firstWord = match[1] as string;
1894
+ // A menu block's body may open with a SUBMENU (`/ip { firewall …`)
1895
+ // or with a COMMAND of the menu itself (`/ip { address { print } }`,
1896
+ // whose inner brace is a body over `/ip/address`) — #348. Requiring
1897
+ // a submenu rejected the second shape, and the device accepts it:
1898
+ // `:parse` lowers it to `(evl (evl (evl /ip/address/print)))` on
1899
+ // 7.21.5 and 7.24.2, and `/interface { ethernet { print } }` the same
1900
+ // way, while `/ip { zzznotamenu { print } }` is rejected there.
1901
+ //
1902
+ // The command half asks the FROZEN verb vocabulary and the catalog,
1903
+ // never a per-menu schema: `VERBS` is the thirteen CRUD verbs that
1904
+ // exist at essentially every configuration menu (`verbs.ts`), and
1905
+ // `lookupPath` names the menu-specific commands. The catalog carries
1906
+ // no `/ip/address/print` row and never will — that is decision 3, not
1907
+ // a gap to fill. An array literal opens with a digit or a quote and
1908
+ // never reaches this identifier branch at all, so `{1;2}` is still
1909
+ // diagnosed.
1910
+ const isMenuContainer = split.candidates.some((candidate) => {
1911
+ const segments = candidate.split("/").filter(Boolean);
1912
+ if (!isKnownMenuPath(segments)) return false;
1913
+ return (
1914
+ isKnownMenuPath([...segments, firstWord]) ||
1915
+ VERBS.has(firstWord) ||
1916
+ lookupPath([...segments, firstWord])?.kind === "command"
1917
+ );
1918
+ });
1919
+ if (isMenuContainer) menuBraceAt = braceAt;
1920
+ }
1921
+ }
1922
+ }
1923
+ for (const at of invalidCommandBraceOffsets(
1924
+ text,
1925
+ split.argsAt,
1926
+ split.path === "/" ? split.verb : undefined,
1927
+ nestedRange(analyzedRange, start, end),
1928
+ )) {
1929
+ if (at === menuBraceAt) continue;
1930
+ out.push({
1931
+ code: "explain/canonicalizer/invalid-command-brace",
1932
+ severity: "error",
1933
+ message:
1934
+ "brace arrays are not valid in command arguments — use a parenthesized comma list such as `(1,2)`, or a bare comma list where the argument schema accepts one",
1935
+ span: { start: start + at, end: start + at + 1 },
1936
+ ev: EV.values,
1937
+ });
1938
+ }
1939
+ }
1940
+ return out;
1941
+ }
1942
+
1943
+ /**
1944
+ * Compose safely located literals into the three-axis #225 value surface.
1945
+ *
1946
+ * `separators` is indexed by split, and its first run is a CUT (#311): from that
1947
+ * byte on, the operand stream belongs to a second command the device never let
1948
+ * start, so no value there can be attributed. On the two probed rows the device
1949
+ * itself draws the line in the same place — `/ip/address add interface=ether1
1950
+ * /ip/route add gateway=…` is read with `gateway` as the VALUE of `address=`
1951
+ * (never as an attribute name), while the head's own `interface=ether1` survives
1952
+ * into the IL, and `/system/note set note=hello /ip/route add …` likewise keeps
1953
+ * `note=hello`. So the cut withdraws what the run contradicts and keeps what it
1954
+ * does not.
1955
+ *
1956
+ * Not "every value in a degraded statement": `values` is deliberately an axis of
1957
+ * its own rather than a projection of `arguments`, and the value census pins a
1958
+ * large retained population in statements whose strict REST reading abstains
1959
+ * (`commands/explain/README.md`, generated block). Widening this to the whole
1960
+ * statement would contradict that measured contract.
1961
+ */
1962
+ function valuesOf(
1963
+ splits: readonly DocumentVerbSplit[],
1964
+ analyzed: string,
1965
+ analyzedRange: MaskedRange,
1966
+ separators: readonly (readonly MissingSeparator[])[],
1967
+ ): ExplainValues {
1968
+ const occurrences: ExplainValueOccurrence[] = [];
1969
+ for (const [splitIndex, split] of splits.entries()) {
1970
+ if (split.resolution !== "resolved" || split.argsAt === null) continue;
1971
+ const cut = separators[splitIndex]?.[0]?.insertAt;
1972
+ const { start, end } = split.span;
1973
+ const text = analyzed.slice(start, end);
1974
+ if (text !== split.text) continue;
1975
+ const anchored = lexValueAnchors(text, split.argsAt, {
1976
+ range: nestedRange(analyzedRange, start, end),
1977
+ // A `{…}` array literal is legal only in a ROOT scripting directive's
1978
+ // value slot; `/ip/dns/set servers={…}` and `:log info message={…}` are
1979
+ // both device syntax errors at the `{`. `path` is how that position is
1980
+ // spelled offline: `:local`/`:put`/`:foreach` resolve to `/`, while
1981
+ // `:log info` resolves to `/log` — and the device agrees with the split.
1982
+ ...(split.path === "/" ? { directiveVerb: split.verb } : {}),
1983
+ });
1984
+ // Prefix completeness is intentionally not an envelope fact: shape hints are
1985
+ // advisory and never turn the later refusal reason into a diagnostic.
1986
+ const ids = new Map<number, string>();
1987
+ for (const [index, anchor] of anchored.anchors.entries()) {
1988
+ // A token that reaches into the second run is not this statement's to
1989
+ // name. Token spans never straddle the cut — the run's path is its own
1990
+ // positional token, so a `{…}` or quoted value either ends before it or
1991
+ // starts after it — which is why a dropped array cannot orphan a member.
1992
+ if (cut !== undefined && start + anchor.tokenSpan.end > cut) continue;
1993
+ const hints: ValueShape[] =
1994
+ // A source-proved shape wins outright: the delimiters prove `array`,
1995
+ // and an `=` that binds no key proves `str` or `bool` (#258). None is
1996
+ // recoverable from the decoded scalar `valueShapeHints` reads.
1997
+ anchor.sourceShape !== undefined
1998
+ ? [anchor.sourceShape]
1999
+ : valueShapeHints(anchor.value, {
2000
+ quoted: anchor.quoted,
2001
+ allowBareString: anchor.kind === "attribute",
2002
+ context: anchor.kind === "element" ? "array-member" : "argument",
2003
+ });
2004
+ if (hints.length === 0) continue;
2005
+ const span = {
2006
+ start: start + anchor.valueSpan.start,
2007
+ end: start + anchor.valueSpan.end,
2008
+ };
2009
+ const id = `v${occurrences.length}`;
2010
+ ids.set(index, id);
2011
+ const parent =
2012
+ anchor.parent === undefined ? undefined : ids.get(anchor.parent);
2013
+ occurrences.push({
2014
+ id,
2015
+ span,
2016
+ tokenSpan: {
2017
+ start: start + anchor.tokenSpan.start,
2018
+ end: start + anchor.tokenSpan.end,
2019
+ },
2020
+ kind: anchor.kind,
2021
+ ...(anchor.name === undefined ? {} : { name: anchor.name }),
2022
+ quoted: anchor.quoted,
2023
+ ...(parent === undefined ? {} : { parent }),
2024
+ facts: { shapeHints: { values: hints, ev: EV.values } },
2025
+ });
2026
+ }
2027
+ }
2028
+ return { occurrences };
2029
+ }
2030
+
2031
+ /**
2032
+ * The last line of the "never two confident answers" rule: where the gate and
2033
+ * the analysis read the SAME bytes and disagree about the arguments, the
2034
+ * analysis abstains.
2035
+ *
2036
+ * `args.ts` guards the characters centrs's two readers are known to differ on,
2037
+ * and that is where a specific, quotable reason belongs. But a token-level guard
2038
+ * can only see what reaches the lexer, and the gate reads the RAW INPUT while
2039
+ * the analysis reads a SEGMENTED statement — so a byte segmentation removes is
2040
+ * invisible to every such guard. A trailing `;` is exactly that: the gate keeps
2041
+ * it (`comment=x;` → the value `x;`), the segmenter strips it as the statement
2042
+ * terminator (`x`), and no character rule in `args.ts` can be written to catch
2043
+ * it, because by then it is gone.
2044
+ *
2045
+ * So the invariant is ENFORCED here rather than claimed. It is checkable only in
2046
+ * the one case where the two readers genuinely share bytes — the gate read the
2047
+ * whole input as `structured`, and there is exactly one statement — which is
2048
+ * also the only case where a consumer could see both answers side by side. A
2049
+ * multi-statement document makes the gate `script` with empty args, so there is
2050
+ * nothing to compare and the token-level guards are what protect each
2051
+ * statement's own reading.
2052
+ *
2053
+ * Abstaining is again not a concession that the gate is right: on `;` the
2054
+ * ANALYSIS is device-correct (`;` ends a statement in RouterOS; it is not part
2055
+ * of a value). The gate is locked, so neither value is published.
2056
+ *
2057
+ * Cost, measured: a printable-ASCII differential over 8 templates finds 675
2058
+ * cases where both decide and 0 surviving mismatches with this in place, and the
2059
+ * corpus parity probe is unchanged at 83 both-decided, 0 contradictions — so
2060
+ * this fires on `;` and nothing else that has been found. Raised as a P1 in
2061
+ * review of #202c-1, after three character-level guards each closed only what
2062
+ * had been reported.
2063
+ */
2064
+ function enforceGateParity(
2065
+ statements: readonly ExplainStatement[],
2066
+ canonical: CanonicalExecuteCommand,
2067
+ ): ExplainStatement[] {
2068
+ const out = [...statements];
2069
+ if (canonical.mode !== "structured" || out.length !== 1) return out;
2070
+ const only = out[0];
2071
+ if (only?.kind !== "command" || only.arguments?.read !== true) return out;
2072
+ const same =
2073
+ JSON.stringify([only.command.args ?? {}, only.arguments.queries]) ===
2074
+ JSON.stringify([canonical.attributes, canonical.queries]);
2075
+ if (same) return out;
2076
+ const { args: _dropped, ...command } = only.command;
2077
+ out[0] = {
2078
+ ...only,
2079
+ command,
2080
+ arguments: {
2081
+ read: false,
2082
+ why: "centrs's execute gate reads these bytes as a different argument list, and offline cannot prove which reading a device would apply",
2083
+ },
2084
+ };
2085
+ return out;
2086
+ }
2087
+
2088
+ /** One `[…]` substitution in the envelope's vocabulary. See {@link ExplainSubcommand}. */
2089
+ function subcommandOf(r: Resolution): ExplainSubcommand {
2090
+ // Q3 refused: the bracket is unreadable and Q6's reading of the same text
2091
+ // cannot promote it.
2092
+ const reading =
2093
+ r.unresolved !== undefined || r.path === null
2094
+ ? {
2095
+ resolution: "unknown" as const,
2096
+ unresolved: r.unresolved ?? "no reading of this substitution",
2097
+ }
2098
+ : readingOf(
2099
+ resolveVerb(
2100
+ r.inner,
2101
+ r.context,
2102
+ rangeForResolution(r) ?? documentRange(r.inner),
2103
+ ),
2104
+ );
2105
+ return {
2106
+ span: { start: r.span.start, end: r.span.end },
2107
+ context: r.context,
2108
+ ...reading,
2109
+ candidates: r.candidates,
2110
+ contextCertain: r.contextCertain,
2111
+ depth: r.depth,
2112
+ ev: EV.subcommands,
2113
+ };
2114
+ }
2115
+
2116
+ /**
2117
+ * The `resolution`/`kind`/`command` fold, shared by statements and subcommands.
2118
+ *
2119
+ * Total over `VerbSplit` with no null-guard, because that module's union states
2120
+ * what each resolution carries: `navigation` has a path, `resolved` has a path
2121
+ * and a verb, a refusal has neither. A guard here would be an unreachable
2122
+ * branch pretending the invariant is unknown.
2123
+ */
2124
+ function readingOf(split: VerbSplit): ExplainReading {
2125
+ if (split.resolution === "navigation")
2126
+ return {
2127
+ resolution: "resolved",
2128
+ kind: "menu",
2129
+ command: { path: split.path },
2130
+ };
2131
+ if (split.resolution === "resolved")
2132
+ return {
2133
+ resolution: "resolved",
2134
+ kind: "command",
2135
+ command: { path: split.path, verb: split.verb },
2136
+ };
2137
+ return { resolution: split.resolution, unresolved: split.why };
2138
+ }
2139
+
2140
+ /** The tristate as the envelope renders it: a real `"unknown"`, never a boolean. */
2141
+ function renderWriteVerdict(verdict: WriteVerdict): boolean | "unknown" {
2142
+ return verdict === "unknown" ? "unknown" : verdict === "true";
2143
+ }
2144
+
2145
+ /**
2146
+ * Per-statement diagnostics — the abstentions, said out loud.
2147
+ *
2148
+ * A refusal is a first-class result, not an error: `ambiguous` and `unknown` are
2149
+ * the ratified vocabulary for "the input has several valid schema-free readings"
2150
+ * and "analysis cannot safely recover one". They are WARNINGS, never errors, so
2151
+ * the default `--fail-on error` does not fail a document whose only sin is being
2152
+ * unreadable without a schema — which is most of RouterOS scripting.
2153
+ */
2154
+ function diagnosticsForStatement(s: ExplainStatement): ExplainDiagnostic[] {
2155
+ const out: ExplainDiagnostic[] = [];
2156
+ if (s.resolution === "ambiguous")
2157
+ out.push({
2158
+ code: "explain/canonicalizer/ambiguous-statement",
2159
+ severity: "warning",
2160
+ message:
2161
+ s.unresolved ??
2162
+ "menu or no-argument command: the same text offline, decidable only against a schema",
2163
+ span: s.span,
2164
+ ev: s.ev,
2165
+ });
2166
+ if (s.resolution === "unknown")
2167
+ out.push({
2168
+ code: "explain/canonicalizer/unresolved-statement",
2169
+ severity: "warning",
2170
+ message: s.unresolved ?? "no safe reading of this statement",
2171
+ span: s.span,
2172
+ ev: s.ev,
2173
+ });
2174
+ // The #192 signal: a reading that is CORRECT while the document context was
2175
+ // already lost. Info severity — the statement resolved without consuming the
2176
+ // context, so nothing about it is wrong; a consumer ranking readings or
2177
+ // offering completions is the one that needs to know.
2178
+ if (s.resolution === "resolved" && !s.contextCertain)
2179
+ out.push({
2180
+ code: "explain/canonicalizer/context-lost",
2181
+ severity: "info",
2182
+ message:
2183
+ "resolved without the document's menu context, which an earlier unreadable statement destroyed",
2184
+ span: s.span,
2185
+ ev: s.ev,
2186
+ });
2187
+ return out;
2188
+ }
2189
+
2190
+ /**
2191
+ * Only evidence something cites.
2192
+ *
2193
+ * A dangling entry would claim a probe ran for a fact that is not in the result
2194
+ * — the opposite of what the provenance table is for.
2195
+ */
2196
+ function citedEvidence(
2197
+ structure: ExplainStructure,
2198
+ diagnostics: readonly ExplainDiagnostic[],
2199
+ spans: readonly ExplainSpan[],
2200
+ symbols: ExplainSymbols,
2201
+ values: ExplainValues,
2202
+ tokens?: readonly ExplainToken[] | readonly ExplainSpan[] | undefined,
2203
+ ): ExplainEvidence[] {
2204
+ // `canonical` and `input` carry no `ev` of their own — they are whole-result
2205
+ // fields, not entries in a list — so their two passes are seeded here.
2206
+ const cited = new Set<string>([EV.canonical, EV.coordinates, structure.ev]);
2207
+ for (const s of structure.statements) cited.add(s.ev);
2208
+ for (const s of structure.statements)
2209
+ if (s.kind === "command" && s.transport !== undefined)
2210
+ cited.add(s.transport.ev);
2211
+ for (const s of structure.subcommands) cited.add(s.ev);
2212
+ for (const b of structure.blocks) cited.add(b.ev);
2213
+ for (const d of diagnostics) cited.add(d.ev);
2214
+ for (const s of spans) cited.add(s.ev);
2215
+ for (const occurrence of symbols.occurrences) cited.add(occurrence.ev);
2216
+ for (const occurrence of values.occurrences) {
2217
+ if (occurrence.facts.shapeHints !== undefined)
2218
+ cited.add(occurrence.facts.shapeHints.ev);
2219
+ if (occurrence.facts.observedType !== undefined)
2220
+ cited.add(occurrence.facts.observedType.ev);
2221
+ if (occurrence.facts.schemaType !== undefined)
2222
+ cited.add(occurrence.facts.schemaType.ev);
2223
+ }
2224
+ if (tokens !== undefined) for (const t of tokens) cited.add(t.ev);
2225
+ return Object.values(EVIDENCE)
2226
+ .filter((e) => cited.has(e.id))
2227
+ .sort((a, b) => a.id.localeCompare(b.id));
2228
+ }
2229
+
2230
+ /**
2231
+ * The offline envelope.
2232
+ *
2233
+ * `ok: true` whenever the analysis RAN, mirroring `check`: the diagnostics are
2234
+ * the data, and `ok: false` is reserved for genuine command failure (an
2235
+ * unresolvable target, a usage error). `via` is `null` because the offline
2236
+ * classifier plans each statement but does not open a protocol connection.
2237
+ */
2238
+ export function explainEnvelope(
2239
+ input: string,
2240
+ options: ExplainEnvelopeOptions = {},
2241
+ ): ExplainEnvelope {
2242
+ const data = explainCommand(input, {
2243
+ curl: options.curl,
2244
+ tokens: options.tokens,
2245
+ });
2246
+ const tips: Tip[] = [
2247
+ buildTip(
2248
+ "tip/explain-offline-only",
2249
+ "No router was given, so this is the canonicalizer's reading alone.",
2250
+ "Completion, schema, and `:parse` evidence come from a live target (`centrs explain <router> '<input>'`), which is phase 2 and not accepted yet.",
2251
+ ),
2252
+ ...(options.tips ?? []),
2253
+ ];
2254
+ const warnings: Warning[] = [...(options.warnings ?? [])];
2255
+ const meta: EnvelopeMeta<ExplainOperationMeta> = {
2256
+ target: {},
2257
+ via: null,
2258
+ settings: settingsMeta(options.format),
2259
+ validation: { enabled: false, result: "skipped" },
2260
+ operation: {
2261
+ command: "explain",
2262
+ mode: "offline",
2263
+ statementCount: data.structure.statementCount,
2264
+ verdict: data.verdict,
2265
+ },
2266
+ };
2267
+ return { ok: true, data, warnings, tips, meta };
2268
+ }
2269
+
2270
+ /**
2271
+ * The facets that need a device, requested offline.
2272
+ *
2273
+ * `--complete` and `--schema` are enumeration, and enumeration is live evidence:
2274
+ * offline has no schema snapshot to consult (the ratified no-static-snapshot
2275
+ * decision), so the honest answer is an empty result plus a tip naming what
2276
+ * would answer it. Fabricating candidates from a bundled table is exactly the
2277
+ * failure mode the decision exists to prevent.
2278
+ */
2279
+ export function buildExplainFacetTip(facets: readonly string[]): Tip {
2280
+ const named = facets.join(" and ");
2281
+ return buildTip(
2282
+ "tip/explain-live-facets",
2283
+ `${named} enumerate what a device accepts, and no router was given — so nothing is enumerated here.`,
2284
+ "Name a router once live evidence lands (phase 2); until then use `rosetta` for documented RouterOS schema facts.",
2285
+ );
2286
+ }
2287
+
2288
+ export const explainOutputFormats = ["text", "json", "yaml"] as const;
2289
+ export type ExplainOutputFormat = (typeof explainOutputFormats)[number];
2290
+
2291
+ /**
2292
+ * Envelope-level options. Curl is forwarded to the analysis as an opt-in
2293
+ * rendering; the remaining fields are invocation metadata the analysis does
2294
+ * not need. Phase 2's `{ target, facets }` remains a separate live concern.
2295
+ */
2296
+ export interface ExplainEnvelopeOptions {
2297
+ format?: ResolvedSetting<ExplainOutputFormat>;
2298
+ /** Include curl rendering for API-candidate statements. */
2299
+ curl?: boolean;
2300
+ /**
2301
+ * Include the gapless token partition behind `data.tokens[]`.
2302
+ *
2303
+ * Mirrors `--complete` / `--schema` / `--curl` facet pattern. The vocabulary
2304
+ * is `ExplainTokenClass` (#264 B5).
2305
+ */
2306
+ tokens?: boolean;
2307
+ tips?: readonly Tip[];
2308
+ /** Facts about the INVOCATION, not the analysis (e.g. an ignored stdin). */
2309
+ warnings?: readonly Warning[];
2310
+ }
2311
+
2312
+ /**
2313
+ * The host process environment, or an empty one where there is no host.
2314
+ *
2315
+ * A bare `Bun.env` default parameter is what a browser consumer of this module
2316
+ * trips over (#312): the reference is evaluated on the first settings-ladder
2317
+ * call, and `Bun` does not exist there. `typeof` is safe on an undeclared
2318
+ * identifier, so the guard costs nothing and changes nothing under Bun — the
2319
+ * ladder still reads the real environment, and a browser caller that never
2320
+ * passes one gets the documented "no env tier" behavior rather than a crash.
2321
+ *
2322
+ * Only the offline analysis entry runs outside Bun; the other commands keep
2323
+ * their `Bun.env` defaults because they open connections regardless.
2324
+ */
2325
+ function hostEnv(): Record<string, string | undefined> {
2326
+ return typeof Bun === "undefined" ? {} : Bun.env;
2327
+ }
2328
+
2329
+ function settingsMeta(
2330
+ format: ResolvedSetting<ExplainOutputFormat> | undefined,
2331
+ ): CommonSettingsMeta {
2332
+ return format === undefined ? {} : { format: toCoreSource(format.source) };
2333
+ }
2334
+
2335
+ /**
2336
+ * Resolve the render format across the settings ladder: `centrs.env` config <
2337
+ * `CENTRS_FORMAT` env < the CLI flag (`docs/CONSTITUTION.md` → Settings
2338
+ * precedence). Throws `settings/invalid-format` on a bad value from ANY tier —
2339
+ * a typo in `centrs.env` that silently rendered text would be the same silent
2340
+ * fallback the constitution forbids on the CLI.
2341
+ */
2342
+ export function resolveExplainFormat(
2343
+ explicit: string | undefined,
2344
+ env: Record<string, string | undefined> = hostEnv(),
2345
+ config: Record<string, string | undefined> = {},
2346
+ ): ResolvedSetting<ExplainOutputFormat> {
2347
+ return resolveStringSetting(
2348
+ explicit,
2349
+ env,
2350
+ "CENTRS_FORMAT",
2351
+ "text",
2352
+ "format",
2353
+ parseExplainOutputFormat,
2354
+ undefined,
2355
+ config,
2356
+ ) as ResolvedSetting<ExplainOutputFormat>;
2357
+ }
2358
+
2359
+ function parseExplainOutputFormat(value: string): ExplainOutputFormat {
2360
+ if ((explainOutputFormats as readonly string[]).includes(value))
2361
+ return value as ExplainOutputFormat;
2362
+ throw new CentrsError({
2363
+ code: "settings/invalid-format",
2364
+ summary: `Unsupported explain output format: ${value}`,
2365
+ remediation: `Choose one of ${explainOutputFormats.join(", ")}.`,
2366
+ context: { format: value },
2367
+ });
2368
+ }
2369
+
2370
+ /** `--fail-on` thresholds, in the order the CLI documents them. */
2371
+ export const explainFailOnLevels = ["error", "warning", "never"] as const;
2372
+ export type ExplainFailOn = (typeof explainFailOnLevels)[number];
2373
+
2374
+ /**
2375
+ * Exit `2` when the verdict meets the threshold, `0` otherwise — the `check`
2376
+ * pattern (`commands/explain/README.md` → Surface).
2377
+ *
2378
+ * Note what `--fail-on error` does NOT fail on: an `ambiguous`/`unknown`
2379
+ * statement is a warning by construction, so a script that is merely unreadable
2380
+ * without a schema still exits `0`. That is the whole reason the two verdicts
2381
+ * are separate — a resolution is not a severity.
2382
+ */
2383
+ export function explainExitCode(
2384
+ verdict: ExplainVerdict,
2385
+ failOn: ExplainFailOn,
2386
+ ): 0 | 2 {
2387
+ if (failOn === "never") return 0;
2388
+ if (failOn === "warning") return verdict === "pass" ? 0 : 2;
2389
+ return verdict === "fail" ? 2 : 0;
2390
+ }
2391
+
2392
+ export type ExplainErrorEnvelope = CentrsErrorEnvelope<ExplainOperationMeta>;
2393
+
2394
+ /**
2395
+ * A usage failure, in the same envelope. No `operation` block: nothing was
2396
+ * analyzed, so there is no statement count or verdict to report, and an
2397
+ * invented one would read as an analysis that ran.
2398
+ */
2399
+ export function buildExplainErrorEnvelope(
2400
+ error: unknown,
2401
+ tips: readonly Tip[] = [],
2402
+ format?: ResolvedSetting<ExplainOutputFormat>,
2403
+ ): ExplainErrorEnvelope {
2404
+ const centrs =
2405
+ error instanceof CentrsError
2406
+ ? error
2407
+ : new CentrsError({
2408
+ code: "internal/unhandled",
2409
+ summary: error instanceof Error ? error.message : String(error),
2410
+ remediation:
2411
+ "This is a centrs bug; re-run with --verbose and file an issue with the printed code.",
2412
+ });
2413
+ return {
2414
+ ok: false,
2415
+ error: serializeCentrsError(centrs),
2416
+ warnings: [],
2417
+ tips,
2418
+ meta: { target: {}, via: null, settings: settingsMeta(format) },
2419
+ };
2420
+ }
2421
+
2422
+ export function renderExplainEnvelope(
2423
+ envelope: ExplainEnvelope | ExplainErrorEnvelope,
2424
+ format: ExplainOutputFormat,
2425
+ ): string {
2426
+ if (format === "json") return JSON.stringify(envelope, null, 2);
2427
+ if (format === "yaml") return toYaml(envelope);
2428
+ return renderExplainText(envelope);
2429
+ }
2430
+
2431
+ function span(s: ExplainSpanRange): string {
2432
+ return `[${s.start},${s.end})`;
2433
+ }
2434
+
2435
+ /** Tips in the shared text shape (`src/cli/missing-target.ts` renders the same block). */
2436
+ function tipLines(tips: readonly Tip[]): string[] {
2437
+ if (tips.length === 0) return [];
2438
+ const lines = ["", "Tips:"];
2439
+ for (const tip of tips) {
2440
+ lines.push(` - [${tip.code}] ${tip.message}`);
2441
+ if (tip.fix) lines.push(` fix: ${tip.fix}`);
2442
+ }
2443
+ return lines;
2444
+ }
2445
+
2446
+ /**
2447
+ * One statement or subcommand: what it resolved to, or why it did not.
2448
+ *
2449
+ * `path=`/`verb=` rather than a bare `<path> <verb>`, because a scripting
2450
+ * command resolves to path `/` plus its verb (`:foreach` is `/` + `foreach`)
2451
+ * and `/ foreach` reads like a typo, while `/foreach` reads like a menu.
2452
+ */
2453
+ function renderReading(reading: ExplainReading): string {
2454
+ if (reading.resolution !== "resolved")
2455
+ return `${reading.resolution.padEnd(9)} ${reading.unresolved}`;
2456
+ return reading.kind === "menu"
2457
+ ? `resolved menu path=${reading.command.path}`
2458
+ : `resolved command path=${reading.command.path} verb=${reading.command.verb}`;
2459
+ }
2460
+
2461
+ function renderSymbol(occurrence: ExplainSymbolOccurrence): string {
2462
+ const binding =
2463
+ occurrence.bindingIds.length === 0
2464
+ ? ""
2465
+ : ` bindings=${occurrence.bindingIds.join(",")}`;
2466
+ const value =
2467
+ occurrence.valueId !== undefined ? ` value=${occurrence.valueId}` : "";
2468
+ const reaching =
2469
+ occurrence.reachingValueIds !== undefined
2470
+ ? ` reaching=[${occurrence.reachingValueIds.join(",")}]${occurrence.reachingUnknown ? "+unknown" : ""}`
2471
+ : occurrence.reachingUnknown
2472
+ ? " reaching=unknown"
2473
+ : "";
2474
+ const note = occurrence.note === undefined ? "" : ` (${occurrence.note})`;
2475
+ return `${(occurrence.class ?? "unknown").padEnd(9)} ${occurrence.role.padEnd(11)} name=${JSON.stringify(occurrence.name)}${binding}${value}${reaching}${note}`;
2476
+ }
2477
+
2478
+ function renderValue(occurrence: ExplainValueOccurrence): string {
2479
+ const name = occurrence.name === undefined ? "" : ` name=${occurrence.name}`;
2480
+ const shapes = occurrence.facts.shapeHints?.values.join("|") ?? "unknown";
2481
+ const parent =
2482
+ occurrence.parent === undefined ? "" : ` in=${occurrence.parent}`;
2483
+ return `${occurrence.kind.padEnd(10)}${name}${parent} shapes=${shapes}`;
2484
+ }
2485
+
2486
+ /**
2487
+ * The human format.
2488
+ *
2489
+ * Deliberately not a pretty-print of `data`: the text surface answers "what did
2490
+ * I write and what is wrong with it", so it leads with the two verdicts and the
2491
+ * gate, and drops empty sections. `--json` is the complete shape.
2492
+ */
2493
+ function renderExplainText(
2494
+ envelope: ExplainEnvelope | ExplainErrorEnvelope,
2495
+ ): string {
2496
+ const lines: string[] = [];
2497
+ if (!envelope.ok) {
2498
+ lines.push(`[${envelope.error.code}] ${envelope.error.summary}`);
2499
+ if (envelope.error.remediation)
2500
+ lines.push(`Fix: ${envelope.error.remediation}`);
2501
+ if (envelope.error.detailsUrl)
2502
+ lines.push(`Details: ${envelope.error.detailsUrl}`);
2503
+ lines.push(...tipLines(envelope.tips));
2504
+ return lines.join("\n");
2505
+ }
2506
+
2507
+ const { data } = envelope;
2508
+ const { canonical, structure } = data;
2509
+ lines.push(
2510
+ `verdict: ${data.verdict} — ${structure.statementCount} statement(s), ${data.diagnostics.length} diagnostic(s)`,
2511
+ );
2512
+ const argCount = Object.keys(canonical.args).length;
2513
+ lines.push(
2514
+ `gate: ${canonical.mode}${canonical.path ? ` ${canonical.path}` : ""}${canonical.verb ? ` ${canonical.verb}` : ""} · ${argCount} arg(s)${canonical.writeShaped ? " · write-shaped" : ""}`,
2515
+ );
2516
+ lines.push(`write: ${structure.containsWrite}`);
2517
+ if (data.input.normalized)
2518
+ lines.push(
2519
+ `input: ${data.input.bytes} byte(s), normalized (non-ASCII stood in for one-for-one)`,
2520
+ );
2521
+ if (structure.statements.length > 0) {
2522
+ lines.push("statements:");
2523
+ for (const s of structure.statements) {
2524
+ lines.push(
2525
+ ` ${span(s.span).padEnd(12)} ${renderReading(s)}${s.kind === "command" && s.transport !== undefined ? ` via=${s.transport.classification}` : ""}${s.contextCertain ? "" : " (context lost)"}`,
2526
+ );
2527
+ if (s.kind === "command" && s.transport?.centrs !== undefined)
2528
+ lines.push(` centrs: ${s.transport.centrs}`);
2529
+ if (s.kind === "command" && s.transport?.curl !== undefined)
2530
+ lines.push(` curl: ${s.transport.curl}`);
2531
+ // A refusal must say why in the DEFAULT surface. `api-candidate` and
2532
+ // `execute` render the command they chose, which is the reason; only
2533
+ // `unknown` renders nothing, and the basis is the actionable half.
2534
+ if (s.kind === "command" && s.transport?.classification === "unknown")
2535
+ lines.push(` why: ${s.transport.basis}`);
2536
+ }
2537
+ }
2538
+ if (structure.subcommands.length > 0) {
2539
+ lines.push("subcommands:");
2540
+ for (const s of structure.subcommands)
2541
+ lines.push(
2542
+ ` ${span(s.span).padEnd(12)} ${renderReading(s)} (in ${s.context}, depth ${s.depth})`,
2543
+ );
2544
+ }
2545
+ if (structure.blocks.length > 0) {
2546
+ lines.push("blocks:");
2547
+ for (const b of structure.blocks)
2548
+ lines.push(` ${span(b.span).padEnd(12)} ${b.name}`);
2549
+ }
2550
+ const comments = data.spans.filter((entry) => entry.class === "comment");
2551
+ if (comments.length > 0) {
2552
+ lines.push("comments:");
2553
+ for (const comment of comments)
2554
+ lines.push(` ${span(comment).padEnd(12)} comment`);
2555
+ }
2556
+ if (data.symbols.occurrences.length > 0) {
2557
+ lines.push("symbols:");
2558
+ for (const occurrence of data.symbols.occurrences)
2559
+ lines.push(
2560
+ ` ${span(occurrence.span).padEnd(12)} ${renderSymbol(occurrence)}`,
2561
+ );
2562
+ }
2563
+ if (data.values.occurrences.length > 0) {
2564
+ lines.push("values:");
2565
+ for (const occurrence of data.values.occurrences)
2566
+ lines.push(
2567
+ ` ${span(occurrence.span).padEnd(12)} ${renderValue(occurrence)}`,
2568
+ );
2569
+ }
2570
+ // `--tokens` is opt-in, so it must CHANGE this surface — a flag that only
2571
+ // moves `--json` reads as a no-op from the default format. The header is the
2572
+ // #289 deliverable (a coverage number) for this one input; the rows are the
2573
+ // partition itself, `unclassified` runs included.
2574
+ if (data.tokens !== undefined) {
2575
+ const classified = data.tokens.reduce(
2576
+ (sum, t) => sum + (t.class === "unclassified" ? 0 : t.end - t.start),
2577
+ 0,
2578
+ );
2579
+ const bytes = data.input.bytes;
2580
+ const pct = bytes === 0 ? 0 : (classified / bytes) * 100;
2581
+ lines.push(
2582
+ `tokens: ${data.tokens.length} token(s), ${classified}/${bytes} byte(s) classified (${pct.toFixed(1)}%)`,
2583
+ );
2584
+ for (const t of data.tokens)
2585
+ lines.push(` ${span(t).padEnd(12)} ${t.class}`);
2586
+ }
2587
+ if (data.diagnostics.length > 0) {
2588
+ lines.push("diagnostics:");
2589
+ for (const d of data.diagnostics)
2590
+ lines.push(
2591
+ ` ${d.severity.padEnd(7)} ${span(d.span).padEnd(12)} ${d.code}: ${d.message}`,
2592
+ );
2593
+ }
2594
+ lines.push(
2595
+ `evidence: ${data.evidence.map((e) => `${e.id} ${e.probe} (${e.basis})`).join(", ")}`,
2596
+ );
2597
+ lines.push(`runtimeAcceptance: ${data.runtimeAcceptance}`);
2598
+ for (const w of envelope.warnings)
2599
+ lines.push(`warning: [${w.code}] ${w.message}`);
2600
+ lines.push(...tipLines(envelope.tips));
2601
+ return lines.join("\n");
2602
+ }