@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,461 @@
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
+ matchingBrace: Map<number, number>;
79
+ }
80
+
81
+ const boundaryCache = new Map<string, StatementIndex>();
82
+ let boundaryCacheBytes = 0;
83
+ // Most probes in one walk ask about the same document. Bypass Map's string-key
84
+ // lookup for that hot path. The slot holds ONE string reference, which may be a
85
+ // content-equal twin of a cache key rather than the key itself (see
86
+ // `adoptBoundaryText`), so retention is the bounded cache plus that one string.
87
+ let recentBoundaryText: string | undefined;
88
+ let recentBoundaryIndex: StatementIndex | undefined;
89
+
90
+ /**
91
+ * Adopt `text` as the single-slot key.
92
+ *
93
+ * A walk asks about several string objects with IDENTICAL content — the
94
+ * `analyzed` surface, the raw input, and a comment mask with nothing to mask
95
+ * are equal strings built by different steps. JavaScript cannot compare string
96
+ * identity, so `recentBoundaryText === text` across two such objects is a full
97
+ * content comparison: measured at 10 us per hit on a 64 KiB document versus
98
+ * 0.01 us when the slot already holds that object. Leaving the slot pointing at
99
+ * the first object made every later probe pay that comparison — a per-call cost
100
+ * that grows with the document, which is the shape #313 saw as superlinear
101
+ * growth through the public entry. Adopting the object on a hit pays it once.
102
+ *
103
+ * Sound because the index is a pure function of content: an index built for one
104
+ * string describes every string equal to it.
105
+ */
106
+ function adoptBoundaryText(text: string, index: StatementIndex): void {
107
+ recentBoundaryText = text;
108
+ recentBoundaryIndex = index;
109
+ }
110
+
111
+ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
112
+ // JavaScript strings use at most two bytes per UTF-16 code unit. Include the
113
+ // key as well as the typed array so unusually large sources cannot be retained.
114
+ return (
115
+ text.length * 2 +
116
+ index.boundaries.byteLength +
117
+ index.firstContent.byteLength +
118
+ index.lastForbidden.byteLength +
119
+ index.matchingBrace.size * 32
120
+ );
121
+ }
122
+
123
+ /** Whether one statement index fits the bounded cache's per-entry ceiling. */
124
+ export function canRetainStatementIndex(text: string): boolean {
125
+ return (
126
+ text.length * 2 + (text.length + 1) * Int32Array.BYTES_PER_ELEMENT * 3 <=
127
+ BOUNDARY_CACHE_BYTE_LIMIT
128
+ );
129
+ }
130
+
131
+ function cacheStatementIndex(text: string, index: StatementIndex): boolean {
132
+ if (!canRetainStatementIndex(text)) return false;
133
+ const entryBytes = boundaryCacheEntryBytes(text, index);
134
+ if (entryBytes > BOUNDARY_CACHE_BYTE_LIMIT) return false;
135
+
136
+ while (
137
+ boundaryCache.size >= BOUNDARY_CACHE_LIMIT ||
138
+ boundaryCacheBytes + entryBytes > BOUNDARY_CACHE_BYTE_LIMIT
139
+ ) {
140
+ const oldest = boundaryCache.entries().next().value;
141
+ if (oldest === undefined) break;
142
+ boundaryCache.delete(oldest[0]);
143
+ boundaryCacheBytes -= boundaryCacheEntryBytes(oldest[0], oldest[1]);
144
+ if (recentBoundaryText === oldest[0]) {
145
+ recentBoundaryText = undefined;
146
+ recentBoundaryIndex = undefined;
147
+ }
148
+ }
149
+ boundaryCache.set(text, index);
150
+ boundaryCacheBytes += entryBytes;
151
+ return true;
152
+ }
153
+
154
+ /** Statement-boundary and prefix facts for every source offset, in one pass. */
155
+ function statementIndex(text: string): StatementIndex {
156
+ if (recentBoundaryText === text && recentBoundaryIndex !== undefined) {
157
+ const hit = recentBoundaryIndex;
158
+ adoptBoundaryText(text, hit);
159
+ return hit;
160
+ }
161
+ const cached = boundaryCache.get(text);
162
+ if (cached !== undefined) {
163
+ // Move to the most-recent end. Eviction walks the Map in INSERTION order,
164
+ // so without this the cache is FIFO: the document-wide index every ranged
165
+ // reader shares is re-read constantly but never re-inserted, and a stream
166
+ // of one-off statement indices ages it out and makes the next reader
167
+ // rebuild it over the whole document (#322).
168
+ boundaryCache.delete(text);
169
+ boundaryCache.set(text, cached);
170
+ adoptBoundaryText(text, cached);
171
+ return cached;
172
+ }
173
+
174
+ const boundaries = new Int32Array(text.length + 1);
175
+ boundaries.fill(-1);
176
+ const firstContent = new Int32Array(text.length + 1);
177
+ firstContent.fill(-1);
178
+ const lastForbidden = new Int32Array(text.length + 1);
179
+ lastForbidden.fill(-1);
180
+ const matchingBrace = new Map<number, number>();
181
+ const delimiters: { char: string; at: number }[] = [];
182
+ let boundary = -1;
183
+ let first = -1;
184
+ let forbidden = -1;
185
+ let i = 0;
186
+ while (i < text.length) {
187
+ boundaries[i] = boundary;
188
+ firstContent[i] = first;
189
+ lastForbidden[i] = forbidden;
190
+ const c = text[i] as string;
191
+ if (c === '"') {
192
+ const end = Math.min(scanQuotedString(text, i).end, text.length);
193
+ boundaries.fill(boundary, i + 1, end + 1);
194
+ firstContent.fill(first === -1 ? i : first, i + 1, end + 1);
195
+ lastForbidden.fill(i, i + 1, end + 1);
196
+ if (first === -1) first = i;
197
+ forbidden = i;
198
+ i = end;
199
+ continue;
200
+ }
201
+ if (c === "{" || c === "[" || c === "(") {
202
+ delimiters.push({ char: c, at: i });
203
+ } else if (c === "}" || c === "]" || c === ")") {
204
+ const want = c === "}" ? "{" : c === "]" ? "[" : "(";
205
+ const delimiter = delimiters.pop();
206
+ if (delimiter?.char === want && c === "}")
207
+ matchingBrace.set(delimiter.at, i);
208
+ }
209
+ if (first === -1 && !isAsciiWhitespace(c)) first = i;
210
+ if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
211
+ if (c === "\n" || c === ";" || c === "{" || c === "[") {
212
+ boundary = i;
213
+ first = -1;
214
+ }
215
+ i++;
216
+ }
217
+ boundaries[text.length] = boundary;
218
+ firstContent[text.length] = first;
219
+ lastForbidden[text.length] = forbidden;
220
+ const index = {
221
+ boundaries,
222
+ firstContent,
223
+ lastForbidden,
224
+ matchingBrace,
225
+ };
226
+ if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
227
+ return index;
228
+ }
229
+
230
+ /** Closing `}` paired with `open`, outside strings/comments, when one exists. */
231
+ export function matchingBraceEnd(
232
+ text: string,
233
+ open: number,
234
+ ): number | undefined {
235
+ return statementIndex(text).matchingBrace.get(open);
236
+ }
237
+
238
+ /**
239
+ * Whether the index itself starts a fresh statement at `start`.
240
+ *
241
+ * Most readers index a comment mask, but segmentation must retain the raw
242
+ * comments. A quote in an earlier comment can make that raw index skip a real
243
+ * body opener as string content. Such a body must build its own local index:
244
+ * clamping lookback alone cannot restore boundaries the outer index skipped.
245
+ */
246
+ export function isIndexedStatementStart(text: string, start: number): boolean {
247
+ return start === 0 || statementIndex(text).boundaries[start] === start - 1;
248
+ }
249
+
250
+ /**
251
+ * The bare scope-slot word before a `{`, when the `=` is simply omitted (#347).
252
+ *
253
+ * The device settles this: `:if (1=1) do={…} else={…}`,
254
+ * `:if (1=1) do={…} else {…}` and `:if (1=1) do {…} else {…}` all lower to
255
+ * BYTE-IDENTICAL IL (`…;do=;(evl …);else=;(evl …)`) on 7.21.5, 7.23.5, 7.24.2
256
+ * and 7.25beta3, with `:if (1=1) do={…} elsy {…}` rejected on all four as the
257
+ * control. The `=` is optional punctuation, not part of the slot.
258
+ *
259
+ * Deliberately narrower than {@link trailingName} in two ways, because a bare
260
+ * word is far weaker evidence than `name=`:
261
+ *
262
+ * - the word must already be a {@link SCOPE_ARG_NAMES} member, so a positional
263
+ * never becomes a slot — `:local z {1;2}` keeps `z` a positional and its
264
+ * brace an array;
265
+ * - it may not be the statement's own leading directive. `:do {` reads its
266
+ * body through {@link DIRECTIVE_BODY} (as `command`, the IL name), and
267
+ * letting the head word match here would rename that slot to `do`.
268
+ */
269
+ function trailingBareScopeName(
270
+ text: string,
271
+ start: number,
272
+ end: number,
273
+ ): string | null {
274
+ let i = end - 1;
275
+ while (i >= start && isAsciiWhitespace(text[i])) i--;
276
+ const nameEnd = i + 1;
277
+ while (i >= start && /[A-Za-z0-9.-]/.test(text[i] as string)) i--;
278
+ const nameStart = i + 1;
279
+ if (nameStart === nameEnd) return null;
280
+ const name = text.slice(nameStart, nameEnd).toLowerCase();
281
+ if (!SCOPE_ARG_NAMES.has(name)) return null;
282
+ // A COLON makes it a directive, never an argument name, and the device is
283
+ // emphatic: `:if (1=1) :do { :put a }` is `expected end of command` on both
284
+ // 7.21.5 and 7.24.2, while the bare `:if (1=1) do { :put a }` parses. So the
285
+ // colon form is refused here and left to the `DIRECTIVE_BODY` branch below,
286
+ // which is what legitimately reads a statement-head `:do { … }`.
287
+ if (text[nameStart - 1] === ":") return null;
288
+ if (leadingWords(text, start, nameStart, 1).length === 0) return null;
289
+ return name;
290
+ }
291
+
292
+ function trailingName(text: string, start: number, end: number): string | null {
293
+ let i = end - 1;
294
+ while (i >= start && isAsciiWhitespace(text[i])) i--;
295
+ if (text[i] !== "=") return null;
296
+ i--;
297
+ const nameEnd = i + 1;
298
+ while (i >= start && /[A-Za-z0-9.-]/.test(text[i] as string)) i--;
299
+ let nameStart = i + 1;
300
+ while (nameStart < nameEnd && !/[A-Za-z]/.test(text[nameStart] as string))
301
+ nameStart++;
302
+ if (nameStart === nameEnd || !/[A-Za-z]/.test(text[nameStart] as string))
303
+ return null;
304
+ return text.slice(nameStart, nameEnd);
305
+ }
306
+
307
+ function leadingWords(
308
+ text: string,
309
+ start: number,
310
+ end: number,
311
+ limit: number,
312
+ removeContinuations = false,
313
+ ): string[] {
314
+ const words: string[] = [];
315
+ let word = "";
316
+ for (let i = start; i < end && words.length < limit; i++) {
317
+ if (
318
+ removeContinuations &&
319
+ text[i] === "\\" &&
320
+ (text[i + 1] === "\n" || (text[i + 1] === "\r" && text[i + 2] === "\n"))
321
+ ) {
322
+ i += text[i + 1] === "\r" ? 2 : 1;
323
+ continue;
324
+ }
325
+ const c = text[i] as string;
326
+ if (isAsciiWhitespace(c)) {
327
+ if (word.length > 0) {
328
+ words.push(word);
329
+ word = "";
330
+ }
331
+ } else {
332
+ word += c;
333
+ }
334
+ }
335
+ if (word.length > 0 && words.length < limit) words.push(word);
336
+ return words;
337
+ }
338
+
339
+ /**
340
+ * Scope name for an already comment-masked source view.
341
+ *
342
+ * `floor` is the start of the region the caller is reading, defaulting to the
343
+ * whole view. Clamping the statement start to it makes an answer read off a
344
+ * RANGE of a document identical to the same answer read off that range taken as
345
+ * a string of its own: a fresh call cannot see before index 0, so a ranged call
346
+ * must not see before `floor` (#322).
347
+ *
348
+ * It is a guard, not a correction. The clamp does fire on ordinary input —
349
+ * 7,448 times over the 948-script corpus, where a region begins somewhere the
350
+ * preceding boundary character does not — but removing it leaves every one of
351
+ * those 948 results byte-identical, because the extra bytes a wider lookback
352
+ * sees do not spell a scope name. What it buys is that the equality holds by
353
+ * CONSTRUCTION rather than by a property of where today's ranges happen to
354
+ * start, which is what the next reader to take a range will rely on.
355
+ *
356
+ * Nothing else needs clamping — `firstContent` and `lastForbidden` are only
357
+ * ever compared against `start`, so a value from before the floor is already
358
+ * excluded by being less than it.
359
+ */
360
+ export function scopeNameFromMasked(
361
+ masked: string,
362
+ open: number,
363
+ floor = 0,
364
+ ): string | null {
365
+ const index = statementIndex(masked);
366
+ const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
367
+ const named = trailingName(masked, start, open);
368
+ if (named !== null) {
369
+ const name = named.toLowerCase();
370
+ if (SCOPE_ARG_NAMES.has(name)) return name;
371
+ const heads = HEAD_SCOPED_ARG_NAMES[name];
372
+ if (heads !== undefined) {
373
+ const first = index.firstContent[open] ?? -1;
374
+ const head =
375
+ first < start
376
+ ? ""
377
+ : (leadingWords(masked, first, open, 1)[0] ?? "").toLowerCase();
378
+ return heads.has(head) ? name : null;
379
+ }
380
+ return null;
381
+ }
382
+ // `else {` / `do {` / `on-error {` — the same slot with the `=` left off.
383
+ // Checked before the directive-body branch so the ARGUMENT reading wins over
384
+ // the head reading for a word that can be either.
385
+ const bare = trailingBareScopeName(masked, start, open);
386
+ if (bare !== null) return bare;
387
+ // `:do {`, `:retry {`, `:onerror Err {` — the directive may carry ONE bare
388
+ // error-variable word before the brace. A second token that is not an
389
+ // identifier (`:onerror [find] {`) is not this form, so the brace is a value.
390
+ const firstContentOffset = index.firstContent[open] ?? -1;
391
+ const words =
392
+ firstContentOffset < start
393
+ ? []
394
+ : leadingWords(masked, firstContentOffset, open, 3);
395
+ const firstWord = (words[0] ?? "").toLowerCase();
396
+ const body = DIRECTIVE_BODY[firstWord];
397
+ if (body === undefined) return null;
398
+ if (words.length === 1) return body;
399
+ if (words.length === 2 && ERROR_VAR.test(words[1] as string)) return body;
400
+ return null;
401
+ }
402
+
403
+ /**
404
+ * Whether this brace begins a context in which statement-leading comments exist.
405
+ *
406
+ * `floor` bounds the lookback exactly as in {@link scopeNameFromMasked}.
407
+ */
408
+ export function braceStartsStatements(
409
+ text: string,
410
+ open: number,
411
+ floor = 0,
412
+ ): boolean {
413
+ if (scopeNameFromMasked(text, open, floor) !== null) return true;
414
+ const index = statementIndex(text);
415
+ const boundary = index.boundaries[open] ?? -1;
416
+ const start = Math.max(boundary + 1, floor);
417
+ const named = trailingName(text, start, open);
418
+ if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
419
+
420
+ // H7's bare `{...}` and `/menu {...}` containers are statement bodies too.
421
+ const first = index.firstContent[open] ?? -1;
422
+ return (
423
+ first < start ||
424
+ (text[first] === "/" && (index.lastForbidden[open] ?? -1) < start)
425
+ );
426
+ }
427
+
428
+ /**
429
+ * Whether an unquoted, non-comment `#` is a device hard error (#245).
430
+ *
431
+ * `inExpression` is the caller's already-known delimiter role: arrays and
432
+ * parenthesized expressions reject every unquoted hash. Outside them, a hash
433
+ * joined to a value (`comment=a#b`) stays content. A whitespace-led hash is
434
+ * rejected only where CHR proves the statement has already consumed the fixed
435
+ * positional operands of a scripting directive, or after a closing scope.
436
+ */
437
+ export function hashStartsHardError(
438
+ text: string,
439
+ at: number,
440
+ inExpression: boolean,
441
+ floor = 0,
442
+ ): boolean {
443
+ if (inExpression) return true;
444
+ if (at <= floor || !isAsciiWhitespace(text[at - 1])) return false;
445
+
446
+ let previous = at - 1;
447
+ while (previous >= floor && isAsciiWhitespace(text[previous])) previous--;
448
+ // The `}` must be inside the region: a fresh call on the region alone runs
449
+ // off index 0 and finds nothing, so a ranged call must stop at `floor`.
450
+ if (previous >= floor && text[previous] === "}") return true;
451
+
452
+ const index = statementIndex(text);
453
+ const start = Math.max((index.boundaries[at] ?? -1) + 1, floor);
454
+ const first = index.firstContent[at] ?? -1;
455
+ const words = first < start ? [] : leadingWords(text, first, at, 3, true);
456
+ const rawHead = (words[0] ?? "").toLowerCase();
457
+ if (!rawHead.startsWith(":") && !rawHead.startsWith("/")) return false;
458
+ const head = rawHead.slice(1);
459
+ const arity = DIRECTIVE_POSITIONAL_ARITY[head];
460
+ return arity !== undefined && words.length - 1 >= arity;
461
+ }