Skip to content

Commit 6479c04

Browse files
committed
feat(dataflow): program_graphs emission, -a 3, --graphs, --graph-field-depth
program_graphs schema section (PyProgramGraphs and friends, versioned 1.0.0 independently of the application schema) attached to PyApplication; -a extended to 3 (cumulative: level 3 keeps PyCG enrichment); --graphs cfg,dfg,pdg,sdg selector with strict validation (unknown values and level<3 usage exit non-zero, never silently fall back); --graph-field-depth k-limit knob recorded in the output. -a 1/2 emit no program_graphs and their pipeline is untouched. (#67)
1 parent 43e0e69 commit 6479c04

7 files changed

Lines changed: 419 additions & 4 deletions

File tree

codeanalyzer/__main__.py

Lines changed: 45 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -114,11 +114,31 @@ def main(
114114
typer.Option(
115115
"-a",
116116
"--analysis-level",
117-
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call graph.",
117+
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call "
118+
"graph, 3=+native dataflow graphs (CFG/PDG/SDG).",
118119
min=1,
119-
max=2,
120+
max=3,
120121
),
121122
] = 1,
123+
graphs: Annotated[
124+
str,
125+
typer.Option(
126+
"--graphs",
127+
help="Level 3 only: comma-separated program-graph sections to emit "
128+
"(cfg, dfg, pdg, sdg). Default: all. `dfg` emits the PDG's data "
129+
"edges only; `sdg` implies the dependence edges it stitches.",
130+
),
131+
] = "cfg,dfg,pdg,sdg",
132+
graph_field_depth: Annotated[
133+
int,
134+
typer.Option(
135+
"--graph-field-depth",
136+
help="Level 3 only: k-limit on access-path depth (x.f.g.h with "
137+
"k=3 becomes x.f.g.*). Mandatory bound — it is what guarantees "
138+
"the interprocedural fixpoint terminates.",
139+
min=1,
140+
),
141+
] = 3,
122142
using_ray: Annotated[
123143
bool,
124144
typer.Option("--ray/--no-ray", help="Enable Ray for distributed analysis."),
@@ -243,6 +263,27 @@ def main(
243263
),
244264
] = 50,
245265
):
266+
# Flag validation (strict: unrecognized values error out, never fall back).
267+
selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
268+
from codeanalyzer.dataflow.builder import VALID_GRAPHS
269+
270+
unknown_graphs = [g for g in selected_graphs if g not in VALID_GRAPHS]
271+
if unknown_graphs:
272+
logger.error(
273+
f"Unrecognized --graphs value(s): {', '.join(unknown_graphs)} "
274+
f"(valid: {', '.join(VALID_GRAPHS)})."
275+
)
276+
raise typer.Exit(code=2)
277+
if not selected_graphs:
278+
logger.error("--graphs requires at least one of: " + ", ".join(VALID_GRAPHS))
279+
raise typer.Exit(code=2)
280+
if analysis_level < 3 and graphs != "cfg,dfg,pdg,sdg":
281+
logger.error("--graphs is a level-3 option; pass -a 3 to emit program graphs.")
282+
raise typer.Exit(code=2)
283+
if analysis_level < 3 and graph_field_depth != 3:
284+
logger.error("--graph-field-depth is a level-3 option; pass -a 3.")
285+
raise typer.Exit(code=2)
286+
246287
options = AnalysisOptions(
247288
input=input,
248289
output=output,
@@ -254,6 +295,8 @@ def main(
254295
neo4j_password=neo4j_password,
255296
neo4j_database=neo4j_database,
256297
analysis_level=analysis_level,
298+
graphs=",".join(selected_graphs),
299+
graph_field_depth=graph_field_depth,
257300
using_ray=using_ray,
258301
rebuild_analysis=rebuild_analysis,
259302
skip_tests=skip_tests,

codeanalyzer/core.py

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -454,10 +454,28 @@ def analyze(self) -> PyApplication:
454454
.external_symbols(external_symbols)
455455
.build()
456456
)
457-
457+
458+
if self.analysis_level >= 3:
459+
# Level 3: native dataflow graphs (CFG/PDG/SDG) over the same
460+
# signatures, gated so -a 1/-a 2 timings stay untouched.
461+
from codeanalyzer.dataflow.builder import (
462+
build_program_graphs,
463+
to_program_graphs,
464+
)
465+
466+
t0_l3 = time.perf_counter()
467+
ir = build_program_graphs(app, k=self.options.graph_field_depth)
468+
app.program_graphs = to_program_graphs(
469+
ir, set(self.options.graphs.split(","))
470+
)
471+
logger.info(
472+
"✅ Program graphs: %d functions, %d SDG edges in %.1fs",
473+
len(ir.functions), len(ir.sdg_edges), time.perf_counter() - t0_l3,
474+
)
475+
458476
# Save to cache
459477
self._save_analysis_cache(app, cache_file)
460-
478+
461479
return app
462480

463481
def _load_pyapplication_from_cache(self, cache_file: Path) -> PyApplication:

codeanalyzer/dataflow/builder.py

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -253,3 +253,101 @@ def build_program_graphs(
253253

254254
summaries = compute_summaries(infos, sorted(set(call_edges)))
255255
return assemble_sdg(infos, summaries, k)
256+
257+
258+
VALID_GRAPHS = ("cfg", "dfg", "pdg", "sdg")
259+
260+
261+
def to_program_graphs(ir: ProgramGraphsIR, graphs: Set[str]):
262+
"""Project the IR onto the ``program_graphs`` schema section, scoped by
263+
the ``--graphs`` selector. ``dfg`` emits the PDG's DDG edges only;
264+
``sdg`` implies the dependence edges it is stitched over."""
265+
from codeanalyzer.schema.py_schema import (
266+
PyCFG,
267+
PyCFGEdge,
268+
PyFunctionGraphs,
269+
PyGraphNode,
270+
PyParamNode,
271+
PyPDG,
272+
PyPDGEdge,
273+
PyProgramGraphs,
274+
PySDGEdge,
275+
PySDGEndpoint,
276+
)
277+
278+
want_pdg = bool({"pdg", "sdg"} & graphs)
279+
want_dfg = want_pdg or "dfg" in graphs
280+
functions: Dict[str, "PyFunctionGraphs"] = {}
281+
for sig in sorted(ir.functions):
282+
fg = ir.functions[sig]
283+
out = PyFunctionGraphs()
284+
if "cfg" in graphs:
285+
out.cfg = PyCFG(
286+
nodes=[
287+
PyGraphNode(
288+
id=n.id,
289+
kind=n.kind,
290+
start_line=n.start_line,
291+
end_line=n.end_line,
292+
start_column=n.start_column,
293+
end_column=n.end_column,
294+
)
295+
for n in fg.pdg.cfg.nodes
296+
],
297+
edges=[
298+
PyCFGEdge(source=e.source, target=e.target, kind=e.kind)
299+
for e in fg.pdg.cfg.edges
300+
],
301+
)
302+
edges: List["PyPDGEdge"] = []
303+
if want_pdg:
304+
edges.extend(
305+
PyPDGEdge(source=e.source, target=e.target, type="CDG")
306+
for e in fg.pdg.edges
307+
if e.type == "CDG"
308+
)
309+
if want_dfg:
310+
edges.extend(
311+
PyPDGEdge(source=e.source, target=e.target, type="DDG", var=e.var)
312+
for e in fg.ddg
313+
)
314+
edges.extend(
315+
PyPDGEdge(source=e.source, target=e.target, type=e.type, var=e.var)
316+
for e in fg.extra_edges
317+
if e.type == "DDG" or want_pdg
318+
)
319+
if edges:
320+
edges.sort(key=lambda e: (e.source, e.target, e.type, e.var or ""))
321+
out.pdg = PyPDG(edges=edges)
322+
if "sdg" in graphs:
323+
out.param_nodes = [
324+
PyParamNode(
325+
id=p.id,
326+
kind=p.kind,
327+
var=p.var,
328+
call_node=p.call_node,
329+
start_line=p.start_line,
330+
end_line=p.end_line,
331+
)
332+
for p in fg.param_nodes
333+
]
334+
functions[sig] = out
335+
336+
sdg_edges = []
337+
if "sdg" in graphs:
338+
sdg_edges = [
339+
PySDGEdge(
340+
source=PySDGEndpoint(signature=e.source_sig, node=e.source_node),
341+
target=PySDGEndpoint(signature=e.target_sig, node=e.target_node),
342+
type=e.type,
343+
var=e.var,
344+
)
345+
for e in ir.sdg_edges
346+
]
347+
348+
return PyProgramGraphs(
349+
schema_version="1.0.0",
350+
k_limit=ir.k_limit,
351+
functions=functions,
352+
sdg_edges=sdg_edges,
353+
)

codeanalyzer/options/options.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,10 @@ class AnalysisOptions:
4949
neo4j_password: str = "neo4j"
5050
neo4j_database: Optional[str] = None
5151
analysis_level: int = 1
52+
# Level-3 dataflow knobs: which program graphs to emit (csv of
53+
# cfg|dfg|pdg|sdg) and the access-path k-limit.
54+
graphs: str = "cfg,dfg,pdg,sdg"
55+
graph_field_depth: int = 3
5256
using_ray: bool = False
5357
rebuild_analysis: bool = False
5458
skip_tests: bool = True

codeanalyzer/schema/__init__.py

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,22 @@
55
PyApplication,
66
PyCallable,
77
PyCallableParameter,
8+
PyCFG,
9+
PyCFGEdge,
810
PyClass,
911
PyClassAttribute,
1012
PyComment,
1113
PyExternalSymbol,
14+
PyFunctionGraphs,
15+
PyGraphNode,
1216
PyImport,
1317
PyModule,
18+
PyParamNode,
19+
PyPDG,
20+
PyPDGEdge,
21+
PyProgramGraphs,
22+
PySDGEdge,
23+
PySDGEndpoint,
1424
PyVariableDeclaration,
1525
)
1626

@@ -25,6 +35,16 @@
2535
"PyCallable",
2636
"PyClassAttribute",
2737
"PyCallableParameter",
38+
"PyGraphNode",
39+
"PyCFGEdge",
40+
"PyPDGEdge",
41+
"PyParamNode",
42+
"PyCFG",
43+
"PyPDG",
44+
"PyFunctionGraphs",
45+
"PySDGEndpoint",
46+
"PySDGEdge",
47+
"PyProgramGraphs",
2848
]
2949

3050
try:

codeanalyzer/schema/py_schema.py

Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -369,6 +369,135 @@ class PyExternalSymbol(BaseModel):
369369
module: Optional[str] = None # best-effort owning module, e.g. "requests"
370370

371371

372+
@builder
373+
@msgpk
374+
class PyGraphNode(BaseModel):
375+
"""A CFG node of one callable's level-3 graphs. ``id`` is the source-span
376+
order index within the callable (synthetic ENTRY = 0, EXIT = last CFG id);
377+
``(signature, id)`` is the cross-section join key."""
378+
379+
id: int
380+
kind: Literal[
381+
"entry", "exit", "statement", "branch", "loop", "return", "raise", "handler"
382+
] = "statement"
383+
start_line: int = -1
384+
end_line: int = -1
385+
start_column: int = -1
386+
end_column: int = -1
387+
388+
389+
@builder
390+
@msgpk
391+
class PyCFGEdge(BaseModel):
392+
"""Control-flow successor edge (shared cross-language kind vocabulary)."""
393+
394+
source: int
395+
target: int
396+
kind: Literal[
397+
"fallthrough",
398+
"true",
399+
"false",
400+
"switch_case",
401+
"loop_back",
402+
"exception",
403+
"return",
404+
"break",
405+
"continue",
406+
"yield",
407+
"await_resume",
408+
] = "fallthrough"
409+
410+
411+
@builder
412+
@msgpk
413+
class PyPDGEdge(BaseModel):
414+
"""Dependence edge: control (``CDG``) or data (``DDG``, labeled with the
415+
k-limited access path being read)."""
416+
417+
source: int
418+
target: int
419+
type: Literal["CDG", "DDG"] = "DDG"
420+
var: Optional[str] = None
421+
422+
423+
@builder
424+
@msgpk
425+
class PyParamNode(BaseModel):
426+
"""HRB parameter-passing node, sharing the owning callable's id space
427+
(allocated after EXIT). ``call_node`` is the owning callsite statement for
428+
actuals; ``var`` is the parameter name, ``<return>``, ``<capture>:name``,
429+
or ``<global>:module::name``."""
430+
431+
id: int
432+
kind: Literal["formal_in", "formal_out", "actual_in", "actual_out"]
433+
var: str
434+
call_node: Optional[int] = None
435+
start_line: int = -1
436+
end_line: int = -1
437+
438+
439+
@builder
440+
@msgpk
441+
class PyCFG(BaseModel):
442+
"""One callable's control-flow graph."""
443+
444+
nodes: List[PyGraphNode] = []
445+
edges: List[PyCFGEdge] = []
446+
447+
448+
@builder
449+
@msgpk
450+
class PyPDG(BaseModel):
451+
"""One callable's dependence edges (over the same node ids as the CFG
452+
plus its parameter nodes)."""
453+
454+
edges: List[PyPDGEdge] = []
455+
456+
457+
@builder
458+
@msgpk
459+
class PyFunctionGraphs(BaseModel):
460+
"""The per-callable level-3 sections, keyed by signature."""
461+
462+
cfg: Optional[PyCFG] = None
463+
pdg: Optional[PyPDG] = None
464+
param_nodes: List[PyParamNode] = []
465+
466+
467+
@builder
468+
@msgpk
469+
class PySDGEndpoint(BaseModel):
470+
"""A ``(signature, node)`` reference into a function's emitted graphs."""
471+
472+
signature: str
473+
node: int
474+
475+
476+
@builder
477+
@msgpk
478+
class PySDGEdge(BaseModel):
479+
"""Interprocedural dependence edge. ``CALL``/``PARAM_IN``/``PARAM_OUT``
480+
cross functions; ``SUMMARY`` connects a callsite's actual_in to its
481+
actual_out within the caller (the callee's transitive flow)."""
482+
483+
source: PySDGEndpoint
484+
target: PySDGEndpoint
485+
type: Literal["CALL", "PARAM_IN", "PARAM_OUT", "SUMMARY"]
486+
var: Optional[str] = None
487+
488+
489+
@builder
490+
@msgpk
491+
class PyProgramGraphs(BaseModel):
492+
"""The optional level-3 top-level section of ``analysis.json`` (present
493+
only at ``-a 3``), versioned independently of the application schema."""
494+
495+
schema_version: str = "1.0.0"
496+
k_limit: int = 3
497+
functions: Dict[str, PyFunctionGraphs] = {}
498+
sdg_edges: List[PySDGEdge] = []
499+
500+
372501
@builder
373502
@msgpk
374503
class PyApplication(BaseModel):
@@ -380,3 +509,5 @@ class PyApplication(BaseModel):
380509
# builtin members), keyed by signature. Populated by the analyzer so every
381510
# backend (JSON and Neo4j) shares one authoritative external-symbol set.
382511
external_symbols: Dict[str, PyExternalSymbol] = {}
512+
# Level-3 native dataflow graphs (CFG/PDG/SDG); None below -a 3.
513+
program_graphs: Optional[PyProgramGraphs] = None

0 commit comments

Comments
 (0)