@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
@@ -0,0 +1,223 @@
1
+ /**
2
+ * The leading path-token run of a statement, for `explain`.
3
+ *
4
+ * A leaf module (it imports only the leaf data tables) so that `segment.ts` and
5
+ * `scope-brace.ts` can ask the question `verbsplit.ts` asks — "does this run
6
+ * already name a command verb?" (#496) — without the import cycle
7
+ * `verbsplit.ts` → `segment.ts` would make.
8
+ * It is the one word scanner both use; a second one written beside it would be
9
+ * free to disagree with the boundary rule. `verbsplit.ts` re-exports the public
10
+ * names, so the Q6 surface is unchanged.
11
+ */
12
+
13
+ import { commandVerbIndex } from "./catalog.ts";
14
+ import type { Span } from "./pathresolve.ts";
15
+ import { VERBS } from "./verbs.ts";
16
+
17
+ const BARE_WORD = /^[A-Za-z][A-Za-z0-9._-]*$/;
18
+ /**
19
+ * A word that ends the leading run: it carries `=` (V2) or opens a group /
20
+ * string / variable (V3). `:` is deliberately NOT here — RouterOS uses it only
21
+ * to start a scripting directive, not as a mid-statement opener, and a
22
+ * `:`-prefixed token is already rejected by `BARE_WORD` below, so it ends the
23
+ * run regardless.
24
+ */
25
+ const RUN_TERMINATOR = /^[[({"$]/;
26
+
27
+ export function isAsciiWhitespace(char: string | undefined): boolean {
28
+ return char === " " || char === "\t" || char === "\r" || char === "\n";
29
+ }
30
+
31
+ /** One whitespace-separated word, and where it sits in the text it came from. */
32
+ export interface Word {
33
+ name: string;
34
+ /** offset of the word's first character, inclusive. */
35
+ start: number;
36
+ /** offset just past its last character, exclusive. */
37
+ end: number;
38
+ /** Source span of each character retained in `name`. */
39
+ chars: Span[];
40
+ }
41
+
42
+ /**
43
+ * The words of a statement, WITH their offsets (#202c).
44
+ *
45
+ * The one word scanner this module has. It replaced a
46
+ * `replace(/\\\r?\n/g,"").split(whitespace)` pair whose output could not be
47
+ * mapped back to the text it came from — and a second scanner written beside it
48
+ * for the offsets would be free to disagree with the boundary rule, which is the
49
+ * failure #202b hit three times in argument handling.
50
+ *
51
+ * H5 from the Q1 segmenter: a `\<newline>` is removed before RouterOS parses the
52
+ * continued line, so it JOINS the words either side of it rather than separating
53
+ * them (`na\<nl>me` is the single word `name`) and contributes no character to
54
+ * the name. That is why a word's `end - start` is not its `name.length`: the span
55
+ * covers the continuation bytes, the name does not. A caller taking an interior
56
+ * offset from a name is therefore wrong on continued statements, and
57
+ * {@link describeStatement} only ever publishes the run's END.
58
+ */
59
+ export function* asciiWordSpans(text: string): Generator<Word> {
60
+ // Yielded ONE WORD AT A TIME rather than returned as a list. Both readers
61
+ // stop at the first word the leading run cannot take, and a statement's text
62
+ // carries its whole nested `do={…}` subtree — so building a word (and a span
63
+ // per character of it) for the rest of that subtree was work no caller ever
64
+ // looked at, once per enclosing level (#322). A word is complete when the
65
+ // next one starts, which is why the yield happens on the following boundary
66
+ // rather than at the point the word is opened.
67
+ let current: Word | null = null;
68
+ let i = 0;
69
+ while (i < text.length) {
70
+ const c = text[i] as string;
71
+ if (
72
+ c === "\\" &&
73
+ (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
74
+ ) {
75
+ // Inside a word this joins; between words it is whitespace that opens
76
+ // none — matching the removal-then-split the rule replaced.
77
+ i += text[i + 1] === "\r" ? 3 : 2;
78
+ if (current !== null) current.end = i;
79
+ continue;
80
+ }
81
+ if (isAsciiWhitespace(c)) {
82
+ if (current !== null) yield current;
83
+ current = null;
84
+ i++;
85
+ continue;
86
+ }
87
+ if (current === null) {
88
+ current = {
89
+ name: c,
90
+ start: i,
91
+ end: i + 1,
92
+ chars: [{ start: i, end: i + 1 }],
93
+ };
94
+ } else {
95
+ current.name += c;
96
+ current.end = i + 1;
97
+ current.chars.push({ start: i, end: i + 1 });
98
+ }
99
+ i++;
100
+ }
101
+ if (current !== null) yield current;
102
+ }
103
+
104
+ /** Separator that precedes a run token (V1). */
105
+ export type Sep = "start" | "slash" | "space";
106
+
107
+ /** One token of the leading path run, with the punctuation that introduced it. */
108
+ export interface RunToken {
109
+ name: string;
110
+ sep: Sep;
111
+ }
112
+
113
+ /** A run token mapped back to the statement bytes that spell it. */
114
+ export interface LocatedRunToken extends RunToken {
115
+ /** Name bytes, split where a `\<newline>` continuation interrupts them. */
116
+ nameSpans: Span[];
117
+ /** One valid `/` immediately before this token, if present. */
118
+ slashBefore?: Span;
119
+ /** One trailing `/` after this token, if present. */
120
+ slashAfter?: Span;
121
+ }
122
+
123
+ function coalesceChars(chars: readonly Span[]): Span[] {
124
+ const out: Span[] = [];
125
+ for (const char of chars) {
126
+ const last = out.at(-1);
127
+ if (last !== undefined && last.end === char.start) last.end = char.end;
128
+ else out.push({ start: char.start, end: char.end });
129
+ }
130
+ return out;
131
+ }
132
+
133
+ /**
134
+ * The leading path-token run WITH separators preserved (V1). Same stopping
135
+ * rules as `pathresolve`'s run (V2/V3), so the two stay comparable: a slash-
136
+ * joined word expands to several `slash`-separated tokens; a space-separated
137
+ * word is one `space` token. Stops at the first `=`/group/string/var word, and
138
+ * at any word carrying a non-`BARE_WORD` segment (a variable segment truncates
139
+ * the run before the verb).
140
+ */
141
+ export function locatedRunTokens(text: string): LocatedRunToken[] {
142
+ const out: LocatedRunToken[] = [];
143
+ for (const word of asciiWordSpans(text)) {
144
+ if (word.name.includes("=") || RUN_TERMINATOR.test(word.name)) break;
145
+ const parts: {
146
+ name: string;
147
+ start: number;
148
+ end: number;
149
+ slashBefore?: Span;
150
+ }[] = [];
151
+ let start = 0;
152
+ for (let i = 0; i <= word.name.length; i++) {
153
+ if (i < word.name.length && word.name[i] !== "/") continue;
154
+ if (start < i) {
155
+ const slashRunStart = start;
156
+ let slashCount = 0;
157
+ while (
158
+ slashRunStart - slashCount - 1 >= 0 &&
159
+ word.name[slashRunStart - slashCount - 1] === "/"
160
+ )
161
+ slashCount++;
162
+ const slashIndex = slashCount === 1 ? slashRunStart - 1 : -1;
163
+ parts.push({
164
+ name: word.name.slice(start, i),
165
+ start,
166
+ end: i,
167
+ ...(slashIndex < 0 ? {} : { slashBefore: word.chars[slashIndex] }),
168
+ });
169
+ }
170
+ start = i + 1;
171
+ }
172
+ if (parts.length === 0) continue;
173
+ if (!parts.every((p) => BARE_WORD.test(p.name))) break;
174
+ for (let i = 0; i < parts.length; i++) {
175
+ const part = parts[i] as (typeof parts)[number];
176
+ const leadingSlash = word.name.startsWith("/") && i === 0;
177
+ const trailingSlash =
178
+ i === parts.length - 1 &&
179
+ word.name.endsWith("/") &&
180
+ !word.name.endsWith("//")
181
+ ? word.chars.at(-1)
182
+ : undefined;
183
+ out.push({
184
+ name: part.name,
185
+ sep:
186
+ out.length === 0
187
+ ? "start"
188
+ : i > 0 || leadingSlash
189
+ ? "slash"
190
+ : "space",
191
+ nameSpans: coalesceChars(word.chars.slice(part.start, part.end)),
192
+ ...(part.slashBefore === undefined
193
+ ? {}
194
+ : { slashBefore: part.slashBefore }),
195
+ ...(trailingSlash === undefined ? {} : { slashAfter: trailingSlash }),
196
+ });
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ export function runTokens(text: string): RunToken[] {
203
+ return locatedRunTokens(text).map(({ name, sep }) => ({ name, sep }));
204
+ }
205
+
206
+ /**
207
+ * #496 — the run before a `{` already names a command, so the brace is that
208
+ * command's operand and not a `/menu { … }` container. `/system/script add
209
+ * name beep source { :beep }` is one `add` whose `source` is the body, and
210
+ * `/ip/route/print {1;2}` is a positional brace the device rejects (CHR
211
+ * 7.22.1/7.24.5 `:parse`); neither is a command followed by a menu body.
212
+ * `segment.ts` (H7) and `scope-brace.ts` each spelled the container rule
213
+ * lexically, and both ask here. The two tables are the ones `verbsplit`
214
+ * decides a verb with — the frozen vocabulary and the catalog's command axis —
215
+ * and both are floors: a run naming neither keeps the container reading
216
+ * (`/system script { print }`).
217
+ */
218
+ export function runNamesCommand(prefix: string): boolean {
219
+ const names = runTokens(prefix).map((token) => token.name);
220
+ return (
221
+ names.some((name) => VERBS.has(name)) || commandVerbIndex(names) !== null
222
+ );
223
+ }
@@ -7,7 +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";
13
+ import { runNamesCommand, runTokens } from "./run-tokens.ts";
14
+ import { VERBS } from "./verbs.ts";
11
15
 
12
16
  /** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
13
17
  const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
@@ -58,8 +62,118 @@ export const DIRECTIVE_BODY: Record<string, string> = {
58
62
  onerror: "in",
59
63
  };
60
64
 
61
- /** Script-valued attribute naming convention already used by the corpus census. */
62
- const SCRIPT_BODY_ARG = /^(?:source|script|on-.*)$/;
65
+ /**
66
+ * Script-valued attribute naming convention, the corpus census's
67
+ * `SCRIPT_BEARING_KEY` shape (`scripts/explain-corpus-census.ts`).
68
+ *
69
+ * A shape, not a list. It decides a brace's ROLE (statement body or value) for
70
+ * every reader of {@link braceStartsStatements}: segmentation, the argument
71
+ * lexer, symbols and path resolution, as well as the `invalid-command-brace`
72
+ * exemption. A name inside the shape is read as a body; a name outside it as a
73
+ * value. The shape errs toward body because the other error is the worse one:
74
+ * reading a real `up-script={ :beep }` as an array hard-failed input the device
75
+ * accepts. The cost is the fail-open direction. An argument the command does
76
+ * not have, `zzz-script={ :beep }` or `on-zzz={ :beep }`, is a device
77
+ * `syntax error` on CHR 7.24.5 that offline does not report, because naming the
78
+ * command's arguments is schema. The `on-…` half of that predates #350.
79
+ * Which slots take a body is per-menu and per-version schema: on CHR 7.24.5
80
+ * `/tool/netwatch add … up-script={ :beep }` and `/ip/dhcp-server set 0
81
+ * lease-script={ :beep }` both parse, while 7.22.1 accepts the first and
82
+ * rejects the second; `comment={ :beep }` and `zzzscript={ :beep }` are
83
+ * `syntax error` on both (#350). The earlier `^(?:source|script|on-.*)$` missed
84
+ * every `…-script` argument and hard-failed the netwatch row the device accepts.
85
+ * The suffix keeps its hyphen, which is how RouterOS spells these names: a bare
86
+ * `…script` would take `:put myScript={#test}`, whose brace CHR 7.23.3 reads as
87
+ * an array (#245).
88
+ */
89
+ const SCRIPT_BODY_ARG = /^(?:source|script|.+-script|on-.+)$/;
90
+
91
+ /**
92
+ * An abbreviation the {@link SCRIPT_BODY_ARG} shape still pins down (#496).
93
+ * RouterOS takes any unique prefix of an argument name: on CHR 7.24.5 and
94
+ * 7.23.8, `sour { :beep }` and `s={ :beep }` lower to `source=`, `on`/`o=` to
95
+ * `on-event=`, and `down-scr`/`down-s=` to `down-script=`. These are the
96
+ * prefixes of `source` and `script`, of `on-`, and a name ending in `-` plus a
97
+ * prefix of `script`, so each can only abbreviate a script-valued name. Like
98
+ * the shape, it errs toward body: `sc { :beep }` is rejected on device and
99
+ * offline misses it.
100
+ *
101
+ * Abbreviating the FIRST segment (`down={ :beep }` for `down-script`, also
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`).
109
+ */
110
+ const SCRIPT_BODY_ABBREVIATION =
111
+ /^(?:s(?:o(?:u(?:r(?:c)?)?)?)?|sc(?:r(?:i(?:p)?)?)?|on?|.+-(?:s(?:c(?:r(?:i(?:p)?)?)?)?)?)$/;
112
+
113
+ /** Whether `name` is a proven script-body slot (full or pinned abbreviation). */
114
+ export function isScriptBodyName(name: string): boolean {
115
+ return SCRIPT_BODY_ARG.test(name) || SCRIPT_BODY_ABBREVIATION.test(name);
116
+ }
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
+ }
63
177
 
64
178
  /** Grounded scripting directives whose positional arity makes a later `#` fatal. */
65
179
  const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
@@ -75,6 +189,12 @@ interface StatementIndex {
75
189
  boundaries: Int32Array;
76
190
  firstContent: Int32Array;
77
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>;
78
198
  matchingBrace: Map<number, number>;
79
199
  }
80
200
 
@@ -116,6 +236,7 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
116
236
  index.boundaries.byteLength +
117
237
  index.firstContent.byteLength +
118
238
  index.lastForbidden.byteLength +
239
+ index.nestedSeparators.size * 32 +
119
240
  index.matchingBrace.size * 32
120
241
  );
121
242
  }
@@ -177,17 +298,34 @@ function statementIndex(text: string): StatementIndex {
177
298
  firstContent.fill(-1);
178
299
  const lastForbidden = new Int32Array(text.length + 1);
179
300
  lastForbidden.fill(-1);
301
+ const nestedSeparators = new Map<number, number>();
180
302
  const matchingBrace = new Map<number, number>();
181
303
  const delimiters: { char: string; at: number }[] = [];
182
304
  let boundary = -1;
183
305
  let first = -1;
184
306
  let forbidden = -1;
307
+ let comment = false;
185
308
  let i = 0;
186
309
  while (i < text.length) {
187
310
  boundaries[i] = boundary;
188
311
  firstContent[i] = first;
189
312
  lastForbidden[i] = forbidden;
190
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
+ }
191
329
  if (c === '"') {
192
330
  const end = Math.min(scanQuotedString(text, i).end, text.length);
193
331
  boundaries.fill(boundary, i + 1, end + 1);
@@ -209,6 +347,9 @@ function statementIndex(text: string): StatementIndex {
209
347
  if (first === -1 && !isAsciiWhitespace(c)) first = i;
210
348
  if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
211
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);
212
353
  boundary = i;
213
354
  first = -1;
214
355
  }
@@ -221,6 +362,7 @@ function statementIndex(text: string): StatementIndex {
221
362
  boundaries,
222
363
  firstContent,
223
364
  lastForbidden,
365
+ nestedSeparators,
224
366
  matchingBrace,
225
367
  };
226
368
  if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
@@ -270,6 +412,19 @@ function trailingBareScopeName(
270
412
  text: string,
271
413
  start: number,
272
414
  end: number,
415
+ ): string | null {
416
+ const name = trailingBareArgumentName(text, start, end);
417
+ return name !== null && SCOPE_ARG_NAMES.has(name) ? name : null;
418
+ }
419
+
420
+ /**
421
+ * The bare word before a `{` when it can be an argument name: not the
422
+ * statement's leading word, and not a `:` directive. Lower-cased.
423
+ */
424
+ function trailingBareArgumentName(
425
+ text: string,
426
+ start: number,
427
+ end: number,
273
428
  ): string | null {
274
429
  let i = end - 1;
275
430
  while (i >= start && isAsciiWhitespace(text[i])) i--;
@@ -278,7 +433,6 @@ function trailingBareScopeName(
278
433
  const nameStart = i + 1;
279
434
  if (nameStart === nameEnd) return null;
280
435
  const name = text.slice(nameStart, nameEnd).toLowerCase();
281
- if (!SCOPE_ARG_NAMES.has(name)) return null;
282
436
  // A COLON makes it a directive, never an argument name, and the device is
283
437
  // emphatic: `:if (1=1) :do { :put a }` is `expected end of command` on both
284
438
  // 7.21.5 and 7.24.2, while the bare `:if (1=1) do { :put a }` parses. So the
@@ -404,24 +558,182 @@ export function scopeNameFromMasked(
404
558
  * Whether this brace begins a context in which statement-leading comments exist.
405
559
  *
406
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"`.
407
568
  */
408
569
  export function braceStartsStatements(
409
570
  text: string,
410
571
  open: number,
411
572
  floor = 0,
573
+ unprovenName: "body" | "value" = "body",
412
574
  ): boolean {
413
575
  if (scopeNameFromMasked(text, open, floor) !== null) return true;
414
576
  const index = statementIndex(text);
415
577
  const boundary = index.boundaries[open] ?? -1;
416
578
  const start = Math.max(boundary + 1, floor);
417
579
  const named = trailingName(text, start, open);
418
- if (named !== null) return SCRIPT_BODY_ARG.test(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
+ }
596
+ // `source { :beep }` — the same body with the `=` left out (#350). Every
597
+ // grounded spelling lowers to the `source=` IL on CHR 7.22.1 and 7.24.5:
598
+ // `/system/script add name=beep source { :beep }`, `/system/scheduler add
599
+ // name=x on-event { :beep }`, `/tool/netwatch add … down-script { :beep }`.
600
+ // The control is `comment { :beep }`, a `syntax error` on both. A
601
+ // declaration's operand is a variable name, not an argument, so
602
+ // `:local myscript {1;2}` keeps its brace an array.
603
+ const bare = trailingBareArgumentName(text, start, open);
604
+ if (bare !== null && isScriptBodyName(bare)) {
605
+ const head = index.firstContent[open] ?? -1;
606
+ const word =
607
+ head < start ? "" : (leadingWords(text, head, open, 1)[0] ?? "");
608
+ if (!/^:|^(?:local|global)$/i.test(word)) return true;
609
+ }
419
610
 
420
- // H7's bare `{...}` and `/menu {...}` containers are statement bodies too.
611
+ // H7's bare `{...}` and `/menu {...}` containers are statement bodies too,
612
+ // but a run that already names a command makes the brace its operand (#496).
613
+ // `start` follows the last `{`, so each brace reads its own run: linear.
421
614
  const first = index.firstContent[open] ?? -1;
615
+ if (first < start) return true;
616
+ if (text[first] === "/")
617
+ return (
618
+ (index.lastForbidden[open] ?? -1) < start &&
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);
717
+ }
718
+
719
+ /**
720
+ * Whether the `#` at `at` opens a comment line inside an array literal (#503).
721
+ *
722
+ * `frame` is the innermost open delimiter. CHR 7.24.5 and 7.22.1 agree: inside
723
+ * a `{…}` that is not a statement body, a `#` at column 0 comments out the
724
+ * rest of its line, at any nesting depth and in argument values too
725
+ * (`:local z {a=1;⏎# c⏎b=2}` → `value=a=1;b=2`, `:foreach i in={1;⏎# c⏎2}`).
726
+ * An indented `#` (space or tab), a `#` after a value or `;` on the same line,
727
+ * and a `#` directly inside `( … )` are still syntax errors. A comment-only
728
+ * array is rejected, but so is `{}`: that is the empty-array rule, not this one.
729
+ */
730
+ export function startsArrayCommentLine(
731
+ text: string,
732
+ at: number,
733
+ frame: { char: string; statements: boolean } | undefined,
734
+ ): boolean {
422
735
  return (
423
- first < start ||
424
- (text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
736
+ text[at - 1] === "\n" && frame?.char === "{" && frame.statements === false
425
737
  );
426
738
  }
427
739