@tikoci/centrs 0.1.3 → 0.1.6

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 (107) hide show
  1. package/CHANGELOG.md +348 -0
  2. package/GLOSSARY.txt +252 -0
  3. package/LICENSE +21 -0
  4. package/README.md +44 -12
  5. package/commands/AGENTS.md +14 -5
  6. package/commands/api/AGENTS.md +13 -1
  7. package/commands/api/README.md +56 -26
  8. package/commands/api/examples.md +46 -1
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +6 -1
  11. package/commands/discover/README.md +5 -11
  12. package/commands/execute/README.md +77 -36
  13. package/commands/execute/examples.md +120 -10
  14. package/commands/explain/README.md +2756 -0
  15. package/commands/explain/examples.md +702 -0
  16. package/commands/mcp/README.md +7 -4
  17. package/commands/mcp/examples.md +2 -2
  18. package/commands/retrieve/README.md +25 -27
  19. package/commands/retrieve/examples.md +16 -0
  20. package/commands/terminal/examples.md +7 -1
  21. package/commands/transfer/README.md +28 -20
  22. package/commands/transfer/examples.md +29 -0
  23. package/docs/CLI.md +353 -0
  24. package/docs/CONSTITUTION.md +138 -30
  25. package/docs/MATRIX.md +92 -18
  26. package/docs/errors/README.md +3 -0
  27. package/docs/errors/quickchr/machine-not-found.md +22 -0
  28. package/docs/errors/quickchr/machine-stopped.md +20 -0
  29. package/docs/errors/quickchr/package-unavailable.md +27 -0
  30. package/docs/errors/quickchr/unsupported-via.md +35 -0
  31. package/docs/errors/quickchr/unsupported.md +35 -0
  32. package/docs/errors/routeros/version-unsupported.md +26 -0
  33. package/docs/errors/transport/provider-trust.md +29 -0
  34. package/docs/errors/usage/stdin-ignored.md +28 -0
  35. package/docs/errors/validation/syntax.md +29 -4
  36. package/docs/errors/validation/unknown-attribute.md +37 -4
  37. package/package.json +56 -10
  38. package/src/api-fanout.ts +46 -18
  39. package/src/api.ts +351 -121
  40. package/src/cli/api.ts +26 -7
  41. package/src/cli/btest.ts +19 -3
  42. package/src/cli/execute.ts +25 -6
  43. package/src/cli/explain.ts +503 -0
  44. package/src/cli/retrieve.ts +33 -1
  45. package/src/cli/selection.ts +71 -11
  46. package/src/cli/settings.ts +2 -2
  47. package/src/cli/terminal.ts +1 -1
  48. package/src/cli/transfer.ts +23 -6
  49. package/src/cli.ts +15 -2
  50. package/src/core/envelope.ts +58 -2
  51. package/src/core/error-catalog.ts +42 -1
  52. package/src/core/execute-command.ts +148 -0
  53. package/src/core/inspect.ts +36 -4
  54. package/src/core/preflight.ts +306 -0
  55. package/src/core/routeros-string.ts +24 -0
  56. package/src/core/version.ts +4 -0
  57. package/src/core/yaml.ts +86 -0
  58. package/src/errors.ts +1 -0
  59. package/src/execute-fanout.ts +47 -18
  60. package/src/execute.ts +339 -307
  61. package/src/explain/arg-tokens.ts +151 -0
  62. package/src/explain/args.ts +1749 -0
  63. package/src/explain/blocks.ts +175 -0
  64. package/src/explain/brace-slots.ts +143 -0
  65. package/src/explain/brace-tokens.ts +39 -0
  66. package/src/explain/catalog.ts +1343 -0
  67. package/src/explain/coordinates.ts +488 -0
  68. package/src/explain/defects.ts +218 -0
  69. package/src/explain/escape-tokens.ts +77 -0
  70. package/src/explain/highlight-projection.ts +308 -0
  71. package/src/explain/is-known-menu.ts +17 -0
  72. package/src/explain/menus.ts +668 -0
  73. package/src/explain/operator-tokens.ts +316 -0
  74. package/src/explain/operators.ts +465 -0
  75. package/src/explain/path-tokens.ts +150 -0
  76. package/src/explain/pathresolve.ts +1569 -0
  77. package/src/explain/quoted-string.ts +331 -0
  78. package/src/explain/scope-brace.ts +461 -0
  79. package/src/explain/segment.ts +1165 -0
  80. package/src/explain/separator.ts +187 -0
  81. package/src/explain/split-owner.ts +194 -0
  82. package/src/explain/string-tokens.ts +77 -0
  83. package/src/explain/symbol-values.ts +556 -0
  84. package/src/explain/symbols.ts +1617 -0
  85. package/src/explain/token-ranges.ts +72 -0
  86. package/src/explain/transport.ts +514 -0
  87. package/src/explain/value-tokens.ts +83 -0
  88. package/src/explain/values.ts +357 -0
  89. package/src/explain/verbs.ts +64 -0
  90. package/src/explain/verbsplit.ts +955 -0
  91. package/src/explain/write.ts +856 -0
  92. package/src/explain.ts +2602 -0
  93. package/src/index.ts +225 -6
  94. package/src/mcp/server.ts +2 -1
  95. package/src/offline-gate.ts +148 -0
  96. package/src/protocols/mac-telnet-console.ts +61 -17
  97. package/src/resolver/facts.ts +1 -1
  98. package/src/resolver/index.ts +23 -4
  99. package/src/resolver/quickchr-provider.ts +597 -0
  100. package/src/resolver/selection.ts +99 -12
  101. package/src/resolver/service-endpoint.ts +155 -0
  102. package/src/resolver/target.ts +1 -1
  103. package/src/retrieve-fanout.ts +27 -13
  104. package/src/retrieve.ts +82 -77
  105. package/src/settings.ts +2 -1
  106. package/src/transfer-fanout.ts +43 -26
  107. package/src/transfer.ts +88 -45
@@ -0,0 +1,556 @@
1
+ /**
2
+ * Flow-sensitive symbol → value-facts for `explain` (#239 S2).
3
+ *
4
+ * S1 surfaced `data.symbols` as the semantic Q13 projection: name/span/class/
5
+ * role/bindingIds per occurrence. S2 connects those occurrences to
6
+ * `data.values` occurrences without executing user code.
7
+ *
8
+ * - A declaration/binding/assignment with a literal RHS gets a `valueId` that
9
+ * points at the `ExplainValueOccurrence` that spells it. No RHS evaluation:
10
+ * `$x`, `[...]` or an expression yields no `valueId` and later references
11
+ * become `unknown` for that binding.
12
+ * - A reference gets the *reaching* literal ids at that program point. Linear
13
+ * code is last-write-wins; a branch merge becomes a set; a loop merge or a
14
+ * non-literal assignment becomes `unknown`. This is deliberately
15
+ * conservative and schema-free — the same branch that would need a schema to
16
+ * decide `variable-undefined` is the one that forces a set here.
17
+ *
18
+ * The analysis never runs user input (`runtimeAcceptance` stays `not-proven`).
19
+ * It reuses the same statement splits and value anchors the diagnostic/value
20
+ * surfaces already gate on, so a def never points outside its own statement.
21
+ */
22
+
23
+ import type {
24
+ ExplainSymbolOccurrence,
25
+ ExplainValueOccurrence,
26
+ } from "../explain.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";
31
+ import type { SymbolOccurrence } from "./symbols.ts";
32
+ import type { DocumentVerbSplit } from "./verbsplit.ts";
33
+
34
+ interface BlockInfo {
35
+ start: number;
36
+ end: number;
37
+ isLoop: boolean;
38
+ branchGroup?: number;
39
+ branchArm?: "do" | "else";
40
+ }
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
+ */
49
+ type BindingState = {
50
+ readonly valueIds: ReadonlySet<string>;
51
+ readonly unknown: boolean;
52
+ };
53
+
54
+ function setsEqual(a: ReadonlySet<string>, b: ReadonlySet<string>): boolean {
55
+ if (a.size !== b.size) return false;
56
+ for (const v of a) if (!b.has(v)) return false;
57
+ return true;
58
+ }
59
+
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 };
102
+ }
103
+ return {
104
+ valueIds: new Set([...left.valueIds, ...right.valueIds]),
105
+ unknown: left.unknown || right.unknown,
106
+ };
107
+ }
108
+
109
+ /**
110
+ * Collect all scope blocks in `range`, recursively.
111
+ *
112
+ * `splits` are the flattened statement splits for the same document; they are
113
+ * used to decide whether a `do={...}` body belongs to a loop verb (`foreach`,
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).
121
+ */
122
+ function collectBlocks(
123
+ range: MaskedRange,
124
+ owners: SplitOwnerIndex,
125
+ splits: readonly DocumentVerbSplit[],
126
+ ): BlockInfo[] {
127
+ const out: BlockInfo[] = [];
128
+ const blocks = scopeBlocksIn(range).map((block) => {
129
+ const start = range.start + block.start;
130
+ const bracePos = start - 1;
131
+ const ownerIndex = owners.ownerOf(bracePos, bracePos + 1);
132
+ return {
133
+ block,
134
+ ownerIndex,
135
+ owner: ownerIndex === undefined ? undefined : splits[ownerIndex],
136
+ };
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
+ }
148
+ const exhaustiveGroups = new Set<number>();
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;
154
+ const doBlocks = siblings.filter(
155
+ (other) => other.block.name.toLowerCase() === "do",
156
+ );
157
+ const elseBlocks = siblings.filter(
158
+ (other) => other.block.name.toLowerCase() === "else",
159
+ );
160
+ if (
161
+ doBlocks.length === 1 &&
162
+ elseBlocks.length === 1 &&
163
+ (doBlocks[0]?.block.start ?? Number.POSITIVE_INFINITY) <
164
+ (elseBlocks[0]?.block.start ?? Number.NEGATIVE_INFINITY)
165
+ )
166
+ exhaustiveGroups.add(ownerIndex);
167
+ }
168
+
169
+ for (const { block: b, ownerIndex, owner } of blocks) {
170
+ const start = range.start + b.start;
171
+ const end = start + b.body.length;
172
+ // If source mapping cannot identify the owning statement, use loop-like
173
+ // merging: an incomplete reaching set is safer than treating an unknown
174
+ // repeated body as a one-shot branch. Otherwise the resolved verb, not a
175
+ // nearby word or string, decides loop semantics.
176
+ const isLoop =
177
+ owner === undefined ||
178
+ (owner.verb !== null &&
179
+ ["foreach", "for", "while"].includes(owner.verb.toLowerCase()));
180
+ const arm = b.name.toLowerCase();
181
+ const branchArm = arm === "do" || arm === "else" ? arm : undefined;
182
+ out.push({
183
+ start,
184
+ end,
185
+ isLoop,
186
+ ...(ownerIndex !== undefined &&
187
+ exhaustiveGroups.has(ownerIndex) &&
188
+ branchArm !== undefined
189
+ ? { branchGroup: ownerIndex, branchArm }
190
+ : {}),
191
+ });
192
+ out.push(
193
+ ...collectBlocks(
194
+ nestedRange(range, b.start, b.start + b.body.length),
195
+ owners,
196
+ splits,
197
+ ),
198
+ );
199
+ }
200
+ return out;
201
+ }
202
+
203
+ /**
204
+ * Find the literal value that a defining occurrence establishes, if any.
205
+ *
206
+ * The declaration's RHS is the first *positional* value anchor in the same
207
+ * statement whose start is after the symbol's end. Attribute values (`name=`)
208
+ * and `element` members are not considered — `:foreach i in={...}` must not
209
+ * link `i` to the `in` attribute. Only the same statement's values are
210
+ * candidates, which also prevents cross-statement mis-linking.
211
+ */
212
+ function defValueId(
213
+ occurrence: SymbolOccurrence,
214
+ splitIndex: number | undefined,
215
+ values: readonly ExplainValueOccurrence[],
216
+ positionalValuesBySplit: ReadonlyMap<number, readonly number[]>,
217
+ ): string | undefined {
218
+ if (
219
+ occurrence.role !== "declaration" &&
220
+ occurrence.role !== "binding" &&
221
+ occurrence.role !== "assignment"
222
+ )
223
+ return undefined;
224
+ if (occurrence.bindingIds.length === 0) return undefined;
225
+ // Only locals/globals have a literal RHS that is a positional value.
226
+ // `auto` (loop var) is initialized by the loop machinery, not by a
227
+ // literal in the same statement, so skip it.
228
+ if (occurrence.cls !== "local" && occurrence.cls !== "global")
229
+ return undefined;
230
+
231
+ if (splitIndex === undefined) return undefined;
232
+
233
+ let best: ExplainValueOccurrence | undefined;
234
+ let bestDist = Number.POSITIVE_INFINITY;
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) ?? []) {
242
+ const value = values[i] as ExplainValueOccurrence;
243
+ if (value.span.start <= occurrence.start) continue;
244
+ const dist = value.span.start - occurrence.end;
245
+ if (dist < 0 || dist >= bestDist) continue;
246
+ bestDist = dist;
247
+ best = value;
248
+ }
249
+ return best?.id;
250
+ }
251
+
252
+ /**
253
+ * Augment the existing symbol/value surfaces with flow-sensitive refs.
254
+ *
255
+ * Returns new `ExplainSymbolOccurrence` rows that carry `valueId` on
256
+ * definitions and `reachingValueIds`/`reachingUnknown` on references. The
257
+ * underlying `SymbolOccurrence`/`ExplainValueOccurrence` inputs are not
258
+ * mutated.
259
+ */
260
+ export function augmentSymbolOccurrences(
261
+ analyzed: string,
262
+ symbols: readonly SymbolOccurrence[],
263
+ values: readonly ExplainValueOccurrence[],
264
+ splits: readonly DocumentVerbSplit[],
265
+ ): ExplainSymbolOccurrence[] {
266
+ const augmented: ExplainSymbolOccurrence[] = [];
267
+ const owners = buildSplitOwnerIndex(analyzed, splits);
268
+ const symbolSplitIndexes = symbols.map((occ) =>
269
+ owners.ownerOf(occ.start, occ.end),
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
+ }
286
+ const valueOrder = new Map(values.map((value, index) => [value.id, index]));
287
+ // Map from occurrence index -> valueId for quick lookup during flow pass
288
+ const defMap = new Map<number, string>();
289
+ for (let i = 0; i < symbols.length; i++) {
290
+ const occ = symbols[i] as SymbolOccurrence;
291
+ const vid = defValueId(
292
+ occ,
293
+ symbolSplitIndexes[i],
294
+ values,
295
+ positionalValuesBySplit,
296
+ );
297
+ if (vid !== undefined) defMap.set(i, vid);
298
+ }
299
+
300
+ const blocks = collectBlocks(documentRange(analyzed), owners, splits);
301
+ type FlowEvent =
302
+ | {
303
+ offset: number;
304
+ type: "enter" | "exit";
305
+ isLoop: boolean;
306
+ branchGroup?: number;
307
+ branchArm?: "do" | "else";
308
+ }
309
+ | { offset: number; type: "unknown-definition"; bindingIds: string[] };
310
+ const events: FlowEvent[] = [];
311
+ for (const b of blocks) {
312
+ const branch =
313
+ b.branchGroup === undefined || b.branchArm === undefined
314
+ ? {}
315
+ : { branchGroup: b.branchGroup, branchArm: b.branchArm };
316
+ events.push({
317
+ offset: b.start,
318
+ type: "enter",
319
+ isLoop: b.isLoop,
320
+ ...branch,
321
+ });
322
+ events.push({
323
+ offset: b.end,
324
+ type: "exit",
325
+ isLoop: b.isLoop,
326
+ ...branch,
327
+ });
328
+ }
329
+ const deferredDefinitions = new Set<number>();
330
+ for (let i = 0; i < symbols.length; i++) {
331
+ const occ = symbols[i] as SymbolOccurrence;
332
+ if (defMap.has(i) || occ.bindingIds.length === 0) continue;
333
+ if (
334
+ occ.role !== "declaration" &&
335
+ occ.role !== "binding" &&
336
+ occ.role !== "assignment"
337
+ )
338
+ continue;
339
+ const splitIndex = symbolSplitIndexes[i];
340
+ if (splitIndex === undefined) continue;
341
+ deferredDefinitions.add(i);
342
+ events.push({
343
+ offset: (splits[splitIndex] as DocumentVerbSplit).span.end,
344
+ type: "unknown-definition",
345
+ bindingIds: [...occ.bindingIds],
346
+ });
347
+ }
348
+ events.sort((a, b) => {
349
+ if (a.offset !== b.offset) return a.offset - b.offset;
350
+ // Enter the block first, then apply a statement-final definition, then
351
+ // exit/merge the block. This keeps a definition ending at a block edge
352
+ // inside that block's final state.
353
+ const priority = { enter: 0, "unknown-definition": 1, exit: 2 } as const;
354
+ return priority[a.type] - priority[b.type];
355
+ });
356
+
357
+ // Occurrences are already sorted by start
358
+ const ordered = symbols.map((occ, index) => ({ occ, index }));
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>;
371
+ isLoop: boolean;
372
+ branchGroup?: number;
373
+ branchArm?: "do" | "else";
374
+ };
375
+ const stack: Frame[] = [];
376
+ /** Per exhaustive `if` group: the `do` arm's pre/post pair per touched id. */
377
+ const branchGroups = new Map<
378
+ number,
379
+ Map<string, { snap: BindingState | undefined; doFinal: BindingState }>
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
+ };
404
+ let eventIdx = 0;
405
+ const processEventsUpTo = (pos: number): void => {
406
+ while (eventIdx < events.length) {
407
+ const ev = events[eventIdx] as FlowEvent;
408
+ if (ev.offset > pos) break;
409
+ eventIdx++;
410
+ if (ev.type === "unknown-definition") {
411
+ for (const bid of ev.bindingIds)
412
+ setBinding(bid, { valueIds: new Set(), unknown: true });
413
+ continue;
414
+ }
415
+ if (ev.type === "enter") {
416
+ stack.push({
417
+ journal: new Map(),
418
+ isLoop: ev.isLoop,
419
+ ...(ev.branchGroup === undefined || ev.branchArm === undefined
420
+ ? {}
421
+ : { branchGroup: ev.branchGroup, branchArm: ev.branchArm }),
422
+ });
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);
441
+ }
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)));
459
+ }
460
+ branchGroups.delete(top.branchGroup);
461
+ continue;
462
+ }
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
+ );
470
+ }
471
+ };
472
+
473
+ for (const { occ, index } of ordered) {
474
+ processEventsUpTo(occ.start);
475
+ const ev = "e7" as const;
476
+ const base: ExplainSymbolOccurrence = {
477
+ name: occ.name,
478
+ span: { start: occ.start, end: occ.end },
479
+ class: occ.cls === "undefined" ? null : occ.cls,
480
+ role: occ.role as ExplainSymbolOccurrence["role"],
481
+ bindingIds: [...occ.bindingIds],
482
+ sigil: occ.sigil,
483
+ ...(occ.note === undefined ? {} : { note: occ.note }),
484
+ ev,
485
+ } as ExplainSymbolOccurrence;
486
+
487
+ const vid = defMap.get(index);
488
+ if (vid !== undefined) {
489
+ (base as { valueId?: string }).valueId = vid;
490
+ // Update flow state for this binding
491
+ for (const bid of occ.bindingIds) {
492
+ setBinding(bid, { valueIds: new Set([vid]), unknown: false });
493
+ }
494
+ } else if (
495
+ occ.role === "declaration" ||
496
+ occ.role === "binding" ||
497
+ occ.role === "assignment"
498
+ ) {
499
+ // Defining occurrence with no literal RHS — introduces unknown
500
+ if (occ.bindingIds.length > 0 && !deferredDefinitions.has(index)) {
501
+ for (const bid of occ.bindingIds) {
502
+ setBinding(bid, { valueIds: new Set(), unknown: true });
503
+ }
504
+ }
505
+ } else if (occ.role === "reference" || occ.role === "field") {
506
+ if (occ.bindingIds.length > 0) {
507
+ // Union across multiple bindingIds (e.g. :onerror)
508
+ const unionIds = new Set<string>();
509
+ let unknown = false;
510
+ let anyDefined = false;
511
+ for (const bid of occ.bindingIds) {
512
+ const state = current.get(bid);
513
+ if (state !== undefined) {
514
+ anyDefined = true;
515
+ for (const v of state.valueIds) unionIds.add(v);
516
+ if (state.unknown) unknown = true;
517
+ } else {
518
+ // No prior definition for this binding — reaching is unknown
519
+ // (closure or pre-declaration reference)
520
+ unknown = true;
521
+ }
522
+ }
523
+ // For a single binding with no prior assignment yet but a
524
+ // visible declaration later? Q13's lookup already ensures we only
525
+ // query after declaration, so undefined here is truly unknown.
526
+ if (!anyDefined) unknown = true;
527
+ const ids = [...unionIds].sort(
528
+ (a, b) =>
529
+ (valueOrder.get(a) ?? Number.POSITIVE_INFINITY) -
530
+ (valueOrder.get(b) ?? Number.POSITIVE_INFINITY),
531
+ );
532
+ (
533
+ base as {
534
+ reachingValueIds?: string[];
535
+ reachingUnknown?: boolean;
536
+ }
537
+ ).reachingValueIds = ids;
538
+ (base as { reachingUnknown?: boolean }).reachingUnknown = unknown
539
+ ? true
540
+ : undefined;
541
+ // Only emit reachingValueIds when we have a concrete set or unknown;
542
+ // for parameter refs (no bindingIds) we omit.
543
+ // Normalize: keep empty array when unknown with no ids.
544
+ if (ids.length === 0 && !unknown) {
545
+ delete (base as { reachingValueIds?: string[] }).reachingValueIds;
546
+ delete (base as { reachingUnknown?: boolean }).reachingUnknown;
547
+ }
548
+ }
549
+ }
550
+ augmented.push(base);
551
+ }
552
+ // Process trailing block exits after last occurrence
553
+ processEventsUpTo(analyzed.length);
554
+
555
+ return augmented;
556
+ }