@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.
- package/CHANGELOG.md +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- 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
|
+
}
|