@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,357 @@
1
+ /**
2
+ * Offline lexical value-shape hints (#225).
3
+ *
4
+ * These are deliberately not RouterOS types: a spelling is one axis, while the
5
+ * live parser, an argument schema, and runtime casting may each answer
6
+ * differently. The vocabulary still borrows RouterOS's type NAMES, so a shape
7
+ * must never be spelled in a way the device would contradict — `num` is
8
+ * integer-only here because RouterOS numbers are integers, and `2.2` is
9
+ * observed as `ip` (`2.0.0.2`), never as a decimal. `mac` is the deliberate
10
+ * exception: it names the CLI Reference's `macAddr` schema spelling, not a
11
+ * `:typeof` result (a bare MAC is observed as `str`).
12
+ *
13
+ * `op` is deliberately absent from this vocabulary (#288, a bounded #225
14
+ * decision). A deferred expression `(> …)` has no offline shape hint: e.g.
15
+ * `(>[:return 1])` is `op` and `(>{"a"=1})` is `array` at runtime, yet both
16
+ * parse identically — head `>` arity 1 — and `:parse` IL cannot distinguish
17
+ * them; only `:typeof` can. Grounded on CHR 7.21.5, 7.23.3 and 7.24rc4 in
18
+ * `test/fixtures/explain/operators.json` → `sweep.runtime` rows
19
+ * `typeof-deferred` / `deferred-array-typeof` and `sweep.opAxis` `defer-*`.
20
+ * Offline abstains on every such form; a live `observedType` reports the
21
+ * runtime type.
22
+ *
23
+ * ## The two contexts are different lexicons, not one lexicon plus a fallback
24
+ *
25
+ * An argument value is console text the command's schema interprets. An ARRAY
26
+ * MEMBER is parsed as an expression, and that changes the answer for whole
27
+ * classes of spelling. Grounded on CHR 7.23.3, reading each member's own
28
+ * `:typeof` and the `:parse` IL of the enclosing statement:
29
+ *
30
+ * | member | IL | `:typeof` |
31
+ * | ------ | -- | --------- |
32
+ * | `{abc}` | `$abc` | `nothing` — a bare word is a VARIABLE REFERENCE |
33
+ * | `{00:11:22:33:44:55}` | `$00:11:…` | `nothing` — no MAC literal exists |
34
+ * | `{100000w}` | `$100000w` | `nothing` — out of the time range |
35
+ * | `{*1}` | — | hard syntax error; `id` has no expression spelling |
36
+ * | `{0x10}` | `16` | `num` — hex is a number here |
37
+ * | `{Yes}` | `$Yes` | `nothing` — the boolean words are lower-case only |
38
+ * | `{"abc"}` | `abc` | `str` — quoting is how a member becomes text |
39
+ *
40
+ * So the member lexicon drops `str`-by-fallback, `mac` and `id`, adds hex, and
41
+ * range-checks time. Everything it cannot place is a variable reference or an
42
+ * expression, and an abstention says exactly that.
43
+ */
44
+
45
+ import { isIP } from "node:net";
46
+
47
+ /**
48
+ * The closed offline vocabulary — an ENUMERATION, not an ordering.
49
+ *
50
+ * Nothing sorts by this list: {@link valueShapeHints} emits in the order it
51
+ * tests spellings, so reordering the members here must not change any result.
52
+ * `array` is emitted by the structured anchor reader rather than
53
+ * {@link valueShapeHints}, which receives decoded scalar values only.
54
+ * `op` is not a member: a deferred `(> …)` form abstains offline and is
55
+ * reported only as a live `observedType` (#288).
56
+ */
57
+ export const VALUE_SHAPES = [
58
+ "num",
59
+ "ip",
60
+ "ip-prefix",
61
+ "ip6",
62
+ "ip6-prefix",
63
+ "id",
64
+ "time",
65
+ "array",
66
+ "mac",
67
+ "bool",
68
+ "str",
69
+ ] as const;
70
+
71
+ export type ValueShape = (typeof VALUE_SHAPES)[number];
72
+
73
+ /**
74
+ * Where the value sits, because RouterOS reads the same bytes differently.
75
+ *
76
+ * `argument` is a command's `name=value` or positional operand; `array-member`
77
+ * is one member of a `{…}`/`(…)` literal, which the device parses as an
78
+ * expression (see the module header's table).
79
+ */
80
+ export type ValueContext = "argument" | "array-member";
81
+
82
+ export interface ValueShapeOptions {
83
+ /** A fully enclosing quoted run always has string shape. */
84
+ quoted: boolean;
85
+ /** A named attribute makes an otherwise-generic bare string addressable. */
86
+ allowBareString?: boolean;
87
+ /** Defaults to `argument`, the only context before #225's interior work. */
88
+ context?: ValueContext;
89
+ }
90
+
91
+ /**
92
+ * Nanoseconds RouterOS can still hold in a time literal, measured rather than
93
+ * assumed: on 7.23.3 `9223372036s` is `time` and `9223372037s` is a variable
94
+ * reference, and the same cliff sits between `15250w`/`15251w` and
95
+ * `106751d`/`106752d`. That is exactly a signed 64-bit nanosecond count.
96
+ */
97
+ const MAX_TIME_NS = 9_223_372_036_854_775_807n;
98
+
99
+ const UNIT_NS: Record<string, bigint> = {
100
+ ns: 1n,
101
+ us: 1_000n,
102
+ ms: 1_000_000n,
103
+ s: 1_000_000_000n,
104
+ m: 60_000_000_000n,
105
+ h: 3_600_000_000_000n,
106
+ d: 86_400_000_000_000n,
107
+ w: 604_800_000_000_000n,
108
+ };
109
+
110
+ function unsignedParts(value: string): bigint[] | null {
111
+ if (value.length > 39) return null;
112
+ if (!/^\d+(?:\.\d+){1,3}$/.test(value)) return null;
113
+ return value.split(".").map((part) => BigInt(part));
114
+ }
115
+
116
+ /**
117
+ * RouterOS's short IPv4 spellings (`1.1`, `2.1.1`, and the full four parts).
118
+ *
119
+ * The shortcut fills the LOW octets — `1.1` is `1.0.0.1` and `2.1.1` is
120
+ * `2.1.0.1` — but it does NOT accept the class-based wide final field this
121
+ * originally allowed (`second <= 16777215`, `third <= 65535`, transcribed from
122
+ * the class-based description). Every part is a single octet on 7.23.3, in both
123
+ * the argument and the member position:
124
+ *
125
+ * | literal | `:typeof` | value |
126
+ * | ------- | --------- | ----- |
127
+ * | `1.255` | `ip` | `1.0.0.255` |
128
+ * | `1.256` | `time` | `00:00:01.256` — a decimal count of seconds |
129
+ * | `1.16777215` | `time` | `00:00:01.167772150`, never `1.255.255.255` |
130
+ * | `1.1.255` | `ip` | `1.1.0.255` |
131
+ * | `1.1.256` | `str` | plain text; as a member, a variable reference |
132
+ *
133
+ * A too-large field therefore does not narrow the address — it stops being an
134
+ * address, which is why the wide bounds produced an `ip` hint the device
135
+ * contradicted.
136
+ */
137
+ function isIpv4Shortcut(value: string): boolean {
138
+ const parts = unsignedParts(value);
139
+ if (parts === null) return false;
140
+ return parts.every((part) => part <= 255n);
141
+ }
142
+
143
+ function prefixParts(
144
+ value: string,
145
+ ): { address: string; prefix: number } | null {
146
+ const slash = value.lastIndexOf("/");
147
+ if (slash <= 0 || slash === value.length - 1) return null;
148
+ const prefixText = value.slice(slash + 1);
149
+ if (!/^\d+$/.test(prefixText)) return null;
150
+ return { address: value.slice(0, slash), prefix: Number(prefixText) };
151
+ }
152
+
153
+ /**
154
+ * Fragment COVERAGE, deliberately not unit order.
155
+ *
156
+ * On 7.23.3 and 7.24rc3 a time literal is order-independent and additive:
157
+ * `1s1m` is `00:01:01`, `1m1m` sums to `00:02:00`, `1h1h1h` to `03:00:00`, and
158
+ * `1d2d` to `3d00:00:00` — all typed `time`. Rejecting an out-of-order or
159
+ * repeated unit would make the hint disagree with the device, so the only
160
+ * question asked here is whether recognized fragments cover the whole value.
161
+ * Reported as an ordering defect in review of #242; the fixture's
162
+ * `timeOrdering` block is the refutation.
163
+ */
164
+ function isTimeShape(value: string): boolean {
165
+ if (value.length > 64) return false;
166
+ // RouterOS also accepts its own display form: optional additive week/day
167
+ // fragments followed by H:M or H:M:S, with a fractional final component.
168
+ // The fields are normalized rather than range-rejected (`00:60:00` becomes
169
+ // `01:00:00`). This branch must run before the colon/address abstention guard.
170
+ if (/^(?:\d+[wd])*\d+:\d+(?::\d+)?(?:\.\d+)?$/.test(value)) return true;
171
+ // Longest suffixes must precede their one-letter prefixes: otherwise `ms`
172
+ // could be consumed as minutes plus seconds instead of milliseconds.
173
+ const matches = [...value.matchAll(/(\d+(?:\.\d+)?)(ns|us|ms|w|d|h|m|s)/g)];
174
+ return (
175
+ matches.length > 0 && matches.map((match) => match[0]).join("") === value
176
+ );
177
+ }
178
+
179
+ /** One `<digits>[.<digits>]` quantity of `unit`, in nanoseconds. */
180
+ function fragmentNanos(quantity: string, unit: string): bigint {
181
+ const scale = UNIT_NS[unit] as bigint;
182
+ const [whole, fraction] = quantity.split(".");
183
+ let total = BigInt(whole as string) * scale;
184
+ if (fraction !== undefined && fraction !== "")
185
+ total += (BigInt(fraction) * scale) / 10n ** BigInt(fraction.length);
186
+ return total;
187
+ }
188
+
189
+ /**
190
+ * Total nanoseconds of a time-shaped value, or null when it does not fit.
191
+ *
192
+ * Only the member context asks: an out-of-range literal is a plain variable
193
+ * reference there (`{100000w}` -> IL `$100000w`), whereas an argument value is
194
+ * console text the schema still reads, which is why the argument lexicon keeps
195
+ * the documented `100000w`-is-`str` deviation instead of abstaining.
196
+ */
197
+ function timeNanos(value: string): bigint | null {
198
+ if (!isTimeShape(value)) return null;
199
+ const colon = value.match(
200
+ /^((?:\d+[wd])*)(\d+):(\d+)(?::(\d+))?(?:\.(\d+))?$/,
201
+ );
202
+ if (colon !== null) {
203
+ let total = 0n;
204
+ for (const [, quantity, unit] of (colon[1] as string).matchAll(
205
+ /(\d+)([wd])/g,
206
+ ))
207
+ total += fragmentNanos(quantity as string, unit as string);
208
+ total += fragmentNanos(colon[2] as string, "h");
209
+ total += fragmentNanos(colon[3] as string, "m");
210
+ if (colon[4] !== undefined) total += fragmentNanos(colon[4], "s");
211
+ if (colon[5] !== undefined) total += fragmentNanos(`0.${colon[5]}`, "s");
212
+ return total > MAX_TIME_NS ? null : total;
213
+ }
214
+ let total = 0n;
215
+ for (const [, quantity, unit] of value.matchAll(
216
+ /(\d+(?:\.\d+)?)(ns|us|ms|w|d|h|m|s)/g,
217
+ )) {
218
+ total += fragmentNanos(quantity as string, unit as string);
219
+ if (total > MAX_TIME_NS) return null;
220
+ }
221
+ return total;
222
+ }
223
+
224
+ /**
225
+ * Shapes for ONE member of an array literal — an expression position.
226
+ *
227
+ * Every abstention here is a positive device reading rather than a gap: what
228
+ * is not a literal in this position is a variable reference (`{abc}`,
229
+ * `{100000w}`), an expression (`{1+1}`, `{a-b}`), or, for `*1`, a hard syntax
230
+ * error. Nested `{…}`/`(…)` members are shaped by the caller from their
231
+ * delimiters, not from here.
232
+ */
233
+ function memberShapeHints(value: string, quoted: boolean): ValueShape[] {
234
+ if (quoted) return ["str"];
235
+ if (/^(?:yes|no|true|false)$/.test(value)) return ["bool"];
236
+ // `0x10` is 16 but `0X10` is a variable reference, and `+1` is a syntax
237
+ // error — the accepted spellings are exactly these two.
238
+ if (/^-?\d+$/.test(value) || /^-?0x[0-9a-fA-F]+$/.test(value)) return ["num"];
239
+ const prefix = prefixParts(value);
240
+ if (prefix !== null) {
241
+ if (prefix.prefix <= 32 && isIpv4Shortcut(prefix.address))
242
+ return ["ip-prefix"];
243
+ if (prefix.prefix <= 128 && isIP(prefix.address) === 6)
244
+ return ["ip6-prefix"];
245
+ return [];
246
+ }
247
+ if (isIpv4Shortcut(value)) return ["ip"];
248
+ if (isIP(value) === 6) return ["ip6"];
249
+ // A dotted decimal that is NOT a valid address falls through to SECONDS on
250
+ // the device (`{256.1}` is `00:04:16.100`, `{1.256}` is `00:00:01.256`).
251
+ // `isTimeShape` does not spell that form, so it abstains here already —
252
+ // deliberately, because the reading depends on the failed address attempt
253
+ // and an abstention never has to be unsaid.
254
+ if (timeNanos(value) !== null) return ["time"];
255
+ return [];
256
+ }
257
+
258
+ /** Full six-octet CLI Reference `macAddr` spelling; shorter runs are time/text. */
259
+ function isMacShape(value: string): boolean {
260
+ return /^[0-9a-fA-F]{2}(?::[0-9a-fA-F]{2}){5}$/.test(value);
261
+ }
262
+
263
+ /**
264
+ * IPv6 text always carries at least two colons (`::` counts as its own pair),
265
+ * so a single colon is not an address attempt: `comment=foo:bar` is a string,
266
+ * while `1::1`, a colon time spelling, and a MAC stay conservative.
267
+ */
268
+ function isColonAddressLike(value: string): boolean {
269
+ if (!value.includes("::") && (value.match(/:/g) ?? []).length < 2)
270
+ return false;
271
+ return /^[0-9a-fA-F:.]+$/.test(value);
272
+ }
273
+
274
+ /**
275
+ * A conservative address attempt whose failed validation must not become `str`.
276
+ *
277
+ * The FIRST slash bounds the address here, while {@link prefixParts} reads the
278
+ * LAST one. The asymmetry is deliberate: a two-slash value like `1.1.1.1/24/x`
279
+ * is not a prefix any reading can accept, and splitting on the first slash is
280
+ * the reading that still recognizes the address attempt and abstains.
281
+ */
282
+ function isAddressLike(value: string): boolean {
283
+ const slash = value.indexOf("/");
284
+ const address = slash < 0 ? value : value.slice(0, slash);
285
+ return /^\d+(?:\.\d+){1,3}$/.test(address) || isColonAddressLike(address);
286
+ }
287
+
288
+ /**
289
+ * The bare comma spelling — the one place a spelling has TWO device readings.
290
+ *
291
+ * `{1;2}` is a syntax error in a command argument, but `=1,2,3` is not, and
292
+ * which reading it gets is decided by the ARGUMENT's type, which offline does
293
+ * not have. On 7.23.3 the device splits it for a list-typed attribute and keeps
294
+ * it whole for a single-valued one, with no syntactic difference between them:
295
+ *
296
+ * | input | `:parse` IL |
297
+ * | ----- | ----------- |
298
+ * | `servers=1.1.1.1,8.8.8.8` | `servers=1.1.1.1;8.8.8.8` — a list |
299
+ * | `dst-port=80,443` | `dst-port=;80;443` — a list |
300
+ * | `connection-state=established,related` | `…=;established;related` — a list |
301
+ * | `comment=a,b` | `comment=a,b` — ONE string |
302
+ * | `interface=ether1,ether2` | `interface=ether1,ether2` — ONE string |
303
+ *
304
+ * So a NAMED attribute carries both readings, which is what the hint list is a
305
+ * list for. In an expression position there is no second reading to carry:
306
+ * `:local x 1,2` is a two-member `array` (and so is `a,b`), and no text reading
307
+ * exists — which is exactly the slots where `allowBareString` is already false.
308
+ *
309
+ * No members are located for it. Whether the run splits at all is the schema's
310
+ * answer, so member spans here would be a guess; the `(1,2)` spelling, where
311
+ * the delimiters prove it, is anchored and descended into as usual.
312
+ */
313
+ function commaSpellingHints(options: ValueShapeOptions): ValueShape[] {
314
+ return options.allowBareString ? ["array", "str"] : ["array"];
315
+ }
316
+
317
+ /**
318
+ * Infer non-authoritative shapes from one already-decoded literal.
319
+ *
320
+ * Generic bare positional words abstain: in `:local x 2.2`, `x` is a binding
321
+ * name, not a value. A named attribute can safely fall back to `str`, and a
322
+ * quoted positional is unambiguously string-shaped.
323
+ */
324
+ export function valueShapeHints(
325
+ value: string,
326
+ options: ValueShapeOptions,
327
+ ): ValueShape[] {
328
+ if (options.context === "array-member")
329
+ return memberShapeHints(value, options.quoted);
330
+ if (options.quoted) return ["str"];
331
+ if (value.includes(",")) return commaSpellingHints(options);
332
+
333
+ const hints: ValueShape[] = [];
334
+ const prefix = prefixParts(value);
335
+ if (prefix !== null) {
336
+ if (prefix.prefix <= 32 && isIpv4Shortcut(prefix.address))
337
+ hints.push("ip-prefix");
338
+ else if (prefix.prefix <= 128 && isIP(prefix.address) === 6)
339
+ hints.push("ip6-prefix");
340
+ if (hints.length > 0 || isAddressLike(value)) return hints;
341
+ return options.allowBareString ? ["str"] : [];
342
+ }
343
+
344
+ if (/^(?:yes|no|true|false)$/.test(value)) hints.push("bool");
345
+ // Integer-only: a dotted decimal is an IPv4 shortcut on the device, not a
346
+ // number, so `num` here would contradict the observed type it is named after.
347
+ if (/^-?\d+$/.test(value)) hints.push("num");
348
+ if (/^\*[0-9a-fA-F]+$/.test(value)) hints.push("id");
349
+ if (isIpv4Shortcut(value)) hints.push("ip");
350
+ else if (isIP(value) === 6) hints.push("ip6");
351
+ if (isTimeShape(value)) hints.push("time");
352
+ if (isMacShape(value)) hints.push("mac");
353
+
354
+ if (hints.length === 0 && options.allowBareString && !isAddressLike(value))
355
+ hints.push("str");
356
+ return hints;
357
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * The frozen console-verb vocabulary for `explain`.
3
+ *
4
+ * A leaf DATA module with no imports, exactly like `menus.ts`, and for the same
5
+ * reason: two modules on opposite sides of the dependency edge both need to ask
6
+ * "is this word a verb?", and they must ask the SAME object. `verbsplit.ts`
7
+ * decides the verb/menu boundary (Q6) and already imports `pathresolve.ts` for
8
+ * the document context walk; `pathresolve.ts`'s R9 (#211) needs the vocabulary
9
+ * to keep `menuNavPath` from claiming `/ip address print` as navigation. Holding
10
+ * the set here rather than in `verbsplit.ts` keeps that a shared leaf instead of
11
+ * an import cycle. `verbsplit.ts` re-exports both names, so the public surface
12
+ * is unchanged.
13
+ */
14
+
15
+ /**
16
+ * FROZEN universal-verb vocabulary: thirteen console CRUD verbs that exist at
17
+ * essentially every configuration menu and have not changed across 7.x. This is
18
+ * deliberately NOT a schema — no menu structure, no per-menu verb list, no
19
+ * version. Do not tune it against scoring output; the whole point is to price a
20
+ * fixed list honestly (it is what ratifies decision 3, "no offline schema
21
+ * snapshot").
22
+ *
23
+ * The entries are lower-case, and the two sides of the R9 seam — `verbsplit`'s
24
+ * `splitRun`/`resolveVerb` and `pathresolve`'s `menuNavPath` — both look up the
25
+ * RAW run-token name, so both are case-sensitive. That agreement is the only
26
+ * thing R9 needs; what matters is that the two use the SAME normalization, not
27
+ * which one they pick.
28
+ *
29
+ * Elsewhere in `explain` the strategy differs: `write.ts`'s `classifyVerb`
30
+ * lower-cases before consulting `READ_VERBS`/`WRITE_VERBS` (a superset of this
31
+ * set — it also carries `monitor`, `reset`, `reset-counters`,
32
+ * `reset-counters-all`), and `menus.ts` lower-cases path segments. So an
33
+ * upper-cased verb is read differently by different modules. That divergence
34
+ * predates R9 and is NOT corpus-reachable — over the whole 911-script frozen
35
+ * corpus, zero run tokens match a verb only after lower-casing — and no claim
36
+ * is made here about which reading the device would give `PRINT`, because that
37
+ * has not been checked on a router.
38
+ */
39
+ export const VERBS: ReadonlySet<string> = new Set([
40
+ "add",
41
+ "comment",
42
+ "disable",
43
+ "edit",
44
+ "enable",
45
+ "export",
46
+ "find",
47
+ "get",
48
+ "move",
49
+ "print",
50
+ "remove",
51
+ "set",
52
+ "unset",
53
+ ]);
54
+
55
+ /**
56
+ * Root menus idiomatically written with a `:` sigil that take a SUB-MENU rather
57
+ * than a positional operand. Measured, not guessed: over the frozen dev split,
58
+ * directive statements with a 2+ token run split 2,088 at run[0] against 18 at
59
+ * run[1], and every one of the 18 is `:log <level>`. `log` has been a root
60
+ * directory since 7.9.2 (checked against the 7.9.2 / 7.20.8 / 7.23rc1 trees),
61
+ * so the exception is version-stable. It is one word, not a schema — a second
62
+ * entry would mean decision 3's fixed-vocabulary claim needs re-examining.
63
+ */
64
+ export const SUBMENU_DIRECTIVES: ReadonlySet<string> = new Set(["log"]);