@tikoci/centrs 0.1.9-next.22 → 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.
@@ -483,7 +483,8 @@ REST body; the comment text is never rendered as an operand.
483
483
 
484
484
  The CHR assertion grounds the wider placement rule on the requested RouterOS
485
485
  channel via `/console/inspect request=highlight` plus `:parse`: unquoted `#`
486
- inside brace arrays and parenthesized expressions is a hard error; the first
486
+ inside brace arrays and parenthesized expressions is a hard error, except at
487
+ column 0 inside an array (example 37); the first
487
488
  non-space `#` in `do`, `else`, `foreach do`, and `on-event` bodies that are
488
489
  themselves in a statement context is a comment — the same `do={ # c` nested
489
490
  inside an array is an array and `#` is an error (placement table, #249), while
@@ -700,3 +701,261 @@ envelope would otherwise have to derive the name/value boundary from
700
701
  The same command also shows the two `=` bytes staying apart: the one in
701
702
  `($x = 1)` is a comparison and comes back `operator`, decided by fill order
702
703
  rather than by a scan for the byte.
704
+
705
+ ### 33. A space for the `=` is not an empty argument list
706
+
707
+ ```bash
708
+ centrs explain '/ip/firewall/layer7-protocol add comment "something" regexp 1.1' --json
709
+ ```
710
+
711
+ RouterOS accepts this as `comment="something" regexp=1.1`: `:parse` lowers both
712
+ spellings to the same IL, and `highlight` marks each gap `syntax-obsolete`.
713
+ Which word is an argument name is per-command schema, so offline publishes
714
+ neither reading. The statement stays `resolved` on
715
+ `/ip/firewall/layer7-protocol add`, `command.args` is absent, `arguments.read`
716
+ is `false` with a `why` naming `` `comment "something"` ``, and
717
+ `transport.classification` stays `"unknown"`.
718
+
719
+ `data.verdict` is `warn`, with one
720
+ `explain/canonicalizer/possible-spaced-argument` **warning** per pair,
721
+ spanning `comment "something"` and `regexp 1.1`. Each says to write the `=`.
722
+ The warning needs a value that cannot be a flag: `/interface set ether1 name x`
723
+ also abstains but does not warn, because `/interface print detail
724
+ without-paging` is the same shape and is two flags
725
+ (`detail=true;without-paging=true`). The `=` spelling still reads both
726
+ arguments, and a script body may drop its `=` too:
727
+ `/system/script add name=beep source { :beep }` is the `source={ :beep }` body,
728
+ not an invalid brace.
729
+
730
+ ### 34. An unlisted bare path does not become a menu
731
+
732
+ ```bash
733
+ centrs explain $'/terminal/cuu\nprint\nadd name=x' --json
734
+ ```
735
+
736
+ `/terminal/cuu` is a no-argument command that neither the menu table nor the
737
+ command catalog lists. `/ip fire conn` has the same shape and is navigation:
738
+ RouterOS accepts abbreviated menus. CHR 7.24.5 `:parse` confirms both, so a
739
+ table miss cannot decide between a menu and a command.
740
+
741
+ The first statement stays `ambiguous`. The two after it are `unknown`
742
+ (`context lost to an earlier unreadable statement`, `contextCertain: false`),
743
+ with no `command` and no `transport`, where they used to resolve to an
744
+ invented `/terminal/cuu/print` and `/terminal/cuu/add`. On the device they are
745
+ `bad command name print` and `bad command name add` at the root.
746
+ `structure.containsWrite` is `unknown` and `data.verdict` is `warn`. A known
747
+ absolute navigation recovers the context: in
748
+ `/terminal/cuu`, `/ip address`, `print`, the last statement resolves to `print`
749
+ at `/ip/address` again.
750
+
751
+ ### 35. A brace after a command is its argument, not a menu block
752
+
753
+ ```bash
754
+ centrs explain '/system/script add name beep source { :beep }' --json
755
+ ```
756
+
757
+ `/menu { … }` opens a menu block only when the words before the brace name a
758
+ menu. Here `add` is a command, so the brace is the value of `source` with the
759
+ `=` left out. CHR 7.22.1 and 7.24.5 `:parse` give the same IL as
760
+ `source={ :beep }`.
761
+
762
+ `data.structure.statements` has one statement, `/system/script add`, whose
763
+ span covers the whole input. `:beep` is no longer published as a second
764
+ top-level statement, which RouterOS never runs. `data.verdict` is `warn`, from
765
+ the `possible-spaced-argument` diagnostic on `source { :beep }`.
766
+
767
+ RouterOS accepts any unique prefix of an argument name. An abbreviation that
768
+ can only stand for a script-valued name keeps its body in either spelling:
769
+ `sour { :beep }`, `down-scr { :beep }` and `o={ :beep }` lower to `source=`,
770
+ `down-script=` and `on-event=` on CHR 7.24.5 and 7.23.8. Abbreviating the first
771
+ segment (`down={ :beep }` for `down-script`) is accepted on the device too, but
772
+ offline still reports `invalid-command-brace`. Every word is a prefix of some
773
+ `<word>-script`, so telling it from `comment={ :beep }` needs the command's
774
+ argument list.
775
+
776
+ A brace straight after a command is a device error, and offline now reports it.
777
+ `/system/script add { :beep }`, `/ip/route/print {1;2}` and
778
+ `/system/reboot { :beep }` each get `invalid-command-brace` and `data.verdict`
779
+ `fail`. A run that names no command still opens a block, so
780
+ `/system script { print }` is navigation to `/system/script` followed by
781
+ `print`.
782
+
783
+ ### 36. A brace in a `where` clause is an array value
784
+
785
+ ```bash
786
+ centrs explain '/ip address print where interface in {"ether1";"ether2"}' --json
787
+ ```
788
+
789
+ A `where` clause is expression context, so the brace is the array operand of
790
+ `in`. CHR 7.24.5 and 7.23.8 `:parse` lower it to
791
+ `(in $interface ether1;ether2)`. `data.verdict` is `pass`, so `centrs execute`
792
+ runs this read. Before #502 its offline stage refused it with
793
+ `invalid-command-brace`. `data.values.occurrences` holds the array and its two
794
+ string members, the same three records as
795
+ `where interface in ("ether1","ether2")`.
796
+
797
+ The brace is also a value after `~`, `<`, `>`, `<=` and `>=`, on the left of an
798
+ operator (`where {"ether1"} in interface`), and after `!interface=` or `"a"=`.
799
+
800
+ After `<name>=`, `<name>!=` or `<name>==`, spaced or not, the device reads the
801
+ value with that property's parser and rejects the brace. Two examples are
802
+ `where interface={"ether1"}` (`expected interface value`) and
803
+ `where disabled={"y"}` (`expected yes or no`). Offline keeps
804
+ `invalid-command-brace` at the `{` there. The device does this only for a
805
+ property the menu knows: `where foo-bar={"y"}` parses. Offline cannot tell
806
+ property names apart without a per-menu list, so it reports that one too.
807
+
808
+ ### 37. A column-0 `#` line inside an array is a comment
809
+
810
+ ```bash
811
+ centrs explain ':local z {a=1;
812
+ # c
813
+ b=2}' --json
814
+ ```
815
+
816
+ `data.verdict` is `pass`. `data.spans` holds a `comment` span over `# c`, and
817
+ `data.values.occurrences` holds the array and its two members `a` and `b`. CHR
818
+ 7.24.5 and 7.22.1 `:parse` lower it to `value=a=1;b=2`. Before #503, offline
819
+ failed it with `invalid-hash` at the `#`.
820
+
821
+ The rule holds at any depth (`{a={⏎# c⏎1}}`) and in argument values
822
+ (`:foreach i in={1;⏎# c⏎2} do={:put $i}`). It needs column 0: an indented `#`
823
+ (`{a=1;⏎ # c⏎b=2}`), and one after `{`, a value or `;` on the same line, keep
824
+ `invalid-hash` at the `#`. A `#` directly inside `( … )` stays an error too. A
825
+ comment-only array, `:local z {⏎# c⏎}`, fails like `:local z {}`: both are empty
826
+ arrays, which the device rejects (#511, example 38).
827
+
828
+ ### 38. An empty array value and a misplaced `;` are rejected
829
+
830
+ ```bash
831
+ centrs explain ':local z {}' --json
832
+ ```
833
+
834
+ `data.verdict` is `fail` with `explain/canonicalizer/empty-group` over `{}`. CHR
835
+ 7.24.5 and 7.23.8 `:parse` answer `syntax error (line 1 column 12)`, the byte
836
+ after the brace. The same holds for `:put {}`, `:return {}`, `[:len {}]` and
837
+ `:foreach i in={} do={…}`. A nested `{}` (`{1;{}}`), one in parentheses
838
+ (`({})`), `where … in {}` and a body (`do={}`, `:execute {}`) parse and pass.
839
+
840
+ `:local z {1⏎;2}` fails with `misplaced-separator` at the `;`: a `;` needs an
841
+ element before it on its line. `{1;}` and `{1⏎2}` pass.
842
+
843
+ ### 39. An expression with a missing operand is rejected
844
+
845
+ ```bash
846
+ centrs explain ':put (1 +)' --json
847
+ ```
848
+
849
+ `data.verdict` is `fail` with `explain/canonicalizer/missing-operand` at the
850
+ `+`. The device answers `syntax error (line 1 column 11)`, at the `)`. `()`
851
+ fails with `empty-group`, and `(1; 2)` and `(1⏎+ 2)` fail with
852
+ `misplaced-separator`. `(1 2)`, `(- 1)` and `(1 + -2)` pass. Inside a query,
853
+ `where (comment=+)` passes: the device reads that value with the property's
854
+ parser.
855
+
856
+ ### 40. A `@` in a command word is rejected
857
+
858
+ ```bash
859
+ centrs explain '[admin@MikroTik] > /ip address print' --json
860
+ ```
861
+
862
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-at-sign` at the
863
+ `@`. The device answers `syntax error (line 1 column 7)`, the same byte. This
864
+ is a pasted console prompt. `:put a@b` and `comment=a@b` pass, because a `@` is
865
+ a value byte in an argument.
866
+
867
+ ### 41. A named comparison brace inside a `[find where …]` is rejected
868
+
869
+ ```bash
870
+ centrs explain ':put [/ip address find where interface={"x"}]' --json
871
+ ```
872
+
873
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-command-brace` at
874
+ the `{`, as at the top level (example 36). The device answers
875
+ `expected interface value (line 1 column 40)`. `where (interface={"x"})` fails
876
+ the same way, and `[find where interface in {"x"}]` passes.
877
+
878
+ ### 42. A stray top-level `}` ends the script, and the rest is discarded
879
+
880
+ ```bash
881
+ centrs explain ':put 1};:put 2' --json
882
+ ```
883
+
884
+ `data.verdict` is `warn` with `explain/canonicalizer/discarded-close` over the
885
+ `}` and everything after it. CHR 7.24.5 and 7.23.8 accept the script and lower
886
+ it to `(evl /putmessage=1)`: the device stops reading at a `}` or `]` that closes
887
+ nothing at the top level, so `:put 2` never runs and
888
+ `/system identity set name=a}` sets `a`. `data.structure` reads only the
889
+ prefix. A stray close inside an open group (`:put (1})`, `{:put 1]}`) and any
890
+ stray `)` are still rejections and still fail with `unbalanced-close`. The
891
+ execute gate refuses this warning (`commands/execute/examples.md` V9).
892
+
893
+ ### 43. A variable name is letters and digits
894
+
895
+ ```bash
896
+ centrs explain ':local a_b 1' --json
897
+ ```
898
+
899
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-name` at the `_`.
900
+ The device answers `expected end of command (line 1 column 9)`, the same byte.
901
+ `:global`, `:set`, `:for` and `:foreach` read the name the same way, and
902
+ `:local $a` and `:local (a)` fail at their first byte. `:local "a_b"`,
903
+ `:local 1a`, `:foreach a,b in={1} do={}` and `:local name=x value=1` pass.
904
+
905
+ ### 44. A `$` must start a variable
906
+
907
+ ```bash
908
+ centrs explain ':put "a$ b"' --json
909
+ ```
910
+
911
+ `data.verdict` is `fail` with `explain/canonicalizer/bad-string-variable` over
912
+ the `$` and the space after it. The device answers `syntax error (line 1 column 9)`, at the space. Outside
913
+ a string, `:put $_` fails with `invalid-variable` the same way. `"$v_x"` (which
914
+ is `$v` then `_x`), `"\$ x"`, `"$(1)"` and `$"a b"` pass. `"a$"` also passes,
915
+ although the device rejects it: it reads `$"` as the start of a quoted name, and
916
+ offline's string scanners end the string there, so offline abstains.
917
+
918
+ ### 45. Nothing may be glued to a closed token
919
+
920
+ ```bash
921
+ centrs explain ':if (true) do={:put 1}else={:put 2}' --json
922
+ ```
923
+
924
+ `data.verdict` is `fail` with `explain/canonicalizer/glued-token` at the `e`.
925
+ The device answers `expected end of command (line 1 column 23)`. The same rule
926
+ rejects `:put "a"b`, `$v.b`, `(1)(2)`, `[:len a]b`, `src-address=$v/24` and a
927
+ bare word run into a `"`, `$` or bracket (`:put a"b"`, `name=a$v`). Inside
928
+ `where` and `find`, and inside a group, the rule does not apply (`where
929
+ comment="a"b` and `("a"b)` parse). A command argument whose property parser
930
+ takes the glued part also passes: `dst-address-list=!"my list"` and
931
+ `dst-port=80,"443"`.
932
+
933
+ ### 46. Statement ids and source ownership
934
+
935
+ ```bash
936
+ centrs explain ':if (true) do={ :foreach i in={1;2} do={ :put $i } } else={ :put 0 }; :put 3' --json
937
+ ```
938
+
939
+ The statements keep discovery order and have ids `s0` through `s4`.
940
+ The `foreach` (`s1`) has `parent: "s0"`; its `put` (`s2`) has `parent: "s1"`;
941
+ the `else` body's `put` (`s3`) has `parent: "s0"`. The final `put` (`s4`) has
942
+ no parent. The published `do` and `else` blocks both have `owner: "s0"`;
943
+ `s1` and `s2` share `block: 0`, and `s3` has `block: 1`. A `[find]` inside a
944
+ body names that body's statement as its `owner`. Links describe source
945
+ ownership; binding and execution-context claims remain separate (#507).
946
+
947
+ ### 47. A stored script stays opaque
948
+
949
+ ```bash
950
+ centrs explain '/system/script/add name=proof source={ :local x 1; :put $x }; :put 2' --json
951
+ ```
952
+
953
+ Only the installing command (`s0`) and final `put` (`s1`) appear in
954
+ `statements[]`; neither has a parent, and `blocks[]` stays empty. The deferred
955
+ body's local and `put` are not exposed as statements. Scheduler `on-event=`
956
+ and Netwatch `down-script=` have the same structural boundary.
957
+
958
+ The CHR controls in `test/integration/explain-structure.test.ts` parse the
959
+ scope examples and compare stored-script creation, explicit `run`, and an
960
+ immediate `:do` body. Those are test operations on disposable CHR; explain
961
+ itself never executes the analyzed input.
@@ -58,6 +58,10 @@ is unsafe the offline stage is what keeps the gate from degrading to nothing. A
58
58
  clean offline pass is **necessary, never sufficient** — `explain` publishes
59
59
  `runtimeAcceptance: "not-proven"` for exactly this reason. An offline `warn` is
60
60
  an ABSTENTION and passes through to the device stage; only a `fail` rejects.
61
+ The one exception is `discarded-close`: a stray top-level `}` or `]` that the
62
+ device accepts and then silently stops reading at (#504). It is a warning,
63
+ because nothing is rejected, and the gate refuses it by code
64
+ (`src/offline-gate.ts`), because the device would run a different script.
61
65
 
62
66
  `meta.validation.stages[]` reports what each stage concluded, including a stage
63
67
  that was skipped and why. A reader must always be able to tell "the device
package/docs/MATRIX.md CHANGED
@@ -182,17 +182,26 @@ Wire, auth, TCP/UDP, and CI caveats live in `commands/btest/README.md` and the
182
182
 
183
183
  ## Current priority
184
184
 
185
- **First: the CMR track** (maintainer decision 2026-10-12 on
186
- [#441](https://github.com/tikoci/centrs/issues/441)). Done: `execute --cmr`
185
+ **First: `explain`** (maintainer decision 2026-10-16 on
186
+ [#90](https://github.com/tikoci/centrs/issues/90)). In order: make the
187
+ published offline readings honest (#350, #211), give the result a statement
188
+ tree, then the live probe foundation (#491). Then use those probes to
189
+ ground #351, #358 and #371, and finish live (#492 → #236 closeout). The
190
+ plan, and what is deliberately out of this run, is the 2026-10-16 plan
191
+ comment on #90. It is step 3 of the validation track below.
192
+
193
+ **The CMR track is paused until RouterOS 7.26beta2** (maintainer decision
194
+ 2026-10-16). It was first from the 2026-10-12 decision on
195
+ [#441](https://github.com/tikoci/centrs/issues/441). Done: `execute --cmr`
187
196
  (#450), which runs one validated command on CMR clients through their
188
197
  controller. The contract is in
189
198
  [`commands/execute/README.md` → CMR](../commands/execute/README.md#cmr-clients-through-their-controller---cmr),
190
199
  with examples CMR0–CMR15. Also done: the fleet blast-radius gate for
191
200
  controller fleet commands run with plain `execute` or `api` (#453), with
192
201
  examples CMR16–CMR18 and api CMR1. The feature map of CMR against centrs is
193
- on #441 (2026-10-13). Next is the read-only slice of #449 (a `devices` view
194
- of members and a default controller). Deferred: CDB linkage (#449) and `retrieve --cmr` (#441).
195
- Live `explain` resumes after this.
202
+ on #441 (2026-10-13). When it resumes, next is the read-only slice of #449 (a
203
+ `devices` view of members and a default controller). Deferred: CDB linkage
204
+ (#449) and `retrieve --cmr` (#441).
196
205
 
197
206
  **The native-api / NDJSON line** (maintainer decision on
198
207
  [#396](https://github.com/tikoci/centrs/issues/396), 2026-10-07): agents in the
@@ -230,16 +239,19 @@ producers owned with #225. REST and native-api cells advance only when their
230
239
  applicable examples pass on CHR via `bun run test:integration`.
231
240
 
232
241
  Open offline follow-ups remain explicit but do not reopen the verified baseline.
233
- Issue #211 retains the fail-closed unlisted-path decision; #272 owns
242
+ Issue #211 made an unlisted absolute bare path abstain (rule F, R14); rule
243
+ P's closed-world reading is still not adopted (#372). #272 owns
234
244
  non-authoritative `.scratch/` citation cleanup. #350 (the broad optional `=`,
235
245
  schema-dependent and so belonging to step 3), #351 (an unresolvable menu that
236
246
  abstains at 2-deep nesting but hard-fails at 3-deep) and #355 (`'` is not a
237
247
  RouterOS token — a false ACCEPT the corpus join cannot see) are bounded analyzer
238
- defects that step 2 now surfaces through the `execute`/`api` gate. #336 and #342 are complete: the
239
- corpus is repinned to three *complete* builds (long-term 7.23.5, stable 7.24.2,
240
- development 7.25beta3), and the operator sweep and highlight slice are re-swept
241
- onto those same builds — plus 7.21.5 retained as an age anchor — so the operator
242
- axis and the token partition are scored on the same firmware. The #335
248
+ defects that step 2 now surfaces through the `execute`/`api` gate. #336, #342 and #498 are complete: the
249
+ corpus is pinned to schema 5, with one recorded session per release channel
250
+ (long-term 7.23.8, stable 7.24.5, testing 7.25rc2, development 7.26beta1) read
251
+ through `scripts/corpus-db.ts`. The highlight slice is cut from those sessions
252
+ and gated in CI, and the operator sweep is re-run on the same builds — plus
253
+ 7.21.5 retained as an age anchor — so the operator axis and the token partition
254
+ are scored on the same firmware. The #335
243
255
  source refresh pins both architectures of those versions beside the 7.10.2 /
244
256
  7.16 history anchors and accepts the current CLI-Reference drift. Issue #223 is
245
257
  a separate MCP response migration. If live evidence finds a wrong offline
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tikoci/centrs",
3
- "version": "0.1.9-next.22",
3
+ "version": "0.1.9-next.23",
4
4
  "main": "src/index.ts",
5
5
  "module": "src/index.ts",
6
6
  "repository": {
@@ -103,11 +103,14 @@
103
103
  "explain:catalog": "bun run scripts/gen-explain-catalog.ts",
104
104
  "explain:catalog:check": "bun run scripts/gen-explain-catalog.ts --check",
105
105
  "explain:corpus-census": "bun run scripts/explain-corpus-census.ts",
106
+ "explain:conformance": "bun run scripts/explain-conformance.ts",
107
+ "explain:conformance:check": "bun run scripts/explain-conformance.ts --check",
106
108
  "explain:highlight-agreement": "bun run scripts/explain-highlight-agreement.ts",
107
109
  "explain:highlight-agreement:check": "bun run scripts/explain-highlight-agreement.ts --check",
108
110
  "explain:highlight-agreement:readme": "bun run scripts/explain-highlight-agreement.ts --readme",
109
111
  "explain:highlight-agreement:readme:check": "bun run scripts/explain-highlight-agreement.ts --readme --check",
110
112
  "explain:highlight-slice": "bun run scripts/explain-highlight-slice.ts",
113
+ "explain:highlight-slice:check": "bun run scripts/explain-highlight-slice.ts --check",
111
114
  "explain:menus": "bun run scripts/gen-explain-menus.ts",
112
115
  "explain:operator-census": "bun run scripts/explain-operator-census.ts",
113
116
  "explain:operator-census:check": "bun run scripts/explain-operator-census.ts --check",
@@ -87,7 +87,7 @@ import {
87
87
  } from "./segment.ts";
88
88
 
89
89
  /** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
90
- const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
90
+ export const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
91
91
 
92
92
  /** What one argument token is. */
93
93
  export type ArgumentKind = "attribute" | "positional" | "query";
@@ -327,6 +327,12 @@ interface WalkOptions {
327
327
  * see {@link braceSlotTakesArray}.
328
328
  */
329
329
  directiveVerb?: string;
330
+ /**
331
+ * Where a command's top-level `where` positional ends, set by the walk itself
332
+ * once it reads one. From there on a brace is an expression value
333
+ * (see {@link comparesNamedValue}).
334
+ */
335
+ whereAt?: number;
330
336
  }
331
337
 
332
338
  /** How deep member scanning descends before it stops and keeps only the shape. */
@@ -363,7 +369,11 @@ function braceOpensArray(
363
369
  options: WalkOptions,
364
370
  ): boolean {
365
371
  const verb = options.directiveVerb;
366
- if (verb === undefined) return false;
372
+ if (verb === undefined)
373
+ return (
374
+ options.whereAt !== undefined &&
375
+ !comparesNamedValue(text, at, options.whereAt)
376
+ );
367
377
  const owner = braceValueOwner(text, tokenStart, at);
368
378
  if (owner === undefined) return false;
369
379
  return braceSlotTakesArray(
@@ -390,6 +400,7 @@ function* walkArguments(
390
400
  let i = Math.max(0, from);
391
401
  // How many positionals have been read, which is the NEXT positional's index.
392
402
  let positionals = 0;
403
+ let walk = options;
393
404
  while (i < text.length) {
394
405
  // The MASKED view decides whitespace, because `maskComments` blanks a
395
406
  // continuation comment that `text` still spells with a `#`. Reading `text`
@@ -412,7 +423,7 @@ function* walkArguments(
412
423
  i += continuation;
413
424
  continue;
414
425
  }
415
- const token = scanToken(text, structural, i, positionals, options);
426
+ const token = scanToken(text, structural, i, positionals, walk);
416
427
  if (typeof token === "string") {
417
428
  yield token;
418
429
  return;
@@ -430,7 +441,7 @@ function* walkArguments(
430
441
  // the refusal exists to prevent. Shape only, never a value.
431
442
  const read =
432
443
  token.why === undefined
433
- ? readToken(text, structural, i, token.end, options)
444
+ ? readToken(text, structural, i, token.end, walk)
434
445
  : undecidedToken(text, i, token.end, token.why);
435
446
  if (typeof read === "string") {
436
447
  if (options.tolerant !== true) {
@@ -446,6 +457,13 @@ function* walkArguments(
446
457
  continue;
447
458
  }
448
459
  if (read.kind === "positional") positionals++;
460
+ if (
461
+ read.kind === "positional" &&
462
+ read.text === "where" &&
463
+ walk.directiveVerb === undefined &&
464
+ walk.whereAt === undefined
465
+ )
466
+ walk = { ...walk, whereAt: token.end };
449
467
  yield read;
450
468
  i = token.end;
451
469
  }
@@ -1661,13 +1679,51 @@ export function lexValueAnchors(
1661
1679
  return { complete: true, anchors };
1662
1680
  }
1663
1681
 
1682
+ /**
1683
+ * Whether the brace at `at` is the right side of a `where` comparison that
1684
+ * RouterOS reads with the named property's own value parser (#502).
1685
+ *
1686
+ * A `where` clause is expression context, so a brace there is an array value —
1687
+ * except straight after `<name>=`, `<name>!=` or `<name>==`, spaced or not.
1688
+ * On CHR 7.24.5 and 7.23.8, `:parse` rejects `/ip/route/print where
1689
+ * comment={1;2}` with `expected comment value`, and `where disabled={…}` with
1690
+ * `expected yes or no`. It accepts the brace after `in`, `~`, `<`, `>`, `<=`
1691
+ * and `>=`, on the left of `=`, and after `!interface=`, `$x=` and `"a"=`.
1692
+ *
1693
+ * Only a property name the menu KNOWS is read this way: `where foo-bar={…}` is
1694
+ * accepted. Offline has no per-menu property list (decision 3), so every bare
1695
+ * name counts, and an unknown one is a pinned over-strict reading.
1696
+ *
1697
+ * The name may open a group, `where (interface={"x"})` and `or(comment={…})`,
1698
+ * which `group-faults.ts` asks about (#509).
1699
+ */
1700
+ export function comparesNamedValue(
1701
+ text: string,
1702
+ at: number,
1703
+ floor: number,
1704
+ ): boolean {
1705
+ let j = at - 1;
1706
+ while (j > floor && /\s/.test(text[j] as string)) j--;
1707
+ if (text[j] !== "=") return false;
1708
+ while (j > floor && text[j] === "=") j--;
1709
+ if (text[j] === "<" || text[j] === ">") return false;
1710
+ if (text[j] === "!") j--;
1711
+ while (j > floor && /\s/.test(text[j] as string)) j--;
1712
+ const nameEnd = j + 1;
1713
+ while (j > floor && /[A-Za-z0-9._-]/.test(text[j] as string)) j--;
1714
+ const name = text.slice(j + 1, nameEnd);
1715
+ return /^[A-Za-z.]/.test(name) && /[\s(]/.test(text[j] as string);
1716
+ }
1717
+
1664
1718
  /**
1665
1719
  * Locate top-level `{...}` values that RouterOS rejects in command arguments.
1666
1720
  *
1667
1721
  * Parentheses and substitutions restore expression context, where a brace
1668
- * literal is legal. Stored-script attributes are also excluded: their brace is
1669
- * a script body, not an array. The caller applies this only to non-root
1670
- * commands; root scripting directives use the `(verb, slot)` table instead.
1722
+ * literal is legal, and so does a `where` clause, apart from the comparisons
1723
+ * {@link comparesNamedValue} names. Stored-script attributes are also
1724
+ * excluded: their brace is a script body, not an array. The caller applies
1725
+ * this only to non-root commands; root scripting directives use the
1726
+ * `(verb, slot)` table instead.
1671
1727
  */
1672
1728
  export function invalidCommandBraceOffsets(
1673
1729
  text: string,
@@ -1681,6 +1737,7 @@ export function invalidCommandBraceOffsets(
1681
1737
  let i = Math.max(0, from);
1682
1738
  let tokenStart: number | null = null;
1683
1739
  let positionals = 0;
1740
+ let whereAt: number | null = null;
1684
1741
  while (i < structural.length) {
1685
1742
  const c = structural[i] as string;
1686
1743
  if (
@@ -1689,6 +1746,7 @@ export function invalidCommandBraceOffsets(
1689
1746
  ) {
1690
1747
  if (tokenStart !== null) {
1691
1748
  const token = structural.slice(tokenStart, i);
1749
+ if (whereAt === null && token === "where") whereAt = i;
1692
1750
  if (
1693
1751
  !token.startsWith("?") &&
1694
1752
  unquotedEquals(token, 0, token.length) === null
@@ -1737,7 +1795,10 @@ export function invalidCommandBraceOffsets(
1737
1795
  : braceStartsStatements(range.masked, range.start + i, range.start)) ||
1738
1796
  (directiveVerb !== undefined &&
1739
1797
  slot !== undefined &&
1740
- braceSlotAcceptsBrace(directiveVerb, slot))
1798
+ braceSlotAcceptsBrace(directiveVerb, slot)) ||
1799
+ (directiveVerb === undefined &&
1800
+ whereAt !== null &&
1801
+ !comparesNamedValue(structural, i, whereAt))
1741
1802
  ) {
1742
1803
  i = end ?? structural.length;
1743
1804
  continue;
@@ -13,10 +13,9 @@
13
13
  * name (and, for the one head-dependent case, the leading directive) off the
14
14
  * statement itself.
15
15
  *
16
- * This module ships only the (single-pass, non-recursive) classification
17
- * primitives the path resolver needs. The recursive block *tree* / topology
18
- * surface stays in the lab until it can be promoted with a bounded, index-based
19
- * traversal of its own.
16
+ * This module ships the single-pass, non-recursive classification primitives
17
+ * the path resolver needs. `explain.ts` publishes source ownership from that
18
+ * resolver's existing bounded traversal; `scopeBlocks` remains a shallow scan.
20
19
  *
21
20
  * Every string skip here goes through `segment.ts`'s `scanQuotedString` rather
22
21
  * than a local quote-to-quote loop, so this module and the segmenter cannot
@@ -134,6 +134,11 @@ export function braceSlotTakesArray(verb: string, slot: string): boolean {
134
134
  return BRACE_ARRAY_SLOTS.get(verb)?.has(slot) === true;
135
135
  }
136
136
 
137
+ /** Whether a `{…}` in this root-directive slot is code or script text. */
138
+ export function braceSlotTakesProgram(verb: string, slot: string): boolean {
139
+ return BRACE_PROGRAM_SLOTS.get(verb)?.has(slot) === true;
140
+ }
141
+
137
142
  /** Whether the device sweep proved this root-directive slot accepts `{...}`. */
138
143
  export function braceSlotAcceptsBrace(verb: string, slot: string): boolean {
139
144
  return (
@@ -1317,6 +1317,11 @@ export function lookupPath(
1317
1317
  return PATH_CATALOG.get(`/${segments.join("/").toLowerCase()}`);
1318
1318
  }
1319
1319
 
1320
+ /** Segments in the deepest catalog path; no lookup past it can match. */
1321
+ const MAX_PATH_DEPTH = Math.max(
1322
+ ...Array.from(PATH_CATALOG.keys(), (path) => path.split("/").length - 1),
1323
+ );
1324
+
1320
1325
  /**
1321
1326
  * Where a published COMMAND ends inside a path run: the index of the run
1322
1327
  * segment that is the VERB, or `null` when no prefix of `segments` is one.
@@ -1340,12 +1345,14 @@ export function lookupPath(
1340
1345
  * question; what the segment IS is not.
1341
1346
  */
1342
1347
  export function commandVerbIndex(segments: readonly string[]): number | null {
1343
- for (let depth = 1; depth <= segments.length; depth++)
1344
- if (
1345
- PATH_CATALOG.get(`/${segments.slice(0, depth).join("/").toLowerCase()}`)
1346
- ?.kind === "command"
1347
- )
1348
- return depth - 1;
1348
+ // Bounded by the deepest row and built incrementally: a run of thousands of
1349
+ // bare words would otherwise re-join every prefix (#496).
1350
+ const limit = Math.min(segments.length, MAX_PATH_DEPTH);
1351
+ let path = "";
1352
+ for (let depth = 0; depth < limit; depth++) {
1353
+ path += `/${(segments[depth] as string).toLowerCase()}`;
1354
+ if (PATH_CATALOG.get(path)?.kind === "command") return depth;
1355
+ }
1349
1356
  return null;
1350
1357
  }
1351
1358
 
@@ -99,6 +99,7 @@ export type DefectCode =
99
99
  | "over-depth"
100
100
  | "bad-escape"
101
101
  | "bad-string-escape"
102
+ | "bad-string-variable"
102
103
  | "bad-sigil"
103
104
  | "invalid-hash"
104
105
  | "invalid-apostrophe"