@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
package/CHANGELOG.md CHANGED
@@ -6,7 +6,423 @@ The product evolves as a stream of changes; there are no alpha gates.
6
6
  Per-feature status is tracked in `docs/MATRIX.md`. Use this file for
7
7
  documenting cross-cutting shifts that affect contributors and consumers.
8
8
 
9
- ## Unreleased
9
+ ## 0.1.7 — 2026-09-27
10
+
11
+ ### Added
12
+
13
+ - **`centrs devices init` creates an empty CDB, `git init`-style (#376).**
14
+ Bootstrapping a device book previously meant conjuring the file some other
15
+ way. `centrs devices init [--cdb-file PATH]` (or `CENTRS_CDB_FILE`, or the
16
+ default path) creates the parent directories and an exclusive `0600` file.
17
+ **It never clobbers**, so a bootstrap script may run it unconditionally: an
18
+ existing readable CDB succeeds with a `cdb/file-exists` warning and nothing
19
+ is written. An existing file that fails to load (`cdb/parse-failed`,
20
+ `cdb/password-required`) reports that error and is left untouched, and a
21
+ location that cannot be written fails with the new `cdb/create-failed`. CLI
22
+ and library (`initCdb`) only; not exposed on the MCP devices tool.
23
+
24
+ - **`devices list` reports the lookup keys it matches on (#378).** Rows now
25
+ carry `identity` / `mac` / `ip` when the comment sets them, with
26
+ `comment-kv` provenance in `sources`, and the text table gains an `IDENTITY`
27
+ column. `comment` itself stays `show`-only.
28
+
29
+ ### Fixed
30
+
31
+ - **Write-shaped commands now require confirmation, on every transport
32
+ (#365, #366).** Write authorization was entangled with script-vs-structured
33
+ transport selection, so several shapes reached a device without passing the
34
+ `--yes` gate: selectors, positional ids, non-CRUD verbs, root mutators,
35
+ indirect execution and relative menu writes. Authorization is now decided
36
+ independently of how the command is carried. Read spellings are unaffected —
37
+ the verb is the first known verb among the path's last segment and the bare
38
+ words following the path token, so `/system identity print` and
39
+ `:put [/system identity get name]` stay reads, while a statement with no
40
+ recognizable verb still fails closed. Caller-addressed API `/execute` script
41
+ strings additionally run offline analysis, then live `:parse`, then execute;
42
+ structured API requests remain on `/console/inspect`. This advances the
43
+ synchronous half of #154 without closing it — caller-controlled `file=`,
44
+ `as-string` and async job-id semantics remain open there.
45
+
46
+ - **RouterOS rejections returned inside a successful transport envelope are no
47
+ longer read as success.** `/execute` can answer 200 while the device's own
48
+ text says it refused. Those are now classified as runtime failures, with the
49
+ passed validation-stage history preserved so the envelope still shows what
50
+ each stage concluded.
51
+
52
+ - **Device-stage validation missed RouterOS's bare `missing …` diagnostics
53
+ (#375).** `classifyParseResult` matched `syntax error`, `bad command name`
54
+ and `expected …` from `:put [:parse …]`, but not `missing closing brace` or
55
+ `missing value for where` — so the device stage reported those rejections as
56
+ a pass and the command ran. `/ip/address/find where=` passes offline, which
57
+ made the device classifier its only gate. Replaying every bare diagnostic in
58
+ the pinned corpus across 7.20.8 → 7.25beta3 finds exactly seven heads, and
59
+ the two `missing …` forms were the only misses; both are now recognized, with
60
+ the device's own position in `error.context.detail`. Echoed script text is
61
+ still not a verdict.
62
+
63
+ - **A stream's `/cancel` acknowledgement is now bounded (#385).** `api
64
+ --stream` stopped by `--duration` or Ctrl-C sent `/cancel` and then waited
65
+ with no bound, so a connected router that never answered held the CLI open
66
+ forever — `backend.close()` in the outer `finally` was unreachable while that
67
+ wait was pending. The wait is now bounded by the request `--timeout`, the
68
+ same bound as every other reply wait; past it the listen ends locally and the
69
+ session closes. The summary keeps its real `stopReason` and adds a
70
+ `transport/cancel-unacknowledged` **warning**, so the outcome never claims an
71
+ acknowledgement that did not arrive. A cooperating router is unchanged.
72
+
73
+ - **`retrieve --attributes` rejected real singleton properties (#377).**
74
+ `isKnownSingletonPath` knew only `/system/resource` and `/system/identity`,
75
+ so every other singleton (`/tool/romon`, `/system/routerboard`, `/ip/dns`, …)
76
+ was inspected as a list menu. `print,proplist` comes back empty on a
77
+ singleton, so validation fell back to `print`'s own flags (`as-value`,
78
+ `file`, `interval`, …) and offered those as the attribute list. The singleton
79
+ bit now comes from the device: a singleton's `get` takes only `value-name`,
80
+ while a list menu's `get` also takes `number`. The hardcoded pair survives
81
+ only as the `--validate=false` fallback, which skips inspect. Costs one extra
82
+ `request=child` for a menu that has `get`.
83
+
84
+ - **Completion candidates mixed help text and syntax rows into property names
85
+ (#380).** `extractCompletionNames` read `completion`/`name`/`value`/`text`
86
+ from every row, so the help string ("DNS cache size in kB") and the
87
+ `show: "false"` syntax rows (`[`, `(`, `$`, `"`, `*`, `<value>`) were offered
88
+ as attributes — which is why `--attributes '$'` passed validation. Rows carry
89
+ only `completion`/`offset`/`preference`/`show`/`style`/`text`/`type` on every
90
+ build recorded 7.9.2 → 7.25beta3, so `name`/`value` never matched anything.
91
+ On CHR 7.23.7, `show: "true"` is exactly the property set. The exported
92
+ `InspectCompletionItem` drops the never-present `name`/`value` and adds
93
+ `show`/`style`/`offset`/`preference`.
94
+
95
+ - **An unparseable `<router>` crashed instead of reporting (#381).**
96
+ `parseHostCandidate` passed the target straight to `new URL` — the only
97
+ `new URL` on user input in `src/` — so with the default CDB, `quickchr:NAME`,
98
+ `a b` or `bad:host:name` failed every command with a `TypeError: Invalid URL`
99
+ surfaced as `internal/unhandled`. It now raises `input/invalid-target` and
100
+ names the valid target forms; a `quickchr:NAME` guess is told to use
101
+ `--quickchr NAME`. With an explicit CDB the same input already stopped at a
102
+ classified `cdb/not-found-target`, so that path is unchanged.
103
+
104
+ ### Changed
105
+
106
+ - **The pinned restraml inspect trees advance to the live channel builds.** The
107
+ pins named "current stable" and "next beta" while pointing a patch behind
108
+ both: stable 7.24.2 → 7.24.4 and development 7.25beta3 → 7.25beta5. History
109
+ anchors 7.10.2 and 7.16 are untouched and both current versions stay
110
+ architecture-paired. The refresh is structurally a no-op — the inspect union
111
+ holds at 617 menus and the catalog at 1,120 paths, with no path added or
112
+ removed and no kind change. What moved is current-only publication: the CLI
113
+ Reference grew 1,053 → 1,055 pages (1,060 → 1,062 entries) and now documents
114
+ `/interface/ethernet/pon` and `/ip/ssh/known-hosts`, so both move `inspect` →
115
+ `both` (961 `both`, 65 `inspect`, 94 `published`). Zero
116
+ navigation-vs-command contradictions, unchanged.
117
+
118
+ ## 0.1.6 — 2026-09-15
119
+
120
+ ### Changed
121
+
122
+ - **Release channels now use standard npm pre-release versions, not the even/odd
123
+ minor rule.** `0.1.x` counted as "pre-release" only by an internal convention
124
+ npm could not see, so every cut since 0.1.0 published to `next` and `latest`
125
+ was left pointing at 0.1.0 — a plain `npm install @tikoci/centrs` handed you a
126
+ June build while six releases sat on `next`. The dist-tag is now selected by
127
+ the SemVer pre-release identifier: a `-`-suffixed version (`0.1.7-next.0`)
128
+ publishes to `next` and never moves `latest`; a plain version (`0.1.6`)
129
+ publishes to `latest` and carries `next` forward with it, so `@next` cannot
130
+ resolve older than `@latest` between cuts. This release is the first plain
131
+ version under the new rule, so `latest` and `next` both land on 0.1.6 — which
132
+ is also the fix for the stale `latest`. The pre-1.0 caveats are unchanged:
133
+ this is still an early preview, and `docs/MATRIX.md` remains the source of
134
+ truth for what works.
135
+
136
+ - **Validation now runs offline first, then on the device.** centrs had two
137
+ validators that did not know about each other: the device preflight
138
+ (`:put [:parse …]` + `/console/inspect request=child`) that `execute` / `api` /
139
+ `validate` share, and offline `explain`, which was byte-accurate and
140
+ corpus-gated but took no part in validation at all. `execute` (all four
141
+ transports) and `api`'s `/execute` script mode now run the analyzer as
142
+ **stage 1**, before any connection: a syntax fault comes back with the
143
+ offending byte span in `error.context.span` and the protocol adapter is never
144
+ dialed, where `:put [` previously cost a full round trip to be told no. The
145
+ `api /execute` carve-out is the larger change — that surface ran **no**
146
+ preflight at all, so its script went to the device ungated.
147
+ `meta.validation.stages[]` reports what each stage concluded and names a
148
+ skipped stage's reason, so a reader can tell "the device accepted it" from
149
+ "the device was never asked". Stage 1 is necessary and never sufficient: it
150
+ has no per-menu schema, so it cannot see an unknown attribute, and the device
151
+ stage is unchanged. An offline `warn` is an abstention and passes through;
152
+ only a `fail` rejects. `--validate=false` disables both stages — one flag, one
153
+ meaning (#354, and the ordering agreed on #236).
154
+
155
+ ### Fixed
156
+
157
+ - **`'` is not a RouterOS token, and offline analysis now says so (#355).** `'`
158
+ is not a string delimiter and not a legal bare byte — only `"` opens a string.
159
+ `:parse` rejects `name=don't`, `name=don't'`, `name='abc'` and `:put 'abc'`
160
+ byte-identically on CHR 7.21.5, 7.23.5, 7.24.2 and 7.25beta3, and offline
161
+ `explain` passed all four with ZERO diagnostics, dropping the byte into
162
+ `unclassified`. It now raises `explain/canonicalizer/invalid-apostrophe` on
163
+ the apostrophe's own byte — which is the device's own reported column in every
164
+ case — while an apostrophe inside a `"`-string or a comment stays legal. Rows
165
+ 2-4 above carry BALANCED apostrophes and are rejected anyway, so this is a
166
+ lexical rule and not a quote-balance one. Being a false ACCEPT, it was
167
+ invisible to the corpus false-reject join, which scores one direction only.
168
+ Over the pinned corpus the rule fires on 40 scripts and every one is
169
+ device-rejected: the false-reject count is unchanged at 1 and the caught count
170
+ goes 62 → 93. Symbol resolution stops at the apostrophe too, the way it
171
+ already stopped at a malformed escape, so `:put 'abc'; :put $after` no longer
172
+ publishes an occurrence for `$after` — that returns 306 bytes to
173
+ `unclassified` and is the only census movement; value, operator and
174
+ bracket-equals are unchanged.
175
+ - **The quote-count preflight that modelled `'` as a delimiter is gone.**
176
+ `execute` carried a private `hasUnbalancedQuotes` check, moved into the
177
+ offline gate by #354 and kept there only because it was the one thing catching
178
+ the apostrophe case. It had no comment awareness, so an English contraction in
179
+ a `#` comment opened a quote it never closed — over the pinned corpus it
180
+ false-rejected **51** of the 617 scripts CHR 7.24.2 accepts, a figure the
181
+ published measurement never saw because that measurement ran `explainCommand`
182
+ rather than the whole gate. Removing it costs 21 catches, all on scripts the
183
+ device rejects anyway and where the device stage still applies, and takes the
184
+ gate's false rejects from 52 to 1.
185
+
186
+ - **CI: the three console reads that RouterOS 7.23.6/7.24.3/7.25beta4 broke.**
187
+ Those builds print a settings menu that has exactly one property — of the
188
+ menus centrs reads, only `/system/identity` — one character per line, so
189
+ `name: CHR` comes back with `C`, `H` and `R` on three lines under the label.
190
+ It is neither a terminal-width effect nor transport-specific: REST `POST /rest/execute` wraps identically to the
191
+ ssh and mac-telnet consoles, and every multi-property `print` is unaffected
192
+ (measured on stock CHR 7.24.2 vs 7.24.3). `execute --via ssh`,
193
+ `execute --via mac-telnet`, and `terminal --via ssh` now assert the identity
194
+ they read with `print` column wrapping collapsed on both sides, so the
195
+ examples are evidence about the console round-trip rather than about a
196
+ device layout MikroTik is free to change (#352).
197
+ - **CI: the CHR image cache now rotates with the RouterOS point release.**
198
+ `actions/cache` only writes on an exact-key miss, so the old static key froze
199
+ the first image it ever saw: every stable bump was re-downloaded from
200
+ download.mikrotik.com on *every* job and never saved. A 186s cold download
201
+ duly timed out the 300s `chr-smoke` test on the 7.24.2 → 7.24.3 bump. The key
202
+ now carries the version the job will boot (`scripts/chr-cache-key.ts`), the
203
+ smoke test gets the headroom a cold download needs, and the QA leg's own
204
+ timeout absorbs one — a cold download had also been cancelling a whole
205
+ channel leg mid-suite with every assertion green (#352).
206
+
207
+ ## 0.1.5 — 2026-09-15
208
+
209
+ *Tagged but never published: the release-tier CHR sweep failed on the
210
+ 7.23.6/7.24.3 console-wrap and image-cache defects that #352 then fixed, so npm
211
+ has no 0.1.5. Everything below shipped in 0.1.6.*
212
+
213
+ ### Added
214
+
215
+ - **Offline `explain`'s token partition is now scored against the device.**
216
+ `bun run explain:highlight-agreement` projects each centrs token class onto
217
+ the RouterOS `/console/inspect request=highlight` class it predicts and
218
+ reports where the two agree, over the committed capture slice — no router, no
219
+ corpus, no network. The report separates what it can score from what it
220
+ cannot: each side's abstentions, and the tail after the device's own one-byte
221
+ `error` — half the slice's bytes, where 7.23.2 falls silent for the rest of
222
+ the script and 7.24rc2 mostly does. A second axis says why an answer was out of reach — offline-decidable,
223
+ schema-dependent, state-dependent or version-dependent — assigned per byte
224
+ from context rather than by class name, since one highlight class carries more
225
+ than one category. Representative source fragments ship with the counts.
226
+ Figures are a trend line over a known-biased corpus, never a pass threshold
227
+ (#264 B4, #263).
228
+
229
+ ### Fixed
230
+
231
+ - **Offline `explain` no longer rejects two RouterOS spellings every tested
232
+ build accepts.** Measured, not guessed: running the analyzer over all 948
233
+ corpus scripts and comparing to each build's own `:parse` verdict found four
234
+ device-accepted scripts that offline analysis hard-failed, identical on
235
+ 7.23.5, 7.24.2 and 7.25beta3. They were two language gaps, and both are now
236
+ fixed — the count is 1, and the one that remains is the device tolerating a
237
+ genuinely extra `}`, where explain is arguably the stricter of the two.
238
+ - A scope slot may drop the `=` before its brace: `:if (…) do={…} else {…}`
239
+ and `do {…}` are the same construct as the documented `else={…}` form.
240
+ `:parse` lowers all of the spellings to byte-identical IL on CHR 7.21.5,
241
+ 7.23.5, 7.24.2 and 7.25beta3, with `elsy {` rejected on all four as the
242
+ control. Only the four `SCOPE_ARG_NAMES` members may drop it, so
243
+ `:local z {1;2}` still reads its brace as an array literal, and a colon
244
+ disqualifies the word — the device parses `do {…}` but refuses `:do {…}` in
245
+ an argument slot, since a colon makes it a directive (#347).
246
+ - A relative menu-scope block composes the path: `/ip { address { print } }`
247
+ is `(evl (evl (evl /ip/address/print)))` on the device, and a block body may
248
+ open with a submenu, one of the thirteen frozen CRUD verbs, a scripting
249
+ directive, or an absolute command. The absolute spelling already worked; the
250
+ nested relative one hard-failed. An unknown menu still does not become a
251
+ pass — the catalog's "a miss says nothing" contract means it abstains rather
252
+ than being claimed either way (#348).
253
+
254
+ As a side effect the offline token partition agrees with the device on 8 more
255
+ bytes of the committed slice, all of them moving out of *offline-silent* into
256
+ *agree*, with no new disagreements.
257
+
258
+ - **`centrs api --raw` no longer silently discards an explicit `--validate`.**
259
+ `--raw` short-circuited the whole settings ladder, so `--raw --validate=true`
260
+ ran no preflight at all — and `CENTRS_VALIDATE`, the CDB comment-kv override
261
+ and config lost to it just as quietly. It is now a precedence layer: the
262
+ explicit `--validate` flag wins, then `--raw`, then the ambient sources, then
263
+ the `true` default. `--raw` alone is unchanged and still behaves identically
264
+ on every machine whatever the environment holds, while `--raw --validate=true`
265
+ runs the gate and reports a rejection in the `--raw` error shape
266
+ (`{code,message}` on stderr, nonzero exit, no `data` key) — the intended way
267
+ to debug `api` when centrs itself is the suspect (#154).
268
+
269
+ - **`execute`/`api` validation no longer probes command-level
270
+ `request=completion`.** On RouterOS 7.12.2 that request shape never answers:
271
+ REST hangs past the client timeout (then surfaces the router's own session
272
+ close mislabeled as `validation/syntax`) and the native API closes the
273
+ connection outright, so every validated call — plain reads included — failed
274
+ on that build while `--no-validate` succeeded instantly. The attribute set
275
+ those two commands check against now comes from command-level
276
+ `request=child` alone, through the shared `inspectArgumentNames` helper;
277
+ `child` returns the complete argument list in single-digit milliseconds on
278
+ the same 7.12.2 controls, so nothing is lost by dropping the union.
279
+ `meta.validation.source` reads `/console/inspect request=child`.
280
+ `retrieve` is unchanged — its argument-level completion probe
281
+ (`print,proplist` / `get,value-name`) is a different request shape and
282
+ answers normally on 7.12.2 (#343).
283
+ - **The reported package version is the real one.** The library's
284
+ `centrsVersion` and the MCP `initialize` handshake both announced a
285
+ hard-coded `0.1.0` regardless of what was published, so a bug report from an
286
+ MCP client named the wrong build. Both now derive from `package.json`
287
+ through `src/core/version.ts`, and a unit test holds them to it (#343).
288
+ - **Offline `explain` no longer invents a verb for a menu path with
289
+ arguments.** `/ip/firewall/address-list name=ytkids timeout=1h` reported
290
+ `resolved`, path `/ip/firewall`, verb `address-list` — and the spaced
291
+ spelling `/ip firewall address-list name=x` promoted `firewall`, one level
292
+ higher still. Both premises were already offline facts and both said no: the
293
+ whole run is a known menu, and the promoted segment is not a console verb.
294
+ The reading is now an `ambiguous` abstention with a located diagnostic saying
295
+ which menu and which segment. Priced on the frozen dev/holdout partition, 10
296
+ of 17,212 statements flip, every one of them a fabrication withdrawn — three
297
+ real RouterOS statements missing their verb and seven lines of pasted
298
+ non-RouterOS prose. Presence in the shipped structure tables still narrows a
299
+ reading and absence still decides nothing, so an unlisted menu is read
300
+ exactly as before (#324).
301
+ - **Offline `explain`'s token partition reaches inside a command
302
+ substitution.** `:put "$[/ip address print as-value]"` came back as one
303
+ opaque `string` run over a command `data.structure.subcommands` already
304
+ reported as resolved, so an editor rendering `data.tokens[]` painted an
305
+ interpolated string flat. The path fill was being handed a span computed as
306
+ "one byte inside the brackets", which is wrong for `$[…]` (the sigil is part
307
+ of the span) and for `[ … ]` (the inner text is trimmed); both spellings now
308
+ carry a published `innerSpan` instead. Byte coverage does not move — what
309
+ moves is which fill owns those bytes (#325).
310
+
311
+ ### Changed
312
+
313
+ - **Every live fixture is re-swept onto the current release channels.** The
314
+ operator sweep was pinned to 7.23.3 stable / 7.24rc4 testing / 7.21.5
315
+ long-term and the highlight slice to 7.23.2 / 7.24rc2 — none of them a
316
+ current channel, and none a build the corpus covers with both oracles, so the
317
+ operator axis and the token partition were never scored on the same firmware.
318
+ Both now run on the three builds the corpus classes `complete`: stable
319
+ **7.24.2**, long-term **7.23.5**, development **7.25beta3**. The slice bases
320
+ on stable, where development serializes as `null` throughout and only the 33
321
+ long-term splits cost bytes. The sweep keeps **7.21.5** as a fourth build,
322
+ explicitly an age anchor rather than a channel claim — it is the only live
323
+ evidence that the operator surface behaves the same on a pre-7.23 branch, and
324
+ dropping it would have retired a fact rather than refreshed one (#342, #336).
325
+ - **The live sweep independently reproduces the corpus's channel split.**
326
+ Highlight agreement is byte-identical between stable and development in every
327
+ bucket and differs on long-term — the same shape the corpus IL showed at
328
+ 87/11 and 33/0. Two oracles, two capture paths, same conclusion.
329
+ - **Array comparison is not fixed on current long-term.** `:put ({2;1} >
330
+ {1;2;3})` evaluates on 7.24.2 and 7.25beta3 and errors on 7.23.5 and 7.21.5.
331
+ 7.23.5 is *newer* than the 7.23.3 this was first measured on and still errors,
332
+ so the change rode the 7.24 line and was never backported —
333
+ `ARRAY_COMPARISON_NOTE` now says so. The device's post-`error` recovery flipped
334
+ with the same realignment: `parser-recovered` is 0 on long-term and 96 bytes
335
+ on both stable and development.
336
+
337
+ - **The bracket-`=` abstention figure is generated and gated instead of
338
+ asserted.** `commands/explain/README.md` and `src/explain/operator-tokens.ts`
339
+ both quoted a `224` / `1,259` / `1,035` split for the cost of leaving `=`
340
+ inside `[ … ]` unclaimed. No script produced it, no `:check` gated it, and no
341
+ universe reproduces it — it was written once in PR #292 and had been prose
342
+ ever since, which is exactly the condition a corpus repin can silently
343
+ invalidate. `bun run explain:bracket-equals` now derives it, with `--check`
344
+ and `--readme --check` gates on the corpus → fixture → README chain like the
345
+ other censuses, and the README block is generated. Measured: **1,079** `=`
346
+ bytes inside `[ … ]` across 263 of 948 scripts, **1,067** left
347
+ `unclassified` — **448** genuine `find`/`where` query comparisons against
348
+ **619** plain `arg=value`. The `arg` fill rescues **none** of them, contrary
349
+ to the obvious guess, because it offers only the `=` its own located argument
350
+ token names. The query reading is the device's: on 7.24.2, 93 of the 109
351
+ scripts that parsed carry at least as many `(= …)` IL nodes, and the
352
+ shortfalls are scripts whose enclosing menu the capture build could not
353
+ resolve (#341).
354
+
355
+ - **The operator census reads only version-comparable RouterOS builds.** The
356
+ corpus is repinned to `lsp-routeros-ts` `7d62355` (schema v4), which carries
357
+ three *complete* builds — long-term 7.23.5, stable 7.24.2 and development
358
+ 7.25beta3, each with both oracles over all 948 scripts — beside six partial
359
+ captures covering 913. The census summed occurrences across every version it
360
+ found, so the added build inflated every head count by ~20% while
361
+ `distinctScripts`, the column carrying the argument, did not move at all. It
362
+ now filters on the new `v_version_coverage.coverage_class = 'complete'` view,
363
+ giving a rectangular universe of 2,841 parse-IL rows — 3 builds × the 947 of
364
+ 948 scripts that produce IL — and states its version set so a gate cannot
365
+ compare two different universes. Presence *is* monotone in coverage, so
366
+ the partial builds keep one job: a new `headFirstSeen` axis dates each head
367
+ against every captured build — which is what still grounds the `any` operator
368
+ at 7.20.8, a build the counting side no longer reads. `source_scripts` is
369
+ byte-identical across the repin, so every text-derived report — the token and
370
+ value censuses, `explain:arg-reach`, the browser-consumer proof — is
371
+ unchanged by construction (#336, #342).
372
+ - **The three complete builds are recorded as disagreeing.** `:parse` IL differs
373
+ on 87 of 948 scripts between long-term 7.23.5 and stable 7.24.2 but on only 11
374
+ between stable and development 7.25beta3; `highlight` differs on 33 and 0. So
375
+ an operator-axis or token-partition figure is a claim about a *channel*, not
376
+ about RouterOS, and `commands/explain/README.md` now says so rather than
377
+ collapsing the builds to one number.
378
+ - **A valid string escape is now its own token class in offline `explain`.**
379
+ `ExplainTokenClass` gains `escaped`, the second and last split #264's rule
380
+ admits: only INVALID escapes surfaced anywhere else, as `bad-string-escape`
381
+ diagnostics, so a consumer holding `tokens[]` had no published surface to join
382
+ against to find the valid ones — and the device names them. The escape grammar
383
+ is not walked a second time to do it: `walkStringEscapes` now returns both
384
+ halves of the one reading `bad-string-escape` already came from, so a token
385
+ and a diagnostic cannot disagree about which bytes are an escape, and it stops
386
+ claiming at the first invalid escape rather than guess past a malformed one.
387
+ **Byte coverage does not move** — classified bytes are identical to the last
388
+ decimal; `escaped`'s 19,960 corpus bytes come out of `string` (−17,672) and
389
+ `value` (−2,288) exactly, and the token count rises only because a run
390
+ carrying an escape becomes three. Against the committed device slice the class
391
+ agrees on 418 of 418 bytes and the `unprojected` residue falls 1,051 → 633.
392
+ Consumers reading `data.tokens[]` for `string` or `value` must now also read
393
+ `escaped` to see bytes they used to get inside one run. A `\`-newline line
394
+ continuation in **code** is deliberately not claimed — the device calls it
395
+ `escaped` too, but its run swallows the next line's indentation, and what a
396
+ continuation owns is #225's question (#264).
397
+
398
+ - **Offline `explain`'s token vocabulary is no longer provisional, and the
399
+ argument `=` has its own class.** `ExplainTokenClass` gains `arg-sep`: an
400
+ argument's name bytes stay `arg`, and the single `=` binding it to its value
401
+ is now named separately. #264 B5 settled the whole vocabulary on a stated
402
+ rule — a merged class is split only where a consumer needs the distinction
403
+ AND cannot recover it by joining `tokens[]` to another published surface on
404
+ byte offsets — so the per-spelling `operator`, per-shape `value`,
405
+ scope-versus-array `brace` and string-delimiter merges stay merged, each with
406
+ the surface that already carries the distinction named. The `=` was the one
407
+ that failed the test: 30.8% of the `=` runs the fill paints (5,088 of 16,526
408
+ on the pinned corpus) belong to a statement whose strict `arguments` reading
409
+ refused, led by the `do=` of every `:if`/`:foreach`/`:while`, so there is no
410
+ published `valueSpan` to derive the boundary from. **Byte coverage does not
411
+ move** — classified bytes are identical to the last decimal; this is a retag,
412
+ and the token count rises only because one run became two. Against the
413
+ committed device slice, projection disagreements fall 360 → 2 as centrs stops
414
+ calling a separator a name. Consumers reading `data.tokens[]` for `arg` must
415
+ now also read `arg-sep` to see the bytes they used to get in one run
416
+ (#264 B5, #316).
417
+
418
+ - **Offline `explain` answers statement-ownership from an index, not a scan.**
419
+ The symbol/value surface asked "which statement owns this span" once per
420
+ symbol, value and scope brace by scanning every statement split, so cost grew
421
+ quadratically with symbol-dense input: 253 KiB of declarations took 4.3 s and
422
+ was still steepening. Output is unchanged — all 1,896 `explainCommand` hashes
423
+ over the pinned corpus match byte for byte under both facet settings — and the
424
+ growth-ratio guard now covers a symbol-dense input shape alongside the
425
+ separator-free one (#317).
10
426
 
11
427
  ## 0.1.4 — 2026-08-31
12
428
 
package/GLOSSARY.txt CHANGED
@@ -149,10 +149,12 @@ npk # MikroTik package format
149
149
  nvme-over-tcp # supported by RouterOS **with** rose-storage.npk extra-package
150
150
  OID
151
151
  PII # Personally Identifiable Information
152
+ PON # passive optical network; RouterOS /interface/ethernet/pon menu
152
153
  POV
153
154
  PRGA
154
155
  proplist
155
156
  pywinauto
157
+ QKD # quantum key distribution; RouterOS IPsec/keymat-provider vocabulary
156
158
  QML
157
159
  qll # CodeQL library file extension (.qll), e.g. CleartextLoggingCustomizations.qll
158
160
  qmljsdebugger
@@ -384,6 +386,16 @@ evl
384
386
  # as "(evl /putmessage=x)", ":global n v" as "(evl /globalname=$n;value=v)" (GH#230).
385
387
  putmessage
386
388
  globalname
389
+ # localname/setnote/runnumber: more of the same head rendering, quoted verbatim as
390
+ # evidence for the missing-separator rule (GH#311). ":put [:parse …]" joins the IL
391
+ # array with NO separator, so the path and its first argument run together —
392
+ # ":local a v" is "(evl /localname=$a;value=v)", "/system/note set note=hello" is
393
+ # "(evl /system/note/setnote=hello)", "/system/script/run /ip/route" is
394
+ # "(evl /system/script/runnumber=/ip/route)". Artefacts of the rendering, not
395
+ # RouterOS argument names; kept unsplit because the quote must match the bytes.
396
+ localname
397
+ setnote
398
+ runnumber
387
399
  # unconfigured: a freshly-flashed RouterOS device with no IP — the mac-telnet use case.
388
400
  unconfigured
389
401
  unwired
@@ -676,6 +688,7 @@ mmips # RouterOS architecture (multi-core MIPS), named in `conditions:` gates
676
688
  CRS # MikroTik Cloud Router Switch — carries the switch-chip menus no CHR has
677
689
  CCR # MikroTik Cloud Core Router
678
690
  superlinear # growth faster than linear, used for resolveDocument wall-clock on separator-free input (#248)
691
+ superlinearity # the property a growth-ratio guard bounds; `superlinear+` is NOT usable here, cspell's compound form forbids the bare word (#313)
679
692
  stmts # `explainCommand` corpus measurement: statement count (shorthand for `statements`)
680
693
  argCandidates # `explainCommand` corpus measurement: arg-bearing candidates
681
694
  argReadable # `explainCommand` corpus measurement: readable statements
@@ -691,7 +704,7 @@ llms # `llms.txt` — MikroTik's published page inventory; the only one listing
691
704
  musicswitch # syscap gate: the Marvell-era switch-chip menu shape — one half of the `/interface/ethernet/switch` pair published under two gates
692
705
  rbswitch # syscap gate: the RouterBOARD switch-chip menu shape — the other half of that pair
693
706
  prestera # Marvell Prestera switch chip — named in the `crs_prestera` syscap gate on `/interface/ethernet/switch/qos`
694
- xfrm # `/interface/xfrm` — the IPsec transform interface, and the one published path with no gate anywhere in its ancestry to explain its absence from a CHR tree
707
+ xfrm # `/interface/xfrm` — the IPsec transform interface, device-confirmed by the pinned 7.25beta3 trees and published by the CLI Reference
695
708
  btrfs # the Linux B-tree filesystem, a `/disk/btrfs/...` menu on RouterOS 7.20+
696
709
 
697
710
  # #255 operator-axis vocabulary: the device-grounded operator surface.
@@ -718,3 +731,29 @@ occ # short for occurrence — local variable in symbol-values flow analysis
718
731
  vid # short for valueId — result-local value occurrence identifier (v0, v1)
719
732
  idx # short for index — loop/event index in flow merging
720
733
  mis # prefix in mis-linking — hyphenated in comments for symbol-values guard
734
+
735
+ # #313 offline performance vocabulary
736
+ JIT # just-in-time compiler — warmed before either half of the growth-ratio timing so the two sizes are compared in the same compilation state
737
+
738
+ # #317 statement-ownership index vocabulary
739
+ laminarity # the property of a set of spans in which every pair is nested or disjoint, never crossing — what makes the shortest containing span reachable by a parent walk
740
+
741
+ # #264 B4 / #263 device-agreement vocabulary
742
+ unprojected # of a byte whose offline/device class PAIR the declared projection does not cover, so it can neither agree nor disagree: an offline class that deliberately maps to no RouterOS `highlight` class, or a device class this build has never seen (#264 B4)
743
+
744
+ # #324 / #325 offline-reading vocabulary. Both words are corpus data: they are
745
+ # quoted from the two slice scripts the device-agreement report names as the
746
+ # exemplars of the cells these fixes empty, so their spellings must match the
747
+ # bytes the device saw.
748
+ ytkids # an address-list name in the corpus line `/ip/firewall/address-list name=ytkids timeout=1h` — the #324 exemplar
749
+ apikey # a local variable in the corpus line `apikey-len=$([:len $apikey])` — the `$(…)` interpolation #325 deliberately leaves unreached
750
+
751
+ # #312 offline-entry boundary vocabulary
752
+ dgram # Node's `node:dgram` UDP socket module — reached by `mac-telnet`, and one of the builtins that made the old `explain` import graph unusable outside Bun; named in the boundary gate's allowlist rationale
753
+
754
+ # #347 / #348 offline false-reject controls. Both words are deliberate
755
+ # NON-words: each is the rejected half of a CHR-confirmed accepted/rejected
756
+ # pair, so its spelling is load-bearing and must not be "corrected".
757
+ elsy # the misspelled `else` in `:if (1=1) do={…} elsy {…}` — rejected as `expected end of command` on CHR 7.21.5/7.23.5/7.24.2/7.25beta3, which is what proves the bare-brace `else {` acceptance is a real device fact and not a probe artifact (#347)
758
+ prnt # a misspelt `print` verb — `/ip/address/prnt` is the offline analyzer's ABSTENTION fixture (`verdict: warn`, not `fail`), which is what proves the two-stage validation gate passes an abstention through to the device rather than refusing it (#354)
759
+ zzznotamenu # a menu name no RouterOS build has — `/ip { zzznotamenu { print } }` is a `syntax error` on device, so menu-scope resolution is schema-dependent and a catalog MISS must abstain rather than accept (#348)
package/README.md CHANGED
@@ -11,9 +11,13 @@ installing centrs or connecting to a router:
11
11
  bunx @tikoci/centrs@next explain '/ip/address print' --json
12
12
  ```
13
13
 
14
- Use `bunx @tikoci/centrs@next --help` to inspect the full preview CLI. The
15
- explicit `@next` matters: `0.1.x` preview releases do not move npm's `latest`
16
- tag.
14
+ Use `bunx @tikoci/centrs@next --help` to inspect the full preview CLI. Since
15
+ 0.1.6 the dist-tag follows standard SemVer semantics rather than the old
16
+ even/odd-minor rule: a plain version (`0.1.6`) publishes to `latest` and carries
17
+ `next` forward with it, while a pre-release suffix (`0.1.7-next.0`) publishes to
18
+ `next` only and never moves `latest`. So `@next` is the leading edge and can
19
+ equal `@latest` between cuts — `npm view @tikoci/centrs dist-tags` is the
20
+ authority.
17
21
 
18
22
  The full flag reference for every command is generated from the CLI metadata
19
23
  into [`docs/CLI.md`](docs/CLI.md); per-command behavior lives in
@@ -58,7 +62,7 @@ product; without them this would just be a worse `curl`.
58
62
  | `execute` | Run RouterOS CLI-shaped read/write commands (add/set/remove) over native API/REST/L2 surfaces. |
59
63
  | `api` | Structured RouterOS API passthrough (gh-api style): one command per operation, structured in/out, can write, over REST or native API. Open-ended follow is `api … --stream` (native API; NDJSON). |
60
64
  | `terminal` | Open an interactive console, primarily SSH or MAC-Telnet. |
61
- | `check` | Probe reachability and management protocol availability. |
65
+ | `check` | Probe reachability and management protocol availability. **Designed, not implemented** — see `docs/MATRIX.md`. |
62
66
  | `explain` | Analyze a RouterOS command before running it: canonical form, syntax diagnostics, and what is valid at a path. Offline analysis runs today; the live-device probes are designed. |
63
67
  | `devices` | View and maintain the CDB-backed device registry (the only writer). |
64
68
  | `discover` | Discover MNDP neighbors and optionally save them into the CDB. |
@@ -178,7 +182,9 @@ and the comment grammar — lives in `commands/devices/README.md` and
178
182
 
179
183
  The load-bearing contract — envelope shape, error model, protocol selection,
180
184
  settings precedence, and the "done" definition — lives in
181
- `docs/CONSTITUTION.md`. The command×protocol status grid is `docs/MATRIX.md`.
185
+ `docs/CONSTITUTION.md`. Capability status and the selected work track,
186
+ including the command×protocol grid and offline analysis, live in
187
+ `docs/MATRIX.md`.
182
188
 
183
189
  ## Repository layout
184
190
 
@@ -187,13 +193,15 @@ code and authority docs live.
187
193
 
188
194
  - **`docs/CONSTITUTION.md`** — load-bearing rules (validation, envelope, error
189
195
  model, settings precedence, identity/CDB, protocol selection, done).
190
- - **`docs/MATRIX.md`** — command×protocol grid; the only status surface.
196
+ - **`docs/MATRIX.md`** — capability status and the selected work track (the
197
+ command×protocol grid plus offline analysis and frontend surfaces); the
198
+ only status surface.
191
199
  - **`commands/<name>/`** — per-command README and executable examples.
192
200
  - **`src/`** — Bun/TypeScript source.
193
201
  - **`test/`** — unit and CHR-backed integration tests.
194
202
 
195
- There is no `docs/specs/`, no `work/`, no roadmap doc. The matrix is the
196
- roadmap.
203
+ There is no `docs/specs/`, no `work/`, no roadmap doc. The matrix and the
204
+ GitHub issues it links are the roadmap.
197
205
 
198
206
  ## Development
199
207
 
@@ -3,9 +3,11 @@
3
3
  Each `commands/<name>/` holds the **executable spec** for one command:
4
4
 
5
5
  - `README.md` — intent, flags, behavior. The "designed" tier.
6
- - `examples.md` — numbered, runnable examples. Each example is one assertion in
7
- the matching `test/integration/<name>.test.ts`; example N ↔ assertion N. This
8
- is what `CHR-passed` is measured against.
6
+ - `examples.md` — numbered, runnable examples mapped to named tests:
7
+ example N ↔ named test N. Live examples require CHR integration; offline
8
+ examples use the unit/fixture tests named by the command — a named test may
9
+ carry several assertions. Verification follows `docs/CONSTITUTION.md` →
10
+ Done definition.
9
11
 
10
12
  **Implemented** flags are generated from `CliCommandMetadata` into
11
13
  `docs/CLI.md` (`bun run docs:cli`; drift-gated in CI) — a command README keeps
@@ -19,7 +21,8 @@ keys, ambiguity, `__default__`, comment-kv allowlist, write strategy) lives in
19
21
  `commands/devices/README.md` — the registry command owns it; the constitution
20
22
  keeps only the identity/CDB principle. A command file covers only what is
21
23
  specific to that command. `docs/MATRIX.md` is the only status surface; a
22
- README's "Status" line must agree with it.
24
+ README's "Status" line must agree with it. GitHub issues track the tasks and
25
+ dependencies within the selected capability; they do not replace its status.
23
26
 
24
27
  ## Verb vocabulary
25
28
 
@@ -183,11 +183,23 @@ green on CHR 7.23.1.
183
183
  not `print`/`get`), so it is a `POST` and **write-classed** → needs `--yes`.
184
184
  - **CONFIRMED — the inspect gate over both transports.** Path existence
185
185
  (`request=child` empty ⇒ `validation/unknown-path`) and add/set attribute
186
- validity (`request=child`+`completion` ⇒ `validation/unknown-attribute`) fire
186
+ validity (`request=child` ⇒ `validation/unknown-attribute`) fire
187
187
  identically over rest-api and native-api, **before** any write — no `:put
188
188
  [:parse]` involved (api input is a path, not a CLI string). A `/execute` script
189
189
  is a CLI string ⇒ `meta.validation.semantic = "not-applicable"`.
190
190
 
191
+ ## CONFIRMED ON CHR (RouterOS 7.12.2 compatibility — GH#343, 2026-09-14)
192
+
193
+ - **Command-level `request=completion` is unsafe on 7.12.2.** For
194
+ `path=system,identity,print`, REST did not answer before the 10 s client
195
+ timeout and native API closed the connection immediately. Adding
196
+ `.proplist=completion` did not change either result. The preceding `:parse`
197
+ and `request=child` controls returned in 1–8 ms on both transports, and
198
+ `child` contained the complete command argument list. Execute/API attribute
199
+ validation therefore uses command-level `request=child` only. This does not
200
+ prohibit argument-level completion probes such as `print,proplist`, which are
201
+ a separate shape and returned immediately on the same 7.12.2 controls.
202
+
191
203
  ## CONFIRMED ON CHR (Phase 4 `--stream` follow — CHR 7.23.1, 2026-06-30)
192
204
 
193
205
  Validated by `test/integration/api-listen.test.ts` (L1–L4) on CHR 7.23.1.