@tikoci/centrs 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -101,6 +101,7 @@ export type DefectCode =
101
101
  | "bad-string-escape"
102
102
  | "bad-sigil"
103
103
  | "invalid-hash"
104
+ | "invalid-apostrophe"
104
105
  | "unterminated-string"
105
106
  | "unclosed"
106
107
  | "unbalanced-close"
@@ -135,7 +136,7 @@ export function isPositionalFact(code: DefectCode): boolean {
135
136
  * Does this list carry a defect an analyzer must ABSTAIN on?
136
137
  *
137
138
  * The abstention gate, and the reason {@link isPositionalFact} is a function
138
- * rather than a comment: a defect list is not uniformly "bad input". Seven codes
139
+ * rather than a comment: a defect list is not uniformly "bad input". Nine codes
139
140
  * mark structure the analyzer could not resolve — part of the document was never
140
141
  * walked, so no verdict over it is safe. The other two are coordinate facts about
141
142
  * bytes that were read perfectly well. Gating on `defects.length > 0` would
@@ -0,0 +1,77 @@
1
+ /**
2
+ * Escape fill — claims valid string-internal escape sequences on the residual.
3
+ *
4
+ * The one split #264 B5's rule admits and PR #330 did not make. B5's rule is
5
+ * that a merged class is split only where a consumer needs the distinction AND
6
+ * cannot recover it by joining `tokens[]` to another published surface on byte
7
+ * offsets. A valid escape passes both halves: only INVALID escapes surface
8
+ * elsewhere (as `bad-string-escape` diagnostics), so a consumer holding the
9
+ * token stream has no published surface to join against to find the valid ones,
10
+ * and the device does name them — `escaped` is one of the 19 classes the Q13
11
+ * capture observed.
12
+ *
13
+ * Unlike `arg-sep` this needs a real walk rather than a relabel of one
14
+ * analyzer's own output, and it takes bytes out of TWO fills at once rather
15
+ * than one. In byte terms it is still a retag: measured over the corpus
16
+ * `escaped` claims 19,960 bytes, `string` gives up 17,672 and `value` 2,288,
17
+ * and classified coverage is identical to the last decimal. Nothing that was
18
+ * `unclassified` becomes claimed — an escape only ever lives inside a run some
19
+ * fill already held.
20
+ *
21
+ * The walk is not this module's. `src/explain/quoted-string.ts` already owned
22
+ * the escape grammar for `bad-string-escape`, and `walkStringEscapes` now
23
+ * returns both halves of that single reading, so an `escaped` token and a
24
+ * `bad-string-escape` diagnostic cannot disagree about which bytes are an
25
+ * escape. This module only clips that list onto the residual.
26
+ *
27
+ * ## Fill order: before `value`, after `arg`
28
+ *
29
+ * An escape is interior to a quoted run, so it must claim before the two fills
30
+ * that would otherwise swallow it whole (`value`, then `string`) — after them
31
+ * it could claim nothing. It runs after `arg` because an argument NAME run is
32
+ * the analyzer's own located token and nothing inside it is a string interior;
33
+ * leaving `arg` ahead keeps the located reading winning over a byte scan, which
34
+ * is the same precedence every earlier fill was given (#290 design decision 1).
35
+ * Splitting a `value` or `string` run into fragments around an escape is the
36
+ * mechanism those fills already have — `~1,720` leaf value bytes were already
37
+ * being fragmented by `variable-*` spans before this fill existed.
38
+ */
39
+
40
+ import type { ExplainToken } from "../explain.ts";
41
+ import type { StringEscapeSpan } from "./quoted-string.ts";
42
+ import { clipToResidual } from "./token-ranges.ts";
43
+
44
+ /**
45
+ * Valid string-internal escape spans on the residual.
46
+ *
47
+ * `analyzed` is the ASCII-normalized document text (length checks only);
48
+ * `residual` is the gap set left by earlier fills (sorted, no overlaps);
49
+ * `escapes` are the valid escapes from `walkStringEscapes`, already in
50
+ * analyzed-byte space. Every emitted span's bytes are fully inside `residual`,
51
+ * sorted by `start`, non-overlapping, and carry `class: "escaped"`.
52
+ */
53
+ export function escapeSpans(
54
+ analyzed: string,
55
+ residual: readonly { start: number; end: number }[],
56
+ escapes: readonly StringEscapeSpan[],
57
+ ): ExplainToken[] {
58
+ const len = analyzed.length;
59
+ if (len === 0 || residual.length === 0 || escapes.length === 0) return [];
60
+
61
+ const out: ExplainToken[] = [];
62
+ for (const { start, end } of escapes) {
63
+ if (!Number.isInteger(start) || !Number.isInteger(end)) continue;
64
+ if (start < 0 || end <= start || end > len) continue;
65
+ for (const r of clipToResidual(start, end, residual)) {
66
+ out.push({
67
+ start: r.start,
68
+ end: r.end,
69
+ class: "escaped" as const,
70
+ ev: "e16",
71
+ });
72
+ }
73
+ }
74
+
75
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
76
+ return out;
77
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * The `centrs → highlight` projection (#264 B4, measured by #263).
3
+ *
4
+ * `data.tokens[]` is a centrs-owned vocabulary; RouterOS
5
+ * `/console/inspect request=highlight` is a *different* vocabulary produced by
6
+ * a *different* kind of analyzer. This module is the declared bridge between
7
+ * them, and it exists so the agreement report
8
+ * (`scripts/explain-highlight-agreement.ts`) can say what offline analysis got
9
+ * right against device truth instead of eyeballing two streams side by side.
10
+ *
11
+ * ## The projection is declared, never fitted
12
+ *
13
+ * Every entry below is justified by what the centrs class was *defined* to
14
+ * mean — `commands/explain/README.md` → the token-census block and
15
+ * `src/explain.ts`'s `ExplainTokenClass` doc — not by which device class it
16
+ * happened to land on most often. A projection tuned to the measurement would
17
+ * make the measurement circular: agreement would only ever say "the map was
18
+ * fitted", which is not a fact about the parser. So a centrs class whose
19
+ * *intent* has no single device counterpart projects to `null` and the report
20
+ * counts those bytes as `unprojected`, reporting what the device said about
21
+ * them as its own table. That table is the evidence #264 read to decide
22
+ * which provisional merges deserved to be split — two did, `arg-sep` (B5) and
23
+ * `escaped` — and `commands/explain/README.md` -> *The vocabulary, and what
24
+ * earns a class* records why the rest stay merged.
25
+ *
26
+ * ## Why the device stream is not one oracle but four
27
+ *
28
+ * Scoring a byte requires knowing what kind of answer the device gave, and
29
+ * `highlight` mixes four:
30
+ *
31
+ * - **syntax** — a class an offline analyzer can in principle decide from the
32
+ * source text alone (`cmd`, `dir`, `arg`, `comment`, the `variable-*`
33
+ * family, `escaped`, `syntax-meta`).
34
+ * - **non-syntax** — `obj-inactive`, `obj-dynamic`, `obj-disabled`,
35
+ * `variable-undefined`, `syntax-obsolete`. The source text alone does not
36
+ * decide these, so they are reported as their own bucket and never as a
37
+ * wrong answer. Note what abstains: centrs has no class for most of them
38
+ * (`ExplainSymbolClass` excludes `undefined`), but where it *does* claim the
39
+ * byte — an `arg` the device calls `obj-inactive` — the token stands and it
40
+ * is the SCORER that declines to grade it. Saying *why* each one is out of
41
+ * reach is a separate question, and it is deliberately **not** answered by
42
+ * the class name — see `applicabilityOf` below.
43
+ * - **silence** — `none`. The device assigned no class. It is the device's
44
+ * abstention, not a class named "none", so agreeing with it is not an
45
+ * agreement (probes: "an abstention is not a disagreement").
46
+ * - **parser-stop** — `error`. Measured over the committed slice this byte
47
+ * appears **at most once per script, always exactly one byte wide**, and on
48
+ * long-term 7.23.5 every byte after it is `none`: the device stops
49
+ * classifying rather than recovering. Most of the slice's bytes sit in that
50
+ * tail, so a percentage that ignores it measures the device giving up.
51
+ * Stable 7.24.2 and development 7.25beta3 do classify again after the `error`
52
+ * in one run, which is why the report gives that a bucket of its own instead
53
+ * of asserting the rule (the counts live in the generated README block, not
54
+ * here, so they cannot go stale). The rule and its exception swapped builds
55
+ * with the #342 channel realignment; the shape of the claim did not.
56
+ *
57
+ * ## Upstream drift
58
+ *
59
+ * `DEVICE_CLASS_KIND` lists the 19 classes the phase-0 capture observed on
60
+ * 7.23.2 / 7.24rc2 — and the #342 recapture found the SAME 19 on 7.24.2,
61
+ * 7.23.5 and 7.25beta3, so the vocabulary has held across five builds and two
62
+ * capture rounds. A class outside it is `"unknown"` — reported, never
63
+ * silently folded into one of the four kinds — because a new upstream class is
64
+ * exactly the drift the centrs-owned vocabulary exists to absorb, and guessing
65
+ * its kind would hide it.
66
+ */
67
+
68
+ import type { ExplainTokenClass } from "../explain.ts";
69
+
70
+ /**
71
+ * What kind of answer a device highlight class is, from an offline analyzer's
72
+ * point of view. See the module header for why the distinction is load-bearing.
73
+ */
74
+ export type DeviceClassKind =
75
+ | "syntax"
76
+ | "non-syntax"
77
+ | "silence"
78
+ | "parser-stop"
79
+ | "unknown";
80
+
81
+ /**
82
+ * The 19 classes the Q13 capture observed, by kind.
83
+ *
84
+ * Source: `test/fixtures/explain/highlight-streams.slice.json` →
85
+ * `selection.classesInCapture`, which the slice's own integrity test pins
86
+ * against the stream data. Keep the two in step: the agreement report fails
87
+ * loudly when the slice carries a class this table does not name.
88
+ */
89
+ export const DEVICE_CLASS_KIND: Readonly<Record<string, DeviceClassKind>> = {
90
+ arg: "syntax",
91
+ "arg-dot": "syntax",
92
+ "arg-scope": "syntax",
93
+ cmd: "syntax",
94
+ comment: "syntax",
95
+ dir: "syntax",
96
+ escaped: "syntax",
97
+ "syntax-meta": "syntax",
98
+ "variable-auto": "syntax",
99
+ "variable-global": "syntax",
100
+ "variable-local": "syntax",
101
+ "variable-parameter": "syntax",
102
+ "obj-disabled": "non-syntax",
103
+ "obj-dynamic": "non-syntax",
104
+ "obj-inactive": "non-syntax",
105
+ "syntax-obsolete": "non-syntax",
106
+ "variable-undefined": "non-syntax",
107
+ none: "silence",
108
+ error: "parser-stop",
109
+ };
110
+
111
+ /** The kind of a device class; `"unknown"` for anything the capture never saw. */
112
+ export function deviceClassKind(deviceClass: string): DeviceClassKind {
113
+ return DEVICE_CLASS_KIND[deviceClass] ?? "unknown";
114
+ }
115
+
116
+ /**
117
+ * One projection entry: the device classes a centrs class predicts, plus the
118
+ * reason it predicts them. `accepts: null` is a deliberate abstention.
119
+ */
120
+ export interface ProjectionEntry {
121
+ /** Device classes this centrs class claims, or `null` to abstain. */
122
+ readonly accepts: readonly string[] | null;
123
+ /** Why — in terms of what the centrs class was defined to mean. */
124
+ readonly because: string;
125
+ }
126
+
127
+ /**
128
+ * centrs token class → the device highlight classes it predicts.
129
+ *
130
+ * Total over `ExplainTokenClass`: every class has an entry, and an entry is
131
+ * either a non-empty accept set or an explicit abstention.
132
+ */
133
+ export const HIGHLIGHT_PROJECTION: Readonly<
134
+ Record<ExplainTokenClass, ProjectionEntry>
135
+ > = {
136
+ comment: {
137
+ accepts: ["comment"],
138
+ because:
139
+ "name-coincident: the class was taken from the device vocabulary and means the same run of source",
140
+ },
141
+ "variable-local": {
142
+ accepts: ["variable-local"],
143
+ because: "name-coincident (the Q13 symbol classes are the device's own)",
144
+ },
145
+ "variable-global": {
146
+ accepts: ["variable-global"],
147
+ because: "name-coincident (the Q13 symbol classes are the device's own)",
148
+ },
149
+ "variable-auto": {
150
+ accepts: ["variable-auto"],
151
+ because: "name-coincident (the Q13 symbol classes are the device's own)",
152
+ },
153
+ "variable-parameter": {
154
+ accepts: ["variable-parameter"],
155
+ because: "name-coincident (the Q13 symbol classes are the device's own)",
156
+ },
157
+ dir: {
158
+ accepts: ["dir"],
159
+ because:
160
+ "name-coincident: resolved menu bytes, leading `/` or `:` included, which is the run the device also calls `dir`",
161
+ },
162
+ cmd: {
163
+ accepts: ["cmd"],
164
+ because: "name-coincident: the resolved command-name run",
165
+ },
166
+ arg: {
167
+ accepts: ["arg", "arg-dot", "arg-scope"],
168
+ because:
169
+ "argument-name bytes; the device splits dotted spellings (`.id`, `security.authentication-types`) into `arg-dot` / `arg-scope` and centrs deliberately does not, so all three are the same claim. The `=` is no longer part of this class — #264 B5 gave it `arg-sep`, which is why this entry now predicts only name classes",
170
+ },
171
+ "arg-sep": {
172
+ accepts: ["syntax-meta"],
173
+ because:
174
+ "the `=` binding one argument name to its value is structure punctuation, not part of the name, and `syntax-meta` is the device's only class for structure. Declared from what the class means, not fitted: before #264 B5 split it out these bytes were the whole of the `arg` -> `syntax-meta` disagreement, which is the measurement that made the split worth its name (the count is in the generated agreement block, so it cannot go stale here)",
175
+ },
176
+ operator: {
177
+ accepts: ["syntax-meta"],
178
+ because:
179
+ "`syntax-meta` is the device's residual structure class and the only place an operator byte can land (#255): the device merges adjacent structure runs, so it names no operator boundary, but it does decide structure-versus-word",
180
+ },
181
+ brace: {
182
+ accepts: ["syntax-meta"],
183
+ because:
184
+ "scope and array delimiters are structure punctuation, and `syntax-meta` is the device's only class for it",
185
+ },
186
+ string: {
187
+ accepts: null,
188
+ because:
189
+ "the token spans the quoted run MINUS its escapes, which the device still splits two ways — delimiters `syntax-meta` and the interior it declines to classify at all. Naming the escapes (#264) removed the third way and is why this entry's residue is now delimiters, not delimiters plus escapes; the run still has no single device class, so the abstention stands",
190
+ },
191
+ escaped: {
192
+ accepts: ["escaped"],
193
+ because:
194
+ "name-coincident: the class means one valid string-internal escape sequence, which is the run the device also calls `escaped`. Declared from meaning before it was measured — `escaped` was already in `DEVICE_CLASS_KIND` as a syntax class, and the agreement report's `unprojected` table is what showed the bytes were being merged away rather than what chose the target",
195
+ },
196
+ value: {
197
+ accepts: null,
198
+ because:
199
+ "the device has no value class. It classifies an argument's value only when the value is itself something else (a substitution, an object name, an escape — the last of which #264 split out), so a `value` token has no device counterpart to be right or wrong about",
200
+ },
201
+ unclassified: {
202
+ accepts: null,
203
+ because: "centrs's own abstention — no analyzer claimed the byte",
204
+ },
205
+ };
206
+
207
+ /** The device classes a centrs class predicts, or `null` where it abstains. */
208
+ export function projectTokenClass(
209
+ tokenClass: ExplainTokenClass,
210
+ ): readonly string[] | null {
211
+ return HIGHLIGHT_PROJECTION[tokenClass].accepts;
212
+ }
213
+
214
+ /**
215
+ * Why a device answer is or is not within reach of offline analysis (#263).
216
+ *
217
+ * This is a **second axis**, orthogonal to whether a byte agreed. Agreement
218
+ * asks "did the two answers match"; applicability asks "what kind of fact was
219
+ * the device deciding, and could source text alone have decided it". A byte
220
+ * has one of each.
221
+ *
222
+ * The categories are #263's, and the load-bearing instruction there is that
223
+ * they are assigned **from context, never by assigning a whole highlight class
224
+ * to one bucket**. So the input is the *pair* — what centrs read the byte as
225
+ * and what the device called it — plus whether the captured versions even
226
+ * agreed on the device's answer.
227
+ */
228
+ export type Applicability =
229
+ /** Syntax and structure justify the conclusion; offline analysis can reach it. */
230
+ | "offline-decidable"
231
+ /** Needs a per-path field/argument schema offline centrs deliberately does not ship. */
232
+ | "schema-dependent"
233
+ /** Needs runtime objects or session state outside the input. */
234
+ | "state-dependent"
235
+ /**
236
+ * Deciding needs version context: either the captured RouterOS versions
237
+ * disagree about this byte, so there is no single device answer, or the
238
+ * device's answer is itself a statement about the running version
239
+ * (`syntax-obsolete`) even where the captures agree.
240
+ */
241
+ | "version-dependent"
242
+ /** No supported basis for a category. Explicit, never a silent default. */
243
+ | "uncategorized"
244
+ /** The device gave no answer here (`none`, or the post-`error` tail). */
245
+ | "no-device-answer";
246
+
247
+ /**
248
+ * Categorize one byte's device answer.
249
+ *
250
+ * `versionsAgree` is whether every captured version gave this byte the same
251
+ * class. It is checked **first**, ahead even of the device's own silence: when
252
+ * the captures disagree there is no single device answer to be schema- or
253
+ * state-dependent about, and the disagreement is itself the fact worth
254
+ * reporting. That ordering matters in practice — a byte one build classifies
255
+ * and the next leaves `none` is a version difference, not an absence, and
256
+ * checking silence first would file it as "the device said nothing" on the
257
+ * strength of whichever build happens to be the base.
258
+ */
259
+ export function applicabilityOf(
260
+ centrsClass: string,
261
+ deviceClass: string,
262
+ versionsAgree: boolean,
263
+ ): Applicability {
264
+ if (!versionsAgree) return "version-dependent";
265
+ const kind = deviceClassKind(deviceClass);
266
+ if (kind === "silence" || kind === "parser-stop") return "no-device-answer";
267
+ if (kind === "syntax") return "offline-decidable";
268
+ // `syntax-obsolete` is the running version's deprecation table talking: the
269
+ // same bytes are unremarkable on an older build. Measured on the slice it
270
+ // lands on the whitespace where modern syntax wants `=` (`} else {`,
271
+ // `:foreach conn in $conns`).
272
+ if (deviceClass === "syntax-obsolete") return "version-dependent";
273
+ if (deviceClass === "obj-dynamic" || deviceClass === "obj-disabled")
274
+ return "state-dependent";
275
+ if (deviceClass === "obj-inactive") {
276
+ // Same class, two different reasons — which is exactly why the class name
277
+ // cannot decide the category. On a name centrs read as an argument, the
278
+ // device is saying the menu's schema has no such field; on a menu or
279
+ // command name it is saying the package or menu is not present on this
280
+ // box.
281
+ //
282
+ // `arg-sep` rides with `arg` because it is the same fact about the same
283
+ // attribute: the device marking an attribute inactive marks the `=` that
284
+ // binds it too, and what it is saying there is still "this menu has no
285
+ // such field". Splitting the class (#264 B5) did not split the reason.
286
+ if (centrsClass === "arg" || centrsClass === "arg-sep")
287
+ return "schema-dependent";
288
+ if (centrsClass === "dir" || centrsClass === "cmd")
289
+ return "state-dependent";
290
+ return "uncategorized";
291
+ }
292
+ // `variable-undefined` is NOT blanket state-dependent (#263, explicitly). A
293
+ // bare word the device resolved to an undefined reference may be an external
294
+ // global, a query/filter field name, or a lexical spelling — three different
295
+ // categories — and nothing committed here separates them. Equally, an absent
296
+ // binding in this document is not evidence of a runtime undefined variable.
297
+ // Leaving it explicit is the point; folding it into a category would be the
298
+ // unsupported claim.
299
+ //
300
+ // This branch reaches the same answer as the fallthrough below on purpose:
301
+ // the requirement belongs at the point of decision, where the next reader
302
+ // making a rule for this class will be standing, not only in the prose. It
303
+ // is pinned by a test.
304
+ if (deviceClass === "variable-undefined") return "uncategorized";
305
+ // Any other non-syntax class, and any class this build has never seen: no
306
+ // supported basis for a category, said out loud rather than guessed.
307
+ return "uncategorized";
308
+ }
@@ -5,18 +5,20 @@
5
5
  * the drift gate. The generator (`scripts/gen-explain-menus.ts`) carries the
6
6
  * full rationale, the source pin, and why absence here is safe.
7
7
  *
8
- * Union of the `dir` and `path` nodes in four pinned restraml typed trees
8
+ * Union of the `dir` and `path` nodes in 6 pinned restraml typed trees
9
9
  * (`https://tikoci.github.io/restraml/`), all extra-packages builds:
10
10
  *
11
11
  * | Tree | Arch | RouterOS | Nodes | Containers |
12
12
  * | ---- | ---- | -------- | ----- | ---------- |
13
13
  * | `7.10.2/extra/inspect.json` | x86 | 7.10.2 | 29,086 | 458 |
14
14
  * | `7.16/extra/inspect.json` | x86 | 7.16 | 34,876 | 522 |
15
- * | `7.23.2/extra/deep-inspect.x86.json` | x86 | 7.23.2 | 40,595 | 550 |
16
- * | `7.24rc2/extra/deep-inspect.arm64.json` | arm64 | 7.24rc2 | 42,690 | 575 |
15
+ * | `7.24.4/extra/deep-inspect.x86.json` | x86 | 7.24.4 | 41,345 | 554 |
16
+ * | `7.24.4/extra/deep-inspect.arm64.json` | arm64 | 7.24.4 | 42,691 | 575 |
17
+ * | `7.25beta5/extra/deep-inspect.x86.json` | x86 | 7.25beta5 | 41,794 | 557 |
18
+ * | `7.25beta5/extra/deep-inspect.arm64.json` | arm64 | 7.25beta5 | 43,150 | 578 |
17
19
  *
18
20
  * Zero node-type conflicts across those trees — no `dir`↔`cmd` flip across
19
- * three versions or across architectures — which is what makes the union
21
+ * 4 versions or across architectures — which is what makes the union
20
22
  * version-less rather than version-keyed. Generation aborts if that ever stops
21
23
  * holding.
22
24
  *
@@ -125,6 +127,7 @@ export const MENU_PATHS: ReadonlySet<string> = new Set([
125
127
  "/interface/eoip",
126
128
  "/interface/eoipv6",
127
129
  "/interface/ethernet",
130
+ "/interface/ethernet/pon",
128
131
  "/interface/ethernet/switch",
129
132
  "/interface/ethernet/switch/host",
130
133
  "/interface/ethernet/switch/port",
@@ -226,6 +229,7 @@ export const MENU_PATHS: ReadonlySet<string> = new Set([
226
229
  "/interface/wireless/sniffer/packet",
227
230
  "/interface/wireless/snooper",
228
231
  "/interface/wireless/wds",
232
+ "/interface/xfrm",
229
233
  "/iot",
230
234
  "/iot/bluetooth",
231
235
  "/iot/bluetooth/advertisers",
@@ -309,7 +313,6 @@ export const MENU_PATHS: ReadonlySet<string> = new Set([
309
313
  "/ip/ipsec/installed-sa",
310
314
  "/ip/ipsec/key",
311
315
  "/ip/ipsec/key/psk",
312
- "/ip/ipsec/key/qkd",
313
316
  "/ip/ipsec/key/rsa",
314
317
  "/ip/ipsec/mode-config",
315
318
  "/ip/ipsec/peer",
@@ -354,6 +357,7 @@ export const MENU_PATHS: ReadonlySet<string> = new Set([
354
357
  "/ip/socks/users",
355
358
  "/ip/socksify",
356
359
  "/ip/ssh",
360
+ "/ip/ssh/known-hosts",
357
361
  "/ip/tftp",
358
362
  "/ip/tftp/settings",
359
363
  "/ip/traffic-flow",
@@ -26,11 +26,15 @@
26
26
  * `(evl /system/identity/get value-name=name)` — the `/` are path
27
27
  * separators and the `=` is an argument separator, with no division and no
28
28
  * comparison node. So the `, / = -` conservatism holds everywhere except
29
- * directly inside `(`. Measured cost: `find where name="x"` inside `[…]`
30
- * *does* lower to a real `(= $name x)` node, and those `=` bytes now stay
31
- * `unclassified` — 224 of the corpus's 1,259 bracket `=` against 1,035 that
32
- * were plain `arg=value`. Abstaining on all of them beats claiming 82%
33
- * wrong; a `where`-aware fill can take them later.
29
+ * directly inside `(`. Measured cost: `find address=$IP` inside `[…]` *does*
30
+ * lower to real `(= $address $IP)` nodes, and those `=` bytes stay
31
+ * `unclassified`. The figures are generated, not asserted here — see
32
+ * `scripts/explain-bracket-equals.ts` and the gated block in
33
+ * `commands/explain/README.md`. The split runs roughly 40/60 query against
34
+ * `arg=value`, so abstaining beats claiming; a `where`-aware fill can take
35
+ * the query half later. The numbers this comment used to carry (224 of
36
+ * 1,259 against 1,035) were unreproducible and ungated, which is what #341
37
+ * fixed — do not re-inline a figure here.
34
38
  * 2. **Glued after `=` is an argument value.** `in-interface-list=!LAN`,
35
39
  * `.id=*2`, `oid=.1.3.6.1.2.1` — the byte after an argument `=` starts the
36
40
  * value, never an operator.
@@ -40,8 +44,10 @@
40
44
  * `security.authentication-types=wpa2-psk` as one name and renders `.id` as
41
45
  * the single symbol `$.id`, never a `(. …)` concat.
42
46
  *
43
- * Vocabulary is provisional: one `operator` class for all 26 spellings + the
44
- * two aliases (`&&`, `||`). Per-operator/per-category legend is #264 B5.
47
+ * One `operator` class for all 26 spellings + the two aliases (`&&`, `||`).
48
+ * #264 B5 kept that merge: a consumer that wants the spelling slices the input
49
+ * at the token's span, and `src/explain/operators.ts` maps it to its IL node,
50
+ * so a per-operator legend would restate what the bytes already say.
45
51
  * `<>` is **not** one token — it re-lexes to `<` then `>` as two tokens.
46
52
  * `syntax-meta` is residual and merged, never a source (#255).
47
53
  */
@@ -1,13 +1,17 @@
1
1
  /**
2
2
  * The RouterOS operator surface, as the DEVICE describes it (#255).
3
3
  *
4
- * Every row here was scored on CHR 7.23.3 (stable) and re-scored on 7.24rc4
5
- * (testing) and 7.21.5 (long-term) by `bun run explain:probe:operators` — one
6
- * build per release channel; the reviewed slice is
4
+ * Every row here was scored by `bun run explain:probe:operators` on the three
5
+ * builds the pinned corpus classes `complete` — stable 7.24.2 (which is also
6
+ * the current testing build), long-term 7.23.5 and development 7.25beta3 — so
7
+ * the operator axis and the token partition are measured on the same firmware
8
+ * (#342). A fourth capture, 7.21.5, is kept as an **age anchor** rather than a
9
+ * channel claim: it is the only live evidence that this surface behaves the
10
+ * same on a pre-7.23 branch. The reviewed slice is
7
11
  * `test/fixtures/explain/operators.json` → `sweep`, and
8
12
  * `test/unit/explain-operators.test.ts` asserts this table still equals it.
9
- * The three versions differ in exactly one row, and it is not a lexer row —
10
- * see {@link ARRAY_COMPARISON_NOTE}.
13
+ * All four builds differ in exactly one row, and it is not a lexer row — see
14
+ * {@link ARRAY_COMPARISON_NOTE}.
11
15
  *
12
16
  * ## Why this is not the manual's list
13
17
  *
@@ -176,7 +180,7 @@ const OPERATORS: readonly RouterosOperator[] = [
176
180
  // them separately: every `(U 1 B 2)` and `(1 B U 2)` for `U` in
177
181
  // `!`/`any`/`~`/`-`/`>` against all 24 binaries, 240 probes, published as
178
182
  // the fixture's `unary` block. All 240 accept and `B` is outermost in
179
- // every one, on 7.21.5, 7.23.3 and 7.24rc4 alike — so both bind TIGHTER
183
+ // every one, on all four swept builds alike — so both bind TIGHTER
180
184
  // than every binary, including `->` (14) and `<%%` (13). That is level 15,
181
185
  // right-assoc, shared with the unary arities of `~`, `-`, `>` (whose binary
182
186
  // arities sit at 5, 11, 5). The single `precedence` per spelling is
@@ -362,24 +366,27 @@ function notOp(
362
366
  * The single version-dependent fact the sweep found.
363
367
  *
364
368
  * Every operator, arity, precedence level, associativity, `highlight` class and
365
- * op-axis row is identical on 7.21.5, 7.23.3 and 7.24rc4 — and identical is a
366
- * measurement here, not a reading: `buildSweep` diffs each of those axes per
367
- * version, so a build that re-ranked `->` would appear in
368
- * `sweep.versionDifferences` rather than in nobody's notes.
369
+ * op-axis row is identical on all four swept builds — 7.24.2, 7.23.5,
370
+ * 7.25beta3 and the 7.21.5 age anchor — and identical is a measurement here,
371
+ * not a reading: `buildSweep` diffs each of those axes per version, so a build
372
+ * that re-ranked `->` would appear in `sweep.versionDifferences` rather than in
373
+ * nobody's notes.
369
374
  *
370
375
  * The one row that moved is a RUNTIME semantic, not a lexer fact:
371
- * `:put ({2;1} > {1;2;3})` is `true` on 7.24rc4 and an error on the two older
372
- * builds. So the offline lexer needs no version gate for operators — and
373
- * anything that reports what a comparison MEANS does.
376
+ * `:put ({2;1} > {1;2;3})` evaluates on 7.24.2 and 7.25beta3 and errors on both
377
+ * long-term builds. So the offline lexer needs no version gate for operators —
378
+ * and anything that reports what a comparison MEANS does.
374
379
  */
375
380
  export const ARRAY_COMPARISON_NOTE =
376
- "array comparison with `>` errors on 7.21.5 and 7.23.3 and evaluates on " +
377
- "7.24rc4; the operator table itself is identical on all three. The two " +
378
- "errors are not the same error: 7.23.3 says `cannot compare if array is " +
379
- "more than array` and 7.21.5 says `cannot compare if nothing is more than " +
380
- "nothing`, so on 7.21.5 the `{2;1}` operands did not survive to the " +
381
- "comparison at all. That is a RUNTIME difference either way — no version " +
382
- "disagrees about what `>` is";
381
+ "array comparison with `>` evaluates on 7.24.2 and 7.25beta3 and errors on " +
382
+ "7.23.5 and 7.21.5; the operator table itself is identical on all four. " +
383
+ "**It is not fixed on current long-term**: 7.23.5 is newer than the 7.23.3 " +
384
+ "this was first measured on and still errors, so the change rode the 7.24 " +
385
+ "line and was not backported. The two errors are not the same error: " +
386
+ "7.23.5 says `cannot compare if array is more than array` and 7.21.5 says " +
387
+ "`cannot compare if nothing is more than nothing`, so on 7.21.5 the " +
388
+ "`{2;1}` operands did not survive to the comparison at all. That is a " +
389
+ "RUNTIME difference either way — no version disagrees about what `>` is";
383
390
 
384
391
  const BY_SPELLING: ReadonlyMap<string, RouterosOperator> = new Map(
385
392
  OPERATORS.map((entry) => [entry.spelling, entry]),
@@ -9,7 +9,7 @@
9
9
  * separators remain unclassified. A `\<newline>` inside a name interrupts the
10
10
  * emitted range so the continuation bytes remain available to the escape fill.
11
11
  *
12
- * Vocabulary is provisional until #264 B5: `dir` and `cmd` mirror the useful
12
+ * Vocabulary settled by #264 B5: `dir` and `cmd` mirror the useful
13
13
  * lexical distinction already produced by RouterOS highlight, but are
14
14
  * centrs-owned classes.
15
15
  */