@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24

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 +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -10,12 +10,11 @@ validation): the cheap, safe knowledge tier in front of the runners
10
10
  (`execute` / `api` / `retrieve`).
11
11
 
12
12
  Status: offline baseline `verified`; live probes over `rest-api` and `native-api`
13
- `designed`. **`centrs explain '<input>'` runs today**, without a router or CDB.
13
+ `CHR-passed`. **`centrs explain '<input>'` runs without a router or CDB**, and
14
+ `centrs explain <router> '<input>'` adds read-only device evidence.
14
15
  [`docs/MATRIX.md`](../../docs/MATRIX.md#offline-analysis) tracks these separately;
15
16
  [#90](https://github.com/tikoci/centrs/issues/90) indexes the remaining tasks.
16
17
  The spec is ratified on the [phase-0 grounding lab](#phase-0-ratification-185).
17
- The current finish line is [Offline baseline acceptance](#offline-baseline-acceptance),
18
- not completion of the live protocol cells.
19
18
  Load-bearing rules — envelope, errors, settings precedence, identity,
20
19
  validation, protocol selection — live in
21
20
  [`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md).
@@ -373,9 +372,8 @@ native API's `/execute` **with `as-string=""`** returns the parser text —
373
372
  including the `(line N column M)` diagnostic on a syntax error — byte-identical
374
373
  to what a console transport prints. The opaque `*NN` value seen otherwise is the
375
374
  **no-`as-string` job handle**, not a `:parse` limitation; the readout rule is
376
- "pass `as-string` and interpret `ret`". The existing execute gate already passes
377
- `as-string` but currently discards `ret`; phase 2 must extract/fix that shared
378
- readout rather than copy the omission. `/console/inspect`
375
+ "pass `as-string` and interpret `ret`". The execute gate passes
376
+ `as-string` and classifies `ret`; live explain reuses that shared classifier. `/console/inspect`
379
377
  (highlight/syntax/completion) works over the native API's `talk` framing too. So
380
378
  the line/column diagnostic is available on both live cells — the earlier caveat
381
379
  that only a console transport surfaces the parser text is retired.
@@ -401,10 +399,8 @@ router therefore never reinterprets a previously valid offline invocation.
401
399
  the offline form with nothing else to analyze. A positional always wins, and
402
400
  when fd 0 is redirected anyway the result carries a
403
401
  [`usage/stdin-ignored`](../../docs/errors/usage/stdin-ignored.md) warning rather
404
- than quietly dropping the pipe. `--file -` names stdin explicitly; it is the
405
- offline fix today, since the live form it also spells
406
- (`… | centrs explain edge1 --file -`) is parsed and then refused with
407
- `usage/not-implemented` until phase 2. The narrower rule is
402
+ than quietly dropping the pipe. `--file -` names stdin explicitly in both forms,
403
+ including live `… | centrs explain edge1 --file -`. The narrower rule is
408
404
  deliberate and measured: `runCli` is called in-process by tests, so reading fd 0
409
405
  on any path a positional invocation reaches consumes the invoking shell's
410
406
  stdin — it fed those bytes to the analyzer and broke 21 unrelated tests once,
@@ -460,6 +456,7 @@ Standard envelope (constitution: result envelope); `data` sketch:
460
456
  "statementCount": 1,
461
457
  "statements": [
462
458
  {
459
+ "id": "s0",
463
460
  "span": { "start": 0, "end": 51 },
464
461
  "resolution": "resolved",
465
462
  "kind": "command",
@@ -511,6 +508,36 @@ Standard envelope (constitution: result envelope); `data` sketch:
511
508
  classification, and evidence id. This is where multi-statement scripts and
512
509
  partial recovery live; a single top-level transport object cannot represent
513
510
  them.
511
+ - **Statement ownership is syntactic (#497).** Every published statement has
512
+ an `id` (`s0`, `s1`, …) in discovery order, stable within one result. A nested
513
+ scope-body statement's `parent` names its owning statement; absence means
514
+ top level. Each substitution's `owner` names its containing statement,
515
+ including nested brackets. These links come from the resolver's traversal,
516
+ so widened Unicode spans do not turn siblings into ancestors. IDs are not
517
+ persistent across edits.
518
+
519
+ The existing `blocks[]` inventory still lists outer scope bodies only.
520
+ A block's optional `owner` names the statement carrying it, and a statement's
521
+ optional `block` indexes its innermost **published** enclosing block. A nested
522
+ body can therefore share its ancestor's block index. Bare/menu containers
523
+ add no synthetic statement or block nodes; the tree links only statements
524
+ the existing analysis exposes. Existing lists, order, spans and readings stay
525
+ unchanged. A widened span remains a coordinate limitation, not a second
526
+ parentage rule.
527
+
528
+ Stored/deferred `source=`, `on-event=` and callback bodies remain opaque:
529
+ their statements are not added to this tree. Creation versus explicit
530
+ stored-script execution is grounded by
531
+ `test/fixtures/explain/statement-ownership.json` and replayed by
532
+ `test/integration/explain-structure.test.ts`. Ownership asserts no lexical
533
+ inheritance, immediate execution, or shared user/policy environment;
534
+ context-sensitive binding/value semantics remain #507's responsibility.
535
+
536
+ `test/unit/explain-structure.test.ts` independently checks ancestry against
537
+ span containment where spans recover exact source text, with explicit
538
+ widened-span controls. After `bun run corpus:fetch`, run
539
+ `CENTRS_CHECK_CORPUS=1 bun test test/unit/explain-structure.test.ts` for the
540
+ same contract over the pinned study population; the corpus CI job runs it.
514
541
  - **Every derived fact keeps provenance via `evidence[]`** — facts reference
515
542
  a stable evidence id (`ev`), and the evidence entry carries source
516
543
  (`canonicalizer` vs `live-inspect`), probe, basis (direct response vs
@@ -621,7 +648,7 @@ and its phase is named below.
621
648
  | `structure.statements[].command` | `path` + `verb`, plus **`args` where the argument list was read** (#202c-1); the ordered token list is `statements[].arguments.tokens` |
622
649
  | `structure.statements[].transport` | complete for commands: `api-candidate`, `execute`, or fail-closed `unknown`, with an equivalent `centrs` invocation and opt-in `curl` for tested REST mappings (#202c-2). The `basis` names the evidence for the route it chose or refused |
623
650
  | `symbols.occurrences[]` | Q13 name/span/class plus semantic `role`, result-local `bindingIds`, sigil spelling, and an abstention note where needed; **plus** (#239 S2) `valueId` on declarations/assignments with a literal RHS in the same statement, and `reachingValueIds` + `reachingUnknown` on references (flow-sensitive, branch → set, loop/non-literal → `unknown`, no RHS evaluation) |
624
- | `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` remain absent until phase 2. `kind: "element"` rows are array members, carrying `parent` and, for a keyed member, `name` |
651
+ | `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` are added only by a live call ([Live value types](#live-value-types-225)). `kind: "element"` rows are array members, carrying `parent` and, for a keyed member, `name` |
625
652
  | `spans` | comment runs and resolved variable classes only; **no value shape or type** |
626
653
  | `schema`, `completion` | absent — live evidence, phase 2 |
627
654
 
@@ -713,13 +740,10 @@ and its phase is named below.
713
740
  rejected by RouterOS at `{` and resolve no reference. In an expression the
714
741
  remaining `-dns` is scanned as subtraction plus its right operand; inside a
715
742
  string it is literal text.
716
- - **Evidence is offline-shaped, not the whole contract.** The bullet above says
717
- an evidence entry carries `source` (`canonicalizer` vs `live-inspect`) and a
718
- RouterOS version stamp; phase 1 emits neither the live source nor the stamp,
719
- because no probe ran and there is no device to stamp. `ExplainEvidence` is
720
- typed to what offline produces rather than pre-declaring variants nothing can
721
- emit, so phase 2 widens the union and adds the stamp — an addition, not a
722
- change to what a caller reads today.
743
+ - **Evidence follows its producer.** Offline calls emit only `canonicalizer`
744
+ entries, with no device version. Live calls add `live-inspect` entries with
745
+ probe outcomes and available device provenance; existing offline entries
746
+ retain their meaning. See [Live probe foundation](#live-probe-foundation-491).
723
747
  - **Value facts are three axes, never one `type` string (#225).** RouterOS
724
748
  types values at parse time while `highlight` classes every value byte `none`,
725
749
  so type evidence is not a subset of Q12 spans. Each result-local
@@ -732,7 +756,7 @@ and its phase is named below.
732
756
  The stable/testing CHR matrix fixes the offline lexicon: `num`, `ip`,
733
757
  `ip-prefix`, `ip6`, `ip6-prefix`, `id`, `time`, `array`, `mac`, `bool`, and
734
758
  `str` — `op` is deliberately not a member: a deferred `(> …)` abstains
735
- offline and is reported only as a live `observedType` (#288); quoted literals
759
+ offline and live alike (#288, #225); quoted literals
736
760
  hint only `str`, while malformed and out-of-range address-like controls
737
761
  abstain even in named attributes. Most vocabulary borrows RouterOS scripting
738
762
  type names (with the explicit `macAddr` schema exception below), so a hint
@@ -766,8 +790,9 @@ and its phase is named below.
766
790
  1 and identical `:parse` IL — only `:typeof` distinguishes them
767
791
  (`test/fixtures/explain/operators.json` → `sweep.runtime`
768
792
  `typeof-deferred`/`deferred-array-typeof` and `sweep.opAxis` `defer-*` on
769
- 7.21.5, 7.23.3, 7.24rc4). Offline abstains on every such form; `op` lives
770
- only as a live `observedType` (#288, a bounded #225 decision).
793
+ 7.21.5, 7.23.3, 7.24rc4). Offline abstains on every such form, and so does
794
+ the live `observedType`, which never sends `:typeof` (#288, #225). `op` has
795
+ no producer.
771
796
  Shape remains non-authoritative: `100000w` is time-shaped but observed as
772
797
  `str`. Argument context remains separate: on 7.23.3 and 7.24rc3, both
773
798
  `/ip/address address=2.2` and firewall `src-address=2.2` canonicalize it to
@@ -781,8 +806,8 @@ and its phase is named below.
781
806
  and does not widen the hint. Thus a hint never validates a value or becomes a
782
807
  diagnostic, and never changes `runtimeAcceptance`. Quoting likewise never
783
808
  earns a wrong-type diagnostic: commands may cast quoted strings at runtime.
784
- Offline emits only `shapeHints`; phase 2 adds the two live facts into their
785
- existing homes when its safe IL/schema producers land.
809
+ Offline emits only `shapeHints`; a live call adds the two live facts into
810
+ their existing homes ([Live value types](#live-value-types-225)).
786
811
 
787
812
  **A located array is read member by member (#225 V1).** Each member of a
788
813
  `{…}`/`(…)` literal is its own `values.occurrences[]` row with `kind:
@@ -907,7 +932,16 @@ and its phase is named below.
907
932
  top-level brace value in any other command argument emits
908
933
  `explain/canonicalizer/invalid-command-brace` at the `{`; parentheses or a
909
934
  `[…]` substitution restore the expression context where the same literal is
910
- legal. An empty member is a
935
+ legal. So does a top-level `where` clause (#502): `print where interface in
936
+ {"ether1";"ether2"}` is a read, and its array is read member by member like
937
+ its `(…)` twin. The exception is the right side of `<name>=`, `!=` or `==`,
938
+ spaced or not, which the device reads with that property's own parser
939
+ (`expected interface value`), so `where comment={1;2}` keeps its error. The
940
+ device does that only for a property the menu knows (`where foo-bar={…}`
941
+ parses). Offline has no property list, so it treats every bare name as one.
942
+ The same comparison inside `( … )` or a `[ … ]` command
943
+ (`[find where interface={"x"}]`, `where (interface={"x"})`) is caught by the
944
+ group walk (#509, below) and reported with the same code. An empty member is a
911
945
  syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`) and withdraws the enclosing
912
946
  `array` shape with it; a single trailing separator is legal in the brace form
913
947
  only (`{1;}` and `{1;2;}` parse, `{1;2,}` and `{2,}` do not).
@@ -947,15 +981,16 @@ and its phase is named below.
947
981
 
948
982
  <!-- BEGIN GENERATED value-census — regenerate with `bun run explain:value-census:readme` -->
949
983
  The corpus census is re-derivable with `bun run explain:value-census` and
950
- covers 948 source scripts. The figures below are generated from
951
- `test/fixtures/explain/values.json` → `corpus` by
952
- `bun run explain:value-census:readme` and gated against it by
984
+ covers 948 source scripts of corpus snapshot `e12d5d4e58a4`, with 882
985
+ auxiliary rows (7 `capture-controls`, 875 `wiresage-conformance`) excluded.
986
+ The figures below are generated from `test/fixtures/explain/values.json` →
987
+ `corpus` by `bun run explain:value-census:readme` and gated against it by
953
988
  `bun run explain:value-census:readme:check`; the fixture itself is gated
954
989
  against a fresh corpus run by `bun run explain:value-census:check`. Of the
955
- 13,161 emitted values in a statement the strict argument lexer ALSO read, 0
990
+ 13,152 emitted values in a statement the strict argument lexer ALSO read, 0
956
991
  disagree with it on half-open byte span or decoded text, while the
957
992
  prefix-safe scan retains a further 6,538 values across 568 statements whose
958
- strict REST reading abstains. Of 19,699 emitted occurrences, 5,636 are array
993
+ strict REST reading abstains. Of 19,690 emitted occurrences, 5,636 are array
959
994
  members (529 keyed, 1,147 nested inside another member) and 808 are arrays;
960
995
  the corpus holds no source-literal `id` example. The three structural
961
996
  counters — spans addressing bytes outside their own source, members naming a
@@ -982,7 +1017,8 @@ comment, a literal hash value, and a hard error at the exact byte.
982
1017
  | Statement-leading at the document root, after a real statement separator, in a `do`/`else`/`foreach do` body, or immediately inside a `[…]` command substitution | `comment` through newline | Mask it and preserve byte offsets. A command first consumes that lead, so `[:put #test]` keeps `#test` as the value. |
983
1018
  | First non-space content inside a stored-script brace such as `on-event={ # c` | `comment` | Treat exact `source`/`script` and `on-*`-named brace values as comment-bearing script bodies; a suffix such as `myScript` is not one. |
984
1019
  | Immediate line start after `\` + newline | `comment`; the pending statement survives | Mask it in both argument views; the continuation-reach rules remain H5/#215. |
985
- | Inside an array (`{#test}`, `{1;#test}`, `{a=1;#b=2}`) or parenthesized expression (`(1,#test)`) | hard `error` at `#`; `:parse` reports `syntax error` | Do not mask it or emit an array value hint; semantic resolution stops there. |
1020
+ | Inside an array (`{#test}`, `{1;#test}`, `{a=1;#b=2}`) or parenthesized expression (`(1,#test)`), except at column 0 inside an array (next row) | hard `error` at `#`; `:parse` reports `syntax error` | Do not mask it or emit an array value hint; semantic resolution stops there. |
1021
+ | At column 0 of its own line inside an array, at any depth and in argument values (`:local z {a=1;⏎# c⏎b=2}`, `{a={⏎# c⏎1}}`, `({⏎# c⏎1})`, `:foreach i in={1;⏎# c⏎2} do=…`), including after `\r⏎` and a `\`-continued line (#503) | `comment` through newline; `:parse` drops it (`value=a=1;b=2`) and `highlight` has no `error` on CHR 7.24.5 and 7.22.1 | Mask it with `startsArrayCommentLine`, which every walker asks with its innermost frame. It ends nothing: the array's statement carries on. An indented `#` (space or tab), one after `{`, a value or `;` on the same line, and one at column 0 directly inside `( … )` stay errors. A comment-only array (`{⏎# c⏎}`) is rejected only because an empty array is (#511). |
986
1022
  | A scope-named brace (`do=`/`else=`/`command=`/`script=`/`source=`/`on-*=`) nested inside an array (`:local z {do={ # c⏎:put 1}}`, `:local z {script={ # c⏎…}}`, `:local z { { # c…}}`) — and likewise a bare `{` or `/menu {` inside an array | hard `error` at `#`; `/console/inspect` request `highlight` classes that byte `error` on CHR 7.23.3 (grounded in #249 on the four `#249` rows plus `bare {`/`source`/`on-event` corners) | A brace bears statements only when its enclosing context already does — `enclosing && braceStartsStatements(…)` on each walker's stack (`contexts`/`delimStack`/`frames`/`stack`). Inside an array the name is an array key and its `{…}` is a nested array. A `[…]` substitution nested in that array does re-enter a statement context, so `[:do { # c…}]` stays a real comment and a bare `{` under a bracket does too. |
987
1023
  | Inside a `[…]` substitution that is itself nested in an array (`{[:put #test]}`, `{1;[:put #test]}`) | literal value (`none`) — the bracket restores the statement role the array dropped | Keep it as content and keep the enclosing array readable. The role is per frame, so an array or group opened again inside that bracket (`{[:put {#test}]}`, `{[:put (1,#test)]}`) is `error` once more; skipping whole `[…]` regions would wrongly accept those. |
988
1024
  | In an attribute or bare value (`comment=#test`, `comment=a#b`, `:global y #test`) | literal value (`none` highlight class) | Keep it as content. |
@@ -1020,6 +1056,14 @@ JSON-like transcript, and two shell-shaped snippets. No RouterOS command reading
1020
1056
  moved; these diagnostics say those bytes are not valid RouterOS, not that the
1021
1057
  foreign snippets were expected to parse as RouterOS.
1022
1058
 
1059
+ Corpus re-measurement for #503: one script of 947 moves. It is a pasted console
1060
+ session (`forum/amm0/topic-264287-…/post-0005-snippet-01.rsc`, which opens with
1061
+ `[admin@MikroTik] > {`), and it moves from `fail` to `warn`. Its `invalid-hash` sat on a column-0
1062
+ `# ... more code` line inside the brace after `>`, which the device reads as
1063
+ an array (`:local z (1 > {⏎:local a 1⏎# c⏎})` parses). The device rejects the
1064
+ script earlier, at the prompt's `@`, which is #512. Every census gate except
1065
+ the token census is unchanged; that one gains the two comment spans.
1066
+
1023
1067
  The default text renderer surfaces comment spans in a `comments:` section; JSON
1024
1068
  continues to carry the same ranges as `spans[]` entries with `class: "comment"`.
1025
1069
 
@@ -1074,10 +1118,18 @@ navigated, so the context stays as unknown as it already was.
1074
1118
  `:parse` `expected message value`, grounded stable+testing #247),
1075
1119
  `bad-sigil`,
1076
1120
  `invalid-hash`, `invalid-apostrophe` (#355, below),
1077
- `missing-statement-separator` (#311, below). Nine classes
1078
- the device itself rejects.
1121
+ `missing-statement-separator` (#311, below), the four group faults
1122
+ `empty-group`, `misplaced-separator`, `missing-operand` and
1123
+ `invalid-at-sign` (#505, #511, #512, below), and the four token faults
1124
+ `glued-token`, `invalid-variable`, `bad-string-variable` and
1125
+ `invalid-name` (#506, #518, below), and the two unquoted-byte faults
1126
+ `unquoted-reserved-byte` and `unquoted-non-ascii` (#358, #371, below).
1127
+ Nineteen classes the device itself rejects.
1079
1128
  - `warning` — `over-depth`, because it is centrs's own resource bound and says
1080
- nothing about whether the input is legal; and an `ambiguous`/`unknown`
1129
+ nothing about whether the input is legal; `possible-spaced-argument`
1130
+ (#350), a spelling RouterOS accepts and marks obsolete; `discarded-close`
1131
+ (#504), a script RouterOS accepts and stops reading early, which the
1132
+ execute gate refuses by code; and an `ambiguous`/`unknown`
1081
1133
  resolution, never an error, so the default `--fail-on error` cannot fail a
1082
1134
  document whose only sin is being unreadable without a schema — which is most
1083
1135
  of RouterOS scripting.
@@ -1182,6 +1234,200 @@ it. The corpus count is unchanged at **0** for the same reason it always was
1182
1234
  the rule is an unterminated string or an unbalanced delimiter, because neither
1183
1235
  walk can find a boundary to resume from.
1184
1236
 
1237
+ ### Faults inside groups (#505, #509, #511, #512)
1238
+
1239
+ Four missed-rejection families share one walk, `src/explain/group-faults.ts`
1240
+ (evidence pass `findGroupFaults`). It reads the comment-masked document with
1241
+ the frame roles every other walker uses: `[` opens a statement, `(` an
1242
+ expression, and `{` a body when `braceStartsStatements` says so or the
1243
+ directive takes a program in that slot (`:execute {…}`, `:time {…}`). Each
1244
+ rule is a `:parse` answer on CHR 7.24.5 and 7.23.8, identical on both, and
1245
+ `test/fixtures/explain/group-faults.json` pins all 186 rows (100 rejections,
1246
+ 86 controls). `test/integration/explain-group-faults.test.ts` holds the
1247
+ device to that fixture, and `test/unit/explain-group-faults.test.ts` holds
1248
+ offline to it.
1249
+
1250
+ | Code | Device rule | Rejected | Accepted |
1251
+ | ---- | ----------- | -------- | -------- |
1252
+ | `empty-group` | A `( )` with nothing in it, and a `{}` that is the whole value of a directive slot | `:put ()`, `:if () do={}`, `:local z {}`, `:put [:len {}]`, `:foreach i in={} do={}` | `{1;{}}`, `{a={}}`, `({})`, `[{}]`, `where … in {}`, `do={}`, `source={}`, `:execute {}` |
1253
+ | `misplaced-separator` | A `;` with no array element before it on its line; a `;` or a bare newline directly inside `( … )` | `{;1}`, `{1;;2}`, `{1⏎;2}`, `(1; 2)`, `(1⏎+ 2)` | `{1;}`, `{1;2;⏎}`, `{1⏎2}`, `{1 \⏎;2}`, `(1 +\⏎2)`, `({1;2})` |
1254
+ | `missing-operand` | An operator with no operand: a binary one first in a group or after another operator, any one last, and a leading or trailing `,` in an array | `(1 +)`, `(+ 1)`, `(1 + + 2)`, `(1 == 2)`, `(!)`, `(1 and)`, `{1,}`, `{,1}` | `(1 2)`, `(- 1)`, `(1 + -2)`, `(-> 1)`, `(any any 1)`, `(and 1)`, `(. 1)`, `(1.)` |
1255
+ | `invalid-at-sign` | A `@` in a statement's head word, in an array, or in an expression | `[admin@MikroTik]`, `admin@x`, `:put@x`, `{1;a@b}`, `(a@b)` | `:put a@b`, `comment=a@b`, `[:len a@b]`, `[:len x] a@b` |
1256
+
1257
+ `invalid-command-brace` gains the #509 positions: a brace after `<name>=`
1258
+ inside `( … )` or a `[ … ]` command whose statement says `where`. The top level
1259
+ and scope bodies stay with `args.ts` (#502), and a `do={` body is never read as
1260
+ a comparison.
1261
+
1262
+ **Prefix operators are the device's set, not the operator table's arity.**
1263
+ `-`, `!`, `>`, `~` and `any` take one operand after them, so `(1 + -2)` and
1264
+ `(-> 1)` (`(- (> 1))`) parse. Every other operator needs a left operand. At
1265
+ operand position `and`, `or`, `in`, `.` and `/` are variable names
1266
+ (`(and 1)` is `( $and 1)`). `1.`, `1/`, `a_b`, `1:2` and `1.2.3.4/24` are
1267
+ single words, while `-` is always an operator (`(a-b)` is `(- $a $b)`).
1268
+
1269
+ **A query is not an expression.** Inside `where …` and `find …`, the device
1270
+ reads the value after `<name>=` with the property's parser. So
1271
+ `where (comment=+)`, `where (comment=a-)`, `where (comment=@)` and
1272
+ `where (.id=*1)` parse, though each is an error in a plain group. A group in a
1273
+ query gets only the structural checks (empty, `;`, newline) and #509.
1274
+
1275
+ **A brace offline cannot place is opaque, not an array.** A `{` after a
1276
+ relative menu word is a menu body (`/ip { route { } }` parses empty), and a
1277
+ brace in a command argument is `args.ts`'s. Neither is checked here. The
1278
+ array rules apply where the role is grounded: a directive slot in the
1279
+ `brace-slots.ts` array table, and an array nested in an array or an expression.
1280
+
1281
+ **Three readings the review round grounded (PR #516).** A `\` continuation
1282
+ runs on across blank lines and line-leading comment lines, as H5 does
1283
+ elsewhere, so `(1 +\⏎# c⏎2)` and `{1\⏎⏎;2}` parse, while a blank line after a
1284
+ comment line ends it (`(1 +\⏎# c⏎⏎2)` is rejected at the blank line). A
1285
+ directive's operand is a name, so `:local where (comment={})` parses and
1286
+ `:local where (1 +)` is checked like any expression. A bare `local`/`global`
1287
+ is the same directive at the root (`local where (comment={})` is
1288
+ `/localname=$where`), while a bare `set` or `find` may be a menu command and
1289
+ stays unread. And a named element takes
1290
+ a trailing `,` (`{a=1,}` is `value=a`, `{a=1,;b=2}` is `value=a;b=2`), while
1291
+ `{a=,1}`, `{a=1,,2}` and a positional `{1,}` are rejected. A faulted group's
1292
+ descendants are opaque, since the device stops at the fault.
1293
+
1294
+ **Spans.** Each fault spans the token it is about: the operator, the
1295
+ separator, the `@`, or the whole empty group. The device's column is the same
1296
+ byte for every rule except an empty group and a dangling operator, where it is
1297
+ the byte after.
1298
+
1299
+ **Measured.** Corpus `e12d5d4e58a4`: 70 of 948 scripts gain an error and all
1300
+ 70 are device rejections on every channel. 42 of those errors sit on the exact
1301
+ byte the 7.24.5 diagnostic names, and the other 28 come after an earlier device
1302
+ error in pasted non-RouterOS text. None precedes the device's error. 41
1303
+ verdicts move to `fail` (40 from `warn`, one from `pass`), so offline now fails
1304
+ 133 of the 374 device-rejected scripts, up from 92, and false rejects stay at 1.
1305
+ `invalid-at-sign` is mostly pasted console prompts. On the conformance cases,
1306
+ 22 `plain-diagnostic` missed rejections move to `fail`, with no new false
1307
+ reject.
1308
+
1309
+ Not covered here: a `$( … )` group, a group under a `find` head
1310
+ (`[find (1 +)]`, read as a query), a `@` in a later head word
1311
+ (`/ip address@ print`), operand rules for array elements other than `,`, a
1312
+ group in a query beyond its structure, and adjacency without whitespace
1313
+ (`"x"~a`, `}else=`), which is the next section's.
1314
+
1315
+ ### Token faults and the discarded tail (#504, #506, #518)
1316
+
1317
+ Three more missed-rejection families, and one reading that was wrong in the
1318
+ other direction. The glued-token, variable and name rules ride the same walk as
1319
+ the group faults (`src/explain/group-faults.ts`), so they share its statement,
1320
+ query and brace roles. The `$` inside a string is checked by the escape walk
1321
+ (`src/explain/quoted-string.ts`), and the stray close by the segmenter
1322
+ (`src/explain/segment.ts` → `discardFrom`). Every rule is a `:parse` answer on
1323
+ CHR 7.24.5 and 7.23.8, identical on both, and
1324
+ `test/fixtures/explain/token-faults.json` pins these rows and the unquoted-byte
1325
+ rows below: 292 in all (184 rejections and 108 acceptances, with the
1326
+ acceptances' IL). `test/integration/explain-token-faults.test.ts`
1327
+ holds the device to that fixture, and `test/unit/explain-token-faults.test.ts`
1328
+ holds offline to it.
1329
+
1330
+ | Code | Device rule | Rejected | Accepted |
1331
+ | ---- | ----------- | -------- | -------- |
1332
+ | `discarded-close` (warning) | A `}` or `]` that closes nothing at the top level ends the script: the device accepts it and drops that byte and everything after it | — | `}` (IL `/`), `:put 1};:put 2` (IL `(evl /putmessage=1)`), `name=a}` (sets `a`) |
1333
+ | `glued-token` | After a closed string, `$name`, `$"…"` or group, only a separator; after a bare word, no `"`, `$`, `(`, `[` or `{` | `:put "a"b`, `$v.b`, `$v$v`, `(1)(2)`, `[:len a]b`, `}else=`, `src-address=$v/24`, `dst-port="80",443`, `:put a"b"`, `name=a$v` | `:put a.b`, `a-b`, `a_b`, `a#b`, `a~b`, `where comment="a"b`, `[find comment="a"b]`, `("a"b)`, `{"a"b}`, `dst-address-list=!"my list"`, `dst-port=80,"443"` |
1334
+ | `invalid-variable` | Outside a string, `$` takes a name (`[A-Za-z0-9]+`) or `$"…"` | `:put $`, `$_`, `$-`, `$.`, a `$` before a space | `$v1`, `$"a b"` |
1335
+ | `bad-string-variable` | Inside a string, `$` takes a name, `"`, `(` or `[` | `"a$ b"`, `"$-"`, `"$_"`, `"${"`, and the same in `where`, an array, a group and a nested `$[…]` string | `"$v_x"` (`$v` then `_x`), `"\$ x"`, `"$(1)"`, `"$[:len a]"` |
1336
+ | `invalid-name` | The first operand of `:local`, `:global`, `:set`, `:for` and `:foreach` is a name of letters and digits, or a string | `:local a_b`, `a-b`, `a.b`, `a$v`, `:local $a`, `:local (a)`, `:for i,j`, `:foreach a,`, `:foreach a,,b` | `:local "a_b"`, `:local 1a`, `:foreach a,b`, `:foreach "a",b`, `:local name=x value=1`, `:set (a) 1` |
1337
+
1338
+ **The stray close was the honest-reading bug.** Offline used to fail
1339
+ `name=a}` with `unbalanced-close`, and the device accepts it. But the device
1340
+ does not just ignore the `}`: it stops reading there. `}⏎:put 1` lowers to `/`,
1341
+ and a write after the brace never runs. So `explainCommand` now analyzes only
1342
+ the prefix the device reads and adds one `warning` that spans the dropped
1343
+ bytes. The token partition still covers the whole input, with the tail
1344
+ `unclassified`. A `)` in that position, and any stray close inside an open
1345
+ group, are rejections and stay `unbalanced-close` errors. The execute gate
1346
+ refuses `discarded-close` by code, the one warning it refuses
1347
+ (`src/offline-gate.ts`; `commands/execute/examples.md` V9).
1348
+
1349
+ **Where a property's parser decides, the glued-token rule abstains.** In a
1350
+ command argument the device hands the value to the property's parser, which can
1351
+ take a glued part after a prefix or separator: `dst-address-list=!"my list"`
1352
+ and `dst-port=80,"443"` parse, while `name=!"a"` and `name=-"a"` do not. So
1353
+ outside a `:` directive, a bare word followed by a `"`, `$` or bracket is a
1354
+ fault only after an alphanumeric byte. After a closed part no property was found
1355
+ that accepts anything (`"80",443`, `$p-90`, `$v%ether1`, `"ether"1` are all
1356
+ rejected), so that half of the rule applies everywhere outside a query.
1357
+
1358
+ **Spans.** Each fault spans its byte, which is the device's byte too. For the
1359
+ two `$` rules the span is the `$` and the byte after it, and the device names
1360
+ that second byte.
1361
+
1362
+ **Measured.** Corpus `e12d5d4e58a4`: 23 scripts move to `fail` (18 from
1363
+ `warn`, 5 from `pass`), and 29 already-failing scripts gain a new code. All are
1364
+ device rejections on every channel. 9 of their first new errors sit on the
1365
+ 7.24.5 diagnostic's byte, 45 come after it, and none precedes it. Offline now
1366
+ fails 154 of the device-rejected scripts, up from 133. The one corpus false
1367
+ reject (a stray `}`) is gone, so false rejects are 0. Three scripts move from
1368
+ `fail` to `warn`: their `unbalanced-close` became `discarded-close`, and two of
1369
+ them are device rejections for an earlier reason offline does not see. On the
1370
+ conformance cases, false rejects go from 3 to 0 and missed rejections from 278
1371
+ to 151.
1372
+
1373
+ **Not covered here.** `"a$"`: the device reads `$"` in a string as a quoted
1374
+ variable name, so the next `"` does not close the string. Every scanner in
1375
+ `explain` closes it there, so both `$` rules and the glued-token rule abstain
1376
+ around a string that ends in `$"` (2 of 948 corpus scripts use the form).
1377
+ `$$`, `$(…)`, `$[…]` and `${…}` outside a string, where the device's byte is
1378
+ not the `$`. A `\` continuation inside a glued token (`"a"\⏎"b"`). A glued
1379
+ head word (`$v"a"`, `{…}:put 2`). `:local a=b`, which 7.24.5 reads as
1380
+ a named argument and 7.23.8 as a syntax error. And a name in `/local` or
1381
+ `:local name=a_b`.
1382
+
1383
+ ### Unquoted bytes (#358, #371)
1384
+
1385
+ A `?`, a backtick and a non-ASCII byte are never part of a bare word, and
1386
+ neither is a second `=` in a menu command's argument. The rules ride the
1387
+ token walk (`src/explain/group-faults.ts`). They were grounded with live
1388
+ explain (`explainLiveEnvelope`: `:parse` and `highlight`) on CHR 7.24.5 and
1389
+ 7.23.8, which answered identically. The 147 rows are in
1390
+ `test/fixtures/explain/token-faults.json`.
1391
+
1392
+ | Code | Device rule | Rejected | Accepted |
1393
+ | ---- | ----------- | -------- | -------- |
1394
+ | `unquoted-reserved-byte` | `?` and a backtick, at the byte, outside a string or comment. A second `=` in a menu command's argument, at that `=` | `name=a?b`, ``name=a`b``, `name=?b`, `:put a?b`, `[:len a?b]`, `where comment=a?b`, `[find comment=a?b]`, `where (comment=a?b)`, `where comment in {a?b}`, `{a?b}`, `(a?b)`, `/ip/address/print?`, `name=a=b`, `name==b`, `comment=x==`, `url=http://x/a?b=c`, `regexp=^a?b`, `public-key=…=`, `secret=abc==`, `on-event=a=b` | `name="a?b"`, ``name="a`b"``, `name="a=b"`, `where comment=a=b`, `[find comment=a=b]`, `[/ip/address/find comment=a=b]`, `where (comment=a=b)`, `where comment in {"a?b"}`, `{a=b}`, `{"a?"=1}`, `# a?b`, `("a"."?")` |
1395
+ | `unquoted-non-ascii` | A non-ASCII byte outside a string or comment, at its first byte; the span is the whole run | `:put café`, `$café`, `name=router-café`, `name=🛰`, `/systemé/identity`, `printé`, `namé=x`, `where comment=café`, `{café}`, `(café)`, `"x"é`, a non-breaking space, `“x”`, `address=…` | `:put "café"`, `:local "café" 1`, `$"café"`, `"a$café"` (`$caf` then `é`), `name="router-🚀"`, `# café`, `do={:put "café"}` |
1396
+
1397
+ **Where the device reports, offline reports.** In a menu command's argument
1398
+ name the device names the name's first byte (`/ip/address/add addré=1` and
1399
+ `a?b=1` at the `a`), so the span is the name. A directive (`:put café=1`) and
1400
+ a `$` call (`$f café=1`) report at the byte. A declared name keeps the
1401
+ existing `invalid-name` (`:local café`, `:foreach café`). A query, and a
1402
+ group or array inside one, keeps `=` as an operator but not these bytes
1403
+ (`where (comment=café)` and `in {café}` are rejected at the `é`), and the slash-joined `[/ip/address/find …]` is a query, as
1404
+ `[find …]` is. A leading BOM is a positional fact and is not read as part of
1405
+ the first word. The informational `non-ascii` fact is still published beside
1406
+ the error, so `isPositionalFact("non-ascii")` is unchanged.
1407
+
1408
+ **No property parser takes these bytes.** This is the difference from the
1409
+ glued-token rule. `regexp=a?b` (layer7 and DNS static), ``regexp=a`b``,
1410
+ `public-key=<base64>=`, `secret=abc==`, `password=abc=`,
1411
+ `http-header-field=a:b=c` and the script bodies `on-event=a=b` and
1412
+ `up-script=a=b` are all rejected at the byte.
1413
+
1414
+ **Measured.** Corpus `e12d5d4e58a4`: 8 scripts move to `fail` (7 from `warn`,
1415
+ one from `pass`), and 20 already-failing scripts gain a new code. All 28 are
1416
+ device rejections on every channel. 8 of their first new errors sit on the
1417
+ 7.24.5 diagnostic's byte, 20 come after it, and none precedes it. There are no
1418
+ false rejects. On the conformance cases, 10 missed rejections move to `fail`
1419
+ (the `non-ascii-outside-strings` u10–u12 and u14–u16, z90, z92, g18 and e33), with no new
1420
+ false reject.
1421
+
1422
+ **Not covered here.** `:put a=b`, `:local x a=b` and `[:len a=b]`: 7.24.5
1423
+ reads them as a named argument (`bad parameter a`, at the end), and 7.23.8
1424
+ rejects the `=`. A query value that starts with `=` (`where comment==b`,
1425
+ `expected comment value`). A bare `source=` value,
1426
+ which the device rejects at its first byte whatever it holds (`source=abc`), so
1427
+ a byte rule there reports after the device's byte. And `:set` of an undeclared
1428
+ name, which the device rejects at the name's first byte (`:set zzz 2`), so
1429
+ `:set café` is reported at the `é`, after it.
1430
+
1185
1431
  ### The token stream a rule reads (#316)
1186
1432
 
1187
1433
  `lexArguments` is all-or-nothing by design: its consumer renders a runnable REST
@@ -1394,17 +1640,108 @@ print } }` is `(evl (evl (evl /ip/address/print)))` on device and
1394
1640
  form failed. A menu block's body is a statement list, so it may open with a
1395
1641
  submenu, with one of the thirteen frozen CRUD verbs (`verbs.ts`), with a
1396
1642
  catalog-named command, with a `:` scripting directive, or with an absolute
1397
- command — corpus 521 is the directive case, `ethernet { :put … }`.
1398
-
1399
- That last rule is bounded by what a STATEMENT can start with, which is what
1400
- keeps `{1;2}` an array literal: an array member opens with a digit or a quote
1401
- and never reaches the identifier branch. It is also deliberately not widened
1402
- into a claim of existence. Menu-scope resolution is schema-dependent —
1403
- `/ip { zzznotamenu { print } }` is a `syntax error` on device — and offline has
1404
- no schema, so a catalog MISS abstains rather than being decided either way.
1405
- That is the catalog's own "a hit is decisive, a miss says nothing" contract, and
1406
- turning such a miss into an acceptance would be the mirror-image defect of the
1407
- one being fixed here.
1643
+ command — corpus script `forum/amm0/topic-184067-routeros-lsp-for-better-syntax-checking-command-completion-in-editors-like-vscod/post-0014-snippet-02.rsc` is the directive case,
1644
+ `ethernet { :put … }`.
1645
+
1646
+ Issue #348 only stopped the failure. It still read `address { … }` as a command
1647
+ `/ip address` whose operand was the brace, so the body was never segmented.
1648
+ That rule was bounded by what a statement can start with, on the theory that
1649
+ `{1;2}` there is an array. The device says otherwise (#351, CHR 7.24.5 and
1650
+ 7.23.8): a brace right after a menu word is a statement block whatever the body
1651
+ holds. `/ip { address { 1;2 } }` is `bad command name 1` at the `1`, and
1652
+ `/ip { address { a;b } }` runs `a` as `add`. The old reading failed the OUTER
1653
+ brace at depth three, and passed `print {1;2}` and `print; zzz` inside the body.
1654
+
1655
+ So a relative run inside a menu block now opens a block of its own when the
1656
+ menu it names is known: the enclosing block's path, read outward from the
1657
+ source, plus the run, named by a shipped structure table
1658
+ (`scope-brace.ts` → `menuBlockPath`). The segmenter, the comment mask, the
1659
+ argument lexer and the group-fault walk all ask it. The body is then read like
1660
+ `/ip/address { … }`: `print {1;2}` there fails at its own `{`, the device's
1661
+ byte, and an unresolvable body statement abstains at every depth.
1662
+
1663
+ It is deliberately not widened into a claim of existence. Menu-scope
1664
+ resolution is schema-dependent — `/ip { zzznotamenu { print } }` is a
1665
+ `syntax error` on device — and offline has no schema, so a catalog MISS
1666
+ abstains rather than being decided either way. That is the catalog's own "a
1667
+ hit is decisive, a miss says nothing" contract. Three spellings keep the older
1668
+ reading because the source alone does not name the menu: a block after a
1669
+ navigation statement (`/ip⏎address { … }`), an abbreviation (`addr {`) and a
1670
+ digit-led menu (`6to4 {`, which the resolver does not read as a path). After a
1671
+ navigation, `/ip⏎address { 1;2 }` still fails at the outer brace and
1672
+ `/ip⏎address { print {1;2} }` still passes; the device answers
1673
+ `bad command name 1` and `expected end of command` at the inner brace.
1674
+
1675
+ ### The `=` is optional in ordinary arguments too (#350)
1676
+
1677
+ The #347 rule is the narrow half of a general one: a space can stand in for the
1678
+ `=` of any command argument. `/ip/firewall/layer7-protocol add comment
1679
+ "something" regexp 1.1` lowers to the IL of `comment="something" regexp=1.1`
1680
+ on CHR 7.22.1, 7.23.8 and 7.24.5, and `highlight` marks each gap
1681
+ `syntax-obsolete`, which is RouterOS calling the spelling deprecated. Unlike
1682
+ the four #347 scope names, this one cannot be read offline. The device pairs
1683
+ `comment` with its value because it knows the command has a `comment`
1684
+ argument, and `/system/script add zzznotanarg beep` is rejected. Which words
1685
+ are argument names is per-command schema, which offline does not ship.
1686
+
1687
+ Offline used to publish the worst reading available: `arguments.read: true`,
1688
+ `args: {}`, the operands as positionals. That is an affirmative claim that the
1689
+ list was read and holds no arguments. Now any adjacent pair of a
1690
+ name-shaped bare operand and another operand withdraws the argument reading,
1691
+ as #311 does. The statement stays `resolved`, `command.args` is absent,
1692
+ `arguments.why` names the pair, and transport stays the `unknown` it already
1693
+ was. A **warning** (`possible-spaced-argument`, example 33) is raised only
1694
+ where the second operand cannot be a flag (quoted, numeric, `$`-led). That is
1695
+ because the same bytes are also two flags: `print detail without-paging`
1696
+ lowers to `detail=true;without-paging=true` with no `syntax-obsolete` marker,
1697
+ while `set ether1 name x` pairs. So two bare words abstain silently. `where`
1698
+ ends the scan, and root directives are excluded because their operands are
1699
+ positional by grammar (`:pick abc 0 1` lowers to `counter=abc`). Live
1700
+ `request=child` discovery (#491) is what confirms a name and makes the warning
1701
+ definite.
1702
+
1703
+ The same applies before a script body. `source { :beep }`,
1704
+ `on-event { :beep }` and `down-script { :beep }` lower to their `=` IL, while
1705
+ `comment { :beep }` is a `syntax error`. So the brace exemption accepts the
1706
+ bare spelling, and its name shape gained the `…-script` arguments
1707
+ (`up-script`, `lease-script`) that hard-failed even with the `=`. Which slots
1708
+ take a body is per-menu and per-version schema (`lease-script={…}` parses on
1709
+ 7.24.5 and not on 7.22.1). The shape decides the brace's role for every
1710
+ reader, not just the error, and it errs toward a body: an argument the
1711
+ command does not have (`zzz-script={…}`, and `on-zzz={…}` before #350) is a
1712
+ device `syntax error` that offline does not report. That is a missed
1713
+ rejection for the device preflight to catch, never a false reject. On the
1714
+ pinned corpus the widening changed no census.
1715
+
1716
+ ### First-segment script-body abbreviations warn instead of rejecting (#525)
1717
+
1718
+ RouterOS takes any unique prefix of an argument name, including the first
1719
+ segment: `down={ :beep }` lowers to `down-script=` on CHR 7.22.1–7.25, as do
1720
+ bodies opening with one bare command word (`down={ beep }`) or after a comment
1721
+ line. Every word is a prefix of `<word>-script`, so without the command's
1722
+ argument list (schema) offline cannot tell `down=` from `comment=`, which the
1723
+ device rejects. The contract: on a non-root command, outside `where`, an
1724
+ unproven `name={` with a script-like body warns
1725
+ `explain/canonicalizer/possible-script-body-abbreviation` (verdict `warn`, the
1726
+ offline gate lets it through) instead of failing with `invalid-command-brace` —
1727
+ glued or with the brace spaced off the `=` (`down= { :beep }` lowers to
1728
+ `down-script=` on CHR 7.25 exactly like the glued spelling, so the spaced
1729
+ owner warns under the same gates). The brace reads as a body for every
1730
+ reader — a `#` inside is a comment, not `invalid-hash` — while proven slots
1731
+ (`down-script=`, `s=`, `on-…`) stay silent and control scopes (`do={…}`) are
1732
+ never guesses. A bare word followed by `;`-separated members is array
1733
+ members, not commands, so `.proplist={name;comment}` keeps its error alongside
1734
+ the other array-content braces (`{1;2}`, `{"x"}`, `{}`), the positional form,
1735
+ and `where`. A root directive's slots (`:put x={…}`, glued or spaced) and a `where` named
1736
+ comparison (`where gateway={…}`, however wrapped) keep their error too: the
1737
+ slot table and the property parser decide there, so the abbreviation guess
1738
+ never shields them (#554). The integration twin pins both device directions.
1739
+
1740
+ The tradeoff is measured: a verdict sweep of the 948-row pinned corpus
1741
+ population against the pre-batch baseline reports zero verdict or diagnostic
1742
+ changes, so nothing in the corpus pays for the abstention. The one deliberate
1743
+ over-warn — `comment={ :beep }` warns though the device rejects it — has no
1744
+ corpus occurrence.
1408
1745
 
1409
1746
  ### `'` is not a RouterOS token (#355)
1410
1747
 
@@ -1443,11 +1780,14 @@ Occurrences BEFORE the defect stand, which is the lab's X1 rule.
1443
1780
 
1444
1781
  **This was a false ACCEPT, which the corpus join cannot see.** The false-reject
1445
1782
  measurement scores one direction only, so the analyzer passing all four forms
1446
- with zero diagnostics never appeared in it. Over the pinned corpus the rule
1447
- fires on 40 of the 7.24.2 scripts and **every one of them is a script the device
1448
- rejects** — none of the 617 it accepts. It moves the false-reject count not at
1449
- all (still 1, the extra `}` of corpus 228) while taking the caught count from 62
1450
- to 93.
1783
+ with zero diagnostics never appeared in it. Over the corpus pinned at the time
1784
+ (stable 7.24.2, before the #498 schema-5 repin) the rule fires on 40 scripts and
1785
+ **every one of them is a script the device rejects** — none of the 617 it
1786
+ accepted. It moves the false-reject count not at all (still 1, the extra `}` of
1787
+ corpus script
1788
+ `forum/amm0/topic-167087-can-t-turn-code-into-a-function/post-0005-snippet-02.rsc`)
1789
+ while taking the caught count from 62 to 93. On the #498 sessions both figures
1790
+ hold on every channel: 1 false reject among 573 accepts, 93 caught.
1451
1791
 
1452
1792
  The token census moves, and only in the direction the fail-closed symbol stop
1453
1793
  predicts: 68 fewer `variable-*` tokens and their 306 bytes returning to
@@ -1524,12 +1864,12 @@ Grounded, not transcribed, for the same reason the escape set above is: the
1524
1864
  [manual's operator list](https://manual.mikrotik.com/docs/developer-guides/scripting/index.md#operators)
1525
1865
  is a lower bound. `bun run explain:probe:operators` sweeps the manual's list
1526
1866
  **plus** plausible non-operators (`not`, `..`, `xor`, `mod`, `is`, `div`, …)
1527
- **plus** every IL head the corpus census saw, on CHR 7.24.2 stable, 7.23.5
1528
- long-term and 7.25beta3 development — the same three builds the pinned corpus
1529
- classes `complete`, so the operator axis and the token partition are finally
1530
- scored on the same firmware (#342) — plus 7.21.5 as an **age anchor**, which is
1531
- not a channel claim but the only live evidence that the surface behaves the same
1532
- on a pre-7.23 branch. Re-cut the fixture with
1867
+ **plus** every IL head the corpus census saw. It runs on CHR 7.24.5 stable,
1868
+ 7.23.8 long-term, 7.25rc2 testing and 7.26beta1 development, the builds the
1869
+ pinned corpus records a channel session for, so the operator axis and the token
1870
+ partition are scored on the same firmware (#342, #498). It also runs on 7.21.5
1871
+ as an **age anchor**, which is not a channel claim but the only live evidence
1872
+ that the surface behaves the same on a pre-7.23 branch. Re-cut the fixture with
1533
1873
  `bun run explain:operator-slice`; the table
1534
1874
  below is generated from `src/explain/operators.ts` by
1535
1875
  `bun run explain:operator-readme` and gated by
@@ -1632,13 +1972,13 @@ not concat at all** — `1.` lexes as a *variable name* and the row comes back a
1632
1972
  juxtaposition, `( $1. 2)`. A rule that claims every `.` byte for the operator
1633
1973
  emits a span the device does not have.
1634
1974
 
1635
- Everything in the sweep is identical across all four builds except one runtime
1636
- row: `:put ({2;1} > {1;2;3})` evaluates to `true` on 7.24.2 and 7.25beta3 and
1637
- errors on both long-term builds. **Array comparison is not fixed on current
1638
- long-term** — 7.23.5 is newer than the 7.23.3 this was first measured on and
1639
- still errors — so the change rode the 7.24 line and was not backported. The two
1640
- errors also differ: 7.23.5 says `cannot compare if array is more than array`,
1641
- 7.21.5 says `cannot compare if nothing is more than nothing`, so on 7.21.5 the
1975
+ Everything in the sweep is identical across all five builds except one runtime
1976
+ row: `:put ({2;1} > {1;2;3})` evaluates to `true` on 7.24.5, 7.25rc2 and
1977
+ 7.26beta1 and errors on both long-term builds. **Array comparison is not fixed
1978
+ on current long-term** — 7.23.8 is newer than the 7.23.3 this was first
1979
+ measured on and still errors — so the change rode the 7.24 line and was not
1980
+ backported. The two errors also differ: 7.23.8 says `cannot compare if array
1981
+ is more than array`, 7.21.5 says `cannot compare if nothing is more than nothing`, so on 7.21.5 the
1642
1982
  operands did not survive to the comparison at all.
1643
1983
  Identical is measured, not assumed — the slice diffs verdict, arity, precedence,
1644
1984
  associativity, `highlight` run, unary placement and op-axis per version. So the
@@ -1733,13 +2073,14 @@ And the grounded complement — asked, and refused:
1733
2073
 
1734
2074
  <!-- BEGIN GENERATED token-census — regenerate with `bun run explain:token-census:readme` -->
1735
2075
  The token census is re-derivable with `bun run explain:token-census` and
1736
- covers 948 source scripts. The figures below are generated from
1737
- `test/fixtures/explain/tokens.json` → `corpus` by
1738
- `bun run explain:token-census:readme` and gated against it by
2076
+ covers 948 source scripts of corpus snapshot `e12d5d4e58a4`, with 882
2077
+ auxiliary rows (7 `capture-controls`, 875 `wiresage-conformance`) excluded.
2078
+ The figures below are generated from `test/fixtures/explain/tokens.json` →
2079
+ `corpus` by `bun run explain:token-census:readme` and gated against it by
1739
2080
  `bun run explain:token-census:readme:check`; the fixture itself is gated
1740
2081
  against a fresh corpus run by `bun run explain:token-census:check`. Of
1741
- 1,426,731 analyzed bytes, 1,023,107 are classified (71.71%), the remaining
1742
- 403,624 are `unclassified`. The census emits 241,539 tokens (avg 254.8 per
2082
+ 1,426,731 analyzed bytes, 1,022,786 are classified (71.69%), the remaining
2083
+ 403,945 are `unclassified`. The census emits 241,282 tokens (avg 254.5 per
1743
2084
  script). Every byte belongs to exactly one token — sorted by `start`, no
1744
2085
  gaps, no overlaps, `join(slice) === input` — and the `class` field is the
1745
2086
  vocabulary #264 B5 settled. Each B2 fill should move the classified
@@ -1820,7 +2161,7 @@ what was wrong was the accounting of it. They are re-derived by
1820
2161
  `bun run explain:bracket-equals:readme:check`.
1821
2162
 
1822
2163
  <!-- BEGIN GENERATED bracket-equals — regenerate with `bun run explain:bracket-equals:readme` -->
1823
- Measured by `bun run explain:bracket-equals` over the pinned corpus:
2164
+ Measured by `bun run explain:bracket-equals` over the population of corpus snapshot `e12d5d4e58a4`, with 882 auxiliary rows (7 `capture-controls`, 875 `wiresage-conformance`) excluded:
1824
2165
  **1,079** `=` bytes sit inside `[ … ]`, across
1825
2166
  263 of 948 scripts, and **1,067** of them come back
1826
2167
  `unclassified` — the operator fill abstains on essentially all of it. No
@@ -1835,15 +2176,15 @@ genuine comparisons — the device lowers `[find address=$IP]` to
1835
2176
  fill can take the query half later. For contrast the fill does claim
1836
2177
  **1,402** `=` bytes whose innermost opener is `(`.
1837
2178
 
1838
- The query reading is the device's, not a guess: on 7.24.2, 93 of the
1839
- 137 scripts carrying one have IL with at least as many `(= …)` nodes.
1840
- Of the rest, 28 are scripts the console refused outright — their IL is prose,
1841
- with no nodes of any kind — and 16 use a menu the capture build could not
1842
- resolve (a forum snippet on `/interface wireless` answers `bad command name
1843
- wireless`), so the device never reached the query to lower it. Neither is a
1844
- counter-example to the reading. Whole-corpus `(= …)` head nodes, which also
1845
- count expression comparisons the fill already claims:
1846
- 7.23.5 1,233, 7.24.2 1,240, 7.25beta3 1,240.
2179
+ The query reading is the device's, not a guess: on 7.24.5, 91 of the
2180
+ 137 scripts carrying one have IL with at least as many comparison nodes.
2181
+ Of the rest, 25 are scripts the console refused outright — their readout is
2182
+ prose, with no nodes of any kind — and 21 lowered fewer comparison nodes than the
2183
+ scan counted, typically because a command the capture build could not resolve
2184
+ (`bad command name …`) stopped the device before it reached the query. Neither is
2185
+ a counter-example to the reading. Whole-corpus `(= …)` head nodes per channel
2186
+ session, which also count expression comparisons the fill already claims:
2187
+ 7.23.8 1,300, 7.24.5 1,306, 7.25rc2 1,306, 7.26beta1 1,306.
1847
2188
  <!-- END GENERATED bracket-equals -->
1848
2189
 
1849
2190
  ### Device agreement for the token partition (#264 B4, #263)
@@ -1893,13 +2234,13 @@ to be filled by assumption.
1893
2234
 
1894
2235
  **The device stops classifying at its first `error`, and mostly does not
1895
2236
  recover.** Measured over the slice, `error` appears **at most once per script
1896
- and is always exactly one byte wide**, and on long-term 7.23.5 every byte after
2237
+ and is always exactly one byte wide**, and on long-term 7.23.8 every byte after
1897
2238
  it comes back `none`. Most of the slice sits in that tail. A percentage computed
1898
2239
  over the whole stream therefore measures the device giving up, not the parser,
1899
2240
  which is why the tail is its own outcome and is never folded into agreement. The
1900
2241
  rule is not absolute across versions, so the report does not assert it: stable
1901
- 7.24.2 and development 7.25beta3 classify again after the `error` in one run and
1902
- stop on fewer scripts, and
2242
+ 7.24.5, testing 7.25rc2 and development 7.26beta1 classify again after the
2243
+ `error` in one run and stop on fewer scripts, and
1903
2244
  `parser-recovered` in the table below is the figure that says by how much —
1904
2245
  zero would have been the claim, and it is not zero. Both counts are generated,
1905
2246
  not quoted here, so neither can go stale against the fixture.
@@ -1921,56 +2262,57 @@ a score.
1921
2262
  The agreement report is re-derivable with
1922
2263
  `bun run explain:highlight-agreement` and needs no router and no corpus: it
1923
2264
  scores the token partition against
1924
- `test/fixtures/explain/highlight-streams.slice.json`, 70 stratified scripts of
2265
+ `test/fixtures/explain/highlight-streams.slice.json`, 67 stratified scripts of
1925
2266
  committed `/console/inspect request=highlight` output. The figures below are
1926
2267
  generated from `test/fixtures/explain/highlight-agreement.json` by
1927
2268
  `bun run explain:highlight-agreement:readme`, gated against it by
1928
2269
  `bun run explain:highlight-agreement:readme:check`, and the fixture itself is
1929
2270
  gated against a fresh measurement by
1930
- `test/unit/explain-highlight-agreement.test.ts`. Of 85,529 bytes at 7.24.2,
1931
- 27,974 are bytes **both** sides decided a syntax class for, and 99.99% of
1932
- those agree (dev 99.99%, holdout 100.00%). Set `comment` aside — it is 65.63%
2271
+ `test/unit/explain-highlight-agreement.test.ts`. Of 81,143 bytes at 7.24.5,
2272
+ 27,538 are bytes **both** sides decided a syntax class for, and 99.99% of
2273
+ those agree (dev 99.99%, holdout 100.00%). Set `comment` aside — it is 63.66%
1933
2274
  of that decided region and mostly the corpus's harness-injected `# Source:`
1934
- banner (#203) — and the remaining 9,615 bytes agree 99.98%. The device stops
1935
- classifying at its one-byte `error`: 36 of 70 scripts carry one, and the
1936
- 41,968 bytes from there on are not a judgment about anything. The oracle
1937
- itself moves between captures: 36 stop at 7.24.2 and 43 stop at 7.23.5 and 36
1938
- stop at 7.25beta3, and 27 of the 70 streams differ between them. **This is a
1939
- trend line, not a pass gate** — the slice is a per-(split, class) quota over a
1940
- corpus that is 96.8% two authors, so the percentage describes this slice.
2275
+ banner (#203) — and the remaining 10,008 bytes agree 99.98%. The device stops
2276
+ classifying at its one-byte `error`: 34 of 67 scripts carry one, and the
2277
+ 38,278 bytes from there on are not a judgment about anything. The oracle
2278
+ itself moves between captures: 34 stop at 7.24.5 and 39 stop at 7.23.8 and 34
2279
+ stop at 7.25rc2 and 34 stop at 7.26beta1, and 25 of the 67 streams differ
2280
+ between them. **This is a trend line, not a pass gate** — the slice is a
2281
+ per-(split, class) quota over a corpus that is 96.8% two authors, so the
2282
+ percentage describes this slice.
1941
2283
 
1942
2284
  | centrs class | projects to | agree | disagree | unprojected | of decided |
1943
2285
  | ------------ | ----------- | ----: | -------: | ----------: | ---------: |
1944
- | `comment` | `comment` | 18,359 | 0 | 0 | 100.00% |
1945
- | `variable-local` | `variable-local` | 1,123 | 0 | 0 | 100.00% |
2286
+ | `comment` | `comment` | 17,530 | 0 | 0 | 100.00% |
2287
+ | `variable-local` | `variable-local` | 1,547 | 0 | 0 | 100.00% |
1946
2288
  | `variable-global` | `variable-global` | 582 | 0 | 0 | 100.00% |
1947
- | `variable-auto` | `variable-auto` | 92 | 0 | 0 | 100.00% |
1948
- | `variable-parameter` | `variable-parameter` | 459 | 0 | 0 | 100.00% |
1949
- | `dir` | `dir` | 1,677 | 0 | 0 | 100.00% |
1950
- | `cmd` | `cmd` | 2,244 | 0 | 0 | 100.00% |
1951
- | `arg` | `arg`, `arg-dot`, `arg-scope` | 2,188 | 0 | 0 | 100.00% |
1952
- | `arg-sep` | `syntax-meta` | 382 | 0 | 0 | 100.00% |
1953
- | `operator` | `syntax-meta` | 237 | 2 | 0 | 99.16% |
1954
- | `brace` | `syntax-meta` | 211 | 0 | 0 | 100.00% |
1955
- | `string` | *abstains* | 0 | 0 | 499 | — |
2289
+ | `variable-auto` | `variable-auto` | 93 | 0 | 0 | 100.00% |
2290
+ | `variable-parameter` | `variable-parameter` | 456 | 0 | 0 | 100.00% |
2291
+ | `dir` | `dir` | 1,701 | 0 | 0 | 100.00% |
2292
+ | `cmd` | `cmd` | 2,224 | 0 | 0 | 100.00% |
2293
+ | `arg` | `arg`, `arg-dot`, `arg-scope` | 2,167 | 0 | 0 | 100.00% |
2294
+ | `arg-sep` | `syntax-meta` | 368 | 0 | 0 | 100.00% |
2295
+ | `operator` | `syntax-meta` | 240 | 2 | 0 | 99.17% |
2296
+ | `brace` | `syntax-meta` | 210 | 0 | 0 | 100.00% |
2297
+ | `string` | *abstains* | 0 | 0 | 573 | — |
1956
2298
  | `escaped` | `escaped` | 418 | 0 | 0 | 100.00% |
1957
- | `value` | *abstains* | 0 | 0 | 144 | — |
1958
-
1959
- | outcome | bytes @7.24.2 | bytes @7.23.5 | bytes @7.25beta3 |
1960
- | ------- | ----: | ----: | ----: |
1961
- | agree — both decided, projection accepts | 27,972 | 27,218 | 27,972 |
1962
- | disagree — both decided, projection rejects | 2 | 2 | 2 |
1963
- | unprojected — no declared projection covers the pair | 643 | 633 | 643 |
1964
- | offline-silent — device decided, centrs abstained | 4,300 | 4,150 | 4,300 |
1965
- | non-syntax — the device answered something syntax cannot decide | 847 | 733 | 847 |
1966
- | device-silent — centrs decided, device said `none` | 5,815 | 5,674 | 5,815 |
1967
- | both-silent | 3,886 | 3,702 | 3,886 |
1968
- | parser-stopped — at/after the device's `error` byte, and silent from there | 41,968 | 43,417 | 41,968 |
1969
- | parser-recovered — past that `error`, and the device classified anyway | 96 | 0 | 96 |
1970
-
1971
- Where the device decided and offline analysis did not (4,300 bytes at 7.24.2,
1972
- the next fill's target list): `syntax-meta` 3,122, `comment` 355,
1973
- `variable-local` 326, `arg` 197, `escaped` 145, `dir` 66, 89 across the rest.
2299
+ | `value` | *abstains* | 0 | 0 | 135 | — |
2300
+
2301
+ | outcome | bytes @7.24.5 | bytes @7.23.8 | bytes @7.25rc2 | bytes @7.26beta1 |
2302
+ | ------- | ----: | ----: | ----: | ----: |
2303
+ | agree — both decided, projection accepts | 27,536 | 27,173 | 27,536 | 27,536 |
2304
+ | disagree — both decided, projection rejects | 2 | 2 | 2 | 2 |
2305
+ | unprojected — no declared projection covers the pair | 708 | 705 | 708 | 708 |
2306
+ | offline-silent — device decided, centrs abstained | 4,288 | 4,187 | 4,288 | 4,288 |
2307
+ | non-syntax — the device answered something syntax cannot decide | 731 | 662 | 721 | 721 |
2308
+ | device-silent — centrs decided, device said `none` | 5,676 | 5,606 | 5,676 | 5,676 |
2309
+ | both-silent | 3,828 | 3,736 | 3,838 | 3,838 |
2310
+ | parser-stopped — at/after the device's `error` byte, and silent from there | 38,278 | 39,072 | 38,278 | 38,278 |
2311
+ | parser-recovered — past that `error`, and the device classified anyway | 96 | 0 | 96 | 96 |
2312
+
2313
+ Where the device decided and offline analysis did not (4,288 bytes at 7.24.5,
2314
+ the next fill's target list): `syntax-meta` 3,090, `comment` 341,
2315
+ `variable-local` 328, `arg` 230, `escaped` 145, `dir` 65, 89 across the rest.
1974
2316
  Most of the `syntax-meta` share is whitespace the device merged into an
1975
2317
  adjacent structure run rather than a token centrs missed.
1976
2318
 
@@ -1982,14 +2324,14 @@ every measured byte, the post-`error` tail included: where one capture stops
1982
2324
  parsing and the next does not, that tail IS the version disagreement, which is
1983
2325
  why it dominates the category.
1984
2326
 
1985
- | applicability @7.24.2 | bytes | leading cells |
2327
+ | applicability @7.24.5 | bytes | leading cells |
1986
2328
  | ---------------------------- | ----: | ------------- |
1987
- | offline-decidable | 32,002 | `comment` → `comment` 18,006, `unclassified` → `syntax-meta` 2,999, `cmd` → `cmd` 2,132 |
1988
- | schema-dependent | 326 | `arg` → `obj-inactive` 326 |
1989
- | state-dependent | 168 | `dir` → `obj-inactive` 120, `cmd` → `obj-inactive` 21, `unclassified` → `obj-dynamic` 20 |
1990
- | version-dependent | 1,158 | `comment` → `comment` 353, `unclassified` → `obj-inactive` 159, `unclassified` → `syntax-meta` 123 |
1991
- | uncategorized | 213 | `unclassified` → `obj-inactive` 90, `unclassified` → `variable-undefined` 75, `value` → `variable-undefined` 21 |
1992
- | no-device-answer | 51,662 | `none` where the captures agree, wherever it falls |
2329
+ | offline-decidable | 32,043 | `comment` → `comment` 17,395, `unclassified` → `syntax-meta` 3,002, `cmd` → `cmd` 2,158 |
2330
+ | schema-dependent | 307 | `arg` → `obj-inactive` 307 |
2331
+ | state-dependent | 94 | `dir` → `obj-inactive` 49, `unclassified` → `obj-dynamic` 20, `cmd` → `obj-inactive` 18 |
2332
+ | version-dependent | 707 | `comment` → `comment` 135, `unclassified` → `obj-inactive` 124, `unclassified` → `syntax-meta` 88 |
2333
+ | uncategorized | 240 | `unclassified` → `obj-inactive` 116, `unclassified` → `variable-undefined` 75, `value` → `variable-undefined` 21 |
2334
+ | no-device-answer | 47,752 | `none` where the captures agree, wherever it falls |
1993
2335
 
1994
2336
  One representative run per cell, so B5 reads a fragment rather than a count.
1995
2337
  `runs` is how often that exact fragment occurs; the location is a slice script
@@ -1998,9 +2340,8 @@ and a byte offset into the stream the device saw.
1998
2340
  | outcome | cell | fragment | runs | first at |
1999
2341
  | ------- | ---- | -------- | ---: | -------- |
2000
2342
  | disagree | `operator` → `arg` | `"in"` | 1 | `forum/amm0/topic-169456-having-the-where-filter-in-scripting-signifantly-increases-the-execution-time-an/post-0006-snippet-01.rsc` @615 |
2001
- | unprojected | `string` → `syntax-meta` | `"\""` | 311 | `eworm/ppp-on-up.rsc` @435 |
2002
- | unprojected | `value` → `syntax-meta` | `"\""` | 88 | `forum/amm0/topic-153357-using-wifiwave2-to-bridge-two-audience-wirelessly-thoughts-4-address-mode/post-0001-snippet-01.rsc` @1436 |
2003
- | unprojected | `string` → `arg` | `"as-value"` | 1 | `forum/amm0/topic-266499-how-to-condense-variables-passed-through-to-function/post-0020-snippet-01.rsc` @279 |
2343
+ | unprojected | `string` → `syntax-meta` | `"\""` | 326 | `eworm/ppp-on-up.rsc` @435 |
2344
+ | unprojected | `value` → `syntax-meta` | `"\""` | 84 | `forum/amm0/topic-153357-using-wifiwave2-to-bridge-two-audience-wirelessly-thoughts-4-address-mode/post-0001-snippet-01.rsc` @1436 |
2004
2345
  | unprojected | `string` → `cmd` | `"len"` | 1 | `forum/amm0/topic-155978-zerotier-on-mikrotik-a-rosetta-stone-v7-1-1/post-0001-snippet-01.rsc` @1794 |
2005
2346
  | unprojected | `string` → `dir` | `":"` | 1 | `forum/amm0/topic-155978-zerotier-on-mikrotik-a-rosetta-stone-v7-1-1/post-0001-snippet-01.rsc` @1793 |
2006
2347
  <!-- END GENERATED highlight-agreement -->
@@ -2090,9 +2431,10 @@ the 16,526 bytes `arg-sep` claims, one per attribute. Token count rises 214,503
2090
2431
  → 231,026 because one run became two. **This is a retag.**
2091
2432
 
2092
2433
  (Those totals are the corpus as it stood for THIS measurement. The classified
2093
- total is 1,023,107 today: #347/#348 later classified 8 further bytes, and #355's
2434
+ total is 1,022,805 today: #347/#348 later classified 8 further bytes, #355's
2094
2435
  fail-closed symbol stop then unclaimed 306 of them — variable bytes sitting
2095
- after a bare apostrophe, which the device never reaches. The identity being
2436
+ after a bare apostrophe, which the device never reaches — and #211's unlisted
2437
+ bare-path abstention unclaimed 302 more. The identity being
2096
2438
  claimed here is between the two sides of the split, not with any later total —
2097
2439
  restating it against today's figure would credit this retag with a movement a
2098
2440
  different change made. The generated block above carries the current numbers.)
@@ -2180,7 +2522,7 @@ bytes came from a fill that already held it — `string` gives up 17,672 and
2180
2522
  last decimal**, 1,023,405 of 1,426,731 (71.73076073906013%), so in byte terms
2181
2523
  this is a retag even though it took a new walk to make — again, the identity is
2182
2524
  between this change's own before and after, and the corpus total has since moved
2183
- to 1,023,107 / 241,539 tokens under #347/#348 and #355. Only the token count
2525
+ to 1,022,805 / 241,442 tokens under #347/#348, #355 and #211. Only the token count
2184
2526
  moves, 231,026 → 241,663: the 7,408 new `escaped` tokens plus the 3,229
2185
2527
  fragments they leave behind when they split a run they sit inside (`string`
2186
2528
  +2,643, `value` +586). That second number is well under one per escape because
@@ -2219,22 +2561,317 @@ last byte. The `$` sigil and the `[` / `]` of a substitution stay `string` on
2219
2561
  the same ground — `structure.subcommands` already publishes the resolution, and
2220
2562
  its `innerSpan` locates the brackets around it.
2221
2563
 
2222
- ### Designed, not implemented (the CLI surface, #202b)
2223
-
2224
- `src/cli/explain.ts` ships the offline command; implemented flags are generated
2225
- into [`docs/CLI.md`](../../docs/CLI.md) and are not restated here. What the
2226
- [Surface](#surface-option-a--decided) table above describes and the command does
2227
- **not** yet accept:
2228
-
2229
- | Flag / form | Phase | Why it is not accepted yet |
2230
- | ----------- | ----- | -------------------------- |
2231
- | `explain <router> '<input>'` | 2 | The grammar is parsed and **refused** with `usage/not-implemented`, naming the router it did not contact. Running the offline analysis under an invocation that asked for probes would report `mode: "offline"` about a request for more. |
2232
- | `--cursor <byte>` | 2 | It positions `--complete`, which enumerates nothing offline. A cursor with no candidate surface to position is a flag with no effect. |
2233
- | `--full` | 3 | It lifts smart-sizing limits, and the offline result is never truncated (`input.truncated` is always `false`). Same rule: no accepted flag is a no-op. |
2234
-
2235
- `--complete` and `--schema` **are** accepted, because they have an offline
2236
- behavior the spec names: they enumerate nothing and emit the live-target tip.
2237
- That is the difference — an accepted flag must do something observable.
2564
+ ### Live probe foundation (#491)
2565
+
2566
+ `centrs explain <router> '<input>'` (or `--quickchr <name>` / `--host <host>`)
2567
+ adds read-only device evidence over REST or native API. Library callers use
2568
+ `explainLiveEnvelope({ input, targetInput, via, ... }, options)` from the root
2569
+ package; the browser-safe `@tikoci/centrs/explain` entry remains offline.
2570
+ Connection/authentication settings use the existing resolver and provider
2571
+ precedence. A pinned unsupported transport is refused. No fan-out or protocol
2572
+ fallback is performed. `AbortSignal` cancels library probes.
2573
+ An explicitly supplied connection flag such as `--via`, `--password` or
2574
+ `--timeout` without `<router>`, `--host` or `--quickchr` is a usage error before
2575
+ input is read. Connection defaults from environment and config remain inert in
2576
+ offline mode.
2577
+ Text output summarizes live observations; `--json` retains full parse and
2578
+ per-byte highlight evidence.
2579
+
2580
+ `data.canonical`, structure, symbols, values and optional tokens retain the
2581
+ offline result. Live observations are additional facts:
2582
+
2583
+ - `data.live.environment` records the observed version, architecture and board.
2584
+ - `data.live.parse` carries the printed parse result, rejection classification
2585
+ and authoritative line/byte-column when reported. Only an escaped
2586
+ `:put [:parse "…"]` wrapper runs; the analyzed input, substitutions and
2587
+ deferred bodies are never executed.
2588
+ - `data.live.highlight` retains raw device classes. Additional `data.spans`
2589
+ cite its evidence and use centrs classes: `dir`, `cmd`, `arg`, `escaped`,
2590
+ comments and variable classes; device state, syntax-meta, error/obsolete and
2591
+ unknown classes map to `unclassified`. Error/obsolete markers produce separate
2592
+ diagnostics. Offline and live spans can overlap; distinguish them by `ev`.
2593
+ - `data.live.arguments[]` contains `path`, `names`, `statementIds` and `ev` for
2594
+ command-level `request=child`. Repeated paths share a probe. Names confirm what
2595
+ the device exposes; they do not rewrite offline arguments, transport eligibility
2596
+ or lexical bindings. The highlight obsolete marker distinguishes `name x`
2597
+ from the two flags `detail without-paging` (example 48).
2598
+
2599
+ Every live evidence entry identifies its probe, observation time, known version
2600
+ and acquisition outcome. Input probes include SHA-256 and normalization;
2601
+ `coverage` identifies the submitted byte range for a usable response, not proof
2602
+ that every byte is syntactically valid. Parse uses original UTF-8 input;
2603
+ highlight uses the existing one-SUB-per-non-ASCII-byte normalization. Its facts
2604
+ therefore describe that explicit variant; both spaces align with `positionMap`.
2605
+ Parse can stop at its first error; highlight `none` remains unclassified.
2606
+
2607
+ Probe outcomes distinguish `ok`, `empty`, `unsupported`, `skipped`, `timeout`,
2608
+ `auth-error`, `transport-error`, `interrupted` and `invalid-response`. A parse
2609
+ rejection is an acquired response (`ok`) plus a diagnostic, not a transport
2610
+ failure. A failed/skipped probe adds a warning diagnostic; acquired offline and
2611
+ live facts remain available. `ok:true` means analysis produced a result, never
2612
+ that all probes succeeded. Resolver/usage failures and transport/authentication/interruption failures before
2613
+ any input probe yields a result remain `ok:false`.
2614
+ `meta.operation.mode` is `live`; no execution-validation verdict is invented:
2615
+ `meta.validation` is `{ enabled: false }`, offline and live, because explain
2616
+ is not a gate (#535). `runtimeAcceptance` always remains `not-proven`.
2617
+
2618
+ Work is bounded by the resolved per-request timeout, 32 distinct command paths,
2619
+ 32,767 bytes for highlight and 65,536 bytes for parse. Larger probes are skipped
2620
+ with their reason; no silent prefix is submitted and `input.truncated` stays
2621
+ false. A transport/authentication failure stops later requests. Scripting-path
2622
+ discovery is conservatively skipped before 7.21.4 or on unknown versions. No
2623
+ command-level completion, user-expression evaluation or related
2624
+ configuration-table collection is part of this slice. `--schema` and
2625
+ `--complete` add the probes in the next section, and the value-type probes
2626
+ after it run on every live call.
2627
+
2628
+ Committed controls are
2629
+ [`live-probes.stable.json`](../../test/fixtures/explain/live-probes.stable.json)
2630
+ (7.24.5) and
2631
+ [`live-probes.long-term.json`](../../test/fixtures/explain/live-probes.long-term.json)
2632
+ (7.23.8), both CHR reporting `x86_64`, with package manifests, input hashes,
2633
+ observation times, raw parse/highlight output and projected child names. Each
2634
+ fixture records its capture command. `test/unit/explain-live.test.ts` replays
2635
+ the parse classifier and source-aligned highlighter against these controls;
2636
+ `test/integration/explain-live.test.ts` repeats the probes on a real CHR and
2637
+ checks that the analyzed identity-setting command did not run.
2638
+
2639
+ ### Live describe and completion (#492)
2640
+
2641
+ `--schema` and `--complete` work on the statement at the cursor. The cursor
2642
+ defaults to the end of the input. `--cursor <byte>` is a UTF-8 byte offset,
2643
+ which is also the analyzed and device byte offset. Library callers may pass
2644
+ `cursor: { line, character }` (0-based, UTF-16) instead. A cursor inside a
2645
+ multi-byte character moves back to its first byte. A cursor outside the input
2646
+ is [`usage/invalid-cursor`](../../docs/errors/usage/invalid-cursor.md), raised
2647
+ before connecting. `--cursor` or `--full` without a facet is
2648
+ `usage/conflicting-flags`. Offline, both facets still return the live-target
2649
+ tip and enumerate nothing.
2650
+
2651
+ **Probe shapes.** Grounded on CHR 7.12.2, 7.20.8, 7.23.8 and 7.24.5 over REST
2652
+ and native API:
2653
+
2654
+ - `child` names what exists at a path and resolves unique abbreviations
2655
+ (`ip,addr` answers as `address`). It is safe on every build tried.
2656
+ - `syntax` is sent only for an **argument** path (`ip,address,set,address`,
2657
+ `put,message`). That shape answers in about 25 ms on every build, scripting
2658
+ keywords included.
2659
+ - `completion` is sent only with `input=`, the text before the cursor. It is
2660
+ safe at every position tried, including scripting keywords and `do={`
2661
+ bodies on 7.20.8.
2662
+ - Never sent: `syntax` or `completion` with `path=` set to a **command**
2663
+ (`ip,address,set`). On 7.12.2 and 7.20.8 REST stops answering, and the
2664
+ requests queued behind it stall, for about 30 s. Native API closes the
2665
+ connection. 7.23.8 and later answer normally, but the rule is
2666
+ version-independent (`commands/api/AGENTS.md`). restraml's crawlers never
2667
+ meet this, because they send `syntax` to `arg` nodes only.
2668
+
2669
+ Every probe is an `evidence[]` entry with `facet: "schema" | "complete"`, its
2670
+ path, version and outcome. Facts cite it by `ev`. A facet probe that answers
2671
+ `empty` is a result, so it adds no warning diagnostic. Other failed outcomes
2672
+ warn as in the foundation, and a transport or authentication failure stops
2673
+ later probes.
2674
+
2675
+ **`data.schema`** describes one path: the resolved statement, or an absolute
2676
+ slash path as typed (`/ip/addr`). The `child` self row decides `node`:
2677
+
2678
+ - `menu`: `verbs[]` and `menus[]`. Each child is expanded by one more `child`
2679
+ probe into `arguments` (verb) or `verbs`/`menus` (submenu) counts.
2680
+ - `command`: `arguments[]`, each with its argument-level `syntax` rows. A
2681
+ flag's blank row is dropped. For `print`, `proplist` holds the output-field
2682
+ names from `print proplist=` (`source: "print-proplist"`). A singleton
2683
+ settings menu offers none there, so `get value-name=` answers instead
2684
+ (`source: "get-value-name"`; `/ip/dns`, `/system/identity`). These are names
2685
+ a print can show, **not print rows**.
2686
+ - `argument`: its `syntax` rows.
2687
+ - `unknown`: the device reports nothing there, or the statement at the cursor
2688
+ is neither resolved nor an absolute path (`reason`).
2689
+
2690
+ Syntax rows are kept verbatim (`symbol`, RouterOS `symbol-type` as `kind`,
2691
+ `text`, `nested` as `depth`). They are declared help, such as
2692
+ `A.B.C.D (IP address)` or `input | forward | output` beside `string value`.
2693
+ They are not the `schemaType` value fact, which normalizes the same rows per
2694
+ value ([Live value types](#live-value-types-225)).
2695
+
2696
+ **Smart sizing.** By default each list shows up to 64 names, and expansion
2697
+ runs only when the listed children fit in 16 probes (a menu's children, or a
2698
+ command's arguments for their `syntax`). Otherwise names are listed without
2699
+ expansion. `counts` always holds the full sizes, `truncated: true`, `next[]`
2700
+ names up to three narrower inputs, and the envelope carries
2701
+ `tip/explain-schema-truncated`. On CHR 7.24.5, `/ip` (2 verbs, 29 menus) and
2702
+ `/ip/address/print` (21 arguments) truncate; `/ip/address` (12 verbs) and
2703
+ `/ip/firewall/filter` (16) do not. `--full` raises the limits to 1024 names and
2704
+ 128 probes. That is the safety ceiling, not unlimited.
2705
+
2706
+ **`data.completion`** reports `cursor`, `at` (original line/character),
2707
+ `replace` (the bytes a candidate replaces, ending at the cursor), `prefix`,
2708
+ `context`, `candidates[]`, `count` and `truncated`. Candidates are capped like
2709
+ names. They are the device's own: one input completion of the text before the
2710
+ cursor, keeping shown, non-syntax rows. The row `offset` sets `replace`. The
2711
+ navigation punctuation (`/`, `:`, `..`) offered everywhere is left out. So a
2712
+ relative path, a block, a `where` clause or an unclosed `[` completes exactly
2713
+ as on the console: `/ip { :put 1; address add comm` → `comment`, and
2714
+ `/ip address⏎` → that menu's verbs. Where the device offers nothing, neither
2715
+ does explain: `do={ add comm` runs at root, so `add` is unknown there.
2716
+
2717
+ The `context` comes from the device highlight of the same text. When the
2718
+ cursor is not at the end, that is one extra highlight probe whose `coverage`
2719
+ says so.
2720
+
2721
+ - `path`: in a `dir`/`cmd` word, after a menu, or at the start of a line.
2722
+ Candidates are `menu` or `verb` by row style. A word the device cannot
2723
+ resolve there is `obj-inactive`, and the token before it gives its role:
2724
+ `/ip/address re` → `remove`, `reset`, and `:lo` → `local`, `lock`, `log`.
2725
+ - `argument-name`: in an `arg` word, or after a command, flag or value.
2726
+ `match: "exact"` marks a complete name. `takesValue` is whether the same
2727
+ answer offers `=` at the cursor: `comment` does, and the flag `detail` does
2728
+ not.
2729
+ - `variable`: a `where` clause, whose property names RouterOS scopes as
2730
+ variables (`where comm` → `comment`).
2731
+ - `value`: after `name=`. Candidates may include configuration state, such as
2732
+ interface names. They are never a closed set:
2733
+ `chain=` offers `forward`, `input` and `output`, but custom chains are legal.
2734
+ For a command argument, `syntax` carries its declared type text, so a
2735
+ free-form `comment=` returns no candidates and `string value`. A `where`
2736
+ value (`disabled=` → `no`, `yes`) has no argument path, so no `syntax` is
2737
+ read.
2738
+ - `unsupported` with a `reason`: strings, the obsolete spaced `name value`
2739
+ spelling, and other tokens the device does not class as a path, name or
2740
+ value.
2741
+
2742
+ Every candidate is an observation of the completion probe its `ev` cites.
2743
+ The declared side is `data.schema`: `child` names and argument `syntax`. So
2744
+ completion carries no second provenance label, and nothing in it is a closed
2745
+ set. `path` names the command at the
2746
+ cursor when it is known. It comes from the offline statement or `[…]`
2747
+ substitution when that resolved, otherwise from the device's `dir`/`cmd` runs.
2748
+ The command is needed only for a value's `syntax`. When it is not visible (a
2749
+ relative path after `;`), the candidates are unaffected and `syntax` is
2750
+ omitted.
2751
+
2752
+ The verdict still judges the input as written, and the two probes agree on
2753
+ it. `/ip/address add comm` is rejected by `:parse` (`expected end of command`
2754
+ at byte 16), and the highlight marks the same byte `error`. So the result
2755
+ fails even while its candidates are published. An assist loop that only wants
2756
+ candidates passes `--fail-on never`.
2757
+
2758
+ Committed controls are
2759
+ [`live-facets.stable.json`](../../test/fixtures/explain/live-facets.stable.json)
2760
+ (7.24.5) and
2761
+ [`live-facets.long-term.json`](../../test/fixtures/explain/live-facets.long-term.json)
2762
+ (7.23.8). They hold every recorded call and its raw answer, plus the projected
2763
+ result. `test/unit/explain-live-facets.test.ts` replays them.
2764
+ `test/integration/explain-live-facets.test.ts` asserts the examples on both
2765
+ transports and regenerates the captures when `CENTRS_EXPLAIN_FACETS_CAPTURE`
2766
+ names an output file.
2767
+
2768
+ Not covered here: MCP (#223), related print rows and topic checks (#493),
2769
+ and per-target caching of describe results (open 3).
2770
+
2771
+ ### Live value types (#225)
2772
+
2773
+ Every live call fills the two live homes on `data.values.occurrences[].facts`
2774
+ that offline leaves empty. Neither changes the verdict or
2775
+ `runtimeAcceptance`, and neither becomes a diagnostic. Both probes are
2776
+ `evidence[]` entries with `facet: "types"` and `basis: "derived"`; an `empty`
2777
+ answer is a result, not a warning. They run after `--schema`/`--complete`.
2778
+
2779
+ **`observedType: { value, rendered, ev }`** is the type the device's parser
2780
+ visibly converted a bare literal to. `:parse` prints a string unquoted, so
2781
+ `:local a 123` and `:local b "123"` both render `value=123`. A rendering is
2782
+ therefore evidence only when it CHANGED the literal:
2783
+
2784
+ | Literal | Device rendering | `observedType` |
2785
+ | ------- | ---------------- | -------------- |
2786
+ | `2.2`, `2.2/8` | `2.0.0.2`, `2.0.0.2/8` | `ip`, `ip-prefix` |
2787
+ | `1:0:0::1` | `1::1` | `ip6` |
2788
+ | `0x10`, `0123`, `99999999999999999999` | `16`, `123`, `9223372036854775807` | `num` |
2789
+ | `yes`, `no` | `true`, `false` | `bool` |
2790
+ | `200ms`, `1d`, `1:2`, `10000w` | `00:00:00.200`, `1d00:00:00`, `01:02:00`, `10000w00:00:00` | `time` |
2791
+ | `123`, `1.1.1.1`, `true`, `00:00:02`, `"2.2"` | unchanged | absent |
2792
+ | `100000w`, `abc`, `1e5`, `300.1.1.1` | unchanged | absent |
2793
+ | member `{abc}`, `{-1}` | `$abc`, `(- 1)` | absent |
2794
+
2795
+ The literal is not looked up in the IL of the analyzed input, where a quoted
2796
+ `"x;y=1"` prints exactly like two arguments. Each bare literal is re-parsed
2797
+ alone: `:local vN <lit>` for a scripting operand, `:local vN {<lit>}` for an
2798
+ array member, `;`-joined into one escaped `:put [:parse "…"]` per context.
2799
+ `:put <lit>` converts exactly as `:local` does. Only literals of the bytes
2800
+ `A-Z a-z 0-9 . _ : / -` are sent, so no literal can leave its slot, and a
2801
+ quoted value never qualifies. One hard-error member (`{*1}`) rejects a whole
2802
+ batch, so the two contexts are separate probes and a rejected batch is
2803
+ `invalid-response` with the device's message; its literals abstain. A batch is
2804
+ at most 256 literals, otherwise `skipped`.
2805
+
2806
+ A scripting directive's named argument converts exactly as an operand
2807
+ (`:for i from=0x10` → `from=16`, `:put message=yes` → `message=true`), so it
2808
+ joins the operand batch. A directive's NAME slot is a variable and never
2809
+ converts: `:local yes 2` renders `name=$yes`, `:for yes from=1` renders
2810
+ `counter=$yes` and `:local name=no` renders `name=$no`. So a value the symbol
2811
+ view claims as a name, or a directive's `name=`/`counter=` argument, is never
2812
+ probed.
2813
+
2814
+ Menu-command arguments are never observed. Their own parser converts them
2815
+ and prints a per-argument encoding (`src-address=!1.1.1.1` →
2816
+ `true;1.1.1.1`, `address=2.2` → `2.0.0.2;`), and a quoted value converts only
2817
+ at run time (`address="2.2"` stays `2.2`). That slot is `schemaType`'s. `op`
2818
+ has no producer: `(>[:return 1])` and `(>{"a"=1})` parse identically, and only
2819
+ `:typeof` separates them.
2820
+
2821
+ **`schemaType: { values, list?, declared, ev }`** is the argument's declared
2822
+ type. For each `name=value` of a resolved menu command, the name is resolved
2823
+ against that command's live `child` names, exactly or as a unique prefix
2824
+ (`dist=` → `distance`). One `syntax` probe per distinct argument path
2825
+ (`ip,route,add,distance`) reads the declaration; at most 32 per call, later
2826
+ ones `skipped`. An ambiguous or unknown name sends no probe. `syntax` is never
2827
+ sent to a command path (see [Live describe and completion](#live-describe-and-completion-492)).
2828
+
2829
+ The rows are a small grammar (`[!]Range`, `Address[%Interface][@VRF]`), so
2830
+ `values` lists the LEAF types the declaration mentions, not alternatives of
2831
+ one value. They use the shape-hint names so the two axes compare directly,
2832
+ plus three schema-only terms:
2833
+
2834
+ | Device leaf | Term |
2835
+ | ----------- | ---- |
2836
+ | `(integer number)`, `(decimal number)`, `integer value`, `0..65535` | `num` |
2837
+ | `(time interval)`, `time interval` | `time` |
2838
+ | `(IP address)`, `IPv4 address`, `A.B.C.D` | `ip` |
2839
+ | `IPv6 address` | `ip6` |
2840
+ | `(IP prefix)`, `(IPv6 prefix)` | `ip-prefix`, `ip6-prefix` |
2841
+ | `(IP address range)` | `ip-range` |
2842
+ | `(MAC address)` | `mac` |
2843
+ | `string value…`, `hexadecimal string value…`, `script`, `filename` | `str` |
2844
+ | a listed word set (`input \| forward \| output`, `ether1 \| lo \| ...`) | `enum` |
2845
+
2846
+ `enum` is never a closed set: the device lists the current configuration
2847
+ names (`ether1 | lo`) exactly like keywords, and ends either kind with `...`
2848
+ when the list is long (`ftp | h323 | … | ...`). `list: true` is present when
2849
+ the declaration spells a repetition (`[,Type*]`, `(max 15 times)`); its
2850
+ absence is not a claim — `servers` (`Address[@VRF]`) splits on a comma
2851
+ without saying so. A keyword's help row (`tcp` → `transmission control
2852
+ protocol`) is not a leaf, and a blank placeholder beside a real leaf is
2853
+ skipped. The fact is ABSENT when the declaration is blank (every flag and
2854
+ boolean: `disabled`, `copy-from`) or when any leaf is `see documentation`
2855
+ (`/ipv6/address add address`). `declared` is the device's top-level text,
2856
+ verbatim (`Address[@VRF]`, `Num`).
2857
+
2858
+ The vocabulary also names `bool` and `name` for a second schema source: the
2859
+ CLI Reference's `Type` cells (`bool`, `iface_enum`, `ipAddr`, `macAddr`,
2860
+ `ubit (…)`), which declare the booleans and object references inspect leaves
2861
+ blank or prints as words. A CLI Reference producer would fill the same fact,
2862
+ told apart by its `ev`; delivering those tables is decision 3 (#501).
2863
+
2864
+ Grounded on CHR 7.24.5 and 7.23.8 over REST and native API, identical on both:
2865
+ a 40-command crawl (863 arguments) set the leaf table (571 project, 282 are
2866
+ blank, 7 cite `see documentation` and 3 list no names), and the conversion
2867
+ matrix is recorded raw in
2868
+ [`live-types.stable.json`](../../test/fixtures/explain/live-types.stable.json)
2869
+ and [`live-types.long-term.json`](../../test/fixtures/explain/live-types.long-term.json).
2870
+ `test/unit/explain-live-types.test.ts` replays them and pins the matrix
2871
+ reading; `test/integration/explain-live-types.test.ts` asserts examples 52–53
2872
+ on both transports and that every executed script is a `:parse` wrapper and
2873
+ every `syntax` probe names a discovered argument. It regenerates the captures
2874
+ when `CENTRS_EXPLAIN_TYPES_CAPTURE` names an output file.
2238
2875
 
2239
2876
  ## MCP and library surfaces
2240
2877
 
@@ -2450,31 +3087,65 @@ config genre in question). Since phase 0 the corpus has grown to 948 with a
2450
3087
  export-banner share to 4.6%; **that stratum is not part of any phase-0 figure
2451
3088
  above**. Current whole-corpus offline censuses include its source text.
2452
3089
 
2453
- The pinned snapshot carries **three complete builds** — long-term `7.23.5`,
2454
- stable `7.24.2` and development `7.25beta3` — each with *both* oracles over all
2455
- 948 scripts, alongside six partial captures (parseIL at 7.20.8, 7.22.1,
2456
- 7.23rc1; highlight at 7.9.2, 7.23.2, 7.24rc2) that cover only 913. Schema v4
2457
- names that split rather than leaving it to be discovered by hand: the
2458
- `v_version_coverage` view classifies each build, and
2459
- `coverage_class = 'complete'` is the filter every version-comparable query uses.
2460
- `source_scripts` is byte-identical across both repins that reached this
2461
- snapshot, so every text-derived report — the token and value censuses,
2462
- `explain:arg-reach`, the browser-consumer proof — is unchanged by construction,
2463
- and only the IL-derived operator census moved.
2464
-
2465
- **The three complete builds do not agree with each other, and that is the
2466
- finding.** Scored over all 948 scripts:
2467
-
2468
- | oracle | long-term 7.23.5 ↔ stable 7.24.2 | stable 7.24.2 ↔ development 7.25beta3 |
2469
- | ------ | -------------------------------: | ------------------------------------: |
2470
- | `:parse` IL (`il_sha256`) | 87 scripts differ | 11 |
2471
- | `highlight` (count + class set) | 33 | 0 |
2472
-
2473
- Stable and development are the same build for these purposes; long-term is the
2474
- one that moves. So an operator-axis or token-partition figure is a claim *about
2475
- a channel*, not about RouterOS — which is why the census reports its builds as
2476
- separate columns and states its version set, rather than collapsing them to one
2477
- number. Long-term is also the channel a real device most likely runs.
3090
+ The pinned snapshot (schema 5, tikoci/lsp-routeros-ts#40, plus the
3091
+ `wiresage-conformance` collection from tikoci/lsp-routeros-ts#42) carries one
3092
+ recorded capture session per release channel — long-term `7.23.8`, stable `7.24.5`,
3093
+ testing `7.25rc2` and development `7.26beta1`. Each is a `:parse` run and a
3094
+ `highlight` run over all 948 population scripts, bound to the exact submitted
3095
+ bytes and one device environment with every package installed. Two legacy
3096
+ `:parse` captures (7.20.8 and 7.22.1: 913 scripts, unknown input bytes, base
3097
+ package only) and a legacy 7.9.2 highlight summary remain as history, never as
3098
+ exact-input evidence.
3099
+
3100
+ Every census reads the corpus through `scripts/corpus-db.ts`, in three ways
3101
+ (#498):
3102
+
3103
+ - **Population.** `v_corpus_population` leaves out the upstream auxiliary
3104
+ collections: 7 `capture-controls` rows and 875 `wiresage-conformance`
3105
+ synthetic grammar cases. An observation is in the population by `script_id`,
3106
+ not by path prefix, so a collection added upstream cannot leak into a census
3107
+ (it did, briefly, when #42 added 875 rows to every run).
3108
+ - **Sessions.** Comparable figures name their channel run ids
3109
+ (`capture_channels`), not a coverage label.
3110
+ - **Verdict.** The device verdict is the upstream `response_class`. Only
3111
+ `il-no-recognized-diagnostic` is an accept, so IL carrying an embedded
3112
+ `bad command name …` is a rejection. The shipped `:parse` reader
3113
+ (`classifyParseResult`) agrees with it on all 5,640 channel and legacy
3114
+ observations.
3115
+
3116
+ The population text is byte-identical to the previous snapshot, so the
3117
+ text-derived reports are unchanged by construction: the token and value
3118
+ censuses, `explain:arg-reach` and the browser-consumer proof. The IL- and
3119
+ highlight-derived figures moved.
3120
+
3121
+ **The channels do not agree with each other, and that is the finding.** Scored
3122
+ over all 948 scripts:
3123
+
3124
+ | oracle | long-term 7.23.8 ↔ stable 7.24.5 | stable ↔ testing 7.25rc2 | testing ↔ development 7.26beta1 |
3125
+ | ------ | -------------------------------: | -----------------------: | ------------------------------: |
3126
+ | `:parse` readout | 95 scripts differ | 12 | 13 |
3127
+ | `:parse` response class | 5 | 0 | 0 |
3128
+ | `highlight` stream | 24 | 1 | 0 |
3129
+
3130
+ The newer channels are nearly the same build for these purposes; long-term is
3131
+ the one that moves. So an operator-axis or token-partition figure is a claim
3132
+ *about a channel*, not about RouterOS. That is why the census reports its
3133
+ builds as separate columns and states its session set, rather than collapsing
3134
+ them to one number. Long-term is also the channel a real device most likely
3135
+ runs.
3136
+
3137
+ **The `wiresage-conformance` cases are read separately, never as population.**
3138
+ `conformanceCases` returns the 875 third-party grammar probes with their
3139
+ upstream label, and `parseObservations(…, { collection })` returns their
3140
+ device answers. `bun run explain:conformance` scores the execute gate's offline
3141
+ answer (`fail` refuses) against `:parse` on every channel. It lists false
3142
+ rejects, missed rejections (split by device class: `plain-diagnostic` is
3143
+ syntax, while `embedded-diagnostic` is mostly a menu or parameter name, which
3144
+ needs the #501 argument lists), cases where the channels disagree, cases
3145
+ with no device answer (`unscorable`, never read as a rejection), and the
3146
+ upstream-label disagreements that #507 needs. The lists are committed in
3147
+ `test/fixtures/explain/conformance.json` and gated in CI, so a fix for any
3148
+ of #504–#506 shows the cases it moved.
2478
3149
 
2479
3150
  The corpus itself is not in this repo and is not moving here — `lsp-routeros-ts`
2480
3151
  owns producing snapshots, centrs owns which snapshot it measures against.
@@ -2544,10 +3215,27 @@ to mention. `/system/reboot` no longer moves the context, so a statement after
2544
3215
  it resolves where it actually is; `verbsplit` and `pathresolve` agree, which
2545
3216
  they would otherwise now do *confidently* in opposite directions. What is left
2546
3217
  is the honest residue of both floors — `/terminal/cuu` is a real no-argument
2547
- command MikroTik publishes nowhere — and it stays pinned as a KNOWN LIMIT
2548
- fixture rather than assumed closed. The contradiction that #202 would have
3218
+ command MikroTik publishes nowhere. The contradiction that #202 would have
2549
3219
  rendered is gone.
2550
3220
 
3221
+ **#211 makes that residue abstain (R14).** A verb-free absolute path that
3222
+ neither table knows used to still move the context, so `/terminal/cuu` then
3223
+ `print` resolved to an invented `/terminal/cuu/print`. On CHR 7.24.5 `:parse`
3224
+ reads `/terminal/cuu` as a command and that `print` as `bad command name print`
3225
+ at the root. `/ip fire conn` has the same shape and is navigation to
3226
+ `/ip/firewall/connection`, because RouterOS accepts abbreviated menus. A table
3227
+ miss therefore cannot decide either way. The statement now reads as R13's
3228
+ relative abstention does: not navigation, flagged `maybeNav`, with the
3229
+ statements after it `unknown` until a known absolute navigation recovers the
3230
+ context ([example 34](examples.md#34-an-unlisted-bare-path-does-not-become-a-menu)).
3231
+ Over the pinned corpus, 14 of 18,650 statement readings lose a path, in 6
3232
+ scripts. Every one was an invented path: `/ip/fire/conn/remove`,
3233
+ `/ip/firewall/layer7/add`, the v6 `/routing/bgp/network/add`, and
3234
+ `/terminal/style/error/put`. No `explain` verdict changes. Four `containsWrite`
3235
+ verdicts move from `true` to `unknown`, because their write was proven only at
3236
+ an invented path. Rule P, which would read the tables as a per-menu closed
3237
+ world and expand abbreviations, is not adopted (#372).
3238
+
2551
3239
  **The command axis also outranks the punctuation guess.** `splitRun`'s last
2552
3240
  rule reads the first space-separated token as the verb, which lands on the
2553
3241
  operand whenever the command itself was written slash-joined:
@@ -2677,8 +3365,8 @@ The offline capability can advance from `coded` to `verified` when:
2677
3365
  - **Reachable evidence and explicit residue:** changed RouterOS semantic claims
2678
3366
  have committed CHR-grounded controls and a reproducible capture/replay path.
2679
3367
  Every remaining issue has a stated scope and dependency; #225 distinguishes
2680
- offline value work from live types, #272 owns evidence reachability, and #211's
2681
- unlisted-path decision retains the current abstention until separately decided.
3368
+ offline value work from live types, #272 owns evidence reachability, and an
3369
+ unlisted bare path abstains (#211) until #372 prices a closed-world reading.
2682
3370
 
2683
3371
  Retain the existing checks: `bun run lint`, `bun run test`, `bun run build`, and
2684
3372
  `bun run lint:ci`, plus the affected CHR grounding/integration procedure from
@@ -2688,9 +3376,10 @@ test framework is part of this baseline.
2688
3376
 
2689
3377
  ### Current and following capabilities
2690
3378
 
2691
- - **Current — live probes (#236, phase 2):** highlight + `:parse` and
2692
- completion/child/syntax over rest-api/native-api. B4's projection/agreement
2693
- informs this work; each live cell requires its own CHR-passing examples.
3379
+ - **Done — live probes (#236, phase 2):** highlight + `:parse` (#491),
3380
+ completion/child/syntax (#492) and live value types (#225) over
3381
+ rest-api/native-api, `CHR-passed` on stable and long-term.
3382
+ `runtimeAcceptance` stays `not-proven`.
2694
3383
  - **MCP alignment (#223):** the deliberate flat-to-rich response migration,
2695
3384
  coordinated with rosetta. Independent of the offline token vocabulary.
2696
3385
  - **Facet polish (phase 3):** live sizing, `--full`, completion/schema ergonomics.
@@ -2761,8 +3450,10 @@ round):
2761
3450
 
2762
3451
  Still open (implementation opens — none block ratification):
2763
3452
 
2764
- 1. Final flag names (`--full`, `--curl`, facet names) and the smart-sizing
2765
- thresholds (what counts as "cheap", what triggers truncation).
3453
+ 1. ~~Final flag names and smart-sizing thresholds.~~ Decided by #492:
3454
+ `--schema`, `--complete`, `--cursor <byte>` and `--full`; 64 names and 16
3455
+ expansion probes by default, and 1024 / 128 with `--full`
3456
+ ([Live describe and completion](#live-describe-and-completion-492)).
2766
3457
  2. The mapping table from RouterOS highlight classes to **colors**, and how
2767
3458
  unknown/new upstream classes degrade. Phase 0 pinned the raw inputs (Q13's
2768
3459
  per-occurrence highlight-class corpus; the observed 19 classes on 7.23.2 plus