@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,955 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Verb / menu boundary resolution for `explain` (centrs canonicalizer).
|
|
3
|
+
*
|
|
4
|
+
* Ratified by the phase-0 lab, question Q6 (#185) and promoted from the
|
|
5
|
+
* throwaway probe `.scratch/explain-lab-verbsplit.ts`. Q6 asks the residual the
|
|
6
|
+
* path resolver (Q3/Q4) deliberately left open: within a statement's leading
|
|
7
|
+
* path run, where does the MENU end and the VERB begin — and when must offline
|
|
8
|
+
* refuse to guess? `pathresolve.ts` reports a greedy full-run `path` plus the
|
|
9
|
+
* shorter `candidates`; this module decides the boundary and, crucially, emits
|
|
10
|
+
* the ratified **`ambiguous`** verdict where a schema-free reading genuinely
|
|
11
|
+
* cannot.
|
|
12
|
+
*
|
|
13
|
+
* The lab priced seven boundary policies (`minimal`, `greedy`, `last`,
|
|
14
|
+
* `punctuation`, `vocab`, `abstain`, `proposed`) against the device command
|
|
15
|
+
* tree as oracle. Only the ratified `proposed` arm is promoted — it scored
|
|
16
|
+
* ~99.9% precision on decided statements at ~3% abstention and is entirely
|
|
17
|
+
* version-independent (no schema snapshot, per decision 3). The other six arms
|
|
18
|
+
* existed only to price it and stayed in the lab.
|
|
19
|
+
*
|
|
20
|
+
* The signals, all schema-free and read off the statement's own text:
|
|
21
|
+
* V1 Every run token carries a separator: `start`, `slash`, `space`.
|
|
22
|
+
* RouterOS console convention writes menu segments slash-joined and the
|
|
23
|
+
* verb space-separated, so the writer's punctuation is a boundary signal
|
|
24
|
+
* that Q3/Q4's flattened run threw away.
|
|
25
|
+
* V2 A token containing `=` is an ARGUMENT and ends the run. The only
|
|
26
|
+
* certain verb/arg tell offline has.
|
|
27
|
+
* V3 A `[`, `(`, `"`, `$` token ends the run.
|
|
28
|
+
* V4 Whether anything FOLLOWS the run is itself a signal. A statement that
|
|
29
|
+
* is nothing but a path (`/ip/address` vs `/system/reboot`) is textually
|
|
30
|
+
* identical whether it navigates to a directory or invokes a no-argument
|
|
31
|
+
* command — Q4's cascade residual. This is the ONLY case `proposed`
|
|
32
|
+
* abstains on, and it carries no length floor.
|
|
33
|
+
*
|
|
34
|
+
* The `proposed` rule, in order: a scripting directive's operand is positional
|
|
35
|
+
* (R10) with the one measured sub-menu exception (`:log <level>`); then a
|
|
36
|
+
* FROZEN 13-verb CRUD vocabulary; then punctuation (the maximal slash-joined
|
|
37
|
+
* prefix is menu, the first space token is the verb); and abstention ONLY at
|
|
38
|
+
* V4's bare path.
|
|
39
|
+
*
|
|
40
|
+
* **V4 is now decidable where the baked container table reaches it (#210).**
|
|
41
|
+
* `splitRun` below still implements `proposed` exactly as ratified — it is the
|
|
42
|
+
* schema-free rule, and it stays that way so its abstention rate remains an
|
|
43
|
+
* honest price for decision 3. `resolveVerb` then reads that `ambiguous`
|
|
44
|
+
* verdict and, for the bare-path case ONLY, asks `menus.ts` whether the path
|
|
45
|
+
* the statement names is a known RouterOS container; if it is, the statement is
|
|
46
|
+
* `navigation` rather than a refusal. `/ip/address` is in the table and
|
|
47
|
+
* `/system/reboot` is not, which is precisely the twin V4 could not separate.
|
|
48
|
+
* A path the table does not carry stays `ambiguous` — the table is a floor, so
|
|
49
|
+
* absence is not evidence of a command (see `menus.ts`).
|
|
50
|
+
*
|
|
51
|
+
* That makes confident menu navigation this module's surface after all, but on
|
|
52
|
+
* a firmer basis than `pathresolve.ts`'s `isNav`, which claims navigation for
|
|
53
|
+
* every `/`-led bare path unconditionally and so reports `/system/reboot` as a
|
|
54
|
+
* menu (#211). Where the two disagree, this module is the narrower answer.
|
|
55
|
+
*
|
|
56
|
+
* **V4's other half is decidable too (#228).** `catalog.ts` carries a published
|
|
57
|
+
* COMMAND axis beside the container table, so the twin `splitRun` cannot
|
|
58
|
+
* separate is now separated from both ends: `/ip/address` is a known menu and
|
|
59
|
+
* `/system/reboot` is a known command. See {@link catalogVerbAt} for the rule,
|
|
60
|
+
* its precedence over the punctuation guess, and the two places it deliberately
|
|
61
|
+
* does not apply. `splitRun` is again untouched — both tables are consumed here.
|
|
62
|
+
*
|
|
63
|
+
* **The other direction closes too (#324).** R9 kept `pathresolve` from
|
|
64
|
+
* claiming navigation where a verb was decided; the symmetric rule keeps
|
|
65
|
+
* `resolveVerb` from claiming a verb where the whole run names a known MENU
|
|
66
|
+
* and the segment it would promote is not in `VERBS`. `splitRun`'s punctuation
|
|
67
|
+
* fallback used to read `/ip/firewall/address-list name=ytkids` as verb
|
|
68
|
+
* `address-list` at `/ip/firewall` — a verb assembled from a path segment, on
|
|
69
|
+
* a statement whose verb is simply missing. It degrades to `ambiguous`. Priced
|
|
70
|
+
* on the frozen partition: 10 of 17,212 statements flip, every one of them a
|
|
71
|
+
* fabrication withdrawn (`commands/explain/README.md` → The menu path with
|
|
72
|
+
* arguments). Both tables stay floors — presence narrows, absence decides
|
|
73
|
+
* nothing.
|
|
74
|
+
*
|
|
75
|
+
* Like `blocks.ts`, this ships only the boundary primitives. Argument parsing
|
|
76
|
+
* (`k=v` / `?query` after the verb) belongs to the phase-1 canonical assembly,
|
|
77
|
+
* not to the Q6 question. `resolveVerb(text, context)` takes the enclosing menu
|
|
78
|
+
* context explicitly; `resolveVerbs(text)` walks a whole document on top of the
|
|
79
|
+
* resolver's context-certainty contract (#192).
|
|
80
|
+
*/
|
|
81
|
+
|
|
82
|
+
import { scopeBlocksIn } from "./blocks.ts";
|
|
83
|
+
import { commandVerbIndex } from "./catalog.ts";
|
|
84
|
+
import type { Defect } from "./defects.ts";
|
|
85
|
+
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
86
|
+
import {
|
|
87
|
+
resolveStatements,
|
|
88
|
+
type Span,
|
|
89
|
+
type StatementAnalysis,
|
|
90
|
+
} from "./pathresolve.ts";
|
|
91
|
+
import {
|
|
92
|
+
documentRange,
|
|
93
|
+
type MaskedRange,
|
|
94
|
+
nestedRange,
|
|
95
|
+
rangeAt,
|
|
96
|
+
} from "./segment.ts";
|
|
97
|
+
import { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
|
|
98
|
+
|
|
99
|
+
const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
100
|
+
/**
|
|
101
|
+
* A word that ends the leading run: it carries `=` (V2) or opens a group /
|
|
102
|
+
* string / variable (V3). `:` is deliberately NOT here — RouterOS uses it only
|
|
103
|
+
* to start a scripting directive, not as a mid-statement opener, and a
|
|
104
|
+
* `:`-prefixed token is already rejected by `BARE_WORD` below, so it ends the
|
|
105
|
+
* run regardless.
|
|
106
|
+
*/
|
|
107
|
+
const RUN_TERMINATOR = /^[[({"$]/;
|
|
108
|
+
|
|
109
|
+
function isAsciiWhitespace(char: string | undefined): boolean {
|
|
110
|
+
return char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function trimAscii(text: string): string {
|
|
114
|
+
let start = 0;
|
|
115
|
+
let end = text.length;
|
|
116
|
+
while (start < end && isAsciiWhitespace(text[start])) start++;
|
|
117
|
+
while (end > start && isAsciiWhitespace(text[end - 1])) end--;
|
|
118
|
+
return text.slice(start, end);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Leading trim only — the half `describeStatement` has to count to rebase offsets. */
|
|
122
|
+
function trimAsciiStart(text: string): string {
|
|
123
|
+
let start = 0;
|
|
124
|
+
while (start < text.length && isAsciiWhitespace(text[start])) start++;
|
|
125
|
+
return text.slice(start);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** One whitespace-separated word, and where it sits in the text it came from. */
|
|
129
|
+
interface Word {
|
|
130
|
+
name: string;
|
|
131
|
+
/** offset of the word's first character, inclusive. */
|
|
132
|
+
start: number;
|
|
133
|
+
/** offset just past its last character, exclusive. */
|
|
134
|
+
end: number;
|
|
135
|
+
/** Source span of each character retained in `name`. */
|
|
136
|
+
chars: Span[];
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* The words of a statement, WITH their offsets (#202c).
|
|
141
|
+
*
|
|
142
|
+
* The one word scanner this module has. It replaced a
|
|
143
|
+
* `replace(/\\\r?\n/g,"").split(whitespace)` pair whose output could not be
|
|
144
|
+
* mapped back to the text it came from — and a second scanner written beside it
|
|
145
|
+
* for the offsets would be free to disagree with the boundary rule, which is the
|
|
146
|
+
* failure #202b hit three times in argument handling.
|
|
147
|
+
*
|
|
148
|
+
* H5 from the Q1 segmenter: a `\<newline>` is removed before RouterOS parses the
|
|
149
|
+
* continued line, so it JOINS the words either side of it rather than separating
|
|
150
|
+
* them (`na\<nl>me` is the single word `name`) and contributes no character to
|
|
151
|
+
* the name. That is why a word's `end - start` is not its `name.length`: the span
|
|
152
|
+
* covers the continuation bytes, the name does not. A caller taking an interior
|
|
153
|
+
* offset from a name is therefore wrong on continued statements, and
|
|
154
|
+
* {@link describeStatement} only ever publishes the run's END.
|
|
155
|
+
*/
|
|
156
|
+
function* asciiWordSpans(text: string): Generator<Word> {
|
|
157
|
+
// Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
|
|
158
|
+
// stop at the first word the leading run cannot take, and a statement's text
|
|
159
|
+
// carries its whole nested `do={…}` subtree — so building a word (and a span
|
|
160
|
+
// per character of it) for the rest of that subtree was work no caller ever
|
|
161
|
+
// looked at, once per enclosing level (#322). A word is complete when the
|
|
162
|
+
// next one starts, which is why the yield happens on the following boundary
|
|
163
|
+
// rather than at the point the word is opened.
|
|
164
|
+
let current: Word | null = null;
|
|
165
|
+
let i = 0;
|
|
166
|
+
while (i < text.length) {
|
|
167
|
+
const c = text[i] as string;
|
|
168
|
+
if (
|
|
169
|
+
c === "\\" &&
|
|
170
|
+
(text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
|
|
171
|
+
) {
|
|
172
|
+
// Inside a word this joins; between words it is whitespace that opens
|
|
173
|
+
// none — matching the removal-then-split the rule replaced.
|
|
174
|
+
i += text[i + 1] === "\r" ? 3 : 2;
|
|
175
|
+
if (current !== null) current.end = i;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
if (isAsciiWhitespace(c)) {
|
|
179
|
+
if (current !== null) yield current;
|
|
180
|
+
current = null;
|
|
181
|
+
i++;
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
184
|
+
if (current === null) {
|
|
185
|
+
current = {
|
|
186
|
+
name: c,
|
|
187
|
+
start: i,
|
|
188
|
+
end: i + 1,
|
|
189
|
+
chars: [{ start: i, end: i + 1 }],
|
|
190
|
+
};
|
|
191
|
+
} else {
|
|
192
|
+
current.name += c;
|
|
193
|
+
current.end = i + 1;
|
|
194
|
+
current.chars.push({ start: i, end: i + 1 });
|
|
195
|
+
}
|
|
196
|
+
i++;
|
|
197
|
+
}
|
|
198
|
+
if (current !== null) yield current;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* The frozen vocabulary lives in the leaf `verbs.ts` because `pathresolve.ts`
|
|
203
|
+
* needs it too (R9, #211) and this module already imports that one. Re-exported
|
|
204
|
+
* here so the vocabulary still reads as part of the Q6 surface.
|
|
205
|
+
*/
|
|
206
|
+
export { SUBMENU_DIRECTIVES, VERBS } from "./verbs.ts";
|
|
207
|
+
|
|
208
|
+
/** Separator that precedes a run token (V1). */
|
|
209
|
+
export type Sep = "start" | "slash" | "space";
|
|
210
|
+
|
|
211
|
+
/** One token of the leading path run, with the punctuation that introduced it. */
|
|
212
|
+
export interface RunToken {
|
|
213
|
+
name: string;
|
|
214
|
+
sep: Sep;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/** A run token mapped back to the statement bytes that spell it. */
|
|
218
|
+
export interface LocatedRunToken extends RunToken {
|
|
219
|
+
/** Name bytes, split where a `\<newline>` continuation interrupts them. */
|
|
220
|
+
nameSpans: Span[];
|
|
221
|
+
/** One valid `/` immediately before this token, if present. */
|
|
222
|
+
slashBefore?: Span;
|
|
223
|
+
/** One trailing `/` after this token, if present. */
|
|
224
|
+
slashAfter?: Span;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function coalesceChars(chars: readonly Span[]): Span[] {
|
|
228
|
+
const out: Span[] = [];
|
|
229
|
+
for (const char of chars) {
|
|
230
|
+
const last = out.at(-1);
|
|
231
|
+
if (last !== undefined && last.end === char.start) last.end = char.end;
|
|
232
|
+
else out.push({ start: char.start, end: char.end });
|
|
233
|
+
}
|
|
234
|
+
return out;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* The leading path-token run WITH separators preserved (V1). Same stopping
|
|
239
|
+
* rules as `pathresolve`'s run (V2/V3), so the two stay comparable: a slash-
|
|
240
|
+
* joined word expands to several `slash`-separated tokens; a space-separated
|
|
241
|
+
* word is one `space` token. Stops at the first `=`/group/string/var word, and
|
|
242
|
+
* at any word carrying a non-`BARE_WORD` segment (a variable segment truncates
|
|
243
|
+
* the run before the verb).
|
|
244
|
+
*/
|
|
245
|
+
export function locatedRunTokens(text: string): LocatedRunToken[] {
|
|
246
|
+
const out: LocatedRunToken[] = [];
|
|
247
|
+
for (const word of asciiWordSpans(text)) {
|
|
248
|
+
if (word.name.includes("=") || RUN_TERMINATOR.test(word.name)) break;
|
|
249
|
+
const parts: {
|
|
250
|
+
name: string;
|
|
251
|
+
start: number;
|
|
252
|
+
end: number;
|
|
253
|
+
slashBefore?: Span;
|
|
254
|
+
}[] = [];
|
|
255
|
+
let start = 0;
|
|
256
|
+
for (let i = 0; i <= word.name.length; i++) {
|
|
257
|
+
if (i < word.name.length && word.name[i] !== "/") continue;
|
|
258
|
+
if (start < i) {
|
|
259
|
+
const slashRunStart = start;
|
|
260
|
+
let slashCount = 0;
|
|
261
|
+
while (
|
|
262
|
+
slashRunStart - slashCount - 1 >= 0 &&
|
|
263
|
+
word.name[slashRunStart - slashCount - 1] === "/"
|
|
264
|
+
)
|
|
265
|
+
slashCount++;
|
|
266
|
+
const slashIndex = slashCount === 1 ? slashRunStart - 1 : -1;
|
|
267
|
+
parts.push({
|
|
268
|
+
name: word.name.slice(start, i),
|
|
269
|
+
start,
|
|
270
|
+
end: i,
|
|
271
|
+
...(slashIndex < 0 ? {} : { slashBefore: word.chars[slashIndex] }),
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
start = i + 1;
|
|
275
|
+
}
|
|
276
|
+
if (parts.length === 0) continue;
|
|
277
|
+
if (!parts.every((p) => BARE_WORD.test(p.name))) break;
|
|
278
|
+
for (let i = 0; i < parts.length; i++) {
|
|
279
|
+
const part = parts[i] as (typeof parts)[number];
|
|
280
|
+
const leadingSlash = word.name.startsWith("/") && i === 0;
|
|
281
|
+
const trailingSlash =
|
|
282
|
+
i === parts.length - 1 &&
|
|
283
|
+
word.name.endsWith("/") &&
|
|
284
|
+
!word.name.endsWith("//")
|
|
285
|
+
? word.chars.at(-1)
|
|
286
|
+
: undefined;
|
|
287
|
+
out.push({
|
|
288
|
+
name: part.name,
|
|
289
|
+
sep:
|
|
290
|
+
out.length === 0
|
|
291
|
+
? "start"
|
|
292
|
+
: i > 0 || leadingSlash
|
|
293
|
+
? "slash"
|
|
294
|
+
: "space",
|
|
295
|
+
nameSpans: coalesceChars(word.chars.slice(part.start, part.end)),
|
|
296
|
+
...(part.slashBefore === undefined
|
|
297
|
+
? {}
|
|
298
|
+
: { slashBefore: part.slashBefore }),
|
|
299
|
+
...(trailingSlash === undefined ? {} : { slashAfter: trailingSlash }),
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return out;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
export function runTokens(text: string): RunToken[] {
|
|
307
|
+
return locatedRunTokens(text).map(({ name, sep }) => ({ name, sep }));
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/** True when the statement is a scripting directive (`:x`, or a bare `x do={…}`). */
|
|
311
|
+
export function isDirective(text: string, range?: MaskedRange): boolean {
|
|
312
|
+
const t = trimAscii(text);
|
|
313
|
+
if (t.startsWith(":")) return true;
|
|
314
|
+
if (t.startsWith("/")) return false;
|
|
315
|
+
const start = text.length - trimAsciiStart(text).length;
|
|
316
|
+
return (
|
|
317
|
+
scopeBlocksIn(
|
|
318
|
+
range === undefined
|
|
319
|
+
? documentRange(t)
|
|
320
|
+
: nestedRange(range, start, start + t.length),
|
|
321
|
+
).length > 0
|
|
322
|
+
);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Everything the boundary rule needs from one statement's text. */
|
|
326
|
+
export function describeStatement(
|
|
327
|
+
text: string,
|
|
328
|
+
range?: MaskedRange,
|
|
329
|
+
): {
|
|
330
|
+
run: RunToken[];
|
|
331
|
+
directive: boolean;
|
|
332
|
+
whole: boolean;
|
|
333
|
+
/**
|
|
334
|
+
* For each run token, the offset in `text` just past the WORD it came from —
|
|
335
|
+
* where the arguments of a command whose verb is that token begin (#202c) —
|
|
336
|
+
* or null where that offset would not mean that.
|
|
337
|
+
*
|
|
338
|
+
* Per RUN TOKEN rather than one run-wide offset, because the run does not
|
|
339
|
+
* stop at the verb: `runTokens` consumes every leading bare word, so
|
|
340
|
+
* `/interface print detail` puts `detail` IN the run. Once the boundary rule
|
|
341
|
+
* has decided the verb, everything after it is by construction an argument —
|
|
342
|
+
* measured at 60% of the corpus's CRUD-verb commands, so ending the argument
|
|
343
|
+
* list at the run instead of at the verb would abstain on most of them.
|
|
344
|
+
*
|
|
345
|
+
* Null in two cases, both fail-closed:
|
|
346
|
+
* - the token is not the LAST part of its word (`/ip/route/add/x` splitting
|
|
347
|
+
* its verb mid-word): the following parts are neither path nor arguments
|
|
348
|
+
* of this reading;
|
|
349
|
+
* - the word walk stopped before reaching that token (the run and the words
|
|
350
|
+
* disagree), so no offset was established for it at all.
|
|
351
|
+
*
|
|
352
|
+
* A word END is the only offset that survives a `\<newline>` continuation —
|
|
353
|
+
* the joined bytes are interior to the word (see {@link asciiWordSpans}),
|
|
354
|
+
* which is why no per-token START is published.
|
|
355
|
+
*/
|
|
356
|
+
runEnds: (number | null)[];
|
|
357
|
+
} {
|
|
358
|
+
const lead = text.length - trimAsciiStart(text).length;
|
|
359
|
+
const t = trimAscii(text);
|
|
360
|
+
const directiveColon = t.startsWith(":") ? 1 : 0;
|
|
361
|
+
const body = t.slice(directiveColon);
|
|
362
|
+
// Body offsets rebase onto `text` by this much: the whitespace `trimAscii`
|
|
363
|
+
// dropped in front, plus a `:` the directive form strips.
|
|
364
|
+
const base = lead + directiveColon;
|
|
365
|
+
const run = runTokens(body);
|
|
366
|
+
// V4 — did the run consume every whitespace-separated word? A slash-joined
|
|
367
|
+
// word expands to several run tokens, so compare against the SOURCE words the
|
|
368
|
+
// run covers, not the run length.
|
|
369
|
+
let covered = 0;
|
|
370
|
+
let words = 0;
|
|
371
|
+
let index = 0;
|
|
372
|
+
// A word the run cannot take is proof there is one more word than the run
|
|
373
|
+
// covered, which is the whole of V4's question — so the walk stops there
|
|
374
|
+
// instead of counting the rest of the statement.
|
|
375
|
+
let everyWordCovered = true;
|
|
376
|
+
const runEnds: (number | null)[] = run.map(() => null);
|
|
377
|
+
for (const word of asciiWordSpans(body)) {
|
|
378
|
+
words++;
|
|
379
|
+
const parts = word.name.split("/").filter((p) => p.length > 0);
|
|
380
|
+
if (parts.length === 0) continue;
|
|
381
|
+
if (index + parts.length > run.length) {
|
|
382
|
+
everyWordCovered = false;
|
|
383
|
+
break;
|
|
384
|
+
}
|
|
385
|
+
if (
|
|
386
|
+
!parts.every(
|
|
387
|
+
(p, i) => (run[index + i] as RunToken | undefined)?.name === p,
|
|
388
|
+
)
|
|
389
|
+
) {
|
|
390
|
+
everyWordCovered = false;
|
|
391
|
+
break;
|
|
392
|
+
}
|
|
393
|
+
// Only the word's LAST part ends where the word does; an earlier part is
|
|
394
|
+
// followed by more path inside the same word.
|
|
395
|
+
runEnds[index + parts.length - 1] = base + word.end;
|
|
396
|
+
index += parts.length;
|
|
397
|
+
covered++;
|
|
398
|
+
}
|
|
399
|
+
return {
|
|
400
|
+
run,
|
|
401
|
+
directive: isDirective(text, range),
|
|
402
|
+
whole: everyWordCovered && covered === words && run.length > 0,
|
|
403
|
+
runEnds,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/** The verb/menu boundary decision for one run. */
|
|
408
|
+
export interface Split {
|
|
409
|
+
/** Index of the verb inside the run, or null for "no verb decided". */
|
|
410
|
+
verbAt: number | null;
|
|
411
|
+
/**
|
|
412
|
+
* True when the schema-free rule cannot decide the run (V4's bare path).
|
|
413
|
+
* `resolveVerb` upgrades this to `navigation` when the container table
|
|
414
|
+
* recognizes the path; here it stays a refusal, so `splitRun`'s abstention
|
|
415
|
+
* rate keeps pricing `proposed` on its own terms.
|
|
416
|
+
*/
|
|
417
|
+
ambiguous: boolean;
|
|
418
|
+
/** The rule that fired, for provenance. */
|
|
419
|
+
why: string;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** The maximal slash-joined prefix length: run[0..k-1] are all `start`/`slash`. */
|
|
423
|
+
function slashPrefixLen(run: RunToken[]): number {
|
|
424
|
+
let k = 0;
|
|
425
|
+
while (k < run.length && (run[k] as RunToken).sep !== "space") k++;
|
|
426
|
+
return k;
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* The ratified `proposed` boundary rule. `directive` marks a `:`/bare scripting
|
|
431
|
+
* directive; `whole` is V4 (the run is the entire statement — nothing follows).
|
|
432
|
+
*/
|
|
433
|
+
export function splitRun(
|
|
434
|
+
run: RunToken[],
|
|
435
|
+
opts: { directive: boolean; whole: boolean },
|
|
436
|
+
): Split {
|
|
437
|
+
if (run.length === 0)
|
|
438
|
+
return { verbAt: null, ambiguous: false, why: "empty run" };
|
|
439
|
+
|
|
440
|
+
if (opts.directive) {
|
|
441
|
+
const head = (run[0] as RunToken).name;
|
|
442
|
+
if (SUBMENU_DIRECTIVES.has(head) && run.length >= 2)
|
|
443
|
+
return {
|
|
444
|
+
verbAt: 1,
|
|
445
|
+
ambiguous: false,
|
|
446
|
+
why: "`:log <level>` — the one sub-menu directive",
|
|
447
|
+
};
|
|
448
|
+
return {
|
|
449
|
+
verbAt: 0,
|
|
450
|
+
ambiguous: false,
|
|
451
|
+
why: "R10: a directive's operand is positional",
|
|
452
|
+
};
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
const vocab = run.findIndex((t) => VERBS.has(t.name));
|
|
456
|
+
if (vocab >= 0)
|
|
457
|
+
return {
|
|
458
|
+
verbAt: vocab,
|
|
459
|
+
ambiguous: false,
|
|
460
|
+
why: `vocabulary hit \`${run[vocab]?.name}\``,
|
|
461
|
+
};
|
|
462
|
+
|
|
463
|
+
// V4 — a statement that is nothing but a path carries no verb signal at all:
|
|
464
|
+
// `/ip/address` (dir) and `/system/reboot` (no-arg cmd) are the same text.
|
|
465
|
+
if (opts.whole)
|
|
466
|
+
return {
|
|
467
|
+
verbAt: null,
|
|
468
|
+
ambiguous: true,
|
|
469
|
+
why: "bare path, nothing follows — navigation or no-arg command?",
|
|
470
|
+
};
|
|
471
|
+
|
|
472
|
+
// Punctuation: the maximal slash-joined prefix is menu; the first space token
|
|
473
|
+
// is the verb; with no space token but arguments following (V4 false), the
|
|
474
|
+
// last run token is the verb (`/system/identity/print value=…`).
|
|
475
|
+
const k = slashPrefixLen(run);
|
|
476
|
+
return k < run.length
|
|
477
|
+
? {
|
|
478
|
+
verbAt: k,
|
|
479
|
+
ambiguous: false,
|
|
480
|
+
why: `first space token after ${k} slash-joined`,
|
|
481
|
+
}
|
|
482
|
+
: {
|
|
483
|
+
verbAt: run.length - 1,
|
|
484
|
+
ambiguous: false,
|
|
485
|
+
why: "no space token, arguments follow — last segment",
|
|
486
|
+
};
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* The verb index the published command catalog decides for a run, or null.
|
|
491
|
+
*
|
|
492
|
+
* The command half of V4 (#228 step 2). `splitRun` above stays the ratified
|
|
493
|
+
* schema-free rule — its abstention rate is what prices decision 3 — so the
|
|
494
|
+
* baked tables are layered by the CONSUMERS, exactly as `menus.ts` has been
|
|
495
|
+
* since #210. This is the other table and the other direction: `menus.ts` says
|
|
496
|
+
* a bare path is a menu, `commandVerbIndex` says a run prefix is a command.
|
|
497
|
+
*
|
|
498
|
+
* Where `splitRun` guesses from punctuation, this is first-order evidence and
|
|
499
|
+
* wins. `/system/gps/monitor once` has one space token, so the punctuation rule
|
|
500
|
+
* calls `once` the verb and `/system/gps/monitor` the menu; the publication says
|
|
501
|
+
* `/system/gps/monitor` IS the command, which makes `once` its argument. The
|
|
502
|
+
* frozen vocabulary is never overruled in practice — all four catalog commands
|
|
503
|
+
* carrying a `VERBS` segment carry it as the LEAF, so the two rules name the
|
|
504
|
+
* same token (pinned by a test) — but where they could differ, the published
|
|
505
|
+
* path is about that exact path and the vocabulary is a curated heuristic.
|
|
506
|
+
*
|
|
507
|
+
* Directives are left alone. R10 ("a directive's operand is positional") was
|
|
508
|
+
* measured at 2,088 against 18 over the dev split, and the seven root-level
|
|
509
|
+
* catalog commands (`/beep`, `/import`, `/password`, …) are all at index 0,
|
|
510
|
+
* where R10 already puts them. Consulting the table there could only move a
|
|
511
|
+
* case the measurement never saw.
|
|
512
|
+
*
|
|
513
|
+
* `base` is the enclosing menu the run extends, so the lookup is over the same
|
|
514
|
+
* absolute path a reader would type. A match falling entirely inside `base`
|
|
515
|
+
* means the CONTEXT is a command and the run continues past it — no reading of
|
|
516
|
+
* this statement follows from that, so it returns null rather than guessing.
|
|
517
|
+
*/
|
|
518
|
+
export function catalogVerbAt(
|
|
519
|
+
run: readonly RunToken[],
|
|
520
|
+
base: string,
|
|
521
|
+
): number | null {
|
|
522
|
+
const prefix = base.split("/").filter(Boolean);
|
|
523
|
+
const at = commandVerbIndex([...prefix, ...run.map((token) => token.name)]);
|
|
524
|
+
return at !== null && at >= prefix.length ? at - prefix.length : null;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
/** What every split carries, whatever it decided. */
|
|
528
|
+
interface VerbSplitCommon {
|
|
529
|
+
/** Context extended by each run prefix — the path readings. */
|
|
530
|
+
candidates: string[];
|
|
531
|
+
/** The rule that fired, for provenance. */
|
|
532
|
+
why: string;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* A decided verb: the menu is the context plus the run before the verb.
|
|
537
|
+
*
|
|
538
|
+
* `kind` is **fully derived** from `resolution` and carries no independent
|
|
539
|
+
* information: `resolution` says what the analyzer did, `kind` says what the
|
|
540
|
+
* statement is. It is kept because it is the ratified envelope's vocabulary
|
|
541
|
+
* (`commands/explain/README.md`) and the field phase 1 renders, so this module
|
|
542
|
+
* does not invent a third word for the same concept; the mapping is pinned in
|
|
543
|
+
* both directions by a test so the two can never drift apart.
|
|
544
|
+
*/
|
|
545
|
+
export interface VerbSplitCommandReading extends VerbSplitCommon {
|
|
546
|
+
resolution: "resolved";
|
|
547
|
+
kind: "command";
|
|
548
|
+
path: string;
|
|
549
|
+
verb: string;
|
|
550
|
+
/** Index of the verb within the run. */
|
|
551
|
+
verbAt: number;
|
|
552
|
+
/**
|
|
553
|
+
* Offset in the statement text where this command's ARGUMENTS begin, or
|
|
554
|
+
* `null` when no such offset exists (#202c).
|
|
555
|
+
*
|
|
556
|
+
* It is the end of the VERB's word, so a bare-word argument the leading run
|
|
557
|
+
* swallowed (`/interface print detail` puts `detail` in the run) is still
|
|
558
|
+
* inside the argument list rather than lost above it.
|
|
559
|
+
*
|
|
560
|
+
* `null` when no such offset exists: the verb is not the last part of its
|
|
561
|
+
* word (`/ip/route/add/x`), or the word walk never reached it. A caller that
|
|
562
|
+
* lexed from a guessed offset would attribute bytes to the wrong command, so
|
|
563
|
+
* the offset is withheld rather than approximated.
|
|
564
|
+
*/
|
|
565
|
+
argsAt: number | null;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/** A bare path the container table confirms: it names a menu and nothing else. */
|
|
569
|
+
export interface VerbSplitMenuReading extends VerbSplitCommon {
|
|
570
|
+
resolution: "navigation";
|
|
571
|
+
kind: "menu";
|
|
572
|
+
path: string;
|
|
573
|
+
verb: null;
|
|
574
|
+
verbAt: null;
|
|
575
|
+
/** Navigation names a menu and nothing else — there is no argument list. */
|
|
576
|
+
argsAt: null;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/** A refusal — the schema-free rule could not decide, or would have to guess. */
|
|
580
|
+
export interface VerbSplitRefusal extends VerbSplitCommon {
|
|
581
|
+
resolution: "ambiguous" | "unknown";
|
|
582
|
+
kind: null;
|
|
583
|
+
path: null;
|
|
584
|
+
verb: null;
|
|
585
|
+
verbAt: null;
|
|
586
|
+
/** No command was read, so nothing here is an argument OF one. */
|
|
587
|
+
argsAt: null;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/**
|
|
591
|
+
* The verb/menu split of one statement, context applied.
|
|
592
|
+
*
|
|
593
|
+
* A union rather than one shape with four nullable fields, because the nulls
|
|
594
|
+
* are not independent: a `resolved` split always has a path AND a verb, a
|
|
595
|
+
* `navigation` split always has a path and never a verb, and a refusal has
|
|
596
|
+
* neither. Stating that in the type means a consumer narrowing on `resolution`
|
|
597
|
+
* gets the fields that exist, and the envelope's fold (`src/explain.ts`) has no
|
|
598
|
+
* unreachable null branch to write. The runtime shape is unchanged — every
|
|
599
|
+
* variant carries every key, so a null is still readable without narrowing.
|
|
600
|
+
*/
|
|
601
|
+
export type VerbSplit =
|
|
602
|
+
| VerbSplitCommandReading
|
|
603
|
+
| VerbSplitMenuReading
|
|
604
|
+
| VerbSplitRefusal;
|
|
605
|
+
|
|
606
|
+
/**
|
|
607
|
+
* Whether a reading was decided, or refused.
|
|
608
|
+
*
|
|
609
|
+
* Derived from the union rather than declared beside it, so the vocabulary and
|
|
610
|
+
* the shapes cannot drift: a new resolution word has to arrive as a variant
|
|
611
|
+
* that says what it carries.
|
|
612
|
+
*/
|
|
613
|
+
export type VerbResolution = VerbSplit["resolution"];
|
|
614
|
+
|
|
615
|
+
/** Join a base menu with bare run segments (run tokens are `BARE_WORD`, no `..`/`.`). */
|
|
616
|
+
function joinBase(base: string, names: string[]): string {
|
|
617
|
+
const parts = [...base.split("/").filter(Boolean), ...names];
|
|
618
|
+
return `/${parts.join("/")}`;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function unknownSplit(why: string): VerbSplitRefusal {
|
|
622
|
+
return {
|
|
623
|
+
resolution: "unknown",
|
|
624
|
+
kind: null,
|
|
625
|
+
path: null,
|
|
626
|
+
verb: null,
|
|
627
|
+
verbAt: null,
|
|
628
|
+
argsAt: null,
|
|
629
|
+
candidates: [],
|
|
630
|
+
why,
|
|
631
|
+
};
|
|
632
|
+
}
|
|
633
|
+
|
|
634
|
+
/**
|
|
635
|
+
* What follows the leading run: an operand stream, a brace body, or unknown.
|
|
636
|
+
*
|
|
637
|
+
* Only the tail's FIRST byte is read, and only three answers come out of it, so
|
|
638
|
+
* this stays a shape test rather than a second parser. `"body"` is the `{` of a
|
|
639
|
+
* menu container or a scope block; `"argument"` is anything else that is there;
|
|
640
|
+
* `"none"` is a run with nothing after it (V4's bare path) or a tail whose
|
|
641
|
+
* offset `describeStatement` could not establish.
|
|
642
|
+
*/
|
|
643
|
+
function operandTail(
|
|
644
|
+
text: string,
|
|
645
|
+
runEnd: number | null,
|
|
646
|
+
): "argument" | "body" | "none" {
|
|
647
|
+
if (runEnd === null) return "none";
|
|
648
|
+
const tail = trimAsciiStart(text.slice(runEnd));
|
|
649
|
+
if (tail.length === 0) return "none";
|
|
650
|
+
return tail.startsWith("{") ? "body" : "argument";
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
/**
|
|
654
|
+
* Resolve one statement's verb/menu boundary, applying the enclosing menu
|
|
655
|
+
* `context`. Mirrors `pathresolve`'s base rule: a `:`/bare directive and an
|
|
656
|
+
* absolute (`/`) statement resolve at the root; everything else inherits
|
|
657
|
+
* `context`. Substitution-headed and variable-segment statements are `unknown`
|
|
658
|
+
* — offline refuses rather than guessing (the Q3/Q4/Q14 fail-closed floor).
|
|
659
|
+
*/
|
|
660
|
+
export function resolveVerb(
|
|
661
|
+
text: string,
|
|
662
|
+
context: string,
|
|
663
|
+
range?: MaskedRange,
|
|
664
|
+
): VerbSplit {
|
|
665
|
+
const t = trimAscii(text);
|
|
666
|
+
if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
|
|
667
|
+
return unknownSplit("dynamic or substitution-headed statement");
|
|
668
|
+
|
|
669
|
+
const { run, directive, whole, runEnds } = describeStatement(text, range);
|
|
670
|
+
if (run.length === 0) return unknownSplit("no leading path token");
|
|
671
|
+
|
|
672
|
+
// `directive` already folds in the bare-directive case (`isDirective`), so it
|
|
673
|
+
// stands in for the `:`-prefixed and `x do={…}` readings — no second
|
|
674
|
+
// `scopeBodies` scan. A directive or an absolute (`/`) statement resolves at
|
|
675
|
+
// the root; everything else inherits `context`.
|
|
676
|
+
const base = directive || t.startsWith("/") ? "/" : context;
|
|
677
|
+
const catalogAt = directive ? null : catalogVerbAt(run, base);
|
|
678
|
+
|
|
679
|
+
// A bare-word head is not RouterOS navigation (Q4's CHR round), so offline
|
|
680
|
+
// cannot tell a relative command from prose. An INHERITED menu context lifts
|
|
681
|
+
// that where the catalog confirms the result: `reset-counters` under
|
|
682
|
+
// `/interface/ethernet` is `/interface/ethernet/reset-counters`, and refusing
|
|
683
|
+
// it costs a curated WRITE verb the tristate exists to catch.
|
|
684
|
+
//
|
|
685
|
+
// At the ROOT context a bare-word COMMAND stays a refusal, and that boundary
|
|
686
|
+
// is not cosmetic. Seven catalog commands sit at the root (`/import`,
|
|
687
|
+
// `/quit`, `/beep`, …) and every one is an ordinary English word: the corpus
|
|
688
|
+
// carries `import serial`, `import io`, `import os` — Python pasted into a
|
|
689
|
+
// `.rsc` snippet — which the table would happily confirm as RouterOS
|
|
690
|
+
// `/import`. A non-root context is itself the evidence that the surrounding
|
|
691
|
+
// text is RouterOS at all; nothing supplies that at the root, so the ratified
|
|
692
|
+
// refusal stands there.
|
|
693
|
+
//
|
|
694
|
+
// #235 lifts the refusal — at any context, root included — when the head
|
|
695
|
+
// joins to a known MENU rather than a command: `address` under `/ip` and
|
|
696
|
+
// `ip` at `/` are both navigation on the device (CHR 7.24rc1 `:parse` IL).
|
|
697
|
+
// Only the menu axis lifts, so root prose like `import` is still refused.
|
|
698
|
+
if (
|
|
699
|
+
!directive &&
|
|
700
|
+
!t.startsWith("/") &&
|
|
701
|
+
!t.startsWith(":") &&
|
|
702
|
+
!VERBS.has((run[0] as RunToken).name) &&
|
|
703
|
+
(catalogAt === null || base === "/") &&
|
|
704
|
+
!isKnownMenuPath([
|
|
705
|
+
...base.split("/").filter(Boolean),
|
|
706
|
+
(run[0] as RunToken).name,
|
|
707
|
+
])
|
|
708
|
+
)
|
|
709
|
+
return unknownSplit("bare-word head is not a known verb");
|
|
710
|
+
|
|
711
|
+
const candidates: string[] = [];
|
|
712
|
+
for (let k = 1; k <= run.length; k++)
|
|
713
|
+
candidates.push(
|
|
714
|
+
joinBase(
|
|
715
|
+
base,
|
|
716
|
+
run.slice(0, k).map((r) => r.name),
|
|
717
|
+
),
|
|
718
|
+
);
|
|
719
|
+
|
|
720
|
+
const split =
|
|
721
|
+
catalogAt === null
|
|
722
|
+
? splitRun(run, { directive, whole })
|
|
723
|
+
: {
|
|
724
|
+
verbAt: catalogAt,
|
|
725
|
+
ambiguous: false,
|
|
726
|
+
why: `published command \`${candidates[catalogAt]}\``,
|
|
727
|
+
};
|
|
728
|
+
// #324 — the symmetric half of R9. R9 made `pathresolve`'s `menuNavPath`
|
|
729
|
+
// consult `VERBS` so it could not claim navigation where a verb was decided;
|
|
730
|
+
// this stops the punctuation rule claiming a verb where the run names a
|
|
731
|
+
// known MENU and the token it would promote is not a console verb.
|
|
732
|
+
// `/ip/firewall/address-list name=ytkids` has no verb in it: the punctuation
|
|
733
|
+
// rule's "no space token, arguments follow — last segment" fallback promotes
|
|
734
|
+
// `address-list`, and the spaced spelling `/ip firewall address-list name=x`
|
|
735
|
+
// promotes `firewall`, one level higher still. Both premises are available
|
|
736
|
+
// offline and both say no, so the reading is a fabrication rather than a
|
|
737
|
+
// guess with a basis.
|
|
738
|
+
//
|
|
739
|
+
// Only the fail-closed direction: PRESENCE in the tables decides, absence
|
|
740
|
+
// decides nothing, so an unlisted menu spelled `…/address-list` keeps
|
|
741
|
+
// today's reading. The vocabulary hit is excluded by the `VERBS` test itself
|
|
742
|
+
// (a vocabulary split promotes a `VERBS` member by construction), the
|
|
743
|
+
// published-command axis by `catalogAt` — first-order evidence about that
|
|
744
|
+
// exact path outranks a container listing of its prefix — and directives by
|
|
745
|
+
// R10, which is measured.
|
|
746
|
+
//
|
|
747
|
+
// This abstains, it does not reject: offline knows it cannot name a verb
|
|
748
|
+
// here, which needs no device evidence. Whether RouterOS refuses the shape
|
|
749
|
+
// outright is a device claim and is deliberately not made (contrast #311's
|
|
750
|
+
// `error`, which is CHR-grounded).
|
|
751
|
+
//
|
|
752
|
+
// One shape is excluded by the tail rather than by a table: a `{` after the
|
|
753
|
+
// run is a menu CONTAINER body (`/system {identity print}`, H7), where the
|
|
754
|
+
// run legitimately names a menu and no verb is missing at all.
|
|
755
|
+
// `invalidCommandBraceDiagnostics` reads that statement's `argsAt` to find
|
|
756
|
+
// the brace, so abstaining here would drop a real `error` on the statement
|
|
757
|
+
// after it. A tail this cannot see (`runEnds` null) is not classified, and
|
|
758
|
+
// the guard stands down there too.
|
|
759
|
+
if (
|
|
760
|
+
!directive &&
|
|
761
|
+
catalogAt === null &&
|
|
762
|
+
!split.ambiguous &&
|
|
763
|
+
split.verbAt !== null &&
|
|
764
|
+
!VERBS.has((run[split.verbAt] as RunToken).name) &&
|
|
765
|
+
operandTail(text, runEnds[run.length - 1] ?? null) === "argument"
|
|
766
|
+
) {
|
|
767
|
+
const full = candidates[candidates.length - 1] as string;
|
|
768
|
+
if (isKnownMenuPath(full.split("/").filter(Boolean)))
|
|
769
|
+
return {
|
|
770
|
+
resolution: "ambiguous",
|
|
771
|
+
kind: null,
|
|
772
|
+
path: null,
|
|
773
|
+
verb: null,
|
|
774
|
+
verbAt: null,
|
|
775
|
+
argsAt: null,
|
|
776
|
+
candidates,
|
|
777
|
+
why: `\`${full}\` is a known RouterOS menu and \`${(run[split.verbAt] as RunToken).name}\` is not a console verb: this run names a menu and the verb is missing, so offline will not promote a path segment to one`,
|
|
778
|
+
};
|
|
779
|
+
}
|
|
780
|
+
if (split.ambiguous) {
|
|
781
|
+
// V4's bare path, the one case `proposed` refuses — and the one the baked
|
|
782
|
+
// container table can answer (#210). The lookup reads the LAST candidate,
|
|
783
|
+
// which is the whole run with `base` already applied, so it stays correct
|
|
784
|
+
// under a non-root context instead of relying on the (currently true)
|
|
785
|
+
// property that only `/`-led statements reach here.
|
|
786
|
+
const full = candidates[candidates.length - 1] as string;
|
|
787
|
+
if (isKnownMenuPath(full.split("/").filter(Boolean)))
|
|
788
|
+
return {
|
|
789
|
+
resolution: "navigation",
|
|
790
|
+
kind: "menu",
|
|
791
|
+
path: full,
|
|
792
|
+
verb: null,
|
|
793
|
+
verbAt: null,
|
|
794
|
+
argsAt: null,
|
|
795
|
+
candidates,
|
|
796
|
+
why: "bare path, and a known RouterOS menu — navigation",
|
|
797
|
+
};
|
|
798
|
+
return {
|
|
799
|
+
resolution: "ambiguous",
|
|
800
|
+
kind: null,
|
|
801
|
+
path: null,
|
|
802
|
+
verb: null,
|
|
803
|
+
verbAt: null,
|
|
804
|
+
argsAt: null,
|
|
805
|
+
candidates,
|
|
806
|
+
why: split.why,
|
|
807
|
+
};
|
|
808
|
+
}
|
|
809
|
+
if (split.verbAt === null) return unknownSplit(split.why);
|
|
810
|
+
|
|
811
|
+
const j = split.verbAt;
|
|
812
|
+
return {
|
|
813
|
+
resolution: "resolved",
|
|
814
|
+
kind: "command",
|
|
815
|
+
path: joinBase(
|
|
816
|
+
base,
|
|
817
|
+
run.slice(0, j).map((r) => r.name),
|
|
818
|
+
),
|
|
819
|
+
verb: (run[j] as RunToken).name,
|
|
820
|
+
verbAt: j,
|
|
821
|
+
// Arguments begin after the VERB's word. Run tokens following the verb are
|
|
822
|
+
// bare-word arguments (`print detail`), which the lexer reads from here as
|
|
823
|
+
// positionals rather than abstaining on.
|
|
824
|
+
argsAt: runEnds[j] ?? null,
|
|
825
|
+
candidates,
|
|
826
|
+
why: split.why,
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* One statement's split, as seen by the DOCUMENT walker.
|
|
832
|
+
*
|
|
833
|
+
* The two extra fields are the difference between the entry points, and both are
|
|
834
|
+
* required rather than optional on purpose. `resolveVerb` is handed one
|
|
835
|
+
* statement and an explicit context, so it has nothing to say about where that
|
|
836
|
+
* statement sits or whether the context was knowable; `resolveVerbs` walks the
|
|
837
|
+
* document and always does. An optional flag would make "absent" mean both
|
|
838
|
+
* "single-statement call" and "certain", and the whole point of the field is
|
|
839
|
+
* that silence must not read as certainty.
|
|
840
|
+
*/
|
|
841
|
+
export type DocumentVerbSplit = VerbSplit & {
|
|
842
|
+
/**
|
|
843
|
+
* This statement in DOCUMENT analyzed-byte space, straight from the resolver.
|
|
844
|
+
*
|
|
845
|
+
* Carried because the walk FLATTENS: a block body's statements are appended
|
|
846
|
+
* after their parent, so `splits` is longer than the top-level segmentation
|
|
847
|
+
* and a caller pairing the two by index silently attaches the wrong span to every
|
|
848
|
+
* statement after the first `do={…}`. With the span here there is nothing to
|
|
849
|
+
* pair — the location travels with the reading.
|
|
850
|
+
*/
|
|
851
|
+
span: Span;
|
|
852
|
+
/**
|
|
853
|
+
* The statement's own text, as the resolver segmented it (#202c).
|
|
854
|
+
*
|
|
855
|
+
* Carried for the same reason {@link DocumentVerbSplit.span} is: the caller
|
|
856
|
+
* that lexes ARGUMENTS from {@link VerbSplitCommandReading.argsAt} needs the
|
|
857
|
+
* text those offsets index, and re-slicing the document by `span` to recover
|
|
858
|
+
* it is exactly the step that silently succeeds when the span is a widened
|
|
859
|
+
* FALLBACK (`pathresolve.ts` → `Loc`) rather than this statement's own bytes.
|
|
860
|
+
* With both here the caller can CHECK that they agree instead of assuming it.
|
|
861
|
+
*/
|
|
862
|
+
text: string;
|
|
863
|
+
/**
|
|
864
|
+
* Was the menu context in force BEFORE this statement known? (Q14 C3b, #192.)
|
|
865
|
+
*
|
|
866
|
+
* `false` does NOT invalidate `path`: the resolver already degrades every
|
|
867
|
+
* context-DEPENDENT statement to `unresolved` when context is lost, so a split
|
|
868
|
+
* that still says `resolved` here resolved WITHOUT consuming the context — an
|
|
869
|
+
* absolute path, a `:` directive, or a bare directive. What this carries is
|
|
870
|
+
* the thing that was otherwise dropped on the floor: `/ip) address` followed
|
|
871
|
+
* by `/ip route print` yields a perfectly correct `resolved` split whose
|
|
872
|
+
* document had already lost its context, and a consumer ranking readings or
|
|
873
|
+
* offering completions needs to know that.
|
|
874
|
+
*/
|
|
875
|
+
contextCertain: boolean;
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/** A document's per-statement verb/menu splits, plus structural defects. */
|
|
879
|
+
export interface VerbAnalysis {
|
|
880
|
+
splits: DocumentVerbSplit[];
|
|
881
|
+
/** Located structural surprises; see `defects.ts`. */
|
|
882
|
+
defects: Defect[];
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
/**
|
|
886
|
+
* Verb/menu split of every statement in source order, tracking the persistent
|
|
887
|
+
* menu context via the shipped Q4 resolver (`resolveStatements`).
|
|
888
|
+
*
|
|
889
|
+
* This walker was pulled from the Q6 PR (#193) because it fabricated on the
|
|
890
|
+
* Q14 C3b cascade: after an upstream defect destroyed the document context, a
|
|
891
|
+
* following relative statement headed by a known CRUD verb still resolved
|
|
892
|
+
* confidently against the stale value. It returns unchanged in shape, because
|
|
893
|
+
* the fix belonged in the resolver, not here — `resolveStatements` now degrades
|
|
894
|
+
* exactly the context-DEPENDENT statements to `unresolved` when the context is
|
|
895
|
+
* lost, while leaving a context-NEUTRAL dynamic head (`$x`) alone. So the
|
|
896
|
+
* single rule below — a statement the resolver refused stays `unknown` here —
|
|
897
|
+
* fails closed on the cascade without a local taint that would over-fire on
|
|
898
|
+
* benign statements. The distinction is only visible to the resolver; this
|
|
899
|
+
* module sees flattened statements.
|
|
900
|
+
*
|
|
901
|
+
* What the walker does add is {@link DocumentVerbSplit.contextCertain} and
|
|
902
|
+
* {@link DocumentVerbSplit.span}: the resolver's per-statement certainty and
|
|
903
|
+
* location, carried through instead of dropped. The certainty was the last of
|
|
904
|
+
* #192's three bullets — the cascade FIX shipped in #197, but the signal it
|
|
905
|
+
* produced stopped here.
|
|
906
|
+
*
|
|
907
|
+
* The returned array is the RESOLVER's statement list, which is longer than the
|
|
908
|
+
* top-level segmentation: a `do={…}` body's statements are flattened in after
|
|
909
|
+
* their parent (matching what IL does to them), so a body statement's span is
|
|
910
|
+
* CONTAINED by its parent's rather than following it. Read the spans; do not
|
|
911
|
+
* pair this array with `segmentStatements` by index.
|
|
912
|
+
*/
|
|
913
|
+
export function resolveVerbs(text: string): VerbAnalysis {
|
|
914
|
+
const analysis = resolveStatements(text);
|
|
915
|
+
return resolveVerbsFromStatements(
|
|
916
|
+
analysis,
|
|
917
|
+
analysis.defects.some((d) => d.code === "bom" || d.code === "non-ascii")
|
|
918
|
+
? undefined
|
|
919
|
+
: documentRange(text, true),
|
|
920
|
+
);
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* Reuse an existing Q4 walk when a composed analysis already performed it.
|
|
925
|
+
* `range`, if supplied, belongs to the same ASCII document as the analysis;
|
|
926
|
+
* statement spans are absolute document offsets, even within a partial range.
|
|
927
|
+
*/
|
|
928
|
+
export function resolveVerbsFromStatements(
|
|
929
|
+
{ statements, defects }: StatementAnalysis,
|
|
930
|
+
range?: MaskedRange,
|
|
931
|
+
): VerbAnalysis {
|
|
932
|
+
return {
|
|
933
|
+
splits: statements.map((s) => ({
|
|
934
|
+
...(s.unresolved
|
|
935
|
+
? unknownSplit(s.unresolved)
|
|
936
|
+
: resolveVerb(
|
|
937
|
+
s.text,
|
|
938
|
+
s.context,
|
|
939
|
+
// The caller supplies a range only for ASCII input; widened
|
|
940
|
+
// fallback spans must still match the statement's own text.
|
|
941
|
+
range !== undefined &&
|
|
942
|
+
s.span.start >= range.start &&
|
|
943
|
+
s.span.end <= range.end &&
|
|
944
|
+
s.text.length === s.span.end - s.span.start &&
|
|
945
|
+
range.text.slice(s.span.start, s.span.end) === s.text
|
|
946
|
+
? rangeAt(range, s.span.start, s.span.end)
|
|
947
|
+
: undefined,
|
|
948
|
+
)),
|
|
949
|
+
span: s.span,
|
|
950
|
+
text: s.text,
|
|
951
|
+
contextCertain: s.contextCertain,
|
|
952
|
+
})),
|
|
953
|
+
defects,
|
|
954
|
+
};
|
|
955
|
+
}
|