@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,1403 @@
1
+ /**
2
+ * Statement-scope ARGUMENT lexing for `explain` (#202c).
3
+ *
4
+ * Phase 0.5 promoted nine analyzers that answer where a statement STARTS, where
5
+ * its menu ends and its verb begins — and stop there. `structure.statements[]`
6
+ * has carried `path` + `verb` and no arguments since #202a, with the gap
7
+ * written down (`commands/explain/README.md` → *What phase 1 offline actually
8
+ * emits*): splitting one statement's arguments needs a lexer for quoted values,
9
+ * `[…]` selectors and `?` queries at statement scope, and it was parked until
10
+ * `--curl` made it load-bearing. This is that lexer.
11
+ *
12
+ * ## It abstains for the WHOLE statement, never per token
13
+ *
14
+ * The consumer is a REST mapping that renders a runnable `curl` body. A
15
+ * partially-read argument list is worse there than no list at all: dropping one
16
+ * token silently changes what the rendered command DOES, which is the
17
+ * fabrication the fail-closed floor exists to prevent. So every token must be
18
+ * decided, or {@link lexArguments} decides nothing and says why. The price is
19
+ * measured rather than assumed — see the abstention rates in the #202c PR body.
20
+ *
21
+ * ## What it refuses, and why each refusal is a fact rather than a gap
22
+ *
23
+ * - **A value that is not a literal.** `[…]` command substitution, `(…)`
24
+ * expression, `$var`, `{…}` array — the token's VALUE is whatever RouterOS
25
+ * computes at run time, so offline has no value to send. (`canonicalize
26
+ * ExecuteCommand` takes the same line for `[`/`]` and falls back to script
27
+ * mode, `src/execute.ts`.)
28
+ * - **An escape inside a quoted value.** `\n`/`\xHH`/`\"` need a decoder to
29
+ * turn source bytes into the value the device receives, and a decoder that
30
+ * is wrong about one escape corrupts a write. Decoding is grounded work
31
+ * (#225's probe matrix territory), not something to guess at here.
32
+ * - **A name that is not a name.** `{a=1;b=2}` fuses an array literal into
33
+ * what looks like `name=value`, which the phase-0 Q0 catalog measured as the
34
+ * IL oracle's own worst confusion (383 corpus hits). A token whose left side
35
+ * is not a bare RouterOS name is not read as an attribute.
36
+ * - **A `\<newline>` continuation.** The bytes of a continued value are not
37
+ * contiguous, so a `valueSpan` over them would be a lie. `verbsplit.ts`'s
38
+ * word scanner handles continuations for the run END; a VALUE needs its
39
+ * interior, which is exactly what a continuation breaks.
40
+ *
41
+ * ## It emits no defects
42
+ *
43
+ * Every defect class this scan could raise (`unterminated-string`,
44
+ * `bad-escape`, `unclosed`) is already located document-wide by `segment.ts` and
45
+ * `symbols.ts`, whose regions are the ratified ones. Re-raising them here with
46
+ * statement-relative regions would double-report the same byte range under two
47
+ * spans, which `attributeDefects` (`src/explain.ts`) cannot de-duplicate because
48
+ * the regions differ. A defect the caller can see is not this module's to
49
+ * re-announce; the abstention reason names it instead.
50
+ */
51
+
52
+ import { isScopeBrace } from "./blocks.ts";
53
+ import { braceSlotAcceptsBrace, braceSlotTakesArray } from "./brace-slots.ts";
54
+ import { braceStartsStatements } from "./scope-brace.ts";
55
+ import { maskComments, scanQuotedString } from "./segment.ts";
56
+
57
+ /** A RouterOS argument name: bare word, optionally dotted (`.id`, `.proplist`). */
58
+ const ARGUMENT_NAME = /^\.?[A-Za-z][A-Za-z0-9._-]*$/;
59
+
60
+ /** What one argument token is. */
61
+ export type ArgumentKind = "attribute" | "positional" | "query";
62
+
63
+ /**
64
+ * One argument token, located in the text it was lexed from.
65
+ *
66
+ * The ordered list is the primary shape and the object view is derived from it
67
+ * (the phase-0 normal form: "an ordered token/argument list with spans, never a
68
+ * pre-reduced args object"). Order and multiplicity are observable here and are
69
+ * flattened by {@link ArgumentReading.args}, so a consumer that needs to know a
70
+ * name was given twice still can.
71
+ */
72
+ export interface Argument {
73
+ kind: ArgumentKind;
74
+ /** The whole token, in the coordinate space of the text passed to the lexer. */
75
+ span: { start: number; end: number };
76
+ /** Attribute name / query word. Absent on a positional. */
77
+ name?: string;
78
+ /**
79
+ * The token's LITERAL value, quotes removed — for an attribute the part after
80
+ * `=`, for a positional the whole token.
81
+ *
82
+ * **Absent means there is no literal value**, whatever the kind: the source
83
+ * spells a substitution, an expression, or an escape this phase does not
84
+ * decode. A consumer rendering a runnable command must read this field, not
85
+ * `text`, and must treat absence as "not renderable" — `text` is the source
86
+ * bytes, which are not the value the device would receive. A query is always
87
+ * absent: `?` words are a grammar of their own and splitting them is not this
88
+ * lexer's claim to make.
89
+ */
90
+ value?: string;
91
+ /** Where the value sits, quotes INCLUDED. Absent whenever `value` is. */
92
+ valueSpan?: { start: number; end: number };
93
+ /** The token verbatim, for a positional and for rendering. */
94
+ text: string;
95
+ }
96
+
97
+ /** Internal token classification; quote state is stripped from the REST view. */
98
+ interface ReadArgument extends Argument {
99
+ literalQuoted?: boolean;
100
+ /** A located structured literal that the strict REST view still refuses. */
101
+ sourceShape?: "array";
102
+ }
103
+
104
+ /** Every token decided. */
105
+ export interface ArgumentsRead {
106
+ read: true;
107
+ tokens: Argument[];
108
+ /**
109
+ * The derived object view: attribute names to values, LAST occurrence winning
110
+ * — the reading `canonicalizeExecuteCommand` already applies to a repeated
111
+ * name (`src/execute.ts`), so the gate and the analysis cannot disagree about
112
+ * a duplicate. `tokens` is where multiplicity survives.
113
+ */
114
+ args: Record<string, string>;
115
+ /** `?`-prefixed query words, in order, verbatim. */
116
+ queries: string[];
117
+ /**
118
+ * Bare tokens, in order — an id (`*1`), a `where`, a `find` operand — as
119
+ * SOURCE text. A consumer needing the value reads `tokens` (see
120
+ * {@link Argument.value}); this list exists so the common "is there an id"
121
+ * question needs no walk.
122
+ */
123
+ positional: string[];
124
+ }
125
+
126
+ /** Nothing decided, and the reason. */
127
+ export interface ArgumentsUnread {
128
+ read: false;
129
+ /** Why the lexer refused. Rendered into the transport basis, never dropped. */
130
+ why: string;
131
+ }
132
+
133
+ export type ArgumentReading = ArgumentsRead | ArgumentsUnread;
134
+
135
+ /** What a located value IS: a command's argument, or one array member. */
136
+ export type ValueAnchorKind = Exclude<ArgumentKind, "query"> | "element";
137
+
138
+ /** One safely located value shape, before any later unreadable token. */
139
+ export interface ValueAnchor {
140
+ kind: ValueAnchorKind;
141
+ /** The whole argument token, or the whole member run including its key. */
142
+ tokenSpan: { start: number; end: number };
143
+ /** Attribute name, or an array member's key; absent on a positional. */
144
+ name?: string;
145
+ /** The literal's source bytes, quotes included. */
146
+ valueSpan: { start: number; end: number };
147
+ /**
148
+ * Decoded scalar value, or exact source spelling when `sourceShape` is set.
149
+ *
150
+ * `str` is the exception in both directions: the leftover of a `{a=}` member
151
+ * is the DECODED name (`{"a b"=}` carries `a b`), because the bytes that
152
+ * spell it are the key's, not a value's.
153
+ */
154
+ value: string;
155
+ /** True only when one quoted run encloses the whole scalar value. */
156
+ quoted: boolean;
157
+ /**
158
+ * Present only when the SOURCE proves the type, rather than scalar decoding.
159
+ *
160
+ * `array` is proved by the delimiters. The other two are proved by an `=`
161
+ * that does not bind a key (#258): with an empty right side the device drops
162
+ * the sign and keeps the name as a `str`, and with a left side that is not a
163
+ * name the sign COMPARES, which returns `bool` whatever the operands hold.
164
+ * None of the three survives {@link valueShapeHints}, which reads a decoded
165
+ * scalar and cannot see the sign.
166
+ */
167
+ sourceShape?: "array" | "str" | "bool";
168
+ /**
169
+ * Index, in this same list, of the array literal this member belongs to.
170
+ *
171
+ * An index rather than a nested list because the list stays ONE ordered
172
+ * sequence of located values — the phase-0 normal form — and because a
173
+ * consumer that only wants spans should not have to walk a tree. Members
174
+ * follow their container immediately, so a parent index is always smaller.
175
+ */
176
+ parent?: number;
177
+ }
178
+
179
+ /** Prefix-safe value anchoring; `complete: false` explains where scanning stopped. */
180
+ export type ValueAnchorReading =
181
+ | { complete: true; anchors: ValueAnchor[] }
182
+ | { complete: false; anchors: ValueAnchor[]; why: string };
183
+
184
+ /** A refusal, carrying the reason a consumer quotes instead of a bare `unknown`. */
185
+ function unread(why: string): ArgumentsUnread {
186
+ return { read: false, why };
187
+ }
188
+
189
+ interface WalkOptions {
190
+ /** Locate `{…}`/`(…)` literals instead of refusing the statement at one. */
191
+ allowArrayValues?: boolean;
192
+ /**
193
+ * The root scripting directive whose slots decide whether a `{…}` here is an
194
+ * array literal, or undefined when this statement has none.
195
+ *
196
+ * A command argument never takes one. On CHR 7.23.3 `/console/inspect`
197
+ * classes the `{` byte `error` and `:parse` refuses the statement for
198
+ * `/ip/route/add comment={1;2}`, `/ip/dns/set servers={1.1.1.1;8.8.8.8}`
199
+ * (a LIST-typed attribute, which rules out a schema-shaped reading),
200
+ * `/interface/print .proplist={name;comment}`,
201
+ * `/ip/route/print where comment={1;2}`, the relative spelling
202
+ * `ip route add comment={1;2}`, and `:log info message={1;2}`. A `(…)`
203
+ * array is accepted in every one of those positions, so only the brace is
204
+ * gated.
205
+ *
206
+ * Being a directive is necessary but NOT sufficient — `:delay {1;2}` and
207
+ * `:local name={1;2}` are syntax errors too — so which slot it is decides:
208
+ * see {@link braceSlotTakesArray}.
209
+ */
210
+ directiveVerb?: string;
211
+ }
212
+
213
+ /** How deep member scanning descends before it stops and keeps only the shape. */
214
+ const MAX_MEMBER_DEPTH = 8;
215
+
216
+ /**
217
+ * The argument name whose value the brace at `at` opens, `null` when the brace
218
+ * IS the token (a positional value), or undefined when it opens neither.
219
+ */
220
+ function braceValueOwner(
221
+ text: string,
222
+ tokenStart: number,
223
+ at: number,
224
+ ): string | null | undefined {
225
+ if (at === tokenStart) return null;
226
+ if (text[at - 1] !== "=") return undefined;
227
+ const name = text.slice(tokenStart, at - 1);
228
+ return ARGUMENT_NAME.test(name) ? name.toLowerCase() : undefined;
229
+ }
230
+
231
+ /**
232
+ * Whether a `{` at `at` may open an array literal in this statement.
233
+ *
234
+ * `positionalIndex` is how many positionals the walk has already read, which is
235
+ * this token's own index if it turns out to be one. Position is load-bearing:
236
+ * `:local {1;2}` puts the literal in the NAME slot and does not parse, while
237
+ * `:local z {1;2}` puts it in the VALUE slot and does.
238
+ */
239
+ function braceOpensArray(
240
+ text: string,
241
+ tokenStart: number,
242
+ at: number,
243
+ positionalIndex: number,
244
+ options: WalkOptions,
245
+ ): boolean {
246
+ const verb = options.directiveVerb;
247
+ if (verb === undefined) return false;
248
+ const owner = braceValueOwner(text, tokenStart, at);
249
+ if (owner === undefined) return false;
250
+ return braceSlotTakesArray(
251
+ verb,
252
+ owner === null ? `#${positionalIndex}` : owner,
253
+ );
254
+ }
255
+
256
+ /**
257
+ * The ONE argument token walk, shared by both readings.
258
+ *
259
+ * Yields each decided token in order, or one refusal string and stops. The
260
+ * strict {@link lexArguments} discards everything on that refusal while
261
+ * {@link lexValueAnchors} keeps the prefix — but they must never disagree about
262
+ * where a token starts, ends, or becomes unreadable, so the boundary rules live
263
+ * here once.
264
+ */
265
+ function* walkArguments(
266
+ text: string,
267
+ from: number,
268
+ options: WalkOptions = {},
269
+ ): Generator<ReadArgument | string> {
270
+ const structural = maskComments(text);
271
+ let i = Math.max(0, from);
272
+ // How many positionals have been read, which is the NEXT positional's index.
273
+ let positionals = 0;
274
+ while (i < text.length) {
275
+ // The MASKED view decides whitespace, because `maskComments` blanks a
276
+ // continuation comment that `text` still spells with a `#`. Reading `text`
277
+ // here left the walk and `scanToken` disagreeing about the same byte: the
278
+ // scan broke on the masked space and returned a zero-length token, so
279
+ // `readToken` emitted an empty positional and `i` never advanced — an
280
+ // `explain` that never returns on `list={1;2} \<nl># c<nl> in=foo`. The
281
+ // strict view uses the same mask: a real continuation comment is whitespace,
282
+ // not positional arguments. Found in review of #243/#245.
283
+ const c = structural[i] as string;
284
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") {
285
+ i++;
286
+ continue;
287
+ }
288
+ // A continuation between tokens is harmless — it is whitespace RouterOS
289
+ // removes — but one INSIDE a token breaks the value's contiguity, which
290
+ // `scanToken` reports.
291
+ const continuation = continuationLength(text, i);
292
+ if (continuation > 0) {
293
+ i += continuation;
294
+ continue;
295
+ }
296
+ const token = scanToken(text, structural, i, positionals, options);
297
+ if (typeof token === "string") {
298
+ yield token;
299
+ return;
300
+ }
301
+ const read = readToken(text, structural, i, token.end, options);
302
+ if (typeof read === "string") {
303
+ yield read;
304
+ return;
305
+ }
306
+ if (read.kind === "positional") positionals++;
307
+ yield read;
308
+ i = token.end;
309
+ }
310
+ }
311
+
312
+ /**
313
+ * Lex the arguments of ONE statement, starting at `from`.
314
+ *
315
+ * `text` is the statement, `from` is where its leading run ended
316
+ * (`VerbSplitCommandReading.argsAt`). Offsets in the result are relative to
317
+ * `text`, so a caller rebases them by the statement's own span; it never throws.
318
+ *
319
+ * Only ASCII text may be passed. The analyzed surface is ASCII by construction
320
+ * (`coordinates.ts` stands one byte in for every non-ASCII one), and a caller
321
+ * that hands over the ORIGINAL text of a non-ASCII statement would get spans
322
+ * that do not map back — `src/explain.ts` verifies the two agree before calling.
323
+ */
324
+ export function lexArguments(text: string, from: number): ArgumentReading {
325
+ const tokens: Argument[] = [];
326
+ for (const step of walkArguments(text, from)) {
327
+ if (typeof step === "string") return unread(step);
328
+ const publicToken = { ...step };
329
+ delete publicToken.literalQuoted;
330
+ tokens.push(publicToken);
331
+ }
332
+
333
+ const args: Record<string, string> = {};
334
+ const queries: string[] = [];
335
+ const positional: string[] = [];
336
+ for (const token of tokens) {
337
+ if (token.kind === "attribute" && token.name !== undefined)
338
+ args[token.name] = token.value ?? "";
339
+ else if (token.kind === "query") queries.push(token.text);
340
+ else if (token.kind === "positional") positional.push(token.text);
341
+ }
342
+ return { read: true, tokens, args, queries, positional };
343
+ }
344
+
345
+ /**
346
+ * Length of the `\<newline>` continuation at `at`, or 0 — the ONE boundary rule
347
+ * this module and `verbsplit.ts`'s word scanner share.
348
+ *
349
+ * A lone `\r` is deliberately NOT a continuation. Accepting one and then
350
+ * skipping three bytes ate the following character, so an argument the source
351
+ * spells `x` + `comment=2` lexed as plain `comment=2` — a token whose reported
352
+ * name is not the one in the source.
353
+ * That is a silent misread rather than a refusal, which is the one failure this
354
+ * module must not have — and the two scanners disagreeing about where a
355
+ * continuation ends is exactly how `argsAt` and the token stream come to
356
+ * describe different text. Found in review of #202c-1; `\<CR>` alone now falls
357
+ * through to the invalid-escape refusal below.
358
+ */
359
+ function continuationLength(text: string, at: number): 0 | 2 | 3 {
360
+ if (text[at] !== "\\") return 0;
361
+ if (text[at + 1] === "\n") return 2;
362
+ if (text[at + 1] === "\r" && text[at + 2] === "\n") return 3;
363
+ return 0;
364
+ }
365
+
366
+ /**
367
+ * Find where the token starting at `start` ends, or return the reason it is not
368
+ * lexable. A quoted run is skipped by the ONE shared string scanner (#199), so a
369
+ * `"` inside this statement is read exactly as the segmenter read it.
370
+ */
371
+ function scanToken(
372
+ text: string,
373
+ structural: string,
374
+ start: number,
375
+ positionalIndex: number,
376
+ options: WalkOptions,
377
+ ): { end: number } | string {
378
+ let i = start;
379
+ while (i < text.length) {
380
+ const c = structural[i] as string;
381
+ if (c === " " || c === "\t" || c === "\r" || c === "\n") break;
382
+ if (c === '"') {
383
+ const scan = scanQuotedString(structural, i);
384
+ if (!scan.closed) return "unterminated string in an argument";
385
+ i = scan.end;
386
+ continue;
387
+ }
388
+ if (c === "[") return "a substitution or expression value";
389
+ if (c === "(" || c === "{") {
390
+ if (!options.allowArrayValues)
391
+ return c === "("
392
+ ? "a substitution or expression value"
393
+ : "an array or block value";
394
+ if (c === "{" && isScopeBrace(text, i)) return "a scope block value";
395
+ if (
396
+ c === "{" &&
397
+ !braceOpensArray(text, start, i, positionalIndex, options)
398
+ )
399
+ return "a brace value RouterOS does not read as an array here";
400
+ const end = delimitedEnd(structural, i);
401
+ if (end === null) return "an unclosed structured argument value";
402
+ if (hasUnquotedHash(structural, i, end))
403
+ return "an invalid hash in a structured argument value";
404
+ i = end;
405
+ const next = nextNonWhitespace(structural, i);
406
+ if (continuesExpression(structural, i, next))
407
+ return "an expression continuing after a structured value";
408
+ continue;
409
+ }
410
+ if (c === "$") return "a variable value";
411
+ if (c === "\\") {
412
+ if (continuationLength(text, i) > 0)
413
+ return "a line continuation inside an argument";
414
+ // The #201 rule: in code a backslash is valid only before whitespace.
415
+ // `symbols.ts` already located the defect; refusing is enough here.
416
+ if (text[i + 1] !== " " && text[i + 1] !== "\t")
417
+ return "an invalid escape in an argument";
418
+ i += 2;
419
+ continue;
420
+ }
421
+ if (c === ";") return "a statement separator inside an argument";
422
+ i++;
423
+ }
424
+ return { end: i };
425
+ }
426
+
427
+ /**
428
+ * RouterOS rejects an unquoted `#` inside array/group expressions (#245) — but
429
+ * only where the expression role actually reaches the hash.
430
+ *
431
+ * A `[…]` substitution nested in an array RE-ENTERS a statement context, and
432
+ * the device keeps the hash there as a value. A flat scan cannot tell the two
433
+ * apart, so carry the same delimiter roles `segment.ts` and `symbols.ts` use.
434
+ * Grounded on CHR 7.23.3 `/console/inspect request=highlight`, class at the `#`:
435
+ *
436
+ * `:local z {[:put #test]}` `none` — bracket restores statements
437
+ * `:local z {1;[:put #test]}` `none`
438
+ * `:local z {[:put {#test}]}` `error` — array again inside the bracket
439
+ * `:local z {[:put (1,#test)]}` `error` — group again inside the bracket
440
+ * `:local z {#test}` `error` — the plain array case
441
+ *
442
+ * Skipping whole `[…]` regions instead would wrongly accept rows 3 and 4.
443
+ */
444
+ function hasUnquotedHash(text: string, start: number, end: number): boolean {
445
+ // `start` is the `{`/`(` of an array-or-group value: not a statement context.
446
+ const statements: boolean[] = [false];
447
+ for (let i = start + 1; i < end - 1; i++) {
448
+ const c = text[i];
449
+ if (c === '"') {
450
+ i = scanQuotedString(text, i).end - 1;
451
+ continue;
452
+ }
453
+ const enclosing = statements[statements.length - 1] === true;
454
+ if (c === "[") statements.push(true);
455
+ else if (c === "{")
456
+ // Only a brace whose enclosing context ALREADY bears statements can be a
457
+ // scope; nested in an array it is another array, so the role is known
458
+ // without the reverse-prefix scan. The short-circuit is what keeps this
459
+ // O(1) per brace on the deep-nesting inputs `explain-write` and Q17 pin.
460
+ statements.push(enclosing && braceStartsStatements(text, i));
461
+ else if (c === "(") statements.push(false);
462
+ else if (c === "]" || c === "}" || c === ")") {
463
+ if (statements.length > 1) statements.pop();
464
+ } else if (c === "#" && statements[statements.length - 1] === false)
465
+ return true;
466
+ }
467
+ return false;
468
+ }
469
+
470
+ function nextNonWhitespace(text: string, from: number): number {
471
+ let i = from;
472
+ while (
473
+ text[i] === " " ||
474
+ text[i] === "\t" ||
475
+ text[i] === "\r" ||
476
+ text[i] === "\n"
477
+ )
478
+ i++;
479
+ return i;
480
+ }
481
+
482
+ function startsDottedArgument(text: string, at: number): boolean {
483
+ const match = text.slice(at).match(/^(\.[A-Za-z][A-Za-z0-9._-]*)=/);
484
+ return match !== null && ARGUMENT_NAME.test(match[1] as string);
485
+ }
486
+
487
+ /** Operators that prove a closed group is only the left side of an expression. */
488
+ function continuesExpression(
489
+ text: string,
490
+ structuredEnd: number,
491
+ at: number,
492
+ ): boolean {
493
+ const c = text[at];
494
+ // A space separates RouterOS arguments. Dotted names are legal arguments,
495
+ // so `{1;2} .proplist=.id` is not the `.` operator. Without the separation,
496
+ // `{1;2}.proplist=...` remains one expression token and must be refused.
497
+ if (c === "." && at > structuredEnd && startsDottedArgument(text, at))
498
+ return false;
499
+ if (c !== undefined && ".,+-*/%&|^~<>=!".includes(c)) return true;
500
+ return /^(?:and|or|in)(?=[ \t\r\n(])/.test(text.slice(at).toLowerCase());
501
+ }
502
+
503
+ /** One balanced `(...)`/`{...}` run, including nested groups and strings. */
504
+ function delimitedEnd(text: string, start: number): number | null {
505
+ const stack: string[] = [];
506
+ for (let i = start; i < text.length; i++) {
507
+ const c = text[i] as string;
508
+ if (c === '"') {
509
+ const scan = scanQuotedString(text, i);
510
+ if (!scan.closed) return null;
511
+ i = scan.end - 1;
512
+ continue;
513
+ }
514
+ if (c === "(" || c === "[" || c === "{") stack.push(c);
515
+ else if (c === ")" || c === "]" || c === "}") {
516
+ const want = c === ")" ? "(" : c === "]" ? "[" : "{";
517
+ if (stack.pop() !== want) return null;
518
+ if (stack.length === 0) return i + 1;
519
+ }
520
+ }
521
+ return null;
522
+ }
523
+
524
+ /** Whether an exact structured source run is an array literal, not grouping. */
525
+ function isArraySource(
526
+ text: string,
527
+ structural: string,
528
+ start: number,
529
+ end: number,
530
+ ): boolean {
531
+ const open = text[start];
532
+ if (open !== "(" && open !== "{") return false;
533
+ if (delimitedEnd(structural, start) !== end) return false;
534
+ if (open === "{")
535
+ return (
536
+ !isScopeBrace(text, start) &&
537
+ structural.slice(start + 1, end - 1).trim().length > 0
538
+ );
539
+
540
+ let depth = 0;
541
+ let memberStart = start + 1;
542
+ let sawComma = false;
543
+ for (let i = start + 1; i < end - 1; i++) {
544
+ const c = structural[i] as string;
545
+ if (c === '"') {
546
+ i = scanQuotedString(structural, i).end - 1;
547
+ continue;
548
+ }
549
+ if (c === "(" || c === "[" || c === "{") depth++;
550
+ else if (c === ")" || c === "]" || c === "}") depth--;
551
+ else if (c === "," && depth === 0) {
552
+ if (structural.slice(memberStart, i).trim().length === 0) return false;
553
+ sawComma = true;
554
+ memberStart = i + 1;
555
+ }
556
+ }
557
+ return sawComma && structural.slice(memberStart, end - 1).trim().length > 0;
558
+ }
559
+
560
+ function startsStructuredSource(text: string, start: number): boolean {
561
+ return text[start] === "(" || text[start] === "{";
562
+ }
563
+
564
+ /** Classify one already-bounded token, or say why it cannot be read. */
565
+ function readToken(
566
+ text: string,
567
+ structural: string,
568
+ start: number,
569
+ end: number,
570
+ options: WalkOptions,
571
+ ): ReadArgument | string {
572
+ const raw = text.slice(start, end);
573
+ const span = { start, end };
574
+ if (raw.startsWith("?")) {
575
+ const divergent = queryDisagreement(raw);
576
+ return divergent ?? { kind: "query", span, name: raw.slice(1), text: raw };
577
+ }
578
+ if (options.allowArrayValues && startsStructuredSource(text, start)) {
579
+ if (isArraySource(text, structural, start, end))
580
+ return {
581
+ kind: "positional",
582
+ span,
583
+ valueSpan: span,
584
+ sourceShape: "array",
585
+ text: raw,
586
+ };
587
+ return "a grouped or expression value";
588
+ }
589
+
590
+ const eq = unquotedEquals(text, start, end);
591
+ if (eq === null) {
592
+ // A positional: an id, a `where`, a `find` operand, a `:log info` message.
593
+ // It carries a literal `value` under exactly the same rule an attribute
594
+ // does, and NOTHING where that rule refuses — `"…$[:tostr $x]"` is a
595
+ // located token whose value only the device knows. That uniformity is the
596
+ // contract a consumer reads: **`value === undefined` means there is no
597
+ // literal value here**, whatever the token's kind. Refusing the statement
598
+ // instead would be wrong — the token IS decided; only its value is not,
599
+ // and a `print` positional never needed one.
600
+ const positional = literalValue(text, start, end);
601
+ return typeof positional === "string"
602
+ ? { kind: "positional", span, text: raw }
603
+ : {
604
+ kind: "positional",
605
+ span,
606
+ value: positional.value,
607
+ valueSpan: span,
608
+ literalQuoted: positional.quoted,
609
+ text: raw,
610
+ };
611
+ }
612
+ const name = text.slice(start, eq);
613
+ if (!ARGUMENT_NAME.test(name))
614
+ return `\`${name}=\` is not a RouterOS argument name`;
615
+ if (options.allowArrayValues && startsStructuredSource(text, eq + 1)) {
616
+ if (isArraySource(text, structural, eq + 1, end))
617
+ return {
618
+ kind: "attribute",
619
+ span,
620
+ name,
621
+ valueSpan: { start: eq + 1, end },
622
+ sourceShape: "array",
623
+ text: raw,
624
+ };
625
+ return "a grouped or expression value";
626
+ }
627
+ // An empty value is LEGAL (`comment=` clears it), so `literalValue` reports a
628
+ // refusal as a `string` and a decided value — empty included — as an object.
629
+ // Two different types rather than a sentinel string, which `""` would collide
630
+ // with.
631
+ const value = literalValue(text, eq + 1, end);
632
+ if (typeof value === "string") return value;
633
+ return {
634
+ kind: "attribute",
635
+ span,
636
+ name,
637
+ value: value.value,
638
+ valueSpan: { start: eq + 1, end },
639
+ literalQuoted: value.quoted,
640
+ text: raw,
641
+ };
642
+ }
643
+
644
+ /** Offset of the first `=` outside a quoted run, or null. */
645
+ function unquotedEquals(
646
+ text: string,
647
+ start: number,
648
+ end: number,
649
+ ): number | null {
650
+ let i = start;
651
+ while (i < end) {
652
+ const c = text[i] as string;
653
+ if (c === '"') {
654
+ i = scanQuotedString(text, i).end;
655
+ continue;
656
+ }
657
+ if (c === "=") return i;
658
+ if (c === "\\") {
659
+ i += 2;
660
+ continue;
661
+ }
662
+ i++;
663
+ }
664
+ return null;
665
+ }
666
+
667
+ /**
668
+ * The literal value in `[start,end)`, or the reason there is no literal one.
669
+ *
670
+ * Two shapes are literal: a bare run, and a run that is ONE fully-enclosing
671
+ * quoted string. Anything else — a partly-quoted value (`a"b"`), or a quoted
672
+ * value carrying an escape — needs a decoder this phase does not have, and a
673
+ * decoder that is wrong about one escape corrupts a write.
674
+ */
675
+ function literalValue(
676
+ text: string,
677
+ start: number,
678
+ end: number,
679
+ ): { value: string; quoted: boolean } | string {
680
+ if (start >= end) return { value: "", quoted: false };
681
+ if ((text[start] as string) === '"') {
682
+ const scan = scanQuotedString(text, start);
683
+ if (!scan.closed) return "unterminated string in an argument value";
684
+ if (scan.end !== end) return "a partly-quoted argument value";
685
+ const body = text.slice(start + 1, end - 1);
686
+ if (body.includes("\\")) return "an escape in a quoted argument value";
687
+ if (body.includes("$")) return "a substitution in a quoted argument value";
688
+ return { value: body, quoted: true };
689
+ }
690
+ const body = text.slice(start, end);
691
+ if (body.includes('"')) return "a partly-quoted argument value";
692
+ // An UNQUOTED escape needs decoding just as much as a quoted one:
693
+ // `comment=a\ b` is the value `a b` on the device, and returning the source
694
+ // run would send the backslash. `scanToken` accepts `\ `/`\\t` as part of a
695
+ // token (it is legal RouterOS), so the refusal has to happen here — the token
696
+ // is decided, its literal value is not. Found in review of #202c-1.
697
+ if (body.includes("\\")) return "an escape in an argument value";
698
+ const disagreement = gateDisagreement(body);
699
+ if (disagreement !== null) return disagreement;
700
+ return { value: body, quoted: false };
701
+ }
702
+
703
+ /**
704
+ * The same guard for a `?query` word — which is where the first two versions of
705
+ * it did NOT reach, because the query branch of {@link readToken} returns before
706
+ * any value is read (found by review of #202c-1, twice: the fix was written at
707
+ * the value level and one of the three token kinds has no value).
708
+ *
709
+ * A query is kept VERBATIM: `ArgumentsRead.queries` is source text, and a query
710
+ * word's `?` forms (`?>`, `?#|`, existence tests) are a grammar this lexer does
711
+ * not split. So the check is not about values but about whether the gate's
712
+ * tokenizer would produce the same STRING — and it is the identity only on a run
713
+ * with no quote, escape, or `\s`-but-not-ASCII-whitespace byte in it:
714
+ *
715
+ * - `'` and `\f`/`\v` — the two disagreements above, unchanged in cause.
716
+ * - `"` — the gate STRIPS quotes, so `?comment="lan uplink"` is the word
717
+ * `?comment=lan uplink` there and the source text here.
718
+ * - `\` — the gate consumes it as an escape, so `?comment=a\ b` is
719
+ * `?comment=a b` there and `?comment=a\ b` here.
720
+ *
721
+ * What survives is exactly the shape on which the gate's tokenizer is a no-op,
722
+ * which is what makes `arguments.queries` and `canonical.queries` comparable at
723
+ * all — and that comparison is now an anchored test rather than an assumption.
724
+ */
725
+ function queryDisagreement(raw: string): string | null {
726
+ const found = gateDisagreement(raw);
727
+ if (found !== null) return found;
728
+ if (raw.includes('"'))
729
+ return "a quoted query word, which centrs's execute gate unquotes and this reading keeps verbatim";
730
+ if (raw.includes("\\"))
731
+ return "an escape in a query word, which centrs's execute gate decodes and this reading keeps verbatim";
732
+ return null;
733
+ }
734
+
735
+ /**
736
+ * Characters an UNQUOTED value may not contain, because centrs's two readers
737
+ * genuinely disagree about them — so phase 1 publishes neither reading.
738
+ *
739
+ * Both cases were found by review of #202c-1, and neither is reachable from the
740
+ * corpus:
741
+ *
742
+ * - **`'`** — RouterOS does not use it as a string delimiter; it is an
743
+ * ordinary character, which is what this lexer reads. But
744
+ * `canonicalizeExecuteCommand`'s tokenizer DOES treat it as a quote
745
+ * (`src/execute.ts`), so `comment='lan uplink'` is `{comment: "lan uplink"}`
746
+ * to the gate and `{comment: "'lan"}` + a positional here, and
747
+ * `comment=it's` is `its` there and `it's` here.
748
+ * - **`\f` / `\v`** — the gate splits tokens on JavaScript `\s`, which
749
+ * includes them; every `explain` module splits on ASCII whitespace (space,
750
+ * tab, CR, LF), which does not. So `comment=x\fdisabled=no` is two
751
+ * arguments to the gate and one value here.
752
+ *
753
+ * **Refusing is not a claim that the gate is right.** The device-correct reading
754
+ * is this module's in both cases, and the gate is locked — widening or
755
+ * correcting it is a product regression (`docs/CONSTITUTION.md`). What is
756
+ * refused is putting a SECOND confident value in the same result, which is the
757
+ * contradiction `command.args` beside `canonical.args` must never contain.
758
+ *
759
+ * Two boundaries this deliberately does NOT cross:
760
+ *
761
+ * - **Only unquoted values.** Inside a `"…"` run the gate treats all three as
762
+ * content exactly as this lexer does, so the two agree and refusing there
763
+ * would cost real readings.
764
+ * - **Only these two control characters, not the gate's whole `\s` class.**
765
+ * Adopting `\s` here would put this scanner at odds with `verbsplit.ts` and
766
+ * `segment.ts` about where a token ends — two scanners disagreeing about a
767
+ * boundary is the failure the shared `continuationLength` above exists to
768
+ * prevent. The rest of `\s` is non-ASCII and never reaches this module
769
+ * as itself: `coordinates.ts` normalization stands a placeholder in, so the
770
+ * statement fails the addressability check in `src/explain.ts` first
771
+ * (verified for NBSP, U+2028, U+FEFF and U+3000).
772
+ */
773
+ function gateDisagreement(body: string): string | null {
774
+ if (body.includes("'"))
775
+ return "a single quote in an unquoted value, which centrs's execute gate and RouterOS read differently";
776
+ if (body.includes("\f") || body.includes("\v"))
777
+ return "a form feed or vertical tab in an unquoted value, which centrs's execute gate treats as a token boundary and RouterOS does not";
778
+ return null;
779
+ }
780
+
781
+ /** Trim ASCII whitespace off a range, returning the tightened bounds. */
782
+ function trimRange(
783
+ text: string,
784
+ start: number,
785
+ end: number,
786
+ ): { start: number; end: number } {
787
+ let from = start;
788
+ let to = end;
789
+ while (from < to && " \t\r\n".includes(text[from] as string)) from++;
790
+ while (to > from && " \t\r\n".includes(text[to - 1] as string)) to--;
791
+ return { start: from, end: to };
792
+ }
793
+
794
+ /**
795
+ * Split `[from,to)` on depth-0 `separator`, or null when the device would call
796
+ * the literal a syntax error.
797
+ *
798
+ * On CHR 7.23.3 an empty member is fatal — `{;}`, `{;1}`, `{1;;2}` and `(1,)`
799
+ * are all `syntax error` — with exactly one exception: a brace array tolerates
800
+ * ONE trailing separator (`{1;}` is a one-member array, `{1;2;}` a two-member
801
+ * one). Returning null rather than "no members" matters, because the enclosing
802
+ * `array` shape is wrong too when the literal does not parse.
803
+ */
804
+ function splitMembers(
805
+ structural: string,
806
+ from: number,
807
+ to: number,
808
+ separator: ";" | ",",
809
+ ): { start: number; end: number }[] | null {
810
+ const runs: { start: number; end: number }[] = [];
811
+ let memberStart = from;
812
+ for (const at of depthZeroOffsets(structural, from, to, separator)) {
813
+ runs.push({ start: memberStart, end: at });
814
+ memberStart = at + 1;
815
+ }
816
+ runs.push({ start: memberStart, end: to });
817
+ const trimmed = runs.map((run) => trimRange(structural, run.start, run.end));
818
+ const trailingEmpty =
819
+ separator === ";" &&
820
+ trimmed.length > 1 &&
821
+ (trimmed[trimmed.length - 1] as { start: number; end: number }).start ===
822
+ (trimmed[trimmed.length - 1] as { start: number; end: number }).end;
823
+ const members = trailingEmpty ? trimmed.slice(0, -1) : trimmed;
824
+ if (members.some((run) => run.start === run.end)) return null;
825
+ return members;
826
+ }
827
+
828
+ /** Why a located array literal was withdrawn; each is a device syntax error. */
829
+ const EMPTY_MEMBER = "an array literal with an empty member";
830
+ const EMPTY_GROUP = "an array member that is an empty group";
831
+ const DEPTH_BOUND_REACHED =
832
+ "an array literal nested deeper than this phase reads";
833
+ const UNPARSEABLE_MEMBER =
834
+ "an array member RouterOS rejects at its first character";
835
+ const HALF_COMPARISON =
836
+ "an array member whose `=` is missing one of its operands";
837
+
838
+ /**
839
+ * Why a `(…)` member cannot stand where it is, or null when it can.
840
+ *
841
+ * A parenthesis inside a literal is either a GROUP — `{(1)}` is the one-member
842
+ * array `1` — or a comma array, and {@link isArraySource} only recognizes the
843
+ * second. That left the first sharing the fallback branch with every other
844
+ * expression, which is an abstention, so a group the device REJECTS kept the
845
+ * enclosing `array` shape instead of withdrawing it (found in review).
846
+ *
847
+ * On CHR 7.23.3 an empty group (`{()}`, `{a=()}`) and an empty comma member
848
+ * (`{(1,)}`, `{(,)}`, `{1;(2,)}`, `(1,(2,))`) are syntax errors at every
849
+ * nesting depth, while `{(1)}`, `{a=(1)}` and `{1;(2)}` all parse — so the
850
+ * check has to name the fault rather than refuse every parenthesis.
851
+ *
852
+ * `highlight` is not the oracle here: it accepts `{1;2,}`, `{2,}` and
853
+ * `{(1,2),}`, all of which `:parse` rejects.
854
+ */
855
+ function parenMemberFault(
856
+ text: string,
857
+ structural: string,
858
+ start: number,
859
+ end: number,
860
+ ): string | null {
861
+ if (text[start] !== "(") return null;
862
+ if (delimitedEnd(structural, start) !== end) return null;
863
+ if (structural.slice(start + 1, end - 1).trim().length === 0)
864
+ return EMPTY_GROUP;
865
+ return splitMembers(structural, start + 1, end - 1, ",") === null
866
+ ? EMPTY_MEMBER
867
+ : null;
868
+ }
869
+
870
+ /** Characters that cannot OPEN an array member; see {@link pushArrayMembers}. */
871
+ function leadsInvalidMember(c: string | undefined): boolean {
872
+ return c === "*" || c === "+";
873
+ }
874
+
875
+ /**
876
+ * A member key: a bare word or one fully-enclosing quoted run.
877
+ *
878
+ * The bare spelling is {@link MEMBER_NAME}, which is NOT the identifier grammar
879
+ * and was measured rather than assumed — see that constant. Review caught the
880
+ * first version of this rejecting `.id`, which the fixture itself held as a
881
+ * valid key.
882
+ */
883
+ function memberKey(
884
+ text: string,
885
+ structural: string,
886
+ start: number,
887
+ end: number,
888
+ ): string | null {
889
+ if (start >= end) return null;
890
+ if (text[start] === '"') {
891
+ const scan = scanQuotedString(structural, start);
892
+ if (!scan.closed || scan.end !== end) return null;
893
+ const body = text.slice(start + 1, end - 1);
894
+ return body.includes("\\") || body.includes("$") ? null : body;
895
+ }
896
+ const raw = text.slice(start, end);
897
+ return MEMBER_NAME.test(raw) ? raw : null;
898
+ }
899
+
900
+ /**
901
+ * A bare array-member key, as the device spells one on 7.23.3 (#258).
902
+ *
903
+ * Swept character by character rather than borrowed from the identifier rules,
904
+ * because the two disagree in both directions. `.`, `-` and `/` are ordinary
905
+ * name bytes ANYWHERE, including alone and repeated: `{.id=1}`, `{..id=1}`,
906
+ * `{.=1}`, `{-=1}`, `{--=1}`, `{/=1}`, `{a/b=1}` and `{-.1=1}` all bind the key
907
+ * spelled exactly that way. But `_` does NOT: `{_a=1}` and `{a_b=1}` lower to
908
+ * `(= $_a 1)` and `(= $a_b 1)`, comparisons — even though `$a_b` is a perfectly
909
+ * good variable. Neither do `: * ~ % > < + ! @ #`.
910
+ *
911
+ * The review that caught `.id` is the reason this is a named constant with its
912
+ * own evidence: a key reader that quietly disagrees with the device about what
913
+ * a name IS makes every rule built on top of it wrong in the same direction.
914
+ */
915
+ const MEMBER_NAME = /^[A-Za-z0-9./-]+$/;
916
+
917
+ /**
918
+ * Characters the device proved cannot appear in a bare member key (#258).
919
+ *
920
+ * Used only to say a left side is POSITIVELY an expression, never to say it is
921
+ * a name. That asymmetry is the fix for the `.id` defect: an unrecognized
922
+ * spelling that is merely not in {@link MEMBER_NAME} abstains, while only a
923
+ * spelling holding one of these bytes is treated as a comparison and can
924
+ * withdraw its literal. Each byte is a syntax error with an empty right side
925
+ * (`{_a=}`, `{a:b=}`, `{a*b=}`, `{a~b=}`, `{a>b=}`, `{a%b=}`, `{1+1=}`,
926
+ * `{a<=}`, `{a b=}`, `{a,b=}`, `{$a=}`, `{(a)=}`, `{[:len 1]=}`, `{{1;2}=}`)
927
+ * or an operator in the IL of the same member with one.
928
+ */
929
+ const NOT_IN_MEMBER_NAME = /[\s,$([{"'+*<>!~%:_@#=]/;
930
+
931
+ /**
932
+ * Whether `[start,end)` ends on an operator with no right operand (#258).
933
+ *
934
+ * A member and a comparison's left side are both runs the device lexes on
935
+ * their own, so both carry this fault: `{-}`, `{--}`, `{a-}`, `{$a-}` and
936
+ * `{$a-;1}` are syntax errors, and so are `{a- =1}` and `{$a-=1}` where the
937
+ * dangling run is only the left side. The trailing-dot half is narrower and
938
+ * the asymmetry is the device's: `{$a.}` and `{$a.=1}` are refused while
939
+ * `{a.}`, `{.}`, `{/}`, `{a/}` and `{a. =1}` all parse as variable references
940
+ * — a bare name absorbs the dot into itself, a `$` name does not.
941
+ *
942
+ * A KEY is not an operand and does not carry it: `{a-=1}` binds the key `a-`
943
+ * and `{a-=}` is the string `a-`, so this is only asked where the sign failed
944
+ * to bind.
945
+ */
946
+ function danglesOperator(text: string, start: number, end: number): boolean {
947
+ if (start >= end) return false;
948
+ const last = text[end - 1];
949
+ if (last === "-") return true;
950
+ return last === "." && text[start] === "$";
951
+ }
952
+
953
+ /**
954
+ * Whether `[start,end)` is ONE complete operand, so a following `=` is the
955
+ * member's top-level operator (#258).
956
+ *
957
+ * This is the whole difference between a member that is provably `bool` and one
958
+ * that must abstain. `=` returns `bool` for any pair of operands, so `{$a=1}`,
959
+ * `{(a)=1}`, `{[:timestamp]=1}` and `{{1;2}=1}` are all `bool` — but only
960
+ * because the `=` is on top. `{a b=1}` lowers to `( $a (= $b 1))`, where the
961
+ * top operator is the space and the type is its operands', and `{$a=1,2}`
962
+ * lowers to `(, (= $a 1) 2)`, an `array` whose FIRST ELEMENT is the comparison.
963
+ * Both were device-typed `bool` under the bindings a probe happened to use, so
964
+ * the shape of the left side — not the observed type — has to decide.
965
+ *
966
+ * A bare name is included because it only reaches here when whitespace kept it
967
+ * from binding a key (`{a =1}` compares, `{a=1}` does not) — but OPERAND
968
+ * position is stricter than key position, so it is not simply
969
+ * {@link memberKey}: `{a-=1}` binds the key `a-`, while `{a- =1}` does not
970
+ * parse at all. Both classes therefore end on an alphanumeric here.
971
+ *
972
+ * The class is narrower than RouterOS's operand grammar on purpose, and the
973
+ * gap is all abstention: `{a_b=1}`, `{:a=1}`, `{1+1=2}` and `{$a/b=1}` are
974
+ * `bool` on the device and silent here. Widening it means proving the `=`
975
+ * stays on top for the new spelling, which is exactly what {@link MEMBER_NAME}
976
+ * had to be measured for.
977
+ */
978
+ function isSingleOperand(
979
+ text: string,
980
+ structural: string,
981
+ start: number,
982
+ end: number,
983
+ ): boolean {
984
+ if (start >= end) return false;
985
+ const open = text[start];
986
+ if (open === "(" || open === "[" || open === "{")
987
+ return delimitedEnd(structural, start) === end;
988
+ // A `$` name is NOT the member-key name, and review's suggestion to widen it
989
+ // was the wrong direction: `$:a`, `$_a`, `$/a` and `$.id` are all syntax
990
+ // errors, so the byte after the `$` must be alphanumeric, and `$a-`/`$a.`
991
+ // are errors too, so the last byte must be as well. In between, `.` and `-`
992
+ // join operands (`{$a-b=1}` is `(= (- $a $b) 1)`) and the `=` stays on top,
993
+ // while `_` and `:` end the name and start a SPACE-concat whose type is its
994
+ // operands' (`{$a_b=1}` is `( $a (= $_b 1))`).
995
+ if (open === "$")
996
+ return /^\$[A-Za-z0-9]+(?:[.-]+[A-Za-z0-9]+)*$/.test(
997
+ text.slice(start, end),
998
+ );
999
+ return (
1000
+ memberKey(text, structural, start, end) !== null &&
1001
+ /[A-Za-z0-9]$/.test(text.slice(start, end))
1002
+ );
1003
+ }
1004
+
1005
+ /**
1006
+ * Every offset of `needle` at depth 0 in `[start,end)`, strings skipped.
1007
+ *
1008
+ * Member splitting, the key split, and the nested-comma test are the same walk
1009
+ * asked three questions, so they share it: this module's standing failure mode
1010
+ * is two scanners disagreeing about one byte (see {@link continuationLength}).
1011
+ */
1012
+ function depthZeroOffsets(
1013
+ structural: string,
1014
+ start: number,
1015
+ end: number,
1016
+ needle: string,
1017
+ ): number[] {
1018
+ const found: number[] = [];
1019
+ let depth = 0;
1020
+ for (let i = start; i < end; i++) {
1021
+ const c = structural[i] as string;
1022
+ if (c === '"') {
1023
+ i = scanQuotedString(structural, i).end - 1;
1024
+ continue;
1025
+ }
1026
+ if (c === "(" || c === "[" || c === "{") depth++;
1027
+ else if (c === ")" || c === "]" || c === "}") depth--;
1028
+ else if (c === needle && depth === 0) found.push(i);
1029
+ }
1030
+ return found;
1031
+ }
1032
+
1033
+ /**
1034
+ * Append the members of one array literal, depth first (#225 V1 interior).
1035
+ *
1036
+ * Returns null when every member was read, or the REASON the literal does not
1037
+ * parse on the device — the caller withdraws the enclosing `array` shape and
1038
+ * publishes that reason, so a consumer is never told "empty member" about a
1039
+ * literal whose actual fault was something else (found in review).
1040
+ *
1041
+ * Every rule below is a CHR 7.23.3 reading, taken from each member's own
1042
+ * `:typeof` under `:foreach k,v` and the statement's `:parse` IL:
1043
+ *
1044
+ * - the separator is the DELIMITER's, not a choice: `{1;2}` has two members
1045
+ * while `{1,2}` has ONE (`(, 1 2)`, a nested array), and `(1;2)` is a
1046
+ * syntax error;
1047
+ * - `=` binds a key only in the brace form, and only where a NAME touches the
1048
+ * sign (`{a=1}` -> key `a`, value `num`); anywhere else it COMPARES, so
1049
+ * `{a =1}`, `{$a=1}` and `(a=1,b=2)` are `bool` members and not keys;
1050
+ * - a key whose right side is EMPTY is not an empty-valued key: the device
1051
+ * drops the sign and keeps the name as a positional `str` (`{a=}` -> `a`),
1052
+ * while an empty side anywhere else is a syntax error;
1053
+ * - a member that is itself `{…}`/`(…)`, or a brace member carrying a depth-0
1054
+ * comma, is an array and is descended into;
1055
+ * - everything else is offered to the literal reader, and what it refuses is
1056
+ * a variable reference or an expression on the device — which is exactly
1057
+ * what an abstention should mean.
1058
+ */
1059
+ function pushArrayMembers(
1060
+ anchors: ValueAnchor[],
1061
+ text: string,
1062
+ structural: string,
1063
+ parent: number,
1064
+ contentStart: number,
1065
+ contentEnd: number,
1066
+ separator: ";" | ",",
1067
+ depth: number,
1068
+ ): string | null {
1069
+ // Past the bound the interior is UNVERIFIED, and an unverified interior can
1070
+ // hold a fault that makes the whole statement a syntax error — `:parse`
1071
+ // rejects a `(1,)` buried nine levels deep exactly as it rejects a shallow
1072
+ // one. Returning null here called those bytes an array anyway, so the bound
1073
+ // withdraws instead. Deepest member in the 948-script corpus is 6, so this
1074
+ // costs nothing that has ever been seen.
1075
+ if (depth >= MAX_MEMBER_DEPTH) return DEPTH_BOUND_REACHED;
1076
+ const members = splitMembers(structural, contentStart, contentEnd, separator);
1077
+ if (members === null) return EMPTY_MEMBER;
1078
+ for (const member of members) {
1079
+ let valueStart = member.start;
1080
+ let valueEnd = member.end;
1081
+ let name: string | undefined;
1082
+ let sourceShape: "str" | "bool" | undefined;
1083
+ let decoded: string | undefined;
1084
+ let quoted = false;
1085
+ // A member may not OPEN with `*` or `+`: `{*1}`, `{ *1 }`, `(*1,2)`,
1086
+ // `{a=*1}` and `{+1}` are all syntax errors on 7.23.3, even though `*1` is
1087
+ // a perfectly good value one position out (`:local x *1` is typed `id`).
1088
+ // Only the first byte is read, because both characters are ordinary
1089
+ // operators between operands — `{2*3}` is `num` 6 and `{1+1}` is 2 — and
1090
+ // unary minus is fine either way (`{-1}` is `num` -1).
1091
+ if (leadsInvalidMember(text[member.start])) return UNPARSEABLE_MEMBER;
1092
+ // `@` is not a member character ANYWHERE, unlike `*`/`+` which are only
1093
+ // fatal at the front: `{a@b}`, `{@a}`, `{a@}`, `{1;a@b}`, `(a@b,2)` and
1094
+ // `{a@b=1}` are all syntax errors, while the same bytes one slot out are
1095
+ // fine (`:local z a@b` parses). Quoted runs keep it — `{"a@b"}` parses —
1096
+ // which is why this is a depth-zero scan and not a substring test.
1097
+ if (depthZeroOffsets(structural, member.start, member.end, "@").length > 0)
1098
+ return UNPARSEABLE_MEMBER;
1099
+ // A `$` takes an alphanumeric or a QUOTE and nothing else: `$.id`, `$_a`,
1100
+ // `$:a`, `$/a`, `$$a`, `$(x)`, `$[:len 1]` and a bare `$` are all syntax
1101
+ // errors, while `$"a b"` is a good quoted variable name — so "alphanumeric"
1102
+ // alone would withdraw a literal the device accepts.
1103
+ for (const at of depthZeroOffsets(
1104
+ structural,
1105
+ member.start,
1106
+ member.end,
1107
+ "$",
1108
+ ))
1109
+ if (!/[A-Za-z0-9"]/.test(text[at + 1] ?? "")) return UNPARSEABLE_MEMBER;
1110
+ if (danglesOperator(text, member.start, member.end))
1111
+ return UNPARSEABLE_MEMBER;
1112
+ const eq = depthZeroOffsets(structural, member.start, member.end, "=")[0];
1113
+ if (eq !== undefined) {
1114
+ const left = trimRange(structural, member.start, eq);
1115
+ const right = trimRange(structural, eq + 1, member.end);
1116
+ // A key binds only in the brace form, and only where the NAME TOUCHES
1117
+ // the sign: `{a=1}` is the key `a`, `{a =1}` is the comparison
1118
+ // `(= $a 1)`, and one byte of space is the whole difference (#258).
1119
+ const key =
1120
+ separator === ";" && eq === left.end
1121
+ ? memberKey(text, structural, left.start, left.end)
1122
+ : null;
1123
+ // An empty LEFT side is a syntax error in both forms and at any
1124
+ // position: `{=}`, `{=1}`, `{ =1 }`, `(=1,2)`, `(1,=2)`.
1125
+ if (left.start === left.end) return HALF_COMPARISON;
1126
+ if (key === null && right.start === right.end) {
1127
+ // An empty RIGHT side is a syntax error too — but ONLY once the left
1128
+ // side is known not to be a name, because a name means the sign is
1129
+ // dropped instead (`{a=}`). Review found the first version of this
1130
+ // asking `memberKey` that question, which withdrew `{.id=}`: a
1131
+ // literal the device accepts, whose member is the string `.id`. So
1132
+ // the fault now needs POSITIVE evidence of an expression, and a
1133
+ // spelling this reader simply does not recognize abstains.
1134
+ //
1135
+ // The paren form needs no such evidence: `=` never binds a key
1136
+ // there, so an empty right side is always a comparison missing an
1137
+ // operand (`(a=)`, `(a=,2)`).
1138
+ //
1139
+ // The span read here is the RAW one, not the trimmed `left`: the
1140
+ // whitespace between a name and the sign is itself the evidence
1141
+ // (`{a =}` and `{.id =}` are syntax errors while `{a=}` and
1142
+ // `{.id=}` are not), and trimming it away hides exactly that.
1143
+ if (
1144
+ separator === "," ||
1145
+ NOT_IN_MEMBER_NAME.test(text.slice(member.start, eq))
1146
+ )
1147
+ return HALF_COMPARISON;
1148
+ continue;
1149
+ }
1150
+ if (key !== null && right.start === right.end) {
1151
+ // `{a=}` is not an empty-valued key: the device DROPS the sign and
1152
+ // keeps the name as a positional `str` — `{1.1=}` is the string
1153
+ // `1.1`, not the address, so no member lexicon runs on it either.
1154
+ sourceShape = "str";
1155
+ decoded = key;
1156
+ quoted = text[left.start] === '"';
1157
+ valueStart = left.start;
1158
+ valueEnd = left.end;
1159
+ } else if (key !== null) {
1160
+ if (leadsInvalidMember(text[right.start])) return UNPARSEABLE_MEMBER;
1161
+ // A value that OPENS with the sign is an `=` with nothing on its
1162
+ // left, which is the same syntax error one level down: `{a==}` is
1163
+ // rejected exactly as `{=1}` is.
1164
+ if (text[right.start] === "=") return HALF_COMPARISON;
1165
+ name = key;
1166
+ valueStart = right.start;
1167
+ } else if (danglesOperator(text, left.start, left.end)) {
1168
+ // The sign's left operand is itself incomplete: `{a- =1}` and
1169
+ // `{$a-=1}` are refused exactly as the bare `{a-}` is.
1170
+ return UNPARSEABLE_MEMBER;
1171
+ } else if (
1172
+ isSingleOperand(text, structural, left.start, left.end) &&
1173
+ depthZeroOffsets(structural, member.start, member.end, ",").length === 0
1174
+ ) {
1175
+ // The left operand is a member in its own right, so it carries the
1176
+ // same faults: `{()=1}` is rejected exactly as `{()}` is, and being
1177
+ // on the left of a comparison does not excuse it.
1178
+ const leftFault = parenMemberFault(
1179
+ text,
1180
+ structural,
1181
+ left.start,
1182
+ left.end,
1183
+ );
1184
+ if (leftFault !== null) return leftFault;
1185
+ // The `=` is on top, so the member is `bool` whatever it compares.
1186
+ sourceShape = "bool";
1187
+ } else {
1188
+ // A left side that is not one operand, or a depth-zero comma that
1189
+ // outranks the sign: the device has a member, but its type is its
1190
+ // operands' and offline cannot read those.
1191
+ continue;
1192
+ }
1193
+ }
1194
+ const anchor: ValueAnchor = {
1195
+ kind: "element",
1196
+ tokenSpan: { start: member.start, end: member.end },
1197
+ ...(name === undefined ? {} : { name }),
1198
+ valueSpan: { start: valueStart, end: valueEnd },
1199
+ value: decoded ?? text.slice(valueStart, valueEnd),
1200
+ quoted,
1201
+ parent,
1202
+ };
1203
+ if (sourceShape !== undefined) {
1204
+ anchors.push({ ...anchor, sourceShape });
1205
+ continue;
1206
+ }
1207
+ const fault = parenMemberFault(text, structural, valueStart, member.end);
1208
+ if (fault !== null) return fault;
1209
+ if (isArraySource(text, structural, valueStart, member.end)) {
1210
+ const at = anchors.length;
1211
+ anchors.push({ ...anchor, sourceShape: "array" });
1212
+ const nested = pushArrayMembers(
1213
+ anchors,
1214
+ text,
1215
+ structural,
1216
+ at,
1217
+ valueStart + 1,
1218
+ member.end - 1,
1219
+ text[valueStart] === "{" ? ";" : ",",
1220
+ depth + 1,
1221
+ );
1222
+ if (nested !== null) return nested;
1223
+ continue;
1224
+ }
1225
+ if (
1226
+ separator === ";" &&
1227
+ depthZeroOffsets(structural, valueStart, member.end, ",").length > 0
1228
+ ) {
1229
+ // `{1;2,3}` is two members, the second a comma array carrying no
1230
+ // delimiters of its own — IL `1;(, 2 3)`. It splits on commas.
1231
+ const at = anchors.length;
1232
+ anchors.push({ ...anchor, sourceShape: "array" });
1233
+ const nested = pushArrayMembers(
1234
+ anchors,
1235
+ text,
1236
+ structural,
1237
+ at,
1238
+ valueStart,
1239
+ member.end,
1240
+ ",",
1241
+ depth + 1,
1242
+ );
1243
+ if (nested !== null) return nested;
1244
+ continue;
1245
+ }
1246
+ const literal = literalValue(text, valueStart, member.end);
1247
+ if (typeof literal === "string") continue;
1248
+ anchors.push({ ...anchor, value: literal.value, quoted: literal.quoted });
1249
+ }
1250
+ return null;
1251
+ }
1252
+
1253
+ /**
1254
+ * Locate the literal-value prefix of one statement's arguments (#225 V1).
1255
+ *
1256
+ * {@link lexArguments} remains all-or-nothing because its consumer may render a
1257
+ * runnable REST request. Hints are different: an unreadable later expression
1258
+ * must not erase an earlier, independently bounded literal. This scan therefore
1259
+ * reuses the exact same token and literal readers, returns every safe anchor
1260
+ * before the first refusal, and stops there. The one V2 extension is an exact
1261
+ * array literal: braces, or parentheses with a depth-zero comma, are locatable
1262
+ * without evaluating their contents. Other expressions, including `.` concat,
1263
+ * still stop the scan. The strict REST reading remains unchanged.
1264
+ *
1265
+ * A located array is then DESCENDED into, so each member is anchored in its own
1266
+ * right ({@link pushArrayMembers}); a member that does not parse withdraws the
1267
+ * whole literal, since the device rejects the statement in that case.
1268
+ */
1269
+ export function lexValueAnchors(
1270
+ text: string,
1271
+ from: number,
1272
+ options: { directiveVerb?: string } = {},
1273
+ ): ValueAnchorReading {
1274
+ const anchors: ValueAnchor[] = [];
1275
+ const structural = maskComments(text);
1276
+ for (const read of walkArguments(text, from, {
1277
+ allowArrayValues: true,
1278
+ ...(options.directiveVerb === undefined
1279
+ ? {}
1280
+ : { directiveVerb: options.directiveVerb }),
1281
+ })) {
1282
+ if (typeof read === "string")
1283
+ return { complete: false, anchors, why: read };
1284
+ if (read.kind !== "query" && read.valueSpan !== undefined) {
1285
+ if (read.sourceShape !== undefined) {
1286
+ const at = anchors.length;
1287
+ anchors.push({
1288
+ kind: read.kind,
1289
+ tokenSpan: read.span,
1290
+ ...(read.name === undefined ? {} : { name: read.name }),
1291
+ valueSpan: read.valueSpan,
1292
+ value: text.slice(read.valueSpan.start, read.valueSpan.end),
1293
+ sourceShape: read.sourceShape,
1294
+ quoted: false,
1295
+ });
1296
+ const unparsed = pushArrayMembers(
1297
+ anchors,
1298
+ text,
1299
+ structural,
1300
+ at,
1301
+ read.valueSpan.start + 1,
1302
+ read.valueSpan.end - 1,
1303
+ text[read.valueSpan.start] === "{" ? ";" : ",",
1304
+ 0,
1305
+ );
1306
+ if (unparsed !== null) {
1307
+ anchors.length = at;
1308
+ return { complete: false, anchors, why: unparsed };
1309
+ }
1310
+ continue;
1311
+ }
1312
+ if (read.value === undefined) continue;
1313
+ anchors.push({
1314
+ kind: read.kind,
1315
+ tokenSpan: read.span,
1316
+ ...(read.name === undefined ? {} : { name: read.name }),
1317
+ valueSpan: read.valueSpan,
1318
+ value: read.value,
1319
+ quoted: read.literalQuoted ?? false,
1320
+ });
1321
+ }
1322
+ }
1323
+ return { complete: true, anchors };
1324
+ }
1325
+
1326
+ /**
1327
+ * Locate top-level `{...}` values that RouterOS rejects in command arguments.
1328
+ *
1329
+ * Parentheses and substitutions restore expression context, where a brace
1330
+ * literal is legal. Stored-script attributes are also excluded: their brace is
1331
+ * a script body, not an array. The caller applies this only to non-root
1332
+ * commands; root scripting directives use the `(verb, slot)` table instead.
1333
+ */
1334
+ export function invalidCommandBraceOffsets(
1335
+ text: string,
1336
+ from: number,
1337
+ directiveVerb?: string,
1338
+ ): number[] {
1339
+ const structural = maskComments(text);
1340
+ const offsets: number[] = [];
1341
+ const stack: string[] = [];
1342
+ let i = Math.max(0, from);
1343
+ let tokenStart: number | null = null;
1344
+ let positionals = 0;
1345
+ while (i < structural.length) {
1346
+ const c = structural[i] as string;
1347
+ if (
1348
+ stack.length === 0 &&
1349
+ (c === " " || c === "\t" || c === "\r" || c === "\n")
1350
+ ) {
1351
+ if (tokenStart !== null) {
1352
+ const token = structural.slice(tokenStart, i);
1353
+ if (
1354
+ !token.startsWith("?") &&
1355
+ unquotedEquals(token, 0, token.length) === null
1356
+ )
1357
+ positionals++;
1358
+ tokenStart = null;
1359
+ }
1360
+ i++;
1361
+ continue;
1362
+ }
1363
+ if (stack.length === 0 && tokenStart === null) tokenStart = i;
1364
+ if (c === '"') {
1365
+ i = scanQuotedString(structural, i).end;
1366
+ continue;
1367
+ }
1368
+ if (c === "[" || c === "(") {
1369
+ stack.push(c);
1370
+ i++;
1371
+ continue;
1372
+ }
1373
+ if (c === "]" || c === ")") {
1374
+ const want = c === "]" ? "[" : "(";
1375
+ if (stack.at(-1) === want) stack.pop();
1376
+ i++;
1377
+ continue;
1378
+ }
1379
+ if (c !== "{" || stack.length > 0) {
1380
+ i++;
1381
+ continue;
1382
+ }
1383
+
1384
+ const end = delimitedEnd(structural, i);
1385
+ const owner =
1386
+ tokenStart === null
1387
+ ? undefined
1388
+ : braceValueOwner(structural, tokenStart, i);
1389
+ const slot = owner === null ? `#${positionals}` : owner;
1390
+ if (
1391
+ braceStartsStatements(structural, i) ||
1392
+ (directiveVerb !== undefined &&
1393
+ slot !== undefined &&
1394
+ braceSlotAcceptsBrace(directiveVerb, slot))
1395
+ ) {
1396
+ i = end ?? structural.length;
1397
+ continue;
1398
+ }
1399
+ offsets.push(i);
1400
+ i = end ?? i + 1;
1401
+ }
1402
+ return offsets;
1403
+ }