@tikoci/centrs 0.1.4 → 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 +309 -0
- package/GLOSSARY.txt +40 -1
- package/README.md +8 -4
- package/commands/AGENTS.md +7 -4
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +37 -8
- package/commands/api/examples.md +31 -1
- package/commands/execute/README.md +39 -10
- package/commands/execute/examples.md +79 -7
- package/commands/explain/README.md +1195 -155
- package/commands/explain/examples.md +99 -3
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/terminal/examples.md +7 -1
- package/docs/CLI.md +2 -2
- package/docs/CONSTITUTION.md +72 -15
- package/docs/MATRIX.md +89 -52
- package/docs/errors/validation/syntax.md +29 -4
- package/package.json +13 -2
- package/src/api-fanout.ts +17 -4
- package/src/api.ts +254 -67
- package/src/cli/api.ts +1 -1
- package/src/cli/btest.ts +1 -1
- package/src/cli/explain.ts +1 -1
- package/src/cli/terminal.ts +1 -1
- package/src/core/envelope.ts +53 -0
- package/src/core/error-catalog.ts +2 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/execute-fanout.ts +18 -4
- package/src/execute.ts +225 -244
- package/src/explain/arg-tokens.ts +62 -25
- package/src/explain/args.ts +398 -52
- package/src/explain/blocks.ts +54 -11
- package/src/explain/brace-tokens.ts +4 -3
- package/src/explain/catalog.ts +42 -44
- package/src/explain/coordinates.ts +159 -0
- package/src/explain/defects.ts +2 -1
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/menus.ts +9 -5
- package/src/explain/operator-tokens.ts +13 -7
- package/src/explain/operators.ts +27 -20
- package/src/explain/path-tokens.ts +1 -1
- package/src/explain/pathresolve.ts +358 -113
- package/src/explain/quoted-string.ts +110 -20
- package/src/explain/scope-brace.ts +175 -22
- package/src/explain/segment.ts +429 -21
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +14 -4
- package/src/explain/symbol-values.ts +209 -173
- package/src/explain/symbols.ts +21 -1
- package/src/explain/transport.ts +17 -0
- package/src/explain/value-tokens.ts +15 -12
- package/src/explain/verbsplit.ts +165 -23
- package/src/explain/write.ts +47 -6
- package/src/explain.ts +522 -72
- package/src/index.ts +19 -2
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/retrieve-fanout.ts +1 -1
- package/src/retrieve.ts +1 -59
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +1 -1
- package/src/transfer.ts +1 -1
package/src/explain.ts
CHANGED
|
@@ -77,6 +77,22 @@
|
|
|
77
77
|
* type-axis question before it is a lexical one.
|
|
78
78
|
* - **`runtimeAcceptance` is always `"not-proven"`**, offline and live alike.
|
|
79
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.
|
|
80
96
|
*/
|
|
81
97
|
|
|
82
98
|
import type {
|
|
@@ -86,22 +102,26 @@ import type {
|
|
|
86
102
|
EnvelopeMeta,
|
|
87
103
|
} from "./core/envelope.ts";
|
|
88
104
|
import { buildTip, type Tip, type Warning } from "./core/envelope.ts";
|
|
89
|
-
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
90
105
|
import {
|
|
91
106
|
type CanonicalExecuteCommand,
|
|
92
107
|
canonicalizeExecuteCommand,
|
|
93
108
|
isWriteShaped,
|
|
94
|
-
} from "./execute.ts";
|
|
109
|
+
} from "./core/execute-command.ts";
|
|
110
|
+
import { toYaml } from "./core/yaml.ts";
|
|
111
|
+
import { CentrsError, serializeCentrsError } from "./errors.ts";
|
|
95
112
|
import { argSpans } from "./explain/arg-tokens.ts";
|
|
96
113
|
import {
|
|
97
114
|
type ArgumentKind,
|
|
115
|
+
type Argument as ExplainArgument,
|
|
98
116
|
invalidCommandBraceOffsets,
|
|
99
117
|
lexArguments,
|
|
118
|
+
lexArgumentTokens,
|
|
100
119
|
lexValueAnchors,
|
|
101
120
|
type ValueAnchorKind,
|
|
102
121
|
} from "./explain/args.ts";
|
|
103
122
|
import { scopeBlocks } from "./explain/blocks.ts";
|
|
104
123
|
import { braceSpans } from "./explain/brace-tokens.ts";
|
|
124
|
+
import { lookupPath } from "./explain/catalog.ts";
|
|
105
125
|
import {
|
|
106
126
|
analyzeCoordinates,
|
|
107
127
|
type CoordinateAnalysis,
|
|
@@ -111,16 +131,28 @@ import {
|
|
|
111
131
|
type DefectCode,
|
|
112
132
|
isPositionalFact,
|
|
113
133
|
} from "./explain/defects.ts";
|
|
134
|
+
import { escapeSpans } from "./explain/escape-tokens.ts";
|
|
114
135
|
import { isKnownMenuPath } from "./explain/is-known-menu.ts";
|
|
115
136
|
import { operatorSpans } from "./explain/operator-tokens.ts";
|
|
116
137
|
import { type PathTokenCandidate, pathSpans } from "./explain/path-tokens.ts";
|
|
117
138
|
import {
|
|
118
139
|
type Resolution,
|
|
140
|
+
rangeForResolution,
|
|
119
141
|
resolveDocument,
|
|
120
142
|
resolveStatements,
|
|
121
143
|
} from "./explain/pathresolve.ts";
|
|
122
|
-
import {
|
|
123
|
-
import {
|
|
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";
|
|
124
156
|
import { stringSpans } from "./explain/string-tokens.ts";
|
|
125
157
|
import { augmentSymbolOccurrences } from "./explain/symbol-values.ts";
|
|
126
158
|
import {
|
|
@@ -138,6 +170,7 @@ import {
|
|
|
138
170
|
type DocumentVerbSplit,
|
|
139
171
|
resolveVerb,
|
|
140
172
|
resolveVerbsFromStatements,
|
|
173
|
+
VERBS,
|
|
141
174
|
type VerbSplit,
|
|
142
175
|
} from "./explain/verbsplit.ts";
|
|
143
176
|
import {
|
|
@@ -149,7 +182,6 @@ import {
|
|
|
149
182
|
resolveStringSetting,
|
|
150
183
|
toCoreSource,
|
|
151
184
|
} from "./resolver/settings.ts";
|
|
152
|
-
import { toYaml } from "./retrieve.ts";
|
|
153
185
|
|
|
154
186
|
/** A half-open analyzed-byte span, the coordinate contract of `coordinates.ts`. */
|
|
155
187
|
export interface ExplainSpanRange {
|
|
@@ -449,18 +481,29 @@ export type TokenFill = readonly ExplainToken[];
|
|
|
449
481
|
* Byte-exact, inheriting the #215/#252 offset discipline. Offsets are on the
|
|
450
482
|
* analyzed text; `input.positionMap` applies when normalized.
|
|
451
483
|
*
|
|
452
|
-
* `class` is
|
|
453
|
-
* not
|
|
454
|
-
* `unclassified
|
|
455
|
-
*
|
|
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.
|
|
456
488
|
*
|
|
457
489
|
* B2 so far: `dir` / `cmd` (resolved path and verb bytes, including valid path
|
|
458
490
|
* slashes), `operator` (26 spellings + 2 aliases, `syntax-meta` is a residual
|
|
459
|
-
* merge, never a source, #255), `arg` (argument names and
|
|
460
|
-
* by `args.ts`), and `value`
|
|
461
|
-
*
|
|
462
|
-
*
|
|
463
|
-
*
|
|
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.
|
|
464
507
|
*/
|
|
465
508
|
export type ExplainTokenClass =
|
|
466
509
|
| ExplainSpanClass
|
|
@@ -468,14 +511,37 @@ export type ExplainTokenClass =
|
|
|
468
511
|
| "cmd"
|
|
469
512
|
| "operator"
|
|
470
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"
|
|
471
524
|
| "value"
|
|
472
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"
|
|
473
538
|
| "brace"
|
|
474
539
|
| "unclassified";
|
|
475
540
|
|
|
476
541
|
export interface ExplainToken extends ExplainSpanRange {
|
|
477
542
|
/**
|
|
478
|
-
*
|
|
543
|
+
* The vocabulary #264 B5 settled — see {@link ExplainTokenClass} for what
|
|
544
|
+
* earns a class and why the remaining merges are deliberate.
|
|
479
545
|
* `unclassified` means no analyzer claimed this byte.
|
|
480
546
|
*/
|
|
481
547
|
class: ExplainTokenClass;
|
|
@@ -626,8 +692,8 @@ export interface ExplainData {
|
|
|
626
692
|
* `--schema` / `--curl` facet pattern. Every byte belongs to exactly one
|
|
627
693
|
* token, sorted by `start`, with no gaps and no overlaps:
|
|
628
694
|
* `tokens.map(t => input.slice(t.start, t.end)).join("") === input`.
|
|
629
|
-
*
|
|
630
|
-
* claims is `unclassified`.
|
|
695
|
+
* `class` is the vocabulary #264 B5 settled ({@link ExplainTokenClass});
|
|
696
|
+
* every byte no analyzer claims is `unclassified`.
|
|
631
697
|
*/
|
|
632
698
|
tokens?: ExplainToken[];
|
|
633
699
|
diagnostics: ExplainDiagnostic[];
|
|
@@ -678,6 +744,8 @@ const EV = {
|
|
|
678
744
|
paths: "e12",
|
|
679
745
|
strings: "e13",
|
|
680
746
|
braces: "e14",
|
|
747
|
+
separators: "e15",
|
|
748
|
+
escapes: "e16",
|
|
681
749
|
} as const;
|
|
682
750
|
|
|
683
751
|
type EvidenceKey = keyof typeof EV;
|
|
@@ -792,6 +860,26 @@ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
|
|
|
792
860
|
basis: "heuristic",
|
|
793
861
|
outcome: "ok",
|
|
794
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
|
+
},
|
|
795
883
|
braces: {
|
|
796
884
|
id: EV.braces,
|
|
797
885
|
source: "canonicalizer",
|
|
@@ -799,6 +887,19 @@ const EVIDENCE: Record<EvidenceKey, ExplainEvidence> = {
|
|
|
799
887
|
basis: "heuristic",
|
|
800
888
|
outcome: "ok",
|
|
801
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
|
+
},
|
|
802
903
|
};
|
|
803
904
|
|
|
804
905
|
/** Diagnostic rendering for each defect class. */
|
|
@@ -837,6 +938,14 @@ const DEFECT_DIAGNOSTICS: Record<
|
|
|
837
938
|
message: () =>
|
|
838
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",
|
|
839
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
|
+
},
|
|
840
949
|
// centrs's own resource bound, not a RouterOS rule. The input may be entirely
|
|
841
950
|
// legal; what is reported is that the analyzer stopped descending, so the
|
|
842
951
|
// honest severity is a warning about incomplete analysis.
|
|
@@ -857,6 +966,38 @@ const DEFECT_DIAGNOSTICS: Record<
|
|
|
857
966
|
},
|
|
858
967
|
};
|
|
859
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
|
+
|
|
860
1001
|
/** A defect plus the analysis pass that raised it. */
|
|
861
1002
|
interface AttributedDefect {
|
|
862
1003
|
defect: Defect;
|
|
@@ -910,6 +1051,17 @@ function attributeDefects(
|
|
|
910
1051
|
|
|
911
1052
|
/** Build `input.positionMap`: coalesced identity runs, one entry per non-ASCII code point. */
|
|
912
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
|
+
];
|
|
913
1065
|
const out: ExplainPositionMapEntry[] = [];
|
|
914
1066
|
let run: ExplainPositionMapEntry | null = null;
|
|
915
1067
|
for (const r of a.runs) {
|
|
@@ -972,8 +1124,8 @@ export function residualRanges(
|
|
|
972
1124
|
*
|
|
973
1125
|
* Every byte of `[0, bytes)` belongs to exactly one token: `spans` are
|
|
974
1126
|
* placed sorted, no gaps become `unclassified`, and the result is sorted by
|
|
975
|
-
* `start` with no overlaps and `join(slice) === input`.
|
|
976
|
-
*
|
|
1127
|
+
* `start` with no overlaps and `join(slice) === input`. See
|
|
1128
|
+
* {@link ExplainTokenClass} for what `class` may be and why.
|
|
977
1129
|
*
|
|
978
1130
|
* B2: `buildTokens` now takes an **ordered list of fills**. Fill 0 claims
|
|
979
1131
|
* first, fill 1 sees only the residual of fill 0, and so on — the argument
|
|
@@ -1092,8 +1244,8 @@ export interface ExplainCommandOptions {
|
|
|
1092
1244
|
* Emit the total, gapless token partition behind `data.tokens[]`.
|
|
1093
1245
|
*
|
|
1094
1246
|
* Mirrors `--complete` / `--schema` / `--curl`: an opt-in facet whose
|
|
1095
|
-
* presence changes the result.
|
|
1096
|
-
* #264 B5.
|
|
1247
|
+
* presence changes the result. See {@link ExplainTokenClass} for the
|
|
1248
|
+
* vocabulary `class` draws on (#264 B5).
|
|
1097
1249
|
*/
|
|
1098
1250
|
tokens?: boolean;
|
|
1099
1251
|
}
|
|
@@ -1113,14 +1265,47 @@ export function explainCommand(
|
|
|
1113
1265
|
// one byte standing in for every non-ASCII one, so an index into it IS a
|
|
1114
1266
|
// document byte offset. Decoding it once here keeps `statementOf` and the
|
|
1115
1267
|
// string-escape walk from re-deriving the same string.
|
|
1116
|
-
const analyzed =
|
|
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);
|
|
1117
1282
|
const segmented = segmentStatements(input);
|
|
1118
1283
|
const statementAnalysis = resolveStatements(input);
|
|
1119
|
-
const
|
|
1284
|
+
const sourceRange = coordinates.ascii ? analyzedRange : undefined;
|
|
1285
|
+
const verbs = resolveVerbsFromStatements(statementAnalysis, sourceRange);
|
|
1120
1286
|
const brackets = resolveDocument(input);
|
|
1121
|
-
const write = containsWriteFromAnalyses(
|
|
1287
|
+
const write = containsWriteFromAnalyses(
|
|
1288
|
+
statementAnalysis,
|
|
1289
|
+
brackets,
|
|
1290
|
+
sourceRange,
|
|
1291
|
+
);
|
|
1122
1292
|
const symbols = resolveSymbols(input);
|
|
1123
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
|
+
|
|
1124
1309
|
// Every analyzer re-derives the document's defects from its own walk, so a
|
|
1125
1310
|
// defect two analyzers both see must be reported once — and tagged with the
|
|
1126
1311
|
// analyzer that RAISED it, not with whichever list was merged first. The
|
|
@@ -1130,14 +1315,22 @@ export function explainCommand(
|
|
|
1130
1315
|
// the segmenter's delimiter stack is a `direct` reading). Attributing all of
|
|
1131
1316
|
// them to the segmenter said "direct/segmentStatements" about a fact the
|
|
1132
1317
|
// segmenter cannot produce.
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
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
|
+
);
|
|
1141
1334
|
|
|
1142
1335
|
// From the SPLITS, not from the segmentation. The resolver flattens `do={…}`
|
|
1143
1336
|
// bodies in after their parent, so its list is longer than the top-level
|
|
@@ -1145,17 +1338,40 @@ export function explainCommand(
|
|
|
1145
1338
|
// the first block. Each split carries its own document-space span.
|
|
1146
1339
|
//
|
|
1147
1340
|
const readStatements: ExplainStatement[] = verbs.splits.map((split) =>
|
|
1148
|
-
statementOf(split, analyzed),
|
|
1341
|
+
statementOf(split, analyzed, analyzedRange),
|
|
1149
1342
|
);
|
|
1150
1343
|
const canonical = canonicalizeExecuteCommand(input);
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
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
|
+
: [],
|
|
1158
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
|
+
});
|
|
1159
1375
|
|
|
1160
1376
|
const diagnostics: ExplainDiagnostic[] = [
|
|
1161
1377
|
...defects.map(({ defect: d, ev }) => {
|
|
@@ -1168,7 +1384,8 @@ export function explainCommand(
|
|
|
1168
1384
|
ev,
|
|
1169
1385
|
};
|
|
1170
1386
|
}),
|
|
1171
|
-
...invalidCommandBraceDiagnostics(verbs.splits, analyzed),
|
|
1387
|
+
...invalidCommandBraceDiagnostics(verbs.splits, analyzed, analyzedRange),
|
|
1388
|
+
...separators.flatMap((runs) => runs.map(missingSeparatorDiagnostic)),
|
|
1172
1389
|
...statements.flatMap((s) => diagnosticsForStatement(s)),
|
|
1173
1390
|
].sort(
|
|
1174
1391
|
(a, b) =>
|
|
@@ -1176,7 +1393,12 @@ export function explainCommand(
|
|
|
1176
1393
|
severityRank(b.severity) - severityRank(a.severity),
|
|
1177
1394
|
);
|
|
1178
1395
|
|
|
1179
|
-
const valueFacts = valuesOf(
|
|
1396
|
+
const valueFacts = valuesOf(
|
|
1397
|
+
verbs.splits,
|
|
1398
|
+
analyzed,
|
|
1399
|
+
analyzedRange,
|
|
1400
|
+
separators,
|
|
1401
|
+
);
|
|
1180
1402
|
const spans: ExplainSpan[] = [
|
|
1181
1403
|
...segmented.comments.map((c) => ({
|
|
1182
1404
|
start: c.start,
|
|
@@ -1219,8 +1441,8 @@ export function explainCommand(
|
|
|
1219
1441
|
|
|
1220
1442
|
// B2/B3 fill order — the argument order IS the order (#290 design decision 1).
|
|
1221
1443
|
// `spans` (proof-only: comment + variables) claims first; path, argument,
|
|
1222
|
-
// value, then string/brace, then operator fills each see only the
|
|
1223
|
-
// left before them. Structural ambiguity (`/` path vs division, `=` arg sep
|
|
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
|
|
1224
1446
|
// vs comparison) therefore resolves by the analyzer that already proved
|
|
1225
1447
|
// ownership, not by a wider byte scanner. The operator scanner keeps its
|
|
1226
1448
|
// conservative guards for bytes no earlier fill can decide.
|
|
@@ -1243,11 +1465,17 @@ export function explainCommand(
|
|
|
1243
1465
|
return [
|
|
1244
1466
|
{
|
|
1245
1467
|
text: resolution.inner,
|
|
1246
|
-
span
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
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
|
+
),
|
|
1251
1479
|
ev: EV.paths,
|
|
1252
1480
|
},
|
|
1253
1481
|
];
|
|
@@ -1258,8 +1486,19 @@ export function explainCommand(
|
|
|
1258
1486
|
...spans,
|
|
1259
1487
|
...pathSpansList,
|
|
1260
1488
|
]);
|
|
1261
|
-
|
|
1262
|
-
|
|
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] ?? []),
|
|
1263
1502
|
);
|
|
1264
1503
|
const argSpansList = argSpans(analyzed, residual1, argCandidates);
|
|
1265
1504
|
const residual2 = residualRanges(analyzed.length, [
|
|
@@ -1267,39 +1506,58 @@ export function explainCommand(
|
|
|
1267
1506
|
...pathSpansList,
|
|
1268
1507
|
...argSpansList,
|
|
1269
1508
|
]);
|
|
1270
|
-
|
|
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(
|
|
1271
1514
|
analyzed,
|
|
1272
1515
|
residual2,
|
|
1273
|
-
|
|
1516
|
+
stringEscapes.escapes,
|
|
1274
1517
|
);
|
|
1275
1518
|
const residual3 = residualRanges(analyzed.length, [
|
|
1276
1519
|
...spans,
|
|
1277
1520
|
...pathSpansList,
|
|
1278
1521
|
...argSpansList,
|
|
1279
|
-
...
|
|
1522
|
+
...escapeSpansList,
|
|
1280
1523
|
]);
|
|
1281
|
-
const
|
|
1524
|
+
const valueSpansList = valueSpans(
|
|
1525
|
+
analyzed,
|
|
1526
|
+
residual3,
|
|
1527
|
+
valueFacts.occurrences,
|
|
1528
|
+
);
|
|
1282
1529
|
const residual4 = residualRanges(analyzed.length, [
|
|
1283
1530
|
...spans,
|
|
1284
1531
|
...pathSpansList,
|
|
1285
1532
|
...argSpansList,
|
|
1533
|
+
...escapeSpansList,
|
|
1286
1534
|
...valueSpansList,
|
|
1287
|
-
...stringSpansList,
|
|
1288
1535
|
]);
|
|
1289
|
-
const
|
|
1536
|
+
const stringSpansList = stringSpans(analyzed, residual4);
|
|
1290
1537
|
const residual5 = residualRanges(analyzed.length, [
|
|
1291
1538
|
...spans,
|
|
1292
1539
|
...pathSpansList,
|
|
1293
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,
|
|
1294
1551
|
...valueSpansList,
|
|
1295
1552
|
...stringSpansList,
|
|
1296
1553
|
...braceSpansList,
|
|
1297
1554
|
]);
|
|
1298
|
-
const opSpans = operatorSpans(analyzed,
|
|
1555
|
+
const opSpans = operatorSpans(analyzed, residual6);
|
|
1299
1556
|
const fills: TokenFill[] = [
|
|
1300
1557
|
spans,
|
|
1301
1558
|
pathSpansList,
|
|
1302
1559
|
argSpansList,
|
|
1560
|
+
escapeSpansList,
|
|
1303
1561
|
valueSpansList,
|
|
1304
1562
|
stringSpansList,
|
|
1305
1563
|
braceSpansList,
|
|
@@ -1311,7 +1569,7 @@ export function explainCommand(
|
|
|
1311
1569
|
return {
|
|
1312
1570
|
input: {
|
|
1313
1571
|
bytes: coordinates.analyzed.length,
|
|
1314
|
-
normalized: coordinates.
|
|
1572
|
+
normalized: !coordinates.ascii,
|
|
1315
1573
|
truncated: false,
|
|
1316
1574
|
positionMap: positionMap(coordinates),
|
|
1317
1575
|
},
|
|
@@ -1347,6 +1605,7 @@ function withStatementTransport(
|
|
|
1347
1605
|
statement: ExplainStatement,
|
|
1348
1606
|
source: string,
|
|
1349
1607
|
options: ExplainCommandOptions,
|
|
1608
|
+
rejected?: string,
|
|
1350
1609
|
): ExplainStatement {
|
|
1351
1610
|
if (statement.kind !== "command" || statement.arguments === undefined)
|
|
1352
1611
|
return statement;
|
|
@@ -1357,6 +1616,7 @@ function withStatementTransport(
|
|
|
1357
1616
|
command: statement.command,
|
|
1358
1617
|
arguments: statement.arguments,
|
|
1359
1618
|
source,
|
|
1619
|
+
...(rejected === undefined ? {} : { rejected }),
|
|
1360
1620
|
},
|
|
1361
1621
|
{ renderCurl: options.curl, evidenceId: EV.transport },
|
|
1362
1622
|
),
|
|
@@ -1381,12 +1641,13 @@ function withStatementTransport(
|
|
|
1381
1641
|
function statementOf(
|
|
1382
1642
|
split: DocumentVerbSplit,
|
|
1383
1643
|
analyzed: string,
|
|
1644
|
+
analyzedRange: MaskedRange,
|
|
1384
1645
|
): ExplainStatement {
|
|
1385
1646
|
const reading = readingOf(split);
|
|
1386
1647
|
return {
|
|
1387
1648
|
span: { start: split.span.start, end: split.span.end },
|
|
1388
1649
|
...(reading.kind === "command"
|
|
1389
|
-
? withArguments(reading, split, analyzed)
|
|
1650
|
+
? withArguments(reading, split, analyzed, analyzedRange)
|
|
1390
1651
|
: reading),
|
|
1391
1652
|
contextCertain: split.contextCertain,
|
|
1392
1653
|
ev: EV.statements,
|
|
@@ -1410,8 +1671,9 @@ function withArguments(
|
|
|
1410
1671
|
reading: ExplainCommandReading,
|
|
1411
1672
|
split: DocumentVerbSplit,
|
|
1412
1673
|
analyzed: string,
|
|
1674
|
+
analyzedRange: MaskedRange,
|
|
1413
1675
|
): ExplainCommandReading {
|
|
1414
|
-
const read = argumentsOf(split, analyzed);
|
|
1676
|
+
const read = argumentsOf(split, analyzed, analyzedRange);
|
|
1415
1677
|
return {
|
|
1416
1678
|
...reading,
|
|
1417
1679
|
command:
|
|
@@ -1436,6 +1698,7 @@ function withArguments(
|
|
|
1436
1698
|
function argumentsOf(
|
|
1437
1699
|
split: DocumentVerbSplit,
|
|
1438
1700
|
analyzed: string,
|
|
1701
|
+
analyzedRange: MaskedRange,
|
|
1439
1702
|
): { arguments: ExplainArguments; args?: Record<string, string> } {
|
|
1440
1703
|
if (split.argsAt === null)
|
|
1441
1704
|
return {
|
|
@@ -1453,7 +1716,11 @@ function argumentsOf(
|
|
|
1453
1716
|
why: "this statement's bytes are not addressable: its text was normalized (non-ASCII), or its span was widened to the enclosing statement",
|
|
1454
1717
|
},
|
|
1455
1718
|
};
|
|
1456
|
-
const lexed = lexArguments(
|
|
1719
|
+
const lexed = lexArguments(
|
|
1720
|
+
text,
|
|
1721
|
+
split.argsAt,
|
|
1722
|
+
nestedRange(analyzedRange, start, end),
|
|
1723
|
+
);
|
|
1457
1724
|
if (!lexed.read) return { arguments: lexed };
|
|
1458
1725
|
return {
|
|
1459
1726
|
args: lexed.args,
|
|
@@ -1477,9 +1744,113 @@ function argumentsOf(
|
|
|
1477
1744
|
};
|
|
1478
1745
|
}
|
|
1479
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
|
+
|
|
1480
1850
|
function invalidCommandBraceDiagnostics(
|
|
1481
1851
|
splits: readonly DocumentVerbSplit[],
|
|
1482
1852
|
analyzed: string,
|
|
1853
|
+
analyzedRange: MaskedRange,
|
|
1483
1854
|
): ExplainDiagnostic[] {
|
|
1484
1855
|
const out: ExplainDiagnostic[] = [];
|
|
1485
1856
|
for (const split of splits) {
|
|
@@ -1504,18 +1875,46 @@ function invalidCommandBraceDiagnostics(
|
|
|
1504
1875
|
)
|
|
1505
1876
|
) {
|
|
1506
1877
|
const braceAt = split.argsAt + leadingBraceAt;
|
|
1507
|
-
const structural =
|
|
1878
|
+
const structural = rangeMask(nestedRange(analyzedRange, start, end));
|
|
1508
1879
|
const trimmed = structural.slice(braceAt + 1).trimStart();
|
|
1509
1880
|
if (trimmed.length === 0 || trimmed[0] === "}") {
|
|
1510
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;
|
|
1511
1890
|
} else {
|
|
1512
|
-
const match =
|
|
1891
|
+
const match = /^([A-Za-z][A-Za-z0-9._-]*)/.exec(trimmed);
|
|
1513
1892
|
if (match) {
|
|
1514
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.
|
|
1515
1910
|
const isMenuContainer = split.candidates.some((candidate) => {
|
|
1516
1911
|
const segments = candidate.split("/").filter(Boolean);
|
|
1517
1912
|
if (!isKnownMenuPath(segments)) return false;
|
|
1518
|
-
return
|
|
1913
|
+
return (
|
|
1914
|
+
isKnownMenuPath([...segments, firstWord]) ||
|
|
1915
|
+
VERBS.has(firstWord) ||
|
|
1916
|
+
lookupPath([...segments, firstWord])?.kind === "command"
|
|
1917
|
+
);
|
|
1519
1918
|
});
|
|
1520
1919
|
if (isMenuContainer) menuBraceAt = braceAt;
|
|
1521
1920
|
}
|
|
@@ -1525,6 +1924,7 @@ function invalidCommandBraceDiagnostics(
|
|
|
1525
1924
|
text,
|
|
1526
1925
|
split.argsAt,
|
|
1527
1926
|
split.path === "/" ? split.verb : undefined,
|
|
1927
|
+
nestedRange(analyzedRange, start, end),
|
|
1528
1928
|
)) {
|
|
1529
1929
|
if (at === menuBraceAt) continue;
|
|
1530
1930
|
out.push({
|
|
@@ -1540,18 +1940,40 @@ function invalidCommandBraceDiagnostics(
|
|
|
1540
1940
|
return out;
|
|
1541
1941
|
}
|
|
1542
1942
|
|
|
1543
|
-
/**
|
|
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
|
+
*/
|
|
1544
1962
|
function valuesOf(
|
|
1545
1963
|
splits: readonly DocumentVerbSplit[],
|
|
1546
1964
|
analyzed: string,
|
|
1965
|
+
analyzedRange: MaskedRange,
|
|
1966
|
+
separators: readonly (readonly MissingSeparator[])[],
|
|
1547
1967
|
): ExplainValues {
|
|
1548
1968
|
const occurrences: ExplainValueOccurrence[] = [];
|
|
1549
|
-
for (const split of splits) {
|
|
1969
|
+
for (const [splitIndex, split] of splits.entries()) {
|
|
1550
1970
|
if (split.resolution !== "resolved" || split.argsAt === null) continue;
|
|
1971
|
+
const cut = separators[splitIndex]?.[0]?.insertAt;
|
|
1551
1972
|
const { start, end } = split.span;
|
|
1552
1973
|
const text = analyzed.slice(start, end);
|
|
1553
1974
|
if (text !== split.text) continue;
|
|
1554
1975
|
const anchored = lexValueAnchors(text, split.argsAt, {
|
|
1976
|
+
range: nestedRange(analyzedRange, start, end),
|
|
1555
1977
|
// A `{…}` array literal is legal only in a ROOT scripting directive's
|
|
1556
1978
|
// value slot; `/ip/dns/set servers={…}` and `:log info message={…}` are
|
|
1557
1979
|
// both device syntax errors at the `{`. `path` is how that position is
|
|
@@ -1563,6 +1985,11 @@ function valuesOf(
|
|
|
1563
1985
|
// advisory and never turn the later refusal reason into a diagnostic.
|
|
1564
1986
|
const ids = new Map<number, string>();
|
|
1565
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;
|
|
1566
1993
|
const hints: ValueShape[] =
|
|
1567
1994
|
// A source-proved shape wins outright: the delimiters prove `array`,
|
|
1568
1995
|
// and an `=` that binds no key proves `str` or `bool` (#258). None is
|
|
@@ -1668,7 +2095,13 @@ function subcommandOf(r: Resolution): ExplainSubcommand {
|
|
|
1668
2095
|
resolution: "unknown" as const,
|
|
1669
2096
|
unresolved: r.unresolved ?? "no reading of this substitution",
|
|
1670
2097
|
}
|
|
1671
|
-
: readingOf(
|
|
2098
|
+
: readingOf(
|
|
2099
|
+
resolveVerb(
|
|
2100
|
+
r.inner,
|
|
2101
|
+
r.context,
|
|
2102
|
+
rangeForResolution(r) ?? documentRange(r.inner),
|
|
2103
|
+
),
|
|
2104
|
+
);
|
|
1672
2105
|
return {
|
|
1673
2106
|
span: { start: r.span.start, end: r.span.end },
|
|
1674
2107
|
context: r.context,
|
|
@@ -1867,8 +2300,8 @@ export interface ExplainEnvelopeOptions {
|
|
|
1867
2300
|
/**
|
|
1868
2301
|
* Include the gapless token partition behind `data.tokens[]`.
|
|
1869
2302
|
*
|
|
1870
|
-
* Mirrors `--complete` / `--schema` / `--curl` facet pattern.
|
|
1871
|
-
*
|
|
2303
|
+
* Mirrors `--complete` / `--schema` / `--curl` facet pattern. The vocabulary
|
|
2304
|
+
* is `ExplainTokenClass` (#264 B5).
|
|
1872
2305
|
*/
|
|
1873
2306
|
tokens?: boolean;
|
|
1874
2307
|
tips?: readonly Tip[];
|
|
@@ -1876,6 +2309,23 @@ export interface ExplainEnvelopeOptions {
|
|
|
1876
2309
|
warnings?: readonly Warning[];
|
|
1877
2310
|
}
|
|
1878
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
|
+
|
|
1879
2329
|
function settingsMeta(
|
|
1880
2330
|
format: ResolvedSetting<ExplainOutputFormat> | undefined,
|
|
1881
2331
|
): CommonSettingsMeta {
|
|
@@ -1891,7 +2341,7 @@ function settingsMeta(
|
|
|
1891
2341
|
*/
|
|
1892
2342
|
export function resolveExplainFormat(
|
|
1893
2343
|
explicit: string | undefined,
|
|
1894
|
-
env: Record<string, string | undefined> =
|
|
2344
|
+
env: Record<string, string | undefined> = hostEnv(),
|
|
1895
2345
|
config: Record<string, string | undefined> = {},
|
|
1896
2346
|
): ResolvedSetting<ExplainOutputFormat> {
|
|
1897
2347
|
return resolveStringSetting(
|
|
@@ -2129,7 +2579,7 @@ function renderExplainText(
|
|
|
2129
2579
|
const bytes = data.input.bytes;
|
|
2130
2580
|
const pct = bytes === 0 ? 0 : (classified / bytes) * 100;
|
|
2131
2581
|
lines.push(
|
|
2132
|
-
`tokens: ${data.tokens.length} token(s), ${classified}/${bytes} byte(s) classified (${pct.toFixed(1)}%)
|
|
2582
|
+
`tokens: ${data.tokens.length} token(s), ${classified}/${bytes} byte(s) classified (${pct.toFixed(1)}%)`,
|
|
2133
2583
|
);
|
|
2134
2584
|
for (const t of data.tokens)
|
|
2135
2585
|
lines.push(` ${span(t).padEnd(12)} ${t.class}`);
|