@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.
- package/CHANGELOG.md +348 -0
- package/GLOSSARY.txt +252 -0
- package/LICENSE +21 -0
- package/README.md +44 -12
- package/commands/AGENTS.md +14 -5
- package/commands/api/AGENTS.md +13 -1
- package/commands/api/README.md +56 -26
- package/commands/api/examples.md +46 -1
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +6 -1
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +77 -36
- package/commands/execute/examples.md +120 -10
- package/commands/explain/README.md +2756 -0
- package/commands/explain/examples.md +702 -0
- package/commands/mcp/README.md +7 -4
- package/commands/mcp/examples.md +2 -2
- package/commands/retrieve/README.md +25 -27
- package/commands/retrieve/examples.md +16 -0
- package/commands/terminal/examples.md +7 -1
- package/commands/transfer/README.md +28 -20
- package/commands/transfer/examples.md +29 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +138 -30
- package/docs/MATRIX.md +92 -18
- package/docs/errors/README.md +3 -0
- package/docs/errors/quickchr/machine-not-found.md +22 -0
- package/docs/errors/quickchr/machine-stopped.md +20 -0
- package/docs/errors/quickchr/package-unavailable.md +27 -0
- package/docs/errors/quickchr/unsupported-via.md +35 -0
- package/docs/errors/quickchr/unsupported.md +35 -0
- package/docs/errors/routeros/version-unsupported.md +26 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/syntax.md +29 -4
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +56 -10
- package/src/api-fanout.ts +46 -18
- package/src/api.ts +351 -121
- package/src/cli/api.ts +26 -7
- package/src/cli/btest.ts +19 -3
- package/src/cli/execute.ts +25 -6
- package/src/cli/explain.ts +503 -0
- package/src/cli/retrieve.ts +33 -1
- package/src/cli/selection.ts +71 -11
- package/src/cli/settings.ts +2 -2
- package/src/cli/terminal.ts +1 -1
- package/src/cli/transfer.ts +23 -6
- package/src/cli.ts +15 -2
- package/src/core/envelope.ts +58 -2
- package/src/core/error-catalog.ts +42 -1
- package/src/core/execute-command.ts +148 -0
- package/src/core/inspect.ts +36 -4
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/core/version.ts +4 -0
- package/src/core/yaml.ts +86 -0
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +47 -18
- package/src/execute.ts +339 -307
- package/src/explain/arg-tokens.ts +151 -0
- package/src/explain/args.ts +1749 -0
- package/src/explain/blocks.ts +175 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +39 -0
- package/src/explain/catalog.ts +1343 -0
- package/src/explain/coordinates.ts +488 -0
- package/src/explain/defects.ts +218 -0
- package/src/explain/escape-tokens.ts +77 -0
- package/src/explain/highlight-projection.ts +308 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +668 -0
- package/src/explain/operator-tokens.ts +316 -0
- package/src/explain/operators.ts +465 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1569 -0
- package/src/explain/quoted-string.ts +331 -0
- package/src/explain/scope-brace.ts +461 -0
- package/src/explain/segment.ts +1165 -0
- package/src/explain/separator.ts +187 -0
- package/src/explain/split-owner.ts +194 -0
- package/src/explain/string-tokens.ts +77 -0
- package/src/explain/symbol-values.ts +556 -0
- package/src/explain/symbols.ts +1617 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +514 -0
- package/src/explain/value-tokens.ts +83 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +955 -0
- package/src/explain/write.ts +856 -0
- package/src/explain.ts +2602 -0
- package/src/index.ts +225 -6
- package/src/mcp/server.ts +2 -1
- package/src/offline-gate.ts +148 -0
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/facts.ts +1 -1
- package/src/resolver/index.ts +23 -4
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +99 -12
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/target.ts +1 -1
- package/src/retrieve-fanout.ts +27 -13
- package/src/retrieve.ts +82 -77
- package/src/settings.ts +2 -1
- package/src/transfer-fanout.ts +43 -26
- 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
|
+
}
|