@tikoci/centrs 0.1.4 → 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 (68) hide show
  1. package/CHANGELOG.md +309 -0
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +8 -4
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +37 -8
  7. package/commands/api/examples.md +31 -1
  8. package/commands/execute/README.md +39 -10
  9. package/commands/execute/examples.md +79 -7
  10. package/commands/explain/README.md +1195 -155
  11. package/commands/explain/examples.md +99 -3
  12. package/commands/mcp/README.md +7 -4
  13. package/commands/mcp/examples.md +2 -2
  14. package/commands/terminal/examples.md +7 -1
  15. package/docs/CLI.md +2 -2
  16. package/docs/CONSTITUTION.md +72 -15
  17. package/docs/MATRIX.md +89 -52
  18. package/docs/errors/validation/syntax.md +29 -4
  19. package/package.json +13 -2
  20. package/src/api-fanout.ts +17 -4
  21. package/src/api.ts +254 -67
  22. package/src/cli/api.ts +1 -1
  23. package/src/cli/btest.ts +1 -1
  24. package/src/cli/explain.ts +1 -1
  25. package/src/cli/terminal.ts +1 -1
  26. package/src/core/envelope.ts +53 -0
  27. package/src/core/error-catalog.ts +2 -1
  28. package/src/core/execute-command.ts +148 -0
  29. package/src/core/inspect.ts +36 -4
  30. package/src/core/version.ts +4 -0
  31. package/src/core/yaml.ts +86 -0
  32. package/src/execute-fanout.ts +18 -4
  33. package/src/execute.ts +225 -244
  34. package/src/explain/arg-tokens.ts +62 -25
  35. package/src/explain/args.ts +398 -52
  36. package/src/explain/blocks.ts +54 -11
  37. package/src/explain/brace-tokens.ts +4 -3
  38. package/src/explain/catalog.ts +42 -44
  39. package/src/explain/coordinates.ts +159 -0
  40. package/src/explain/defects.ts +2 -1
  41. package/src/explain/escape-tokens.ts +77 -0
  42. package/src/explain/highlight-projection.ts +308 -0
  43. package/src/explain/menus.ts +9 -5
  44. package/src/explain/operator-tokens.ts +13 -7
  45. package/src/explain/operators.ts +27 -20
  46. package/src/explain/path-tokens.ts +1 -1
  47. package/src/explain/pathresolve.ts +358 -113
  48. package/src/explain/quoted-string.ts +110 -20
  49. package/src/explain/scope-brace.ts +175 -22
  50. package/src/explain/segment.ts +429 -21
  51. package/src/explain/separator.ts +187 -0
  52. package/src/explain/split-owner.ts +194 -0
  53. package/src/explain/string-tokens.ts +14 -4
  54. package/src/explain/symbol-values.ts +209 -173
  55. package/src/explain/symbols.ts +21 -1
  56. package/src/explain/transport.ts +17 -0
  57. package/src/explain/value-tokens.ts +15 -12
  58. package/src/explain/verbsplit.ts +165 -23
  59. package/src/explain/write.ts +47 -6
  60. package/src/explain.ts +522 -72
  61. package/src/index.ts +19 -2
  62. package/src/mcp/server.ts +2 -1
  63. package/src/offline-gate.ts +148 -0
  64. package/src/retrieve-fanout.ts +1 -1
  65. package/src/retrieve.ts +1 -59
  66. package/src/settings.ts +2 -1
  67. package/src/transfer-fanout.ts +1 -1
  68. package/src/transfer.ts +1 -1
@@ -9,7 +9,7 @@
9
9
  import { type Defect, defectAt } from "./defects.ts";
10
10
 
11
11
  /** Analyzer resource bound, not a RouterOS grammar limit. */
12
- const MAX_STRING_FRAME_DEPTH = 256;
12
+ export const MAX_STRING_FRAME_DEPTH = 256;
13
13
 
14
14
  /** Where a double-quoted string ends, and whether it was closed at all. */
15
15
  export interface QuotedStringScan {
@@ -18,6 +18,13 @@ export interface QuotedStringScan {
18
18
  closed: boolean;
19
19
  }
20
20
 
21
+ type InterpolationEnd = (
22
+ text: string,
23
+ open: number,
24
+ limit: number,
25
+ frameDepth: number,
26
+ ) => number;
27
+
21
28
  /**
22
29
  * Valid single-char escapes after `\` inside a RouterOS string.
23
30
  *
@@ -97,7 +104,7 @@ function escapeLength(text: string, at: number): 2 | 3 {
97
104
  // ---------------------------------------------------------------------------
98
105
  // Single source of truth for the frame grammar (#253).
99
106
  //
100
- // Both `scanQuotedString` and `collectStringEscapeDefects` walk the same
107
+ // Both `scanQuotedString` and `walkStringEscapes` walk the same
101
108
  // nesting: a `"` frame is string phase, anything else is code phase where
102
109
  // `$[`/`$(` inside a string push a bracket frame and brackets nest until
103
110
  // matched. The helpers below own the transitions, the depth guard, and the
@@ -117,8 +124,9 @@ function stepFrame(
117
124
  i: number,
118
125
  frames: string[],
119
126
  onStringEscape: (at: number) => number | Defect,
127
+ maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
120
128
  ): number | Defect {
121
- if (frames.length > MAX_STRING_FRAME_DEPTH) return 0;
129
+ if (frames.length > maxFrameDepth) return 0;
122
130
  const top = frames[frames.length - 1] as string | undefined;
123
131
  const c = text[i] as string;
124
132
  if (top === '"') {
@@ -186,33 +194,98 @@ function stringEscapeValidated(text: string, at: number): number | Defect {
186
194
  *
187
195
  * A `$[…]` or `$(…)` substitution inside a string is code and may contain
188
196
  * nested strings, so the next quote is not necessarily the outer close.
197
+ *
198
+ * `limit` is the end of the region the caller is reading, defaulting to the
199
+ * whole text. A reader working on a RANGE of a larger document must pass it:
200
+ * a string left unterminated inside the range would otherwise be closed by a
201
+ * quote belonging to later text, which is not the answer the same reader gets
202
+ * from the range taken as a string of its own (#322). Lookahead past `limit`
203
+ * cannot change the result — every advance that reads ahead is at least one
204
+ * byte, so it can only overshoot an end that is reported clamped either way.
189
205
  */
190
- export function scanQuotedString(text: string, open: number): QuotedStringScan {
206
+ export function scanQuotedString(
207
+ text: string,
208
+ open: number,
209
+ limit: number = text.length,
210
+ interpolationEnd?: InterpolationEnd,
211
+ maxFrameDepth: number = MAX_STRING_FRAME_DEPTH,
212
+ ): QuotedStringScan {
213
+ if (maxFrameDepth < 1) return { end: limit, closed: false };
191
214
  const frames: string[] = ['"'];
192
215
  let i = open + 1;
193
- while (i < text.length) {
194
- const res = stepFrame(text, i, frames, (at) => stringEscapeSkip(text, at));
216
+ while (i < limit) {
217
+ if (
218
+ frames.at(-1) === '"' &&
219
+ text[i] === "$" &&
220
+ text[i + 1] === "[" &&
221
+ interpolationEnd !== undefined
222
+ ) {
223
+ const end = interpolationEnd(text, i + 1, limit, frames.length);
224
+ if (end >= limit) return { end: limit, closed: false };
225
+ i = end + 1;
226
+ continue;
227
+ }
228
+ const res = stepFrame(
229
+ text,
230
+ i,
231
+ frames,
232
+ (at) => stringEscapeSkip(text, at),
233
+ maxFrameDepth,
234
+ );
195
235
  if (typeof res !== "number") break;
196
236
  if (res === 0) break;
197
237
  i += res;
198
238
  if (frames.length === 0) return { end: i, closed: true };
199
239
  }
200
- return { end: text.length, closed: false };
240
+ return { end: limit, closed: false };
241
+ }
242
+
243
+ /** One string-internal escape: `[start, end)` over the `\` and what it escapes. */
244
+ export interface StringEscapeSpan {
245
+ start: number;
246
+ end: number;
201
247
  }
202
248
 
203
249
  /**
204
- * Collect the first invalid string-internal escape in `text`, if any.
250
+ * What one document-wide escape walk found: the valid escapes, and the first
251
+ * invalid one if the walk hit one.
252
+ */
253
+ export interface StringEscapeWalk {
254
+ /** Valid escapes in document order, up to (not including) the first invalid one. */
255
+ escapes: StringEscapeSpan[];
256
+ /** At most one defect, at the first invalid escape. */
257
+ defects: Defect[];
258
+ }
259
+
260
+ /**
261
+ * Walk every string-internal escape in `text`, once.
205
262
  *
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.
263
+ * Same frame model as `scanQuotedString`, validating each `\` inside a string
264
+ * frame against the documented escape table (capital hex, truncated, unknown).
265
+ * Comment spans are skipped: a `"` inside a comment is not a string.
266
+ *
267
+ * The walk yields BOTH answers because they are the same reading (#264): a
268
+ * valid escape becomes an `escaped` token and an invalid one becomes a
269
+ * `bad-string-escape` diagnostic, and deriving them from two walks would let
270
+ * the token stream and the diagnostics disagree about which bytes are an
271
+ * escape. It stops at the first invalid escape, so the escapes list is the
272
+ * prefix the validator actually vouched for — past a malformed escape the
273
+ * string boundaries themselves are in doubt, and painting `escaped` there
274
+ * would be a claim this walk cannot support.
275
+ *
276
+ * `collectEscapes: false` validates without retaining the valid spans. The
277
+ * default `explainCommand` path consumes only `defects`, and materializing a
278
+ * span per escape for a caller that never asked for `data.tokens` is the kind
279
+ * of cost #313/#317/#320 were about — work nobody requested. Validation is identical
280
+ * either way — only the retention differs, so the two callers cannot diverge
281
+ * on what counts as an escape.
211
282
  */
212
- export function collectStringEscapeDefects(
283
+ export function walkStringEscapes(
213
284
  text: string,
214
285
  comments: readonly { start: number; end: number }[] = [],
215
- ): Defect[] {
286
+ collectEscapes = true,
287
+ ): StringEscapeWalk {
288
+ const escapes: StringEscapeSpan[] = [];
216
289
  const frames: string[] = [];
217
290
  let i = 0;
218
291
  let ci = 0;
@@ -230,12 +303,29 @@ export function collectStringEscapeDefects(
230
303
  continue;
231
304
  }
232
305
  }
233
- const res = stepFrame(text, i, frames, (at) =>
234
- stringEscapeValidated(text, at),
235
- );
236
- if (typeof res !== "number") return [res];
306
+ const res = stepFrame(text, i, frames, (at) => {
307
+ const step = stringEscapeValidated(text, at);
308
+ if (collectEscapes && typeof step === "number")
309
+ escapes.push({ start: at, end: at + step });
310
+ return step;
311
+ });
312
+ if (typeof res !== "number") return { escapes, defects: [res] };
237
313
  if (res === 0) break;
238
314
  i += res;
239
315
  }
240
- return [];
316
+ return { escapes, defects: [] };
317
+ }
318
+
319
+ /**
320
+ * Collect the first invalid string-internal escape in `text`, if any.
321
+ *
322
+ * The defect half of {@link walkStringEscapes}, retaining no escape spans.
323
+ * Callers that also need the valid escapes should call the walk directly
324
+ * rather than walking twice.
325
+ */
326
+ export function collectStringEscapeDefects(
327
+ text: string,
328
+ comments: readonly { start: number; end: number }[] = [],
329
+ ): Defect[] {
330
+ return walkStringEscapes(text, comments, false).defects;
241
331
  }
@@ -75,15 +75,39 @@ interface StatementIndex {
75
75
  boundaries: Int32Array;
76
76
  firstContent: Int32Array;
77
77
  lastForbidden: Int32Array;
78
+ matchingBrace: Map<number, number>;
78
79
  }
79
80
 
80
81
  const boundaryCache = new Map<string, StatementIndex>();
81
82
  let boundaryCacheBytes = 0;
82
83
  // 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
+ // 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.
84
87
  let recentBoundaryText: string | undefined;
85
88
  let recentBoundaryIndex: StatementIndex | undefined;
86
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
+
87
111
  function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
88
112
  // JavaScript strings use at most two bytes per UTF-16 code unit. Include the
89
113
  // key as well as the typed array so unusually large sources cannot be retained.
@@ -91,11 +115,21 @@ function boundaryCacheEntryBytes(text: string, index: StatementIndex): number {
91
115
  text.length * 2 +
92
116
  index.boundaries.byteLength +
93
117
  index.firstContent.byteLength +
94
- index.lastForbidden.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
95
128
  );
96
129
  }
97
130
 
98
131
  function cacheStatementIndex(text: string, index: StatementIndex): boolean {
132
+ if (!canRetainStatementIndex(text)) return false;
99
133
  const entryBytes = boundaryCacheEntryBytes(text, index);
100
134
  if (entryBytes > BOUNDARY_CACHE_BYTE_LIMIT) return false;
101
135
 
@@ -119,12 +153,21 @@ function cacheStatementIndex(text: string, index: StatementIndex): boolean {
119
153
 
120
154
  /** Statement-boundary and prefix facts for every source offset, in one pass. */
121
155
  function statementIndex(text: string): StatementIndex {
122
- if (recentBoundaryText === text && recentBoundaryIndex !== undefined)
123
- return recentBoundaryIndex;
156
+ if (recentBoundaryText === text && recentBoundaryIndex !== undefined) {
157
+ const hit = recentBoundaryIndex;
158
+ adoptBoundaryText(text, hit);
159
+ return hit;
160
+ }
124
161
  const cached = boundaryCache.get(text);
125
162
  if (cached !== undefined) {
126
- recentBoundaryText = text;
127
- recentBoundaryIndex = cached;
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);
128
171
  return cached;
129
172
  }
130
173
 
@@ -134,6 +177,8 @@ function statementIndex(text: string): StatementIndex {
134
177
  firstContent.fill(-1);
135
178
  const lastForbidden = new Int32Array(text.length + 1);
136
179
  lastForbidden.fill(-1);
180
+ const matchingBrace = new Map<number, number>();
181
+ const delimiters: { char: string; at: number }[] = [];
137
182
  let boundary = -1;
138
183
  let first = -1;
139
184
  let forbidden = -1;
@@ -153,6 +198,14 @@ function statementIndex(text: string): StatementIndex {
153
198
  i = end;
154
199
  continue;
155
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
+ }
156
209
  if (first === -1 && !isAsciiWhitespace(c)) first = i;
157
210
  if (c === "=" || c === "[" || c === "(" || c === "$") forbidden = i;
158
211
  if (c === "\n" || c === ";" || c === "{" || c === "[") {
@@ -164,15 +217,78 @@ function statementIndex(text: string): StatementIndex {
164
217
  boundaries[text.length] = boundary;
165
218
  firstContent[text.length] = first;
166
219
  lastForbidden[text.length] = forbidden;
167
-
168
- const index = { boundaries, firstContent, lastForbidden };
169
- if (cacheStatementIndex(text, index)) {
170
- recentBoundaryText = text;
171
- recentBoundaryIndex = index;
172
- }
220
+ const index = {
221
+ boundaries,
222
+ firstContent,
223
+ lastForbidden,
224
+ matchingBrace,
225
+ };
226
+ if (cacheStatementIndex(text, index)) adoptBoundaryText(text, index);
173
227
  return index;
174
228
  }
175
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
+
176
292
  function trailingName(text: string, start: number, end: number): string | null {
177
293
  let i = end - 1;
178
294
  while (i >= start && isAsciiWhitespace(text[i])) i--;
@@ -220,13 +336,34 @@ function leadingWords(
220
336
  return words;
221
337
  }
222
338
 
223
- /** Scope name for an already comment-masked source view. */
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
+ */
224
360
  export function scopeNameFromMasked(
225
361
  masked: string,
226
362
  open: number,
363
+ floor = 0,
227
364
  ): string | null {
228
365
  const index = statementIndex(masked);
229
- const start = (index.boundaries[open] ?? -1) + 1;
366
+ const start = Math.max((index.boundaries[open] ?? -1) + 1, floor);
230
367
  const named = trailingName(masked, start, open);
231
368
  if (named !== null) {
232
369
  const name = named.toLowerCase();
@@ -242,6 +379,11 @@ export function scopeNameFromMasked(
242
379
  }
243
380
  return null;
244
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;
245
387
  // `:do {`, `:retry {`, `:onerror Err {` — the directive may carry ONE bare
246
388
  // error-variable word before the brace. A second token that is not an
247
389
  // identifier (`:onerror [find] {`) is not this form, so the brace is a value.
@@ -258,12 +400,20 @@ export function scopeNameFromMasked(
258
400
  return null;
259
401
  }
260
402
 
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;
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;
264
414
  const index = statementIndex(text);
265
415
  const boundary = index.boundaries[open] ?? -1;
266
- const start = boundary + 1;
416
+ const start = Math.max(boundary + 1, floor);
267
417
  const named = trailingName(text, start, open);
268
418
  if (named !== null) return SCRIPT_BODY_ARG.test(named.toLowerCase());
269
419
 
@@ -288,16 +438,19 @@ export function hashStartsHardError(
288
438
  text: string,
289
439
  at: number,
290
440
  inExpression: boolean,
441
+ floor = 0,
291
442
  ): boolean {
292
443
  if (inExpression) return true;
293
- if (at <= 0 || !isAsciiWhitespace(text[at - 1])) return false;
444
+ if (at <= floor || !isAsciiWhitespace(text[at - 1])) return false;
294
445
 
295
446
  let previous = at - 1;
296
- while (previous >= 0 && isAsciiWhitespace(text[previous])) previous--;
297
- if (text[previous] === "}") return true;
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;
298
451
 
299
452
  const index = statementIndex(text);
300
- const start = (index.boundaries[at] ?? -1) + 1;
453
+ const start = Math.max((index.boundaries[at] ?? -1) + 1, floor);
301
454
  const first = index.firstContent[at] ?? -1;
302
455
  const words = first < start ? [] : leadingWords(text, first, at, 3, true);
303
456
  const rawHead = (words[0] ?? "").toLowerCase();