@tikoci/centrs 0.1.4 → 0.1.7

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 (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -80,8 +80,18 @@ import {
80
80
  runAtByte,
81
81
  } from "./coordinates.ts";
82
82
  import { type Defect, defectAt, mergeDefects } from "./defects.ts";
83
- import { scanQuotedString as scanQuotedStringShared } from "./quoted-string.ts";
84
- import { braceStartsStatements, hashStartsHardError } from "./scope-brace.ts";
83
+ import {
84
+ MAX_STRING_FRAME_DEPTH,
85
+ scanQuotedString as scanQuotedStringShared,
86
+ } from "./quoted-string.ts";
87
+ import {
88
+ braceStartsStatements,
89
+ canRetainStatementIndex,
90
+ hashStartsHardError,
91
+ isIndexedStatementStart,
92
+ matchingBraceEnd,
93
+ scopeNameFromMasked,
94
+ } from "./scope-brace.ts";
85
95
 
86
96
  /** One top-level statement located in analyzed-byte space. */
87
97
  export interface Segment {
@@ -108,6 +118,13 @@ export interface SegmentResult {
108
118
  segments: Segment[];
109
119
  /** comment spans (analyzed-byte offsets), dropped from the statement stream. */
110
120
  comments: { start: number; end: number }[];
121
+ /**
122
+ * The input was pure ASCII, so an analyzed-byte offset into it is also a JS
123
+ * string index — the test a caller needs before it may treat the spans above
124
+ * as offsets into `original` itself. Published from the coordinate pass this
125
+ * result already ran rather than re-derived, so the two cannot disagree.
126
+ */
127
+ ascii: boolean;
111
128
  /**
112
129
  * Unbalanced delimiters and other structural surprises, each located; never
113
130
  * a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
@@ -154,8 +171,33 @@ export interface QuotedStringScan {
154
171
  * can reach it, which is malformed by construction; sequential substitutions
155
172
  * (`"$[a]$[b]…"`) pop and never accumulate.
156
173
  */
157
- export function scanQuotedString(text: string, open: number): QuotedStringScan {
158
- return scanQuotedStringShared(text, open);
174
+ export function scanQuotedString(
175
+ text: string,
176
+ open: number,
177
+ limit: number = text.length,
178
+ ): QuotedStringScan {
179
+ return scanQuotedStringWithin(text, open, limit, MAX_STRING_FRAME_DEPTH);
180
+ }
181
+
182
+ function scanQuotedStringWithin(
183
+ text: string,
184
+ open: number,
185
+ limit: number,
186
+ frameBudget: number,
187
+ ): QuotedStringScan {
188
+ return scanQuotedStringShared(
189
+ text,
190
+ open,
191
+ limit,
192
+ (source, bracket, end, frameDepth) =>
193
+ scanStringInterpolationWithin(
194
+ source,
195
+ bracket,
196
+ end,
197
+ frameBudget - frameDepth,
198
+ ).end,
199
+ frameBudget,
200
+ );
159
201
  }
160
202
 
161
203
  /**
@@ -188,7 +230,250 @@ export type Continuation = "none" | "escape" | "comment";
188
230
  * can slice the original for content. Idempotent.
189
231
  */
190
232
  export function maskComments(original: string): string {
191
- const out = original.split("");
233
+ if (recentMaskInput === original && recentMaskOutput !== undefined)
234
+ return recentMaskOutput;
235
+ const cached = maskCache.get(original);
236
+ if (cached !== undefined) {
237
+ // Refresh recency — see `scope-brace.ts` → `statementIndex` for why an
238
+ // insertion-ordered eviction loses exactly the entry worth keeping.
239
+ maskCache.delete(original);
240
+ maskCache.set(original, cached);
241
+ adoptMask(original, cached);
242
+ return cached;
243
+ }
244
+ const masked = maskUncached(original);
245
+ if (cacheMask(original, masked)) adoptMask(original, masked);
246
+ return masked;
247
+ }
248
+
249
+ const MASK_CACHE_LIMIT = 4;
250
+ const MASK_CACHE_BYTE_LIMIT = 8 * 1024 * 1024;
251
+ const maskCache = new Map<string, string>();
252
+ let maskCacheBytes = 0;
253
+ // Same shape, and the same reason, as `scope-brace.ts`'s boundary cache: one
254
+ // slot for the hot object, a small bounded map behind it, and adoption on a hit
255
+ // so a content-equal twin is compared once rather than on every call.
256
+ let recentMaskInput: string | undefined;
257
+ let recentMaskOutput: string | undefined;
258
+
259
+ /** Adopt `original` as the single-slot key (see `scope-brace.ts` → `adoptBoundaryText`). */
260
+ function adoptMask(original: string, masked: string): void {
261
+ recentMaskInput = original;
262
+ recentMaskOutput = masked;
263
+ }
264
+
265
+ function maskCacheEntryBytes(original: string, masked: string): number {
266
+ // Comment-free input is its own mask, so that entry holds one string.
267
+ return (original.length + (masked === original ? 0 : masked.length)) * 2;
268
+ }
269
+
270
+ function cacheMask(original: string, masked: string): boolean {
271
+ const entryBytes = maskCacheEntryBytes(original, masked);
272
+ if (entryBytes > MASK_CACHE_BYTE_LIMIT) return false;
273
+ while (
274
+ maskCache.size >= MASK_CACHE_LIMIT ||
275
+ maskCacheBytes + entryBytes > MASK_CACHE_BYTE_LIMIT
276
+ ) {
277
+ const oldest = maskCache.entries().next().value;
278
+ if (oldest === undefined) break;
279
+ maskCache.delete(oldest[0]);
280
+ maskCacheBytes -= maskCacheEntryBytes(oldest[0], oldest[1]);
281
+ if (recentMaskInput === oldest[0]) {
282
+ recentMaskInput = undefined;
283
+ recentMaskOutput = undefined;
284
+ }
285
+ }
286
+ maskCache.set(original, masked);
287
+ maskCacheBytes += entryBytes;
288
+ return true;
289
+ }
290
+
291
+ /**
292
+ * A region of one document, carried with that document's comment mask.
293
+ *
294
+ * Every reader below the statement walk asks its questions of a statement's
295
+ * `text` or of a `do={…}` body — and a statement's text CONTAINS its whole
296
+ * nested subtree, so each enclosing level re-derived the mask, and the
297
+ * statement index behind it, over bytes an inner level had already read. That
298
+ * is the O(bytes x depth) term: a 60 KiB document nested 64 deep masked 27.7 MB
299
+ * (#322).
300
+ *
301
+ * A range replaces the substring. `text` and `masked` are always the WHOLE
302
+ * document; `start` and `end` say which part of it this reader may look at. The
303
+ * mask is then derived once per document and every nested reading is an offset
304
+ * into it.
305
+ *
306
+ * Soundness is a property of where the ranges come from, not of this type: a
307
+ * comment mask is decided by the statement context a byte sits in, and a
308
+ * statement boundary and a scope body both begin one — statement-leading, no
309
+ * continuation pending, an enclosing context that admits statements. So the
310
+ * mask of such a region is the document's mask restricted to it. Readers still
311
+ * have to be told the floor (see `scope-brace.ts` → `scopeNameFromMasked`) so
312
+ * that lookback stops where a fresh call on the region alone would stop.
313
+ */
314
+ export interface MaskedRange {
315
+ /** The whole document. Offsets below index into this, never into a slice. */
316
+ readonly text: string;
317
+ /** `maskComments(text)`. */
318
+ readonly masked: string;
319
+ readonly start: number;
320
+ readonly end: number;
321
+ /** This range's string indices are analyzed-byte offsets. */
322
+ readonly coordinateIdentity?: true;
323
+ }
324
+
325
+ /** The range covering all of `text`, masking it once. */
326
+ export function documentRange(
327
+ text: string,
328
+ coordinateIdentity = false,
329
+ ): MaskedRange {
330
+ return {
331
+ text,
332
+ masked: maskComments(text),
333
+ start: 0,
334
+ end: text.length,
335
+ ...(coordinateIdentity ? { coordinateIdentity: true as const } : {}),
336
+ };
337
+ }
338
+
339
+ /**
340
+ * Locate one `$[…]` body's closing bracket using its own comment context.
341
+ *
342
+ * The document mask deliberately leaves an enclosing string opaque. Once `$[`
343
+ * enters code, statement-leading comments are real again, so brackets inside
344
+ * them cannot close or extend the substitution. The returned range starts at
345
+ * `open` and retains that local mask for the readers of the body.
346
+ */
347
+ export function scanStringInterpolation(
348
+ text: string,
349
+ open: number,
350
+ limit: number = text.length,
351
+ ): { end: number; range: MaskedRange } {
352
+ return scanStringInterpolationWithin(
353
+ text,
354
+ open,
355
+ limit,
356
+ MAX_STRING_FRAME_DEPTH - 1,
357
+ );
358
+ }
359
+
360
+ function scanStringInterpolationWithin(
361
+ text: string,
362
+ open: number,
363
+ limit: number,
364
+ frameBudget: number,
365
+ ): { end: number; range: MaskedRange } {
366
+ const local = text.slice(open, limit);
367
+ // Masking must find each code-level string boundary comment-aware. Reuse
368
+ // those answers below instead of recursively scanning the same strings again.
369
+ const quotedEnds = new Map<number, number>();
370
+ const range: MaskedRange = {
371
+ text: local,
372
+ masked: maskUncached(local, frameBudget, quotedEnds),
373
+ start: 0,
374
+ end: local.length,
375
+ };
376
+ if (frameBudget < 1) return { end: limit, range };
377
+ const frames: string[] = ["["];
378
+ for (let i = 1; i < range.end; i++) {
379
+ const c = range.masked[i];
380
+ if (c === '"') {
381
+ const quotedEnd = quotedEnds.get(i);
382
+ if (quotedEnd === undefined) return { end: limit, range };
383
+ i = quotedEnd - 1;
384
+ continue;
385
+ }
386
+ if (c === "[" || c === "(" || c === "{") {
387
+ if (frames.length >= frameBudget) return { end: limit, range };
388
+ frames.push(c);
389
+ continue;
390
+ }
391
+ if (c !== "]" && c !== ")" && c !== "}") continue;
392
+ const want = c === "]" ? "[" : c === ")" ? "(" : "{";
393
+ if (frames.at(-1) !== want) continue;
394
+ frames.pop();
395
+ if (frames.length === 0) return { end: open + i, range };
396
+ }
397
+ return { end: limit, range };
398
+ }
399
+
400
+ /**
401
+ * The sub-range `[start, end)` of `parent`, in `parent`'s own coordinates.
402
+ *
403
+ * Offsets must be JS string indices — for a statement span that means the
404
+ * document was ASCII ({@link SegmentResult.ascii}), which is the only case in
405
+ * which an analyzed-byte offset is one.
406
+ */
407
+ export function nestedRange(
408
+ parent: MaskedRange,
409
+ start: number,
410
+ end: number,
411
+ ): MaskedRange {
412
+ return {
413
+ text: parent.text,
414
+ masked: parent.masked,
415
+ start: parent.start + start,
416
+ end: parent.start + end,
417
+ ...(parent.coordinateIdentity ? { coordinateIdentity: true as const } : {}),
418
+ };
419
+ }
420
+
421
+ /**
422
+ * The range `[start, end)` of the same document, in ABSOLUTE offsets.
423
+ *
424
+ * The counterpart to {@link nestedRange}, for a reader that is already working
425
+ * in the document's own offsets rather than in a parent's.
426
+ */
427
+ export function rangeAt(
428
+ parent: MaskedRange,
429
+ start: number,
430
+ end: number,
431
+ ): MaskedRange {
432
+ return {
433
+ text: parent.text,
434
+ masked: parent.masked,
435
+ start,
436
+ end,
437
+ ...(parent.coordinateIdentity ? { coordinateIdentity: true as const } : {}),
438
+ };
439
+ }
440
+
441
+ /** The comment mask of what this range covers. */
442
+ export function rangeMask(range: MaskedRange): string {
443
+ return range.masked.slice(range.start, range.end);
444
+ }
445
+
446
+ /**
447
+ * {@link rangeMask}, tolerating no range — for readers that take one OPTIONALLY
448
+ * and fall back to masking their own text: `maskOf(range) ?? maskComments(text)`.
449
+ */
450
+ export function maskOf(range: MaskedRange | undefined): string | undefined {
451
+ return range === undefined ? undefined : rangeMask(range);
452
+ }
453
+
454
+ /**
455
+ * The mask itself. Pure, so the memo above cannot change an answer — only how
456
+ * often it is computed.
457
+ *
458
+ * The walk asks for the mask of the SAME statement several times over: the
459
+ * structural-defect scan, the scope-block scan and the bracket scan each take
460
+ * one, and the bare-directive test takes another over the trimmed spelling.
461
+ * Each of those re-ran this scan AND, through `braceStartsStatements`, the
462
+ * statement index behind it — and a statement's text carries its whole nested
463
+ * subtree, so the repetition was paid once per enclosing level (#322).
464
+ */
465
+ function maskUncached(
466
+ original: string,
467
+ frameBudget: number = MAX_STRING_FRAME_DEPTH,
468
+ quotedEnds?: Map<number, number>,
469
+ ): string {
470
+ // Masked output is built from whole slices, and only once a comment is
471
+ // actually found: comment-free input is returned as the SAME string object.
472
+ // `split("")` allocated one string per character of every call — 16 walks
473
+ // over a 64 KiB document made a million of them — where a document's comment
474
+ // runs are a handful of slices (#313).
475
+ let pieces: string[] | undefined;
476
+ let copied = 0;
192
477
  const contexts: { char: "{" | "[" | "("; statements: boolean }[] = [];
193
478
  let atLead = true;
194
479
  let cont: Continuation = "none";
@@ -211,7 +496,14 @@ export function maskComments(original: string): string {
211
496
  }
212
497
  if (c === '"') {
213
498
  atLead = false;
214
- i = scanQuotedString(original, i).end - 1;
499
+ const end = scanQuotedStringWithin(
500
+ original,
501
+ i,
502
+ original.length,
503
+ frameBudget - contexts.length,
504
+ ).end;
505
+ quotedEnds?.set(i, end);
506
+ i = end - 1;
215
507
  continue; // i rests on the closing quote (or past end)
216
508
  }
217
509
  // H4 is unchanged and H5 only ADDS the immediate-line-start case: an
@@ -219,10 +511,14 @@ export function maskComments(original: string): string {
219
511
  // newline + ` # c`) and content when it is not (`:put a\` + newline +
220
512
  // ` # x`, which the device classes `error`) — exactly what `atLead` says.
221
513
  if (c === "#" && (atLead || contComment)) {
222
- while (i < original.length && original[i] !== "\n") {
223
- out[i] = " ";
224
- i++;
225
- }
514
+ const commentStart = i;
515
+ while (i < original.length && original[i] !== "\n") i++;
516
+ if (pieces === undefined) pieces = [];
517
+ pieces.push(
518
+ original.slice(copied, commentStart),
519
+ " ".repeat(i - commentStart),
520
+ );
521
+ copied = i;
226
522
  if (contComment) {
227
523
  // The comment line does not end the statement: step over its newline
228
524
  // (the loop's i++) with the continuation still pending.
@@ -263,7 +559,9 @@ export function maskComments(original: string): string {
263
559
  atLead = contexts.at(-1)?.statements ?? true;
264
560
  } else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
265
561
  }
266
- return out.join("");
562
+ if (pieces === undefined) return original;
563
+ pieces.push(original.slice(copied));
564
+ return pieces.join("");
267
565
  }
268
566
 
269
567
  /**
@@ -282,23 +580,76 @@ const MAX_CONTAINER_DEPTH = 256;
282
580
  /**
283
581
  * Segment `original` into top-level statements. Spans are analyzed-byte offsets
284
582
  * (see module header); `text` is the original substring for each span.
583
+ *
584
+ * A statement-bearing `range` may anchor the same `original` text in its
585
+ * enclosing document. On ASCII input, structural lookback can reuse that
586
+ * document's statement index instead of indexing every nested body (#322).
587
+ * The scan still reads the original bytes, including comments: replacing them
588
+ * with the mask would lose comment spans and H5 continuation state. Non-ASCII
589
+ * input, or a body opener skipped by the raw document index (a quote inside an
590
+ * earlier comment), keeps the standalone analyzed-byte scan.
285
591
  */
286
- export function segmentStatements(original: string): SegmentResult {
287
- const analysis = analyzeCoordinates(original);
592
+ export function segmentStatements(
593
+ original: string,
594
+ range?: MaskedRange,
595
+ ): SegmentResult {
596
+ return segmentStatementsInternal(original, range, false);
597
+ }
598
+
599
+ /** Segment one known scope body while treating its child scopes as opaque. */
600
+ export function segmentScopeBody(
601
+ original: string,
602
+ range: MaskedRange,
603
+ ): SegmentResult {
604
+ return segmentStatementsInternal(
605
+ original,
606
+ range,
607
+ range.coordinateIdentity === true,
608
+ );
609
+ }
610
+
611
+ function segmentStatementsInternal(
612
+ original: string,
613
+ range: MaskedRange | undefined,
614
+ opaqueChildScopes: boolean,
615
+ ): SegmentResult {
616
+ const identity =
617
+ range?.coordinateIdentity === true &&
618
+ range.end - range.start === original.length;
619
+ const analysis = identity ? undefined : analyzeCoordinates(original);
288
620
  // `analyzed` is pure ASCII, so a string built from it has index === byte.
289
- const ascii = new TextDecoder().decode(analysis.analyzed);
290
- const raw = scanAscii(ascii);
621
+ // When the INPUT was already ASCII the two are the same sequence, so the
622
+ // original is that string and the decode is skipped (#322).
623
+ const ascii =
624
+ analysis === undefined || analysis.ascii
625
+ ? original
626
+ : new TextDecoder().decode(analysis.analyzed);
627
+ const anchored =
628
+ (analysis === undefined || analysis.ascii) &&
629
+ range !== undefined &&
630
+ canRetainStatementIndex(range.text) &&
631
+ isIndexedStatementStart(range.text, range.start)
632
+ ? range
633
+ : undefined;
634
+ const raw = scanAscii(ascii, anchored, opaqueChildScopes);
291
635
  // Recover the human-readable `text` for each segment from the original.
292
636
  const segments = raw.segments.map((s) => ({
293
637
  ...s,
294
- text: originalSlice(analysis, s.start, s.end),
638
+ text:
639
+ analysis === undefined
640
+ ? original.slice(s.start, s.end)
641
+ : originalSlice(analysis, s.start, s.end),
295
642
  }));
296
643
  return {
297
644
  segments,
298
645
  comments: raw.comments,
646
+ ascii: analysis?.ascii ?? true,
299
647
  // Coordinate-pass regions first: they are facts about the input as
300
648
  // received, and they exist even when the scan finds nothing structural.
301
- defects: mergeDefects(coordinateDefects(analysis), raw.defects),
649
+ defects: mergeDefects(
650
+ analysis === undefined ? [] : coordinateDefects(analysis),
651
+ raw.defects,
652
+ ),
302
653
  };
303
654
  }
304
655
 
@@ -314,7 +665,8 @@ function originalSlice(
314
665
  /** UTF-16 offset in the original for an analyzed-byte boundary. */
315
666
  function utf16At(a: CoordinateAnalysis, byte: number): number {
316
667
  if (byte >= a.analyzed.length) return a.original.length;
317
- return runAtByte(a, byte).utf16Start;
668
+ // Identity on an all-ASCII input: one byte per code unit in both spaces.
669
+ return a.ascii ? byte : runAtByte(a, byte).utf16Start;
318
670
  }
319
671
 
320
672
  /** A located statement before its `text` is recovered from the original. */
@@ -366,15 +718,26 @@ interface Frame {
366
718
  * flattening H7 containers inline. Returns spans only; `segmentStatements`
367
719
  * recovers each `text` from the original afterward.
368
720
  */
369
- function scanAscii(ascii: string): {
721
+ function scanAscii(
722
+ ascii: string,
723
+ range?: MaskedRange,
724
+ opaqueChildScopes = false,
725
+ ): {
370
726
  segments: RawSegment[];
371
727
  comments: { start: number; end: number }[];
372
728
  defects: Defect[];
373
729
  } {
730
+ // Local indices remain local in the result; only index-backed questions use
731
+ // document coordinates. The floor prevents an enclosing head from becoming
732
+ // the head of the body being segmented.
733
+ const indexed = range?.text ?? ascii;
734
+ const structural = range?.masked ?? ascii;
735
+ const floor = range?.start ?? 0;
374
736
  const comments: { start: number; end: number }[] = [];
375
737
  const defects: Defect[] = [];
376
738
  const overDepth: number[] = [];
377
739
  let hardHashSeen = false;
740
+ let hardApostropheSeen = false;
378
741
  // H2 — every open bracket, for balance. Each frame carries WHERE it opened so
379
742
  // an `unclosed` defect can point at the opener rather than at the end of
380
743
  // input.
@@ -596,7 +959,12 @@ function scanAscii(ascii: string): {
596
959
  if (
597
960
  c === "#" &&
598
961
  !hardHashSeen &&
599
- hashStartsHardError(ascii, i, delimStack.at(-1)?.statements === false)
962
+ hashStartsHardError(
963
+ indexed,
964
+ floor + i,
965
+ delimStack.at(-1)?.statements === false,
966
+ floor,
967
+ )
600
968
  ) {
601
969
  defects.push(defectAt("invalid-hash", i, "#"));
602
970
  hardHashSeen = true;
@@ -607,6 +975,31 @@ function scanAscii(ascii: string): {
607
975
  continue;
608
976
  }
609
977
 
978
+ // #355 — `'` is NOT a RouterOS token: not a string delimiter, and not a
979
+ // legal bare byte. `:put [:parse]` rejects `name=don't`, `name=don't'`,
980
+ // `name='abc'` and `:put 'abc'` byte-identically on 7.21.5 / 7.23.5 /
981
+ // 7.24.2 / 7.25beta3, so this is a lexical rule and not a quote-balance
982
+ // one — the BALANCED forms are rejected too, and only `"` opens a string.
983
+ //
984
+ // Only the FIRST apostrophe is recorded, for the same reason as the hard
985
+ // hash above and grounded the same way: the device reports one column and
986
+ // it is always the first apostrophe (`name=don't'` reports column 30, the
987
+ // one at `don|'t`, not the trailing one), because classification stops
988
+ // there. A second occurrence is not an independent finding.
989
+ //
990
+ // Apostrophes inside a `"`-string or a comment never reach this branch:
991
+ // both runs are consumed whole above (H3 and H4/H5), which is why
992
+ // `comment="it's here"` stays legal with no special case here.
993
+ if (c === "'" && !hardApostropheSeen) {
994
+ defects.push(defectAt("invalid-apostrophe", i, "'"));
995
+ hardApostropheSeen = true;
996
+ f.structurallyInvalid = true;
997
+ ensureStmt(f, i);
998
+ f.atLead = false;
999
+ i++;
1000
+ continue;
1001
+ }
1002
+
610
1003
  // H3 — string.
611
1004
  if (c === '"') {
612
1005
  ensureStmt(f, i);
@@ -657,9 +1050,24 @@ function scanAscii(ascii: string): {
657
1050
  } else {
658
1051
  ensureStmt(f, i);
659
1052
  const enclosing = delimStack.at(-1)?.statements ?? true;
1053
+ if (
1054
+ opaqueChildScopes &&
1055
+ c === "{" &&
1056
+ atContainerLevel() &&
1057
+ scopeNameFromMasked(structural, floor + i, floor) !== null
1058
+ ) {
1059
+ const end = matchingBraceEnd(structural, floor + i);
1060
+ if (end !== undefined && end < floor + ascii.length) {
1061
+ f.atLead = false;
1062
+ i = end - floor + 1;
1063
+ continue;
1064
+ }
1065
+ }
660
1066
  const statements =
661
1067
  c === "[" ||
662
- (c === "{" && enclosing && braceStartsStatements(ascii, i));
1068
+ (c === "{" &&
1069
+ enclosing &&
1070
+ braceStartsStatements(indexed, floor + i, floor));
663
1071
  delimStack.push({ char: c, at: i, statements });
664
1072
  f.atLead = statements;
665
1073
  }