Skip to content

fix(dataflow): connect the L4 port lattice to the statement ddg - #169

Merged
rahlk merged 1 commit into
mainfrom
fix/issue-81-l4-port-binding
Sep 6, 2026
Merged

fix(dataflow): connect the L4 port lattice to the statement ddg#169
rahlk merged 1 commit into
mainfrom
fix/issue-81-l4-port-binding

Conversation

@rahlk

@rahlk rahlk commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Closes #81, closes #80 (its twin; the "verify on a larger corpus first" it asked for is below). Python parity with codeanalyzer-python #115.

The gap, measured

dataflow-app at -a 4 — 33 callables, 24 formal_in, 20 actual_in, 21 actual_out vertices. ddg edges touching a port, before:

class edges
statement → formal_out 31
formal_in → * 0
* → actual_in 0
actual_out → * 0

One of four binding classes. The lattice was connected across callables (param_in 21, param_out 22) but not into the statement-level ddg on either side of a call. So an end-to-end flows_to walk crossed a call only on the return leg: a caller's definition never reached actual_in, actual_out never reached a use, and a walk entering through param_in dead-ended at formal_in. Categorical on 33 callables, not a one-call-site artefact.

What changes

Python's fix emits extra_edges its SDG assembler already computed. Ours computes none, so the three classes are derived at emit time in emitL4 from facts that are present — the intra-callable ddg, the PARAM_IN/PARAM_OUT edges, CFG node spans, and the callable's own call_sites (INTERNAL, still on the tree during the pass):

  • formal_in:n → first-use — every param-sourced intra ddg edge, re-sourced from the port. The L3 @entry → use edge stays (a param folds onto @entry below L4), so L3 ⊆ L4 holds.
  • def stmt → actual_in:k — a variable reaching the call statement binds to argument k when its head is a whole word in that argument's text. A parameter passed straight through binds from its own @formal_in:n.
  • actual_out → callsite — python's class exactly: the return value flows into the statement that made the call; the existing L → use edges carry it onward.

All prov: ["reaching-defs"] (the label both analyzers share), deduped on (src, dst, var, prov), sorted.

After:

class edges
formal_in → statement / → actual_in / → call 31 / 8 / 1
statement → actual_in 5
actual_out → statement / → call 11 / 6
statement → formal_out 31

Verification

  • L4 port lattice is partially disconnected from the statement ddg (3 of 4 binding-edge classes missing) #81's acceptance test, verbatim: on y = build(x) all four classes exist, reaching-defs tagged, every ddg endpoint names a body node; a pass-through parameter binds from its own port; L3 ddg ⊆ L4 ddg
  • Mutation-checked: dropping the formal_in re-sourcing and breaking the argument match each fail the test
  • bun test green; container tests run (TS_DDG rows changed); no schema, Neo4j label or SCHEMA_VERSION change; decisions recorded in .claude/SCHEMA_DECISIONS.md

The ceiling, stated

def → actual_in:k binds by whole-word text match on the argument — there is no per-argument AST at this stage. A name that is both a local and a property (f(o.v) with a local v) over-binds; a value threaded through a helper inside the argument still binds, which is the reaching-defs reading. Python has the AST and doesn't pay this; noted so it isn't mistaken for precision it doesn't have.

#80's side question — are TS call vertices on the CFG spine? — is answered by the measurement: statement → call, entry → call, call → statement edges exist, so unlike python's they are not cfg-orphaned.

Measured on dataflow-app at -a 4 (33 callables; 24 formal_in, 20 actual_in, 21
actual_out vertices), the ddg edges touching a port were:

    statement -> formal_out   31
    formal_in -> *             0
    *         -> actual_in     0
    actual_out -> *            0

One of four binding classes. The port lattice was connected ACROSS callables
(param_in 21, param_out 22) but not INTO the statement-level dataflow on either
side of a call, so an end-to-end walk could cross a call only on the return leg:
a caller's definition never reached actual_in, actual_out never reached a use, and
a walk entering through param_in dead-ended at formal_in. #81 named the three
missing classes; #80 asked for the absence to be verified on a larger corpus first
-- done, and categorical.

Python fixed its twin (#115) by emitting the extra_edges its SDG assembler already
computed. Ours computes none, so the three classes are derived at emit time from
what is present: `formal_in:n -> first-use` re-sources every param-sourced intra
ddg edge from the port (the L3 `@entry -> use` edge stays); `def stmt ->
actual_in:k` binds a reaching variable to argument k when its head is a whole
word in that argument's text at the call site (the callable's own call_sites,
still present during the pass); `actual_out -> callsite` sends the return value
into the call statement, whose existing `L -> use` edges carry it on. All tagged
prov ["reaching-defs"], deduped, sorted.

After:

    formal_in  -> statement   31     statement  -> actual_in    5
    formal_in  -> actual_in    8     actual_out -> statement   11
    formal_in  -> call         1     actual_out -> call         6
    statement  -> formal_out  31 (unchanged)

tagged, every endpoint in body; a pass-through parameter binds from its own port;
L3 ddg ⊆ L4 ddg. Mutation-checked: dropping the formal_in re-sourcing and breaking
the argument match each fail it. #80's side question -- are TS call vertices on
the CFG spine? -- is answered by the measurement: statement/entry/call edges exist,
so unlike python's they are not cfg-orphaned.
@rahlk
rahlk force-pushed the fix/issue-81-l4-port-binding branch from 799f2f0 to 83cc71b Compare September 6, 2026 13:01
@rahlk
rahlk merged commit f720067 into main Sep 6, 2026
1 check passed
@rahlk
rahlk deleted the fix/issue-81-l4-port-binding branch September 6, 2026 13:01
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant