Skip to content

L4 port lattice is disconnected from the statement ddg: a formal_in has out-degree zero #227

Closed

Description

@rahlk

Problem

The level-4 port vertices (formal_in, actual_in, formal_out, actual_out) are emitted, and the lattice edges between them are emitted, but no dependence edge ever touches a port. A formal_in — a callable's entry point for a parameter, and the natural seed for "what does this argument reach" — has out-degree zero.

Measured on a full-depth Neo4j projection holding daytrader8 and ThingsBoard (3.0.1, contract 2.0.0):

MATCH (p:JBodyNode)-[r:J_DDG]->()  WHERE p.kind IN ['formal_in','actual_in','formal_out','actual_out']
RETURN count(r)   // 0
MATCH ()-[r:J_DDG]->(p:JBodyNode)  WHERE p.kind IN ['formal_in','actual_in','formal_out','actual_out']
RETURN count(r)   // 0

Zero of 134,742 J_DDG edges and zero of 46,936 J_CDG edges have a port at either end. The lattice's only edges are J_PARAM_IN (actual_in→formal_in), J_PARAM_OUT (formal_out→actual_out) and J_SUMMARY (actual_in→actual_out), so the port layer is an island: reachable from the statement graph in neither direction.

This is not a projection artefact. The same gap is in analysis.json, on the committed -a 4 fixture and on a whole-application -a 4 run.

The sibling analyzers fixed this

  • codeanalyzer-python#115 — closed. Its graph now has 262,582 PY_DDG edges leaving a port.
  • codeanalyzer-typescript#80 and Fix issue #79 #81 — both closed.

Java appears to be the last of the three with the layer disconnected.

Why it matters downstream

Interprocedural value questions are exactly what L4 exists to answer, and each of them silently degenerates. In the SDK, slice_forward from a parameter returns the seed alone, paths_between returns no paths, and flows_to_call/flows_to_argument return False — for every input, whatever the program does. An empty answer that is indistinguishable from "no flow exists" is worse than an error, so the SDK now refuses those four on a Java graph rather than answering, via a probe for whether any dependence edge touches a port. When this is fixed they will answer with no SDK change, and the probe will pass on its own.

slice_backward is unaffected and is not gated: it walks J_PARAM_IN in reverse and reaches the argument vertex at each call site.

Repro

java -jar codeanalyzer-3.0.1.jar -i <any project> -a 4 -o out

then, on out/analysis.json, count ddg edges whose src or dst is a formal_in/actual_in/formal_out/actual_out key. Zero on every project tried.

Definition of done

  • A parameter's formal_in has outgoing ddg to the statements that read it, and the statements that compute a returned value have ddg into formal_out — the shape codeanalyzer-python#115 landed.

Activity

  1. added a commit that references this issue on Sep 7, 2026
    c3ba6de
  2. rahlk commented on Sep 7, 2026

    @rahlk
    CollaboratorAuthor

    Re-measured the lattice while building SDK coverage for the fix, and the definition of done above is missing two of the four crossings. Flagging before 3.0.3 goes out, because a fix that lands only the two named directions leaves the interprocedural accessors exactly as degenerate as they are today — but silently, since the SDK's probe would start passing.

    What 3.0.2 emits. Every edge that touches a port is port-to-port; nothing crosses between the lattice and the statement graph. Neo4j projection holding daytrader8 and ThingsBoard (181,355 port vertices):

    src kind rel dst kind edges
    actual_in J_PARAM_IN formal_in 76,810
    formal_out J_PARAM_OUT actual_out 44,961
    actual_in J_SUMMARY actual_out 22,222

    Which gives formal_in out-degree 0, actual_in in-degree 0, formal_out in-degree 0, actual_out out-degree 0. The same four numbers hold on the committed -a 4 analysis.json fixture, so it is not a projection artefact.

    What the fixed sibling emits. codeanalyzer-python#115's graph carries all four crossings, not two:

    direction edges
    statement/call/branch/entry/loop → actual_in 2,598,415
    formal_in → statement/call/return/branch/raise/loop 129,302
    statement/call/return/branch/loop → formal_out 163,359
    actual_out → statement/call/branch/return/loop 131,147

    Why the two omitted directions are the ones that matter for interprocedural queries. A forward walk from a parameter of A into a call A → B has to leave A's statement graph through B's actual_in vertex to cross J_PARAM_IN. With no edge into an actual_in, that vertex is unreachable, so the walk stops inside A no matter how well formal_in → statement works. Concretely, in the SDK:

    • slice_forward from a parameter — fixed by formal_in → statement alone. ✅
    • flows_to_call, flows_to_argument, paths_between — still False / [] for every input, because their target set is a callee's formal_in vertices and no path can reach one. ❌

    And the probe stops catching it. The SDK refuses those accessors on a measurement of the data — "does any formal_in have an outgoing dependence edge". A formal_in-only fix satisfies that probe, so the refusal lifts and three accessors go back to answering a uniform False that is indistinguishable from a proved absence of flow. That is the failure this issue was filed to end, so it is worth closing all four crossings in one release rather than two now and two later.

    Suggested addition to the definition of done:

    • the value computed for a call argument has ddg into that call's actual_in, and a call's actual_out has ddg into the statements that read the result — the other half of the shape codeanalyzer-python#115 landed.

    Happy to re-run both measurements against 3.0.3 and post the table.

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions