candor-ts 0.5.23 → 0.5.28
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/package.json +1 -1
- package/query-core.mjs +86 -1
- package/query.mjs +16 -15
- package/scan.mjs +50 -16
package/package.json
CHANGED
package/query-core.mjs
CHANGED
|
@@ -26,7 +26,7 @@ function siblings(prefix, predicate) {
|
|
|
26
26
|
// calibrated-coverage sidecar). Exported so `hasReport` (the MCP existence check) uses the SAME predicate
|
|
27
27
|
// as the loader — else a prefix whose only sibling is `.encountered-*`/`.calibrated.json` passes the
|
|
28
28
|
// existence check but loads ZERO functions → an authoritative-empty result (silent under-report; review find).
|
|
29
|
-
export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json");
|
|
29
|
+
export const isReport = (f) => !f.endsWith(".callgraph.json") && !f.endsWith(".hierarchy.json") && !f.includes(".encountered-") && !f.endsWith(".calibrated.json");
|
|
30
30
|
|
|
31
31
|
// Defend the queries against a partial/old-engine/hand-edited report: the §2 required fields are
|
|
32
32
|
// defaulted, and a WRONG-TYPE field is coerced — a non-array `inferred` (e.g. the string "Net") must
|
|
@@ -158,6 +158,65 @@ export function callers(cg, q) {
|
|
|
158
158
|
return { of: targets, direct: [...direct].sort(), transitive: [...transitive].sort() };
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
// The bare method name / declaring type of a `mod.Class.member` qual (drop a `#line:col` function-scoped
|
|
162
|
+
// suffix, then split on the last dot). Used by the dispatch-frontier to match a confirmed reacher against
|
|
163
|
+
// a `dispatch:OWNER.member` owner.
|
|
164
|
+
const stripPos = (s) => { const h = s.indexOf("#"); return h >= 0 ? s.slice(0, h) : s; };
|
|
165
|
+
export function simpleMethod(fn) { const b = stripPos(fn); const i = b.lastIndexOf("."); return i >= 0 ? b.slice(i + 1) : b; }
|
|
166
|
+
export function declaringType(fn) { const b = stripPos(fn); const i = b.lastIndexOf("."); return i >= 0 ? b.slice(0, i) : b; }
|
|
167
|
+
|
|
168
|
+
// Load the type-hierarchy sidecar (`<prefix>.hierarchy.json`, 0.7), or {} if absent (→ the frontier
|
|
169
|
+
// falls back to a simple-name match, which over-lists — the safe direction).
|
|
170
|
+
export function loadHierarchy(prefix) {
|
|
171
|
+
const norm = (h) => (h && typeof h === "object" && !Array.isArray(h))
|
|
172
|
+
? Object.fromEntries(Object.entries(h).map(([k, v]) => [k, Array.isArray(v) ? v : []])) : {};
|
|
173
|
+
if (fs.existsSync(`${prefix}.hierarchy.json`)) {
|
|
174
|
+
try { return norm(JSON.parse(fs.readFileSync(`${prefix}.hierarchy.json`, "utf8"))); } catch { return {}; }
|
|
175
|
+
}
|
|
176
|
+
const h = {};
|
|
177
|
+
for (const f of siblings(prefix, (x) => x.endsWith(".hierarchy.json"))) {
|
|
178
|
+
try { Object.assign(h, JSON.parse(fs.readFileSync(f, "utf8"))); } catch { /* tolerate */ }
|
|
179
|
+
}
|
|
180
|
+
return norm(h);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Reflexive+transitive subtype test over the hierarchy sidecar.
|
|
184
|
+
function isSubtypeOf(type, owner, hierarchy) {
|
|
185
|
+
if (type === owner) return true;
|
|
186
|
+
const seen = new Set(), stack = [type];
|
|
187
|
+
while (stack.length) {
|
|
188
|
+
for (const s of hierarchy[stack.pop()] ?? []) { if (s === owner) return true; if (!seen.has(s)) { seen.add(s); stack.push(s); } }
|
|
189
|
+
}
|
|
190
|
+
return false;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// callers + the unresolved-dispatch frontier (--include-unknown, SPEC §3.1/§4 0.7): the CONFIRMED set,
|
|
194
|
+
// plus functions that reach `q` only through a `dispatch:OWNER.member` the engine declined to resolve —
|
|
195
|
+
// disclosed iff a confirmed reacher is an override of OWNER.member (same method AND a subtype of OWNER
|
|
196
|
+
// per the hierarchy; empty hierarchy → simple-name match, over-lists). Never asserted ("cannot confirm").
|
|
197
|
+
export function callersFrontier(cg, fns, hierarchy, q) {
|
|
198
|
+
const base = callers(cg, q);
|
|
199
|
+
const confirmed = new Set([...base.of, ...base.transitive]);
|
|
200
|
+
const typesByMethod = new Map();
|
|
201
|
+
for (const r of confirmed) { const m = simpleMethod(r); (typesByMethod.get(m) ?? typesByMethod.set(m, []).get(m)).push(declaringType(r)); }
|
|
202
|
+
const hasHier = hierarchy && Object.keys(hierarchy).length > 0;
|
|
203
|
+
const possible = [];
|
|
204
|
+
for (const f of fns) {
|
|
205
|
+
if (confirmed.has(f.fn)) continue;
|
|
206
|
+
const hits = new Set();
|
|
207
|
+
for (const w of f.unknownWhy ?? []) {
|
|
208
|
+
if (!w.startsWith("dispatch:")) continue;
|
|
209
|
+
const key = w.slice("dispatch:".length), m = simpleMethod(key), owner = declaringType(key);
|
|
210
|
+
const types = typesByMethod.get(m);
|
|
211
|
+
if (!types) continue;
|
|
212
|
+
if (!hasHier || types.some((t) => isSubtypeOf(t, owner, hierarchy))) hits.add(m);
|
|
213
|
+
}
|
|
214
|
+
if (hits.size) possible.push({ fn: f.fn, viaDispatchOn: [...hits].sort().join(",") });
|
|
215
|
+
}
|
|
216
|
+
possible.sort((a, b) => a.fn.localeCompare(b.fn));
|
|
217
|
+
return { ...base, possibleViaUnknownDispatch: possible };
|
|
218
|
+
}
|
|
219
|
+
|
|
161
220
|
export function map(fns) {
|
|
162
221
|
const mods = {};
|
|
163
222
|
for (const e of fns) {
|
|
@@ -205,6 +264,32 @@ export function impact(fns, cg, q) {
|
|
|
205
264
|
return { fn: tgt ?? q, affectedCount: affected.length, affected, entryPoints };
|
|
206
265
|
}
|
|
207
266
|
|
|
267
|
+
// blindspots (SPEC §3.1 ⟨0.6⟩): the Unknown SOURCES — fns whose OWN body has an unresolvable call (so
|
|
268
|
+
// they carry `unknownWhy`), each ranked by its Unknown blast radius (the transitive callers that inherit
|
|
269
|
+
// Unknown through it). The actionable inverse of a widely-propagated Unknown: a report can read mostly
|
|
270
|
+
// Unknown from a handful of root causes — this names them, ranked, to declare/resolve/accept. Matches
|
|
271
|
+
// candor-java/candor-query: { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
272
|
+
export function blindspots(fns, cg) {
|
|
273
|
+
const rev = reverseGraph(cg);
|
|
274
|
+
const totalUnknown = fns.filter((e) => (e.inferred ?? []).includes("Unknown")).length;
|
|
275
|
+
const sources = [];
|
|
276
|
+
for (const e of fns) {
|
|
277
|
+
const why = e.unknownWhy ?? [];
|
|
278
|
+
if (why.length === 0) continue; // a SOURCE carries its own unknownWhy; a purely-transitive Unknown does not
|
|
279
|
+
const reached = new Set();
|
|
280
|
+
const queue = [e.fn];
|
|
281
|
+
const seen = new Set([e.fn]);
|
|
282
|
+
while (queue.length) {
|
|
283
|
+
const n = queue.pop();
|
|
284
|
+
for (const c of rev.get(n) ?? []) if (!seen.has(c)) { seen.add(c); reached.add(c); queue.push(c); }
|
|
285
|
+
}
|
|
286
|
+
const affected = [...reached].sort();
|
|
287
|
+
sources.push({ fn: e.fn, why, reaches: affected.length, affected });
|
|
288
|
+
}
|
|
289
|
+
sources.sort((a, b) => b.reaches - a.reaches || a.fn.localeCompare(b.fn)); // most-smearing first, stable
|
|
290
|
+
return { sources, totalUnknown };
|
|
291
|
+
}
|
|
292
|
+
|
|
208
293
|
// path: the FORWARD provenance — a shortest BFS over the calls graph from `fn` to the nearest unit
|
|
209
294
|
// that performs `eff` DIRECTLY (the source). Matches candor-query's {effect, fn, path:[{fn,loc,source}]}.
|
|
210
295
|
export function path(fns, cg, fnQ, eff) {
|
package/query.mjs
CHANGED
|
@@ -25,7 +25,8 @@ import { printAgents } from "./contract.mjs";
|
|
|
25
25
|
// a `matchTier` missing `#` (so the SAME query resolved differently between `impact` and `callers` on a
|
|
26
26
|
// JVM `Type#method` report). Importing the shared functions removes all three divergences (review find).
|
|
27
27
|
import { impact as coreImpact, path as corePath, gains as coreGains,
|
|
28
|
-
show as coreShow,
|
|
28
|
+
show as coreShow, blindspots as coreBlindspots,
|
|
29
|
+
callers as coreCallers, callersFrontier, loadHierarchy,
|
|
29
30
|
loadReport, loadCallgraph, matches } from "./query-core.mjs";
|
|
30
31
|
const emit = (v) => console.log(JSON.stringify(v, null, 1));
|
|
31
32
|
|
|
@@ -58,21 +59,14 @@ switch (cmd) {
|
|
|
58
59
|
break;
|
|
59
60
|
}
|
|
60
61
|
case "callers": {
|
|
61
|
-
|
|
62
|
+
// --include-unknown ⟨0.7⟩ adds the unresolved-dispatch frontier (possibleViaUnknownDispatch); without
|
|
63
|
+
// it, the byte-for-byte {of,direct,transitive} shape is unchanged (cross-engine parity). Call the
|
|
64
|
+
// shared query-core so the CLI and MCP compute one truth (the prior inline copy had drifted before).
|
|
65
|
+
const includeUnknown = args.includes("--include-unknown");
|
|
66
|
+
const [prefix, q] = args.filter((a) => a !== "--include-unknown");
|
|
62
67
|
const cg = loadCallgraph(prefix);
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
const rev = new Map();
|
|
66
|
-
for (const [caller, callees] of Object.entries(cg))
|
|
67
|
-
for (const c of callees) (rev.get(c) ?? rev.set(c, []).get(c)).push(caller);
|
|
68
|
-
const direct = new Set(), transitive = new Set();
|
|
69
|
-
const queue = [...targets];
|
|
70
|
-
for (const t of targets) for (const c of rev.get(t) ?? []) direct.add(c);
|
|
71
|
-
while (queue.length) {
|
|
72
|
-
const n = queue.pop();
|
|
73
|
-
for (const c of rev.get(n) ?? []) if (!transitive.has(c) && !targets.includes(c)) { transitive.add(c); queue.push(c); }
|
|
74
|
-
}
|
|
75
|
-
emit({ of: targets, direct: [...direct].sort(), transitive: [...transitive].sort() });
|
|
68
|
+
if (includeUnknown) emit(callersFrontier(cg, loadReport(prefix), loadHierarchy(prefix), q));
|
|
69
|
+
else emit(coreCallers(cg, q));
|
|
76
70
|
break;
|
|
77
71
|
}
|
|
78
72
|
case "map": {
|
|
@@ -127,6 +121,13 @@ switch (cmd) {
|
|
|
127
121
|
emit(coreImpact(loadReport(prefix), loadCallgraph(prefix), q));
|
|
128
122
|
break;
|
|
129
123
|
}
|
|
124
|
+
case "blindspots": {
|
|
125
|
+
// the Unknown SOURCES, ranked by blast radius — the actionable inverse of a widely-propagated
|
|
126
|
+
// Unknown (SPEC §3.1 ⟨0.6⟩): { sources:[{fn,why,reaches,affected}], totalUnknown }.
|
|
127
|
+
const [prefix] = args;
|
|
128
|
+
emit(coreBlindspots(loadReport(prefix), loadCallgraph(prefix)));
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
130
131
|
case "gains": {
|
|
131
132
|
// the supply-chain alarm (SPEC §5.1): {gained:[Effect], byFunction:[{fn,effect}]} — what the
|
|
132
133
|
// surface gained between two reports (base → cur), the cross-engine machine-readable form.
|
package/scan.mjs
CHANGED
|
@@ -36,7 +36,7 @@ const ENGINE_DIR = path.dirname(fileURLToPath(import.meta.url));
|
|
|
36
36
|
// literal stamped into the envelope's `spec` field, so the doc lines and the report can never drift.
|
|
37
37
|
// Reused, never re-littered.
|
|
38
38
|
const PKG_VERSION = JSON.parse(fs.readFileSync(path.join(ENGINE_DIR, "package.json"), "utf8")).version;
|
|
39
|
-
const SPEC_VERSION = "0.
|
|
39
|
+
const SPEC_VERSION = "0.7";
|
|
40
40
|
|
|
41
41
|
// --version: a print-and-exit MODE, handled before the main arg walk so it never depends on a target.
|
|
42
42
|
// Fully OFFLINE — candor never phones home. Staying current is the AGENT's job: read the installed
|
|
@@ -857,7 +857,7 @@ function recordAccessorHit(owner, hit, label) {
|
|
|
857
857
|
rec.edges.add(t); // (EDGE) into the accessor unit — effects propagate
|
|
858
858
|
} else {
|
|
859
859
|
rec.direct.add("Unknown");
|
|
860
|
-
rec.why.add(`accessor:${label}`);
|
|
860
|
+
rec.why.add(`reflect:accessor:${label}`); // a defineProperty runtime accessor (descriptor get/set unseen) — metaprogramming, canonical `reflect:`
|
|
861
861
|
}
|
|
862
862
|
}
|
|
863
863
|
|
|
@@ -1051,8 +1051,8 @@ function opaqueIterableWhy(expr) {
|
|
|
1051
1051
|
if (!t) return null;
|
|
1052
1052
|
// (a) `any` or a bare TYPE PARAMETER (`<T extends Iterable<…>>(x: T)`): the concrete iterable is
|
|
1053
1053
|
// indeterminate, so its iterator body is unknowable — never silently pure.
|
|
1054
|
-
if (t.flags & ts.TypeFlags.Any) return "
|
|
1055
|
-
if (t.flags & ts.TypeFlags.TypeParameter) return "
|
|
1054
|
+
if (t.flags & ts.TypeFlags.Any) return "callback:opaque-iterable:any";
|
|
1055
|
+
if (t.flags & ts.TypeFlags.TypeParameter) return "callback:opaque-iterable:typeparam";
|
|
1056
1056
|
// (b) the type IS an opaque iterable INTERFACE *and* the value is CALLER-SUPPLIED (a parameter / binding
|
|
1057
1057
|
// element): `collect(source: Iterable<T>)`, `nexts(it: Iterator<T>)`, `drain(g: Generator<T>)`. The
|
|
1058
1058
|
// caller chooses the concrete iterator (arbitrary I/O), identical to invoking an opaque callback. BOTH
|
|
@@ -1065,7 +1065,7 @@ function opaqueIterableWhy(expr) {
|
|
|
1065
1065
|
const d = realDecl(checker.getSymbolAtLocation(expr));
|
|
1066
1066
|
if (d && (ts.isParameter(d) || ts.isBindingElement(d))) {
|
|
1067
1067
|
const idx = ts.isParameter(d) && d.parent ? d.parent.parameters.indexOf(d) : -1;
|
|
1068
|
-
return idx >= 0 ? `
|
|
1068
|
+
return idx >= 0 ? `callback:opaque-iterable:param#${idx}` : "callback:opaque-iterable:param";
|
|
1069
1069
|
}
|
|
1070
1070
|
}
|
|
1071
1071
|
return null;
|
|
@@ -1155,7 +1155,7 @@ function visitCalls(node) {
|
|
|
1155
1155
|
} else {
|
|
1156
1156
|
rec.direct.add("Unknown"); // unresolvable call → Unknown, never silent-pure (SPEC §4)
|
|
1157
1157
|
const callee = (node.expression?.getText?.() ?? "?").replace(/\s+/g, "").slice(0, 60);
|
|
1158
|
-
rec.why.add(`
|
|
1158
|
+
rec.why.add(`callback:${callee}`); // an `any`-typed/indeterminate callee (a function VALUE) — canonical `callback:`
|
|
1159
1159
|
}
|
|
1160
1160
|
}
|
|
1161
1161
|
} else {
|
|
@@ -1191,7 +1191,7 @@ function visitCalls(node) {
|
|
|
1191
1191
|
if (tb) rec.edges.add(tb);
|
|
1192
1192
|
else {
|
|
1193
1193
|
rec.direct.add("Unknown");
|
|
1194
|
-
rec.why.add(`bind:${(bref ?? a).getText().replace(/\s+/g, "").slice(0, 40)}`);
|
|
1194
|
+
rec.why.add(`callback:bind:${(bref ?? a).getText().replace(/\s+/g, "").slice(0, 40)}`); // `.bind(...)` yields a function VALUE — canonical `callback:`
|
|
1195
1195
|
}
|
|
1196
1196
|
continue;
|
|
1197
1197
|
}
|
|
@@ -1232,7 +1232,7 @@ function visitCalls(node) {
|
|
|
1232
1232
|
// non-value receiver — a type, a literal — resolves to no decl and stays out, no fabrication.)
|
|
1233
1233
|
else if (d2 && (ts.isVariableDeclaration(d2) || ts.isBindingElement(d2) || ts.isParameter(d2))) {
|
|
1234
1234
|
rec.direct.add("Unknown");
|
|
1235
|
-
rec.why.add(`
|
|
1235
|
+
rec.why.add(`callback:${recvText.slice(0, 40)}.${m}`); // method on an indeterminate-valued receiver (no resolvable owner TYPE) — canonical `callback:`, not the frontier's `dispatch:OWNER.member`
|
|
1236
1236
|
}
|
|
1237
1237
|
}
|
|
1238
1238
|
// EXPLICIT iterator force: `it.next()` / `it.return()` / `it.throw()` on an OPAQUE iterator
|
|
@@ -1254,7 +1254,7 @@ function visitCalls(node) {
|
|
|
1254
1254
|
]);
|
|
1255
1255
|
if (why && sn && ITER_PROTO.has(sn)) {
|
|
1256
1256
|
rec.direct.add("Unknown");
|
|
1257
|
-
rec.why.add(why); // `
|
|
1257
|
+
rec.why.add(why); // `callback:opaque-iterable:param#i` / `:any` / `:typeparam` (opaque iteration ≈ opaque callback)
|
|
1258
1258
|
}
|
|
1259
1259
|
}
|
|
1260
1260
|
}
|
|
@@ -1308,11 +1308,11 @@ function visitCalls(node) {
|
|
|
1308
1308
|
for (const ot of oTargets) rec.edges.add(ot);
|
|
1309
1309
|
if (!allResolved) {
|
|
1310
1310
|
rec.direct.add("Unknown");
|
|
1311
|
-
rec.why.add(`
|
|
1311
|
+
rec.why.add(`dispatch:${decl.parent?.name ? `${moduleOf(decl.parent.getSourceFile())}.${decl.parent.name.getText()}` : "type"}.${decl.name?.getText?.() ?? "member"}`); // class-override dispatch — canonical `dispatch:QUALIFIED-OWNER.member`, frontier-relevant
|
|
1312
1312
|
}
|
|
1313
1313
|
} else {
|
|
1314
1314
|
rec.direct.add("Unknown"); // override family too wide to enumerate soundly
|
|
1315
|
-
rec.why.add(`
|
|
1315
|
+
rec.why.add(`dispatch:${decl.parent?.name?.getText?.() ?? "type"}.${decl.name?.getText?.() ?? "member"}`); // class-override dispatch (overridable member, unresolved/too-wide family) — canonical `dispatch:OWNER.member`, frontier-relevant
|
|
1316
1316
|
}
|
|
1317
1317
|
}
|
|
1318
1318
|
}
|
|
@@ -1375,8 +1375,14 @@ function visitCalls(node) {
|
|
|
1375
1375
|
}
|
|
1376
1376
|
if (!edged) {
|
|
1377
1377
|
rec.direct.add("Unknown");
|
|
1378
|
-
|
|
1379
|
-
|
|
1378
|
+
// QUALIFIED owner (module.Type), matching the `mod.Class.member` fn quals so the
|
|
1379
|
+
// dispatch-frontier (callers --include-unknown) can resolve overrides against the
|
|
1380
|
+
// hierarchy sidecar. Bare `decl.parent.name` would not match a reacher's declaringType.
|
|
1381
|
+
const tn = decl.parent?.name
|
|
1382
|
+
? `${moduleOf(decl.parent.getSourceFile())}.${decl.parent.name.getText()}`
|
|
1383
|
+
: "type";
|
|
1384
|
+
const mn = decl.name?.getText?.() ?? "member";
|
|
1385
|
+
rec.why.add(`dispatch:${tn}.${mn}`); // resolution landed on a type, not a body — canonical `dispatch:OWNER.member` (frontier-relevant)
|
|
1380
1386
|
}
|
|
1381
1387
|
}
|
|
1382
1388
|
}
|
|
@@ -1395,7 +1401,7 @@ function visitCalls(node) {
|
|
|
1395
1401
|
// resolved to a benign es-lib member and read SILENT-PURE (a code-execution sink reported pure).
|
|
1396
1402
|
if (name === "eval" && parent !== "Math" && parent !== "JSON") {
|
|
1397
1403
|
rec.direct.add("Unknown");
|
|
1398
|
-
rec.why.add("
|
|
1404
|
+
rec.why.add("reflect:eval"); // eval executes a runtime-supplied string — canonical `reflect:`
|
|
1399
1405
|
}
|
|
1400
1406
|
if ((parent === "DateConstructor" && name === "now") || (parent === "Performance" && name === "now"))
|
|
1401
1407
|
rec.direct.add("Clock");
|
|
@@ -1711,7 +1717,7 @@ function visitCalls(node) {
|
|
|
1711
1717
|
const kinds = (rsym && definePropDynamicKey.get(rsym)) || (rsym0 && definePropDynamicKey.get(rsym0));
|
|
1712
1718
|
if (kinds && kinds.has(kind)) {
|
|
1713
1719
|
const owner = enclosing(node);
|
|
1714
|
-
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`defineProperty:dynamic-key`); }
|
|
1720
|
+
if (owner) { fns.get(owner).direct.add("Unknown"); fns.get(owner).why.add(`reflect:defineProperty:dynamic-key`); } // dynamic-key descriptor install — metaprogramming, canonical `reflect:`
|
|
1715
1721
|
}
|
|
1716
1722
|
}
|
|
1717
1723
|
};
|
|
@@ -1935,7 +1941,11 @@ for (const [name, rec] of fns) {
|
|
|
1935
1941
|
if (inf.includes("Db") && rec.tables.size) entry.tables = [...rec.tables].sort();
|
|
1936
1942
|
if (inf.includes("Exec") && rec.cmds.size) entry.cmds = [...rec.cmds].sort();
|
|
1937
1943
|
if (inf.includes("Fs") && rec.paths.size) entry.paths = [...rec.paths].sort();
|
|
1938
|
-
|
|
1944
|
+
// ⟨0.6⟩ unknownWhy — REQUIRED on a DIRECT Unknown SOURCE (this fn's own body has the unresolvable call,
|
|
1945
|
+
// so `rec.direct` carries Unknown), absent on a purely-transitive Unknown. The rich per-site reasons
|
|
1946
|
+
// (rec.why: callback:/dispatch:/dynamic-key:) when recorded, else a generic fallback so a source is
|
|
1947
|
+
// never left un-tagged — the source/transitive split the `blindspots` query needs (SPEC §3.1/§4).
|
|
1948
|
+
if (rec.direct.has("Unknown")) entry.unknownWhy = rec.why.size ? [...rec.why].sort() : ["unresolved"];
|
|
1939
1949
|
// HONESTY: the npm packages this fn transitively reaches that κ couldn't see through — effects through
|
|
1940
1950
|
// them are NOT in `inferred`, so it is a LOWER BOUND when this is non-empty. Omitted when none.
|
|
1941
1951
|
if (rec.blind.size) entry.invisible = [...rec.blind].sort();
|
|
@@ -1956,6 +1966,30 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
|
|
|
1956
1966
|
const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
|
|
1957
1967
|
writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
|
|
1958
1968
|
writeAtomic(`${outPrefix}.callgraph.json`, JSON.stringify(cg, null, 1));
|
|
1969
|
+
// Type-hierarchy sidecar (SPEC §4 / 0.7): each project class/interface (qualified `mod.Name`, matching
|
|
1970
|
+
// the `mod.Class.member` fn quals) -> its qualified direct supertypes/interfaces. Compact (O(types)),
|
|
1971
|
+
// lets `callers --include-unknown` resolve whether a confirmed reacher is an override of a `dispatch:`
|
|
1972
|
+
// owner WITHOUT storing the dropped candidate edges (which would re-encode the flood bounded-CHA prevents).
|
|
1973
|
+
const hierarchy = {};
|
|
1974
|
+
for (const sf of sources) {
|
|
1975
|
+
const mod = moduleOf(sf);
|
|
1976
|
+
(function walk(node) {
|
|
1977
|
+
if ((ts.isClassDeclaration(node) || ts.isInterfaceDeclaration(node)) && node.name) {
|
|
1978
|
+
const supers = [];
|
|
1979
|
+
for (const h of node.heritageClauses ?? []) {
|
|
1980
|
+
for (const t of h.types ?? []) {
|
|
1981
|
+
let sym = checker.getSymbolAtLocation(t.expression);
|
|
1982
|
+
if (sym && sym.flags & ts.SymbolFlags.Alias) { try { sym = checker.getAliasedSymbol(sym); } catch { /* keep */ } }
|
|
1983
|
+
const d = (sym?.declarations ?? []).find((x) => ts.isClassDeclaration(x) || ts.isInterfaceDeclaration(x));
|
|
1984
|
+
supers.push(d && d.name ? `${moduleOf(d.getSourceFile())}.${d.name.getText()}` : t.expression.getText());
|
|
1985
|
+
}
|
|
1986
|
+
}
|
|
1987
|
+
if (supers.length) hierarchy[`${mod}.${node.name.getText()}`] = supers;
|
|
1988
|
+
}
|
|
1989
|
+
ts.forEachChild(node, walk);
|
|
1990
|
+
})(sf);
|
|
1991
|
+
}
|
|
1992
|
+
writeAtomic(`${outPrefix}.hierarchy.json`, JSON.stringify(hierarchy, null, 1));
|
|
1959
1993
|
console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
|
|
1960
1994
|
if (unlistedSeen.size > 0) {
|
|
1961
1995
|
const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));
|