@tikoci/centrs 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +8 -4
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +37 -8
  7. package/commands/api/examples.md +31 -1
  8. package/commands/execute/README.md +39 -10
  9. package/commands/execute/examples.md +79 -7
  10. package/commands/explain/README.md +1195 -155
  11. package/commands/explain/examples.md +99 -3
  12. package/commands/mcp/README.md +7 -4
  13. package/commands/mcp/examples.md +2 -2
  14. package/commands/terminal/examples.md +7 -1
  15. package/docs/CLI.md +2 -2
  16. package/docs/CONSTITUTION.md +72 -15
  17. package/docs/MATRIX.md +89 -52
  18. package/docs/errors/validation/syntax.md +29 -4
  19. package/package.json +13 -2
  20. package/src/api-fanout.ts +17 -4
  21. package/src/api.ts +254 -67
  22. package/src/cli/api.ts +1 -1
  23. package/src/cli/btest.ts +1 -1
  24. package/src/cli/explain.ts +1 -1
  25. package/src/cli/terminal.ts +1 -1
  26. package/src/core/envelope.ts +53 -0
  27. package/src/core/error-catalog.ts +2 -1
  28. package/src/core/execute-command.ts +148 -0
  29. package/src/core/inspect.ts +36 -4
  30. package/src/core/version.ts +4 -0
  31. package/src/core/yaml.ts +86 -0
  32. package/src/execute-fanout.ts +18 -4
  33. package/src/execute.ts +225 -244
  34. package/src/explain/arg-tokens.ts +62 -25
  35. package/src/explain/args.ts +398 -52
  36. package/src/explain/blocks.ts +54 -11
  37. package/src/explain/brace-tokens.ts +4 -3
  38. package/src/explain/catalog.ts +42 -44
  39. package/src/explain/coordinates.ts +159 -0
  40. package/src/explain/defects.ts +2 -1
  41. package/src/explain/escape-tokens.ts +77 -0
  42. package/src/explain/highlight-projection.ts +308 -0
  43. package/src/explain/menus.ts +9 -5
  44. package/src/explain/operator-tokens.ts +13 -7
  45. package/src/explain/operators.ts +27 -20
  46. package/src/explain/path-tokens.ts +1 -1
  47. package/src/explain/pathresolve.ts +358 -113
  48. package/src/explain/quoted-string.ts +110 -20
  49. package/src/explain/scope-brace.ts +175 -22
  50. package/src/explain/segment.ts +429 -21
  51. package/src/explain/separator.ts +187 -0
  52. package/src/explain/split-owner.ts +194 -0
  53. package/src/explain/string-tokens.ts +14 -4
  54. package/src/explain/symbol-values.ts +209 -173
  55. package/src/explain/symbols.ts +21 -1
  56. package/src/explain/transport.ts +17 -0
  57. package/src/explain/value-tokens.ts +15 -12
  58. package/src/explain/verbsplit.ts +165 -23
  59. package/src/explain/write.ts +47 -6
  60. package/src/explain.ts +522 -72
  61. package/src/index.ts +19 -2
  62. package/src/mcp/server.ts +2 -1
  63. package/src/offline-gate.ts +148 -0
  64. package/src/retrieve-fanout.ts +1 -1
  65. package/src/retrieve.ts +1 -59
  66. package/src/settings.ts +2 -1
  67. package/src/transfer-fanout.ts +1 -1
  68. package/src/transfer.ts +1 -1
package/src/explain.ts CHANGED
@@ -77,6 +77,22 @@
77
77
  * type-axis question before it is a lexical one.
78
78
  * - **`runtimeAcceptance` is always `"not-proven"`**, offline and live alike.
79
79
  * It is the inspect-vs-runtime gap made machine-readable, not a placeholder.
80
+ *
81
+ * ## This module is the public offline entry — its imports are a contract
82
+ *
83
+ * `@tikoci/centrs/explain` resolves here, and the claim that offline analysis is a
84
+ * LIBRARY capability rests on what this file imports: nothing in the graph below
85
+ * it may open a connection, read CDB, or touch the filesystem, so the module
86
+ * bundles and runs in a browser, a Worker, or an editor host (#312).
87
+ *
88
+ * That is easy to break with one line, and it was: `toYaml` and
89
+ * `canonicalizeExecuteCommand` are pure, but importing them from `retrieve.ts`
90
+ * and `execute.ts` pulled mac-telnet (`node:dgram`), native-api (`node:crypto`)
91
+ * and ssh in behind them. Both now live in `src/core/`. **Before adding an
92
+ * import here, check it is pure** — `bun run explain:browser-consumer` walks the
93
+ * graph and names the offending edge, and
94
+ * `test/unit/explain-browser-entry.test.ts` gates it. A pure helper that lives
95
+ * in a transport-reaching module belongs in `src/core/`, not in an import here.
80
96
  */
81
97
 
82
98
  import type {
@@ -86,22 +102,26 @@ import type {
86
102
  EnvelopeMeta,
87
103
  } from "./core/envelope.ts";
88
104
  import { buildTip, type Tip, type Warning } from "./core/envelope.ts";
89
- import { CentrsError, serializeCentrsError } from "./errors.ts";
90
105
  import {
91
106
  type CanonicalExecuteCommand,
92
107
  canonicalizeExecuteCommand,
93
108
  isWriteShaped,
94
- } from "./execute.ts";
109
+ } from "./core/execute-command.ts";
110
+ import { toYaml } from "./core/yaml.ts";
111
+ import { CentrsError, serializeCentrsError } from "./errors.ts";
95
112
  import { argSpans } from "./explain/arg-tokens.ts";
96
113
  import {
97
114
  type ArgumentKind,
115
+ type Argument as ExplainArgument,
98
116
  invalidCommandBraceOffsets,
99
117
  lexArguments,
118
+ lexArgumentTokens,
100
119
  lexValueAnchors,
101
120
  type ValueAnchorKind,
102
121
  } from "./explain/args.ts";
103
122
  import { scopeBlocks } from "./explain/blocks.ts";
104
123
  import { braceSpans } from "./explain/brace-tokens.ts";
124
+ import { lookupPath } from "./explain/catalog.ts";
105
125
  import {
106
126
  analyzeCoordinates,
107
127
  type CoordinateAnalysis,
@@ -111,16 +131,28 @@ import {
111
131
  type DefectCode,
112
132
  isPositionalFact,
113
133
  } from "./explain/defects.ts";
134
+ import { escapeSpans } from "./explain/escape-tokens.ts";
114
135
  import { isKnownMenuPath } from "./explain/is-known-menu.ts";
115
136
  import { operatorSpans } from "./explain/operator-tokens.ts";
116
137
  import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
117
138
  import {
118
139
  type Resolution,
140
+ rangeForResolution,
119
141
  resolveDocument,
120
142
  resolveStatements,
121
143
  } from "./explain/pathresolve.ts";
122
- import { collectStringEscapeDefects } from "./explain/quoted-string.ts";
123
- import { maskComments, segmentStatements } from "./explain/segment.ts";
144
+ import { walkStringEscapes } from "./explain/quoted-string.ts";
145
+ import {
146
+ documentRange,
147
+ type MaskedRange,
148
+ nestedRange,
149
+ rangeMask,
150
+ segmentStatements,
151
+ } from "./explain/segment.ts";
152
+ import {
153
+ findMissingSeparators,
154
+ type MissingSeparator,
155
+ } from "./explain/separator.ts";
124
156
  import { stringSpans } from "./explain/string-tokens.ts";
125
157
  import { augmentSymbolOccurrences } from "./explain/symbol-values.ts";
126
158
  import {
@@ -138,6 +170,7 @@ import {
138
170
  type DocumentVerbSplit,
139
171
  resolveVerb,
140
172
  resolveVerbsFromStatements,
173
+ VERBS,
141
174
  type VerbSplit,
142
175
  } from "./explain/verbsplit.ts";
143
176
  import {
@@ -149,7 +182,6 @@ import {
149
182
  resolveStringSetting,
150
183
  toCoreSource,
151
184
  } from "./resolver/settings.ts";
152
- import { toYaml } from "./retrieve.ts";
153
185
 
154
186
  /** A half-open analyzed-byte span, the coordinate contract of `coordinates.ts`. */
155
187
  export interface ExplainSpanRange {
@@ -449,18 +481,29 @@ export type TokenFill = readonly ExplainToken[];
449
481
  * Byte-exact, inheriting the #215/#252 offset discipline. Offsets are on the
450
482
  * analyzed text; `input.positionMap` applies when normalized.
451
483
  *
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.
484
+ * `class` is the vocabulary #264 B5 settled (see below); it is not an LSP/SCIP
485
+ * legend and does not try to be one. Every byte no analyzer claims becomes
486
+ * `unclassified`, which is a first-class answer, not a placeholder to be
487
+ * avoided.
456
488
  *
457
489
  * B2 so far: `dir` / `cmd` (resolved path and verb bytes, including valid path
458
490
  * 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.
491
+ * merge, never a source, #255), `arg` / `arg-sep` (argument names and the `=`
492
+ * that binds each one to its value, both located by `args.ts`), and `value`
493
+ * (argument value bytes and leaf array-literal members from
494
+ * `data.values.occurrences`, quotes included, #295).
495
+ *
496
+ * **B5 settled which merges get their own name** (`commands/explain/README.md`
497
+ * -> *The vocabulary, and what earns a class*, where the figures live beside
498
+ * the generated blocks that keep them honest): a class is split only where a
499
+ * consumer holding the whole envelope could not recover the distinction by
500
+ * joining `tokens[]` to another published surface on byte offsets. `arg-sep` is
501
+ * the one split that test forced, because a large minority of the `=` runs the
502
+ * arg fill paints belong to a statement whose strict `arguments` reading
503
+ * refused, so there is no `ExplainArgumentToken.valueSpan` to subtract one
504
+ * from. The per-spelling `operator`, per-shape `value`, and scope-versus-array
505
+ * `brace` merges all stay merged — the bytes, `data.values.occurrences[]`, and
506
+ * `structure.blocks[]` already carry those distinctions.
464
507
  */
465
508
  export type ExplainTokenClass =
466
509
  | ExplainSpanClass
@@ -468,14 +511,37 @@ export type ExplainTokenClass =
468
511
  | "cmd"
469
512
  | "operator"
470
513
  | "arg"
514
+ /**
515
+ * The single `=` byte binding one argument name to its value (#264 B5).
516
+ *
517
+ * Never a comparison `=`, and the separation is fill order rather than a
518
+ * byte scan: the `arg` fill offers only the `=` its own located token names,
519
+ * so a comparison inside a `( )` group falls through to `operator`, and one
520
+ * inside a `[ … ]` substitution stays `unclassified` where the operator fill
521
+ * abstains (`src/explain/operator-tokens.ts`).
522
+ */
523
+ | "arg-sep"
471
524
  | "value"
472
525
  | "string"
526
+ /**
527
+ * One valid string-internal escape sequence — the `\` and the bytes it
528
+ * escapes (#264).
529
+ *
530
+ * The one split B5's rule admits that naming the `=` did not make. A valid
531
+ * escape is recoverable from no other published surface: only INVALID
532
+ * escapes appear elsewhere, as `bad-string-escape` diagnostics. Both come
533
+ * from the same walk (`walkStringEscapes`), so a token and a diagnostic
534
+ * cannot disagree about which bytes are an escape, and past the first
535
+ * invalid escape the walk stops claiming rather than guess.
536
+ */
537
+ | "escaped"
473
538
  | "brace"
474
539
  | "unclassified";
475
540
 
476
541
  export interface ExplainToken extends ExplainSpanRange {
477
542
  /**
478
- * Provisional until #264 B5 — do not treat as the final vocabulary.
543
+ * The vocabulary #264 B5 settled — see {@link ExplainTokenClass} for what
544
+ * earns a class and why the remaining merges are deliberate.
479
545
  * `unclassified` means no analyzer claimed this byte.
480
546
  */
481
547
  class: ExplainTokenClass;
@@ -626,8 +692,8 @@ export interface ExplainData {
626
692
  * `--schema` / `--curl` facet pattern. Every byte belongs to exactly one
627
693
  * token, sorted by `start`, with no gaps and no overlaps:
628
694
  * `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`.
695
+ * `class` is the vocabulary #264 B5 settled ({@link ExplainTokenClass});
696
+ * every byte no analyzer claims is `unclassified`.
631
697
  */
632
698
  tokens?: ExplainToken[];
633
699
  diagnostics: ExplainDiagnostic[];
@@ -678,6 +744,8 @@ const EV = {
678
744
  paths: "e12",
679
745
  strings: "e13",
680
746
  braces: "e14",
747
+ separators: "e15",
748
+ escapes: "e16",
681
749
  } as const;
682
750
 
683
751
  type EvidenceKey = keyof typeof EV;
@@ -792,6 +860,26 @@ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
792
860
  basis: "heuristic",
793
861
  outcome: "ok",
794
862
  },
863
+ /**
864
+ * `heuristic`, and #252 is the reason rather than caution: the accepted
865
+ * escape set is a ratified offline rule that a live probe CAN overturn, and
866
+ * once did. The manual's "Constant Escape Sequences" table turned out to be
867
+ * a lower bound — the CHR byte sweep found `\?` missing from it — so the
868
+ * set pinned in `src/explain/quoted-string.ts` is the best current reading
869
+ * of a device's answer, not a deterministic reading of the input bytes.
870
+ * A future RouterOS could accept one more.
871
+ *
872
+ * Cited by the `escaped` tokens AND by `bad-string-escape`: one walk, one
873
+ * evidence entry. That defect used to cite `resolveSymbols`, which never
874
+ * produced it — `symbols.ts` raises the different `bad-escape` code.
875
+ */
876
+ escapes: {
877
+ id: EV.escapes,
878
+ source: "canonicalizer",
879
+ probe: "walkStringEscapes",
880
+ basis: "heuristic",
881
+ outcome: "ok",
882
+ },
795
883
  braces: {
796
884
  id: EV.braces,
797
885
  source: "canonicalizer",
@@ -799,6 +887,19 @@ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
799
887
  basis: "heuristic",
800
888
  outcome: "ok",
801
889
  },
890
+ /**
891
+ * `heuristic`: the run is decided from the frozen verb vocabulary and the
892
+ * shipped structure table, both of which a live probe could overturn on a
893
+ * menu the table does not list. The rejection it reports is device-grounded
894
+ * (`src/explain/separator.ts`); which byte runs are that shape is not.
895
+ */
896
+ separators: {
897
+ id: EV.separators,
898
+ source: "canonicalizer",
899
+ probe: "findMissingSeparators",
900
+ basis: "heuristic",
901
+ outcome: "ok",
902
+ },
802
903
  };
803
904
 
804
905
  /** Diagnostic rendering for each defect class. */
@@ -837,6 +938,14 @@ const DEFECT_DIAGNOSTICS: Record<
837
938
  message: () =>
838
939
  "unquoted `#` is a syntax error here — quote it where a value is accepted, or move it to statement-leading position to start a comment",
839
940
  },
941
+ // #355. Not a quote-balance rule: `name='abc'` and `:put 'abc'` are rejected
942
+ // too. `'` simply is not a RouterOS token, so the remedy is the double quote,
943
+ // never a matching apostrophe.
944
+ "invalid-apostrophe": {
945
+ severity: "error",
946
+ message: () =>
947
+ '`\'` is not a RouterOS token — only `"` opens a string; use `"` here, or put the apostrophe inside a `"`-string',
948
+ },
840
949
  // centrs's own resource bound, not a RouterOS rule. The input may be entirely
841
950
  // legal; what is reported is that the analyzer stopped descending, so the
842
951
  // honest severity is a warning about incomplete analysis.
@@ -857,6 +966,38 @@ const DEFECT_DIAGNOSTICS: Record<
857
966
  },
858
967
  };
859
968
 
969
+ /**
970
+ * Keep only the EARLIEST `invalid-apostrophe`, document-wide (#355).
971
+ *
972
+ * The rule suppresses repeats within one scan, but a nested scope body is a
973
+ * SEPARATE scan — `segmentScopeBody` is called per block and its defects are
974
+ * rebased in — so `:put 'a'; :if (true) do={ :put 'b' }` produced one from each.
975
+ * The device reports one error and stops, so a second occurrence is not an
976
+ * independent finding no matter which scope it sits in, and the contract is
977
+ * stated document-wide.
978
+ *
979
+ * Collapsing HERE rather than threading a flag through the walkers is the point:
980
+ * `segmentStatements` and `segmentScopeBody` stay pure per call, which is what
981
+ * lets a body be segmented on its own. The other classes are deliberately
982
+ * untouched — two `unclosed` delimiters ARE two findings.
983
+ */
984
+ function collapseToFirstApostrophe(
985
+ defects: readonly AttributedDefect[],
986
+ ): AttributedDefect[] {
987
+ let seen = false;
988
+ const out: AttributedDefect[] = [];
989
+ for (const entry of [...defects].sort(
990
+ (a, b) => a.defect.start - b.defect.start,
991
+ )) {
992
+ if (entry.defect.code === "invalid-apostrophe") {
993
+ if (seen) continue;
994
+ seen = true;
995
+ }
996
+ out.push(entry);
997
+ }
998
+ return out;
999
+ }
1000
+
860
1001
  /** A defect plus the analysis pass that raised it. */
861
1002
  interface AttributedDefect {
862
1003
  defect: Defect;
@@ -910,6 +1051,17 @@ function attributeDefects(
910
1051
 
911
1052
  /** Build `input.positionMap`: coalesced identity runs, one entry per non-ASCII code point. */
912
1053
  function positionMap(a: CoordinateAnalysis): ExplainPositionMapEntry[] {
1054
+ // An all-ASCII input coalesces to the single identity run the loop below
1055
+ // would build, so say it rather than walk one object per character (#322).
1056
+ if (a.ascii)
1057
+ return a.original.length === 0
1058
+ ? []
1059
+ : [
1060
+ {
1061
+ analyzed: { start: 0, end: a.original.length },
1062
+ originalUtf16: { start: 0, end: a.original.length },
1063
+ },
1064
+ ];
913
1065
  const out: ExplainPositionMapEntry[] = [];
914
1066
  let run: ExplainPositionMapEntry | null = null;
915
1067
  for (const r of a.runs) {
@@ -972,8 +1124,8 @@ export function residualRanges(
972
1124
  *
973
1125
  * Every byte of `[0, bytes)` belongs to exactly one token: `spans` are
974
1126
  * 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.
1127
+ * `start` with no overlaps and `join(slice) === input`. See
1128
+ * {@link ExplainTokenClass} for what `class` may be and why.
977
1129
  *
978
1130
  * B2: `buildTokens` now takes an **ordered list of fills**. Fill 0 claims
979
1131
  * first, fill 1 sees only the residual of fill 0, and so on — the argument
@@ -1092,8 +1244,8 @@ export interface ExplainCommandOptions {
1092
1244
  * Emit the total, gapless token partition behind `data.tokens[]`.
1093
1245
  *
1094
1246
  * Mirrors `--complete` / `--schema` / `--curl`: an opt-in facet whose
1095
- * presence changes the result. The `class` field is provisional until
1096
- * #264 B5.
1247
+ * presence changes the result. See {@link ExplainTokenClass} for the
1248
+ * vocabulary `class` draws on (#264 B5).
1097
1249
  */
1098
1250
  tokens?: boolean;
1099
1251
  }
@@ -1113,14 +1265,47 @@ export function explainCommand(
1113
1265
  // one byte standing in for every non-ASCII one, so an index into it IS a
1114
1266
  // document byte offset. Decoding it once here keeps `statementOf` and the
1115
1267
  // string-escape walk from re-deriving the same string.
1116
- const analyzed = new TextDecoder().decode(coordinates.analyzed);
1268
+ const analyzed = coordinates.ascii
1269
+ ? input
1270
+ : new TextDecoder().decode(coordinates.analyzed);
1271
+ // The whole document as a masked range, in `analyzed` coordinates, derived
1272
+ // ONCE.
1273
+ //
1274
+ // Four passes below read the same statement — the strict argument reading,
1275
+ // the tolerant token stream, the brace diagnostic and the value anchors —
1276
+ // and each masked that statement's text for itself. A statement's text
1277
+ // carries its whole nested `do={…}` subtree, so on a 60 KiB document nested
1278
+ // 64 deep those four passes masked 15.8 MB between them. A statement's mask
1279
+ // is this mask restricted to its span (see `segment.ts` → `MaskedRange`), and
1280
+ // `analyzed` is ASCII by construction, so the offsets need no coordinate test.
1281
+ const analyzedRange = documentRange(analyzed, coordinates.ascii);
1117
1282
  const segmented = segmentStatements(input);
1118
1283
  const statementAnalysis = resolveStatements(input);
1119
- const verbs = resolveVerbsFromStatements(statementAnalysis);
1284
+ const sourceRange = coordinates.ascii ? analyzedRange : undefined;
1285
+ const verbs = resolveVerbsFromStatements(statementAnalysis, sourceRange);
1120
1286
  const brackets = resolveDocument(input);
1121
- const write = containsWriteFromAnalyses(statementAnalysis, brackets);
1287
+ const write = containsWriteFromAnalyses(
1288
+ statementAnalysis,
1289
+ brackets,
1290
+ sourceRange,
1291
+ );
1122
1292
  const symbols = resolveSymbols(input);
1123
1293
 
1294
+ // ONE escape walk, read twice (#264): its invalid half is a
1295
+ // `bad-string-escape` diagnostic and its valid half is the `escaped` token
1296
+ // fill further down. Walking separately for each would let the diagnostics
1297
+ // and the token stream disagree about which bytes are an escape.
1298
+ //
1299
+ // The third argument only decides whether the valid spans are RETAINED, not
1300
+ // whether they are validated, so gating it on the token facet cannot change
1301
+ // a diagnostic — it just stops a caller who never asked for `data.tokens`
1302
+ // from paying for a span object per escape.
1303
+ const stringEscapes = walkStringEscapes(
1304
+ analyzed,
1305
+ segmented.comments,
1306
+ options.tokens === true,
1307
+ );
1308
+
1124
1309
  // Every analyzer re-derives the document's defects from its own walk, so a
1125
1310
  // defect two analyzers both see must be reported once — and tagged with the
1126
1311
  // analyzer that RAISED it, not with whichever list was merged first. The
@@ -1130,14 +1315,22 @@ export function explainCommand(
1130
1315
  // the segmenter's delimiter stack is a `direct` reading). Attributing all of
1131
1316
  // them to the segmenter said "direct/segmentStatements" about a fact the
1132
1317
  // 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
- ]);
1318
+ //
1319
+ // `bad-string-escape` is the same mistake one module over, and #264 is what
1320
+ // made it fixable: it cited `resolveSymbols`, which never produced it —
1321
+ // `symbols.ts` raises the DIFFERENT `bad-escape` code. Until the escape walk
1322
+ // had an evidence entry of its own there was nothing truer to cite; now
1323
+ // there is, and the token and the diagnostic cite the same one.
1324
+ const defects = collapseToFirstApostrophe(
1325
+ attributeDefects([
1326
+ [EV.segment, segmented.defects],
1327
+ [EV.statements, verbs.defects],
1328
+ [EV.subcommands, brackets.defects],
1329
+ [EV.write, write.defects],
1330
+ [EV.symbols, symbols.defects],
1331
+ [EV.escapes, stringEscapes.defects],
1332
+ ]),
1333
+ );
1141
1334
 
1142
1335
  // From the SPLITS, not from the segmentation. The resolver flattens `do={…}`
1143
1336
  // bodies in after their parent, so its list is longer than the top-level
@@ -1145,17 +1338,40 @@ export function explainCommand(
1145
1338
  // the first block. Each split carries its own document-space span.
1146
1339
  //
1147
1340
  const readStatements: ExplainStatement[] = verbs.splits.map((split) =>
1148
- statementOf(split, analyzed),
1341
+ statementOf(split, analyzed, analyzedRange),
1149
1342
  );
1150
1343
  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
- ),
1344
+ // The skip-tolerant token stream every TOKEN-LEVEL rule reads (#316), one
1345
+ // list per split. It is not `statement.arguments`: that reading stays
1346
+ // all-or-nothing because its consumer renders a runnable request, and a token
1347
+ // carrying `undecided` has no value to render. These two never disagree about
1348
+ // where a token starts or ends — `args.ts` owns the one boundary walk.
1349
+ const argumentTokens = readStatements.map((statement, index) => {
1350
+ const split = verbs.splits[index];
1351
+ return split === undefined || statement.kind !== "command"
1352
+ ? []
1353
+ : argumentTokensOf(split, analyzed, analyzedRange);
1354
+ });
1355
+ // #311: the second command-shaped run is found on the statement's OWN
1356
+ // argument tokens, then the reading is degraded — in that order, because the
1357
+ // tokens are the evidence for the diagnostic and the refusal both. Transport
1358
+ // runs after, so it classifies the degraded reading rather than a list the
1359
+ // device never accepted.
1360
+ const separators = readStatements.map((statement, index) =>
1361
+ statement.resolution === "resolved"
1362
+ ? findMissingSeparators(argumentTokens[index] ?? [])
1363
+ : [],
1158
1364
  );
1365
+ const gated = enforceGateParity(readStatements, canonical);
1366
+ const statements = gated.map((statement, index) => {
1367
+ const runs = separators[index] ?? [];
1368
+ return withStatementTransport(
1369
+ withoutSeparatedArguments(statement, runs),
1370
+ verbs.splits[index]?.text ?? "",
1371
+ options,
1372
+ runs.length === 0 ? undefined : rejectedBySeparator(runs),
1373
+ );
1374
+ });
1159
1375
 
1160
1376
  const diagnostics: ExplainDiagnostic[] = [
1161
1377
  ...defects.map(({ defect: d, ev }) => {
@@ -1168,7 +1384,8 @@ export function explainCommand(
1168
1384
  ev,
1169
1385
  };
1170
1386
  }),
1171
- ...invalidCommandBraceDiagnostics(verbs.splits, analyzed),
1387
+ ...invalidCommandBraceDiagnostics(verbs.splits, analyzed, analyzedRange),
1388
+ ...separators.flatMap((runs) => runs.map(missingSeparatorDiagnostic)),
1172
1389
  ...statements.flatMap((s) => diagnosticsForStatement(s)),
1173
1390
  ].sort(
1174
1391
  (a, b) =>
@@ -1176,7 +1393,12 @@ export function explainCommand(
1176
1393
  severityRank(b.severity) - severityRank(a.severity),
1177
1394
  );
1178
1395
 
1179
- const valueFacts = valuesOf(verbs.splits, analyzed);
1396
+ const valueFacts = valuesOf(
1397
+ verbs.splits,
1398
+ analyzed,
1399
+ analyzedRange,
1400
+ separators,
1401
+ );
1180
1402
  const spans: ExplainSpan[] = [
1181
1403
  ...segmented.comments.map((c) => ({
1182
1404
  start: c.start,
@@ -1219,8 +1441,8 @@ export function explainCommand(
1219
1441
 
1220
1442
  // B2/B3 fill order — the argument order IS the order (#290 design decision 1).
1221
1443
  // `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
1444
+ // escape, value, then string/brace, then operator fills each see only the
1445
+ // residual left before them. Structural ambiguity (`/` path vs division, `=` arg sep
1224
1446
  // vs comparison) therefore resolves by the analyzer that already proved
1225
1447
  // ownership, not by a wider byte scanner. The operator scanner keeps its
1226
1448
  // conservative guards for bytes no earlier fill can decide.
@@ -1243,11 +1465,17 @@ export function explainCommand(
1243
1465
  return [
1244
1466
  {
1245
1467
  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),
1468
+ // `innerSpan`, never `span ± 1`: `span` covers `[…]` for a bare
1469
+ // substitution but `$[…]` for an interpolated one, so the
1470
+ // arithmetic was one byte early inside a string and `pathSpans`
1471
+ // dropped every such candidate on its text check — leaving a
1472
+ // resolved command to the string fill (#325).
1473
+ span: resolution.innerSpan,
1474
+ split: resolveVerb(
1475
+ resolution.inner,
1476
+ resolution.context,
1477
+ rangeForResolution(resolution) ?? documentRange(resolution.inner),
1478
+ ),
1251
1479
  ev: EV.paths,
1252
1480
  },
1253
1481
  ];
@@ -1258,8 +1486,19 @@ export function explainCommand(
1258
1486
  ...spans,
1259
1487
  ...pathSpansList,
1260
1488
  ]);
1261
- const argCandidates = statements.flatMap((s) =>
1262
- s.arguments?.read === true ? s.arguments.tokens : [],
1489
+ // Every WITHDRAWN reading contributes nothing, exactly as before #316: a
1490
+ // second command-shaped run (`withoutSeparatedArguments`) and a gate-parity
1491
+ // disagreement (`enforceGateParity`) both mean centrs cannot say which
1492
+ // bytes are this command's arguments, and painting `name=` runs inside one
1493
+ // would be that claim in lexical clothing. Everything else now paints from
1494
+ // the tolerant stream, so `gateway=$g` gets its name and `=` claimed where
1495
+ // the strict reading left the whole statement `unclassified`.
1496
+ const argCandidates = gated.flatMap((statement, index) =>
1497
+ (separators[index]?.length ?? 0) > 0 ||
1498
+ (readStatements[index]?.arguments?.read === true &&
1499
+ statement.arguments?.read !== true)
1500
+ ? []
1501
+ : (argumentTokens[index] ?? []),
1263
1502
  );
1264
1503
  const argSpansList = argSpans(analyzed, residual1, argCandidates);
1265
1504
  const residual2 = residualRanges(analyzed.length, [
@@ -1267,39 +1506,58 @@ export function explainCommand(
1267
1506
  ...pathSpansList,
1268
1507
  ...argSpansList,
1269
1508
  ]);
1270
- const valueSpansList = valueSpans(
1509
+ // An escape is interior to a quoted run, so it claims BEFORE the two
1510
+ // fills that would otherwise swallow it whole (#264) — it takes the
1511
+ // residual `value` used to see, and `value` and `string` then fragment
1512
+ // around what it claimed.
1513
+ const escapeSpansList = escapeSpans(
1271
1514
  analyzed,
1272
1515
  residual2,
1273
- valueFacts.occurrences,
1516
+ stringEscapes.escapes,
1274
1517
  );
1275
1518
  const residual3 = residualRanges(analyzed.length, [
1276
1519
  ...spans,
1277
1520
  ...pathSpansList,
1278
1521
  ...argSpansList,
1279
- ...valueSpansList,
1522
+ ...escapeSpansList,
1280
1523
  ]);
1281
- const stringSpansList = stringSpans(analyzed, residual3);
1524
+ const valueSpansList = valueSpans(
1525
+ analyzed,
1526
+ residual3,
1527
+ valueFacts.occurrences,
1528
+ );
1282
1529
  const residual4 = residualRanges(analyzed.length, [
1283
1530
  ...spans,
1284
1531
  ...pathSpansList,
1285
1532
  ...argSpansList,
1533
+ ...escapeSpansList,
1286
1534
  ...valueSpansList,
1287
- ...stringSpansList,
1288
1535
  ]);
1289
- const braceSpansList = braceSpans(analyzed, residual4);
1536
+ const stringSpansList = stringSpans(analyzed, residual4);
1290
1537
  const residual5 = residualRanges(analyzed.length, [
1291
1538
  ...spans,
1292
1539
  ...pathSpansList,
1293
1540
  ...argSpansList,
1541
+ ...escapeSpansList,
1542
+ ...valueSpansList,
1543
+ ...stringSpansList,
1544
+ ]);
1545
+ const braceSpansList = braceSpans(analyzed, residual5);
1546
+ const residual6 = residualRanges(analyzed.length, [
1547
+ ...spans,
1548
+ ...pathSpansList,
1549
+ ...argSpansList,
1550
+ ...escapeSpansList,
1294
1551
  ...valueSpansList,
1295
1552
  ...stringSpansList,
1296
1553
  ...braceSpansList,
1297
1554
  ]);
1298
- const opSpans = operatorSpans(analyzed, residual5);
1555
+ const opSpans = operatorSpans(analyzed, residual6);
1299
1556
  const fills: TokenFill[] = [
1300
1557
  spans,
1301
1558
  pathSpansList,
1302
1559
  argSpansList,
1560
+ escapeSpansList,
1303
1561
  valueSpansList,
1304
1562
  stringSpansList,
1305
1563
  braceSpansList,
@@ -1311,7 +1569,7 @@ export function explainCommand(
1311
1569
  return {
1312
1570
  input: {
1313
1571
  bytes: coordinates.analyzed.length,
1314
- normalized: coordinates.runs.some((r) => !r.ascii),
1572
+ normalized: !coordinates.ascii,
1315
1573
  truncated: false,
1316
1574
  positionMap: positionMap(coordinates),
1317
1575
  },
@@ -1347,6 +1605,7 @@ function withStatementTransport(
1347
1605
  statement: ExplainStatement,
1348
1606
  source: string,
1349
1607
  options: ExplainCommandOptions,
1608
+ rejected?: string,
1350
1609
  ): ExplainStatement {
1351
1610
  if (statement.kind !== "command" || statement.arguments === undefined)
1352
1611
  return statement;
@@ -1357,6 +1616,7 @@ function withStatementTransport(
1357
1616
  command: statement.command,
1358
1617
  arguments: statement.arguments,
1359
1618
  source,
1619
+ ...(rejected === undefined ? {} : { rejected }),
1360
1620
  },
1361
1621
  { renderCurl: options.curl, evidenceId: EV.transport },
1362
1622
  ),
@@ -1381,12 +1641,13 @@ function withStatementTransport(
1381
1641
  function statementOf(
1382
1642
  split: DocumentVerbSplit,
1383
1643
  analyzed: string,
1644
+ analyzedRange: MaskedRange,
1384
1645
  ): ExplainStatement {
1385
1646
  const reading = readingOf(split);
1386
1647
  return {
1387
1648
  span: { start: split.span.start, end: split.span.end },
1388
1649
  ...(reading.kind === "command"
1389
- ? withArguments(reading, split, analyzed)
1650
+ ? withArguments(reading, split, analyzed, analyzedRange)
1390
1651
  : reading),
1391
1652
  contextCertain: split.contextCertain,
1392
1653
  ev: EV.statements,
@@ -1410,8 +1671,9 @@ function withArguments(
1410
1671
  reading: ExplainCommandReading,
1411
1672
  split: DocumentVerbSplit,
1412
1673
  analyzed: string,
1674
+ analyzedRange: MaskedRange,
1413
1675
  ): ExplainCommandReading {
1414
- const read = argumentsOf(split, analyzed);
1676
+ const read = argumentsOf(split, analyzed, analyzedRange);
1415
1677
  return {
1416
1678
  ...reading,
1417
1679
  command:
@@ -1436,6 +1698,7 @@ function withArguments(
1436
1698
  function argumentsOf(
1437
1699
  split: DocumentVerbSplit,
1438
1700
  analyzed: string,
1701
+ analyzedRange: MaskedRange,
1439
1702
  ): { arguments: ExplainArguments; args?: Record<string, string> } {
1440
1703
  if (split.argsAt === null)
1441
1704
  return {
@@ -1453,7 +1716,11 @@ function argumentsOf(
1453
1716
  why: "this statement's bytes are not addressable: its text was normalized (non-ASCII), or its span was widened to the enclosing statement",
1454
1717
  },
1455
1718
  };
1456
- const lexed = lexArguments(text, split.argsAt);
1719
+ const lexed = lexArguments(
1720
+ text,
1721
+ split.argsAt,
1722
+ nestedRange(analyzedRange, start, end),
1723
+ );
1457
1724
  if (!lexed.read) return { arguments: lexed };
1458
1725
  return {
1459
1726
  args: lexed.args,
@@ -1477,9 +1744,113 @@ function argumentsOf(
1477
1744
  };
1478
1745
  }
1479
1746
 
1747
+ /**
1748
+ * The statement's skip-tolerant argument token stream, rebased into document
1749
+ * space (#316).
1750
+ *
1751
+ * Same two guards as {@link argumentsOf}, for the same reason: `argsAt` indexes
1752
+ * the statement's own text, so offsets are only rebased when the analyzed
1753
+ * slice really is that text. What differs is only the reading — the tokens come
1754
+ * back with the undecodable ones located rather than the whole list discarded.
1755
+ * A statement that is not addressable, or has no verb, yields no tokens at all, which is the
1756
+ * same silence the strict reading gives it.
1757
+ */
1758
+ function argumentTokensOf(
1759
+ split: DocumentVerbSplit,
1760
+ analyzed: string,
1761
+ analyzedRange: MaskedRange,
1762
+ ): ExplainArgument[] {
1763
+ if (split.argsAt === null) return [];
1764
+ const { start, end } = split.span;
1765
+ const text = analyzed.slice(start, end);
1766
+ if (text !== split.text) return [];
1767
+ return lexArgumentTokens(
1768
+ text,
1769
+ split.argsAt,
1770
+ nestedRange(analyzedRange, start, end),
1771
+ ).tokens.map((token) => ({
1772
+ ...token,
1773
+ span: { start: start + token.span.start, end: start + token.span.end },
1774
+ ...(token.valueSpan === undefined
1775
+ ? {}
1776
+ : {
1777
+ valueSpan: {
1778
+ start: start + token.valueSpan.start,
1779
+ end: start + token.valueSpan.end,
1780
+ },
1781
+ }),
1782
+ }));
1783
+ }
1784
+
1785
+ /**
1786
+ * A second command-shaped run, said out loud (#311).
1787
+ *
1788
+ * `error`, not `warning`: the abstention vocabulary is for input with several
1789
+ * valid schema-free readings, and this one has none — RouterOS answers every
1790
+ * probed spelling of this shape with `bad parameter <token>` or
1791
+ * `expected end of command` (`src/explain/separator.ts`). What the message may
1792
+ * claim is bounded by the same head-dependence: it says the bytes READ as a
1793
+ * second command, which is centrs's own heuristic reading, and that RouterOS
1794
+ * rejects the statement, which is the grounded part. It does not say which byte
1795
+ * the device will name, because that is per-menu schema — on a head whose value
1796
+ * slot takes a bare positional the path is absorbed into it and the VERB is
1797
+ * refused instead (`:put /ip/route print` → `/putmessage=/ip/route` plus
1798
+ * `bad parameter print`, CHR 7.24.2). The span is the whole second run so an
1799
+ * editor can highlight what has to move; `insertAt` is where the separator
1800
+ * goes, which is that span's start.
1801
+ */
1802
+ function missingSeparatorDiagnostic(run: MissingSeparator): ExplainDiagnostic {
1803
+ return {
1804
+ code: "explain/canonicalizer/missing-statement-separator",
1805
+ severity: "error",
1806
+ message: `missing statement separator: \`${run.path}\` is a menu path and \`${run.verb}\` a command verb, so this reads as a second command with no separator before it, and RouterOS rejects the statement (\`bad parameter\` or \`expected end of command\`, on a token that depends on the head's own argument names) — insert \`;\` or a newline before \`${run.path}\``,
1807
+ span: { start: run.span.start, end: run.span.end },
1808
+ ev: EV.separators,
1809
+ };
1810
+ }
1811
+
1812
+ /**
1813
+ * Withdraw an argument reading the second run contradicts (#311).
1814
+ *
1815
+ * Same move `enforceGateParity` makes, for the same reason: the argument list is
1816
+ * all-or-nothing because a partially-read one changes what the rendered command
1817
+ * means. Reporting `gateway=` as an attribute of `/ip/address add` is the false
1818
+ * confident reading #311 names, and the device says the withdrawal is right
1819
+ * rather than merely cautious — `/ip/address add interface=ether1 /ip/route add
1820
+ * placeholder` lowers to `address=placeholder;;interface=ether1` on CHR 7.24.2, a phantom
1821
+ * attribute the source never wrote, because the second run's words can
1822
+ * abbreviate the HEAD's argument names. Which head arguments exist is schema,
1823
+ * so offline cannot predict when that happens and must not publish either
1824
+ * reading.
1825
+ *
1826
+ * The statement itself stays `resolved`: `/ip/address add` really is the head,
1827
+ * the device highlight agrees, and the source is one invalid statement rather
1828
+ * than two fabricated valid ones.
1829
+ */
1830
+ function withoutSeparatedArguments(
1831
+ statement: ExplainStatement,
1832
+ runs: readonly MissingSeparator[],
1833
+ ): ExplainStatement {
1834
+ if (runs.length === 0 || statement.kind !== "command") return statement;
1835
+ if (statement.arguments?.read !== true) return statement;
1836
+ const { args: _dropped, ...command } = statement.command;
1837
+ return {
1838
+ ...statement,
1839
+ command,
1840
+ arguments: { read: false, why: rejectedBySeparator(runs) },
1841
+ };
1842
+ }
1843
+
1844
+ /** The one sentence both the withdrawn argument list and transport refuse on. */
1845
+ function rejectedBySeparator(runs: readonly MissingSeparator[]): string {
1846
+ const first = runs[0];
1847
+ return `a second command-shaped run (\`${first?.path} ${first?.verb}\`) sits in this operand stream with no \`;\` or newline before it, so this is not one accepted statement`;
1848
+ }
1849
+
1480
1850
  function invalidCommandBraceDiagnostics(
1481
1851
  splits: readonly DocumentVerbSplit[],
1482
1852
  analyzed: string,
1853
+ analyzedRange: MaskedRange,
1483
1854
  ): ExplainDiagnostic[] {
1484
1855
  const out: ExplainDiagnostic[] = [];
1485
1856
  for (const split of splits) {
@@ -1504,18 +1875,46 @@ function invalidCommandBraceDiagnostics(
1504
1875
  )
1505
1876
  ) {
1506
1877
  const braceAt = split.argsAt + leadingBraceAt;
1507
- const structural = maskComments(text);
1878
+ const structural = rangeMask(nestedRange(analyzedRange, start, end));
1508
1879
  const trimmed = structural.slice(braceAt + 1).trimStart();
1509
1880
  if (trimmed.length === 0 || trimmed[0] === "}") {
1510
1881
  menuBraceAt = braceAt;
1882
+ } else if (trimmed[0] === ":" || trimmed[0] === "/") {
1883
+ // A menu block body is a statement list, and a statement may be a
1884
+ // scripting directive or an ABSOLUTE command — neither of which the
1885
+ // enclosing menu can vouch for, and neither of which any array literal
1886
+ // can start with. `/interface { ethernet { :put x } }` is corpus 521,
1887
+ // accepted on 7.21.5/7.23.5/7.24.2/7.25beta3 as
1888
+ // `(evl /interface/ethernet/ (evl /putmessage=…))` — #348.
1889
+ menuBraceAt = braceAt;
1511
1890
  } else {
1512
- const match = /^\/?([A-Za-z][A-Za-z0-9._-]*)/.exec(trimmed);
1891
+ const match = /^([A-Za-z][A-Za-z0-9._-]*)/.exec(trimmed);
1513
1892
  if (match) {
1514
1893
  const firstWord = match[1] as string;
1894
+ // A menu block's body may open with a SUBMENU (`/ip { firewall …`)
1895
+ // or with a COMMAND of the menu itself (`/ip { address { print } }`,
1896
+ // whose inner brace is a body over `/ip/address`) — #348. Requiring
1897
+ // a submenu rejected the second shape, and the device accepts it:
1898
+ // `:parse` lowers it to `(evl (evl (evl /ip/address/print)))` on
1899
+ // 7.21.5 and 7.24.2, and `/interface { ethernet { print } }` the same
1900
+ // way, while `/ip { zzznotamenu { print } }` is rejected there.
1901
+ //
1902
+ // The command half asks the FROZEN verb vocabulary and the catalog,
1903
+ // never a per-menu schema: `VERBS` is the thirteen CRUD verbs that
1904
+ // exist at essentially every configuration menu (`verbs.ts`), and
1905
+ // `lookupPath` names the menu-specific commands. The catalog carries
1906
+ // no `/ip/address/print` row and never will — that is decision 3, not
1907
+ // a gap to fill. An array literal opens with a digit or a quote and
1908
+ // never reaches this identifier branch at all, so `{1;2}` is still
1909
+ // diagnosed.
1515
1910
  const isMenuContainer = split.candidates.some((candidate) => {
1516
1911
  const segments = candidate.split("/").filter(Boolean);
1517
1912
  if (!isKnownMenuPath(segments)) return false;
1518
- return isKnownMenuPath([...segments, firstWord]);
1913
+ return (
1914
+ isKnownMenuPath([...segments, firstWord]) ||
1915
+ VERBS.has(firstWord) ||
1916
+ lookupPath([...segments, firstWord])?.kind === "command"
1917
+ );
1519
1918
  });
1520
1919
  if (isMenuContainer) menuBraceAt = braceAt;
1521
1920
  }
@@ -1525,6 +1924,7 @@ function invalidCommandBraceDiagnostics(
1525
1924
  text,
1526
1925
  split.argsAt,
1527
1926
  split.path === "/" ? split.verb : undefined,
1927
+ nestedRange(analyzedRange, start, end),
1528
1928
  )) {
1529
1929
  if (at === menuBraceAt) continue;
1530
1930
  out.push({
@@ -1540,18 +1940,40 @@ function invalidCommandBraceDiagnostics(
1540
1940
  return out;
1541
1941
  }
1542
1942
 
1543
- /** Compose safely located literals into the three-axis #225 value surface. */
1943
+ /**
1944
+ * Compose safely located literals into the three-axis #225 value surface.
1945
+ *
1946
+ * `separators` is indexed by split, and its first run is a CUT (#311): from that
1947
+ * byte on, the operand stream belongs to a second command the device never let
1948
+ * start, so no value there can be attributed. On the two probed rows the device
1949
+ * itself draws the line in the same place — `/ip/address add interface=ether1
1950
+ * /ip/route add gateway=…` is read with `gateway` as the VALUE of `address=`
1951
+ * (never as an attribute name), while the head's own `interface=ether1` survives
1952
+ * into the IL, and `/system/note set note=hello /ip/route add …` likewise keeps
1953
+ * `note=hello`. So the cut withdraws what the run contradicts and keeps what it
1954
+ * does not.
1955
+ *
1956
+ * Not "every value in a degraded statement": `values` is deliberately an axis of
1957
+ * its own rather than a projection of `arguments`, and the value census pins a
1958
+ * large retained population in statements whose strict REST reading abstains
1959
+ * (`commands/explain/README.md`, generated block). Widening this to the whole
1960
+ * statement would contradict that measured contract.
1961
+ */
1544
1962
  function valuesOf(
1545
1963
  splits: readonly DocumentVerbSplit[],
1546
1964
  analyzed: string,
1965
+ analyzedRange: MaskedRange,
1966
+ separators: readonly (readonly MissingSeparator[])[],
1547
1967
  ): ExplainValues {
1548
1968
  const occurrences: ExplainValueOccurrence[] = [];
1549
- for (const split of splits) {
1969
+ for (const [splitIndex, split] of splits.entries()) {
1550
1970
  if (split.resolution !== "resolved" || split.argsAt === null) continue;
1971
+ const cut = separators[splitIndex]?.[0]?.insertAt;
1551
1972
  const { start, end } = split.span;
1552
1973
  const text = analyzed.slice(start, end);
1553
1974
  if (text !== split.text) continue;
1554
1975
  const anchored = lexValueAnchors(text, split.argsAt, {
1976
+ range: nestedRange(analyzedRange, start, end),
1555
1977
  // A `{…}` array literal is legal only in a ROOT scripting directive's
1556
1978
  // value slot; `/ip/dns/set servers={…}` and `:log info message={…}` are
1557
1979
  // both device syntax errors at the `{`. `path` is how that position is
@@ -1563,6 +1985,11 @@ function valuesOf(
1563
1985
  // advisory and never turn the later refusal reason into a diagnostic.
1564
1986
  const ids = new Map<number, string>();
1565
1987
  for (const [index, anchor] of anchored.anchors.entries()) {
1988
+ // A token that reaches into the second run is not this statement's to
1989
+ // name. Token spans never straddle the cut — the run's path is its own
1990
+ // positional token, so a `{…}` or quoted value either ends before it or
1991
+ // starts after it — which is why a dropped array cannot orphan a member.
1992
+ if (cut !== undefined && start + anchor.tokenSpan.end > cut) continue;
1566
1993
  const hints: ValueShape[] =
1567
1994
  // A source-proved shape wins outright: the delimiters prove `array`,
1568
1995
  // and an `=` that binds no key proves `str` or `bool` (#258). None is
@@ -1668,7 +2095,13 @@ function subcommandOf(r: Resolution): ExplainSubcommand {
1668
2095
  resolution: "unknown" as const,
1669
2096
  unresolved: r.unresolved ?? "no reading of this substitution",
1670
2097
  }
1671
- : readingOf(resolveVerb(r.inner, r.context));
2098
+ : readingOf(
2099
+ resolveVerb(
2100
+ r.inner,
2101
+ r.context,
2102
+ rangeForResolution(r) ?? documentRange(r.inner),
2103
+ ),
2104
+ );
1672
2105
  return {
1673
2106
  span: { start: r.span.start, end: r.span.end },
1674
2107
  context: r.context,
@@ -1867,8 +2300,8 @@ export interface ExplainEnvelopeOptions {
1867
2300
  /**
1868
2301
  * Include the gapless token partition behind `data.tokens[]`.
1869
2302
  *
1870
- * Mirrors `--complete` / `--schema` / `--curl` facet pattern. Provisional
1871
- * vocabulary until #264 B5.
2303
+ * Mirrors `--complete` / `--schema` / `--curl` facet pattern. The vocabulary
2304
+ * is `ExplainTokenClass` (#264 B5).
1872
2305
  */
1873
2306
  tokens?: boolean;
1874
2307
  tips?: readonly Tip[];
@@ -1876,6 +2309,23 @@ export interface ExplainEnvelopeOptions {
1876
2309
  warnings?: readonly Warning[];
1877
2310
  }
1878
2311
 
2312
+ /**
2313
+ * The host process environment, or an empty one where there is no host.
2314
+ *
2315
+ * A bare `Bun.env` default parameter is what a browser consumer of this module
2316
+ * trips over (#312): the reference is evaluated on the first settings-ladder
2317
+ * call, and `Bun` does not exist there. `typeof` is safe on an undeclared
2318
+ * identifier, so the guard costs nothing and changes nothing under Bun — the
2319
+ * ladder still reads the real environment, and a browser caller that never
2320
+ * passes one gets the documented "no env tier" behavior rather than a crash.
2321
+ *
2322
+ * Only the offline analysis entry runs outside Bun; the other commands keep
2323
+ * their `Bun.env` defaults because they open connections regardless.
2324
+ */
2325
+ function hostEnv(): Record<string, string | undefined> {
2326
+ return typeof Bun === "undefined" ? {} : Bun.env;
2327
+ }
2328
+
1879
2329
  function settingsMeta(
1880
2330
  format: ResolvedSetting<ExplainOutputFormat> | undefined,
1881
2331
  ): CommonSettingsMeta {
@@ -1891,7 +2341,7 @@ function settingsMeta(
1891
2341
  */
1892
2342
  export function resolveExplainFormat(
1893
2343
  explicit: string | undefined,
1894
- env: Record<string, string | undefined> = Bun.env,
2344
+ env: Record<string, string | undefined> = hostEnv(),
1895
2345
  config: Record<string, string | undefined> = {},
1896
2346
  ): ResolvedSetting<ExplainOutputFormat> {
1897
2347
  return resolveStringSetting(
@@ -2129,7 +2579,7 @@ function renderExplainText(
2129
2579
  const bytes = data.input.bytes;
2130
2580
  const pct = bytes === 0 ? 0 : (classified / bytes) * 100;
2131
2581
  lines.push(
2132
- `tokens: ${data.tokens.length} token(s), ${classified}/${bytes} byte(s) classified (${pct.toFixed(1)}%), class provisional`,
2582
+ `tokens: ${data.tokens.length} token(s), ${classified}/${bytes} byte(s) classified (${pct.toFixed(1)}%)`,
2133
2583
  );
2134
2584
  for (const t of data.tokens)
2135
2585
  lines.push(` ${span(t).padEnd(12)} ${t.class}`);