@tikoci/centrs 0.1.3 → 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 (90) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/GLOSSARY.txt +213 -0
  3. package/LICENSE +21 -0
  4. package/README.md +36 -8
  5. package/commands/AGENTS.md +7 -1
  6. package/commands/api/README.md +19 -18
  7. package/commands/api/examples.md +15 -0
  8. package/commands/btest/README.md +21 -32
  9. package/commands/check/README.md +6 -1
  10. package/commands/discover/README.md +5 -11
  11. package/commands/execute/README.md +41 -29
  12. package/commands/execute/examples.md +41 -3
  13. package/commands/explain/README.md +1716 -0
  14. package/commands/explain/examples.md +606 -0
  15. package/commands/retrieve/README.md +25 -27
  16. package/commands/retrieve/examples.md +16 -0
  17. package/commands/transfer/README.md +28 -20
  18. package/commands/transfer/examples.md +29 -0
  19. package/docs/CLI.md +353 -0
  20. package/docs/CONSTITUTION.md +66 -15
  21. package/docs/MATRIX.md +38 -1
  22. package/docs/errors/README.md +3 -0
  23. package/docs/errors/quickchr/machine-not-found.md +22 -0
  24. package/docs/errors/quickchr/machine-stopped.md +20 -0
  25. package/docs/errors/quickchr/package-unavailable.md +27 -0
  26. package/docs/errors/quickchr/unsupported-via.md +35 -0
  27. package/docs/errors/quickchr/unsupported.md +35 -0
  28. package/docs/errors/routeros/version-unsupported.md +26 -0
  29. package/docs/errors/transport/provider-trust.md +29 -0
  30. package/docs/errors/usage/stdin-ignored.md +28 -0
  31. package/docs/errors/validation/unknown-attribute.md +37 -4
  32. package/package.json +45 -10
  33. package/src/api-fanout.ts +29 -14
  34. package/src/api.ts +97 -54
  35. package/src/cli/api.ts +25 -6
  36. package/src/cli/btest.ts +18 -2
  37. package/src/cli/execute.ts +25 -6
  38. package/src/cli/explain.ts +503 -0
  39. package/src/cli/retrieve.ts +33 -1
  40. package/src/cli/selection.ts +71 -11
  41. package/src/cli/settings.ts +2 -2
  42. package/src/cli/transfer.ts +23 -6
  43. package/src/cli.ts +15 -2
  44. package/src/core/envelope.ts +5 -2
  45. package/src/core/error-catalog.ts +40 -0
  46. package/src/core/preflight.ts +306 -0
  47. package/src/core/routeros-string.ts +24 -0
  48. package/src/errors.ts +1 -0
  49. package/src/execute-fanout.ts +29 -14
  50. package/src/execute.ts +114 -63
  51. package/src/explain/arg-tokens.ts +114 -0
  52. package/src/explain/args.ts +1403 -0
  53. package/src/explain/blocks.ts +132 -0
  54. package/src/explain/brace-slots.ts +143 -0
  55. package/src/explain/brace-tokens.ts +38 -0
  56. package/src/explain/catalog.ts +1345 -0
  57. package/src/explain/coordinates.ts +329 -0
  58. package/src/explain/defects.ts +217 -0
  59. package/src/explain/is-known-menu.ts +17 -0
  60. package/src/explain/menus.ts +664 -0
  61. package/src/explain/operator-tokens.ts +310 -0
  62. package/src/explain/operators.ts +458 -0
  63. package/src/explain/path-tokens.ts +150 -0
  64. package/src/explain/pathresolve.ts +1324 -0
  65. package/src/explain/quoted-string.ts +241 -0
  66. package/src/explain/scope-brace.ts +308 -0
  67. package/src/explain/segment.ts +757 -0
  68. package/src/explain/string-tokens.ts +67 -0
  69. package/src/explain/symbol-values.ts +520 -0
  70. package/src/explain/symbols.ts +1597 -0
  71. package/src/explain/token-ranges.ts +72 -0
  72. package/src/explain/transport.ts +497 -0
  73. package/src/explain/value-tokens.ts +80 -0
  74. package/src/explain/values.ts +357 -0
  75. package/src/explain/verbs.ts +64 -0
  76. package/src/explain/verbsplit.ts +813 -0
  77. package/src/explain/write.ts +815 -0
  78. package/src/explain.ts +2152 -0
  79. package/src/index.ts +206 -4
  80. package/src/protocols/mac-telnet-console.ts +61 -17
  81. package/src/resolver/facts.ts +1 -1
  82. package/src/resolver/index.ts +23 -4
  83. package/src/resolver/quickchr-provider.ts +597 -0
  84. package/src/resolver/selection.ts +99 -12
  85. package/src/resolver/service-endpoint.ts +155 -0
  86. package/src/resolver/target.ts +1 -1
  87. package/src/retrieve-fanout.ts +26 -12
  88. package/src/retrieve.ts +81 -18
  89. package/src/transfer-fanout.ts +42 -25
  90. package/src/transfer.ts +87 -44
@@ -0,0 +1,1597 @@
1
+ /**
2
+ * Symbol scope resolution for `explain` (centrs canonicalizer).
3
+ *
4
+ * Ratified by the phase-0 lab, question Q13 (#185) and promoted from the
5
+ * throwaway probe `.scratch/explain-lab-symbols.ts`. Q13 asks whether an
6
+ * offline, schema-free reader can reproduce the class the console assigns to
7
+ * every variable occurrence — `variable-local` / `-global` / `-auto` /
8
+ * `-parameter` / `-undefined` in `/console/inspect request=highlight`.
9
+ *
10
+ * **The ratified answer: yes for the lexical classes, and `undefined` must
11
+ * ABSTAIN.** The naive rule from the device's own documentation ("a bare
12
+ * identifier in expression position that resolves to nothing is undefined")
13
+ * scored 2.5% precision in the lab — 39 false positives against 1 true
14
+ * positive — because a bare word in expression position is far more often a
15
+ * menu field the console binds as a local, a bare query flag, or an operator
16
+ * keyword. The device emits `variable-undefined` in 9 of 580 clean corpus
17
+ * files. So this module says what a name is bound TO and never says that a
18
+ * bare word is unbound: `cls: null` is a first-class outcome, the same
19
+ * abstain-never-assert conclusion Q3 (absolute inner paths), Q6 (`ambiguous`)
20
+ * and Q16 (`unknown`) reached independently.
21
+ *
22
+ * Two console conflations are MATCHED rather than beaten:
23
+ * - an undeclared `$typo` is `parameter`, not an error — the console cannot
24
+ * tell "undeclared" from "supplied at call time", so S5 is the ANSWER, not
25
+ * a fallback. Flagging it would be wrong.
26
+ * - a bare identifier in `where`/filter position is a menu field bound as a
27
+ * local, and telling a real field from a typo needs the menu schema, so
28
+ * S8 abstains.
29
+ *
30
+ * **The three causes are not one gap, and this abstention is not permanent.**
31
+ * An operator keyword is purely lexical — and device-grounded, since `not` and
32
+ * `..` class `variable-undefined` while a doc-derived operator list would call
33
+ * them operators (#255) — so closing that cause needs no schema. A menu field
34
+ * and a bare query flag both need a per-path argument list, which `catalog.ts`
35
+ * deliberately is not ("never what a command accepts"); those are
36
+ * schema-shaped holes, closable by an enriched caller without a router. What
37
+ * genuinely requires a live device is narrower than the abstention: a
38
+ * `:global` persisted from a previous console session. Do not read `cls: null`
39
+ * here as "undefined is unreachable offline" (#263).
40
+ *
41
+ * Spans are **analyzed-byte offsets** (half-open), like `segment.ts` and the
42
+ * coordinate contract (#188): the ASCII normalization is byte-count-preserving,
43
+ * so an occurrence offset is also its offset in the device's highlight byte
44
+ * stream — which is what made scoring against that stream possible at all.
45
+ * An occurrence's `name` is the key it binds as, which is not always its span's
46
+ * text (S11 quotes); slice the span for display text.
47
+ *
48
+ * The rules, as ratified (S-numbers are the lab's):
49
+ * S1 `:local NAME` declares NAME `local` from the declaration to the end of
50
+ * the enclosing brace scope; an inner `:local` shadows an outer one. (The
51
+ * `:` is optional on every head below — see F3.)
52
+ * S2 `:global NAME` declares NAME `global`. (As ratified this said "visible
53
+ * for the remainder of the document regardless of brace nesting"; the
54
+ * device disagrees — see F7, which supersedes the extent half of it.)
55
+ * S3 `:foreach A[,B] in=… do={…}` / `:for I from=… do={…}` declare their
56
+ * loop variables `auto`. (Their EXTENT is F7's statement scope.)
57
+ * S4 A reference resolves to the nearest enclosing declaration and takes
58
+ * its class. (Within one scope, "nearest" is F7's claim, not the nearest
59
+ * preceding declaration.)
60
+ * S5 `$NAME` with no visible declaration is `parameter` (see above).
61
+ * S6 `$0`, `$1`, … are `parameter`.
62
+ * S7 A bare identifier in expression position is NOT asserted `undefined`;
63
+ * a resolvable one takes its binding's class, an unresolvable one
64
+ * abstains (the 2.5%-precision finding above).
65
+ * S8 Every bare identifier in a `where`/`find` filter region is a menu
66
+ * field — not only the `field=value` form — so it abstains.
67
+ * S9 `$NAME` inside a double-quoted string interpolates and is classified;
68
+ * inside a comment it is not.
69
+ * S10 `:set NAME` is a REFERENCE, never a declaration; on an undeclared name
70
+ * the device raises a hard error, which is not a class — so it abstains.
71
+ * S11 A quoted declaration name (`:global "set-dns"`) carries its class
72
+ * across the quotes, and the span includes them.
73
+ * S12 A word carrying a `:` or `/` sigil is a directive or menu-path
74
+ * segment, never a symbol.
75
+ * S13 Bare literals (`true`/`false`/`yes`/`no`/`nothing`, anything starting
76
+ * with a digit) are values.
77
+ * S14 The reference-name boundary is not the argument-name charset: `-` and
78
+ * `.` terminate a bare name.
79
+ * S15 A bare word right after `=` or `,` is a VALUE.
80
+ * S16 A bare word continuing a space-separated menu path is a path segment.
81
+ * S17 `or` / `and` / `not` / `in` are operators, not symbols.
82
+ * S18 `:onerror NAME` binds NAME `local` (in TWO places — see F7).
83
+ * S19 `-` always terminates a bare `$name`; hyphenated names are reachable
84
+ * only through the quoted `$"set-dns"` spelling. `${...}` is not a
85
+ * RouterOS reference form and is skipped rather than resolved. In
86
+ * expression position the remaining bytes are scanned independently, so
87
+ * `($a-b)` resolves both operands while `"$set-dns"` leaves `-dns` as
88
+ * string content.
89
+ *
90
+ * EIGHT behaviors go beyond the lab SUT. None is invented: the probe declared the
91
+ * first two and left them unmodeled (it was throwaway and depth-scoped), F2 is
92
+ * required verbatim by the ratified spec — `commands/explain/README.md`, "Symbol
93
+ * scopes follow RouterOS scope identity, not brace depth alone" — and F3/F4 are
94
+ * lexical facts the probe simply got wrong. Each was priced against the frozen
95
+ * corpus streams before being shipped, then device-verified per occurrence on
96
+ * CHR 7.23.2 (`bun run explain:probe:symbol-arms-score` +
97
+ * `bun run explain:probe:symbol-anchors`, the Q16 re-score method):
98
+ *
99
+ * F1 A `$[…]` / `$(…)` substitution inside a double-quoted string is CODE
100
+ * (Q3 grounded the same fact in `:parse` IL). A scan that treats it as
101
+ * string bytes flips its quote phase on the first nested string, which
102
+ * silently destroys every later binding. Modeling it moved holdout
103
+ * precision 96.75% → 99.85% and dev 93.06% → 98.50%.
104
+ * F3 The SIGIL on a scripting head is optional in both directions, but the
105
+ * head must be at STATEMENT START and carry at most ONE sigil character. Q2
106
+ * grounded the colon-less form for body-taking directives (`do {` ≡
107
+ * `:do {`); CHR 7.23.2 extends it to every head this module reads —
108
+ * `local foo 1`, `global foo 1`, `foreach i in={1} …`, `for i from=1 …`
109
+ * and `set foo 2` bind exactly like their `:` twins, and so does the `/`
110
+ * spelling, because these directives are also ROOT commands (`/global g 1`
111
+ * → `g` = `variable-global`, the same root vocabulary `write.ts` froze as
112
+ * `ROOT_CMDS`). A deeper path is untouched: in `/ip/local foo 1` the head
113
+ * word is `ip` and `local` is an `obj-inactive` menu item. Requiring the
114
+ * colon dropped those declarations and then reported every later use as a
115
+ * confident `parameter` — a false class on valid input, which is exactly
116
+ * what Q13's posture forbids. The two guards matter as much as the rule:
117
+ * `$fn local foo 1` makes `local` an ordinary word (device: `none`), and
118
+ * `//local foo 1` is a device error, so neither may declare.
119
+ * F5 A sigil RUN at statement start is a hard error — `//local`, `::put` and
120
+ * `:/local` all error at the second character and class every later byte
121
+ * `none` — so it fails closed with `bad-sigil:<offset>` like a malformed
122
+ * escape. Inside a VALUE a doubled slash is ordinary text (`url=http://…`
123
+ * and `comment=a//b` are clean). F8 extends the same stop past the
124
+ * statement lead.
125
+ * F4 A backslash in CODE is valid only before whitespace; before a newline it
126
+ * is the H5 CONTINUATION, so the statement head, the pending declaration
127
+ * and `declaredHere` all survive it. Treating it as a boundary lost
128
+ * `:local \<nl>foo 1` outright and reset the closure flag in
129
+ * `:local fn do=\<nl>{ … }`, leaking the outer scope into a body the device
130
+ * treats as a closure. Every other spelling (`\a`, `\$v`, `\\ `, `\{`) is a
131
+ * hard device error that classes the whole remainder `none`, so the walker
132
+ * records `bad-escape:<offset>` and stops there instead of reading
133
+ * `do=\{` as a closure. `escapeKind` is shared with the `do=` lookback on
134
+ * purpose: `do=\ {`, `do=\<tab>{` and `do=\<nl>{` are all `do={` on the
135
+ * device, and a scan that accepted an escape the lookback did not would
136
+ * silently stop treating the body as a closure. The continuation reaches
137
+ * past its own line — blank lines and an IMMEDIATE `#` comment line keep
138
+ * the statement pending, and statement-leading position survives both — so
139
+ * the walker carries the `Continuation` state `segment.ts` defines, in the
140
+ * same shape as the two scanners there (#215).
141
+ * F2 A NAMED-FUNCTION body (`:local`/`:global NAME do={…}`) is a CLOSURE:
142
+ * outer names read `parameter` inside it, and a global needs an in-body
143
+ * `:global NAME` re-import to read `global`. A control-flow `do={…}`
144
+ * (`:if`/`:foreach`/`:while`/`:do`) SHARES the enclosing scope; both
145
+ * spell `do={`, and the statement head is the distinguisher. The lab
146
+ * could not express this because a depth counter has no scope IDENTITY;
147
+ * this walker carries a scope stack, so it does. Modeling it moved
148
+ * holdout 99.85% → 99.97% and dev 98.50% → 99.73% at zero abstention
149
+ * cost. The ratified alternative — abstaining inside function bodies
150
+ * instead — measured 100.00% / 99.76% but at +1.7pp / +2.9pp abstention,
151
+ * so modeling won on the trade.
152
+ *
153
+ * F6 A `[…]` substitution is a nested STATEMENT context, and the two
154
+ * positions it can appear in differ in SCOPE EXTENT (#201, was K1). The
155
+ * claim K1 carried was measured from a mid-statement bracket and
156
+ * generalized; CHR 7.23.2 splits them:
157
+ *
158
+ * - a statement-LEADING `[` lets what it declares ESCAPE. `[:local b 1]`
159
+ * + `:put $b` reads `local`, and so does a `:global`. "Leading"
160
+ * survives indentation, a preceding `;`, and the lead of a `do={`
161
+ * body, and it composes: a lead bracket nested in a mid-statement one
162
+ * escapes INTO that bracket, not past it. (F6 called this TRANSPARENT.
163
+ * F7 corrects the mechanism: the bracket has its own scope and
164
+ * PROMOTES it outwards at the `]`. The two are indistinguishable until
165
+ * the enclosing scope already claims the name.)
166
+ * - a MID-statement `[` CONFINES what it declares — `:put [:local b 1]`
167
+ * + `:put $b` reads `parameter`. Globals are confined too, which is
168
+ * where K1 was most wrong (`:put [:global g 1]` does NOT escape; only
169
+ * the statement-leading spelling does).
170
+ *
171
+ * Either way the bracket SHARES the enclosing bindings — it is not a
172
+ * closure — and the `]` RESUMES the interrupted statement instead of
173
+ * starting one (`:put [:put 1] :local v 1` declares nothing), which is why
174
+ * the statement state is saved and restored rather than reset. A `$[…]` in
175
+ * a string is the same context and can never be leading. `(` is NOT this:
176
+ * the device reads a directive inside parens as an ordinary expression
177
+ * term (`(:local v 1)` classes the `:local` text itself
178
+ * `variable-undefined`), so no statement context opens there.
179
+ *
180
+ * Modeling it moved holdout abstention 4.53% → 4.25% and dev 6.27% →
181
+ * 5.40%, at no precision cost. Two rules had to move to the HEAD branch to
182
+ * hold that: a `/`-sigilled head opens a menu path (S16) and a
183
+ * `find`/`where` head opens a filter region (S8). Both were previously
184
+ * written only on the general word path, and were unreachable from a head
185
+ * only because a bracket used to inherit its head from the enclosing
186
+ * statement; without them `[find comment=$tag]`, the corpus's commonest
187
+ * bracket, stopped reporting its field. That is #201's "one rule, one
188
+ * implementation" exactly.
189
+ *
190
+ * F6 also composes with H4: the first position inside a `[` is
191
+ * statement-leading, so a `#` there opens a COMMENT — which then swallows
192
+ * the `]` and runs to the newline. CHR 7.23.2 agrees (`[# c<nl>…` classes
193
+ * `# c<nl>` `comment` in both spellings, while a mid-statement `#` is a
194
+ * hard `error`), so the declarations that follow such a `]` are real. The
195
+ * head-position `find` is device-grounded the same way
196
+ * (`/ip route<nl>find comment=x` reads `comment` `variable-local`).
197
+ *
198
+ * F7 The FIRST declaration written directly in a scope CLAIMS the name for
199
+ * that scope (#201, was the K3 known limit). A later redeclaration in the
200
+ * same scope binds nothing — its own span still reads its own head's
201
+ * class, but every reference keeps the first one's:
202
+ *
203
+ * :global v 1 / :local v 2 / :put $v -> the use is GLOBAL
204
+ * :local v 1 / :global v 2 / :put $v -> the use is LOCAL
205
+ *
206
+ * K3 read the first row as "an earlier `:global` outranks a later
207
+ * `:local`". The second row shows it is not a precedence between the
208
+ * classes at all. Probing the extent of the real rule overturned three
209
+ * more things, all measured on CHR 7.23.2 AND 7.24rc2, which agreed on
210
+ * every row of the deciding round:
211
+ *
212
+ * - A `:global` does NOT escape the `{…}` it was written in. S2 as
213
+ * ratified says it is visible for the remainder of the document;
214
+ * `:if (1=1) do={:global v 1}` + `:put $v` reads PARAMETER. `:global`
215
+ * and `:local` have IDENTICAL lexical visibility and differ only in
216
+ * the class they emit, so `bind` writes to the current scope and
217
+ * nowhere else. This is the only rule here the CORPUS reaches: it
218
+ * moved 4 dev occurrences, all onto the device's reading (a `:global`
219
+ * re-import inside a named-function body that used to leak out of it).
220
+ * - A statement-LEADING `[` PROMOTES rather than being transparent (see
221
+ * F6). Promotion is subject to the claim rule, which is the only thing
222
+ * that distinguishes the two: with `:global x 1` in force,
223
+ * `[:local x 2; :put $x]` reads the in-bracket use LOCAL and the use
224
+ * after the `]` GLOBAL.
225
+ * - Loop variables (S3) and the `:onerror` error variable (S18) bind in
226
+ * a STATEMENT scope that wraps the rest of the statement, body
227
+ * included. Neither the enclosing scope nor the body scope can hold
228
+ * them: an enclosing claim would swallow the binding (`:global i 1` +
229
+ * a loop over `i` still reads `auto` inside the body), and the body's
230
+ * own `:local i` has to be able to shadow it. `:onerror` ALSO claims
231
+ * the enclosing scope, which a loop variable does not — the device
232
+ * reads `$e` after `:onerror e …` as `local` but `$i` after
233
+ * `:foreach i …` as `parameter`.
234
+ *
235
+ * The control that made all of this measurable: `request=highlight` is
236
+ * purely LEXICAL. Inspecting `:global zzz 1` does not change how a later
237
+ * inspect reads `$zzz`, and `/system/script/environment` stays empty
238
+ * throughout, so none of these readings are probe-ordering artifacts.
239
+ *
240
+ * Modeling it moved dev precision 99.75% → 99.79% at unchanged abstention
241
+ * and unchanged `missed`, with holdout unchanged; a per-offset corpus diff
242
+ * against the pre-change module shows 4 changed occurrences on dev, all 4
243
+ * toward the device, none away, none dropped.
244
+ *
245
+ * F8 A sigil is a DEFECT in menu-PATH position, not only at the statement
246
+ * lead (#201, was the K2 known limit). `/ip//address print` is a hard
247
+ * device error exactly like `//local foo 1`, and the walker now stops on
248
+ * both. Three device readings fix the shape:
249
+ *
250
+ * - The region ENDS AT THE COMMAND, and the device does not end it at a
251
+ * verb. `/ip address zzzz //foo` is as clean as
252
+ * `/ip address print //foo`, while `/ip address //foo` — nothing
253
+ * having ended the path — errors. So the question is "is this word
254
+ * still a submenu?", which `menus.ts` (#207) answers; K2 read it as
255
+ * needing parser context that did not exist, when what it needed was
256
+ * the baked table.
257
+ * - WHICH BYTE errors depends on the sigil. A `/` is a legal separator
258
+ * when it is ADJACENT to the segment before it, so a doubled one errors
259
+ * on the second (`/ip//address` → offset 4); after a space it is
260
+ * already wrong, so `/ip /address` errors on the first (offset 4 too,
261
+ * for a different reason). A `:` is never legal in a path and errors on
262
+ * itself, doubled or not (`/ip:address` → offset 3).
263
+ * - A `:`-spelled head is a scripting directive and opens no path at all
264
+ * (`:put //foo`, `:put ::foo`, `:put :/foo` are clean), and so is a
265
+ * colon-less head the table does not know (`put //foo`).
266
+ *
267
+ * - A token that cannot be a path segment ENDS the region. A digit-led
268
+ * object name is the one that matters: `/ip 1 //ip/address` is CLEAN
269
+ * on the device (`1` is `obj-inactive`, every later byte `none`) and
270
+ * only the RUNTIME rejects it — "bad command name 1 (line 1 column
271
+ * 5)", at the `1`, never at the `//`. `$`, `"`, `(` and `[` there are
272
+ * hard errors on their own first byte, which this walker does not
273
+ * model, so it closes the region and stays silent rather than
274
+ * reporting a defect at the wrong offset.
275
+ *
276
+ * `menus.ts` being a FLOOR is the safe direction: an unlisted menu closes
277
+ * the region and the defect goes unreported, rather than a valid script
278
+ * being truncated. Measured that way — 91/97 device rows, **0 false
279
+ * positives and 0 wrong offsets** — and over the whole 913-script frozen
280
+ * corpus **no file where this stops and the device is clean**. The 6
281
+ * remaining rows are device errors this walker does not model at all (the
282
+ * "argument before a command" error, which lands on the `=`, and the four
283
+ * non-path tokens above), every one of them the silent direction. The
284
+ * corpus moves no occurrence: the 14 files it newly stops in are
285
+ * non-RouterOS snippets (YAML, log text) that the device already rejects
286
+ * earlier, so they were past a cliff already.
287
+ *
288
+ * The path-region guard came out of review. Without it the walker scored 7
289
+ * FALSE POSITIVES and 4 wrong offsets on those same rows — a defect stop on
290
+ * input the device's highlight reads clean, which is the one failure mode
291
+ * this rule may not have.
292
+ *
293
+ * Measured on the frozen split (`test/fixtures/explain/corpus-partition.json`,
294
+ * promoted in #186 — it is the leakage guard, and only scores taken on the
295
+ * SAME split are comparable to the ones below) against
296
+ * the per-occurrence highlight streams for 7.23.2 AND 7.24rc2. On 7.23.2:
297
+ * **holdout 99.89% precision on decided (6144/6151), 3.60% abstention, 19
298
+ * missed; dev 99.79% (8733/8751), 4.20%, 32 missed.** On 7.24rc2 the decided
299
+ * precision is unchanged (holdout 6144/6151; dev 8747/8765), with 3.63%/4.22%
300
+ * abstention and 19/30 missed.
301
+ *
302
+ * The scan is a single left-to-right pass with an explicit delimiter stack (no
303
+ * recursion, Q17 posture) and never throws; structural surprises land in
304
+ * `defects` with the same vocabulary the segmenter uses. Scope creation is capped
305
+ * at `MAX_SCOPE_DEPTH` so pathological nesting cannot make lookup unbounded.
306
+ */
307
+
308
+ import { analyzeCoordinates } from "./coordinates.ts";
309
+ import { type Defect, defectAt } from "./defects.ts";
310
+ import { isMenuPath } from "./menus.ts";
311
+ import { braceStartsStatements, hashStartsHardError } from "./scope-brace.ts";
312
+ import type { Continuation } from "./segment.ts";
313
+
314
+ /** The five variable classes the console assigns. */
315
+ export type SymbolClass =
316
+ | "local"
317
+ | "global"
318
+ | "auto"
319
+ | "parameter"
320
+ | "undefined";
321
+
322
+ /** The source-level job an occurrence performs. */
323
+ export type SymbolRole =
324
+ | "declaration"
325
+ | "binding"
326
+ | "assignment"
327
+ | "reference"
328
+ | "field";
329
+
330
+ /**
331
+ * Device highlight token-class name for each class, for callers comparing
332
+ * against `/console/inspect request=highlight` output.
333
+ *
334
+ * `undefined` is present for completeness of the device vocabulary: this
335
+ * resolver never emits it offline (the ratified Q13 answer), it abstains
336
+ * instead.
337
+ */
338
+ export const HIGHLIGHT_CLASS: Record<SymbolClass, string> = {
339
+ local: "variable-local",
340
+ global: "variable-global",
341
+ auto: "variable-auto",
342
+ parameter: "variable-parameter",
343
+ undefined: "variable-undefined",
344
+ };
345
+
346
+ /** One located semantic symbol occurrence. */
347
+ export interface SymbolOccurrence {
348
+ /** analyzed-byte offset, inclusive. */
349
+ start: number;
350
+ /** analyzed-byte offset, exclusive. */
351
+ end: number;
352
+ /**
353
+ * the symbol's name — the key it binds/resolves as, which is NOT always the
354
+ * span's text: a quoted declaration's span includes its quotes (S11). Slice
355
+ * `start`/`end` out of the original for display text. Names are read off the
356
+ * `analyzed` surface, so a non-ASCII byte in a name reads as the `SUB`
357
+ * placeholder.
358
+ */
359
+ name: string;
360
+ /** true when written with the `$` sigil. */
361
+ sigil: boolean;
362
+ /** true at a declaration/binding site, false otherwise. */
363
+ declaration: boolean;
364
+ /** Declaration/binding, assignment target, reference, or filter-field site. */
365
+ role: SymbolRole;
366
+ /**
367
+ * Result-local identities of the bindings this occurrence establishes or
368
+ * resolves to. Usually zero or one; `:onerror` establishes both its statement
369
+ * binding and (when unclaimed) an enclosing binding, so declarations may name
370
+ * two. Statement-leading bracket promotion preserves the original identity.
371
+ */
372
+ bindingIds: string[];
373
+ /** resolved class, or null where offline must abstain (S7/S8/S10). */
374
+ cls: SymbolClass | null;
375
+ /** why the resolver abstained, or how the reference was read. */
376
+ note?: string;
377
+ }
378
+
379
+ export interface SymbolAnalysis {
380
+ occurrences: SymbolOccurrence[];
381
+ /** Located structural surprises; never a throw. See `defects.ts`. */
382
+ defects: Defect[];
383
+ }
384
+
385
+ interface Binding {
386
+ /** Stable only within one `resolveSymbols` result. */
387
+ id: string;
388
+ cls: SymbolClass;
389
+ /** analyzed-byte offset from which the binding is visible. */
390
+ from: number;
391
+ }
392
+
393
+ interface Scope {
394
+ /** true for a named-function body: a closure boundary (F2). */
395
+ closure: boolean;
396
+ /**
397
+ * At most ONE binding per name: the FIRST declaration written directly in
398
+ * this scope CLAIMS the name and no later one in the same scope rebinds it
399
+ * (F7).
400
+ */
401
+ bindings: Map<string, Binding>;
402
+ }
403
+
404
+ /**
405
+ * Statement state saved at a `[`, restored at its `]` (F6).
406
+ *
407
+ * A bracket is a nested STATEMENT context, so the walk resets the statement
408
+ * bookkeeping on the way in; the enclosing statement has to survive that, since
409
+ * a `]` resumes it rather than starting a new one.
410
+ */
411
+ interface BracketFrame {
412
+ head: string | null;
413
+ filterDepth: number | null;
414
+ inMenuPath: boolean;
415
+ pendingSetTarget: boolean;
416
+ pendingErrVar: boolean;
417
+ pendingDecl: SymbolClass | null;
418
+ pendingLoopVars: boolean;
419
+ declaredHere: boolean;
420
+ headSigil: ":" | "/" | "";
421
+ cont: Continuation;
422
+ contLineStart: boolean;
423
+ /** the scope this bracket pushed, or null when the depth cap refused one. */
424
+ scope: Scope | null;
425
+ /** statement-LEADING: its claims are PROMOTED to the enclosing scope (F7). */
426
+ lead: boolean;
427
+ /** F8 — the enclosing statement's menu-path state. */
428
+ pathSegments: string[];
429
+ pathOpen: boolean;
430
+ }
431
+
432
+ /**
433
+ * F7 — `:foreach`/`:for` loop variables and the `:onerror` error variable bind
434
+ * in a scope of their own that wraps the rest of the STATEMENT, body included,
435
+ * and pops with it.
436
+ *
437
+ * Neither the enclosing scope nor the body scope can hold them: an enclosing
438
+ * claim would swallow the binding (`:global i 1` + a loop over `i` still reads
439
+ * `auto` inside the body), and the body's own `:local i` has to be able to
440
+ * shadow it (`:foreach i … do={:local i 9; :put $i}` reads `local`).
441
+ */
442
+ interface StatementScope {
443
+ scope: Scope;
444
+ /** delimiter depth of the binding head; the scope pops at a reset back here. */
445
+ depth: number;
446
+ }
447
+
448
+ /** Heads that declare a name in their next word. */
449
+ const DECL: Record<string, SymbolClass> = { local: "local", global: "global" };
450
+ /** Heads whose leading words are loop variables (S3). */
451
+ const LOOP_HEADS: ReadonlySet<string> = new Set(["foreach", "for"]);
452
+ /** Heads that bind an error variable in their next word (S18). */
453
+ const ERRVAR_HEADS: ReadonlySet<string> = new Set(["onerror"]);
454
+ /**
455
+ * Words that open a filter/query region where bare identifiers are menu fields
456
+ * (S8). `where` is the documented one; `find` takes the same shorthand.
457
+ */
458
+ const FILTER_WORDS: ReadonlySet<string> = new Set(["where", "find"]);
459
+ /** Operator keywords the console tokenizes as `syntax-meta`, not symbols (S17). */
460
+ const OPERATOR_WORDS: ReadonlySet<string> = new Set(["or", "and", "not", "in"]);
461
+
462
+ /**
463
+ * Scope-stack cap.
464
+ *
465
+ * Not a RouterOS limit — the same resource guard `segment.ts` applies to
466
+ * container frames. Beyond this depth a `{` still tracks as a delimiter but
467
+ * opens no new scope (inner declarations join the innermost one) and an
468
+ * `over-depth` defect is raised at that offset, so lookup stays bounded on
469
+ * adversarial input.
470
+ */
471
+ const MAX_SCOPE_DEPTH = 256;
472
+
473
+ const isIdent = (c: string): boolean => /[A-Za-z0-9_.-]/.test(c);
474
+ const isIdentStart = (c: string): boolean => /[A-Za-z_]/.test(c);
475
+
476
+ /** Resolve every variable occurrence in `original`. Never throws. */
477
+ export function resolveSymbols(original: string): SymbolAnalysis {
478
+ const analysis = analyzeCoordinates(original);
479
+ // `analyzed` is pure ASCII, so a string built from it has index === byte.
480
+ const text = new TextDecoder().decode(analysis.analyzed);
481
+
482
+ // S7's "inside a `(…)` expression" test, precomputed in one forward pass.
483
+ // Scanning backward per bare word was O(n²) on a long single-line script: an
484
+ // unmatched `)` kept the scan from stopping at the newline, so it could run
485
+ // back to offset 0 for every word. This table is line-scoped, which is what
486
+ // the rule says and what the backward scan did on balanced input.
487
+ const openParen = parenMap(text);
488
+
489
+ const occurrences: SymbolOccurrence[] = [];
490
+ const defects: Defect[] = [];
491
+ let nextBindingId = 0;
492
+ const root: Scope = { closure: false, bindings: new Map() };
493
+ const scopes: Scope[] = [root];
494
+ /** F6 — saved statement state per open `[`, innermost last. */
495
+ const brackets: BracketFrame[] = [];
496
+ /** F7 — open statement scopes (loop / error variables), innermost last. */
497
+ const statementScopes: StatementScope[] = [];
498
+ /**
499
+ * open delimiters, innermost last; `"` is a string frame. Each carries WHERE
500
+ * it opened so an `unclosed`/`unterminated-string` defect can point at the
501
+ * opener instead of at end-of-input (#192).
502
+ */
503
+ const frames: {
504
+ char: "{" | "[" | "(" | '"';
505
+ at: number;
506
+ statements: boolean;
507
+ }[] = [];
508
+ /** bracket/brace nesting, strings excluded (the S8 filter region uses it). */
509
+ let depth = 0;
510
+ /** `{` opens past the cap that pushed no scope; their `}` must not pop one. */
511
+ let suppressedScopes = 0;
512
+ let overDepth = false;
513
+ /** a malformed escape ended the analysis (the device errors out there). */
514
+ let defect = false;
515
+
516
+ /** lowercased leading word of the current statement. */
517
+ let head: string | null = null;
518
+ /** depth at which a filter word opened its region, or null. */
519
+ let filterDepth: number | null = null;
520
+ let pendingDecl: SymbolClass | null = null;
521
+ let pendingLoopVars = false;
522
+ /** inside a space-separated menu path run, where bare words are `dir` (S16). */
523
+ let inMenuPath = false;
524
+ /**
525
+ * F8 — the menu-path segments read so far in this statement, and whether the
526
+ * console would still be reading the PATH at this point.
527
+ *
528
+ * Deliberately NOT `inMenuPath`, which answers a different question (is this
529
+ * bare word a symbol?) and stays true past the verb to the end of the
530
+ * statement. The defect rule needs the narrower region that ENDS at the
531
+ * command, so it accumulates segments and asks `menus.ts`.
532
+ */
533
+ let pathSegments: string[] = [];
534
+ let pathOpen = false;
535
+ let pendingSetTarget = false;
536
+ let pendingErrVar = false;
537
+ /** this statement declared a name, so a `do={` it opens is a closure (F2). */
538
+ let declaredHere = false;
539
+ /** the sigil the statement head was written with: `:`, `/`, or none. */
540
+ let headSigil: ":" | "/" | "" = "";
541
+ /** H4 — still before the first real token of the current statement? */
542
+ let atLead = true;
543
+ /** H5 — how far the pending `\<newline>` continuation still reaches (#215). */
544
+ let cont: Continuation = "none";
545
+ /** H5 — at the immediate start of a line the continuation carried into? */
546
+ let contLineStart = false;
547
+
548
+ /**
549
+ * F7 — drop every statement scope whose statement cannot still be running at
550
+ * the current depth. A reset INSIDE the body (its newlines, its `{`) must not
551
+ * take one down, which is why the depth of the binding head is what decides.
552
+ */
553
+ const unwindStatementScopes = (): void => {
554
+ while (statementScopes.length > 0) {
555
+ const top = statementScopes[statementScopes.length - 1] as StatementScope;
556
+ if (top.depth < depth) return;
557
+ statementScopes.pop();
558
+ if (scopes[scopes.length - 1] === top.scope) scopes.pop();
559
+ }
560
+ };
561
+
562
+ /**
563
+ * F7 — open the scope a statement binds its own variables in, once per
564
+ * statement (`:foreach k,v in=…` shares one).
565
+ */
566
+ const openStatementScope = (at: number): void => {
567
+ const top = statementScopes[statementScopes.length - 1];
568
+ if (top !== undefined && top.depth === depth) return;
569
+ if (scopes.length >= MAX_SCOPE_DEPTH) {
570
+ if (!overDepth) {
571
+ overDepth = true;
572
+ defects.push(defectAt("over-depth", at));
573
+ }
574
+ return;
575
+ }
576
+ const scope: Scope = { closure: false, bindings: new Map() };
577
+ scopes.push(scope);
578
+ statementScopes.push({ scope, depth });
579
+ };
580
+
581
+ const resetStatement = (): void => {
582
+ unwindStatementScopes();
583
+ head = null;
584
+ filterDepth = null;
585
+ inMenuPath = false;
586
+ pendingSetTarget = false;
587
+ pendingErrVar = false;
588
+ pendingDecl = null;
589
+ pendingLoopVars = false;
590
+ declaredHere = false;
591
+ headSigil = "";
592
+ cont = "none";
593
+ contLineStart = false;
594
+ pathSegments = [];
595
+ pathOpen = false;
596
+ };
597
+
598
+ /**
599
+ * Nearest visible binding for `name` at `at`, innermost scope first. The
600
+ * search STOPS at a closure boundary (F2): a named-function body cannot see
601
+ * the names around it.
602
+ */
603
+ const lookup = (name: string, at: number): Binding | null => {
604
+ for (let s = scopes.length - 1; s >= 0; s--) {
605
+ const scope = scopes[s] as Scope;
606
+ const b = scope.bindings.get(name);
607
+ // A claim that has not been reached yet does not hide the enclosing
608
+ // binding: in `:global v 1` + `{:put $v; :local v 2; :put $v}` the device
609
+ // reads the first in-body use `global` and the second `local`.
610
+ if (b !== undefined && b.from <= at) return b;
611
+ if (scope.closure) return null;
612
+ }
613
+ return null;
614
+ };
615
+
616
+ /**
617
+ * F7 — the FIRST declaration written directly in a scope claims the name.
618
+ * Returns null when the scope already had it, which is not an error: the
619
+ * declaration's own span still records its own head's class, it just binds
620
+ * nothing.
621
+ */
622
+ const claim = (
623
+ scope: Scope,
624
+ name: string,
625
+ cls: SymbolClass,
626
+ from: number,
627
+ promotedId?: string,
628
+ ): Binding | null => {
629
+ if (scope.bindings.has(name)) return null;
630
+ const binding = {
631
+ id: promotedId ?? `b${nextBindingId++}`,
632
+ cls,
633
+ from,
634
+ };
635
+ scope.bindings.set(name, binding);
636
+ return binding;
637
+ };
638
+
639
+ // Case matters: RouterOS variable resolution is CASE-SENSITIVE (`:local
640
+ // UserName` then `$username` reads `parameter` on the device, not local), so
641
+ // binding keys are never folded.
642
+ //
643
+ // F7 — `:global` and `:local` bind IDENTICALLY: current scope, from here on,
644
+ // not escaping the enclosing `{…}`. Only the class they emit differs.
645
+ const bind = (name: string, cls: SymbolClass, from: number): Binding | null =>
646
+ claim(scopes[scopes.length - 1] as Scope, name, cls, from);
647
+
648
+ /**
649
+ * F6/F7 — enter a `[`. Saves the enclosing statement, opens a nested
650
+ * statement context, and pushes a scope. EVERY bracket gets one: a
651
+ * statement-LEADING bracket differs only in what happens at the `]`, where
652
+ * its claims are promoted outwards.
653
+ */
654
+ const openBracket = (lead: boolean, at: number): void => {
655
+ const frame: BracketFrame = {
656
+ head,
657
+ filterDepth,
658
+ inMenuPath,
659
+ pendingSetTarget,
660
+ pendingErrVar,
661
+ pendingDecl,
662
+ pendingLoopVars,
663
+ declaredHere,
664
+ headSigil,
665
+ cont,
666
+ contLineStart,
667
+ scope: null,
668
+ lead,
669
+ pathSegments,
670
+ pathOpen,
671
+ };
672
+ if (scopes.length >= MAX_SCOPE_DEPTH) {
673
+ if (!overDepth) {
674
+ overDepth = true;
675
+ defects.push(defectAt("over-depth", at));
676
+ }
677
+ } else {
678
+ const scope: Scope = { closure: false, bindings: new Map() };
679
+ scopes.push(scope);
680
+ frame.scope = scope;
681
+ }
682
+ brackets.push(frame);
683
+ resetStatement();
684
+ };
685
+
686
+ /**
687
+ * F6/F7 — leave a `]`. `atLead` is deliberately NOT restored: the H4
688
+ * bookkeeping already cleared it for this character, and the device agrees
689
+ * that a `]` resumes the enclosing statement rather than starting one
690
+ * (`:put [:put 1] :local v 1` declares nothing).
691
+ */
692
+ const closeBracket = (): void => {
693
+ const frame = brackets.pop();
694
+ if (frame === undefined) return;
695
+ if (frame.scope !== null && scopes[scopes.length - 1] === frame.scope) {
696
+ scopes.pop();
697
+ // F7 — a statement-LEADING bracket runs at the enclosing level, so what
698
+ // it declared escapes: each claim is re-offered outwards, and the claim
699
+ // rule decides. A name the enclosing scope already holds keeps its
700
+ // class, which is the whole lead-vs-mid difference once a conflict
701
+ // exists (`:global x 1` + `[:local x 2; :put $x]` reads the in-bracket
702
+ // use `local` and the one after the `]` `global`).
703
+ if (frame.lead) {
704
+ const outer = scopes[scopes.length - 1] as Scope;
705
+ for (const [name, b] of frame.scope.bindings)
706
+ claim(outer, name, b.cls, b.from, b.id);
707
+ }
708
+ }
709
+ head = frame.head;
710
+ filterDepth = frame.filterDepth;
711
+ inMenuPath = frame.inMenuPath;
712
+ pendingSetTarget = frame.pendingSetTarget;
713
+ pendingErrVar = frame.pendingErrVar;
714
+ pendingDecl = frame.pendingDecl;
715
+ pendingLoopVars = frame.pendingLoopVars;
716
+ declaredHere = frame.declaredHere;
717
+ headSigil = frame.headSigil;
718
+ cont = frame.cont;
719
+ contLineStart = frame.contLineStart;
720
+ pathSegments = frame.pathSegments;
721
+ pathOpen = frame.pathOpen;
722
+ };
723
+
724
+ /** Record a `$`-sigilled reference, applying S5/S6. */
725
+ const pushRef = (
726
+ r: { start: number; end: number; name: string },
727
+ note?: string,
728
+ ): void => {
729
+ if (/^\d+$/.test(r.name)) {
730
+ // S6 — `$0`, `$1`, … are positional parameters.
731
+ occurrences.push({
732
+ start: r.start,
733
+ end: r.end,
734
+ name: r.name,
735
+ sigil: true,
736
+ declaration: false,
737
+ role: "reference",
738
+ bindingIds: [],
739
+ cls: "parameter",
740
+ note,
741
+ });
742
+ return;
743
+ }
744
+ const binding = lookup(r.name, r.start);
745
+ occurrences.push({
746
+ start: r.start,
747
+ end: r.end,
748
+ name: r.name,
749
+ sigil: true,
750
+ declaration: false,
751
+ role: "reference",
752
+ bindingIds: binding === null ? [] : [binding.id],
753
+ // S5 — no visible declaration is `parameter`, matching the console.
754
+ cls: binding === null ? "parameter" : binding.cls,
755
+ note,
756
+ });
757
+ };
758
+
759
+ const record = (
760
+ start: number,
761
+ end: number,
762
+ name: string,
763
+ cls: SymbolClass | null,
764
+ declaration: boolean,
765
+ note?: string,
766
+ role: SymbolRole = declaration ? "declaration" : "reference",
767
+ bindingIds: string[] = [],
768
+ ): void => {
769
+ occurrences.push({
770
+ start,
771
+ end,
772
+ name,
773
+ sigil: false,
774
+ declaration,
775
+ role,
776
+ bindingIds,
777
+ cls,
778
+ note,
779
+ });
780
+ };
781
+
782
+ for (let i = 0; i < text.length; i++) {
783
+ const c = text[i] as string;
784
+
785
+ // --- inside a double-quoted string --------------------------------------
786
+ if (frames[frames.length - 1]?.char === '"') {
787
+ if (c === "\\") {
788
+ i++;
789
+ continue;
790
+ }
791
+ if (c === '"') {
792
+ frames.pop();
793
+ continue;
794
+ }
795
+ if (c === "$") {
796
+ const next = text[i + 1];
797
+ if (next === "[" || next === "(") {
798
+ // F1 — a substitution inside a string is CODE. Push its frame and
799
+ // keep scanning normally; the matching close pops back into the
800
+ // string. A scan that swallowed this as string bytes would flip
801
+ // its quote phase on the first nested string and lose every
802
+ // binding after it.
803
+ frames.push({ char: next, at: i + 1, statements: next === "[" });
804
+ depth++;
805
+ if (next === "[") {
806
+ // F6 — and it is a statement context like any other `[`. A `$[`
807
+ // can never be statement-LEADING (a string opened it), so it is
808
+ // always the confining, mid-statement form.
809
+ openBracket(false, i + 1);
810
+ atLead = true;
811
+ }
812
+ i++;
813
+ continue;
814
+ }
815
+ const r = readRef(text, i, true);
816
+ if (r !== null) {
817
+ // S9 — interpolated references classify like any other.
818
+ if (r.name !== null) pushRef({ ...r, name: r.name }, "in-string");
819
+ i = r.next - 1;
820
+ }
821
+ }
822
+ continue;
823
+ }
824
+
825
+ // H5 continuation reach (#215) — decided BEFORE the bookkeeping below, so an
826
+ // indented `#` mid-continuation stays content. See `Continuation`.
827
+ const inCont = cont !== "none";
828
+ const contComment = c === "#" && inCont && contLineStart;
829
+ if (inCont && !contComment) {
830
+ if (c === " " || c === "\t" || c === "\r") {
831
+ contLineStart = false;
832
+ } else if (c === "\n" && cont === "escape") {
833
+ // A blank line inside the `\` run is still the run: the statement, its
834
+ // head and its pending declaration all survive it.
835
+ contLineStart = true;
836
+ continue;
837
+ } else {
838
+ // Real content, or the blank line that spends a comment run's reach —
839
+ // which then ends the statement in the newline branch below.
840
+ cont = "none";
841
+ contLineStart = false;
842
+ }
843
+ }
844
+
845
+ // --- H5 line continuation and escape validity ---------------------------
846
+ //
847
+ // In CODE a backslash is valid ONLY before whitespace (CHR 7.23.2:
848
+ // `:put \\ $v` and `:put \\<tab>$v` are clean, `:put \\a`, `:put \\$v` and
849
+ // `:put \\\\ $v` all raise a hard `error`). Before a newline it is the H5
850
+ // CONTINUATION, so the statement head, the pending declaration and
851
+ // `declaredHere` — which decides whether the next `do={` opens a closure —
852
+ // must all survive it.
853
+ //
854
+ // Anything else is a malformed escape. The device dies there and classes
855
+ // every later byte `none`, so this resolver stops too: it records
856
+ // `bad-escape:<offset>` and reports nothing further, rather than carrying
857
+ // confident classes across a boundary the console never crossed. Occurrences
858
+ // BEFORE the defect stand (the lab's X1 rule).
859
+ if (c === "\\") {
860
+ const kind = escapeKind(text, i);
861
+ if (kind === "continuation") {
862
+ // `atLead` survives: on CHR 7.23.3 `do={\` + newline + `:local x 1`
863
+ // still reads `:local` as the head and declares `x` (#215).
864
+ cont = "escape";
865
+ contLineStart = true;
866
+ i += text[i + 1] === "\r" ? 2 : 1;
867
+ continue;
868
+ }
869
+ if (kind === "whitespace") {
870
+ i += 1;
871
+ continue;
872
+ }
873
+ defects.push(defectAt("bad-escape", i));
874
+ defect = true;
875
+ break;
876
+ }
877
+
878
+ // --- comments: `#` in statement-leading position (Q1 rule H4) ------------
879
+ //
880
+ // Done inline rather than through `segment.ts`'s shared `maskComments`:
881
+ // this walk needs the substitution's code CLASSIFIED, not skipped, and it
882
+ // already carries the frame stack for that. Pre-masking measured 98.50% dev
883
+ // where this reads 99.59% — the F1 defect behind that gap (a `$[…]` with a
884
+ // nested string flipping the quote phase, so every later `#` line read as
885
+ // string content) is now fixed in the shared scanner too, via the same
886
+ // frame model (#199); the two must stay in step.
887
+ if (c === "#" && (atLead || contComment)) {
888
+ while (i < text.length && text[i] !== "\n") i++;
889
+ if (contComment) {
890
+ // H5 — the comment line does not end the statement: the head, the
891
+ // pending declaration and `declaredHere` all survive it. The loop's
892
+ // i++ steps over the newline so it cannot reset anything either.
893
+ cont = "comment";
894
+ contLineStart = true;
895
+ } else {
896
+ atLead = true;
897
+ resetStatement();
898
+ }
899
+ continue;
900
+ }
901
+ // #245, CHR 7.23.3/7.24rc3: an unquoted `#` inside an array or
902
+ // parenthesized expression is a hard error, not a value or comment. The
903
+ // device stops highlighting there, so semantic resolution must stop too.
904
+ if (
905
+ c === "#" &&
906
+ hashStartsHardError(text, i, frames.at(-1)?.statements === false)
907
+ ) {
908
+ defects.push(defectAt("invalid-hash", i, "#"));
909
+ defect = true;
910
+ break;
911
+ }
912
+ // H4 bookkeeping. `leadBefore` is the value for the character ABOUT to be
913
+ // read, which is what decides whether a word is the statement HEAD.
914
+ const leadBefore = atLead;
915
+ // F6 — a `[` opens a nested statement context, so the word after it is a
916
+ // HEAD. `(` does not: the device reads a directive there as an ordinary
917
+ // expression term (`(:local v 1)` classes `:local` itself
918
+ // `variable-undefined`), which is why only the bracket is listed here.
919
+ if (c === ";" || c === "\n") atLead = frames.at(-1)?.statements ?? true;
920
+ else if (c === "{") {
921
+ const enclosing = frames.at(-1)?.statements ?? true;
922
+ atLead = enclosing && braceStartsStatements(text, i);
923
+ } else if (c === "[") atLead = true;
924
+ else if (c !== " " && c !== "\t" && c !== "\r") atLead = false;
925
+
926
+ // F8 — anything that cannot be a path token ENDS the path region, so no
927
+ // defect may be reported past it. A digit-led object name is the case that
928
+ // matters: `/ip 1 //ip/address` is CLEAN on CHR 7.23.2 (`1` is
929
+ // `obj-inactive`, every later byte `none`) and only the RUNTIME rejects it,
930
+ // at the `1` — "bad command name 1 (line 1 column 5)" — never at the `//`.
931
+ // Reporting the `//` there was a false positive against the very oracle
932
+ // this module is scored on. The same holds for `1.1.1.1`, `-1` and a `:`
933
+ // following any of them.
934
+ //
935
+ // `$`, `"`, `(` and `[` in path position are hard device ERRORS on their
936
+ // own first byte, which this walker does not model; closing the region
937
+ // there keeps it silent rather than reporting a defect at the wrong offset.
938
+ // Word characters never reach here — the word branch consumes them — and
939
+ // `\` is the H5 continuation, which a path survives.
940
+ if (
941
+ pathOpen &&
942
+ !isIdentStart(c) &&
943
+ c !== ":" &&
944
+ c !== "/" &&
945
+ c !== " " &&
946
+ c !== "\t" &&
947
+ c !== "\r" &&
948
+ c !== "\\"
949
+ )
950
+ pathOpen = false;
951
+
952
+ if (c === '"') {
953
+ // S11 — a QUOTED declaration name. `:global "set-dns" do={…}` declares
954
+ // `set-dns`, and the console carries the class across the quotes, so the
955
+ // span includes them. Without this the string is skipped and the next
956
+ // bare word (`do`) is declared instead.
957
+ if (pendingDecl !== null || pendingLoopVars || pendingErrVar) {
958
+ const close = text.indexOf('"', i + 1);
959
+ if (close > i) {
960
+ const name = text.slice(i + 1, close);
961
+ // F7 — a quoted name binds exactly like a bare one, and the three
962
+ // binding heads differ. Routing them all through `bind` in the
963
+ // enclosing scope (what this branch used to do) broke every F7 rule
964
+ // for the quoted spelling, device-verified on CHR 7.23.2:
965
+ // `:foreach "i" …` + `$"i"` reads `parameter` after the statement,
966
+ // an enclosing `:global i` does not swallow the in-body `auto`, and
967
+ // `:for "i" … do={$"i"}` reads `auto` — so `declaredHere`, which
968
+ // makes the following `do={` an F2 closure, belongs to a
969
+ // `:local`/`:global` declaration ONLY. `:onerror` was not accepted
970
+ // here at all, so its `pendingErrVar` survived to the next bare
971
+ // word and declared `in`.
972
+ const cls: SymbolClass =
973
+ pendingDecl ?? (pendingErrVar ? "local" : "auto");
974
+ const role: SymbolRole =
975
+ pendingDecl !== null ? "declaration" : "binding";
976
+ const bindingIds: string[] = [];
977
+ if (pendingDecl !== null) {
978
+ const binding = bind(name, cls, i + 1);
979
+ if (binding !== null) bindingIds.push(binding.id);
980
+ pendingDecl = null;
981
+ declaredHere = true;
982
+ } else {
983
+ // F7 — statement scope, and for `:onerror` the enclosing claim
984
+ // too. Same order as the bare paths: claim before the statement
985
+ // scope exists.
986
+ if (pendingErrVar) {
987
+ const outer = claim(
988
+ scopes[scopes.length - 1] as Scope,
989
+ name,
990
+ "local",
991
+ i + 1,
992
+ );
993
+ if (outer !== null) bindingIds.push(outer.id);
994
+ }
995
+ openStatementScope(i + 1);
996
+ const statement = bind(name, cls, i + 1);
997
+ if (statement !== null) bindingIds.push(statement.id);
998
+ pendingErrVar = false;
999
+ }
1000
+ // The span includes the quotes; the NAME does not.
1001
+ record(i, close + 1, name, cls, true, undefined, role, bindingIds);
1002
+ i = close;
1003
+ continue;
1004
+ }
1005
+ }
1006
+ frames.push({ char: '"', at: i, statements: false });
1007
+ continue;
1008
+ }
1009
+
1010
+ if (c === "{" || c === "[" || c === "(") {
1011
+ frames.push({
1012
+ char: c,
1013
+ at: i,
1014
+ statements:
1015
+ c === "[" ||
1016
+ (c === "{" &&
1017
+ (frames.at(-1)?.statements ?? true) &&
1018
+ braceStartsStatements(text, i)),
1019
+ });
1020
+ depth++;
1021
+ if (c === "[") openBracket(leadBefore, i);
1022
+ if (c === "{") {
1023
+ if (scopes.length >= MAX_SCOPE_DEPTH) {
1024
+ suppressedScopes++;
1025
+ if (!overDepth) {
1026
+ overDepth = true;
1027
+ defects.push(defectAt("over-depth", i));
1028
+ }
1029
+ } else {
1030
+ // F2 — `:local F do={…}` opens a closure; `:if … do={…}` does not.
1031
+ // Both spell `do={`; the declaring statement head decides.
1032
+ scopes.push({
1033
+ closure: declaredHere && argNameBefore(text, i) === "do",
1034
+ bindings: new Map(),
1035
+ });
1036
+ }
1037
+ resetStatement();
1038
+ }
1039
+ continue;
1040
+ }
1041
+ if (c === "}" || c === "]" || c === ")") {
1042
+ const want = c === "}" ? "{" : c === "]" ? "[" : "(";
1043
+ // F7 — a statement scope opened INSIDE this delimiter ends with it, and
1044
+ // it sits above the scope this close is about to pop, so it comes down
1045
+ // first. `depth` is still the inner value here (it decrements below).
1046
+ if (frames[frames.length - 1]?.char === want) unwindStatementScopes();
1047
+ if (frames[frames.length - 1]?.char !== want) {
1048
+ // A mismatched close is not a close: popping it would unwind a real
1049
+ // enclosing scope and drop its bindings early. `segment.ts` takes the
1050
+ // same line — report it and treat the character as content.
1051
+ defects.push(defectAt("unbalanced-close", i, c));
1052
+ continue;
1053
+ }
1054
+ frames.pop();
1055
+ if (filterDepth !== null && depth <= filterDepth) filterDepth = null;
1056
+ // F6 — restore the statement the `[` interrupted, and drop the bracket's
1057
+ // scope. Ordered after the `filterDepth` clear on purpose: the saved
1058
+ // value is the enclosing region, which outlives this bracket.
1059
+ if (c === "]") closeBracket();
1060
+ if (c === "}") {
1061
+ // S1 — locals declared inside this brace scope go out of view.
1062
+ if (suppressedScopes > 0) suppressedScopes--;
1063
+ else if (scopes.length > 1) scopes.pop();
1064
+ resetStatement();
1065
+ }
1066
+ if (depth > 0) depth--;
1067
+ continue;
1068
+ }
1069
+
1070
+ if (c === ";" || c === "\n") {
1071
+ resetStatement();
1072
+ continue;
1073
+ }
1074
+
1075
+ // --- `$` reference --------------------------------------------------------
1076
+ if (c === "$") {
1077
+ const r = readRef(text, i, false);
1078
+ if (r !== null) {
1079
+ if (r.name !== null) pushRef({ ...r, name: r.name });
1080
+ i = r.next - 1;
1081
+ }
1082
+ continue;
1083
+ }
1084
+
1085
+ // S13 — consume the whole identifier-shaped tail of a digit-led literal.
1086
+ // Without this, `1d` skipped the `1` and revisited `d` as a bare symbol;
1087
+ // the same leak affected duration forms such as `1w7h2s`. Identifier-shaped
1088
+ // tails, including `.` and `-`, are consumed here; separators such as `:` and
1089
+ // `/` stop the run and return to the ordinary byte walk.
1090
+ if (/[0-9]/.test(c)) {
1091
+ let j = i + 1;
1092
+ while (j < text.length && isIdent(text[j] as string)) j++;
1093
+ i = j - 1;
1094
+ continue;
1095
+ }
1096
+
1097
+ // --- bare word ------------------------------------------------------------
1098
+ if (isIdentStart(c) || c === ":" || c === "/") {
1099
+ const start = i;
1100
+ // `:` and `/` are sigils/path characters, not part of the word.
1101
+ let j = i;
1102
+ while (j < text.length && (text[j] === ":" || text[j] === "/")) j++;
1103
+ const wordStart = j;
1104
+ while (j < text.length && isIdent(text[j] as string)) j++;
1105
+ const word = text.slice(wordStart, j);
1106
+ const lower = word.toLowerCase();
1107
+ const sigilRun = wordStart - start;
1108
+ i = j - 1;
1109
+ // A sigil RUN at statement start is a hard device error — `//local`,
1110
+ // `::put`, `:/local` and a bare `//` all error at the second character
1111
+ // and class every later byte `none`. Checked BEFORE the empty-word exit,
1112
+ // because `//` on its own carries no word and would otherwise slip past.
1113
+ // Inside a value a doubled slash is ordinary text (`url=http://example.com`,
1114
+ // `comment=a//b`, `:put //foo`), so this gate is statement-leading only.
1115
+ // The MID-statement form (`/ip//address`) is a defect too, but only in
1116
+ // menu-path position — F8 below decides that, and the value forms above
1117
+ // stay clean because the path region has already closed by then.
1118
+ if (leadBefore && sigilRun > 1) {
1119
+ defects.push(defectAt("bad-sigil", start + 1));
1120
+ defect = true;
1121
+ break;
1122
+ }
1123
+ // F8 — the same failure MID-statement, now that the walker can tell
1124
+ // path position from the rest. Only inside the path region (see
1125
+ // `pathOpen`), and the offending byte differs by which sigil it is:
1126
+ // a `/` is a legal SEPARATOR when it is adjacent to the segment before
1127
+ // it, so the error lands on the second one; after a space it is already
1128
+ // wrong, so it lands on the first. A `:` is never legal in a path.
1129
+ if (pathOpen && sigilRun > 0) {
1130
+ const adjacent = start > 0 && !/\s/.test(text[start - 1] as string);
1131
+ const at =
1132
+ text[start] === ":" || !adjacent
1133
+ ? start
1134
+ : sigilRun > 1
1135
+ ? start + 1
1136
+ : -1;
1137
+ if (at >= 0) {
1138
+ defects.push(defectAt("bad-sigil", at));
1139
+ defect = true;
1140
+ break;
1141
+ }
1142
+ }
1143
+ if (word === "") continue;
1144
+
1145
+ // A directive head must be at STATEMENT START. `head === null` alone is
1146
+ // not that test: nothing before it sets `head`, so after a dynamic
1147
+ // command (`$fn local foo 1`) or a bracket the next word looked like a
1148
+ // head and fabricated a declaration. CHR 7.23.2 classes `local`/`foo`
1149
+ // there as `none` and the later `$foo` as `variable-parameter`.
1150
+ //
1151
+ // The sigil run must also be zero or one character: `//local foo 1` and
1152
+ // `/:local foo 1` are device errors (every byte `none`), not
1153
+ // declarations.
1154
+ if (head === null && leadBefore && sigilRun <= 1) {
1155
+ head = lower;
1156
+ // The sigil on a scripting head is OPTIONAL — in BOTH directions.
1157
+ // Q2 grounded the colon-less form for body-taking directives
1158
+ // (`do {` ≡ `:do {`), and CHR 7.23.2 classes `local foo 1`,
1159
+ // `global foo 1`, `foreach i in={1} do={…}` and `for i from=1 …`
1160
+ // exactly like their `:`-spelled twins. The `/` spelling binds too,
1161
+ // because these directives ARE root commands (`/global g 1` →
1162
+ // `g` = `variable-global`) — the same root vocabulary `write.ts`
1163
+ // froze as ROOT_CMDS. A DEEPER path is unaffected: `/ip/local foo 1`
1164
+ // and `/interface local foo 1` make `local` an `obj-inactive` menu
1165
+ // item, and there the head word is `ip`/`interface`, not `local`.
1166
+ headSigil = text[start] === ":" ? ":" : text[start] === "/" ? "/" : "";
1167
+ // S16 — a `/`-sigilled HEAD opens a menu path just as a `/`-sigilled
1168
+ // later word does, so the space-separated segments after it are `dir`,
1169
+ // not symbols. The rule lived only on the non-head path below, which
1170
+ // was invisible while a bracket inherited its head from the enclosing
1171
+ // statement; once `[` opens its own statement context (F6) the first
1172
+ // word inside IS the head, and `[/interface list find …]` started
1173
+ // abstaining on `list`.
1174
+ if (headSigil === "/") inMenuPath = true;
1175
+ // F8 — the DEFECT region opens only on a head the baked menu table
1176
+ // recognizes, whatever its sigil. A `:`-spelled head is a scripting
1177
+ // directive, never a path (`:put //foo` and `:put ::foo` are clean on
1178
+ // the device), and a `/`-spelled head that is not a known menu — the
1179
+ // `/global g 1` form F3 grounds — is a command, not navigation. The
1180
+ // table being a FLOOR is the safe direction here: an unlisted menu
1181
+ // closes the region and the defect simply goes unreported.
1182
+ if (headSigil !== ":" && isMenuPath([lower])) {
1183
+ pathSegments = [lower];
1184
+ pathOpen = true;
1185
+ }
1186
+ if (DECL[lower] !== undefined) pendingDecl = DECL[lower] as SymbolClass;
1187
+ else if (LOOP_HEADS.has(lower)) pendingLoopVars = true;
1188
+ else if (lower === "set") pendingSetTarget = true;
1189
+ else if (ERRVAR_HEADS.has(lower)) pendingErrVar = true;
1190
+ // S8 — `find`/`where` open a filter region from HEAD position too.
1191
+ // `[find comment=$tag]` is the commonest shape of the whole corpus and
1192
+ // its `find` only became a head once `[` started a statement context
1193
+ // (F6); before that the bracket inherited the enclosing head, so this
1194
+ // branch never saw a filter word and the region was opened below.
1195
+ else if (FILTER_WORDS.has(lower)) filterDepth = depth;
1196
+ continue;
1197
+ }
1198
+
1199
+ const prevChar = prevNonSpace(text, start);
1200
+ const isValue = prevChar === "=" || prevChar === ",";
1201
+ const sigilled = wordStart > start;
1202
+
1203
+ // F8 — extend or CLOSE the path region. The device ends it at the first
1204
+ // word that is not a known submenu of the path so far, whether or not
1205
+ // that word is a verb: `/ip address zzzz //foo` is clean exactly like
1206
+ // `/ip address print //foo`, while `/ip address //foo` — nothing having
1207
+ // ended the path — is a hard error. An `arg=value` also ends it (the
1208
+ // device rejects an argument before a command outright), which keeps
1209
+ // the `://` inside a `url=` value out of path position no matter how the
1210
+ // menu table reads the words before it.
1211
+ if (pathOpen) {
1212
+ if (isValue || nextNonSpace(text, j) === "=") pathOpen = false;
1213
+ else if (isMenuPath([...pathSegments, lower])) pathSegments.push(lower);
1214
+ else pathOpen = false;
1215
+ }
1216
+
1217
+ // S10 — `:set NAME` is a REFERENCE carrying the class NAME was declared
1218
+ // as. On an undeclared name the device raises a hard error, which is not
1219
+ // a class at all, so abstain rather than invent one.
1220
+ if (head === "set" && pendingSetTarget) {
1221
+ pendingSetTarget = false;
1222
+ const binding = lookup(word, start);
1223
+ // Spelling decides what an unresolved target means on the device:
1224
+ // `set foo 2` with no `foo` in scope is the MENU verb `set` and the
1225
+ // device reports no variable at all, while `:set`/`/set` are the
1226
+ // scripting command and report a hard `error` — not a class either,
1227
+ // so those abstain (S10) instead of inventing one.
1228
+ if (binding === null && headSigil === "") continue;
1229
+ record(
1230
+ wordStart,
1231
+ j,
1232
+ word,
1233
+ binding === null ? null : binding.cls,
1234
+ false,
1235
+ binding === null
1236
+ ? "S10 :set on an undeclared name — the device raises a hard error here"
1237
+ : undefined,
1238
+ "assignment",
1239
+ binding === null ? [] : [binding.id],
1240
+ );
1241
+ continue;
1242
+ }
1243
+
1244
+ // S18 — the error variable of `:onerror NAME {…}`.
1245
+ if (pendingErrVar) {
1246
+ pendingErrVar = false;
1247
+ // F7 — it binds TWICE, and the two are separable on the device: a
1248
+ // statement scope makes it `local` inside the body even when the
1249
+ // enclosing scope already claims the name (`:global e 1` + `:onerror
1250
+ // e … do={:put $e}` reads the use `local`), while a claim on the
1251
+ // enclosing scope is what keeps it visible AFTER the statement
1252
+ // (`:onerror e …` + `:put $e` reads `local`, where the same shape
1253
+ // with a loop variable reads `parameter`). Order matters: the claim
1254
+ // is offered to the ENCLOSING scope, before the statement scope
1255
+ // exists.
1256
+ const bindingIds: string[] = [];
1257
+ const outer = claim(
1258
+ scopes[scopes.length - 1] as Scope,
1259
+ word,
1260
+ "local",
1261
+ wordStart,
1262
+ );
1263
+ if (outer !== null) bindingIds.push(outer.id);
1264
+ openStatementScope(wordStart);
1265
+ const statement = bind(word, "local", wordStart);
1266
+ if (statement !== null) bindingIds.push(statement.id);
1267
+ record(
1268
+ wordStart,
1269
+ j,
1270
+ word,
1271
+ "local",
1272
+ true,
1273
+ undefined,
1274
+ "binding",
1275
+ bindingIds,
1276
+ );
1277
+ continue;
1278
+ }
1279
+
1280
+ // S1/S2 — this word is the declared name.
1281
+ if (pendingDecl !== null) {
1282
+ const binding = bind(word, pendingDecl, wordStart);
1283
+ record(
1284
+ wordStart,
1285
+ j,
1286
+ word,
1287
+ pendingDecl,
1288
+ true,
1289
+ undefined,
1290
+ "declaration",
1291
+ binding === null ? [] : [binding.id],
1292
+ );
1293
+ pendingDecl = null;
1294
+ declaredHere = true;
1295
+ continue;
1296
+ }
1297
+
1298
+ // S3 — `:foreach k,v in=…`: every word before `in=`/`from=` is a loop
1299
+ // variable.
1300
+ if (pendingLoopVars) {
1301
+ if (lower === "in" || lower === "from") {
1302
+ pendingLoopVars = false;
1303
+ continue;
1304
+ }
1305
+ // F7 — loop variables get a scope of their own, wrapping the rest of
1306
+ // the statement including its body.
1307
+ openStatementScope(wordStart);
1308
+ const binding = bind(word, "auto", wordStart);
1309
+ record(
1310
+ wordStart,
1311
+ j,
1312
+ word,
1313
+ "auto",
1314
+ true,
1315
+ undefined,
1316
+ "binding",
1317
+ binding === null ? [] : [binding.id],
1318
+ );
1319
+ continue;
1320
+ }
1321
+
1322
+ if (FILTER_WORDS.has(lower)) {
1323
+ filterDepth = depth;
1324
+ continue;
1325
+ }
1326
+
1327
+ // S8 — inside a filter/query region EVERY bare identifier is a menu
1328
+ // field, not only the `field=value` form: `[find … !ca (common-name or
1329
+ // subject-alt-name)]` and the bare flag in `[find running]` are all
1330
+ // `variable-local` on the device. Schema-dependent, so abstain.
1331
+ if (
1332
+ filterDepth !== null &&
1333
+ !isValue &&
1334
+ !sigilled &&
1335
+ !isLiteralWord(lower) &&
1336
+ !OPERATOR_WORDS.has(lower)
1337
+ ) {
1338
+ record(wordStart, j, word, null, false, FILTER_NOTE, "field");
1339
+ continue;
1340
+ }
1341
+
1342
+ // A bare word followed by `=` is an argument NAME, not a symbol — except
1343
+ // inside a `(…)` expression, where `word = value` is a COMPARISON and the
1344
+ // left side is a symbol position (the device's own `variable-undefined`
1345
+ // example is `(lppp=0)` in an `:if` condition). Abstain there rather than
1346
+ // stay silent, so a caller still learns a symbol is present.
1347
+ if (nextNonSpace(text, j) === "=") {
1348
+ if (
1349
+ filterDepth !== null ||
1350
+ (!sigilled && !isLiteralWord(lower) && openParen[start] === 1)
1351
+ )
1352
+ record(
1353
+ wordStart,
1354
+ j,
1355
+ word,
1356
+ null,
1357
+ false,
1358
+ filterDepth !== null ? FILTER_NOTE : BARE_WORD_NOTE,
1359
+ filterDepth !== null ? "field" : "reference",
1360
+ );
1361
+ continue;
1362
+ }
1363
+
1364
+ // S16 — a bare word CONTINUING a space-separated menu path is a path
1365
+ // segment (`/interface list find`), which the S12 sigil rule alone
1366
+ // cannot see because only the first segment carries the `/`.
1367
+ if (sigilled && text[start] === "/") inMenuPath = true;
1368
+ else if (
1369
+ isValue ||
1370
+ prevChar === "[" ||
1371
+ prevChar === "(" ||
1372
+ prevChar === null
1373
+ )
1374
+ inMenuPath = false;
1375
+ if (inMenuPath) continue;
1376
+ // S17 — operator keywords are `syntax-meta`, never symbols. Schema-free
1377
+ // and knowable, so they are excluded outright rather than abstained.
1378
+ if (OPERATOR_WORDS.has(lower)) continue;
1379
+ // S7/S12/S13/S15 — only inside an expression or a filter region does the
1380
+ // console treat a bare word as a symbol at all; a sigilled word is a
1381
+ // directive or path segment, a literal is a value, and a word right
1382
+ // after `=`/`,` is a value.
1383
+ if (
1384
+ !sigilled &&
1385
+ !isValue &&
1386
+ !isLiteralWord(lower) &&
1387
+ depth > 0 &&
1388
+ (filterDepth !== null || openParen[start] === 1)
1389
+ ) {
1390
+ const binding = lookup(word, start);
1391
+ record(
1392
+ wordStart,
1393
+ j,
1394
+ word,
1395
+ binding === null ? null : binding.cls,
1396
+ false,
1397
+ // S7 — a resolved binding is asserted (that part is lexical and
1398
+ // scored 100% precision); an unresolved bare word is NOT called
1399
+ // `undefined`, because menu field vs unbound name needs a schema.
1400
+ binding === null ? BARE_WORD_NOTE : undefined,
1401
+ "reference",
1402
+ binding === null ? [] : [binding.id],
1403
+ );
1404
+ }
1405
+ }
1406
+ }
1407
+
1408
+ if (!defect && frames.length > 0) {
1409
+ // One defect per open frame, pointing at its OPENER. An unterminated string
1410
+ // spans from its quote to end of input — that whole run is what the scan
1411
+ // swallowed — while a bracket marks just its opener, since its intended
1412
+ // extent is exactly what is unknown.
1413
+ for (const f of frames)
1414
+ defects.push(
1415
+ f.char === '"'
1416
+ ? { code: "unterminated-string", start: f.at, end: text.length }
1417
+ : defectAt("unclosed", f.at, f.char),
1418
+ );
1419
+ }
1420
+
1421
+ return { occurrences, defects };
1422
+ }
1423
+
1424
+ const FILTER_NOTE =
1425
+ "S8 filter-field: schema-dependent (local if a real menu field, undefined if not)";
1426
+ const BARE_WORD_NOTE =
1427
+ "S7 bare word in expression position: menu field vs undefined name needs a schema";
1428
+
1429
+ /**
1430
+ * Read a `$name` or `$"quoted name"` reference at `at`.
1431
+ *
1432
+ * `next` is where the scan RESUMES, which is not always `end`: `$"…"` consumes
1433
+ * its closing delimiter, while a bare `$name` stops ON its terminator, which
1434
+ * the caller still has to see. `${…}` is rejected by RouterOS at `{`; consume
1435
+ * the invalid form without producing an occurrence so its contents cannot be
1436
+ * mistaken for symbols or scope. Conflating `next` and `end` made every `$var]`
1437
+ * swallow its bracket in the lab's first run, drifting the nesting depth upward
1438
+ * across a whole script.
1439
+ */
1440
+ function readRef(
1441
+ text: string,
1442
+ at: number,
1443
+ inString: boolean,
1444
+ ): { start: number; end: number; name: string | null; next: number } | null {
1445
+ let i = at + 1;
1446
+ if (text[i] === "{") {
1447
+ const close = text.indexOf("}", i);
1448
+ if (close < 0) return null;
1449
+ return {
1450
+ start: i + 1,
1451
+ end: close,
1452
+ name: null,
1453
+ next: close + 1,
1454
+ };
1455
+ }
1456
+ if (text[i] === '"' && !inString) {
1457
+ const close = text.indexOf('"', i + 1);
1458
+ if (close < 0) return null;
1459
+ // The span includes the quotes — the class runs across them (S11).
1460
+ return {
1461
+ start: i,
1462
+ end: close + 1,
1463
+ name: text.slice(i + 1, close),
1464
+ next: close + 1,
1465
+ };
1466
+ }
1467
+ if (text[i] === "[" || text[i] === "(") return null; // substitution, not a name
1468
+ const start = i;
1469
+ // S14 — the reference-name boundary is NOT the argument-name charset:
1470
+ // `-` always terminates a bare reference. `$set-dns` is `$set` followed
1471
+ // by subtraction/string content; only `$"set-dns"` names a
1472
+ // hyphenated variable. This also covers `->` indexing.
1473
+ // `.` property access. `$ipprefix.0` is `ipprefix` then `.0`.
1474
+ while (i < text.length) {
1475
+ const ch = text[i] as string;
1476
+ if (ch === "." || ch === "-") break;
1477
+ if (!isIdent(ch)) break;
1478
+ i++;
1479
+ }
1480
+ if (i === start) return null;
1481
+ return { start, end: i, name: text.slice(start, i), next: i };
1482
+ }
1483
+
1484
+ /** The `name=` immediately before the `{` at `open`, lowercased, or null. */
1485
+ function argNameBefore(text: string, open: number): string | null {
1486
+ let i = open - 1;
1487
+ // Every escape the WALKER accepts has to be transparent here too, or the two
1488
+ // disagree about where `do=` ends: `do=\ {`, `do=\<tab>{` and `do=\<nl>{` are
1489
+ // all `do={` on the device (the body is a closure), while `do=\{` is a hard
1490
+ // error the walker already stopped on. Sharing `escapeKind` is what keeps the
1491
+ // closure test and the scan from drifting apart.
1492
+ while (i >= 0) {
1493
+ const c = text[i] as string;
1494
+ if (isSpace(c)) {
1495
+ i--;
1496
+ continue;
1497
+ }
1498
+ if (c === "\\" && escapeKind(text, i) !== "invalid") {
1499
+ i--;
1500
+ continue;
1501
+ }
1502
+ break;
1503
+ }
1504
+ if (text[i] !== "=") return null;
1505
+ i--;
1506
+ const end = i + 1;
1507
+ while (i >= 0 && isIdent(text[i] as string)) i--;
1508
+ const name = text.slice(i + 1, end);
1509
+ return name === "" ? null : name.toLowerCase();
1510
+ }
1511
+
1512
+ /**
1513
+ * What the backslash at `at` means in CODE.
1514
+ *
1515
+ * Grounded on CHR 7.23.2: a backslash is valid ONLY before whitespace. Before a
1516
+ * newline it is the H5 line continuation (`\<nl>` and `\<cr><nl>`); before a
1517
+ * space, a tab or a lone carriage return it escapes that character and the
1518
+ * statement carries on; before anything else (`\a`, `\$v`, `\\`, `\{`) the
1519
+ * console raises a hard error and classes the whole remainder `none`.
1520
+ */
1521
+ function escapeKind(
1522
+ text: string,
1523
+ at: number,
1524
+ ): "continuation" | "whitespace" | "invalid" {
1525
+ const next = text[at + 1];
1526
+ if (next === "\n") return "continuation";
1527
+ if (next === "\r")
1528
+ return text[at + 2] === "\n" ? "continuation" : "whitespace";
1529
+ if (next === " " || next === "\t") return "whitespace";
1530
+ return "invalid";
1531
+ }
1532
+
1533
+ const isSpace = (c: string): boolean =>
1534
+ c === " " || c === "\t" || c === "\r" || c === "\n";
1535
+
1536
+ /** Previous non-blank character on this line, or null at line start. */
1537
+ function prevNonSpace(text: string, from: number): string | null {
1538
+ for (let i = from - 1; i >= 0; i--) {
1539
+ const c = text[i] as string;
1540
+ if (c === " " || c === "\t") continue;
1541
+ if (c === "\n") return null;
1542
+ return c;
1543
+ }
1544
+ return null;
1545
+ }
1546
+
1547
+ /** Next non-blank character at or after `from`, or null at end of input. */
1548
+ function nextNonSpace(text: string, from: number): string | null {
1549
+ for (let i = from; i < text.length; i++) {
1550
+ const c = text[i] as string;
1551
+ if (c !== " " && c !== "\t") return c;
1552
+ }
1553
+ return null;
1554
+ }
1555
+
1556
+ /**
1557
+ * Per-byte flag: is this offset inside an unclosed `(` on its own line?
1558
+ *
1559
+ * Strings are deliberately not special-cased — the rule this feeds (S7) was
1560
+ * scored with a paren test that counted every `(`, and this table reproduces it
1561
+ * on any line whose parentheses balance.
1562
+ */
1563
+ function parenMap(text: string): Uint8Array {
1564
+ const open = new Uint8Array(text.length);
1565
+ let depth = 0;
1566
+ for (let i = 0; i < text.length; i++) {
1567
+ const c = text[i];
1568
+ if (c === "\n") {
1569
+ depth = 0;
1570
+ continue;
1571
+ }
1572
+ if (c === "(") {
1573
+ open[i] = depth > 0 ? 1 : 0;
1574
+ depth++;
1575
+ continue;
1576
+ }
1577
+ if (c === ")" && depth > 0) depth--;
1578
+ open[i] = depth > 0 ? 1 : 0;
1579
+ }
1580
+ return open;
1581
+ }
1582
+
1583
+ /**
1584
+ * S13 — bare words the console classifies as `none` (literal values), not as
1585
+ * symbols. Booleans/nothing are keywords; anything starting with a digit is a
1586
+ * number, time, IP, or version literal.
1587
+ */
1588
+ function isLiteralWord(lower: string): boolean {
1589
+ if (/^[0-9]/.test(lower)) return true;
1590
+ return (
1591
+ lower === "true" ||
1592
+ lower === "false" ||
1593
+ lower === "yes" ||
1594
+ lower === "no" ||
1595
+ lower === "nothing"
1596
+ );
1597
+ }