@tikoci/centrs 0.1.2 → 0.1.4

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