@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.
- package/GLOSSARY.txt +24 -0
- package/commands/execute/examples.md +19 -0
- package/commands/explain/README.md +428 -128
- package/commands/explain/examples.md +260 -1
- package/docs/CONSTITUTION.md +4 -0
- package/docs/MATRIX.md +23 -11
- package/package.json +4 -1
- package/src/explain/args.ts +69 -8
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +927 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +20 -19
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +103 -6
- package/src/explain/segment.ts +41 -3
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +17 -1
- package/src/explain/transport.ts +13 -0
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain.ts +378 -18
- package/src/offline-gate.ts +30 -9
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
|
@@ -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
|
|
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.
|
package/docs/CONSTITUTION.md
CHANGED
|
@@ -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:
|
|
186
|
-
[#
|
|
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).
|
|
194
|
-
of members and a default controller). Deferred: CDB linkage
|
|
195
|
-
|
|
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
|
|
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 #
|
|
239
|
-
corpus is
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
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.
|
|
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",
|
package/src/explain/args.ts
CHANGED
|
@@ -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)
|
|
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,
|
|
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,
|
|
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
|
|
1669
|
-
*
|
|
1670
|
-
*
|
|
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;
|
package/src/explain/blocks.ts
CHANGED
|
@@ -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
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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 (
|
package/src/explain/catalog.ts
CHANGED
|
@@ -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
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
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
|
|