@tikoci/centrs 0.1.4 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +417 -1
  2. package/GLOSSARY.txt +40 -1
  3. package/README.md +16 -8
  4. package/commands/AGENTS.md +7 -4
  5. package/commands/api/AGENTS.md +13 -1
  6. package/commands/api/README.md +50 -10
  7. package/commands/api/examples.md +100 -2
  8. package/commands/devices/README.md +10 -1
  9. package/commands/devices/examples.md +33 -2
  10. package/commands/execute/README.md +68 -25
  11. package/commands/execute/examples.md +168 -7
  12. package/commands/explain/README.md +1206 -155
  13. package/commands/explain/examples.md +99 -3
  14. package/commands/mcp/README.md +9 -5
  15. package/commands/mcp/examples.md +2 -2
  16. package/commands/retrieve/examples.md +20 -0
  17. package/commands/terminal/examples.md +7 -1
  18. package/docs/CLI.md +3 -3
  19. package/docs/CONSTITUTION.md +83 -21
  20. package/docs/MATRIX.md +89 -52
  21. package/docs/errors/cdb/create-failed.md +20 -0
  22. package/docs/errors/cdb/file-exists.md +23 -0
  23. package/docs/errors/input/invalid-target.md +25 -3
  24. package/docs/errors/routeros/invalid-value.md +3 -2
  25. package/docs/errors/transport/cancel-unacknowledged.md +24 -0
  26. package/docs/errors/validation/syntax.md +29 -4
  27. package/package.json +13 -2
  28. package/src/api-fanout.ts +17 -4
  29. package/src/api.ts +346 -75
  30. package/src/cli/api.ts +1 -1
  31. package/src/cli/btest.ts +1 -1
  32. package/src/cli/devices.ts +16 -3
  33. package/src/cli/explain.ts +1 -1
  34. package/src/cli/terminal.ts +1 -1
  35. package/src/core/envelope.ts +53 -0
  36. package/src/core/error-catalog.ts +16 -1
  37. package/src/core/execute-command.ts +455 -0
  38. package/src/core/inspect.ts +58 -26
  39. package/src/core/routeros-errors.ts +66 -0
  40. package/src/core/version.ts +4 -0
  41. package/src/core/yaml.ts +86 -0
  42. package/src/data/winbox-cdb-write.ts +22 -8
  43. package/src/devices.ts +142 -28
  44. package/src/execute-fanout.ts +19 -5
  45. package/src/execute.ts +247 -295
  46. package/src/explain/arg-tokens.ts +62 -25
  47. package/src/explain/args.ts +398 -52
  48. package/src/explain/blocks.ts +54 -11
  49. package/src/explain/brace-tokens.ts +4 -3
  50. package/src/explain/catalog.ts +42 -44
  51. package/src/explain/coordinates.ts +159 -0
  52. package/src/explain/defects.ts +2 -1
  53. package/src/explain/escape-tokens.ts +77 -0
  54. package/src/explain/highlight-projection.ts +308 -0
  55. package/src/explain/menus.ts +9 -5
  56. package/src/explain/operator-tokens.ts +13 -7
  57. package/src/explain/operators.ts +27 -20
  58. package/src/explain/path-tokens.ts +1 -1
  59. package/src/explain/pathresolve.ts +358 -113
  60. package/src/explain/quoted-string.ts +110 -20
  61. package/src/explain/scope-brace.ts +175 -22
  62. package/src/explain/segment.ts +429 -21
  63. package/src/explain/separator.ts +187 -0
  64. package/src/explain/split-owner.ts +194 -0
  65. package/src/explain/string-tokens.ts +14 -4
  66. package/src/explain/symbol-values.ts +209 -173
  67. package/src/explain/symbols.ts +21 -1
  68. package/src/explain/transport.ts +17 -0
  69. package/src/explain/value-tokens.ts +15 -12
  70. package/src/explain/verbsplit.ts +165 -23
  71. package/src/explain/write.ts +47 -6
  72. package/src/explain.ts +522 -72
  73. package/src/index.ts +21 -2
  74. package/src/mcp/server.ts +2 -1
  75. package/src/mcp/tools.ts +1 -1
  76. package/src/offline-gate.ts +148 -0
  77. package/src/protocols/adapter.ts +12 -1
  78. package/src/protocols/mac-telnet-console.ts +6 -4
  79. package/src/protocols/native-api.ts +29 -3
  80. package/src/resolver/target.ts +32 -12
  81. package/src/retrieve-fanout.ts +1 -1
  82. package/src/retrieve.ts +18 -63
  83. package/src/settings.ts +2 -1
  84. package/src/transfer-fanout.ts +1 -1
  85. package/src/transfer.ts +1 -1
@@ -24,7 +24,10 @@ import type {
24
24
  ExplainSymbolOccurrence,
25
25
  ExplainValueOccurrence,
26
26
  } from "../explain.ts";
27
- import { scopeBlocks } from "./blocks.ts";
27
+ import { scopeBlocksIn } from "./blocks.ts";
28
+ import { documentRange, type MaskedRange, nestedRange } from "./segment.ts";
29
+ import type { SplitOwnerIndex } from "./split-owner.ts";
30
+ import { buildSplitOwnerIndex } from "./split-owner.ts";
28
31
  import type { SymbolOccurrence } from "./symbols.ts";
29
32
  import type { DocumentVerbSplit } from "./verbsplit.ts";
30
33
 
@@ -36,88 +39,118 @@ interface BlockInfo {
36
39
  branchArm?: "do" | "else";
37
40
  }
38
41
 
42
+ /**
43
+ * One binding's reaching literals at a program point.
44
+ *
45
+ * Treated as immutable: a state is replaced, never edited in place, so the same
46
+ * object can sit in the live map and in a journal entry at once. That aliasing
47
+ * is what makes an unchanged binding cost nothing at a block edge (#320).
48
+ */
39
49
  type BindingState = {
40
- valueIds: Set<string>;
41
- unknown: boolean;
50
+ readonly valueIds: ReadonlySet<string>;
51
+ readonly unknown: boolean;
42
52
  };
43
53
 
44
- function cloneStateMap(
45
- source: Map<string, BindingState>,
46
- ): Map<string, BindingState> {
47
- const out = new Map<string, BindingState>();
48
- for (const [k, v] of source)
49
- out.set(k, { valueIds: new Set(v.valueIds), unknown: v.unknown });
50
- return out;
51
- }
52
-
53
- function setsEqual(a: Set<string>, b: Set<string>): boolean {
54
+ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
54
55
  if (a.size !== b.size) return false;
55
56
  for (const v of a) if (!b.has(v)) return false;
56
57
  return true;
57
58
  }
58
59
 
59
- function mergeExhaustiveBranches(
60
- left: Map<string, BindingState>,
61
- right: Map<string, BindingState>,
62
- ): Map<string, BindingState> {
63
- const merged = new Map<string, BindingState>();
64
- const allIds = new Set<string>([...left.keys(), ...right.keys()]);
65
- for (const id of allIds) {
66
- const a = left.get(id);
67
- const b = right.get(id);
68
- if (a === undefined || b === undefined) {
69
- const present = a ?? b;
70
- if (present !== undefined)
71
- merged.set(id, {
72
- valueIds: new Set(present.valueIds),
73
- unknown: true,
74
- });
75
- continue;
76
- }
77
- merged.set(id, {
78
- valueIds: new Set([...a.valueIds, ...b.valueIds]),
79
- unknown: a.unknown || b.unknown,
80
- });
60
+ /**
61
+ * Merge one binding across a non-exhaustive block edge: the block may not have
62
+ * run, so the state after it admits both the pre-block value and whatever the
63
+ * block left. A loop body may also have run more than once, which is what makes
64
+ * a changed binding `unknown` even when both ends spell the same literal.
65
+ *
66
+ * Returning `snap` itself when the block did not change the binding is
67
+ * deliberate: it is the case that used to cost a full map copy.
68
+ */
69
+ function mergeBlockExit(
70
+ snap: BindingState | undefined,
71
+ current: BindingState,
72
+ isLoop: boolean,
73
+ ): BindingState {
74
+ // Introduced inside the block: after the block it may never have been set.
75
+ if (snap === undefined)
76
+ return { valueIds: new Set(current.valueIds), unknown: true };
77
+ if (
78
+ !snap.unknown &&
79
+ !current.unknown &&
80
+ setsEqual(snap.valueIds, current.valueIds)
81
+ )
82
+ return snap;
83
+ return {
84
+ valueIds: new Set([...snap.valueIds, ...current.valueIds]),
85
+ unknown: snap.unknown || current.unknown || isLoop,
86
+ };
87
+ }
88
+
89
+ /**
90
+ * Merge one binding across an exhaustive `if`/`else` pair. Exactly one arm runs,
91
+ * so a value seen in both arms stays known and the reaching set is their union;
92
+ * a binding only one arm defines is `unknown` after the merge.
93
+ */
94
+ function mergeBranchArms(
95
+ left: BindingState | undefined,
96
+ right: BindingState | undefined,
97
+ ): BindingState | undefined {
98
+ if (left === undefined || right === undefined) {
99
+ const present = left ?? right;
100
+ if (present === undefined) return undefined;
101
+ return { valueIds: new Set(present.valueIds), unknown: true };
81
102
  }
82
- return merged;
103
+ return {
104
+ valueIds: new Set([...left.valueIds, ...right.valueIds]),
105
+ unknown: left.unknown || right.unknown,
106
+ };
83
107
  }
84
108
 
85
109
  /**
86
- * Collect all scope blocks in `text`, rebased to `base`, recursively.
110
+ * Collect all scope blocks in `range`, recursively.
87
111
  *
88
112
  * `splits` are the flattened statement splits for the same document; they are
89
113
  * used to decide whether a `do={...}` body belongs to a loop verb (`foreach`,
90
114
  * `for`, `while`) or to a branch (`if`, `do`, `else`, `on-error`).
115
+ *
116
+ * The range replaces the `(text, base)` pair it used to take — `base` was
117
+ * already the body's offset in the document, so nothing is lost, and the
118
+ * document's comment mask now descends with it instead of being re-derived over
119
+ * each nested body. A body carries every body below it, so that re-derivation
120
+ * was the O(bytes x depth) term (#322).
91
121
  */
92
122
  function collectBlocks(
93
- text: string,
94
- base: number,
95
- analyzed: string,
123
+ range: MaskedRange,
124
+ owners: SplitOwnerIndex,
96
125
  splits: readonly DocumentVerbSplit[],
97
126
  ): BlockInfo[] {
98
127
  const out: BlockInfo[] = [];
99
- const blocks = scopeBlocks(text).map((block) => {
100
- const start = base + block.start;
128
+ const blocks = scopeBlocksIn(range).map((block) => {
129
+ const start = range.start + block.start;
101
130
  const bracePos = start - 1;
102
- const ownerIndex = owningSplitIndex(
103
- analyzed,
104
- splits,
105
- bracePos,
106
- bracePos + 1,
107
- );
131
+ const ownerIndex = owners.ownerOf(bracePos, bracePos + 1);
108
132
  return {
109
133
  block,
110
134
  ownerIndex,
111
135
  owner: ownerIndex === undefined ? undefined : splits[ownerIndex],
112
136
  };
113
137
  });
138
+ // Group the siblings once. Re-filtering `blocks` inside a loop over
139
+ // `blocks` is the same quadratic shape #317 removed from the owner lookup,
140
+ // and a `do={…}`-heavy document grows both factors together.
141
+ const byOwner = new Map<number, typeof blocks>();
142
+ for (const entry of blocks) {
143
+ if (entry.ownerIndex === undefined) continue;
144
+ const list = byOwner.get(entry.ownerIndex);
145
+ if (list === undefined) byOwner.set(entry.ownerIndex, [entry]);
146
+ else list.push(entry);
147
+ }
114
148
  const exhaustiveGroups = new Set<number>();
115
- for (const candidate of blocks) {
116
- if (candidate.ownerIndex === undefined) continue;
117
- if (candidate.owner?.verb?.toLowerCase() !== "if") continue;
118
- const siblings = blocks.filter(
119
- (other) => other.ownerIndex === candidate.ownerIndex,
120
- );
149
+ // One pass per owner, not per block: every sibling of an `if` reaches the
150
+ // same verdict, so asking each of them re-runs both filters over the whole
151
+ // group. Siblings share an owner index, so they share the owner itself.
152
+ for (const [ownerIndex, siblings] of byOwner) {
153
+ if (siblings[0]?.owner?.verb?.toLowerCase() !== "if") continue;
121
154
  const doBlocks = siblings.filter(
122
155
  (other) => other.block.name.toLowerCase() === "do",
123
156
  );
@@ -130,11 +163,11 @@ function collectBlocks(
130
163
  (doBlocks[0]?.block.start ?? Number.POSITIVE_INFINITY) <
131
164
  (elseBlocks[0]?.block.start ?? Number.NEGATIVE_INFINITY)
132
165
  )
133
- exhaustiveGroups.add(candidate.ownerIndex);
166
+ exhaustiveGroups.add(ownerIndex);
134
167
  }
135
168
 
136
169
  for (const { block: b, ownerIndex, owner } of blocks) {
137
- const start = base + b.start;
170
+ const start = range.start + b.start;
138
171
  const end = start + b.body.length;
139
172
  // If source mapping cannot identify the owning statement, use loop-like
140
173
  // merging: an incomplete reaching set is safer than treating an unknown
@@ -156,7 +189,13 @@ function collectBlocks(
156
189
  ? { branchGroup: ownerIndex, branchArm }
157
190
  : {}),
158
191
  });
159
- out.push(...collectBlocks(b.body, start, analyzed, splits));
192
+ out.push(
193
+ ...collectBlocks(
194
+ nestedRange(range, b.start, b.start + b.body.length),
195
+ owners,
196
+ splits,
197
+ ),
198
+ );
160
199
  }
161
200
  return out;
162
201
  }
@@ -170,35 +209,11 @@ function collectBlocks(
170
209
  * link `i` to the `in` attribute. Only the same statement's values are
171
210
  * candidates, which also prevents cross-statement mis-linking.
172
211
  */
173
- function owningSplitIndex(
174
- analyzed: string,
175
- splits: readonly DocumentVerbSplit[],
176
- start: number,
177
- end: number,
178
- ): number | undefined {
179
- let owner: number | undefined;
180
- let ownerLength = Number.POSITIVE_INFINITY;
181
- for (let i = 0; i < splits.length; i++) {
182
- const split = splits[i] as DocumentVerbSplit;
183
- if (split.span.start > start || end > split.span.end) continue;
184
- // A widened fallback span cannot prove statement ownership. Match the
185
- // same addressability gate used by `valuesOf`/`argumentsOf`.
186
- if (analyzed.slice(split.span.start, split.span.end) !== split.text)
187
- continue;
188
- const length = split.span.end - split.span.start;
189
- if (length < ownerLength) {
190
- owner = i;
191
- ownerLength = length;
192
- }
193
- }
194
- return owner;
195
- }
196
-
197
212
  function defValueId(
198
213
  occurrence: SymbolOccurrence,
199
214
  splitIndex: number | undefined,
200
215
  values: readonly ExplainValueOccurrence[],
201
- valueSplitIndexes: readonly (number | undefined)[],
216
+ positionalValuesBySplit: ReadonlyMap<number, readonly number[]>,
202
217
  ): string | undefined {
203
218
  if (
204
219
  occurrence.role !== "declaration" &&
@@ -217,18 +232,15 @@ function defValueId(
217
232
 
218
233
  let best: ExplainValueOccurrence | undefined;
219
234
  let bestDist = Number.POSITIVE_INFINITY;
220
- for (let i = 0; i < values.length; i++) {
235
+ // Parent statement spans include their scope bodies. Requiring the
236
+ // innermost addressable statement to match prevents `:local f do={:put 1}`
237
+ // from claiming the body's `1`, and prevents one branch from lending a
238
+ // literal to a non-literal definition in another branch. That same
239
+ // requirement is what lets the candidates be bucketed per statement once
240
+ // instead of re-scanned per symbol (#317).
241
+ for (const i of positionalValuesBySplit.get(splitIndex) ?? []) {
221
242
  const value = values[i] as ExplainValueOccurrence;
222
- // Parent statement spans include their scope bodies. Requiring the
223
- // innermost addressable statement to match prevents `:local f do={:put
224
- // 1}` from claiming the body's `1`, and prevents one branch from lending
225
- // a literal to a non-literal definition in another branch.
226
- if (valueSplitIndexes[i] !== splitIndex) continue;
227
- if (value.kind === "element") continue;
228
243
  if (value.span.start <= occurrence.start) continue;
229
- // RouterOS declaration/assignment RHS syntax is positional. Attribute
230
- // values in the same statement belong to the directive, not this symbol.
231
- if (value.kind !== "positional") continue;
232
244
  const dist = value.span.start - occurrence.end;
233
245
  if (dist < 0 || dist >= bestDist) continue;
234
246
  bestDist = dist;
@@ -252,12 +264,25 @@ export function augmentSymbolOccurrences(
252
264
  splits: readonly DocumentVerbSplit[],
253
265
  ): ExplainSymbolOccurrence[] {
254
266
  const augmented: ExplainSymbolOccurrence[] = [];
267
+ const owners = buildSplitOwnerIndex(analyzed, splits);
255
268
  const symbolSplitIndexes = symbols.map((occ) =>
256
- owningSplitIndex(analyzed, splits, occ.start, occ.end),
257
- );
258
- const valueSplitIndexes = values.map((value) =>
259
- owningSplitIndex(analyzed, splits, value.span.start, value.span.end),
269
+ owners.ownerOf(occ.start, occ.end),
260
270
  );
271
+ // RouterOS declaration/assignment RHS syntax is positional, so an attribute
272
+ // value in the same statement belongs to the directive, not to the symbol —
273
+ // `:foreach i in={…}` must not link `i` to the `in` attribute. Bucketing
274
+ // only the positional values keeps `defValueId`'s search inside the one
275
+ // statement that can supply the RHS.
276
+ const positionalValuesBySplit = new Map<number, number[]>();
277
+ for (let i = 0; i < values.length; i++) {
278
+ const value = values[i] as ExplainValueOccurrence;
279
+ if (value.kind !== "positional") continue;
280
+ const splitIndex = owners.ownerOf(value.span.start, value.span.end);
281
+ if (splitIndex === undefined) continue;
282
+ const list = positionalValuesBySplit.get(splitIndex);
283
+ if (list === undefined) positionalValuesBySplit.set(splitIndex, [i]);
284
+ else list.push(i);
285
+ }
261
286
  const valueOrder = new Map(values.map((value, index) => [value.id, index]));
262
287
  // Map from occurrence index -> valueId for quick lookup during flow pass
263
288
  const defMap = new Map<number, string>();
@@ -267,12 +292,12 @@ export function augmentSymbolOccurrences(
267
292
  occ,
268
293
  symbolSplitIndexes[i],
269
294
  values,
270
- valueSplitIndexes,
295
+ positionalValuesBySplit,
271
296
  );
272
297
  if (vid !== undefined) defMap.set(i, vid);
273
298
  }
274
299
 
275
- const blocks = collectBlocks(analyzed, 0, analyzed, splits);
300
+ const blocks = collectBlocks(documentRange(analyzed), owners, splits);
276
301
  type FlowEvent =
277
302
  | {
278
303
  offset: number;
@@ -331,18 +356,51 @@ export function augmentSymbolOccurrences(
331
356
 
332
357
  // Occurrences are already sorted by start
333
358
  const ordered = symbols.map((occ, index) => ({ occ, index }));
334
- // Flow state
335
- let current = new Map<string, BindingState>();
336
- const stack: {
337
- snapshot: Map<string, BindingState>;
359
+ // Flow state.
360
+ //
361
+ // One live map, plus a journal per open block recording the pre-block value
362
+ // of every binding that block writes. A block edge then costs what the block
363
+ // CHANGED, not what the document declares (#320): snapshotting the whole map
364
+ // at every enter and rebuilding it at every exit made a branch-dense script
365
+ // quadratic — 3.1 M binding copies on 89 KiB — because blocks and bindings
366
+ // both grow with the document.
367
+ const current = new Map<string, BindingState>();
368
+ type Frame = {
369
+ /** binding id -> its value when this block was entered (absent = new). */
370
+ journal: Map<string, BindingState | undefined>;
338
371
  isLoop: boolean;
339
372
  branchGroup?: number;
340
373
  branchArm?: "do" | "else";
341
- }[] = [];
374
+ };
375
+ const stack: Frame[] = [];
376
+ /** Per exhaustive `if` group: the `do` arm's pre/post pair per touched id. */
342
377
  const branchGroups = new Map<
343
378
  number,
344
- { doFinal: Map<string, BindingState> }
379
+ Map<string, { snap: BindingState | undefined; doFinal: BindingState }>
345
380
  >();
381
+ /** Record a binding's pre-block value once per frame; later writes keep it. */
382
+ const note = (
383
+ frame: Frame | undefined,
384
+ id: string,
385
+ before: BindingState | undefined,
386
+ ): void => {
387
+ if (frame !== undefined && !frame.journal.has(id))
388
+ frame.journal.set(id, before);
389
+ };
390
+ const setBinding = (id: string, state: BindingState): void => {
391
+ note(stack[stack.length - 1], id, current.get(id));
392
+ current.set(id, state);
393
+ };
394
+ /** Write a merged/rewound value for a binding the enclosing frame now owns. */
395
+ const applyToParent = (
396
+ id: string,
397
+ before: BindingState | undefined,
398
+ after: BindingState | undefined,
399
+ ): void => {
400
+ note(stack[stack.length - 1], id, before);
401
+ if (after === undefined) current.delete(id);
402
+ else current.set(id, after);
403
+ };
346
404
  let eventIdx = 0;
347
405
  const processEventsUpTo = (pos: number): void => {
348
406
  while (eventIdx < events.length) {
@@ -351,86 +409,64 @@ export function augmentSymbolOccurrences(
351
409
  eventIdx++;
352
410
  if (ev.type === "unknown-definition") {
353
411
  for (const bid of ev.bindingIds)
354
- current.set(bid, { valueIds: new Set(), unknown: true });
412
+ setBinding(bid, { valueIds: new Set(), unknown: true });
355
413
  continue;
356
414
  }
357
415
  if (ev.type === "enter") {
358
416
  stack.push({
359
- snapshot: cloneStateMap(current),
417
+ journal: new Map(),
360
418
  isLoop: ev.isLoop,
361
419
  ...(ev.branchGroup === undefined || ev.branchArm === undefined
362
420
  ? {}
363
421
  : { branchGroup: ev.branchGroup, branchArm: ev.branchArm }),
364
422
  });
365
- } else {
366
- const top = stack.pop();
367
- if (top === undefined) continue;
368
- const snapshot = top.snapshot;
369
- const isLoop = top.isLoop;
370
- if (top.branchGroup !== undefined && top.branchArm === "do") {
371
- branchGroups.set(top.branchGroup, {
372
- doFinal: cloneStateMap(current),
373
- });
374
- current = cloneStateMap(snapshot);
375
- continue;
376
- }
377
- if (top.branchGroup !== undefined && top.branchArm === "else") {
378
- const group = branchGroups.get(top.branchGroup);
379
- if (group !== undefined) {
380
- current = mergeExhaustiveBranches(group.doFinal, current);
381
- branchGroups.delete(top.branchGroup);
382
- continue;
383
- }
384
- }
385
- const merged = cloneStateMap(snapshot);
386
- const allIds = new Set<string>([...snapshot.keys(), ...current.keys()]);
387
- for (const id of allIds) {
388
- const snap = snapshot.get(id);
389
- const cur = current.get(id);
390
- if (snap === undefined && cur !== undefined) {
391
- // Defined inside block only — after block it may not have run
392
- merged.set(id, {
393
- valueIds: new Set(cur.valueIds),
394
- unknown: true,
395
- });
396
- } else if (snap !== undefined && cur !== undefined) {
397
- const equal =
398
- !snap.unknown &&
399
- !cur.unknown &&
400
- setsEqual(snap.valueIds, cur.valueIds);
401
- if (!equal) {
402
- const union = new Set<string>([
403
- ...snap.valueIds,
404
- ...cur.valueIds,
405
- ]);
406
- const unknown = snap.unknown || cur.unknown || isLoop;
407
- merged.set(id, { valueIds: union, unknown });
408
- } else if (snap.unknown || cur.unknown) {
409
- merged.set(id, {
410
- valueIds: new Set(snap.valueIds),
411
- unknown: true,
412
- });
413
- }
414
- }
423
+ continue;
424
+ }
425
+ const top = stack.pop();
426
+ if (top === undefined) continue;
427
+ // A binding the block never wrote has the same state on both sides of
428
+ // the edge, and every merge rule below maps that pair back to itself.
429
+ // So the journal — the bindings the block DID write — is the whole
430
+ // edge, whatever else the document has declared.
431
+ if (top.branchGroup !== undefined && top.branchArm === "do") {
432
+ const arm = new Map<
433
+ string,
434
+ { snap: BindingState | undefined; doFinal: BindingState }
435
+ >();
436
+ for (const [id, snap] of top.journal) {
437
+ arm.set(id, { snap, doFinal: current.get(id) as BindingState });
438
+ // Rewind: the `else` arm starts from the state before the `if`.
439
+ if (snap === undefined) current.delete(id);
440
+ else current.set(id, snap);
415
441
  }
416
- // Loops make every variable assigned inside unknown after exit,
417
- // even if the pre/post sets happen to match (e.g. same literal)
418
- if (isLoop) {
419
- for (const id of current.keys()) {
420
- const snap = snapshot.get(id);
421
- const cur = current.get(id);
422
- if (cur === undefined) continue;
423
- if (snap === undefined) {
424
- const m = merged.get(id);
425
- if (m !== undefined) m.unknown = true;
426
- } else if (!setsEqual(snap.valueIds, cur.valueIds)) {
427
- const m = merged.get(id);
428
- if (m !== undefined) m.unknown = true;
429
- }
442
+ branchGroups.set(top.branchGroup, arm);
443
+ continue;
444
+ }
445
+ if (top.branchGroup !== undefined && top.branchArm === "else") {
446
+ const arm = branchGroups.get(top.branchGroup);
447
+ if (arm !== undefined) {
448
+ const touched = new Set<string>([
449
+ ...arm.keys(),
450
+ ...top.journal.keys(),
451
+ ]);
452
+ for (const id of touched) {
453
+ const recorded = arm.get(id);
454
+ // Untouched by one arm means that arm left the pre-`if` value.
455
+ const before =
456
+ recorded === undefined ? top.journal.get(id) : recorded.snap;
457
+ const left = recorded === undefined ? before : recorded.doFinal;
458
+ applyToParent(id, before, mergeBranchArms(left, current.get(id)));
430
459
  }
460
+ branchGroups.delete(top.branchGroup);
461
+ continue;
431
462
  }
432
- current = merged;
433
463
  }
464
+ for (const [id, snap] of top.journal)
465
+ applyToParent(
466
+ id,
467
+ snap,
468
+ mergeBlockExit(snap, current.get(id) as BindingState, top.isLoop),
469
+ );
434
470
  }
435
471
  };
436
472
 
@@ -453,7 +489,7 @@ export function augmentSymbolOccurrences(
453
489
  (base as { valueId?: string }).valueId = vid;
454
490
  // Update flow state for this binding
455
491
  for (const bid of occ.bindingIds) {
456
- current.set(bid, { valueIds: new Set([vid]), unknown: false });
492
+ setBinding(bid, { valueIds: new Set([vid]), unknown: false });
457
493
  }
458
494
  } else if (
459
495
  occ.role === "declaration" ||
@@ -463,7 +499,7 @@ export function augmentSymbolOccurrences(
463
499
  // Defining occurrence with no literal RHS — introduces unknown
464
500
  if (occ.bindingIds.length > 0 && !deferredDefinitions.has(index)) {
465
501
  for (const bid of occ.bindingIds) {
466
- current.set(bid, { valueIds: new Set(), unknown: true });
502
+ setBinding(bid, { valueIds: new Set(), unknown: true });
467
503
  }
468
504
  }
469
505
  } else if (occ.role === "reference" || occ.role === "field") {
@@ -477,7 +477,10 @@ const isIdentStart = (c: string): boolean => /[A-Za-z_]/.test(c);
477
477
  export function resolveSymbols(original: string): SymbolAnalysis {
478
478
  const analysis = analyzeCoordinates(original);
479
479
  // `analyzed` is pure ASCII, so a string built from it has index === byte.
480
- const text = new TextDecoder().decode(analysis.analyzed);
480
+ // An already-ASCII input is that string, so the decode is skipped (#322).
481
+ const text = analysis.ascii
482
+ ? original
483
+ : new TextDecoder().decode(analysis.analyzed);
481
484
 
482
485
  // S7's "inside a `(…)` expression" test, precomputed in one forward pass.
483
486
  // Scanning backward per bare word was O(n²) on a long single-line script: an
@@ -875,6 +878,23 @@ export function resolveSymbols(original: string): SymbolAnalysis {
875
878
  break;
876
879
  }
877
880
 
881
+ // --- `'` is not a RouterOS token (#355) ---------------------------------
882
+ //
883
+ // Same fail-closed stop as the malformed escape above, for the same reason:
884
+ // the device rejects at this byte and classes nothing after it, so carrying
885
+ // confident symbol classes across it would publish facts about bytes the
886
+ // console never reached. `:put 'abc'; :put $after` must not name `$after`.
887
+ //
888
+ // This walk reaches here only in CODE — a `"` string is consumed by the
889
+ // quote phase above and a comment by the `#` branch below — so an
890
+ // apostrophe inside either never trips it, matching `segment.ts`.
891
+ // Occurrences BEFORE the defect stand (the lab's X1 rule).
892
+ if (c === "'") {
893
+ defects.push(defectAt("invalid-apostrophe", i, "'"));
894
+ defect = true;
895
+ break;
896
+ }
897
+
878
898
  // --- comments: `#` in statement-leading position (Q1 rule H4) ------------
879
899
  //
880
900
  // Done inline rather than through `segment.ts`'s shared `maskComments`:
@@ -101,6 +101,16 @@ export interface ExplainTransportInput {
101
101
  arguments: ExplainArguments;
102
102
  /** The statement exactly as written, for the execute invocation. */
103
103
  source: string;
104
+ /**
105
+ * Why the statement is not accepted by RouterOS, when the analysis has decided
106
+ * that it is not (#311).
107
+ *
108
+ * Distinct from an unread argument list, which means "offline cannot name the
109
+ * request" and correctly falls through to `execute` — the raw CLI surface can
110
+ * still carry those bytes. Here the bytes themselves are the defect, so no
111
+ * centrs surface should be offered for them and none is rendered.
112
+ */
113
+ rejected?: string;
104
114
  }
105
115
 
106
116
  export interface ExplainTransportOptions {
@@ -424,6 +434,13 @@ export function classifyExplainTransport(
424
434
  const { path, verb, args = {} } = input.command;
425
435
  const lower = verb.toLowerCase();
426
436
 
437
+ // Before every mapping rule: a statement the analysis has REJECTED gets no
438
+ // invocation at all. Falling through to `execute` here would print a
439
+ // ready-to-paste `centrs execute` line for bytes the same result declares a
440
+ // syntax error, which is the false confident reading in a second place.
441
+ if (input.rejected !== undefined)
442
+ return unknown(input.rejected, options.evidenceId);
443
+
427
444
  // Root scripting directives have no RouterOS REST path. They always ride the
428
445
  // raw execute surface, regardless of whether their operands are literal.
429
446
  if (path === "/")
@@ -2,11 +2,11 @@
2
2
  * B2 value fill — claims argument value bytes and array-literal interiors on the residual.
3
3
  *
4
4
  * This is the third B2 fill of #264, after the argument `name=` fill. It runs
5
- * **after** proof-only `spans` (comment + variable-*) and the `arg` fill and
6
- * **before** the operator fill, so the fill order is structural:
7
- * `spans → arg → value → operator`. That ordering retires the glued-dot residue
8
- * already noted in `operator-tokens.ts`: once value bytes are claimed a `.`
9
- * inside a value is no longer read as concatenation.
5
+ * **after** proof-only `spans` (comment + variable-*), the `arg` fill and the
6
+ * `escaped` fill, and **before** the operator fill, so the fill order is
7
+ * structural: `spans → arg → escaped → value → operator`. That ordering retires
8
+ * the glued-dot residue already noted in `operator-tokens.ts`: once value bytes
9
+ * are claimed a `.` inside a value is no longer read as concatenation.
10
10
  *
11
11
  * Source is `data.values.occurrences` (already rebased into document analyzed-byte
12
12
  * space by `src/explain.ts`), not `Argument.valueSpan`. That is the load-bearing
@@ -20,14 +20,17 @@
20
20
  * that is nobody's `parent`. Container detection is `Set` of `parent` ids — no
21
21
  * tree walk, members follow their container in the same flat list.
22
22
  *
23
- * `span` is quotes-INCLUDED by contract (deliberate; the quotes/escapes fill is
24
- * a separate staging row and must not double-claim them).
23
+ * `span` is quotes-INCLUDED by contract, and the quotes stay part of the `value`
24
+ * run — a consumer recovers them from its own first and last byte (#264 B5).
25
+ * Escapes do NOT: the `escaped` fill claims them one stage earlier, so a value
26
+ * carrying one arrives here already holed and this fill emits a fragment either
27
+ * side. Nothing double-claims, because that fill offered only residual.
25
28
  *
26
- * Vocabulary is provisional until #264 B5: one `value` class for every leaf span,
27
- * regardless of shape/observed/schema type — the three #225 facts stay where
28
- * they already are, in `data.values[]`, joined by offset. Do not read
29
- * `ExplainValueOccurrence.facts` to pick a class. Whether `value` later splits
30
- * per shape is B5 and does not move byte coverage.
29
+ * One `value` class for every leaf span, regardless of shape/observed/schema
30
+ * type — the three #225 facts stay where they already are, in `data.values[]`,
31
+ * joined by offset. Do not read `ExplainValueOccurrence.facts` to pick a class.
32
+ * #264 B5 kept this merge for that reason: a class per shape would be a fourth
33
+ * copy of those axes, free to contradict the three that are already published.
31
34
  *
32
35
  * ~1,720 leaf bytes are already held by `variable-*` spans (a variable inside
33
36
  * a quoted value). Reuses the clipping shape from `arg-tokens.ts`: offer only