@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,465 @@
1
+ /**
2
+ * The RouterOS operator surface, as the DEVICE describes it (#255).
3
+ *
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
11
+ * `test/fixtures/explain/operators.json` → `sweep`, and
12
+ * `test/unit/explain-operators.test.ts` asserts this table still equals it.
13
+ * All four builds differ in exactly one row, and it is not a lexer row — see
14
+ * {@link ARRAY_COMPARISON_NOTE}.
15
+ *
16
+ * ## Why this is not the manual's list
17
+ *
18
+ * The manual's operator section is a LOWER BOUND, the same way its escape table
19
+ * was in #252. Scoring the complement on the device moved five things:
20
+ *
21
+ * 1. **`not` is not an operator.** Neither are `xor`, `mod`, `is`, `div`,
22
+ * `band`, `bor`, `shl`, `shr`, `eq` or `ne`. They lex as bare words, which
23
+ * inside a paren group become VARIABLE REFERENCES: `(1 not 2)` parses, as
24
+ * `( 1 $not 2)`. A reader that treats "parsed" as "is an operator" gets
25
+ * all eleven wrong.
26
+ * 2. **`..` is not a range operator.** `(1 .. 2)` is `( (. 1 $.) 2)` —
27
+ * concatenation applied to a variable named `.`. Likewise `//` is `/` and
28
+ * `$/`, and `<>` is `<` applied to a DEFERRED `2`, not "not equal".
29
+ * 3. **`$`, `[`, `]`, `;` are syntax, not operators**, though the manual lists
30
+ * the first three under "other operators". They never head a node: `$x` is
31
+ * an atom in the IL and `[…]` lowers to `(evl …)`. Keeping them in an
32
+ * operator table would put a class on bytes that belong to another axis.
33
+ * 4. **`any` is an operator and the manual does not list it.** Prefix, arity
34
+ * 1: `(any 1)` lowers to `(any 1)`.
35
+ * 5. **`&&` and `||` are spellings, not operators.** They lower to the `and`
36
+ * and `or` nodes; nothing in the IL remembers which spelling was written.
37
+ *
38
+ * And one the manual does not contradict so much as omit: **spacing decides the
39
+ * tokens.** `(1.2)` is an IP literal and `(.1)` a time literal, neither of them
40
+ * concatenation. `(1 . 2)` and `(1 .2)` are both `(. 1 2)` — a leading `.` is
41
+ * still the operator — but `(1. 2)` is `( $1. 2)`: `1.` lexes as a VARIABLE
42
+ * NAME and the row is juxtaposition, with no operator in it at all. A fill rule
43
+ * that claims every `.` byte gets that last one wrong in the one direction that
44
+ * matters, by emitting a span the device does not have.
45
+ *
46
+ * ## Which oracle decided what, and what `highlight` cannot do
47
+ *
48
+ * **`:parse` IL decided every row.** IL is prefix form with the operator as the
49
+ * head of its node, so it names the operator and shows its operands — the only
50
+ * oracle that can tell `(> 1)` from `(2 > 1)`.
51
+ *
52
+ * **`highlight` decided nothing, and must not be read as if it could.** Its
53
+ * `syntax-meta` class is the device's RESIDUAL STRUCTURE class: measured over
54
+ * `test/fixtures/explain/highlight-streams.slice.json` it covers `=`, `"`, `$`,
55
+ * `[`, `]`, `{`, `}`, `(`, `)`, `;`, `,`, `/` and whitespace runs alongside
56
+ * `||`, `&&`, `!=`, `.` and `~`. Adjacent same-class bytes are also MERGED into
57
+ * one run — `="`, `($`, `")+`, `]!="` each arrive as a single token, and in
58
+ * this sweep the run for `and`, `or` and `in` carried the leading space. So an
59
+ * operator boundary is not recoverable from `highlight` at all, and
60
+ * {@link RouterosOperator.deviceHighlightRun} records the run rather than a
61
+ * per-byte class for that reason.
62
+ *
63
+ * What `highlight` does decide is **structure versus word**, which is exactly
64
+ * what rules `not` out: it comes back `variable-undefined`, the device saying
65
+ * it lexed a variable reference.
66
+ *
67
+ * ## Nothing here emits a span
68
+ *
69
+ * This module is data plus accessors. Turning it into token output is #264's
70
+ * B2 operator fill, and the `centrs -> highlight` projection is B4; both read
71
+ * this table rather than re-deriving it. The one consumer today is the
72
+ * generated operator section of `commands/explain/README.md`.
73
+ */
74
+
75
+ /** Where the manual files an operator, or `other` for the ones it does not. */
76
+ export type OperatorCategory =
77
+ | "arithmetic"
78
+ | "relational"
79
+ | "logical"
80
+ | "bitwise"
81
+ | "concatenation"
82
+ | "access"
83
+ | "apply";
84
+
85
+ /**
86
+ * How the device nested `(1 A 2 A 3)`.
87
+ *
88
+ * `variadic` is a real third answer, not a way of saying "either": RouterOS
89
+ * FLATTENS those operators into one node with three children, so there is no
90
+ * inner node to associate. A consumer building a tree has to know that `+` is
91
+ * variadic while `-` is binary-left, or it will build a shape the device never
92
+ * produces.
93
+ */
94
+ export type OperatorAssociativity = "variadic" | "left" | "right";
95
+
96
+ export interface RouterosOperator {
97
+ /** As written in source. */
98
+ spelling: string;
99
+ /** The IL node head. Equal to the spelling for every row in this table. */
100
+ ilHead: string;
101
+ /** Operand counts the device accepted, ascending. */
102
+ arities: readonly number[];
103
+ /**
104
+ * 1 binds loosest, 14 binds tightest; `null` for a prefix-only operator.
105
+ *
106
+ * Measured, not transcribed: over all 576 ordered pairs of binary operators,
107
+ * count how often each was the OUTER node. The counts landed exactly on
108
+ * `2 x (tighter) + (same level)` for all 24 spellings with 0 pairs dropped,
109
+ * which is what a strict weak order looks like — so these levels are read
110
+ * off the device rather than assumed. `!` and `any` are prefix-only, never
111
+ * appear in a pair, and are honestly `null` rather than guessed.
112
+ */
113
+ precedence: number | null;
114
+ /** `null` where precedence is null, for the same reason. */
115
+ associativity: OperatorAssociativity | null;
116
+ category: OperatorCategory;
117
+ /**
118
+ * The `highlight` RUN containing this operator in `:put (1 <op> 2)` — not
119
+ * the operator's own class, because the device has no such thing. For
120
+ * `and`, `or` and `in` the run includes the preceding space.
121
+ */
122
+ deviceHighlightRun: string;
123
+ /** The class of that run. `syntax-meta` for every operator in this table. */
124
+ deviceHighlightClass: string;
125
+ }
126
+
127
+ /**
128
+ * The 26 operators, in the order the sweep scored them.
129
+ *
130
+ * `<%%` and the arity-1 `>` are in here on the same footing as `+`: they are
131
+ * undocumented, but the device builds nodes headed by them, which is the only
132
+ * membership test this table uses.
133
+ */
134
+ const OPERATORS: readonly RouterosOperator[] = [
135
+ op("+", [2], 11, "variadic", "arithmetic", "+"),
136
+ op("-", [1, 2], 11, "left", "arithmetic", "-"),
137
+ op("*", [2], 12, "variadic", "arithmetic", "*"),
138
+ op("/", [2], 12, "left", "arithmetic", "/"),
139
+ op("%", [2], 12, "left", "arithmetic", "%"),
140
+ op("<", [2], 5, "left", "relational", "<"),
141
+ // Arity 1 is NOT "less than with one operand": `(> x)` is the deferred
142
+ // expression form, `(>[…])`, whose runtime type is `op`. Same spelling,
143
+ // different operator, and arity is the only thing that separates them.
144
+ // Offline value-shape hints abstain on every such form: identical
145
+ // `(> …)` syntax can be `op` or `array` at runtime and `:parse` IL cannot
146
+ // distinguish them, so `op` lives only as a live `observedType` (#288).
147
+ op(">", [1, 2], 5, "left", "relational", ">"),
148
+ op("=", [2], 5, "left", "relational", "="),
149
+ op("<=", [2], 5, "left", "relational", "<="),
150
+ op(">=", [2], 5, "left", "relational", ">="),
151
+ op("!=", [2], 5, "left", "relational", "!="),
152
+ op("!", [1], null, null, "logical", "!"),
153
+ op("and", [2], 3, "variadic", "logical", " and"),
154
+ op("or", [2], 2, "variadic", "logical", " or"),
155
+ op("in", [2], 4, "left", "logical", " in"),
156
+ op("~", [1, 2], 5, "left", "relational", "~"),
157
+ op("|", [2], 8, "variadic", "bitwise", "|"),
158
+ op("^", [2], 9, "variadic", "bitwise", "^"),
159
+ op("&", [2], 10, "variadic", "bitwise", "&"),
160
+ op("<<", [2], 7, "right", "bitwise", "<<"),
161
+ op(">>", [2], 7, "right", "bitwise", ">>"),
162
+ op(".", [2], 6, "variadic", "concatenation", "."),
163
+ op(",", [2], 1, "variadic", "concatenation", ","),
164
+ op("->", [2], 14, "left", "access", "->"),
165
+ op("<%%", [2], 13, "right", "apply", "<%%"),
166
+ // Undocumented and prefix-only. Not in the manual's list at all.
167
+ //
168
+ // `any` is a nil-check: `:typeof (any x)` is always `bool`; it is `false`
169
+ // only for `nil`/`nothing` (the value of an undefined `:local` and of
170
+ // `[:nothing]`), `true` for everything else — `0`, `""`, `false`, arrays
171
+ // included. Typical use is `:if (any $x) ...` to test a `:local`/`:global`
172
+ // that may be `nil` (see issue #255 thread: `any $x` guards the
173
+ // `[:typeof]` branch). Infix uses like `(true any false)` are not an infix
174
+ // `any` at all — the space-separated form is the juxtaposition node
175
+ // `( true (any false))`, and `(1 any [:nothing])` is juxt `1` and `false`
176
+ // while `(1 . any [:nothing])` is concat `1`+`false`.
177
+ //
178
+ // `!` and `any` are prefix-only, so the PAIR sweep never carries them and
179
+ // the table honestly stores `null` for measured precedence. The sweep asks
180
+ // them separately: every `(U 1 B 2)` and `(1 B U 2)` for `U` in
181
+ // `!`/`any`/`~`/`-`/`>` against all 24 binaries, 240 probes, published as
182
+ // the fixture's `unary` block. All 240 accept and `B` is outermost in
183
+ // every one, on all four swept builds alike — so both bind TIGHTER
184
+ // than every binary, including `->` (14) and `<%%` (13). That is level 15,
185
+ // right-assoc, shared with the unary arities of `~`, `-`, `>` (whose binary
186
+ // arities sit at 5, 11, 5). The single `precedence` per spelling is
187
+ // therefore the binary level; the unary level is documented here until a
188
+ // split field lands.
189
+ op("any", [1], null, null, "logical", "any"),
190
+ ];
191
+
192
+ function op(
193
+ spelling: string,
194
+ arities: number[],
195
+ precedence: number | null,
196
+ associativity: OperatorAssociativity | null,
197
+ category: OperatorCategory,
198
+ deviceHighlightRun: string,
199
+ ): RouterosOperator {
200
+ return {
201
+ spelling,
202
+ ilHead: spelling,
203
+ arities,
204
+ precedence,
205
+ associativity,
206
+ category,
207
+ deviceHighlightRun,
208
+ // Every operator scored `syntax-meta`. Stored per row rather than implied
209
+ // so a future version that moves one is a diff, not a rewrite.
210
+ deviceHighlightClass: "syntax-meta",
211
+ };
212
+ }
213
+
214
+ /**
215
+ * A spelling the device reads as something OTHER than an operator of its own
216
+ * name.
217
+ *
218
+ * Two different things, and the distinction is load-bearing for anything that
219
+ * wants to render or rewrite source:
220
+ *
221
+ * - `alias` — the whole spelling becomes one node with the same operands.
222
+ * `(1 && 2)` is `(and 1 2)`. Round-tripping through IL loses the spelling.
223
+ * - `re-lexed` — the spelling was never one token. `(1 <> 2)` is
224
+ * `(< 1 (> 2))`: `<` applied to a DEFERRED `2`. It is not "not equal", it is
225
+ * two operators, and a table that recorded `<>` as an alias of `!=` would be
226
+ * confidently wrong about what the program does.
227
+ */
228
+ export interface LoweredSpelling {
229
+ spelling: string;
230
+ kind: "alias" | "re-lexed";
231
+ /** The head(s) the device actually built. */
232
+ ilHeads: readonly string[];
233
+ /** The device IL for the binary carrier, as evidence for `kind`. */
234
+ example: string;
235
+ }
236
+
237
+ const LOWERED: readonly LoweredSpelling[] = [
238
+ {
239
+ spelling: "&&",
240
+ kind: "alias",
241
+ ilHeads: ["and"],
242
+ example: "(<%% (and 1 2) )",
243
+ },
244
+ {
245
+ spelling: "||",
246
+ kind: "alias",
247
+ ilHeads: ["or"],
248
+ example: "(<%% (or 1 2) )",
249
+ },
250
+ {
251
+ spelling: "<>",
252
+ kind: "re-lexed",
253
+ ilHeads: ["<"],
254
+ example: "(<%% (< 1 (> 2)) )",
255
+ },
256
+ ];
257
+
258
+ /**
259
+ * Why a swept spelling is not an operator. Three device behaviors, not one.
260
+ *
261
+ * These are the sweep's derived categories, not a reading of them — the slice
262
+ * computes each from the carriers' IL and `test/unit/explain-operators.test.ts`
263
+ * asserts the mapping. Where a row deserves an interpretation on top (`$` and
264
+ * `[` are syntax on the substitution axis, whatever the carriers did with
265
+ * them), that interpretation lives in {@link MANUAL_OTHER_OPERATORS} and in
266
+ * prose, so it stays visibly separate from what the device said.
267
+ */
268
+ export type NonOperatorReason =
269
+ /** `:parse` refused every carrier. */
270
+ | "rejected"
271
+ /**
272
+ * Accepted somewhere, but with part of the spelling lexed as a `$name`:
273
+ * `(1 not 2)` -> `( 1 $not 2)`, `(1 .. 2)` -> `( (. 1 $.) 2)`.
274
+ */
275
+ | "reads-as-variable"
276
+ /**
277
+ * Accepted only inside RouterOS's unnamed juxtaposition node, with no
278
+ * invented `$` and no node of its own.
279
+ */
280
+ | "juxtaposition-only";
281
+
282
+ export interface NonOperator {
283
+ spelling: string;
284
+ reason: NonOperatorReason;
285
+ /** What `highlight` called it. `variable-undefined` for every word here. */
286
+ deviceHighlightClass: string;
287
+ }
288
+
289
+ /**
290
+ * The grounded COMPLEMENT — spellings the sweep asked about and the device
291
+ * refused.
292
+ *
293
+ * This half is the point. An allow-list assembled from documentation cannot say
294
+ * a spelling is absent; it can only fail to mention it, which is a different
295
+ * claim and the one #252 got wrong. Eighteen of these are word-shaped and would
296
+ * have been plausible keywords in any other language — including the eleven the
297
+ * probe's first verdict rule wrongly promoted, listed at the top of this file.
298
+ */
299
+ const NOT_OPERATORS: readonly NonOperator[] = [
300
+ // Word-shaped. Every one of these would be a keyword in some other language,
301
+ // and every one is a variable reference here.
302
+ notOp("not", "reads-as-variable", "variable-undefined"),
303
+ notOp("xor", "reads-as-variable", "variable-undefined"),
304
+ notOp("mod", "reads-as-variable", "variable-undefined"),
305
+ notOp("is", "reads-as-variable", "variable-undefined"),
306
+ notOp("div", "reads-as-variable", "variable-undefined"),
307
+ notOp("band", "reads-as-variable", "variable-undefined"),
308
+ notOp("bor", "reads-as-variable", "variable-undefined"),
309
+ notOp("shl", "reads-as-variable", "variable-undefined"),
310
+ notOp("shr", "reads-as-variable", "variable-undefined"),
311
+ notOp("eq", "reads-as-variable", "variable-undefined"),
312
+ notOp("ne", "reads-as-variable", "variable-undefined"),
313
+ notOp("line", "reads-as-variable", "variable-undefined"),
314
+ notOp("array", "reads-as-variable", "variable-undefined"),
315
+ notOp("as", "reads-as-variable", "variable-undefined"),
316
+ notOp("at", "reads-as-variable", "variable-undefined"),
317
+ notOp("for", "reads-as-variable", "variable-undefined"),
318
+ notOp("none", "reads-as-variable", "variable-undefined"),
319
+ notOp("outside", "reads-as-variable", "variable-undefined"),
320
+ // `evl` is the IL's own node name for a command. It is not source syntax:
321
+ // only the no-space carrier `(1evl2)` survives, and it survives as `$1evl2`.
322
+ notOp("evl", "reads-as-variable", "syntax-meta"),
323
+ // Punctuation that re-lexes into an operator plus a variable named after the
324
+ // remaining byte: `..` -> `.` and `$.`; `//` -> `/` and `$/`.
325
+ notOp("..", "reads-as-variable", "syntax-meta"),
326
+ notOp("//", "reads-as-variable", "syntax-meta"),
327
+ notOp("**", "rejected", "syntax-meta"),
328
+ notOp("===", "rejected", "syntax-meta"),
329
+ notOp("+=", "rejected", "syntax-meta"),
330
+ notOp("++", "rejected", "syntax-meta"),
331
+ notOp("?", "rejected", "error"),
332
+ notOp("?:", "rejected", "error"),
333
+ notOp("<%", "rejected", "syntax-meta"),
334
+ notOp("%%", "rejected", "syntax-meta"),
335
+ notOp(";", "rejected", "error"),
336
+ // The manual's "other operators" — see MANUAL_OTHER_OPERATORS.
337
+ notOp("$", "juxtaposition-only", "syntax-meta"),
338
+ notOp("[", "rejected", "syntax-meta"),
339
+ notOp("]", "rejected", "error"),
340
+ ];
341
+
342
+ /**
343
+ * Spellings the manual files under "other operators" that never head a node.
344
+ *
345
+ * `$`, `[` and `]` are real RouterOS syntax and the manual is not wrong to
346
+ * mention them — but they are on the SUBSTITUTION axis, not the operator axis.
347
+ * `$x` stays an atom in the IL (`(+ 1 $x)`), and `[:tostr 1]` lowers to an
348
+ * `evl` node named after the command, not to a node named `[`. Their carrier
349
+ * rows in {@link nonOperators} say only what the generic carriers did with
350
+ * them, which for `[` is "rejected" — true, and not the reason they are out.
351
+ *
352
+ * A token stream must still claim these bytes; they belong to `symbols.ts` and
353
+ * to whatever B2 fill covers bracket substitution, not to an operator fill.
354
+ */
355
+ export const MANUAL_OTHER_OPERATORS: readonly string[] = ["$", "[", "]"];
356
+
357
+ function notOp(
358
+ spelling: string,
359
+ reason: NonOperatorReason,
360
+ deviceHighlightClass: string,
361
+ ): NonOperator {
362
+ return { spelling, reason, deviceHighlightClass };
363
+ }
364
+
365
+ /**
366
+ * The single version-dependent fact the sweep found.
367
+ *
368
+ * Every operator, arity, precedence level, associativity, `highlight` class and
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.
374
+ *
375
+ * The one row that moved is a RUNTIME semantic, not a lexer fact:
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.
379
+ */
380
+ export const ARRAY_COMPARISON_NOTE =
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";
390
+
391
+ const BY_SPELLING: ReadonlyMap<string, RouterosOperator> = new Map(
392
+ OPERATORS.map((entry) => [entry.spelling, entry]),
393
+ );
394
+
395
+ /** Every operator, in table order. */
396
+ export function routerosOperators(): readonly RouterosOperator[] {
397
+ return OPERATORS;
398
+ }
399
+
400
+ /** Spellings the device reads as another operator, or as several. */
401
+ export function loweredSpellings(): readonly LoweredSpelling[] {
402
+ return LOWERED;
403
+ }
404
+
405
+ /** The grounded complement: swept spellings that are not operators. */
406
+ export function nonOperators(): readonly NonOperator[] {
407
+ return NOT_OPERATORS;
408
+ }
409
+
410
+ /** The operator with this exact spelling, or `undefined`. */
411
+ export function operatorFor(spelling: string): RouterosOperator | undefined {
412
+ return BY_SPELLING.get(spelling);
413
+ }
414
+
415
+ /**
416
+ * Whether this spelling heads an operator node on the device.
417
+ *
418
+ * `false` for `&&` and `||` even though both are real RouterOS operators to
419
+ * write — they are spellings of `and` and `or`, and this predicate answers
420
+ * "does the IL have a node by this name". Use {@link resolveSpelling} to go
421
+ * from source text to the operator it denotes.
422
+ */
423
+ export function isOperatorSpelling(spelling: string): boolean {
424
+ return BY_SPELLING.has(spelling);
425
+ }
426
+
427
+ /**
428
+ * Source spelling -> the operator it denotes, following one alias hop.
429
+ *
430
+ * Returns `undefined` for a re-lexed spelling such as `<>`, because there is no
431
+ * single operator to return: the device read it as two.
432
+ */
433
+ export function resolveSpelling(
434
+ spelling: string,
435
+ ): RouterosOperator | undefined {
436
+ const direct = BY_SPELLING.get(spelling);
437
+ if (direct !== undefined) return direct;
438
+ const lowered = LOWERED.find((entry) => entry.spelling === spelling);
439
+ if (lowered === undefined || lowered.kind !== "alias") return undefined;
440
+ return BY_SPELLING.get(lowered.ilHeads[0] ?? "");
441
+ }
442
+
443
+ /**
444
+ * The table as flat comparable rows, for the fixture-drift assertion.
445
+ *
446
+ * Flat strings rather than objects so a mismatch prints the row that moved
447
+ * instead of two JSON blobs (the #260 lesson about comparing serialized
448
+ * tallies).
449
+ */
450
+ export function operatorRows(): string[] {
451
+ return OPERATORS.map(
452
+ (entry) =>
453
+ `${entry.spelling} arities=${entry.arities.join(",")} ` +
454
+ `precedence=${entry.precedence ?? "-"} assoc=${entry.associativity ?? "-"} ` +
455
+ `hl=${entry.deviceHighlightClass}`,
456
+ ).sort();
457
+ }
458
+
459
+ /** The complement as flat comparable rows, same reason. */
460
+ export function nonOperatorRows(): string[] {
461
+ return NOT_OPERATORS.map(
462
+ (entry) =>
463
+ `${entry.spelling} ${entry.reason} hl=${entry.deviceHighlightClass}`,
464
+ ).sort();
465
+ }
@@ -0,0 +1,150 @@
1
+ /**
2
+ * B2 path fill — claims resolved menu and command-name bytes on the residual.
3
+ *
4
+ * Only decided `navigation` and `resolved` readings participate. Ambiguous or
5
+ * unknown runs stay unclassified, and a statement whose source bytes cannot be
6
+ * mapped exactly is refused just like argument/value fills.
7
+ *
8
+ * Slash separators belong to `dir` when they are part of a valid path. Space
9
+ * separators remain unclassified. A `\<newline>` inside a name interrupts the
10
+ * emitted range so the continuation bytes remain available to the escape fill.
11
+ *
12
+ * Vocabulary settled by #264 B5: `dir` and `cmd` mirror the useful
13
+ * lexical distinction already produced by RouterOS highlight, but are
14
+ * centrs-owned classes.
15
+ */
16
+
17
+ import type { ExplainToken } from "../explain.ts";
18
+ import {
19
+ mergeAdjacentTokens,
20
+ type TokenRange,
21
+ tokensOnResidual,
22
+ } from "./token-ranges.ts";
23
+ import { locatedRunTokens, type VerbSplit } from "./verbsplit.ts";
24
+
25
+ export interface PathTokenCandidate {
26
+ text: string;
27
+ span: TokenRange;
28
+ split: VerbSplit;
29
+ ev: string;
30
+ }
31
+
32
+ export function pathSpans(
33
+ analyzed: string,
34
+ residual: readonly TokenRange[],
35
+ candidates: readonly PathTokenCandidate[],
36
+ ): ExplainToken[] {
37
+ if (analyzed.length === 0 || residual.length === 0) return [];
38
+ const out: ExplainToken[] = [];
39
+
40
+ for (const candidate of candidates) {
41
+ if (
42
+ candidate.split.resolution !== "resolved" &&
43
+ candidate.split.resolution !== "navigation"
44
+ )
45
+ continue;
46
+ const { start, end } = candidate.span;
47
+ if (
48
+ !Number.isInteger(start) ||
49
+ !Number.isInteger(end) ||
50
+ start < 0 ||
51
+ end <= start ||
52
+ end > analyzed.length ||
53
+ analyzed.slice(start, end) !== candidate.text
54
+ )
55
+ continue;
56
+
57
+ let bodyStart = 0;
58
+ while (
59
+ bodyStart < candidate.text.length &&
60
+ [" ", "\t", "\r", "\n"].includes(candidate.text[bodyStart] as string)
61
+ )
62
+ bodyStart++;
63
+ const directiveColon = candidate.text[bodyStart] === ":";
64
+ if (directiveColon) bodyStart++;
65
+ const run = locatedRunTokens(candidate.text.slice(bodyStart));
66
+ const last =
67
+ candidate.split.resolution === "resolved"
68
+ ? candidate.split.verbAt
69
+ : run.length - 1;
70
+ if (last < 0 || last >= run.length) continue;
71
+ const slashPositions = new Set<number>();
72
+ let coveredEnd = bodyStart;
73
+ for (let index = 0; index <= last; index++) {
74
+ const token = run[index];
75
+ if (token === undefined) continue;
76
+ for (const range of token.nameSpans)
77
+ coveredEnd = Math.max(coveredEnd, bodyStart + range.end);
78
+ if (token.slashBefore !== undefined) {
79
+ slashPositions.add(bodyStart + token.slashBefore.start);
80
+ coveredEnd = Math.max(coveredEnd, bodyStart + token.slashBefore.end);
81
+ }
82
+ if (token.slashAfter !== undefined) {
83
+ slashPositions.add(bodyStart + token.slashAfter.start);
84
+ coveredEnd = Math.max(coveredEnd, bodyStart + token.slashAfter.end);
85
+ }
86
+ }
87
+ let malformedSlash = false;
88
+ for (let offset = 0; offset < coveredEnd; offset++) {
89
+ if (candidate.text[offset] === "/" && !slashPositions.has(offset)) {
90
+ malformedSlash = true;
91
+ break;
92
+ }
93
+ }
94
+ if (candidate.text[coveredEnd] === "/") malformedSlash = true;
95
+ if (malformedSlash) continue;
96
+ if (directiveColon) {
97
+ out.push(
98
+ ...tokensOnResidual(
99
+ [
100
+ {
101
+ start: start + bodyStart - 1,
102
+ end: start + bodyStart,
103
+ },
104
+ ],
105
+ residual,
106
+ "dir",
107
+ candidate.ev,
108
+ ),
109
+ );
110
+ }
111
+
112
+ for (let index = 0; index <= last; index++) {
113
+ const token = run[index];
114
+ if (token === undefined) continue;
115
+ const class_ =
116
+ candidate.split.resolution === "resolved" &&
117
+ index === candidate.split.verbAt
118
+ ? "cmd"
119
+ : "dir";
120
+ const ranges: TokenRange[] = token.nameSpans.map((range) => ({
121
+ start: start + bodyStart + range.start,
122
+ end: start + bodyStart + range.end,
123
+ }));
124
+ if (token.slashBefore !== undefined) {
125
+ out.push(
126
+ ...tokensOnResidual(
127
+ [
128
+ {
129
+ start: start + bodyStart + token.slashBefore.start,
130
+ end: start + bodyStart + token.slashBefore.end,
131
+ },
132
+ ],
133
+ residual,
134
+ "dir",
135
+ candidate.ev,
136
+ ),
137
+ );
138
+ }
139
+ if (class_ === "dir" && token.slashAfter !== undefined) {
140
+ ranges.push({
141
+ start: start + bodyStart + token.slashAfter.start,
142
+ end: start + bodyStart + token.slashAfter.end,
143
+ });
144
+ }
145
+ out.push(...tokensOnResidual(ranges, residual, class_, candidate.ev));
146
+ }
147
+ }
148
+
149
+ return mergeAdjacentTokens(out);
150
+ }