@tikoci/centrs 0.1.2 → 0.1.4

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 (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Shared RouterOS double-quoted string boundary scanner.
3
+ *
4
+ * Kept below both `segment.ts` and `scope-brace.ts` so statement-boundary
5
+ * lookback can ignore separators inside strings without creating an import
6
+ * cycle or growing a second, drifting quote scanner (#199/#246 review).
7
+ */
8
+
9
+ import { type Defect, defectAt } from "./defects.ts";
10
+
11
+ /** Analyzer resource bound, not a RouterOS grammar limit. */
12
+ const MAX_STRING_FRAME_DEPTH = 256;
13
+
14
+ /** Where a double-quoted string ends, and whether it was closed at all. */
15
+ export interface QuotedStringScan {
16
+ /** index just past the closing `"`, or `text.length` when unterminated. */
17
+ end: number;
18
+ closed: boolean;
19
+ }
20
+
21
+ /**
22
+ * Valid single-char escapes after `\` inside a RouterOS string.
23
+ *
24
+ * This is the DEVICE's set, not the manual's table. The manual's
25
+ * "Constant Escape Sequences" list is a lower bound: it omits `\?`, which
26
+ * RouterOS classes `escaped` and evaluates to `?`. Swept byte by byte on CHR
27
+ * 7.23.3 — `:put "\<c>"` for every 0x20–0x7E plus TAB/LF/CRLF, scored on both
28
+ * the `/console/inspect request=highlight` class and the runtime result — and
29
+ * the accepted set is exactly these plus whitespace continuations plus
30
+ * two-digit uppercase hex (#252). Re-derive with
31
+ * `bun run explain:probe:escapes` (#186).
32
+ */
33
+ const VALID_SINGLE = new Set([
34
+ '"',
35
+ "\\",
36
+ "n",
37
+ "r",
38
+ "t",
39
+ "$",
40
+ "_",
41
+ "a",
42
+ "b",
43
+ "f",
44
+ "v",
45
+ "?",
46
+ ]);
47
+
48
+ function isHexUpper(c: string): boolean {
49
+ return (c >= "0" && c <= "9") || (c >= "A" && c <= "F");
50
+ }
51
+
52
+ /**
53
+ * `\` before whitespace is a line continuation INSIDE a string too, not only in
54
+ * code: the device swallows the pair and emits nothing (`:put "a\ b"` -> `ab`,
55
+ * and the same for TAB/CR/LF/CRLF). The manual states it under "Line joining" —
56
+ * "A backslash does not continue a token except for string". Every multi-line
57
+ * `source=`/`on-event=` script in the wild relies on it.
58
+ */
59
+ function isContinuationWhitespace(c: string): boolean {
60
+ return c === " " || c === "\t" || c === "\r" || c === "\n";
61
+ }
62
+
63
+ /**
64
+ * Is the escape at `at` (where text[at] === `\`) inside a string valid?
65
+ * Returns true for valid, false for invalid (unknown, lowercase hex, truncated).
66
+ */
67
+ function isValidStringEscape(text: string, at: number): boolean {
68
+ const next1 = text[at + 1];
69
+ if (next1 === undefined) return false;
70
+ if (VALID_SINGLE.has(next1)) return true;
71
+ if (isContinuationWhitespace(next1)) return true;
72
+ if (isHexUpper(next1)) {
73
+ const next2 = text[at + 2];
74
+ if (next2 !== undefined && isHexUpper(next2)) return true;
75
+ return false;
76
+ }
77
+ return false;
78
+ }
79
+
80
+ /**
81
+ * Bytes to skip past the escape at `at`. Always 2 or 3 so recovery advances
82
+ * even when the escape is malformed (#199). CRLF is ONE continuation, so it is
83
+ * the only non-hex three-byte escape.
84
+ */
85
+ function escapeLength(text: string, at: number): 2 | 3 {
86
+ const next1 = text[at + 1];
87
+ if (next1 === "\r" && text[at + 2] === "\n") return 3;
88
+ if (
89
+ next1 !== undefined &&
90
+ isHexUpper(next1) &&
91
+ isHexUpper(text[at + 2] ?? "")
92
+ )
93
+ return 3;
94
+ return 2;
95
+ }
96
+
97
+ // ---------------------------------------------------------------------------
98
+ // Single source of truth for the frame grammar (#253).
99
+ //
100
+ // Both `scanQuotedString` and `collectStringEscapeDefects` walk the same
101
+ // nesting: a `"` frame is string phase, anything else is code phase where
102
+ // `$[`/`$(` inside a string push a bracket frame and brackets nest until
103
+ // matched. The helpers below own the transitions, the depth guard, and the
104
+ // phase dispatch once — callers differ only in what they do with an escape
105
+ // (skip vs validate) and whether they skip comment spans.
106
+ // ---------------------------------------------------------------------------
107
+
108
+ /**
109
+ * One step of the shared frame machine. Owns the depth guard, the `top` read,
110
+ * and the phase dispatch (`"` vs code) so callers cannot drift on them.
111
+ * When inside a string at `\`, delegates to `onStringEscape` so callers can
112
+ * validate or just skip. Returns bytes to advance, or a Defect, or 0 when the
113
+ * shared depth cap is hit (caller must break).
114
+ */
115
+ function stepFrame(
116
+ text: string,
117
+ i: number,
118
+ frames: string[],
119
+ onStringEscape: (at: number) => number | Defect,
120
+ ): number | Defect {
121
+ if (frames.length > MAX_STRING_FRAME_DEPTH) return 0;
122
+ const top = frames[frames.length - 1] as string | undefined;
123
+ const c = text[i] as string;
124
+ if (top === '"') {
125
+ if (c === "\\") return onStringEscape(i);
126
+ if (c === '"') {
127
+ frames.pop();
128
+ return 1;
129
+ }
130
+ if (c === "$" && (text[i + 1] === "[" || text[i + 1] === "(")) {
131
+ frames.push(text[i + 1] as string);
132
+ return 2;
133
+ }
134
+ return 1;
135
+ }
136
+ if (c === '"') {
137
+ frames.push('"');
138
+ return 1;
139
+ }
140
+ if (c === "[" || c === "(" || c === "{") {
141
+ frames.push(c);
142
+ return 1;
143
+ }
144
+ if (c === "]" || c === ")" || c === "}") {
145
+ const want = c === "]" ? "[" : c === ")" ? "(" : "{";
146
+ if (top === want) frames.pop();
147
+ return 1;
148
+ }
149
+ return 1;
150
+ }
151
+
152
+ /**
153
+ * Boundary recovery must skip the escaped byte even when the escape itself
154
+ * is malformed, otherwise the closing quote is lost and every later comment
155
+ * is swallowed (#199). Validation is separate; here we just advance.
156
+ */
157
+ function stringEscapeSkip(text: string, at: number): number {
158
+ return escapeLength(text, at);
159
+ }
160
+
161
+ /**
162
+ * Locate defect at the escaped byte the device marks `error`.
163
+ * For `\q` it's `q`; for `\0a` it's `a`.
164
+ * For truncated `\0` before `"` the device marks the closing quote;
165
+ * centrs reports at the backslash instead, because that byte is stable
166
+ * when the string is unterminated (#252).
167
+ */
168
+ function stringEscapeValidated(text: string, at: number): number | Defect {
169
+ if (!isValidStringEscape(text, at)) {
170
+ const next1 = text[at + 1];
171
+ let defectAtOffset: number;
172
+ if (next1 === undefined) defectAtOffset = at;
173
+ else if (isHexUpper(next1)) {
174
+ const next2 = text[at + 2];
175
+ if (next2 === undefined) defectAtOffset = at;
176
+ else if (!isHexUpper(next2)) defectAtOffset = at + 2;
177
+ else defectAtOffset = at;
178
+ } else defectAtOffset = at + 1;
179
+ return defectAt("bad-string-escape", defectAtOffset);
180
+ }
181
+ return escapeLength(text, at);
182
+ }
183
+
184
+ /**
185
+ * Find the end of the double-quoted string that opens at `open`.
186
+ *
187
+ * A `$[…]` or `$(…)` substitution inside a string is code and may contain
188
+ * nested strings, so the next quote is not necessarily the outer close.
189
+ */
190
+ export function scanQuotedString(text: string, open: number): QuotedStringScan {
191
+ const frames: string[] = ['"'];
192
+ let i = open + 1;
193
+ while (i < text.length) {
194
+ const res = stepFrame(text, i, frames, (at) => stringEscapeSkip(text, at));
195
+ if (typeof res !== "number") break;
196
+ if (res === 0) break;
197
+ i += res;
198
+ if (frames.length === 0) return { end: i, closed: true };
199
+ }
200
+ return { end: text.length, closed: false };
201
+ }
202
+
203
+ /**
204
+ * Collect the first invalid string-internal escape in `text`, if any.
205
+ *
206
+ * Walks the entire document with the same frame model as `scanQuotedString`,
207
+ * but validates each `\` inside a string frame against the documented escape
208
+ * table (capital hex, truncated, unknown). Returns an array with at most one
209
+ * defect at the first invalid escape. Comment spans are skipped: a `"` inside
210
+ * a comment is not a string.
211
+ */
212
+ export function collectStringEscapeDefects(
213
+ text: string,
214
+ comments: readonly { start: number; end: number }[] = [],
215
+ ): Defect[] {
216
+ const frames: string[] = [];
217
+ let i = 0;
218
+ let ci = 0;
219
+ while (i < text.length) {
220
+ // Skip comment spans (analyzed-byte offsets, same space as `text`).
221
+ while (
222
+ ci < comments.length &&
223
+ i >= (comments[ci] as { start: number; end: number }).end
224
+ )
225
+ ci++;
226
+ if (ci < comments.length) {
227
+ const c = comments[ci] as { start: number; end: number };
228
+ if (i >= c.start && i < c.end) {
229
+ i = c.end;
230
+ continue;
231
+ }
232
+ }
233
+ const res = stepFrame(text, i, frames, (at) =>
234
+ stringEscapeValidated(text, at),
235
+ );
236
+ if (typeof res !== "number") return [res];
237
+ if (res === 0) break;
238
+ i += res;
239
+ }
240
+ return [];
241
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Source-visible brace roles shared by the explain walkers.
3
+ *
4
+ * RouterOS does not make every `{` a statement body. `do={...}` and stored
5
+ * script attributes do, while `name={...}` is ordinarily an array literal.
6
+ * Keeping this decision below `segment.ts` avoids a cycle with `blocks.ts` and
7
+ * gives comment masking, segmentation, and symbol resolution one rule.
8
+ */
9
+
10
+ import { scanQuotedString } from "./quoted-string.ts";
11
+
12
+ /** Error-variable shape for `:onerror V { … }` — a bare/`$`-prefixed name. */
13
+ const ERROR_VAR = /^\$?[A-Za-z][A-Za-z0-9._-]*$/;
14
+
15
+ function isAsciiWhitespace(char: string | undefined): boolean {
16
+ return char === " " || char === "\t" || char === "\r" || char === "\n";
17
+ }
18
+
19
+ /**
20
+ * Source-visible argument names whose `{…}` is a scope. Closed set, harvested
21
+ * from every projected corpus capture (`do` 5623, `else` 720, `command` 177,
22
+ * `on-error` 132).
23
+ *
24
+ * `command` is here because users DO write it explicitly:
25
+ * `:retry delay=1s max=3 on-error={…} command={…}`
26
+ * (topic-169237/post-0021-snippet-01 @ 7.22.1).
27
+ *
28
+ * `in` is deliberately NOT here even though IL lowers `:onerror V { … }` under
29
+ * that name — in SOURCE, `in={…}` is always an array literal
30
+ * (`:foreach Type in={ "p12"; "pem" }` → `in=p12;pem`,
31
+ * eworm/check-certificates.rsc @ 7.22.1). The IL name and the source name
32
+ * collide; only the source spelling governs offline.
33
+ */
34
+ export const SCOPE_ARG_NAMES: ReadonlySet<string> = new Set([
35
+ "do",
36
+ "else",
37
+ "on-error",
38
+ "command",
39
+ ]);
40
+
41
+ /** Argument names that are scopes only under a specific head directive. */
42
+ export const HEAD_SCOPED_ARG_NAMES: Record<string, ReadonlySet<string>> = {
43
+ in: new Set([":onerror", "onerror"]),
44
+ };
45
+
46
+ /**
47
+ * Directives whose brace body attaches to the directive itself, with the IL
48
+ * name the body is lowered under. The colon is optional in practice: `do {`
49
+ * opens a body the same way `:do {` does (topic-142687/post-0010-snippet-01 @
50
+ * 7.22.1).
51
+ */
52
+ export const DIRECTIVE_BODY: Record<string, string> = {
53
+ ":do": "command",
54
+ ":retry": "command",
55
+ ":onerror": "in",
56
+ do: "command",
57
+ retry: "command",
58
+ onerror: "in",
59
+ };
60
+
61
+ /** Script-valued attribute naming convention already used by the corpus census. */
62
+ const SCRIPT_BODY_ARG = /^(?:source|script|on-.*)$/;
63
+
64
+ /** Grounded scripting directives whose positional arity makes a later `#` fatal. */
65
+ const DIRECTIVE_POSITIONAL_ARITY: Readonly<Record<string, number>> = {
66
+ local: 2,
67
+ global: 2,
68
+ set: 2,
69
+ put: 1,
70
+ };
71
+
72
+ const BOUNDARY_CACHE_LIMIT = 4;
73
+ const BOUNDARY_CACHE_BYTE_LIMIT = 8 * 1024 * 1024;
74
+ interface StatementIndex {
75
+ boundaries: Int32Array;
76
+ firstContent: Int32Array;
77
+ lastForbidden: Int32Array;
78
+ }
79
+
80
+ const boundaryCache = new Map<string, StatementIndex>();
81
+ let boundaryCacheBytes = 0;
82
+ // Most probes in one walk ask about the same document. Bypass Map's string-key
83
+ // lookup for that hot path without retaining anything outside the bounded cache.
84
+ let recentBoundaryText: string | undefined;
85
+ let recentBoundaryIndex: StatementIndex | undefined;
86
+
87
+ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
88
+ // JavaScript strings use at most two bytes per UTF-16 code unit. Include the
89
+ // key as well as the typed array so unusually large sources cannot be retained.
90
+ return (
91
+ text.length * 2 +
92
+ index.boundaries.byteLength +
93
+ index.firstContent.byteLength +
94
+ index.lastForbidden.byteLength
95
+ );
96
+ }
97
+
98
+ function cacheStatementIndex(text: string, index: StatementIndex): boolean {
99
+ const entryBytes = boundaryCacheEntryBytes(text, index);
100
+ if (entryBytes > BOUNDARY_CACHE_BYTE_LIMIT) return false;
101
+
102
+ while (
103
+ boundaryCache.size >= BOUNDARY_CACHE_LIMIT ||
104
+ boundaryCacheBytes + entryBytes > BOUNDARY_CACHE_BYTE_LIMIT
105
+ ) {
106
+ const oldest = boundaryCache.entries().next().value;
107
+ if (oldest === undefined) break;
108
+ boundaryCache.delete(oldest[0]);
109
+ boundaryCacheBytes -= boundaryCacheEntryBytes(oldest[0], oldest[1]);
110
+ if (recentBoundaryText === oldest[0]) {
111
+ recentBoundaryText = undefined;
112
+ recentBoundaryIndex = undefined;
113
+ }
114
+ }
115
+ boundaryCache.set(text, index);
116
+ boundaryCacheBytes += entryBytes;
117
+ return true;
118
+ }
119
+
120
+ /** Statement-boundary and prefix facts for every source offset, in one pass. */
121
+ function statementIndex(text: string): StatementIndex {
122
+ if (recentBoundaryText === text && recentBoundaryIndex !== undefined)
123
+ return recentBoundaryIndex;
124
+ const cached = boundaryCache.get(text);
125
+ if (cached !== undefined) {
126
+ recentBoundaryText = text;
127
+ recentBoundaryIndex = cached;
128
+ return cached;
129
+ }
130
+
131
+ const boundaries = new Int32Array(text.length + 1);
132
+ boundaries.fill(-1);
133
+ const firstContent = new Int32Array(text.length + 1);
134
+ firstContent.fill(-1);
135
+ const lastForbidden = new Int32Array(text.length + 1);
136
+ lastForbidden.fill(-1);
137
+ let boundary = -1;
138
+ let first = -1;
139
+ let forbidden = -1;
140
+ let i = 0;
141
+ while (i < text.length) {
142
+ boundaries[i] = boundary;
143
+ firstContent[i] = first;
144
+ lastForbidden[i] = forbidden;
145
+ const c = text[i] as string;
146
+ if (c === '"') {
147
+ const end = Math.min(scanQuotedString(text, i).end, text.length);
148
+ boundaries.fill(boundary, i + 1, end + 1);
149
+ firstContent.fill(first === -1 ? i : first, i + 1, end + 1);
150
+ lastForbidden.fill(i, i + 1, end + 1);
151
+ if (first === -1) first = i;
152
+ forbidden = i;
153
+ i = end;
154
+ continue;
155
+ }
156
+ if (first === -1 && !isAsciiWhitespace(c)) first = i;
157
+ if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
158
+ if (c === "\n" || c === ";" || c === "{" || c === "[") {
159
+ boundary = i;
160
+ first = -1;
161
+ }
162
+ i++;
163
+ }
164
+ boundaries[text.length] = boundary;
165
+ firstContent[text.length] = first;
166
+ lastForbidden[text.length] = forbidden;
167
+
168
+ const index = { boundaries, firstContent, lastForbidden };
169
+ if (cacheStatementIndex(text, index)) {
170
+ recentBoundaryText = text;
171
+ recentBoundaryIndex = index;
172
+ }
173
+ return index;
174
+ }
175
+
176
+ function trailingName(text: string, start: number, end: number): string | null {
177
+ let i = end - 1;
178
+ while (i >= start && isAsciiWhitespace(text[i])) i--;
179
+ if (text[i] !== "=") return null;
180
+ i--;
181
+ const nameEnd = i + 1;
182
+ while (i >= start && /[A-Za-z0-9.-]/.test(text[i] as string)) i--;
183
+ let nameStart = i + 1;
184
+ while (nameStart < nameEnd && !/[A-Za-z]/.test(text[nameStart] as string))
185
+ nameStart++;
186
+ if (nameStart === nameEnd || !/[A-Za-z]/.test(text[nameStart] as string))
187
+ return null;
188
+ return text.slice(nameStart, nameEnd);
189
+ }
190
+
191
+ function leadingWords(
192
+ text: string,
193
+ start: number,
194
+ end: number,
195
+ limit: number,
196
+ removeContinuations = false,
197
+ ): string[] {
198
+ const words: string[] = [];
199
+ let word = "";
200
+ for (let i = start; i < end && words.length < limit; i++) {
201
+ if (
202
+ removeContinuations &&
203
+ text[i] === "\\" &&
204
+ (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
205
+ ) {
206
+ i += text[i + 1] === "\r" ? 2 : 1;
207
+ continue;
208
+ }
209
+ const c = text[i] as string;
210
+ if (isAsciiWhitespace(c)) {
211
+ if (word.length > 0) {
212
+ words.push(word);
213
+ word = "";
214
+ }
215
+ } else {
216
+ word += c;
217
+ }
218
+ }
219
+ if (word.length > 0 && words.length < limit) words.push(word);
220
+ return words;
221
+ }
222
+
223
+ /** Scope name for an already comment-masked source view. */
224
+ export function scopeNameFromMasked(
225
+ masked: string,
226
+ open: number,
227
+ ): string | null {
228
+ const index = statementIndex(masked);
229
+ const start = (index.boundaries[open] ?? -1) + 1;
230
+ const named = trailingName(masked, start, open);
231
+ if (named !== null) {
232
+ const name = named.toLowerCase();
233
+ if (SCOPE_ARG_NAMES.has(name)) return name;
234
+ const heads = HEAD_SCOPED_ARG_NAMES[name];
235
+ if (heads !== undefined) {
236
+ const first = index.firstContent[open] ?? -1;
237
+ const head =
238
+ first < start
239
+ ? ""
240
+ : (leadingWords(masked, first, open, 1)[0] ?? "").toLowerCase();
241
+ return heads.has(head) ? name : null;
242
+ }
243
+ return null;
244
+ }
245
+ // `:do {`, `:retry {`, `:onerror Err {` — the directive may carry ONE bare
246
+ // error-variable word before the brace. A second token that is not an
247
+ // identifier (`:onerror [find] {`) is not this form, so the brace is a value.
248
+ const firstContentOffset = index.firstContent[open] ?? -1;
249
+ const words =
250
+ firstContentOffset < start
251
+ ? []
252
+ : leadingWords(masked, firstContentOffset, open, 3);
253
+ const firstWord = (words[0] ?? "").toLowerCase();
254
+ const body = DIRECTIVE_BODY[firstWord];
255
+ if (body === undefined) return null;
256
+ if (words.length === 1) return body;
257
+ if (words.length === 2 && ERROR_VAR.test(words[1] as string)) return body;
258
+ return null;
259
+ }
260
+
261
+ /** Whether this brace begins a context in which statement-leading comments exist. */
262
+ export function braceStartsStatements(text: string, open: number): boolean {
263
+ if (scopeNameFromMasked(text, open) !== null) return true;
264
+ const index = statementIndex(text);
265
+ const boundary = index.boundaries[open] ?? -1;
266
+ const start = boundary + 1;
267
+ const named = trailingName(text, start, open);
268
+ if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
269
+
270
+ // H7's bare `{...}` and `/menu {...}` containers are statement bodies too.
271
+ const first = index.firstContent[open] ?? -1;
272
+ return (
273
+ first < start ||
274
+ (text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
275
+ );
276
+ }
277
+
278
+ /**
279
+ * Whether an unquoted, non-comment `#` is a device hard error (#245).
280
+ *
281
+ * `inExpression` is the caller's already-known delimiter role: arrays and
282
+ * parenthesized expressions reject every unquoted hash. Outside them, a hash
283
+ * joined to a value (`comment=a#b`) stays content. A whitespace-led hash is
284
+ * rejected only where CHR proves the statement has already consumed the fixed
285
+ * positional operands of a scripting directive, or after a closing scope.
286
+ */
287
+ export function hashStartsHardError(
288
+ text: string,
289
+ at: number,
290
+ inExpression: boolean,
291
+ ): boolean {
292
+ if (inExpression) return true;
293
+ if (at <= 0 || !isAsciiWhitespace(text[at - 1])) return false;
294
+
295
+ let previous = at - 1;
296
+ while (previous >= 0 && isAsciiWhitespace(text[previous])) previous--;
297
+ if (text[previous] === "}") return true;
298
+
299
+ const index = statementIndex(text);
300
+ const start = (index.boundaries[at] ?? -1) + 1;
301
+ const first = index.firstContent[at] ?? -1;
302
+ const words = first < start ? [] : leadingWords(text, first, at, 3, true);
303
+ const rawHead = (words[0] ?? "").toLowerCase();
304
+ if (!rawHead.startsWith(":") && !rawHead.startsWith("/")) return false;
305
+ const head = rawHead.slice(1);
306
+ const arity = DIRECTIVE_POSITIONAL_ARITY[head];
307
+ return arity !== undefined && words.length - 1 >= arity;
308
+ }