Skip to content

feat(go): consume the L3/L4 dataflow substrate (models, accessors, both backends) - #444

Open
sinha108 wants to merge 15 commits into
mainfrom
feat/issue-436
Open

sinha108 wants to merge 15 commits into
mainfrom
feat/issue-436

Conversation

@sinha108

@sinha108 sinha108 commented Nov 9, 2026 •

Copy link
Copy Markdown
Collaborator

Closes #436.

codeanalyzer-go 0.3.0 emits the full L3/L4 substrate; the SDK's models stopped at L2 and could
not parse current -a 1 output at all. This adds the consumer surface.

Two bugs this fixes

GoType.kind omitted alias. kind is a Literal and extra="ignore" does not cover a
Literal mismatch, so an unlisted kind fails the whole document. Any project containing a single
type Alias = int did not load:

Input should be 'struct', 'interface' or 'defined' [input_value='alias']

A -a 4 document parsed and silently dropped the graph. GoApplication had no
param_in/param_out/external_symbols, the envelope no degraded, and GoBodyNode kept only
kind/span/callee/is_goroutine. _argv also capped -a at 2, so a level-4 request quietly
produced a level-2 analysis whose empty dataflow was indistinguishable from a proved absence.

The surface

get_cfg / get_cdg / get_ddg, resolve_callable / resolve_value, slice_backward /
slice_forward / backward_cone, reaches / paths_between, flows_to_call /
flows_to_argument, taint, get_external_symbols, get_degraded, describe — on CLDK.go(...)
and on both backends, with PythonAnalysisBackend's names and signatures.

taint is concrete on GoAnalysisBackend, so neither backend owns the policy. The
port-connectivity gate is Java's, declared on the ABC and implemented on both; it reads the data,
never a version string.

Four Go-specific traps, in cldk/analysis/go/ids.py

Each is a rule that holds for Python or TypeScript and not for Go, so a shared helper would be
wrong rather than absent. All measured on real 0.3.0 output:

Trap Evidence
Never split a body-node id on its first @ — a stand-in's id holds /@external/ before the vertex @ the naive partition("@") mis-owns all 129 external vertices
A formal resolves by its own of, never by ordinal into parameters[] a method's formal_in:0 is the receiver; of == parameters[0].name on only 174 of 276 callables
A global's module half is slash-separated libflow.Last failed dot-wise segment_match on all 23 globals; the package-qualified spelling is added as a second match name
degraded[] is the honesty signal, not a refusal voids every exhausted certification; envelope scope, beside application — not on it, as the issue text implies

A Go signature is also not always a unique address: several files in one package may each declare
their own init(). Both backends hand the shared policy one candidate per declaration, and
in_module= naming a path picks one.

Verification

A real Neo4j was stood up and a real L4 graph pushed (3,936 nodes, 11,112 relationships) rather than
shipping the Cypher unverified. That found four bugs invisible from the local side:

Bug Symptom
the scope guard admitted only <app>/go/ every stand-in id refused as out-of-scope
:GoExternal conflates callable stand-ins with import ghosts get_external_symbols reported 51 against 35
_REACHES pinned its endpoint to :GoCallable reaches(caller, "DB.Query") was False for a call that exists; 69 of 247 GO_CALLS edges land on a stand-in
reconstruct.body_node dropped of/parent the graph spells them var/call_node, so every L4 vertex came back with of=None

Review found two more, both fixed: the colliding-signature divergence above, and
get_cfg/get_cdg/get_ddg raising AttributeError on a stand-in where the graph returned an
empty page (empty is correct — a stand-in has no statements, only ports).

  • models round-trip real -a 4 losslessly: 319 callables, 1299 cfg / 666 cdg / 2854 ddg / 704
    summary edges, 3324 body nodes, zero unmodeled wire fields
  • both backends answer identically on 66 probed questions, miss paths and taint verdicts included
  • 178 Go tests with the live graph, 146 + 32 clean skips without it
  • 2024 tests pass overall. The one failure, test_public_surface.py::test_c_modules_are_gone,
    reproduces on main
  • every factual claim in the new docs section checked against running code

The gated HTTP→SQL flow answers end to end: flows_to_argument("id", "DB.Query", "query", within="sqlRepo.Find") is True, with paths_between returning the
id → n → query → DB.Query.query witness.

Relation sweeps

The dataflow tests pinned each property at one hand-picked position. A follow-up commit keeps the
relations and adds the sampling — every addressable position, both backends: a deeper slice reaches
more; a capped slice is a prefix and reports the whole total; flows_to_argument ⟹
flows_to_call; a returned path ⟹ the predicate agrees; every edge endpoint is an id get_source
can read.

This is the one part of the analyzer's metamorphic design that transfers. It varies the input
program and holds the analyzer fixed, which the SDK cannot do because it produces no analysis — so
the SDK's dual is to vary the query and hold the analysis fixed. Not generative: a property-based
library over this space found nothing a sweep did not.

They found a parity gap on their first run. get_source accepted a body-node id on the graph
backend and raised on the local one, whose index held only types, callables and fields — and every
edge endpoint and SliceNode.ref is a body-node id. Fixing it exposed describe diverging the
other way: local accepted a bare id, the graph silently dropped it. Both now accept both forms.

Checked by mutation, not by passing: six behavior-changing mutations in the shapes these relations
guard are all caught.

Real-app evaluation

Run against the analyzer's own pinned eval corpus (codeanalyzer-go/scripts/eval_l3.sh), comparing
counts taken from the json module against the same counts taken from cldk.models.go over the
same file
— a toolchain-independent oracle, where any difference is the SDK losing or miscounting
something. Seven of the eight apps; dae is Linux-only.

app payload callables body nodes DDG edges summary RAW==SDK unmodelled fields
cobra 6.9 MB 320 11,171 22,887 6,056 23/23 none
listmonk 105 MB 820 74,801 583,307 40,912 23/23 none
memos 188 MB 6,077 250,434 406,509 236,583 23/23 none
k8s-api 369 MB 15,427 598,588 1,107,780 205,643 23/23 none
openbao 582 MB 8,725 625,208 1,164,315 680,354 23/23 none
kubernetes 1,469 MB 44,535 1,708,051 3,210,628 1,361,329 23/23 none
nats-server 1,829 MB 5,286 1,690,827 3,749,264 2,577,267 23/23 none

10.2M DDG edges and 5.0M body nodes across the corpus, with zero unmodelled wire fields
anywhere
. Every count also matched the analyzer's published figures
(codeanalyzer-go/docs/design/notes/l3-dataflow-metrics.md § L4) except points_to_ddg, which
drifts 0.004–0.03% from this host's go1.26.4 against the doc's go1.27.3. The scale benchmark
reproduced closely: Kubernetes at 38.90 s / 8.72 GB against the doc's 38.37 s / 8,076 MB.

The traps also hold at a scale the fixture cannot reach. On memos, formal_in:0 is absent from
parameters[] on 3,950 of 3,950 methods (trap 2); on nats-server, 343 of 355 globals need
the package-qualified spelling (trap 3). api.init is declared in 9 generated .pb.go files in
memos, so the multi-file init() collision is real in the wild — it raises AmbiguousName and
narrows by in_module. And degraded[] is common: listmonk, memos, openbao, nats-server and
kubernetes all carry range_func / unmapped_ssa, where the committed fixture is clean — so trap 4
is now verified on real data rather than by monkeypatching get_degraded.

Two defects it found, fixed here

  • reach expanded each node once per predecessor, not once. seen was updated after the level
    was built, so a node with k predecessors in the current frontier entered it k times and the
    next level expanded every copy — the frontier tracked path count. Answers were never wrong (a set
    cannot hold duplicates), so only cost showed it: 151,417,783 frontier expansions at depth 8
    against 670,450, and unbounded it did not terminate. depth=None is the default on
    paths_between / flows_to_* / taint, so the unusable case was the default one. The walk is
    now commons.graphs.reach, lifted out of four byte-identical copies, so Python, Java and
    TypeScript are fixed too
    — four copies were four places to drift, which is how one bug came to
    live in all of them.
  • split_body_node_id re-sorted its candidate list on every call, so a bulk get_source over a
    page's endpoints sorted 5,955 ids per endpoint on nats-server. Hoisted to a cached
    longest-first order.

Scale guidance

On nats-server an unbounded reach from a typical port touches 1,631,237 of 1,693,330 nodes —
96% of the graph
. The slices default to DEFAULT_DEPTH = 5 and are unaffected; paths_between,
flows_to_* and taint default to depth=None, which is correct for soundness (a bound makes a
False ambiguous) but means the default query on a large application is inherently whole-graph.
Peak SDK memory is 13–19× the payload: 3.4 GB on memos, 8.1 GB on openbao, 23.2 GB on nats-server.

Known limitation, tracked separately

#446 — shortest_walks cannot produce a witness for a flow flows_to_argument confirms. On
nats-server the predicate answers True in 0.9 s while taint / paths_between do not return in
180 s, because the DFS replay prunes only on BFS layer boundaries and never toward the sink: dst
sits at distance 42 with 110,833 nodes sharing that distance, so it enumerates ~1.49e16 shortest
paths where 736,848 reach dst. Pre-existing, shared by all four languages, and not fixed here
— it needs its own change, because the pruning interacts with the max_paths prefix contract, the
sanitizer cuts, and backend parity.

Reproducing the live tests

docker run -d --name neo4j -p 7687:7687 -e NEO4J_AUTH=neo4j/<pw> neo4j:5.26
cango -i <analyzer>/testdata/dataflow -a 4 -o /tmp/go-l4 --app-name dataflow --namespace dataflow
NEO4J_URI=bolt://localhost:7687 NEO4J_USERNAME=neo4j NEO4J_PASSWORD=<pw> \
  cango -i <analyzer>/testdata/dataflow --emit neo4j --app-name dataflow --namespace dataflow

CLDK_TEST_GO_NEO4J_URI=bolt://localhost:7687 CLDK_TEST_GO_NEO4J_PASSWORD=<pw> \
CLDK_TEST_GO_NEO4J_APP=dataflow CLDK_TEST_GO_NEO4J_NAMESPACE=dataflow \
CLDK_TEST_GO_ANALYSIS_JSON=/tmp/go-l4/analysis.json \
  pytest tests/analysis/go/

Without those variables the live tests skip and the rest still run.

Also in this change

CLAUDE.md's language table (its Go row said -a 1..2 only and "no release cut yet", both stale)
plus a leg note; a Go section in docs/agent-api-reference.md alongside TypeScript's and Java's;
CLDK.go's docstring, which still claimed levels 1 and 2 only. Diagnostic.code gains
analysis_degraded — a closed Literal and a published contract, named language-neutrally because
"the analyzer told us it fell back" is not Go's alone.

Not addressed here

Scale is measured above rather than estimated; #446 is the one known limitation on this surface.

GoType.kind rejected "alias", so real -a 1 output from any project declaring
a type alias failed to parse. Widen it to all four kinds the analyzer emits.

Add the dataflow fields that -a 3/-a 4 output carried but the models dropped:
body-node id/of/parent/is_deferred, per-callable cfg/cdg/ddg/summary,
application-scope param_in/param_out/external_symbols, and envelope-scope
degraded. The overlays are Optional so "not computed" stays distinct from
"computed, no edges".

Verified lossless against cango 0.3.0 -a 4 on testdata/dataflow: 319
callables, 1299 cfg / 666 cdg / 2854 ddg / 704 summary edges and 3324 body
nodes all round-trip, with no unmodelled wire fields.

Refs #436
The fifteen accessors with PythonAnalysisBackend's names and signatures, so
one caller's dataflow code ports between languages. taint() is concrete on
the ABC -- both backends share the policy rather than reimplementing it --
and adds a Go-specific rule: a non-empty degraded[] voids every exhausted
certification, since absence of a path is not a proof over an approximated
graph. Also declares the Java-style port-connectivity gate.

ids.py isolates the four traps where a shared helper would be wrong for Go
rather than merely absent, verified on cango 0.3.0 output:

- a body-node id must never be split on its first @ -- a stand-in's id holds
  /@external/ before the vertex @, and the naive partition mis-owns all 129
  external vertices in testdata/dataflow;
- a formal resolves by its own `of`, not by ordinal into parameters[], since
  a method's formal_in:0 is the receiver (of == parameters[0] on only
  174/276 callables);
- a global's module half is slash-separated, so the spelling a Go programmer
  writes (libflow.Last) failed dot-wise segment_match on all 23 globals;
- degraded[] is the honesty signal, not a refusal.

Refs #436
Lifts the -a 2 cap in _argv: cango has emitted L3/L4 since 0.2.0, and
capping a level-4 request at 2 silently produced a shallow analysis whose
empty dataflow was indistinguishable from a proved absence.

The backend builds the cross-callable SDG index out of the five lists the
payload already carries -- ddg/cdg/summary keyed by local body key, param_in
/param_out already absolute -- which are the same five the Neo4j projector
emits, so the answer is the graph's answer rather than an intraprocedural
one presented as complete.

resolve_callable's domain includes the library stand-ins: spec section 7's
own example, database/sql.DB.Query, is one, and a stand-in's formal_in ports
are real L4 vertices a flow passes through.

Measured on testdata/dataflow at -a 4: the four receiver spellings all
resolve; a method's receiver resolves by `of` as formal_in:0; the two
packages that both export lastQuery are ambiguous by leaf and split by the
package-qualified spelling; and the gated HTTP->SQL flow answers True for
flows_to_call and flows_to_argument, with paths_between returning the
id -> n -> query -> DB.Query.query witness.

Refs #436
The Neo4j backend's half of the dataflow surface, plus the parity contract:
both backends answer all 66 probed questions identically on the same
application, miss paths and taint verdicts included.

Four bugs the live graph found that no mocked test could, each fixed with
the measurement in the comment:

- the scope guard admitted only `<app>/go/`, so every library stand-in id
  was refused as "outside the bound Go application scope" -- one cause
  behind 19 of 22 initial diffs. A stand-in is this application's node: the
  projector mints it under this prefix and it carries this app's ports.
- :GoExternal is two things. 35 callable stand-ins carry a signature; 16
  import ghosts (a package an unresolved import named) do not, and
  analysis.json carries only the first. get_external_symbols reported 51
  against the local backend's 35.
- _REACHES pinned every hop AND the endpoint to :GoCallable, so
  reaches(caller, "DB.Query") was False for a call that exists -- 69 of 247
  GO_CALLS edges land on a stand-in. The interior stays pinned; the endpoint
  is now free, which cannot widen the interior because no stand-in has an
  outgoing call edge (0 of 247, in both the graph and the JSON).
- reconstruct.body_node dropped `of`/`parent`: the graph spells them `var`
  and `call_node`, so every L4 vertex came back with of=None -- and `of` is
  what resolves a formal.

The live suite's local fixture was also stale: it seeded <root>/go rather
than the identity-digest path cache_subdir has keyed since #434, so it wrote
where nothing reads and the backend re-ran cango against /unused.

Refs #436
CLDK.go(...) now reaches the fifteen accessors, so the dataflow surface is
usable without touching the backend. Also corrects CLDK.go's docstring,
which still claimed cango produces levels 1 and 2 only.

Two committed fixtures (tests/resources/go/dataflow, with a README on how
each was produced and why): httpsql_l4.json is the analyzer's own HTTP->SQL
fixture at -a 4, 116 KB, whose flow needs interface dispatch, interprocedural
binding and the library stand-in all to be right; four_type_kinds.json is one
declaration of each GoType.kind at -a 1, 3 KB.

50 tests over them, pinning behaviour a caller depends on rather than edge
counts that move with every analyzer release: the gated flow is found, each
of the four Go id/name traps is handled, and a refutation is only made where
one is warranted (an explicit depth certifies nothing, a degraded analysis
certifies nothing, an unreachable pair is refuted).

Diagnostic.code gains `analysis_degraded`. It is a closed Literal and a
published contract, so the addition is documented there; the name is
language-neutral because "the analyzer told us it fell back" is not Go's
alone.

Refs #436
The language table's Go row said '-a 1..2 only' and 'no release cut yet';
both are stale. Adds the leg note for #436 with the four id/name traps and
cango's three Neo4j property renames, and a Go section to the agent
reference alongside TypeScript's and Java's.

Every factual claim in the new section was checked against running code: the
three DB.Query spellings, the receiver at formal_in:0 and absent from
parameters[], the interface/implementation ambiguity, the level gate's
message, the four depth defaults, and that all 16 documented accessors exist.

Refs #436
A degraded analysis is a property of the whole analysis -- the approximation
is somewhere in the SDG and nothing can say which pairs ran through it -- so
it is one diagnostic under a key no pair claims, which is taint_verdict's own
mechanism for a diagnostic it cannot attribute. The verdict is unchanged
(nothing certified, batch not complete); filing per pair said the same thing
m*n times, measured at 6 identical copies on a 3x2 batch and 400 on a 40x10.

Refs #436
#436 widened the Neo4j point-lookup guard to admit <app>/@external/ beside
<app>/go/, since a library stand-in is this application's node. These pin
what that did and did not open: the three own-node shapes are admitted, and
all three foreign axes still refuse -- foreign namespace, foreign application
(both separator-extended 'svc-extra' and bare 'svcX'), and sibling language
-- as do the look-alike segments a prefix test could confuse ('gopher',
'@externalish', and either bare segment with no trailing separator).

Refs #436
…tand-in

Two bugs found by review against the live graph; both were backend
disagreements invisible from either side alone.

A Go signature is not always a unique address -- several files in one package
may each declare their own init(), so one signature names two callables with
different bodies (3 such collisions on the analyzer's own corpus). The local
backend keyed its candidate list by signature, collapsing them into one entry
and hiding the ambiguity from the shared policy: it raised a bare ValueError
where the graph raised AmbiguousName, and in_module= naming a file narrowed
on neither. Both now hand the policy one candidate per declaration, and
ids.pick_declaration re-applies the caller's keywords to choose among the
declarations behind the resolved signature -- which the Neo4j side previously
discarded by re-looking-up on the bare signature and re-colliding.

get_cfg/get_cdg/get_ddg on a library stand-in raised AttributeError locally
(GoExternalSymbol has no cfg field) while the graph returned a clean empty
page. Empty is the honest answer: a stand-in has no statements, only
formal_in/formal_out ports. That is distinct from the level gate's D7 case,
where empty would be indistinguishable from "no dependence" because the pass
never ran; both the ABC docstring and the tests now say so.

Verified on the live graph: 11 of 11 probes that differed now agree, and the
66-check parity suite still passes.

Refs #436
…tests

The alias fix stays bundled with this branch rather than shipping separately.
Prompted by prototyping the standalone version, which surfaced two things
worth keeping:

The two kind tests assert a property of the model over -a 1 output, with
nothing to do with the L3/L4 overlays, so they move from test_go_dataflow.py
to test_go_analysis.py beside the other model tests. The second now pins
ValidationError specifically rather than bare Exception, and says why the fix
listed the missing kind instead of widening the field to str -- widening
would have turned a loud failure into silence for every future kind.

GoType's docstring now names the analyzer file rather than a line number that
will drift, and states the consequence that makes this severe: an unlisted
kind fails the whole document, not just the one type.

Verified the regression test fails without the one-line fix and passes with
it, so it measures the fix rather than merely accompanying it.

Refs #436
@sinha108 sinha108 added the enhancement New feature or request label Nov 9, 2026
The existing tests pin each property at one hand-picked position; these keep
the relations and add the sampling. R1 depth monotonicity, R2 a capped slice
is a prefix (not merely a subset) and reports the whole total, R3
flows_to_argument implies flows_to_call, R4 a returned path implies the
predicate, R7 every edge endpoint is an id get_source can read -- each on
both backends over every addressable position, capped at 60.

This is the part of the analyzer's metamorphic design that transfers: it
varies the input program and holds the analyzer fixed, which the SDK cannot
do because it produces no analysis, so the SDK's dual is to vary the query
and hold the analysis fixed. Not generative -- a property-based library over
this space found nothing a deterministic sweep did not.

The sweeps found a parity gap on their first run: get_source accepted a
body-node id on the graph backend and raised on the local one, whose index
held only types, callables and fields, though every edge endpoint and
SliceNode.ref is one. Fixing it exposed describe diverging the other way --
local accepted a bare id, the graph silently dropped it. Both now accept
both forms.

Verified by mutation rather than by passing: six behavior-changing mutations
in the shapes these relations guard are all caught. R7 anchors on
GoBodyNode.id, the analyzer's own string, because comparing the SDK's
composed id against the SDK's own lookup let a corrupted join move both
sides together.

Also tidies prose in the Go docstrings and tests.

Refs #436
@sinha108
sinha108 requested review from pavuluri and rahlk November 12, 2026 12:50
@sinha108
sinha108 marked this pull request as draft November 12, 2026 14:30
Found by evaluating against the analyzer's own real-app corpus.

_reach built each level as a comprehension and updated `seen` afterwards, so
`seen` did not yet contain the level being built: a node with k predecessors
in the current frontier was appended k times and the next level expanded
every copy. The frontier tracked the number of paths rather than the number
of nodes.

The answer was never wrong -- a set cannot hold duplicates, and over 250
memos ports the reachable set is identical at every depth -- so only cost
revealed it. On memos (208,293 source nodes, 743,154 edges): 151,417,783
frontier expansions at depth 8 against 670,450, 83.65s against 0.11s, and
unbounded it did not terminate. depth=None is the default on paths_between,
flows_to_call, flows_to_argument and taint, so the unusable case was the
default one.

After the fix, memos answers in 17s end to end: 11,477 ports swept at a max
of 0.1s each, RSS flat at 3.4 GB, and taint finds 40 paths into
database/sql.DB.QueryContext -- the HTTP-to-SQL-through-an-interface flow that
app is in the corpus for.

Pinned by counting expansions rather than by timing or frontier size: the
committed fixture is too small to blow up, so the test wraps the adjacency and
asserts each node is looked up at most once. It fails on the old walk.

Refs #436
The frontier fix belonged in the language-independent code, not in Go's
copy. _reach was byte-identical in the python, java, typescript and go local
backends -- four copies of a graph algorithm over an adjacency of strings,
which is the same argument that lifted shortest_walks in leg 2.5b. Four
copies were four places to drift, and that is precisely how one bug came to
live in all of them.

commons.graphs.reach now owns the walk and the reason for its shape; each
backend's _reach is a one-liner over it, keeping only `direction` -- which
half of the cached adjacency to read -- as a backend concern.

So the fix now covers Python, Java and TypeScript too, where the same
unbounded walk was non-terminating on a sufficiently connected graph and
depth=None is likewise the default on paths_between, flows_to_call,
flows_to_argument and taint.

Tests move to the shared suite beside call_reaches, the other lifted graph
helper: a diamond chain where 55 nodes carry 262,144 paths pins expansion
counting (1,048,573 frontier expansions before, 55 now), plus depth
monotonicity, the fixed point, a cycle, an unknown seed, and that no backend
keeps a copy. 2030 pass; the one failure predates the branch.

Refs #436
split_body_node_id sorted every callable id on every call, and
_body_source_of calls it once per get_source of a body-node id -- so a bulk
get_source over a page's endpoints sorted 5,955 ids per endpoint on
nats-server. The order is a property of the application, not of the node being
looked up.

longest_first() now produces it and the backend caches it in
_owner_ids_longest_first. Ties break on the id, so the order is total and a
lookup is reproducible rather than dependent on dict iteration.

Found evaluating against the analyzer's real-app corpus.

Refs #436
The dataflow note covers Go's #436 surface; the reach lift is cross-language
and belongs beside the other all-languages entries, with #446 named as the
one limitation left on that surface.
@sinha108
sinha108 marked this pull request as ready for review November 12, 2026 20:40

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Go L3/L4: consume the dataflow substrate (models, accessors, both backends)

1 participant