Skip to content

Go L3 intraprocedural dataflow, app-outermost ids and L2 library calls - #16

Merged
pavuluri merged 46 commits into
mainfrom
feat/l3-dataflow
Oct 27, 2026
Merged

pavuluri merged 46 commits into
mainfrom
feat/l3-dataflow

Conversation

@sinha108

@sinha108 sinha108 commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

Adds L3 intraprocedural dataflow to codeanalyzer-go at -a 3, switches ids to the app-outermost can://<app>/go/… grammar, and makes L2 keep calls to library functions.

Motivation and Context

L3 brings the Go analyzer to parity with codeanalyzer-python and codeanalyzer-java. It is also what python-sdk's slicing and taint walk over: every callable now carries statement body nodes plus cfg / cdg / ddg edges, in both analysis.json and the Neo4j projection.

The id change (can://go/<app> becomes can://<app>/go) puts the app outermost, as the Python and Java analyzers already do, so a polyglot app's analyses share one can://<app> prefix.

L2 previously resolved calls to library functions and then threw them away. It now keeps them as call_graph edges into a new application.external_symbols map.

Design: docs/design/specs/v2-l3-l4-dataflow.md. L4 (interprocedural SDG) is a separate, later PR.

How Has This Been Tested?

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

    • exact-edge CFG tests for each Go construct (defer, panic, select, labels, goto, fallthrough, type switch, range)
    • a differential check against x/tools/go/cfg
    • a brute-force control-dependence reference over the whole fixture corpus
    • hand-derived exact backward slices
    • -j 1 vs -j N byte-identity
    • L2 ⊆ L3 monotonicity checked against the JSON
  • Live Neo4j 5.26: the neo4j_it suite (41 tests) and repeated --eager pushes, run in a throwaway Docker container.

  • Real-app evaluation, reproducible with scripts/eval_l3.sh --prepare then scripts/eval_l3.sh --all:

    • 8 apps at pinned commits: cobra, listmonk, memos, nats-server, openbao, dae, and Kubernetes v1.37.1 (root module and k8s.io/api)
    • all type-check fully, all pass every consistency check, and all give byte-identical output across -j 1, -j 16 and a second -j 16
    • results: docs/design/notes/l3-dataflow-metrics.md and docs/design/notes/l3-eval-2026-10.md
    • re-run on the Kubernetes root after the last code changes: still complete, deterministic, and no function's L3 analysis failed
    6 apps Kubernetes (root + k8s.io/api)
    callables with a body 22,971 58,030
    body nodes 385,505 848,801
    cfg / cdg / ddg edges 468k / 262k / 778k 923k / 545k / 1.46M
    L3 time over -a 2 (-j 1) +18% +23% (root)
    peak RSS at -a 3 263 MB – 5.1 GB 7.1 GB (root)
    analysis.json, -a 2 → -a 3 2.3× 2.0× (root), 3.3× (k8s.io/api)

    Takeaways:

    • L3 is cheap in time and costly in bytes. It adds about 20% wall time but doubles to triples the JSON.
    • Kubernetes' root module runs -a 3 in 12.4 s at 7.1 GB.
    • Generated protobuf marshallers stay bounded. On k8s.io/api, 627k ddg edges come almost entirely from generated.pb.go, and the largest marshaller has 14.8k edges.
    • The ddg is long-tailed. The top 1% of callables hold 18–53% of each app's edges; the largest is generated conversion code at 130k.
  • Bugs found by the evaluation and reviews, fixed here with regression tests:

    • L2 lost and reordered call edges for repeated func init().
    • Neo4j --eager push failed on Neo4j 5.x.
    • --eager combined with --target-files purged the whole app from Neo4j and pushed back only the targeted packages. The purge now runs only on a full run.
    • L2 lost callees for calls in files with //line directives.
    • Calls inside go statement arguments, such as g() in go f(g()), were not recorded.
    • A badly malformed source file crashed the analyzer at every level.
    • x/tools v0.46 could not read Go 1.27's compiled package data; upgraded to v0.52.

Breaking Changes

  • Ids: can://go/<app>/<file>/… becomes can://<app>/go/<file>/…. External symbols are can://<app>/@external/<import-path>/<sig>.

  • Body nodes: they now carry id (<callable-id>@<local>) at every level, including -a 1 and -a 2, as codeanalyzer-python does.

  • L2 output gains library edges. call_graph gains edges to library callees, a call node's callee is set for them instead of null, and application.external_symbols is new. A type conversion is still a call node, but its callee stays null; it is no longer resolved to a <Type>.__new__ pseudo-callee.

  • L1 records more:

    • type kind can be alias or defined
    • deferred calls carry is_deferred: true
    • calls that start at the same position, such as s.A().B(), are separate call nodes keyed l:c and l:c/2
    • calls inside go statement arguments are now call sites

    Consumers that count call nodes, or parse body keys as plain line:col, are affected.

  • Neo4j:

    • --emit neo4j now projects at L3, adding GO_CFG_NEXT, GO_CDG and GO_DDG.
    • :GoBodyNode.call_node is the local parent key, matching Python.
  • python-sdk: saved Go analysis.json fixtures need regenerating, and the Go backend's reader must map call_node back to parent (python-sdk#434).

  • Toolchain:

    • Building from source needs Go 1.26 or newer; go.mod moves from go 1.25.0 to go 1.26.0, because golang.org/x/tools v0.52 requires it.
    • Release binaries are built with the latest stable Go.
    • A binary built with an older Go than a target module's go line prints one clear warning instead of degrading silently.
  • Upgrade notes for all of the above 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

…ojectDir; span-less external params; chi externals determinism
BuildCFG now returns the pruned node set with the edges.
Adds a by-definition CDG reference check over the dataflow and chi corpora.
BuildNodes takes the keys in recording order instead of rebuilding a
token.Pos from line:col, which is wrong under //line directives. Nodes
also carry their directive-adjusted line:col range for emission.
dataflow.Run builds cfg/cdg/ddg per callable over -j workers; core runs it
at -a 3 and v2emit merges the side table into each callable (statement
nodes, id/parent, edge lists, k_limit). L1/L2 output is unchanged.
Also cover the unmatched-closure skip path.
GO_CFG_NEXT (_k=kind), GO_CDG and GO_DDG (_k=var|prov) are now live, keyed on
body-node global ids. :GoBodyNode gains call_node and var.
The edge dedup was keyed on (signature, callee), so with several func init()
in one package only the first-visited file's init kept an edge to a shared
callee, and the visit order was random map order. Key the dedup on the
declaring file too, and walk files, types and callables in sorted order.
Neo4j only accepts CALL { ... } IN TRANSACTIONS in an auto-commit
transaction; the purge ran inside ExecuteWrite, so every --eager push onto
existing data failed.
Per-app volume, kind breakdowns, DDG distribution, cost, size and
consistency checks, with the extraction script.
r.Read(buf[:n]), io.ReadFull(r, hdr[:]), copy(dst[i:], src) and
clear(s[:n]) recorded no write: pathOf has no SliceExpr case. Strip
slice expressions to the base and weak-define its elements.
L1 records call sites via fset.Position, which applies //line
directives; the index rebuilt a token.Pos from that virtual line:col,
so sites under a directive lost their callee and edge. Key the index
by (file, start, end) as L1 records them; drops the per-site file scan.
A field selector with no recorded selection, an undeclared name, or a
member of a failed import dropped the def and turned the LHS into a
use. Treat the selector as a field step, give unresolved names a
spelling-identified base (weak root.*), and never make an LHS use-only.
The analyzer type-checks targets with its own go/types, so a binary
built with an older Go silently degrades on newer modules. Release with
go-version: stable, add a CI job on stable, document it, and replace the
per-package TooNew warnings with one consolidated warning.
The deferred call node now uses the paths it may write through, so a
write after the defer registration (defer w.Flush(); w.buf = ...)
reaches it. Document the remaining L3 precision gaps.
External call edges and external_symbols exist; --emit neo4j is L3;
ids are can://<app>/go/...; record the L3 Go leaves (defer two-node
rule, defer_resume, body kinds, ids from L1, k_limit, span-less
@entry/@EXIT). Mark the R19 closure source_file gap fixed.
Clone, prep, tool checks and type-check status per app; the run phase
reads the prepared entries, builds the analyzer with EVAL_GO and adds a
type-check column. Kubernetes (root and k8s.io/api) joins the manifest.
Rerun the six apps and both Kubernetes modules (root, k8s.io/api) with the
post-fix analyzer; measure protobuf-marshaller DDG volume (I5) on k8s-api.
cockroach kept as a labelled pre-fix subsection. l3_metrics.py groups DDG
by callable name and counts zz_generated files as generated.
x/tools v0.46 cannot read Go 1.27's export data (version 5), so loads that
read export data instead of type-checking deps from source fail on Go 1.27.
v0.50+ supports it and requires Go 1.26, raising the go.mod minimum.
On malformed source the parser can end a function one past its file, where
the FileSet has no token.File; nodeSource dereferenced it and the analyzer
panicked at every level. It now returns "" for such nodes.
buildCallSites stopped at a go statement's call, so g() in go f(g()) had no
call node, callee or edge. It now tags the launched call is_goroutine and
records nested calls as ordinary sites. The L3 mirror (recordedCalls) and the
test mirror (gateCallKeys) change in lockstep; at L3 the nested calls are
satellites of the go node.
append(dst[:0], x), or append into spare capacity, writes dst's backing
array; it now weak-defines dst[*] alongside the LHS def. A per-callable
panic also logs its stack at -vv.
…al params

DeferWrap shows a deferred named-result write never kills; a recording
session shows eagerPurge runs its IN TRANSACTIONS statement through Run, not
ExecuteWrite; vlib.G/H show unnamed and blank library params become _<i>.
With --target-files the push carries only the targeted packages, so an
app-wide purge first would leave the rest of the app missing from the graph.
@sinha108

Copy link
Copy Markdown
Contributor Author

Addresses the L3 part of #3 (CFG, CDG and DDG per function). The SDG is L4 and is not in this PR, so #3 stays open.

@sinha108
sinha108 marked this pull request as ready for review October 25, 2026 15:23
@sinha108
sinha108 requested a review from pavuluri October 25, 2026 15:25

@pavuluri pavuluri left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR is rather large with substantial code changes. The main addition is intraprocedural L3 analysis: for each callable, it produces a control-flow graph (CFG), control-dependence graph (CDG), and data-dependence graph (DDG), in addition to the existing symbol and call-graph information. It also projects those graphs into Neo4j. It includes extensive tests and a real-application evaluation, with good scale results.

@pavuluri
pavuluri merged commit f9a39bb into main Oct 27, 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

Development

Successfully merging this pull request may close these issues.

2 participants