Repository navigation
Conversation
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
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
force-pushed
the
feat/issue-436
branch
from
November 12, 2026 12:48
f965611 to
badc527
Compare
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
sinha108
marked this pull request as ready for review
November 12, 2026 20:40
This branch has not been deployed
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 #436.
codeanalyzer-go0.3.0 emits the full L3/L4 substrate; the SDK's models stopped at L2 and couldnot parse current
-a 1output at all. This adds the consumer surface.Two bugs this fixes
GoType.kindomittedalias.kindis aLiteralandextra="ignore"does not cover aLiteralmismatch, so an unlisted kind fails the whole document. Any project containing a singletype Alias = intdid not load:A
-a 4document parsed and silently dropped the graph.GoApplicationhad noparam_in/param_out/external_symbols, the envelope nodegraded, andGoBodyNodekept onlykind/span/callee/is_goroutine._argvalso capped-aat 2, so a level-4 request quietlyproduced 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— onCLDK.go(...)and on both backends, with
PythonAnalysisBackend's names and signatures.taintis concrete onGoAnalysisBackend, so neither backend owns the policy. Theport-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.pyEach 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:
@— a stand-in's id holds/@external/before the vertex@partition("@")mis-owns all 129 external verticesof, never by ordinal intoparameters[]formal_in:0is the receiver;of == parameters[0].nameon only 174 of 276 callableslibflow.Lastfailed dot-wisesegment_matchon all 23 globals; the package-qualified spelling is added as a second match namedegraded[]is the honesty signal, not a refusalexhaustedcertification; envelope scope, besideapplication— not on it, as the issue text impliesA 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, andin_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:
<app>/go/:GoExternalconflates callable stand-ins with import ghostsget_external_symbolsreported 51 against 35_REACHESpinned its endpoint to:GoCallablereaches(caller, "DB.Query")wasFalsefor a call that exists; 69 of 247GO_CALLSedges land on a stand-inreconstruct.body_nodedroppedof/parentvar/call_node, so every L4 vertex came back withof=NoneReview found two more, both fixed: the colliding-signature divergence above, and
get_cfg/get_cdg/get_ddgraisingAttributeErroron a stand-in where the graph returned anempty page (empty is correct — a stand-in has no statements, only ports).
-a 4losslessly: 319 callables, 1299 cfg / 666 cdg / 2854 ddg / 704summary edges, 3324 body nodes, zero unmodeled wire fields
test_public_surface.py::test_c_modules_are_gone,reproduces on
mainThe gated HTTP→SQL flow answers end to end:
flows_to_argument("id", "DB.Query", "query", within="sqlRepo.Find")isTrue, withpaths_betweenreturning theid → n → query → DB.Query.querywitness.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 idget_sourcecan 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_sourceaccepted a body-node id on the graphbackend and raised on the local one, whose index held only types, callables and fields — and every
edge endpoint and
SliceNode.refis a body-node id. Fixing it exposeddescribediverging theother 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), comparingcounts taken from the
jsonmodule against the same counts taken fromcldk.models.goover thesame file — a toolchain-independent oracle, where any difference is the SDK losing or miscounting
something. Seven of the eight apps;
daeis Linux-only.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) exceptpoints_to_ddg, whichdrifts 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:0is absent fromparameters[]on 3,950 of 3,950 methods (trap 2); on nats-server, 343 of 355 globals needthe package-qualified spelling (trap 3).
api.initis declared in 9 generated.pb.gofiles inmemos, so the multi-file
init()collision is real in the wild — it raisesAmbiguousNameandnarrows by
in_module. Anddegraded[]is common: listmonk, memos, openbao, nats-server andkubernetes all carry
range_func/unmapped_ssa, where the committed fixture is clean — so trap 4is now verified on real data rather than by monkeypatching
get_degraded.Two defects it found, fixed here
reachexpanded each node once per predecessor, not once.seenwas updated after the levelwas 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=Noneis the default onpaths_between/flows_to_*/taint, so the unusable case was the default one. The walk isnow
commons.graphs.reach, lifted out of four byte-identical copies, so Python, Java andTypeScript 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_idre-sorted its candidate list on every call, so a bulkget_sourceover apage'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
reachfrom a typical port touches 1,631,237 of 1,693,330 nodes —96% of the graph. The slices default to
DEFAULT_DEPTH = 5and are unaffected;paths_between,flows_to_*andtaintdefault todepth=None, which is correct for soundness (a bound makes aFalseambiguous) 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_walkscannot produce a witness for a flowflows_to_argumentconfirms. Onnats-server the predicate answers
Truein 0.9 s whiletaint/paths_betweendo not return in180 s, because the DFS replay prunes only on BFS layer boundaries and never toward the sink:
dstsits 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_pathsprefix contract, thesanitizer cuts, and backend parity.
Reproducing the live tests
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 onlyand "no release cut yet", both stale)plus a leg note; a Go section in
docs/agent-api-reference.mdalongside TypeScript's and Java's;CLDK.go's docstring, which still claimed levels 1 and 2 only.Diagnostic.codegainsanalysis_degraded— a closedLiteraland a published contract, named language-neutrally because"the analyzer told us it fell back" is not Go's alone.
Not addressed here
strconv.Atoidoes notsever the
SanitizedFindpath, because the DDG runsid → n → querywithout passing throughAtoi's nodes. Analyzer-side; a variable cut severs it correctly today.summaryedges (L4: global → global summary edges are omitted; reverse for context-sensitive walks or finer wiring codeanalyzer-go#30): reachability unchanged,but a depth-bounded walk may stop short.
projections.pyfor Go — out of SDK3 scope, additive later.Scale is measured above rather than estimated; #446 is the one known limitation on this surface.