@tikoci/centrs 0.1.3 → 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.
- package/CHANGELOG.md +39 -0
- package/GLOSSARY.txt +213 -0
- package/LICENSE +21 -0
- package/README.md +36 -8
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +19 -18
- package/commands/api/examples.md +15 -0
- 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 +41 -29
- package/commands/execute/examples.md +41 -3
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +66 -15
- package/docs/MATRIX.md +38 -1
- 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/unknown-attribute.md +37 -4
- package/package.json +45 -10
- package/src/api-fanout.ts +29 -14
- package/src/api.ts +97 -54
- package/src/cli/api.ts +25 -6
- package/src/cli/btest.ts +18 -2
- 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/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +40 -0
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +29 -14
- package/src/execute.ts +114 -63
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +206 -4
- 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 +26 -12
- package/src/retrieve.ts +81 -18
- package/src/transfer-fanout.ts +42 -25
- package/src/transfer.ts +87 -44
|
@@ -0,0 +1,1324 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Offline path resolution for `explain` (centrs canonicalizer).
|
|
3
|
+
*
|
|
4
|
+
* Ratified by the phase-0 lab, questions Q3 and Q4 (#185), promoted from the
|
|
5
|
+
* throwaway probe `.scratch/explain-lab-pathresolve.ts`. Two surfaces:
|
|
6
|
+
*
|
|
7
|
+
* resolveStatements (Q4) — the fully-qualified canonical path of every
|
|
8
|
+
* statement in source order, tracking the persistent menu
|
|
9
|
+
* context across statements.
|
|
10
|
+
* resolveDocument (Q3) — the re-constituted path of every `[…]` command
|
|
11
|
+
* substitution, the spec's flagship example
|
|
12
|
+
* (`remove [find …]` inherits the statement's own menu).
|
|
13
|
+
*
|
|
14
|
+
* The lab priced three context models; only the ratified `stateful` rule set is
|
|
15
|
+
* promoted (`stateless`/`stateful-append` were A/B arms to justify it, left in
|
|
16
|
+
* the lab). The rules:
|
|
17
|
+
* R1 An inner command starting with `/` is ABSOLUTE; context is ignored.
|
|
18
|
+
* R2 Otherwise it inherits the nearest enclosing MENU CONTEXT.
|
|
19
|
+
* R3 The nearest enclosing context of a `[…]` inside a statement is that
|
|
20
|
+
* STATEMENT's own path, not the document's.
|
|
21
|
+
* R4 Document context is set by a menu-navigation statement and by a
|
|
22
|
+
* menu-block prefix, and persists to following statements. An absolute
|
|
23
|
+
* NAVIGATION replaces context; an absolute COMMAND does not move it (the
|
|
24
|
+
* CHR-confirmed cascade — see the Q4 corners).
|
|
25
|
+
* R5 A block body inherits the context in force where the block appears.
|
|
26
|
+
* R6 Nested `[…]` inherit from the bracket that encloses them.
|
|
27
|
+
* R7 A variable (`$x`) path segment makes the path UNRESOLVED — offline says
|
|
28
|
+
* so rather than guessing.
|
|
29
|
+
* R8 `..` ascends and a bare `/` resets; a bare word alone is navigation only
|
|
30
|
+
* when a shipped structure table confirms the context-applied path (R13).
|
|
31
|
+
* R9 A `/`-led bare path whose run carries a FROZEN-VOCABULARY VERB is a
|
|
32
|
+
* command, not navigation (#211). Q6 already decides these, so without it
|
|
33
|
+
* this module and `verbsplit` returned contradictory answers for the same
|
|
34
|
+
* text — and this one INVENTED a menu (`/ip address print` navigating to
|
|
35
|
+
* `/ip/address/print`). See `menuNavPath`.
|
|
36
|
+
* R10 A `:` scripting directive takes its operand POSITIONALLY (`:global x`
|
|
37
|
+
* is `/global`, not `/global/x`); a menu command spells its menu out.
|
|
38
|
+
* R11 A bare directive (`while (…) do={…}`) is at the root, and hands its
|
|
39
|
+
* body the root context too. The schema-free tell is the scope block: no
|
|
40
|
+
* menu command takes `do=`.
|
|
41
|
+
* R12 A `/`-led bare path the published CATALOG names as a command is not
|
|
42
|
+
* navigation either (#228). The verb-free half R9 left open (#211 B2):
|
|
43
|
+
* `/system/reboot` is a command, so it must not move the context and the
|
|
44
|
+
* statement after it must not resolve against `/system/reboot`.
|
|
45
|
+
* R13 A relative bare path that a shipped table names as a menu advances the
|
|
46
|
+
* context (#235). Its run is read exactly as R9 reads an absolute one —
|
|
47
|
+
* whitespace and `/` are interchangeable on the device, so `firewall
|
|
48
|
+
* filter` and `firewall/filter` under `/ip` are one form. If neither table
|
|
49
|
+
* knows the joined path, offline abstains and the following relative
|
|
50
|
+
* statements lose context certainty.
|
|
51
|
+
*
|
|
52
|
+
* Verb-vs-menu identification is deliberately NOT decided here — offline cannot
|
|
53
|
+
* tell `find` (verb) from a deeper menu without a schema (that is Q6). A
|
|
54
|
+
* resolution therefore reports a best-guess `path` AND the full `candidates`
|
|
55
|
+
* set (context, then context extended by each leading token), so a consumer can
|
|
56
|
+
* present alternatives rather than assert a coin flip.
|
|
57
|
+
*
|
|
58
|
+
* ## Context certainty (Q14 C3b — the cascade, #192)
|
|
59
|
+
*
|
|
60
|
+
* The Q14 floor shipped in #191 degrades a MALFORMED statement to `unresolved`
|
|
61
|
+
* and does not descend into it. That is statement-local, and the lab measured a
|
|
62
|
+
* second failure on top of it: the defect also destroys the DOCUMENT CONTEXT,
|
|
63
|
+
* and every later RELATIVE statement was still resolved confidently against the
|
|
64
|
+
* stale value. `/ip) address` + `add address=…` reported a confident `/add` —
|
|
65
|
+
* a path no device would accept, invented from a context the resolver had
|
|
66
|
+
* already admitted it could not read.
|
|
67
|
+
*
|
|
68
|
+
* So each statement now also reports `contextCertain`: whether the menu context
|
|
69
|
+
* in force BEFORE it is known. A statement that CONSUMES an unknown context
|
|
70
|
+
* degrades to `unresolved` instead of resolving; one that does not consume it
|
|
71
|
+
* is unaffected. Which is which is exactly the `base` split already used for
|
|
72
|
+
* path resolution:
|
|
73
|
+
* - context-INDEPENDENT — an absolute (`/`-led) statement, a `:` directive,
|
|
74
|
+
* and a bare directive (R11) all resolve against the root. They keep
|
|
75
|
+
* resolving confidently while context is lost.
|
|
76
|
+
* - context-DEPENDENT — a relative statement (`add address=…`) resolves
|
|
77
|
+
* against the context, so it degrades.
|
|
78
|
+
*
|
|
79
|
+
* Three things make the context unknown, all cases where the statement might
|
|
80
|
+
* have been navigation and offline cannot read where to:
|
|
81
|
+
* - a structural defect (the text is unreadable, so it may have been a nav),
|
|
82
|
+
* - a `/`-led statement whose leading run is unreadable (`/ip/$menu`, and
|
|
83
|
+
* equally the mixed spelling `/ip route/$menu`), which is a navigation to a
|
|
84
|
+
* computed menu as easily as it is a command,
|
|
85
|
+
* - a relative bare path that neither shipped structure table knows (#235).
|
|
86
|
+
* A dynamic-headed statement (`$x`, `[…]`, `(…)`) is context-NEUTRAL — it
|
|
87
|
+
* evaluates a value and does not navigate — so it must NOT poison; that
|
|
88
|
+
* distinction is the whole reason this lives here rather than in a caller,
|
|
89
|
+
* which sees only flattened statements. An ABSOLUTE navigation re-establishes
|
|
90
|
+
* certainty, because R4 has it REPLACE the context rather than extend it.
|
|
91
|
+
*/
|
|
92
|
+
|
|
93
|
+
import { scopeBlocks, scopeBodies } from "./blocks.ts";
|
|
94
|
+
import { commandVerbIndex } from "./catalog.ts";
|
|
95
|
+
import {
|
|
96
|
+
type Defect,
|
|
97
|
+
isPositionalFact,
|
|
98
|
+
mergeDefects,
|
|
99
|
+
rebaseDefects,
|
|
100
|
+
} from "./defects.ts";
|
|
101
|
+
import { isKnownMenuPath } from "./is-known-menu.ts";
|
|
102
|
+
import {
|
|
103
|
+
braceStartsStatements,
|
|
104
|
+
hashStartsHardError,
|
|
105
|
+
scopeNameFromMasked,
|
|
106
|
+
} from "./scope-brace.ts";
|
|
107
|
+
import {
|
|
108
|
+
maskComments,
|
|
109
|
+
type SegmentResult,
|
|
110
|
+
scanQuotedString,
|
|
111
|
+
segmentStatements,
|
|
112
|
+
} from "./segment.ts";
|
|
113
|
+
import { VERBS } from "./verbs.ts";
|
|
114
|
+
|
|
115
|
+
const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
|
|
116
|
+
/**
|
|
117
|
+
* A whole path segment that is a variable reference (`$menu` in `/ip/$menu`).
|
|
118
|
+
*
|
|
119
|
+
* Deliberately "begins with `$`" and NOT an enumeration of name spellings. This
|
|
120
|
+
* makes NO claim about which spellings RouterOS accepts — the point is the
|
|
121
|
+
* opposite: every `$`-headed segment is unreadable HERE, whether it is a valid
|
|
122
|
+
* reference (`$menu`, `$"quoted name"`) or something the device would itself
|
|
123
|
+
* reject (`${braced}` is rejected at the `{` on 7.24rc1; a bare `$`). Offline
|
|
124
|
+
* cannot resolve either kind, so both must abstain.
|
|
125
|
+
*
|
|
126
|
+
* A pattern like `\$[A-Za-z_]…` instead recognizes only the shapes it lists and
|
|
127
|
+
* lets every other one through as an ordinary readable menu segment, which
|
|
128
|
+
* fails OPEN. That is the same trap as the unanchored-regex and reason-prefix
|
|
129
|
+
* bugs earlier in this module: a guard that decides whether to ABSTAIN must
|
|
130
|
+
* match the general shape and let the unknown case block, never enumerate the
|
|
131
|
+
* known ones. `write.ts` reaches the same conclusion at head position, where
|
|
132
|
+
* `isDynamicForm` tests a bare `text.startsWith("$")`.
|
|
133
|
+
*/
|
|
134
|
+
function isVariableSegment(part: string): boolean {
|
|
135
|
+
return part.startsWith("$");
|
|
136
|
+
}
|
|
137
|
+
const ASCII_WHITESPACE = /[ \t\r\n]+/;
|
|
138
|
+
|
|
139
|
+
function isAsciiWhitespace(char: string | undefined): boolean {
|
|
140
|
+
return char === " " || char === "\t" || char === "\r" || char === "\n";
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function trimAscii(text: string): string {
|
|
144
|
+
let start = 0;
|
|
145
|
+
let end = text.length;
|
|
146
|
+
while (start < end && isAsciiWhitespace(text[start])) start++;
|
|
147
|
+
while (end > start && isAsciiWhitespace(text[end - 1])) end--;
|
|
148
|
+
return text.slice(start, end);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function trimAsciiStart(text: string): string {
|
|
152
|
+
let start = 0;
|
|
153
|
+
while (start < text.length && isAsciiWhitespace(text[start])) start++;
|
|
154
|
+
return text.slice(start);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function asciiWords(text: string): string[] {
|
|
158
|
+
const trimmed = trimAscii(text);
|
|
159
|
+
return trimmed.length === 0 ? [] : trimmed.split(ASCII_WHITESPACE);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function isAbsolutePathToken(token: string): boolean {
|
|
163
|
+
if (!token.startsWith("/")) return false;
|
|
164
|
+
const parts = token.split("/").filter(Boolean);
|
|
165
|
+
return parts.length > 0 && parts.every((part) => BARE_WORD.test(part));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Defensive recursion bound. `explain` accepts untrusted editor/MCP input, so
|
|
170
|
+
* deeply nested substitutions/blocks must make the analysis ABSTAIN (an
|
|
171
|
+
* `over-depth` note) rather than consume the JS stack. Matches the segmenter's
|
|
172
|
+
* `MAX_CONTAINER_DEPTH`; far beyond any real script. Unlike the segmenter's H7
|
|
173
|
+
* flattening — now a single left-to-right pass (#190) — this resolver's descent
|
|
174
|
+
* is a genuine tree walk (block topology + bracket sub-command path
|
|
175
|
+
* re-constitution), so it stays recursive under this bound.
|
|
176
|
+
*/
|
|
177
|
+
const MAX_DEPTH = 256;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* True when a statement's own delimiters are not well-formed — unbalanced
|
|
181
|
+
* `()`/`[]`/`{}` or an unterminated string. Q14 fail-closed floor: a malformed
|
|
182
|
+
* statement must never yield a confident command, so the resolver degrades it
|
|
183
|
+
* to `unresolved` and does not descend into it. Statement-local; the
|
|
184
|
+
* segmenter's document-level defects are surfaced separately on the envelope.
|
|
185
|
+
*/
|
|
186
|
+
function structuralDefect(text: string): string | null {
|
|
187
|
+
// Mask comments so a `#`-comment `}`/`)` is not counted as a real delimiter.
|
|
188
|
+
const masked = maskComments(text);
|
|
189
|
+
const openOf: Record<string, string> = { ")": "(", "]": "[", "}": "{" };
|
|
190
|
+
const stack: { char: string; statements: boolean }[] = [];
|
|
191
|
+
for (let i = 0; i < masked.length; i++) {
|
|
192
|
+
const c = masked[i];
|
|
193
|
+
if (c === '"') {
|
|
194
|
+
const str = scanQuotedString(masked, i);
|
|
195
|
+
if (!str.closed)
|
|
196
|
+
return "structural defect: unbalanced delimiter or string";
|
|
197
|
+
i = str.end - 1;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
if (
|
|
201
|
+
c === "#" &&
|
|
202
|
+
hashStartsHardError(masked, i, stack.at(-1)?.statements === false)
|
|
203
|
+
)
|
|
204
|
+
return "structural defect: invalid unquoted hash";
|
|
205
|
+
if (c === "(" || c === "[" || c === "{")
|
|
206
|
+
stack.push({
|
|
207
|
+
char: c,
|
|
208
|
+
statements:
|
|
209
|
+
c === "[" ||
|
|
210
|
+
(c === "{" &&
|
|
211
|
+
(stack.at(-1)?.statements ?? true) &&
|
|
212
|
+
braceStartsStatements(masked, i)),
|
|
213
|
+
});
|
|
214
|
+
else if (c === ")" || c === "]" || c === "}") {
|
|
215
|
+
if (stack.pop()?.char !== openOf[c])
|
|
216
|
+
return "structural defect: unbalanced delimiter or string";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
return stack.length > 0
|
|
220
|
+
? "structural defect: unbalanced delimiter or string"
|
|
221
|
+
: null;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The `unresolved` reason of a statement (or bracket) that needed the document
|
|
226
|
+
* context and could not have it. Distinct from every other reason: the
|
|
227
|
+
* statement itself is perfectly readable — what failed is upstream.
|
|
228
|
+
*/
|
|
229
|
+
const CONTEXT_LOST = "context lost to an earlier unreadable statement";
|
|
230
|
+
|
|
231
|
+
/** R11 — a directive written without its colon, told by its scope block. */
|
|
232
|
+
function isBareDirective(trimmed: string): boolean {
|
|
233
|
+
return (
|
|
234
|
+
!trimmed.startsWith(":") &&
|
|
235
|
+
!trimmed.startsWith("/") &&
|
|
236
|
+
scopeBodies(trimmed).length > 0
|
|
237
|
+
);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Does this statement resolve its OWN path without consulting the document
|
|
242
|
+
* context? Mirrors `canonicalPath`'s `base` exactly — absolute, `:` directive,
|
|
243
|
+
* or bare directive (R11) all resolve at the root.
|
|
244
|
+
*/
|
|
245
|
+
function isContextIndependent(text: string): boolean {
|
|
246
|
+
const t = trimAscii(text);
|
|
247
|
+
return t.startsWith(":") || t.startsWith("/") || isBareDirective(t);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
/**
|
|
251
|
+
* Does the context this statement hands its BLOCK BODIES (and its own brackets,
|
|
252
|
+
* R3) ignore the document context? Mirrors `statementPath`'s base, which is NOT
|
|
253
|
+
* the same predicate as `isContextIndependent`: a `:` directive resolves itself
|
|
254
|
+
* at the root but hands its body the context in force (R5), so `:foreach … do={
|
|
255
|
+
* add … }` still depends on an upstream context its own path does not.
|
|
256
|
+
*/
|
|
257
|
+
function isBodyContextIndependent(text: string): boolean {
|
|
258
|
+
const t = trimAscii(text);
|
|
259
|
+
return t.startsWith("/") || isBareDirective(t);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Does this text NAME a menu path that offline could not read?
|
|
264
|
+
*
|
|
265
|
+
* True when the leading run starts out path-shaped and is then TRUNCATED by a
|
|
266
|
+
* variable segment: `/ip/$menu/remove`, `/ip route/$menu/remove`, and the
|
|
267
|
+
* relative `route/$verb`. R7 says such a path is unresolved, so nothing may
|
|
268
|
+
* inherit a base derived from it.
|
|
269
|
+
*
|
|
270
|
+
* Two traps this exists to avoid, both found in review:
|
|
271
|
+
* - `statementRun` DISCARDS the whole word carrying the variable, so after a
|
|
272
|
+
* static word (`/ip route/$menu/…`) its output is a clean, variable-free
|
|
273
|
+
* prefix and the statement reads as perfectly resolvable. The signal has to
|
|
274
|
+
* be taken from the raw words, not from the collected run.
|
|
275
|
+
* - Spelling is not the discriminator. Mixed space/slash menu spelling is a
|
|
276
|
+
* supported contract, and a RELATIVE head is just as much a menu name, so
|
|
277
|
+
* this must not test for a leading `/`.
|
|
278
|
+
*
|
|
279
|
+
* What it deliberately does NOT match is text that names no menu at all — a
|
|
280
|
+
* head that is a variable, group, string or directive (`$fn [get …]`,
|
|
281
|
+
* `-z "$URL"`, `iter(hip)`), and a `$` word in ARGUMENT position
|
|
282
|
+
* (`get $item time`), which is a positional operand rather than a path
|
|
283
|
+
* segment. Those establish no menu, so the ambient context still governs.
|
|
284
|
+
*/
|
|
285
|
+
function isUnreadablePath(text: string): boolean {
|
|
286
|
+
const t = trimAscii(text);
|
|
287
|
+
if (t.length === 0) return false;
|
|
288
|
+
// A head that is not path-shaped names no menu.
|
|
289
|
+
if (/^[$[("':]/.test(t)) return false;
|
|
290
|
+
for (const word of asciiWords(t)) {
|
|
291
|
+
// Arguments begin: everything before this was readable. A `$` word
|
|
292
|
+
// STANDING ALONE is a positional operand (`get $item time`), not a path
|
|
293
|
+
// segment — only a `$` slash-joined inside a word is a menu segment.
|
|
294
|
+
if (word.includes("=") || /^[[({"$:]/.test(word)) return false;
|
|
295
|
+
const parts = word.split("/").filter((p) => p.length > 0);
|
|
296
|
+
if (parts.length === 0) continue;
|
|
297
|
+
for (const part of parts) {
|
|
298
|
+
if (BARE_WORD.test(part)) continue;
|
|
299
|
+
// A whole segment that is a variable is the unreadable-menu case.
|
|
300
|
+
if (isVariableSegment(part)) return true;
|
|
301
|
+
// Anything else is not a path segment at all, so the run ended here and
|
|
302
|
+
// everything before it was readable. This is why the test cannot simply
|
|
303
|
+
// look for a `$` anywhere: `print where comment~[$strfind …]` and
|
|
304
|
+
// `/ppp secret print where comment~"\\$SECRET"` carry a `$` inside an
|
|
305
|
+
// ARGUMENT — the `~` means no `=` ends the run first — and both name a
|
|
306
|
+
// perfectly readable menu.
|
|
307
|
+
return false;
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
return false;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* A `/`-led statement whose leading run offline cannot read — a variable
|
|
315
|
+
* segment truncates it (`/ip/$menu`, `/interface/$type/print`). It is a
|
|
316
|
+
* navigation to a computed menu exactly as plausibly as it is a command, so the
|
|
317
|
+
* context after it is unknown. `menuNavPath` has already claimed every readable
|
|
318
|
+
* absolute navigation before this is consulted.
|
|
319
|
+
*/
|
|
320
|
+
function isUnreadableAbsolute(text: string): boolean {
|
|
321
|
+
const t = trimAscii(text);
|
|
322
|
+
if (!t.startsWith("/")) return false;
|
|
323
|
+
if (isUnreadablePath(t)) return true;
|
|
324
|
+
const tokens = statementRun(t);
|
|
325
|
+
return tokens.length === 0 || tokens.some((x) => x.startsWith("$"));
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Is the context this statement hands its own brackets (R3) and its block
|
|
330
|
+
* bodies (R5) KNOWN?
|
|
331
|
+
*
|
|
332
|
+
* Two conditions, and BOTH are needed. The base must be knowable — the
|
|
333
|
+
* statement is `/`-led or a bare directive, or the document context is certain.
|
|
334
|
+
* And the statement's own leading run must be READABLE: `/ip/$menu/remove` is
|
|
335
|
+
* `/`-led, so its base does not depend on the document context, but `R7` says a
|
|
336
|
+
* variable path segment leaves the path unresolved, and `statementPath` derives
|
|
337
|
+
* a menu from it anyway (`/ip/$menu`). Anchoring brackets to that hands them a
|
|
338
|
+
* fabricated base — `[find]` at a "path" containing a literal `$menu` — while
|
|
339
|
+
* `resolveStatements` refuses the very same statement. So an unreadable menu
|
|
340
|
+
* anchors nothing, in EITHER spelling: `statementPath` derives a base from a
|
|
341
|
+
* relative one too (`route/$verb remove [find]` yields `<ctx>/route/$verb`).
|
|
342
|
+
*/
|
|
343
|
+
function handsKnownContext(text: string, contextCertain: boolean): boolean {
|
|
344
|
+
// Either spelling of an unreadable menu anchors nothing. `statementPath`
|
|
345
|
+
// derives a base from a RELATIVE one too (`route/$verb remove [find]` yields
|
|
346
|
+
// `<ctx>/route/$verb`), so this cannot test only the absolute form.
|
|
347
|
+
if (isUnreadableAbsolute(text) || isUnreadablePath(text)) return false;
|
|
348
|
+
return contextCertain || isBodyContextIndependent(text);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** A re-constituted `[…]` command substitution (Q3). */
|
|
352
|
+
export interface Resolution {
|
|
353
|
+
/** Raw text inside the brackets. */
|
|
354
|
+
inner: string;
|
|
355
|
+
/** Context in force at the bracket, `/` when at document root. */
|
|
356
|
+
context: string;
|
|
357
|
+
/** Leading `/`-path token run of the inner command, context applied. */
|
|
358
|
+
tokens: string[];
|
|
359
|
+
/**
|
|
360
|
+
* Every path the inner command could resolve to: the context alone, then the
|
|
361
|
+
* context extended by each leading token in turn. Offline cannot pick one
|
|
362
|
+
* without knowing which token is the verb (Q6); `path` is the best guess.
|
|
363
|
+
*/
|
|
364
|
+
candidates: string[];
|
|
365
|
+
/** Best single guess: the first bare token is the verb (Q1's rule). */
|
|
366
|
+
path: string | null;
|
|
367
|
+
unresolved?: string;
|
|
368
|
+
/**
|
|
369
|
+
* Was the menu context this bracket inherited (R3) known? False once an
|
|
370
|
+
* earlier unreadable statement destroyed it — a RELATIVE inner command is
|
|
371
|
+
* then `unresolved` rather than resolved against a stale value.
|
|
372
|
+
*/
|
|
373
|
+
contextCertain: boolean;
|
|
374
|
+
/** Nesting depth of this bracket (0 = directly in a statement). */
|
|
375
|
+
depth: number;
|
|
376
|
+
/** Feature class, for per-class reporting. */
|
|
377
|
+
klass: string;
|
|
378
|
+
/**
|
|
379
|
+
* The `[…]` in DOCUMENT analyzed-byte space, closing bracket included. Widens
|
|
380
|
+
* to the enclosing statement where interior offsets cannot be mapped — see
|
|
381
|
+
* {@link Loc}.
|
|
382
|
+
*/
|
|
383
|
+
span: Span;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
/** The canonical path of one statement in source order (Q4). */
|
|
387
|
+
export interface StatementResolution {
|
|
388
|
+
text: string;
|
|
389
|
+
/** Offline read this statement as menu navigation, not a command. */
|
|
390
|
+
isNav: boolean;
|
|
391
|
+
/** Context in force BEFORE this statement. */
|
|
392
|
+
context: string;
|
|
393
|
+
/**
|
|
394
|
+
* Fully-qualified canonical path, menu AND verb (`/ip/route/add`). Where the
|
|
395
|
+
* path stops and POSITIONAL arguments begin is still a Q6 boundary: `path` is
|
|
396
|
+
* the greedy full-run reading and `candidates` carries the shorter readings.
|
|
397
|
+
*/
|
|
398
|
+
path: string | null;
|
|
399
|
+
/** Context extended by each prefix of the leading run, shortest first. */
|
|
400
|
+
candidates?: string[];
|
|
401
|
+
unresolved?: string;
|
|
402
|
+
/**
|
|
403
|
+
* Was the menu context in force BEFORE this statement known? False once an
|
|
404
|
+
* earlier unreadable statement destroyed it (Q14 C3b). A context-DEPENDENT
|
|
405
|
+
* statement is then `unresolved`; a context-INDEPENDENT one still resolves,
|
|
406
|
+
* so a `false` here does not by itself invalidate `path`.
|
|
407
|
+
*/
|
|
408
|
+
contextCertain: boolean;
|
|
409
|
+
/** This statement in DOCUMENT analyzed-byte space. */
|
|
410
|
+
span: Span;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Q3 result: bracket resolutions plus any structural / over-depth defects. */
|
|
414
|
+
export interface DocumentAnalysis {
|
|
415
|
+
resolutions: Resolution[];
|
|
416
|
+
/**
|
|
417
|
+
* Located defects surfaced from the segmenter (`unclosed`, `unbalanced-close`,
|
|
418
|
+
* `unterminated-string`) plus `over-depth` when a bounded traversal abstained,
|
|
419
|
+
* and defects raised inside a block body. A caller must not treat resolutions
|
|
420
|
+
* as confident while a NON-POSITIONAL defect is present — `isPositionalFact`
|
|
421
|
+
* in `defects.ts` is that test.
|
|
422
|
+
*/
|
|
423
|
+
defects: Defect[];
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/** Q4 result: per-statement resolutions plus structural / over-depth defects. */
|
|
427
|
+
export interface StatementAnalysis {
|
|
428
|
+
statements: StatementResolution[];
|
|
429
|
+
/** As {@link DocumentAnalysis.defects}. */
|
|
430
|
+
defects: Defect[];
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/** A half-open analyzed-byte span. */
|
|
434
|
+
export interface Span {
|
|
435
|
+
start: number;
|
|
436
|
+
end: number;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/**
|
|
440
|
+
* Where the text a walker is scanning sits in the DOCUMENT.
|
|
441
|
+
*
|
|
442
|
+
* `base` is the document analyzed-byte offset that index 0 of that text maps to,
|
|
443
|
+
* or -1 when the mapping is unavailable. It goes to -1 for exactly one reason:
|
|
444
|
+
* a statement's `text` is the ORIGINAL substring (UTF-16 code units), so an
|
|
445
|
+
* index into it is an analyzed-byte offset only while the statement is pure
|
|
446
|
+
* ASCII. Once -1 it stays -1 for everything nested inside.
|
|
447
|
+
*
|
|
448
|
+
* The check for that is a length comparison, and it is exact rather than a
|
|
449
|
+
* heuristic: every non-ASCII code point has a UTF-8 length strictly greater than
|
|
450
|
+
* its UTF-16 length (2>1, 3>1, 4>2), so the two lengths agree if and only if
|
|
451
|
+
* every character is ASCII.
|
|
452
|
+
*
|
|
453
|
+
* `fallback` is the enclosing statement's span, used when `base` is -1. Widening
|
|
454
|
+
* a defect to its statement is the fail-closed choice in the Q14 sense: a region
|
|
455
|
+
* that is too WIDE still points the reader at real text, while one computed from
|
|
456
|
+
* a mismatched index space points confidently at the wrong byte.
|
|
457
|
+
*/
|
|
458
|
+
interface Loc {
|
|
459
|
+
base: number;
|
|
460
|
+
fallback: Span;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** A `[start, end)` of the scanned text, in document space. */
|
|
464
|
+
function spanIn(loc: Loc, start: number, end: number): Span {
|
|
465
|
+
return loc.base < 0
|
|
466
|
+
? { start: loc.fallback.start, end: loc.fallback.end }
|
|
467
|
+
: { start: loc.base + start, end: loc.base + end };
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/**
|
|
471
|
+
* A defect region for `[start, end)` of the scanned text, never zero-width.
|
|
472
|
+
*
|
|
473
|
+
* An empty extent is reachable — `do={}` at the block-depth limit, `[]` at the
|
|
474
|
+
* bracket limit — and `Defect` contracts `end > start`, so an empty one falls
|
|
475
|
+
* back to the enclosing statement rather than emitting `start === end`.
|
|
476
|
+
*/
|
|
477
|
+
function regionIn(loc: Loc, start: number, end: number): Span {
|
|
478
|
+
return end > start
|
|
479
|
+
? spanIn(loc, start, end)
|
|
480
|
+
: { start: loc.fallback.start, end: loc.fallback.end };
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/** Move `loc` to a nested text beginning at index `start` of the current one. */
|
|
484
|
+
function nest(loc: Loc, start: number, fallback: Span): Loc {
|
|
485
|
+
return { base: loc.base < 0 ? -1 : loc.base + start, fallback };
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/** Carry `defects` (offsets into the scanned text) into document space. */
|
|
489
|
+
function attribute(defects: readonly Defect[], loc: Loc): Defect[] {
|
|
490
|
+
if (loc.base >= 0) return rebaseDefects(defects, loc.base);
|
|
491
|
+
return defects.map((d) =>
|
|
492
|
+
d.detail === undefined
|
|
493
|
+
? { code: d.code, start: loc.fallback.start, end: loc.fallback.end }
|
|
494
|
+
: {
|
|
495
|
+
code: d.code,
|
|
496
|
+
start: loc.fallback.start,
|
|
497
|
+
end: loc.fallback.end,
|
|
498
|
+
detail: d.detail,
|
|
499
|
+
},
|
|
500
|
+
);
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** One statement to walk, already located in document space. */
|
|
504
|
+
interface Located {
|
|
505
|
+
text: string;
|
|
506
|
+
span: Span;
|
|
507
|
+
loc: Loc;
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
/**
|
|
511
|
+
* Segment `text` and locate every statement in document space, carrying the
|
|
512
|
+
* segmenter's own defects out with them.
|
|
513
|
+
*
|
|
514
|
+
* The offsets and defects a plain `segments.map((s) => s.text)` would discard
|
|
515
|
+
* are what every span and region downstream is built from.
|
|
516
|
+
*/
|
|
517
|
+
function locate(
|
|
518
|
+
segmented: SegmentResult,
|
|
519
|
+
loc: Loc,
|
|
520
|
+
): { units: Located[]; defects: Defect[] } {
|
|
521
|
+
const units = segmented.segments.map((s) => {
|
|
522
|
+
const span = spanIn(loc, s.start, s.end);
|
|
523
|
+
// `s.start`/`s.end` are analyzed bytes of THIS text; `s.text` is the
|
|
524
|
+
// original substring. Interior indices into `s.text` are analyzed-byte
|
|
525
|
+
// offsets only when the two lengths agree (see `Loc`).
|
|
526
|
+
const asciiOnly = s.end - s.start === s.text.length;
|
|
527
|
+
return {
|
|
528
|
+
text: s.text,
|
|
529
|
+
span,
|
|
530
|
+
loc:
|
|
531
|
+
loc.base < 0 || !asciiOnly
|
|
532
|
+
? { base: -1, fallback: span }
|
|
533
|
+
: { base: loc.base + s.start, fallback: span },
|
|
534
|
+
};
|
|
535
|
+
});
|
|
536
|
+
// STRUCTURAL defects only. `segmentStatements` merges `coordinateDefects`
|
|
537
|
+
// into every result, so a nested call on a block body would re-run the
|
|
538
|
+
// coordinate pass over that SUBSTRING — and a coordinate fact is a property
|
|
539
|
+
// of the ROOT input, not of any slice of it. Two things went wrong when this
|
|
540
|
+
// filter was missing: a non-ASCII run already reported by the document pass
|
|
541
|
+
// came back a second time widened to its whole statement, and a U+FEFF in
|
|
542
|
+
// document-MIDDLE position was re-classified `bom` because it happens to be
|
|
543
|
+
// leading within the body — contradicting `coordinateDefects`' own contract.
|
|
544
|
+
// The entry points seed these once from the root `SegmentResult`.
|
|
545
|
+
const structural = segmented.defects.filter((d) => !isPositionalFact(d.code));
|
|
546
|
+
return { units, defects: attribute(structural, loc) };
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The whole-document starting location. `base` is 0 and always valid — the
|
|
551
|
+
* segmenter's offsets are already document analyzed bytes — so the fallback is
|
|
552
|
+
* unreachable here and every statement gets its own from {@link locate}.
|
|
553
|
+
*/
|
|
554
|
+
const DOCUMENT_LOC: Loc = { base: 0, fallback: { start: 0, end: 0 } };
|
|
555
|
+
|
|
556
|
+
/** Q3 — resolve every `[…]` command substitution in `text`. */
|
|
557
|
+
export function resolveDocument(text: string): DocumentAnalysis {
|
|
558
|
+
// Segment ONCE and hand the result to `locate` — `containsWrite` calls both
|
|
559
|
+
// entry points, and re-segmenting here would put a document through the
|
|
560
|
+
// segmenter four times on input its own comment calls "not cheap".
|
|
561
|
+
const segmented = segmentStatements(text);
|
|
562
|
+
// The ROOT segmentation is the only source of coordinate facts (`bom`,
|
|
563
|
+
// `non-ascii`); `locate` strips them so recursion cannot re-derive them in a
|
|
564
|
+
// substring's coordinate space.
|
|
565
|
+
const defects: Defect[] = [...segmented.defects];
|
|
566
|
+
const resolutions: Resolution[] = [];
|
|
567
|
+
const top = locate(segmented, DOCUMENT_LOC);
|
|
568
|
+
defects.push(...top.defects);
|
|
569
|
+
walk(top.units, "/", resolutions, 0, defects, true);
|
|
570
|
+
return { resolutions, defects: mergeDefects(defects) };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function walk(
|
|
574
|
+
units: Located[],
|
|
575
|
+
context: string,
|
|
576
|
+
out: Resolution[],
|
|
577
|
+
blockDepth: number,
|
|
578
|
+
defects: Defect[],
|
|
579
|
+
contextCertain: boolean,
|
|
580
|
+
): void {
|
|
581
|
+
let ctx = context;
|
|
582
|
+
let certain = contextCertain;
|
|
583
|
+
for (const unit of units) {
|
|
584
|
+
const { text, loc } = unit;
|
|
585
|
+
// R4 — a menu-navigation statement moves the document context.
|
|
586
|
+
const nav = menuNavPath(text, ctx);
|
|
587
|
+
if (nav !== null) {
|
|
588
|
+
const relative = !trimAscii(text).startsWith("/");
|
|
589
|
+
// A `..` read against an unknown context stays unknown; an absolute
|
|
590
|
+
// navigation replaces it and re-establishes certainty.
|
|
591
|
+
if (relative && !certain) continue;
|
|
592
|
+
ctx = nav;
|
|
593
|
+
if (!relative) certain = true;
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
// Q14 fail-closed — a structurally malformed statement yields no confident
|
|
597
|
+
// bracket path and is not descended. It may have been a navigation, so the
|
|
598
|
+
// context after it is unknown (Q14 C3b, the same contract the statement
|
|
599
|
+
// walk applies — these two walks must move in lockstep).
|
|
600
|
+
if (structuralDefect(text) !== null) {
|
|
601
|
+
certain = false;
|
|
602
|
+
continue;
|
|
603
|
+
}
|
|
604
|
+
// #235 — a relative bare-word that abstained (neither table) poisons
|
|
605
|
+
// downstream context, so the next relative statement cannot fabricate.
|
|
606
|
+
const poisonAbstention = isAbstainedRelativeNav(text, ctx);
|
|
607
|
+
// R3 — the statement's own path is the context its brackets see.
|
|
608
|
+
const stmtCtx = statementPath(text, ctx);
|
|
609
|
+
// Derived BEFORE the poison, from the certainty in force at this statement:
|
|
610
|
+
// `handsKnownContext` answers about the context this statement HANDS DOWN,
|
|
611
|
+
// which its own unreadability already governs. An abstained nav poisons
|
|
612
|
+
// what follows, so its own body must already be uncertain.
|
|
613
|
+
const stmtCertain = poisonAbstention
|
|
614
|
+
? false
|
|
615
|
+
: handsKnownContext(text, certain);
|
|
616
|
+
if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
|
|
617
|
+
collectBrackets(text, stmtCtx, 0, out, defects, stmtCertain, loc);
|
|
618
|
+
// R5 — block bodies inherit the context in force here.
|
|
619
|
+
for (const block of scopeBlocks(text)) {
|
|
620
|
+
if (blockDepth >= MAX_DEPTH) {
|
|
621
|
+
// The BLOCK that exceeded the limit, not the whole statement —
|
|
622
|
+
// narrowing regions is what this channel is for.
|
|
623
|
+
defects.push({
|
|
624
|
+
code: "over-depth",
|
|
625
|
+
...regionIn(loc, block.start, block.start + block.body.length),
|
|
626
|
+
});
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
const bodyLoc = nest(loc, block.start, unit.span);
|
|
630
|
+
const body = locate(segmentStatements(block.body), bodyLoc);
|
|
631
|
+
defects.push(...body.defects);
|
|
632
|
+
walk(body.units, stmtCtx, out, blockDepth + 1, defects, stmtCertain);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
/** Q4 — canonical path of every statement in source order. */
|
|
638
|
+
export function resolveStatements(text: string): StatementAnalysis {
|
|
639
|
+
const segmented = segmentStatements(text);
|
|
640
|
+
// As `resolveDocument`: coordinate facts come from the root only.
|
|
641
|
+
const defects: Defect[] = [...segmented.defects];
|
|
642
|
+
const statements: StatementResolution[] = [];
|
|
643
|
+
const top = locate(segmented, DOCUMENT_LOC);
|
|
644
|
+
defects.push(...top.defects);
|
|
645
|
+
walkStatements(top.units, "/", statements, 0, defects, true);
|
|
646
|
+
return { statements, defects: mergeDefects(defects) };
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function walkStatements(
|
|
650
|
+
units: Located[],
|
|
651
|
+
context: string,
|
|
652
|
+
out: StatementResolution[],
|
|
653
|
+
blockDepth: number,
|
|
654
|
+
defects: Defect[],
|
|
655
|
+
contextCertain: boolean,
|
|
656
|
+
): void {
|
|
657
|
+
let ctx = context;
|
|
658
|
+
let certain = contextCertain;
|
|
659
|
+
for (const unit of units) {
|
|
660
|
+
const { text, span } = unit;
|
|
661
|
+
const nav = menuNavPath(text, ctx);
|
|
662
|
+
if (nav !== null) {
|
|
663
|
+
// `..` ascends FROM the context, so it cannot be read while the context
|
|
664
|
+
// is unknown. A `/`-led navigation (and a bare `/`) REPLACES it (R4) and
|
|
665
|
+
// therefore re-establishes certainty.
|
|
666
|
+
const relative = !trimAscii(text).startsWith("/");
|
|
667
|
+
if (relative && !certain) {
|
|
668
|
+
out.push({
|
|
669
|
+
text,
|
|
670
|
+
isNav: true,
|
|
671
|
+
context: ctx,
|
|
672
|
+
path: null,
|
|
673
|
+
unresolved: CONTEXT_LOST,
|
|
674
|
+
contextCertain: false,
|
|
675
|
+
span,
|
|
676
|
+
});
|
|
677
|
+
continue;
|
|
678
|
+
}
|
|
679
|
+
out.push({
|
|
680
|
+
text,
|
|
681
|
+
isNav: true,
|
|
682
|
+
context: ctx,
|
|
683
|
+
path: nav,
|
|
684
|
+
contextCertain: certain,
|
|
685
|
+
span,
|
|
686
|
+
});
|
|
687
|
+
ctx = nav;
|
|
688
|
+
if (!relative) certain = true;
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
// Q14 fail-closed — a malformed statement degrades to unresolved and is
|
|
692
|
+
// not descended; it does not move the context either. It may however have
|
|
693
|
+
// BEEN a navigation, so what the context now is becomes unknown.
|
|
694
|
+
const defectReason = structuralDefect(text);
|
|
695
|
+
if (defectReason !== null) {
|
|
696
|
+
out.push({
|
|
697
|
+
text,
|
|
698
|
+
isNav: false,
|
|
699
|
+
context: ctx,
|
|
700
|
+
path: null,
|
|
701
|
+
unresolved: defectReason,
|
|
702
|
+
contextCertain: certain,
|
|
703
|
+
span,
|
|
704
|
+
});
|
|
705
|
+
certain = false;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
// #235 — a relative bare-word that abstained (neither table) poisons
|
|
709
|
+
// downstream context. Capture before the push so the current statement's
|
|
710
|
+
// own `contextCertain` stays true while the following statements lose it.
|
|
711
|
+
const poisonAbstention = isAbstainedRelativeNav(text, ctx);
|
|
712
|
+
// Q14 C3b — the cascade. A statement that CONSUMES the context cannot be
|
|
713
|
+
// resolved against a value the resolver has already admitted it lost.
|
|
714
|
+
// Applied to the RESOLUTION, not ahead of it, so a statement that would
|
|
715
|
+
// have refused on its own terms keeps its own — more specific — reason.
|
|
716
|
+
const resolved = canonicalPath(text, ctx);
|
|
717
|
+
out.push({
|
|
718
|
+
text,
|
|
719
|
+
isNav: false,
|
|
720
|
+
context: ctx,
|
|
721
|
+
...(resolved.path !== null && !certain && !isContextIndependent(text)
|
|
722
|
+
? { path: null, unresolved: CONTEXT_LOST }
|
|
723
|
+
: resolved),
|
|
724
|
+
contextCertain: certain,
|
|
725
|
+
span,
|
|
726
|
+
});
|
|
727
|
+
// A block body's statements are the parent's siblings after flattening,
|
|
728
|
+
// and R5 gives them the context in force here — which is the ROOT, hence
|
|
729
|
+
// knowable, when the statement spells its path out or is a bare directive,
|
|
730
|
+
// and unknowable when the statement's own path could not be read. Same
|
|
731
|
+
// predicate and same order as the bracket walk, per the lockstep rule.
|
|
732
|
+
const stmtCtx = statementPath(text, ctx);
|
|
733
|
+
const bodyCertain = poisonAbstention
|
|
734
|
+
? false
|
|
735
|
+
: handsKnownContext(text, certain);
|
|
736
|
+
if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
|
|
737
|
+
for (const block of scopeBlocks(text)) {
|
|
738
|
+
if (blockDepth >= MAX_DEPTH) {
|
|
739
|
+
defects.push({
|
|
740
|
+
code: "over-depth",
|
|
741
|
+
...regionIn(unit.loc, block.start, block.start + block.body.length),
|
|
742
|
+
});
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
const bodyLoc = nest(unit.loc, block.start, span);
|
|
746
|
+
const body = locate(segmentStatements(block.body), bodyLoc);
|
|
747
|
+
defects.push(...body.defects);
|
|
748
|
+
walkStatements(
|
|
749
|
+
body.units,
|
|
750
|
+
stmtCtx,
|
|
751
|
+
out,
|
|
752
|
+
blockDepth + 1,
|
|
753
|
+
defects,
|
|
754
|
+
bodyCertain,
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
/**
|
|
761
|
+
* `/ip address` alone on a statement → the new context. Otherwise null.
|
|
762
|
+
*
|
|
763
|
+
* R8/R13 — `..` ascends, a bare `/` resets, and a relative bare path advances
|
|
764
|
+
* only when a shipped structure table confirms the context-applied menu
|
|
765
|
+
* ({@link readRelativeBare}). An absolute navigation REPLACES the context
|
|
766
|
+
* rather than extending it; a relative one EXTENDS it.
|
|
767
|
+
*
|
|
768
|
+
* R9 (#211 B1) — a `/`-led bare path whose run carries a FROZEN-VOCABULARY VERB
|
|
769
|
+
* is a command, not navigation. Without this the shape test alone claimed
|
|
770
|
+
* `/ip address print` as a navigation to `/ip/address/print` — a menu that does
|
|
771
|
+
* not exist — and then resolved the rest of the document against it. Q6 already
|
|
772
|
+
* decides these statements: `verbsplit` reads the very same text as verb `print`
|
|
773
|
+
* at `/ip/address`, so the two promoted modules returned contradictory answers
|
|
774
|
+
* for 34 dev / 13 holdout statements, `verbsplit` right in every one. The verb
|
|
775
|
+
* set is deliberately the SAME `VERBS` object and the same case-sensitive
|
|
776
|
+
* lookup, so the modules cannot drift apart; `explain-pathresolve.test.ts` pins
|
|
777
|
+
* the invariant that a `verbsplit`-`resolved` statement is never `isNav`.
|
|
778
|
+
*
|
|
779
|
+
* This costs no abstention — the statements it removes from navigation were
|
|
780
|
+
* already decided, just decided wrongly. It also does not collide with any menu
|
|
781
|
+
* the generated table knows: no path in `MENU_PATHS` carries a verb segment,
|
|
782
|
+
* asserted in the same test so a regeneration that introduced one fails. That
|
|
783
|
+
* is a claim about the TABLE, not about RouterOS — `MENU_PATHS` is a floor
|
|
784
|
+
* (#207), so a real menu absent from it and named `.../print` would still be
|
|
785
|
+
* read as a command here. Nothing in the corpus or the four pinned trees looks
|
|
786
|
+
* like that, and such a menu would be indistinguishable from `print` at its
|
|
787
|
+
* parent anyway.
|
|
788
|
+
*
|
|
789
|
+
* R12 (#228 step 2) — a `/`-led bare path the published CATALOG names as a
|
|
790
|
+
* command is not navigation either. This is the harder half R9 could not reach
|
|
791
|
+
* (#211 B2): `/system/reboot`, `/quit` and `/tool/speed-test` carry no
|
|
792
|
+
* vocabulary verb, and #211 could only have refused them by reading ABSENCE from
|
|
793
|
+
* `MENU_PATHS` as evidence of a command — which inverts the floor contract
|
|
794
|
+
* (#207) and is why B2 was parked rather than guessed at. The command axis is
|
|
795
|
+
* the missing POSITIVE evidence, so nothing about the floor changes: presence in
|
|
796
|
+
* either table decides, absence from both still abstains.
|
|
797
|
+
*
|
|
798
|
+
* It is also no longer optional. `verbsplit` reads `/system reboot` as verb
|
|
799
|
+
* `reboot` at `/system`, so leaving it navigation here would re-open exactly the
|
|
800
|
+
* contradiction R9 closed — and worse than before, since the two modules would
|
|
801
|
+
* now disagree CONFIDENTLY rather than one of them refusing. The same test pins
|
|
802
|
+
* the invariant for both rules.
|
|
803
|
+
*/
|
|
804
|
+
function menuNavPath(text: string, ctx: string): string | null {
|
|
805
|
+
const trimmed = trimAscii(text);
|
|
806
|
+
if (trimmed === "/") return "/";
|
|
807
|
+
if (/^\.\.(?:[ \t\r\n]|$)/.test(trimmed)) {
|
|
808
|
+
const tokens = asciiWords(trimmed);
|
|
809
|
+
if (!tokens.every((token) => token === "..")) return null;
|
|
810
|
+
return joinPath(ctx, tokens.join("/"));
|
|
811
|
+
}
|
|
812
|
+
if (!trimmed.startsWith("/")) {
|
|
813
|
+
// #235 R13 — relative bare-word menu navigation.
|
|
814
|
+
const reading = readRelativeBare(text, ctx);
|
|
815
|
+
return reading?.kind === "nav" ? reading.path : null;
|
|
816
|
+
}
|
|
817
|
+
if (/[=[({"$]/.test(trimmed)) return null;
|
|
818
|
+
const tokens = asciiWords(trimmed);
|
|
819
|
+
if (
|
|
820
|
+
!tokens.every((token, i) =>
|
|
821
|
+
i === 0 ? isAbsolutePathToken(token) : BARE_WORD.test(token),
|
|
822
|
+
)
|
|
823
|
+
)
|
|
824
|
+
return null;
|
|
825
|
+
// R9 — the run is verb-free, or this is not navigation. Tokens may be
|
|
826
|
+
// slash-joined (`/ip/address/print` is one token), so the verb test has to run
|
|
827
|
+
// over path SEGMENTS, exactly as `verbsplit`'s `runTokens` splits them.
|
|
828
|
+
const segments = tokens.flatMap((token) =>
|
|
829
|
+
token.split("/").filter((part) => part.length > 0),
|
|
830
|
+
);
|
|
831
|
+
if (segments.some((segment) => VERBS.has(segment))) return null;
|
|
832
|
+
// R12 — the same segments, against the published command axis.
|
|
833
|
+
if (commandVerbIndex(segments) !== null) return null;
|
|
834
|
+
return joinPath("/", tokens.join("/"));
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
/**
|
|
838
|
+
* How a RELATIVE bare-path statement reads against `ctx` (#235, R13).
|
|
839
|
+
*
|
|
840
|
+
* - `nav` — a shipped table confirms the context-applied path, so it navigates.
|
|
841
|
+
* - `abstain` — the statement HAS the shape of relative navigation but neither
|
|
842
|
+
* table knows the joined path. Offline cannot say which it was, so the caller
|
|
843
|
+
* poisons what follows rather than resolve it against a context that may
|
|
844
|
+
* already have moved.
|
|
845
|
+
* - `null` — not a relative bare path at all: absolute, dynamic, a directive,
|
|
846
|
+
* or a run carrying a vocabulary verb / published command, which is a
|
|
847
|
+
* COMMAND and therefore not a maybe-navigation either.
|
|
848
|
+
*
|
|
849
|
+
* ONE classifier rather than a shape test per question. The nav reading and the
|
|
850
|
+
* poison test must agree about which statements are in scope; written twice
|
|
851
|
+
* they can drift, and a drift there is silently either a lost navigation or a
|
|
852
|
+
* poisoned document.
|
|
853
|
+
*/
|
|
854
|
+
type RelativeBareReading = { kind: "nav"; path: string } | { kind: "abstain" };
|
|
855
|
+
|
|
856
|
+
function readRelativeBare(
|
|
857
|
+
text: string,
|
|
858
|
+
ctx: string,
|
|
859
|
+
): RelativeBareReading | null {
|
|
860
|
+
const trimmed = trimAscii(text);
|
|
861
|
+
if (
|
|
862
|
+
trimmed.length === 0 ||
|
|
863
|
+
trimmed.startsWith("/") ||
|
|
864
|
+
trimmed.startsWith(":") ||
|
|
865
|
+
trimmed.startsWith("$") ||
|
|
866
|
+
trimmed.startsWith("[") ||
|
|
867
|
+
trimmed.startsWith("(") ||
|
|
868
|
+
isBareDirective(trimmed)
|
|
869
|
+
)
|
|
870
|
+
return null;
|
|
871
|
+
// Whitespace and `/` are interchangeable on the device, so the run is read
|
|
872
|
+
// over path SEGMENTS exactly as R9 reads an absolute one: `firewall filter`
|
|
873
|
+
// and `firewall/filter` under `/ip` are the same navigation (CHR 7.24rc1 —
|
|
874
|
+
// see the `pathresolve.json` corner). A `..`/`.` segment fails `BARE_WORD`,
|
|
875
|
+
// so the `..` form above stays the only ascent this function admits.
|
|
876
|
+
const parts = asciiWords(trimmed).flatMap((word) => word.split("/"));
|
|
877
|
+
if (!parts.every((part) => BARE_WORD.test(part))) return null;
|
|
878
|
+
const path = joinPath(ctx, parts.join("/"));
|
|
879
|
+
const segments = path.split("/").filter(Boolean);
|
|
880
|
+
// R9/R12 on the JOINED path — a verb-bearing or published-command path is a
|
|
881
|
+
// command, not navigation, and not an abstention either: `address print`
|
|
882
|
+
// under `/ip` is verb `print` at `/ip/address`, which `verbsplit` decides.
|
|
883
|
+
if (segments.some((segment) => VERBS.has(segment))) return null;
|
|
884
|
+
if (commandVerbIndex(segments) !== null) return null;
|
|
885
|
+
return isKnownMenuPath(segments)
|
|
886
|
+
? { kind: "nav", path }
|
|
887
|
+
: { kind: "abstain" };
|
|
888
|
+
}
|
|
889
|
+
|
|
890
|
+
/**
|
|
891
|
+
* #235 — does `text` look like a relative menu navigation that ABSTAINED?
|
|
892
|
+
*
|
|
893
|
+
* Presence in a table → nav; absence → abstain → poison downstream, so the
|
|
894
|
+
* next relative statement cannot resolve against a context that may have moved.
|
|
895
|
+
*/
|
|
896
|
+
function isAbstainedRelativeNav(text: string, ctx: string): boolean {
|
|
897
|
+
return readRelativeBare(text, ctx)?.kind === "abstain";
|
|
898
|
+
}
|
|
899
|
+
|
|
900
|
+
function canonicalPath(
|
|
901
|
+
text: string,
|
|
902
|
+
ctx: string,
|
|
903
|
+
): { path: string | null; candidates?: string[]; unresolved?: string } {
|
|
904
|
+
const t = trimAscii(text);
|
|
905
|
+
if (t.startsWith("$") || t.startsWith("[") || t.startsWith("("))
|
|
906
|
+
return {
|
|
907
|
+
path: null,
|
|
908
|
+
unresolved: "dynamic or substitution-headed statement",
|
|
909
|
+
};
|
|
910
|
+
// A `:` scripting directive is always at the ROOT, whatever the context —
|
|
911
|
+
// but it is not necessarily one segment: `:log info "x"` is `/log/info`.
|
|
912
|
+
const body = t.startsWith(":") ? t.slice(1) : t;
|
|
913
|
+
// R11 — a scripting directive written WITHOUT its colon is still at the root.
|
|
914
|
+
// A statement that carries a SCOPE-valued block is a directive, because no
|
|
915
|
+
// menu command takes one — a schema-free tell.
|
|
916
|
+
const bareDirective = isBareDirective(t);
|
|
917
|
+
// The cascade contract reads this same split to decide which statements may
|
|
918
|
+
// keep resolving once the context is lost, so both must come from one place.
|
|
919
|
+
const base = isContextIndependent(t) ? "/" : ctx;
|
|
920
|
+
// R7 — a variable segment anywhere in the leading run leaves the path
|
|
921
|
+
// unresolved, INCLUDING after a static word. `statementRun` drops the whole
|
|
922
|
+
// word carrying the variable, so `/ip route/$menu/remove` would otherwise
|
|
923
|
+
// report a confident, truncated `/ip`.
|
|
924
|
+
if (isUnreadablePath(t))
|
|
925
|
+
return { path: null, unresolved: "variable path segment" };
|
|
926
|
+
const tokens = statementRun(body);
|
|
927
|
+
if (tokens.length === 0)
|
|
928
|
+
return { path: null, unresolved: "no leading path token" };
|
|
929
|
+
if (tokens.some((x) => x.startsWith("$")))
|
|
930
|
+
return { path: null, unresolved: "variable path segment" };
|
|
931
|
+
const candidates: string[] = [];
|
|
932
|
+
for (let k = 1; k <= tokens.length; k++)
|
|
933
|
+
candidates.push(joinPath(base, tokens.slice(0, k).join("/")));
|
|
934
|
+
// R10 — the path/positional boundary, split by statement kind: a `:` (or
|
|
935
|
+
// bare) directive takes its operand positionally, so its path is the
|
|
936
|
+
// directive alone; a menu command spells its whole menu out.
|
|
937
|
+
const path =
|
|
938
|
+
t.startsWith(":") || bareDirective
|
|
939
|
+
? (candidates[0] as string)
|
|
940
|
+
: joinPath(base, tokens.join("/"));
|
|
941
|
+
return { path, candidates };
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
/**
|
|
945
|
+
* Leading path-token run of a statement. Stops at the first token that cannot
|
|
946
|
+
* be a path segment: an argument (`x=1`), a group, a quoted string, or anything
|
|
947
|
+
* not shaped like an identifier — a bare `9` is a positional argument, never a
|
|
948
|
+
* menu. It does NOT stop at identifier-shaped positional arguments (`enable
|
|
949
|
+
* www-ssl`); nothing offline can, and that residue is Q6.
|
|
950
|
+
*/
|
|
951
|
+
function statementRun(text: string): string[] {
|
|
952
|
+
const out: string[] = [];
|
|
953
|
+
for (const t of asciiWords(text)) {
|
|
954
|
+
if (t.includes("=") || /^[[({"$:]/.test(t)) break;
|
|
955
|
+
const parts = t.split("/").filter((p) => p.length > 0);
|
|
956
|
+
if (parts.length === 0) continue;
|
|
957
|
+
if (!parts.every((p) => BARE_WORD.test(p))) break;
|
|
958
|
+
out.push(...parts);
|
|
959
|
+
}
|
|
960
|
+
return out;
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* The menu path a statement's own brackets inherit. For `/ip route remove
|
|
965
|
+
* [find …]` that is `/ip/route`: the leading path run minus its last token,
|
|
966
|
+
* which is the verb (Q6's reading, right ~93.7% of the time).
|
|
967
|
+
*/
|
|
968
|
+
function statementPath(text: string, ctx: string): string {
|
|
969
|
+
const t = trimAsciiStart(text);
|
|
970
|
+
// R11 — a bare directive is at the root, so the context it hands its body is
|
|
971
|
+
// the root too, not `<ctx>/while`.
|
|
972
|
+
if (isBareDirective(trimAscii(t))) return "/";
|
|
973
|
+
const lead = leadingRun(text);
|
|
974
|
+
if (lead.length === 0) return ctx;
|
|
975
|
+
// Same split `isBodyContextIndependent` reports to the cascade contract.
|
|
976
|
+
const base = isBodyContextIndependent(t) ? "/" : ctx;
|
|
977
|
+
return joinPath(base, lead.slice(0, -1).join("/"));
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
/** Leading bare/path tokens, stopping at the first argument or group. */
|
|
981
|
+
function leadingRun(text: string): string[] {
|
|
982
|
+
const out: string[] = [];
|
|
983
|
+
for (const t of asciiWords(text)) {
|
|
984
|
+
if (t.includes("=") || /^[[({"$:]/.test(t)) break;
|
|
985
|
+
for (const part of t.split("/")) if (part.length > 0) out.push(part);
|
|
986
|
+
if (out.length === 0 && t === "/") out.push("");
|
|
987
|
+
}
|
|
988
|
+
return out;
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
function joinPath(base: string, rest: string): string {
|
|
992
|
+
const parts = [
|
|
993
|
+
...base.split("/").filter(Boolean),
|
|
994
|
+
...rest.split("/").filter((p) => p.length > 0),
|
|
995
|
+
];
|
|
996
|
+
const stack: string[] = [];
|
|
997
|
+
for (const p of parts) {
|
|
998
|
+
if (p === "..") stack.pop();
|
|
999
|
+
else if (p !== ".") stack.push(p);
|
|
1000
|
+
}
|
|
1001
|
+
return `/${stack.join("/")}`;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function collectBrackets(
|
|
1005
|
+
text: string,
|
|
1006
|
+
ctx: string,
|
|
1007
|
+
depth: number,
|
|
1008
|
+
out: Resolution[],
|
|
1009
|
+
defects: Defect[],
|
|
1010
|
+
contextCertain: boolean,
|
|
1011
|
+
loc: Loc,
|
|
1012
|
+
inLiteral = false,
|
|
1013
|
+
): void {
|
|
1014
|
+
// Bound the recursion (bracket nesting AND literal-brace descent) so
|
|
1015
|
+
// untrusted deeply nested input abstains instead of overflowing the stack.
|
|
1016
|
+
if (depth >= MAX_DEPTH) {
|
|
1017
|
+
// `text` is empty when the nesting bottoms out on an empty body (`[]`),
|
|
1018
|
+
// which `regionIn` widens rather than emitting a zero-width region.
|
|
1019
|
+
defects.push({ code: "over-depth", ...regionIn(loc, 0, text.length) });
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
// Scan a comment-masked copy so a `#`-comment `[`/`{` is not treated as a
|
|
1023
|
+
// substitution or scope. Comments never hold valid inner commands (`#` mid
|
|
1024
|
+
// `[…]` is not statement-leading), so masked slices equal the originals.
|
|
1025
|
+
const masked = maskComments(text);
|
|
1026
|
+
for (let i = 0; i < masked.length; i++) {
|
|
1027
|
+
const c = masked[i];
|
|
1028
|
+
if (c === '"') {
|
|
1029
|
+
// A string is opaque EXCEPT for `$[ … ]` interpolation, a real command
|
|
1030
|
+
// substitution: `:put "$[:pick $ip 0 $n]"` lowers with `/pick` inside
|
|
1031
|
+
// (topic-…/post-0003-snippet-01 @ 7.22.1).
|
|
1032
|
+
const strEnd = stringEnd(masked, i);
|
|
1033
|
+
scanInterpolations(
|
|
1034
|
+
masked.slice(i + 1, strEnd),
|
|
1035
|
+
ctx,
|
|
1036
|
+
depth,
|
|
1037
|
+
out,
|
|
1038
|
+
defects,
|
|
1039
|
+
contextCertain,
|
|
1040
|
+
nest(loc, i + 1, loc.fallback),
|
|
1041
|
+
);
|
|
1042
|
+
i = strEnd;
|
|
1043
|
+
continue;
|
|
1044
|
+
}
|
|
1045
|
+
if (c === "{") {
|
|
1046
|
+
// A literal body is a value (Q2), but can still contain command
|
|
1047
|
+
// substitutions: `{[/terminal/inkey]}` lowers the bracket. Descend for
|
|
1048
|
+
// brackets only; scope bodies are walked by walk().
|
|
1049
|
+
const end = matchDelim(masked, i, "{", "}");
|
|
1050
|
+
const body = masked.slice(i + 1, end);
|
|
1051
|
+
const isScope = !inLiteral && scopeNameFromMasked(masked, i) !== null;
|
|
1052
|
+
if (!isScope && (body.includes("[") || body.includes("{")))
|
|
1053
|
+
collectBrackets(
|
|
1054
|
+
body,
|
|
1055
|
+
ctx,
|
|
1056
|
+
depth + 1,
|
|
1057
|
+
out,
|
|
1058
|
+
defects,
|
|
1059
|
+
contextCertain,
|
|
1060
|
+
nest(loc, i + 1, loc.fallback),
|
|
1061
|
+
true,
|
|
1062
|
+
);
|
|
1063
|
+
i = end;
|
|
1064
|
+
continue;
|
|
1065
|
+
}
|
|
1066
|
+
if (c !== "[") continue;
|
|
1067
|
+
const end = matchDelim(masked, i, "[", "]");
|
|
1068
|
+
const inner = trimAscii(masked.slice(i + 1, end));
|
|
1069
|
+
// The span covers the whole `[…]`, closing bracket included.
|
|
1070
|
+
out.push({
|
|
1071
|
+
...resolveInner(inner, ctx, depth, contextCertain),
|
|
1072
|
+
span: spanIn(loc, i, Math.min(end + 1, text.length)),
|
|
1073
|
+
});
|
|
1074
|
+
// R6 — nested brackets inherit from this one's resolution.
|
|
1075
|
+
const nestedCtx = out[out.length - 1]?.path ?? ctx;
|
|
1076
|
+
collectBrackets(
|
|
1077
|
+
inner,
|
|
1078
|
+
nestedCtx,
|
|
1079
|
+
depth + 1,
|
|
1080
|
+
out,
|
|
1081
|
+
defects,
|
|
1082
|
+
nestedCertainty(out[out.length - 1], contextCertain),
|
|
1083
|
+
// `inner` is the trimmed slice of `masked` starting at i+1; the leading
|
|
1084
|
+
// whitespace trim shifts it further right.
|
|
1085
|
+
nest(
|
|
1086
|
+
loc,
|
|
1087
|
+
i +
|
|
1088
|
+
1 +
|
|
1089
|
+
(masked.slice(i + 1, end).length -
|
|
1090
|
+
trimAsciiStart(masked.slice(i + 1, end)).length),
|
|
1091
|
+
loc.fallback,
|
|
1092
|
+
),
|
|
1093
|
+
);
|
|
1094
|
+
i = end;
|
|
1095
|
+
}
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
/**
|
|
1099
|
+
* Index of the closing quote of the string starting at `open`. Delegates to the
|
|
1100
|
+
* shared `scanQuotedString`, so a `$[…]` substitution carrying its own string
|
|
1101
|
+
* does not end the outer one early and cut `scanInterpolations`' body short
|
|
1102
|
+
* (#199).
|
|
1103
|
+
*/
|
|
1104
|
+
function stringEnd(text: string, open: number): number {
|
|
1105
|
+
const str = scanQuotedString(text, open);
|
|
1106
|
+
return str.closed ? str.end - 1 : text.length;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/** `$[ … ]` command substitutions inside a string body. */
|
|
1110
|
+
function scanInterpolations(
|
|
1111
|
+
body: string,
|
|
1112
|
+
ctx: string,
|
|
1113
|
+
depth: number,
|
|
1114
|
+
out: Resolution[],
|
|
1115
|
+
defects: Defect[],
|
|
1116
|
+
contextCertain: boolean,
|
|
1117
|
+
loc: Loc,
|
|
1118
|
+
): void {
|
|
1119
|
+
for (let i = 0; i < body.length - 1; i++) {
|
|
1120
|
+
if (body[i] !== "$" || body[i + 1] !== "[") continue;
|
|
1121
|
+
const end = matchDelim(body, i + 1, "[", "]");
|
|
1122
|
+
const raw = body.slice(i + 2, end);
|
|
1123
|
+
const inner = trimAscii(raw);
|
|
1124
|
+
// Span the `$[…]` from its sigil through the closing bracket.
|
|
1125
|
+
out.push({
|
|
1126
|
+
...resolveInner(inner, ctx, depth, contextCertain),
|
|
1127
|
+
span: spanIn(loc, i, Math.min(end + 1, body.length)),
|
|
1128
|
+
});
|
|
1129
|
+
collectBrackets(
|
|
1130
|
+
inner,
|
|
1131
|
+
out[out.length - 1]?.path ?? ctx,
|
|
1132
|
+
depth + 1,
|
|
1133
|
+
out,
|
|
1134
|
+
defects,
|
|
1135
|
+
nestedCertainty(out[out.length - 1], contextCertain),
|
|
1136
|
+
nest(
|
|
1137
|
+
loc,
|
|
1138
|
+
i + 2 + (raw.length - trimAsciiStart(raw).length),
|
|
1139
|
+
loc.fallback,
|
|
1140
|
+
),
|
|
1141
|
+
);
|
|
1142
|
+
i = end;
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
/**
|
|
1147
|
+
* Certainty a NESTED bracket inherits (R6). Three cases, and the middle one is
|
|
1148
|
+
* the whole subtlety:
|
|
1149
|
+
*
|
|
1150
|
+
* 1. The enclosing bracket RESOLVED — its path is the nested bracket's base,
|
|
1151
|
+
* so the nested one is anchored even while the document context is lost
|
|
1152
|
+
* (`[/ip/route/get [find …] gateway]` is absolute, hence independent).
|
|
1153
|
+
* 2. The enclosing bracket is a MENU COMMAND WE COULD NOT READ — then the
|
|
1154
|
+
* real base is a menu we cannot name, and `collectBrackets` falls back to
|
|
1155
|
+
* the STATEMENT's context, which is a different menu. In `/ip route` +
|
|
1156
|
+
* `remove [/interface/$type/get [find]]` that fallback reports the nested
|
|
1157
|
+
* `[find]` at `/ip/route`, a context it never had. It must abstain — and
|
|
1158
|
+
* equally for a RELATIVE unreadable menu (`[route/$verb [find]]`), because
|
|
1159
|
+
* spelling is not what makes a menu unreadable.
|
|
1160
|
+
* 3. The enclosing bracket is NOT A MENU COMMAND AT ALL — a user function
|
|
1161
|
+
* (`$formatDate`), a value expression, a `:` directive. It
|
|
1162
|
+
* establishes no menu, so the AMBIENT context still governs its arguments
|
|
1163
|
+
* and the fallback is correct. Corpus evidence for this being the common
|
|
1164
|
+
* shape rather than a corner: under `/log`,
|
|
1165
|
+
* `:set x "$[$formatDate [get $item time]]"` must keep resolving
|
|
1166
|
+
* `[get …]` at `/log` (rextended/topic-166898). Refusing case 3 as well
|
|
1167
|
+
* costs 12 further brackets and one document's `containsWrite` verdict,
|
|
1168
|
+
* all of them correct answers thrown away.
|
|
1169
|
+
*
|
|
1170
|
+
* Case 2 is told from case 3 by `isUnreadablePath`, the same test the statement
|
|
1171
|
+
* walk uses: the inner NAMES a menu (a path-shaped head) whose leading run is
|
|
1172
|
+
* truncated by a variable segment, in either the absolute or the relative
|
|
1173
|
+
* spelling. Case 3 is text that names no menu at all.
|
|
1174
|
+
*/
|
|
1175
|
+
function nestedCertainty(
|
|
1176
|
+
enclosing: Pick<Resolution, "path" | "inner"> | undefined,
|
|
1177
|
+
contextCertain: boolean,
|
|
1178
|
+
): boolean {
|
|
1179
|
+
if (enclosing === undefined) return contextCertain;
|
|
1180
|
+
if (enclosing.path !== null) return true;
|
|
1181
|
+
if (isUnreadablePath(enclosing.inner)) return false;
|
|
1182
|
+
return contextCertain;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
/**
|
|
1186
|
+
* Resolve one bracket body. The caller owns the `span`, because only it knows
|
|
1187
|
+
* where the bracket sat in the text it was scanning.
|
|
1188
|
+
*/
|
|
1189
|
+
function resolveInner(
|
|
1190
|
+
inner: string,
|
|
1191
|
+
ctx: string,
|
|
1192
|
+
depth: number,
|
|
1193
|
+
contextCertain: boolean,
|
|
1194
|
+
): Omit<Resolution, "span"> {
|
|
1195
|
+
const absolute = inner.startsWith("/");
|
|
1196
|
+
const klass = classify(inner, absolute, depth);
|
|
1197
|
+
if (klass === "cli-prompt-artifact")
|
|
1198
|
+
return {
|
|
1199
|
+
inner,
|
|
1200
|
+
context: ctx,
|
|
1201
|
+
tokens: [],
|
|
1202
|
+
candidates: [],
|
|
1203
|
+
path: null,
|
|
1204
|
+
unresolved: "looks like a pasted CLI prompt, not a substitution",
|
|
1205
|
+
contextCertain,
|
|
1206
|
+
depth,
|
|
1207
|
+
klass,
|
|
1208
|
+
};
|
|
1209
|
+
// A `:` scripting directive is ALWAYS at the root — `[:tostr $x]` lowers to
|
|
1210
|
+
// `(evl (evl /tostrvalue=$x))`, path `/`, whatever context is in force.
|
|
1211
|
+
if (trimAsciiStart(inner).startsWith(":"))
|
|
1212
|
+
return {
|
|
1213
|
+
inner,
|
|
1214
|
+
context: ctx,
|
|
1215
|
+
tokens: [],
|
|
1216
|
+
candidates: ["/"],
|
|
1217
|
+
path: "/",
|
|
1218
|
+
contextCertain,
|
|
1219
|
+
depth,
|
|
1220
|
+
klass,
|
|
1221
|
+
};
|
|
1222
|
+
const tokens = leadingRun(inner);
|
|
1223
|
+
if (tokens.length === 0)
|
|
1224
|
+
return {
|
|
1225
|
+
inner,
|
|
1226
|
+
context: ctx,
|
|
1227
|
+
tokens,
|
|
1228
|
+
candidates: [],
|
|
1229
|
+
path: null,
|
|
1230
|
+
unresolved: "no leading path token",
|
|
1231
|
+
contextCertain,
|
|
1232
|
+
depth,
|
|
1233
|
+
klass,
|
|
1234
|
+
};
|
|
1235
|
+
// R7 — a `$var` anywhere in the leading run makes the path unknowable.
|
|
1236
|
+
if (tokens.some((t) => t.startsWith("$")))
|
|
1237
|
+
return {
|
|
1238
|
+
inner,
|
|
1239
|
+
context: ctx,
|
|
1240
|
+
tokens,
|
|
1241
|
+
candidates: [],
|
|
1242
|
+
path: null,
|
|
1243
|
+
unresolved: "variable path segment",
|
|
1244
|
+
contextCertain,
|
|
1245
|
+
depth,
|
|
1246
|
+
klass,
|
|
1247
|
+
};
|
|
1248
|
+
// Q14 C3b — a RELATIVE inner command inherits the statement's menu (R2/R3),
|
|
1249
|
+
// so it cannot be re-constituted once that context is lost. Checked only once
|
|
1250
|
+
// the inner command has been read, so a bracket that refuses on its own terms
|
|
1251
|
+
// keeps its own reason; an absolute inner path and the `:` directive above are
|
|
1252
|
+
// context-independent and still resolve.
|
|
1253
|
+
if (!contextCertain && !absolute)
|
|
1254
|
+
return {
|
|
1255
|
+
inner,
|
|
1256
|
+
context: ctx,
|
|
1257
|
+
tokens,
|
|
1258
|
+
candidates: [],
|
|
1259
|
+
path: null,
|
|
1260
|
+
unresolved: CONTEXT_LOST,
|
|
1261
|
+
contextCertain,
|
|
1262
|
+
depth,
|
|
1263
|
+
klass,
|
|
1264
|
+
};
|
|
1265
|
+
const base = absolute ? "/" : ctx; // R1 / R2
|
|
1266
|
+
const candidates: string[] = [joinPath(base, "")];
|
|
1267
|
+
for (let k = 1; k < tokens.length; k++)
|
|
1268
|
+
candidates.push(joinPath(base, tokens.slice(0, k).join("/")));
|
|
1269
|
+
return {
|
|
1270
|
+
inner,
|
|
1271
|
+
context: ctx,
|
|
1272
|
+
tokens,
|
|
1273
|
+
candidates,
|
|
1274
|
+
// Absolute inner paths spell their menu out, so the verb is the last
|
|
1275
|
+
// segment of the slash-joined head; relative ones lead with the verb.
|
|
1276
|
+
path: absolute
|
|
1277
|
+
? joinPath(base, tokens.slice(0, -1).join("/"))
|
|
1278
|
+
: joinPath(base, ""),
|
|
1279
|
+
contextCertain,
|
|
1280
|
+
depth,
|
|
1281
|
+
klass,
|
|
1282
|
+
};
|
|
1283
|
+
}
|
|
1284
|
+
|
|
1285
|
+
// `[admin@Router] > /ip address print` — a pasted terminal transcript. The
|
|
1286
|
+
// prompt is bracketed exactly like a command substitution, and offline has no
|
|
1287
|
+
// way to tell them apart by shape alone. Left unguarded it manufactures phantom
|
|
1288
|
+
// inner commands from any pasted session, which matters because `explain` takes
|
|
1289
|
+
// editor and MCP input. `user@host` with no command shape is the tell; the
|
|
1290
|
+
// resolver abstains rather than inventing a path.
|
|
1291
|
+
const CLI_PROMPT_RE = /^[^ \t\r\n@/[\]]+@[^ \t\r\n@/[\]]+$/;
|
|
1292
|
+
|
|
1293
|
+
function classify(inner: string, absolute: boolean, depth: number): string {
|
|
1294
|
+
if (CLI_PROMPT_RE.test(trimAscii(inner))) return "cli-prompt-artifact";
|
|
1295
|
+
if (depth > 0) return "nested-bracket";
|
|
1296
|
+
if (absolute) return "absolute-inner-path";
|
|
1297
|
+
const head = (asciiWords(inner)[0] ?? "").toLowerCase();
|
|
1298
|
+
if (head.startsWith("$")) return "dynamic-invocation";
|
|
1299
|
+
if (head.startsWith(":")) return "scripting-directive";
|
|
1300
|
+
if (head === "find") return "bare-find";
|
|
1301
|
+
return "bare-inner-command";
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
function matchDelim(
|
|
1305
|
+
text: string,
|
|
1306
|
+
start: number,
|
|
1307
|
+
open: string,
|
|
1308
|
+
close: string,
|
|
1309
|
+
): number {
|
|
1310
|
+
let depth = 0;
|
|
1311
|
+
for (let i = start; i < text.length; i++) {
|
|
1312
|
+
const c = text[i];
|
|
1313
|
+
if (c === '"') {
|
|
1314
|
+
i = scanQuotedString(text, i).end - 1;
|
|
1315
|
+
continue;
|
|
1316
|
+
}
|
|
1317
|
+
if (c === open) depth++;
|
|
1318
|
+
else if (c === close) {
|
|
1319
|
+
depth--;
|
|
1320
|
+
if (depth === 0) return i;
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
return text.length;
|
|
1324
|
+
}
|