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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. package/src/transfer.ts +38 -13
@@ -1,15 +1,12 @@
1
1
  # explain — examples
2
2
 
3
- Each numbered example is an executable spec. Once `explain` is implemented,
4
- offline examples run under `test/unit/explain.test.ts` and live examples under
5
- `test/integration/explain.test.ts` (rest-api) and
6
- `test/integration/native-api-explain.test.ts` (native-api) against a CHR booted by
7
- `@tikoci/quickchr` — one named test per example (example N ↔ named test N); a
8
- named test may carry several assertions. The offline examples are **green**
9
- as of #202c-2, including 1, 2, 6 and 23's `transport`/`--curl` assertions.
10
- The live examples are still the **target**:
11
- those cells are `designed`, and flag and field names track the ratified surface
12
- in `README.md`.
3
+ Each numbered example is an executable spec. Offline examples run under the
4
+ named unit/fixture suites. Live foundation examples 11, 12, 16, 24 and 48 run
5
+ under `test/integration/explain-live.test.ts`, and describe/completion examples
6
+ 7–10, 13–15 and 19 under `test/integration/explain-live-facets.test.ts`, on REST
7
+ and native API with a CHR booted by `@tikoci/quickchr`. Live types 52–53 run
8
+ under `test/integration/explain-live-types.test.ts`. All of them pass on stable
9
+ 7.24.5 and long-term 7.23.8, so both protocol cells are `CHR-passed` (#236).
13
10
 
14
11
  A letter-suffixed example (`1b`, `4b`, `18b`) is the **counterpart** of the number it
15
12
  follows: the same question with the other answer, added where implementing the
@@ -151,10 +148,10 @@ renders no curl.
151
148
  centrs explain $R /ip/address -u $U -p $P --schema --json
152
149
  ```
153
150
 
154
- `data.schema.verbs` lists the verbs available at `/ip/address` (`add`, `set`,
155
- `remove`, `print`, …), each with an arg count or a drill-down hint rather than
156
- the full arg expansion; facts cite `live-inspect` evidence stamped with the
157
- CHR's RouterOS version.
151
+ `data.schema.node` is `menu`, and `data.schema.verbs` lists the verbs at
152
+ `/ip/address` (`add`, `set`, `remove`, `print`, …). Each carries its argument
153
+ count from one more `child` probe rather than the full argument expansion.
154
+ Facts cite `live-inspect` evidence stamped with the CHR's RouterOS version.
158
155
 
159
156
  ### 8. Describe a verb: its arguments
160
157
 
@@ -162,8 +159,9 @@ CHR's RouterOS version.
162
159
  centrs explain $R /ip/address/set -u $U -p $P --schema --json
163
160
  ```
164
161
 
165
- `data.schema` enumerates the settable arguments of `set` (`address`,
166
- `interface`, `disabled`, …) with types where the device reports them.
162
+ `data.schema.arguments` enumerates the settable arguments of `set` (`address`,
163
+ `interface`, `disabled`, …). Each has its argument-level `syntax` rows where the
164
+ device reports them (`address`: `A.B.C.D (IP address)`).
167
165
 
168
166
  ### 9. Describe print: the proplist special case
169
167
 
@@ -171,10 +169,12 @@ centrs explain $R /ip/address/set -u $U -p $P --schema --json
171
169
  centrs explain $R /ip/address/print -u $U -p $P --schema --json
172
170
  ```
173
171
 
174
- `data.schema` returns the `.proplist` value set — what the output *can*
175
- contain — rather than treating `print` like a plain verb. The grounded probe is
172
+ `data.schema.proplist` returns the `.proplist` value set — what the output
173
+ *can* contain — besides `print`'s own arguments. The grounded probe is
176
174
  `completion input="/ip/address/print proplist="` (dot-free console spelling),
177
- filtered to rows with `show=true`; each returned fact cites that live evidence.
175
+ filtered to rows with `show=true`; it cites that live evidence. A singleton
176
+ settings menu (`/ip/dns/print`) offers no proplist names, so
177
+ `get value-name=` answers with `source: "get-value-name"`.
178
178
 
179
179
  ### 10. Smart sizing truncates with counts, `--full` lifts it
180
180
 
@@ -182,9 +182,11 @@ filtered to rows with `show=true`; each returned fact cites that live evidence.
182
182
  centrs explain $R /ip -u $U -p $P --schema --json
183
183
  ```
184
184
 
185
- The `/ip` subtree is large: `data.schema.truncated` is `true`, counts are
186
- reported in place of the full expansion, and a warning/hint names the
187
- narrower query (or `--full`) that expands it.
185
+ The `/ip` subtree is large (31 children, past the 16-probe default):
186
+ `data.schema.truncated` is `true`, the names are listed with `counts` but not
187
+ expanded, and `tip/explain-schema-truncated` names the narrower inputs in
188
+ `data.schema.next` or `--full`. With `--full`, every submenu carries its verb
189
+ and menu counts.
188
190
 
189
191
  ### 11. Spans and the error byte (centrs vocabulary)
190
192
 
@@ -192,9 +194,21 @@ narrower query (or `--full`) that expands it.
192
194
  centrs explain $R '/ip/address add address=bogus interface=' -u $U -p $P --json
193
195
  ```
194
196
 
195
- `data.spans` classify the input byte-by-byte using the **centrs** span
196
- vocabulary (not raw RouterOS highlight classes); the first hard error's byte
197
- offset appears in a diagnostic citing `live-inspect` evidence.
197
+ `data.spans` includes device-derived spans in the **centrs** vocabulary,
198
+ citing `live-inspect` evidence. On CHR 7.24.5, this original example produces
199
+ no parse rejection or highlight error: `address=bogus` is not value-validated,
200
+ and the trailing empty argument is omitted from the parse output. Keep it as
201
+ a probe-limit control, not a promised syntax error.
202
+
203
+ A confirmed hard-error control is:
204
+
205
+ ```bash
206
+ centrs explain $R '/ip/route add dst-address=10.9.0.0/16 blackhole=yes' -u $U -p $P --json
207
+ ```
208
+
209
+ On CHR 7.24.5, highlight reports byte 47 and parse reports line 1 column 48.
210
+ The test asserts that source-located diagnostic separately from runtime
211
+ acceptance; the original input remains a control on both transports.
198
212
 
199
213
  ### 12. Structure via :parse
200
214
 
@@ -213,8 +227,14 @@ here over rest-api; example 24 asserts the same readout over native API using
213
227
  centrs explain $R '/ip/address add comm' -u $U -p $P --complete --json
214
228
  ```
215
229
 
216
- `data.completion` includes `comment` as a completing candidate for the
217
- partial word `comm` — a valid partial, no unknown-word diagnostic.
230
+ `data.completion` is `context: "argument-name"` with `comment` as a
231
+ `partial` candidate replacing bytes 16–20. No canonicalizer diagnostic calls
232
+ `comm` unknown. The device still rejects the input as written: `:parse` and
233
+ the highlight `error` both name byte 16. So pass `--fail-on never` when only
234
+ the candidates matter. `--cursor 20` gives the same answer inside a longer
235
+ input. The same candidate comes back for a relative path after `;`
236
+ (`/ip { :put 1; address add comm`), and `where comm` completes `comment` as a
237
+ `variable`.
218
238
 
219
239
  ### 14. Completion: complete argument name, no value yet
220
240
 
@@ -222,9 +242,10 @@ partial word `comm` — a valid partial, no unknown-word diagnostic.
222
242
  centrs explain $R '/ip/address add comment' -u $U -p $P --complete --json
223
243
  ```
224
244
 
225
- The candidate set marks `comment` as a complete argument name expecting a
226
- value (`=` continuation) — distinct from example 13's partial match and from
227
- an unknown word.
245
+ The candidate set marks `comment` as `match: "exact"` with `takesValue: true`,
246
+ because the device offers `=` at the cursor. That is distinct from example 13's
247
+ partial match and from an unknown word. The flag `/interface print detail` is
248
+ exact with `takesValue: false`.
228
249
 
229
250
  ### 15. Completion: value position
230
251
 
@@ -233,9 +254,11 @@ centrs explain $R '/ip/firewall/filter add chain=' -u $U -p $P --complete --json
233
254
  ```
234
255
 
235
256
  With the input ending in `=`, candidates are **values** for `chain`
236
- (`forward`, `input`, `output` — labeled as observed, never a closed set). For
237
- a free-form argument like `comment=`, the same probe yields type info (string)
238
- rather than candidates.
257
+ (`forward`, `input`, `output` — observed from the completion probe, never a closed set). For
258
+ a free-form argument like `comment=`, the same probe yields no candidates and
259
+ `data.completion.syntax` carries `string value`. An unclosed substitution
260
+ (`:local x [/ip/address add interface=`) still completes, from the device's
261
+ own path runs.
239
262
 
240
263
  ### 16. Runtime acceptance is never claimed
241
264
 
@@ -343,9 +366,9 @@ curated read verb; before the command axis this document abstained.
343
366
  centrs explain $R '/ip/route' -u $U -p $P --json
344
367
  ```
345
368
 
346
- `/console/inspect` completion and highlight agree that the terminal token is a
347
- `dir`; the first statement is `resolution: "resolved"`, `kind: "menu"`, and
348
- has no runnable transport rendering. The evidence entry is version-stamped and
369
+ The highlight classes every byte `dir`, and with `--schema` the `child` self
370
+ row says `menu`; the first statement is `resolution: "resolved"`,
371
+ `kind: "menu"`, and has no runnable transport rendering. The evidence entry is version-stamped and
349
372
  the probe never executes the path. Since #207 the offline reading (example 18b)
350
373
  already says the same thing from the generated table, so what the live probe
351
374
  adds here is **provenance and a version stamp**, not the answer — and it is the
@@ -452,7 +475,8 @@ full MAC spelling carry `["array"]`, `["time"]`, `["id"]`, and `["mac"]`.
452
475
  is `str` while the CLI Reference declares `mac-address` as `macAddr`. Each hint
453
476
  fact cites canonicalizer evidence with
454
477
  `basis: "heuristic"`; no record has `observedType` or `schemaType`, because
455
- offline analysis has neither live parser output nor an argument schema. A hint
478
+ offline analysis has neither live parser output nor an argument schema
479
+ (examples 52 and 53 are the live counterparts). A hint
456
480
  is not a diagnostic and does not change `data.verdict` or
457
481
  `runtimeAcceptance: "not-proven"`.
458
482
 
@@ -483,7 +507,8 @@ REST body; the comment text is never rendered as an operand.
483
507
 
484
508
  The CHR assertion grounds the wider placement rule on the requested RouterOS
485
509
  channel via `/console/inspect request=highlight` plus `:parse`: unquoted `#`
486
- inside brace arrays and parenthesized expressions is a hard error; the first
510
+ inside brace arrays and parenthesized expressions is a hard error, except at
511
+ column 0 inside an array (example 37); the first
487
512
  non-space `#` in `do`, `else`, `foreach do`, and `on-event` bodies that are
488
513
  themselves in a statement context is a comment — the same `do={ # c` nested
489
514
  inside an array is an array and `#` is an error (placement table, #249), while
@@ -700,3 +725,387 @@ envelope would otherwise have to derive the name/value boundary from
700
725
  The same command also shows the two `=` bytes staying apart: the one in
701
726
  `($x = 1)` is a comparison and comes back `operator`, decided by fill order
702
727
  rather than by a scan for the byte.
728
+
729
+ ### 33. A space for the `=` is not an empty argument list
730
+
731
+ ```bash
732
+ centrs explain '/ip/firewall/layer7-protocol add comment "something" regexp 1.1' --json
733
+ ```
734
+
735
+ RouterOS accepts this as `comment="something" regexp=1.1`: `:parse` lowers both
736
+ spellings to the same IL, and `highlight` marks each gap `syntax-obsolete`.
737
+ Which word is an argument name is per-command schema, so offline publishes
738
+ neither reading. The statement stays `resolved` on
739
+ `/ip/firewall/layer7-protocol add`, `command.args` is absent, `arguments.read`
740
+ is `false` with a `why` naming `` `comment "something"` ``, and
741
+ `transport.classification` stays `"unknown"`.
742
+
743
+ `data.verdict` is `warn`, with one
744
+ `explain/canonicalizer/possible-spaced-argument` **warning** per pair,
745
+ spanning `comment "something"` and `regexp 1.1`. Each says to write the `=`.
746
+ The warning needs a value that cannot be a flag: `/interface set ether1 name x`
747
+ also abstains but does not warn, because `/interface print detail
748
+ without-paging` is the same shape and is two flags
749
+ (`detail=true;without-paging=true`). The `=` spelling still reads both
750
+ arguments, and a script body may drop its `=` too:
751
+ `/system/script add name=beep source { :beep }` is the `source={ :beep }` body,
752
+ not an invalid brace.
753
+
754
+ ### 34. An unlisted bare path does not become a menu
755
+
756
+ ```bash
757
+ centrs explain $'/terminal/cuu\nprint\nadd name=x' --json
758
+ ```
759
+
760
+ `/terminal/cuu` is a no-argument command that neither the menu table nor the
761
+ command catalog lists. `/ip fire conn` has the same shape and is navigation:
762
+ RouterOS accepts abbreviated menus. CHR 7.24.5 `:parse` confirms both, so a
763
+ table miss cannot decide between a menu and a command.
764
+
765
+ The first statement stays `ambiguous`. The two after it are `unknown`
766
+ (`context lost to an earlier unreadable statement`, `contextCertain: false`),
767
+ with no `command` and no `transport`, where they used to resolve to an
768
+ invented `/terminal/cuu/print` and `/terminal/cuu/add`. On the device they are
769
+ `bad command name print` and `bad command name add` at the root.
770
+ `structure.containsWrite` is `unknown` and `data.verdict` is `warn`. A known
771
+ absolute navigation recovers the context: in
772
+ `/terminal/cuu`, `/ip address`, `print`, the last statement resolves to `print`
773
+ at `/ip/address` again.
774
+
775
+ ### 35. A brace after a command is its argument, not a menu block
776
+
777
+ ```bash
778
+ centrs explain '/system/script add name beep source { :beep }' --json
779
+ ```
780
+
781
+ `/menu { … }` opens a menu block only when the words before the brace name a
782
+ menu. Here `add` is a command, so the brace is the value of `source` with the
783
+ `=` left out. CHR 7.22.1 and 7.24.5 `:parse` give the same IL as
784
+ `source={ :beep }`.
785
+
786
+ `data.structure.statements` has one statement, `/system/script add`, whose
787
+ span covers the whole input. `:beep` is no longer published as a second
788
+ top-level statement, which RouterOS never runs. `data.verdict` is `warn`, from
789
+ the `possible-spaced-argument` diagnostic on `source { :beep }`.
790
+
791
+ RouterOS accepts any unique prefix of an argument name. An abbreviation that
792
+ can only stand for a script-valued name keeps its body in either spelling:
793
+ `sour { :beep }`, `down-scr { :beep }` and `o={ :beep }` lower to `source=`,
794
+ `down-script=` and `on-event=` on CHR 7.24.5 and 7.23.8. Abbreviating the first
795
+ segment (`down={ :beep }` for `down-script`) is accepted on the device too, and
796
+ offline warns `possible-script-body-abbreviation` there instead of rejecting
797
+ (#525): every word is a prefix of some `<word>-script`, so telling it from
798
+ `comment={ :beep }` needs the command's argument list, and the device decides.
799
+ The tradeoff is deliberate — `comment={ :beep }`, which the device rejects,
800
+ carries the same warning — and `data.verdict` stays `warn`, never `pass`.
801
+
802
+ A brace straight after a command is a device error, and offline now reports it.
803
+ `/system/script add { :beep }`, `/ip/route/print {1;2}` and
804
+ `/system/reboot { :beep }` each get `invalid-command-brace` and `data.verdict`
805
+ `fail`. A run that names no command still opens a block, so
806
+ `/system script { print }` is navigation to `/system/script` followed by
807
+ `print`.
808
+
809
+ ### 36. A brace in a `where` clause is an array value
810
+
811
+ ```bash
812
+ centrs explain '/ip address print where interface in {"ether1";"ether2"}' --json
813
+ ```
814
+
815
+ A `where` clause is expression context, so the brace is the array operand of
816
+ `in`. CHR 7.24.5 and 7.23.8 `:parse` lower it to
817
+ `(in $interface ether1;ether2)`. `data.verdict` is `pass`, so `centrs execute`
818
+ runs this read. Before #502 its offline stage refused it with
819
+ `invalid-command-brace`. `data.values.occurrences` holds the array and its two
820
+ string members, the same three records as
821
+ `where interface in ("ether1","ether2")`.
822
+
823
+ The brace is also a value after `~`, `<`, `>`, `<=` and `>=`, on the left of an
824
+ operator (`where {"ether1"} in interface`), and after `!interface=` or `"a"=`.
825
+
826
+ After `<name>=`, `<name>!=` or `<name>==`, spaced or not, the device reads the
827
+ value with that property's parser and rejects the brace. Two examples are
828
+ `where interface={"ether1"}` (`expected interface value`) and
829
+ `where disabled={"y"}` (`expected yes or no`). Offline keeps
830
+ `invalid-command-brace` at the `{` there. The device does this only for a
831
+ property the menu knows: `where foo-bar={"y"}` parses. Offline cannot tell
832
+ property names apart without a per-menu list, so it reports that one too.
833
+
834
+ ### 37. A column-0 `#` line inside an array is a comment
835
+
836
+ ```bash
837
+ centrs explain ':local z {a=1;
838
+ # c
839
+ b=2}' --json
840
+ ```
841
+
842
+ `data.verdict` is `pass`. `data.spans` holds a `comment` span over `# c`, and
843
+ `data.values.occurrences` holds the array and its two members `a` and `b`. CHR
844
+ 7.24.5 and 7.22.1 `:parse` lower it to `value=a=1;b=2`. Before #503, offline
845
+ failed it with `invalid-hash` at the `#`.
846
+
847
+ The rule holds at any depth (`{a={⏎# c⏎1}}`) and in argument values
848
+ (`:foreach i in={1;⏎# c⏎2} do={:put $i}`). It needs column 0: an indented `#`
849
+ (`{a=1;⏎ # c⏎b=2}`), and one after `{`, a value or `;` on the same line, keep
850
+ `invalid-hash` at the `#`. A `#` directly inside `( … )` stays an error too. A
851
+ comment-only array, `:local z {⏎# c⏎}`, fails like `:local z {}`: both are empty
852
+ arrays, which the device rejects (#511, example 38).
853
+
854
+ ### 38. An empty array value and a misplaced `;` are rejected
855
+
856
+ ```bash
857
+ centrs explain ':local z {}' --json
858
+ ```
859
+
860
+ `data.verdict` is `fail` with `explain/canonicalizer/empty-group` over `{}`. CHR
861
+ 7.24.5 and 7.23.8 `:parse` answer `syntax error (line 1 column 12)`, the byte
862
+ after the brace. The same holds for `:put {}`, `:return {}`, `[:len {}]` and
863
+ `:foreach i in={} do={…}`. A nested `{}` (`{1;{}}`), one in parentheses
864
+ (`({})`), `where … in {}` and a body (`do={}`, `:execute {}`) parse and pass.
865
+
866
+ `:local z {1⏎;2}` fails with `misplaced-separator` at the `;`: a `;` needs an
867
+ element before it on its line. `{1;}` and `{1⏎2}` pass.
868
+
869
+ ### 39. An expression with a missing operand is rejected
870
+
871
+ ```bash
872
+ centrs explain ':put (1 +)' --json
873
+ ```
874
+
875
+ `data.verdict` is `fail` with `explain/canonicalizer/missing-operand` at the
876
+ `+`. The device answers `syntax error (line 1 column 11)`, at the `)`. `()`
877
+ fails with `empty-group`, and `(1; 2)` and `(1⏎+ 2)` fail with
878
+ `misplaced-separator`. `(1 2)`, `(- 1)` and `(1 + -2)` pass. Inside a query,
879
+ `where (comment=+)` passes: the device reads that value with the property's
880
+ parser.
881
+
882
+ ### 40. A `@` in a command word is rejected
883
+
884
+ ```bash
885
+ centrs explain '[admin@MikroTik] > /ip address print' --json
886
+ ```
887
+
888
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-at-sign` at the
889
+ `@`. The device answers `syntax error (line 1 column 7)`, the same byte. This
890
+ is a pasted console prompt. `:put a@b` and `comment=a@b` pass, because a `@` is
891
+ a value byte in an argument.
892
+
893
+ ### 41. A named comparison brace inside a `[find where …]` is rejected
894
+
895
+ ```bash
896
+ centrs explain ':put [/ip address find where interface={"x"}]' --json
897
+ ```
898
+
899
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-command-brace` at
900
+ the `{`, as at the top level (example 36). The device answers
901
+ `expected interface value (line 1 column 40)`. `where (interface={"x"})` fails
902
+ the same way, and `[find where interface in {"x"}]` passes.
903
+
904
+ ### 42. A stray top-level `}` ends the script, and the rest is discarded
905
+
906
+ ```bash
907
+ centrs explain ':put 1};:put 2' --json
908
+ ```
909
+
910
+ `data.verdict` is `warn` with `explain/canonicalizer/discarded-close` over the
911
+ `}` and everything after it. CHR 7.24.5 and 7.23.8 accept the script and lower
912
+ it to `(evl /putmessage=1)`: the device stops reading at a `}` or `]` that closes
913
+ nothing at the top level, so `:put 2` never runs and
914
+ `/system identity set name=a}` sets `a`. `data.structure` reads only the
915
+ prefix. A stray close inside an open group (`:put (1})`, `{:put 1]}`) and any
916
+ stray `)` are still rejections and still fail with `unbalanced-close`. The
917
+ execute gate refuses this warning (`commands/execute/examples.md` V9).
918
+
919
+ ### 43. A variable name is letters and digits
920
+
921
+ ```bash
922
+ centrs explain ':local a_b 1' --json
923
+ ```
924
+
925
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-name` at the `_`.
926
+ The device answers `expected end of command (line 1 column 9)`, the same byte.
927
+ `:global`, `:set`, `:for` and `:foreach` read the name the same way, and
928
+ `:local $a` and `:local (a)` fail at their first byte. `:local "a_b"`,
929
+ `:local 1a`, `:foreach a,b in={1} do={}` and `:local name=x value=1` pass.
930
+
931
+ ### 44. A `$` must start a variable
932
+
933
+ ```bash
934
+ centrs explain ':put "a$ b"' --json
935
+ ```
936
+
937
+ `data.verdict` is `fail` with `explain/canonicalizer/bad-string-variable` over
938
+ the `$` and the space after it. The device answers `syntax error (line 1 column 9)`, at the space. Outside
939
+ a string, `:put $_` fails with `invalid-variable` the same way. `"$v_x"` (which
940
+ is `$v` then `_x`), `"\$ x"`, `"$(1)"` and `$"a b"` pass. `"a$"` also passes,
941
+ although the device rejects it: it reads `$"` as the start of a quoted name, and
942
+ offline's string scanners end the string there, so offline abstains.
943
+
944
+ ### 45. Nothing may be glued to a closed token
945
+
946
+ ```bash
947
+ centrs explain ':if (true) do={:put 1}else={:put 2}' --json
948
+ ```
949
+
950
+ `data.verdict` is `fail` with `explain/canonicalizer/glued-token` at the `e`.
951
+ The device answers `expected end of command (line 1 column 23)`. The same rule
952
+ rejects `:put "a"b`, `$v.b`, `(1)(2)`, `[:len a]b`, `src-address=$v/24` and a
953
+ bare word run into a `"`, `$` or bracket (`:put a"b"`, `name=a$v`). Inside
954
+ `where` and `find`, and inside a group, the rule does not apply (`where
955
+ comment="a"b` and `("a"b)` parse). A command argument whose property parser
956
+ takes the glued part also passes: `dst-address-list=!"my list"` and
957
+ `dst-port=80,"443"`.
958
+
959
+ ### 46. Statement ids and source ownership
960
+
961
+ ```bash
962
+ centrs explain ':if (true) do={ :foreach i in={1;2} do={ :put $i } } else={ :put 0 }; :put 3' --json
963
+ ```
964
+
965
+ The statements keep discovery order and have ids `s0` through `s4`.
966
+ The `foreach` (`s1`) has `parent: "s0"`; its `put` (`s2`) has `parent: "s1"`;
967
+ the `else` body's `put` (`s3`) has `parent: "s0"`. The final `put` (`s4`) has
968
+ no parent. The published `do` and `else` blocks both have `owner: "s0"`;
969
+ `s1` and `s2` share `block: 0`, and `s3` has `block: 1`. A `[find]` inside a
970
+ body names that body's statement as its `owner`. Links describe source
971
+ ownership; binding and execution-context claims remain separate (#507).
972
+
973
+ ### 47. A stored script stays opaque
974
+
975
+ ```bash
976
+ centrs explain '/system/script/add name=proof source={ :local x 1; :put $x }; :put 2' --json
977
+ ```
978
+
979
+ Only the installing command (`s0`) and final `put` (`s1`) appear in
980
+ `statements[]`; neither has a parent, and `blocks[]` stays empty. The deferred
981
+ body's local and `put` are not exposed as statements. Scheduler `on-event=`
982
+ and Netwatch `down-script=` have the same structural boundary.
983
+
984
+ The CHR controls in `test/integration/explain-structure.test.ts` parse the
985
+ scope examples and compare stored-script creation, explicit `run`, and an
986
+ immediate `:do` body. Those are test operations on disposable CHR; explain
987
+ itself never executes the analyzed input.
988
+
989
+ ### 48. Safe argument discovery distinguishes spaced arguments from flags
990
+
991
+ ```bash
992
+ centrs explain $R '/interface set ether1 name x' -u $U -p $P --json
993
+ centrs explain $R '/interface print detail without-paging' -u $U -p $P --json
994
+ ```
995
+
996
+ `data.live.arguments[]` cites command-level child evidence and statement `s0`.
997
+ The first result includes `name`, the second includes `detail`. Both parse;
998
+ only `name x` gets the device's obsolete-syntax warning. An unknown argument
999
+ control (`/interface set ether1 definitely-unknown-491=x`) carries the shared
1000
+ parse classifier's rejection. No command-level completion is requested.
1001
+
1002
+ The integration controls also explain a write-shaped identity change and a
1003
+ substitution without executing either, verify identity before/after, exercise
1004
+ CLI stdin and quickchr resolution, and skip oversized highlight without
1005
+ submitting a prefix. Parse/hash/normalization evidence preserves the Unicode
1006
+ byte coordinates. Run on long-term as well as stable before changing these
1007
+ claims. Sanitized captures can be regenerated with `CENTRS_EXPLAIN_CAPTURE`
1008
+ pointing at an ignored output file when running the integration suite.
1009
+
1010
+ ### 49. `?`, a backtick and a second `=` need quotes
1011
+
1012
+ ```bash
1013
+ centrs explain '/tool/fetch url=http://x/a?b=c' --json
1014
+ ```
1015
+
1016
+ `data.verdict` is `fail` with `explain/canonicalizer/unquoted-reserved-byte`
1017
+ at the `?`. The device answers `expected end of command (line 1 column 27)`,
1018
+ the same byte. A backtick is rejected the same way, in a value, a
1019
+ positional, a `where`, a `find`, an array and an expression. A second `=` in
1020
+ a menu command's argument is rejected at that `=`. So `name=a=b` and a bare
1021
+ base64 key ending in `=` both fail. The value's property parser never takes
1022
+ these bytes: `regexp=a?b` and `secret=abc==` are rejected too. In a menu
1023
+ command's argument name, the device reports from the name's first byte
1024
+ (`/ip/address/add a?b=1` at the `a`). `url="http://x/a?b=c"`,
1025
+ `where comment=a=b`, `[find comment=a=b]`, `{a=b}` and `# a?b` pass.
1026
+ `:put a=b` abstains: 7.24.5 reads it as a named argument and 7.23.8 rejects
1027
+ the `=`.
1028
+
1029
+ ### 50. A non-ASCII character outside a string is rejected
1030
+
1031
+ ```bash
1032
+ centrs explain ':put $café' --json
1033
+ ```
1034
+
1035
+ `data.verdict` is `fail` with `explain/canonicalizer/unquoted-non-ascii` over
1036
+ the `é`. The device answers `expected end of command (line 1 column 10)`, at
1037
+ the `é`'s first byte. RouterOS reads only ASCII outside a string. A bare
1038
+ value (`name=router-café`, `name=🛰`), a reference, a path (`/systemé`), a
1039
+ verb, an argument name, a `where` value, an array and an expression are all
1040
+ rejected. So are pasted non-breaking spaces, curly quotes and ellipses. A
1041
+ declared name (`:local café`) keeps its `invalid-name` error. Strings and
1042
+ comments carry any byte: `:put "café"`, `:local "café" 1`, `$"café"`,
1043
+ `name="router-🚀"`, `# café` and a multi-line string pass. The informational
1044
+ `non-ascii` fact is still published beside the error, and a leading BOM is
1045
+ not read as part of the first word.
1046
+
1047
+ ### 51. A brace after a submenu opens a block at any depth
1048
+
1049
+ ```bash
1050
+ centrs explain '/ip { address { print {1;2} } }' --json
1051
+ ```
1052
+
1053
+ `data.verdict` is `fail` with `explain/canonicalizer/invalid-command-brace` at
1054
+ the inner `{`. The device answers `expected end of command (line 1 column 23)`,
1055
+ the same byte. `address {` inside `/ip { … }` is a block over `/ip/address`,
1056
+ so `data.structure.statements` reads `/ip`, `/ip/address` and
1057
+ `/ip/address print`, and the `{1;2}` is the operand of `print`.
1058
+
1059
+ The device reads a brace after a menu word as statements whatever the body
1060
+ holds, at any depth. A body offline cannot resolve abstains with
1061
+ `unresolved-statement` instead of failing as an array:
1062
+ `/ip { address { 1;2 } }` (the device says `bad command name 1`) and
1063
+ `/ip { address { zzznotamenu { print } } }` (`syntax error`) both give `warn`,
1064
+ as `/ip/address { 1;2 }` and `/ip { zzznotamenu { print } }` do.
1065
+ `/ip { address { print } }` and `/ip { firewall { filter { print } } }` pass.
1066
+ The menu must be known from the source alone, so a block after a navigation
1067
+ statement (`/ip⏎address { … }`), an abbreviation (`addr {`) and a digit-led
1068
+ menu (`6to4 {`) keep the older reading.
1069
+
1070
+ ### 52. A live call observes a type only where the device converts a literal
1071
+
1072
+ ```bash
1073
+ centrs explain $R ':local x 2.2; :set x "2.2"; :local z (1,0x10,2.2); :local t 200ms; :local i *1; :local y yes' -u $U -p $P --json
1074
+ ```
1075
+
1076
+ This is example 26's input against a target. The bare `2.2` gains
1077
+ `facts.observedType: { value: "ip", rendered: "2.0.0.2" }`, `200ms` gains
1078
+ `time` (`00:00:00.200`) and `yes` gains `bool` (`true`). Of the array's
1079
+ members, `0x10` is `num` (`16`) and `2.2` is `ip`, while `1` renders unchanged
1080
+ and gets nothing. The quoted `"2.2"` and the id `*1` also get nothing, because
1081
+ an unchanged rendering proves no type: `:parse` prints a string unquoted. The
1082
+ `shapeHints` are untouched, and no record has `schemaType`, because no value
1083
+ here is a menu command's argument.
1084
+
1085
+ A scripting directive's named argument is an operand too:
1086
+ `:for i from=0x10 to=20 do={:put $i}` gives `from` the fact `num` (`16`), and
1087
+ `to=20` renders unchanged and gets nothing. A NAME slot is a variable, never a
1088
+ value: in `:local yes 2` and `:local name=no value=yes`, the names `yes` and
1089
+ `no` get nothing (the device renders `name=$yes`, `name=$no`), while
1090
+ `value=yes` is `bool`.
1091
+
1092
+ Each fact cites one `:parse` evidence entry per context (operands, members)
1093
+ with `facet: "types"` and `basis: "derived"`. The probe re-parses each literal
1094
+ alone, as `:local vN <lit>` or `:local vN {<lit>}`; the analyzed input and
1095
+ the literals are never evaluated, and no `:typeof` is sent.
1096
+
1097
+ ### 53. A live call normalizes each argument's declared type
1098
+
1099
+ ```bash
1100
+ centrs explain $R '/ip/dns/set servers=1.1.1.1,8.8.8.8; /ip/route/add gateway=2.2 dist=2 comment=abc; /ip/firewall/filter/add chain=input dst-port=80,443 protocol=tcp disabled=yes' -u $U -p $P --json
1101
+ ```
1102
+
1103
+ Each `name=value` gains `facts.schemaType` from one argument-level `syntax`
1104
+ probe. `servers` is `{ values: ["ip", "ip6", "enum"], declared:
1105
+ "Address[@VRF]" }`, and `gateway` is `ip`, `ip6`, `enum`. `dist` resolves as
1106
+ the unique prefix of `distance` and is `num`. `comment` is `str`. `chain` is
1107
+ `enum` plus `str`, since custom chains are legal. `dst-port` is `num` with
1108
+ `list: true`. `disabled` gets nothing, because the device declares a boolean
1109
+ with a blank row. No menu argument gets `observedType`: its own parser
1110
+ converts it, which is the schema's slot. The text output prints
1111
+ `observed=ip(2.0.0.2)` and `schema=ip|ip6|enum` on the value rows.
@@ -87,8 +87,11 @@ alternatives in the error envelope.
87
87
  `failed` when the gate refuses: `validation/*`, or `--query` on a singleton.
88
88
  It is absent when a `transport/*`, `auth/*`, `routeros/*` or `internal/*` error
89
89
  stopped inspect from answering; the device stage then says `not reached`
90
- (examples 12 and 16b; 16c is the `passed` case). With `--validate=false` it
91
- is `{enabled: false, source: "disabled"}`.
90
+ (examples 12 and 16b; 16c is the `passed` case). The same holds for a
91
+ `wait/*` error from a `--wait` whose gate never settled, and for
92
+ `--sample` / `--follow` cancelled before the gate ran. That last case has no
93
+ error code, so its reason is `not reached: interrupted` (#537). With
94
+ `--validate=false` it is `{enabled: false, source: "disabled"}`.
92
95
 
93
96
  If `--all-attributes` is combined with `--attribute(s)`, fail with
94
97
  `usage/conflicting-flags` *before* hitting the network.
@@ -147,6 +150,14 @@ handling.
147
150
  }
148
151
  ```
149
152
 
153
+ Projection rule: the bare value is about the attribute *count*, not the flag
154
+ spelling. On a singleton, exactly one projected attribute — `--attribute x`
155
+ or `--attributes x` alike — returns the bare value (`data` is the scalar,
156
+ not an object); two or more return an object with those keys. On a list, any
157
+ projection returns an array of objects, even a single attribute. Unprojected
158
+ reads return the full object (singleton) or the row array (list). (Examples
159
+ 5–6, 20, N5–N6 pin every cell of this table.)
160
+
150
161
  `--max-bytes` / `--max-rows` truncation populates
151
162
  `meta.truncated = { returned, total, totalBytes }` and keeps `ok: true` with a
152
163
  warning entry — it is not an error. (Earlier text said a byte overflow returns an
@@ -22,7 +22,8 @@ centrs retrieve $R /system/resource --username $U --password $P --json
22
22
  ```
23
23
 
24
24
  Envelope: `ok: true`, `data` is an object, `meta.via=rest-api`,
25
- `meta.validation.source=/console/inspect`.
25
+ `meta.validation.source=/console/inspect`, and `meta.validation.stages` is the
26
+ one `device: passed` stage (#535).
26
27
 
27
28
  ### 2. /system/identity
28
29
 
@@ -70,6 +71,10 @@ centrs retrieve $R /interface --attributes name,type --username $U --password $P
70
71
 
71
72
  Envelope: `data` is an array of objects each containing only `name` and `type`.
72
73
 
74
+ A single attribute (`--attributes name`) keeps the array shape: an array of
75
+ single-key objects. On a singleton, one item via either flag returns the bare
76
+ value (example 5) — the rule is the count, not the flag.
77
+
73
78
  ### 7. --all-attributes (RouterOS details=true)
74
79
 
75
80
  ```bash
@@ -121,6 +126,18 @@ available.
121
126
  centrs retrieve $R /not/a/real/path --username $U --password $P --json
122
127
  ```
123
128
 
129
+ `/system/routerboard` earns a targeted remediation instead of the generic
130
+ one: the menu exists only on MikroTik hardware, so its absence on CHR and
131
+ x86 systems is expected rather than a typo, and the remediation points at
132
+ `/system/license` for the device's license ID (`system-id` on CHR,
133
+ `software-id` on x86 — a projected attribute would fail on one platform or
134
+ the other, so the example reads the whole object).
135
+
136
+ ```bash
137
+ centrs retrieve $R /system/routerboard --username $U --password $P --json
138
+ centrs retrieve $R /system/license --username $U --password $P --json
139
+ ```
140
+
124
141
  ### 11. Unknown attribute
125
142
 
126
143
  `validation/unknown-attribute` with suggestions.
@@ -362,6 +379,10 @@ centrs retrieve $A /interface --attributes name,type --via native-api --port $AP
362
379
 
363
380
  `data` is an array of objects each containing only `name` and `type`.
364
381
 
382
+ A single attribute (`--attributes name`) keeps the array shape here too: an
383
+ array of single-key objects. On a singleton, one item via either flag returns
384
+ the bare value (N5) — the rule is the count, not the flag.
385
+
365
386
  ### N7. `--all-attributes` (native `print detail`)
366
387
 
367
388
  ```bash
@@ -380,6 +401,14 @@ centrs retrieve $A /system/resource --list-attributes --via native-api --port $A
380
401
  centrs retrieve $A /not/a/real/path --via native-api --port $API_PORT --username $U --password $P
381
402
  ```
382
403
 
404
+ `/system/routerboard` on CHR earns the targeted remediation (no routerboard
405
+ outside MikroTik hardware; read `/system/license` for the license ID
406
+ instead), same as example 10.
407
+
408
+ ```bash
409
+ centrs retrieve $A /system/routerboard --via native-api --port $API_PORT --username $U --password $P
410
+ ```
411
+
383
412
  ### N10. Unknown attribute → `validation/unknown-attribute`
384
413
 
385
414
  ```bash