Skip to content

feat(python): flows_to and the L3/L4 slice/flow verbs on the Python facade (#270, rc.1 leg) - #297

Open
rahlk wants to merge 25 commits into
release/2.0from
feat/issue-270-python-wiring
Open

feat(python): flows_to and the L3/L4 slice/flow verbs on the Python facade (#270, rc.1 leg)#297
rahlk wants to merge 25 commits into
release/2.0from
feat/issue-270-python-wiring

Conversation

@rahlk

@rahlk rahlk commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Part of #270 (rc.1 leg of the staged rollout — rc.2 TS, rc.3 Java, rc.4 Go, rc.5 C++, 2.0.0 Rust engine follow).

What this adds

Five verbs on PythonAnalysis, backed by the shared engine merged in #271, on both backends:

  • slice_backward / slice_forward / control_deps / def_use / flows_to (interprocedural at L4, witness paths with per-hop kind/var/confidence)
  • Level plumbing: the facade's AnalysisLevel maps to the analyzer's -a 1..4; max_level is recorded from the same in-process run (never sniffed); underscore level names ("call_graph") accepted alongside enum values
  • CpgLocalProviderMixin: language-neutral local provider over the (schema-2.0-shaped) upstream models — mints can://<callable>@<key> vertex URIs matching the analyzers' own param_in/param_out vocabulary; recurses into closures and inner-class methods
  • PyNeo4jBackend: the five provider primitives in Cypher over the PY_* L3/L4 overlay, speaking the emitter's real can:// vertex ids (closes #295 found mid-branch by the live parity suite)
  • cldk.graph public exports; capability gate degrades honestly below level floors, strict=True raises

Verification

  • 124 passed / 12 skipped across tests/graph + tests/analysis/python (exact-set goldens hand-derived from the committed py-a4.json L4 sample)
  • Live dual-backend parity (env-gated, real podman Neo4j + real analyzer emit): 6/6 — program_graph (incl. prov), sdg edges, source_slice/callable_of per vertex, resolve_location hit-lists, and all five verbs agree local vs Neo4j
  • Real-repo smoke test on cldk/ itself at -a 4: 8/8 legacy accessors, 6/6 new-verb calls, genuine cross-callable flows_to witness, honest degrade at L2

Known deferrals (rc.2 touch-up candidates)

  • Neo4j basename-suffix resolve_location has the Cypher fix but no discriminating stub test (stub ignores params; live suite only probes full paths)
  • _MODULES_CTE recomputes app scoping per call instead of reusing cached self._modules
  • Live suite couples to the mixin's private _index() (no public callable-enumerator yet)
  • Upstream: codeanalyzer-python#112 (persist max_level on the PyApplication node; SDK currently derives it from overlay presence)

#270 stays open for the remaining legs.

rahlk and others added 25 commits July 15, 2026 00:14
…40) (#274)

* feat(cpg): _NullSafeBase + Span (#240)

* feat(cpg): Edge + Import leaf models (#240)

* test(cpg): cover Edge empty-prov default and Import.span default (#240)

* feat(cpg): open-kind Node covering type/callable/body facets (#240)

* feat(cpg): Module/Application/Analyzer/AnalysisPayload + exports (#240)

* test(cpg): cover Application->Module->Node deep composition (#240)

* test(cpg): parse real L1/L4 samples from both analyzers + superset gate (#240)

* fix(cpg): enforce id on durable nodes positionally; body nodes exempt (#240)

* test(cpg): pin the F7/F3 accessor contract against real L4 samples (#240)

* test(cpg): genuinely pin module.functions accessor; strengthen TS source check (#240)

* fix(cpg): add Module.span so the common span field parses (#240)

span is listed as a common field on every node in the keystone (Part
II), module included, but Module had no span field so it degraded to
a raw dict in model_extra instead of parsing as Span. The ts-a4/ts-a1
fixtures emit span on the module node.

* test(cpg): pin cdg/summary/k_limit/TS body+cfg in the accessor contract (#240)

extra="allow" absorbs unknown keys, so deleting a canonical field
leaves every parse test green — the accessor contract is the only
guard. cdg and summary (both in F7's cfg/cdg/ddg/summary read set),
the envelope k_limit, and TS callable body/cfg were unpinned.

Each new assertion dereferences an element/field rather than doing a
bare isinstance check, since a raw dict-of-dicts under extra="allow"
still satisfies isinstance(list)/isinstance(dict) — confirmed by
temporarily removing each field from the model and watching the new
asserts fail before restoring.
* feat(graph): slice/flow result objects (#270)

* fix(graph): serialize FlowResult paths in to_json (#270)

* feat(graph): provider ABC seam + polymorphic resolve_vertex (#270)

* feat(graph): capability gating with honest-degrade + strict (#270)

* feat(graph): engine intraprocedural slices + control_deps (#270)

* fix(graph): MultiDiGraph program graph + seed-consistent slices (#270)

* feat(graph): flows_to witnesses + def_use with data-derived confidence (#270)

* fix(graph): dedup flows_to witnesses over parallel edges; def_use seed-consistency (#270)

* feat(graph): level-driven interprocedural slice depth (#270)

* fix(graph): control_deps is intraprocedural — no sdg overlay at L4 (#270)

* fix(graph): gate the sdg overlay on the ddg edge family (#270)

_intra applied the sdg (dataflow) overlay whenever the backend was L4 and
interprocedural was wanted, ignoring the edges family filter — so a cfg- or
cdg-only slice picked up dataflow vertices from foreign callables. Fold the
family gate into want_inter so the overlay predicate and
explain()["interprocedural"] stay one source of truth: no dataflow family
requested means no boundary crossing. Subsumes the redundant max_level()>=4
check on the overlay branch; control_deps' explicit interprocedural=False
stays as belt-and-suspenders.

* fix(graph): flows_to requires L4, honest-degrade to intra ddg at L3 (#270)

flows_to's full semantics are ddg + summary/param_in/param_out — an
interprocedural (L4) capability — but it gated on require(3), so an L3
backend silently returned intra-only results as if they were complete.
Raise the requirement to L4: non-strict now attaches the degraded note
(absence is UNKNOWN, not safety) while still returning the intra ddg
witnesses it can compute; strict=True raises CapabilityError.

* fix(graph): flows_to spans source and sink callables (#270)

flows_to built its dataflow graph from the source's callable only, so a
sink inside a different callable (reachable via param_in into the callee
interior) was unreachable and reported a false "no flow". _dataflow_graph
now unions the intra ddg graphs of the given callables before adding the
sdg overlay, and flows_to passes both endpoint callables. def_use keeps
its single-callable scope with a NOTE — interprocedural completeness
lands with the whole-program dataflow graph (deferred to Task 7).

* fix(graph): empty location resolution raises ValueError, not IndexError (#270)

resolve_location legitimately returns [] when no vertex sits at the given
line, but every engine verb indexes resolve_vertex(...)[0], turning an
ordinary user miss into an IndexError. Raise a descriptive ValueError at
the source in resolve_vertex instead, converting all [0] call sites into
a clean failure mode.

* fix(graph): MultiDiGraph annotations and provider contract docstrings (#270)

The graph is an nx.MultiDiGraph everywhere, but three annotations still
said nx.DiGraph: ProgramGraphProvider.program_graph, GraphResult.subgraph
(and engine._dataflow_graph, already corrected in the C2 commit). Fix the
first two and document the provider contract: parallel cfg/cdg/ddg edges
between the same vertex pair must stay distinct edges with their own
family/var/prov/kind, and sub-L3 providers still answer the structural
methods — the engine handles level gating via require(...).

* fix(graph): preserve sdg edge kind in flow witnesses (#270)

The sdg overlay dropped e.kind (param_in/param_out/summary) on the floor
in both _intra and _dataflow_graph, so a FlowPath hop crossing a callable
boundary could only say "sdg" — not which boundary edge carried the
flow. Carry kind on the overlay edges and have the hop dict prefer the
edge kind over the family: intra hops still report cfg/cdg/ddg, boundary
hops now report the concrete sdg kind.

* fix(graph): per-verb evidence roles — control and use, not always def (#270)

_evidence stamped every non-seed vertex "def", which is wrong for
control_deps (the guard CONTROLS the seed) and def_use (downstream
vertices are USES of the definition). Thread a default_role through
_evidence and _intra: slices and flows keep "def", control_deps passes
"control", def_use passes "use"; seeds are always "seed".

* fix(graph): bound flows_to witness enumeration, report truncation (#270)

flows_to enumerated simple paths with a hard-coded cutoff=64 and no path
cap, and never told the caller when witnesses were dropped. Hoist the
bounds to module constants (_PATH_CUTOFF=64, _MAX_PATHS=1000 — provisional,
to be tuned on real graphs), stop collecting at _MAX_PATHS, and surface
explain()["truncated"] so a partial witness set is never silently
presented as complete.

* fix(graph): _ddg_tier ranks by prov membership, not exact-list match (#270)

prov is a provenance set in list form; the exact-list comparisons
(prov == ["points-to"] / == ["ssa"]) made the STRONGER combined
provenance ["ssa", "points-to"] fall through to "unresolved".
Rank by membership: points-to anywhere means resolved, else ssa means
structural, else unresolved.
… CanNode keys, fail-fast version check (closes #268)

Rewrite the read-only TS Neo4j backend to query the canonical 2.0.0 graph
(CanNode identity, TS-prefixed labels/relationships) and reject any other
schema on connect.

- Every Cypher query maps to the 2.0.0 vocabulary: :Symbol -> :CanNode,
  :Callable/:Class/... -> :TSCallable/:TSClass/..., CALLS/DECLARES/HAS_METHOD/
  RESOLVES_TO/HAS_MODULE -> TS_CALLS/TS_DECLARES/TS_HAS_METHOD/TS_RESOLVES_TO/
  TS_HAS_MODULE. Application matched by `id ENDS WITH "/" + $app` (no `name`);
  modules keyed by `_module` (no `file_key`).
- Call sites are now :TSBodyNode {kind:"call"} reached via TS_HAS_BODY_NODE and
  resolved through TS_RESOLVES_TO (no :CallSite nodes); reconstruct reads the
  target from `callee`.
- Fail-fast: `_check_schema_version` reads (:Application).schema_version once in
  __init__ and raises the new CldkSchemaMismatchException unless it is "2.0.0".
- Accessors whose vocabulary 2.0.0 does not project raise NotImplementedError
  (no JSON fallback exists for a read-only Cypher client): get_decorators,
  get_class_decorators, get_methods_with_decorators, get_classes_with_decorators,
  get_all_fields, get_interface_properties, get_imports, get_all_exports,
  get_all_variables. Whole-object reconstruction degrades those sub-fields to
  empty so graph-supported accessors keep working. Public facade names and
  return types are unchanged.
- Tests: new test_typescript_neo4j_schema.py covers the version gate and the
  fallback raises without a live graph; the stubbed-graph parity tests adopt the
  2.0.0 query strings; the live-graph integration suite is skipped until the
  analyzer pin moves to >=1.0.0 (Task 9).
…rify assumptions (#268)

Review fixes on the graph-schema-2.0.0 rewrite:

- `_resolve_application_id` resolves `application_name` to exactly one
  :Application `can://` id on connect; 0 or >1 suffix matches raise
  CodeanalyzerUsageException naming the candidate ids, so a shared database
  with two apps ending in the same path segment can no longer silently merge
  module scopes. `_load_module_keys` anchors on the resolved id. The schema
  check still runs first (DB-level) so pre-2.0.0 graphs keep the actionable
  mismatch error; rationale commented in __init__.
- Every unverified 2.0.0 assumption now carries a VERIFY(2.0.0-e2e) marker at
  its query site (`_module` scoping, array-prop class hierarchy, method_name
  fallback, reconstruct's module_name->name fallback); the module docstring
  states the `_module` claim as to-verify, and the two leftover v1
  "Symbol-keyed" prose comments are reworded.
- Query-construction unit tests capture the built Cypher through the `_run`
  seam for the two riskiest rewrites: the TS_HAS_BODY_NODE / TSBodyNode
  {kind:"call"} / TS_RESOLVES_TO call-site path (reading `callee`) and
  TSModule `_module` scoping, plus the app-id guard (0-match and 2-match).
- CldkSchemaMismatchException exported from cldk.utils.exceptions __all__.
…all-graph keys, fail-fast version checks

Adapt the Python facade to codeanalyzer-python 1.0.x (analysis schema 2.0.0):

- unwrap the v2 Analysis envelope in _run_analyzer and fail fast unless
  schema_version is the SUPPORTED_ANALYSIS_SCHEMA (2.0.0)
- follow the model renames everywhere the backends walk the symbol table:
  module.classes→types, class methods→callables, inner_classes→types,
  callable inner_callables→callables, inner_classes→types
- recover callable source by slicing module.source with span.bytes (_code_of):
  schema v2 stores source once per module instead of a code field per callable
- translate CanNode can:// call-edge endpoints (PyCallEdge.src/dst) back to
  dotted signatures for the public call graph; externals keep their raw ids
- read the renamed PY_CALLS edge property (prov) and construct PyCallEdge with
  src/dst/prov in the Neo4j backend
- add a fail-fast schema_version gate to PyNeo4jBackend, reading the stamp on
  the scoped :PyApplication node (mirrors TSNeo4jBackend)
- map reconstruct.py's graph-vocabulary kwargs (methods/inner_*) onto the new
  model fields; the callable's code node property is no longer reconstructed
- pin codeanalyzer-python==1.0.2 (1.0.0/1.0.1 emitter fix in #104 verified) and
  codeanalyzer-typescript==1.0.0 in dependencies and [tool.backend-versions]
- rewrite the hand-built test fixtures in the new vocabulary and add
  test_python_schema_contract.py: both fail-fast gates + CanNode translation
…S.md)

Record the locked design decisions for the Rust query engine (epic #279):
M1 scope, can:// identity, fresh plan-algebra ADR, serde+Bolt data plane,
Opaque plan-split, fat-wheel packaging, cldk.graph slicer replacement, and
the root crates/ workspace layout. Repo-level gitignore exception overrides
the global .claude ignore for this one file.
, #290, #292) (#294)

Ports the fixes shipped on main to the 2.0 line (2.0.0-rc.1 shipped jarless):
- Force-include the codeanalyzer-java JAR via [tool.hatch.build] artifacts (#284)
- Pipe-safe release guard that fails a jarless build before publishing (#284)
- Source release notes from CHANGELOG.md and fail on blank; drop the label-based
  changelog scraper and orphaned release_config.json (#289)
- Remove the obsolete native-binary test and its unused import (#290)
- Fix the stale conftest docstring for the retired native binary (#292)

Verified: build on this branch (with .git) yields a 32MB wheel+sdist containing the
JAR; tests/analysis/java/test_jcodeanalyzer.py -> 42 passed.
…al provider (#270)

_index() walked only mod.functions and one level of mod.types[*].callables, so closures
(a callable's own nested callables) and nested classes (classes inside classes or inside
callables) were invisible to program_graph/callable_of/resolve_location — silently
degrading to seed-only with no note, even though the Neo4j emitter and the analyzer's own
call-graph walker both recurse fully. _add/_add_type now recurse into getattr(...,
"callables"/"types", None) defensively, so every callable that owns a body ends up indexed
regardless of nesting depth, while staying a no-op on model families (e.g. the cldk-native
cpg.models.Node) that don't support nested classes at all.

Also fixes two vacuous assertions in the same test file surfaced alongside this change: a
`... or True` tautology, and an `and`/`or` precedence bug that made a source-slice
assertion pass regardless of its left operand.
…_slice contract (#270)

resolve_location: the local mixin accepts a basename/suffix seed (path.endswith("/" +
file)) in addition to an exact module-path match, and a golden test already pins this; the
Neo4j backend only matched exactly, so the same seed string behaved differently per
backend. Cypher now reads (n._module = $file OR n._module ENDS WITH $suffix).

source_slice: aligned to the adjudicated three-way contract (vertex exists without a
span/start_line -> (module_path, None); vertex unknown/not found -> (None, None); never
fabricate a line number by parsing the vertex key's shape). The previous implementation
degraded synthetic ports (@entry, @formal_in:N, ...) to (None, None) instead of
(module_path, None), and worse, could fabricate a plausible-looking "module:line" for a
vertex that doesn't exist at all whenever its key happened to parse as line:col shape.
Rewritten to resolve the exact vertex by comparing _to_uri(n.id) against the requested
vertex_uri, scoped to the owning callable's PyCFGNode rows (one round trip).

sdg_edges: scoped the destination side too (b._module IN mods), matching the existing
source-side scoping and every other query in this backend.
…n, flows_to, slice_forward (#270)

program_graph parity now includes prov (as a tuple) in the edge-comparison key, so a
backend that dropped or reordered ddg provenance no longer passes silently.

Two new tests assert full-coverage parity rather than one hand-picked seed per verb:
source_slice/callable_of agreement for every vertex of every callable, and
resolve_location's full hit-list agreement for every (file, line) that owns a real
vertex.

test_verb_parity now also drives flows_to (fed a real param_in pair discovered
dynamically from the live application, since the fixture's app_name is a random
tmp-dir name each run) and slice_forward, alongside the existing slice_backward/
def_use/control_deps.

Also corrects the module docstring's claim that the analyzer's bolt writer prunes
other applications globally per emit -- it scopes the orphan-module prune to this
application's own app_name; the dedicated-container advice is kept for run isolation,
not because of any cross-application risk.
…comments (#270)

max_level capture is now unconditional (self._max_level = analysis.max_level, no
hasattr guard) and max_level() drops its getattr(..., 1) fallback -- an envelope that
somehow lacks max_level now fails loudly instead of silently reporting level 1.
_StubAnalysis in the schema-contract test gains a max_level default to match.

Kept the hasattr(self, "_level_int") guard around opts["analysis_level"]: verified it
IS load-bearing -- test_python_schema_contract.py's _bare_local_backend() exercises
_run_analyzer on a __new__ instance that never set _level_int, to isolate the
schema-envelope gate from analyzer construction. Added a comment naming that test as
the reason instead of removing it.

Dropped the unused json/Path imports from test_facade_delegates.py.
Drop-in compatible per the 2026-07-27 e2e validation: schema 2.0.0 unchanged,
prov vocabulary unchanged (scalpel default L4 oracle emits the same ssa/points-to),
fixture-identical output on the pyfix sample, live dual-backend parity 6/6,
real-repo smoke 8/8 old + 6/6 new API.
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.

1 participant