@tikoci/centrs 0.1.9-next.22 → 0.1.9-next.24
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 +106 -0
- package/GLOSSARY.txt +38 -0
- package/README.md +2 -2
- package/commands/api/AGENTS.md +18 -1
- package/commands/api/README.md +10 -6
- package/commands/api/examples.md +1 -1
- package/commands/execute/README.md +26 -6
- package/commands/execute/examples.md +43 -5
- package/commands/explain/README.md +873 -182
- package/commands/explain/examples.md +447 -38
- package/commands/retrieve/README.md +13 -2
- package/commands/retrieve/examples.md +30 -1
- package/commands/transfer/README.md +8 -4
- package/docs/CLI.md +19 -5
- package/docs/CONSTITUTION.md +18 -9
- package/docs/MATRIX.md +51 -26
- package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
- package/docs/errors/routeros/permission-denied.md +24 -0
- package/docs/errors/transport/edge-auth-failed.md +22 -0
- package/docs/errors/transport/write-outcome-unknown.md +3 -0
- package/docs/errors/usage/invalid-cursor.md +20 -0
- package/docs/errors/validation/unknown-path.md +9 -0
- package/package.json +5 -2
- package/src/api.ts +34 -39
- package/src/cli/explain.ts +206 -47
- package/src/core/envelope.ts +10 -3
- package/src/core/error-catalog.ts +15 -0
- package/src/core/execute-command.ts +53 -0
- package/src/core/fanout.ts +12 -2
- package/src/core/inspect.ts +4 -5
- package/src/core/routeros-errors.ts +25 -0
- package/src/execute-cmr.ts +2 -8
- package/src/execute.ts +48 -20
- package/src/explain/args.ts +181 -15
- package/src/explain/blocks.ts +3 -4
- package/src/explain/brace-slots.ts +5 -0
- package/src/explain/catalog.ts +13 -6
- package/src/explain/defects.ts +1 -0
- package/src/explain/group-faults.ts +1060 -0
- package/src/explain/highlight-projection.ts +10 -8
- package/src/explain/operators.ts +22 -20
- package/src/explain/pathresolve.ts +102 -20
- package/src/explain/quoted-string.ts +41 -6
- package/src/explain/run-tokens.ts +223 -0
- package/src/explain/scope-brace.ts +319 -7
- package/src/explain/segment.ts +177 -4
- package/src/explain/spaced-args.ts +116 -0
- package/src/explain/symbols.ts +163 -7
- package/src/explain/transport.ts +13 -0
- package/src/explain/values.ts +55 -4
- package/src/explain/verbsplit.ts +14 -188
- package/src/explain/write.ts +8 -1
- package/src/explain-live-facets.ts +739 -0
- package/src/explain-live-types.ts +490 -0
- package/src/explain-live.ts +593 -0
- package/src/explain.ts +772 -60
- package/src/index.ts +6 -0
- package/src/offline-gate.ts +30 -9
- package/src/protocols/adapter.ts +88 -10
- package/src/protocols/btest-session.ts +30 -10
- package/src/protocols/mac-telnet-console.ts +16 -0
- package/src/protocols/mac-telnet.ts +38 -10
- package/src/protocols/native-api.ts +13 -12
- package/src/retrieve-follow.ts +2 -1
- package/src/retrieve-sample.ts +2 -1
- package/src/retrieve-wait.ts +10 -6
- package/src/retrieve.ts +77 -27
- package/src/transfer.ts +38 -13
|
@@ -45,21 +45,23 @@
|
|
|
45
45
|
* agreement (probes: "an abstention is not a disagreement").
|
|
46
46
|
* - **parser-stop** — `error`. Measured over the committed slice this byte
|
|
47
47
|
* appears **at most once per script, always exactly one byte wide**, and on
|
|
48
|
-
* long-term 7.23.
|
|
48
|
+
* long-term 7.23.8 every byte after it is `none`: the device stops
|
|
49
49
|
* classifying rather than recovering. Most of the slice's bytes sit in that
|
|
50
50
|
* tail, so a percentage that ignores it measures the device giving up.
|
|
51
|
-
* Stable 7.24.
|
|
52
|
-
* in one run, which is why the report gives that a bucket
|
|
53
|
-
* of asserting the rule (the counts live in the generated
|
|
54
|
-
* here, so they cannot go stale). The rule and its
|
|
55
|
-
* with the #342 channel realignment
|
|
51
|
+
* Stable 7.24.5, testing 7.25rc2 and development 7.26beta1 do classify again
|
|
52
|
+
* after the `error` in one run, which is why the report gives that a bucket
|
|
53
|
+
* of its own instead of asserting the rule (the counts live in the generated
|
|
54
|
+
* README block, not here, so they cannot go stale). The rule and its
|
|
55
|
+
* exception swapped builds with the #342 channel realignment and held
|
|
56
|
+
* through the #498 repin; the shape of the claim did not change.
|
|
56
57
|
*
|
|
57
58
|
* ## Upstream drift
|
|
58
59
|
*
|
|
59
60
|
* `DEVICE_CLASS_KIND` lists the 19 classes the phase-0 capture observed on
|
|
60
61
|
* 7.23.2 / 7.24rc2 — and the #342 recapture found the SAME 19 on 7.24.2,
|
|
61
|
-
* 7.23.5 and 7.25beta3,
|
|
62
|
-
*
|
|
62
|
+
* 7.23.5 and 7.25beta3, as did the #498 corpus sessions on 7.23.8, 7.24.5,
|
|
63
|
+
* 7.25rc2 and 7.26beta1, so the vocabulary has held across nine builds and
|
|
64
|
+
* three capture rounds. A class outside it is `"unknown"` — reported, never
|
|
63
65
|
* silently folded into one of the four kinds — because a new upstream class is
|
|
64
66
|
* exactly the drift the centrs-owned vocabulary exists to absorb, and guessing
|
|
65
67
|
* its kind would hide it.
|
package/src/explain/operators.ts
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* The RouterOS operator surface, as the DEVICE describes it (#255).
|
|
3
3
|
*
|
|
4
|
-
* Every row here was scored by `bun run explain:probe:operators` on the
|
|
5
|
-
* builds the pinned corpus
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* (#342). A
|
|
9
|
-
* channel claim: it is the only live evidence that this surface behaves the
|
|
4
|
+
* Every row here was scored by `bun run explain:probe:operators` on the four
|
|
5
|
+
* builds the pinned corpus records a channel session for — stable 7.24.5,
|
|
6
|
+
* long-term 7.23.8, testing 7.25rc2 and development 7.26beta1 — so the
|
|
7
|
+
* operator axis and the token partition are measured on the same firmware
|
|
8
|
+
* (#342, #498). A fifth capture, 7.21.5, is kept as an **age anchor** rather
|
|
9
|
+
* than a channel claim: it is the only live evidence that this surface behaves the
|
|
10
10
|
* same on a pre-7.23 branch. The reviewed slice is
|
|
11
11
|
* `test/fixtures/explain/operators.json` → `sweep`, and
|
|
12
12
|
* `test/unit/explain-operators.test.ts` asserts this table still equals it.
|
|
@@ -143,7 +143,8 @@ const OPERATORS: readonly RouterosOperator[] = [
|
|
|
143
143
|
// different operator, and arity is the only thing that separates them.
|
|
144
144
|
// Offline value-shape hints abstain on every such form: identical
|
|
145
145
|
// `(> …)` syntax can be `op` or `array` at runtime and `:parse` IL cannot
|
|
146
|
-
// distinguish them
|
|
146
|
+
// distinguish them; only `:typeof` can, and live explain never sends it,
|
|
147
|
+
// so `op` has no producer at all (#288, #225).
|
|
147
148
|
op(">", [1, 2], 5, "left", "relational", ">"),
|
|
148
149
|
op("=", [2], 5, "left", "relational", "="),
|
|
149
150
|
op("<=", [2], 5, "left", "relational", "<="),
|
|
@@ -366,24 +367,25 @@ function notOp(
|
|
|
366
367
|
* The single version-dependent fact the sweep found.
|
|
367
368
|
*
|
|
368
369
|
* Every operator, arity, precedence level, associativity, `highlight` class and
|
|
369
|
-
* op-axis row is identical on all
|
|
370
|
-
* 7.
|
|
371
|
-
* not a reading: `buildSweep` diffs each of those axes per
|
|
372
|
-
* that re-ranked `->` would appear in
|
|
373
|
-
* nobody's notes.
|
|
370
|
+
* op-axis row is identical on all five swept builds — 7.24.5, 7.23.8,
|
|
371
|
+
* 7.25rc2, 7.26beta1 and the 7.21.5 age anchor — and identical is a
|
|
372
|
+
* measurement here, not a reading: `buildSweep` diffs each of those axes per
|
|
373
|
+
* version, so a build that re-ranked `->` would appear in
|
|
374
|
+
* `sweep.versionDifferences` rather than in nobody's notes.
|
|
374
375
|
*
|
|
375
376
|
* The one row that moved is a RUNTIME semantic, not a lexer fact:
|
|
376
|
-
* `:put ({2;1} > {1;2;3})` evaluates on 7.24.
|
|
377
|
-
* long-term builds. So the offline lexer needs no version gate for operators —
|
|
377
|
+
* `:put ({2;1} > {1;2;3})` evaluates on 7.24.5, 7.25rc2 and 7.26beta1 and
|
|
378
|
+
* errors on both long-term builds. So the offline lexer needs no version gate for operators —
|
|
378
379
|
* and anything that reports what a comparison MEANS does.
|
|
379
380
|
*/
|
|
380
381
|
export const ARRAY_COMPARISON_NOTE =
|
|
381
|
-
"array comparison with `>` evaluates on 7.24.
|
|
382
|
-
"7.23.
|
|
383
|
-
"**It is not fixed on current long-term**: 7.23.
|
|
384
|
-
"this was first measured on and
|
|
385
|
-
"line and was not backported. The two
|
|
386
|
-
"7.23.
|
|
382
|
+
"array comparison with `>` evaluates on 7.24.5, 7.25rc2 and 7.26beta1 and " +
|
|
383
|
+
"errors on 7.23.8 and 7.21.5; the operator table itself is identical on all " +
|
|
384
|
+
"five. **It is not fixed on current long-term**: 7.23.8 is newer than the " +
|
|
385
|
+
"7.23.3 this was first measured on (and the 7.23.5 of #342) and still " +
|
|
386
|
+
"errors, so the change rode the 7.24 line and was not backported. The two " +
|
|
387
|
+
"errors are not the same error: 7.23.8 says `cannot compare if array is more " +
|
|
388
|
+
"than array` and 7.21.5 says " +
|
|
387
389
|
"`cannot compare if nothing is more than nothing`, so on 7.21.5 the " +
|
|
388
390
|
"`{2;1}` operands did not survive to the comparison at all. That is a " +
|
|
389
391
|
"RUNTIME difference either way — no version disagrees about what `>` is";
|
|
@@ -48,6 +48,15 @@
|
|
|
48
48
|
* filter` and `firewall/filter` under `/ip` are one form. If neither table
|
|
49
49
|
* knows the joined path, offline abstains and the following relative
|
|
50
50
|
* statements lose context certainty.
|
|
51
|
+
* R14 An absolute bare path is navigation only when a shipped table names it
|
|
52
|
+
* as a menu, exactly as R13 reads a relative one (#211). The verb-free
|
|
53
|
+
* residue R9 and R12 leave (`/terminal/cuu`, a no-argument command neither
|
|
54
|
+
* table lists) is a menu or a command, and offline cannot say which. So it
|
|
55
|
+
* abstains, and the statements after it lose context certainty until an
|
|
56
|
+
* absolute navigation re-establishes it. Absence is not evidence of a
|
|
57
|
+
* command either: RouterOS accepts abbreviated menus (`/ip fire conn` is
|
|
58
|
+
* `/ip/firewall/connection` in the device's own IL), so this does not read
|
|
59
|
+
* the tables as a closed world.
|
|
51
60
|
*
|
|
52
61
|
* Verb-vs-menu identification is deliberately NOT decided here — offline cannot
|
|
53
62
|
* tell `find` (verb) from a deeper menu without a schema (that is Q6). A
|
|
@@ -76,13 +85,15 @@
|
|
|
76
85
|
* - context-DEPENDENT — a relative statement (`add address=…`) resolves
|
|
77
86
|
* against the context, so it degrades.
|
|
78
87
|
*
|
|
79
|
-
*
|
|
88
|
+
* Four things make the context unknown, all cases where the statement might
|
|
80
89
|
* have been navigation and offline cannot read where to:
|
|
81
90
|
* - a structural defect (the text is unreadable, so it may have been a nav),
|
|
82
91
|
* - a `/`-led statement whose leading run is unreadable (`/ip/$menu`, and
|
|
83
92
|
* equally the mixed spelling `/ip route/$menu`), which is a navigation to a
|
|
84
93
|
* computed menu as easily as it is a command,
|
|
85
|
-
* - a relative bare path that neither shipped structure table knows (#235)
|
|
94
|
+
* - a relative bare path that neither shipped structure table knows (#235),
|
|
95
|
+
* - an absolute bare path that neither table knows and no rule reads as a
|
|
96
|
+
* command (#211, R14).
|
|
86
97
|
* A dynamic-headed statement (`$x`, `[…]`, `(…)`) is context-NEUTRAL — it
|
|
87
98
|
* evaluates a value and does not navigate — so it must NOT poison; that
|
|
88
99
|
* distinction is the whole reason this lives here rather than in a caller,
|
|
@@ -457,6 +468,8 @@ function handsKnownContext(
|
|
|
457
468
|
|
|
458
469
|
/** A re-constituted `[…]` command substitution (Q3). */
|
|
459
470
|
export interface Resolution {
|
|
471
|
+
/** Index of the owning statement in `resolveStatements(text).statements`. */
|
|
472
|
+
owner: number;
|
|
460
473
|
/** Raw text inside the brackets. */
|
|
461
474
|
inner: string;
|
|
462
475
|
/** Context in force at the bracket, `/` when at document root. */
|
|
@@ -516,9 +529,22 @@ export function rangeForResolution(
|
|
|
516
529
|
|
|
517
530
|
/** The canonical path of one statement in source order (Q4). */
|
|
518
531
|
export interface StatementResolution {
|
|
532
|
+
/** Source ownership from traversal, independent of potentially widened spans. */
|
|
533
|
+
ownership?: {
|
|
534
|
+
/** Index of the statement whose scope body contains this statement. */
|
|
535
|
+
parent: number;
|
|
536
|
+
/** Body start as a UTF-16 offset in that parent's original text. */
|
|
537
|
+
bodyStart: number;
|
|
538
|
+
};
|
|
519
539
|
text: string;
|
|
520
540
|
/** Offline read this statement as menu navigation, not a command. */
|
|
521
541
|
isNav: boolean;
|
|
542
|
+
/**
|
|
543
|
+
* A verb-free bare path that neither shipped table knows (#211, #235): it is
|
|
544
|
+
* a menu navigation or a command, and offline cannot say which. `isNav` is
|
|
545
|
+
* false, and the statements after it lose context certainty.
|
|
546
|
+
*/
|
|
547
|
+
maybeNav?: true;
|
|
522
548
|
/** Context in force BEFORE this statement. */
|
|
523
549
|
context: string;
|
|
524
550
|
/**
|
|
@@ -737,7 +763,7 @@ export function resolveDocument(text: string): DocumentAnalysis {
|
|
|
737
763
|
const resolutions: Resolution[] = [];
|
|
738
764
|
const top = locate(segmented, DOCUMENT_LOC, rootRange(text, segmented));
|
|
739
765
|
defects.push(...top.defects);
|
|
740
|
-
walk(top.units, "/", resolutions, 0, defects, true);
|
|
766
|
+
walk(top.units, "/", resolutions, 0, defects, true, { next: 0 });
|
|
741
767
|
return { resolutions, defects: mergeDefects(defects) };
|
|
742
768
|
}
|
|
743
769
|
|
|
@@ -748,10 +774,14 @@ function walk(
|
|
|
748
774
|
blockDepth: number,
|
|
749
775
|
defects: Defect[],
|
|
750
776
|
contextCertain: boolean,
|
|
777
|
+
statements: { next: number },
|
|
751
778
|
): void {
|
|
752
779
|
let ctx = context;
|
|
753
780
|
let certain = contextCertain;
|
|
754
781
|
for (const unit of units) {
|
|
782
|
+
// Count navigation and unreadable statements too, exactly as the Q4 walk
|
|
783
|
+
// does. Brackets in one unit (including nested brackets) share its owner.
|
|
784
|
+
const owner = statements.next++;
|
|
755
785
|
const { text, loc, range } = unit;
|
|
756
786
|
// R4 — a menu-navigation statement moves the document context.
|
|
757
787
|
const nav = menuNavPath(text, ctx, range);
|
|
@@ -772,9 +802,9 @@ function walk(
|
|
|
772
802
|
certain = false;
|
|
773
803
|
continue;
|
|
774
804
|
}
|
|
775
|
-
// #235 — a
|
|
805
|
+
// #235, #211 — a bare path that abstained (neither table) poisons
|
|
776
806
|
// downstream context, so the next relative statement cannot fabricate.
|
|
777
|
-
const poisonAbstention =
|
|
807
|
+
const poisonAbstention = isAbstainedNav(text, ctx, range);
|
|
778
808
|
// R3 — the statement's own path is the context its brackets see.
|
|
779
809
|
const stmtCtx = statementPath(text, ctx, range);
|
|
780
810
|
// Derived BEFORE the poison, from the certainty in force at this statement:
|
|
@@ -785,7 +815,7 @@ function walk(
|
|
|
785
815
|
? false
|
|
786
816
|
: handsKnownContext(text, certain, range);
|
|
787
817
|
if (isUnreadableAbsolute(text) || poisonAbstention) certain = false;
|
|
788
|
-
collectBrackets(range, stmtCtx, 0, out, defects, stmtCertain, loc);
|
|
818
|
+
collectBrackets(range, stmtCtx, 0, out, defects, stmtCertain, loc, owner);
|
|
789
819
|
// R5 — block bodies inherit the context in force here.
|
|
790
820
|
for (const block of scopeBlocksIn(range)) {
|
|
791
821
|
if (blockDepth >= MAX_DEPTH) {
|
|
@@ -804,7 +834,15 @@ function walk(
|
|
|
804
834
|
bodyRange(range, block),
|
|
805
835
|
);
|
|
806
836
|
defects.push(...body.defects);
|
|
807
|
-
walk(
|
|
837
|
+
walk(
|
|
838
|
+
body.units,
|
|
839
|
+
stmtCtx,
|
|
840
|
+
out,
|
|
841
|
+
blockDepth + 1,
|
|
842
|
+
defects,
|
|
843
|
+
stmtCertain,
|
|
844
|
+
statements,
|
|
845
|
+
);
|
|
808
846
|
}
|
|
809
847
|
}
|
|
810
848
|
}
|
|
@@ -828,10 +866,16 @@ function walkStatements(
|
|
|
828
866
|
blockDepth: number,
|
|
829
867
|
defects: Defect[],
|
|
830
868
|
contextCertain: boolean,
|
|
869
|
+
ownership?: StatementResolution["ownership"],
|
|
831
870
|
): void {
|
|
832
871
|
let ctx = context;
|
|
833
872
|
let certain = contextCertain;
|
|
873
|
+
const push = (statement: StatementResolution): void => {
|
|
874
|
+
if (ownership !== undefined) statement.ownership = ownership;
|
|
875
|
+
out.push(statement);
|
|
876
|
+
};
|
|
834
877
|
for (const unit of units) {
|
|
878
|
+
const parent = out.length;
|
|
835
879
|
const { text, span, range } = unit;
|
|
836
880
|
const nav = menuNavPath(text, ctx, range);
|
|
837
881
|
if (nav !== null) {
|
|
@@ -840,7 +884,7 @@ function walkStatements(
|
|
|
840
884
|
// therefore re-establishes certainty.
|
|
841
885
|
const relative = !trimAscii(text).startsWith("/");
|
|
842
886
|
if (relative && !certain) {
|
|
843
|
-
|
|
887
|
+
push({
|
|
844
888
|
text,
|
|
845
889
|
isNav: true,
|
|
846
890
|
context: ctx,
|
|
@@ -851,7 +895,7 @@ function walkStatements(
|
|
|
851
895
|
});
|
|
852
896
|
continue;
|
|
853
897
|
}
|
|
854
|
-
|
|
898
|
+
push({
|
|
855
899
|
text,
|
|
856
900
|
isNav: true,
|
|
857
901
|
context: ctx,
|
|
@@ -868,7 +912,7 @@ function walkStatements(
|
|
|
868
912
|
// BEEN a navigation, so what the context now is becomes unknown.
|
|
869
913
|
const defectReason = structuralDefectIn(range);
|
|
870
914
|
if (defectReason !== null) {
|
|
871
|
-
|
|
915
|
+
push({
|
|
872
916
|
text,
|
|
873
917
|
isNav: false,
|
|
874
918
|
context: ctx,
|
|
@@ -880,18 +924,19 @@ function walkStatements(
|
|
|
880
924
|
certain = false;
|
|
881
925
|
continue;
|
|
882
926
|
}
|
|
883
|
-
// #235 — a
|
|
927
|
+
// #235, #211 — a bare path that abstained (neither table) poisons
|
|
884
928
|
// downstream context. Capture before the push so the current statement's
|
|
885
929
|
// own `contextCertain` stays true while the following statements lose it.
|
|
886
|
-
const poisonAbstention =
|
|
930
|
+
const poisonAbstention = isAbstainedNav(text, ctx, range);
|
|
887
931
|
// Q14 C3b — the cascade. A statement that CONSUMES the context cannot be
|
|
888
932
|
// resolved against a value the resolver has already admitted it lost.
|
|
889
933
|
// Applied to the RESOLUTION, not ahead of it, so a statement that would
|
|
890
934
|
// have refused on its own terms keeps its own — more specific — reason.
|
|
891
935
|
const resolved = canonicalPath(text, ctx, range);
|
|
892
|
-
|
|
936
|
+
push({
|
|
893
937
|
text,
|
|
894
938
|
isNav: false,
|
|
939
|
+
...(poisonAbstention ? { maybeNav: true as const } : {}),
|
|
895
940
|
context: ctx,
|
|
896
941
|
...(resolved.path !== null &&
|
|
897
942
|
!certain &&
|
|
@@ -933,6 +978,7 @@ function walkStatements(
|
|
|
933
978
|
blockDepth + 1,
|
|
934
979
|
defects,
|
|
935
980
|
bodyCertain,
|
|
981
|
+
{ parent, bodyStart: block.start },
|
|
936
982
|
);
|
|
937
983
|
}
|
|
938
984
|
}
|
|
@@ -981,6 +1027,12 @@ function walkStatements(
|
|
|
981
1027
|
* contradiction R9 closed — and worse than before, since the two modules would
|
|
982
1028
|
* now disagree CONFIDENTLY rather than one of them refusing. The same test pins
|
|
983
1029
|
* the invariant for both rules.
|
|
1030
|
+
*
|
|
1031
|
+
* R14 (#211) — "absence from both still abstains" was not true here until R14.
|
|
1032
|
+
* A verb-free path neither table knows still came back as a navigation, so
|
|
1033
|
+
* `/terminal/cuu` moved the context and the `print` after it resolved
|
|
1034
|
+
* confidently to `/terminal/cuu/print`. Now it abstains the way R13's relative
|
|
1035
|
+
* path does: the statement is not navigation, and what follows loses certainty.
|
|
984
1036
|
*/
|
|
985
1037
|
function menuNavPath(
|
|
986
1038
|
text: string,
|
|
@@ -999,6 +1051,19 @@ function menuNavPath(
|
|
|
999
1051
|
const reading = readRelativeBare(text, ctx, range);
|
|
1000
1052
|
return reading?.kind === "nav" ? reading.path : null;
|
|
1001
1053
|
}
|
|
1054
|
+
const reading = readAbsoluteBare(trimmed);
|
|
1055
|
+
return reading?.kind === "nav" ? reading.path : null;
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/**
|
|
1059
|
+
* How an ABSOLUTE bare-path statement reads (R9, R12, R14).
|
|
1060
|
+
*
|
|
1061
|
+
* The `/`-led twin of {@link readRelativeBare}, with the same three answers. A
|
|
1062
|
+
* run carrying a vocabulary verb (R9) or naming a published command (R12) is a
|
|
1063
|
+
* COMMAND: `null`. Otherwise it is verb-free, and a shipped table decides: a
|
|
1064
|
+
* known menu is `nav`, and a path neither table knows is `abstain` (R14).
|
|
1065
|
+
*/
|
|
1066
|
+
function readAbsoluteBare(trimmed: string): BarePathReading | null {
|
|
1002
1067
|
if (/[=[({"$]/.test(trimmed)) return null;
|
|
1003
1068
|
const tokens = asciiWords(trimmed);
|
|
1004
1069
|
if (
|
|
@@ -1016,7 +1081,11 @@ function menuNavPath(
|
|
|
1016
1081
|
if (segments.some((segment) => VERBS.has(segment))) return null;
|
|
1017
1082
|
// R12 — the same segments, against the published command axis.
|
|
1018
1083
|
if (commandVerbIndex(segments) !== null) return null;
|
|
1019
|
-
|
|
1084
|
+
// R14 (#211) — the verb-free residue. Presence in a table decides, as R13
|
|
1085
|
+
// decides a relative one; absence abstains rather than navigate.
|
|
1086
|
+
return isKnownMenuPath(segments)
|
|
1087
|
+
? { kind: "nav", path: joinPath("/", tokens.join("/")) }
|
|
1088
|
+
: { kind: "abstain" };
|
|
1020
1089
|
}
|
|
1021
1090
|
|
|
1022
1091
|
/**
|
|
@@ -1036,13 +1105,13 @@ function menuNavPath(
|
|
|
1036
1105
|
* they can drift, and a drift there is silently either a lost navigation or a
|
|
1037
1106
|
* poisoned document.
|
|
1038
1107
|
*/
|
|
1039
|
-
type
|
|
1108
|
+
type BarePathReading = { kind: "nav"; path: string } | { kind: "abstain" };
|
|
1040
1109
|
|
|
1041
1110
|
function readRelativeBare(
|
|
1042
1111
|
text: string,
|
|
1043
1112
|
ctx: string,
|
|
1044
1113
|
range: MaskedRange,
|
|
1045
|
-
):
|
|
1114
|
+
): BarePathReading | null {
|
|
1046
1115
|
const trimmed = trimAscii(text);
|
|
1047
1116
|
if (
|
|
1048
1117
|
trimmed.length === 0 ||
|
|
@@ -1074,17 +1143,22 @@ function readRelativeBare(
|
|
|
1074
1143
|
}
|
|
1075
1144
|
|
|
1076
1145
|
/**
|
|
1077
|
-
* #235 — does `text` look like a
|
|
1146
|
+
* #235, #211 — does `text` look like a menu navigation that ABSTAINED, in
|
|
1147
|
+
* either spelling?
|
|
1078
1148
|
*
|
|
1079
1149
|
* Presence in a table → nav; absence → abstain → poison downstream, so the
|
|
1080
1150
|
* next relative statement cannot resolve against a context that may have moved.
|
|
1081
1151
|
*/
|
|
1082
|
-
function
|
|
1152
|
+
function isAbstainedNav(
|
|
1083
1153
|
text: string,
|
|
1084
1154
|
ctx: string,
|
|
1085
1155
|
range: MaskedRange,
|
|
1086
1156
|
): boolean {
|
|
1087
|
-
|
|
1157
|
+
const trimmed = trimAscii(text);
|
|
1158
|
+
const reading = trimmed.startsWith("/")
|
|
1159
|
+
? readAbsoluteBare(trimmed)
|
|
1160
|
+
: readRelativeBare(text, ctx, range);
|
|
1161
|
+
return reading?.kind === "abstain";
|
|
1088
1162
|
}
|
|
1089
1163
|
|
|
1090
1164
|
function canonicalPath(
|
|
@@ -1214,6 +1288,7 @@ function collectBrackets(
|
|
|
1214
1288
|
defects: Defect[],
|
|
1215
1289
|
contextCertain: boolean,
|
|
1216
1290
|
loc: Loc,
|
|
1291
|
+
owner: number,
|
|
1217
1292
|
inLiteral = false,
|
|
1218
1293
|
): void {
|
|
1219
1294
|
const { masked, start: lo, end: hi } = range;
|
|
@@ -1240,6 +1315,7 @@ function collectBrackets(
|
|
|
1240
1315
|
defects,
|
|
1241
1316
|
contextCertain,
|
|
1242
1317
|
nest(loc, i + 1 - lo, loc.fallback),
|
|
1318
|
+
owner,
|
|
1243
1319
|
);
|
|
1244
1320
|
i = strEnd;
|
|
1245
1321
|
continue;
|
|
@@ -1259,6 +1335,7 @@ function collectBrackets(
|
|
|
1259
1335
|
defects,
|
|
1260
1336
|
contextCertain,
|
|
1261
1337
|
nest(loc, i + 1 - lo, loc.fallback),
|
|
1338
|
+
owner,
|
|
1262
1339
|
true,
|
|
1263
1340
|
);
|
|
1264
1341
|
i = end;
|
|
@@ -1273,6 +1350,7 @@ function collectBrackets(
|
|
|
1273
1350
|
const inner = masked.slice(innerAt, innerEnd);
|
|
1274
1351
|
// The span covers the whole `[…]`, closing bracket included.
|
|
1275
1352
|
const resolution: Resolution = {
|
|
1353
|
+
owner,
|
|
1276
1354
|
...resolveInner(inner, ctx, depth, contextCertain),
|
|
1277
1355
|
span: spanIn(loc, i - lo, Math.min(end + 1, hi) - lo),
|
|
1278
1356
|
innerSpan: spanIn(loc, innerAt - lo, innerEnd - lo),
|
|
@@ -1289,6 +1367,7 @@ function collectBrackets(
|
|
|
1289
1367
|
defects,
|
|
1290
1368
|
nestedCertainty(resolution, contextCertain),
|
|
1291
1369
|
nest(loc, innerAt - lo, loc.fallback),
|
|
1370
|
+
owner,
|
|
1292
1371
|
);
|
|
1293
1372
|
i = end;
|
|
1294
1373
|
}
|
|
@@ -1346,6 +1425,7 @@ function scanInterpolations(
|
|
|
1346
1425
|
defects: Defect[],
|
|
1347
1426
|
contextCertain: boolean,
|
|
1348
1427
|
loc: Loc,
|
|
1428
|
+
owner: number,
|
|
1349
1429
|
): void {
|
|
1350
1430
|
const { text, start: lo, end: hi } = range;
|
|
1351
1431
|
for (let i = lo; i < hi - 1; i++) {
|
|
@@ -1367,6 +1447,7 @@ function scanInterpolations(
|
|
|
1367
1447
|
// Span the `$[…]` from its sigil through the closing bracket; `innerSpan`
|
|
1368
1448
|
// is the two-byte-in start that difference makes non-derivable.
|
|
1369
1449
|
const resolution: Resolution = {
|
|
1450
|
+
owner,
|
|
1370
1451
|
...resolveInner(maskedInner, ctx, depth, contextCertain),
|
|
1371
1452
|
inner,
|
|
1372
1453
|
span: spanIn(loc, i - lo, Math.min(end + 1, hi) - lo),
|
|
@@ -1382,6 +1463,7 @@ function scanInterpolations(
|
|
|
1382
1463
|
defects,
|
|
1383
1464
|
nestedCertainty(resolution, contextCertain),
|
|
1384
1465
|
nest(loc, innerAt - lo, loc.fallback),
|
|
1466
|
+
owner,
|
|
1385
1467
|
);
|
|
1386
1468
|
i = end;
|
|
1387
1469
|
}
|
|
@@ -1435,7 +1517,7 @@ function resolveInner(
|
|
|
1435
1517
|
ctx: string,
|
|
1436
1518
|
depth: number,
|
|
1437
1519
|
contextCertain: boolean,
|
|
1438
|
-
): Omit<Resolution, "span" | "innerSpan"> {
|
|
1520
|
+
): Omit<Resolution, "span" | "innerSpan" | "owner"> {
|
|
1439
1521
|
const absolute = inner.startsWith("/");
|
|
1440
1522
|
const klass = classify(inner, absolute, depth);
|
|
1441
1523
|
if (klass === "cli-prompt-artifact")
|
|
@@ -112,6 +112,31 @@ function escapeLength(text: string, at: number): 2 | 3 {
|
|
|
112
112
|
// (skip vs validate) and whether they skip comment spans.
|
|
113
113
|
// ---------------------------------------------------------------------------
|
|
114
114
|
|
|
115
|
+
/**
|
|
116
|
+
* Whether the byte after a string-phase `$` makes it a device error.
|
|
117
|
+
*
|
|
118
|
+
* Inside a string a `$` must start an interpolation: a name
|
|
119
|
+
* (`"$v"`, `"$v_x"` is `$v` then `_x`), `$"…"`, `$(…)` or `$[…]`. Every other
|
|
120
|
+
* follower is `syntax error` at that byte on CHR 7.24.5 and 7.23.8, swept with
|
|
121
|
+
* `:parse` on a stored script source: `"a$ b"`, `"$-"`, `"$."`, `"$,"`, `"$_"`,
|
|
122
|
+
* `"$;"`, `"$="`, `"${"`, `"$)"`, `"$\n"`, a TAB, and the same inside
|
|
123
|
+
* `where`, an array, a group and a nested `$[…]` string (#506). A literal
|
|
124
|
+
* dollar is `\$`.
|
|
125
|
+
*
|
|
126
|
+
* `$$` abstains: the device does not stop at the second `$` (`"$$"` is
|
|
127
|
+
* rejected at the closing quote), so what it reads there is not known.
|
|
128
|
+
*
|
|
129
|
+
* `$"` abstains. The device reads it as a quoted variable name (`"a$"v""` is
|
|
130
|
+
* `(. a $v)`), so the next `"` does not close the string, and every scanner in
|
|
131
|
+
* `explain` closes it there; `"a$"` is a device rejection this cannot see. A
|
|
132
|
+
* non-ASCII stand-in (`SUB`) abstains too: the byte it replaced is #371's.
|
|
133
|
+
*/
|
|
134
|
+
function invalidStringSigil(next: string | undefined): boolean {
|
|
135
|
+
if (next === undefined || next === '"' || next === "$" || next === "\x1a")
|
|
136
|
+
return false;
|
|
137
|
+
return !/[A-Za-z0-9[(]/.test(next);
|
|
138
|
+
}
|
|
139
|
+
|
|
115
140
|
/**
|
|
116
141
|
* One step of the shared frame machine. Owns the depth guard, the `top` read,
|
|
117
142
|
* and the phase dispatch (`"` vs code) so callers cannot drift on them.
|
|
@@ -125,6 +150,7 @@ function stepFrame(
|
|
|
125
150
|
frames: string[],
|
|
126
151
|
onStringEscape: (at: number) => number | Defect,
|
|
127
152
|
maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
|
|
153
|
+
validateSigil = false,
|
|
128
154
|
): number | Defect {
|
|
129
155
|
if (frames.length > maxFrameDepth) return 0;
|
|
130
156
|
const top = frames[frames.length - 1] as string | undefined;
|
|
@@ -139,6 +165,8 @@ function stepFrame(
|
|
|
139
165
|
frames.push(text[i + 1] as string);
|
|
140
166
|
return 2;
|
|
141
167
|
}
|
|
168
|
+
if (c === "$" && validateSigil && invalidStringSigil(text[i + 1]))
|
|
169
|
+
return { code: "bad-string-variable", start: i, end: i + 2 };
|
|
142
170
|
return 1;
|
|
143
171
|
}
|
|
144
172
|
if (c === '"') {
|
|
@@ -303,12 +331,19 @@ export function walkStringEscapes(
|
|
|
303
331
|
continue;
|
|
304
332
|
}
|
|
305
333
|
}
|
|
306
|
-
const res = stepFrame(
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
334
|
+
const res = stepFrame(
|
|
335
|
+
text,
|
|
336
|
+
i,
|
|
337
|
+
frames,
|
|
338
|
+
(at) => {
|
|
339
|
+
const step = stringEscapeValidated(text, at);
|
|
340
|
+
if (collectEscapes && typeof step === "number")
|
|
341
|
+
escapes.push({ start: at, end: at + step });
|
|
342
|
+
return step;
|
|
343
|
+
},
|
|
344
|
+
MAX_STRING_FRAME_DEPTH,
|
|
345
|
+
true,
|
|
346
|
+
);
|
|
312
347
|
if (typeof res !== "number") return { escapes, defects: [res] };
|
|
313
348
|
if (res === 0) break;
|
|
314
349
|
i += res;
|