Skip to content

Commit 74510fb

Browse files
authored
feat(graph): L3/L4 program-slice engine core (Tasks 1–6 of #270) (#271)
* 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.
1 parent 46dfd5a commit 74510fb

12 files changed

Lines changed: 827 additions & 0 deletions

cldk/graph/__init__.py

Whitespace-only changes.

cldk/graph/capability.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
from typing import Optional, Dict
3+
4+
5+
class CapabilityError(Exception):
6+
pass
7+
8+
9+
def require(level_needed: int, provider, *, strict: bool, what: str) -> Optional[Dict]:
10+
available = provider.max_level()
11+
if available >= level_needed:
12+
return None
13+
if strict:
14+
raise CapabilityError(
15+
f"{what} requires analysis level {level_needed}; backend is at level {available}. "
16+
f"Re-analyze at -a {level_needed} or drop strict=True to degrade.")
17+
return {
18+
"requested": level_needed,
19+
"available": available,
20+
"gap": f"{what} requires level {level_needed}; backend at level {available} — "
21+
f"reduced result returned; absence of a result here is UNKNOWN, not safety.",
22+
}

cldk/graph/engine.py

Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
1+
# cldk/graph/engine.py
2+
from __future__ import annotations
3+
from typing import Iterable, List, Optional, Tuple
4+
import networkx as nx
5+
from cldk.graph.provider import ProgramGraphProvider, resolve_vertex
6+
from cldk.graph.capability import require
7+
from cldk.graph.result import SliceResult, FlowResult, FlowPath
8+
9+
10+
def _filter_edges(g: nx.MultiDiGraph, families: Iterable[str]) -> nx.MultiDiGraph:
11+
fam = set(families)
12+
out = nx.MultiDiGraph()
13+
out.add_nodes_from(g.nodes(data=True))
14+
for u, v, k, d in g.edges(keys=True, data=True):
15+
if d.get("family") in fam:
16+
out.add_edge(u, v, key=k, **d)
17+
return out
18+
19+
20+
_TIER_RANK = {"unresolved": 0, "structural": 1, "resolved": 2}
21+
_RANK_TIER = {v: k for k, v in _TIER_RANK.items()}
22+
23+
# Provisional witness-enumeration bounds (to be tuned against real graphs in a later
24+
# task): flows_to explores simple paths no deeper than _PATH_CUTOFF hops and stops
25+
# collecting witnesses at _MAX_PATHS; explain()["truncated"] reports whether the
26+
# path cap was hit (depth-cutoff drops are not separately detectable and are folded
27+
# into the same provisional-bounds caveat).
28+
_MAX_PATHS = 1000
29+
_PATH_CUTOFF = 64
30+
31+
32+
def _ddg_tier(prov) -> str:
33+
# Membership, not exact-list: prov is a provenance set in list form, and
34+
# ["ssa", "points-to"] is STRONGER evidence than ["points-to"] alone.
35+
prov = prov or []
36+
if "points-to" in prov:
37+
return "resolved"
38+
if "ssa" in prov:
39+
return "structural"
40+
return "unresolved"
41+
42+
43+
class Engine:
44+
def __init__(self, provider: ProgramGraphProvider):
45+
self.p = provider
46+
47+
def _evidence(self, uris, seeds, roles=None, default_role="def"):
48+
# Seeds are always "seed"; other vertices take the verb's default_role
49+
# (slices/flows: "def", control_deps: "control", def_use: "use").
50+
roles = roles or {}
51+
ev = []
52+
for u in uris:
53+
fl, code = self.p.source_slice(u)
54+
ev.append({"uri": u, "file_line": fl, "code": code,
55+
"role": "seed" if u in seeds else roles.get(u, default_role)})
56+
return ev
57+
58+
def _intra(self, seed, edges, backward, strict, what, interprocedural=None,
59+
default_role="def") -> SliceResult:
60+
note = require(3, self.p, strict=strict, what=what)
61+
want_inter = interprocedural if interprocedural is not None else (self.p.max_level() >= 4)
62+
if interprocedural is True:
63+
inter_note = require(4, self.p, strict=strict, what=f"interprocedural {what}")
64+
if inter_note:
65+
note = inter_note
66+
want_inter = False
67+
# Only dataflow (param_in/param_out/summary) crosses callable boundaries, so the
68+
# sdg overlay is additionally gated on the ddg family being requested: a cfg- or
69+
# cdg-only slice never crosses, even on an L4 backend. want_inter is the single
70+
# source of truth — it both gates the overlay and feeds explain()["interprocedural"].
71+
want_inter = want_inter and self.p.max_level() >= 4 and "ddg" in set(edges)
72+
seeds = resolve_vertex(self.p, seed)
73+
g = _filter_edges(self.p.program_graph(self.p.callable_of(seeds[0])), edges)
74+
if want_inter:
75+
for e in self.p.sdg_edges():
76+
g.add_edge(e.src, e.dst, family="sdg", kind=getattr(e, "kind", None),
77+
var=getattr(e, "var", None), prov=getattr(e, "prov", []))
78+
walk = g.reverse(copy=False) if backward else g
79+
reached = set(seeds)
80+
for s in seeds:
81+
if s in walk:
82+
reached |= nx.descendants(walk, s)
83+
# seed-consistency (Task 4 fix, carried here): evidence/uris must equal subgraph nodes,
84+
# and a seed is trivially in its own slice.
85+
sub = g.subgraph(reached & set(g.nodes())).copy() # MultiDiGraph
86+
for s in seeds:
87+
if s not in sub:
88+
sub.add_node(s, kind="seed")
89+
ev_nodes = sorted(sub.nodes()) # deterministic; evidence set == subgraph nodes
90+
explain = {"seed": seeds, "direction": "backward" if backward else "forward",
91+
"edges": list(edges), "level": self.p.max_level(),
92+
"vertices": len(sub), "interprocedural": bool(want_inter)}
93+
if note:
94+
explain["degraded"] = note
95+
return SliceResult(subgraph=sub,
96+
evidence=self._evidence(ev_nodes, set(seeds),
97+
default_role=default_role),
98+
_explain=explain)
99+
100+
def slice_backward(self, seed, *, edges=("cfg", "cdg", "ddg"),
101+
interprocedural: Optional[bool] = None, strict: bool = False) -> SliceResult:
102+
return self._intra(seed, edges, True, strict, "slice_backward", interprocedural)
103+
104+
def slice_forward(self, seed, *, edges=("cfg", "cdg", "ddg"),
105+
interprocedural: Optional[bool] = None, strict: bool = False) -> SliceResult:
106+
return self._intra(seed, edges, False, strict, "slice_forward", interprocedural)
107+
108+
def control_deps(self, seed, *, strict: bool = False) -> SliceResult:
109+
# Control dependence is intraprocedural in this model — only dataflow (param/summary)
110+
# crosses boundaries. Force interprocedural=False so the sdg overlay is never merged
111+
# into a pure CDG slice, even on an L4 backend.
112+
return self._intra(seed, ("cdg",), backward=True, strict=strict,
113+
what="control_deps", interprocedural=False,
114+
default_role="control")
115+
116+
def _dataflow_graph(self, *callable_uris) -> nx.MultiDiGraph:
117+
# Union of the given callables' intra ddg graphs, plus the summary/param_*
118+
# (inter) sdg overlay at L4; below L4 this is intraprocedural ddg only.
119+
g = nx.MultiDiGraph()
120+
for c in dict.fromkeys(callable_uris): # dedupe, keep order
121+
cg = _filter_edges(self.p.program_graph(c), ("ddg",))
122+
g.add_nodes_from(cg.nodes(data=True))
123+
for u, v, k, d in cg.edges(keys=True, data=True):
124+
g.add_edge(u, v, key=k, **d)
125+
if self.p.max_level() >= 4:
126+
for e in self.p.sdg_edges():
127+
g.add_edge(e.src, e.dst, family="sdg", kind=getattr(e, "kind", None),
128+
var=getattr(e, "var", None), prov=getattr(e, "prov", []))
129+
return g
130+
131+
def flows_to(self, source_seed, sink_seed, *, strict: bool = False) -> FlowResult:
132+
# Full flows_to semantics are interprocedural (ddg + param_in/param_out/summary),
133+
# which is L4. Below that, non-strict degrades honestly: the note is attached and
134+
# the intra-only ddg witnesses that CAN be computed are still returned.
135+
note = require(4, self.p, strict=strict, what="flows_to")
136+
src = resolve_vertex(self.p, source_seed)[0]
137+
dst = resolve_vertex(self.p, sink_seed)[0]
138+
# A sink in a different callable is reachable via param_in/param_out/summary,
139+
# so the dataflow graph must span BOTH endpoint callables. (Multi-hop flows
140+
# through a THIRD callable's interior need the whole-program graph — deferred.)
141+
g = self._dataflow_graph(self.p.callable_of(src), self.p.callable_of(dst))
142+
paths: List[FlowPath] = []
143+
reached = set()
144+
truncated = False
145+
if src in g and dst in g:
146+
# A MultiDiGraph enumerates a route once per parallel-edge combination, yielding
147+
# byte-identical duplicate witnesses. Enumerate over a plain-DiGraph VIEW (one path
148+
# per distinct node route) and read per-hop parallel evidence from the MultiDiGraph g.
149+
routes = nx.DiGraph(g)
150+
for path in nx.all_simple_paths(routes, src, dst, cutoff=_PATH_CUTOFF):
151+
if len(paths) >= _MAX_PATHS:
152+
truncated = True
153+
break
154+
hops, tiers = [], []
155+
for a, b in zip(path, path[1:]):
156+
# MultiDiGraph: get_edge_data returns {key: attrdict} over parallel edges.
157+
# Pick the strongest-confidence parallel edge as the hop's evidence (the step
158+
# is as strong as its best evidence; the path is as weak as its weakest step).
159+
parallels = g.get_edge_data(a, b)
160+
best = max(parallels.values(),
161+
key=lambda d: _TIER_RANK[_ddg_tier(d.get("prov", []))])
162+
t = _ddg_tier(best.get("prov", []))
163+
tiers.append(t)
164+
# Intra edges report their family (cfg/cdg/ddg have no kind); sdg
165+
# boundary edges report the concrete kind (param_in/param_out/summary).
166+
hops.append({"from": a, "to": b,
167+
"kind": best.get("kind") or best.get("family"),
168+
"var": best.get("var"), "confidence": t})
169+
conf = _RANK_TIER[min(_TIER_RANK[t] for t in tiers)] if tiers else "unresolved"
170+
paths.append(FlowPath(source=src, sink=dst, hops=hops, confidence=conf))
171+
reached.update(path)
172+
explain = {"source": src, "sink": dst, "level": self.p.max_level(),
173+
"paths": len(paths), "truncated": truncated}
174+
if note:
175+
explain["degraded"] = note
176+
sub = g.subgraph(reached).copy()
177+
return FlowResult(subgraph=sub, evidence=self._evidence(sorted(sub.nodes()), {src, dst}),
178+
_explain=explain, paths=paths)
179+
180+
def def_use(self, seed, *, strict: bool = False) -> FlowResult:
181+
note = require(3, self.p, strict=strict, what="def_use")
182+
s = resolve_vertex(self.p, seed)[0]
183+
# NOTE: currently scoped to the seed's callable plus sdg endpoints; uses inside
184+
# OTHER callables' interiors arrive with the whole-program dataflow graph (deferred).
185+
g = self._dataflow_graph(self.p.callable_of(s))
186+
reached = {s} | (nx.descendants(g, s) if s in g else set())
187+
sub = g.subgraph(reached & set(g.nodes())).copy()
188+
if s not in sub: # a seed is trivially in its own def-use result
189+
sub.add_node(s, kind="seed")
190+
explain = {"seed": s, "level": self.p.max_level(), "vertices": len(sub)}
191+
if note:
192+
explain["degraded"] = note
193+
return FlowResult(subgraph=sub,
194+
evidence=self._evidence(sorted(sub.nodes()), {s},
195+
default_role="use"),
196+
_explain=explain, paths=[])

cldk/graph/provider.py

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
from __future__ import annotations
2+
import re
3+
from abc import ABC, abstractmethod
4+
from typing import List, Tuple, Iterable, Optional, Any
5+
import networkx as nx
6+
7+
_LOC = re.compile(r"^(?P<file>.+?):(?P<line>\d+)(?::(?P<col>\d+))?$")
8+
9+
10+
class ProgramGraphProvider(ABC):
11+
"""The per-backend data seam the shared engine consumes. Implemented by local backends
12+
(from cpg models) and Neo4j backends (from Cypher). Traversal lives in the engine, not here.
13+
14+
Below the level a verb requires, the engine gates via require(...); providers should
15+
still answer program_graph/resolve_location/callable_of structurally — none of these
16+
ever need L3+ data to do so."""
17+
18+
@abstractmethod
19+
def program_graph(self, callable_uri: str) -> nx.MultiDiGraph:
20+
"""Parallel cfg/cdg/ddg edges between the same vertex pair must stay distinct
21+
edges, each carrying its own family/var/prov/kind."""
22+
@abstractmethod
23+
def sdg_edges(self) -> Iterable[Any]: ...
24+
@abstractmethod
25+
def resolve_location(self, file: str, line: int, col: Optional[int] = None) -> List[str]: ...
26+
@abstractmethod
27+
def source_slice(self, vertex_uri: str) -> Tuple[Optional[str], Optional[str]]: ...
28+
@abstractmethod
29+
def callable_of(self, vertex_uri: str) -> Optional[str]: ...
30+
@abstractmethod
31+
def max_level(self) -> int: ...
32+
33+
34+
def resolve_vertex(provider: ProgramGraphProvider, seed: Any) -> List[str]:
35+
"""Normalize a polymorphic seed to vertex ids: a BodyNode-like object (has .id), a can:// id
36+
string, or a 'file:line[:col]' location string."""
37+
if hasattr(seed, "id"):
38+
return [seed.id]
39+
if isinstance(seed, str):
40+
if seed.startswith("can://"):
41+
return [seed]
42+
m = _LOC.match(seed)
43+
if m:
44+
col = int(m["col"]) if m["col"] is not None else None
45+
found = provider.resolve_location(m["file"], int(m["line"]), col)
46+
if not found:
47+
# An ordinary user miss (no vertex at that line) must surface as a clean
48+
# ValueError here — every verb indexes the result, and [] would IndexError.
49+
raise ValueError(f"no vertex at location {seed!r}")
50+
return found
51+
raise ValueError(f"cannot resolve seed to a vertex: {seed!r} "
52+
f"(expected 'file:line[:col]', a can:// id, or a body node)")

cldk/graph/result.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
from __future__ import annotations
2+
import json
3+
from dataclasses import dataclass, field, asdict
4+
from typing import List, Dict, Literal
5+
import networkx as nx
6+
7+
Confidence = Literal["resolved", "structural", "unresolved"]
8+
9+
10+
@dataclass(frozen=True)
11+
class FlowPath:
12+
source: str
13+
sink: str
14+
hops: List[Dict] = field(default_factory=list)
15+
confidence: Confidence = "unresolved"
16+
17+
18+
@dataclass
19+
class GraphResult:
20+
subgraph: nx.MultiDiGraph
21+
evidence: List[Dict]
22+
_explain: Dict
23+
24+
def uris(self) -> List[str]:
25+
return [e["uri"] for e in self.evidence]
26+
27+
def explain(self) -> Dict:
28+
return dict(self._explain)
29+
30+
def to_json(self) -> str:
31+
return json.dumps({"evidence": self.evidence, "explain": self._explain,
32+
"vertices": list(self.subgraph.nodes)}, sort_keys=True)
33+
34+
def __len__(self) -> int:
35+
return self.subgraph.number_of_nodes()
36+
37+
def __bool__(self) -> bool:
38+
return self.subgraph.number_of_nodes() > 0
39+
40+
41+
@dataclass
42+
class SliceResult(GraphResult):
43+
pass
44+
45+
46+
@dataclass
47+
class FlowResult(GraphResult):
48+
paths: List[FlowPath] = field(default_factory=list)
49+
50+
def to_json(self) -> str:
51+
base = json.loads(super().to_json())
52+
base["paths"] = [asdict(p) for p in self.paths]
53+
return json.dumps(base, sort_keys=True)

tests/graph/__init__.py

Whitespace-only changes.

tests/graph/test_capability.py

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import pytest
2+
from cldk.graph.capability import require, CapabilityError
3+
4+
5+
class P:
6+
def __init__(self, lvl): self._l = lvl
7+
def max_level(self): return self._l
8+
9+
10+
def test_satisfied_returns_none():
11+
assert require(3, P(4), strict=False, what="slice_backward") is None
12+
13+
14+
def test_degrade_returns_note():
15+
note = require(4, P(3), strict=False, what="interprocedural flows_to")
16+
assert note["requested"] == 4 and note["available"] == 3
17+
assert "UNKNOWN, not safety" in note["gap"]
18+
19+
20+
def test_strict_raises():
21+
with pytest.raises(CapabilityError) as e:
22+
require(4, P(3), strict=True, what="flows_to")
23+
assert "level 4" in str(e.value)

0 commit comments

Comments
 (0)