@tikoci/centrs 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/GLOSSARY.txt +284 -0
  3. package/LICENSE +21 -0
  4. package/README.md +55 -11
  5. package/SECURITY.md +3 -3
  6. package/commands/AGENTS.md +7 -1
  7. package/commands/api/README.md +46 -27
  8. package/commands/api/examples.md +124 -6
  9. package/commands/btest/README.md +21 -32
  10. package/commands/check/README.md +376 -53
  11. package/commands/check/examples.md +390 -0
  12. package/commands/devices/README.md +177 -37
  13. package/commands/devices/examples.md +148 -0
  14. package/commands/discover/README.md +5 -11
  15. package/commands/execute/README.md +73 -26
  16. package/commands/execute/examples.md +131 -9
  17. package/commands/explain/README.md +1716 -0
  18. package/commands/explain/examples.md +606 -0
  19. package/commands/retrieve/README.md +55 -71
  20. package/commands/retrieve/examples.md +56 -9
  21. package/commands/settings/README.md +286 -22
  22. package/commands/settings/examples.md +408 -0
  23. package/commands/terminal/README.md +6 -0
  24. package/commands/transfer/README.md +57 -19
  25. package/commands/transfer/examples.md +95 -0
  26. package/docs/CLI.md +353 -0
  27. package/docs/CONSTITUTION.md +102 -26
  28. package/docs/MATRIX.md +106 -173
  29. package/docs/errors/README.md +5 -1
  30. package/docs/errors/cdb/empty-selection.md +19 -0
  31. package/docs/errors/input/incomplete-gps.md +9 -0
  32. package/docs/errors/input/invalid-altitude.md +9 -0
  33. package/docs/errors/input/invalid-bbox.md +9 -0
  34. package/docs/errors/input/invalid-coordinate.md +9 -0
  35. package/docs/errors/input/invalid-radius.md +9 -0
  36. package/docs/errors/internal/settings-failed.md +9 -0
  37. package/docs/errors/quickchr/machine-not-found.md +22 -0
  38. package/docs/errors/quickchr/machine-stopped.md +20 -0
  39. package/docs/errors/quickchr/package-unavailable.md +27 -0
  40. package/docs/errors/quickchr/unsupported-via.md +35 -0
  41. package/docs/errors/quickchr/unsupported.md +35 -0
  42. package/docs/errors/routeros/version-unsupported.md +26 -0
  43. package/docs/errors/settings/consequential-value.md +26 -0
  44. package/docs/errors/settings/invalid-boolean.md +21 -3
  45. package/docs/errors/settings/invalid-format.md +18 -3
  46. package/docs/errors/settings/invalid-integer.md +18 -3
  47. package/docs/errors/settings/invalid-timeout.md +20 -3
  48. package/docs/errors/settings/invalid-via.md +22 -3
  49. package/docs/errors/settings/reserved-key.md +29 -0
  50. package/docs/errors/settings/skip-env-file-active.md +25 -0
  51. package/docs/errors/settings/unknown-key.md +25 -0
  52. package/docs/errors/transport/provider-trust.md +29 -0
  53. package/docs/errors/usage/fanout-not-supported.md +16 -0
  54. package/docs/errors/usage/stdin-ignored.md +28 -0
  55. package/docs/errors/validation/unknown-attribute.md +37 -4
  56. package/package.json +49 -14
  57. package/src/api-fanout.ts +469 -0
  58. package/src/api.ts +171 -56
  59. package/src/cli/api.ts +156 -13
  60. package/src/cli/btest.ts +38 -4
  61. package/src/cli/common.ts +11 -0
  62. package/src/cli/devices.ts +235 -6
  63. package/src/cli/discover.ts +3 -1
  64. package/src/cli/execute.ts +129 -9
  65. package/src/cli/explain.ts +503 -0
  66. package/src/cli/mcp.ts +8 -1
  67. package/src/cli/retrieve.ts +167 -90
  68. package/src/cli/selection.ts +260 -0
  69. package/src/cli/settings.ts +286 -0
  70. package/src/cli/terminal.ts +22 -7
  71. package/src/cli/transfer.ts +192 -51
  72. package/src/cli.ts +22 -2
  73. package/src/core/envelope.ts +5 -2
  74. package/src/core/error-catalog.ts +95 -0
  75. package/src/core/fanout.ts +143 -2
  76. package/src/core/preflight.ts +306 -0
  77. package/src/core/routeros-string.ts +24 -0
  78. package/src/devices.ts +212 -20
  79. package/src/discover.ts +7 -2
  80. package/src/errors.ts +1 -0
  81. package/src/execute-fanout.ts +453 -0
  82. package/src/execute.ts +216 -68
  83. package/src/explain/arg-tokens.ts +114 -0
  84. package/src/explain/args.ts +1403 -0
  85. package/src/explain/blocks.ts +132 -0
  86. package/src/explain/brace-slots.ts +143 -0
  87. package/src/explain/brace-tokens.ts +38 -0
  88. package/src/explain/catalog.ts +1345 -0
  89. package/src/explain/coordinates.ts +329 -0
  90. package/src/explain/defects.ts +217 -0
  91. package/src/explain/is-known-menu.ts +17 -0
  92. package/src/explain/menus.ts +664 -0
  93. package/src/explain/operator-tokens.ts +310 -0
  94. package/src/explain/operators.ts +458 -0
  95. package/src/explain/path-tokens.ts +150 -0
  96. package/src/explain/pathresolve.ts +1324 -0
  97. package/src/explain/quoted-string.ts +241 -0
  98. package/src/explain/scope-brace.ts +308 -0
  99. package/src/explain/segment.ts +757 -0
  100. package/src/explain/string-tokens.ts +67 -0
  101. package/src/explain/symbol-values.ts +520 -0
  102. package/src/explain/symbols.ts +1597 -0
  103. package/src/explain/token-ranges.ts +72 -0
  104. package/src/explain/transport.ts +497 -0
  105. package/src/explain/value-tokens.ts +80 -0
  106. package/src/explain/values.ts +357 -0
  107. package/src/explain/verbs.ts +64 -0
  108. package/src/explain/verbsplit.ts +813 -0
  109. package/src/explain/write.ts +815 -0
  110. package/src/explain.ts +2152 -0
  111. package/src/index.ts +307 -1
  112. package/src/mcp/config.ts +12 -2
  113. package/src/mcp/tools.ts +137 -2
  114. package/src/protocols/adapter.ts +38 -18
  115. package/src/protocols/index.ts +11 -5
  116. package/src/protocols/mac-telnet-console.ts +61 -17
  117. package/src/resolver/cdb.ts +10 -2
  118. package/src/resolver/comment-kv.ts +58 -4
  119. package/src/resolver/config-file.ts +136 -0
  120. package/src/resolver/facts.ts +78 -0
  121. package/src/resolver/geo.ts +453 -0
  122. package/src/resolver/index.ts +65 -0
  123. package/src/resolver/mac.ts +12 -3
  124. package/src/resolver/quickchr-provider.ts +597 -0
  125. package/src/resolver/selection.ts +391 -0
  126. package/src/resolver/service-endpoint.ts +155 -0
  127. package/src/resolver/settings.ts +54 -5
  128. package/src/resolver/target.ts +21 -3
  129. package/src/retrieve-fanout.ts +239 -133
  130. package/src/retrieve.ts +120 -25
  131. package/src/settings.ts +1078 -0
  132. package/src/terminal.ts +11 -1
  133. package/src/transfer-fanout.ts +544 -0
  134. package/src/transfer.ts +133 -40
@@ -0,0 +1,67 @@
1
+ /**
2
+ * B3 string fill — claims quoted-string spans on the residual.
3
+ *
4
+ * Every RouterOS double-quoted string is a lexical unit the device highlights
5
+ * (even when its value is not a literal, the delimiters are). `data.values`
6
+ * already claims argument value strings (quotes included), so this fill runs
7
+ * **after** `value` and sees only the residual: quoted declaration names
8
+ * (`:global "set-dns"`), quoted variable references (`$"set-dns"`), and
9
+ * string content inside substitutions that value anchoring did not locate.
10
+ *
11
+ * All quoted runs are produced by the single shared scanner
12
+ * `src/explain/quoted-string.ts` (`scanQuotedString`), the same primitive the
13
+ * segmenter and symbol resolver use, so boundaries cannot drift.
14
+ *
15
+ * Vocabulary is provisional until #264 B5: one `string` class for every quoted
16
+ * run (delimiters included), regardless of whether the string is a literal
17
+ * value, a name, or code. Whether escapes or interpolations later deserve a
18
+ * distinct class is B5 and does not move byte coverage.
19
+ */
20
+
21
+ import type { ExplainToken } from "../explain.ts";
22
+ import { scanQuotedString } from "./quoted-string.ts";
23
+ import { clipToResidual } from "./token-ranges.ts";
24
+
25
+ /** Quoted-string spans on the residual. */
26
+ export function stringSpans(
27
+ analyzed: string,
28
+ residual: readonly { start: number; end: number }[],
29
+ ): ExplainToken[] {
30
+ const len = analyzed.length;
31
+ if (len === 0 || residual.length === 0) return [];
32
+ const raw: { start: number; end: number }[] = [];
33
+ for (let i = 0; i < analyzed.length; i++) {
34
+ if (analyzed[i] !== '"') continue;
35
+ // Earlier fills own semantic regions. In particular, a quote inside a
36
+ // comment must not open a string that paints later statements when that
37
+ // comment has no matching quote. A real residual string always has its
38
+ // opening delimiter on the residual; already-owned strings need no fill.
39
+ if (clipToResidual(i, i + 1, residual).length === 0) continue;
40
+ const scan = scanQuotedString(analyzed, i);
41
+ if (!scan.closed) {
42
+ // Unterminated strings are already a defect; claim from opener to
43
+ // end so the residual does not stay gap-filled as unclassified
44
+ // while the diagnostic says the same bytes are defective.
45
+ raw.push({ start: i, end: analyzed.length });
46
+ break;
47
+ }
48
+ raw.push({ start: i, end: scan.end });
49
+ i = scan.end - 1;
50
+ }
51
+ if (raw.length === 0) return [];
52
+ const out: ExplainToken[] = [];
53
+ for (const { start, end } of raw) {
54
+ if (!Number.isInteger(start) || !Number.isInteger(end)) continue;
55
+ if (start < 0 || end <= start || end > len) continue;
56
+ for (const r of clipToResidual(start, end, residual)) {
57
+ out.push({
58
+ start: r.start,
59
+ end: r.end,
60
+ class: "string" as const,
61
+ ev: "e13",
62
+ });
63
+ }
64
+ }
65
+ out.sort((a, b) => a.start - b.start || a.end - b.end);
66
+ return out;
67
+ }
@@ -0,0 +1,520 @@
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 { scopeBlocks } from "./blocks.ts";
28
+ import type { SymbolOccurrence } from "./symbols.ts";
29
+ import type { DocumentVerbSplit } from "./verbsplit.ts";
30
+
31
+ interface BlockInfo {
32
+ start: number;
33
+ end: number;
34
+ isLoop: boolean;
35
+ branchGroup?: number;
36
+ branchArm?: "do" | "else";
37
+ }
38
+
39
+ type BindingState = {
40
+ valueIds: Set<string>;
41
+ unknown: boolean;
42
+ };
43
+
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
+ if (a.size !== b.size) return false;
55
+ for (const v of a) if (!b.has(v)) return false;
56
+ return true;
57
+ }
58
+
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
+ });
81
+ }
82
+ return merged;
83
+ }
84
+
85
+ /**
86
+ * Collect all scope blocks in `text`, rebased to `base`, recursively.
87
+ *
88
+ * `splits` are the flattened statement splits for the same document; they are
89
+ * used to decide whether a `do={...}` body belongs to a loop verb (`foreach`,
90
+ * `for`, `while`) or to a branch (`if`, `do`, `else`, `on-error`).
91
+ */
92
+ function collectBlocks(
93
+ text: string,
94
+ base: number,
95
+ analyzed: string,
96
+ splits: readonly DocumentVerbSplit[],
97
+ ): BlockInfo[] {
98
+ const out: BlockInfo[] = [];
99
+ const blocks = scopeBlocks(text).map((block) => {
100
+ const start = base + block.start;
101
+ const bracePos = start - 1;
102
+ const ownerIndex = owningSplitIndex(
103
+ analyzed,
104
+ splits,
105
+ bracePos,
106
+ bracePos + 1,
107
+ );
108
+ return {
109
+ block,
110
+ ownerIndex,
111
+ owner: ownerIndex === undefined ? undefined : splits[ownerIndex],
112
+ };
113
+ });
114
+ 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
+ );
121
+ const doBlocks = siblings.filter(
122
+ (other) => other.block.name.toLowerCase() === "do",
123
+ );
124
+ const elseBlocks = siblings.filter(
125
+ (other) => other.block.name.toLowerCase() === "else",
126
+ );
127
+ if (
128
+ doBlocks.length === 1 &&
129
+ elseBlocks.length === 1 &&
130
+ (doBlocks[0]?.block.start ?? Number.POSITIVE_INFINITY) <
131
+ (elseBlocks[0]?.block.start ?? Number.NEGATIVE_INFINITY)
132
+ )
133
+ exhaustiveGroups.add(candidate.ownerIndex);
134
+ }
135
+
136
+ for (const { block: b, ownerIndex, owner } of blocks) {
137
+ const start = base + b.start;
138
+ const end = start + b.body.length;
139
+ // If source mapping cannot identify the owning statement, use loop-like
140
+ // merging: an incomplete reaching set is safer than treating an unknown
141
+ // repeated body as a one-shot branch. Otherwise the resolved verb, not a
142
+ // nearby word or string, decides loop semantics.
143
+ const isLoop =
144
+ owner === undefined ||
145
+ (owner.verb !== null &&
146
+ ["foreach", "for", "while"].includes(owner.verb.toLowerCase()));
147
+ const arm = b.name.toLowerCase();
148
+ const branchArm = arm === "do" || arm === "else" ? arm : undefined;
149
+ out.push({
150
+ start,
151
+ end,
152
+ isLoop,
153
+ ...(ownerIndex !== undefined &&
154
+ exhaustiveGroups.has(ownerIndex) &&
155
+ branchArm !== undefined
156
+ ? { branchGroup: ownerIndex, branchArm }
157
+ : {}),
158
+ });
159
+ out.push(...collectBlocks(b.body, start, analyzed, splits));
160
+ }
161
+ return out;
162
+ }
163
+
164
+ /**
165
+ * Find the literal value that a defining occurrence establishes, if any.
166
+ *
167
+ * The declaration's RHS is the first *positional* value anchor in the same
168
+ * statement whose start is after the symbol's end. Attribute values (`name=`)
169
+ * and `element` members are not considered — `:foreach i in={...}` must not
170
+ * link `i` to the `in` attribute. Only the same statement's values are
171
+ * candidates, which also prevents cross-statement mis-linking.
172
+ */
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
+ function defValueId(
198
+ occurrence: SymbolOccurrence,
199
+ splitIndex: number | undefined,
200
+ values: readonly ExplainValueOccurrence[],
201
+ valueSplitIndexes: readonly (number | undefined)[],
202
+ ): string | undefined {
203
+ if (
204
+ occurrence.role !== "declaration" &&
205
+ occurrence.role !== "binding" &&
206
+ occurrence.role !== "assignment"
207
+ )
208
+ return undefined;
209
+ if (occurrence.bindingIds.length === 0) return undefined;
210
+ // Only locals/globals have a literal RHS that is a positional value.
211
+ // `auto` (loop var) is initialized by the loop machinery, not by a
212
+ // literal in the same statement, so skip it.
213
+ if (occurrence.cls !== "local" && occurrence.cls !== "global")
214
+ return undefined;
215
+
216
+ if (splitIndex === undefined) return undefined;
217
+
218
+ let best: ExplainValueOccurrence | undefined;
219
+ let bestDist = Number.POSITIVE_INFINITY;
220
+ for (let i = 0; i < values.length; i++) {
221
+ 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
+ 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
+ const dist = value.span.start - occurrence.end;
233
+ if (dist < 0 || dist >= bestDist) continue;
234
+ bestDist = dist;
235
+ best = value;
236
+ }
237
+ return best?.id;
238
+ }
239
+
240
+ /**
241
+ * Augment the existing symbol/value surfaces with flow-sensitive refs.
242
+ *
243
+ * Returns new `ExplainSymbolOccurrence` rows that carry `valueId` on
244
+ * definitions and `reachingValueIds`/`reachingUnknown` on references. The
245
+ * underlying `SymbolOccurrence`/`ExplainValueOccurrence` inputs are not
246
+ * mutated.
247
+ */
248
+ export function augmentSymbolOccurrences(
249
+ analyzed: string,
250
+ symbols: readonly SymbolOccurrence[],
251
+ values: readonly ExplainValueOccurrence[],
252
+ splits: readonly DocumentVerbSplit[],
253
+ ): ExplainSymbolOccurrence[] {
254
+ const augmented: ExplainSymbolOccurrence[] = [];
255
+ 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),
260
+ );
261
+ const valueOrder = new Map(values.map((value, index) => [value.id, index]));
262
+ // Map from occurrence index -> valueId for quick lookup during flow pass
263
+ const defMap = new Map<number, string>();
264
+ for (let i = 0; i < symbols.length; i++) {
265
+ const occ = symbols[i] as SymbolOccurrence;
266
+ const vid = defValueId(
267
+ occ,
268
+ symbolSplitIndexes[i],
269
+ values,
270
+ valueSplitIndexes,
271
+ );
272
+ if (vid !== undefined) defMap.set(i, vid);
273
+ }
274
+
275
+ const blocks = collectBlocks(analyzed, 0, analyzed, splits);
276
+ type FlowEvent =
277
+ | {
278
+ offset: number;
279
+ type: "enter" | "exit";
280
+ isLoop: boolean;
281
+ branchGroup?: number;
282
+ branchArm?: "do" | "else";
283
+ }
284
+ | { offset: number; type: "unknown-definition"; bindingIds: string[] };
285
+ const events: FlowEvent[] = [];
286
+ for (const b of blocks) {
287
+ const branch =
288
+ b.branchGroup === undefined || b.branchArm === undefined
289
+ ? {}
290
+ : { branchGroup: b.branchGroup, branchArm: b.branchArm };
291
+ events.push({
292
+ offset: b.start,
293
+ type: "enter",
294
+ isLoop: b.isLoop,
295
+ ...branch,
296
+ });
297
+ events.push({
298
+ offset: b.end,
299
+ type: "exit",
300
+ isLoop: b.isLoop,
301
+ ...branch,
302
+ });
303
+ }
304
+ const deferredDefinitions = new Set<number>();
305
+ for (let i = 0; i < symbols.length; i++) {
306
+ const occ = symbols[i] as SymbolOccurrence;
307
+ if (defMap.has(i) || occ.bindingIds.length === 0) continue;
308
+ if (
309
+ occ.role !== "declaration" &&
310
+ occ.role !== "binding" &&
311
+ occ.role !== "assignment"
312
+ )
313
+ continue;
314
+ const splitIndex = symbolSplitIndexes[i];
315
+ if (splitIndex === undefined) continue;
316
+ deferredDefinitions.add(i);
317
+ events.push({
318
+ offset: (splits[splitIndex] as DocumentVerbSplit).span.end,
319
+ type: "unknown-definition",
320
+ bindingIds: [...occ.bindingIds],
321
+ });
322
+ }
323
+ events.sort((a, b) => {
324
+ if (a.offset !== b.offset) return a.offset - b.offset;
325
+ // Enter the block first, then apply a statement-final definition, then
326
+ // exit/merge the block. This keeps a definition ending at a block edge
327
+ // inside that block's final state.
328
+ const priority = { enter: 0, "unknown-definition": 1, exit: 2 } as const;
329
+ return priority[a.type] - priority[b.type];
330
+ });
331
+
332
+ // Occurrences are already sorted by start
333
+ 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>;
338
+ isLoop: boolean;
339
+ branchGroup?: number;
340
+ branchArm?: "do" | "else";
341
+ }[] = [];
342
+ const branchGroups = new Map<
343
+ number,
344
+ { doFinal: Map<string, BindingState> }
345
+ >();
346
+ let eventIdx = 0;
347
+ const processEventsUpTo = (pos: number): void => {
348
+ while (eventIdx < events.length) {
349
+ const ev = events[eventIdx] as FlowEvent;
350
+ if (ev.offset > pos) break;
351
+ eventIdx++;
352
+ if (ev.type === "unknown-definition") {
353
+ for (const bid of ev.bindingIds)
354
+ current.set(bid, { valueIds: new Set(), unknown: true });
355
+ continue;
356
+ }
357
+ if (ev.type === "enter") {
358
+ stack.push({
359
+ snapshot: cloneStateMap(current),
360
+ isLoop: ev.isLoop,
361
+ ...(ev.branchGroup === undefined || ev.branchArm === undefined
362
+ ? {}
363
+ : { branchGroup: ev.branchGroup, branchArm: ev.branchArm }),
364
+ });
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
+ }
415
+ }
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
+ }
430
+ }
431
+ }
432
+ current = merged;
433
+ }
434
+ }
435
+ };
436
+
437
+ for (const { occ, index } of ordered) {
438
+ processEventsUpTo(occ.start);
439
+ const ev = "e7" as const;
440
+ const base: ExplainSymbolOccurrence = {
441
+ name: occ.name,
442
+ span: { start: occ.start, end: occ.end },
443
+ class: occ.cls === "undefined" ? null : occ.cls,
444
+ role: occ.role as ExplainSymbolOccurrence["role"],
445
+ bindingIds: [...occ.bindingIds],
446
+ sigil: occ.sigil,
447
+ ...(occ.note === undefined ? {} : { note: occ.note }),
448
+ ev,
449
+ } as ExplainSymbolOccurrence;
450
+
451
+ const vid = defMap.get(index);
452
+ if (vid !== undefined) {
453
+ (base as { valueId?: string }).valueId = vid;
454
+ // Update flow state for this binding
455
+ for (const bid of occ.bindingIds) {
456
+ current.set(bid, { valueIds: new Set([vid]), unknown: false });
457
+ }
458
+ } else if (
459
+ occ.role === "declaration" ||
460
+ occ.role === "binding" ||
461
+ occ.role === "assignment"
462
+ ) {
463
+ // Defining occurrence with no literal RHS — introduces unknown
464
+ if (occ.bindingIds.length > 0 && !deferredDefinitions.has(index)) {
465
+ for (const bid of occ.bindingIds) {
466
+ current.set(bid, { valueIds: new Set(), unknown: true });
467
+ }
468
+ }
469
+ } else if (occ.role === "reference" || occ.role === "field") {
470
+ if (occ.bindingIds.length > 0) {
471
+ // Union across multiple bindingIds (e.g. :onerror)
472
+ const unionIds = new Set<string>();
473
+ let unknown = false;
474
+ let anyDefined = false;
475
+ for (const bid of occ.bindingIds) {
476
+ const state = current.get(bid);
477
+ if (state !== undefined) {
478
+ anyDefined = true;
479
+ for (const v of state.valueIds) unionIds.add(v);
480
+ if (state.unknown) unknown = true;
481
+ } else {
482
+ // No prior definition for this binding — reaching is unknown
483
+ // (closure or pre-declaration reference)
484
+ unknown = true;
485
+ }
486
+ }
487
+ // For a single binding with no prior assignment yet but a
488
+ // visible declaration later? Q13's lookup already ensures we only
489
+ // query after declaration, so undefined here is truly unknown.
490
+ if (!anyDefined) unknown = true;
491
+ const ids = [...unionIds].sort(
492
+ (a, b) =>
493
+ (valueOrder.get(a) ?? Number.POSITIVE_INFINITY) -
494
+ (valueOrder.get(b) ?? Number.POSITIVE_INFINITY),
495
+ );
496
+ (
497
+ base as {
498
+ reachingValueIds?: string[];
499
+ reachingUnknown?: boolean;
500
+ }
501
+ ).reachingValueIds = ids;
502
+ (base as { reachingUnknown?: boolean }).reachingUnknown = unknown
503
+ ? true
504
+ : undefined;
505
+ // Only emit reachingValueIds when we have a concrete set or unknown;
506
+ // for parameter refs (no bindingIds) we omit.
507
+ // Normalize: keep empty array when unknown with no ids.
508
+ if (ids.length === 0 && !unknown) {
509
+ delete (base as { reachingValueIds?: string[] }).reachingValueIds;
510
+ delete (base as { reachingUnknown?: boolean }).reachingUnknown;
511
+ }
512
+ }
513
+ }
514
+ augmented.push(base);
515
+ }
516
+ // Process trailing block exits after last occurrence
517
+ processEventsUpTo(analyzed.length);
518
+
519
+ return augmented;
520
+ }