feat(callgraph)!: replace Jelly with the defuse linker — tsc + per-callable tiers - #99
Open
rahlk wants to merge 15 commits into
Open
feat(callgraph)!: replace Jelly with the defuse linker — tsc + per-callable tiers#99rahlk wants to merge 15 commits into
rahlk wants to merge 15 commits into
Conversation
This was referenced Aug 27, 2026
BREAKING: the released --call-graph-provider and --tsc-only flags are removed (commander now rejects them); the tsc resolver is the one base call graph, per the defuse-linker design (docs/design/specs/defuse-linker-call-graph.md). Gone with them: the union/jelly providers and selector, the __jelly multi-call binary mode and CANTS_SELF_JELLY re-exec, the @cs-au-dk/jelly dependency, its patch, and the --external @babel/preset-typescript bundling workaround (bundle: 998 → 455 modules). mergeCallGraphs stays — the defuse linker overlays the tsc base through it. python-sdk's tsc_only kwarg keeps passing --tsc-only until its tracked follow-up lands (spec, release plan). Full suite + typecheck green; binary builds.
…#98) walkBody only visited the body's CHILDREN unless the body was itself a callable boundary, so `u => u.describe()` never recorded u.describe() as a call site — an L1 gap the Jelly leg papered over with its own approximated edge. Visiting the body node itself closes it: the site lands in the anon's call_sites, the tsc resolver types it precisely (users: User[] ⇒ u: User), and the body{} call node appears at L1. Additive wire change (a real call site that was missing).
…base (#98) The local pass that backfills what the resolver missed (docs/design/specs/defuse-linker-call-graph.md; python's Jedi+defuse architecture). Per-callable and bounded-round only, sorted iteration — deterministic by construction, no whole-program fixpoint: T1 local value chase (alias chains via bounded symbol hops) T2 decorator invocations (method/accessor/parameter owners; edge-only) T3 external-callback rule (function value passed to an external or unresolved callee ⇒ enclosing→lambda; edge-only — the documented divergence from python) T4 bounded interprocedural votes (param-invoking sites resolve to the functions passed by resolved-internal callers, two rounds; factory returns through a unique returned function) T5 CHA-by-name fallback (bounded fan, skip-not-truncate over the cap) Edges carry prov ["defuse"] and overlay the tsc base via mergeCallGraphs; body-node resolutions return out-of-band and are applied by backfillCallees — never persisted into callee_signature (cache provenance rule). Jelly-recovery on the fixtures: 6/6 jelly-only edges (3 decorator, 1 callback via T3, 2 upgraded to typed tsc resolutions by the concise-arrow fix); edge counts equal the old union exactly (57 / 22 / 2). Full suite + typecheck green.
…ual 0 (#98) The reference-validation harness (scripts/joern: cpg call dump + the signature-mapping comparator) and the audited exception ledger (docs/design/specs/defuse-linker-joern-ledger.md). Corpus: the three repo fixtures + nestjs-realworld-example-app @ c1c2cc4. Every Joern real pair is covered; exclusions are classified per family (their lowering synthetics, parameters-as-callees stubs, super-to-interface fabrications, decorator-attribution variants) — python's ledger discipline. A/B paired runs byte-identical on all four apps.
`inline\`url(...)\`` never recorded a call site — walkBody, the callee resolver, and the call-expression index all matched Call/New only, while the L3 exception model already treated tagged templates as calls. Found by the vscode Joern ledger (432-strong same-file residual family). Additive wire change: real call nodes that were missing.
…+ regression test (#98)
…lable (#98) `createCSSRule(sel, style = getSharedStyleSheet())` executes the default in the callee's activation, but walkBody only covered getBody() — the call (and any nested arrow) was invisible. Found by the vscode ledger (domStylesheets family). Additive wire change + regression test.
…ied (#98) Corpus gate holds at residual 0 (74/74). vscode @ a3c9dc6 (8,735 files, 1.15M LOC): 54,703/54,947 single-candidate Joern real pairs covered after four ledger-driven fix rounds; the 244 residual is classified (escaped closure-locals — python #150's staged tier, vendored marked, the static/instance signature-collision finding, accessor tails). Scale table: cants L2 5m52s/24.4GB for 1.02M edges vs Joern parse 9m06s/30.3GB. Comparator gained fan-row and misresolution-variant rules (their multi-candidate rows are enumeration, not resolution).
…ain (#98) Two residual families from the vscode ledger: - .js/.jsx/.mjs/.cjs sources were never DISCOVERED (SOURCE_EXTS was ts-only) — vscode's vendored marked.js was analyzed through its bodiless .d.ts, so every internal edge was missing. JS files are now first-class, with a compiled-sibling guard (a .js next to its same- prefix .ts is build output, skipped — the compiler's own allowJs duplicate rule). - T4c: `this.field(...)` where the field's value arrives through the constructor (parameter property or this.f = param) resolves to the function values passed at the class's `new` sites — with ONE bounded parameter hop (register(key, cb) → new Migration(key, cb)) and module-scope resolved calls now feeding the vote rounds. The registered callbacks' own param-invoking sites then resolve in the existing T4 rounds: the full migrateOptions chain (apply → callback → apply.write) lands, no fixpoint anywhere.
#98) The compiled-sibling guard registered marked.d.ts's prefix and skipped marked.js — inverted for the hand-written-declarations pattern. Sibling rules are now two-way: .js beside a REAL .ts source is build output (skipped); .d.ts beside an analyzed .js is its declaration file (skipped as a module; the checker still reads it from disk).
…sidual 189 classified (#98)
rahlk
force-pushed
the
feat/issue-098-defuse-linker
branch
from
August 27, 2026 13:27
cb52efd to
1dea432
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #98. Stacked on #97 (native v2 model). Design record: docs/design/specs/defuse-linker-call-graph.md · audited gate: docs/design/specs/defuse-linker-joern-ledger.md.
What
Jelly is removed wholesale — the whole-program flow analysis (the same cost class as python's removed PyCG), the
__jellymulti-call binary mode,CANTS_SELF_JELLY, the@cs-au-dk/jellydependency and its patch, and BREAKING: the released--call-graph-providerand--tsc-onlyflags (one code path, no backend flag — python 1.2.0 precedent). Bundle: 998 → 455 modules.In its place, the defuse linker (
src/semantic_analysis/defuseLinker.ts): a deterministic per-callable pass over the tsc resolver's leftovers, python's Jedi+defuse architecture instantiated for TS. Tiers, in order: T1 local value chase (bounded alias hops) · T2 decorator invocations (method/accessor/param owners; class/property decorators attribute to the module) · T3 external-callback rule (function value passed to an external/unresolved callee ⇒ enclosing→lambda — the documented divergence from python; JS is callback-central) · T4 bounded interprocedural votes (param-invoking sites, factory returns; two rounds, no fixpoint) · T5 CHA-by-name fallback (bounded fan, skip-not-truncate). Edges carryprov: ["defuse"]; resolutions reach the L1callnodes out-of-band and are never persisted intocallee_signature(cache provenance rule). Module-scope execution is now a call-graph SOURCE (python #131 parity): top-level calls, class decorators, and the top-level express idiom attribute to the MODULE, id-homed onto the module node.The validation drove six real analyzer fixes
Iterating the Joern ledger surfaced L1/L2 gaps Jelly had been masking, each now regression-tested:
u => u.describe()) recorded no call siteinline`url(...)`) were invisible end to endf(sel, style = mkSheet())) were never walkedthis.field(...)through parameter properties with one bounded parameter hop; vscode's migrateOptions registration pattern closes end-to-end (apply → callback → apply.write), no fixpointGates
jssrc2cpgreal pairs across sample-app, dataflow-app, anon-app, nestjs-realworld @ c1c2cc4 (ours 126 internal edges vs Joern's 30 there)schema.neo4j.jsonregen byte-stable, binary buildsFollow-ups recorded (not in this PR)
tsc_onlykwarg removal (spec release plan; the one known break until then)