@tikoci/centrs 0.1.9-next.23 → 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 (53) hide show
  1. package/CHANGELOG.md +106 -0
  2. package/GLOSSARY.txt +14 -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 +24 -5
  9. package/commands/explain/README.md +452 -61
  10. package/commands/explain/examples.md +191 -41
  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 +14 -9
  16. package/docs/MATRIX.md +34 -21
  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 +2 -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 +115 -10
  35. package/src/explain/group-faults.ts +162 -29
  36. package/src/explain/operators.ts +2 -1
  37. package/src/explain/scope-brace.ts +226 -11
  38. package/src/explain/segment.ts +136 -1
  39. package/src/explain/symbols.ts +146 -6
  40. package/src/explain/values.ts +55 -4
  41. package/src/explain-live-facets.ts +739 -0
  42. package/src/explain-live-types.ts +490 -0
  43. package/src/explain-live.ts +593 -0
  44. package/src/explain.ts +395 -43
  45. package/src/index.ts +6 -0
  46. package/src/protocols/adapter.ts +88 -10
  47. package/src/protocols/mac-telnet.ts +38 -10
  48. package/src/protocols/native-api.ts +13 -12
  49. package/src/retrieve-follow.ts +2 -1
  50. package/src/retrieve-sample.ts +2 -1
  51. package/src/retrieve-wait.ts +10 -6
  52. package/src/retrieve.ts +77 -27
  53. package/src/transfer.ts +38 -13
@@ -29,8 +29,8 @@
29
29
  * after `<name>=` with the property's own parser: `where (comment=+)`,
30
30
  * `(comment=a-)`, `(comment=@)` and `(.id=*1)` parse, though each is an
31
31
  * expression error elsewhere. So a group in a query gets only the structural
32
- * checks (empty, `;`, newline) and #509's brace rule, never the operand or
33
- * `@` rules.
32
+ * checks (empty, `;`, newline), #509's brace rule and the unquoted-byte
33
+ * rule below, never the operand or `@` rules.
34
34
  * - **Arrays (#511).** An array that is the whole value of a statement or an
35
35
  * argument must have an element: `:local z {}`, `:put {}`, `:return {}`,
36
36
  * `[:len {}]` and `in={}` are errors, while `{1;{}}`, `{a={}}`, `({})`,
@@ -53,6 +53,21 @@
53
53
  * declared name that is not letters and digits. `gluedAt`, `wordFaults` and
54
54
  * `nameFault` carry the device rules. One token fault is reported per
55
55
  * statement, and the head word is never read.
56
+ * - **Unquoted bytes (#358, #371).** A `?`, a backtick and a non-ASCII byte
57
+ * are never part of a bare word: the device rejects each at its byte in a
58
+ * head word, a name, a `$name`, a value, a query, an array and an
59
+ * expression (`/systemé`, `print?`, `$café`, `url=http://x/a?b`,
60
+ * `where comment=café`, `{a`b}`, `(café)`), but a menu command's argument
61
+ * name at the name's first byte (`addré=1`, `a?b=1`). A second `=` in a
62
+ * menu command's argument is rejected too (`name=a=b`, a bare base64 key
63
+ * ending in `=`). In a query and in an array it is an operator and a key
64
+ * (`where comment=a=b`, `{a=b}` parse), and a query group takes the byte
65
+ * rule but no other (`where (comment=a?b)` and `in {a?b}` are rejected at
66
+ * the `?`, `where (comment=a=b)` parses); a directive's `a=b` is a named
67
+ * argument on 7.24.5 and a syntax error on 7.23.8, so it is left alone. No
68
+ * property parser was found that takes these bytes (`regexp=a?b`,
69
+ * `public-key=…=`, `secret=…==`, `on-event=a=b` are all rejected). Strings
70
+ * and comments carry any byte (`"café"`, `$"café"`, `:local "café"`).
56
71
  *
57
72
  * Deliberately not here: a `$( … )` group (`:put $({})` is rejected and is
58
73
  * read as `({})`), the `@` in a later head word (`/ip address@ print`), and
@@ -72,7 +87,9 @@ export type GroupFaultCode =
72
87
  | "named-value-brace"
73
88
  | "glued-token"
74
89
  | "invalid-variable"
75
- | "invalid-name";
90
+ | "invalid-name"
91
+ | "unquoted-non-ascii"
92
+ | "unquoted-reserved-byte";
76
93
 
77
94
  export interface GroupFault {
78
95
  code: GroupFaultCode;
@@ -84,6 +101,12 @@ export interface GroupFault {
84
101
  detail?: string;
85
102
  /** The group the fault sits in, for a message that must say which. */
86
103
  group?: "(" | "{";
104
+ /**
105
+ * Where an unquoted-byte fault sits when it is not in a value: a menu
106
+ * command's argument name, or a statement's head word (path, command,
107
+ * `$name`). The remedy differs, since neither has a value to quote.
108
+ */
109
+ site?: "argument-name" | "head-word";
87
110
  }
88
111
 
89
112
  interface StatementFrame {
@@ -95,6 +118,8 @@ interface StatementFrame {
95
118
  whereEnd: number | null;
96
119
  /** The head word without its colon, the key into `brace-slots.ts`. */
97
120
  verb: string | null;
121
+ /** The head word is a `$` call, whose arguments no menu reads (`$f a=1`). */
122
+ call: boolean;
98
123
  /** Positional operands read after the head. */
99
124
  positionals: number;
100
125
  /** No token of the current statement read yet. */
@@ -116,6 +141,8 @@ interface StatementToken {
116
141
  closedAt: number;
117
142
  /** Just past the token's last bare-word run, or -1. */
118
143
  wordEnd: number;
144
+ /** The token's first bare `=`, or -1. */
145
+ eqAt: number;
119
146
  }
120
147
 
121
148
  interface ArrayFrame {
@@ -201,6 +228,31 @@ const STRICT_NAME_DIRECTIVES = new Set(["local", "global", "foreach"]);
201
228
  const ALNUM = /[A-Za-z0-9]/;
202
229
  const SUB = "\x1a";
203
230
 
231
+ /** A byte no bare word holds: `?`, a backtick, a non-ASCII stand-in (#358, #371). */
232
+ function isUnquotedFaultByte(c: string | undefined): boolean {
233
+ return c === "?" || c === "`" || c === SUB;
234
+ }
235
+
236
+ /** The first such byte in `[from, end)`, or -1. */
237
+ function unquotedFaultByteAt(masked: string, from: number, end: number) {
238
+ for (let j = from; j < end; j++) if (isUnquotedFaultByte(masked[j])) return j;
239
+ return -1;
240
+ }
241
+
242
+ /** The fault for the byte at `at`; a non-ASCII run is reported whole. */
243
+ function unquotedByteFault(masked: string, at: number): GroupFault {
244
+ if (masked[at] !== SUB)
245
+ return {
246
+ code: "unquoted-reserved-byte",
247
+ start: at,
248
+ end: at + 1,
249
+ detail: masked[at],
250
+ };
251
+ let end = at;
252
+ while (masked[end] === SUB) end++;
253
+ return { code: "unquoted-non-ascii", start: at, end };
254
+ }
255
+
204
256
  function statementFrame(char: StatementFrame["char"]): StatementFrame {
205
257
  return {
206
258
  kind: "statement",
@@ -208,6 +260,7 @@ function statementFrame(char: StatementFrame["char"]): StatementFrame {
208
260
  query: false,
209
261
  whereEnd: null,
210
262
  verb: null,
263
+ call: false,
211
264
  positionals: 0,
212
265
  lead: true,
213
266
  token: null,
@@ -245,6 +298,7 @@ function openToken(frame: StatementFrame, at: number) {
245
298
  equals: false,
246
299
  closedAt: -1,
247
300
  wordEnd: -1,
301
+ eqAt: -1,
248
302
  };
249
303
  frame.lead = false;
250
304
  return frame.token;
@@ -287,12 +341,10 @@ function gluedAt(
287
341
  (frame.verb !== null || ALNUM.test(before ?? "")));
288
342
  if (!glued) return null;
289
343
  const c = masked[at];
290
- // A stray `\` is `symbols.ts`'s, and a non-ASCII stand-in is #371's.
291
- if (c === "\\" || c === SUB) {
292
- frame.settled = true;
293
- return null;
294
- }
295
344
  frame.settled = true;
345
+ // A stray `\` is `symbols.ts`'s; a non-ASCII byte is its own fault (`"x"é`).
346
+ if (c === "\\") return null;
347
+ if (c === SUB) return unquotedByteFault(masked, at);
296
348
  return { code: "glued-token", start: at, end: at + 1 };
297
349
  }
298
350
 
@@ -343,7 +395,21 @@ function wordFaults(
343
395
  if (named !== undefined) return named;
344
396
  }
345
397
  while (j < end) {
346
- if (masked[j] !== "$") {
398
+ const c = masked[j];
399
+ if (isUnquotedFaultByte(c)) {
400
+ frame.settled = true;
401
+ return (
402
+ argumentNameFault(masked, frame, j, end) ?? unquotedByteFault(masked, j)
403
+ );
404
+ }
405
+ if (c === "=") {
406
+ if (token.eqAt < 0) token.eqAt = j;
407
+ else if (frame.verb === null && !frame.call) {
408
+ frame.settled = true;
409
+ return unquotedByteFault(masked, j);
410
+ }
411
+ }
412
+ if (c !== "$") {
347
413
  j++;
348
414
  continue;
349
415
  }
@@ -367,14 +433,12 @@ function wordFaults(
367
433
  token.wordEnd = -1;
368
434
  return null;
369
435
  }
436
+ if (next === SUB) {
437
+ frame.settled = true;
438
+ return unquotedByteFault(masked, j + 1);
439
+ }
370
440
  // `$$v` is rejected after the name, not at the second `$`.
371
- if (
372
- next === "(" ||
373
- next === "[" ||
374
- next === "{" ||
375
- next === "$" ||
376
- next === SUB
377
- ) {
441
+ if (next === "(" || next === "[" || next === "{" || next === "$") {
378
442
  frame.settled = true;
379
443
  return null;
380
444
  }
@@ -390,6 +454,26 @@ function wordFaults(
390
454
  return null;
391
455
  }
392
456
 
457
+ /**
458
+ * A bad byte at `at` inside a menu command's argument name: the device
459
+ * reports it at the name's first byte (`/ip/address/add addré=1` and
460
+ * `a?b=1` at the `a`), and the span is the name. A directive (`:put café=1`)
461
+ * and a `$` call (`$f café=1`) report at the byte, so this is `null` there.
462
+ */
463
+ function argumentNameFault(
464
+ masked: string,
465
+ frame: StatementFrame & { token: StatementToken },
466
+ at: number,
467
+ end: number,
468
+ ): GroupFault | null {
469
+ const { token } = frame;
470
+ if (frame.verb !== null || frame.call || token.eqAt >= 0) return null;
471
+ const eq = masked.indexOf("=", at);
472
+ if (eq < 0 || eq >= end) return null;
473
+ const fault = unquotedByteFault(masked, at);
474
+ return { ...fault, start: token.start, end: eq, site: "argument-name" };
475
+ }
476
+
393
477
  /**
394
478
  * The declared-name fault in the word at `at`, `null` when the name is
395
479
  * well formed, or `undefined` when the word is not a bare name at all (a
@@ -473,9 +557,15 @@ const CLOSER = { "(": ")", "[": "]", "{": "}" } as const;
473
557
 
474
558
  /**
475
559
  * Find every group fault in an analyzed document. `masked` is its comment
476
- * mask; `text` is read only to tell a masked comment line from spaces.
560
+ * mask; `text` is read only to tell a masked comment line from spaces. The
561
+ * walk starts at `from`, past a leading BOM, which is a positional fact and
562
+ * not a byte of the first word.
477
563
  */
478
- export function findGroupFaults(text: string, masked: string): GroupFault[] {
564
+ export function findGroupFaults(
565
+ text: string,
566
+ masked: string,
567
+ from = 0,
568
+ ): GroupFault[] {
479
569
  const faults: GroupFault[] = [];
480
570
  const frames: Frame[] = [statementFrame("")];
481
571
  const top = (): Frame => frames[frames.length - 1] as Frame;
@@ -502,7 +592,7 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
502
592
  // `(1 +\⏎⏎2)`, and reject `(1 +\⏎# c⏎⏎2)` at the blank line.
503
593
  let cont: "none" | "escape" | "comment" = "none";
504
594
  let contLineStart = false;
505
- let i = 0;
595
+ let i = from;
506
596
  while (i < masked.length) {
507
597
  const c = masked[i] as string;
508
598
  const frame = top();
@@ -597,20 +687,27 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
597
687
  (frame.kind === "statement" && frame.char === "[")
598
688
  ? whereEnd
599
689
  : null;
690
+ // A `where` named comparison never takes a script body through an
691
+ // unproven name — `[find where comment={ :beep }]` is `expected
692
+ // comment value` on CHR 7.25 — so the brace reads as a VALUE for
693
+ // the role below and the fault stands (#554). Proven slots still
694
+ // read as bodies; the gate is only the unproven disjunct.
695
+ const inWhereComparison =
696
+ c === "{" && floor !== null && comparesNamedValue(masked, i, floor);
600
697
  const role =
601
698
  c === "{" && frame.kind === "statement"
602
- ? braceStartsStatements(masked, i)
699
+ ? braceStartsStatements(
700
+ masked,
701
+ i,
702
+ 0,
703
+ inWhereComparison ? "value" : "body",
704
+ )
603
705
  ? "program"
604
706
  : directiveSlotRole(masked, i, frame)
605
707
  : null;
606
708
  // A body (`do={`) is never a comparison, though `where` is still open:
607
709
  // corpus `eworm/hotspot-to-wpa.template.rsc` interleaves two `:if`s.
608
- if (
609
- c === "{" &&
610
- role !== "program" &&
611
- floor !== null &&
612
- comparesNamedValue(masked, i, floor)
613
- ) {
710
+ if (c === "{" && role !== "program" && inWhereComparison) {
614
711
  // The device stops at this brace, so neither its contents nor the
615
712
  // rest of the enclosing group are read.
616
713
  faults.push({ code: "named-value-brace", start: i, end: i + 1 });
@@ -738,10 +835,26 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
738
835
  : BARE_DECLARATIONS.has(word)
739
836
  ? word
740
837
  : null;
838
+ frame.call = word.startsWith("$");
839
+ // `[/ip/address/find comment=a=b]` is a query, as `[find …]` is.
840
+ if (frame.verb === null && /(^|\/)find$/i.test(word))
841
+ frame.query = true;
842
+ const bad = unquotedFaultByteAt(masked, i, end);
843
+ if (bad >= 0 && (at < 0 || i + at > bad)) {
844
+ frame.settled = true;
845
+ faults.push({ ...unquotedByteFault(masked, bad), site: "head-word" });
846
+ }
741
847
  }
742
848
  if (readsTokens(frame)) {
743
849
  const fault = wordFaults(masked, frame, i, end);
744
850
  if (fault !== null) faults.push(fault);
851
+ } else if (frame.query && !frame.settled && !token.head) {
852
+ // A query keeps `=` as an operator, but not these bytes.
853
+ const bad = unquotedFaultByteAt(masked, i, end);
854
+ if (bad >= 0) {
855
+ frame.settled = true;
856
+ faults.push(unquotedByteFault(masked, bad));
857
+ }
745
858
  }
746
859
  if (word.includes("=")) token.equals = true;
747
860
  const lower = word.toLowerCase();
@@ -760,6 +873,12 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
760
873
  }
761
874
 
762
875
  if (frame.kind === "opaque") {
876
+ // A query's `in {…}` is read as opaque, so `where … in {}` keeps
877
+ // parsing; its bytes are still rejected (`in {a?b}` at the `?`).
878
+ if (frame.query && !frame.muted && isUnquotedFaultByte(c)) {
879
+ faults.push(unquotedByteFault(masked, i));
880
+ frame.muted = true;
881
+ }
763
882
  i++;
764
883
  continue;
765
884
  }
@@ -824,6 +943,12 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
824
943
  i++;
825
944
  continue;
826
945
  }
946
+ // Unlike `@`, these bytes are rejected in a query too (`in {a?b}`).
947
+ if (isUnquotedFaultByte(c)) {
948
+ report(unquotedByteFault(masked, i), frame);
949
+ i++;
950
+ continue;
951
+ }
827
952
  frame.element = true;
828
953
  frame.last = "element";
829
954
  i++;
@@ -849,8 +974,16 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
849
974
  i++;
850
975
  continue;
851
976
  }
977
+ // A query group keeps its operators, but not these bytes:
978
+ // `where (comment=a?b)` is a `syntax error` at the `?`.
979
+ if (isUnquotedFaultByte(c)) {
980
+ report(unquotedByteFault(masked, i), frame);
981
+ i++;
982
+ continue;
983
+ }
852
984
  if (frame.query) {
853
- // Structural checks only (module header): any other byte is content.
985
+ // Structural and byte checks only (module header): any other byte is
986
+ // content.
854
987
  frame.empty = false;
855
988
  frame.wantOperand = false;
856
989
  i++;
@@ -918,8 +1051,8 @@ export function findGroupFaults(text: string, masked: string): GroupFault[] {
918
1051
  i += spelling.length;
919
1052
  continue;
920
1053
  }
921
- // `#`, `?`, a backtick, `'`, a stray `\`, a non-ASCII placeholder: another
922
- // rule's byte, and nothing here can say what the group means after it.
1054
+ // `#`, `'`, a stray `\`: another rule's byte, and nothing here can say
1055
+ // what the group means after it.
923
1056
  frame.done = true;
924
1057
  i++;
925
1058
  }
@@ -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", "<="),
@@ -7,8 +7,11 @@
7
7
  * gives comment masking, segmentation, and symbol resolution one rule.
8
8
  */
9
9
 
10
+ import { commandVerbIndex } from "./catalog.ts";
11
+ import { isKnownMenuPath } from "./is-known-menu.ts";
10
12
  import { scanQuotedString } from "./quoted-string.ts";
11
- import { runNamesCommand } from "./run-tokens.ts";
13
+ import { runNamesCommand, runTokens } from "./run-tokens.ts";
14
+ import { VERBS } from "./verbs.ts";
12
15
 
13
16
  /** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
14
17
  const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
@@ -96,17 +99,82 @@ const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
96
99
  * offline misses it.
97
100
  *
98
101
  * Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
99
- * accepted on device) stays outside. Every word is a prefix of `<word>-script`,
100
- * so admitting it would admit `comment={ :beep }`. Telling them apart needs the
101
- * command's argument list, which is schema.
102
+ * accepted on device) stays outside the PROVEN shape. Every word is a prefix
103
+ * of `<word>-script`, so admitting it here would admit `comment={ :beep }` as
104
+ * a confident body. Telling them apart needs the command's argument list,
105
+ * which is schema — so a `name={` with a script-like body that reaches this
106
+ * far reads as a body for every reader (comments inside are comments) but is
107
+ * warned as a possible abbreviation instead of passing silently (#525,
108
+ * `args.ts` → `invalidCommandBraceOffsets`).
102
109
  */
103
110
  const SCRIPT_BODY_ABBREVIATION =
104
111
  /^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
105
112
 
106
- function isScriptBodyName(name: string): boolean {
113
+ /** Whether `name` is a proven script-body slot (full or pinned abbreviation). */
114
+ export function isScriptBodyName(name: string): boolean {
107
115
  return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
108
116
  }
109
117
 
118
+ function isAsciiLetter(char: string | undefined): boolean {
119
+ if (char === undefined) return false;
120
+ return (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
121
+ }
122
+
123
+ /**
124
+ * Whether the `{` at `open` holds a script-like body (#525). `end` is the
125
+ * exclusive brace end (or the text length when unclosed).
126
+ *
127
+ * A body opens with a directive, an absolute command, or one bare command
128
+ * word: `{ :beep }`, `{ /ip … }`, `{ beep }` and `{ # comment⏎:beep }` are
129
+ * all accepted on CHR 7.25. A bare word followed by a top-level `;` is array
130
+ * members, not commands — `.proplist={name;comment}` is device-rejected —
131
+ * so it keeps the error, as do `{1;2}`, `{"x"}`, `{}` and `$`-led values,
132
+ * which cannot open a command at all. The scan touches each byte between
133
+ * the braces once, skipping strings and `#`-to-end-of-line runs, so it is
134
+ * linear; it works on masked and unmasked text alike (a masked comment is
135
+ * already whitespace).
136
+ */
137
+ export function braceBodyIsScriptLike(
138
+ text: string,
139
+ open: number,
140
+ end: number,
141
+ ): boolean {
142
+ let j = open + 1;
143
+ for (;;) {
144
+ if (j >= end) return false;
145
+ const c = text[j] as string;
146
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
147
+ j++;
148
+ continue;
149
+ }
150
+ if (c === "#") {
151
+ while (j < end && text[j] !== "\n") j++;
152
+ continue;
153
+ }
154
+ if (c === ":" || c === "/") return true;
155
+ if (!isAsciiLetter(c)) return false;
156
+ break;
157
+ }
158
+ let depth = 0;
159
+ while (j < end) {
160
+ const c = text[j] as string;
161
+ if (c === '"') {
162
+ const scan = scanQuotedString(text, j);
163
+ j = Math.min(scan.end, end);
164
+ continue;
165
+ }
166
+ if (c === "#") {
167
+ while (j < end && text[j] !== "\n") j++;
168
+ continue;
169
+ }
170
+ if (c === "{" || c === "[" || c === "(") depth++;
171
+ else if ((c === "}" || c === "]" || c === ")") && depth > 0) depth--;
172
+ else if (c === ";" && depth === 0) return false;
173
+ j++;
174
+ }
175
+ return true;
176
+ }
177
+
110
178
  /** Grounded scripting directives whose positional arity makes a later `#` fatal. */
111
179
  const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
112
180
  local: 2,
@@ -121,6 +189,12 @@ interface StatementIndex {
121
189
  boundaries: Int32Array;
122
190
  firstContent: Int32Array;
123
191
  lastForbidden: Int32Array;
192
+ /**
193
+ * The innermost open delimiter at each nested `;`/newline boundary. Sparse,
194
+ * because a per-offset array pushed a 540 KiB document past
195
+ * `BOUNDARY_CACHE_BYTE_LIMIT` and made every probe rebuild the index.
196
+ */
197
+ nestedSeparators: Map<number, number>;
124
198
  matchingBrace: Map<number, number>;
125
199
  }
126
200
 
@@ -162,6 +236,7 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
162
236
  index.boundaries.byteLength +
163
237
  index.firstContent.byteLength +
164
238
  index.lastForbidden.byteLength +
239
+ index.nestedSeparators.size * 32 +
165
240
  index.matchingBrace.size * 32
166
241
  );
167
242
  }
@@ -223,17 +298,34 @@ function statementIndex(text: string): StatementIndex {
223
298
  firstContent.fill(-1);
224
299
  const lastForbidden = new Int32Array(text.length + 1);
225
300
  lastForbidden.fill(-1);
301
+ const nestedSeparators = new Map<number, number>();
226
302
  const matchingBrace = new Map<number, number>();
227
303
  const delimiters: { char: string; at: number }[] = [];
228
304
  let boundary = -1;
229
305
  let first = -1;
230
306
  let forbidden = -1;
307
+ let comment = false;
231
308
  let i = 0;
232
309
  while (i < text.length) {
233
310
  boundaries[i] = boundary;
234
311
  firstContent[i] = first;
235
312
  lastForbidden[i] = forbidden;
236
313
  const c = text[i] as string;
314
+ // A statement-leading `#` runs to the line end, and a `{`, `}` or `"` in
315
+ // it is not structure: `/ip { # }⏎address { print {1;2} } }` is rejected
316
+ // at the inner `{` on CHR 7.24.5 and 7.23.8, so the `}` must not close
317
+ // `/ip {` (#351). Read raw, the bytes are blank, as `maskComments` makes
318
+ // them; read masked, they already are.
319
+ if (comment && c !== "\n") {
320
+ i++;
321
+ continue;
322
+ }
323
+ comment = false;
324
+ if (c === "#" && first === -1 && delimiters.at(-1)?.char !== "(") {
325
+ comment = true;
326
+ i++;
327
+ continue;
328
+ }
237
329
  if (c === '"') {
238
330
  const end = Math.min(scanQuotedString(text, i).end, text.length);
239
331
  boundaries.fill(boundary, i + 1, end + 1);
@@ -255,6 +347,9 @@ function statementIndex(text: string): StatementIndex {
255
347
  if (first === -1 && !isAsciiWhitespace(c)) first = i;
256
348
  if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
257
349
  if (c === "\n" || c === ";" || c === "{" || c === "[") {
350
+ const open = delimiters.at(-1);
351
+ if ((c === "\n" || c === ";") && open !== undefined)
352
+ nestedSeparators.set(i, open.at);
258
353
  boundary = i;
259
354
  first = -1;
260
355
  }
@@ -267,6 +362,7 @@ function statementIndex(text: string): StatementIndex {
267
362
  boundaries,
268
363
  firstContent,
269
364
  lastForbidden,
365
+ nestedSeparators,
270
366
  matchingBrace,
271
367
  };
272
368
  if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
@@ -462,18 +558,41 @@ export function scopeNameFromMasked(
462
558
  * Whether this brace begins a context in which statement-leading comments exist.
463
559
  *
464
560
  * `floor` bounds the lookback exactly as in {@link scopeNameFromMasked}.
561
+ * `unprovenName` decides the unproven `name={` guess below: `"body"` reads it
562
+ * as a body, `"value"` as a value. Only the script-like disjunct is gated —
563
+ * a proven slot stays a body either way. Readers that are themselves deciding
564
+ * an error AT this brace pass `"value"` where the slot cannot bear a body
565
+ * (`args.ts` for a root directive's slots and a `where` named comparison,
566
+ * `group-faults.ts` for the bracketed form), so the guess does not swallow
567
+ * their error; every other reader keeps `"body"`.
465
568
  */
466
569
  export function braceStartsStatements(
467
570
  text: string,
468
571
  open: number,
469
572
  floor = 0,
573
+ unprovenName: "body" | "value" = "body",
470
574
  ): boolean {
471
575
  if (scopeNameFromMasked(text, open, floor) !== null) return true;
472
576
  const index = statementIndex(text);
473
577
  const boundary = index.boundaries[open] ?? -1;
474
578
  const start = Math.max(boundary + 1, floor);
475
579
  const named = trailingName(text, start, open);
476
- if (named !== null) return isScriptBodyName(named.toLowerCase());
580
+ if (named !== null) {
581
+ // A `name={` with a script-like body reads as a body for every
582
+ // reader, proven slot or not (#525): `down={ beep }` and
583
+ // `down={ # comment⏎:beep }` are accepted on CHR 7.25, and the `#`
584
+ // is only a comment when the brace bears statements. The warning
585
+ // that marks the unproven slot lives in `args.ts`, which checks
586
+ // ambiguity before this skip.
587
+ if (isScriptBodyName(named.toLowerCase())) return true;
588
+ if (unprovenName === "value") return false;
589
+ const close = matchingBraceEnd(text, open);
590
+ return braceBodyIsScriptLike(
591
+ text,
592
+ open,
593
+ close === undefined ? text.length : close + 1,
594
+ );
595
+ }
477
596
  // `source { :beep }` — the same body with the `=` left out (#350). Every
478
597
  // grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
479
598
  // `/system/script add name=beep source { :beep }`, `/system/scheduler add
@@ -493,12 +612,108 @@ export function braceStartsStatements(
493
612
  // but a run that already names a command makes the brace its operand (#496).
494
613
  // `start` follows the last `{`, so each brace reads its own run: linear.
495
614
  const first = index.firstContent[open] ?? -1;
496
- return (
497
- first < start ||
498
- (text[first] === "/" &&
615
+ if (first < start) return true;
616
+ if (text[first] === "/")
617
+ return (
499
618
  (index.lastForbidden[open] ?? -1) < start &&
500
- !runNamesCommand(text.slice(first, open)))
501
- );
619
+ !runNamesCommand(text.slice(first, open))
620
+ );
621
+ return menuBlockPath(text, open, floor) !== null;
622
+ }
623
+
624
+ /** Nesting a {@link menuBlockPath} lookup follows before it abstains. */
625
+ const MAX_MENU_BLOCK_DEPTH = 64;
626
+
627
+ /**
628
+ * A relative run of bare path words, space- or slash-separated. Each word is
629
+ * `pathresolve`'s `BARE_WORD`: the resolver reads no digit-led word as a path,
630
+ * so admitting `6to4 {` here would flatten a block whose navigation it then
631
+ * ignores, resolving the body's `print` against `/interface`.
632
+ */
633
+ const RELATIVE_MENU_RUN =
634
+ /^[A-Za-z][A-Za-z0-9._-]*(?:[ \t/]+[A-Za-z][A-Za-z0-9._-]*)*$/;
635
+
636
+ /**
637
+ * The menu a `{` opens a statement block over, when the source alone says so
638
+ * (#351). Null means offline cannot tell, not that the brace is a value.
639
+ *
640
+ * On CHR 7.24.5 and 7.23.8 a brace right after a menu word is always a
641
+ * statement block, at any depth and whatever the body holds:
642
+ * `/ip { address { 1;2 } }` is `bad command name 1` at the `1`, and
643
+ * `/ip { address { a;b } }` reads `a` as `add`. A brace after a command word is
644
+ * always an error at the `{` (`/ip { address { print {1;2} } }`). The `/`-led
645
+ * spelling was already read as a block; the RELATIVE one (`address {` inside
646
+ * `/ip { … }`) was read as `/ip address` with a brace operand. That is what
647
+ * hard-failed the outer brace at depth three and hid every fault in the body.
648
+ *
649
+ * A relative run is a block only when the menu it names is known: the
650
+ * enclosing container's path, read the same way outward, plus the run, named by
651
+ * a shipped structure table. A miss abstains, so an unknown word keeps today's
652
+ * reading and the resolver says `unresolved`. The path is lexical: a
653
+ * navigation statement earlier in the same body (`/ip { firewall; address {`)
654
+ * is not followed, and the resolver checks the flattened run against the real
655
+ * context, so that residue can only abstain. Abbreviated menus (`addr {`) are
656
+ * not expanded, as R13 does not expand them.
657
+ *
658
+ * `floor` keeps the ranged-call contract of {@link scopeNameFromMasked}: no
659
+ * enclosing brace before it is consulted, so a range reads exactly as the same
660
+ * text taken alone.
661
+ */
662
+ export function menuBlockPath(
663
+ text: string,
664
+ open: number,
665
+ floor = 0,
666
+ ): readonly string[] | null {
667
+ return blockPathAt(text, statementIndex(text), open, floor, 0);
668
+ }
669
+
670
+ function blockPathAt(
671
+ text: string,
672
+ index: StatementIndex,
673
+ open: number,
674
+ floor: number,
675
+ depth: number,
676
+ ): readonly string[] | null {
677
+ if (depth > MAX_MENU_BLOCK_DEPTH) return null;
678
+ const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
679
+ const first = index.firstContent[open] ?? -1;
680
+ if (first < start) return enclosingBlockPath(text, index, open, floor, depth);
681
+ if ((index.lastForbidden[open] ?? -1) >= start) return null;
682
+ const run = text.slice(first, open);
683
+ if (text[first] === "/") {
684
+ if (runNamesCommand(run)) return null;
685
+ return runTokens(run).map((token) => token.name);
686
+ }
687
+ const trimmed = run.trim();
688
+ if (!RELATIVE_MENU_RUN.test(trimmed)) return null;
689
+ const names = trimmed.split(/[\s/]+/);
690
+ if (names.some((name) => VERBS.has(name))) return null;
691
+ const parent = enclosingBlockPath(text, index, open, floor, depth);
692
+ if (parent === null) return null;
693
+ const path = [...parent, ...names];
694
+ if (commandVerbIndex(path) !== null || !isKnownMenuPath(path)) return null;
695
+ return path;
696
+ }
697
+
698
+ /**
699
+ * The path of the container `{` around the statement that holds `at`, when it
700
+ * is one. The statement starts after a boundary: an opening `{`/`[` is the
701
+ * enclosing delimiter itself, and a separator records it.
702
+ */
703
+ function enclosingBlockPath(
704
+ text: string,
705
+ index: StatementIndex,
706
+ at: number,
707
+ floor: number,
708
+ depth: number,
709
+ ): readonly string[] | null {
710
+ const boundary = index.boundaries[at] ?? -1;
711
+ const outer =
712
+ boundary < 0 || text[boundary] === "{" || text[boundary] === "["
713
+ ? boundary
714
+ : (index.nestedSeparators.get(boundary) ?? -1);
715
+ if (outer < floor || text[outer] !== "{") return null;
716
+ return blockPathAt(text, index, outer, floor, depth + 1);
502
717
  }
503
718
 
504
719
  /**