@tikoci/centrs 0.1.3 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,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
+ }
@@ -0,0 +1,17 @@
1
+ import { lookupPath } from "./catalog.ts";
2
+ import { isMenuPath } from "./menus.ts";
3
+
4
+ /**
5
+ * Whether `segments` name a known RouterOS menu or settings path.
6
+ *
7
+ * Union of the two shipped structure tables (#235): `MENU_PATHS` (#207,
8
+ * `isMenuPath`) and `PATH_CATALOG` menu/settings rows (#228, `lookupPath`).
9
+ * Presence is load-bearing, absence abstains — the caller must not read
10
+ * absence as evidence of a command.
11
+ */
12
+ export function isKnownMenuPath(segments: readonly string[]): boolean {
13
+ const entry = lookupPath(segments);
14
+ return (
15
+ isMenuPath(segments) || entry?.kind === "menu" || entry?.kind === "settings"
16
+ );
17
+ }