@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,702 @@
1
+ # explain — examples
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`.
13
+
14
+ A letter-suffixed example (`1b`, `4b`, `18b`) is the **counterpart** of the number it
15
+ follows: the same question with the other answer, added where implementing the
16
+ spec showed that one example was carrying two contradictory readings. It gets its
17
+ own assertion (`1b` ↔ assertion `1b`); the numbering never renumbers, because
18
+ these numbers are cited from issues and commit messages.
19
+
20
+ `$R` is `<host>:<rest-port>` resolved by quickchr; `$A` is `<host>` and
21
+ `$API_PORT` is the native API port (`chr.ports.api`); `$U` / `$P` are CHR
22
+ credentials from the harness. Live examples are **target-first**
23
+ (`explain <router> '<input>'`); RouterOS input is single-quoted so the shell
24
+ never expands `$vars` or splits on `>`. Envelope-asserting examples pass
25
+ `--json`; live examples ride **rest-api** unless they explicitly pin
26
+ `--via native-api`.
27
+
28
+ ## Offline (no router, no CDB — canonicalizer only)
29
+
30
+ ### 1. Canonical form, write shape, and transport
31
+
32
+ ```bash
33
+ centrs explain '/ip/route/add dst-address=10.9.0.0/16 gateway=192.0.2.1' --json
34
+ ```
35
+
36
+ `ok: true`; `data.canonical` is
37
+ `{ path: "/ip/route", verb: "add", mode: "structured", writeShaped: true }`
38
+ with the args split out; `data.structure.statements[0].resolution` is
39
+ `"resolved"` and its `transport.classification` is `"api-candidate"` (the
40
+ statement is covered by a tested REST mapping rule); `data.verdict` is `pass`;
41
+ exit `0`.
42
+
43
+ **The verb must be IN the path for `mode` to be `structured`.** This example
44
+ originally spelled the input `/ip/route add …`, which the execute gate reads as
45
+ `script` — `add` is a bare token with no `=`, and widening the gate to accept it
46
+ is a product regression (`docs/CONSTITUTION.md`). `data.canonical` reproduces
47
+ that gate verbatim, so the example uses the spelling that is actually
48
+ structured. The space spelling is example 1b.
49
+
50
+ ### 1b. The CLI spelling is script to the gate and a command to the analysis
51
+
52
+ ```bash
53
+ centrs explain '/ip/route add dst-address=10.9.0.0/16 gateway=192.0.2.1' --json
54
+ ```
55
+
56
+ `data.canonical.mode` is `"script"` and `writeShaped` is `false` — the gate's
57
+ answer to "may this be sent as a structured REST call". Beside it,
58
+ `data.structure.statements[0].command` is `{ path: "/ip/route", verb: "add" }`
59
+ and `data.structure.containsWrite` is `true`: the analysis answers "what did the
60
+ human write", and the two questions have different answers over the same bytes.
61
+ Neither is wrong and the gate is never widened by the analysis.
62
+
63
+ ### 2. Script mode is reported, not widened — and routes to execute
64
+
65
+ ```bash
66
+ centrs explain ':foreach i in=[/ip/address find] do={ :put $i }' --json
67
+ ```
68
+
69
+ `data.canonical.mode` is `"script"` and `writeShaped` reflects the gate's
70
+ conservative verdict — the same answer `execute` would act on.
71
+ Each resolved command under `data.structure.statements[]` classifies
72
+ `"execute"` with the `centrs execute` invocation rendered, so the right runner
73
+ surfaces even for script input.
74
+
75
+ ### 3. Sub-command paths are re-constituted; the gate verdict is untouched
76
+
77
+ ```bash
78
+ centrs explain '/ip/address remove [find comment=defconf]' --json
79
+ ```
80
+
81
+ `data.canonical` matches `canonicalizeExecuteCommand` exactly as its anchor
82
+ tests pin it: `mode: "script"`, `writeShaped: false` (a bracketed sub-command
83
+ selector is never write-shaped structured). The richer inference lives beside
84
+ it: `data.structure.subcommands` exposes the inner command with its resolved
85
+ enclosing path `{ path: "/ip/address", verb: "find" }` plus its span, and
86
+ `data.structure.containsWrite` is `true` (basis `heuristic` in the referenced
87
+ evidence entry) because of the outer `remove`.
88
+
89
+ ### 4. Same token, different role: `comment` as verb vs argument
90
+
91
+ ```bash
92
+ centrs explain '/ip/address/comment numbers=0 comment=uplink' --json
93
+ ```
94
+
95
+ `data.canonical.verb` is `"comment"` — at this position `comment` is the
96
+ RouterOS *verb* — while `data.canonical.args` carries the `comment` *argument*
97
+ with its value. The two roles of the same word are distinguished by position,
98
+ and both facts cite canonicalizer evidence (offline heuristics; live
99
+ completion is the authority — see examples 13–15).
100
+
101
+ **The verb must be IN the path here too**, for the same reason as example 1:
102
+ this example originally spelled the input `/ip/address comment …`, which the
103
+ gate reads as `script` with an empty `path`/`verb`/`args`, so neither role was
104
+ visible in `canonical` at all. The two-role contrast is a claim about the
105
+ gate's split, so the example uses the spelling the gate splits. The space
106
+ spelling is example 4b.
107
+
108
+ ### 4b. The space spelling keeps both roles in the analysis, and neither in the gate
109
+
110
+ ```bash
111
+ centrs explain '/ip/address comment numbers=0 comment=uplink' --json
112
+ ```
113
+
114
+ `data.canonical.mode` is `"script"` with `verb: ""` and `args: {}` — the gate
115
+ declines the whole input, so it distinguishes no roles at all.
116
+ `data.structure.statements[0].command` is
117
+ `{ path: "/ip/address", verb: "comment", args: { numbers: "0", comment: "uplink" } }`:
118
+ the analysis reads the verb role from either spelling, and since #202c it reads
119
+ the *argument* role here too. Both roles of the same word, from bytes the gate
120
+ declined — which is the clearest statement of why the two surfaces are separate.
121
+
122
+ ### 5. Offline is honest about what it cannot know
123
+
124
+ ```bash
125
+ centrs explain '/ip/address print' --schema --json
126
+ ```
127
+
128
+ No router was named, so no enumeration is fabricated: `data.schema` is absent
129
+ (or empty with `truncated: false`), every emitted fact cites `canonicalizer`
130
+ evidence, and a tip states that richer schema/completion data is available
131
+ when a router is provided.
132
+
133
+ ### 6. curl rendering with a placeholder host
134
+
135
+ ```bash
136
+ centrs explain '/ip/address print' --curl --json
137
+ ```
138
+
139
+ The first statement's `transport.rest` carries the method +
140
+ `/rest/ip/address` mapping and its `transport.curl` renders a ready-to-edit
141
+ `curl` command using a placeholder host (no router was given) and elided
142
+ credentials; the equivalent `centrs api` invocation is rendered alongside. A
143
+ statement *not* covered by a tested mapping rule classifies `"unknown"` and
144
+ renders no curl.
145
+
146
+ ## Live (CHR target, rest-api)
147
+
148
+ ### 7. Describe a path: verbs first
149
+
150
+ ```bash
151
+ centrs explain $R /ip/address -u $U -p $P --schema --json
152
+ ```
153
+
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.
158
+
159
+ ### 8. Describe a verb: its arguments
160
+
161
+ ```bash
162
+ centrs explain $R /ip/address/set -u $U -p $P --schema --json
163
+ ```
164
+
165
+ `data.schema` enumerates the settable arguments of `set` (`address`,
166
+ `interface`, `disabled`, …) with types where the device reports them.
167
+
168
+ ### 9. Describe print: the proplist special case
169
+
170
+ ```bash
171
+ centrs explain $R /ip/address/print -u $U -p $P --schema --json
172
+ ```
173
+
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
176
+ `completion input="/ip/address/print proplist="` (dot-free console spelling),
177
+ filtered to rows with `show=true`; each returned fact cites that live evidence.
178
+
179
+ ### 10. Smart sizing truncates with counts, `--full` lifts it
180
+
181
+ ```bash
182
+ centrs explain $R /ip -u $U -p $P --schema --json
183
+ ```
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.
188
+
189
+ ### 11. Spans and the error byte (centrs vocabulary)
190
+
191
+ ```bash
192
+ centrs explain $R '/ip/address add address=bogus interface=' -u $U -p $P --json
193
+ ```
194
+
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.
198
+
199
+ ### 12. Structure via :parse
200
+
201
+ ```bash
202
+ centrs explain $R ':if (1 > 2) do={ :put x' -u $U -p $P --json
203
+ ```
204
+
205
+ The unclosed block yields a `:parse`-derived diagnostic; no partial structure
206
+ is fabricated past the first hard error. The line/column detail is asserted
207
+ here over rest-api; example 24 asserts the same readout over native API using
208
+ `/execute as-string`.
209
+
210
+ ### 13. Completion: partial argument name
211
+
212
+ ```bash
213
+ centrs explain $R '/ip/address add comm' -u $U -p $P --complete --json
214
+ ```
215
+
216
+ `data.completion` includes `comment` as a completing candidate for the
217
+ partial word `comm` — a valid partial, no unknown-word diagnostic.
218
+
219
+ ### 14. Completion: complete argument name, no value yet
220
+
221
+ ```bash
222
+ centrs explain $R '/ip/address add comment' -u $U -p $P --complete --json
223
+ ```
224
+
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.
228
+
229
+ ### 15. Completion: value position
230
+
231
+ ```bash
232
+ centrs explain $R '/ip/firewall/filter add chain=' -u $U -p $P --complete --json
233
+ ```
234
+
235
+ 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.
239
+
240
+ ### 16. Runtime acceptance is never claimed
241
+
242
+ ```bash
243
+ centrs explain $R '/ip/route add dst-address=10.9.0.0/16 blackhole=yes' -u $U -p $P --json
244
+ ```
245
+
246
+ Even when every probe passes, `data.runtimeAcceptance` is `"not-proven"` —
247
+ the inspect-vs-runtime gap stays machine-readable.
248
+
249
+ ### 17. Fan-out is rejected
250
+
251
+ ```bash
252
+ centrs explain --group lab '/ip/route print' --json
253
+ ```
254
+
255
+ `ok: false` with `usage/fanout-not-supported`; `explain` takes at most one
256
+ router.
257
+
258
+ ## Phase-0-derived contract anchors
259
+
260
+ These examples are the product-facing subset of the phase-0 findings. The
261
+ larger mutation, coordinate, and stress matrices live as product-owned fixture
262
+ tests per README phase 0.5; no test imports `.scratch/` code.
263
+
264
+ ### 18. A bare path neither table knows is ambiguous offline
265
+
266
+ ```bash
267
+ centrs explain '/disk/format-drive' --json
268
+ ```
269
+
270
+ The first statement has `resolution: "ambiguous"`: without a schema, the same
271
+ shape can be a menu or a no-argument command. It carries no invented `command`,
272
+ its transport classification is `"unknown"`, and a canonicalizer diagnostic
273
+ explains the menu-vs-command ambiguity. `data.verdict` remains the independent
274
+ diagnostic severity summary; it is not the statement-resolution field.
275
+
276
+ **The input has changed twice, for the same reason each time.** It first read
277
+ `/ip/route`, from before the baked menu table existed; #207 decided that one (see
278
+ example 18b). It then read `/system/reboot`, which #228's published command axis
279
+ decides (see example 18c). Both tables are floors, so the example needs a path in
280
+ *neither*: `/disk/format-drive` is real enough to appear in the corpus, but 7.23.2
281
+ spells it `/disk format`, and MikroTik publishes no such entry.
282
+
283
+ ### 18b. A bare path the menu table DOES know resolves as a menu, offline
284
+
285
+ ```bash
286
+ centrs explain '/ip/route' --json
287
+ ```
288
+
289
+ `resolution: "resolved"` with `kind: "menu"` and `command: { path: "/ip/route" }`
290
+ — no verb, because a navigation statement names only a menu. The evidence is the
291
+ generated table (#207), not a guess about hyphens or token counts, and it is why
292
+ the export-abstention rate went to zero. Example 19 asserts the same reading
293
+ against a live device, which is a confirmation rather than a promotion.
294
+
295
+ ### 18c. A bare path the COMMAND axis knows resolves as a command, offline
296
+
297
+ ```bash
298
+ centrs explain '/system/reboot' --json
299
+ ```
300
+
301
+ `resolution: "resolved"` with `kind: "command"`, `command.path: "/system"` and
302
+ `command.verb: "reboot"` — the other half of the twin in example 18b, from the
303
+ other half of the same generated table (#228). `/system/reboot` and `/ip/address`
304
+ are the same text shape, and offline now decides both from published evidence
305
+ instead of refusing both.
306
+
307
+ `structure.containsWrite` is deliberately *not* moved by this: knowing a path is a
308
+ command says nothing about whether it mutates, so this document still reports
309
+ `"unknown"`. Examples 18d and 18e are the two things the same evidence *does*
310
+ move.
311
+
312
+ ### 18d. A published command does not move the menu context
313
+
314
+ ```bash
315
+ centrs explain '/ip route
316
+ /system reboot
317
+ add dst-address=8.8.8.8/32 gateway=1.1.1.1' --json
318
+ ```
319
+
320
+ Three statements: `resolved`/`menu` at `/ip/route`, `resolved`/`command` at
321
+ `/system` verb `reboot`, and `resolved`/`command` at **`/ip/route`** verb `add` —
322
+ not at `/system/reboot`. A command is not navigation, so the context it does not
323
+ establish is not inherited by what follows. This was `#211 B2`, pinned as a KNOWN
324
+ LIMIT until the command axis supplied the positive evidence that a path is a
325
+ command; it is why `structure.containsWrite` is `true` here rather than describing
326
+ an `add` under a menu that does not exist.
327
+
328
+ ### 18e. A published command outranks the punctuation guess
329
+
330
+ ```bash
331
+ centrs explain '/system/gps/monitor once' --json
332
+ ```
333
+
334
+ `command.verb: "monitor"` with `once` inside the argument list — where the
335
+ schema-free punctuation rule reads the first space-separated token as the verb and
336
+ so lands on `once`. The publication names the command, so the operand goes back to
337
+ being an operand. `structure.containsWrite` is `false`, because `monitor` is a
338
+ curated read verb; before the command axis this document abstained.
339
+
340
+ ### 19. Live evidence confirms the same bare path as a menu
341
+
342
+ ```bash
343
+ centrs explain $R '/ip/route' -u $U -p $P --json
344
+ ```
345
+
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
349
+ the probe never executes the path. Since #207 the offline reading (example 18b)
350
+ already says the same thing from the generated table, so what the live probe
351
+ adds here is **provenance and a version stamp**, not the answer — and it is the
352
+ oracle for a path the table does not list, where offline abstains (example 18).
353
+
354
+ ### 20. Explain-only write detection is three-valued
355
+
356
+ ```bash
357
+ centrs explain '/ip/address add address=198.51.100.10/32 interface=ether1' --json
358
+ centrs explain '/ip/address print' --json
359
+ centrs explain '/disk format-drive disk1' --json
360
+ ```
361
+
362
+ `data.structure.containsWrite` is respectively `true`, `false`, and
363
+ `"unknown"`. The third result must not become `false`: `format-drive` is
364
+ write-shaped but outside the small, version-stable write table. These values do
365
+ not alter the execute gate's `canonical.writeShaped` verdict.
366
+
367
+ ### 21. A defect cannot fabricate a following command
368
+
369
+ ```bash
370
+ centrs explain '/interface bridge add name=br;0 protocol-mode=none' --json
371
+ ```
372
+
373
+ The injected `;` has RouterOS statement-separator semantics, so the input is two
374
+ statements, not one with a corrupt value. The tail statement beginning `0`
375
+ resolves `"unknown"` — a bare-word head fails closed — with no confident `/r0`
376
+ command or runnable transport rendered, and a
377
+ `explain/canonicalizer/unresolved-statement` diagnostic carries **that
378
+ statement's** byte region. The analysis itself is `ok: true`; `data.verdict` and
379
+ `--fail-on` report the diagnostic.
380
+
381
+ **No defect region is emitted for the `;` itself.** A stray mid-token delimiter
382
+ is one of the two classes deliberately NOT detected (see *Result shape* → defect
383
+ regions, and #192): it is plausible but ungrounded, and it needs a probe matrix
384
+ before a detector is worth having. What this example pins is the fail-closed
385
+ floor that matters — corruption may degrade a reading, never invent one.
386
+
387
+ ### 22. Normalization preserves device byte offsets and LSP positions
388
+
389
+ ```bash
390
+ centrs explain '/system identity set name="router-🚀"' --json
391
+ ```
392
+
393
+ `data.input.normalized` is `true`; the four UTF-8 bytes of `🚀` occupy four
394
+ analyzed bytes and map to its two original UTF-16 code units through
395
+ `data.input.positionMap[]`. Every span remains half-open and in bounds,
396
+ `end === input.bytes` is legal, and a cursor inside those four bytes snaps to
397
+ the character boundary rather than splitting the original character.
398
+
399
+ ### 23. Selector-less set fails closed offline
400
+
401
+ ```bash
402
+ centrs explain '/ip/dns set use-doh-server=https://resolver.example/dns-query' --curl --json
403
+ ```
404
+
405
+ The statement's transport classification is `"unknown"` and no curl is
406
+ rendered. Offline cannot prove that `/ip/dns` is a singleton; the same shape on
407
+ an id-bearing table requires an id. Live schema evidence may lift this case to
408
+ `api-candidate`.
409
+
410
+ ### 24. Native API returns the same parse diagnostic
411
+
412
+ ```bash
413
+ centrs explain $A ':if (1 > 2) do={ :put x' --via native-api --port $API_PORT --username $U --password $P --json
414
+ ```
415
+
416
+ `meta.via` is `native-api`; `:put [:parse …]` runs through `/execute` with
417
+ `as-string`, and the diagnostic carries the RouterOS line/column text rather
418
+ than an opaque job handle. The corresponding evidence entry names `:parse`,
419
+ is version-stamped, and no command is executed.
420
+
421
+ ### 25. Offline semantic symbols retain roles and binding identity
422
+
423
+ ```bash
424
+ centrs explain '{:local x 1.3; :put [:typeof $x]; :set x 2.1.1; /put $x; :local z (1.1,1::1,"abc",1d,1w7h2s,1.1.1.1/24,123,[:parse "(1+1)"],(1w+1d),2008:1::2/128,[:timestamp],"a"."b"."c",4%2,-1); :put "$[:typeof $z]"; :foreach i,v in=$z do={:put "$i = $v; types i = $[:typeof $i], v = $[:typeof $v]"}}'
425
+ ```
426
+
427
+ The text output has a `symbols:` section. The declaration, `:set` target, and
428
+ references for `x` share one result-local binding identity; `z` has another;
429
+ and the `:foreach` bindings `i` and `v` each have their own identity. Their
430
+ roles are respectively `declaration`, `assignment`, `reference`, and `binding`.
431
+ Duration literals such as `1d` and `1w7h2s` do not fabricate symbol names.
432
+
433
+ The JSON/YAML form carries the same facts under
434
+ `data.symbols.occurrences[]` as `name`, byte `span`, `class`, `role`,
435
+ `bindingIds`, `sigil`, optional `note`, and `ev`. This is the Q13 symbol view;
436
+ it does not infer value-flow types, which remain #239 S2 after #225.
437
+
438
+ ### 26. Value facts keep shape, observed type, and schema type separate
439
+
440
+ ```bash
441
+ centrs explain ':local x 2.2; :set x "2.2"; :local z (1,2,3); :local t 00:00:02; :local i *1; /ip/arp/add mac-address=00:11:22:33:44:55' --json
442
+ ```
443
+
444
+ `data.values.occurrences[]` contains nine result-local value records — six
445
+ argument values and the three members of the array literal (example 28 owns the
446
+ interior). The bare
447
+ `2.2` carries `facts.shapeHints.values: ["ip"]` — RouterOS numbers are integers,
448
+ so a dotted decimal is an IPv4 shortcut (`2.0.0.2`) and never `num`; the quoted
449
+ `"2.2"` carries `["str"]`. The array literal, colon-form time, internal ID, and
450
+ full MAC spelling carry `["array"]`, `["time"]`, `["id"]`, and `["mac"]`.
451
+ `mac` is a schema spelling clue, not a RouterOS scripting type: the bare scalar
452
+ is `str` while the CLI Reference declares `mac-address` as `macAddr`. Each hint
453
+ fact cites canonicalizer evidence with
454
+ `basis: "heuristic"`; no record has `observedType` or `schemaType`, because
455
+ offline analysis has neither live parser output nor an argument schema. A hint
456
+ is not a diagnostic and does not change `data.verdict` or
457
+ `runtimeAcceptance: "not-proven"`.
458
+
459
+ The CHR assertion for this example also pins the boolean boundary: scalar
460
+ `true`/`false` and `yes`/`no` are `bool`, quoted forms are `str`, CLI boolean
461
+ attributes accept `yes`/`no` rather than `true`/`false`, and REST accepts JSON
462
+ booleans. These live/context observations ground the offline `bool` spelling
463
+ hint; they are not emitted as `observedType` or `schemaType` by offline explain.
464
+ The same assertion grounds the new boundaries: brace/comma literals are
465
+ `array`; colon times are `time`; `*1` is `id`; `.` concat can return `str` or
466
+ distribute over an array; `[:toarray ""]` is the only empty-array construction
467
+ and indexing it yields `nothing`; `[:parse "…"]` returns `code`; and `nil` has
468
+ no standalone offline spelling. Produced empty arrays, concat, `code`, and
469
+ `nil` remain abstentions rather than guessed hints.
470
+
471
+ ### 27. Continuation comments are not REST operands
472
+
473
+ ```bash
474
+ centrs explain '/ip/address/add address=1.2.3.4 \
475
+ # a note
476
+ comment=x' --json
477
+ ```
478
+
479
+ The statement's argument reading is complete: `command.args` is
480
+ `{ address: "1.2.3.4", comment: "x" }`, and `arguments.positional` is empty. Its
481
+ transport classification stays `api-candidate`, with the same two fields in the
482
+ REST body; the comment text is never rendered as an operand.
483
+
484
+ The CHR assertion grounds the wider placement rule on the requested RouterOS
485
+ channel via `/console/inspect request=highlight` plus `:parse`: unquoted `#`
486
+ inside brace arrays and parenthesized expressions is a hard error; the first
487
+ non-space `#` in `do`, `else`, `foreach do`, and `on-event` bodies that are
488
+ themselves in a statement context is a comment — the same `do={ # c` nested
489
+ inside an array is an array and `#` is an error (placement table, #249), while
490
+ a `[…]` substitution nested in that array re-enters a statement context so
491
+ `[:do { # c` is a comment again; the same lead inside a `[…]` command
492
+ substitution is a comment while `[:put #test]` keeps `#test` as a value;
493
+ attribute and bare-value `#` is content; and `#` after `}` is a hard error.
494
+ The stable/testing grounding run for #245 used RouterOS 7.23.3 and 7.24rc3;
495
+ the enclosing-array exception is grounded on CHR 7.23.3 via #249.
496
+
497
+ A same-line hash after a completed scripting directive is likewise an error:
498
+ `:local x 2 #` produces an `explain/canonicalizer/invalid-hash` diagnostic at
499
+ the hash byte and stops later semantic claims. Statement-leading comments are
500
+ listed with their byte ranges under `comments:` in the default text output.
501
+
502
+ ### 28. An array literal is read member by member, and only where it is one
503
+
504
+ ```bash
505
+ centrs explain ':local z {1.1;"abc";1d;{2;3};a=0x10;b=100000w}; /ip/route/add comment={1;2}' --json
506
+ ```
507
+
508
+ Members of a located array literal are values in their own right.
509
+ `data.values.occurrences[]` carries one record per member, each with its own
510
+ document-byte span, its own `shapeHints`, and a `parent` naming the array's
511
+ result-local id — `1.1` is `["ip"]`, `"abc"` is `["str"]`, `1d` is `["time"]`,
512
+ the nested `{2;3}` is `["array"]` with two `["num"]` members of its own, and the
513
+ keyed member carries `name: "a"` with `["num"]` for `0x10`. `kind` is `element`,
514
+ so a caller can tell a member from a command argument without re-parsing.
515
+
516
+ **A member is not an argument value, because RouterOS does not parse it as
517
+ one.** Inside a literal the device reads an expression, so `b=100000w` hints
518
+ nothing at all: `:parse` lowers it to `$100000w`, a variable reference, because
519
+ the literal overflows the signed 64-bit nanosecond range that ends between
520
+ `15250w` and `15251w`. The same rule removes three spellings the argument
521
+ lexicon does hint — a bare word (`{abc}` is `$abc`), a full MAC
522
+ (`{00:11:22:33:44:55}` is a variable, though `:local x 00:11:22:33:44:55` is
523
+ `str`), and an internal id (`{*1}` is a hard syntax error, though `:local x *1`
524
+ is `id`) — and adds hexadecimal, which is a number here (`0x10` is 16, while
525
+ `0X10` is a variable). Expressions and substitutions abstain: `{1+1}`,
526
+ `{"a"."b"}`, `{[:parse ":put 1"]}` and `{$x}` produce no member hint.
527
+
528
+ The second statement produces **no value record** and an
529
+ `explain/canonicalizer/invalid-command-brace` error at its `{`. A `{…}` array
530
+ literal is legal only in a scripting directive's value slot:
531
+ `/console/inspect` classes
532
+ the `{` byte `error` and `:parse` refuses `/ip/route/add comment={1;2}`,
533
+ `/ip/dns/set servers={1.1.1.1;8.8.8.8}` — whose attribute really is list-typed,
534
+ which is what rules out a schema-shaped reading — `.proplist={name;comment}`,
535
+ the relative spelling `ip route add comment={1;2}`, and even
536
+ `:log info message={1;2}`. The `(1,2)` spelling is never rejected at the
537
+ delimiter in any of those positions, and a `(…)` or `[…]` around the brace
538
+ restores the expression context (`comment=({1;2})` and `comment=[:tostr {1;2}]`
539
+ both parse). The braces a
540
+ command argument does accept are script bodies (`source=`, `on-event=`), and
541
+ those are refused earlier as scope blocks rather than read as arrays.
542
+
543
+ An empty member is a device syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`), so
544
+ the enclosing `array` shape is withdrawn with it; a single trailing separator is
545
+ legal (`{1;}` is a one-member array). Shape hints never become diagnostics.
546
+ The command-brace error is a separate, device-grounded syntax fact rather than
547
+ an inference from the missing hint.
548
+
549
+ ### 28c. Which slot the brace sits in decides whether it is an array
550
+
551
+ ```bash
552
+ centrs explain ':local z {1;2}; :delay {1;2}; :for i from={1;2} to=2 do={:put 1}; :execute script={1;2}' --json
553
+ ```
554
+
555
+ Only the first and third statements produce an array record. Being a scripting
556
+ directive is necessary but not sufficient: the gate is keyed on **(verb, slot)**
557
+ from a device sweep of every root builtin and every slot it completes, and a
558
+ slot has four outcomes. `:local z {…}` and `:for from={…}` EVALUATE the brace
559
+ (IL `(, 1 2)` for `{(1,2)}`), so they are arrays. `:delay {1;2}` is a syntax
560
+ error — a scalar-typed slot at the same `/` path — and produces nothing.
561
+ `:execute script={1;2}` parses but keeps the brace VERBATIM as a script
562
+ (`script=(1,2)`, not `(, 1 2)`), so it is not an array either, and neither are
563
+ the code slots `:onerror in=`, `:retry command=` and `:if do=`. The same name
564
+ can differ by verb: `:foreach i in={1;2}` is an array while
565
+ `:onerror e in={1;2}` is a program.
566
+
567
+ A positional is keyed by its index, because position decides: `:local {1;2}`
568
+ puts the literal in the NAME slot and does not parse, while `:local z {1;2}`
569
+ puts it in the VALUE slot and does. The verb is matched verbatim for the same
570
+ reason: RouterOS accepts only the lower-case spelling, so `:LOCAL z {1;2}` is
571
+ `expected command name` and gets no array, while an argument's casing is free
572
+ (`:local Z {1;2}` binds `$Z` and does). Everything the sweep could not ask is
573
+ refused, so the gate loses coverage rather than inventing a shape.
574
+
575
+ Two rejections a member walk cannot see from the outside are also refused here.
576
+ A nested literal the device rejects withdraws its container — `{(1,)}`,
577
+ `{a=(1,)}`, `{1;(2,)}`, `{()}`, `{a=()}`, `{(,)}` and `(1,(2,))` are all
578
+ `:parse` syntax errors, even though a `(…)` member is usually a group (`{(1)}`
579
+ is the one-member array `1`). And member descent stops after eight frames: past
580
+ that the interior is unverified, and a fault below the bound still makes the
581
+ whole statement a syntax error, so the literal is refused rather than called an
582
+ array on the strength of its delimiters alone.
583
+
584
+ ### 28b. The comma spelling is accepted everywhere, and means two things
585
+
586
+ ```bash
587
+ centrs explain '/ip/dns/set servers=1.1.1.1,8.8.8.8; /ip/route/add comment=a,b; :local x 1,2' --json
588
+ ```
589
+
590
+ The brace restriction is about the brace, not about lists: `=1,2,3` is a syntax
591
+ error nowhere, and list-taking arguments really do take it. What the device does
592
+ with it depends on the argument's TYPE, which offline does not have —
593
+ `servers=1.1.1.1,8.8.8.8` lowers to `servers=1.1.1.1;8.8.8.8` and
594
+ `dst-port=80,443` to `dst-port=;80;443`, while `comment=a,b` and
595
+ `interface=ether1,ether2` keep the whole run as a single string.
596
+
597
+ So the first two records carry **`["array", "str"]`** — the first genuinely
598
+ plural hint, which is what `shapeHints.values` is a list for. Neither reading is
599
+ validated and neither becomes a diagnostic; the split is a `schemaType` fact and
600
+ that home stays empty offline. The third record, in a scripting directive's
601
+ value slot, carries `["array"]` alone: `:local x 1,2` is a two-member array on
602
+ the device and no text reading exists there.
603
+
604
+ No members are emitted for a bare comma run, because whether it splits at all is
605
+ the schema's answer. The `(1,2)` spelling is different — its delimiters prove
606
+ the array — so `servers=(1.1.1.1,8.8.8.8)` yields the array plus two `["ip"]`
607
+ members.
608
+
609
+ ### 29. A second command-shaped run is a missing separator, not an operand
610
+
611
+ ```bash
612
+ centrs explain '/ip/address add interface=ether1 /ip/route add gateway=192.168.88.1' --json
613
+ ```
614
+
615
+ `data.verdict` is `fail` with one
616
+ `explain/canonicalizer/missing-statement-separator` **error** spanning the whole
617
+ second run (`[33,46)` — `/ip/route add`), whose message says to insert `;` or a
618
+ newline before it.
619
+
620
+ The source stays **one** statement: `resolution` is `"resolved"` on
621
+ `/ip/address add`, because that head really was read and the device's own
622
+ `highlight` agrees. What is withdrawn is the reading the run contradicts —
623
+ `command.args` is absent, `arguments.read` is `false`, and `transport.
624
+ classification` is `"unknown"`, so no `centrs execute` line is offered for bytes
625
+ the same result rejects. `data.values.occurrences` is cut at the same byte: it
626
+ keeps `interface` and drops `gateway`, which the device reads as the value of
627
+ `/ip/address/add`'s own `address=` rather than as an attribute name.
628
+
629
+ The rule is a known **menu path** followed by a console **verb**, never "a `/`
630
+ after the verb": `/file remove /flash/skins/foo.html` is legal and lowers to
631
+ `numbers=/flash/skins/foo.html`. It abstains on a menu path with no verb after
632
+ it and on an operand no structure table lists, both of which draw the device's
633
+ generic `bad parameter` and need per-menu positional schema to decide.
634
+
635
+ ### 29b. The same commands, separated, read as two
636
+
637
+ ```bash
638
+ centrs explain '/ip/address add interface=ether1; /ip/route add gateway=192.168.88.1' --json
639
+ ```
640
+
641
+ `data.verdict` is `pass` with no diagnostics, two statements, and each keeps its
642
+ own `command.args`. The newline spelling is identical.
643
+
644
+ ### 30. A known menu path with arguments does not get an invented verb
645
+
646
+ ```bash
647
+ centrs explain '/ip/firewall/address-list name=ytkids timeout=1h' --json
648
+ ```
649
+
650
+ One statement with `resolution: "ambiguous"` and no `command` at all, plus an
651
+ `explain/canonicalizer/ambiguous-statement` **warning** over the whole
652
+ statement naming both premises: `/ip/firewall/address-list` is a known menu,
653
+ and `address-list` is not a console verb. `data.verdict` is `warn`, so the
654
+ default `--fail-on error` still exits `0` — offline is saying it cannot read
655
+ this, not that RouterOS rejects it.
656
+
657
+ It used to answer `resolution: "resolved"`, `command.path: "/ip/firewall"`,
658
+ `command.verb: "address-list"` — a verb assembled out of a path segment. The
659
+ spaced spelling `/ip firewall address-list name=x` was worse, promoting
660
+ `firewall` and reporting the menu as `/ip`. Adding the verb resolves it again
661
+ (`/ip/firewall/address-list add name=ytkids` is `resolved`/`command`), and the
662
+ bare path without arguments is still `resolved`/`menu` (example 18b's rule).
663
+
664
+ ### 31. A command substitution inside a string keeps its own tokens
665
+
666
+ ```bash
667
+ centrs explain ':put "$[/ip address print as-value]"' --tokens --json
668
+ ```
669
+
670
+ `data.structure.subcommands[0]` resolves `/ip/address` verb `print` — it always
671
+ did — and `data.tokens[]` now agrees with it: `dir` over `/ip` and `address`,
672
+ `cmd` over `print`, with the quote, the `$[`, the separators and the trailing
673
+ `as-value]"` left as `string`. The whole quoted run used to be one opaque
674
+ `string` token, so an editor rendering the partition painted a command centrs
675
+ had already read as flat text.
676
+
677
+ Byte coverage does not change — the partition is still total and gapless — only
678
+ which fill owns those bytes. The `$` sigil and the `[` / `]` delimiters stay
679
+ `string`: #264 B5 kept them there because `structure.subcommands` already
680
+ publishes the resolution and its `innerSpan` locates the brackets around it.
681
+
682
+ ### 32. A block binder the envelope cannot publish is still located
683
+
684
+ ```bash
685
+ centrs explain ':if ($x = 1) do={ :put "hi" }' --tokens --json
686
+ ```
687
+
688
+ `data.structure.statements[0].arguments` is `{ read: false, why: "a
689
+ substitution or expression value" }` — the strict reading is all-or-nothing and
690
+ a runnable request cannot be rendered from a half-read argument list, so the
691
+ envelope publishes no `ExplainArgumentToken` here at all. `data.tokens[]` still
692
+ locates the argument: `arg` over `do`, `arg-sep` over its `=`, `brace` over the
693
+ `{`.
694
+
695
+ That is why #264 B5 gave the `=` its own class. A consumer holding only the
696
+ envelope would otherwise have to derive the name/value boundary from
697
+ `arguments[].valueSpan.start - 1`, and for 30.8% of the corpus's `=` runs —
698
+ `do=` first among them — there is no published `valueSpan` to derive it from.
699
+
700
+ The same command also shows the two `=` bytes staying apart: the one in
701
+ `($x = 1)` is a comparison and comes back `operator`, decided by fill order
702
+ rather than by a scan for the byte.