From bfa8de466701baa752424e5cf49393a16c59b7df Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 27 Aug 2026 05:55:14 -0400 Subject: [PATCH 1/3] feat(callgraph): propagation tiers + property-initializer attribution (#100) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Class property initializers execute in the constructor: their call sites now attribute to the ctor (explicit or implicit), and an initializer ARROW materializes as a class-scoped positional anon — signature ↔ containment stays aligned, and the property-arrow known-gap closes. Instance-prop sites leave the module sweeps (vscode's Registry.as-in-field-initializer family). - T4a property form: 'template.onChange(...)' where the receiver is a parameter votes over object-literal properties (incl. method shorthand) passed at that position. - T4b chained return summaries: 'return makeInner()' follows one resolved-internal level, memoized and cycle-guarded. - Joern comparator: methods' parameter tables ride the dump; a residual whose target leaf-name is a parameter of the Joern caller is their parameters-as-callees fabrication wearing a real name — proven by their own table, classified, never gated. Corpus gate: 74/74, residual 0. vscode re-audit deferred until the L4 benchmark run frees the box. --- scripts/joern/compare_joern.py | 12 +++++-- scripts/joern/dump-calls.sc | 1 + src/semantic_analysis/callGraph.ts | 11 +++++- src/semantic_analysis/defuseLinker.ts | 49 +++++++++++++++++++++++---- src/syntactic_analysis/builders.ts | 23 +++++++++++++ 5 files changed, 86 insertions(+), 10 deletions(-) diff --git a/scripts/joern/compare_joern.py b/scripts/joern/compare_joern.py index 7dc44c3..e46205c 100755 --- a/scripts/joern/compare_joern.py +++ b/scripts/joern/compare_joern.py @@ -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") @@ -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)$") @@ -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 = [], [] @@ -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, diff --git a/scripts/joern/dump-calls.sc b/scripts/joern/dump-calls.sc index 0fa57cc..27e07af 100644 --- a/scripts/joern/dump-calls.sc +++ b/scripts/joern/dump-calls.sc @@ -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() } diff --git a/src/semantic_analysis/callGraph.ts b/src/semantic_analysis/callGraph.ts index dd2a37f..a60835a 100644 --- a/src/semantic_analysis/callGraph.ts +++ b/src/semantic_analysis/callGraph.ts @@ -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); } @@ -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; diff --git a/src/semantic_analysis/defuseLinker.ts b/src/semantic_analysis/defuseLinker.ts index 28295a3..f815f80 100644 --- a/src/semantic_analysis/defuseLinker.ts +++ b/src/semantic_analysis/defuseLinker.ts @@ -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>; @@ -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; @@ -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. @@ -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; @@ -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); @@ -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(); 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(""); + 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(""); }); if (returned.size === 1 && !returned.has("")) out = [...returned][0] as string; } diff --git a/src/syntactic_analysis/builders.ts b/src/syntactic_analysis/builders.ts index 16a1477..3bfd5c2 100644 --- a/src/syntactic_analysis/builders.ts +++ b/src/syntactic_analysis/builders.ts @@ -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.`, 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?.(); From f74f7d56866c7c70a0d597888d0844b2668a46ff Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 27 Aug 2026 06:49:37 -0400 Subject: [PATCH 2/3] =?UTF-8?q?test(ledger):=20rounds=209-10=20=E2=80=94?= =?UTF-8?q?=2099.72%,=20residual=20135;=20param-shadow=20proven=20by=20the?= =?UTF-8?q?ir=20tables=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/specs/defuse-linker-joern-ledger.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/design/specs/defuse-linker-joern-ledger.md b/docs/design/specs/defuse-linker-joern-ledger.md index e474651..d2b0109 100644 --- a/docs/design/specs/defuse-linker-joern-ledger.md +++ b/docs/design/specs/defuse-linker-joern-ledger.md @@ -33,9 +33,12 @@ Joern on all 10 cores at `-Xmx48g`. | 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 +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) @@ -80,11 +83,10 @@ class (PyCG 3h19m without convergence; Fraunhofer CPG OOM at 44GB). | Family | ≈count | Nature | | --- | --- | --- | -| Registry-pattern generics | ~16 | `Registry.as(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) From b520db3b39b195d7d8db02f2de87c9c11581b4e9 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Thu, 27 Aug 2026 07:00:23 -0400 Subject: [PATCH 3/3] =?UTF-8?q?test(ledger):=20vscode=20L4=20end-to-end=20?= =?UTF-8?q?=E2=80=94=2010m42s=20/=2028.6GB,=20SDG=20at=20scale,=20OOM=20ce?= =?UTF-8?q?iling=20removed=20(#100)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/design/specs/defuse-linker-joern-ledger.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/design/specs/defuse-linker-joern-ledger.md b/docs/design/specs/defuse-linker-joern-ledger.md index d2b0109..8503d1d 100644 --- a/docs/design/specs/defuse-linker-joern-ledger.md +++ b/docs/design/specs/defuse-linker-joern-ledger.md @@ -31,7 +31,8 @@ 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 | +| 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%,