@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.
Files changed (68) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +38 -0
  3. package/README.md +2 -2
  4. package/commands/api/AGENTS.md +18 -1
  5. package/commands/api/README.md +10 -6
  6. package/commands/api/examples.md +1 -1
  7. package/commands/execute/README.md +26 -6
  8. package/commands/execute/examples.md +43 -5
  9. package/commands/explain/README.md +873 -182
  10. package/commands/explain/examples.md +447 -38
  11. package/commands/retrieve/README.md +13 -2
  12. package/commands/retrieve/examples.md +30 -1
  13. package/commands/transfer/README.md +8 -4
  14. package/docs/CLI.md +19 -5
  15. package/docs/CONSTITUTION.md +18 -9
  16. package/docs/MATRIX.md +51 -26
  17. package/docs/errors/cmr/caller-policy-not-enforced.md +5 -3
  18. package/docs/errors/routeros/permission-denied.md +24 -0
  19. package/docs/errors/transport/edge-auth-failed.md +22 -0
  20. package/docs/errors/transport/write-outcome-unknown.md +3 -0
  21. package/docs/errors/usage/invalid-cursor.md +20 -0
  22. package/docs/errors/validation/unknown-path.md +9 -0
  23. package/package.json +5 -2
  24. package/src/api.ts +34 -39
  25. package/src/cli/explain.ts +206 -47
  26. package/src/core/envelope.ts +10 -3
  27. package/src/core/error-catalog.ts +15 -0
  28. package/src/core/execute-command.ts +53 -0
  29. package/src/core/fanout.ts +12 -2
  30. package/src/core/inspect.ts +4 -5
  31. package/src/core/routeros-errors.ts +25 -0
  32. package/src/execute-cmr.ts +2 -8
  33. package/src/execute.ts +48 -20
  34. package/src/explain/args.ts +181 -15
  35. package/src/explain/blocks.ts +3 -4
  36. package/src/explain/brace-slots.ts +5 -0
  37. package/src/explain/catalog.ts +13 -6
  38. package/src/explain/defects.ts +1 -0
  39. package/src/explain/group-faults.ts +1060 -0
  40. package/src/explain/highlight-projection.ts +10 -8
  41. package/src/explain/operators.ts +22 -20
  42. package/src/explain/pathresolve.ts +102 -20
  43. package/src/explain/quoted-string.ts +41 -6
  44. package/src/explain/run-tokens.ts +223 -0
  45. package/src/explain/scope-brace.ts +319 -7
  46. package/src/explain/segment.ts +177 -4
  47. package/src/explain/spaced-args.ts +116 -0
  48. package/src/explain/symbols.ts +163 -7
  49. package/src/explain/transport.ts +13 -0
  50. package/src/explain/values.ts +55 -4
  51. package/src/explain/verbsplit.ts +14 -188
  52. package/src/explain/write.ts +8 -1
  53. package/src/explain-live-facets.ts +739 -0
  54. package/src/explain-live-types.ts +490 -0
  55. package/src/explain-live.ts +593 -0
  56. package/src/explain.ts +772 -60
  57. package/src/index.ts +6 -0
  58. package/src/offline-gate.ts +30 -9
  59. package/src/protocols/adapter.ts +88 -10
  60. package/src/protocols/btest-session.ts +30 -10
  61. package/src/protocols/mac-telnet-console.ts +16 -0
  62. package/src/protocols/mac-telnet.ts +38 -10
  63. package/src/protocols/native-api.ts +13 -12
  64. package/src/retrieve-follow.ts +2 -1
  65. package/src/retrieve-sample.ts +2 -1
  66. package/src/retrieve-wait.ts +10 -6
  67. package/src/retrieve.ts +77 -27
  68. 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.5 every byte after it is `none`: the device stops
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.2 and development 7.25beta3 do classify again after the `error`
52
- * in one run, which is why the report gives that a bucket of its own instead
53
- * of asserting the rule (the counts live in the generated README block, not
54
- * here, so they cannot go stale). The rule and its exception swapped builds
55
- * with the #342 channel realignment; the shape of the claim did not.
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, so the vocabulary has held across five builds and two
62
- * capture rounds. A class outside it is `"unknown"` — reported, never
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.
@@ -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 three
5
- * builds the pinned corpus classes `complete` — stable 7.24.2 (which is also
6
- * the current testing build), long-term 7.23.5 and development 7.25beta3 — so
7
- * the operator axis and the token partition are measured on the same firmware
8
- * (#342). A fourth capture, 7.21.5, is kept as an **age anchor** rather than 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, so `op` lives only as a live `observedType` (#288).
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 four swept builds — 7.24.2, 7.23.5,
370
- * 7.25beta3 and the 7.21.5 age anchor — and identical is a measurement here,
371
- * not a reading: `buildSweep` diffs each of those axes per version, so a build
372
- * that re-ranked `->` would appear in `sweep.versionDifferences` rather than 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.2 and 7.25beta3 and errors on both
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.2 and 7.25beta3 and errors on " +
382
- "7.23.5 and 7.21.5; the operator table itself is identical on all four. " +
383
- "**It is not fixed on current long-term**: 7.23.5 is newer than the 7.23.3 " +
384
- "this was first measured on and still errors, so the change rode the 7.24 " +
385
- "line and was not backported. The two errors are not the same error: " +
386
- "7.23.5 says `cannot compare if array is more than array` and 7.21.5 says " +
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
- * Three things make the context unknown, all cases where the statement might
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 relative bare-word that abstained (neither table) poisons
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 = isAbstainedRelativeNav(text, ctx, range);
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(body.units, stmtCtx, out, blockDepth + 1, defects, stmtCertain);
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
- out.push({
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
- out.push({
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
- out.push({
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 relative bare-word that abstained (neither table) poisons
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 = isAbstainedRelativeNav(text, ctx, range);
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
- out.push({
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
- return joinPath("/", tokens.join("/"));
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 RelativeBareReading = { kind: "nav"; path: string } | { kind: "abstain" };
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
- ): RelativeBareReading | null {
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 relative menu navigation that ABSTAINED?
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 isAbstainedRelativeNav(
1152
+ function isAbstainedNav(
1083
1153
  text: string,
1084
1154
  ctx: string,
1085
1155
  range: MaskedRange,
1086
1156
  ): boolean {
1087
- return readRelativeBare(text, ctx, range)?.kind === "abstain";
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(text, i, frames, (at) => {
307
- const step = stringEscapeValidated(text, at);
308
- if (collectEscapes && typeof step === "number")
309
- escapes.push({ start: at, end: at + step });
310
- return step;
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;