candor-ts 0.5.25 → 0.7.0

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "candor-ts",
3
- "version": "0.5.25",
3
+ "version": "0.7.0",
4
4
  "description": "candor for TypeScript — per-function side effects, transitively, with a policy gate (candor-spec 0.5)",
5
5
  "type": "module",
6
6
  "dependencies": {
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) {
package/query.mjs CHANGED
@@ -26,6 +26,7 @@ import { printAgents } from "./contract.mjs";
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
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
- const [prefix, q] = args;
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
- const names = Object.keys(cg);
64
- const targets = matches(names, q);
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": {
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.6";
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 "iterate:any";
1055
- if (t.flags & ts.TypeFlags.TypeParameter) return "iterate:typeparam";
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 ? `iterate:param#${idx}` : "iterate:param";
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(`call:${callee}`); // an `any`-typed/indeterminate callee — named, so triage starts here
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(`call:${recvText.slice(0, 40)}.${m}`);
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); // `iterate:param#i` / `iterate:any` / `iterate:typeparam`
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(`override:${decl.name?.getText?.() ?? "member"}`);
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(`override:${decl.name?.getText?.() ?? "member"}`);
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
- const tn = decl.parent?.name?.getText?.() ?? decl.name?.getText?.() ?? "type";
1379
- rec.why.add(`dispatch:${tn}`); // resolution landed on a type, not a body
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("call:eval");
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
  };
@@ -1960,6 +1966,30 @@ for (const [name, rec] of fns) cg[name] = [...rec.edges].sort();
1960
1966
  const writeAtomic = (file, text) => { const tmp = `${file}.${process.pid}.tmp`; fs.writeFileSync(tmp, text); fs.renameSync(tmp, file); };
1961
1967
  writeAtomic(`${outPrefix}.json`, JSON.stringify(envelope, null, 1));
1962
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));
1963
1993
  console.error(`candor-ts: wrote ${functions.length} effectful functions (${fns.size} analyzed, ${sources.length} files) to ${outPrefix}.json`);
1964
1994
  if (unlistedSeen.size > 0) {
1965
1995
  const top = [...unlistedSeen.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]));