@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
package/src/explain.ts
ADDED
|
@@ -0,0 +1,2602 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `explain` — the offline analysis, composed.
|
|
3
|
+
*
|
|
4
|
+
* Phase 0.5 promoted nine ratified analyzers into `src/explain/` with **no
|
|
5
|
+
* caller**: segment, blocks, coordinates, pathresolve, verbsplit, write,
|
|
6
|
+
* symbols, plus the internal menus/verbs tables. Each answers one lab question
|
|
7
|
+
* and none of them knows the others exist. This module is the composition
|
|
8
|
+
* (#202): one pass over one input producing the result shape
|
|
9
|
+
* `commands/explain/README.md` → *Result shape* specifies, so a caller sequences
|
|
10
|
+
* nothing and every fact carries its provenance.
|
|
11
|
+
*
|
|
12
|
+
* ## The two verdicts are different questions
|
|
13
|
+
*
|
|
14
|
+
* `data.verdict` (`pass`/`warn`/`fail`) is the maximum DIAGNOSTIC SEVERITY. It
|
|
15
|
+
* exists to drive `--fail-on` and nothing else.
|
|
16
|
+
* `structure.statements[].resolution` (`resolved`/`ambiguous`/`unknown`) is
|
|
17
|
+
* what the CANONICALIZER decided about one statement.
|
|
18
|
+
*
|
|
19
|
+
* They are deliberately not collapsed: a document can be perfectly well-formed
|
|
20
|
+
* (`verdict: "pass"`) and still be unreadable offline (`/system/reboot` is
|
|
21
|
+
* `ambiguous` — a menu and a no-argument command are the same text without a
|
|
22
|
+
* schema), and a document riddled with defects still returns `ok: true`
|
|
23
|
+
* because the diagnostics ARE the data.
|
|
24
|
+
*
|
|
25
|
+
* ## What the gate is, and what it is not
|
|
26
|
+
*
|
|
27
|
+
* `data.canonical` reproduces `canonicalizeExecuteCommand` field for field.
|
|
28
|
+
* That function is centrs's script-vs-structured EXECUTION gate and widening
|
|
29
|
+
* it is a product regression (`docs/CONSTITUTION.md`); the richer analysis
|
|
30
|
+
* sits BESIDE it and never feeds back into it. Two consequences a reader will
|
|
31
|
+
* otherwise trip on:
|
|
32
|
+
*
|
|
33
|
+
* - The gate wants the verb IN the path. `/ip/route/add dst-address=…` is
|
|
34
|
+
* `structured`; the CLI spelling `/ip/route add dst-address=…` is `script`,
|
|
35
|
+
* because `add` is a bare token with no `=`. `structure.statements[]` reads
|
|
36
|
+
* BOTH as `/ip/route` + `add`, and that disagreement is not a bug — the gate
|
|
37
|
+
* is answering "may this be sent as a structured REST call", the analysis is
|
|
38
|
+
* answering "what did the human write".
|
|
39
|
+
* - The gate reads a bare `/ip/route` as path `/ip` + verb `route`, while the
|
|
40
|
+
* analysis reads it as menu navigation (the #207 table confirms it). Again
|
|
41
|
+
* two different questions over the same bytes.
|
|
42
|
+
*
|
|
43
|
+
* ## Declared gaps (phase 1 offline)
|
|
44
|
+
*
|
|
45
|
+
* - **Per-statement arguments are read where they are LITERAL** (#202c).
|
|
46
|
+
* `structure.statements[].command.args` and the ordered `arguments.tokens`
|
|
47
|
+
* beside it come from `explain/args.ts`, which decides every token of a
|
|
48
|
+
* statement or refuses the whole list with a reason — a partially-read list
|
|
49
|
+
* would silently change what a rendered command does. On the frozen corpus
|
|
50
|
+
* 40% of CRUD-verb commands are read; the refusals are overwhelmingly a
|
|
51
|
+
* value only the device can compute (`[…]`, `$x`, `{…}`). The gate's
|
|
52
|
+
* `canonical.args` and a read list never contradict each other (measured:
|
|
53
|
+
* 83 corpus statements where both decided, 0 contradictions), though the
|
|
54
|
+
* analysis may abstain where the gate decided.
|
|
55
|
+
* - **Transport classification is fail-closed.** `api-candidate` is emitted
|
|
56
|
+
* only for the nine Q8 shapes runtime-exercised on two CHR versions;
|
|
57
|
+
* script-shaped input routes to `execute`, and every other resolved command
|
|
58
|
+
* is `unknown` with a reason. Curl is rendered only when requested.
|
|
59
|
+
* - **`spans` covers what offline can PROVE**: comment runs and resolved
|
|
60
|
+
* variable occurrences (Q13 scored 100% precision on resolved bindings, and
|
|
61
|
+
* abstentions are omitted rather than guessed). The full Q12 vocabulary over
|
|
62
|
+
* path/verb/argument bytes needs device `highlight` as its oracle and is
|
|
63
|
+
* phase 2. A subset is not a claim that the vocabulary is closed.
|
|
64
|
+
* - **Value facts have three axes, and highlight supplies none of them.**
|
|
65
|
+
* Offline analysis now publishes non-authoritative lexical SHAPE hints. The
|
|
66
|
+
* hint list may carry several shapes, but it borrows RouterOS's own type
|
|
67
|
+
* names, so it must never spell one the device contradicts: `2.2` is an
|
|
68
|
+
* IPv4 shortcut (`2.0.0.2`), not a decimal, because RouterOS numbers are
|
|
69
|
+
* integers. The type OBSERVED from a live reading and the SCHEMA type have
|
|
70
|
+
* separate optional homes but no offline producer. Each fact has its own
|
|
71
|
+
* provenance; no hint validates a value or becomes a diagnostic. The
|
|
72
|
+
* decision and the probe matrix are #225; `op` is deliberately not a shape
|
|
73
|
+
* hint — `(> …)` abstains offline and is reported only as a live
|
|
74
|
+
* `observedType` because identical syntax can be `op` or `array` and
|
|
75
|
+
* `:parse` IL cannot distinguish them (#288). This module is deliberately
|
|
76
|
+
* type-blind, which is why a verdict that looks wrong around a value is a
|
|
77
|
+
* type-axis question before it is a lexical one.
|
|
78
|
+
* - **`runtimeAcceptance` is always `"not-proven"`**, offline and live alike.
|
|
79
|
+
* It is the inspect-vs-runtime gap made machine-readable, not a placeholder.
|
|
80
|
+
*
|
|
81
|
+
* ## This module is the public offline entry — its imports are a contract
|
|
82
|
+
*
|
|
83
|
+
* `@tikoci/centrs/explain` resolves here, and the claim that offline analysis is a
|
|
84
|
+
* LIBRARY capability rests on what this file imports: nothing in the graph below
|
|
85
|
+
* it may open a connection, read CDB, or touch the filesystem, so the module
|
|
86
|
+
* bundles and runs in a browser, a Worker, or an editor host (#312).
|
|
87
|
+
*
|
|
88
|
+
* That is easy to break with one line, and it was: `toYaml` and
|
|
89
|
+
* `canonicalizeExecuteCommand` are pure, but importing them from `retrieve.ts`
|
|
90
|
+
* and `execute.ts` pulled mac-telnet (`node:dgram`), native-api (`node:crypto`)
|
|
91
|
+
* and ssh in behind them. Both now live in `src/core/`. **Before adding an
|
|
92
|
+
* import here, check it is pure** — `bun run explain:browser-consumer` walks the
|
|
93
|
+
* graph and names the offending edge, and
|
|
94
|
+
* `test/unit/explain-browser-entry.test.ts` gates it. A pure helper that lives
|
|
95
|
+
* in a transport-reaching module belongs in `src/core/`, not in an import here.
|
|
96
|
+
*/
|
|
97
|
+
|
|
98
|
+
import type {
|
|
99
|
+
CentrsErrorEnvelope,
|
|
100
|
+
CentrsSuccessEnvelope,
|
|
101
|
+
CommonSettingsMeta,
|
|
102
|
+
EnvelopeMeta,
|
|
103
|
+
} from "./core/envelope.ts";
|
|
104
|
+
import { buildTip, type Tip, type Warning } from "./core/envelope.ts";
|
|
105
|
+
import {
|
|
106
|
+
type CanonicalExecuteCommand,
|
|
107
|
+
canonicalizeExecuteCommand,
|
|
108
|
+
isWriteShaped,
|
|
109
|
+
} from "./core/execute-command.ts";
|
|
110
|
+
import { toYaml } from "./core/yaml.ts";
|
|
111
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
112
|
+
import { argSpans } from "./explain/arg-tokens.ts";
|
|
113
|
+
import {
|
|
114
|
+
type ArgumentKind,
|
|
115
|
+
type Argument as ExplainArgument,
|
|
116
|
+
invalidCommandBraceOffsets,
|
|
117
|
+
lexArguments,
|
|
118
|
+
lexArgumentTokens,
|
|
119
|
+
lexValueAnchors,
|
|
120
|
+
type ValueAnchorKind,
|
|
121
|
+
} from "./explain/args.ts";
|
|
122
|
+
import { scopeBlocks } from "./explain/blocks.ts";
|
|
123
|
+
import { braceSpans } from "./explain/brace-tokens.ts";
|
|
124
|
+
import { lookupPath } from "./explain/catalog.ts";
|
|
125
|
+
import {
|
|
126
|
+
analyzeCoordinates,
|
|
127
|
+
type CoordinateAnalysis,
|
|
128
|
+
} from "./explain/coordinates.ts";
|
|
129
|
+
import {
|
|
130
|
+
type Defect,
|
|
131
|
+
type DefectCode,
|
|
132
|
+
isPositionalFact,
|
|
133
|
+
} from "./explain/defects.ts";
|
|
134
|
+
import { escapeSpans } from "./explain/escape-tokens.ts";
|
|
135
|
+
import { isKnownMenuPath } from "./explain/is-known-menu.ts";
|
|
136
|
+
import { operatorSpans } from "./explain/operator-tokens.ts";
|
|
137
|
+
import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
|
|
138
|
+
import {
|
|
139
|
+
type Resolution,
|
|
140
|
+
rangeForResolution,
|
|
141
|
+
resolveDocument,
|
|
142
|
+
resolveStatements,
|
|
143
|
+
} from "./explain/pathresolve.ts";
|
|
144
|
+
import { walkStringEscapes } from "./explain/quoted-string.ts";
|
|
145
|
+
import {
|
|
146
|
+
documentRange,
|
|
147
|
+
type MaskedRange,
|
|
148
|
+
nestedRange,
|
|
149
|
+
rangeMask,
|
|
150
|
+
segmentStatements,
|
|
151
|
+
} from "./explain/segment.ts";
|
|
152
|
+
import {
|
|
153
|
+
findMissingSeparators,
|
|
154
|
+
type MissingSeparator,
|
|
155
|
+
} from "./explain/separator.ts";
|
|
156
|
+
import { stringSpans } from "./explain/string-tokens.ts";
|
|
157
|
+
import { augmentSymbolOccurrences } from "./explain/symbol-values.ts";
|
|
158
|
+
import {
|
|
159
|
+
resolveSymbols,
|
|
160
|
+
type SymbolClass,
|
|
161
|
+
type SymbolRole,
|
|
162
|
+
} from "./explain/symbols.ts";
|
|
163
|
+
import {
|
|
164
|
+
classifyExplainTransport,
|
|
165
|
+
type ExplainTransport,
|
|
166
|
+
} from "./explain/transport.ts";
|
|
167
|
+
import { valueSpans } from "./explain/value-tokens.ts";
|
|
168
|
+
import { type ValueShape, valueShapeHints } from "./explain/values.ts";
|
|
169
|
+
import {
|
|
170
|
+
type DocumentVerbSplit,
|
|
171
|
+
resolveVerb,
|
|
172
|
+
resolveVerbsFromStatements,
|
|
173
|
+
VERBS,
|
|
174
|
+
type VerbSplit,
|
|
175
|
+
} from "./explain/verbsplit.ts";
|
|
176
|
+
import {
|
|
177
|
+
containsWriteFromAnalyses,
|
|
178
|
+
type WriteVerdict,
|
|
179
|
+
} from "./explain/write.ts";
|
|
180
|
+
import {
|
|
181
|
+
type ResolvedSetting,
|
|
182
|
+
resolveStringSetting,
|
|
183
|
+
toCoreSource,
|
|
184
|
+
} from "./resolver/settings.ts";
|
|
185
|
+
|
|
186
|
+
/** A half-open analyzed-byte span, the coordinate contract of `coordinates.ts`. */
|
|
187
|
+
export interface ExplainSpanRange {
|
|
188
|
+
start: number;
|
|
189
|
+
end: number;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* One run of the analyzed input mapped back to the original.
|
|
194
|
+
*
|
|
195
|
+
* Identity runs are retained (the spec's requirement) so a consumer needs no
|
|
196
|
+
* second coordinate contract for un-normalized input. A run is coalesced only
|
|
197
|
+
* while the mapping inside it is 1 byte : 1 UTF-16 unit; every non-ASCII code
|
|
198
|
+
* point gets its own entry, because the mapping across a 4-byte/2-unit
|
|
199
|
+
* character is not linear and a coalesced run could not be indexed into.
|
|
200
|
+
*/
|
|
201
|
+
export interface ExplainPositionMapEntry {
|
|
202
|
+
analyzed: ExplainSpanRange;
|
|
203
|
+
originalUtf16: ExplainSpanRange;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export interface ExplainInput {
|
|
207
|
+
/** Length of the analyzed surface, in bytes. Equals the UTF-8 byte length. */
|
|
208
|
+
bytes: number;
|
|
209
|
+
/** True when normalization stood in for at least one non-ASCII byte. */
|
|
210
|
+
normalized: boolean;
|
|
211
|
+
/**
|
|
212
|
+
* Whether the ANALYZED INPUT was cut short. Always `false` in phase 1: there
|
|
213
|
+
* is no input size cap yet. It is not the `--full` smart-sizing flag, which
|
|
214
|
+
* bounds the RESULT.
|
|
215
|
+
*/
|
|
216
|
+
truncated: boolean;
|
|
217
|
+
positionMap: ExplainPositionMapEntry[];
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/** Maximum diagnostic severity. Drives `--fail-on`; nothing else. */
|
|
221
|
+
export type ExplainVerdict = "pass" | "warn" | "fail";
|
|
222
|
+
|
|
223
|
+
/** The whole-input execute gate, reproduced. */
|
|
224
|
+
export interface ExplainCanonical {
|
|
225
|
+
mode: CanonicalExecuteCommand["mode"];
|
|
226
|
+
path: string;
|
|
227
|
+
verb: string;
|
|
228
|
+
/** `CanonicalExecuteCommand.attributes` under the ratified envelope name. */
|
|
229
|
+
args: Record<string, string>;
|
|
230
|
+
queries: string[];
|
|
231
|
+
/** `isWriteShaped(canonical)` — the write-confirmation predicate, verbatim. */
|
|
232
|
+
writeShaped: boolean;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/** What the canonicalizer decided about one statement. */
|
|
236
|
+
export type ExplainResolution = "resolved" | "ambiguous" | "unknown";
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* A resolved menu. Navigation names a menu and nothing else, so there is no
|
|
240
|
+
* verb — not an absent one.
|
|
241
|
+
*/
|
|
242
|
+
export interface ExplainMenuReading {
|
|
243
|
+
resolution: "resolved";
|
|
244
|
+
kind: "menu";
|
|
245
|
+
command: { path: string };
|
|
246
|
+
/** Navigation names a menu and nothing else — there is no argument list. */
|
|
247
|
+
arguments?: undefined;
|
|
248
|
+
/** A menu is navigation, not a transportable command. */
|
|
249
|
+
transport?: undefined;
|
|
250
|
+
unresolved?: undefined;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* One argument token, located in DOCUMENT analyzed-byte space.
|
|
255
|
+
*
|
|
256
|
+
* The ordered list is the primary shape and {@link ExplainCommandReading.command}
|
|
257
|
+
* `.args` is the object VIEW derived from it — the phase-0 normal form, which
|
|
258
|
+
* requires order and multiplicity to survive somewhere a consumer can read them.
|
|
259
|
+
*/
|
|
260
|
+
export interface ExplainArgumentToken {
|
|
261
|
+
kind: ArgumentKind;
|
|
262
|
+
span: ExplainSpanRange;
|
|
263
|
+
/** Attribute name / query word; absent on a positional. */
|
|
264
|
+
name?: string;
|
|
265
|
+
/**
|
|
266
|
+
* The token's LITERAL value, quotes removed — for an attribute the part after
|
|
267
|
+
* `=`, for a positional the whole token.
|
|
268
|
+
*
|
|
269
|
+
* **Absent means there is no literal value**, whatever the kind: the source
|
|
270
|
+
* spells a substitution, an expression, or an escape this phase does not
|
|
271
|
+
* decode. A consumer rendering a runnable command reads this, never `text`,
|
|
272
|
+
* and treats absence as "not renderable". A query is always absent.
|
|
273
|
+
*/
|
|
274
|
+
value?: string;
|
|
275
|
+
/** Where the value sits, quotes INCLUDED. Absent whenever `value` is. */
|
|
276
|
+
valueSpan?: ExplainSpanRange;
|
|
277
|
+
/** The token verbatim. */
|
|
278
|
+
text: string;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* What the statement-scope lexer decided about one command's arguments.
|
|
283
|
+
*
|
|
284
|
+
* All-or-nothing by construction (`src/explain/args.ts`): the consumer is a REST
|
|
285
|
+
* rendering, and a partially-read argument list changes what the rendered
|
|
286
|
+
* command DOES. `read: false` carries the reason, which the transport basis then
|
|
287
|
+
* quotes rather than reporting a bare `unknown`.
|
|
288
|
+
*/
|
|
289
|
+
export type ExplainArguments =
|
|
290
|
+
| {
|
|
291
|
+
read: true;
|
|
292
|
+
tokens: ExplainArgumentToken[];
|
|
293
|
+
/** `?`-prefixed query words, in order. */
|
|
294
|
+
queries: string[];
|
|
295
|
+
/** Bare tokens, in order — an id (`*1`), a `where`, a `find` operand. */
|
|
296
|
+
positional: string[];
|
|
297
|
+
}
|
|
298
|
+
| { read: false; why: string };
|
|
299
|
+
|
|
300
|
+
/** A resolved command: a path AND the verb that was decided on it. */
|
|
301
|
+
export interface ExplainCommandReading {
|
|
302
|
+
resolution: "resolved";
|
|
303
|
+
kind: "command";
|
|
304
|
+
/**
|
|
305
|
+
* `args` is present exactly when {@link arguments} read: it is that reading's
|
|
306
|
+
* object view (last occurrence wins), in the ratified sketch's place
|
|
307
|
+
* (`commands/explain/README.md` → *Result shape*). Absent is not `{}` — a
|
|
308
|
+
* command with no arguments reads as `{}`.
|
|
309
|
+
*/
|
|
310
|
+
command: { path: string; verb: string; args?: Record<string, string> };
|
|
311
|
+
/**
|
|
312
|
+
* ABSENT rather than refused where no attempt was made — the same convention
|
|
313
|
+
* transport follows. Statements always carry it; a `[…]` subcommand does not,
|
|
314
|
+
* because its inner text has its own coordinate space that phase 1 does not
|
|
315
|
+
* rebase (see {@link ExplainSubcommand}).
|
|
316
|
+
*/
|
|
317
|
+
arguments?: ExplainArguments;
|
|
318
|
+
/**
|
|
319
|
+
* Present on document statements after the #202c-2 transport pass. Absent on
|
|
320
|
+
* subcommands, whose inner argument coordinates phase 1 does not rebase.
|
|
321
|
+
*/
|
|
322
|
+
transport?: ExplainTransport;
|
|
323
|
+
unresolved?: undefined;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** A refusal. It carries its reason and nothing that would read as a reading. */
|
|
327
|
+
export interface ExplainRefusal {
|
|
328
|
+
resolution: "ambiguous" | "unknown";
|
|
329
|
+
kind?: undefined;
|
|
330
|
+
command?: undefined;
|
|
331
|
+
/** No command was read, so nothing here is an argument OF one. */
|
|
332
|
+
arguments?: undefined;
|
|
333
|
+
/** No command was read, so no transport can be classified. */
|
|
334
|
+
transport?: undefined;
|
|
335
|
+
/** Why the analyzer refused. */
|
|
336
|
+
unresolved: string;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* What the analyzer read, as a discriminated union.
|
|
341
|
+
*
|
|
342
|
+
* `verbsplit` reports menu navigation as its own `navigation` resolution so
|
|
343
|
+
* that `resolved` keeps meaning "a verb was decided" for its callers; the
|
|
344
|
+
* envelope folds the two into `resolved` + `kind`, which is the ratified
|
|
345
|
+
* vocabulary. The union rather than four independent optionals, because these
|
|
346
|
+
* fields co-vary and the intended consumers (LSP, MCP) narrow on them:
|
|
347
|
+
* `resolution === "resolved"` must yield a `command`, and `kind === "command"`
|
|
348
|
+
* must yield a `verb`, at compile time. Independently optional fields also
|
|
349
|
+
* describe three shapes that cannot occur — a resolved statement with no
|
|
350
|
+
* command, a menu with a verb, a refusal with a command.
|
|
351
|
+
*
|
|
352
|
+
* Every variant declares the other variants' discriminants as `?: undefined`,
|
|
353
|
+
* so `s.kind` and `s.command` stay readable without narrowing first.
|
|
354
|
+
*/
|
|
355
|
+
export type ExplainReading =
|
|
356
|
+
| ExplainMenuReading
|
|
357
|
+
| ExplainCommandReading
|
|
358
|
+
| ExplainRefusal;
|
|
359
|
+
|
|
360
|
+
/** Where a statement is, and how much the analyzer knew when it read it. */
|
|
361
|
+
export interface ExplainStatementFacts {
|
|
362
|
+
span: ExplainSpanRange;
|
|
363
|
+
/**
|
|
364
|
+
* Was the menu context in force BEFORE this statement known? A `false` here
|
|
365
|
+
* does not invalidate a `resolved` reading — the resolver already degrades
|
|
366
|
+
* every context-DEPENDENT statement when context is lost, so a statement that
|
|
367
|
+
* still resolved did not consume it (#192, #197).
|
|
368
|
+
*/
|
|
369
|
+
contextCertain: boolean;
|
|
370
|
+
ev: string;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export type ExplainStatement = ExplainStatementFacts & ExplainReading;
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* A `[…]` command substitution, re-constituted against its enclosing menu.
|
|
377
|
+
*
|
|
378
|
+
* Same vocabulary as {@link ExplainStatement}, deliberately: an inner command is
|
|
379
|
+
* a command, and a caller should not have to learn a second shape to read one.
|
|
380
|
+
*
|
|
381
|
+
* That means the verb boundary here is Q6's answer (`resolveVerb` over the
|
|
382
|
+
* bracket's inner text), not Q3's `Resolution.path`. The two are not always the
|
|
383
|
+
* same — Q3's `path` is documented as the greedy leading run, its "best guess",
|
|
384
|
+
* and it reads `[/system/identity/get name]` as the menu
|
|
385
|
+
* `/system/identity/get` where Q6 reads menu `/system/identity` + verb `get`.
|
|
386
|
+
* Q6 is the module that decides verbs (and #211's R9 exists precisely so these
|
|
387
|
+
* two stop contradicting each other), so it is what the envelope presents; Q3's
|
|
388
|
+
* alternative readings survive as {@link candidates}. Where Q3 REFUSED, this
|
|
389
|
+
* refuses too, whatever Q6 says — the fail-closed floor is not up for a second
|
|
390
|
+
* opinion.
|
|
391
|
+
*/
|
|
392
|
+
export interface ExplainSubcommandFacts extends ExplainStatementFacts {
|
|
393
|
+
/** Menu context in force at the bracket, `/` at document root. */
|
|
394
|
+
context: string;
|
|
395
|
+
/** Every path the inner command could resolve to, shortest first. */
|
|
396
|
+
candidates: string[];
|
|
397
|
+
/** Bracket nesting depth; 0 is directly inside a statement. */
|
|
398
|
+
depth: number;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
export type ExplainSubcommand = ExplainSubcommandFacts & ExplainReading;
|
|
402
|
+
|
|
403
|
+
/** A `{…}` scope at document level. */
|
|
404
|
+
export interface ExplainBlock {
|
|
405
|
+
/** The argument or directive that opened it (`do`, `on-error`, …). */
|
|
406
|
+
name: string;
|
|
407
|
+
/** The BODY's span, braces excluded. */
|
|
408
|
+
span: ExplainSpanRange;
|
|
409
|
+
ev: string;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
export interface ExplainStructure {
|
|
413
|
+
/**
|
|
414
|
+
* `statements.length`, which counts BLOCK-BODY statements too — see
|
|
415
|
+
* {@link ExplainStructure.statements}. Not the number of top-level statements.
|
|
416
|
+
*/
|
|
417
|
+
statementCount: number;
|
|
418
|
+
/**
|
|
419
|
+
* Every statement the analyzer read, in discovery order.
|
|
420
|
+
*
|
|
421
|
+
* A `do={…}` body's statements are flattened in after their parent (which is
|
|
422
|
+
* what RouterOS IL does to them), so a body statement's span is CONTAINED by
|
|
423
|
+
* its parent's rather than following it, and spans may overlap. A consumer
|
|
424
|
+
* that wants only the top level filters on containment; one that wants "every
|
|
425
|
+
* command in this script" — the common case, and the reason to flatten — reads
|
|
426
|
+
* the list as it stands.
|
|
427
|
+
*/
|
|
428
|
+
statements: ExplainStatement[];
|
|
429
|
+
blocks: ExplainBlock[];
|
|
430
|
+
/**
|
|
431
|
+
* The Q16 tristate. `"unknown"` is a real value, not a missing one: a
|
|
432
|
+
* write-shaped command outside the small version-stable write table
|
|
433
|
+
* (`/disk format-drive`) must never report `false`.
|
|
434
|
+
*/
|
|
435
|
+
containsWrite: boolean | "unknown";
|
|
436
|
+
subcommands: ExplainSubcommand[];
|
|
437
|
+
ev: string;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/**
|
|
441
|
+
* A classified byte run.
|
|
442
|
+
*
|
|
443
|
+
* The vocabulary is centrs-owned (the ratified decision): consumers depend on
|
|
444
|
+
* it rather than on raw RouterOS `highlight` classes, so cross-version drift in
|
|
445
|
+
* those classes is centrs's problem to absorb. For these five the two names
|
|
446
|
+
* currently coincide, which is a convenience, not the contract.
|
|
447
|
+
*/
|
|
448
|
+
export type ExplainSpanClass =
|
|
449
|
+
| "comment"
|
|
450
|
+
| "variable-local"
|
|
451
|
+
| "variable-global"
|
|
452
|
+
| "variable-auto"
|
|
453
|
+
| "variable-parameter";
|
|
454
|
+
|
|
455
|
+
export interface ExplainSpan extends ExplainSpanRange {
|
|
456
|
+
class: ExplainSpanClass;
|
|
457
|
+
ev: string;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
/**
|
|
461
|
+
* One analyzer's contribution to the token partition.
|
|
462
|
+
*
|
|
463
|
+
* A fill is already-sorted, non-overlapping spans in document (analyzed-byte)
|
|
464
|
+
* space. `buildTokens` takes fills **in order** — fill 0 claims first, fill 1
|
|
465
|
+
* sees only the residual, and so on — so the argument order IS the fill order
|
|
466
|
+
* (#290 design decision 1) and a structural ambiguity (`/` path sep vs `division`)
|
|
467
|
+
* resolves by which analyzer came first rather than by a smarter byte scanner.
|
|
468
|
+
*
|
|
469
|
+
* A fill is typed as `ExplainToken[]` because B2 fills (path, argument, value,
|
|
470
|
+
* then operator) introduce classes outside `ExplainSpanClass`; the proof-only
|
|
471
|
+
* `spans[]` (ExplainSpan[]) remains a subtype and so fits the same slot.
|
|
472
|
+
*/
|
|
473
|
+
export type TokenFill = readonly ExplainToken[];
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* A token in the total, gapless byte partition (B1).
|
|
477
|
+
*
|
|
478
|
+
* Every byte of the analyzed input belongs to exactly one token — a
|
|
479
|
+
* contiguous, non-overlapping partition of `[0, input.bytes)` — sorted by
|
|
480
|
+
* `start`, with no gaps and `tokens.map(t => slice).join("") === input`.
|
|
481
|
+
* Byte-exact, inheriting the #215/#252 offset discipline. Offsets are on the
|
|
482
|
+
* analyzed text; `input.positionMap` applies when normalized.
|
|
483
|
+
*
|
|
484
|
+
* `class` is the vocabulary #264 B5 settled (see below); it is not an LSP/SCIP
|
|
485
|
+
* legend and does not try to be one. Every byte no analyzer claims becomes
|
|
486
|
+
* `unclassified`, which is a first-class answer, not a placeholder to be
|
|
487
|
+
* avoided.
|
|
488
|
+
*
|
|
489
|
+
* B2 so far: `dir` / `cmd` (resolved path and verb bytes, including valid path
|
|
490
|
+
* slashes), `operator` (26 spellings + 2 aliases, `syntax-meta` is a residual
|
|
491
|
+
* merge, never a source, #255), `arg` / `arg-sep` (argument names and the `=`
|
|
492
|
+
* that binds each one to its value, both located by `args.ts`), and `value`
|
|
493
|
+
* (argument value bytes and leaf array-literal members from
|
|
494
|
+
* `data.values.occurrences`, quotes included, #295).
|
|
495
|
+
*
|
|
496
|
+
* **B5 settled which merges get their own name** (`commands/explain/README.md`
|
|
497
|
+
* -> *The vocabulary, and what earns a class*, where the figures live beside
|
|
498
|
+
* the generated blocks that keep them honest): a class is split only where a
|
|
499
|
+
* consumer holding the whole envelope could not recover the distinction by
|
|
500
|
+
* joining `tokens[]` to another published surface on byte offsets. `arg-sep` is
|
|
501
|
+
* the one split that test forced, because a large minority of the `=` runs the
|
|
502
|
+
* arg fill paints belong to a statement whose strict `arguments` reading
|
|
503
|
+
* refused, so there is no `ExplainArgumentToken.valueSpan` to subtract one
|
|
504
|
+
* from. The per-spelling `operator`, per-shape `value`, and scope-versus-array
|
|
505
|
+
* `brace` merges all stay merged — the bytes, `data.values.occurrences[]`, and
|
|
506
|
+
* `structure.blocks[]` already carry those distinctions.
|
|
507
|
+
*/
|
|
508
|
+
export type ExplainTokenClass =
|
|
509
|
+
| ExplainSpanClass
|
|
510
|
+
| "dir"
|
|
511
|
+
| "cmd"
|
|
512
|
+
| "operator"
|
|
513
|
+
| "arg"
|
|
514
|
+
/**
|
|
515
|
+
* The single `=` byte binding one argument name to its value (#264 B5).
|
|
516
|
+
*
|
|
517
|
+
* Never a comparison `=`, and the separation is fill order rather than a
|
|
518
|
+
* byte scan: the `arg` fill offers only the `=` its own located token names,
|
|
519
|
+
* so a comparison inside a `( )` group falls through to `operator`, and one
|
|
520
|
+
* inside a `[ … ]` substitution stays `unclassified` where the operator fill
|
|
521
|
+
* abstains (`src/explain/operator-tokens.ts`).
|
|
522
|
+
*/
|
|
523
|
+
| "arg-sep"
|
|
524
|
+
| "value"
|
|
525
|
+
| "string"
|
|
526
|
+
/**
|
|
527
|
+
* One valid string-internal escape sequence — the `\` and the bytes it
|
|
528
|
+
* escapes (#264).
|
|
529
|
+
*
|
|
530
|
+
* The one split B5's rule admits that naming the `=` did not make. A valid
|
|
531
|
+
* escape is recoverable from no other published surface: only INVALID
|
|
532
|
+
* escapes appear elsewhere, as `bad-string-escape` diagnostics. Both come
|
|
533
|
+
* from the same walk (`walkStringEscapes`), so a token and a diagnostic
|
|
534
|
+
* cannot disagree about which bytes are an escape, and past the first
|
|
535
|
+
* invalid escape the walk stops claiming rather than guess.
|
|
536
|
+
*/
|
|
537
|
+
| "escaped"
|
|
538
|
+
| "brace"
|
|
539
|
+
| "unclassified";
|
|
540
|
+
|
|
541
|
+
export interface ExplainToken extends ExplainSpanRange {
|
|
542
|
+
/**
|
|
543
|
+
* The vocabulary #264 B5 settled — see {@link ExplainTokenClass} for what
|
|
544
|
+
* earns a class and why the remaining merges are deliberate.
|
|
545
|
+
* `unclassified` means no analyzer claimed this byte.
|
|
546
|
+
*/
|
|
547
|
+
class: ExplainTokenClass;
|
|
548
|
+
ev: string;
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/** The Q13 class, or `null` where offline analysis must abstain. */
|
|
552
|
+
export type ExplainSymbolClass = Exclude<SymbolClass, "undefined"> | null;
|
|
553
|
+
|
|
554
|
+
export interface ExplainSymbolOccurrence {
|
|
555
|
+
name: string;
|
|
556
|
+
span: ExplainSpanRange;
|
|
557
|
+
class: ExplainSymbolClass;
|
|
558
|
+
role: SymbolRole;
|
|
559
|
+
/** Stable only within this result; empty when no visible binding was decided. */
|
|
560
|
+
bindingIds: string[];
|
|
561
|
+
/** Whether the source used a `$`-sigilled reference spelling. */
|
|
562
|
+
sigil: boolean;
|
|
563
|
+
/** The resolver's reason for an abstention or special reading. */
|
|
564
|
+
note?: string;
|
|
565
|
+
ev: string;
|
|
566
|
+
/**
|
|
567
|
+
* For a declaration/binding/assignment with a literal RHS in the same
|
|
568
|
+
* statement, the `ExplainValueOccurrence.id` that spells it. Absent when
|
|
569
|
+
* the RHS is a variable, substitution, expression, or absent.
|
|
570
|
+
*
|
|
571
|
+
* No RHS evaluation (#239 S2): only a directly literal positional value
|
|
572
|
+
* in the same statement is linked.
|
|
573
|
+
*/
|
|
574
|
+
valueId?: string;
|
|
575
|
+
/**
|
|
576
|
+
* For a reference, the set of literal `valueId`s that may reach this
|
|
577
|
+
* program point (flow-sensitive, conservative). Linear code is
|
|
578
|
+
* last-write-wins (single element); a branch merge is a set; a loop merge
|
|
579
|
+
* or a non-literal assignment is `unknown`. Branch/loop bodies are scope
|
|
580
|
+
* blocks; an optional branch unions the pre-block and block-final values,
|
|
581
|
+
* while an exhaustive `if`/`else` unions the two arm-final values.
|
|
582
|
+
* `reachingUnknown` true means the set may be incomplete; callers should
|
|
583
|
+
* treat the shape as `unknown` rather than the listed ids.
|
|
584
|
+
*/
|
|
585
|
+
reachingValueIds?: string[];
|
|
586
|
+
/** True when the reaching set is incomplete or otherwise unknown. */
|
|
587
|
+
reachingUnknown?: boolean;
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
/** Semantic symbol facts; `spans` remains the token/LSP-oriented projection. */
|
|
591
|
+
export interface ExplainSymbols {
|
|
592
|
+
occurrences: ExplainSymbolOccurrence[];
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export interface ExplainValueShapeFact {
|
|
596
|
+
/**
|
|
597
|
+
* A list because the axis admits overlap; the 7.23.3/7.24rc3-grounded V1
|
|
598
|
+
* lexicon happens to assign at most one shape per spelling, and abstains
|
|
599
|
+
* where it has no grounded member (#243) rather than widening a near miss.
|
|
600
|
+
*/
|
|
601
|
+
values: ValueShape[];
|
|
602
|
+
ev: string;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
export interface ExplainValueTypeFact {
|
|
606
|
+
value: string;
|
|
607
|
+
ev: string;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/** The three #225 axes; live producers add the latter two without reshaping. */
|
|
611
|
+
export interface ExplainValueFacts {
|
|
612
|
+
shapeHints?: ExplainValueShapeFact;
|
|
613
|
+
observedType?: ExplainValueTypeFact;
|
|
614
|
+
schemaType?: ExplainValueTypeFact;
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
export interface ExplainValueOccurrence {
|
|
618
|
+
/** Result-local identity for #239's later flow-sensitive references. */
|
|
619
|
+
id: string;
|
|
620
|
+
/** Literal source bytes, quotes included. */
|
|
621
|
+
span: ExplainSpanRange;
|
|
622
|
+
tokenSpan: ExplainSpanRange;
|
|
623
|
+
/** `element` is one member of an array literal, located inside its container. */
|
|
624
|
+
kind: ValueAnchorKind;
|
|
625
|
+
/** An attribute's name, or an array member's key. */
|
|
626
|
+
name?: string;
|
|
627
|
+
quoted: boolean;
|
|
628
|
+
/**
|
|
629
|
+
* The `id` of the array literal this value is a member of.
|
|
630
|
+
*
|
|
631
|
+
* Present only on `element` rows. Containment is a reference rather than
|
|
632
|
+
* nesting so the list stays flat and ordered, and so #239 can point a symbol
|
|
633
|
+
* at one member without re-walking a tree.
|
|
634
|
+
*/
|
|
635
|
+
parent?: string;
|
|
636
|
+
facts: ExplainValueFacts;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
export interface ExplainValues {
|
|
640
|
+
occurrences: ExplainValueOccurrence[];
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
export type ExplainSeverity = "error" | "warning" | "info";
|
|
644
|
+
|
|
645
|
+
export interface ExplainDiagnostic {
|
|
646
|
+
/** Slash-namespaced `explain/<source>/<slug>`. */
|
|
647
|
+
code: string;
|
|
648
|
+
severity: ExplainSeverity;
|
|
649
|
+
message: string;
|
|
650
|
+
span: ExplainSpanRange;
|
|
651
|
+
ev: string;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/**
|
|
655
|
+
* Where a derived fact came from — the OFFLINE SUBSET of the contract.
|
|
656
|
+
*
|
|
657
|
+
* The spec's evidence entry also carries `source: "live-inspect"` and a
|
|
658
|
+
* RouterOS version stamp per probe, neither of which phase 1 can produce: no
|
|
659
|
+
* probe runs and there is no device to stamp. The type says only what offline
|
|
660
|
+
* emits rather than pre-declaring variants with no producer, so a consumer
|
|
661
|
+
* cannot narrow on a `source` that never appears; phase 2 widens the union and
|
|
662
|
+
* adds the stamp, which is an ADDITION to the shape a caller reads today.
|
|
663
|
+
*/
|
|
664
|
+
export interface ExplainEvidence {
|
|
665
|
+
id: string;
|
|
666
|
+
source: "canonicalizer";
|
|
667
|
+
/** The analyzer that produced the facts citing this entry. */
|
|
668
|
+
probe: string;
|
|
669
|
+
/**
|
|
670
|
+
* `direct` — a deterministic reading of the bytes (the gate, the coordinate
|
|
671
|
+
* pass). `heuristic` — a ratified offline rule that a live probe could
|
|
672
|
+
* overturn (path resolution, the verb boundary, the write tristate, symbol
|
|
673
|
+
* classes). No offline fact is `derived`, which is reserved for a conclusion
|
|
674
|
+
* drawn from live responses.
|
|
675
|
+
*/
|
|
676
|
+
basis: "direct" | "heuristic" | "derived";
|
|
677
|
+
outcome: "ok" | "empty" | "timeout" | "transport-error";
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
export interface ExplainData {
|
|
681
|
+
input: ExplainInput;
|
|
682
|
+
verdict: ExplainVerdict;
|
|
683
|
+
canonical: ExplainCanonical;
|
|
684
|
+
structure: ExplainStructure;
|
|
685
|
+
symbols: ExplainSymbols;
|
|
686
|
+
values: ExplainValues;
|
|
687
|
+
spans: ExplainSpan[];
|
|
688
|
+
/**
|
|
689
|
+
* Total, gapless token partition of `[0, input.bytes)`.
|
|
690
|
+
*
|
|
691
|
+
* Present only when `--tokens` is requested, matching the `--complete` /
|
|
692
|
+
* `--schema` / `--curl` facet pattern. Every byte belongs to exactly one
|
|
693
|
+
* token, sorted by `start`, with no gaps and no overlaps:
|
|
694
|
+
* `tokens.map(t => input.slice(t.start, t.end)).join("") === input`.
|
|
695
|
+
* `class` is the vocabulary #264 B5 settled ({@link ExplainTokenClass});
|
|
696
|
+
* every byte no analyzer claims is `unclassified`.
|
|
697
|
+
*/
|
|
698
|
+
tokens?: ExplainToken[];
|
|
699
|
+
diagnostics: ExplainDiagnostic[];
|
|
700
|
+
evidence: ExplainEvidence[];
|
|
701
|
+
/**
|
|
702
|
+
* Always `"not-proven"`. `/console/inspect` accepts forms the runtime
|
|
703
|
+
* rejects, so no amount of offline or probe evidence proves acceptance.
|
|
704
|
+
*/
|
|
705
|
+
runtimeAcceptance: "not-proven";
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
export interface ExplainOperationMeta {
|
|
709
|
+
command: "explain";
|
|
710
|
+
/** `offline` is the only mode phase 1 has; live evidence is phase 2. */
|
|
711
|
+
mode: "offline";
|
|
712
|
+
statementCount: number;
|
|
713
|
+
verdict: ExplainVerdict;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
export type ExplainEnvelope = CentrsSuccessEnvelope<
|
|
717
|
+
ExplainData,
|
|
718
|
+
ExplainOperationMeta
|
|
719
|
+
>;
|
|
720
|
+
|
|
721
|
+
/**
|
|
722
|
+
* Stable evidence ids, one per analysis PASS.
|
|
723
|
+
*
|
|
724
|
+
* A pass is the finest distinction that is REAL offline: every fact a pass
|
|
725
|
+
* produces has the same provenance, and a per-fact id would imply a granularity
|
|
726
|
+
* the analyzers do not have. But it has to be the pass that actually produced
|
|
727
|
+
* the fact — `statements` and `subcommands` are separate entries because they
|
|
728
|
+
* come from different walks, and a defect is tagged where it is RAISED (below),
|
|
729
|
+
* not with whichever analyzer happens to be merged first.
|
|
730
|
+
*/
|
|
731
|
+
const EV = {
|
|
732
|
+
canonical: "e0",
|
|
733
|
+
coordinates: "e1",
|
|
734
|
+
segment: "e2",
|
|
735
|
+
blocks: "e3",
|
|
736
|
+
statements: "e4",
|
|
737
|
+
subcommands: "e5",
|
|
738
|
+
write: "e6",
|
|
739
|
+
symbols: "e7",
|
|
740
|
+
transport: "e8",
|
|
741
|
+
values: "e9",
|
|
742
|
+
operators: "e10",
|
|
743
|
+
args: "e11",
|
|
744
|
+
paths: "e12",
|
|
745
|
+
strings: "e13",
|
|
746
|
+
braces: "e14",
|
|
747
|
+
separators: "e15",
|
|
748
|
+
escapes: "e16",
|
|
749
|
+
} as const;
|
|
750
|
+
|
|
751
|
+
type EvidenceKey = keyof typeof EV;
|
|
752
|
+
|
|
753
|
+
const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
|
|
754
|
+
canonical: {
|
|
755
|
+
id: EV.canonical,
|
|
756
|
+
source: "canonicalizer",
|
|
757
|
+
probe: "canonicalizeExecuteCommand",
|
|
758
|
+
basis: "direct",
|
|
759
|
+
outcome: "ok",
|
|
760
|
+
},
|
|
761
|
+
coordinates: {
|
|
762
|
+
id: EV.coordinates,
|
|
763
|
+
source: "canonicalizer",
|
|
764
|
+
probe: "analyzeCoordinates",
|
|
765
|
+
basis: "direct",
|
|
766
|
+
outcome: "ok",
|
|
767
|
+
},
|
|
768
|
+
segment: {
|
|
769
|
+
id: EV.segment,
|
|
770
|
+
source: "canonicalizer",
|
|
771
|
+
probe: "segmentStatements",
|
|
772
|
+
basis: "direct",
|
|
773
|
+
outcome: "ok",
|
|
774
|
+
},
|
|
775
|
+
blocks: {
|
|
776
|
+
id: EV.blocks,
|
|
777
|
+
source: "canonicalizer",
|
|
778
|
+
probe: "scopeBlocks",
|
|
779
|
+
basis: "direct",
|
|
780
|
+
outcome: "ok",
|
|
781
|
+
},
|
|
782
|
+
statements: {
|
|
783
|
+
id: EV.statements,
|
|
784
|
+
source: "canonicalizer",
|
|
785
|
+
probe: "resolveVerbs",
|
|
786
|
+
basis: "heuristic",
|
|
787
|
+
outcome: "ok",
|
|
788
|
+
},
|
|
789
|
+
subcommands: {
|
|
790
|
+
id: EV.subcommands,
|
|
791
|
+
source: "canonicalizer",
|
|
792
|
+
probe: "resolveDocument + resolveVerb",
|
|
793
|
+
basis: "heuristic",
|
|
794
|
+
outcome: "ok",
|
|
795
|
+
},
|
|
796
|
+
write: {
|
|
797
|
+
id: EV.write,
|
|
798
|
+
source: "canonicalizer",
|
|
799
|
+
probe: "containsWrite",
|
|
800
|
+
basis: "heuristic",
|
|
801
|
+
outcome: "ok",
|
|
802
|
+
},
|
|
803
|
+
symbols: {
|
|
804
|
+
id: EV.symbols,
|
|
805
|
+
source: "canonicalizer",
|
|
806
|
+
probe: "resolveSymbols",
|
|
807
|
+
basis: "heuristic",
|
|
808
|
+
outcome: "ok",
|
|
809
|
+
},
|
|
810
|
+
transport: {
|
|
811
|
+
id: EV.transport,
|
|
812
|
+
source: "canonicalizer",
|
|
813
|
+
probe: "classifyExplainTransport",
|
|
814
|
+
basis: "heuristic",
|
|
815
|
+
outcome: "ok",
|
|
816
|
+
},
|
|
817
|
+
values: {
|
|
818
|
+
id: EV.values,
|
|
819
|
+
source: "canonicalizer",
|
|
820
|
+
probe: "valueShapeHints",
|
|
821
|
+
basis: "heuristic",
|
|
822
|
+
outcome: "ok",
|
|
823
|
+
},
|
|
824
|
+
operators: {
|
|
825
|
+
id: EV.operators,
|
|
826
|
+
source: "canonicalizer",
|
|
827
|
+
probe: "operatorSpans",
|
|
828
|
+
basis: "heuristic",
|
|
829
|
+
outcome: "ok",
|
|
830
|
+
},
|
|
831
|
+
/**
|
|
832
|
+
* `heuristic`, not `direct`, even though the span arithmetic is exact.
|
|
833
|
+
*
|
|
834
|
+
* `basis` describes the FACT, not the arithmetic. The fact here is "these
|
|
835
|
+
* bytes are an argument name and its `=`", and it rests on `argsAt` — the
|
|
836
|
+
* verb boundary, which `EV.statements` already records as `heuristic` and
|
|
837
|
+
* which this type's own doc names as the example of a rule a live probe
|
|
838
|
+
* could overturn. A misread boundary shifts every token this fill emits.
|
|
839
|
+
* `operatorSpans` sits at `heuristic` on identical grounds: deterministic
|
|
840
|
+
* byte arithmetic over a grounded table, downstream of an offline rule.
|
|
841
|
+
*/
|
|
842
|
+
args: {
|
|
843
|
+
id: EV.args,
|
|
844
|
+
source: "canonicalizer",
|
|
845
|
+
probe: "argSpans",
|
|
846
|
+
basis: "heuristic",
|
|
847
|
+
outcome: "ok",
|
|
848
|
+
},
|
|
849
|
+
paths: {
|
|
850
|
+
id: EV.paths,
|
|
851
|
+
source: "canonicalizer",
|
|
852
|
+
probe: "pathSpans",
|
|
853
|
+
basis: "heuristic",
|
|
854
|
+
outcome: "ok",
|
|
855
|
+
},
|
|
856
|
+
strings: {
|
|
857
|
+
id: EV.strings,
|
|
858
|
+
source: "canonicalizer",
|
|
859
|
+
probe: "stringSpans",
|
|
860
|
+
basis: "heuristic",
|
|
861
|
+
outcome: "ok",
|
|
862
|
+
},
|
|
863
|
+
/**
|
|
864
|
+
* `heuristic`, and #252 is the reason rather than caution: the accepted
|
|
865
|
+
* escape set is a ratified offline rule that a live probe CAN overturn, and
|
|
866
|
+
* once did. The manual's "Constant Escape Sequences" table turned out to be
|
|
867
|
+
* a lower bound — the CHR byte sweep found `\?` missing from it — so the
|
|
868
|
+
* set pinned in `src/explain/quoted-string.ts` is the best current reading
|
|
869
|
+
* of a device's answer, not a deterministic reading of the input bytes.
|
|
870
|
+
* A future RouterOS could accept one more.
|
|
871
|
+
*
|
|
872
|
+
* Cited by the `escaped` tokens AND by `bad-string-escape`: one walk, one
|
|
873
|
+
* evidence entry. That defect used to cite `resolveSymbols`, which never
|
|
874
|
+
* produced it — `symbols.ts` raises the different `bad-escape` code.
|
|
875
|
+
*/
|
|
876
|
+
escapes: {
|
|
877
|
+
id: EV.escapes,
|
|
878
|
+
source: "canonicalizer",
|
|
879
|
+
probe: "walkStringEscapes",
|
|
880
|
+
basis: "heuristic",
|
|
881
|
+
outcome: "ok",
|
|
882
|
+
},
|
|
883
|
+
braces: {
|
|
884
|
+
id: EV.braces,
|
|
885
|
+
source: "canonicalizer",
|
|
886
|
+
probe: "braceSpans",
|
|
887
|
+
basis: "heuristic",
|
|
888
|
+
outcome: "ok",
|
|
889
|
+
},
|
|
890
|
+
/**
|
|
891
|
+
* `heuristic`: the run is decided from the frozen verb vocabulary and the
|
|
892
|
+
* shipped structure table, both of which a live probe could overturn on a
|
|
893
|
+
* menu the table does not list. The rejection it reports is device-grounded
|
|
894
|
+
* (`src/explain/separator.ts`); which byte runs are that shape is not.
|
|
895
|
+
*/
|
|
896
|
+
separators: {
|
|
897
|
+
id: EV.separators,
|
|
898
|
+
source: "canonicalizer",
|
|
899
|
+
probe: "findMissingSeparators",
|
|
900
|
+
basis: "heuristic",
|
|
901
|
+
outcome: "ok",
|
|
902
|
+
},
|
|
903
|
+
};
|
|
904
|
+
|
|
905
|
+
/** Diagnostic rendering for each defect class. */
|
|
906
|
+
const DEFECT_DIAGNOSTICS: Record<
|
|
907
|
+
DefectCode,
|
|
908
|
+
{ severity: ExplainSeverity; message: (d: Defect) => string }
|
|
909
|
+
> = {
|
|
910
|
+
unclosed: {
|
|
911
|
+
severity: "error",
|
|
912
|
+
message: (d) => `unclosed \`${d.detail ?? "delimiter"}\` — never closed`,
|
|
913
|
+
},
|
|
914
|
+
"unbalanced-close": {
|
|
915
|
+
severity: "error",
|
|
916
|
+
message: (d) => `stray \`${d.detail ?? "close"}\` closes nothing`,
|
|
917
|
+
},
|
|
918
|
+
"unterminated-string": {
|
|
919
|
+
severity: "error",
|
|
920
|
+
message: () => "unterminated string — no closing quote",
|
|
921
|
+
},
|
|
922
|
+
"bad-escape": {
|
|
923
|
+
severity: "error",
|
|
924
|
+
message: () =>
|
|
925
|
+
"invalid escape: a backslash in code is valid only before whitespace",
|
|
926
|
+
},
|
|
927
|
+
"bad-string-escape": {
|
|
928
|
+
severity: "error",
|
|
929
|
+
message: () =>
|
|
930
|
+
'invalid escape in string: unknown escape, truncated hex, or lowercase hex digit — use \\n \\r \\t \\" \\\\ \\$ \\_ \\? \\a \\b \\f \\v, \\XX with uppercase hex, or \\ before whitespace to continue the line',
|
|
931
|
+
},
|
|
932
|
+
"bad-sigil": {
|
|
933
|
+
severity: "error",
|
|
934
|
+
message: () => "invalid sigil: the run must be zero or one character",
|
|
935
|
+
},
|
|
936
|
+
"invalid-hash": {
|
|
937
|
+
severity: "error",
|
|
938
|
+
message: () =>
|
|
939
|
+
"unquoted `#` is a syntax error here — quote it where a value is accepted, or move it to statement-leading position to start a comment",
|
|
940
|
+
},
|
|
941
|
+
// #355. Not a quote-balance rule: `name='abc'` and `:put 'abc'` are rejected
|
|
942
|
+
// too. `'` simply is not a RouterOS token, so the remedy is the double quote,
|
|
943
|
+
// never a matching apostrophe.
|
|
944
|
+
"invalid-apostrophe": {
|
|
945
|
+
severity: "error",
|
|
946
|
+
message: () =>
|
|
947
|
+
'`\'` is not a RouterOS token — only `"` opens a string; use `"` here, or put the apostrophe inside a `"`-string',
|
|
948
|
+
},
|
|
949
|
+
// centrs's own resource bound, not a RouterOS rule. The input may be entirely
|
|
950
|
+
// legal; what is reported is that the analyzer stopped descending, so the
|
|
951
|
+
// honest severity is a warning about incomplete analysis.
|
|
952
|
+
"over-depth": {
|
|
953
|
+
severity: "warning",
|
|
954
|
+
message: () => "nesting past the analyzer's depth bound — not descended",
|
|
955
|
+
},
|
|
956
|
+
// Positional facts. `isPositionalFact` exists so these can never be given
|
|
957
|
+
// error severity: `name="router-🚀"` is a legal command and non-ASCII occurs
|
|
958
|
+
// in ~12% of the phase-0 corpus.
|
|
959
|
+
bom: {
|
|
960
|
+
severity: "info",
|
|
961
|
+
message: () => "byte-order mark",
|
|
962
|
+
},
|
|
963
|
+
"non-ascii": {
|
|
964
|
+
severity: "info",
|
|
965
|
+
message: () => "non-ASCII bytes, normalized one-for-one for analysis",
|
|
966
|
+
},
|
|
967
|
+
};
|
|
968
|
+
|
|
969
|
+
/**
|
|
970
|
+
* Keep only the EARLIEST `invalid-apostrophe`, document-wide (#355).
|
|
971
|
+
*
|
|
972
|
+
* The rule suppresses repeats within one scan, but a nested scope body is a
|
|
973
|
+
* SEPARATE scan — `segmentScopeBody` is called per block and its defects are
|
|
974
|
+
* rebased in — so `:put 'a'; :if (true) do={ :put 'b' }` produced one from each.
|
|
975
|
+
* The device reports one error and stops, so a second occurrence is not an
|
|
976
|
+
* independent finding no matter which scope it sits in, and the contract is
|
|
977
|
+
* stated document-wide.
|
|
978
|
+
*
|
|
979
|
+
* Collapsing HERE rather than threading a flag through the walkers is the point:
|
|
980
|
+
* `segmentStatements` and `segmentScopeBody` stay pure per call, which is what
|
|
981
|
+
* lets a body be segmented on its own. The other classes are deliberately
|
|
982
|
+
* untouched — two `unclosed` delimiters ARE two findings.
|
|
983
|
+
*/
|
|
984
|
+
function collapseToFirstApostrophe(
|
|
985
|
+
defects: readonly AttributedDefect[],
|
|
986
|
+
): AttributedDefect[] {
|
|
987
|
+
let seen = false;
|
|
988
|
+
const out: AttributedDefect[] = [];
|
|
989
|
+
for (const entry of [...defects].sort(
|
|
990
|
+
(a, b) => a.defect.start - b.defect.start,
|
|
991
|
+
)) {
|
|
992
|
+
if (entry.defect.code === "invalid-apostrophe") {
|
|
993
|
+
if (seen) continue;
|
|
994
|
+
seen = true;
|
|
995
|
+
}
|
|
996
|
+
out.push(entry);
|
|
997
|
+
}
|
|
998
|
+
return out;
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/** A defect plus the analysis pass that raised it. */
|
|
1002
|
+
interface AttributedDefect {
|
|
1003
|
+
defect: Defect;
|
|
1004
|
+
ev: string;
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/**
|
|
1008
|
+
* De-duplicate the analyzers' defect lists, keeping each region's FIRST
|
|
1009
|
+
* attribution.
|
|
1010
|
+
*
|
|
1011
|
+
* `mergeDefects` answers "which regions are defective"; this also answers "who
|
|
1012
|
+
* said so", which the evidence table needs and cannot recover afterwards — once
|
|
1013
|
+
* the lists are concatenated a `bad-sigil` is indistinguishable from an
|
|
1014
|
+
* `unclosed`, and guessing by class is wrong for the three classes two analyzers
|
|
1015
|
+
* both raise.
|
|
1016
|
+
*
|
|
1017
|
+
* FIRST wins rather than "all who saw it", because a fact carries ONE `ev`. The
|
|
1018
|
+
* order is the argument order, and the coordinate pass is not in it at all: its
|
|
1019
|
+
* two classes are raised inside `segmentStatements`' result, so they are
|
|
1020
|
+
* re-tagged here by class — they are readings of the input as received, not of
|
|
1021
|
+
* any structural walk.
|
|
1022
|
+
*
|
|
1023
|
+
* Identity matches `mergeDefects`: the whole region plus `detail`, never the
|
|
1024
|
+
* code alone, so two `over-depth` events at different offsets stay two defects.
|
|
1025
|
+
*/
|
|
1026
|
+
function attributeDefects(
|
|
1027
|
+
sources: readonly (readonly [string, readonly Defect[]])[],
|
|
1028
|
+
): AttributedDefect[] {
|
|
1029
|
+
const seen = new Map<string, AttributedDefect>();
|
|
1030
|
+
for (const [ev, list] of sources)
|
|
1031
|
+
for (const defect of list) {
|
|
1032
|
+
const key = JSON.stringify([
|
|
1033
|
+
defect.code,
|
|
1034
|
+
defect.start,
|
|
1035
|
+
defect.end,
|
|
1036
|
+
defect.detail,
|
|
1037
|
+
]);
|
|
1038
|
+
if (seen.has(key)) continue;
|
|
1039
|
+
seen.set(key, {
|
|
1040
|
+
defect,
|
|
1041
|
+
ev: isPositionalFact(defect.code) ? EV.coordinates : ev,
|
|
1042
|
+
});
|
|
1043
|
+
}
|
|
1044
|
+
return [...seen.values()].sort(
|
|
1045
|
+
(a, b) =>
|
|
1046
|
+
a.defect.start - b.defect.start ||
|
|
1047
|
+
a.defect.end - b.defect.end ||
|
|
1048
|
+
a.defect.code.localeCompare(b.defect.code),
|
|
1049
|
+
);
|
|
1050
|
+
}
|
|
1051
|
+
|
|
1052
|
+
/** Build `input.positionMap`: coalesced identity runs, one entry per non-ASCII code point. */
|
|
1053
|
+
function positionMap(a: CoordinateAnalysis): ExplainPositionMapEntry[] {
|
|
1054
|
+
// An all-ASCII input coalesces to the single identity run the loop below
|
|
1055
|
+
// would build, so say it rather than walk one object per character (#322).
|
|
1056
|
+
if (a.ascii)
|
|
1057
|
+
return a.original.length === 0
|
|
1058
|
+
? []
|
|
1059
|
+
: [
|
|
1060
|
+
{
|
|
1061
|
+
analyzed: { start: 0, end: a.original.length },
|
|
1062
|
+
originalUtf16: { start: 0, end: a.original.length },
|
|
1063
|
+
},
|
|
1064
|
+
];
|
|
1065
|
+
const out: ExplainPositionMapEntry[] = [];
|
|
1066
|
+
let run: ExplainPositionMapEntry | null = null;
|
|
1067
|
+
for (const r of a.runs) {
|
|
1068
|
+
const identity = r.ascii;
|
|
1069
|
+
if (
|
|
1070
|
+
identity &&
|
|
1071
|
+
run !== null &&
|
|
1072
|
+
run.analyzed.end === r.byteStart &&
|
|
1073
|
+
run.originalUtf16.end === r.utf16Start
|
|
1074
|
+
) {
|
|
1075
|
+
run.analyzed.end = r.byteStart + r.byteLen;
|
|
1076
|
+
run.originalUtf16.end = r.utf16Start + r.utf16Len;
|
|
1077
|
+
continue;
|
|
1078
|
+
}
|
|
1079
|
+
run = {
|
|
1080
|
+
analyzed: { start: r.byteStart, end: r.byteStart + r.byteLen },
|
|
1081
|
+
originalUtf16: { start: r.utf16Start, end: r.utf16Start + r.utf16Len },
|
|
1082
|
+
};
|
|
1083
|
+
out.push(run);
|
|
1084
|
+
if (!identity) run = null;
|
|
1085
|
+
}
|
|
1086
|
+
return out;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
const SPAN_CLASS_OF_SYMBOL: Record<string, ExplainSpanClass> = {
|
|
1090
|
+
local: "variable-local",
|
|
1091
|
+
global: "variable-global",
|
|
1092
|
+
auto: "variable-auto",
|
|
1093
|
+
parameter: "variable-parameter",
|
|
1094
|
+
};
|
|
1095
|
+
|
|
1096
|
+
/**
|
|
1097
|
+
* Complement of claimed spans within `[0, len)` — the residual runs a fill
|
|
1098
|
+
* sees.
|
|
1099
|
+
*
|
|
1100
|
+
* Factored out of `buildTokens` so every fill can compute it and so tests can
|
|
1101
|
+
* assert it directly. `claimed` need not be sorted; the result is sorted and
|
|
1102
|
+
* coalesced with no gaps or overlaps relative to the claimed set.
|
|
1103
|
+
*/
|
|
1104
|
+
export function residualRanges(
|
|
1105
|
+
len: number,
|
|
1106
|
+
claimed: readonly ExplainSpanRange[],
|
|
1107
|
+
): { start: number; end: number }[] {
|
|
1108
|
+
if (len === 0) return [];
|
|
1109
|
+
const sorted = [...claimed].sort(
|
|
1110
|
+
(a, b) => a.start - b.start || a.end - b.end,
|
|
1111
|
+
);
|
|
1112
|
+
let cursor = 0;
|
|
1113
|
+
const out: { start: number; end: number }[] = [];
|
|
1114
|
+
for (const r of sorted) {
|
|
1115
|
+
if (cursor < r.start) out.push({ start: cursor, end: r.start });
|
|
1116
|
+
cursor = Math.max(cursor, r.end);
|
|
1117
|
+
}
|
|
1118
|
+
if (cursor < len) out.push({ start: cursor, end: len });
|
|
1119
|
+
return out;
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
/**
|
|
1123
|
+
* Build the total, gapless token partition (B1).
|
|
1124
|
+
*
|
|
1125
|
+
* Every byte of `[0, bytes)` belongs to exactly one token: `spans` are
|
|
1126
|
+
* placed sorted, no gaps become `unclassified`, and the result is sorted by
|
|
1127
|
+
* `start` with no overlaps and `join(slice) === input`. See
|
|
1128
|
+
* {@link ExplainTokenClass} for what `class` may be and why.
|
|
1129
|
+
*
|
|
1130
|
+
* B2: `buildTokens` now takes an **ordered list of fills**. Fill 0 claims
|
|
1131
|
+
* first, fill 1 sees only the residual of fill 0, and so on — the argument
|
|
1132
|
+
* order IS the fill order (#290 design decision 1). An overlap across fills is
|
|
1133
|
+
* a hard throw (structural impossibility is achieved by callers offering only
|
|
1134
|
+
* residual; the throw is the safety net). For backward compatibility a single
|
|
1135
|
+
* fill may be passed as a flat span array.
|
|
1136
|
+
*/
|
|
1137
|
+
export function buildTokens(
|
|
1138
|
+
analyzed: string,
|
|
1139
|
+
spansOrFills: readonly ExplainSpan[] | readonly TokenFill[],
|
|
1140
|
+
): ExplainToken[] {
|
|
1141
|
+
const len = analyzed.length;
|
|
1142
|
+
// Normalize the overload: a flat span array is one fill.
|
|
1143
|
+
let fills: readonly TokenFill[];
|
|
1144
|
+
if (spansOrFills.length === 0) {
|
|
1145
|
+
fills = [];
|
|
1146
|
+
} else {
|
|
1147
|
+
const first = spansOrFills[0] as unknown as Record<string, unknown>;
|
|
1148
|
+
const isFlatSpan =
|
|
1149
|
+
first !== null &&
|
|
1150
|
+
typeof first === "object" &&
|
|
1151
|
+
"class" in first &&
|
|
1152
|
+
"ev" in first &&
|
|
1153
|
+
"start" in first;
|
|
1154
|
+
fills = isFlatSpan
|
|
1155
|
+
? ([spansOrFills] as unknown as readonly TokenFill[])
|
|
1156
|
+
: (spansOrFills as readonly TokenFill[]);
|
|
1157
|
+
}
|
|
1158
|
+
// Validate each fill internally (non-integer, bounds, overlap within fill).
|
|
1159
|
+
for (const fill of fills) {
|
|
1160
|
+
const sorted = [...fill].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1161
|
+
let prev = 0;
|
|
1162
|
+
for (const s of sorted) {
|
|
1163
|
+
if (!Number.isInteger(s.start) || !Number.isInteger(s.end))
|
|
1164
|
+
throw new Error(
|
|
1165
|
+
`buildTokens: non-integer span [${s.start},${s.end}) for length ${len}`,
|
|
1166
|
+
);
|
|
1167
|
+
if (s.start < 0 || s.end <= s.start || s.end > len)
|
|
1168
|
+
throw new Error(
|
|
1169
|
+
`buildTokens: span out of bounds [${s.start},${s.end}) for length ${len}`,
|
|
1170
|
+
);
|
|
1171
|
+
if (s.start < prev)
|
|
1172
|
+
throw new Error(
|
|
1173
|
+
`buildTokens: overlapping spans at [${s.start},${s.end})`,
|
|
1174
|
+
);
|
|
1175
|
+
prev = Math.max(prev, s.end);
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
const flat: ExplainToken[] = fills.flatMap((fill) => [...fill]);
|
|
1179
|
+
const sorted = [...flat].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1180
|
+
// Cross-fill overlap is also a hard throw — callers achieve impossibility by
|
|
1181
|
+
// offering only residual; this is the safety net.
|
|
1182
|
+
let prev = 0;
|
|
1183
|
+
for (const s of sorted) {
|
|
1184
|
+
if (s.start < prev)
|
|
1185
|
+
throw new Error(
|
|
1186
|
+
`buildTokens: overlapping spans at [${s.start},${s.end})`,
|
|
1187
|
+
);
|
|
1188
|
+
prev = Math.max(prev, s.end);
|
|
1189
|
+
}
|
|
1190
|
+
if (len === 0) return [];
|
|
1191
|
+
const out: ExplainToken[] = [];
|
|
1192
|
+
let cursor = 0;
|
|
1193
|
+
for (const s of sorted) {
|
|
1194
|
+
if (cursor < s.start) {
|
|
1195
|
+
out.push({
|
|
1196
|
+
start: cursor,
|
|
1197
|
+
end: s.start,
|
|
1198
|
+
class: "unclassified",
|
|
1199
|
+
// The pass that produced an UNCLAIMED byte is the one that produced
|
|
1200
|
+
// the analyzed surface it sits on — `analyzeCoordinates`, not the
|
|
1201
|
+
// execute canonicalizer. A B2 fill replaces this with the pass that
|
|
1202
|
+
// claimed the byte.
|
|
1203
|
+
ev: EV.coordinates,
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
out.push({ start: s.start, end: s.end, class: s.class, ev: s.ev });
|
|
1207
|
+
cursor = s.end;
|
|
1208
|
+
}
|
|
1209
|
+
if (cursor < len) {
|
|
1210
|
+
out.push({
|
|
1211
|
+
start: cursor,
|
|
1212
|
+
end: len,
|
|
1213
|
+
class: "unclassified",
|
|
1214
|
+
ev: EV.coordinates, // see above
|
|
1215
|
+
});
|
|
1216
|
+
}
|
|
1217
|
+
return out;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
function severityRank(s: ExplainSeverity): number {
|
|
1221
|
+
return s === "error" ? 2 : s === "warning" ? 1 : 0;
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function verdictOf(diagnostics: readonly ExplainDiagnostic[]): ExplainVerdict {
|
|
1225
|
+
let worst = 0;
|
|
1226
|
+
for (const d of diagnostics)
|
|
1227
|
+
worst = Math.max(worst, severityRank(d.severity));
|
|
1228
|
+
return worst === 2 ? "fail" : worst === 1 ? "warn" : "pass";
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
/**
|
|
1232
|
+
* Extensible analysis options.
|
|
1233
|
+
*
|
|
1234
|
+
* ONE additive bag, which is why the previous no-options signature could be
|
|
1235
|
+
* widened without a second shape: phase 2 puts `target` and the live facets
|
|
1236
|
+
* here, and `curl` stays orthogonal to both — it asks for a rendering of a
|
|
1237
|
+
* result, not for more evidence. A caller can still tell an honored option from
|
|
1238
|
+
* an ignored one, because every field here changes the result.
|
|
1239
|
+
*/
|
|
1240
|
+
export interface ExplainCommandOptions {
|
|
1241
|
+
/** Include a ready-to-edit REST curl on API-candidate statements. */
|
|
1242
|
+
curl?: boolean;
|
|
1243
|
+
/**
|
|
1244
|
+
* Emit the total, gapless token partition behind `data.tokens[]`.
|
|
1245
|
+
*
|
|
1246
|
+
* Mirrors `--complete` / `--schema` / `--curl`: an opt-in facet whose
|
|
1247
|
+
* presence changes the result. See {@link ExplainTokenClass} for the
|
|
1248
|
+
* vocabulary `class` draws on (#264 B5).
|
|
1249
|
+
*/
|
|
1250
|
+
tokens?: boolean;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
/**
|
|
1254
|
+
* Analyze one RouterOS input offline. Never throws, never contacts a device.
|
|
1255
|
+
*
|
|
1256
|
+
* Curl rendering is an opt-in presentation concern. It adds a shell-safe view
|
|
1257
|
+
* of an already-classified request and does not alter canonical analysis.
|
|
1258
|
+
*/
|
|
1259
|
+
export function explainCommand(
|
|
1260
|
+
input: string,
|
|
1261
|
+
options: ExplainCommandOptions = {},
|
|
1262
|
+
): ExplainData {
|
|
1263
|
+
const coordinates = analyzeCoordinates(input);
|
|
1264
|
+
// The ANALYZED text is what arguments are lexed from: it is pure ASCII with
|
|
1265
|
+
// one byte standing in for every non-ASCII one, so an index into it IS a
|
|
1266
|
+
// document byte offset. Decoding it once here keeps `statementOf` and the
|
|
1267
|
+
// string-escape walk from re-deriving the same string.
|
|
1268
|
+
const analyzed = coordinates.ascii
|
|
1269
|
+
? input
|
|
1270
|
+
: new TextDecoder().decode(coordinates.analyzed);
|
|
1271
|
+
// The whole document as a masked range, in `analyzed` coordinates, derived
|
|
1272
|
+
// ONCE.
|
|
1273
|
+
//
|
|
1274
|
+
// Four passes below read the same statement — the strict argument reading,
|
|
1275
|
+
// the tolerant token stream, the brace diagnostic and the value anchors —
|
|
1276
|
+
// and each masked that statement's text for itself. A statement's text
|
|
1277
|
+
// carries its whole nested `do={…}` subtree, so on a 60 KiB document nested
|
|
1278
|
+
// 64 deep those four passes masked 15.8 MB between them. A statement's mask
|
|
1279
|
+
// is this mask restricted to its span (see `segment.ts` → `MaskedRange`), and
|
|
1280
|
+
// `analyzed` is ASCII by construction, so the offsets need no coordinate test.
|
|
1281
|
+
const analyzedRange = documentRange(analyzed, coordinates.ascii);
|
|
1282
|
+
const segmented = segmentStatements(input);
|
|
1283
|
+
const statementAnalysis = resolveStatements(input);
|
|
1284
|
+
const sourceRange = coordinates.ascii ? analyzedRange : undefined;
|
|
1285
|
+
const verbs = resolveVerbsFromStatements(statementAnalysis, sourceRange);
|
|
1286
|
+
const brackets = resolveDocument(input);
|
|
1287
|
+
const write = containsWriteFromAnalyses(
|
|
1288
|
+
statementAnalysis,
|
|
1289
|
+
brackets,
|
|
1290
|
+
sourceRange,
|
|
1291
|
+
);
|
|
1292
|
+
const symbols = resolveSymbols(input);
|
|
1293
|
+
|
|
1294
|
+
// ONE escape walk, read twice (#264): its invalid half is a
|
|
1295
|
+
// `bad-string-escape` diagnostic and its valid half is the `escaped` token
|
|
1296
|
+
// fill further down. Walking separately for each would let the diagnostics
|
|
1297
|
+
// and the token stream disagree about which bytes are an escape.
|
|
1298
|
+
//
|
|
1299
|
+
// The third argument only decides whether the valid spans are RETAINED, not
|
|
1300
|
+
// whether they are validated, so gating it on the token facet cannot change
|
|
1301
|
+
// a diagnostic — it just stops a caller who never asked for `data.tokens`
|
|
1302
|
+
// from paying for a span object per escape.
|
|
1303
|
+
const stringEscapes = walkStringEscapes(
|
|
1304
|
+
analyzed,
|
|
1305
|
+
segmented.comments,
|
|
1306
|
+
options.tokens === true,
|
|
1307
|
+
);
|
|
1308
|
+
|
|
1309
|
+
// Every analyzer re-derives the document's defects from its own walk, so a
|
|
1310
|
+
// defect two analyzers both see must be reported once — and tagged with the
|
|
1311
|
+
// analyzer that RAISED it, not with whichever list was merged first. The
|
|
1312
|
+
// classes do not partition: `unclosed`/`unbalanced-close`/`unterminated-string`
|
|
1313
|
+
// come from the segmenter AND the symbol scan, while `bad-escape`/`bad-sigil`
|
|
1314
|
+
// are the symbol scan alone (they are lexical rules, hence `heuristic`, where
|
|
1315
|
+
// the segmenter's delimiter stack is a `direct` reading). Attributing all of
|
|
1316
|
+
// them to the segmenter said "direct/segmentStatements" about a fact the
|
|
1317
|
+
// segmenter cannot produce.
|
|
1318
|
+
//
|
|
1319
|
+
// `bad-string-escape` is the same mistake one module over, and #264 is what
|
|
1320
|
+
// made it fixable: it cited `resolveSymbols`, which never produced it —
|
|
1321
|
+
// `symbols.ts` raises the DIFFERENT `bad-escape` code. Until the escape walk
|
|
1322
|
+
// had an evidence entry of its own there was nothing truer to cite; now
|
|
1323
|
+
// there is, and the token and the diagnostic cite the same one.
|
|
1324
|
+
const defects = collapseToFirstApostrophe(
|
|
1325
|
+
attributeDefects([
|
|
1326
|
+
[EV.segment, segmented.defects],
|
|
1327
|
+
[EV.statements, verbs.defects],
|
|
1328
|
+
[EV.subcommands, brackets.defects],
|
|
1329
|
+
[EV.write, write.defects],
|
|
1330
|
+
[EV.symbols, symbols.defects],
|
|
1331
|
+
[EV.escapes, stringEscapes.defects],
|
|
1332
|
+
]),
|
|
1333
|
+
);
|
|
1334
|
+
|
|
1335
|
+
// From the SPLITS, not from the segmentation. The resolver flattens `do={…}`
|
|
1336
|
+
// bodies in after their parent, so its list is longer than the top-level
|
|
1337
|
+
// segments and pairing the two by index attaches the wrong span to every statement after
|
|
1338
|
+
// the first block. Each split carries its own document-space span.
|
|
1339
|
+
//
|
|
1340
|
+
const readStatements: ExplainStatement[] = verbs.splits.map((split) =>
|
|
1341
|
+
statementOf(split, analyzed, analyzedRange),
|
|
1342
|
+
);
|
|
1343
|
+
const canonical = canonicalizeExecuteCommand(input);
|
|
1344
|
+
// The skip-tolerant token stream every TOKEN-LEVEL rule reads (#316), one
|
|
1345
|
+
// list per split. It is not `statement.arguments`: that reading stays
|
|
1346
|
+
// all-or-nothing because its consumer renders a runnable request, and a token
|
|
1347
|
+
// carrying `undecided` has no value to render. These two never disagree about
|
|
1348
|
+
// where a token starts or ends — `args.ts` owns the one boundary walk.
|
|
1349
|
+
const argumentTokens = readStatements.map((statement, index) => {
|
|
1350
|
+
const split = verbs.splits[index];
|
|
1351
|
+
return split === undefined || statement.kind !== "command"
|
|
1352
|
+
? []
|
|
1353
|
+
: argumentTokensOf(split, analyzed, analyzedRange);
|
|
1354
|
+
});
|
|
1355
|
+
// #311: the second command-shaped run is found on the statement's OWN
|
|
1356
|
+
// argument tokens, then the reading is degraded — in that order, because the
|
|
1357
|
+
// tokens are the evidence for the diagnostic and the refusal both. Transport
|
|
1358
|
+
// runs after, so it classifies the degraded reading rather than a list the
|
|
1359
|
+
// device never accepted.
|
|
1360
|
+
const separators = readStatements.map((statement, index) =>
|
|
1361
|
+
statement.resolution === "resolved"
|
|
1362
|
+
? findMissingSeparators(argumentTokens[index] ?? [])
|
|
1363
|
+
: [],
|
|
1364
|
+
);
|
|
1365
|
+
const gated = enforceGateParity(readStatements, canonical);
|
|
1366
|
+
const statements = gated.map((statement, index) => {
|
|
1367
|
+
const runs = separators[index] ?? [];
|
|
1368
|
+
return withStatementTransport(
|
|
1369
|
+
withoutSeparatedArguments(statement, runs),
|
|
1370
|
+
verbs.splits[index]?.text ?? "",
|
|
1371
|
+
options,
|
|
1372
|
+
runs.length === 0 ? undefined : rejectedBySeparator(runs),
|
|
1373
|
+
);
|
|
1374
|
+
});
|
|
1375
|
+
|
|
1376
|
+
const diagnostics: ExplainDiagnostic[] = [
|
|
1377
|
+
...defects.map(({ defect: d, ev }) => {
|
|
1378
|
+
const render = DEFECT_DIAGNOSTICS[d.code];
|
|
1379
|
+
return {
|
|
1380
|
+
code: `explain/canonicalizer/${d.code}`,
|
|
1381
|
+
severity: render.severity,
|
|
1382
|
+
message: render.message(d),
|
|
1383
|
+
span: { start: d.start, end: d.end },
|
|
1384
|
+
ev,
|
|
1385
|
+
};
|
|
1386
|
+
}),
|
|
1387
|
+
...invalidCommandBraceDiagnostics(verbs.splits, analyzed, analyzedRange),
|
|
1388
|
+
...separators.flatMap((runs) => runs.map(missingSeparatorDiagnostic)),
|
|
1389
|
+
...statements.flatMap((s) => diagnosticsForStatement(s)),
|
|
1390
|
+
].sort(
|
|
1391
|
+
(a, b) =>
|
|
1392
|
+
a.span.start - b.span.start ||
|
|
1393
|
+
severityRank(b.severity) - severityRank(a.severity),
|
|
1394
|
+
);
|
|
1395
|
+
|
|
1396
|
+
const valueFacts = valuesOf(
|
|
1397
|
+
verbs.splits,
|
|
1398
|
+
analyzed,
|
|
1399
|
+
analyzedRange,
|
|
1400
|
+
separators,
|
|
1401
|
+
);
|
|
1402
|
+
const spans: ExplainSpan[] = [
|
|
1403
|
+
...segmented.comments.map((c) => ({
|
|
1404
|
+
start: c.start,
|
|
1405
|
+
end: c.end,
|
|
1406
|
+
class: "comment" as const,
|
|
1407
|
+
ev: EV.segment,
|
|
1408
|
+
})),
|
|
1409
|
+
...symbols.occurrences.flatMap((o) => {
|
|
1410
|
+
const cls = o.cls === null ? undefined : SPAN_CLASS_OF_SYMBOL[o.cls];
|
|
1411
|
+
// An abstention (`cls: null`) and the device-only `undefined` class are
|
|
1412
|
+
// omitted rather than rendered as a guess.
|
|
1413
|
+
return cls === undefined
|
|
1414
|
+
? []
|
|
1415
|
+
: [{ start: o.start, end: o.end, class: cls, ev: EV.symbols }];
|
|
1416
|
+
}),
|
|
1417
|
+
].sort((a, b) => a.start - b.start || a.end - b.end);
|
|
1418
|
+
const symbolFacts: ExplainSymbols = {
|
|
1419
|
+
occurrences: augmentSymbolOccurrences(
|
|
1420
|
+
analyzed,
|
|
1421
|
+
symbols.occurrences,
|
|
1422
|
+
valueFacts.occurrences,
|
|
1423
|
+
verbs.splits,
|
|
1424
|
+
),
|
|
1425
|
+
};
|
|
1426
|
+
|
|
1427
|
+
const structure: ExplainStructure = {
|
|
1428
|
+
statementCount: statements.length,
|
|
1429
|
+
statements,
|
|
1430
|
+
blocks: scopeBlocks(input).map((b) => ({
|
|
1431
|
+
name: b.name,
|
|
1432
|
+
span: { start: b.start, end: b.start + b.body.length },
|
|
1433
|
+
ev: EV.blocks,
|
|
1434
|
+
})),
|
|
1435
|
+
containsWrite: renderWriteVerdict(write.verdict),
|
|
1436
|
+
subcommands: brackets.resolutions.map(subcommandOf),
|
|
1437
|
+
// `containsWrite` is the fact `structure` itself asserts; the statements
|
|
1438
|
+
// and subcommands beside it carry their own.
|
|
1439
|
+
ev: EV.write,
|
|
1440
|
+
};
|
|
1441
|
+
|
|
1442
|
+
// B2/B3 fill order — the argument order IS the order (#290 design decision 1).
|
|
1443
|
+
// `spans` (proof-only: comment + variables) claims first; path, argument,
|
|
1444
|
+
// escape, value, then string/brace, then operator fills each see only the
|
|
1445
|
+
// residual left before them. Structural ambiguity (`/` path vs division, `=` arg sep
|
|
1446
|
+
// vs comparison) therefore resolves by the analyzer that already proved
|
|
1447
|
+
// ownership, not by a wider byte scanner. The operator scanner keeps its
|
|
1448
|
+
// conservative guards for bytes no earlier fill can decide.
|
|
1449
|
+
//
|
|
1450
|
+
// Gate the scan on `options.tokens` — no residual work when the caller
|
|
1451
|
+
// did not ask for `data.tokens`. Future fills belong inside this branch.
|
|
1452
|
+
let tokens: ExplainToken[] | undefined;
|
|
1453
|
+
if (options.tokens === true) {
|
|
1454
|
+
const residual0 = residualRanges(analyzed.length, spans);
|
|
1455
|
+
const pathCandidates: PathTokenCandidate[] = [
|
|
1456
|
+
...verbs.splits.map((split) => ({
|
|
1457
|
+
text: split.text,
|
|
1458
|
+
span: split.span,
|
|
1459
|
+
split,
|
|
1460
|
+
ev: EV.paths,
|
|
1461
|
+
})),
|
|
1462
|
+
...brackets.resolutions.flatMap((resolution) => {
|
|
1463
|
+
if (resolution.unresolved !== undefined || resolution.path === null)
|
|
1464
|
+
return [];
|
|
1465
|
+
return [
|
|
1466
|
+
{
|
|
1467
|
+
text: resolution.inner,
|
|
1468
|
+
// `innerSpan`, never `span ± 1`: `span` covers `[…]` for a bare
|
|
1469
|
+
// substitution but `$[…]` for an interpolated one, so the
|
|
1470
|
+
// arithmetic was one byte early inside a string and `pathSpans`
|
|
1471
|
+
// dropped every such candidate on its text check — leaving a
|
|
1472
|
+
// resolved command to the string fill (#325).
|
|
1473
|
+
span: resolution.innerSpan,
|
|
1474
|
+
split: resolveVerb(
|
|
1475
|
+
resolution.inner,
|
|
1476
|
+
resolution.context,
|
|
1477
|
+
rangeForResolution(resolution) ?? documentRange(resolution.inner),
|
|
1478
|
+
),
|
|
1479
|
+
ev: EV.paths,
|
|
1480
|
+
},
|
|
1481
|
+
];
|
|
1482
|
+
}),
|
|
1483
|
+
];
|
|
1484
|
+
const pathSpansList = pathSpans(analyzed, residual0, pathCandidates);
|
|
1485
|
+
const residual1 = residualRanges(analyzed.length, [
|
|
1486
|
+
...spans,
|
|
1487
|
+
...pathSpansList,
|
|
1488
|
+
]);
|
|
1489
|
+
// Every WITHDRAWN reading contributes nothing, exactly as before #316: a
|
|
1490
|
+
// second command-shaped run (`withoutSeparatedArguments`) and a gate-parity
|
|
1491
|
+
// disagreement (`enforceGateParity`) both mean centrs cannot say which
|
|
1492
|
+
// bytes are this command's arguments, and painting `name=` runs inside one
|
|
1493
|
+
// would be that claim in lexical clothing. Everything else now paints from
|
|
1494
|
+
// the tolerant stream, so `gateway=$g` gets its name and `=` claimed where
|
|
1495
|
+
// the strict reading left the whole statement `unclassified`.
|
|
1496
|
+
const argCandidates = gated.flatMap((statement, index) =>
|
|
1497
|
+
(separators[index]?.length ?? 0) > 0 ||
|
|
1498
|
+
(readStatements[index]?.arguments?.read === true &&
|
|
1499
|
+
statement.arguments?.read !== true)
|
|
1500
|
+
? []
|
|
1501
|
+
: (argumentTokens[index] ?? []),
|
|
1502
|
+
);
|
|
1503
|
+
const argSpansList = argSpans(analyzed, residual1, argCandidates);
|
|
1504
|
+
const residual2 = residualRanges(analyzed.length, [
|
|
1505
|
+
...spans,
|
|
1506
|
+
...pathSpansList,
|
|
1507
|
+
...argSpansList,
|
|
1508
|
+
]);
|
|
1509
|
+
// An escape is interior to a quoted run, so it claims BEFORE the two
|
|
1510
|
+
// fills that would otherwise swallow it whole (#264) — it takes the
|
|
1511
|
+
// residual `value` used to see, and `value` and `string` then fragment
|
|
1512
|
+
// around what it claimed.
|
|
1513
|
+
const escapeSpansList = escapeSpans(
|
|
1514
|
+
analyzed,
|
|
1515
|
+
residual2,
|
|
1516
|
+
stringEscapes.escapes,
|
|
1517
|
+
);
|
|
1518
|
+
const residual3 = residualRanges(analyzed.length, [
|
|
1519
|
+
...spans,
|
|
1520
|
+
...pathSpansList,
|
|
1521
|
+
...argSpansList,
|
|
1522
|
+
...escapeSpansList,
|
|
1523
|
+
]);
|
|
1524
|
+
const valueSpansList = valueSpans(
|
|
1525
|
+
analyzed,
|
|
1526
|
+
residual3,
|
|
1527
|
+
valueFacts.occurrences,
|
|
1528
|
+
);
|
|
1529
|
+
const residual4 = residualRanges(analyzed.length, [
|
|
1530
|
+
...spans,
|
|
1531
|
+
...pathSpansList,
|
|
1532
|
+
...argSpansList,
|
|
1533
|
+
...escapeSpansList,
|
|
1534
|
+
...valueSpansList,
|
|
1535
|
+
]);
|
|
1536
|
+
const stringSpansList = stringSpans(analyzed, residual4);
|
|
1537
|
+
const residual5 = residualRanges(analyzed.length, [
|
|
1538
|
+
...spans,
|
|
1539
|
+
...pathSpansList,
|
|
1540
|
+
...argSpansList,
|
|
1541
|
+
...escapeSpansList,
|
|
1542
|
+
...valueSpansList,
|
|
1543
|
+
...stringSpansList,
|
|
1544
|
+
]);
|
|
1545
|
+
const braceSpansList = braceSpans(analyzed, residual5);
|
|
1546
|
+
const residual6 = residualRanges(analyzed.length, [
|
|
1547
|
+
...spans,
|
|
1548
|
+
...pathSpansList,
|
|
1549
|
+
...argSpansList,
|
|
1550
|
+
...escapeSpansList,
|
|
1551
|
+
...valueSpansList,
|
|
1552
|
+
...stringSpansList,
|
|
1553
|
+
...braceSpansList,
|
|
1554
|
+
]);
|
|
1555
|
+
const opSpans = operatorSpans(analyzed, residual6);
|
|
1556
|
+
const fills: TokenFill[] = [
|
|
1557
|
+
spans,
|
|
1558
|
+
pathSpansList,
|
|
1559
|
+
argSpansList,
|
|
1560
|
+
escapeSpansList,
|
|
1561
|
+
valueSpansList,
|
|
1562
|
+
stringSpansList,
|
|
1563
|
+
braceSpansList,
|
|
1564
|
+
opSpans,
|
|
1565
|
+
];
|
|
1566
|
+
tokens = buildTokens(analyzed, fills);
|
|
1567
|
+
}
|
|
1568
|
+
|
|
1569
|
+
return {
|
|
1570
|
+
input: {
|
|
1571
|
+
bytes: coordinates.analyzed.length,
|
|
1572
|
+
normalized: !coordinates.ascii,
|
|
1573
|
+
truncated: false,
|
|
1574
|
+
positionMap: positionMap(coordinates),
|
|
1575
|
+
},
|
|
1576
|
+
verdict: verdictOf(diagnostics),
|
|
1577
|
+
canonical: {
|
|
1578
|
+
mode: canonical.mode,
|
|
1579
|
+
path: canonical.path,
|
|
1580
|
+
verb: canonical.verb,
|
|
1581
|
+
args: canonical.attributes,
|
|
1582
|
+
queries: canonical.queries,
|
|
1583
|
+
writeShaped: isWriteShaped(canonical),
|
|
1584
|
+
},
|
|
1585
|
+
structure,
|
|
1586
|
+
symbols: symbolFacts,
|
|
1587
|
+
values: valueFacts,
|
|
1588
|
+
spans,
|
|
1589
|
+
...(tokens === undefined ? {} : { tokens }),
|
|
1590
|
+
diagnostics,
|
|
1591
|
+
evidence: citedEvidence(
|
|
1592
|
+
structure,
|
|
1593
|
+
diagnostics,
|
|
1594
|
+
spans,
|
|
1595
|
+
symbolFacts,
|
|
1596
|
+
valueFacts,
|
|
1597
|
+
tokens,
|
|
1598
|
+
),
|
|
1599
|
+
runtimeAcceptance: "not-proven",
|
|
1600
|
+
};
|
|
1601
|
+
}
|
|
1602
|
+
|
|
1603
|
+
/** Attach transport only after the gate-parity guard has had the final word. */
|
|
1604
|
+
function withStatementTransport(
|
|
1605
|
+
statement: ExplainStatement,
|
|
1606
|
+
source: string,
|
|
1607
|
+
options: ExplainCommandOptions,
|
|
1608
|
+
rejected?: string,
|
|
1609
|
+
): ExplainStatement {
|
|
1610
|
+
if (statement.kind !== "command" || statement.arguments === undefined)
|
|
1611
|
+
return statement;
|
|
1612
|
+
return {
|
|
1613
|
+
...statement,
|
|
1614
|
+
transport: classifyExplainTransport(
|
|
1615
|
+
{
|
|
1616
|
+
command: statement.command,
|
|
1617
|
+
arguments: statement.arguments,
|
|
1618
|
+
source,
|
|
1619
|
+
...(rejected === undefined ? {} : { rejected }),
|
|
1620
|
+
},
|
|
1621
|
+
{ renderCurl: options.curl, evidenceId: EV.transport },
|
|
1622
|
+
),
|
|
1623
|
+
};
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
/**
|
|
1627
|
+
* One statement, in the envelope's vocabulary.
|
|
1628
|
+
*
|
|
1629
|
+
* The fold that happens here: `verbsplit` reports menu navigation as its own
|
|
1630
|
+
* `navigation` resolution, because for ITS callers `resolved` has to keep
|
|
1631
|
+
* meaning "a verb was decided". The envelope's ratified vocabulary is
|
|
1632
|
+
* `resolved` + `kind`, and the mapping is total — `navigation` → menu,
|
|
1633
|
+
* `resolved` → command, and the two refusals carry no kind at all.
|
|
1634
|
+
*
|
|
1635
|
+
* The span comes from the split itself, because the resolver FLATTENS block
|
|
1636
|
+
* bodies into its statement list — a body statement's span is contained by its
|
|
1637
|
+
* parent's rather than following it, and the list is longer than the top-level
|
|
1638
|
+
* segmentation. That is why `statements[]` may overlap, and why the count is not
|
|
1639
|
+
* the number of top-level statements.
|
|
1640
|
+
*/
|
|
1641
|
+
function statementOf(
|
|
1642
|
+
split: DocumentVerbSplit,
|
|
1643
|
+
analyzed: string,
|
|
1644
|
+
analyzedRange: MaskedRange,
|
|
1645
|
+
): ExplainStatement {
|
|
1646
|
+
const reading = readingOf(split);
|
|
1647
|
+
return {
|
|
1648
|
+
span: { start: split.span.start, end: split.span.end },
|
|
1649
|
+
...(reading.kind === "command"
|
|
1650
|
+
? withArguments(reading, split, analyzed, analyzedRange)
|
|
1651
|
+
: reading),
|
|
1652
|
+
contextCertain: split.contextCertain,
|
|
1653
|
+
ev: EV.statements,
|
|
1654
|
+
};
|
|
1655
|
+
}
|
|
1656
|
+
|
|
1657
|
+
/**
|
|
1658
|
+
* Attach the statement's argument reading, rebased into document space.
|
|
1659
|
+
*
|
|
1660
|
+
* The **span check** is the load-bearing line. `argsAt` indexes the statement's
|
|
1661
|
+
* OWN text, and the document offsets come from `span` — so the two are only
|
|
1662
|
+
* compatible when `span` really is where that text sits. It is not always:
|
|
1663
|
+
* `pathresolve.ts`'s `Loc` widens a nested body statement's span to the
|
|
1664
|
+
* enclosing statement when interior offsets cannot be mapped, and a non-ASCII
|
|
1665
|
+
* statement's original text is shorter than its analyzed byte range. Both would
|
|
1666
|
+
* lex the wrong bytes and report confident arguments for them, which is the one
|
|
1667
|
+
* failure a fail-closed lexer must not have. Comparing the analyzed slice with
|
|
1668
|
+
* the resolver's own text catches both at once and costs one string compare.
|
|
1669
|
+
*/
|
|
1670
|
+
function withArguments(
|
|
1671
|
+
reading: ExplainCommandReading,
|
|
1672
|
+
split: DocumentVerbSplit,
|
|
1673
|
+
analyzed: string,
|
|
1674
|
+
analyzedRange: MaskedRange,
|
|
1675
|
+
): ExplainCommandReading {
|
|
1676
|
+
const read = argumentsOf(split, analyzed, analyzedRange);
|
|
1677
|
+
return {
|
|
1678
|
+
...reading,
|
|
1679
|
+
command:
|
|
1680
|
+
read.args === undefined
|
|
1681
|
+
? reading.command
|
|
1682
|
+
: { ...reading.command, args: read.args },
|
|
1683
|
+
arguments: read.arguments,
|
|
1684
|
+
};
|
|
1685
|
+
}
|
|
1686
|
+
|
|
1687
|
+
/**
|
|
1688
|
+
* The statement's argument reading, plus the object view that goes on
|
|
1689
|
+
* `command.args`.
|
|
1690
|
+
*
|
|
1691
|
+
* The two travel together because the LEXER owns the object view: it builds a
|
|
1692
|
+
* last-wins map while it walks the tokens, and recomputing that here from the
|
|
1693
|
+
* rebased tokens would be a second implementation of one rule — free to diverge
|
|
1694
|
+
* from the lexer's, and so from `canonicalizeExecuteCommand`'s, which is the
|
|
1695
|
+
* duplicate-name agreement this module's header asserts. Raised in review of
|
|
1696
|
+
* #202c-1.
|
|
1697
|
+
*/
|
|
1698
|
+
function argumentsOf(
|
|
1699
|
+
split: DocumentVerbSplit,
|
|
1700
|
+
analyzed: string,
|
|
1701
|
+
analyzedRange: MaskedRange,
|
|
1702
|
+
): { arguments: ExplainArguments; args?: Record<string, string> } {
|
|
1703
|
+
if (split.argsAt === null)
|
|
1704
|
+
return {
|
|
1705
|
+
arguments: {
|
|
1706
|
+
read: false,
|
|
1707
|
+
why: "the verb is not the last token of the leading path run, so what follows the run is not this command's argument list",
|
|
1708
|
+
},
|
|
1709
|
+
};
|
|
1710
|
+
const { start, end } = split.span;
|
|
1711
|
+
const text = analyzed.slice(start, end);
|
|
1712
|
+
if (text !== split.text)
|
|
1713
|
+
return {
|
|
1714
|
+
arguments: {
|
|
1715
|
+
read: false,
|
|
1716
|
+
why: "this statement's bytes are not addressable: its text was normalized (non-ASCII), or its span was widened to the enclosing statement",
|
|
1717
|
+
},
|
|
1718
|
+
};
|
|
1719
|
+
const lexed = lexArguments(
|
|
1720
|
+
text,
|
|
1721
|
+
split.argsAt,
|
|
1722
|
+
nestedRange(analyzedRange, start, end),
|
|
1723
|
+
);
|
|
1724
|
+
if (!lexed.read) return { arguments: lexed };
|
|
1725
|
+
return {
|
|
1726
|
+
args: lexed.args,
|
|
1727
|
+
arguments: {
|
|
1728
|
+
read: true,
|
|
1729
|
+
tokens: lexed.tokens.map((token) => ({
|
|
1730
|
+
...token,
|
|
1731
|
+
span: { start: start + token.span.start, end: start + token.span.end },
|
|
1732
|
+
...(token.valueSpan === undefined
|
|
1733
|
+
? {}
|
|
1734
|
+
: {
|
|
1735
|
+
valueSpan: {
|
|
1736
|
+
start: start + token.valueSpan.start,
|
|
1737
|
+
end: start + token.valueSpan.end,
|
|
1738
|
+
},
|
|
1739
|
+
}),
|
|
1740
|
+
})),
|
|
1741
|
+
queries: lexed.queries,
|
|
1742
|
+
positional: lexed.positional,
|
|
1743
|
+
},
|
|
1744
|
+
};
|
|
1745
|
+
}
|
|
1746
|
+
|
|
1747
|
+
/**
|
|
1748
|
+
* The statement's skip-tolerant argument token stream, rebased into document
|
|
1749
|
+
* space (#316).
|
|
1750
|
+
*
|
|
1751
|
+
* Same two guards as {@link argumentsOf}, for the same reason: `argsAt` indexes
|
|
1752
|
+
* the statement's own text, so offsets are only rebased when the analyzed
|
|
1753
|
+
* slice really is that text. What differs is only the reading — the tokens come
|
|
1754
|
+
* back with the undecodable ones located rather than the whole list discarded.
|
|
1755
|
+
* A statement that is not addressable, or has no verb, yields no tokens at all, which is the
|
|
1756
|
+
* same silence the strict reading gives it.
|
|
1757
|
+
*/
|
|
1758
|
+
function argumentTokensOf(
|
|
1759
|
+
split: DocumentVerbSplit,
|
|
1760
|
+
analyzed: string,
|
|
1761
|
+
analyzedRange: MaskedRange,
|
|
1762
|
+
): ExplainArgument[] {
|
|
1763
|
+
if (split.argsAt === null) return [];
|
|
1764
|
+
const { start, end } = split.span;
|
|
1765
|
+
const text = analyzed.slice(start, end);
|
|
1766
|
+
if (text !== split.text) return [];
|
|
1767
|
+
return lexArgumentTokens(
|
|
1768
|
+
text,
|
|
1769
|
+
split.argsAt,
|
|
1770
|
+
nestedRange(analyzedRange, start, end),
|
|
1771
|
+
).tokens.map((token) => ({
|
|
1772
|
+
...token,
|
|
1773
|
+
span: { start: start + token.span.start, end: start + token.span.end },
|
|
1774
|
+
...(token.valueSpan === undefined
|
|
1775
|
+
? {}
|
|
1776
|
+
: {
|
|
1777
|
+
valueSpan: {
|
|
1778
|
+
start: start + token.valueSpan.start,
|
|
1779
|
+
end: start + token.valueSpan.end,
|
|
1780
|
+
},
|
|
1781
|
+
}),
|
|
1782
|
+
}));
|
|
1783
|
+
}
|
|
1784
|
+
|
|
1785
|
+
/**
|
|
1786
|
+
* A second command-shaped run, said out loud (#311).
|
|
1787
|
+
*
|
|
1788
|
+
* `error`, not `warning`: the abstention vocabulary is for input with several
|
|
1789
|
+
* valid schema-free readings, and this one has none — RouterOS answers every
|
|
1790
|
+
* probed spelling of this shape with `bad parameter <token>` or
|
|
1791
|
+
* `expected end of command` (`src/explain/separator.ts`). What the message may
|
|
1792
|
+
* claim is bounded by the same head-dependence: it says the bytes READ as a
|
|
1793
|
+
* second command, which is centrs's own heuristic reading, and that RouterOS
|
|
1794
|
+
* rejects the statement, which is the grounded part. It does not say which byte
|
|
1795
|
+
* the device will name, because that is per-menu schema — on a head whose value
|
|
1796
|
+
* slot takes a bare positional the path is absorbed into it and the VERB is
|
|
1797
|
+
* refused instead (`:put /ip/route print` → `/putmessage=/ip/route` plus
|
|
1798
|
+
* `bad parameter print`, CHR 7.24.2). The span is the whole second run so an
|
|
1799
|
+
* editor can highlight what has to move; `insertAt` is where the separator
|
|
1800
|
+
* goes, which is that span's start.
|
|
1801
|
+
*/
|
|
1802
|
+
function missingSeparatorDiagnostic(run: MissingSeparator): ExplainDiagnostic {
|
|
1803
|
+
return {
|
|
1804
|
+
code: "explain/canonicalizer/missing-statement-separator",
|
|
1805
|
+
severity: "error",
|
|
1806
|
+
message: `missing statement separator: \`${run.path}\` is a menu path and \`${run.verb}\` a command verb, so this reads as a second command with no separator before it, and RouterOS rejects the statement (\`bad parameter\` or \`expected end of command\`, on a token that depends on the head's own argument names) — insert \`;\` or a newline before \`${run.path}\``,
|
|
1807
|
+
span: { start: run.span.start, end: run.span.end },
|
|
1808
|
+
ev: EV.separators,
|
|
1809
|
+
};
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
/**
|
|
1813
|
+
* Withdraw an argument reading the second run contradicts (#311).
|
|
1814
|
+
*
|
|
1815
|
+
* Same move `enforceGateParity` makes, for the same reason: the argument list is
|
|
1816
|
+
* all-or-nothing because a partially-read one changes what the rendered command
|
|
1817
|
+
* means. Reporting `gateway=` as an attribute of `/ip/address add` is the false
|
|
1818
|
+
* confident reading #311 names, and the device says the withdrawal is right
|
|
1819
|
+
* rather than merely cautious — `/ip/address add interface=ether1 /ip/route add
|
|
1820
|
+
* placeholder` lowers to `address=placeholder;;interface=ether1` on CHR 7.24.2, a phantom
|
|
1821
|
+
* attribute the source never wrote, because the second run's words can
|
|
1822
|
+
* abbreviate the HEAD's argument names. Which head arguments exist is schema,
|
|
1823
|
+
* so offline cannot predict when that happens and must not publish either
|
|
1824
|
+
* reading.
|
|
1825
|
+
*
|
|
1826
|
+
* The statement itself stays `resolved`: `/ip/address add` really is the head,
|
|
1827
|
+
* the device highlight agrees, and the source is one invalid statement rather
|
|
1828
|
+
* than two fabricated valid ones.
|
|
1829
|
+
*/
|
|
1830
|
+
function withoutSeparatedArguments(
|
|
1831
|
+
statement: ExplainStatement,
|
|
1832
|
+
runs: readonly MissingSeparator[],
|
|
1833
|
+
): ExplainStatement {
|
|
1834
|
+
if (runs.length === 0 || statement.kind !== "command") return statement;
|
|
1835
|
+
if (statement.arguments?.read !== true) return statement;
|
|
1836
|
+
const { args: _dropped, ...command } = statement.command;
|
|
1837
|
+
return {
|
|
1838
|
+
...statement,
|
|
1839
|
+
command,
|
|
1840
|
+
arguments: { read: false, why: rejectedBySeparator(runs) },
|
|
1841
|
+
};
|
|
1842
|
+
}
|
|
1843
|
+
|
|
1844
|
+
/** The one sentence both the withdrawn argument list and transport refuse on. */
|
|
1845
|
+
function rejectedBySeparator(runs: readonly MissingSeparator[]): string {
|
|
1846
|
+
const first = runs[0];
|
|
1847
|
+
return `a second command-shaped run (\`${first?.path} ${first?.verb}\`) sits in this operand stream with no \`;\` or newline before it, so this is not one accepted statement`;
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
function invalidCommandBraceDiagnostics(
|
|
1851
|
+
splits: readonly DocumentVerbSplit[],
|
|
1852
|
+
analyzed: string,
|
|
1853
|
+
analyzedRange: MaskedRange,
|
|
1854
|
+
): ExplainDiagnostic[] {
|
|
1855
|
+
const out: ExplainDiagnostic[] = [];
|
|
1856
|
+
for (const split of splits) {
|
|
1857
|
+
if (split.resolution !== "resolved" || split.argsAt === null) continue;
|
|
1858
|
+
const { start, end } = split.span;
|
|
1859
|
+
const text = analyzed.slice(start, end);
|
|
1860
|
+
if (text !== split.text) continue;
|
|
1861
|
+
// The verb splitter cannot yet flatten every relative nested menu block.
|
|
1862
|
+
// Do not reinterpret a known submenu's leading `{` as an argument value,
|
|
1863
|
+
// but retain diagnostics for rejected braces later in the same split.
|
|
1864
|
+
// Require evidence that the brace actually opens a menu container
|
|
1865
|
+
// (body starts with a submenu name), so `/ip/route/print {1;2}` and
|
|
1866
|
+
// `/ip { firewall/filter/print {1;2} }` still diagnose the `{1;2}` array.
|
|
1867
|
+
const argsText = text.slice(split.argsAt);
|
|
1868
|
+
const leadingBraceAt = argsText.search(/\S/);
|
|
1869
|
+
let menuBraceAt: number | null = null;
|
|
1870
|
+
if (
|
|
1871
|
+
leadingBraceAt >= 0 &&
|
|
1872
|
+
argsText[leadingBraceAt] === "{" &&
|
|
1873
|
+
split.candidates.some((candidate) =>
|
|
1874
|
+
isKnownMenuPath(candidate.split("/").filter(Boolean)),
|
|
1875
|
+
)
|
|
1876
|
+
) {
|
|
1877
|
+
const braceAt = split.argsAt + leadingBraceAt;
|
|
1878
|
+
const structural = rangeMask(nestedRange(analyzedRange, start, end));
|
|
1879
|
+
const trimmed = structural.slice(braceAt + 1).trimStart();
|
|
1880
|
+
if (trimmed.length === 0 || trimmed[0] === "}") {
|
|
1881
|
+
menuBraceAt = braceAt;
|
|
1882
|
+
} else if (trimmed[0] === ":" || trimmed[0] === "/") {
|
|
1883
|
+
// A menu block body is a statement list, and a statement may be a
|
|
1884
|
+
// scripting directive or an ABSOLUTE command — neither of which the
|
|
1885
|
+
// enclosing menu can vouch for, and neither of which any array literal
|
|
1886
|
+
// can start with. `/interface { ethernet { :put x } }` is corpus 521,
|
|
1887
|
+
// accepted on 7.21.5/7.23.5/7.24.2/7.25beta3 as
|
|
1888
|
+
// `(evl /interface/ethernet/ (evl /putmessage=…))` — #348.
|
|
1889
|
+
menuBraceAt = braceAt;
|
|
1890
|
+
} else {
|
|
1891
|
+
const match = /^([A-Za-z][A-Za-z0-9._-]*)/.exec(trimmed);
|
|
1892
|
+
if (match) {
|
|
1893
|
+
const firstWord = match[1] as string;
|
|
1894
|
+
// A menu block's body may open with a SUBMENU (`/ip { firewall …`)
|
|
1895
|
+
// or with a COMMAND of the menu itself (`/ip { address { print } }`,
|
|
1896
|
+
// whose inner brace is a body over `/ip/address`) — #348. Requiring
|
|
1897
|
+
// a submenu rejected the second shape, and the device accepts it:
|
|
1898
|
+
// `:parse` lowers it to `(evl (evl (evl /ip/address/print)))` on
|
|
1899
|
+
// 7.21.5 and 7.24.2, and `/interface { ethernet { print } }` the same
|
|
1900
|
+
// way, while `/ip { zzznotamenu { print } }` is rejected there.
|
|
1901
|
+
//
|
|
1902
|
+
// The command half asks the FROZEN verb vocabulary and the catalog,
|
|
1903
|
+
// never a per-menu schema: `VERBS` is the thirteen CRUD verbs that
|
|
1904
|
+
// exist at essentially every configuration menu (`verbs.ts`), and
|
|
1905
|
+
// `lookupPath` names the menu-specific commands. The catalog carries
|
|
1906
|
+
// no `/ip/address/print` row and never will — that is decision 3, not
|
|
1907
|
+
// a gap to fill. An array literal opens with a digit or a quote and
|
|
1908
|
+
// never reaches this identifier branch at all, so `{1;2}` is still
|
|
1909
|
+
// diagnosed.
|
|
1910
|
+
const isMenuContainer = split.candidates.some((candidate) => {
|
|
1911
|
+
const segments = candidate.split("/").filter(Boolean);
|
|
1912
|
+
if (!isKnownMenuPath(segments)) return false;
|
|
1913
|
+
return (
|
|
1914
|
+
isKnownMenuPath([...segments, firstWord]) ||
|
|
1915
|
+
VERBS.has(firstWord) ||
|
|
1916
|
+
lookupPath([...segments, firstWord])?.kind === "command"
|
|
1917
|
+
);
|
|
1918
|
+
});
|
|
1919
|
+
if (isMenuContainer) menuBraceAt = braceAt;
|
|
1920
|
+
}
|
|
1921
|
+
}
|
|
1922
|
+
}
|
|
1923
|
+
for (const at of invalidCommandBraceOffsets(
|
|
1924
|
+
text,
|
|
1925
|
+
split.argsAt,
|
|
1926
|
+
split.path === "/" ? split.verb : undefined,
|
|
1927
|
+
nestedRange(analyzedRange, start, end),
|
|
1928
|
+
)) {
|
|
1929
|
+
if (at === menuBraceAt) continue;
|
|
1930
|
+
out.push({
|
|
1931
|
+
code: "explain/canonicalizer/invalid-command-brace",
|
|
1932
|
+
severity: "error",
|
|
1933
|
+
message:
|
|
1934
|
+
"brace arrays are not valid in command arguments — use a parenthesized comma list such as `(1,2)`, or a bare comma list where the argument schema accepts one",
|
|
1935
|
+
span: { start: start + at, end: start + at + 1 },
|
|
1936
|
+
ev: EV.values,
|
|
1937
|
+
});
|
|
1938
|
+
}
|
|
1939
|
+
}
|
|
1940
|
+
return out;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* Compose safely located literals into the three-axis #225 value surface.
|
|
1945
|
+
*
|
|
1946
|
+
* `separators` is indexed by split, and its first run is a CUT (#311): from that
|
|
1947
|
+
* byte on, the operand stream belongs to a second command the device never let
|
|
1948
|
+
* start, so no value there can be attributed. On the two probed rows the device
|
|
1949
|
+
* itself draws the line in the same place — `/ip/address add interface=ether1
|
|
1950
|
+
* /ip/route add gateway=…` is read with `gateway` as the VALUE of `address=`
|
|
1951
|
+
* (never as an attribute name), while the head's own `interface=ether1` survives
|
|
1952
|
+
* into the IL, and `/system/note set note=hello /ip/route add …` likewise keeps
|
|
1953
|
+
* `note=hello`. So the cut withdraws what the run contradicts and keeps what it
|
|
1954
|
+
* does not.
|
|
1955
|
+
*
|
|
1956
|
+
* Not "every value in a degraded statement": `values` is deliberately an axis of
|
|
1957
|
+
* its own rather than a projection of `arguments`, and the value census pins a
|
|
1958
|
+
* large retained population in statements whose strict REST reading abstains
|
|
1959
|
+
* (`commands/explain/README.md`, generated block). Widening this to the whole
|
|
1960
|
+
* statement would contradict that measured contract.
|
|
1961
|
+
*/
|
|
1962
|
+
function valuesOf(
|
|
1963
|
+
splits: readonly DocumentVerbSplit[],
|
|
1964
|
+
analyzed: string,
|
|
1965
|
+
analyzedRange: MaskedRange,
|
|
1966
|
+
separators: readonly (readonly MissingSeparator[])[],
|
|
1967
|
+
): ExplainValues {
|
|
1968
|
+
const occurrences: ExplainValueOccurrence[] = [];
|
|
1969
|
+
for (const [splitIndex, split] of splits.entries()) {
|
|
1970
|
+
if (split.resolution !== "resolved" || split.argsAt === null) continue;
|
|
1971
|
+
const cut = separators[splitIndex]?.[0]?.insertAt;
|
|
1972
|
+
const { start, end } = split.span;
|
|
1973
|
+
const text = analyzed.slice(start, end);
|
|
1974
|
+
if (text !== split.text) continue;
|
|
1975
|
+
const anchored = lexValueAnchors(text, split.argsAt, {
|
|
1976
|
+
range: nestedRange(analyzedRange, start, end),
|
|
1977
|
+
// A `{…}` array literal is legal only in a ROOT scripting directive's
|
|
1978
|
+
// value slot; `/ip/dns/set servers={…}` and `:log info message={…}` are
|
|
1979
|
+
// both device syntax errors at the `{`. `path` is how that position is
|
|
1980
|
+
// spelled offline: `:local`/`:put`/`:foreach` resolve to `/`, while
|
|
1981
|
+
// `:log info` resolves to `/log` — and the device agrees with the split.
|
|
1982
|
+
...(split.path === "/" ? { directiveVerb: split.verb } : {}),
|
|
1983
|
+
});
|
|
1984
|
+
// Prefix completeness is intentionally not an envelope fact: shape hints are
|
|
1985
|
+
// advisory and never turn the later refusal reason into a diagnostic.
|
|
1986
|
+
const ids = new Map<number, string>();
|
|
1987
|
+
for (const [index, anchor] of anchored.anchors.entries()) {
|
|
1988
|
+
// A token that reaches into the second run is not this statement's to
|
|
1989
|
+
// name. Token spans never straddle the cut — the run's path is its own
|
|
1990
|
+
// positional token, so a `{…}` or quoted value either ends before it or
|
|
1991
|
+
// starts after it — which is why a dropped array cannot orphan a member.
|
|
1992
|
+
if (cut !== undefined && start + anchor.tokenSpan.end > cut) continue;
|
|
1993
|
+
const hints: ValueShape[] =
|
|
1994
|
+
// A source-proved shape wins outright: the delimiters prove `array`,
|
|
1995
|
+
// and an `=` that binds no key proves `str` or `bool` (#258). None is
|
|
1996
|
+
// recoverable from the decoded scalar `valueShapeHints` reads.
|
|
1997
|
+
anchor.sourceShape !== undefined
|
|
1998
|
+
? [anchor.sourceShape]
|
|
1999
|
+
: valueShapeHints(anchor.value, {
|
|
2000
|
+
quoted: anchor.quoted,
|
|
2001
|
+
allowBareString: anchor.kind === "attribute",
|
|
2002
|
+
context: anchor.kind === "element" ? "array-member" : "argument",
|
|
2003
|
+
});
|
|
2004
|
+
if (hints.length === 0) continue;
|
|
2005
|
+
const span = {
|
|
2006
|
+
start: start + anchor.valueSpan.start,
|
|
2007
|
+
end: start + anchor.valueSpan.end,
|
|
2008
|
+
};
|
|
2009
|
+
const id = `v${occurrences.length}`;
|
|
2010
|
+
ids.set(index, id);
|
|
2011
|
+
const parent =
|
|
2012
|
+
anchor.parent === undefined ? undefined : ids.get(anchor.parent);
|
|
2013
|
+
occurrences.push({
|
|
2014
|
+
id,
|
|
2015
|
+
span,
|
|
2016
|
+
tokenSpan: {
|
|
2017
|
+
start: start + anchor.tokenSpan.start,
|
|
2018
|
+
end: start + anchor.tokenSpan.end,
|
|
2019
|
+
},
|
|
2020
|
+
kind: anchor.kind,
|
|
2021
|
+
...(anchor.name === undefined ? {} : { name: anchor.name }),
|
|
2022
|
+
quoted: anchor.quoted,
|
|
2023
|
+
...(parent === undefined ? {} : { parent }),
|
|
2024
|
+
facts: { shapeHints: { values: hints, ev: EV.values } },
|
|
2025
|
+
});
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
return { occurrences };
|
|
2029
|
+
}
|
|
2030
|
+
|
|
2031
|
+
/**
|
|
2032
|
+
* The last line of the "never two confident answers" rule: where the gate and
|
|
2033
|
+
* the analysis read the SAME bytes and disagree about the arguments, the
|
|
2034
|
+
* analysis abstains.
|
|
2035
|
+
*
|
|
2036
|
+
* `args.ts` guards the characters centrs's two readers are known to differ on,
|
|
2037
|
+
* and that is where a specific, quotable reason belongs. But a token-level guard
|
|
2038
|
+
* can only see what reaches the lexer, and the gate reads the RAW INPUT while
|
|
2039
|
+
* the analysis reads a SEGMENTED statement — so a byte segmentation removes is
|
|
2040
|
+
* invisible to every such guard. A trailing `;` is exactly that: the gate keeps
|
|
2041
|
+
* it (`comment=x;` → the value `x;`), the segmenter strips it as the statement
|
|
2042
|
+
* terminator (`x`), and no character rule in `args.ts` can be written to catch
|
|
2043
|
+
* it, because by then it is gone.
|
|
2044
|
+
*
|
|
2045
|
+
* So the invariant is ENFORCED here rather than claimed. It is checkable only in
|
|
2046
|
+
* the one case where the two readers genuinely share bytes — the gate read the
|
|
2047
|
+
* whole input as `structured`, and there is exactly one statement — which is
|
|
2048
|
+
* also the only case where a consumer could see both answers side by side. A
|
|
2049
|
+
* multi-statement document makes the gate `script` with empty args, so there is
|
|
2050
|
+
* nothing to compare and the token-level guards are what protect each
|
|
2051
|
+
* statement's own reading.
|
|
2052
|
+
*
|
|
2053
|
+
* Abstaining is again not a concession that the gate is right: on `;` the
|
|
2054
|
+
* ANALYSIS is device-correct (`;` ends a statement in RouterOS; it is not part
|
|
2055
|
+
* of a value). The gate is locked, so neither value is published.
|
|
2056
|
+
*
|
|
2057
|
+
* Cost, measured: a printable-ASCII differential over 8 templates finds 675
|
|
2058
|
+
* cases where both decide and 0 surviving mismatches with this in place, and the
|
|
2059
|
+
* corpus parity probe is unchanged at 83 both-decided, 0 contradictions — so
|
|
2060
|
+
* this fires on `;` and nothing else that has been found. Raised as a P1 in
|
|
2061
|
+
* review of #202c-1, after three character-level guards each closed only what
|
|
2062
|
+
* had been reported.
|
|
2063
|
+
*/
|
|
2064
|
+
function enforceGateParity(
|
|
2065
|
+
statements: readonly ExplainStatement[],
|
|
2066
|
+
canonical: CanonicalExecuteCommand,
|
|
2067
|
+
): ExplainStatement[] {
|
|
2068
|
+
const out = [...statements];
|
|
2069
|
+
if (canonical.mode !== "structured" || out.length !== 1) return out;
|
|
2070
|
+
const only = out[0];
|
|
2071
|
+
if (only?.kind !== "command" || only.arguments?.read !== true) return out;
|
|
2072
|
+
const same =
|
|
2073
|
+
JSON.stringify([only.command.args ?? {}, only.arguments.queries]) ===
|
|
2074
|
+
JSON.stringify([canonical.attributes, canonical.queries]);
|
|
2075
|
+
if (same) return out;
|
|
2076
|
+
const { args: _dropped, ...command } = only.command;
|
|
2077
|
+
out[0] = {
|
|
2078
|
+
...only,
|
|
2079
|
+
command,
|
|
2080
|
+
arguments: {
|
|
2081
|
+
read: false,
|
|
2082
|
+
why: "centrs's execute gate reads these bytes as a different argument list, and offline cannot prove which reading a device would apply",
|
|
2083
|
+
},
|
|
2084
|
+
};
|
|
2085
|
+
return out;
|
|
2086
|
+
}
|
|
2087
|
+
|
|
2088
|
+
/** One `[…]` substitution in the envelope's vocabulary. See {@link ExplainSubcommand}. */
|
|
2089
|
+
function subcommandOf(r: Resolution): ExplainSubcommand {
|
|
2090
|
+
// Q3 refused: the bracket is unreadable and Q6's reading of the same text
|
|
2091
|
+
// cannot promote it.
|
|
2092
|
+
const reading =
|
|
2093
|
+
r.unresolved !== undefined || r.path === null
|
|
2094
|
+
? {
|
|
2095
|
+
resolution: "unknown" as const,
|
|
2096
|
+
unresolved: r.unresolved ?? "no reading of this substitution",
|
|
2097
|
+
}
|
|
2098
|
+
: readingOf(
|
|
2099
|
+
resolveVerb(
|
|
2100
|
+
r.inner,
|
|
2101
|
+
r.context,
|
|
2102
|
+
rangeForResolution(r) ?? documentRange(r.inner),
|
|
2103
|
+
),
|
|
2104
|
+
);
|
|
2105
|
+
return {
|
|
2106
|
+
span: { start: r.span.start, end: r.span.end },
|
|
2107
|
+
context: r.context,
|
|
2108
|
+
...reading,
|
|
2109
|
+
candidates: r.candidates,
|
|
2110
|
+
contextCertain: r.contextCertain,
|
|
2111
|
+
depth: r.depth,
|
|
2112
|
+
ev: EV.subcommands,
|
|
2113
|
+
};
|
|
2114
|
+
}
|
|
2115
|
+
|
|
2116
|
+
/**
|
|
2117
|
+
* The `resolution`/`kind`/`command` fold, shared by statements and subcommands.
|
|
2118
|
+
*
|
|
2119
|
+
* Total over `VerbSplit` with no null-guard, because that module's union states
|
|
2120
|
+
* what each resolution carries: `navigation` has a path, `resolved` has a path
|
|
2121
|
+
* and a verb, a refusal has neither. A guard here would be an unreachable
|
|
2122
|
+
* branch pretending the invariant is unknown.
|
|
2123
|
+
*/
|
|
2124
|
+
function readingOf(split: VerbSplit): ExplainReading {
|
|
2125
|
+
if (split.resolution === "navigation")
|
|
2126
|
+
return {
|
|
2127
|
+
resolution: "resolved",
|
|
2128
|
+
kind: "menu",
|
|
2129
|
+
command: { path: split.path },
|
|
2130
|
+
};
|
|
2131
|
+
if (split.resolution === "resolved")
|
|
2132
|
+
return {
|
|
2133
|
+
resolution: "resolved",
|
|
2134
|
+
kind: "command",
|
|
2135
|
+
command: { path: split.path, verb: split.verb },
|
|
2136
|
+
};
|
|
2137
|
+
return { resolution: split.resolution, unresolved: split.why };
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
/** The tristate as the envelope renders it: a real `"unknown"`, never a boolean. */
|
|
2141
|
+
function renderWriteVerdict(verdict: WriteVerdict): boolean | "unknown" {
|
|
2142
|
+
return verdict === "unknown" ? "unknown" : verdict === "true";
|
|
2143
|
+
}
|
|
2144
|
+
|
|
2145
|
+
/**
|
|
2146
|
+
* Per-statement diagnostics — the abstentions, said out loud.
|
|
2147
|
+
*
|
|
2148
|
+
* A refusal is a first-class result, not an error: `ambiguous` and `unknown` are
|
|
2149
|
+
* the ratified vocabulary for "the input has several valid schema-free readings"
|
|
2150
|
+
* and "analysis cannot safely recover one". They are WARNINGS, never errors, so
|
|
2151
|
+
* the default `--fail-on error` does not fail a document whose only sin is being
|
|
2152
|
+
* unreadable without a schema — which is most of RouterOS scripting.
|
|
2153
|
+
*/
|
|
2154
|
+
function diagnosticsForStatement(s: ExplainStatement): ExplainDiagnostic[] {
|
|
2155
|
+
const out: ExplainDiagnostic[] = [];
|
|
2156
|
+
if (s.resolution === "ambiguous")
|
|
2157
|
+
out.push({
|
|
2158
|
+
code: "explain/canonicalizer/ambiguous-statement",
|
|
2159
|
+
severity: "warning",
|
|
2160
|
+
message:
|
|
2161
|
+
s.unresolved ??
|
|
2162
|
+
"menu or no-argument command: the same text offline, decidable only against a schema",
|
|
2163
|
+
span: s.span,
|
|
2164
|
+
ev: s.ev,
|
|
2165
|
+
});
|
|
2166
|
+
if (s.resolution === "unknown")
|
|
2167
|
+
out.push({
|
|
2168
|
+
code: "explain/canonicalizer/unresolved-statement",
|
|
2169
|
+
severity: "warning",
|
|
2170
|
+
message: s.unresolved ?? "no safe reading of this statement",
|
|
2171
|
+
span: s.span,
|
|
2172
|
+
ev: s.ev,
|
|
2173
|
+
});
|
|
2174
|
+
// The #192 signal: a reading that is CORRECT while the document context was
|
|
2175
|
+
// already lost. Info severity — the statement resolved without consuming the
|
|
2176
|
+
// context, so nothing about it is wrong; a consumer ranking readings or
|
|
2177
|
+
// offering completions is the one that needs to know.
|
|
2178
|
+
if (s.resolution === "resolved" && !s.contextCertain)
|
|
2179
|
+
out.push({
|
|
2180
|
+
code: "explain/canonicalizer/context-lost",
|
|
2181
|
+
severity: "info",
|
|
2182
|
+
message:
|
|
2183
|
+
"resolved without the document's menu context, which an earlier unreadable statement destroyed",
|
|
2184
|
+
span: s.span,
|
|
2185
|
+
ev: s.ev,
|
|
2186
|
+
});
|
|
2187
|
+
return out;
|
|
2188
|
+
}
|
|
2189
|
+
|
|
2190
|
+
/**
|
|
2191
|
+
* Only evidence something cites.
|
|
2192
|
+
*
|
|
2193
|
+
* A dangling entry would claim a probe ran for a fact that is not in the result
|
|
2194
|
+
* — the opposite of what the provenance table is for.
|
|
2195
|
+
*/
|
|
2196
|
+
function citedEvidence(
|
|
2197
|
+
structure: ExplainStructure,
|
|
2198
|
+
diagnostics: readonly ExplainDiagnostic[],
|
|
2199
|
+
spans: readonly ExplainSpan[],
|
|
2200
|
+
symbols: ExplainSymbols,
|
|
2201
|
+
values: ExplainValues,
|
|
2202
|
+
tokens?: readonly ExplainToken[] | readonly ExplainSpan[] | undefined,
|
|
2203
|
+
): ExplainEvidence[] {
|
|
2204
|
+
// `canonical` and `input` carry no `ev` of their own — they are whole-result
|
|
2205
|
+
// fields, not entries in a list — so their two passes are seeded here.
|
|
2206
|
+
const cited = new Set<string>([EV.canonical, EV.coordinates, structure.ev]);
|
|
2207
|
+
for (const s of structure.statements) cited.add(s.ev);
|
|
2208
|
+
for (const s of structure.statements)
|
|
2209
|
+
if (s.kind === "command" && s.transport !== undefined)
|
|
2210
|
+
cited.add(s.transport.ev);
|
|
2211
|
+
for (const s of structure.subcommands) cited.add(s.ev);
|
|
2212
|
+
for (const b of structure.blocks) cited.add(b.ev);
|
|
2213
|
+
for (const d of diagnostics) cited.add(d.ev);
|
|
2214
|
+
for (const s of spans) cited.add(s.ev);
|
|
2215
|
+
for (const occurrence of symbols.occurrences) cited.add(occurrence.ev);
|
|
2216
|
+
for (const occurrence of values.occurrences) {
|
|
2217
|
+
if (occurrence.facts.shapeHints !== undefined)
|
|
2218
|
+
cited.add(occurrence.facts.shapeHints.ev);
|
|
2219
|
+
if (occurrence.facts.observedType !== undefined)
|
|
2220
|
+
cited.add(occurrence.facts.observedType.ev);
|
|
2221
|
+
if (occurrence.facts.schemaType !== undefined)
|
|
2222
|
+
cited.add(occurrence.facts.schemaType.ev);
|
|
2223
|
+
}
|
|
2224
|
+
if (tokens !== undefined) for (const t of tokens) cited.add(t.ev);
|
|
2225
|
+
return Object.values(EVIDENCE)
|
|
2226
|
+
.filter((e) => cited.has(e.id))
|
|
2227
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
2228
|
+
}
|
|
2229
|
+
|
|
2230
|
+
/**
|
|
2231
|
+
* The offline envelope.
|
|
2232
|
+
*
|
|
2233
|
+
* `ok: true` whenever the analysis RAN, mirroring `check`: the diagnostics are
|
|
2234
|
+
* the data, and `ok: false` is reserved for genuine command failure (an
|
|
2235
|
+
* unresolvable target, a usage error). `via` is `null` because the offline
|
|
2236
|
+
* classifier plans each statement but does not open a protocol connection.
|
|
2237
|
+
*/
|
|
2238
|
+
export function explainEnvelope(
|
|
2239
|
+
input: string,
|
|
2240
|
+
options: ExplainEnvelopeOptions = {},
|
|
2241
|
+
): ExplainEnvelope {
|
|
2242
|
+
const data = explainCommand(input, {
|
|
2243
|
+
curl: options.curl,
|
|
2244
|
+
tokens: options.tokens,
|
|
2245
|
+
});
|
|
2246
|
+
const tips: Tip[] = [
|
|
2247
|
+
buildTip(
|
|
2248
|
+
"tip/explain-offline-only",
|
|
2249
|
+
"No router was given, so this is the canonicalizer's reading alone.",
|
|
2250
|
+
"Completion, schema, and `:parse` evidence come from a live target (`centrs explain <router> '<input>'`), which is phase 2 and not accepted yet.",
|
|
2251
|
+
),
|
|
2252
|
+
...(options.tips ?? []),
|
|
2253
|
+
];
|
|
2254
|
+
const warnings: Warning[] = [...(options.warnings ?? [])];
|
|
2255
|
+
const meta: EnvelopeMeta<ExplainOperationMeta> = {
|
|
2256
|
+
target: {},
|
|
2257
|
+
via: null,
|
|
2258
|
+
settings: settingsMeta(options.format),
|
|
2259
|
+
validation: { enabled: false, result: "skipped" },
|
|
2260
|
+
operation: {
|
|
2261
|
+
command: "explain",
|
|
2262
|
+
mode: "offline",
|
|
2263
|
+
statementCount: data.structure.statementCount,
|
|
2264
|
+
verdict: data.verdict,
|
|
2265
|
+
},
|
|
2266
|
+
};
|
|
2267
|
+
return { ok: true, data, warnings, tips, meta };
|
|
2268
|
+
}
|
|
2269
|
+
|
|
2270
|
+
/**
|
|
2271
|
+
* The facets that need a device, requested offline.
|
|
2272
|
+
*
|
|
2273
|
+
* `--complete` and `--schema` are enumeration, and enumeration is live evidence:
|
|
2274
|
+
* offline has no schema snapshot to consult (the ratified no-static-snapshot
|
|
2275
|
+
* decision), so the honest answer is an empty result plus a tip naming what
|
|
2276
|
+
* would answer it. Fabricating candidates from a bundled table is exactly the
|
|
2277
|
+
* failure mode the decision exists to prevent.
|
|
2278
|
+
*/
|
|
2279
|
+
export function buildExplainFacetTip(facets: readonly string[]): Tip {
|
|
2280
|
+
const named = facets.join(" and ");
|
|
2281
|
+
return buildTip(
|
|
2282
|
+
"tip/explain-live-facets",
|
|
2283
|
+
`${named} enumerate what a device accepts, and no router was given — so nothing is enumerated here.`,
|
|
2284
|
+
"Name a router once live evidence lands (phase 2); until then use `rosetta` for documented RouterOS schema facts.",
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
|
|
2288
|
+
export const explainOutputFormats = ["text", "json", "yaml"] as const;
|
|
2289
|
+
export type ExplainOutputFormat = (typeof explainOutputFormats)[number];
|
|
2290
|
+
|
|
2291
|
+
/**
|
|
2292
|
+
* Envelope-level options. Curl is forwarded to the analysis as an opt-in
|
|
2293
|
+
* rendering; the remaining fields are invocation metadata the analysis does
|
|
2294
|
+
* not need. Phase 2's `{ target, facets }` remains a separate live concern.
|
|
2295
|
+
*/
|
|
2296
|
+
export interface ExplainEnvelopeOptions {
|
|
2297
|
+
format?: ResolvedSetting<ExplainOutputFormat>;
|
|
2298
|
+
/** Include curl rendering for API-candidate statements. */
|
|
2299
|
+
curl?: boolean;
|
|
2300
|
+
/**
|
|
2301
|
+
* Include the gapless token partition behind `data.tokens[]`.
|
|
2302
|
+
*
|
|
2303
|
+
* Mirrors `--complete` / `--schema` / `--curl` facet pattern. The vocabulary
|
|
2304
|
+
* is `ExplainTokenClass` (#264 B5).
|
|
2305
|
+
*/
|
|
2306
|
+
tokens?: boolean;
|
|
2307
|
+
tips?: readonly Tip[];
|
|
2308
|
+
/** Facts about the INVOCATION, not the analysis (e.g. an ignored stdin). */
|
|
2309
|
+
warnings?: readonly Warning[];
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2312
|
+
/**
|
|
2313
|
+
* The host process environment, or an empty one where there is no host.
|
|
2314
|
+
*
|
|
2315
|
+
* A bare `Bun.env` default parameter is what a browser consumer of this module
|
|
2316
|
+
* trips over (#312): the reference is evaluated on the first settings-ladder
|
|
2317
|
+
* call, and `Bun` does not exist there. `typeof` is safe on an undeclared
|
|
2318
|
+
* identifier, so the guard costs nothing and changes nothing under Bun — the
|
|
2319
|
+
* ladder still reads the real environment, and a browser caller that never
|
|
2320
|
+
* passes one gets the documented "no env tier" behavior rather than a crash.
|
|
2321
|
+
*
|
|
2322
|
+
* Only the offline analysis entry runs outside Bun; the other commands keep
|
|
2323
|
+
* their `Bun.env` defaults because they open connections regardless.
|
|
2324
|
+
*/
|
|
2325
|
+
function hostEnv(): Record<string, string | undefined> {
|
|
2326
|
+
return typeof Bun === "undefined" ? {} : Bun.env;
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function settingsMeta(
|
|
2330
|
+
format: ResolvedSetting<ExplainOutputFormat> | undefined,
|
|
2331
|
+
): CommonSettingsMeta {
|
|
2332
|
+
return format === undefined ? {} : { format: toCoreSource(format.source) };
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
/**
|
|
2336
|
+
* Resolve the render format across the settings ladder: `centrs.env` config <
|
|
2337
|
+
* `CENTRS_FORMAT` env < the CLI flag (`docs/CONSTITUTION.md` → Settings
|
|
2338
|
+
* precedence). Throws `settings/invalid-format` on a bad value from ANY tier —
|
|
2339
|
+
* a typo in `centrs.env` that silently rendered text would be the same silent
|
|
2340
|
+
* fallback the constitution forbids on the CLI.
|
|
2341
|
+
*/
|
|
2342
|
+
export function resolveExplainFormat(
|
|
2343
|
+
explicit: string | undefined,
|
|
2344
|
+
env: Record<string, string | undefined> = hostEnv(),
|
|
2345
|
+
config: Record<string, string | undefined> = {},
|
|
2346
|
+
): ResolvedSetting<ExplainOutputFormat> {
|
|
2347
|
+
return resolveStringSetting(
|
|
2348
|
+
explicit,
|
|
2349
|
+
env,
|
|
2350
|
+
"CENTRS_FORMAT",
|
|
2351
|
+
"text",
|
|
2352
|
+
"format",
|
|
2353
|
+
parseExplainOutputFormat,
|
|
2354
|
+
undefined,
|
|
2355
|
+
config,
|
|
2356
|
+
) as ResolvedSetting<ExplainOutputFormat>;
|
|
2357
|
+
}
|
|
2358
|
+
|
|
2359
|
+
function parseExplainOutputFormat(value: string): ExplainOutputFormat {
|
|
2360
|
+
if ((explainOutputFormats as readonly string[]).includes(value))
|
|
2361
|
+
return value as ExplainOutputFormat;
|
|
2362
|
+
throw new CentrsError({
|
|
2363
|
+
code: "settings/invalid-format",
|
|
2364
|
+
summary: `Unsupported explain output format: ${value}`,
|
|
2365
|
+
remediation: `Choose one of ${explainOutputFormats.join(", ")}.`,
|
|
2366
|
+
context: { format: value },
|
|
2367
|
+
});
|
|
2368
|
+
}
|
|
2369
|
+
|
|
2370
|
+
/** `--fail-on` thresholds, in the order the CLI documents them. */
|
|
2371
|
+
export const explainFailOnLevels = ["error", "warning", "never"] as const;
|
|
2372
|
+
export type ExplainFailOn = (typeof explainFailOnLevels)[number];
|
|
2373
|
+
|
|
2374
|
+
/**
|
|
2375
|
+
* Exit `2` when the verdict meets the threshold, `0` otherwise — the `check`
|
|
2376
|
+
* pattern (`commands/explain/README.md` → Surface).
|
|
2377
|
+
*
|
|
2378
|
+
* Note what `--fail-on error` does NOT fail on: an `ambiguous`/`unknown`
|
|
2379
|
+
* statement is a warning by construction, so a script that is merely unreadable
|
|
2380
|
+
* without a schema still exits `0`. That is the whole reason the two verdicts
|
|
2381
|
+
* are separate — a resolution is not a severity.
|
|
2382
|
+
*/
|
|
2383
|
+
export function explainExitCode(
|
|
2384
|
+
verdict: ExplainVerdict,
|
|
2385
|
+
failOn: ExplainFailOn,
|
|
2386
|
+
): 0 | 2 {
|
|
2387
|
+
if (failOn === "never") return 0;
|
|
2388
|
+
if (failOn === "warning") return verdict === "pass" ? 0 : 2;
|
|
2389
|
+
return verdict === "fail" ? 2 : 0;
|
|
2390
|
+
}
|
|
2391
|
+
|
|
2392
|
+
export type ExplainErrorEnvelope = CentrsErrorEnvelope<ExplainOperationMeta>;
|
|
2393
|
+
|
|
2394
|
+
/**
|
|
2395
|
+
* A usage failure, in the same envelope. No `operation` block: nothing was
|
|
2396
|
+
* analyzed, so there is no statement count or verdict to report, and an
|
|
2397
|
+
* invented one would read as an analysis that ran.
|
|
2398
|
+
*/
|
|
2399
|
+
export function buildExplainErrorEnvelope(
|
|
2400
|
+
error: unknown,
|
|
2401
|
+
tips: readonly Tip[] = [],
|
|
2402
|
+
format?: ResolvedSetting<ExplainOutputFormat>,
|
|
2403
|
+
): ExplainErrorEnvelope {
|
|
2404
|
+
const centrs =
|
|
2405
|
+
error instanceof CentrsError
|
|
2406
|
+
? error
|
|
2407
|
+
: new CentrsError({
|
|
2408
|
+
code: "internal/unhandled",
|
|
2409
|
+
summary: error instanceof Error ? error.message : String(error),
|
|
2410
|
+
remediation:
|
|
2411
|
+
"This is a centrs bug; re-run with --verbose and file an issue with the printed code.",
|
|
2412
|
+
});
|
|
2413
|
+
return {
|
|
2414
|
+
ok: false,
|
|
2415
|
+
error: serializeCentrsError(centrs),
|
|
2416
|
+
warnings: [],
|
|
2417
|
+
tips,
|
|
2418
|
+
meta: { target: {}, via: null, settings: settingsMeta(format) },
|
|
2419
|
+
};
|
|
2420
|
+
}
|
|
2421
|
+
|
|
2422
|
+
export function renderExplainEnvelope(
|
|
2423
|
+
envelope: ExplainEnvelope | ExplainErrorEnvelope,
|
|
2424
|
+
format: ExplainOutputFormat,
|
|
2425
|
+
): string {
|
|
2426
|
+
if (format === "json") return JSON.stringify(envelope, null, 2);
|
|
2427
|
+
if (format === "yaml") return toYaml(envelope);
|
|
2428
|
+
return renderExplainText(envelope);
|
|
2429
|
+
}
|
|
2430
|
+
|
|
2431
|
+
function span(s: ExplainSpanRange): string {
|
|
2432
|
+
return `[${s.start},${s.end})`;
|
|
2433
|
+
}
|
|
2434
|
+
|
|
2435
|
+
/** Tips in the shared text shape (`src/cli/missing-target.ts` renders the same block). */
|
|
2436
|
+
function tipLines(tips: readonly Tip[]): string[] {
|
|
2437
|
+
if (tips.length === 0) return [];
|
|
2438
|
+
const lines = ["", "Tips:"];
|
|
2439
|
+
for (const tip of tips) {
|
|
2440
|
+
lines.push(` - [${tip.code}] ${tip.message}`);
|
|
2441
|
+
if (tip.fix) lines.push(` fix: ${tip.fix}`);
|
|
2442
|
+
}
|
|
2443
|
+
return lines;
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
/**
|
|
2447
|
+
* One statement or subcommand: what it resolved to, or why it did not.
|
|
2448
|
+
*
|
|
2449
|
+
* `path=`/`verb=` rather than a bare `<path> <verb>`, because a scripting
|
|
2450
|
+
* command resolves to path `/` plus its verb (`:foreach` is `/` + `foreach`)
|
|
2451
|
+
* and `/ foreach` reads like a typo, while `/foreach` reads like a menu.
|
|
2452
|
+
*/
|
|
2453
|
+
function renderReading(reading: ExplainReading): string {
|
|
2454
|
+
if (reading.resolution !== "resolved")
|
|
2455
|
+
return `${reading.resolution.padEnd(9)} ${reading.unresolved}`;
|
|
2456
|
+
return reading.kind === "menu"
|
|
2457
|
+
? `resolved menu path=${reading.command.path}`
|
|
2458
|
+
: `resolved command path=${reading.command.path} verb=${reading.command.verb}`;
|
|
2459
|
+
}
|
|
2460
|
+
|
|
2461
|
+
function renderSymbol(occurrence: ExplainSymbolOccurrence): string {
|
|
2462
|
+
const binding =
|
|
2463
|
+
occurrence.bindingIds.length === 0
|
|
2464
|
+
? ""
|
|
2465
|
+
: ` bindings=${occurrence.bindingIds.join(",")}`;
|
|
2466
|
+
const value =
|
|
2467
|
+
occurrence.valueId !== undefined ? ` value=${occurrence.valueId}` : "";
|
|
2468
|
+
const reaching =
|
|
2469
|
+
occurrence.reachingValueIds !== undefined
|
|
2470
|
+
? ` reaching=[${occurrence.reachingValueIds.join(",")}]${occurrence.reachingUnknown ? "+unknown" : ""}`
|
|
2471
|
+
: occurrence.reachingUnknown
|
|
2472
|
+
? " reaching=unknown"
|
|
2473
|
+
: "";
|
|
2474
|
+
const note = occurrence.note === undefined ? "" : ` (${occurrence.note})`;
|
|
2475
|
+
return `${(occurrence.class ?? "unknown").padEnd(9)} ${occurrence.role.padEnd(11)} name=${JSON.stringify(occurrence.name)}${binding}${value}${reaching}${note}`;
|
|
2476
|
+
}
|
|
2477
|
+
|
|
2478
|
+
function renderValue(occurrence: ExplainValueOccurrence): string {
|
|
2479
|
+
const name = occurrence.name === undefined ? "" : ` name=${occurrence.name}`;
|
|
2480
|
+
const shapes = occurrence.facts.shapeHints?.values.join("|") ?? "unknown";
|
|
2481
|
+
const parent =
|
|
2482
|
+
occurrence.parent === undefined ? "" : ` in=${occurrence.parent}`;
|
|
2483
|
+
return `${occurrence.kind.padEnd(10)}${name}${parent} shapes=${shapes}`;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
/**
|
|
2487
|
+
* The human format.
|
|
2488
|
+
*
|
|
2489
|
+
* Deliberately not a pretty-print of `data`: the text surface answers "what did
|
|
2490
|
+
* I write and what is wrong with it", so it leads with the two verdicts and the
|
|
2491
|
+
* gate, and drops empty sections. `--json` is the complete shape.
|
|
2492
|
+
*/
|
|
2493
|
+
function renderExplainText(
|
|
2494
|
+
envelope: ExplainEnvelope | ExplainErrorEnvelope,
|
|
2495
|
+
): string {
|
|
2496
|
+
const lines: string[] = [];
|
|
2497
|
+
if (!envelope.ok) {
|
|
2498
|
+
lines.push(`[${envelope.error.code}] ${envelope.error.summary}`);
|
|
2499
|
+
if (envelope.error.remediation)
|
|
2500
|
+
lines.push(`Fix: ${envelope.error.remediation}`);
|
|
2501
|
+
if (envelope.error.detailsUrl)
|
|
2502
|
+
lines.push(`Details: ${envelope.error.detailsUrl}`);
|
|
2503
|
+
lines.push(...tipLines(envelope.tips));
|
|
2504
|
+
return lines.join("\n");
|
|
2505
|
+
}
|
|
2506
|
+
|
|
2507
|
+
const { data } = envelope;
|
|
2508
|
+
const { canonical, structure } = data;
|
|
2509
|
+
lines.push(
|
|
2510
|
+
`verdict: ${data.verdict} — ${structure.statementCount} statement(s), ${data.diagnostics.length} diagnostic(s)`,
|
|
2511
|
+
);
|
|
2512
|
+
const argCount = Object.keys(canonical.args).length;
|
|
2513
|
+
lines.push(
|
|
2514
|
+
`gate: ${canonical.mode}${canonical.path ? ` ${canonical.path}` : ""}${canonical.verb ? ` ${canonical.verb}` : ""} · ${argCount} arg(s)${canonical.writeShaped ? " · write-shaped" : ""}`,
|
|
2515
|
+
);
|
|
2516
|
+
lines.push(`write: ${structure.containsWrite}`);
|
|
2517
|
+
if (data.input.normalized)
|
|
2518
|
+
lines.push(
|
|
2519
|
+
`input: ${data.input.bytes} byte(s), normalized (non-ASCII stood in for one-for-one)`,
|
|
2520
|
+
);
|
|
2521
|
+
if (structure.statements.length > 0) {
|
|
2522
|
+
lines.push("statements:");
|
|
2523
|
+
for (const s of structure.statements) {
|
|
2524
|
+
lines.push(
|
|
2525
|
+
` ${span(s.span).padEnd(12)} ${renderReading(s)}${s.kind === "command" && s.transport !== undefined ? ` via=${s.transport.classification}` : ""}${s.contextCertain ? "" : " (context lost)"}`,
|
|
2526
|
+
);
|
|
2527
|
+
if (s.kind === "command" && s.transport?.centrs !== undefined)
|
|
2528
|
+
lines.push(` centrs: ${s.transport.centrs}`);
|
|
2529
|
+
if (s.kind === "command" && s.transport?.curl !== undefined)
|
|
2530
|
+
lines.push(` curl: ${s.transport.curl}`);
|
|
2531
|
+
// A refusal must say why in the DEFAULT surface. `api-candidate` and
|
|
2532
|
+
// `execute` render the command they chose, which is the reason; only
|
|
2533
|
+
// `unknown` renders nothing, and the basis is the actionable half.
|
|
2534
|
+
if (s.kind === "command" && s.transport?.classification === "unknown")
|
|
2535
|
+
lines.push(` why: ${s.transport.basis}`);
|
|
2536
|
+
}
|
|
2537
|
+
}
|
|
2538
|
+
if (structure.subcommands.length > 0) {
|
|
2539
|
+
lines.push("subcommands:");
|
|
2540
|
+
for (const s of structure.subcommands)
|
|
2541
|
+
lines.push(
|
|
2542
|
+
` ${span(s.span).padEnd(12)} ${renderReading(s)} (in ${s.context}, depth ${s.depth})`,
|
|
2543
|
+
);
|
|
2544
|
+
}
|
|
2545
|
+
if (structure.blocks.length > 0) {
|
|
2546
|
+
lines.push("blocks:");
|
|
2547
|
+
for (const b of structure.blocks)
|
|
2548
|
+
lines.push(` ${span(b.span).padEnd(12)} ${b.name}`);
|
|
2549
|
+
}
|
|
2550
|
+
const comments = data.spans.filter((entry) => entry.class === "comment");
|
|
2551
|
+
if (comments.length > 0) {
|
|
2552
|
+
lines.push("comments:");
|
|
2553
|
+
for (const comment of comments)
|
|
2554
|
+
lines.push(` ${span(comment).padEnd(12)} comment`);
|
|
2555
|
+
}
|
|
2556
|
+
if (data.symbols.occurrences.length > 0) {
|
|
2557
|
+
lines.push("symbols:");
|
|
2558
|
+
for (const occurrence of data.symbols.occurrences)
|
|
2559
|
+
lines.push(
|
|
2560
|
+
` ${span(occurrence.span).padEnd(12)} ${renderSymbol(occurrence)}`,
|
|
2561
|
+
);
|
|
2562
|
+
}
|
|
2563
|
+
if (data.values.occurrences.length > 0) {
|
|
2564
|
+
lines.push("values:");
|
|
2565
|
+
for (const occurrence of data.values.occurrences)
|
|
2566
|
+
lines.push(
|
|
2567
|
+
` ${span(occurrence.span).padEnd(12)} ${renderValue(occurrence)}`,
|
|
2568
|
+
);
|
|
2569
|
+
}
|
|
2570
|
+
// `--tokens` is opt-in, so it must CHANGE this surface — a flag that only
|
|
2571
|
+
// moves `--json` reads as a no-op from the default format. The header is the
|
|
2572
|
+
// #289 deliverable (a coverage number) for this one input; the rows are the
|
|
2573
|
+
// partition itself, `unclassified` runs included.
|
|
2574
|
+
if (data.tokens !== undefined) {
|
|
2575
|
+
const classified = data.tokens.reduce(
|
|
2576
|
+
(sum, t) => sum + (t.class === "unclassified" ? 0 : t.end - t.start),
|
|
2577
|
+
0,
|
|
2578
|
+
);
|
|
2579
|
+
const bytes = data.input.bytes;
|
|
2580
|
+
const pct = bytes === 0 ? 0 : (classified / bytes) * 100;
|
|
2581
|
+
lines.push(
|
|
2582
|
+
`tokens: ${data.tokens.length} token(s), ${classified}/${bytes} byte(s) classified (${pct.toFixed(1)}%)`,
|
|
2583
|
+
);
|
|
2584
|
+
for (const t of data.tokens)
|
|
2585
|
+
lines.push(` ${span(t).padEnd(12)} ${t.class}`);
|
|
2586
|
+
}
|
|
2587
|
+
if (data.diagnostics.length > 0) {
|
|
2588
|
+
lines.push("diagnostics:");
|
|
2589
|
+
for (const d of data.diagnostics)
|
|
2590
|
+
lines.push(
|
|
2591
|
+
` ${d.severity.padEnd(7)} ${span(d.span).padEnd(12)} ${d.code}: ${d.message}`,
|
|
2592
|
+
);
|
|
2593
|
+
}
|
|
2594
|
+
lines.push(
|
|
2595
|
+
`evidence: ${data.evidence.map((e) => `${e.id} ${e.probe} (${e.basis})`).join(", ")}`,
|
|
2596
|
+
);
|
|
2597
|
+
lines.push(`runtimeAcceptance: ${data.runtimeAcceptance}`);
|
|
2598
|
+
for (const w of envelope.warnings)
|
|
2599
|
+
lines.push(`warning: [${w.code}] ${w.message}`);
|
|
2600
|
+
lines.push(...tipLines(envelope.tips));
|
|
2601
|
+
return lines.join("\n");
|
|
2602
|
+
}
|