Skip to content

Go L4 interprocedural SDG, VTA dispatch and library stand-ins - #36

Merged
pavuluri merged 65 commits into
mainfrom
feat/l4-sdg
Oct 29, 2026
Merged

pavuluri merged 65 commits into
mainfrom
feat/l4-sdg

Conversation

@sinha108

@sinha108 sinha108 commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Adds L4, the interprocedural system dependence graph (SDG), to codeanalyzer-go at -a 4, and makes --emit neo4j project at L4. It also closes L2 and L3 gaps that L4 depends on.

Motivation and Context

L4 brings the Go analyzer to the same level as codeanalyzer-python and codeanalyzer-java. It is the graph that python-sdk's interprocedural slicing and taint walk over: each callable gets formal_in / formal_out vertices and each resolved call site actual_in / actual_out, wired by param_in / param_out and per-call-site summary edges, with VTA dispatch for interface and function-value calls and library stand-ins so sinks can name library functions.

Design: docs/design/specs/v2-l3-l4-dataflow.md §4.6–§4.16 and §6, amended through this work. Decisions are tagged [user] / [call] in §4.16.

How Has This Been Tested?

  • Unit and gate tests (go test ./..., CI on Go 1.26 and the latest stable Go):

    • per-construct fixtures for vertices, port wiring, summaries, param edges, VTA through wrappers, stand-ins and --library-models, the may-alias delta, and the library-origin CHA complement
    • the HTTP → SQL "done" fixture: a handler's request parameter reaches database/sql.DB.Query's query through an interface call that only VTA resolves, and an unrelated pair is unreachable
    • -a 1 ⊆ -a 2 ⊆ -a 3 ⊆ -a 4 monotonicity on the JSON, with a self-test of the checker itself
    • -j 1 vs -j N byte-identity, and an order-independence test that permutes the library-origin fixpoint's work order
    • a non-gating differential of L3 def-use against go/ssa
  • Metamorphic tests (new, internal/metamorphic): 15 relations with exact, reachability and edge-delta oracles, over the fixtures, chi and a focused examples module (go test), and memos by default under scripts/metamorphic.sh (--all adds nats-server). They found and fixed two soundness bugs in closure captures (L3 and L4).

  • Live Neo4j 5.26 (neo4j_it, throwaway Docker container), including a lazy L4 push over an older lower-level graph.

  • Real-app evaluation, reproducible with CANGO_LEVELS="2 3 4" scripts/eval_l3.sh --all (same 8 apps and pinned commits as the L3 PR); results in docs/design/notes/l3-dataflow-metrics.md § L4 (go1.27.3, measured at the PR's final analyzer code, 98ebfef). Every app completes -a 4 with no crash, and -j 1 / -j 16 / a second -j 16 are byte-identical.

    6 apps Kubernetes (root / k8s.io/api)
    wall -a 3 → -a 4 (-j 1) 12.7 → 48.0 s 12.8 → 38.4 s / 5.2 → 10.6 s
    peak RSS at -a 4 (× -a 3) 287 MB (1.15×) – 5.9 GB (1.17×); nats-server 4.1 GB (3.77×) 7.9 GB (0.99–1.13×) / 2.5 GB (1.23×)
    analysis.json -a 3 → -a 4 263 MB → 2.83 GB 343 MB → 1.43 GB / 193 → 360 MB
    vertices 2.49M 1.22M / 242k
    param_in / param_out 1.24M / 0.77M 495k / 432k · 91k / 88k
    summary 3.79M 1.36M / 206k

    Takeaways:

    • The scale gate passes. The Kubernetes root runs -a 4 in 38.4 s at 7.9 GB, 0.99–1.13× its -a 3 peak (which varies by about 1 GB run to run); the package load still dominates memory. About 6 s of its L4 time is the CHA complement's interprocedural library-origin fixpoint.
    • Output size, not memory, is L4's cost. JSON grows 2–23×, driven by global variables: 93% of nats-server's port edges carry a global (one 822-callable recursive SCC). Memory is measured and reported, not gated (D3).
    • Degradation is reported, never silent. 6 of the 8 apps use range-over-func (range_func, not modelled at L4, D8), so the SDK will withhold "no flow" answers on them; unmapped_ssa comes almost entirely from closures in package-level var initializers.
    • The final-review fixes grow L4 where library-originated receivers are common. Against the first eval (before them), vertices grow 5–47% on memos, openbao, dae and Kubernetes and under 0.2% elsewhere; a bisect puts nearly all of it on the CHA complement.
    • The memos metamorphic run (scripts/metamorphic.sh) passes on the same head with 0 violations in 14 relations.
  • Bugs found by the reviews and metamorphic tests, fixed here with regression tests:

    • methods declared above their receiver type were dropped at every level (L1)
    • call sites inside closures were never resolved at L2 (L2 does not resolve call sites inside closures #17)
    • call spans under //line directives were empty (Call-site recording: empty spans under //line, and one walker shared by L1 and L3 #26)
    • VTA dropped concrete targets reached through method values and interface-embedding wrappers
    • --skip-tests=false silently lost SSA for packages with tests
    • interface calls on values from library code (ctx.Value(k).(I), sync.Map.Load) had no concrete targets
    • a closure writing through a local alias of a captured pointer lost the flow (L3 and L4)
    • a lazy Neo4j push over a graph stored at a lower level silently dropped L4 rows
    • analysis.json ignored the umask and was not fsynced before the rename

Breaking Changes

  • CLI:
    • -a 4 is accepted; --strict makes any degradation a non-zero exit; --library-models <path.json> (valid only at L4).
    • --emit neo4j now projects at L4. With --target-files it falls back to L3 and prints a warning; -a 4 -t is an error (L4 is whole-program).
  • JSON (all additive; -a 1..3 output is a subset of -a 4):
    • body vertices with of; callable summary; application param_in / param_out; degraded[]; external_symbols[].receiver and .body; ddg edges with prov reaching-defs and points-to; call_graph edges with prov vta.
    • at L2, external methods now carry receiver.
  • L3 output changes (more edges, none removed):
  • Neo4j: GO_PARAM_IN / GO_PARAM_OUT (var) and GO_SUMMARY are live; stand-in vertices hang off :GoExternal; :GoApplication.degraded_json. A lazy push re-upserts every L4 row and forces a full push when the stored level is lower; removing stale L4 rows needs --eager.
  • Output streaming: analysis.json is streamed module by module and written atomically (temp file plus rename); the bytes are unchanged.
  • Upgrade notes are in the README's "Upgrading" section.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)
  • New feature (non-breaking change which adds functionality)
  • Breaking change (fix or feature that would cause existing functionality to change)
  • Documentation update

Checklist

  • I have read the Codellm-Devkit Documentation
  • My code follows the repository's style guidelines
  • New and existing tests pass locally
  • I have added appropriate error handling
  • I have added or updated documentation as needed

Additional context

A closure that writes through a local alias of a captured pointer
(`q := p; q.n = x`) writes no path of p, so it had no capture formal_out
for p and the flow x -> p was refuted. Apply the shared by-ref predicate
(MayHoldRef) to captures, as to params. Found by metamorphic relation MR5.
A literal writing through a local alias of a captured pointer
(`q := p; q.n = x`) writes no path of p, so at -a 3 its host did not
weak-define p.* and a later read of p.n had no edge from it. Add x.* for
every capture x that may hold a reference, mirroring 078616f at L4.
CANGO_LEVELS="2 3 4" runs the -a 4 triple (-j 1, -j N, rerun) with a byte-identity check and records L4 counts, times, bytes and degraded codes; l3_metrics.py adds an l4 section.
With strconv/Atoi: [] the Atoi summary edges go and the flow stays; a cut on n within SanitizedFind is bypassed by err (cdg, return) and the receiver (coarse host wiring), all correct dependence (codellm-devkit/.github#103). The no-flow pair moves to a separate entry, Audit, that shares no host variable.
A method value of an interface or a struct embedding one makes VTA return a
$bound or promotion wrapper whose own call of the concrete method sits at a
synthetic site no call node joins; the wrapper's VTA callees are now targets
of the original site.
With tests loaded the PkgPath-keyed package map keeps only the test variant,
so the plain package other packages import had no SSA bodies and VTA missed
the types flowing through it. L4 now builds SSA over every loaded variant.
A D7 interface-method target (or L2's static callee) at a site with no L2
edge was emitted with prov ["vta"]. It stays a dispatch target with its
param edges; only VTA's own callees become call_graph edges.
formalIndex built a partially initialised join just to call it.
VTA sees no concrete type in library code, so ctx.Value(k).(Store).Find or
a sync.Map round-trip kept only the D7 interface method. An invoke site whose
receiver originates in a type assertion, a library call or a library load now
also targets every project type implementing the interface. Also documents
F1 and F7 in spec §4.8.
The documented code was never produced. It lists the project packages
go/packages loaded with errors, sorted.
SET n += row.p keeps a property the row lacks, so a clean push over a graph
a degraded run wrote kept its degraded_json. The application row now carries
an explicit null. Also: Degraded.Code is an open vocabulary.
A library-originated value returned by or passed into a project function
is part of the remaining gap, not only one stored in project storage.
A library-originated value that reached an interface call through a project
function's result or parameter, a closure capture, a field, a global or a
container element kept only the D7 interface method. A whole-program monotone
fixpoint now tracks it (storage by container type), and map lookups, index
and channel receives count as origins. CHA runs under recover: a panic drops
its targets and records no_cha.
callLO read the implementer-is-library-code memo before implementers filled
it, and the memo is not a fact, so an invoke through a promoted library
method got no CHA target when its caller was visited first. The memo is now
filled with the implementer list it belongs to. runCHA clones the site
slices it restores on a panic.
…s as L2 does, list range-over-func in package-level literals
…nd-trip; bind on go/types' method-expression kind
@sinha108
sinha108 marked this pull request as ready for review October 28, 2026 21:21
@sinha108
sinha108 requested review from pavuluri and rahlk October 28, 2026 21:21
@pavuluri
pavuluri merged commit 25dc74f into main Oct 29, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants