@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
package/src/execute.ts CHANGED
@@ -27,6 +27,7 @@ import { buildTip, notReachedReason } from "./core/envelope.ts";
27
27
  import {
28
28
  type CanonicalExecuteCommand,
29
29
  canonicalizeExecuteCommand,
30
+ isBareMenuPathCommand,
30
31
  isWriteShaped,
31
32
  } from "./core/execute-command.ts";
32
33
  import { sentWriteWarnings } from "./core/fanout.ts";
@@ -40,6 +41,7 @@ import {
40
41
  SYNTAX_REJECTED_REMEDIATION,
41
42
  serializeCentrsError,
42
43
  } from "./errors.ts";
44
+ import { countExecutableStatements } from "./explain/segment.ts";
43
45
  import {
44
46
  assertOfflineSyntax,
45
47
  isOfflineGateRejection,
@@ -162,6 +164,7 @@ export type ExecuteErrorEnvelope = CentrsErrorEnvelope<ExecuteOperationMeta>;
162
164
  export {
163
165
  type CanonicalExecuteCommand,
164
166
  canonicalizeExecuteCommand,
167
+ isBareMenuPathCommand,
165
168
  isWriteShaped,
166
169
  };
167
170
 
@@ -608,6 +611,22 @@ export function buildExecuteErrorEnvelope(
608
611
  };
609
612
  }
610
613
 
614
+ /**
615
+ * Whether the dispatch carried more than one statement (#549). Mirrors
616
+ * `runCommand`: the consoles run the raw CLI text whatever the canonical mode,
617
+ * while REST/native run structured input as one path-POST/talk sentence — a
618
+ * `;` inside a structured value never separates there, so the refusal means
619
+ * nothing ran. Executed control scopes count too: `:if … do={ A; B }` applies
620
+ * A before a refused B, while stored bodies never run at dispatch.
621
+ */
622
+ function isMultiStatementDispatch(resolved: ResolvedExecuteRequest): boolean {
623
+ const runsRawText =
624
+ resolved.via.value === "mac-telnet" ||
625
+ resolved.via.value === "ssh" ||
626
+ resolved.canonical.mode === "script";
627
+ return runsRawText && countExecutableStatements(resolved.command) > 1;
628
+ }
629
+
611
630
  export function buildExecuteErrorEnvelopeFromResolved(
612
631
  resolved: ResolvedExecuteRequest,
613
632
  error: unknown,
@@ -633,6 +652,7 @@ export function buildExecuteErrorEnvelopeFromResolved(
633
652
  isWriteShaped(resolved.canonical),
634
653
  centrsError,
635
654
  trace,
655
+ isMultiStatementDispatch(resolved),
636
656
  ),
637
657
  ],
638
658
  tips: resolvedExecuteTips(resolved),
@@ -664,20 +684,15 @@ function failedValidationMeta(
664
684
  }
665
685
  const deviceSource = deviceStageSource(resolved);
666
686
  const structured = resolved.canonical.mode === "structured";
667
- // A flat summary is present only when its check gave a verdict (GH#435).
668
- // Script-shaped input has no semantic half at all: `not-applicable`. For
669
- // structured input, a syntax rejection means the name-level check was
670
- // never asked, so `semantic` is left out rather than reported `false`.
671
- const semanticNotReached = structured
672
- ? {}
673
- : { semantic: "not-applicable" as const };
687
+ // A flat summary is present only when a stage gave that verdict (#535). A
688
+ // syntax rejection means the name-level check was never asked, so
689
+ // `semantic` is left out rather than reported `false`.
674
690
  if (isOfflineGateRejection(error)) {
675
691
  return {
676
692
  enabled: true,
677
693
  source: OFFLINE_GATE_SOURCE,
678
694
  result: "failed",
679
695
  syntax: false,
680
- ...semanticNotReached,
681
696
  stages: [
682
697
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "failed" },
683
698
  {
@@ -705,9 +720,7 @@ function failedValidationMeta(
705
720
  enabled: true,
706
721
  source: deviceSource,
707
722
  result: "failed",
708
- ...(nameRejected
709
- ? { syntax: true, semantic: false }
710
- : { syntax: false, ...semanticNotReached }),
723
+ ...(nameRejected ? { syntax: true, semantic: false } : { syntax: false }),
711
724
  stages: [
712
725
  offlineStagePassed(trace?.offlineVerdict),
713
726
  { stage: "device", source: deviceSource, result: "failed" },
@@ -781,15 +794,14 @@ export function offlineStagePassed(
781
794
  * `validate=false`. Both stages are listed as `skipped` rather than omitted:
782
795
  * the constitution's rule is that a gated surface always reports each stage, so
783
796
  * a consumer reading `stages` never has to special-case the disabled shape.
784
- * `reason` is left off — `enabled: false` already says why.
797
+ * `reason` is left off — `enabled: false` already says why. No stage answered,
798
+ * so there is no `syntax` / `semantic` summary (#535).
785
799
  */
786
800
  export function disabledValidationMeta(): EnvelopeValidationMeta {
787
801
  return {
788
802
  enabled: false,
789
803
  source: "disabled",
790
804
  result: "skipped",
791
- syntax: false,
792
- semantic: false,
793
805
  stages: [
794
806
  { stage: "offline", source: OFFLINE_GATE_SOURCE, result: "skipped" },
795
807
  { stage: "device", source: "device preflight", result: "skipped" },
@@ -862,7 +874,6 @@ async function validateExecuteCommand(
862
874
  source: ":put [:parse]",
863
875
  result: "passed",
864
876
  syntax: true,
865
- semantic: "not-applicable",
866
877
  stages: passedStages(offline, ":put [:parse]"),
867
878
  };
868
879
  }
@@ -929,8 +940,7 @@ async function validateConsoleParseCommand(
929
940
  source: `:put [:parse ...] over ${resolved.via.value}`,
930
941
  result: "passed",
931
942
  syntax: true,
932
- semantic:
933
- resolved.canonical.mode === "structured" ? true : "not-applicable",
943
+ ...(resolved.canonical.mode === "structured" ? { semantic: true } : {}),
934
944
  stages: passedStages(
935
945
  offline,
936
946
  `:put [:parse ...] over ${resolved.via.value}`,
@@ -1452,20 +1462,38 @@ function executeRequestSummary(
1452
1462
  * downstream auth error.
1453
1463
  */
1454
1464
  export function resolvedExecuteTips(resolved: ResolvedExecuteRequest): Tip[] {
1465
+ const tips: Tip[] = [];
1455
1466
  if (
1456
1467
  resolved.via.value === "mac-telnet" &&
1457
1468
  resolved.auth.username === undefined &&
1458
1469
  !resolved.auth.passwordProvided
1459
1470
  ) {
1460
- return [
1471
+ tips.push(
1461
1472
  buildTip(
1462
1473
  "tip/mac-telnet-no-credentials",
1463
1474
  "No username or password was resolved for this mac-telnet target.",
1464
1475
  "mac-telnet logs in with RouterOS credentials over MTWEI (MD5 is refused): pass --username/--password, set CENTRS_USERNAME/CENTRS_PASSWORD, or add a CDB record for this device.",
1465
1476
  ),
1466
- ];
1477
+ );
1478
+ }
1479
+ // A bare menu path runs — the write gate no longer stops it — but it
1480
+ // answers nothing: consoles navigate silently and the APIs refuse it with
1481
+ // `no such command`. Name the read that does answer. `canonical` is
1482
+ // always resolved in production; the guard keeps hermetic stubs total.
1483
+ if (
1484
+ resolved.canonical !== undefined &&
1485
+ isBareMenuPathCommand(resolved.canonical)
1486
+ ) {
1487
+ const menu = `${resolved.canonical.path}/${resolved.canonical.verb}`;
1488
+ tips.push(
1489
+ buildTip(
1490
+ "tip/execute-bare-menu-path",
1491
+ `"${menu}" is a menu path, not a command: consoles navigate to it (no output) and the APIs refuse it with "no such command".`,
1492
+ `Append a verb such as "${menu}/print", or read rows with "centrs retrieve <router> ${menu}".`,
1493
+ ),
1494
+ );
1467
1495
  }
1468
- return [];
1496
+ return tips;
1469
1497
  }
1470
1498
 
1471
1499
  function dataFromResult(result: ProtocolExecuteResult): unknown {
@@ -78,7 +78,12 @@
78
78
 
79
79
  import { isScopeBrace, isScopeBraceIn } from "./blocks.ts";
80
80
  import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
81
- import { braceStartsStatements } from "./scope-brace.ts";
81
+ import {
82
+ braceBodyIsScriptLike,
83
+ braceStartsStatements,
84
+ isScriptBodyName,
85
+ scopeNameFromMasked,
86
+ } from "./scope-brace.ts";
82
87
  import {
83
88
  type MaskedRange,
84
89
  maskComments,
@@ -87,7 +92,7 @@ import {
87
92
  } from "./segment.ts";
88
93
 
89
94
  /** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
90
- const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
95
+ export const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
91
96
 
92
97
  /** What one argument token is. */
93
98
  export type ArgumentKind = "attribute" | "positional" | "query";
@@ -327,6 +332,12 @@ interface WalkOptions {
327
332
  * see {@link braceSlotTakesArray}.
328
333
  */
329
334
  directiveVerb?: string;
335
+ /**
336
+ * Where a command's top-level `where` positional ends, set by the walk itself
337
+ * once it reads one. From there on a brace is an expression value
338
+ * (see {@link comparesNamedValue}).
339
+ */
340
+ whereAt?: number;
330
341
  }
331
342
 
332
343
  /** How deep member scanning descends before it stops and keeps only the shape. */
@@ -347,6 +358,34 @@ function braceValueOwner(
347
358
  return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
348
359
  }
349
360
 
361
+ /**
362
+ * The `name` of a `name= {…}` brace: the token before the gap ends in a glued
363
+ * `name=` run (#554).
364
+ *
365
+ * {@link braceValueOwner} only sees the `=` glued to the brace (`down={`). A
366
+ * space after the `=` starts a new token, so the brace reads as positional
367
+ * there — but on CHR 7.25 `down= { :beep }` lowers to `down-script=` exactly
368
+ * like the glued spelling, while `comment= {1;2}` is a `syntax error` at the
369
+ * `{`. Only spaces and tabs span the gap: a newline there is a statement
370
+ * boundary, not a gap, and the name run must start its token, so `=down= {`
371
+ * and `down = {` own nothing.
372
+ */
373
+ function spacedBraceOwner(text: string, at: number): string | undefined {
374
+ let j = at - 1;
375
+ let gap = 0;
376
+ while (text[j] === " " || text[j] === "\t") {
377
+ j--;
378
+ gap++;
379
+ }
380
+ if (gap === 0 || text[j] !== "=") return undefined;
381
+ const nameEnd = j;
382
+ j--;
383
+ while (j >= 0 && /[A-Za-z0-9.-]/.test(text[j] as string)) j--;
384
+ if (j >= 0 && !/[\s([]/.test(text[j] as string)) return undefined;
385
+ const name = text.slice(j + 1, nameEnd);
386
+ return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
387
+ }
388
+
350
389
  /**
351
390
  * Whether a `{` at `at` may open an array literal in this statement.
352
391
  *
@@ -363,7 +402,11 @@ function braceOpensArray(
363
402
  options: WalkOptions,
364
403
  ): boolean {
365
404
  const verb = options.directiveVerb;
366
- if (verb === undefined) return false;
405
+ if (verb === undefined)
406
+ return (
407
+ options.whereAt !== undefined &&
408
+ !comparesNamedValue(text, at, options.whereAt)
409
+ );
367
410
  const owner = braceValueOwner(text, tokenStart, at);
368
411
  if (owner === undefined) return false;
369
412
  return braceSlotTakesArray(
@@ -390,6 +433,7 @@ function* walkArguments(
390
433
  let i = Math.max(0, from);
391
434
  // How many positionals have been read, which is the NEXT positional's index.
392
435
  let positionals = 0;
436
+ let walk = options;
393
437
  while (i < text.length) {
394
438
  // The MASKED view decides whitespace, because `maskComments` blanks a
395
439
  // continuation comment that `text` still spells with a `#`. Reading `text`
@@ -412,7 +456,7 @@ function* walkArguments(
412
456
  i += continuation;
413
457
  continue;
414
458
  }
415
- const token = scanToken(text, structural, i, positionals, options);
459
+ const token = scanToken(text, structural, i, positionals, walk);
416
460
  if (typeof token === "string") {
417
461
  yield token;
418
462
  return;
@@ -430,7 +474,7 @@ function* walkArguments(
430
474
  // the refusal exists to prevent. Shape only, never a value.
431
475
  const read =
432
476
  token.why === undefined
433
- ? readToken(text, structural, i, token.end, options)
477
+ ? readToken(text, structural, i, token.end, walk)
434
478
  : undecidedToken(text, i, token.end, token.why);
435
479
  if (typeof read === "string") {
436
480
  if (options.tolerant !== true) {
@@ -446,6 +490,13 @@ function* walkArguments(
446
490
  continue;
447
491
  }
448
492
  if (read.kind === "positional") positionals++;
493
+ if (
494
+ read.kind === "positional" &&
495
+ read.text === "where" &&
496
+ walk.directiveVerb === undefined &&
497
+ walk.whereAt === undefined
498
+ )
499
+ walk = { ...walk, whereAt: token.end };
449
500
  yield read;
450
501
  i = token.end;
451
502
  }
@@ -1661,26 +1712,87 @@ export function lexValueAnchors(
1661
1712
  return { complete: true, anchors };
1662
1713
  }
1663
1714
 
1715
+ /**
1716
+ * Whether the brace at `at` is the right side of a `where` comparison that
1717
+ * RouterOS reads with the named property's own value parser (#502).
1718
+ *
1719
+ * A `where` clause is expression context, so a brace there is an array value —
1720
+ * except straight after `<name>=`, `<name>!=` or `<name>==`, spaced or not.
1721
+ * On CHR 7.24.5 and 7.23.8, `:parse` rejects `/ip/route/print where
1722
+ * comment={1;2}` with `expected comment value`, and `where disabled={…}` with
1723
+ * `expected yes or no`. It accepts the brace after `in`, `~`, `<`, `>`, `<=`
1724
+ * and `>=`, on the left of `=`, and after `!interface=`, `$x=` and `"a"=`.
1725
+ *
1726
+ * Only a property name the menu KNOWS is read this way: `where foo-bar={…}` is
1727
+ * accepted. Offline has no per-menu property list (decision 3), so every bare
1728
+ * name counts, and an unknown one is a pinned over-strict reading.
1729
+ *
1730
+ * The name may open a group, `where (interface={"x"})` and `or(comment={…})`,
1731
+ * which `group-faults.ts` asks about (#509).
1732
+ */
1733
+ export function comparesNamedValue(
1734
+ text: string,
1735
+ at: number,
1736
+ floor: number,
1737
+ ): boolean {
1738
+ let j = at - 1;
1739
+ while (j > floor && /\s/.test(text[j] as string)) j--;
1740
+ if (text[j] !== "=") return false;
1741
+ while (j > floor && text[j] === "=") j--;
1742
+ if (text[j] === "<" || text[j] === ">") return false;
1743
+ if (text[j] === "!") j--;
1744
+ while (j > floor && /\s/.test(text[j] as string)) j--;
1745
+ const nameEnd = j + 1;
1746
+ while (j > floor && /[A-Za-z0-9._-]/.test(text[j] as string)) j--;
1747
+ const name = text.slice(j + 1, nameEnd);
1748
+ return /^[A-Za-z.]/.test(name) && /[\s(]/.test(text[j] as string);
1749
+ }
1750
+
1751
+ /** One top-level `{` in command arguments that is not a proven body. */
1752
+ export interface CommandBraceFinding {
1753
+ /** Offset of the `{` in the text passed to the lexer. */
1754
+ at: number;
1755
+ /**
1756
+ * True when `name={` with a script-like body on a non-root command outside
1757
+ * `where`, where `name` is not a proven script-body slot (#525). The brace
1758
+ * may abbreviate one (`down=` for `down-script=`), but telling it from
1759
+ * `comment=` needs the command's argument list, which is schema — so the
1760
+ * caller warns instead of rejecting, and the device decides.
1761
+ */
1762
+ ambiguous: boolean;
1763
+ /** The `name` of `name=`, lower-cased, when `ambiguous` is true. */
1764
+ name?: string;
1765
+ }
1766
+
1664
1767
  /**
1665
1768
  * Locate top-level `{...}` values that RouterOS rejects in command arguments.
1666
1769
  *
1667
1770
  * Parentheses and substitutions restore expression context, where a brace
1668
- * literal is legal. Stored-script attributes are also excluded: their brace is
1669
- * a script body, not an array. The caller applies this only to non-root
1670
- * commands; root scripting directives use the `(verb, slot)` table instead.
1771
+ * literal is legal, and so does a `where` clause, apart from the comparisons
1772
+ * {@link comparesNamedValue} names. Stored-script attributes are also
1773
+ * excluded: their brace is a script body, not an array. The caller applies
1774
+ * this only to non-root commands; root scripting directives use the
1775
+ * `(verb, slot)` table instead.
1776
+ *
1777
+ * A `name={` with a script-like body that is not a proven slot is returned
1778
+ * with `ambiguous: true` rather than as an error (#525), and so is the spaced
1779
+ * `name= {…}` spelling (`down= { :beep }` lowers to `down-script=` on CHR
1780
+ * 7.25). Array-content braces (`comment={1;2}`), positional braces, `where`
1781
+ * comparisons and root slots keep their rejection.
1671
1782
  */
1672
1783
  export function invalidCommandBraceOffsets(
1673
1784
  text: string,
1674
1785
  from: number,
1675
1786
  directiveVerb?: string,
1676
1787
  range?: MaskedRange,
1677
- ): number[] {
1788
+ ): CommandBraceFinding[] {
1678
1789
  const structural = maskOf(range) ?? maskComments(text);
1679
- const offsets: number[] = [];
1790
+ const offsets: CommandBraceFinding[] = [];
1680
1791
  const stack: string[] = [];
1681
1792
  let i = Math.max(0, from);
1682
1793
  let tokenStart: number | null = null;
1683
1794
  let positionals = 0;
1795
+ let whereAt: number | null = null;
1684
1796
  while (i < structural.length) {
1685
1797
  const c = structural[i] as string;
1686
1798
  if (
@@ -1689,6 +1801,7 @@ export function invalidCommandBraceOffsets(
1689
1801
  ) {
1690
1802
  if (tokenStart !== null) {
1691
1803
  const token = structural.slice(tokenStart, i);
1804
+ if (whereAt === null && token === "where") whereAt = i;
1692
1805
  if (
1693
1806
  !token.startsWith("?") &&
1694
1807
  unquotedEquals(token, 0, token.length) === null
@@ -1725,7 +1838,54 @@ export function invalidCommandBraceOffsets(
1725
1838
  tokenStart === null
1726
1839
  ? undefined
1727
1840
  : braceValueOwner(structural, tokenStart, i);
1728
- const slot = owner === null ? `#${positionals}` : owner;
1841
+ // A space after the `=` owns the brace the same way the glued `=`
1842
+ // does (#554), so the slot attributes the spaced owner before
1843
+ // falling back to positional: `:put x= { :beep }` reads slot `x`,
1844
+ // not `#0` (which `put` accepts, swallowing the error).
1845
+ const spacedOwner =
1846
+ typeof owner === "string" ? undefined : spacedBraceOwner(structural, i);
1847
+ const slot = owner === null ? (spacedOwner ?? `#${positionals}`) : owner;
1848
+ // A `where` named comparison reads the brace with the property's own
1849
+ // parser and rejects it (`expected <name> value`), so the #525 body
1850
+ // guess must not shield it below — `gateway={ :beep }` included.
1851
+ const inWhereComparison =
1852
+ directiveVerb === undefined &&
1853
+ whereAt !== null &&
1854
+ comparesNamedValue(structural, i, whereAt);
1855
+ // The ambiguity check comes first: `braceStartsStatements` reads an
1856
+ // unproven `name={` with a script-like body as a body too (#525), so
1857
+ // reaching that skip would swallow the warning that marks the guess.
1858
+ // Control scopes (`do={` on a monitor, `:retry … command={`) are not
1859
+ // guesses — the scope table already proves them — so they stay out.
1860
+ // A space after the `=` owns the brace the same way the glued `=`
1861
+ // does (`down= { :beep }` lowers to `down-script=` on CHR 7.25), so
1862
+ // the spaced owner warns under the same gates.
1863
+ const scopeName =
1864
+ directiveVerb === undefined && whereAt === null
1865
+ ? range === undefined
1866
+ ? scopeNameFromMasked(structural, i)
1867
+ : scopeNameFromMasked(range.masked, range.start + i, range.start)
1868
+ : null;
1869
+ const ambiguousOwner = typeof owner === "string" ? owner : spacedOwner;
1870
+ if (
1871
+ directiveVerb === undefined &&
1872
+ whereAt === null &&
1873
+ scopeName === null &&
1874
+ typeof ambiguousOwner === "string" &&
1875
+ !isScriptBodyName(ambiguousOwner) &&
1876
+ braceBodyIsScriptLike(structural, i, end ?? structural.length)
1877
+ ) {
1878
+ offsets.push({ at: i, ambiguous: true, name: ambiguousOwner });
1879
+ i = end ?? i + 1;
1880
+ continue;
1881
+ }
1882
+ // A root directive's slots and a `where` named comparison never take
1883
+ // a script body through an unproven name: `:put x={ :beep }` and
1884
+ // `where gateway={ :beep }` are device rejections, so the brace reads
1885
+ // as a VALUE for the body check and the error below stands (#554).
1886
+ // Proven slots still skip — the gate is only the unproven disjunct.
1887
+ const unprovenName =
1888
+ directiveVerb !== undefined || inWhereComparison ? "value" : "body";
1729
1889
  if (
1730
1890
  // Asked of the DOCUMENT when the caller anchored this statement, so the
1731
1891
  // statement index behind it is the document's one index rather than a
@@ -1733,16 +1893,22 @@ export function invalidCommandBraceOffsets(
1733
1893
  // subtree, and so was rebuilt once per enclosing level (#322). The floor
1734
1894
  // makes the two readings the same answer (`scope-brace.ts`).
1735
1895
  (range === undefined
1736
- ? braceStartsStatements(structural, i)
1737
- : braceStartsStatements(range.masked, range.start + i, range.start)) ||
1896
+ ? braceStartsStatements(structural, i, 0, unprovenName)
1897
+ : braceStartsStatements(
1898
+ range.masked,
1899
+ range.start + i,
1900
+ range.start,
1901
+ unprovenName,
1902
+ )) ||
1738
1903
  (directiveVerb !== undefined &&
1739
1904
  slot !== undefined &&
1740
- braceSlotAcceptsBrace(directiveVerb, slot))
1905
+ braceSlotAcceptsBrace(directiveVerb, slot)) ||
1906
+ (directiveVerb === undefined && whereAt !== null && !inWhereComparison)
1741
1907
  ) {
1742
1908
  i = end ?? structural.length;
1743
1909
  continue;
1744
1910
  }
1745
- offsets.push(i);
1911
+ offsets.push({ at: i, ambiguous: false });
1746
1912
  i = end ?? i + 1;
1747
1913
  }
1748
1914
  return offsets;
@@ -13,10 +13,9 @@
13
13
  * name (and, for the one head-dependent case, the leading directive) off the
14
14
  * statement itself.
15
15
  *
16
- * This module ships only the (single-pass, non-recursive) classification
17
- * primitives the path resolver needs. The recursive block *tree* / topology
18
- * surface stays in the lab until it can be promoted with a bounded, index-based
19
- * traversal of its own.
16
+ * This module ships the single-pass, non-recursive classification primitives
17
+ * the path resolver needs. `explain.ts` publishes source ownership from that
18
+ * resolver's existing bounded traversal; `scopeBlocks` remains a shallow scan.
20
19
  *
21
20
  * Every string skip here goes through `segment.ts`'s `scanQuotedString` rather
22
21
  * than a local quote-to-quote loop, so this module and the segmenter cannot
@@ -134,6 +134,11 @@ export function braceSlotTakesArray(verb: string, slot: string): boolean {
134
134
  return BRACE_ARRAY_SLOTS.get(verb)?.has(slot) === true;
135
135
  }
136
136
 
137
+ /** Whether a `{…}` in this root-directive slot is code or script text. */
138
+ export function braceSlotTakesProgram(verb: string, slot: string): boolean {
139
+ return BRACE_PROGRAM_SLOTS.get(verb)?.has(slot) === true;
140
+ }
141
+
137
142
  /** Whether the device sweep proved this root-directive slot accepts `{...}`. */
138
143
  export function braceSlotAcceptsBrace(verb: string, slot: string): boolean {
139
144
  return (
@@ -1317,6 +1317,11 @@ export function lookupPath(
1317
1317
  return PATH_CATALOG.get(`/${segments.join("/").toLowerCase()}`);
1318
1318
  }
1319
1319
 
1320
+ /** Segments in the deepest catalog path; no lookup past it can match. */
1321
+ const MAX_PATH_DEPTH = Math.max(
1322
+ ...Array.from(PATH_CATALOG.keys(), (path) => path.split("/").length - 1),
1323
+ );
1324
+
1320
1325
  /**
1321
1326
  * Where a published COMMAND ends inside a path run: the index of the run
1322
1327
  * segment that is the VERB, or `null` when no prefix of `segments` is one.
@@ -1340,12 +1345,14 @@ export function lookupPath(
1340
1345
  * question; what the segment IS is not.
1341
1346
  */
1342
1347
  export function commandVerbIndex(segments: readonly string[]): number | null {
1343
- for (let depth = 1; depth <= segments.length; depth++)
1344
- if (
1345
- PATH_CATALOG.get(`/${segments.slice(0, depth).join("/").toLowerCase()}`)
1346
- ?.kind === "command"
1347
- )
1348
- return depth - 1;
1348
+ // Bounded by the deepest row and built incrementally: a run of thousands of
1349
+ // bare words would otherwise re-join every prefix (#496).
1350
+ const limit = Math.min(segments.length, MAX_PATH_DEPTH);
1351
+ let path = "";
1352
+ for (let depth = 0; depth < limit; depth++) {
1353
+ path += `/${(segments[depth] as string).toLowerCase()}`;
1354
+ if (PATH_CATALOG.get(path)?.kind === "command") return depth;
1355
+ }
1349
1356
  return null;
1350
1357
  }
1351
1358
 
@@ -99,6 +99,7 @@ export type DefectCode =
99
99
  | "over-depth"
100
100
  | "bad-escape"
101
101
  | "bad-string-escape"
102
+ | "bad-string-variable"
102
103
  | "bad-sigil"
103
104
  | "invalid-hash"
104
105
  | "invalid-apostrophe"