@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.
- package/CHANGELOG.md +98 -0
- package/GLOSSARY.txt +284 -0
- package/LICENSE +21 -0
- package/README.md +55 -11
- package/SECURITY.md +3 -3
- package/commands/AGENTS.md +7 -1
- package/commands/api/README.md +46 -27
- package/commands/api/examples.md +124 -6
- package/commands/btest/README.md +21 -32
- package/commands/check/README.md +376 -53
- package/commands/check/examples.md +390 -0
- package/commands/devices/README.md +177 -37
- package/commands/devices/examples.md +148 -0
- package/commands/discover/README.md +5 -11
- package/commands/execute/README.md +73 -26
- package/commands/execute/examples.md +131 -9
- package/commands/explain/README.md +1716 -0
- package/commands/explain/examples.md +606 -0
- package/commands/retrieve/README.md +55 -71
- package/commands/retrieve/examples.md +56 -9
- package/commands/settings/README.md +286 -22
- package/commands/settings/examples.md +408 -0
- package/commands/terminal/README.md +6 -0
- package/commands/transfer/README.md +57 -19
- package/commands/transfer/examples.md +95 -0
- package/docs/CLI.md +353 -0
- package/docs/CONSTITUTION.md +102 -26
- package/docs/MATRIX.md +106 -173
- package/docs/errors/README.md +5 -1
- package/docs/errors/cdb/empty-selection.md +19 -0
- package/docs/errors/input/incomplete-gps.md +9 -0
- package/docs/errors/input/invalid-altitude.md +9 -0
- package/docs/errors/input/invalid-bbox.md +9 -0
- package/docs/errors/input/invalid-coordinate.md +9 -0
- package/docs/errors/input/invalid-radius.md +9 -0
- package/docs/errors/internal/settings-failed.md +9 -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/settings/consequential-value.md +26 -0
- package/docs/errors/settings/invalid-boolean.md +21 -3
- package/docs/errors/settings/invalid-format.md +18 -3
- package/docs/errors/settings/invalid-integer.md +18 -3
- package/docs/errors/settings/invalid-timeout.md +20 -3
- package/docs/errors/settings/invalid-via.md +22 -3
- package/docs/errors/settings/reserved-key.md +29 -0
- package/docs/errors/settings/skip-env-file-active.md +25 -0
- package/docs/errors/settings/unknown-key.md +25 -0
- package/docs/errors/transport/provider-trust.md +29 -0
- package/docs/errors/usage/fanout-not-supported.md +16 -0
- package/docs/errors/usage/stdin-ignored.md +28 -0
- package/docs/errors/validation/unknown-attribute.md +37 -4
- package/package.json +49 -14
- package/src/api-fanout.ts +469 -0
- package/src/api.ts +171 -56
- package/src/cli/api.ts +156 -13
- package/src/cli/btest.ts +38 -4
- package/src/cli/common.ts +11 -0
- package/src/cli/devices.ts +235 -6
- package/src/cli/discover.ts +3 -1
- package/src/cli/execute.ts +129 -9
- package/src/cli/explain.ts +503 -0
- package/src/cli/mcp.ts +8 -1
- package/src/cli/retrieve.ts +167 -90
- package/src/cli/selection.ts +260 -0
- package/src/cli/settings.ts +286 -0
- package/src/cli/terminal.ts +22 -7
- package/src/cli/transfer.ts +192 -51
- package/src/cli.ts +22 -2
- package/src/core/envelope.ts +5 -2
- package/src/core/error-catalog.ts +95 -0
- package/src/core/fanout.ts +143 -2
- package/src/core/preflight.ts +306 -0
- package/src/core/routeros-string.ts +24 -0
- package/src/devices.ts +212 -20
- package/src/discover.ts +7 -2
- package/src/errors.ts +1 -0
- package/src/execute-fanout.ts +453 -0
- package/src/execute.ts +216 -68
- package/src/explain/arg-tokens.ts +114 -0
- package/src/explain/args.ts +1403 -0
- package/src/explain/blocks.ts +132 -0
- package/src/explain/brace-slots.ts +143 -0
- package/src/explain/brace-tokens.ts +38 -0
- package/src/explain/catalog.ts +1345 -0
- package/src/explain/coordinates.ts +329 -0
- package/src/explain/defects.ts +217 -0
- package/src/explain/is-known-menu.ts +17 -0
- package/src/explain/menus.ts +664 -0
- package/src/explain/operator-tokens.ts +310 -0
- package/src/explain/operators.ts +458 -0
- package/src/explain/path-tokens.ts +150 -0
- package/src/explain/pathresolve.ts +1324 -0
- package/src/explain/quoted-string.ts +241 -0
- package/src/explain/scope-brace.ts +308 -0
- package/src/explain/segment.ts +757 -0
- package/src/explain/string-tokens.ts +67 -0
- package/src/explain/symbol-values.ts +520 -0
- package/src/explain/symbols.ts +1597 -0
- package/src/explain/token-ranges.ts +72 -0
- package/src/explain/transport.ts +497 -0
- package/src/explain/value-tokens.ts +80 -0
- package/src/explain/values.ts +357 -0
- package/src/explain/verbs.ts +64 -0
- package/src/explain/verbsplit.ts +813 -0
- package/src/explain/write.ts +815 -0
- package/src/explain.ts +2152 -0
- package/src/index.ts +307 -1
- package/src/mcp/config.ts +12 -2
- package/src/mcp/tools.ts +137 -2
- package/src/protocols/adapter.ts +38 -18
- package/src/protocols/index.ts +11 -5
- package/src/protocols/mac-telnet-console.ts +61 -17
- package/src/resolver/cdb.ts +10 -2
- package/src/resolver/comment-kv.ts +58 -4
- package/src/resolver/config-file.ts +136 -0
- package/src/resolver/facts.ts +78 -0
- package/src/resolver/geo.ts +453 -0
- package/src/resolver/index.ts +65 -0
- package/src/resolver/mac.ts +12 -3
- package/src/resolver/quickchr-provider.ts +597 -0
- package/src/resolver/selection.ts +391 -0
- package/src/resolver/service-endpoint.ts +155 -0
- package/src/resolver/settings.ts +54 -5
- package/src/resolver/target.ts +21 -3
- package/src/retrieve-fanout.ts +239 -133
- package/src/retrieve.ts +120 -25
- package/src/settings.ts +1078 -0
- package/src/terminal.ts +11 -1
- package/src/transfer-fanout.ts +544 -0
- 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
|
+
}
|