@tikoci/centrs 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- package/src/transfer.ts +133 -40
|
@@ -0,0 +1,1716 @@
|
|
|
1
|
+
# explain
|
|
2
|
+
|
|
3
|
+
Analyze a RouterOS command **before** anything runs it: canonical form and
|
|
4
|
+
structure, LSP-like token spans and diagnostics, how the command would actually
|
|
5
|
+
run (`api`-able vs `execute`-required, including a rendered REST/`curl` form),
|
|
6
|
+
and — against a live device — what commands, arguments, and values are valid at
|
|
7
|
+
a path, with completion-style candidates for building the right command.
|
|
8
|
+
`explain` is the "E" of the explain → validate → run split (constitution:
|
|
9
|
+
validation): the cheap, safe knowledge tier in front of the runners
|
|
10
|
+
(`execute` / `api` / `retrieve`).
|
|
11
|
+
|
|
12
|
+
Status: `designed` over `rest-api` and `native-api` — the transports the live
|
|
13
|
+
inspection probes ride; every other cell is `—`. The offline mode is
|
|
14
|
+
protocol-connection-less, and **`centrs explain '<input>'` runs today** (issues #202b
|
|
15
|
+
and #202c): the row stays `designed` because the grid tracks protocol cells and
|
|
16
|
+
offline occupies none. See `docs/MATRIX.md` for the row. A first design round
|
|
17
|
+
(2026-07-19, recorded in #90) settled the surface shape and the offline model;
|
|
18
|
+
the **phase-0 canonicalization grounding lab (#185) is complete and this spec
|
|
19
|
+
is now ratified** — every ratification-gating question was answered with cited
|
|
20
|
+
evidence (see [Phase-0 ratification](#phase-0-ratification-185)). The findings
|
|
21
|
+
folded in below; a few of them **amend** the surface (tristate `containsWrite`,
|
|
22
|
+
an `ambiguous` canonicalizer verdict, a defect-region diagnostics contract, a
|
|
23
|
+
byte-count-preserving coordinate rule, the native-api `:parse` readout). Flag
|
|
24
|
+
names and sizing thresholds stay provisional — those are implementation opens,
|
|
25
|
+
not ratification blockers.
|
|
26
|
+
Load-bearing rules — envelope, errors, settings precedence, identity,
|
|
27
|
+
validation, protocol selection — live in
|
|
28
|
+
[`docs/CONSTITUTION.md`](../../docs/CONSTITUTION.md).
|
|
29
|
+
|
|
30
|
+
## Why (grounding)
|
|
31
|
+
|
|
32
|
+
- `~/GitHub/bench-routeros-tools` (`REPORT.md`) grounds the split empirically:
|
|
33
|
+
agents need a cheap, safe knowledge/validation tier gating a thin runner, and
|
|
34
|
+
a handful of verbs over canonical `{path, verb, args}` beats per-command
|
|
35
|
+
tools. It also measured the **inspect-vs-runtime gap** (`/console/inspect`
|
|
36
|
+
accepted `blackhole=yes` where the runtime wanted the bare flag): schema
|
|
37
|
+
acceptance is necessary, never sufficient.
|
|
38
|
+
- The wire-format research is done and public:
|
|
39
|
+
[`routeros-syntax-inspection`](https://github.com/tikoci/routeros-skills/tree/main/routeros-syntax-inspection)
|
|
40
|
+
(probe selection, highlight/parseIL/completion/child/syntax semantics,
|
|
41
|
+
hazards, provenance rules) and
|
|
42
|
+
[`routeros-command-tree`](https://github.com/tikoci/routeros-skills/tree/main/routeros-command-tree)
|
|
43
|
+
(tree traversal, schema generation), both grounded by full-corpus captures in
|
|
44
|
+
[tikoci/lsp-routeros-ts `docs/`](https://github.com/tikoci/lsp-routeros-ts/tree/main/docs)
|
|
45
|
+
(913 scripts × 7.9.2/7.23.2/7.24rc2). This spec cites those references
|
|
46
|
+
rather than restating them; they are the canonical wire truth.
|
|
47
|
+
- Decisions locked in #90: `validate` stays **boolean** (the dry-run gate
|
|
48
|
+
inside `execute`/`api` and `centrs_validate`); `explain` is the surface that
|
|
49
|
+
carries richness; centrs is the **canonicalizer/explain owner** and
|
|
50
|
+
`lsp-routeros-ts` (and tikbook) eventually consume the centrs library
|
|
51
|
+
instead of owning their own analysis core.
|
|
52
|
+
- Boundary from rosetta B-0013 (#175), sharpened by the 2026-07-19 round:
|
|
53
|
+
rosetta owns *static, docs-grounded* explanation — prose, URLs, version
|
|
54
|
+
history, "what version supports what" — and never grows validate/run; centrs
|
|
55
|
+
owns canonicalization and anything device-aware. **centrs does not call
|
|
56
|
+
rosetta, and does not consume restraml/rosetta schema data, for now**: the
|
|
57
|
+
projects stay decoupled until centrs's final shape is known. At most,
|
|
58
|
+
`explain` output may *steer* toward rosetta in a tip (e.g. "for docs/version
|
|
59
|
+
history, ask rosetta"). Deeper integration (for example rosetta publishing
|
|
60
|
+
stable GitHub-Pages links centrs help could cite) is future rosetta-side
|
|
61
|
+
work, tracked there, not here.
|
|
62
|
+
|
|
63
|
+
## Where `explain` sits
|
|
64
|
+
|
|
65
|
+
- **Never executes, never mutates.** All live probes (`/console/inspect`
|
|
66
|
+
highlight/completion/syntax/child and `:put [:parse …]`) are read-only
|
|
67
|
+
inspection. Execution-based probes (required-argument discovery via
|
|
68
|
+
deliberate errors) are out of scope — they belong to research tooling, not
|
|
69
|
+
this verb.
|
|
70
|
+
- **`validate` is unchanged.** `--validate` remains the boolean gate on the
|
|
71
|
+
runners; `explain` is what you call when you want to know *why*, or to build
|
|
72
|
+
the command in the first place. Internally they share machinery; externally
|
|
73
|
+
they stay distinct surfaces (constitution: validation).
|
|
74
|
+
- **The structured-mode gate stays authoritative.** `explain` exposes the
|
|
75
|
+
canonicalizer's verdict (`mode`, `writeShaped`) but must not widen what
|
|
76
|
+
counts as `structured` (constitution: canonicalizer ownership).
|
|
77
|
+
- **Passing `explain` is not a promise the router will accept the command.**
|
|
78
|
+
Every result carries this asymmetry explicitly (see
|
|
79
|
+
[Result shape](#result-shape-sketch)); only `execute`/`api` against a real
|
|
80
|
+
target proves runtime acceptance.
|
|
81
|
+
- **Library-first.** The deliverable is exported functions on `@tikoci/centrs`
|
|
82
|
+
that need no CLI and (offline) no CDB; the CLI verb and the MCP tool are
|
|
83
|
+
thin adapters. centrs ships no LSP server, but the library API must be able
|
|
84
|
+
to **support one**: hover, diagnostics, completion, and semantic-token needs
|
|
85
|
+
of `lsp-routeros-ts`/tikbook are first-class consumers of the export shape.
|
|
86
|
+
|
|
87
|
+
## Canonicalization is the core
|
|
88
|
+
|
|
89
|
+
The engine is a **grown `canonicalizeExecuteCommand`** (decision: grow the
|
|
90
|
+
in-repo canonicalizer; rosetta's pure `canonicalize.ts` is reference material
|
|
91
|
+
to borrow from as needed, with **no sync obligation** — centrs solves a
|
|
92
|
+
different problem and is expected to diverge). Target capabilities, beyond
|
|
93
|
+
today's path/verb/args split and script-vs-structured gate:
|
|
94
|
+
|
|
95
|
+
- **Structure, not just segmentation.** Parse RouterOS input into an AST-like
|
|
96
|
+
structure so complex expressions, blocks, and scopes can be identified —
|
|
97
|
+
including multi-statement `.rsc` scripts.
|
|
98
|
+
- **Sub-command resolution with re-constituted paths.** In
|
|
99
|
+
`/ip/address remove [find comment=defconf]`, the inner `find` carries the
|
|
100
|
+
enclosing path (`ip,address`); the canonical structure exposes that inner
|
|
101
|
+
command with its resolved path rather than treating `[…]` as an opaque blob.
|
|
102
|
+
- **Symbol scopes follow RouterOS scope identity, not brace depth alone.** Names
|
|
103
|
+
are case-sensitive. Control-flow `do={…}` bodies share the enclosing scope,
|
|
104
|
+
but a named-function definition (`:local F do={…}` / `:global F do={…}`)
|
|
105
|
+
starts a closure: outer locals become parameters there, and a global must be
|
|
106
|
+
re-imported inside the body. If the parser cannot establish which body owns a
|
|
107
|
+
binding, it abstains instead of assigning a confident symbol class (Q13).
|
|
108
|
+
- **Transport classification — fail-closed.** For each statement, say how it
|
|
109
|
+
would actually run: `api-candidate` (representable as a structured `api`
|
|
110
|
+
operation, with the REST method/path/body), `execute` (script mode,
|
|
111
|
+
expressions, sub-commands), or `unknown` — each with a stated basis. The
|
|
112
|
+
canonicalizer alone cannot *prove* a REST mapping (the syntax-inspection map
|
|
113
|
+
keeps CLI→REST conversion separate from source parsing), so a ready-to-run
|
|
114
|
+
`curl` renders **only** where an explicit, tested mapping rule covers the
|
|
115
|
+
command family; anything else classifies `unknown` rather than promoting a
|
|
116
|
+
heuristic into executable output. The `centrs execute`/`centrs api`
|
|
117
|
+
invocation renders for either classified case. In particular, selector-based
|
|
118
|
+
writes route to `execute`, while selector-less `set` is `unknown` offline:
|
|
119
|
+
without a schema, `/ip/dns set …` (a singleton) and
|
|
120
|
+
`/ip/firewall/filter set …` (an id-bearing table) have the same shape. Live
|
|
121
|
+
evidence may lift the singleton case; offline never guesses it.
|
|
122
|
+
- **Mutation analysis is separate from the gate.** The execute gate's verdict
|
|
123
|
+
(`mode`, `writeShaped`) is reproduced **unchanged** — a bracketed
|
|
124
|
+
sub-command selector stays `mode: "script"` / `writeShaped: false`, exactly
|
|
125
|
+
as `canonicalizeExecuteCommand` and its anchor tests
|
|
126
|
+
(`test/unit/execute-canonicalize-contract.test.ts`) pin it. Richer
|
|
127
|
+
explain-only inference (an inner `remove` detected in structure) lands in a
|
|
128
|
+
**distinct** field, `structure.containsWrite` (basis `heuristic`) — never by
|
|
129
|
+
widening *or* reinterpreting `writeShaped`, so agents cannot mistake an
|
|
130
|
+
explain inference for a guard `execute` actually applies. **`containsWrite`
|
|
131
|
+
is three-valued `true | false | unknown`, not a boolean** (Q16, ratified): a
|
|
132
|
+
curated write-verb table covers ~93% of command nodes and is near-immune to
|
|
133
|
+
version drift (~0.07% of nodes per minor version), but ~6–7% of nodes carry a
|
|
134
|
+
write-shaped verb *outside* any small table (`reboot`, `format`, `delete`,
|
|
135
|
+
`reset-configuration`, …), so a two-valued field would have to call
|
|
136
|
+
`/disk format-drive` a non-write. The field therefore **fails closed to
|
|
137
|
+
`unknown`** for any verb it cannot confirm, with a **hard requirement of zero
|
|
138
|
+
false negatives on statically obvious writes** and `unknown` reported
|
|
139
|
+
separately from `false`. Dynamic execution (`:parse`, stored scripts,
|
|
140
|
+
variable/function invocation) is `unknown` by construction.
|
|
141
|
+
- **Heuristics owned and labeled.** Offline conclusions are canonicalizer
|
|
142
|
+
facts (provenance `canonicalizer`, basis `heuristic`/`derived`) — never
|
|
143
|
+
presented as device or schema truth.
|
|
144
|
+
- **Fail-closed with an `ambiguous` verdict.** Schema-free analysis has a hard
|
|
145
|
+
floor: a statement that is *nothing but a path* (`/ip/route` is a directory,
|
|
146
|
+
`/system/reboot` is a command, and the text is identical) cannot be resolved
|
|
147
|
+
by a text rule alone. The canonicalizer must abstain wherever nothing but the
|
|
148
|
+
text is available to decide — a distinct **`ambiguous`** verdict, separate
|
|
149
|
+
from "unresolved" — rather than guess. Since #210 the baked container floor
|
|
150
|
+
answers this one pair where it can confirm the path, and since #228 the
|
|
151
|
+
published command axis answers it from the other side, so the requirement
|
|
152
|
+
binds on everything *neither* table carries — still most of it; the two
|
|
153
|
+
paragraphs below that scope it are normative, not a footnote. Four
|
|
154
|
+
ratification questions reached this floor independently (Q6 verb/menu
|
|
155
|
+
boundary, Q3 absolute inner paths, Q13 `variable-undefined`, Q14 bare-word
|
|
156
|
+
recovery), so it is a **structural property of schema-free canonicalization**,
|
|
157
|
+
not a per-case wart; the fail-closed rule is promoted to the constitution's
|
|
158
|
+
canonicalizer-ownership section. The measured cost is small: the shipped
|
|
159
|
+
no-schema verb/menu rule scores **99.9%
|
|
160
|
+
precision on decided at ~3% abstention**, and a static snapshot would recover
|
|
161
|
+
only ~2.3pp of that abstention while churning ~9% of tree nodes per minor
|
|
162
|
+
version — which is why **decision 3 (no offline schema snapshot) ratifies
|
|
163
|
+
unchanged**.
|
|
164
|
+
|
|
165
|
+
The *container* half of that pair is since decidable. `src/explain/menus.ts`
|
|
166
|
+
(#207) bakes only the menu-vs-command node type — not a schema — and it is
|
|
167
|
+
version-less because the pinned trees show no `dir`↔`cmd` flip across
|
|
168
|
+
7.10.2 → 7.24rc2 or across architectures. So `/ip/route` now resolves as
|
|
169
|
+
`navigation` (#210). The floor is unmoved: the table is deliberately
|
|
170
|
+
incomplete, absence from it decides nothing, and every rule above it still
|
|
171
|
+
refuses rather than guesses.
|
|
172
|
+
|
|
173
|
+
The *command* half is what #228 addressed, by adding a second first-order
|
|
174
|
+
source rather than a schema. MikroTik's CLI Reference is generated from the
|
|
175
|
+
definition structs, and measured against the same four pinned trees it has
|
|
176
|
+
**zero navigation-vs-command contradictions** across 968 exactly-matching
|
|
177
|
+
paths, three RouterOS versions and two architectures; all but one of the 102
|
|
178
|
+
published paths absent from every tree carry a `package`/`conditions`/`syscap`
|
|
179
|
+
gate — their own or an ancestor's — that predicts the absence.
|
|
180
|
+
`src/explain/catalog.ts` unions the two with per-entry provenance, which is
|
|
181
|
+
what keeps `menus.ts`'s device-confirmed floor intact rather than diluting it.
|
|
182
|
+
The publication is still not device truth, but as of #285 it is at least
|
|
183
|
+
spelled like the CLI: MikroTik reshaped it from module pages into per-command
|
|
184
|
+
leaf pages whose slug *is* the CLI path, so the definition-module spellings
|
|
185
|
+
(`caps-man/acl/access-list`) are gone and the alias allowlist that recovered
|
|
186
|
+
them is empty — kept, and kept asserted, to refuse the next one loudly. So
|
|
187
|
+
`/system/reboot` now resolves as a `command` at `/system`, and the floor is
|
|
188
|
+
again unmoved — the residual is a path in *neither* table, such as
|
|
189
|
+
`/disk/format-drive`, which 7.23.2 spells `/disk format` and MikroTik does not
|
|
190
|
+
publish at all.
|
|
191
|
+
|
|
192
|
+
Discovery is the union of MikroTik's two published inventories, not the
|
|
193
|
+
sitemap alone. A *branching* menu is served as a trailing-slash category URL
|
|
194
|
+
with no `.md` of its own, while that menu's own entry is published at
|
|
195
|
+
`<dir>/<basename>.md` and listed only in `llms.txt` — so sitemap-only
|
|
196
|
+
discovery dropped 256 of 1,070 pages, silently and field-heavily. Generation
|
|
197
|
+
asserts that every category dir still contributes its leaf, so the next
|
|
198
|
+
inventory reshape fails loudly instead of shrinking the table (#285,
|
|
199
|
+
tikoci/rosetta#137).
|
|
200
|
+
|
|
201
|
+
There are real unknowns in how far offline parsing can go (expression
|
|
202
|
+
grammar, scope fidelity vs `:parse`, `[]`-nesting corner cases). Grounding
|
|
203
|
+
those unknowns was **phase 0** (#185); its findings are folded in throughout
|
|
204
|
+
this spec and summarized in [Phase-0 ratification](#phase-0-ratification-185).
|
|
205
|
+
|
|
206
|
+
## Three questions, one pipeline
|
|
207
|
+
|
|
208
|
+
An agent (the primary caller) asks `explain` three kinds of question before
|
|
209
|
+
touching `execute` / `api` / `retrieve`:
|
|
210
|
+
|
|
211
|
+
1. **Check** — *"is this command right, and how does it run?"*
|
|
212
|
+
`/ip/route add dst-address=10.9.0.0/16 blackhole=yes` → canonical
|
|
213
|
+
`{path, verb, args, mode, writeShaped}`, spans, diagnostics, transport
|
|
214
|
+
classification (+ `curl`/runner rendering on request).
|
|
215
|
+
2. **Assist** — *"what can come next?"*
|
|
216
|
+
`/ip/firewall/filter add ch` + cursor → completion candidates with
|
|
217
|
+
provenance, so the caller composes a correct command instead of guessing
|
|
218
|
+
and retrying.
|
|
219
|
+
3. **Describe** — *"what exists at this path?"* (live)
|
|
220
|
+
A broad query returns broader, **bounded** results that invite drill-down:
|
|
221
|
+
- `/ip/address` → the verbs available there (with arg counts or a hint to
|
|
222
|
+
name a verb for its args);
|
|
223
|
+
- `/ip/address/set` → the settable arguments;
|
|
224
|
+
- `/ip/address/print` → special-cased to the `.proplist` value set, i.e.
|
|
225
|
+
what the output *can* contain. **The probe recipe is grounded** (Q10,
|
|
226
|
+
version-flat 7.23.2/7.24rc2): `completion input="<menu>/print proplist="`
|
|
227
|
+
with rows filtered `show === "true"` yields the property-name set (a
|
|
228
|
+
superset of the keys a `GET` returns). Two device traps are pinned:
|
|
229
|
+
(1) the console word is the dot-free `proplist=` — the REST body key
|
|
230
|
+
`.proplist` returns generic value metadata, not the set; and (2) the rows
|
|
231
|
+
are selected by `show=true`, not by `style` (whose value here is `none`,
|
|
232
|
+
not `arg`). The `child`/`syntax` fallback is needed only for singleton
|
|
233
|
+
settings menus that expose no `.proplist`.
|
|
234
|
+
|
|
235
|
+
Positions matter more than tokens: the same word can be a verb or an argument
|
|
236
|
+
(`/ip/address comment …` is the `comment` *verb*; `/ip/address/add comment=…`
|
|
237
|
+
is the `comment` *argument* of `add`), and a probe at a value position (input
|
|
238
|
+
ending in `=`) asks for value candidates or type info rather than names. The
|
|
239
|
+
canonicalizer resolves the role offline where it can and labels the basis;
|
|
240
|
+
live completion is the authority.
|
|
241
|
+
|
|
242
|
+
Offline, `explain` answers with canonicalizer facts only (structure, gate,
|
|
243
|
+
transport shape). Enumerating what a device accepts — verbs, args, enums,
|
|
244
|
+
`.proplist` sets — is **live** evidence; a no-router run that would benefit says so
|
|
245
|
+
with a tip ("richer data is available if you name a router").
|
|
246
|
+
|
|
247
|
+
## Evidence modes
|
|
248
|
+
|
|
249
|
+
| Mode | Trigger | Sources | Provenance label |
|
|
250
|
+
| ---- | ------- | ------- | ---------------- |
|
|
251
|
+
| **Offline** (default) | no `<router>` given | the centrs canonicalizer only: segmentation, AST-like structure, path/verb/args split, script-vs-structured, write-shape, transport classification, `curl`/runner rendering | `canonicalizer` (basis `heuristic`/`derived`) |
|
|
252
|
+
| **Live** | `<router>` given | `request=highlight` (byte-classified spans, first hard error), `:parse` (structure, error line/column), `request=completion` / `syntax` / `child` (candidates, structured help, children/args/`.proplist` sets) | `live-inspect`, stamped with the device's version |
|
|
253
|
+
|
|
254
|
+
There is **no static schema snapshot** (decision, 2026-07-19, amended 2026-08-07
|
|
255
|
+
by #228): offline mode makes no call at analysis time — not to a router, not to
|
|
256
|
+
rosetta, not to the network — and ships no argument names, types, enums,
|
|
257
|
+
per-menu verb lists or `.proplist`. Version and schema questions are steered to
|
|
258
|
+
a live router (or to rosetta, by tip). Offline diagnostics are structural
|
|
259
|
+
("this does not parse", "this is script-shaped"), never claims about what a
|
|
260
|
+
command accepts.
|
|
261
|
+
|
|
262
|
+
Offline mode does ship two generated **structure** tables, baked at build time
|
|
263
|
+
from pinned sources and read as ordinary closed lists. They may say whether a
|
|
264
|
+
path is navigation or a command; they never describe what a command accepts.
|
|
265
|
+
Absence from either abstains and never rejects.
|
|
266
|
+
|
|
267
|
+
- `src/explain/menus.ts` (#207) — container paths from pinned restraml
|
|
268
|
+
`/console/inspect` trees. **Read today** to confirm navigation, on its own by
|
|
269
|
+
`write.ts` and `symbols.ts` and unioned by `is-known-menu.ts` elsewhere.
|
|
270
|
+
- `src/explain/catalog.ts` (#228) — path → kind, per-entry provenance and
|
|
271
|
+
MikroTik's published applicability gate, unioned from those same trees and
|
|
272
|
+
CLI Reference. Its **command axis is read today** by `pathresolve.ts` (R12),
|
|
273
|
+
`verbsplit.ts` and `write.ts`. Since #235 its **menu/settings kind is read
|
|
274
|
+
too**, unioned with `menus.ts` behind one `isKnownMenuPath` helper
|
|
275
|
+
(`src/explain/is-known-menu.ts`): it confirms a relative bare path once the
|
|
276
|
+
inherited context is applied (`filter` or `firewall filter` under `/ip`), and
|
|
277
|
+
it is the same union `verbsplit.ts` consults for an absolute bare path, which
|
|
278
|
+
now reads `/interface/ethernet/poe` as navigation instead of `ambiguous`. An
|
|
279
|
+
unknown joined path abstains and poisons following relative statements. A gate
|
|
280
|
+
never decides anything offline — it explains why a published path may be
|
|
281
|
+
missing from a given router, and no router was consulted to build the table.
|
|
282
|
+
|
|
283
|
+
`write.ts` deliberately stays on `menus.ts` alone, and that asymmetry is the
|
|
284
|
+
safety rule rather than an oversight: a published command misread as a menu
|
|
285
|
+
would drop a WRITE as navigation, so a `published`-only entry is decisive for
|
|
286
|
+
`command` and only a tie-breaker for `menu`. The #235 union is safe where it is
|
|
287
|
+
read because both consumers there answer "where does the context move?", and a
|
|
288
|
+
wrong menu costs a path, not a missed write.
|
|
289
|
+
|
|
290
|
+
A live target arrives through the **same resolver as every other command**
|
|
291
|
+
(CDB name/MAC/group keys, `--quickchr`, future TikTOML — #134/#174): a
|
|
292
|
+
target-aware `explain` gets provider targets for free and adds no bespoke
|
|
293
|
+
resolution path. Fan-out does not apply — `explain` takes at most one router
|
|
294
|
+
and rejects selectors with `usage/fanout-not-supported`, like `terminal`.
|
|
295
|
+
|
|
296
|
+
Implementation-normative safety rules (evidence in the skill/LSP references):
|
|
297
|
+
per-probe timeouts always; skip scripting-keyword paths for `syntax`/
|
|
298
|
+
`completion` on versions **before 7.21.4** — the REST-server deadlock is
|
|
299
|
+
measured through 7.20.8 and 7.21.4 is the first version proven fixed
|
|
300
|
+
(SUP-127641), so the conservative skip covers the untested gap between;
|
|
301
|
+
route input past the 32,767-byte highlight cap to `:parse` or report
|
|
302
|
+
truncation — never analyze a silent prefix; offsets are **UTF-8 byte** offsets
|
|
303
|
+
over ASCII-normalized input and the normalization is recorded (the exact
|
|
304
|
+
normalization rule is pinned under [Result shape](#result-shape-sketch), Q15);
|
|
305
|
+
`[]`, timeout, and transport failure stay distinct outcomes.
|
|
306
|
+
|
|
307
|
+
The **native-api `:parse` readout is grounded, and the cell is *not* degraded**
|
|
308
|
+
(Q11, version-flat 7.23.2/7.24rc2): `:put [:parse "<input>"]` invoked over the
|
|
309
|
+
native API's `/execute` **with `as-string=""`** returns the parser text —
|
|
310
|
+
including the `(line N column M)` diagnostic on a syntax error — byte-identical
|
|
311
|
+
to what a console transport prints. The opaque `*NN` value seen otherwise is the
|
|
312
|
+
**no-`as-string` job handle**, not a `:parse` limitation; the readout rule is
|
|
313
|
+
"pass `as-string` and interpret `ret`". The existing execute gate already passes
|
|
314
|
+
`as-string` but currently discards `ret`; phase 2 must extract/fix that shared
|
|
315
|
+
readout rather than copy the omission. `/console/inspect`
|
|
316
|
+
(highlight/syntax/completion) works over the native API's `talk` framing too. So
|
|
317
|
+
the line/column diagnostic is available on both live cells — the earlier caveat
|
|
318
|
+
that only a console transport surfaces the parser text is retired.
|
|
319
|
+
|
|
320
|
+
## Surface (Option A — decided)
|
|
321
|
+
|
|
322
|
+
```bash
|
|
323
|
+
centrs explain '<input>' [flags] # offline
|
|
324
|
+
centrs explain <router> '<input>' [flags] # live — target-first
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
`<input>` is a command, a script fragment (`--file` / stdin for scripts), or a
|
|
328
|
+
bare menu path. The positional grammar stays **target-first like every other
|
|
329
|
+
router-taking command** (amended in PR review — the shared resolver/selection
|
|
330
|
+
helpers assume it): one positional means offline and it *is* the input; two
|
|
331
|
+
positionals mean live with the router first; `--` is accepted before the
|
|
332
|
+
input; **`--file` replaces the input positional in either form**, so with it
|
|
333
|
+
every positional is a target. Adding a
|
|
334
|
+
router therefore never reinterprets a previously valid offline invocation.
|
|
335
|
+
|
|
336
|
+
**Ambient stdin is read only when no positional could be the input** — that is,
|
|
337
|
+
the offline form with nothing else to analyze. A positional always wins, and
|
|
338
|
+
when fd 0 is redirected anyway the result carries a
|
|
339
|
+
[`usage/stdin-ignored`](../../docs/errors/usage/stdin-ignored.md) warning rather
|
|
340
|
+
than quietly dropping the pipe. `--file -` names stdin explicitly; it is the
|
|
341
|
+
offline fix today, since the live form it also spells
|
|
342
|
+
(`… | centrs explain edge1 --file -`) is parsed and then refused with
|
|
343
|
+
`usage/not-implemented` until phase 2. The narrower rule is
|
|
344
|
+
deliberate and measured: `runCli` is called in-process by tests, so reading fd 0
|
|
345
|
+
on any path a positional invocation reaches consumes the invoking shell's
|
|
346
|
+
stdin — it fed those bytes to the analyzer and broke 21 unrelated tests once,
|
|
347
|
+
then passed on the next run because the read had drained the fd. Detecting the
|
|
348
|
+
collision is a stat on fd 0, never a read, which is why it is reported instead
|
|
349
|
+
of resolved.
|
|
350
|
+
One verb serves all three intents; the accepted risk is that
|
|
351
|
+
the refined "broad query → broader results" scheme adds complexity to one
|
|
352
|
+
surface rather than splitting into sub-verbs. (Option B — sub-verbs
|
|
353
|
+
`explain check|complete|path` — and Option C — folding into the runners — were
|
|
354
|
+
considered and set aside; C is rejected outright: the bench grounds a
|
|
355
|
+
*separable* cheap tier, offline explain has no runner to ride, and MCP already
|
|
356
|
+
serves `centrs_explain` with no CDB.)
|
|
357
|
+
|
|
358
|
+
**Smart sizing, not depth knobs.** There is no `--depth`. `explain` returns a
|
|
359
|
+
*reasonable* amount by default: when more detail is cheap it is included; when
|
|
360
|
+
a subtree (paths × verbs × args) is large, it returns **counts plus a warning
|
|
361
|
+
that the tree was truncated**, with hints for the narrower query that expands
|
|
362
|
+
the interesting part. A single override flag — working name `--full`, final
|
|
363
|
+
name TBD — lifts the size limits regardless of result size.
|
|
364
|
+
|
|
365
|
+
| Flag | Adds | Notes |
|
|
366
|
+
| ---- | ---- | ----- |
|
|
367
|
+
| *(base)* | canonical structure, spans, diagnostics, transport classification | offline: pure canonicalizer; live: + highlight + `:parse` |
|
|
368
|
+
| `--complete [--cursor <byte>]` | continuation candidates at the cursor (default: end of input) | live evidence; offline emits the live-target tip |
|
|
369
|
+
| `--schema` | path enumeration: verbs, args, types, enums, `.proplist` | live evidence; offline emits the live-target tip |
|
|
370
|
+
| `--curl` | rendered REST call (`curl …`) for `api-candidate` statements covered by a tested mapping rule, plus the equivalent `centrs api`/`centrs execute` invocation | offline-capable; placeholder host + elided credentials when no router is given; `unknown` classifications render no curl |
|
|
371
|
+
| `--full` *(name TBD)* | lift smart-sizing limits | — |
|
|
372
|
+
|
|
373
|
+
Rendering stays separate: `--format`/`--json` remain the settings-vocabulary
|
|
374
|
+
output switch. The longer-term wish behind "format levels" — a colorized
|
|
375
|
+
breakdown of the command (e.g. unset attributes in grey) — is a **rendering**
|
|
376
|
+
concern for the CLI/TUI layer over the same data, not a richness level.
|
|
377
|
+
|
|
378
|
+
Exit codes follow the `check` pattern: `0` clean, `2` when diagnostics meet
|
|
379
|
+
`--fail-on <error|warning|never>` (default `error`), `1` on command failure.
|
|
380
|
+
|
|
381
|
+
## Result shape (sketch)
|
|
382
|
+
|
|
383
|
+
Standard envelope (constitution: result envelope); `data` sketch:
|
|
384
|
+
|
|
385
|
+
```json
|
|
386
|
+
{
|
|
387
|
+
"input": {
|
|
388
|
+
"bytes": 51,
|
|
389
|
+
"normalized": false,
|
|
390
|
+
"truncated": false,
|
|
391
|
+
"positionMap": [ { "analyzed": { "start": 0, "end": 51 }, "originalUtf16": { "start": 0, "end": 51 } } ]
|
|
392
|
+
},
|
|
393
|
+
"verdict": "pass",
|
|
394
|
+
"canonical": { "path": "/ip/route", "verb": "add", "args": { "dst-address": "10.9.0.0/16", "blackhole": "yes" }, "mode": "structured", "writeShaped": true },
|
|
395
|
+
"structure": {
|
|
396
|
+
"statementCount": 1,
|
|
397
|
+
"statements": [
|
|
398
|
+
{
|
|
399
|
+
"span": { "start": 0, "end": 51 },
|
|
400
|
+
"resolution": "resolved",
|
|
401
|
+
"kind": "command",
|
|
402
|
+
"command": { "path": "/ip/route", "verb": "add", "args": { "dst-address": "10.9.0.0/16", "blackhole": "yes" } },
|
|
403
|
+
"transport": { "classification": "api-candidate", "rest": { "method": "PUT", "path": "/rest/ip/route" }, "curl": "curl -u … https://<router>/rest/ip/route …", "centrs": "centrs api <router> …" },
|
|
404
|
+
"ev": "e0"
|
|
405
|
+
}
|
|
406
|
+
],
|
|
407
|
+
"blocks": [],
|
|
408
|
+
"containsWrite": true,
|
|
409
|
+
"subcommands": []
|
|
410
|
+
},
|
|
411
|
+
"symbols": { "occurrences": [] },
|
|
412
|
+
"values": { "occurrences": [ { "id": "v0", "span": { "start": 26, "end": 37 }, "tokenSpan": { "start": 14, "end": 37 }, "kind": "attribute", "name": "dst-address", "quoted": false, "facts": { "shapeHints": { "values": ["ip-prefix"], "ev": "e9" } } } ] },
|
|
413
|
+
"spans": [ { "start": 0, "end": 9, "class": "path", "ev": "e0" } ],
|
|
414
|
+
"diagnostics": [],
|
|
415
|
+
"evidence": [
|
|
416
|
+
{ "id": "e0", "source": "canonicalizer", "probe": "resolveVerbs", "basis": "heuristic", "outcome": "ok" },
|
|
417
|
+
{ "id": "e9", "source": "canonicalizer", "probe": "valueShapeHints", "basis": "heuristic", "outcome": "ok" }
|
|
418
|
+
],
|
|
419
|
+
"runtimeAcceptance": "not-proven"
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
- **The two verdicts are separate.** `data.verdict` is the maximum diagnostic
|
|
424
|
+
severity (`pass` / `warn` / `fail`) and drives `--fail-on` only. Each entry in
|
|
425
|
+
`structure.statements[]` has a canonicalizer `resolution` of `resolved`,
|
|
426
|
+
`ambiguous`, or `unknown`. `ambiguous` means the input has multiple valid
|
|
427
|
+
schema-free readings (for example a bare path may be a menu or a no-argument
|
|
428
|
+
command); `unknown` means analysis cannot safely recover a reading. Neither is
|
|
429
|
+
silently collapsed to a diagnostic severity or to a guessed command. A
|
|
430
|
+
resolved statement also carries `kind: "menu" | "command"`, and the three
|
|
431
|
+
shapes are a **discriminated union**, not four independent optional fields:
|
|
432
|
+
a menu carries `command: { path }` and no verb (navigation names a menu and
|
|
433
|
+
nothing else), a command carries `command: { path, verb }`, and a refusal
|
|
434
|
+
carries `unresolved` and no `command`. Narrowing on `resolution`/`kind` yields
|
|
435
|
+
the fields that exist, which is what the LSP and MCP consumers need from the
|
|
436
|
+
exported types. The library reports the two kinds as *separate
|
|
437
|
+
resolutions* — `src/explain/verbsplit.ts` returns `navigation` (with
|
|
438
|
+
`kind: "menu"`) beside `resolved` (with `kind: "command"`), so `resolved`
|
|
439
|
+
keeps meaning "a verb was decided" for its callers. Phase 1 folds `navigation`
|
|
440
|
+
into this envelope's `resolved` + `kind: "menu"`; the envelope vocabulary here
|
|
441
|
+
is unchanged.
|
|
442
|
+
- **Whole-input gate and per-statement analysis stay distinct.** `canonical`
|
|
443
|
+
reproduces the existing execute gate's whole-input fields unchanged, including
|
|
444
|
+
its `mode` and `writeShaped` decision. `structure.statements[]` is the
|
|
445
|
+
addressable rich surface for script-scale input: every statement carries its
|
|
446
|
+
source span, resolution, resolved command when available, transport
|
|
447
|
+
classification, and evidence id. This is where multi-statement scripts and
|
|
448
|
+
partial recovery live; a single top-level transport object cannot represent
|
|
449
|
+
them.
|
|
450
|
+
- **Every derived fact keeps provenance via `evidence[]`** — facts reference
|
|
451
|
+
a stable evidence id (`ev`), and the evidence entry carries source
|
|
452
|
+
(`canonicalizer` vs `live-inspect`), probe, basis (direct response vs
|
|
453
|
+
derived vs heuristic), outcome (`ok`/`empty`/`timeout`/`transport-error`),
|
|
454
|
+
and the RouterOS version stamp. A single global version field is *not*
|
|
455
|
+
enough: facts in one result can come from different probes with partial
|
|
456
|
+
failures. The `RouterOsSyntaxEvidence` contract in lsp-routeros-ts
|
|
457
|
+
`docs/syntax-inspection-map.md` is the adaptation source. Resolved target
|
|
458
|
+
identity lives in the envelope's `meta.target` (constitution) — `data`
|
|
459
|
+
grows no competing identity surface.
|
|
460
|
+
- **Envelope semantics mirror `check`**: an analysis that ran is `ok: true`
|
|
461
|
+
even when the input is riddled with errors — the diagnostics *are* the
|
|
462
|
+
data, summarized by `data.verdict` (max severity after analysis). Exit
|
|
463
|
+
codes derive from the verdict vs `--fail-on`; `ok: false` is reserved for
|
|
464
|
+
genuine command failure (unresolvable target, usage error, probe transport
|
|
465
|
+
failure). This lets the library and MCP reproduce the same decision without
|
|
466
|
+
a process exit code.
|
|
467
|
+
- **Coordinates are contracted, not implied**: probe offsets are UTF-8 byte
|
|
468
|
+
offsets over the *analyzed* (ASCII-normalized) input with `end` exclusive,
|
|
469
|
+
and `input.positionMap[]` maps half-open analyzed-byte runs to half-open
|
|
470
|
+
original UTF-16 runs. Identity runs are retained, so consumers never need a
|
|
471
|
+
separate coordinate contract for normalized and unnormalized input.
|
|
472
|
+
The library surface additionally provides original-document positions
|
|
473
|
+
(line + UTF-16 character) for LSP consumers; `--cursor <byte>` stays a
|
|
474
|
+
wire-level CLI convenience. **The ASCII-normalization is byte-count-preserving**
|
|
475
|
+
(Q15, ratified): each non-ASCII byte is replaced by a single placeholder byte
|
|
476
|
+
one-for-one (a multi-byte character becomes N placeholder bytes, never
|
|
477
|
+
collapsed to one), so an analyzed byte offset **equals** the device's UTF-8 /
|
|
478
|
+
`highlight` byte offset identically and offline spans and highlight spans share
|
|
479
|
+
one coordinate system with no fixup. A reference mapper validated the required
|
|
480
|
+
invariants at 100% over ASCII / BMP / astral-surrogate / combining-mark / tab /
|
|
481
|
+
LF-CRLF / lone-surrogate fixtures: every span in bounds; original↔analyzed
|
|
482
|
+
round-trips at boundaries; slicing the original range yields the intended
|
|
483
|
+
token; `end === len` is the legal end-of-input cursor; a cursor interior to a
|
|
484
|
+
multi-byte character snaps to that character's boundary. centrs does not
|
|
485
|
+
NFC-fold — a combining mark is its own position — and `\r\n` counts as one line
|
|
486
|
+
advance while a lone `\r` is not a line break. The production parser runs on the
|
|
487
|
+
analyzed string directly (it is pure ASCII, so its JS index *is* the byte
|
|
488
|
+
offset) and this mapper converts to original line/UTF-16 positions.
|
|
489
|
+
- **Spans use a centrs-owned vocabulary** (decision): raw RouterOS highlight
|
|
490
|
+
classes are not the default surface. The centrs vocabulary must be at least
|
|
491
|
+
as rich as the RouterOS token classes, and centrs maintains the mapping —
|
|
492
|
+
including color mappings derived from the original RouterOS highlight
|
|
493
|
+
colors — so an LSP consuming centrs can reproduce the current RouterOS
|
|
494
|
+
color scheme faithfully. The indirection decouples consumers from
|
|
495
|
+
cross-version drift in the raw classes; raw classes stay available in
|
|
496
|
+
provenance/debug output.
|
|
497
|
+
- Diagnostics are slash-namespaced (`explain/<source>/<slug>`), carry byte
|
|
498
|
+
spans (or line/column from `:parse`), and follow the standard error-model
|
|
499
|
+
severity channels. **Malformed input carries a defect *region*** (Q14): each
|
|
500
|
+
diagnostic points at the byte span of its defect, and the detectable classes
|
|
501
|
+
are {unbalanced delimiter, unterminated string, invalid escape, invalid sigil,
|
|
502
|
+
invalid unquoted hash, BOM, non-ASCII, over-depth nesting}. **Two classes named
|
|
503
|
+
in the phase-0 draft
|
|
504
|
+
of this list are deliberately deferred** (maintainer decision, #192), because
|
|
505
|
+
each would require offline to assert something it cannot prove:
|
|
506
|
+
- **truncation** — offline cannot distinguish a truncated *complete-looking*
|
|
507
|
+
token from a finished one without a schema, as the `--complete` paragraph
|
|
508
|
+
below already states. What is detectable is the *continuation state*
|
|
509
|
+
(trailing `=`, open delimiter), which is a `--complete` concern reported
|
|
510
|
+
against a live target, not a defect region. Emitting it as a defect would be
|
|
511
|
+
the confident-claim-on-ambiguous-input posture Q14 forbids.
|
|
512
|
+
- **stray mid-token delimiter** — plausible but ungrounded. It needs a probe
|
|
513
|
+
matrix (spellings × positions × accepted-form neighbors) before a detector
|
|
514
|
+
is worth having; the #201 round is the standing evidence that a lexical rule
|
|
515
|
+
curated from intuition produces defects the corpus cannot reach.
|
|
516
|
+
|
|
517
|
+
Both remain candidates; re-opening either means grounding it first and
|
|
518
|
+
amending this list. `BOM` and `non-ASCII` are **positional facts, never
|
|
519
|
+
errors** — they record where the byte-count-preserving normalization stood in
|
|
520
|
+
for unreadable bytes, and a renderer must not give them error severity (a
|
|
521
|
+
non-ASCII value such as `name="router-🚀"` is a legal command, and non-ASCII
|
|
522
|
+
occurs in ~12% of the phase-0 corpus). Two hard
|
|
523
|
+
recovery rules follow from the phase-0 mutation suite, both **fail-closed**:
|
|
524
|
+
(a) corruption may only degrade a command to `ambiguous`/`unknown`, **never
|
|
525
|
+
invent a confident one** after a defect region — the same fail-closed floor as
|
|
526
|
+
the canonicalizer's `ambiguous` verdict, and the direct fix for the measured
|
|
527
|
+
case where a stray `;` inside a value splits off a garbage fragment that
|
|
528
|
+
offline would otherwise resolve to a bogus path; (b) a **bare-word-headed
|
|
529
|
+
statement** resolved against document context degrades to `unknown` when the
|
|
530
|
+
context is uncertain (an upstream defect can poison it), because a bare word is
|
|
531
|
+
Q4's cascade residual. Deeply nested delimiters emit an over-depth diagnostic
|
|
532
|
+
rather than recursing (Q17 measured stack-overflow and O(n²) blow-up in a naive
|
|
533
|
+
recursive walker; the product parser bounds depth and scans single-pass).
|
|
534
|
+
Offline `--complete` detects trailing-`=` and open-delimiter continuation but
|
|
535
|
+
cannot distinguish a truncated *complete-looking* token from a finished one
|
|
536
|
+
without a schema — so it surfaces the detectable continuation state and defers
|
|
537
|
+
candidate generation to a live target.
|
|
538
|
+
- `runtimeAcceptance: "not-proven"` is structural, not boilerplate: it is the
|
|
539
|
+
inspect-vs-runtime gap made machine-readable.
|
|
540
|
+
- Live-state token classes (disabled/dynamic/inactive object references)
|
|
541
|
+
surface as facts, not errors; severity policy belongs to the caller.
|
|
542
|
+
|
|
543
|
+
### What phase 1 offline actually emits (#202a)
|
|
544
|
+
|
|
545
|
+
The sketch above is the whole shape, and `examples.md` describes the FINISHED
|
|
546
|
+
command — both stay as they are. This table says which parts `src/explain.ts`
|
|
547
|
+
ships today, so a reader can tell a decision from an oversight. Every gap closes
|
|
548
|
+
by ADDING a field, never by changing one, which is why the examples do not need
|
|
549
|
+
to move: an example asserting a field this phase omits is simply not green yet,
|
|
550
|
+
and its phase is named below.
|
|
551
|
+
|
|
552
|
+
| Part | Phase 1 offline |
|
|
553
|
+
| ---- | --------------- |
|
|
554
|
+
| `input`, `verdict`, `canonical`, `structure`, `diagnostics`, `runtimeAcceptance` | complete |
|
|
555
|
+
| `evidence[]` | the **offline subset**: `source` is always `canonicalizer` and there is no RouterOS version stamp |
|
|
556
|
+
| `structure.statements[].command` | `path` + `verb`, plus **`args` where the argument list was read** (#202c-1); the ordered token list is `statements[].arguments.tokens` |
|
|
557
|
+
| `structure.statements[].transport` | complete for commands: `api-candidate`, `execute`, or fail-closed `unknown`, with an equivalent `centrs` invocation and opt-in `curl` for tested REST mappings (#202c-2). The `basis` names the evidence for the route it chose or refused |
|
|
558
|
+
| `symbols.occurrences[]` | Q13 name/span/class plus semantic `role`, result-local `bindingIds`, sigil spelling, and an abstention note where needed; **plus** (#239 S2) `valueId` on declarations/assignments with a literal RHS in the same statement, and `reachingValueIds` + `reachingUnknown` on references (flow-sensitive, branch → set, loop/non-literal → `unknown`, no RHS evaluation) |
|
|
559
|
+
| `values.occurrences[]` | result-local value id/span plus three separate fact homes: offline `shapeHints`; live `observedType` and `schemaType` remain absent until phase 2. `kind: "element"` rows are array members, carrying `parent` and, for a keyed member, `name` |
|
|
560
|
+
| `spans` | comment runs and resolved variable classes only; **no value shape or type** |
|
|
561
|
+
| `schema`, `completion` | absent — live evidence, phase 2 |
|
|
562
|
+
|
|
563
|
+
- **Per-statement arguments are lexed, and the list is all-or-nothing** (#202c-1,
|
|
564
|
+
`src/explain/args.ts`). The ordered token list with spans is
|
|
565
|
+
`statements[].arguments.tokens` (the phase-0 normal form); `command.args` is
|
|
566
|
+
its derived object view, present only when the list was read. Two different
|
|
567
|
+
outcomes, which a reader must not conflate:
|
|
568
|
+
- **The whole list is refused** — `arguments.read` is `false` with a reason,
|
|
569
|
+
and `command.args` is absent — when a token cannot be delimited or named at
|
|
570
|
+
all: a `[…]`/`(…)`/`$x`/`{…}` value, an escape this phase does not decode, a
|
|
571
|
+
left-hand side that is not a RouterOS name, a token split by a continuation.
|
|
572
|
+
Never partially read, because a dropped argument silently changes what a
|
|
573
|
+
rendered `curl` DOES. Two refusals are not lexical at all, but
|
|
574
|
+
places where centrs's two readers disagree about an **unquoted** value: a
|
|
575
|
+
`'`, which RouterOS treats as an ordinary character and the locked execute
|
|
576
|
+
gate treats as a quote (`comment=it's` is `it's` here and `its` to the
|
|
577
|
+
gate); and a `\f`/`\v`, which the gate splits tokens on (JavaScript `\s`)
|
|
578
|
+
and every explain module does not (ASCII whitespace). The device-correct
|
|
579
|
+
reading is the analysis's in both, but the gate cannot be corrected — so
|
|
580
|
+
phase 1 publishes neither rather than putting two confident values in one
|
|
581
|
+
result. Inside a `"…"` run the two agree and nothing is refused. The
|
|
582
|
+
advisory value-anchor view is intentionally wider only for exact array
|
|
583
|
+
literals: a non-empty `{…}` value or a parenthesized run with a depth-zero
|
|
584
|
+
comma is locatable and hints `array`, while the strict REST view continues
|
|
585
|
+
to refuse the whole list.
|
|
586
|
+
- **A token is read but carries no `value`** — the list still reads. The token
|
|
587
|
+
is delimited and classified; only its literal value is unknowable, as for
|
|
588
|
+
the positional in `:log info "result: $[…]"`. **`value` absent means there
|
|
589
|
+
is no literal value**, whatever the token's kind, so a consumer rendering a
|
|
590
|
+
runnable command reads `value`, never the source `text`, and treats absence
|
|
591
|
+
as not-renderable.
|
|
592
|
+
|
|
593
|
+
Measured on the frozen corpus: 40.0% of CRUD-verb commands read, 0 arguments
|
|
594
|
+
dropped against the IL oracle, and 0 contradictions with `canonical.args`
|
|
595
|
+
where both decided. That last one holds *because* of the fail-closed rules
|
|
596
|
+
above, not by accident — a trailing `;`, a `'`, and `\f`/`\v` each
|
|
597
|
+
contradicted until review found them. The `;` case is enforced at the
|
|
598
|
+
composition boundary rather than in the lexer, because segmentation strips the
|
|
599
|
+
delimiter before the lexer ever sees it: where the gate read the whole input
|
|
600
|
+
as `structured` and the single statement's reading differs, the analysis
|
|
601
|
+
abstains.
|
|
602
|
+
|
|
603
|
+
What offline still cannot do is NAME a positional operand — RouterOS binds
|
|
604
|
+
`:log info "x"` to `message=x` from its schema, and offline reports the
|
|
605
|
+
located positional instead of inventing the name.
|
|
606
|
+
- **Transport is classified per resolved command and fails closed.** Only the
|
|
607
|
+
Q8 rules exercised on CHR 7.23.2 and 7.24rc2 become `api-candidate`;
|
|
608
|
+
script-shaped inputs become `execute`, and untested or ambiguous mappings
|
|
609
|
+
become `unknown` with no runnable REST rendering. Four rules are keyed on a
|
|
610
|
+
literal verb (`add`/`get`/`set`/`remove`) and `print` has its three shapes;
|
|
611
|
+
the fifth was recorded as the RULE `action → POST /rest/<path>/<command>`, so
|
|
612
|
+
every other verb rides it. That rule maps a URL and does **not** claim the
|
|
613
|
+
verb exists — a `catalog.ts` miss narrows the basis string, never the
|
|
614
|
+
classification, because "a MISS says nothing" is that table's contract and
|
|
615
|
+
existence is the phase-2 `/console/inspect` answer. An action operand offline
|
|
616
|
+
cannot NAME (`enable *A`, `monitor 0`) fails closed, since RouterOS binds it
|
|
617
|
+
from per-menu schema. Curl is opt-in and uses placeholders offline. Menus and
|
|
618
|
+
refused readings have no transport because there is no command to classify.
|
|
619
|
+
- **`spans` carries what offline can prove.** Comment runs, and the variable
|
|
620
|
+
classes Q13 scored at 100% precision on resolved bindings; an abstention is
|
|
621
|
+
omitted rather than rendered as a guess. The Q12 vocabulary over
|
|
622
|
+
path/verb/argument bytes wants device `highlight` as its oracle. A subset is
|
|
623
|
+
not a claim that the vocabulary is closed.
|
|
624
|
+
- **`symbols` is the semantic Q13 projection plus flow-sensitive value refs
|
|
625
|
+
(#239 S2).** Each occurrence carries its source name/span, resolved class
|
|
626
|
+
(or `null` when offline abstains), role (`declaration`, `binding`,
|
|
627
|
+
`assignment`, `reference`, or filter `field`), and result-local binding
|
|
628
|
+
identities. Those identities distinguish shadowed names and connect `:set`/
|
|
629
|
+
reference sites to their declaration; they are stable only within one result.
|
|
630
|
+
When `:onerror` establishes two bindings, the list is ordered outer-scope
|
|
631
|
+
binding first, statement-scope binding second. A declaration/binding/
|
|
632
|
+
assignment with a literal RHS in the same statement carries `valueId`
|
|
633
|
+
pointing at its `values.occurrences[]` row; a reference carries
|
|
634
|
+
`reachingValueIds` (the set of literal ids that may reach it) and
|
|
635
|
+
`reachingUnknown` when the set is incomplete. Linear code is last-write-wins;
|
|
636
|
+
a branch merge is a set; a loop merge or a non-literal RHS (`$x`,
|
|
637
|
+
`[find …]`, an expression) is `unknown` — no RHS evaluation, so a
|
|
638
|
+
`reachingUnknown` reference must not be read as a type assertion. The three
|
|
639
|
+
value facts themselves stay on `values.occurrences[].facts`; symbols only
|
|
640
|
+
*refer* to them. A hyphen always terminates a bare `$name`: `$set-dns` reads
|
|
641
|
+
`$set`, while `$"set-dns"` is the spelling that reaches a hyphenated
|
|
642
|
+
declaration. Braced forms such as `${set-dns}` and `${"set-dns"}` are
|
|
643
|
+
rejected by RouterOS at `{` and resolve no reference. In an expression the
|
|
644
|
+
remaining `-dns` is scanned as subtraction plus its right operand; inside a
|
|
645
|
+
string it is literal text.
|
|
646
|
+
- **Evidence is offline-shaped, not the whole contract.** The bullet above says
|
|
647
|
+
an evidence entry carries `source` (`canonicalizer` vs `live-inspect`) and a
|
|
648
|
+
RouterOS version stamp; phase 1 emits neither the live source nor the stamp,
|
|
649
|
+
because no probe ran and there is no device to stamp. `ExplainEvidence` is
|
|
650
|
+
typed to what offline produces rather than pre-declaring variants nothing can
|
|
651
|
+
emit, so phase 2 widens the union and adds the stamp — an addition, not a
|
|
652
|
+
change to what a caller reads today.
|
|
653
|
+
- **Value facts are three axes, never one `type` string (#225).** RouterOS
|
|
654
|
+
types values at parse time while `highlight` classes every value byte `none`,
|
|
655
|
+
so type evidence is not a subset of Q12 spans. Each result-local
|
|
656
|
+
`values.occurrences[]` row owns one value span and a `facts` object:
|
|
657
|
+
1. `shapeHints: { values, ev }` is offline and heuristic — a list, because the
|
|
658
|
+
axis admits overlap;
|
|
659
|
+
2. `observedType?: { value, ev }` is what a live parser/IL reading supports;
|
|
660
|
+
3. `schemaType?: { value, ev }` is the argument's declared live-schema type.
|
|
661
|
+
|
|
662
|
+
The stable/testing CHR matrix fixes the offline lexicon: `num`, `ip`,
|
|
663
|
+
`ip-prefix`, `ip6`, `ip6-prefix`, `id`, `time`, `array`, `mac`, `bool`, and
|
|
664
|
+
`str` — `op` is deliberately not a member: a deferred `(> …)` abstains
|
|
665
|
+
offline and is reported only as a live `observedType` (#288); quoted literals
|
|
666
|
+
hint only `str`, while malformed and out-of-range address-like controls
|
|
667
|
+
abstain even in named attributes. Most vocabulary borrows RouterOS scripting
|
|
668
|
+
type names (with the explicit `macAddr` schema exception below), so a hint
|
|
669
|
+
must never spell a scripting type the device contradicts: `num` is
|
|
670
|
+
**integer-only**, since RouterOS numbers are integers and a dotted decimal is
|
|
671
|
+
an IPv4 shortcut — bare `2.2` is observed as `ip` (`2.0.0.2`), so it hints
|
|
672
|
+
`ip` alone. On that grounded lexicon each spelling reaches at most one shape.
|
|
673
|
+
Colon-form time accepts RouterOS's displayed H:M / H:M:S spelling plus
|
|
674
|
+
week/day prefixes and fractional tails; `00:11:22` is therefore `time`, not a
|
|
675
|
+
short MAC. `mac` deliberately names the CLI Reference `macAddr` schema
|
|
676
|
+
spelling rather than a scripting type: a full six-octet MAC hints `mac` while
|
|
677
|
+
scalar `:typeof` reports `str`. `*1` hints the documented `id` type. A single
|
|
678
|
+
non-time colon is not an address attempt — `comment=foo:bar` hints `str` —
|
|
679
|
+
while malformed multi-colon runs stay fail-closed.
|
|
680
|
+
|
|
681
|
+
Arrays are the one structured literal admitted by offline hint anchoring.
|
|
682
|
+
Non-empty `{…}` values and parenthesized expressions with a depth-zero comma
|
|
683
|
+
hint `array`; grouping `(1)`, empty groups, and scope braces do not. The
|
|
684
|
+
strict REST argument reader still refuses every structured value. RouterOS
|
|
685
|
+
has no empty-array literal (`{}` is a syntax error); `[:toarray ""]` produces
|
|
686
|
+
an empty array, but remains an expression and therefore an offline
|
|
687
|
+
abstention. Indexing that empty array produces `nothing`. `.` remains an
|
|
688
|
+
expression operator rather than part of a literal: it produces `str` for
|
|
689
|
+
`1 . 2`, but distributes over arrays in either operand order, so offline
|
|
690
|
+
abstains on the whole concat expression and leaves its result type to #236.
|
|
691
|
+
`[:parse "…"]` similarly produces the observed but undocumented `code` type,
|
|
692
|
+
and `[:nothing]` produces `nil`; neither has a standalone literal spelling,
|
|
693
|
+
so neither becomes an offline shape member. `(> …)` deferred expressions
|
|
694
|
+
are the same case with a different outcome: `(>[:return 1])` is `op` and
|
|
695
|
+
`(>{"a"=1})` is `array` at runtime, but both lower to `(> …)` with arity
|
|
696
|
+
1 and identical `:parse` IL — only `:typeof` distinguishes them
|
|
697
|
+
(`test/fixtures/explain/operators.json` → `sweep.runtime`
|
|
698
|
+
`typeof-deferred`/`deferred-array-typeof` and `sweep.opAxis` `defer-*` on
|
|
699
|
+
7.21.5, 7.23.3, 7.24rc4). Offline abstains on every such form; `op` lives
|
|
700
|
+
only as a live `observedType` (#288, a bounded #225 decision).
|
|
701
|
+
Shape remains non-authoritative: `100000w` is time-shaped but observed as
|
|
702
|
+
`str`. Argument context remains separate: on 7.23.3 and 7.24rc3, both
|
|
703
|
+
`/ip/address address=2.2` and firewall `src-address=2.2` canonicalize it to
|
|
704
|
+
`2.0.0.2`, while `comment=2.2` stays text, netwatch `interval=2.2`
|
|
705
|
+
normalizes to `00:00:02.200`, a boolean slot rejects it, and `:parse` still
|
|
706
|
+
accepts `src-address=not-an-ip`. Boolean grounding has the same separation:
|
|
707
|
+
bare `true`/`false` and `yes`/`no` are scalar `bool`, while their quoted forms
|
|
708
|
+
are `str`; a CLI `disabled=` slot accepts bare or quoted `yes`/`no` but rejects
|
|
709
|
+
`true`/`false`, while REST accepts JSON booleans and the string forms
|
|
710
|
+
`yes`/`no`/`true`/`false`. `:tobool` conversion is a separate runtime operation
|
|
711
|
+
and does not widen the hint. Thus a hint never validates a value or becomes a
|
|
712
|
+
diagnostic, and never changes `runtimeAcceptance`. Quoting likewise never
|
|
713
|
+
earns a wrong-type diagnostic: commands may cast quoted strings at runtime.
|
|
714
|
+
Offline emits only `shapeHints`; phase 2 adds the two live facts into their
|
|
715
|
+
existing homes when its safe IL/schema producers land.
|
|
716
|
+
|
|
717
|
+
**A located array is read member by member (#225 V1).** Each member of a
|
|
718
|
+
`{…}`/`(…)` literal is its own `values.occurrences[]` row with `kind:
|
|
719
|
+
"element"`, its own span and hints, a `parent` naming the container's
|
|
720
|
+
result-local id, and `name` when the brace form spells a key. The separator is
|
|
721
|
+
the delimiter's and not a choice: a brace splits on `;` while a depth-zero `,`
|
|
722
|
+
inside one builds a nested array (`{1,2}` is a ONE-member array whose member
|
|
723
|
+
is `(, 1 2)`), a paren splits on `,`, and `(1;2)` does not parse.
|
|
724
|
+
|
|
725
|
+
**`=` does three different things inside a literal, and a byte of whitespace
|
|
726
|
+
picks between them (#258).** It binds a key only in the brace form, and only
|
|
727
|
+
where the NAME TOUCHES the sign: `{a=1}` is the key `a`, but `{a =1}` lowers
|
|
728
|
+
to `(= $a 1)` and `(a=1,b=2)` is two comparisons, so keys are read in braces
|
|
729
|
+
only. A key whose right side is EMPTY is not an empty-valued key — the device
|
|
730
|
+
drops the sign and keeps the name as a POSITIONAL `str`, so `{a=}` is the
|
|
731
|
+
one-member array `a`, `{"a b"=}` decodes to `a b`, and `{1.1=}` is the string
|
|
732
|
+
`1.1` rather than the address (no member lexicon runs on a name). Everywhere
|
|
733
|
+
else the sign compares, which returns `bool` whatever it compares, so
|
|
734
|
+
`{$a=1}`, `{(a)=1}`, `{[:timestamp]=1}` and `{{1;2}=1}` are each one `bool`
|
|
735
|
+
member. Every key is a `str` however it is spelled: `{1=1}` keys on the string
|
|
736
|
+
`1`, and `{1.1=1.1}` keys on `1.1` while its VALUE goes through the address
|
|
737
|
+
shortcut to `1.0.0.1`.
|
|
738
|
+
|
|
739
|
+
**A member key's name is not the identifier grammar**, and was swept
|
|
740
|
+
character by character rather than borrowed. `.`, `-` and `/` are ordinary
|
|
741
|
+
name bytes anywhere in it, including alone and repeated — `{.id=1}`,
|
|
742
|
+
`{..id=1}`, `{.=1}`, `{-=1}`, `{--=1}`, `{/=1}`, `{a/b=1}` and `{-.1=1}` each
|
|
743
|
+
bind the key spelled exactly that way — while `_` does NOT, even though
|
|
744
|
+
`$a_b` is a good variable: `{_a=1}` and `{a_b=1}` lower to comparisons.
|
|
745
|
+
Neither do `: * ~ % > < + ! @ #`. `@` is stronger still — not a member
|
|
746
|
+
character at ANY position, so `{a@b}`, `{@a}`, `{1;a@b}` and `(a@b,2)` are
|
|
747
|
+
withdrawn outright, though the same bytes are fine one slot out
|
|
748
|
+
(`:local z a@b` parses) or inside quotes (`{"a@b"}`).
|
|
749
|
+
|
|
750
|
+
`bool` is claimed only where the `=` is the member's TOP operator, which
|
|
751
|
+
offline reads as a left side that is one complete operand — a name, `$name`,
|
|
752
|
+
or a fully-enclosing `(…)`/`[…]`/`{…}`. `{a b=1}` lowers to `( $a (= $b 1))`
|
|
753
|
+
and `{$a=1,2}` to `(, (= $a 1) 2)`; in both the sign is nested under an
|
|
754
|
+
operator whose type is its operands', so both abstain. That operand class is
|
|
755
|
+
**narrower than RouterOS's**, deliberately: `{a_b=1}`, `{:a=1}`, `{1+1=2}`
|
|
756
|
+
and `{$a/b=1}` are `bool` on the device and silent here, because widening it
|
|
757
|
+
means first proving the `=` stays on top for the new spelling. Operand
|
|
758
|
+
position is also stricter than KEY position — `{a-=1}` binds the key `a-`
|
|
759
|
+
while `{a- =1}` does not parse at all — so both classes end on an
|
|
760
|
+
alphanumeric there.
|
|
761
|
+
|
|
762
|
+
An empty side is a
|
|
763
|
+
syntax error in every spelling asked — `{=}`, `{=1}`, `(=1,2)`, `{$a=}`,
|
|
764
|
+
`{(a)=}`, `{a =}`, `{a b=}`, `{a,b=}`, `{[:len 1]=}`, `{{1;2}=}`, `{1+1=}`,
|
|
765
|
+
`{a<=}`, `{a==}`, `(a=,2)` — so it withdraws the literal rather than
|
|
766
|
+
abstaining on the member. That withdrawal needs POSITIVE evidence that the
|
|
767
|
+
left side is an expression (a byte no key may hold — `NOT_IN_MEMBER_NAME` in
|
|
768
|
+
`src/explain/args.ts`, the complement of `MEMBER_NAME`), never the key
|
|
769
|
+
reader's silence: a name spelling this phase does not recognize abstains
|
|
770
|
+
instead, so the next `.id` costs a dropped member rather than a withdrawn
|
|
771
|
+
literal.
|
|
772
|
+
|
|
773
|
+
**Two member faults have nothing to do with `=` and are checked before it.**
|
|
774
|
+
A `$` takes an alphanumeric or a quote and nothing else — `$"a b"` is a good
|
|
775
|
+
variable name while `$.id`, `$_a`, `$:a`, `$/a`, `$$a`, `$(x)` and a bare `$`
|
|
776
|
+
are syntax errors — and no run may END on a dangling operator: `{-}`, `{--}`,
|
|
777
|
+
`{a-}`, `{$a-}` and `{$a.}` are refused, though `{a.}`, `{.}`, `{/}` and
|
|
778
|
+
`{a/}` parse, because a bare name absorbs a trailing dot and a `$` name does
|
|
779
|
+
not. Both hold anywhere in a member, and both reach the `=` axis only through
|
|
780
|
+
a left operand (`{a- =1}`, `{$a-=1}`, `{$.id=1}`). The 186
|
|
781
|
+
rows are in
|
|
782
|
+
`test/fixtures/explain/values.json` → `interiorGrounding.keyBinding`, and the
|
|
783
|
+
value census below is unchanged by all of it — no statement in the 948-script
|
|
784
|
+
corpus reaches any of these branches, so the fixture is the only falsifier
|
|
785
|
+
they have.
|
|
786
|
+
|
|
787
|
+
**A member is not an argument value, because the device does not parse it as
|
|
788
|
+
one.** Inside a literal RouterOS reads an expression, so the member lexicon is
|
|
789
|
+
its own: a bare word is a variable reference (`{abc}` lowers to `$abc`), and
|
|
790
|
+
so is a full MAC (`{00:11:22:33:44:55}`) and any time literal past the signed
|
|
791
|
+
64-bit nanosecond range, whose cliff sits between `15250w` and `15251w`,
|
|
792
|
+
`106751d` and `106752d`, `9223372036s` and `9223372037s`. `*1` does not parse
|
|
793
|
+
at all there, though `:local x *1` is `id`. Hexadecimal is added — `0x10` is
|
|
794
|
+
16, while `0X10` is a variable — and `str`-by-fallback, `mac` and `id` are
|
|
795
|
+
dropped. Everything else abstains: expressions, substitutions, `$x`, and the
|
|
796
|
+
dotted-decimal-to-seconds fallback described below.
|
|
797
|
+
|
|
798
|
+
**A `{…}` array literal is legal only in a root scripting directive's value
|
|
799
|
+
slot, and only in a slot the device proved.** `/console/inspect` classes the
|
|
800
|
+
`{` byte `error` and `:parse` refuses the statement for
|
|
801
|
+
`/ip/route/add comment={1;2}`, `/ip/dns/set servers={1.1.1.1;8.8.8.8}` — a
|
|
802
|
+
LIST-typed attribute, which is what rules out a schema-shaped reading —
|
|
803
|
+
`/interface/print .proplist={name;comment}`,
|
|
804
|
+
`/ip/route/print where comment={1;2}`, the relative spelling
|
|
805
|
+
`ip route add comment={1;2}`, and `:log info message={1;2}`; while
|
|
806
|
+
`:local z {1;2}`, `:put {1;2}`, `:len {1;2}` and `:foreach i in={1;2}` all
|
|
807
|
+
parse.
|
|
808
|
+
|
|
809
|
+
Being a directive is necessary but not sufficient, and the path does not
|
|
810
|
+
decide: `:delay {1;2}`, `:beep {1;2}`, `:resolve {1;2}`,
|
|
811
|
+
`:if condition={1;2}` and `:local name={1;2}` are syntax errors at a `/` path
|
|
812
|
+
too. The gate is keyed on **(verb, slot)** from a device sweep of every root
|
|
813
|
+
builtin and every slot it completes — 222 rows in
|
|
814
|
+
`test/fixtures/explain/values.json` → `interiorGrounding.braceSlots`, baked
|
|
815
|
+
into `src/explain/brace-slots.ts` — and a slot has four outcomes, not two:
|
|
816
|
+
|
|
817
|
+
| outcome | what the slot does with `{(1,2)}` | example |
|
|
818
|
+
| ------- | --------------------------------- | ------- |
|
|
819
|
+
| `array` | evaluates it — IL `(, 1 2)` | `:local z`, `:put`, `:foreach in=`, `:for from=` |
|
|
820
|
+
| `code` | runs it as a program | `:onerror in=`, `:retry command=`, `:if do=` |
|
|
821
|
+
| `text` | keeps it verbatim as a script | `:execute script=`, `:grep script=` |
|
|
822
|
+
| `error` | refuses the statement | `:delay`, `:beep`, `:local name=` |
|
|
823
|
+
|
|
824
|
+
Only `array` slots are read as arrays; the other three and the 30 rows the
|
|
825
|
+
sweep could not ask are refused, which costs coverage and never correctness.
|
|
826
|
+
A positional is keyed by its index, because position decides — `:local {1;2}`
|
|
827
|
+
puts the literal in the NAME slot and does not parse, while `:local z {1;2}`
|
|
828
|
+
puts it in the VALUE slot and does. `{1;2}` alone cannot build this table:
|
|
829
|
+
`:local z {1;2}` and `:execute script={1;2}` both lower to `…=1;2`, so the
|
|
830
|
+
array test has to be a literal whose evaluated and verbatim readings differ. The `(…)` spelling is never rejected at the delimiter in any of those
|
|
831
|
+
positions — `.proplist=(name,comment)` still draws `bad parameter .proplist`,
|
|
832
|
+
but that is a name-level diagnostic and the plain `name,comment` spelling
|
|
833
|
+
draws it too — and a `(…)` or `[…]` around a brace restores the expression
|
|
834
|
+
context. The braces a
|
|
835
|
+
command argument does take are script bodies (`source=`, `on-event=`), and
|
|
836
|
+
those are not arrays either — they are refused earlier as scope blocks. A
|
|
837
|
+
top-level brace value in any other command argument emits
|
|
838
|
+
`explain/canonicalizer/invalid-command-brace` at the `{`; parentheses or a
|
|
839
|
+
`[…]` substitution restore the expression context where the same literal is
|
|
840
|
+
legal. An empty member is a
|
|
841
|
+
syntax error (`{;}`, `{;1}`, `{1;;2}`, `(1,)`) and withdraws the enclosing
|
|
842
|
+
`array` shape with it; a single trailing separator is legal in the brace form
|
|
843
|
+
only (`{1;}` and `{1;2;}` parse, `{1;2,}` and `{2,}` do not).
|
|
844
|
+
|
|
845
|
+
**A nested member the device rejects withdraws its container too.** A `(…)`
|
|
846
|
+
inside a literal is a GROUP as often as an array — `{(1)}` is the one-member
|
|
847
|
+
array `1` — so an empty group (`{()}`, `{a=()}`) and an empty comma member
|
|
848
|
+
(`{(1,)}`, `{(,)}`, `{1;(2,)}`, `(1,(2,))`) are named as faults rather than
|
|
849
|
+
abstained on: `:parse` rejects each of them at every nesting depth, while
|
|
850
|
+
`{(1)}`, `{a=(1)}` and `{1;(2)}` parse. `highlight` is not the oracle for this
|
|
851
|
+
family — it accepts `{1;2,}`, `{2,}` and `{(1,2),}`, all of which `:parse`
|
|
852
|
+
rejects. Member descent stops after eight frames, and the bound WITHDRAWS
|
|
853
|
+
rather than keeping an unverified shape: a fault below it still makes the
|
|
854
|
+
whole statement a syntax error — `:parse` rejects a `(1,)` buried nine levels
|
|
855
|
+
deep exactly as it rejects a shallow one — so a literal nested deeper than
|
|
856
|
+
eight is refused instead of called an array. The deepest member in the
|
|
857
|
+
948-script corpus sits at depth 6.
|
|
858
|
+
|
|
859
|
+
**The bare comma spelling is the one place a hint is genuinely plural.**
|
|
860
|
+
`=1,2,3` is not a syntax error anywhere, and whether the device SPLITS it is
|
|
861
|
+
decided by the argument's type, which offline does not have:
|
|
862
|
+
`servers=1.1.1.1,8.8.8.8` lowers to `servers=1.1.1.1;8.8.8.8` and
|
|
863
|
+
`dst-port=80,443` to `dst-port=;80;443`, while `comment=a,b` and
|
|
864
|
+
`interface=ether1,ether2` keep the whole run as one string. A named attribute
|
|
865
|
+
therefore carries `["array", "str"]` — both readings, neither validated. In an
|
|
866
|
+
expression position there is no second reading (`:local x 1,2` is a
|
|
867
|
+
two-member `array`, and so is `a,b`), so the hint is singular there. No
|
|
868
|
+
members are located for a bare comma run: whether it splits at all is the
|
|
869
|
+
schema's answer, and a member span would be a guess. The `(1,2)` spelling,
|
|
870
|
+
where the delimiters prove it, is anchored and descended into as usual.
|
|
871
|
+
|
|
872
|
+
The short IPv4 spelling fills the LOW octets and every field is one octet:
|
|
873
|
+
`1.255` is `1.0.0.255` but `1.256` is `time` `00:00:01.256`, `1.16777215` is
|
|
874
|
+
`time` and not `1.255.255.255`, and `1.1.256` is plain text. Offline abstains
|
|
875
|
+
on the seconds fallback rather than encoding a chain that depends on a failed
|
|
876
|
+
address attempt.
|
|
877
|
+
|
|
878
|
+
<!-- BEGIN GENERATED value-census — regenerate with `bun run explain:value-census:readme` -->
|
|
879
|
+
The corpus census is re-derivable with `bun run explain:value-census` and
|
|
880
|
+
covers 948 source scripts. The figures below are generated from
|
|
881
|
+
`test/fixtures/explain/values.json` → `corpus` by
|
|
882
|
+
`bun run explain:value-census:readme` and gated against it by
|
|
883
|
+
`bun run explain:value-census:readme:check`; the fixture itself is gated
|
|
884
|
+
against a fresh corpus run by `bun run explain:value-census:check`. Of the
|
|
885
|
+
13,168 emitted values in a statement the strict argument lexer ALSO read, 0
|
|
886
|
+
disagree with it on half-open byte span or decoded text, while the
|
|
887
|
+
prefix-safe scan retains a further 6,540 values across 569 statements whose
|
|
888
|
+
strict REST reading abstains. Of 19,708 emitted occurrences, 5,636 are array
|
|
889
|
+
members (529 keyed, 1,147 nested inside another member) and 813 are arrays;
|
|
890
|
+
the corpus holds no source-literal `id` example. The three structural
|
|
891
|
+
counters — spans addressing bytes outside their own source, members naming a
|
|
892
|
+
container that does not exist, members escaping the container they name —
|
|
893
|
+
read 0, 0 and 0, and each must stay 0.
|
|
894
|
+
<!-- END GENERATED value-census -->
|
|
895
|
+
|
|
896
|
+
The strict lexer remains all-or-nothing; only non-authoritative hints use the
|
|
897
|
+
wider view.
|
|
898
|
+
|
|
899
|
+
### Offline comment placement (#245)
|
|
900
|
+
|
|
901
|
+
Comment recognition is contextual, not a rule that every unquoted `#` begins a
|
|
902
|
+
comment. The shared offline walkers follow this table, grounded with
|
|
903
|
+
`/console/inspect request=highlight`, `:parse` IL, and `:typeof` on CHR 7.23.3
|
|
904
|
+
(stable) and 7.24rc3 (testing):
|
|
905
|
+
|
|
906
|
+
This intentionally follows the live parser rather than the scripting manual's
|
|
907
|
+
broader prose that a `#` starts a comment. The same live probes distinguish a
|
|
908
|
+
comment, a literal hash value, and a hard error at the exact byte.
|
|
909
|
+
|
|
910
|
+
| Position | Device reading | Offline rule |
|
|
911
|
+
| -------- | -------------- | ------------ |
|
|
912
|
+
| Statement-leading at the document root, after a real statement separator, in a `do`/`else`/`foreach do` body, or immediately inside a `[…]` command substitution | `comment` through newline | Mask it and preserve byte offsets. A command first consumes that lead, so `[:put #test]` keeps `#test` as the value. |
|
|
913
|
+
| First non-space content inside a stored-script brace such as `on-event={ # c` | `comment` | Treat exact `source`/`script` and `on-*`-named brace values as comment-bearing script bodies; a suffix such as `myScript` is not one. |
|
|
914
|
+
| Immediate line start after `\` + newline | `comment`; the pending statement survives | Mask it in both argument views; the continuation-reach rules remain H5/#215. |
|
|
915
|
+
| Inside an array (`{#test}`, `{1;#test}`, `{a=1;#b=2}`) or parenthesized expression (`(1,#test)`) | hard `error` at `#`; `:parse` reports `syntax error` | Do not mask it or emit an array value hint; semantic resolution stops there. |
|
|
916
|
+
| A scope-named brace (`do=`/`else=`/`command=`/`script=`/`source=`/`on-*=`) nested inside an array (`:local z {do={ # c⏎:put 1}}`, `:local z {script={ # c⏎…}}`, `:local z { { # c…}}`) — and likewise a bare `{` or `/menu {` inside an array | hard `error` at `#`; `/console/inspect` request `highlight` classes that byte `error` on CHR 7.23.3 (grounded in #249 on the four `#249` rows plus `bare {`/`source`/`on-event` corners) | A brace bears statements only when its enclosing context already does — `enclosing && braceStartsStatements(…)` on each walker's stack (`contexts`/`delimStack`/`frames`/`stack`). Inside an array the name is an array key and its `{…}` is a nested array. A `[…]` substitution nested in that array does re-enter a statement context, so `[:do { # c…}]` stays a real comment and a bare `{` under a bracket does too. |
|
|
917
|
+
| Inside a `[…]` substitution that is itself nested in an array (`{[:put #test]}`, `{1;[:put #test]}`) | literal value (`none`) — the bracket restores the statement role the array dropped | Keep it as content and keep the enclosing array readable. The role is per frame, so an array or group opened again inside that bracket (`{[:put {#test}]}`, `{[:put (1,#test)]}`) is `error` once more; skipping whole `[…]` regions would wrongly accept those. |
|
|
918
|
+
| In an attribute or bare value (`comment=#test`, `comment=a#b`, `:global y #test`) | literal value (`none` highlight class) | Keep it as content. |
|
|
919
|
+
| Inside a quoted run | string content | Keep it as content. |
|
|
920
|
+
| After a closing scope brace (`} # c`) | hard `error` at `#` | Do not mask it. |
|
|
921
|
+
| After a complete fixed-arity scripting directive (`:local x 2 #`, `:set x 3 #`, `:put 2 #`) | hard `error` at `#`; `:parse` reports `expected end of command` | Emit `explain/canonicalizer/invalid-hash` at the first such byte and fail closed after it. A hash in the still-open value slot (`:local x #test`, `:set x #test`, `:put #test`) remains content. |
|
|
922
|
+
|
|
923
|
+
An otherwise unrecognized `name={…}` is structurally an expression/array, not
|
|
924
|
+
a stored-script body. Whether the target command's schema accepts an array at
|
|
925
|
+
that argument is a separate live schema/runtime question; comment classification
|
|
926
|
+
does not imply type acceptance.
|
|
927
|
+
|
|
928
|
+
This distinction is why brace role lives in one shared primitive below the
|
|
929
|
+
segmenter, block reader, and symbol resolver. The `#249` widening adds the
|
|
930
|
+
single conjunct `enclosing && braceStartsStatements(…)` at the four walker
|
|
931
|
+
sites so a scope-named or bare brace inside an array is another array.
|
|
932
|
+
The strict argument lexer now uses the same comment-masked structural view as value anchoring, so a continuation
|
|
933
|
+
comment cannot fabricate positional operands or downgrade a tested REST shape.
|
|
934
|
+
The 948-script corpus contains no such continuation-comment argument case. The same holds for #249:
|
|
935
|
+
the corpus re-measurement below shows no readable/abstention movement — no
|
|
936
|
+
`do=`/`else=`/`command=`/`script=` brace sits inside an array in the 948
|
|
937
|
+
scripts, so the fix closes a device-grounded false `pass` with zero corpus
|
|
938
|
+
blast radius. The readable set stayed exactly 7,385 statements. Recognizing a bracket-leading hash
|
|
939
|
+
as a comment conservatively folds two previously separate abstentions inside
|
|
940
|
+
one foreign kernel-panic transcript (`[#1]` / `[#2]`) into its already-unknown
|
|
941
|
+
outer statement, leaving 14,329 argument-bearing candidates and 6,944
|
|
942
|
+
abstentions (48.4612%). That two-statement movement is a blast-radius
|
|
943
|
+
measurement, not the grounding evidence; the two CHR versions above are the
|
|
944
|
+
oracle. (Those are the #249-era figures; the #285 catalog correction has since
|
|
945
|
+
moved the readable set to 7,392 — see the re-measurement below.)
|
|
946
|
+
|
|
947
|
+
The same corpus run finds five new `invalid-hash` diagnostics, all in pasted
|
|
948
|
+
non-RouterOS material: one NGINX server block, one JavaScript highlighter, one
|
|
949
|
+
JSON-like transcript, and two shell-shaped snippets. No RouterOS command reading
|
|
950
|
+
moved; these diagnostics say those bytes are not valid RouterOS, not that the
|
|
951
|
+
foreign snippets were expected to parse as RouterOS.
|
|
952
|
+
|
|
953
|
+
The default text renderer surfaces comment spans in a `comments:` section; JSON
|
|
954
|
+
continues to carry the same ranges as `spans[]` entries with `class: "comment"`.
|
|
955
|
+
|
|
956
|
+
Corpus re-measurement for #249 — `bun -e 'explainCommand'` over the 948-script
|
|
957
|
+
`lsp-routeros-ts/test-data/corpus.sqlite` (same harness as § *What `offline` actually emits*):
|
|
958
|
+
|
|
959
|
+
- `stmts` 18,648 · `argCandidates` 14,329 · `argReadable` 7,385 · `abstentions`
|
|
960
|
+
6,944 (48.4612%) — **identical** before and after the `enclosing &&`
|
|
961
|
+
conjunct (stashed vs patched, verified by `git stash --keep-index`).
|
|
962
|
+
- `invalid-hash` diagnostics: 5 files / 5 diagnostics both before and after —
|
|
963
|
+
the widening has no corpus hit, because the corpus contains no `do=`/`else=`/
|
|
964
|
+
`command=`/`script=` brace nested in an array. Zero blast radius is the
|
|
965
|
+
expected outcome; the grounding remains the 15-input CHR probe below
|
|
966
|
+
(CHR 7.23.3 `highlight` at the `#` byte, 0/15 mismatches), not a corpus delta.
|
|
967
|
+
|
|
968
|
+
The #285 catalog correction moved those figures, and only upward. Same harness,
|
|
969
|
+
old table vs new:
|
|
970
|
+
|
|
971
|
+
| | #249 | #285 |
|
|
972
|
+
| --- | ---: | ---: |
|
|
973
|
+
| `stmts` | 18,648 | 18,648 |
|
|
974
|
+
| `argCandidates` | 14,329 | **14,336** |
|
|
975
|
+
| `argReadable` | 7,385 | **7,392** |
|
|
976
|
+
| `abstentions` | 6,944 | 6,944 (**48.4375%**) |
|
|
977
|
+
| `ambiguous-statement` | 91 | **85** |
|
|
978
|
+
| `unresolved-statement` | 3,911 | **3,910** |
|
|
979
|
+
| `context-lost` | 69 | **83** |
|
|
980
|
+
|
|
981
|
+
Six scripts move, and every statement in them is a correction. Seven statements
|
|
982
|
+
that read as `ambiguous`/`unknown` now resolve and their arguments read —
|
|
983
|
+
`/interface/monitor-traffic`, `/system/package/apply-changes`,
|
|
984
|
+
`/system/package/update/{check-for-updates,download,install}` — which is the
|
|
985
|
+
whole of the `argCandidates`/`argReadable` gain, with `abstentions` flat. Five
|
|
986
|
+
more read the same but with the right verb: `/ip hotspot user reset-counters
|
|
987
|
+
[find …]` took `hotspot` as the verb and now takes `reset-counters`, which also
|
|
988
|
+
reclassifies it as a write. No document changes `containsWrite` verdict.
|
|
989
|
+
|
|
990
|
+
`context-lost` rising is the resolver **withdrawing** a certainty claim, not
|
|
991
|
+
losing ground. It is `info` severity and marks a statement that resolved
|
|
992
|
+
correctly while the document context was already unknown. All 14 are in one
|
|
993
|
+
script — a bare list of command paths, one per line, with no navigation
|
|
994
|
+
anywhere. Previously `/interface/monitor-traffic` was unreadable and so *might*
|
|
995
|
+
have been an absolute navigation, which R4 has REPLACE the context; certainty
|
|
996
|
+
was restored on that maybe. Knowing it is a command, the resolver knows it never
|
|
997
|
+
navigated, so the context stays as unknown as it already was.
|
|
998
|
+
|
|
999
|
+
- **Severity is fixed here, because it drives `--fail-on`.** Three buckets, and
|
|
1000
|
+
the split is not "structural vs not":
|
|
1001
|
+
- `error` — `unclosed`, `unbalanced-close`, `unterminated-string`,
|
|
1002
|
+
`bad-escape` (code-position `\` + non-whitespace), `bad-string-escape`
|
|
1003
|
+
(string-internal unknown/lowercase-hex/truncated — CHR `highlight` `error`
|
|
1004
|
+
`:parse` `expected message value`, grounded stable+testing #247),
|
|
1005
|
+
`bad-sigil`,
|
|
1006
|
+
`invalid-hash`. Seven classes the device itself rejects.
|
|
1007
|
+
- `warning` — `over-depth`, because it is centrs's own resource bound and says
|
|
1008
|
+
nothing about whether the input is legal; and an `ambiguous`/`unknown`
|
|
1009
|
+
resolution, never an error, so the default `--fail-on error` cannot fail a
|
|
1010
|
+
document whose only sin is being unreadable without a schema — which is most
|
|
1011
|
+
of RouterOS scripting.
|
|
1012
|
+
- `info` — `bom`/`non-ascii` (positional facts: a legal command must not
|
|
1013
|
+
fail), and `context-lost`, which reports a reading that is correct while the
|
|
1014
|
+
document's menu context was already gone.
|
|
1015
|
+
|
|
1016
|
+
### String escape validation (#247, #252)
|
|
1017
|
+
|
|
1018
|
+
The accepted set is the **device's**, not the manual's. The
|
|
1019
|
+
[Scripting manual's constant-escape table](https://manual.mikrotik.com/docs/developer-guides/scripting/#constant-escape-sequences)
|
|
1020
|
+
is a *lower bound*: it omits `\?`, and it documents the whitespace continuation
|
|
1021
|
+
only under a separate "Line joining" section. Treating that table as a closed
|
|
1022
|
+
allow-list is what #251 did and what #252 had to undo — it made 44 device-valid
|
|
1023
|
+
corpus scripts read `fail`. The set below comes from sweeping `:put "\<c>"` over
|
|
1024
|
+
every byte `0x20`–`0x7E` plus TAB/LF/CR/CRLF on CHR 7.23.3, scored on both the
|
|
1025
|
+
`/console/inspect request=highlight` class and the runtime result:
|
|
1026
|
+
|
|
1027
|
+
- `\"` `\\` `\n` `\r` `\t` `\$` `\_` `\?` `\a` `\b` `\f` `\v`
|
|
1028
|
+
- `\XX` where `XX` is **uppercase** hex
|
|
1029
|
+
- `\` followed by whitespace (space, TAB, CR, LF, CRLF) — a **line
|
|
1030
|
+
continuation**, valid inside a string and not just in code. The device
|
|
1031
|
+
swallows the pair and emits nothing (`:put "a\ b"` → `ab`). CRLF counts as one
|
|
1032
|
+
continuation, so it is the only non-hex three-byte escape.
|
|
1033
|
+
|
|
1034
|
+
Everything else is rejected, including `\` before a non-ASCII byte. `\$` is
|
|
1035
|
+
grounded on `highlight` (`escaped,escaped`, no `error` on CHR 7.23.3/7.24rc3)
|
|
1036
|
+
and the manual; the `:parse` wrapper (`:put [:parse "..."]`) is not used for
|
|
1037
|
+
that escape because outer-string escaping makes the inner `\$` appear as `$` to
|
|
1038
|
+
the parser — for the same reason #252's rows are grounded by executing the input
|
|
1039
|
+
directly rather than through the wrapper. Grounded on CHR 7.23.3 stable and
|
|
1040
|
+
7.24rc3 testing (`highlight` hard `error` + `:parse` `expected message value`
|
|
1041
|
+
for the three issue rows `:put "\q"` / `:put "\x0a"` / `:put "\0a"` vs
|
|
1042
|
+
`":put "\0A"` valid):
|
|
1043
|
+
|
|
1044
|
+
- A malformed escape (`\q`, `\x`, `\x0a`, truncated single-hex `\0`, lowercase
|
|
1045
|
+
hex `\0a`/`\5f`) is a located `bad-string-escape` structural defect
|
|
1046
|
+
(severity `error`) at the byte the device marks `error` — the unknown char or
|
|
1047
|
+
the lowercase second hex digit. For a truncated single-hex escape the device
|
|
1048
|
+
marks the closing quote; centrs reports at the backslash instead, because that
|
|
1049
|
+
byte is stable when the string is unterminated.
|
|
1050
|
+
- `\ff` is `\f` + literal `f` and `\aF` is `\a` + `F`; neither is invalid hex.
|
|
1051
|
+
- The one shared `scanQuotedString` below every walker still **recovers the
|
|
1052
|
+
closing quote** past a malformed escape, so no #199 nested-substitution
|
|
1053
|
+
quote-phase bug is reintroduced. Validation is a shared
|
|
1054
|
+
`collectStringEscapeDefects` walk with the same substitution frames; only the
|
|
1055
|
+
first invalid escape per document is reported and it fails closed after it.
|
|
1056
|
+
- Corpus blast radius (948 scripts): **4** `bad-string-escape` diagnostics (vs
|
|
1057
|
+
15 code-position `bad-escape`), and the device rejects all 4. Under #251's
|
|
1058
|
+
closed allow-list this was 62 flags of which **44 were false positives on
|
|
1059
|
+
valid RouterOS** — mostly multi-line `source=`/`on-event=` strings (`\`+LF)
|
|
1060
|
+
and `\?`.
|
|
1061
|
+
- Scoring a defect rule against the corpus needs the right oracle.
|
|
1062
|
+
`parseil_results.ok` is **not** one: `:parse` never throws, it returns a
|
|
1063
|
+
diagnostic *value*, so the capture records `ok=1 / status="ok"` for scripts
|
|
1064
|
+
the device rejected (only 3 of 2739 rows are `ok=0`, and those are capture
|
|
1065
|
+
failures). The verdict is in `il_text` — plain prose such as
|
|
1066
|
+
`expected input value (line 7 column 49)` means rejected, IL means accepted.
|
|
1067
|
+
|
|
1068
|
+
### The operator surface (#255)
|
|
1069
|
+
|
|
1070
|
+
Grounded, not transcribed, for the same reason the escape set above is: the
|
|
1071
|
+
[manual's operator list](https://manual.mikrotik.com/docs/developer-guides/scripting/index.md#operators)
|
|
1072
|
+
is a lower bound. `bun run explain:probe:operators` sweeps the manual's list
|
|
1073
|
+
**plus** plausible non-operators (`not`, `..`, `xor`, `mod`, `is`, `div`, …)
|
|
1074
|
+
**plus** every IL head the corpus census saw, on CHR 7.23.3 stable, 7.24rc4
|
|
1075
|
+
testing and 7.21.5 long-term — one build per release channel, so a claim about
|
|
1076
|
+
any channel is evidence rather than an assumption. Re-cut the fixture with
|
|
1077
|
+
`bun run explain:operator-slice`; the table
|
|
1078
|
+
below is generated from `src/explain/operators.ts` by
|
|
1079
|
+
`bun run explain:operator-readme` and gated by
|
|
1080
|
+
`bun run explain:operator-readme:check`.
|
|
1081
|
+
|
|
1082
|
+
**`:parse` IL is the oracle, and `highlight` cannot be.** IL is prefix form with
|
|
1083
|
+
the operator as its node's head, so it names the operator and shows its
|
|
1084
|
+
operands. `highlight`'s `syntax-meta` is the device's *residual structure*
|
|
1085
|
+
class — measured over `test/fixtures/explain/highlight-streams.slice.json` it
|
|
1086
|
+
covers `=`, `"`, `$`, brackets, braces, parens, `;`, `,`, `/` and whitespace
|
|
1087
|
+
runs as well as `||`, `&&`, `!=`, `.` and `~` — and adjacent runs are **merged**
|
|
1088
|
+
(`="`, `($`, `")+` each arrive as one token; the run around `and`, `or` and `in`
|
|
1089
|
+
swallows the preceding space). An operator boundary is not recoverable from it.
|
|
1090
|
+
What it does decide is *structure versus word*, which is what rules `not` out:
|
|
1091
|
+
it comes back `variable-undefined`.
|
|
1092
|
+
|
|
1093
|
+
Five things the device says that the manual does not:
|
|
1094
|
+
|
|
1095
|
+
1. **`not` is not an operator.** Nor are `xor`, `mod`, `is`, `div`, `band`,
|
|
1096
|
+
`bor`, `shl`, `shr`, `eq`, `ne`. Inside a paren group a bare word is a
|
|
1097
|
+
**variable reference**, so `(1 not 2)` parses — as `( 1 $not 2)`, with an
|
|
1098
|
+
unnamed juxtaposition node. "It parsed" is therefore not evidence of an
|
|
1099
|
+
operator, which is what the sweep's `(1 zzz 2)` control exists to prove.
|
|
1100
|
+
2. **`..` is not a range operator** — `(1 .. 2)` is `( (. 1 $.) 2)`, concat
|
|
1101
|
+
applied to a variable named `.`. Same for `//`. And `<>` is not "not equal":
|
|
1102
|
+
`(1 <> 2)` is `(< 1 (> 2))`, less-than applied to a *deferred* `2`.
|
|
1103
|
+
3. **`$`, `[`, `]` are syntax, not operator heads.** `$x` stays an atom in the
|
|
1104
|
+
IL and `[:tostr 1]` lowers to an `evl` node. Those bytes belong to the
|
|
1105
|
+
substitution axis.
|
|
1106
|
+
4. **`any` is an operator** (prefix, arity 1) and is not in the manual's list — a **nil-check**: `:typeof (any x)` is `bool`, `false` only for `nil`/`nothing` (the value of an undefined `:local` and of `[:nothing]`), `true` for everything else including `0`, `""` and `false`. It is the idiom `:if (any $x) ...` to test a variable that may be `nil`; `(true any false)` is not infix at all but juxtaposition `( true (any false))`, and `(1 . any [:nothing])` is concat `1`+`false`. Present since at least 7.20.8 (corpus `any|7.20.8:2`, and swept live on 7.21.5 long-term, 7.23.3 stable and 7.24rc4 testing with no difference between them).
|
|
1107
|
+
5. **`&&` and `||` are spellings**, lowering to the `and` and `or` nodes.
|
|
1108
|
+
|
|
1109
|
+
The `(>…)` and `<%%` forms are in the table on the same footing as `+`.
|
|
1110
|
+
`(> x)` at arity 1 is the deferred-expression form — `[:typeof (>[:return 1])]`
|
|
1111
|
+
is `op` — while `(2 > 1)` at arity 2 is the comparison; **arity is the only
|
|
1112
|
+
thing that separates them**, so a table keyed on spelling alone gets one wrong.
|
|
1113
|
+
`<%%` applies a deferred expression to an argument array, binding positionals
|
|
1114
|
+
from `$0`; a `do={…}` function binds the same arguments from `$1`, because
|
|
1115
|
+
there `$0` is the function's own name.
|
|
1116
|
+
|
|
1117
|
+
Both `!` and `any` are prefix-only, so the *pair* sweep cannot carry them and
|
|
1118
|
+
their `precedence` is honestly `null`. That is "unmeasured", not "unknown": the
|
|
1119
|
+
sweep asks them separately, every `(U 1 B 2)` and `(1 B U 2)` for
|
|
1120
|
+
`U`∈`!`,`any`,`~`,`-`,`>` against all 24 binaries — 240 probes, recorded in the
|
|
1121
|
+
fixture's `unary` block. All 240 are accepted and the **binary is outer in every
|
|
1122
|
+
one**, on all three versions, so each prefix operator binds tighter than every
|
|
1123
|
+
binary including `->` (14) and `<%%` (13). The table stores one `precedence` per
|
|
1124
|
+
spelling, which is the binary level; `~`, `-` and `>` are here because their
|
|
1125
|
+
unary reading has no other record.
|
|
1126
|
+
|
|
1127
|
+
Spacing decides the *tokens*, not just the tree. `(1.2)` is an IP literal and
|
|
1128
|
+
`(.1)` a time literal, but the rest of the matrix is where a tokenizer goes
|
|
1129
|
+
wrong: `(1 . 2)` and `(1 .2)` are both concat (`(. 1 2)`), while **`(1. 2)` is
|
|
1130
|
+
not concat at all** — `1.` lexes as a *variable name* and the row comes back as
|
|
1131
|
+
juxtaposition, `( $1. 2)`. A rule that claims every `.` byte for the operator
|
|
1132
|
+
emits a span the device does not have.
|
|
1133
|
+
|
|
1134
|
+
Everything in the sweep is identical across 7.21.5, 7.23.3 and 7.24rc4 except
|
|
1135
|
+
one runtime row: `:put ({2;1} > {1;2;3})` evaluates to `true` only on 7.24rc4.
|
|
1136
|
+
Identical is measured, not assumed — the slice diffs verdict, arity, precedence,
|
|
1137
|
+
associativity, `highlight` run, unary placement and op-axis per version. So the
|
|
1138
|
+
operator table needs no version gate; anything reporting what a comparison
|
|
1139
|
+
*means* does.
|
|
1140
|
+
|
|
1141
|
+
<!-- BEGIN GENERATED operator-table — regenerate with `bun run explain:operator-readme` -->
|
|
1142
|
+
The device builds a node for **26** spellings, reads **3**
|
|
1143
|
+
as something else, and refuses the other **33** the sweep asked about.
|
|
1144
|
+
|
|
1145
|
+
| operator | arity | precedence | associativity | category |
|
|
1146
|
+
| -------- | ----- | ---------- | ------------- | -------- |
|
|
1147
|
+
| `,` | 2 | 1 | variadic | concatenation |
|
|
1148
|
+
| `or` | 2 | 2 | variadic | logical |
|
|
1149
|
+
| `and` | 2 | 3 | variadic | logical |
|
|
1150
|
+
| `in` | 2 | 4 | left | logical |
|
|
1151
|
+
| `<` | 2 | 5 | left | relational |
|
|
1152
|
+
| `>` | 1, 2 | 5 | left | relational |
|
|
1153
|
+
| `=` | 2 | 5 | left | relational |
|
|
1154
|
+
| `<=` | 2 | 5 | left | relational |
|
|
1155
|
+
| `>=` | 2 | 5 | left | relational |
|
|
1156
|
+
| `!=` | 2 | 5 | left | relational |
|
|
1157
|
+
| `~` | 1, 2 | 5 | left | relational |
|
|
1158
|
+
| `.` | 2 | 6 | variadic | concatenation |
|
|
1159
|
+
| `<<` | 2 | 7 | right | bitwise |
|
|
1160
|
+
| `>>` | 2 | 7 | right | bitwise |
|
|
1161
|
+
| `\|` | 2 | 8 | variadic | bitwise |
|
|
1162
|
+
| `^` | 2 | 9 | variadic | bitwise |
|
|
1163
|
+
| `&` | 2 | 10 | variadic | bitwise |
|
|
1164
|
+
| `+` | 2 | 11 | variadic | arithmetic |
|
|
1165
|
+
| `-` | 1, 2 | 11 | left | arithmetic |
|
|
1166
|
+
| `*` | 2 | 12 | variadic | arithmetic |
|
|
1167
|
+
| `/` | 2 | 12 | left | arithmetic |
|
|
1168
|
+
| `%` | 2 | 12 | left | arithmetic |
|
|
1169
|
+
| `<%%` | 2 | 13 | right | apply |
|
|
1170
|
+
| `->` | 2 | 14 | left | access |
|
|
1171
|
+
| `!` | 1 | not measured | not measured | logical |
|
|
1172
|
+
| `any` | 1 | not measured | not measured | logical |
|
|
1173
|
+
|
|
1174
|
+
Precedence runs 1 (loosest) to 14 (tightest), measured over every
|
|
1175
|
+
ordered pair rather than transcribed. `variadic` means the device FLATTENS
|
|
1176
|
+
the operator — `(1 + 2 + 3)` is one node with three children, not two nested
|
|
1177
|
+
ones. The 2 prefix-only operators never appear in a pair and so
|
|
1178
|
+
carry no measured level.
|
|
1179
|
+
|
|
1180
|
+
Spellings the device reads as something else:
|
|
1181
|
+
|
|
1182
|
+
| spelling | reads as | kind |
|
|
1183
|
+
| -------- | -------- | ---- |
|
|
1184
|
+
| `&&` | `(<%% (and 1 2) )` | alias |
|
|
1185
|
+
| `\|\|` | `(<%% (or 1 2) )` | alias |
|
|
1186
|
+
| `<>` | `(<%% (< 1 (> 2)) )` | re-lexed |
|
|
1187
|
+
|
|
1188
|
+
And the grounded complement — asked, and refused:
|
|
1189
|
+
|
|
1190
|
+
| spelling | why not |
|
|
1191
|
+
| -------- | ------- |
|
|
1192
|
+
| `not` | reads-as-variable |
|
|
1193
|
+
| `xor` | reads-as-variable |
|
|
1194
|
+
| `mod` | reads-as-variable |
|
|
1195
|
+
| `is` | reads-as-variable |
|
|
1196
|
+
| `div` | reads-as-variable |
|
|
1197
|
+
| `band` | reads-as-variable |
|
|
1198
|
+
| `bor` | reads-as-variable |
|
|
1199
|
+
| `shl` | reads-as-variable |
|
|
1200
|
+
| `shr` | reads-as-variable |
|
|
1201
|
+
| `eq` | reads-as-variable |
|
|
1202
|
+
| `ne` | reads-as-variable |
|
|
1203
|
+
| `line` | reads-as-variable |
|
|
1204
|
+
| `array` | reads-as-variable |
|
|
1205
|
+
| `as` | reads-as-variable |
|
|
1206
|
+
| `at` | reads-as-variable |
|
|
1207
|
+
| `for` | reads-as-variable |
|
|
1208
|
+
| `none` | reads-as-variable |
|
|
1209
|
+
| `outside` | reads-as-variable |
|
|
1210
|
+
| `evl` | reads-as-variable |
|
|
1211
|
+
| `..` | reads-as-variable |
|
|
1212
|
+
| `//` | reads-as-variable |
|
|
1213
|
+
| `**` | rejected |
|
|
1214
|
+
| `===` | rejected |
|
|
1215
|
+
| `+=` | rejected |
|
|
1216
|
+
| `++` | rejected |
|
|
1217
|
+
| `?` | rejected |
|
|
1218
|
+
| `?:` | rejected |
|
|
1219
|
+
| `<%` | rejected |
|
|
1220
|
+
| `%%` | rejected |
|
|
1221
|
+
| `;` | rejected |
|
|
1222
|
+
| `$` | juxtaposition-only |
|
|
1223
|
+
| `[` | rejected |
|
|
1224
|
+
| `]` | rejected |
|
|
1225
|
+
<!-- END GENERATED operator-table -->
|
|
1226
|
+
|
|
1227
|
+
<!-- BEGIN GENERATED token-census — regenerate with `bun run explain:token-census:readme` -->
|
|
1228
|
+
The token census is re-derivable with `bun run explain:token-census` and
|
|
1229
|
+
covers 948 source scripts. The figures below are generated from
|
|
1230
|
+
`test/fixtures/explain/tokens.json` → `corpus` by
|
|
1231
|
+
`bun run explain:token-census:readme` and gated against it by
|
|
1232
|
+
`bun run explain:token-census:readme:check`; the fixture itself is gated
|
|
1233
|
+
against a fresh corpus run by `bun run explain:token-census:check`. Of
|
|
1234
|
+
1,426,731 analyzed bytes, 990,840 are classified (69.45%), the remaining
|
|
1235
|
+
435,891 are `unclassified`. The census emits 205,072 tokens (avg 216.3 per
|
|
1236
|
+
script). Every byte belongs to exactly one token — sorted by `start`, no
|
|
1237
|
+
gaps, no overlaps, `join(slice) === input` — and the `class` field is
|
|
1238
|
+
provisional until #264 B5. Each B2 fill should move the classified
|
|
1239
|
+
percentage.
|
|
1240
|
+
<!-- END GENERATED token-census -->
|
|
1241
|
+
|
|
1242
|
+
B1's `data.tokens[]` is live behind `--tokens` — a total, gapless byte
|
|
1243
|
+
partition whose `class` is provisional until #264 B5 (every unclaimed byte is
|
|
1244
|
+
`unclassified`). Since B1 its only fill source was `data.spans[]` (comment runs
|
|
1245
|
+
and resolved variable occurrences); **the path fill claims resolved menu and
|
|
1246
|
+
command-name bytes as provisional `dir` / `cmd` tokens**, including valid path
|
|
1247
|
+
slashes and nested command substitutions, while ambiguous, malformed, and
|
|
1248
|
+
source-unmapped runs stay unclassified. **`#293`'s arg fill** in
|
|
1249
|
+
`src/explain/arg-tokens.ts` then claims argument names and
|
|
1250
|
+
their `=` (name run
|
|
1251
|
+
`[span.start, valueSpan.start - 1)` plus the single `=` byte at
|
|
1252
|
+
`valueSpan.start - 1`) on the residual left by spans, before operators see it
|
|
1253
|
+
— and **`#295`'s value fill follows it** — `src/explain/value-tokens.ts`
|
|
1254
|
+
claims argument value bytes and leaf array-literal members
|
|
1255
|
+
(`data.values.occurrences`, leaves only via `parent` containment,
|
|
1256
|
+
quotes-included) on the residual left by `spans`+`path`+`arg`, before the
|
|
1257
|
+
**B3 string/brace fills** see it — `src/explain/string-tokens.ts` claims
|
|
1258
|
+
quoted-string runs (delimiters included) and `src/explain/brace-tokens.ts`
|
|
1259
|
+
claims every residual brace delimiter (`{`/`}`), including delimiters from
|
|
1260
|
+
invalid braced references, on the residual left by
|
|
1261
|
+
`spans`+`path`+`arg`+`value`, and finally `operator` sees only what none of
|
|
1262
|
+
them wanted — which is why the operator fill can abstain on `, / = -` outside
|
|
1263
|
+
`( )`, on bytes glued after an argument `=`, and on bytes glued into an
|
|
1264
|
+
argument name without a smarter operator scanner. Fill order is enforced by
|
|
1265
|
+
argument order to `buildTokens` ([#290 design decision 1](../../src/explain.ts)):
|
|
1266
|
+
`spans` claims first, then `path`, `arg`, `value`, `string`, `brace`, and
|
|
1267
|
+
finally `operator`.
|
|
1268
|
+
`ExplainTokenClass` is
|
|
1269
|
+
`ExplainSpanClass | "dir" | "cmd" | "operator" | "arg" | "value" | "string" |
|
|
1270
|
+
"brace" | "unclassified"` — one
|
|
1271
|
+
provisional `operator` class for all 26 spellings + 2 aliases, one provisional
|
|
1272
|
+
`arg` class for both the name bytes and the `=` separator, one provisional
|
|
1273
|
+
`value` class for every leaf value span, plus provisional `string` (quoted
|
|
1274
|
+
runs) and `brace` (scope delimiters) — emit first, name later (whether the `=`
|
|
1275
|
+
or per-shape `value` later deserve their own classes is #264 B5 and does not
|
|
1276
|
+
move the byte coverage), not per-operator/per-category/shape (`#264` B5).
|
|
1277
|
+
`ExplainSpanClass` and `data.spans[]` stay proof-only; `src/explain/operators.ts`
|
|
1278
|
+
remains data plus accessors, and the operator table above is its source. The
|
|
1279
|
+
`centrs → highlight` projection is B4 and reads both.
|
|
1280
|
+
|
|
1281
|
+
**Where the operator fill abstains.** Fill order is the resolution mechanism, so
|
|
1282
|
+
a byte that is structurally part of a path or an argument is left
|
|
1283
|
+
`unclassified` for the path/arg fills rather than claimed. Three abstentions,
|
|
1284
|
+
each grounded on the corpus device oracle (`parseil_results.il_text`):
|
|
1285
|
+
|
|
1286
|
+
| Abstention | Grounding |
|
|
1287
|
+
| ---------- | --------- |
|
|
1288
|
+
| `, / = -` outside a `( )` group. `(` opens an expression; `[` opens a **command substitution** and `{` a block or array literal. | The IL for `[ /system/identity/get value-name=name ]` is `(evl /system/identity/get value-name=name)` — path separators and an argument separator, no division and no comparison node. |
|
|
1289
|
+
| A spelling glued immediately after an argument `=`. | `in-interface-list=!LAN`, `.id=*2`, `oid=.1.3.6.1.2.1` — the byte after an argument `=` starts the value. |
|
|
1290
|
+
| A spelling glued into an argument **name**. | `:foreach x in=$list` is `/foreach counter=$x` with no `(in …)` node anywhere; the IL keeps `security.authentication-types=wpa2-psk` as one name and renders `.id` as the single symbol `$.id`, never `(. …)`. |
|
|
1291
|
+
|
|
1292
|
+
The first abstention has a measured cost: `find where name="x"` inside `[ … ]`
|
|
1293
|
+
*does* lower to a real `(= $name x)` node, so 224 of the corpus's 1,259
|
|
1294
|
+
bracket-interior `=` bytes are genuine comparisons that now stay
|
|
1295
|
+
`unclassified` — against 1,035 that were plain `arg=value`. A `where`-aware
|
|
1296
|
+
fill can take them back later; claiming all 1,259 would be 82% wrong.
|
|
1297
|
+
|
|
1298
|
+
### Designed, not implemented (the CLI surface, #202b)
|
|
1299
|
+
|
|
1300
|
+
`src/cli/explain.ts` ships the offline command; implemented flags are generated
|
|
1301
|
+
into [`docs/CLI.md`](../../docs/CLI.md) and are not restated here. What the
|
|
1302
|
+
[Surface](#surface-option-a--decided) table above describes and the command does
|
|
1303
|
+
**not** yet accept:
|
|
1304
|
+
|
|
1305
|
+
| Flag / form | Phase | Why it is not accepted yet |
|
|
1306
|
+
| ----------- | ----- | -------------------------- |
|
|
1307
|
+
| `explain <router> '<input>'` | 2 | The grammar is parsed and **refused** with `usage/not-implemented`, naming the router it did not contact. Running the offline analysis under an invocation that asked for probes would report `mode: "offline"` about a request for more. |
|
|
1308
|
+
| `--cursor <byte>` | 2 | It positions `--complete`, which enumerates nothing offline. A cursor with no candidate surface to position is a flag with no effect. |
|
|
1309
|
+
| `--full` | 3 | It lifts smart-sizing limits, and the offline result is never truncated (`input.truncated` is always `false`). Same rule: no accepted flag is a no-op. |
|
|
1310
|
+
|
|
1311
|
+
`--complete` and `--schema` **are** accepted, because they have an offline
|
|
1312
|
+
behavior the spec names: they enumerate nothing and emit the live-target tip.
|
|
1313
|
+
That is the difference — an accepted flag must do something observable.
|
|
1314
|
+
|
|
1315
|
+
## MCP and library surfaces
|
|
1316
|
+
|
|
1317
|
+
- **The cross-project handoff is the structure layer, not the token layer.**
|
|
1318
|
+
`explain`'s data is three layers: byte ranges and their classes
|
|
1319
|
+
(`spans`, and `tokens` when #264 lands) — centrs-owned, and nothing outside
|
|
1320
|
+
centrs consumes them; the resolved reading (`structure`, `symbols`,
|
|
1321
|
+
`values`) — path, verb, argument names, value spans; and doc/version
|
|
1322
|
+
enrichment, which is rosetta's (#175). A consumer that wants enrichment takes
|
|
1323
|
+
the **middle** layer: given `{path, verb, args}` plus a version it does its
|
|
1324
|
+
own lookups, whereas handing it tokens would make it re-derive from bytes a
|
|
1325
|
+
path centrs already resolved. So #223 is a decision about the structure
|
|
1326
|
+
layer, and the token vocabulary (#264) is deliberately outside its scope —
|
|
1327
|
+
the two do not need to be sequenced against each other. The same split says
|
|
1328
|
+
where a schema-shaped gap belongs: the per-path argument list `catalog.ts`
|
|
1329
|
+
refuses to be is enrichment, not lexing.
|
|
1330
|
+
- `centrs_explain` today wraps `canonicalizeExecuteCommand` (offline, no CDB —
|
|
1331
|
+
the only tool that serves without one). It grows toward this spec
|
|
1332
|
+
**following the CLI scheme** (decision): same facets, same envelope-shaped
|
|
1333
|
+
data as the CLI's `--json`, optionally a CDB-resolved target for live
|
|
1334
|
+
evidence. The no-CDB offline path must survive, and the MCP adapter must
|
|
1335
|
+
not widen the structured gate. The current flat `centrs_explain` shape
|
|
1336
|
+
(`{ input, mode, path, verb, attributes, queries, writeShaped }`) is
|
|
1337
|
+
**superseded** when this lands — a deliberate pre-1.0 breaking change, with
|
|
1338
|
+
`commands/mcp/` examples and integration tests updated in the same change;
|
|
1339
|
+
no dual-shape compatibility layer is planned. **That supersession is held out
|
|
1340
|
+
of #202 and tracked in #223** (maintainer decision): `centrs_explain` and
|
|
1341
|
+
rosetta's tool surface must stay aligned for agent usage, which is a
|
|
1342
|
+
cross-project decision. Until #223 lands the MCP keeps its flat shape, so the
|
|
1343
|
+
CLI's `--json` and the MCP's `data` are **deliberately divergent**.
|
|
1344
|
+
- Library: `explainCommand(input, options?: ExplainCommandOptions)` is the
|
|
1345
|
+
offline analysis (`src/explain.ts`, #202a/#202c) and
|
|
1346
|
+
`explainEnvelope(input, options?: ExplainEnvelopeOptions)` wraps it in the
|
|
1347
|
+
standard envelope. Both option types are exported. Phase 2 extends that same
|
|
1348
|
+
analysis bag with `target` and `facets`; `curl` stays an orthogonal rendering
|
|
1349
|
+
request, so offline and live calls do not need competing second-argument
|
|
1350
|
+
shapes. `lsp-routeros-ts` and
|
|
1351
|
+
tikbook are the intended external consumers (hover/diagnostics/completion/
|
|
1352
|
+
semantic tokens over these calls); an LSP *protocol* surface on centrs
|
|
1353
|
+
stays out of scope (#90) — but the export shape is validated against a real
|
|
1354
|
+
LSP consumption spike before it hardens (staging phase 4).
|
|
1355
|
+
|
|
1356
|
+
## Non-goals
|
|
1357
|
+
|
|
1358
|
+
- No LSP server in centrs — the LSP is a consumer the library must support,
|
|
1359
|
+
nothing more.
|
|
1360
|
+
- No execution probes; no state mutation of any kind.
|
|
1361
|
+
- No fan-out.
|
|
1362
|
+
- No rosetta/restraml integration at runtime (decision, amended by #228): no
|
|
1363
|
+
shell-outs, no calls, no vendored *schema* artifact, no version-history facts.
|
|
1364
|
+
The two generated structure tables are built from pinned public sources by
|
|
1365
|
+
scripts in `scripts/`, and centrs vendors its own CLI-Reference ETL rather
|
|
1366
|
+
than depending on rosetta for it. Output may tip toward rosetta for
|
|
1367
|
+
docs/version questions; deeper integration waits on centrs's shape settling
|
|
1368
|
+
and rosetta publishing stable consumable surfaces.
|
|
1369
|
+
- No promise of enum exhaustiveness or of the upstream `completion-tricks`
|
|
1370
|
+
synthetic-probe recipes (still open research in lsp-routeros-ts
|
|
1371
|
+
`BACKLOG.md`) — candidates are labeled as observed, not closed sets.
|
|
1372
|
+
|
|
1373
|
+
## Phase-0 ratification (#185)
|
|
1374
|
+
|
|
1375
|
+
The phase-0 canonicalization grounding lab (#185, rev 2) is complete. It scored
|
|
1376
|
+
a schema-free offline parser against device truth — `:parse` IL and
|
|
1377
|
+
`/console/inspect` highlight, cross-checked on CHR 7.23.2 and 7.24rc2 — over a
|
|
1378
|
+
frozen train/holdout split of the 913-script lsp-routeros-ts corpus, plus
|
|
1379
|
+
constructed corner and mutation suites. Each question below names the spec claim
|
|
1380
|
+
it gated and links its exit artifact (the artifact carries the fixture/split
|
|
1381
|
+
hashes, RouterOS versions, oracle source, per-class precision/recall/abstention,
|
|
1382
|
+
and disagreement samples). Probes are throwaway (`.scratch/`, non-authoritative);
|
|
1383
|
+
the findings here are the deliverable, promoted per
|
|
1384
|
+
`.github/instructions/routeros-grounding.instructions.md`.
|
|
1385
|
+
|
|
1386
|
+
**Ratified as designed:** Q1 statement segmentation · Q2 block topology · Q3
|
|
1387
|
+
`[]` sub-command path re-constitution · Q4 stateful-per-document path context ·
|
|
1388
|
+
Q6 no-schema verb/menu boundary (**decision 3 unchanged**) · Q8 tested REST
|
|
1389
|
+
mapping rules + fail-closed boundary · Q10 `.proplist` recipe · Q13 symbol scope
|
|
1390
|
+
classes.
|
|
1391
|
+
|
|
1392
|
+
**Amended the surface (folded in above):**
|
|
1393
|
+
|
|
1394
|
+
| Finding | Amendment | Exit artifact |
|
|
1395
|
+
| ------- | --------- | ------------- |
|
|
1396
|
+
| **Q16** write-shape | `structure.containsWrite` is three-valued `true \| false \| unknown` (boolean rejected as unsafe); zero false negatives on obvious writes; `unknown` separate from `false` | [#5027578882](https://github.com/tikoci/centrs/issues/185#issuecomment-5027578882) |
|
|
1397
|
+
| **Q6/Q3/Q13/Q14** schema-free floor | a distinct **`ambiguous`** canonicalizer verdict; fail-closed rule promoted to the constitution | [#5025480566](https://github.com/tikoci/centrs/issues/185#issuecomment-5025480566) |
|
|
1398
|
+
| **Q14** malformed/partial | diagnostics carry a **defect region**; fail-closed recovery (no invented commands after a defect; bare-word heads → `unknown`); `--complete` bounded offline | [#5048400737](https://github.com/tikoci/centrs/issues/185#issuecomment-5048400737) |
|
|
1399
|
+
| **Q15** coordinates | ASCII-normalization is **byte-count-preserving** (non-ASCII byte → placeholder, one-for-one) so analyzed offsets ≡ highlight byte offsets; invariants validated 100% | [#5048304595](https://github.com/tikoci/centrs/issues/185#issuecomment-5048304595) |
|
|
1400
|
+
| **Q11** native-api readout | the cell is **not** degraded — `:put [:parse …]` over native `/execute` with `as-string` returns line/column; the old console-only caveat is retired | [#5046924529](https://github.com/tikoci/centrs/issues/185#issuecomment-5046924529) |
|
|
1401
|
+
| **Q17** robustness | product parser bounds nesting depth (over-depth diagnostic) and scans single-pass; determinism + well-formed span trees are property tests | [#5040370326](https://github.com/tikoci/centrs/issues/185#issuecomment-5040370326) |
|
|
1402
|
+
|
|
1403
|
+
**Pre-registered thresholds — outcomes:** `api-candidate`/write-shape precision
|
|
1404
|
+
and coordinate invariants met; the **one hard-0 threshold that was *not* met is
|
|
1405
|
+
Q14's "no fabrication after the first defect"** — the throwaway lab walker
|
|
1406
|
+
invents a command in 3 of 76 mutants, which is exactly why the fail-closed
|
|
1407
|
+
bare-word rule (b) above is a **hard product requirement**, not a nicety. The lab
|
|
1408
|
+
probes were left unpatched by design; the guard belongs in the product parser.
|
|
1409
|
+
|
|
1410
|
+
**Corpus coverage caveat (#203).** Every figure above was measured on the
|
|
1411
|
+
913-script corpus, which is **96.8% two forum authors** (`amm0` 630,
|
|
1412
|
+
`rextended` 254) and **53.8% scripting-directive-bearing**. Pure `add`/`set`
|
|
1413
|
+
configuration is **2.3%** (21 files), genuine device-emitted `/export` output
|
|
1414
|
+
is **1.0%** (9 files), and the three `/export` serializations — `compact`,
|
|
1415
|
+
`terse`, `verbose` — were never captured at phase 0. Nearly every file also
|
|
1416
|
+
opens with a harness-injected `# Source: …` line the device never wrote (884
|
|
1417
|
+
of 913). The ratified answers hold for what was measured; they are **not**
|
|
1418
|
+
evidence of coverage over device configuration, which is the genre most likely
|
|
1419
|
+
to be handed to `explain` and the one that concentrates Q4 path context and
|
|
1420
|
+
Q6's bare-path ambiguity. Quote phase-0 numbers per genre, never as one blended
|
|
1421
|
+
figure. #203 tracks the capture and the re-score.
|
|
1422
|
+
|
|
1423
|
+
Those figures are re-derivable, not eyeballed: `bun run explain:corpus-census`
|
|
1424
|
+
(`scripts/explain-corpus-census.ts`) reproduces the whole genre table from
|
|
1425
|
+
`corpus.sqlite`, with a per-collection stratum breakdown, and is the source of
|
|
1426
|
+
truth over #203's prose where the two disagree — it corrects the issue's
|
|
1427
|
+
export-banner count (9, not 8: the issue's own 22 − 13 shebangs) and its
|
|
1428
|
+
pure-config count (21, not 23: the first pass counted MAC addresses, IPv6
|
|
1429
|
+
literals, and DHCP client-ids as `:` directives, which under-counts exactly the
|
|
1430
|
+
config genre in question). Since phase 0 the corpus has grown to 948 with a
|
|
1431
|
+
`tangentsoft` stratum of 35 genuine `/export … terse` device captures, taking
|
|
1432
|
+
export-banner share to 4.6%; **that stratum is not part of any phase-0 figure
|
|
1433
|
+
above**, and no promoted module has been scored against it yet (#203
|
|
1434
|
+
deliverable 2).
|
|
1435
|
+
|
|
1436
|
+
The corpus itself is not in this repo and is not moving here — `lsp-routeros-ts`
|
|
1437
|
+
owns producing snapshots, centrs owns which snapshot it measures against.
|
|
1438
|
+
`scripts/corpus-pin.json` is that second half: the commit and the blob sha256 a
|
|
1439
|
+
census run measures. It identifies the **current** 948-script snapshot only —
|
|
1440
|
+
the phase-0 figures above were measured on the frozen 913-script corpus and no
|
|
1441
|
+
pin describes them. `bun run corpus:fetch` downloads and verifies it, so the
|
|
1442
|
+
censuses run from a bare clone and in CI rather than on one machine (#186).
|
|
1443
|
+
|
|
1444
|
+
**All three export serializations parse clean offline (#203 deliverable 2,
|
|
1445
|
+
re-scored after #207).** Every one of the 120 CHR captures — `compact`, `terse`
|
|
1446
|
+
and `verbose` × `hide-sensitive` / `show-sensitive` × both pinned versions —
|
|
1447
|
+
runs through the promoted modules with **zero segmenter structural notes, 100%
|
|
1448
|
+
Q4 path resolution, and 0.0% Q16 abstention with no blockers anywhere in the
|
|
1449
|
+
stratum**. The last figure is new: before #207's baked menu table, the
|
|
1450
|
+
`isConfirmedNav` hyphen rule abstained on 25 distinct genuine menus. This is
|
|
1451
|
+
the coverage claim that matters for `explain`, and it holds.
|
|
1452
|
+
|
|
1453
|
+
**Q6's bare-path abstention is closed on the same table (#210).** `verbsplit.ts`
|
|
1454
|
+
originally did not consume `menus.ts`, so Q6 kept returning `ambiguous` on
|
|
1455
|
+
`compact`/`verbose` for exactly the bare-path lines the container table can
|
|
1456
|
+
decide. It now reads that table at V4 — the one case the ratified `proposed`
|
|
1457
|
+
rule refuses — and emits a `navigation` resolution when the path is a known
|
|
1458
|
+
menu. Abstention on device-emitted export goes to **0.0% on every stratum**
|
|
1459
|
+
(from 37.6–38.4% on `compact`/`verbose`; `terse` was already 0% by
|
|
1460
|
+
construction, since it never emits a bare menu line), and on the frozen corpus
|
|
1461
|
+
from 4.0% → **1.4%** dev and 1.2% → **0.5%** holdout.
|
|
1462
|
+
|
|
1463
|
+
The table is consulted in `resolveVerb`, never in `splitRun`, so the ratified
|
|
1464
|
+
schema-free rule still prices decision 3 on its own terms and Q16's abstention
|
|
1465
|
+
is unchanged to the decimal (44.8% dev / 46.0% holdout). Precision was checked
|
|
1466
|
+
against the device parser rather than asserted: over 1,689 IL (`:parse`)
|
|
1467
|
+
projections — which distinguish `menuctx` from `cmd` natively — the table never
|
|
1468
|
+
calls a menu what the device read as a command, neither across the whole table
|
|
1469
|
+
nor across the 104 paths this rule flips. The residual is the right residual:
|
|
1470
|
+
`/system/reboot`, `/quit`, `/tool/speed-test` and abbreviated forms like
|
|
1471
|
+
`/ip fire conn` stay `ambiguous`, because the table is a floor and absence from
|
|
1472
|
+
it is not evidence of a command. *(#228 has since decided all but the
|
|
1473
|
+
abbreviated forms — see below.)*
|
|
1474
|
+
|
|
1475
|
+
**The contradicting half of that seam is closed too (#211 B1).**
|
|
1476
|
+
`pathresolve.ts` claimed **every** `/`-led bare path was navigation, with no
|
|
1477
|
+
check that the path is a menu, so `/ip address print` navigated to the
|
|
1478
|
+
non-existent `/ip/address/print` while `verbsplit` read the identical text as
|
|
1479
|
+
verb `print` at `/ip/address` — two promoted modules, contradictory answers,
|
|
1480
|
+
34 dev / 13 holdout statements, `verbsplit` right in every one. `menuNavPath`
|
|
1481
|
+
now consults the same frozen `VERBS` set (R9), which takes those statements to
|
|
1482
|
+
**0 dev / 0 holdout** at no abstention cost: they were already decided, just
|
|
1483
|
+
decided wrongly. The vocabulary moved to the leaf `verbs.ts` so both modules
|
|
1484
|
+
share one object and cannot drift apart, and the invariant — a
|
|
1485
|
+
`verbsplit`-`resolved` statement is never `isNav` — is pinned in
|
|
1486
|
+
`test/unit/explain-pathresolve.test.ts` alongside R9's premise: no path in the
|
|
1487
|
+
generated `MENU_PATHS` table carries a verb segment. That premise is scoped to
|
|
1488
|
+
the table, not to RouterOS — `menus.ts` is a floor, so it cannot rule out an
|
|
1489
|
+
unlisted menu named `.../print`, which R9 would read as a command.
|
|
1490
|
+
|
|
1491
|
+
R9 left the harder half open (**#211 B2**): a bare path with *no* verb
|
|
1492
|
+
(`/system/reboot`, `/quit`, `/terminal/cuu`) was still read as navigation and
|
|
1493
|
+
still cascaded, because the menu table is a floor and absence from it is not
|
|
1494
|
+
evidence of a command. B2 was parked rather than guessed at for exactly that
|
|
1495
|
+
reason — the only rule available would have had to invert the floor contract.
|
|
1496
|
+
|
|
1497
|
+
**#228 closes B2 for the published subset, without touching the floor.** The
|
|
1498
|
+
command axis is the missing *positive* evidence, so R12 refuses navigation into
|
|
1499
|
+
a path a table SAYS is a command rather than into one another table happens not
|
|
1500
|
+
to mention. `/system/reboot` no longer moves the context, so a statement after
|
|
1501
|
+
it resolves where it actually is; `verbsplit` and `pathresolve` agree, which
|
|
1502
|
+
they would otherwise now do *confidently* in opposite directions. What is left
|
|
1503
|
+
is the honest residue of both floors — `/terminal/cuu` is a real no-argument
|
|
1504
|
+
command MikroTik publishes nowhere — and it stays pinned as a KNOWN LIMIT
|
|
1505
|
+
fixture rather than assumed closed. The contradiction that #202 would have
|
|
1506
|
+
rendered is gone.
|
|
1507
|
+
|
|
1508
|
+
**The command axis also outranks the punctuation guess.** `splitRun`'s last
|
|
1509
|
+
rule reads the first space-separated token as the verb, which lands on the
|
|
1510
|
+
operand whenever the command itself was written slash-joined:
|
|
1511
|
+
`/system/gps/monitor once` put the verb on `once`, and
|
|
1512
|
+
`/interface ethernet reset-counters ether1` on `ethernet`. A published command
|
|
1513
|
+
names its own boundary, so the operand goes back to being an argument and
|
|
1514
|
+
`argsAt` moves with it. Over the frozen corpus plus the export stratum, 171 of
|
|
1515
|
+
19,002 statement readings change and every one names a better token; Q16
|
|
1516
|
+
abstention moves 44.8% → **44.6%** dev, 46.0% holdout and 0.0% export unchanged,
|
|
1517
|
+
and exactly one document changes verdict (`unknown` → `false`, a pasted
|
|
1518
|
+
`/system/gps/monitor once` transcript — an abstention #207 recorded as correct
|
|
1519
|
+
*given no evidence*, which there now is). The command axis says which token is
|
|
1520
|
+
the verb; it says nothing about whether that verb mutates, so `write.ts`'s
|
|
1521
|
+
curated vocabularies are unchanged and `/system/reboot` still abstains.
|
|
1522
|
+
|
|
1523
|
+
`splitRun` itself is untouched again — both tables are consumed by the callers —
|
|
1524
|
+
so the ratified schema-free rule keeps pricing decision 3 on its own terms. Two
|
|
1525
|
+
places deliberately do *not* consult the catalog: scripting directives, where
|
|
1526
|
+
R10 was measured 2,088-to-18 and the seven root-level catalog commands all sit
|
|
1527
|
+
where R10 already puts them; and a bare-word head at the ROOT context, because
|
|
1528
|
+
those seven are ordinary English words and the corpus's pasted `import serial` /
|
|
1529
|
+
`import io` / `import os` would otherwise read as RouterOS `/import`. An
|
|
1530
|
+
*inherited* menu context is itself the evidence that the surrounding text is
|
|
1531
|
+
RouterOS, so a relative `stop [find …]` under `/container` does resolve.
|
|
1532
|
+
|
|
1533
|
+
**Importability was also measured, and is a weaker, narrower result than it
|
|
1534
|
+
first appears (#203 deliverable 4).** On CHR, none of the six root `/export`
|
|
1535
|
+
documents imports into a blank same-version CHR; per-menu exports do, and
|
|
1536
|
+
round-trip byte-exactly including continuation-wrapped comments. RouterOS
|
|
1537
|
+
`/import` is fail-fast, so it aborts at the first error and every later
|
|
1538
|
+
statement silently never runs. But **all four failure causes are semantic or
|
|
1539
|
+
device-state rejections, not syntax**: a duplicate object, a built-in object,
|
|
1540
|
+
a missing mandatory value, and a name collision. That was verified rather than
|
|
1541
|
+
assumed — each blocker statement was replayed through `:put [:parse …]` and
|
|
1542
|
+
`/console/inspect request=highlight` on both versions and **accepted every
|
|
1543
|
+
time**, with controls (`no-such-attr=`, an unterminated string, a nonexistent
|
|
1544
|
+
menu) correctly rejected. So these results carry **no** implication about
|
|
1545
|
+
whether `explain` can parse the forms, and they do not license claims about the
|
|
1546
|
+
export styles in general: the captures come from one synthetic configuration on
|
|
1547
|
+
CHR, which has a single NIC and no switch or wireless hardware, so its defconf
|
|
1548
|
+
and its available menus differ from any real device's. Treat the provenance
|
|
1549
|
+
label as **L1-emitted, L2-refuted for these captures on CHR**. Harness and
|
|
1550
|
+
per-document results: `.scratch/explain-lab-q203-import-verify.ts`;
|
|
1551
|
+
validity check: `.scratch/q203-blocker-validity.ts`.
|
|
1552
|
+
|
|
1553
|
+
**Non-blocking / deferred (not ratification-gating):** Q5 expression depth, Q7
|
|
1554
|
+
tokenizer corners, Q9 potential-command taxonomy, Q12 span-vocabulary draft —
|
|
1555
|
+
these refine the surface during implementation but did not gate ratification.
|
|
1556
|
+
They keep their homes in staging: **Q7** is now concrete as #201 (lexical
|
|
1557
|
+
boundaries — sigil spellings, escape validity, statement-start eligibility)
|
|
1558
|
+
and #199 (the shared scanner's `$[…]`-in-string blind spot), both raised by the
|
|
1559
|
+
Q13 promotion and both spanning already-promoted modules; **Q5** and **Q9** are
|
|
1560
|
+
phase-1 measurements; **Q12** is spec open item 2 and hardens in phase 4. The
|
|
1561
|
+
probe framework's disposition (reuse of the mutation suite, coordinate
|
|
1562
|
+
fixtures, and adversarial generators as product test fixtures) is tracked in
|
|
1563
|
+
issue #186 rather than carried into mainline.
|
|
1564
|
+
|
|
1565
|
+
## Definition of done and staging
|
|
1566
|
+
|
|
1567
|
+
`designed` on the strength of this README. When implementation starts, offline
|
|
1568
|
+
examples gate via unit/fixture tests and each live cell advances to
|
|
1569
|
+
`CHR-passed` only when its `examples.md` entries run green via
|
|
1570
|
+
`bun run test:integration` (constitution: done definition). Suggested staging
|
|
1571
|
+
(sequence, not schedule):
|
|
1572
|
+
|
|
1573
|
+
- **Phase 0 — canonicalization grounding lab — COMPLETE (#185).** Experiments (corpus +
|
|
1574
|
+
CHR cross-checks against `:parse`/highlight) established what offline parsing
|
|
1575
|
+
can actually achieve: statement segmentation, block/scope resolution,
|
|
1576
|
+
sub-command path re-constitution, stateful-per-document path context, symbol
|
|
1577
|
+
scope, transport classification, write-shape, malformed-input recovery,
|
|
1578
|
+
coordinates, and stress invariants. Every ratification-gating question was
|
|
1579
|
+
answered with cited evidence; the findings are folded into the surface above
|
|
1580
|
+
and summarized in [Phase-0 ratification](#phase-0-ratification-185). This
|
|
1581
|
+
spec is ratified on that basis.
|
|
1582
|
+
- **Phase 0.5 — product contract fixtures — SUBSTANTIALLY COMPLETE (#186).**
|
|
1583
|
+
Promote a minimal, reviewed subset of the lab into product-owned tests; do
|
|
1584
|
+
not import or execute `.scratch/` code. One vertical-slice PR per lab suite,
|
|
1585
|
+
each landing an exported module under `src/explain/` plus frozen fixtures
|
|
1586
|
+
under `test/fixtures/explain/`. Shipped: Q15 coordinates (#188 →
|
|
1587
|
+
`coordinates.ts`), Q1 segmentation (#189 → `segment.ts`), Q2–Q4 path
|
|
1588
|
+
resolution (#191 → `pathresolve.ts`), Q17 single-pass container walker
|
|
1589
|
+
(#194 → `blocks.ts`, closing #190), Q6 verb/menu boundary (#193 →
|
|
1590
|
+
`verbsplit.ts`), Q16 write-shape tristate (#195 → `write.ts`), Q14 cascade /
|
|
1591
|
+
context-certainty (#197), Q13 symbol scope (#200 → `symbols.ts`). These pin
|
|
1592
|
+
the ratified thresholds: no confident command invented after a defect, zero
|
|
1593
|
+
false negatives on statically obvious writes, 100% coordinate invariants,
|
|
1594
|
+
deterministic/well-formed spans, bounded depth, no throw, and roughly linear
|
|
1595
|
+
scaling. **Remaining:** #192 (Q14 defect-*region* spans and the
|
|
1596
|
+
`ambiguous`/`unknown` verdict vocabulary — the verdict half wants the phase-1
|
|
1597
|
+
envelope). The lab framework and its two ~7.5 MB per-character highlight
|
|
1598
|
+
captures stay out of mainline (#186). What durable files CITE does not stay
|
|
1599
|
+
out: the frozen dev/holdout split, the Q8 REST probe capture, a stratified
|
|
1600
|
+
slice of the highlight streams, and the three device re-derivation probes
|
|
1601
|
+
are committed under `test/fixtures/explain/` and `scripts/probes/`.
|
|
1602
|
+
- **Phase 1 — offline core** — the grown canonicalizer: structure + gate
|
|
1603
|
+
verdict + per-statement resolution and transport classification +
|
|
1604
|
+
diagnostics (+ `--curl` rendering), and the CLI/MCP surface over it. Phase
|
|
1605
|
+
0.5 shipped the analysis modules as library exports only; phase 1 composes
|
|
1606
|
+
them behind one entry point, adds the envelope and `src/cli/explain.ts`, and
|
|
1607
|
+
turns `examples.md` green. **Landing in four PRs:** #192a defect regions
|
|
1608
|
+
(#222), #202a composition + envelope (#224), **#202b the CLI surface + the
|
|
1609
|
+
offline examples**, then #202c-1 argument reading and #202c-2 transport
|
|
1610
|
+
classification + `--curl`, which close #202. Carried in with it: the lexical-boundary
|
|
1611
|
+
hardening (#201) and the shared-scanner substitution blind spot (#199), both
|
|
1612
|
+
found during Q13 and both spanning modules already promoted; plus the
|
|
1613
|
+
deferred lab questions Q5 (expression depth) and Q9 (potential-command
|
|
1614
|
+
taxonomy), which become focused tests as this surface hardens.
|
|
1615
|
+
- **Phase 2 — live probes** — highlight + `:parse` (+
|
|
1616
|
+
completion/child/syntax facets) over rest-api/native-api with the safety rules
|
|
1617
|
+
above, including the
|
|
1618
|
+
broad-query describe ladder, smart sizing, and CHR-backed anchors for Q6,
|
|
1619
|
+
Q8, Q10, and Q11.
|
|
1620
|
+
- **Phase 3 — facet polish** — `--complete`/`--schema` ergonomics, truncation
|
|
1621
|
+
counts/hints, `--full`.
|
|
1622
|
+
- **Phase 4 — library/LSP alignment** — export shape hardened against a real
|
|
1623
|
+
lsp-routeros-ts consumption spike (semantic tokens via the centrs span
|
|
1624
|
+
vocabulary + color map).
|
|
1625
|
+
|
|
1626
|
+
## Decisions (2026-07-19 round) and remaining opens
|
|
1627
|
+
|
|
1628
|
+
Decided this round (details inline above; recorded in #90):
|
|
1629
|
+
|
|
1630
|
+
- **Option A** — one verb + facet flags; broad-query ladder
|
|
1631
|
+
(path → verbs, path/verb → args, `print` → `.proplist`); complexity of the
|
|
1632
|
+
refined scheme is an accepted risk.
|
|
1633
|
+
- **Smart sizing** over `--depth`; counts + truncation warning for large
|
|
1634
|
+
subtrees; `--full` (name TBD) as the only override. `--format` stays a
|
|
1635
|
+
rendering switch; colorized command breakdown is a rendering feature.
|
|
1636
|
+
- **No offline schema snapshot** — offline is the canonicalizer, full stop;
|
|
1637
|
+
schema/version truth comes from a live router (steered by tip) and
|
|
1638
|
+
docs/version history stays rosetta's domain.
|
|
1639
|
+
*Amended 2026-08-07 (#228).* The rejected candidate was the
|
|
1640
|
+
restraml/deep-inspect command tree: indirect, version-shaped schema data with
|
|
1641
|
+
substantial churn, and that reasoning still holds for it. The carve-out is
|
|
1642
|
+
narrower: offline ships **no schema** — no argument names, no types, no enums,
|
|
1643
|
+
no per-menu verb lists, no `.proplist` — but it does ship a generated
|
|
1644
|
+
**structure** table (path, kind, provenance, published applicability gate)
|
|
1645
|
+
unioned from pinned RouterOS inspect trees and MikroTik's published CLI
|
|
1646
|
+
Reference. Such a table may say whether a path is navigation or a command; it
|
|
1647
|
+
never describes what a command accepts, and absence from it abstains and never
|
|
1648
|
+
rejects. `src/explain/catalog.ts` is that table. Its **command axis is read**
|
|
1649
|
+
by `pathresolve.ts`, `verbsplit.ts` and `write.ts`. *Amended again by #235:*
|
|
1650
|
+
its **menu/settings kind is read too**, unioned with `menus.ts` — decisive for
|
|
1651
|
+
a relative bare path once its inherited context is applied, and for the
|
|
1652
|
+
absolute bare path `verbsplit.ts` used to call `ambiguous`. Presence still
|
|
1653
|
+
decides in one direction only; absence still abstains. It is still a static
|
|
1654
|
+
snapshot of vendor-published structure, which is why the decision is amended
|
|
1655
|
+
rather than argued around.
|
|
1656
|
+
- **No rosetta coupling for now** — at most steering tips; no calls, no
|
|
1657
|
+
artifacts, no maintained bindings.
|
|
1658
|
+
- **centrs-owned span vocabulary** with RouterOS-fidelity color mapping for
|
|
1659
|
+
LSP consumers; raw highlight classes are provenance/debug, not the surface.
|
|
1660
|
+
- **Grow `canonicalizeExecuteCommand`**; rosetta's `canonicalize.ts` is
|
|
1661
|
+
reference-only, divergence expected.
|
|
1662
|
+
- **MCP mirrors the CLI scheme** and result shape.
|
|
1663
|
+
- **Script-scale explain is in scope** — block/scope analysis and
|
|
1664
|
+
sub-command path re-constitution are target canonicalizer capabilities, and
|
|
1665
|
+
per-statement transport classification (execute vs api, `curl` rendering)
|
|
1666
|
+
is part of the base output.
|
|
1667
|
+
|
|
1668
|
+
Amendments from the PR review pass (Codex/CodeRabbit/Copilot on #184, same
|
|
1669
|
+
round):
|
|
1670
|
+
|
|
1671
|
+
- **Positional grammar is target-first** when live (`explain <router>
|
|
1672
|
+
'<input>'`); one positional = offline input. Matches every other
|
|
1673
|
+
router-taking command and the shared selection helpers.
|
|
1674
|
+
- **Transport classification is fail-closed** (`api-candidate` / `execute` /
|
|
1675
|
+
`unknown`); `curl` renders only from explicit, tested mapping rules.
|
|
1676
|
+
- **The gate verdict is reproduced verbatim**; explain-only mutation
|
|
1677
|
+
inference lives in `structure.containsWrite`, never in `writeShaped`.
|
|
1678
|
+
- **Per-fact provenance is an `evidence[]` table** with stable ids; target
|
|
1679
|
+
identity stays in `meta.target`.
|
|
1680
|
+
- **Envelope mirrors `check`** (`ok: true` + `data.verdict` for analyzed
|
|
1681
|
+
input); coordinate contract (byte offsets, exclusive `end`,
|
|
1682
|
+
original-document mapping for LSP) is explicit.
|
|
1683
|
+
- **`:parse` readout is transport-specific** (constitution); native API uses
|
|
1684
|
+
`/execute as-string` and returns the same parser text as REST/console, while
|
|
1685
|
+
omitting `as-string` returns only the job handle (Q11).
|
|
1686
|
+
- **MCP shape change is a deliberate pre-1.0 break**, migrated in one change.
|
|
1687
|
+
|
|
1688
|
+
Still open (implementation opens — none block ratification):
|
|
1689
|
+
|
|
1690
|
+
1. Final flag names (`--full`, `--curl`, facet names) and the smart-sizing
|
|
1691
|
+
thresholds (what counts as "cheap", what triggers truncation).
|
|
1692
|
+
2. The centrs span vocabulary itself: class list, mapping table from RouterOS
|
|
1693
|
+
highlight classes (and their colors), and how unknown/new upstream classes
|
|
1694
|
+
degrade. Phase 0 pinned the raw inputs (Q13's per-occurrence highlight-class
|
|
1695
|
+
corpus; the observed 19 classes on 7.23.2 plus drift on 7.24rc2) but the
|
|
1696
|
+
centrs-owned class list and color map are a **draft** (lab question Q12,
|
|
1697
|
+
non-ratification-gating); they harden in staging phase 4 against the real
|
|
1698
|
+
lsp-routeros-ts consumption spike.
|
|
1699
|
+
3. Whether the live describe ladder needs result caching per target+version
|
|
1700
|
+
(probe cost vs freshness) — deferred to implementation evidence.
|
|
1701
|
+
|
|
1702
|
+
Resolved during phase 0:
|
|
1703
|
+
|
|
1704
|
+
- **First CLI→REST families (Q8).** Nine mapping rules are
|
|
1705
|
+
runtime-exercised (2xx on 7.23.2 and 7.24rc2): `add`→`PUT`, `print`→`GET`,
|
|
1706
|
+
`get`→`GET/<id>`, `set`→`PATCH/<id>`, `remove`→`DELETE/<id>`,
|
|
1707
|
+
`run(action)`→`POST/<path>/<command>` (demonstrated by `/ip/dns/cache flush`
|
|
1708
|
+
with an empty body, and read as the family it names, not as the one verb),
|
|
1709
|
+
and `proplist`/`query`→`POST/<path>/print`. Selector
|
|
1710
|
+
writes (`set`/`remove [find …]`), singleton-menu `set`, and non-CRUD actions
|
|
1711
|
+
fail closed to `execute`/`unknown` (no `curl`). Placeholder-host and
|
|
1712
|
+
credential-elision rendering details stay an implementation open; `curl` is
|
|
1713
|
+
REST-only (native-api has no curl analogue).
|
|
1714
|
+
- **Ratification question list (#185).** The phase-0 question list and the spec
|
|
1715
|
+
claims each gated were the deliverable of #185; every ratification-gating
|
|
1716
|
+
question is answered.
|