@tikoci/centrs 0.1.3 → 0.1.4

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