@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,757 @@
1
+ /**
2
+ * Statement segmenter for `explain` (centrs canonicalizer).
3
+ *
4
+ * Ratified by the phase-0 lab, question Q1 (#185) and promoted from the
5
+ * throwaway probe `.scratch/explain-lab-segmenter.ts`. This is the first walker
6
+ * stage: a schema-free, device-free splitter of multi-statement RouterOS input
7
+ * into top-level statements, matching `(evl …)` sibling adjacency in `:parse`
8
+ * IL. It never throws; structural surprises are reported as located
9
+ * {@link Defect}s.
10
+ *
11
+ * Segmentation runs on the byte-count-preserving `analyzed` surface from
12
+ * `./coordinates.ts`, so a segment's `start`/`end` are **analyzed-byte offsets**
13
+ * (half-open, aligned with the coordinate contract and device highlight spans),
14
+ * while `text` is the **original** human-readable statement, recovered through
15
+ * the coordinate mapping. Because non-ASCII bytes are `SUB` in `analyzed` and
16
+ * `SUB` is none of the RouterOS delimiters, prose or names cannot corrupt the
17
+ * delimiter stack.
18
+ *
19
+ * The boundary rules (grounded from the documented scripting semantics before
20
+ * any scoring, so the disagreement bins meant something):
21
+ * H1 Statements separate on `;` and newline, at nesting depth 0 only.
22
+ * H2 Depth counts `{}`, `[]`, `()` uniformly; a newline inside any of them
23
+ * continues the statement.
24
+ * H3 `"` opens a string; `\` escapes the next char inside it; a string
25
+ * suppresses every separator and depth character. RouterOS has no
26
+ * single-quote string form, so `'` is an ordinary character. A string is
27
+ * not opaque to the NEXT quote, though: a `$[…]`/`$(…)` substitution
28
+ * inside it is code that may carry strings of its own, so where a string
29
+ * ends is decided by the one shared `scanQuotedString` (#199).
30
+ * H4 `#` starts a comment in statement-leading position (start of input, or
31
+ * the first non-space after a separator in a statement-bearing context)
32
+ * — and, per H5 below, at the immediate start of a line that a
33
+ * continuation carried into. A scope/stored-script brace starts such a
34
+ * context; an array brace or parenthesized expression does not, and an
35
+ * unquoted `#` there is a hard device error (#245). A real comment runs to
36
+ * end of line and produces no statement, so its apostrophes and braces
37
+ * cannot leak into the delimiter stack.
38
+ * H5 A backslash at end of line is a continuation: it does not separate. Its
39
+ * reach is wider than one line, and was measured on CHR 7.23.3 (#215):
40
+ * - blank and whitespace-only lines right after the `\` are part of the
41
+ * continuation (the device classes the whole run `escaped`);
42
+ * - a `#` at the IMMEDIATE start of such a line is a comment, and the
43
+ * comment line does not end the pending statement — `:local \` +
44
+ * newline + `# c` + newline + `foo 1` still declares `foo`;
45
+ * - after a continuation COMMENT line the reach is spent: the next
46
+ * blank line ENDS the statement (where directly after the `\` it
47
+ * would not), while ordinary content simply continues it;
48
+ * - a continuation never moves statement-leading position. `do={\` +
49
+ * newline + `:local x 1` still reads `:local` as the head, and an
50
+ * indented `#` is a comment there (lead) but content mid-statement.
51
+ * H6 Empty statements (`;;`, blank lines) produce nothing.
52
+ * H7 A statement that is only a `{…}` group (optionally behind a bare menu
53
+ * path) is a CONTAINER: IL flattens its children into siblings, so its
54
+ * children are segmented in its place.
55
+ *
56
+ * The scan is a single left-to-right pass (Q17, #190): one index-based walker
57
+ * with an explicit `frames` stack does H7 flattening inline — no per-container
58
+ * recursion and no re-scan of container bodies. Each open container is a frame
59
+ * whose inner statements accumulate in a buffer; on its closing `}` the frame
60
+ * either COMMITS (its buffered statements, behind an optional menu-prefix
61
+ * segment, replace the container) or is DISCARDED (an empty body, trailing
62
+ * content after the `}`, or an unclosed brace — the parent statement absorbs the
63
+ * whole `{…}` verbatim). This reproduces the recursive `containerOpen` +
64
+ * body re-scan contract the earlier version pinned, at O(n) time / O(depth)
65
+ * space with no stack-overflow surface.
66
+ *
67
+ * H7 stays depth-bounded even so. `explain` accepts untrusted editor/MCP input,
68
+ * so `MAX_CONTAINER_DEPTH` container frames is a hard cap: a `{` that would open
69
+ * a deeper container is treated as an opaque group instead, the innermost
70
+ * remainder stays one segment, and an `over-depth` defect is raised at that
71
+ * offset. With recursion gone the cap is now a resource guard (bounding the
72
+ * `frames` array and abstention granularity), not the stack-safety boundary it
73
+ * once was.
74
+ */
75
+
76
+ import {
77
+ analyzeCoordinates,
78
+ type CoordinateAnalysis,
79
+ coordinateDefects,
80
+ runAtByte,
81
+ } from "./coordinates.ts";
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";
85
+
86
+ /** One top-level statement located in analyzed-byte space. */
87
+ export interface Segment {
88
+ /** analyzed-byte offset, inclusive. */
89
+ start: number;
90
+ /** analyzed-byte offset, exclusive. */
91
+ end: number;
92
+ /** the original (human-readable) statement text, trimmed. */
93
+ text: string;
94
+ /** the separator that ENDED this statement, for the boundary taxonomy. */
95
+ terminator: ";" | "newline" | "eof";
96
+ /**
97
+ * true when the statement *looks* like pure menu navigation: a `/`-rooted
98
+ * path of path-shaped tokens with no `=`, quotes, brackets, or `$`. This is
99
+ * a SHAPE hint, not a verb claim — offline (schema-free) cannot tell a
100
+ * trailing verb (`/ip address print`) from a deeper menu (`/ip address`), so
101
+ * both are `menuOnly: true`. Q4/Q6 decide what it means. See `isMenuOnly`.
102
+ */
103
+ menuOnly: boolean;
104
+ }
105
+
106
+ /** The full segmentation of one input string. */
107
+ export interface SegmentResult {
108
+ segments: Segment[];
109
+ /** comment spans (analyzed-byte offsets), dropped from the statement stream. */
110
+ comments: { start: number; end: number }[];
111
+ /**
112
+ * Unbalanced delimiters and other structural surprises, each located; never
113
+ * a throw. Includes the two coordinate-pass classes (`bom`, `non-ascii`).
114
+ */
115
+ defects: Defect[];
116
+ }
117
+
118
+ const isSpace = (c: string): boolean => c === " " || c === "\t" || c === "\r";
119
+
120
+ /** Where a double-quoted string ends, and whether it was closed at all. */
121
+ export interface QuotedStringScan {
122
+ /** index just past the closing `"`, or `text.length` when unterminated. */
123
+ end: number;
124
+ closed: boolean;
125
+ }
126
+
127
+ /**
128
+ * Find the end of the double-quoted string that opens at `open` (H3).
129
+ *
130
+ * The one shared string skip for every structural scan in `explain`. A string is
131
+ * NOT opaque up to the next `"`: a `$[…]` or `$(…)` substitution inside it is
132
+ * CODE, and that code may open strings of its own —
133
+ * `:local a "$[[:parse "(\"x\")"]]"` is ONE string on the device (CHR 7.23.2
134
+ * `/console/inspect request=highlight` classes the following `#` line `comment`
135
+ * and both `$a` occurrences `variable-local`). A scanner that stops at the first
136
+ * nested `"` flips its quote phase and reads every later comment as string
137
+ * content; on the frozen 913-script corpus that hid 2,184 device-`comment` bytes
138
+ * across 7 files, and cost `symbols.ts` 3.1 points of precision before it grew
139
+ * the frame model this function now shares (#198/#199).
140
+ *
141
+ * Frames mirror `symbols.ts`: a `"` frame is string phase (`\` escapes the next
142
+ * character, `$[`/`$(` push a code frame), anything else is code phase (`"`
143
+ * opens a nested string, brackets nest, a mismatched close is content rather
144
+ * than a close — the same line `segment.ts` and `symbols.ts` take elsewhere).
145
+ * Iterative, so deeply nested input cannot overflow the stack. Escapes and
146
+ * comments inside the substitution are NOT interpreted here: the whole string,
147
+ * substitutions included, stays opaque to the caller — only its END moves.
148
+ *
149
+ * Frame depth is capped for the same reason `MAX_CONTAINER_DEPTH` is: `explain`
150
+ * accepts untrusted editor/MCP input. Past `MAX_STRING_FRAME_DEPTH` the scan
151
+ * stops and reports the string UNCLOSED — fail-closed, the direction every
152
+ * caller already handles (`unterminated-string` → `structuralDefect` →
153
+ * `unresolved`). Only an input that opens that many frames without closing them
154
+ * can reach it, which is malformed by construction; sequential substitutions
155
+ * (`"$[a]$[b]…"`) pop and never accumulate.
156
+ */
157
+ export function scanQuotedString(text: string, open: number): QuotedStringScan {
158
+ return scanQuotedStringShared(text, open);
159
+ }
160
+
161
+ /**
162
+ * How far a H5 `\<newline>` continuation still reaches (#215, CHR 7.23.3).
163
+ *
164
+ * "none" — no continuation pending.
165
+ * "escape" — inside the `\<newline>` run itself. Blank and whitespace-only
166
+ * lines stay inside it (the device classes them `escaped`).
167
+ * "comment" — the run was ended by a continuation COMMENT line, which does
168
+ * not end the pending statement but does spend the run's reach:
169
+ * a following blank line now terminates.
170
+ *
171
+ * `Continuation` and the `lineStart` flag beside it are the vocabulary the
172
+ * three parallel walkers (`maskComments`, `scanAscii`, `symbols.ts`) share, so
173
+ * a change to the rule can be made in the same shape in each. #199 was two of
174
+ * them drifting apart; this state is where the third one can.
175
+ */
176
+ export type Continuation = "none" | "escape" | "comment";
177
+
178
+ /**
179
+ * Blank out RouterOS comments so a later delimiter re-scan cannot be fooled by
180
+ * `#` text (a `}` or `[` inside a comment is not a real delimiter). A `#` starts
181
+ * a comment in statement-leading position (H4) or at the immediate start of a
182
+ * line a continuation carried into (H5). Statement-leading is contextual:
183
+ * scope/stored-script braces admit it, array/group expressions do not. Any
184
+ * intervening space or tab makes a continuation-line `#` content unless the
185
+ * surrounding statement context is still empty. Comments are opaque inside
186
+ * strings. Comment characters become spaces; length and every non-comment
187
+ * offset are preserved, so indices stay valid against the original and callers
188
+ * can slice the original for content. Idempotent.
189
+ */
190
+ export function maskComments(original: string): string {
191
+ const out = original.split("");
192
+ const contexts: { char: "{" | "[" | "("; statements: boolean }[] = [];
193
+ let atLead = true;
194
+ let cont: Continuation = "none";
195
+ let contLineStart = false;
196
+ for (let i = 0; i < original.length; i++) {
197
+ const c = original[i];
198
+ const inCont = cont !== "none";
199
+ const contComment = c === "#" && inCont && contLineStart;
200
+ if (inCont && !contComment) {
201
+ if (c === " " || c === "\t" || c === "\r") {
202
+ contLineStart = false;
203
+ } else if (c === "\n" && cont === "escape") {
204
+ contLineStart = true;
205
+ continue; // a blank line inside the `\` run is still the run
206
+ } else {
207
+ // Real content, or the blank line that spends a comment run's reach.
208
+ cont = "none";
209
+ contLineStart = false;
210
+ }
211
+ }
212
+ if (c === '"') {
213
+ atLead = false;
214
+ i = scanQuotedString(original, i).end - 1;
215
+ continue; // i rests on the closing quote (or past end)
216
+ }
217
+ // H4 is unchanged and H5 only ADDS the immediate-line-start case: an
218
+ // indented `#` is a comment when the statement is still empty (`do={\` +
219
+ // newline + ` # c`) and content when it is not (`:put a\` + newline +
220
+ // ` # x`, which the device classes `error`) — exactly what `atLead` says.
221
+ if (c === "#" && (atLead || contComment)) {
222
+ while (i < original.length && original[i] !== "\n") {
223
+ out[i] = " ";
224
+ i++;
225
+ }
226
+ if (contComment) {
227
+ // The comment line does not end the statement: step over its newline
228
+ // (the loop's i++) with the continuation still pending.
229
+ cont = "comment";
230
+ contLineStart = true;
231
+ continue;
232
+ }
233
+ i--; // let the loop advance onto the newline (or end)
234
+ continue;
235
+ }
236
+ if (
237
+ c === "\\" &&
238
+ (original[i + 1] === "\n" ||
239
+ (original[i + 1] === "\r" && original[i + 2] === "\n"))
240
+ ) {
241
+ // H5 leaves `atLead` alone: `do={\` + newline + `:local x 1` still reads
242
+ // `:local` as the statement head on the device.
243
+ cont = "escape";
244
+ contLineStart = true;
245
+ i += original[i + 1] === "\r" ? 2 : 1;
246
+ continue;
247
+ }
248
+ if (c === "{" || c === "[" || c === "(") {
249
+ const enclosing = contexts.at(-1)?.statements ?? true;
250
+ const statements =
251
+ c === "[" ||
252
+ (c === "{" && enclosing && braceStartsStatements(original, i));
253
+ contexts.push({ char: c, statements });
254
+ // A bracket is a nested statement context too: `[# c\n:put 1]` starts
255
+ // with a real comment, while `[:put #value]` consumes the lead on `:put`
256
+ // and keeps the hash as that command's value.
257
+ atLead = statements;
258
+ } else if (c === "}" || c === "]" || c === ")") {
259
+ const want = c === "}" ? "{" : c === "]" ? "[" : "(";
260
+ if (contexts[contexts.length - 1]?.char === want) contexts.pop();
261
+ atLead = false;
262
+ } else if (c === ";" || c === "\n") {
263
+ atLead = contexts.at(-1)?.statements ?? true;
264
+ } else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
265
+ }
266
+ return out.join("");
267
+ }
268
+
269
+ /**
270
+ * Container-frame depth cap.
271
+ *
272
+ * Not a RouterOS grammar limit — an implementation guard derived from the Q17
273
+ * stress finding (the lab walker overflowed the JS stack around 32k nested
274
+ * containers). The single-pass scanner no longer recurses, so this is now a
275
+ * resource guard rather than a stack-safety boundary: 256 preserves far more
276
+ * nesting than normal scripts use while bounding the `frames` array. A `{` that
277
+ * would open frame 257 is kept as an opaque group and the analysis abstains with
278
+ * an `over-depth:<analyzed-byte-offset>` note.
279
+ */
280
+ const MAX_CONTAINER_DEPTH = 256;
281
+
282
+ /**
283
+ * Segment `original` into top-level statements. Spans are analyzed-byte offsets
284
+ * (see module header); `text` is the original substring for each span.
285
+ */
286
+ export function segmentStatements(original: string): SegmentResult {
287
+ const analysis = analyzeCoordinates(original);
288
+ // `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);
291
+ // Recover the human-readable `text` for each segment from the original.
292
+ const segments = raw.segments.map((s) => ({
293
+ ...s,
294
+ text: originalSlice(analysis, s.start, s.end),
295
+ }));
296
+ return {
297
+ segments,
298
+ comments: raw.comments,
299
+ // Coordinate-pass regions first: they are facts about the input as
300
+ // received, and they exist even when the scan finds nothing structural.
301
+ defects: mergeDefects(coordinateDefects(analysis), raw.defects),
302
+ };
303
+ }
304
+
305
+ /** Original substring for an analyzed-byte span (boundaries are char-aligned). */
306
+ function originalSlice(
307
+ a: CoordinateAnalysis,
308
+ start: number,
309
+ end: number,
310
+ ): string {
311
+ return a.original.slice(utf16At(a, start), utf16At(a, end));
312
+ }
313
+
314
+ /** UTF-16 offset in the original for an analyzed-byte boundary. */
315
+ function utf16At(a: CoordinateAnalysis, byte: number): number {
316
+ if (byte >= a.analyzed.length) return a.original.length;
317
+ return runAtByte(a, byte).utf16Start;
318
+ }
319
+
320
+ /** A located statement before its `text` is recovered from the original. */
321
+ type RawSegment = Omit<Segment, "text">;
322
+
323
+ /**
324
+ * One nesting level of the single-pass walker. The top level is `frames[0]`
325
+ * (`container: false`); each open CONTAINER `{…}` pushes a frame. Statements
326
+ * accumulate in the active (innermost) frame's `buffer`; a container's buffer is
327
+ * spliced into its parent on COMMIT and dropped on DISCARD.
328
+ */
329
+ interface Frame {
330
+ /** in-progress statement start in analyzed-byte space, or -1 (H6). */
331
+ stmtStart: number;
332
+ /** H4 — still before the first real token of the current statement? */
333
+ atLead: boolean;
334
+ /** H5 — how far the pending `\<newline>` continuation still reaches. */
335
+ cont: Continuation;
336
+ /** H5 — at the immediate start of a line the continuation carried into? */
337
+ contLineStart: boolean;
338
+ /**
339
+ * A mismatched close occurred in this statement/container. A container with
340
+ * a known structural defect must DISCARD rather than promote its buffered
341
+ * children as confident sibling statements.
342
+ */
343
+ structurallyInvalid: boolean;
344
+ /** Start/end of the prefix range already classified for H7. */
345
+ prefixScanStart: number;
346
+ prefixScanEnd: number;
347
+ /** Incremental equivalent of trimming and checking the H7 prefix. */
348
+ prefixSawNonSpace: boolean;
349
+ prefixValid: boolean;
350
+ /** statements collected here; the flattened output for a committed container. */
351
+ buffer: RawSegment[];
352
+ /** true for a container frame; false for the synthetic top level. */
353
+ container: boolean;
354
+ /** analyzed-byte index of the `{` that opened this container. */
355
+ openIndex: number;
356
+ /**
357
+ * Start of the statement this container belongs to — the menu prefix before
358
+ * the `{`. On DISCARD the parent statement resumes from here, so the whole
359
+ * `{…}` is absorbed verbatim, matching the old `containerOpen` = -1 path.
360
+ */
361
+ prefixStart: number;
362
+ }
363
+
364
+ /**
365
+ * Single left-to-right walk of a pure-ASCII string (byte === string index),
366
+ * flattening H7 containers inline. Returns spans only; `segmentStatements`
367
+ * recovers each `text` from the original afterward.
368
+ */
369
+ function scanAscii(ascii: string): {
370
+ segments: RawSegment[];
371
+ comments: { start: number; end: number }[];
372
+ defects: Defect[];
373
+ } {
374
+ const comments: { start: number; end: number }[] = [];
375
+ const defects: Defect[] = [];
376
+ const overDepth: number[] = [];
377
+ let hardHashSeen = false;
378
+ // H2 — every open bracket, for balance. Each frame carries WHERE it opened so
379
+ // an `unclosed` defect can point at the opener rather than at the end of
380
+ // input.
381
+ const delimStack: { char: string; at: number; statements: boolean }[] = [];
382
+ const top: Frame = {
383
+ stmtStart: -1,
384
+ atLead: true,
385
+ cont: "none",
386
+ contLineStart: false,
387
+ structurallyInvalid: false,
388
+ prefixScanStart: -1,
389
+ prefixScanEnd: -1,
390
+ prefixSawNonSpace: false,
391
+ prefixValid: true,
392
+ buffer: [],
393
+ container: false,
394
+ openIndex: -1,
395
+ prefixStart: -1,
396
+ };
397
+ const frames: Frame[] = [top];
398
+ const cur = (): Frame => frames[frames.length - 1] as Frame;
399
+
400
+ // At container level when every open bracket is a container `{` — i.e. no
401
+ // value bracket (`[ ( ` or a non-container `{`) is open. Only then do
402
+ // separators split and only then can a `{` open a new container. (Container
403
+ // frames only open at this level, so they always sit at the bottom of
404
+ // `delimStack`; plain brackets sit above.)
405
+ const atContainerLevel = (): boolean =>
406
+ delimStack.length === frames.length - 1;
407
+
408
+ const ensureStmt = (f: Frame, i: number): void => {
409
+ if (f.stmtStart < 0) f.stmtStart = i;
410
+ };
411
+
412
+ const resetPrefixScan = (f: Frame): void => {
413
+ f.prefixScanStart = -1;
414
+ f.prefixScanEnd = -1;
415
+ f.prefixSawNonSpace = false;
416
+ f.prefixValid = true;
417
+ };
418
+
419
+ const makeSegment = (
420
+ start: number,
421
+ end: number,
422
+ terminator: Segment["terminator"],
423
+ ): RawSegment | null => {
424
+ const raw = ascii.slice(start, end);
425
+ const trimmed = raw.trim();
426
+ if (trimmed.length === 0) return null; // H6
427
+ const lead = raw.length - raw.trimStart().length;
428
+ const s = start + lead;
429
+ return {
430
+ start: s,
431
+ end: s + trimmed.length,
432
+ terminator,
433
+ menuOnly: isMenuOnly(trimmed),
434
+ };
435
+ };
436
+
437
+ const flush = (
438
+ f: Frame,
439
+ end: number,
440
+ terminator: Segment["terminator"],
441
+ ): void => {
442
+ if (f.stmtStart < 0) return;
443
+ const s = makeSegment(f.stmtStart, end, terminator);
444
+ f.stmtStart = -1;
445
+ resetPrefixScan(f);
446
+ if (s) f.buffer.push(s);
447
+ // The synthetic top frame can recover at a real statement boundary. A
448
+ // container stays poisoned until it closes so a defect in one buffered
449
+ // child cannot make later children look independently trustworthy.
450
+ if (!f.container) f.structurallyInvalid = false;
451
+ };
452
+
453
+ // A container prefix is empty or a bare `/`-menu path with no `=[($"` — the
454
+ // same test the old `containerOpen` applied to the group's prefix.
455
+ const validPrefix = (f: Frame, start: number, end: number): boolean => {
456
+ if (f.prefixScanStart !== start || f.prefixScanEnd > end) {
457
+ resetPrefixScan(f);
458
+ f.prefixScanStart = start;
459
+ f.prefixScanEnd = start;
460
+ }
461
+ if (!f.prefixValid) {
462
+ f.prefixScanEnd = end;
463
+ return false;
464
+ }
465
+ for (let i = f.prefixScanEnd; i < end; i++) {
466
+ const c = ascii[i] as string;
467
+ if (!f.prefixSawNonSpace) {
468
+ if (c.trim().length === 0) continue;
469
+ f.prefixSawNonSpace = true;
470
+ if (c !== "/") {
471
+ f.prefixValid = false;
472
+ break;
473
+ }
474
+ }
475
+ if (/[=[($"]/.test(c)) {
476
+ f.prefixValid = false;
477
+ break;
478
+ }
479
+ }
480
+ f.prefixScanEnd = end;
481
+ return f.prefixValid;
482
+ };
483
+
484
+ const openContainer = (i: number): void => {
485
+ const parent = cur();
486
+ // prefixStart = the parent statement's start, or the `{` itself when the
487
+ // container has no prefix (so a DISCARD absorbs from the `{`).
488
+ const prefixStart = parent.stmtStart >= 0 ? parent.stmtStart : i;
489
+ delimStack.push({ char: "{", at: i, statements: true });
490
+ frames.push({
491
+ stmtStart: -1,
492
+ atLead: true,
493
+ cont: "none",
494
+ contLineStart: false,
495
+ structurallyInvalid: parent.structurallyInvalid,
496
+ prefixScanStart: -1,
497
+ prefixScanEnd: -1,
498
+ prefixSawNonSpace: false,
499
+ prefixValid: true,
500
+ buffer: [],
501
+ container: true,
502
+ openIndex: i,
503
+ prefixStart,
504
+ });
505
+ };
506
+
507
+ // A container's `}` ends the parent statement (so the group is a real trailing
508
+ // container) when the next non-blank char is a separator, end of input, or the
509
+ // `}` that closes an enclosing container. Any other trailing content means the
510
+ // `}` was mid-statement — the old `containerOpen` = -1 case.
511
+ const trailingEndsStatement = (closeIndex: number): boolean => {
512
+ let j = closeIndex + 1;
513
+ while (j < ascii.length && isSpace(ascii[j] as string)) j++;
514
+ if (j >= ascii.length) return true;
515
+ const n = ascii[j];
516
+ if (n === ";" || n === "\n") return true;
517
+ if (n === "}") return cur().container; // closes the parent container
518
+ return false;
519
+ };
520
+
521
+ const closeContainer = (i: number): void => {
522
+ const frame = frames.pop() as Frame;
523
+ // Flush the body's last statement. Its terminator is "eof" — the end of
524
+ // the container body, matching the old per-body scan; the `}` itself is
525
+ // not a statement terminator token (only `;`/newline/actual EOF are).
526
+ flush(frame, i, "eof");
527
+ const parent = cur();
528
+ if (
529
+ !frame.structurallyInvalid &&
530
+ frame.buffer.length > 0 &&
531
+ trailingEndsStatement(i)
532
+ ) {
533
+ // COMMIT: menu-prefix segment (if any), then the buffered body, replace
534
+ // the container in the parent stream; the parent statement is consumed.
535
+ const prefix = makeSegment(frame.prefixStart, frame.openIndex, "newline");
536
+ if (prefix) parent.buffer.push(prefix);
537
+ for (const s of frame.buffer) parent.buffer.push(s);
538
+ parent.stmtStart = -1;
539
+ resetPrefixScan(parent);
540
+ } else {
541
+ // DISCARD: empty body or trailing content — the parent statement absorbs
542
+ // the whole `{…}` verbatim by resuming from the container's prefix.
543
+ parent.stmtStart = frame.prefixStart;
544
+ parent.structurallyInvalid ||= frame.structurallyInvalid;
545
+ }
546
+ parent.atLead = false;
547
+ };
548
+
549
+ let i = 0;
550
+ while (i < ascii.length) {
551
+ const c = ascii[i] as string;
552
+ const f = cur();
553
+
554
+ // H4/H5 — a comment is statement-leading, or at the immediate start of a
555
+ // line a continuation carried into. Both are decided from state as it
556
+ // stands BEFORE the continuation bookkeeping below, so an indented `#`
557
+ // mid-continuation stays content.
558
+ const inCont = f.cont !== "none";
559
+ const contComment = c === "#" && inCont && f.contLineStart;
560
+ if (c === "#" && (f.atLead || contComment)) {
561
+ const nl = ascii.indexOf("\n", i);
562
+ const end = nl === -1 ? ascii.length : nl;
563
+ comments.push({ start: i, end });
564
+ if (contComment) {
565
+ // The comment line does not end the statement — step over its newline
566
+ // rather than letting the separator branch flush there.
567
+ f.cont = "comment";
568
+ f.contLineStart = true;
569
+ i = nl === -1 ? end : end + 1;
570
+ } else {
571
+ i = end;
572
+ }
573
+ continue;
574
+ }
575
+
576
+ if (inCont) {
577
+ if (c === " " || c === "\t" || c === "\r") {
578
+ f.contLineStart = false;
579
+ } else if (c === "\n" && f.cont === "escape") {
580
+ // A blank line inside the `\` run is still the run: no flush.
581
+ f.contLineStart = true;
582
+ i++;
583
+ continue;
584
+ } else {
585
+ // Real content, or the blank line that spends a comment run's reach
586
+ // and so falls through to the separator branch below.
587
+ f.cont = "none";
588
+ f.contLineStart = false;
589
+ }
590
+ }
591
+
592
+ // #245 — once the device reaches an unquoted hash that cannot be a
593
+ // statement-leading comment or value, highlight marks that byte `error` and
594
+ // stops. Record only that first hard hash, then keep scanning solely to
595
+ // recover balanced spans without pretending later hashes were classified.
596
+ if (
597
+ c === "#" &&
598
+ !hardHashSeen &&
599
+ hashStartsHardError(ascii, i, delimStack.at(-1)?.statements === false)
600
+ ) {
601
+ defects.push(defectAt("invalid-hash", i, "#"));
602
+ hardHashSeen = true;
603
+ f.structurallyInvalid = true;
604
+ ensureStmt(f, i);
605
+ f.atLead = false;
606
+ i++;
607
+ continue;
608
+ }
609
+
610
+ // H3 — string.
611
+ if (c === '"') {
612
+ ensureStmt(f, i);
613
+ f.atLead = false;
614
+ const str = scanQuotedString(ascii, i);
615
+ if (!str.closed) {
616
+ // The region is the whole unterminated run — from the opening quote
617
+ // to where the scan gave up — not just the quote. A consumer
618
+ // highlighting the defect wants the text that is swallowed by it.
619
+ defects.push({ code: "unterminated-string", start: i, end: str.end });
620
+ }
621
+ i = str.end;
622
+ continue;
623
+ }
624
+
625
+ // H5 — line continuation.
626
+ if (
627
+ c === "\\" &&
628
+ (ascii[i + 1] === "\n" ||
629
+ (ascii[i + 1] === "\r" && ascii[i + 2] === "\n"))
630
+ ) {
631
+ // `atLead` deliberately survives: `do={\` + newline + `:local x 1` still
632
+ // reads `:local` as the statement head on the device (#215).
633
+ ensureStmt(f, i);
634
+ f.cont = "escape";
635
+ f.contLineStart = true;
636
+ i += ascii[i + 1] === "\r" ? 3 : 2;
637
+ continue;
638
+ }
639
+
640
+ // H2/H7 — opens. A `{` at container level with a valid prefix opens a
641
+ // container frame (or, past the cap, abstains); everything else is a plain
642
+ // group that only tracks balance and suppresses separators.
643
+ if (c === "{" || c === "[" || c === "(") {
644
+ if (
645
+ c === "{" &&
646
+ atContainerLevel() &&
647
+ validPrefix(f, f.stmtStart >= 0 ? f.stmtStart : i, i)
648
+ ) {
649
+ if (frames.length - 1 < MAX_CONTAINER_DEPTH) {
650
+ openContainer(i);
651
+ } else {
652
+ overDepth.push(i);
653
+ ensureStmt(f, i);
654
+ delimStack.push({ char: "{", at: i, statements: true });
655
+ f.atLead = true;
656
+ }
657
+ } else {
658
+ ensureStmt(f, i);
659
+ const enclosing = delimStack.at(-1)?.statements ?? true;
660
+ const statements =
661
+ c === "[" ||
662
+ (c === "{" && enclosing && braceStartsStatements(ascii, i));
663
+ delimStack.push({ char: c, at: i, statements });
664
+ f.atLead = statements;
665
+ }
666
+ i++;
667
+ continue;
668
+ }
669
+
670
+ // H2 — closes.
671
+ if (c === "}" || c === "]" || c === ")") {
672
+ const want = c === "}" ? "{" : c === "]" ? "[" : "(";
673
+ if (delimStack[delimStack.length - 1]?.char === want) {
674
+ const isContainerClose =
675
+ c === "}" &&
676
+ frames.length > 1 &&
677
+ delimStack.length === frames.length - 1;
678
+ delimStack.pop();
679
+ if (isContainerClose) {
680
+ closeContainer(i);
681
+ } else {
682
+ ensureStmt(f, i);
683
+ f.atLead = false;
684
+ }
685
+ } else {
686
+ defects.push(defectAt("unbalanced-close", i, c));
687
+ ensureStmt(f, i);
688
+ f.atLead = false;
689
+ f.structurallyInvalid = true;
690
+ }
691
+ i++;
692
+ continue;
693
+ }
694
+
695
+ // H1 — separators. They END a statement only at container level, but
696
+ // restore statement-leading position only in a statement-bearing context.
697
+ // Array and parenthesized expression members do not admit bare `#` comments.
698
+ if (c === ";" || c === "\n") {
699
+ if (atContainerLevel()) flush(f, i, c === ";" ? ";" : "newline");
700
+ f.atLead = delimStack.at(-1)?.statements ?? true;
701
+ i++;
702
+ continue;
703
+ }
704
+
705
+ if (isSpace(c)) {
706
+ i++;
707
+ continue;
708
+ }
709
+
710
+ ensureStmt(f, i);
711
+ f.atLead = false;
712
+ i++;
713
+ }
714
+
715
+ // End of input: any still-open container never closed, so it never flattens —
716
+ // unwind each as a DISCARD (outermost open container's prefix wins), then flush
717
+ // the surviving top-level statement.
718
+ while (frames.length > 1) {
719
+ const frame = frames.pop() as Frame;
720
+ const parent = cur();
721
+ parent.stmtStart = frame.prefixStart;
722
+ parent.atLead = false;
723
+ }
724
+ flush(top, ascii.length, "eof");
725
+ if (delimStack.length > 0) {
726
+ // One defect per still-open delimiter, each pointing at its OPENER —
727
+ // "something is unclosed" is not actionable, "the `{` at byte 41 is
728
+ // unclosed" is.
729
+ for (const d of delimStack)
730
+ defects.push(defectAt("unclosed", d.at, d.char));
731
+ }
732
+
733
+ return {
734
+ segments: top.buffer,
735
+ comments,
736
+ defects: [
737
+ ...defects,
738
+ // No `detail`: `symbols.ts` and `pathresolve.ts` emit this class without
739
+ // one, and `mergeDefects` keys on it, so a `"{"` here would make the same
740
+ // event fail to de-duplicate across analyzers.
741
+ ...overDepth.map((offset) => defectAt("over-depth", offset)),
742
+ ],
743
+ };
744
+ }
745
+
746
+ /**
747
+ * A menu-navigation SHAPE: a `/`-rooted path of path-shaped tokens (letters,
748
+ * digits, space, and `/_.-`) with no `=`, quotes, brackets, or `$` — e.g.
749
+ * `/ip address`, `/interface`, `/`. It is deliberately shape-only: offline
750
+ * cannot tell a trailing verb (`/ip address print`) from a deeper menu without
751
+ * a schema, so `print` still reads as menu-shaped here. A HINT for Q4/Q6, not a
752
+ * claim that no verb is present.
753
+ */
754
+ function isMenuOnly(trimmed: string): boolean {
755
+ if (!trimmed.startsWith("/")) return false;
756
+ return /^\/[A-Za-z0-9 /_.-]*$/.test(trimmed);
757
+ }