Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 12 additions & 9 deletions docs/design/specs/defuse-linker-joern-ledger.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,15 @@ Joern on all 10 cores at `-Xmx48g`.
| --- | --- | --- | --- |
| cants L1 | **4m15s** | 18.9GB | 136,973 callables |
| cants L2 | **5m52s** | 24.4GB | **1,024,232 edges** — tsc 970,334 (324,525 resolved + 778,070 RTA + 89,344 phantom), defuse 54,170 (430 decorator / 26,466 callback / 1,797 votes / 31,581 CHA / rest chase) |
| Joern jssrc2cpg parse | **9m06s** | 30.3GB | CPG; 941,132 call rows dumped |

Superset audit against Joern's single-candidate real pairs, after seven ledger-driven fix
rounds: **54,885 / 55,074 covered (99.66%), residual 189** — past python's odoo bar (99.0%,
final residual 243). Reference: the engines this architecture replaced DNF'd at this scale
| cants **L4** (full SDG + artifact layer) | **10m42s** | 28.6GB | 1,028,736 call edges; CFG/CDG/DDG attached for 123,221 callables; **param_in 722,820 / param_out 200,775**; finalize survives via the structural (structuredClone) strip — the prior stringify-roundtrip clone OOM'd at exactly this scale, measured |
| Joern jssrc2cpg parse | **9m06s** | 30.3GB | CPG; 941,132 call rows + 768,350 parameter rows dumped (streamed writer — the single-StringBuilder dump crossed the JVM's 2GB array cap) |

Superset audit against Joern's single-candidate real pairs, after nine ledger-driven fix
rounds: **54,918 / 55,074 covered (99.72%), residual 135** — past python's odoo bar (99.0%,
final residual 243). Round 9/10 (#100): property-initializer attribution closed the whole
Registry-as-field family; the T4a property votes, T4b chained returns, and T4c ctor-field chain
landed; and Joern's parameter tables now PROVE the Promise-executor shadows (21 classified
`joern-param-shadow` by their own dump). Reference: the engines this architecture replaced DNF'd at this scale
class (PyCG 3h19m without convergence; Fraunhofer CPG OOM at 44GB).

### Analyzer fixes the ledger forced (python's reference-validation experience, repeated)
Expand Down Expand Up @@ -80,11 +84,10 @@ class (PyCG 3h19m without convergence; Fraunhofer CPG OOM at 44GB).

| Family | ≈count | Nature |
| --- | --- | --- |
| Registry-pattern generics | ~16 | `Registry.as<T>(Extensions.X)` through re-exported const + type args — the shape resolves in isolation; the vscode instantiation defeats the checker without deps materialized |
| Promise-executor params named like real functions | ~14 | `new Promise(resolve => … resolve())` where the file also declares a real `resolve` — Joern name-links the free function; the true target is the parameter (their parameters-as-callees family wearing a real name) |
| Residual Promise-executor shadows | ~13 | Deeper lambda callers whose parameter tables Joern itself under-reports — same fabrication family as the 21 their tables DO prove |
| **Static/instance same-name collision** | 11 | `Range.isEmpty` (instance) calls `Range.isEmpty` (static): the signature grammar cannot mark static, both collapse to ONE signature — the pair is unrepresentable and the collision gate flags it. A REAL schema-grammar limitation surfaced by this audit → design-mode follow-up |
| Closure-local callables through deep value flow | ~60 | Functions escaping via event emitters/registries beyond T4/T4c's bounded hops (settingsTree `onChange`, event utilities) — python zeroed its analog only with whole-program propagation (#150), the staged next step |
| Accessor/duck-typed and misc tails | ~88 | Getter-vs-method naming (`EventMultiplexer.event`), interface duck-typing (`ISearchTreeFolderMatch.id`), terminalTaskSystem dynamic patterns |
| Closure-local callables through deep value flow | ~55 | Functions escaping via event emitters/registries beyond T4/T4a/T4b/T4c's bounded hops (settingsTree `onChange`, event utilities, `registerAction` registries) — python zeroed its analog only with whole-program propagation (#150), the staged next step |
| Accessor/duck-typed and misc tails | ~56 | Getter-vs-method naming (`EventMultiplexer.event`), interface duck-typing (`ISearchTreeFolderMatch.id`), terminalTaskSystem/resources dynamic patterns |

## Known non-goals (recorded, deliberate)

Expand Down
12 changes: 10 additions & 2 deletions scripts/joern/compare_joern.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@

def load_joern(tsv):
calls, methods = [], {}
params = {}
malformed = 0
for line in open(tsv, errors="replace"):
parts = line.rstrip("\n").split("\t")
Expand All @@ -25,13 +26,15 @@ def load_joern(tsv):
elif parts[0] == "M" and len(parts) == 4:
_, fn, ln, col = parts
methods[fn] = (int(ln), int(col))
elif parts[0] == "P" and len(parts) == 3:
params.setdefault(parts[1], set()).add(parts[2])
else:
malformed += 1 # identifiers containing tabs/newlines (template literals etc.)
except ValueError:
malformed += 1
if malformed:
print(f" [note] {malformed} malformed dump rows skipped (control chars in identifiers)")
return calls, methods
return calls, methods, params

STRIP_EXT = re.compile(r"\.(d\.ts|tsx|ts|jsx|js|mts|cts|mjs|cjs)$")

Expand Down Expand Up @@ -95,7 +98,7 @@ def our_edges(fixture, dump=None):
return set(map(tuple, d["edges"])), set(d["sigs"])

def main(fixture, tsv, dump=None):
calls, methods = load_joern(tsv)
calls, methods, jparams = load_joern(tsv)
edges, sigs = our_edges(fixture, dump)
anon_ix, edges_by_target, edges_by_src = build_indexes(sigs, edges)
covered, residual = [], []
Expand Down Expand Up @@ -155,6 +158,11 @@ def main(fixture, tsv, dump=None):
if variant:
classes["joern-this-misresolution (typed edge held)"] += 1
covered.append(pair)
elif dname in jparams.get(caller, ()):
# The target's leaf name is a PARAMETER of the Joern caller: `new Promise(resolve
# => … resolve())` name-linked to a real free `resolve` — their parameters-as-
# callees family wearing a real name. Proven by their own parameter table.
classes["joern-param-shadow (fabricated target)"] += 1
elif any(t.rsplit(".", 1)[-1] == dname for t in edges_by_src.get(src, ())):
# Weaker tier: from the same caller we hold a typed edge to a target of the SAME
# LEAF NAME in another file (e.g. the imported free `dispose` from lifecycle.ts,
Expand Down
1 change: 1 addition & 0 deletions scripts/joern/dump-calls.sc
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
}
cpg.method.foreach { m =>
sb.append(s"M\t${m.fullName}\t${m.lineNumber.getOrElse(-1)}\t${m.columnNumber.getOrElse(-1)}\n")
m.parameter.foreach { p => sb.append(s"P\t${m.fullName}\t${p.name}\n") }
}
val pw = new java.io.PrintWriter(outFile); pw.write(sb.toString); pw.close()
}
11 changes: 10 additions & 1 deletion src/semantic_analysis/callGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,15 @@ function enclosingCallable(node: Node): Node | undefined {
return undefined;
}

/** Inside a NON-static class property initializer — owned by the constructor, not module scope. */
export function inInstancePropInit(node: Node): boolean {
for (const a of node.getAncestors()) {
if (isCallableDecl(a)) return false;
if (Node.isPropertyDeclaration(a)) return !(a as unknown as { isStatic?: () => boolean }).isStatic?.();
}
return false;
}

function fileKeyOfNode(node: Node, root: string): { fileKey: string; modulePrefix: string } {
return fileKeyOf(node.getSourceFile().getFilePath(), root);
}
Expand Down Expand Up @@ -153,7 +162,7 @@ export function buildCallGraph(
// (source = the module prefix, re-identified onto the module node at L2). These sites are
// never recorded in call_sites (modules have no body{}), so resolve them straight off the AST.
for (const node of callExprIndex.values()) {
if (enclosingCallable(node)) continue;
if (enclosingCallable(node) || inInstancePropInit(node)) continue;
const fileKey = fileKeyOfNode(node, root);
if (only && !only.has(fileKey.fileKey)) continue;
const source = fileKey.modulePrefix;
Expand Down
49 changes: 42 additions & 7 deletions src/semantic_analysis/defuseLinker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ import { computeSignatureForDecl, externalHomeOf, fileKeyOf, isCallableDecl, res
import { callBodyKeys } from "../schema/l1Body";
import type { CallGraphContext } from "./provider";
import type { CallGraphResult } from "./callGraph";
import { indexCallExpressions } from "./callGraph";
import { inInstancePropInit, indexCallExpressions } from "./callGraph";

/** Per-call-site resolutions for the sanctioned `callee: null→id` refinement: callerSig → bodyKey → calleeSig. */
export type LinkerResolutions = Map<string, Map<string, string>>;
Expand Down Expand Up @@ -158,6 +158,7 @@ export function runDefuseLinker(ctx: CallGraphContext): LinkerOutput {
enclosing: TSCallable;
bodyKey: string;
paramIndex: number;
propertyName?: string; // `template.onChange(...)` where `template` is the parameter
}
interface FactorySite {
enclosing: TSCallable;
Expand Down Expand Up @@ -219,7 +220,12 @@ export function runDefuseLinker(ctx: CallGraphContext): LinkerOutput {
}
}
} else if (Node.isPropertyAccessExpression(expr) && cs.receiver_expr != null && !cs.is_constructor_call) {
if (cs.receiver_expr === "this") {
const recvIdx = paramIndexOf(expr.getExpression(), c);
if (recvIdx !== null) {
// T4a property form: the receiver IS a parameter — candidates are the matching
// object-literal property values passed at that position by resolved callers.
paramSites.push({ enclosing: c, bodyKey, paramIndex: recvIdx, propertyName: expr.getName() });
} else if (cs.receiver_expr === "this") {
// T4c — `this.field(...)`: the field's value flows from the constructor (a
// parameter property or a `this.field = …` assignment); resolved below, feeding
// the T4 vote rounds. Falls back to T5 if the chain yields nothing.
Expand Down Expand Up @@ -254,7 +260,7 @@ export function runDefuseLinker(ctx: CallGraphContext): LinkerOutput {
return undefined;
};
for (const [, node] of [...callExprIndex.entries()].sort(([a], [b]) => a.localeCompare(b))) {
if (enclosingCallable(node)) continue;
if (enclosingCallable(node) || inInstancePropInit(node)) continue;
const fk = fileKeyOf(node.getSourceFile().getFilePath(), root);
if (ctx.only && !ctx.only.has(fk.fileKey)) continue;
const source = fk.modulePrefix;
Expand Down Expand Up @@ -443,8 +449,22 @@ export function runDefuseLinker(ctx: CallGraphContext): LinkerOutput {
for (const args of argsByTarget.get(site.enclosing.signature) ?? []) {
const arg = args[site.paramIndex];
if (!arg) continue;
const fn = functionValueSig(arg);
if (fn) candidates.add(fn);
if (site.propertyName !== undefined) {
// object-literal property flow: `render({ onChange: fn })` → `template.onChange()`
if (Node.isObjectLiteralExpression(arg)) {
const prop = arg.getProperty(site.propertyName);
const init = prop && Node.isPropertyAssignment(prop) ? prop.getInitializer() : undefined;
const fn = init ? functionValueSig(init) : null;
if (fn) candidates.add(fn);
else if (prop && Node.isMethodDeclaration(prop)) {
const s2 = computeSignatureForDecl(prop, root);
if (s2 && allSignatures.has(s2)) candidates.add(s2);
}
}
} else {
const fn = functionValueSig(arg);
if (fn) candidates.add(fn);
}
}
if (!candidates.size) {
unresolvedNext.push(site);
Expand Down Expand Up @@ -480,14 +500,29 @@ export function runDefuseLinker(ctx: CallGraphContext): LinkerOutput {
const declNode = sf?.getDescendantAtPos(fc.span.bytes[0]);
const fnNode = declNode ? [declNode, ...declNode.getAncestors()].find((a) => computeSignatureForDecl(a, root) === factorySig) : undefined;
if (fnNode) {
returnSummary.set(factorySig, null); // cycle guard before descending
const returned = new Set<string>();
fnNode.forEachDescendant((d) => {
if (!Node.isReturnStatement(d)) return;
const e = d.getExpression();
if (!e) return;
const fn = functionValueSig(e);
if (fn) returned.add(fn);
else returned.add("<opaque>");
if (fn) {
returned.add(fn);
return;
}
// chained: `return makeInner()` — follow ONE resolved-internal level, memoized
if (Node.isCallExpression(e)) {
const r = resolveCalleeSignature(e, root, allSignatures);
if (r && !r.external && allSignatures.has(r.signature)) {
const inner = uniqueReturnedFn(r.signature);
if (inner) {
returned.add(inner);
return;
}
}
}
returned.add("<opaque>");
});
if (returned.size === 1 && !returned.has("<opaque>")) out = [...returned][0] as string;
}
Expand Down
23 changes: 23 additions & 0 deletions src/syntactic_analysis/builders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,29 @@ export function buildClass(cls: Node, root: string): { sig: string; cls: TSType
}
}

// Instance property INITIALIZERS execute in the constructor (`private readonly registry =
// Registry.as(...)`): their call sites belong to the ctor (explicit or the synthesized
// implicit one), and an initializer ARROW is a class-scoped positional anon callable —
// `contributorName` signs it `Class.<anon@l:c>`, so it lives in the class's callables{},
// keeping signature ↔ containment aligned. Static initializers run at class-definition time
// and stay with the module-scope sweep (callGraph.ts).
{
const ctorCallable = callables[memberKey(constructorSignatureOf(sig))];
for (const p of c.getProperties()) {
if (boolOf(p, "isStatic")) continue;
const init = (p as unknown as { getInitializer?: () => Node | undefined }).getInitializer?.();
if (!init || !ctorCallable) continue;
walkBody(init, {
onCall: (n) => ctorCallable.call_sites.push(buildCallsite(n)),
onNestedCallable: (n) => {
const r = buildNestedCallable(n, root);
if (r) callables[memberKey(r.sig, r.callable.accessor_kind)] = r.callable;
},
onNestedClass: () => {}, // class expression inside a property initializer — out of scope
});
}
}

const base_classes: string[] = [];
const implements_types: string[] = [];
const ext = c.getExtends?.();
Expand Down
Loading