@tikoci/centrs 0.1.9-next.0 → 0.1.9-next.23

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 (88) hide show
  1. package/CHANGELOG.md +177 -40
  2. package/GLOSSARY.txt +32 -0
  3. package/README.md +12 -8
  4. package/SECURITY.md +11 -3
  5. package/commands/api/README.md +39 -15
  6. package/commands/api/examples.md +44 -6
  7. package/commands/execute/README.md +108 -14
  8. package/commands/execute/examples.md +137 -3
  9. package/commands/explain/README.md +430 -129
  10. package/commands/explain/examples.md +260 -1
  11. package/commands/mcp/README.md +11 -0
  12. package/commands/mcp/examples.md +6 -0
  13. package/commands/retrieve/README.md +30 -13
  14. package/commands/retrieve/examples.md +10 -6
  15. package/commands/transfer/examples.md +9 -0
  16. package/docs/CLI.md +8 -8
  17. package/docs/CONSTITUTION.md +29 -2
  18. package/docs/MATRIX.md +26 -13
  19. package/docs/errors/cdb/empty-group.md +19 -3
  20. package/docs/errors/cdb/empty-selection.md +9 -3
  21. package/docs/errors/cmr/caller-policy-not-enforced.md +46 -0
  22. package/docs/errors/cmr/empty-selection.md +17 -3
  23. package/docs/errors/cmr/rule-selects-every-device.md +32 -0
  24. package/docs/errors/cmr/selection-includes-controller.md +27 -0
  25. package/docs/errors/usage/flag-after-separator.md +34 -0
  26. package/package.json +10 -5
  27. package/src/api-fanout.ts +11 -1
  28. package/src/api.ts +205 -65
  29. package/src/btest.ts +7 -2
  30. package/src/cli/api.ts +30 -16
  31. package/src/cli/common.ts +180 -1
  32. package/src/cli/devices.ts +0 -2
  33. package/src/cli/discover.ts +0 -1
  34. package/src/cli/execute.ts +44 -20
  35. package/src/cli/explain.ts +2 -0
  36. package/src/cli/mcp.ts +0 -1
  37. package/src/cli/retrieve.ts +40 -18
  38. package/src/cli/settings.ts +0 -3
  39. package/src/cli/terminal.ts +0 -2
  40. package/src/cli/transfer.ts +10 -8
  41. package/src/cli/version.ts +87 -0
  42. package/src/cli.ts +5 -3
  43. package/src/core/cmr-fleet.ts +486 -0
  44. package/src/core/cmr.ts +78 -0
  45. package/src/core/envelope.ts +32 -3
  46. package/src/core/error-catalog.ts +25 -3
  47. package/src/core/execute-command.ts +144 -19
  48. package/src/core/fanout.ts +130 -6
  49. package/src/errors.ts +2 -1
  50. package/src/execute-cmr.ts +95 -78
  51. package/src/execute-fanout.ts +11 -1
  52. package/src/execute.ts +105 -23
  53. package/src/explain/args.ts +69 -8
  54. package/src/explain/blocks.ts +3 -4
  55. package/src/explain/brace-slots.ts +5 -0
  56. package/src/explain/catalog.ts +13 -6
  57. package/src/explain/defects.ts +1 -0
  58. package/src/explain/group-faults.ts +927 -0
  59. package/src/explain/highlight-projection.ts +10 -8
  60. package/src/explain/operators.ts +20 -19
  61. package/src/explain/pathresolve.ts +102 -20
  62. package/src/explain/quoted-string.ts +41 -6
  63. package/src/explain/run-tokens.ts +223 -0
  64. package/src/explain/scope-brace.ts +103 -6
  65. package/src/explain/segment.ts +41 -3
  66. package/src/explain/spaced-args.ts +116 -0
  67. package/src/explain/symbols.ts +17 -1
  68. package/src/explain/transport.ts +13 -0
  69. package/src/explain/verbsplit.ts +14 -188
  70. package/src/explain/write.ts +8 -1
  71. package/src/explain.ts +378 -18
  72. package/src/index.ts +18 -1
  73. package/src/mcp/safety.ts +11 -0
  74. package/src/mcp/tools.ts +4 -1
  75. package/src/offline-gate.ts +30 -9
  76. package/src/protocols/btest-session.ts +30 -10
  77. package/src/protocols/mac-telnet-console.ts +16 -0
  78. package/src/resolver/cdb.ts +13 -1
  79. package/src/resolver/index.ts +4 -0
  80. package/src/resolver/selection.ts +37 -1
  81. package/src/resolver/target.ts +96 -8
  82. package/src/retrieve-fanout.ts +4 -1
  83. package/src/retrieve-follow.ts +29 -33
  84. package/src/retrieve-sample.ts +14 -8
  85. package/src/retrieve-stream.ts +38 -6
  86. package/src/retrieve.ts +73 -59
  87. package/src/transfer-fanout.ts +8 -1
  88. package/src/transfer.ts +15 -19
@@ -392,7 +392,8 @@ bare menu path. The positional grammar stays **target-first like every other
392
392
  router-taking command** (amended in PR review — the shared resolver/selection
393
393
  helpers assume it): one positional means offline and it *is* the input; two
394
394
  positionals mean live with the router first; `--` is accepted before the
395
- input; **`--file` replaces the input positional in either form**, so with it
395
+ input, and one of explain's own long flags after it is refused as
396
+ `usage/flag-after-separator` (#459); **`--file` replaces the input positional in either form**, so with it
396
397
  every positional is a target. Adding a
397
398
  router therefore never reinterprets a previously valid offline invocation.
398
399
 
@@ -459,6 +460,7 @@ Standard envelope (constitution: result envelope); `data` sketch:
459
460
  "statementCount": 1,
460
461
  "statements": [
461
462
  {
463
+ "id": "s0",
462
464
  "span": { "start": 0, "end": 51 },
463
465
  "resolution": "resolved",
464
466
  "kind": "command",
@@ -510,6 +512,36 @@ Standard envelope (constitution: result envelope); `data` sketch:
510
512
  classification, and evidence id. This is where multi-statement scripts and
511
513
  partial recovery live; a single top-level transport object cannot represent
512
514
  them.
515
+ - **Statement ownership is syntactic (#497).** Every published statement has
516
+ an `id` (`s0`, `s1`, …) in discovery order, stable within one result. A nested
517
+ scope-body statement's `parent` names its owning statement; absence means
518
+ top level. Each substitution's `owner` names its containing statement,
519
+ including nested brackets. These links come from the resolver's traversal,
520
+ so widened Unicode spans do not turn siblings into ancestors. IDs are not
521
+ persistent across edits.
522
+
523
+ The existing `blocks[]` inventory still lists outer scope bodies only.
524
+ A block's optional `owner` names the statement carrying it, and a statement's
525
+ optional `block` indexes its innermost **published** enclosing block. A nested
526
+ body can therefore share its ancestor's block index. Bare/menu containers
527
+ add no synthetic statement or block nodes; the tree links only statements
528
+ the existing analysis exposes. Existing lists, order, spans and readings stay
529
+ unchanged. A widened span remains a coordinate limitation, not a second
530
+ parentage rule.
531
+
532
+ Stored/deferred `source=`, `on-event=` and callback bodies remain opaque:
533
+ their statements are not added to this tree. Creation versus explicit
534
+ stored-script execution is grounded by
535
+ `test/fixtures/explain/statement-ownership.json` and replayed by
536
+ `test/integration/explain-structure.test.ts`. Ownership asserts no lexical
537
+ inheritance, immediate execution, or shared user/policy environment;
538
+ context-sensitive binding/value semantics remain #507's responsibility.
539
+
540
+ `test/unit/explain-structure.test.ts` independently checks ancestry against
541
+ span containment where spans recover exact source text, with explicit
542
+ widened-span controls. After `bun run corpus:fetch`, run
543
+ `CENTRS_CHECK_CORPUS=1 bun test test/unit/explain-structure.test.ts` for the
544
+ same contract over the pinned study population; the corpus CI job runs it.
513
545
  - **Every derived fact keeps provenance via `evidence[]`** — facts reference
514
546
  a stable evidence id (`ev`), and the evidence entry carries source
515
547
  (`canonicalizer` vs `live-inspect`), probe, basis (direct response vs
@@ -906,7 +938,16 @@ and its phase is named below.
906
938
  top-level brace value in any other command argument emits
907
939
  `explain/canonicalizer/invalid-command-brace` at the `{`; parentheses or a
908
940
  `[…]` substitution restore the expression context where the same literal is
909
- legal. An empty member is a
941
+ legal. So does a top-level `where` clause (#502): `print where interface in
942
+ {"ether1";"ether2"}` is a read, and its array is read member by member like
943
+ its `(…)` twin. The exception is the right side of `<name>=`, `!=` or `==`,
944
+ spaced or not, which the device reads with that property's own parser
945
+ (`expected interface value`), so `where comment={1;2}` keeps its error. The
946
+ device does that only for a property the menu knows (`where foo-bar={…}`
947
+ parses). Offline has no property list, so it treats every bare name as one.
948
+ The same comparison inside `( … )` or a `[ … ]` command
949
+ (`[find where interface={"x"}]`, `where (interface={"x"})`) is caught by the
950
+ group walk (#509, below) and reported with the same code. An empty member is a
910
951
  syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`) and withdraws the enclosing
911
952
  `array` shape with it; a single trailing separator is legal in the brace form
912
953
  only (`{1;}` and `{1;2;}` parse, `{1;2,}` and `{2,}` do not).
@@ -946,15 +987,16 @@ and its phase is named below.
946
987
 
947
988
  <!-- BEGIN GENERATED value-census — regenerate with `bun run explain:value-census:readme` -->
948
989
  The corpus census is re-derivable with `bun run explain:value-census` and
949
- covers 948 source scripts. The figures below are generated from
950
- `test/fixtures/explain/values.json` → `corpus` by
951
- `bun run explain:value-census:readme` and gated against it by
990
+ covers 948 source scripts of corpus snapshot `e12d5d4e58a4`, with 882
991
+ auxiliary rows (7 `capture-controls`, 875 `wiresage-conformance`) excluded.
992
+ The figures below are generated from `test/fixtures/explain/values.json` →
993
+ `corpus` by `bun run explain:value-census:readme` and gated against it by
952
994
  `bun run explain:value-census:readme:check`; the fixture itself is gated
953
995
  against a fresh corpus run by `bun run explain:value-census:check`. Of the
954
- 13,161 emitted values in a statement the strict argument lexer ALSO read, 0
996
+ 13,152 emitted values in a statement the strict argument lexer ALSO read, 0
955
997
  disagree with it on half-open byte span or decoded text, while the
956
998
  prefix-safe scan retains a further 6,538 values across 568 statements whose
957
- strict REST reading abstains. Of 19,699 emitted occurrences, 5,636 are array
999
+ strict REST reading abstains. Of 19,690 emitted occurrences, 5,636 are array
958
1000
  members (529 keyed, 1,147 nested inside another member) and 808 are arrays;
959
1001
  the corpus holds no source-literal `id` example. The three structural
960
1002
  counters — spans addressing bytes outside their own source, members naming a
@@ -981,7 +1023,8 @@ comment, a literal hash value, and a hard error at the exact byte.
981
1023
  | 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. |
982
1024
  | 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. |
983
1025
  | Immediate line start after `\` + newline | `comment`; the pending statement survives | Mask it in both argument views; the continuation-reach rules remain H5/#215. |
984
- | 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. |
1026
+ | 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. |
1027
+ | 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). |
985
1028
  | 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. |
986
1029
  | 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. |
987
1030
  | In an attribute or bare value (`comment=#test`, `comment=a#b`, `:global y #test`) | literal value (`none` highlight class) | Keep it as content. |
@@ -1019,6 +1062,14 @@ JSON-like transcript, and two shell-shaped snippets. No RouterOS command reading
1019
1062
  moved; these diagnostics say those bytes are not valid RouterOS, not that the
1020
1063
  foreign snippets were expected to parse as RouterOS.
1021
1064
 
1065
+ Corpus re-measurement for #503: one script of 947 moves. It is a pasted console
1066
+ session (`forum/amm0/topic-264287-…/post-0005-snippet-01.rsc`, which opens with
1067
+ `[admin@MikroTik] > {`), and it moves from `fail` to `warn`. Its `invalid-hash` sat on a column-0
1068
+ `# ... more code` line inside the brace after `>`, which the device reads as
1069
+ an array (`:local z (1 > {⏎:local a 1⏎# c⏎})` parses). The device rejects the
1070
+ script earlier, at the prompt's `@`, which is #512. Every census gate except
1071
+ the token census is unchanged; that one gains the two comment spans.
1072
+
1022
1073
  The default text renderer surfaces comment spans in a `comments:` section; JSON
1023
1074
  continues to carry the same ranges as `spans[]` entries with `class: "comment"`.
1024
1075
 
@@ -1073,10 +1124,17 @@ navigated, so the context stays as unknown as it already was.
1073
1124
  `:parse` `expected message value`, grounded stable+testing #247),
1074
1125
  `bad-sigil`,
1075
1126
  `invalid-hash`, `invalid-apostrophe` (#355, below),
1076
- `missing-statement-separator` (#311, below). Nine classes
1077
- the device itself rejects.
1127
+ `missing-statement-separator` (#311, below), the four group faults
1128
+ `empty-group`, `misplaced-separator`, `missing-operand` and
1129
+ `invalid-at-sign` (#505, #511, #512, below), and the four token faults
1130
+ `glued-token`, `invalid-variable`, `bad-string-variable` and
1131
+ `invalid-name` (#506, #518, below). Seventeen classes the device itself
1132
+ rejects.
1078
1133
  - `warning` — `over-depth`, because it is centrs's own resource bound and says
1079
- nothing about whether the input is legal; and an `ambiguous`/`unknown`
1134
+ nothing about whether the input is legal; `possible-spaced-argument`
1135
+ (#350), a spelling RouterOS accepts and marks obsolete; `discarded-close`
1136
+ (#504), a script RouterOS accepts and stops reading early, which the
1137
+ execute gate refuses by code; and an `ambiguous`/`unknown`
1080
1138
  resolution, never an error, so the default `--fail-on error` cannot fail a
1081
1139
  document whose only sin is being unreadable without a schema — which is most
1082
1140
  of RouterOS scripting.
@@ -1181,6 +1239,151 @@ it. The corpus count is unchanged at **0** for the same reason it always was
1181
1239
  the rule is an unterminated string or an unbalanced delimiter, because neither
1182
1240
  walk can find a boundary to resume from.
1183
1241
 
1242
+ ### Faults inside groups (#505, #509, #511, #512)
1243
+
1244
+ Four missed-rejection families share one walk, `src/explain/group-faults.ts`
1245
+ (evidence pass `findGroupFaults`). It reads the comment-masked document with
1246
+ the frame roles every other walker uses: `[` opens a statement, `(` an
1247
+ expression, and `{` a body when `braceStartsStatements` says so or the
1248
+ directive takes a program in that slot (`:execute {…}`, `:time {…}`). Each
1249
+ rule is a `:parse` answer on CHR 7.24.5 and 7.23.8, identical on both, and
1250
+ `test/fixtures/explain/group-faults.json` pins all 186 rows (100 rejections,
1251
+ 86 controls). `test/integration/explain-group-faults.test.ts` holds the
1252
+ device to that fixture, and `test/unit/explain-group-faults.test.ts` holds
1253
+ offline to it.
1254
+
1255
+ | Code | Device rule | Rejected | Accepted |
1256
+ | ---- | ----------- | -------- | -------- |
1257
+ | `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 {}` |
1258
+ | `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})` |
1259
+ | `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.)` |
1260
+ | `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` |
1261
+
1262
+ `invalid-command-brace` gains the #509 positions: a brace after `<name>=`
1263
+ inside `( … )` or a `[ … ]` command whose statement says `where`. The top level
1264
+ and scope bodies stay with `args.ts` (#502), and a `do={` body is never read as
1265
+ a comparison.
1266
+
1267
+ **Prefix operators are the device's set, not the operator table's arity.**
1268
+ `-`, `!`, `>`, `~` and `any` take one operand after them, so `(1 + -2)` and
1269
+ `(-> 1)` (`(- (> 1))`) parse. Every other operator needs a left operand. At
1270
+ operand position `and`, `or`, `in`, `.` and `/` are variable names
1271
+ (`(and 1)` is `( $and 1)`). `1.`, `1/`, `a_b`, `1:2` and `1.2.3.4/24` are
1272
+ single words, while `-` is always an operator (`(a-b)` is `(- $a $b)`).
1273
+
1274
+ **A query is not an expression.** Inside `where …` and `find …`, the device
1275
+ reads the value after `<name>=` with the property's parser. So
1276
+ `where (comment=+)`, `where (comment=a-)`, `where (comment=@)` and
1277
+ `where (.id=*1)` parse, though each is an error in a plain group. A group in a
1278
+ query gets only the structural checks (empty, `;`, newline) and #509.
1279
+
1280
+ **A brace offline cannot place is opaque, not an array.** A `{` after a
1281
+ relative menu word is a menu body (`/ip { route { } }` parses empty), and a
1282
+ brace in a command argument is `args.ts`'s. Neither is checked here. The
1283
+ array rules apply where the role is grounded: a directive slot in the
1284
+ `brace-slots.ts` array table, and an array nested in an array or an expression.
1285
+
1286
+ **Three readings the review round grounded (PR #516).** A `\` continuation
1287
+ runs on across blank lines and line-leading comment lines, as H5 does
1288
+ elsewhere, so `(1 +\⏎# c⏎2)` and `{1\⏎⏎;2}` parse, while a blank line after a
1289
+ comment line ends it (`(1 +\⏎# c⏎⏎2)` is rejected at the blank line). A
1290
+ directive's operand is a name, so `:local where (comment={})` parses and
1291
+ `:local where (1 +)` is checked like any expression. A bare `local`/`global`
1292
+ is the same directive at the root (`local where (comment={})` is
1293
+ `/localname=$where`), while a bare `set` or `find` may be a menu command and
1294
+ stays unread. And a named element takes
1295
+ a trailing `,` (`{a=1,}` is `value=a`, `{a=1,;b=2}` is `value=a;b=2`), while
1296
+ `{a=,1}`, `{a=1,,2}` and a positional `{1,}` are rejected. A faulted group's
1297
+ descendants are opaque, since the device stops at the fault.
1298
+
1299
+ **Spans.** Each fault spans the token it is about: the operator, the
1300
+ separator, the `@`, or the whole empty group. The device's column is the same
1301
+ byte for every rule except an empty group and a dangling operator, where it is
1302
+ the byte after.
1303
+
1304
+ **Measured.** Corpus `e12d5d4e58a4`: 70 of 948 scripts gain an error and all
1305
+ 70 are device rejections on every channel. 42 of those errors sit on the exact
1306
+ byte the 7.24.5 diagnostic names, and the other 28 come after an earlier device
1307
+ error in pasted non-RouterOS text. None precedes the device's error. 41
1308
+ verdicts move to `fail` (40 from `warn`, one from `pass`), so offline now fails
1309
+ 133 of the 374 device-rejected scripts, up from 92, and false rejects stay at 1.
1310
+ `invalid-at-sign` is mostly pasted console prompts. On the conformance cases,
1311
+ 22 `plain-diagnostic` missed rejections move to `fail`, with no new false
1312
+ reject.
1313
+
1314
+ Not covered here: a `$( … )` group, a group under a `find` head
1315
+ (`[find (1 +)]`, read as a query), a `@` in a later head word
1316
+ (`/ip address@ print`), operand rules for array elements other than `,`, a
1317
+ group in a query beyond its structure, and adjacency without whitespace
1318
+ (`"x"~a`, `}else=`), which is the next section's.
1319
+
1320
+ ### Token faults and the discarded tail (#504, #506, #518)
1321
+
1322
+ Three more missed-rejection families, and one reading that was wrong in the
1323
+ other direction. The glued-token, variable and name rules ride the same walk as
1324
+ the group faults (`src/explain/group-faults.ts`), so they share its statement,
1325
+ query and brace roles. The `$` inside a string is checked by the escape walk
1326
+ (`src/explain/quoted-string.ts`), and the stray close by the segmenter
1327
+ (`src/explain/segment.ts` → `discardFrom`). Every rule is a `:parse` answer on
1328
+ CHR 7.24.5 and 7.23.8, identical on both, and
1329
+ `test/fixtures/explain/token-faults.json` pins all 145 rows (80 rejections and
1330
+ 65 acceptances, with the acceptances' IL). `test/integration/explain-token-faults.test.ts`
1331
+ holds the device to that fixture, and `test/unit/explain-token-faults.test.ts`
1332
+ holds offline to it.
1333
+
1334
+ | Code | Device rule | Rejected | Accepted |
1335
+ | ---- | ----------- | -------- | -------- |
1336
+ | `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`) |
1337
+ | `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"` |
1338
+ | `invalid-variable` | Outside a string, `$` takes a name (`[A-Za-z0-9]+`) or `$"…"` | `:put $`, `$_`, `$-`, `$.`, a `$` before a space | `$v1`, `$"a b"` |
1339
+ | `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]"` |
1340
+ | `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` |
1341
+
1342
+ **The stray close was the honest-reading bug.** Offline used to fail
1343
+ `name=a}` with `unbalanced-close`, and the device accepts it. But the device
1344
+ does not just ignore the `}`: it stops reading there. `}⏎:put 1` lowers to `/`,
1345
+ and a write after the brace never runs. So `explainCommand` now analyzes only
1346
+ the prefix the device reads and adds one `warning` that spans the dropped
1347
+ bytes. The token partition still covers the whole input, with the tail
1348
+ `unclassified`. A `)` in that position, and any stray close inside an open
1349
+ group, are rejections and stay `unbalanced-close` errors. The execute gate
1350
+ refuses `discarded-close` by code, the one warning it refuses
1351
+ (`src/offline-gate.ts`; `commands/execute/examples.md` V9).
1352
+
1353
+ **Where a property's parser decides, the glued-token rule abstains.** In a
1354
+ command argument the device hands the value to the property's parser, which can
1355
+ take a glued part after a prefix or separator: `dst-address-list=!"my list"`
1356
+ and `dst-port=80,"443"` parse, while `name=!"a"` and `name=-"a"` do not. So
1357
+ outside a `:` directive, a bare word followed by a `"`, `$` or bracket is a
1358
+ fault only after an alphanumeric byte. After a closed part no property was found
1359
+ that accepts anything (`"80",443`, `$p-90`, `$v%ether1`, `"ether"1` are all
1360
+ rejected), so that half of the rule applies everywhere outside a query.
1361
+
1362
+ **Spans.** Each fault spans its byte, which is the device's byte too. For the
1363
+ two `$` rules the span is the `$` and the byte after it, and the device names
1364
+ that second byte.
1365
+
1366
+ **Measured.** Corpus `e12d5d4e58a4`: 23 scripts move to `fail` (18 from
1367
+ `warn`, 5 from `pass`), and 29 already-failing scripts gain a new code. All are
1368
+ device rejections on every channel. 9 of their first new errors sit on the
1369
+ 7.24.5 diagnostic's byte, 45 come after it, and none precedes it. Offline now
1370
+ fails 154 of the device-rejected scripts, up from 133. The one corpus false
1371
+ reject (a stray `}`) is gone, so false rejects are 0. Three scripts move from
1372
+ `fail` to `warn`: their `unbalanced-close` became `discarded-close`, and two of
1373
+ them are device rejections for an earlier reason offline does not see. On the
1374
+ conformance cases, false rejects go from 3 to 0 and missed rejections from 278
1375
+ to 151.
1376
+
1377
+ **Not covered here.** `"a$"`: the device reads `$"` in a string as a quoted
1378
+ variable name, so the next `"` does not close the string. Every scanner in
1379
+ `explain` closes it there, so both `$` rules and the glued-token rule abstain
1380
+ around a string that ends in `$"` (2 of 948 corpus scripts use the form).
1381
+ `$$`, `$(…)`, `$[…]` and `${…}` outside a string, where the device's byte is
1382
+ not the `$`. A `\` continuation inside a glued token (`"a"\⏎"b"`). The head
1383
+ word of a statement (`$v"a"`, `{…}:put 2`). `:local a=b`, which 7.24.5 reads as
1384
+ a named argument and 7.23.8 as a syntax error. And a name in `/local` or
1385
+ `:local name=a_b`.
1386
+
1184
1387
  ### The token stream a rule reads (#316)
1185
1388
 
1186
1389
  `lexArguments` is all-or-nothing by design: its consumer renders a runnable REST
@@ -1393,7 +1596,8 @@ print } }` is `(evl (evl (evl /ip/address/print)))` on device and
1393
1596
  form failed. A menu block's body is a statement list, so it may open with a
1394
1597
  submenu, with one of the thirteen frozen CRUD verbs (`verbs.ts`), with a
1395
1598
  catalog-named command, with a `:` scripting directive, or with an absolute
1396
- command — corpus 521 is the directive case, `ethernet { :put … }`.
1599
+ 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,
1600
+ `ethernet { :put … }`.
1397
1601
 
1398
1602
  That last rule is bounded by what a STATEMENT can start with, which is what
1399
1603
  keeps `{1;2}` an array literal: an array member opens with a digit or a quote
@@ -1405,6 +1609,47 @@ That is the catalog's own "a hit is decisive, a miss says nothing" contract, and
1405
1609
  turning such a miss into an acceptance would be the mirror-image defect of the
1406
1610
  one being fixed here.
1407
1611
 
1612
+ ### The `=` is optional in ordinary arguments too (#350)
1613
+
1614
+ The #347 rule is the narrow half of a general one: a space can stand in for the
1615
+ `=` of any command argument. `/ip/firewall/layer7-protocol add comment
1616
+ "something" regexp 1.1` lowers to the IL of `comment="something" regexp=1.1`
1617
+ on CHR 7.22.1, 7.23.8 and 7.24.5, and `highlight` marks each gap
1618
+ `syntax-obsolete`, which is RouterOS calling the spelling deprecated. Unlike
1619
+ the four #347 scope names, this one cannot be read offline. The device pairs
1620
+ `comment` with its value because it knows the command has a `comment`
1621
+ argument, and `/system/script add zzznotanarg beep` is rejected. Which words
1622
+ are argument names is per-command schema, which offline does not ship.
1623
+
1624
+ Offline used to publish the worst reading available: `arguments.read: true`,
1625
+ `args: {}`, the operands as positionals. That is an affirmative claim that the
1626
+ list was read and holds no arguments. Now any adjacent pair of a
1627
+ name-shaped bare operand and another operand withdraws the argument reading,
1628
+ as #311 does. The statement stays `resolved`, `command.args` is absent,
1629
+ `arguments.why` names the pair, and transport stays the `unknown` it already
1630
+ was. A **warning** (`possible-spaced-argument`, example 33) is raised only
1631
+ where the second operand cannot be a flag (quoted, numeric, `$`-led). That is
1632
+ because the same bytes are also two flags: `print detail without-paging`
1633
+ lowers to `detail=true;without-paging=true` with no `syntax-obsolete` marker,
1634
+ while `set ether1 name x` pairs. So two bare words abstain silently. `where`
1635
+ ends the scan, and root directives are excluded because their operands are
1636
+ positional by grammar (`:pick abc 0 1` lowers to `counter=abc`). Live
1637
+ `request=child` discovery (#491) is what confirms a name and makes the warning
1638
+ definite.
1639
+
1640
+ The same applies before a script body. `source { :beep }`,
1641
+ `on-event { :beep }` and `down-script { :beep }` lower to their `=` IL, while
1642
+ `comment { :beep }` is a `syntax error`. So the brace exemption accepts the
1643
+ bare spelling, and its name shape gained the `…-script` arguments
1644
+ (`up-script`, `lease-script`) that hard-failed even with the `=`. Which slots
1645
+ take a body is per-menu and per-version schema (`lease-script={…}` parses on
1646
+ 7.24.5 and not on 7.22.1). The shape decides the brace's role for every
1647
+ reader, not just the error, and it errs toward a body: an argument the
1648
+ command does not have (`zzz-script={…}`, and `on-zzz={…}` before #350) is a
1649
+ device `syntax error` that offline does not report. That is a missed
1650
+ rejection for the device preflight to catch, never a false reject. On the
1651
+ pinned corpus the widening changed no census.
1652
+
1408
1653
  ### `'` is not a RouterOS token (#355)
1409
1654
 
1410
1655
  `'` (U+0027) is not a string delimiter and not a legal bare byte. Only `"` opens
@@ -1442,11 +1687,14 @@ Occurrences BEFORE the defect stand, which is the lab's X1 rule.
1442
1687
 
1443
1688
  **This was a false ACCEPT, which the corpus join cannot see.** The false-reject
1444
1689
  measurement scores one direction only, so the analyzer passing all four forms
1445
- with zero diagnostics never appeared in it. Over the pinned corpus the rule
1446
- fires on 40 of the 7.24.2 scripts and **every one of them is a script the device
1447
- rejects** — none of the 617 it accepts. It moves the false-reject count not at
1448
- all (still 1, the extra `}` of corpus 228) while taking the caught count from 62
1449
- to 93.
1690
+ with zero diagnostics never appeared in it. Over the corpus pinned at the time
1691
+ (stable 7.24.2, before the #498 schema-5 repin) the rule fires on 40 scripts and
1692
+ **every one of them is a script the device rejects** — none of the 617 it
1693
+ accepted. It moves the false-reject count not at all (still 1, the extra `}` of
1694
+ corpus script
1695
+ `forum/amm0/topic-167087-can-t-turn-code-into-a-function/post-0005-snippet-02.rsc`)
1696
+ while taking the caught count from 62 to 93. On the #498 sessions both figures
1697
+ hold on every channel: 1 false reject among 573 accepts, 93 caught.
1450
1698
 
1451
1699
  The token census moves, and only in the direction the fail-closed symbol stop
1452
1700
  predicts: 68 fewer `variable-*` tokens and their 306 bytes returning to
@@ -1523,12 +1771,12 @@ Grounded, not transcribed, for the same reason the escape set above is: the
1523
1771
  [manual's operator list](https://manual.mikrotik.com/docs/developer-guides/scripting/index.md#operators)
1524
1772
  is a lower bound. `bun run explain:probe:operators` sweeps the manual's list
1525
1773
  **plus** plausible non-operators (`not`, `..`, `xor`, `mod`, `is`, `div`, …)
1526
- **plus** every IL head the corpus census saw, on CHR 7.24.2 stable, 7.23.5
1527
- long-term and 7.25beta3 development — the same three builds the pinned corpus
1528
- classes `complete`, so the operator axis and the token partition are finally
1529
- scored on the same firmware (#342) — plus 7.21.5 as an **age anchor**, which is
1530
- not a channel claim but the only live evidence that the surface behaves the same
1531
- on a pre-7.23 branch. Re-cut the fixture with
1774
+ **plus** every IL head the corpus census saw. It runs on CHR 7.24.5 stable,
1775
+ 7.23.8 long-term, 7.25rc2 testing and 7.26beta1 development, the builds the
1776
+ pinned corpus records a channel session for, so the operator axis and the token
1777
+ partition are scored on the same firmware (#342, #498). It also runs on 7.21.5
1778
+ as an **age anchor**, which is not a channel claim but the only live evidence
1779
+ that the surface behaves the same on a pre-7.23 branch. Re-cut the fixture with
1532
1780
  `bun run explain:operator-slice`; the table
1533
1781
  below is generated from `src/explain/operators.ts` by
1534
1782
  `bun run explain:operator-readme` and gated by
@@ -1631,13 +1879,13 @@ not concat at all** — `1.` lexes as a *variable name* and the row comes back a
1631
1879
  juxtaposition, `( $1. 2)`. A rule that claims every `.` byte for the operator
1632
1880
  emits a span the device does not have.
1633
1881
 
1634
- Everything in the sweep is identical across all four builds except one runtime
1635
- row: `:put ({2;1} > {1;2;3})` evaluates to `true` on 7.24.2 and 7.25beta3 and
1636
- errors on both long-term builds. **Array comparison is not fixed on current
1637
- long-term** — 7.23.5 is newer than the 7.23.3 this was first measured on and
1638
- still errors — so the change rode the 7.24 line and was not backported. The two
1639
- errors also differ: 7.23.5 says `cannot compare if array is more than array`,
1640
- 7.21.5 says `cannot compare if nothing is more than nothing`, so on 7.21.5 the
1882
+ Everything in the sweep is identical across all five builds except one runtime
1883
+ row: `:put ({2;1} > {1;2;3})` evaluates to `true` on 7.24.5, 7.25rc2 and
1884
+ 7.26beta1 and errors on both long-term builds. **Array comparison is not fixed
1885
+ on current long-term** — 7.23.8 is newer than the 7.23.3 this was first
1886
+ measured on and still errors — so the change rode the 7.24 line and was not
1887
+ backported. The two errors also differ: 7.23.8 says `cannot compare if array
1888
+ is more than array`, 7.21.5 says `cannot compare if nothing is more than nothing`, so on 7.21.5 the
1641
1889
  operands did not survive to the comparison at all.
1642
1890
  Identical is measured, not assumed — the slice diffs verdict, arity, precedence,
1643
1891
  associativity, `highlight` run, unary placement and op-axis per version. So the
@@ -1732,13 +1980,14 @@ And the grounded complement — asked, and refused:
1732
1980
 
1733
1981
  <!-- BEGIN GENERATED token-census — regenerate with `bun run explain:token-census:readme` -->
1734
1982
  The token census is re-derivable with `bun run explain:token-census` and
1735
- covers 948 source scripts. The figures below are generated from
1736
- `test/fixtures/explain/tokens.json` → `corpus` by
1737
- `bun run explain:token-census:readme` and gated against it by
1983
+ covers 948 source scripts of corpus snapshot `e12d5d4e58a4`, with 882
1984
+ auxiliary rows (7 `capture-controls`, 875 `wiresage-conformance`) excluded.
1985
+ The figures below are generated from `test/fixtures/explain/tokens.json` →
1986
+ `corpus` by `bun run explain:token-census:readme` and gated against it by
1738
1987
  `bun run explain:token-census:readme:check`; the fixture itself is gated
1739
1988
  against a fresh corpus run by `bun run explain:token-census:check`. Of
1740
- 1,426,731 analyzed bytes, 1,023,107 are classified (71.71%), the remaining
1741
- 403,624 are `unclassified`. The census emits 241,539 tokens (avg 254.8 per
1989
+ 1,426,731 analyzed bytes, 1,022,782 are classified (71.69%), the remaining
1990
+ 403,949 are `unclassified`. The census emits 241,279 tokens (avg 254.5 per
1742
1991
  script). Every byte belongs to exactly one token — sorted by `start`, no
1743
1992
  gaps, no overlaps, `join(slice) === input` — and the `class` field is the
1744
1993
  vocabulary #264 B5 settled. Each B2 fill should move the classified
@@ -1819,7 +2068,7 @@ what was wrong was the accounting of it. They are re-derived by
1819
2068
  `bun run explain:bracket-equals:readme:check`.
1820
2069
 
1821
2070
  <!-- BEGIN GENERATED bracket-equals — regenerate with `bun run explain:bracket-equals:readme` -->
1822
- Measured by `bun run explain:bracket-equals` over the pinned corpus:
2071
+ 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:
1823
2072
  **1,079** `=` bytes sit inside `[ … ]`, across
1824
2073
  263 of 948 scripts, and **1,067** of them come back
1825
2074
  `unclassified` — the operator fill abstains on essentially all of it. No
@@ -1834,15 +2083,15 @@ genuine comparisons — the device lowers `[find address=$IP]` to
1834
2083
  fill can take the query half later. For contrast the fill does claim
1835
2084
  **1,402** `=` bytes whose innermost opener is `(`.
1836
2085
 
1837
- The query reading is the device's, not a guess: on 7.24.2, 93 of the
1838
- 137 scripts carrying one have IL with at least as many `(= …)` nodes.
1839
- Of the rest, 28 are scripts the console refused outright — their IL is prose,
1840
- with no nodes of any kind — and 16 use a menu the capture build could not
1841
- resolve (a forum snippet on `/interface wireless` answers `bad command name
1842
- wireless`), so the device never reached the query to lower it. Neither is a
1843
- counter-example to the reading. Whole-corpus `(= …)` head nodes, which also
1844
- count expression comparisons the fill already claims:
1845
- 7.23.5 1,233, 7.24.2 1,240, 7.25beta3 1,240.
2086
+ The query reading is the device's, not a guess: on 7.24.5, 91 of the
2087
+ 137 scripts carrying one have IL with at least as many comparison nodes.
2088
+ Of the rest, 25 are scripts the console refused outright — their readout is
2089
+ prose, with no nodes of any kind — and 21 lowered fewer comparison nodes than the
2090
+ scan counted, typically because a command the capture build could not resolve
2091
+ (`bad command name …`) stopped the device before it reached the query. Neither is
2092
+ a counter-example to the reading. Whole-corpus `(= …)` head nodes per channel
2093
+ session, which also count expression comparisons the fill already claims:
2094
+ 7.23.8 1,300, 7.24.5 1,306, 7.25rc2 1,306, 7.26beta1 1,306.
1846
2095
  <!-- END GENERATED bracket-equals -->
1847
2096
 
1848
2097
  ### Device agreement for the token partition (#264 B4, #263)
@@ -1892,13 +2141,13 @@ to be filled by assumption.
1892
2141
 
1893
2142
  **The device stops classifying at its first `error`, and mostly does not
1894
2143
  recover.** Measured over the slice, `error` appears **at most once per script
1895
- and is always exactly one byte wide**, and on long-term 7.23.5 every byte after
2144
+ and is always exactly one byte wide**, and on long-term 7.23.8 every byte after
1896
2145
  it comes back `none`. Most of the slice sits in that tail. A percentage computed
1897
2146
  over the whole stream therefore measures the device giving up, not the parser,
1898
2147
  which is why the tail is its own outcome and is never folded into agreement. The
1899
2148
  rule is not absolute across versions, so the report does not assert it: stable
1900
- 7.24.2 and development 7.25beta3 classify again after the `error` in one run and
1901
- stop on fewer scripts, and
2149
+ 7.24.5, testing 7.25rc2 and development 7.26beta1 classify again after the
2150
+ `error` in one run and stop on fewer scripts, and
1902
2151
  `parser-recovered` in the table below is the figure that says by how much —
1903
2152
  zero would have been the claim, and it is not zero. Both counts are generated,
1904
2153
  not quoted here, so neither can go stale against the fixture.
@@ -1920,56 +2169,57 @@ a score.
1920
2169
  The agreement report is re-derivable with
1921
2170
  `bun run explain:highlight-agreement` and needs no router and no corpus: it
1922
2171
  scores the token partition against
1923
- `test/fixtures/explain/highlight-streams.slice.json`, 70 stratified scripts of
2172
+ `test/fixtures/explain/highlight-streams.slice.json`, 67 stratified scripts of
1924
2173
  committed `/console/inspect request=highlight` output. The figures below are
1925
2174
  generated from `test/fixtures/explain/highlight-agreement.json` by
1926
2175
  `bun run explain:highlight-agreement:readme`, gated against it by
1927
2176
  `bun run explain:highlight-agreement:readme:check`, and the fixture itself is
1928
2177
  gated against a fresh measurement by
1929
- `test/unit/explain-highlight-agreement.test.ts`. Of 85,529 bytes at 7.24.2,
1930
- 27,974 are bytes **both** sides decided a syntax class for, and 99.99% of
1931
- those agree (dev 99.99%, holdout 100.00%). Set `comment` aside — it is 65.63%
2178
+ `test/unit/explain-highlight-agreement.test.ts`. Of 81,143 bytes at 7.24.5,
2179
+ 27,538 are bytes **both** sides decided a syntax class for, and 99.99% of
2180
+ those agree (dev 99.99%, holdout 100.00%). Set `comment` aside — it is 63.66%
1932
2181
  of that decided region and mostly the corpus's harness-injected `# Source:`
1933
- banner (#203) — and the remaining 9,615 bytes agree 99.98%. The device stops
1934
- classifying at its one-byte `error`: 36 of 70 scripts carry one, and the
1935
- 41,968 bytes from there on are not a judgment about anything. The oracle
1936
- itself moves between captures: 36 stop at 7.24.2 and 43 stop at 7.23.5 and 36
1937
- stop at 7.25beta3, and 27 of the 70 streams differ between them. **This is a
1938
- trend line, not a pass gate** — the slice is a per-(split, class) quota over a
1939
- corpus that is 96.8% two authors, so the percentage describes this slice.
2182
+ banner (#203) — and the remaining 10,008 bytes agree 99.98%. The device stops
2183
+ classifying at its one-byte `error`: 34 of 67 scripts carry one, and the
2184
+ 38,278 bytes from there on are not a judgment about anything. The oracle
2185
+ itself moves between captures: 34 stop at 7.24.5 and 39 stop at 7.23.8 and 34
2186
+ stop at 7.25rc2 and 34 stop at 7.26beta1, and 25 of the 67 streams differ
2187
+ between them. **This is a trend line, not a pass gate** — the slice is a
2188
+ per-(split, class) quota over a corpus that is 96.8% two authors, so the
2189
+ percentage describes this slice.
1940
2190
 
1941
2191
  | centrs class | projects to | agree | disagree | unprojected | of decided |
1942
2192
  | ------------ | ----------- | ----: | -------: | ----------: | ---------: |
1943
- | `comment` | `comment` | 18,359 | 0 | 0 | 100.00% |
1944
- | `variable-local` | `variable-local` | 1,123 | 0 | 0 | 100.00% |
2193
+ | `comment` | `comment` | 17,530 | 0 | 0 | 100.00% |
2194
+ | `variable-local` | `variable-local` | 1,547 | 0 | 0 | 100.00% |
1945
2195
  | `variable-global` | `variable-global` | 582 | 0 | 0 | 100.00% |
1946
- | `variable-auto` | `variable-auto` | 92 | 0 | 0 | 100.00% |
1947
- | `variable-parameter` | `variable-parameter` | 459 | 0 | 0 | 100.00% |
1948
- | `dir` | `dir` | 1,677 | 0 | 0 | 100.00% |
1949
- | `cmd` | `cmd` | 2,244 | 0 | 0 | 100.00% |
1950
- | `arg` | `arg`, `arg-dot`, `arg-scope` | 2,188 | 0 | 0 | 100.00% |
1951
- | `arg-sep` | `syntax-meta` | 382 | 0 | 0 | 100.00% |
1952
- | `operator` | `syntax-meta` | 237 | 2 | 0 | 99.16% |
1953
- | `brace` | `syntax-meta` | 211 | 0 | 0 | 100.00% |
1954
- | `string` | *abstains* | 0 | 0 | 499 | — |
2196
+ | `variable-auto` | `variable-auto` | 93 | 0 | 0 | 100.00% |
2197
+ | `variable-parameter` | `variable-parameter` | 456 | 0 | 0 | 100.00% |
2198
+ | `dir` | `dir` | 1,701 | 0 | 0 | 100.00% |
2199
+ | `cmd` | `cmd` | 2,224 | 0 | 0 | 100.00% |
2200
+ | `arg` | `arg`, `arg-dot`, `arg-scope` | 2,167 | 0 | 0 | 100.00% |
2201
+ | `arg-sep` | `syntax-meta` | 368 | 0 | 0 | 100.00% |
2202
+ | `operator` | `syntax-meta` | 240 | 2 | 0 | 99.17% |
2203
+ | `brace` | `syntax-meta` | 210 | 0 | 0 | 100.00% |
2204
+ | `string` | *abstains* | 0 | 0 | 573 | — |
1955
2205
  | `escaped` | `escaped` | 418 | 0 | 0 | 100.00% |
1956
- | `value` | *abstains* | 0 | 0 | 144 | — |
1957
-
1958
- | outcome | bytes @7.24.2 | bytes @7.23.5 | bytes @7.25beta3 |
1959
- | ------- | ----: | ----: | ----: |
1960
- | agree — both decided, projection accepts | 27,972 | 27,218 | 27,972 |
1961
- | disagree — both decided, projection rejects | 2 | 2 | 2 |
1962
- | unprojected — no declared projection covers the pair | 643 | 633 | 643 |
1963
- | offline-silent — device decided, centrs abstained | 4,300 | 4,150 | 4,300 |
1964
- | non-syntax — the device answered something syntax cannot decide | 847 | 733 | 847 |
1965
- | device-silent — centrs decided, device said `none` | 5,815 | 5,674 | 5,815 |
1966
- | both-silent | 3,886 | 3,702 | 3,886 |
1967
- | parser-stopped — at/after the device's `error` byte, and silent from there | 41,968 | 43,417 | 41,968 |
1968
- | parser-recovered — past that `error`, and the device classified anyway | 96 | 0 | 96 |
1969
-
1970
- Where the device decided and offline analysis did not (4,300 bytes at 7.24.2,
1971
- the next fill's target list): `syntax-meta` 3,122, `comment` 355,
1972
- `variable-local` 326, `arg` 197, `escaped` 145, `dir` 66, 89 across the rest.
2206
+ | `value` | *abstains* | 0 | 0 | 135 | — |
2207
+
2208
+ | outcome | bytes @7.24.5 | bytes @7.23.8 | bytes @7.25rc2 | bytes @7.26beta1 |
2209
+ | ------- | ----: | ----: | ----: | ----: |
2210
+ | agree — both decided, projection accepts | 27,536 | 27,173 | 27,536 | 27,536 |
2211
+ | disagree — both decided, projection rejects | 2 | 2 | 2 | 2 |
2212
+ | unprojected — no declared projection covers the pair | 708 | 705 | 708 | 708 |
2213
+ | offline-silent — device decided, centrs abstained | 4,288 | 4,187 | 4,288 | 4,288 |
2214
+ | non-syntax — the device answered something syntax cannot decide | 731 | 662 | 721 | 721 |
2215
+ | device-silent — centrs decided, device said `none` | 5,676 | 5,606 | 5,676 | 5,676 |
2216
+ | both-silent | 3,828 | 3,736 | 3,838 | 3,838 |
2217
+ | parser-stopped — at/after the device's `error` byte, and silent from there | 38,278 | 39,072 | 38,278 | 38,278 |
2218
+ | parser-recovered — past that `error`, and the device classified anyway | 96 | 0 | 96 | 96 |
2219
+
2220
+ Where the device decided and offline analysis did not (4,288 bytes at 7.24.5,
2221
+ the next fill's target list): `syntax-meta` 3,090, `comment` 341,
2222
+ `variable-local` 328, `arg` 230, `escaped` 145, `dir` 65, 89 across the rest.
1973
2223
  Most of the `syntax-meta` share is whitespace the device merged into an
1974
2224
  adjacent structure run rather than a token centrs missed.
1975
2225
 
@@ -1981,14 +2231,14 @@ every measured byte, the post-`error` tail included: where one capture stops
1981
2231
  parsing and the next does not, that tail IS the version disagreement, which is
1982
2232
  why it dominates the category.
1983
2233
 
1984
- | applicability @7.24.2 | bytes | leading cells |
2234
+ | applicability @7.24.5 | bytes | leading cells |
1985
2235
  | ---------------------------- | ----: | ------------- |
1986
- | offline-decidable | 32,002 | `comment` → `comment` 18,006, `unclassified` → `syntax-meta` 2,999, `cmd` → `cmd` 2,132 |
1987
- | schema-dependent | 326 | `arg` → `obj-inactive` 326 |
1988
- | state-dependent | 168 | `dir` → `obj-inactive` 120, `cmd` → `obj-inactive` 21, `unclassified` → `obj-dynamic` 20 |
1989
- | version-dependent | 1,158 | `comment` → `comment` 353, `unclassified` → `obj-inactive` 159, `unclassified` → `syntax-meta` 123 |
1990
- | uncategorized | 213 | `unclassified` → `obj-inactive` 90, `unclassified` → `variable-undefined` 75, `value` → `variable-undefined` 21 |
1991
- | no-device-answer | 51,662 | `none` where the captures agree, wherever it falls |
2236
+ | offline-decidable | 32,043 | `comment` → `comment` 17,395, `unclassified` → `syntax-meta` 3,002, `cmd` → `cmd` 2,158 |
2237
+ | schema-dependent | 307 | `arg` → `obj-inactive` 307 |
2238
+ | state-dependent | 94 | `dir` → `obj-inactive` 49, `unclassified` → `obj-dynamic` 20, `cmd` → `obj-inactive` 18 |
2239
+ | version-dependent | 707 | `comment` → `comment` 135, `unclassified` → `obj-inactive` 124, `unclassified` → `syntax-meta` 88 |
2240
+ | uncategorized | 240 | `unclassified` → `obj-inactive` 116, `unclassified` → `variable-undefined` 75, `value` → `variable-undefined` 21 |
2241
+ | no-device-answer | 47,752 | `none` where the captures agree, wherever it falls |
1992
2242
 
1993
2243
  One representative run per cell, so B5 reads a fragment rather than a count.
1994
2244
  `runs` is how often that exact fragment occurs; the location is a slice script
@@ -1997,9 +2247,8 @@ and a byte offset into the stream the device saw.
1997
2247
  | outcome | cell | fragment | runs | first at |
1998
2248
  | ------- | ---- | -------- | ---: | -------- |
1999
2249
  | 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 |
2000
- | unprojected | `string` → `syntax-meta` | `"\""` | 311 | `eworm/ppp-on-up.rsc` @435 |
2001
- | 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 |
2002
- | unprojected | `string` → `arg` | `"as-value"` | 1 | `forum/amm0/topic-266499-how-to-condense-variables-passed-through-to-function/post-0020-snippet-01.rsc` @279 |
2250
+ | unprojected | `string` → `syntax-meta` | `"\""` | 326 | `eworm/ppp-on-up.rsc` @435 |
2251
+ | 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 |
2003
2252
  | unprojected | `string` → `cmd` | `"len"` | 1 | `forum/amm0/topic-155978-zerotier-on-mikrotik-a-rosetta-stone-v7-1-1/post-0001-snippet-01.rsc` @1794 |
2004
2253
  | unprojected | `string` → `dir` | `":"` | 1 | `forum/amm0/topic-155978-zerotier-on-mikrotik-a-rosetta-stone-v7-1-1/post-0001-snippet-01.rsc` @1793 |
2005
2254
  <!-- END GENERATED highlight-agreement -->
@@ -2089,9 +2338,10 @@ the 16,526 bytes `arg-sep` claims, one per attribute. Token count rises 214,503
2089
2338
  → 231,026 because one run became two. **This is a retag.**
2090
2339
 
2091
2340
  (Those totals are the corpus as it stood for THIS measurement. The classified
2092
- total is 1,023,107 today: #347/#348 later classified 8 further bytes, and #355's
2341
+ total is 1,022,805 today: #347/#348 later classified 8 further bytes, #355's
2093
2342
  fail-closed symbol stop then unclaimed 306 of them — variable bytes sitting
2094
- after a bare apostrophe, which the device never reaches. The identity being
2343
+ after a bare apostrophe, which the device never reaches — and #211's unlisted
2344
+ bare-path abstention unclaimed 302 more. The identity being
2095
2345
  claimed here is between the two sides of the split, not with any later total —
2096
2346
  restating it against today's figure would credit this retag with a movement a
2097
2347
  different change made. The generated block above carries the current numbers.)
@@ -2179,7 +2429,7 @@ bytes came from a fill that already held it — `string` gives up 17,672 and
2179
2429
  last decimal**, 1,023,405 of 1,426,731 (71.73076073906013%), so in byte terms
2180
2430
  this is a retag even though it took a new walk to make — again, the identity is
2181
2431
  between this change's own before and after, and the corpus total has since moved
2182
- to 1,023,107 / 241,539 tokens under #347/#348 and #355. Only the token count
2432
+ to 1,022,805 / 241,442 tokens under #347/#348, #355 and #211. Only the token count
2183
2433
  moves, 231,026 → 241,663: the 7,408 new `escaped` tokens plus the 3,229
2184
2434
  fragments they leave behind when they split a run they sit inside (`string`
2185
2435
  +2,643, `value` +586). That second number is well under one per escape because
@@ -2449,31 +2699,65 @@ config genre in question). Since phase 0 the corpus has grown to 948 with a
2449
2699
  export-banner share to 4.6%; **that stratum is not part of any phase-0 figure
2450
2700
  above**. Current whole-corpus offline censuses include its source text.
2451
2701
 
2452
- The pinned snapshot carries **three complete builds** — long-term `7.23.5`,
2453
- stable `7.24.2` and development `7.25beta3` — each with *both* oracles over all
2454
- 948 scripts, alongside six partial captures (parseIL at 7.20.8, 7.22.1,
2455
- 7.23rc1; highlight at 7.9.2, 7.23.2, 7.24rc2) that cover only 913. Schema v4
2456
- names that split rather than leaving it to be discovered by hand: the
2457
- `v_version_coverage` view classifies each build, and
2458
- `coverage_class = 'complete'` is the filter every version-comparable query uses.
2459
- `source_scripts` is byte-identical across both repins that reached this
2460
- snapshot, so every text-derived report — the token and value censuses,
2461
- `explain:arg-reach`, the browser-consumer proof — is unchanged by construction,
2462
- and only the IL-derived operator census moved.
2463
-
2464
- **The three complete builds do not agree with each other, and that is the
2465
- finding.** Scored over all 948 scripts:
2466
-
2467
- | oracle | long-term 7.23.5 ↔ stable 7.24.2 | stable 7.24.2 ↔ development 7.25beta3 |
2468
- | ------ | -------------------------------: | ------------------------------------: |
2469
- | `:parse` IL (`il_sha256`) | 87 scripts differ | 11 |
2470
- | `highlight` (count + class set) | 33 | 0 |
2471
-
2472
- Stable and development are the same build for these purposes; long-term is the
2473
- one that moves. So an operator-axis or token-partition figure is a claim *about
2474
- a channel*, not about RouterOS — which is why the census reports its builds as
2475
- separate columns and states its version set, rather than collapsing them to one
2476
- number. Long-term is also the channel a real device most likely runs.
2702
+ The pinned snapshot (schema 5, tikoci/lsp-routeros-ts#40, plus the
2703
+ `wiresage-conformance` collection from tikoci/lsp-routeros-ts#42) carries one
2704
+ recorded capture session per release channel — long-term `7.23.8`, stable `7.24.5`,
2705
+ testing `7.25rc2` and development `7.26beta1`. Each is a `:parse` run and a
2706
+ `highlight` run over all 948 population scripts, bound to the exact submitted
2707
+ bytes and one device environment with every package installed. Two legacy
2708
+ `:parse` captures (7.20.8 and 7.22.1: 913 scripts, unknown input bytes, base
2709
+ package only) and a legacy 7.9.2 highlight summary remain as history, never as
2710
+ exact-input evidence.
2711
+
2712
+ Every census reads the corpus through `scripts/corpus-db.ts`, in three ways
2713
+ (#498):
2714
+
2715
+ - **Population.** `v_corpus_population` leaves out the upstream auxiliary
2716
+ collections: 7 `capture-controls` rows and 875 `wiresage-conformance`
2717
+ synthetic grammar cases. An observation is in the population by `script_id`,
2718
+ not by path prefix, so a collection added upstream cannot leak into a census
2719
+ (it did, briefly, when #42 added 875 rows to every run).
2720
+ - **Sessions.** Comparable figures name their channel run ids
2721
+ (`capture_channels`), not a coverage label.
2722
+ - **Verdict.** The device verdict is the upstream `response_class`. Only
2723
+ `il-no-recognized-diagnostic` is an accept, so IL carrying an embedded
2724
+ `bad command name …` is a rejection. The shipped `:parse` reader
2725
+ (`classifyParseResult`) agrees with it on all 5,640 channel and legacy
2726
+ observations.
2727
+
2728
+ The population text is byte-identical to the previous snapshot, so the
2729
+ text-derived reports are unchanged by construction: the token and value
2730
+ censuses, `explain:arg-reach` and the browser-consumer proof. The IL- and
2731
+ highlight-derived figures moved.
2732
+
2733
+ **The channels do not agree with each other, and that is the finding.** Scored
2734
+ over all 948 scripts:
2735
+
2736
+ | oracle | long-term 7.23.8 ↔ stable 7.24.5 | stable ↔ testing 7.25rc2 | testing ↔ development 7.26beta1 |
2737
+ | ------ | -------------------------------: | -----------------------: | ------------------------------: |
2738
+ | `:parse` readout | 95 scripts differ | 12 | 13 |
2739
+ | `:parse` response class | 5 | 0 | 0 |
2740
+ | `highlight` stream | 24 | 1 | 0 |
2741
+
2742
+ The newer channels are nearly the same build for these purposes; long-term is
2743
+ the one that moves. So an operator-axis or token-partition figure is a claim
2744
+ *about a channel*, not about RouterOS. That is why the census reports its
2745
+ builds as separate columns and states its session set, rather than collapsing
2746
+ them to one number. Long-term is also the channel a real device most likely
2747
+ runs.
2748
+
2749
+ **The `wiresage-conformance` cases are read separately, never as population.**
2750
+ `conformanceCases` returns the 875 third-party grammar probes with their
2751
+ upstream label, and `parseObservations(…, { collection })` returns their
2752
+ device answers. `bun run explain:conformance` scores the execute gate's offline
2753
+ answer (`fail` refuses) against `:parse` on every channel. It lists false
2754
+ rejects, missed rejections (split by device class: `plain-diagnostic` is
2755
+ syntax, while `embedded-diagnostic` is mostly a menu or parameter name, which
2756
+ needs the #501 argument lists), cases where the channels disagree, cases
2757
+ with no device answer (`unscorable`, never read as a rejection), and the
2758
+ upstream-label disagreements that #507 needs. The lists are committed in
2759
+ `test/fixtures/explain/conformance.json` and gated in CI, so a fix for any
2760
+ of #504–#506 shows the cases it moved.
2477
2761
 
2478
2762
  The corpus itself is not in this repo and is not moving here — `lsp-routeros-ts`
2479
2763
  owns producing snapshots, centrs owns which snapshot it measures against.
@@ -2543,10 +2827,27 @@ to mention. `/system/reboot` no longer moves the context, so a statement after
2543
2827
  it resolves where it actually is; `verbsplit` and `pathresolve` agree, which
2544
2828
  they would otherwise now do *confidently* in opposite directions. What is left
2545
2829
  is the honest residue of both floors — `/terminal/cuu` is a real no-argument
2546
- command MikroTik publishes nowhere — and it stays pinned as a KNOWN LIMIT
2547
- fixture rather than assumed closed. The contradiction that #202 would have
2830
+ command MikroTik publishes nowhere. The contradiction that #202 would have
2548
2831
  rendered is gone.
2549
2832
 
2833
+ **#211 makes that residue abstain (R14).** A verb-free absolute path that
2834
+ neither table knows used to still move the context, so `/terminal/cuu` then
2835
+ `print` resolved to an invented `/terminal/cuu/print`. On CHR 7.24.5 `:parse`
2836
+ reads `/terminal/cuu` as a command and that `print` as `bad command name print`
2837
+ at the root. `/ip fire conn` has the same shape and is navigation to
2838
+ `/ip/firewall/connection`, because RouterOS accepts abbreviated menus. A table
2839
+ miss therefore cannot decide either way. The statement now reads as R13's
2840
+ relative abstention does: not navigation, flagged `maybeNav`, with the
2841
+ statements after it `unknown` until a known absolute navigation recovers the
2842
+ context ([example 34](examples.md#34-an-unlisted-bare-path-does-not-become-a-menu)).
2843
+ Over the pinned corpus, 14 of 18,650 statement readings lose a path, in 6
2844
+ scripts. Every one was an invented path: `/ip/fire/conn/remove`,
2845
+ `/ip/firewall/layer7/add`, the v6 `/routing/bgp/network/add`, and
2846
+ `/terminal/style/error/put`. No `explain` verdict changes. Four `containsWrite`
2847
+ verdicts move from `true` to `unknown`, because their write was proven only at
2848
+ an invented path. Rule P, which would read the tables as a per-menu closed
2849
+ world and expand abbreviations, is not adopted (#372).
2850
+
2550
2851
  **The command axis also outranks the punctuation guess.** `splitRun`'s last
2551
2852
  rule reads the first space-separated token as the verb, which lands on the
2552
2853
  operand whenever the command itself was written slash-joined:
@@ -2676,8 +2977,8 @@ The offline capability can advance from `coded` to `verified` when:
2676
2977
  - **Reachable evidence and explicit residue:** changed RouterOS semantic claims
2677
2978
  have committed CHR-grounded controls and a reproducible capture/replay path.
2678
2979
  Every remaining issue has a stated scope and dependency; #225 distinguishes
2679
- offline value work from live types, #272 owns evidence reachability, and #211's
2680
- unlisted-path decision retains the current abstention until separately decided.
2980
+ offline value work from live types, #272 owns evidence reachability, and an
2981
+ unlisted bare path abstains (#211) until #372 prices a closed-world reading.
2681
2982
 
2682
2983
  Retain the existing checks: `bun run lint`, `bun run test`, `bun run build`, and
2683
2984
  `bun run lint:ci`, plus the affected CHR grounding/integration procedure from