Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions codeanalyzer/__main__.py
Original file line number Diff line number Diff line change
Expand Up @@ -353,7 +353,7 @@ def main(

emit_neo4j(artifacts, options)
elif options.output is None:
print(model_dump_json(artifacts, separators=(",", ":")))
print(model_dump_json(artifacts, exclude_none=True))
else:
options.output.mkdir(parents=True, exist_ok=True)
_write_output(artifacts, options.output, options.format)
Expand All @@ -364,7 +364,7 @@ def _write_output(artifacts, output_dir: Path, format: OutputFormat):
if format == OutputFormat.JSON:
output_file = output_dir / "analysis.json"
# Use Pydantic's model_dump_json() for compact output
json_str = model_dump_json(artifacts, indent=None)
json_str = model_dump_json(artifacts, indent=None, exclude_none=True)
with output_file.open("w") as f:
f.write(json_str)
logger.info(f"Analysis saved to {output_file}")
Expand Down
104 changes: 58 additions & 46 deletions codeanalyzer/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,15 @@
import ray
from codeanalyzer.utils import logger
from codeanalyzer.schema import (
Analysis,
PyApplication,
PyExternalSymbol,
PyModule,
model_dump_json,
model_validate_json,
)
from codeanalyzer.schema.assign_ids import assign_ids
from codeanalyzer.schema.l1_body import populate_l1_body
from codeanalyzer.schema.py_schema import PyCallEdge
from codeanalyzer.semantic_analysis.call_graph import (
filter_external_edges,
Expand Down Expand Up @@ -47,7 +50,7 @@ def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, s
try:
py_file = Path(py_file)
symbol_table_builder = SymbolTableBuilder(project_dir, virtualenv)
module_map[str(py_file)] = symbol_table_builder.build_pymodule_from_file(py_file)
module_map[str(py_file.relative_to(Path(project_dir)))] = symbol_table_builder.build_pymodule_from_file(py_file)
except Exception as e:
console.log(f"❌ Failed to process {py_file}: {e}")
raise SymbolTableBuilderRayError(f"Ray processing error for {py_file}: {e}")
Expand Down Expand Up @@ -405,25 +408,26 @@ def walk_class(cl):
externals[sig] = PyExternalSymbol(name=name, module=module)
return externals

def analyze(self) -> PyApplication:
"""Analyze the project and return a PyApplication with symbol table.
def analyze(self) -> Analysis:
"""Analyze the project and return the v2 ``Analysis`` envelope.

Uses caching to avoid re-analyzing unchanged files.
"""
cache_file = self.cache_dir / "analysis_cache.json"

# Try to load existing cached analysis
cached_pyapplication = None
# Try to load existing cached analysis
cached = None
if not self.rebuild_analysis and cache_file.exists():
try:
cached_pyapplication = self._load_pyapplication_from_cache(cache_file)
logger.info("Loaded cached analysis")
cached = self._load_pyapplication_from_cache(cache_file)
if cached is not None:
logger.info("Loaded cached analysis")
except Exception as e:
logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
cached_pyapplication = None
cached = None

# Build symbol table from cached application if available (if no available, the build a new one)
symbol_table = self._build_symbol_table(cached_pyapplication.symbol_table if cached_pyapplication else {})
symbol_table = self._build_symbol_table(cached.application.symbol_table if cached else {})

resolve_unresolved_constructors(symbol_table)

Expand Down Expand Up @@ -455,54 +459,62 @@ def analyze(self) -> PyApplication:
.build()
)

if self.analysis_level >= 3:
# Level 3: native dataflow graphs (CFG/PDG/SDG) over the same
# signatures, gated so -a 1/-a 2 timings stay untouched.
from codeanalyzer.dataflow.builder import (
build_program_graphs,
to_program_graphs,
)
assign_ids(app, self.options.app_name or self.project_dir.name)
populate_l1_body(app)

t0_l3 = time.perf_counter()
ir = build_program_graphs(app, k=self.options.graph_field_depth)
app.program_graphs = to_program_graphs(
ir, set(self.options.graphs.split(","))
)
logger.info(
"✅ Program graphs: %d functions, %d SDG edges in %.1fs",
len(ir.functions), len(ir.sdg_edges), time.perf_counter() - t0_l3,
)
# L3/L4 dataflow emission rebuilt on the v2 tree in Stage 3+

# Save to cache
self._save_analysis_cache(app, cache_file)
# Build the v2 envelope, then persist it (the cache stores the full
# ``Analysis`` envelope so a reused cache round-trips schema_version).
analysis = Analysis(
max_level=self.analysis_level,
k_limit=self.options.graph_field_depth,
application=app,
)
self._save_analysis_cache(analysis, cache_file)

return app
return analysis

def _load_pyapplication_from_cache(self, cache_file: Path) -> Optional[Analysis]:
"""Load a cached v2 ``Analysis`` envelope from file.

A cache written by an older (v1) analyzer stored a bare
``PyApplication`` with no ``schema_version``; such a payload no longer
validates as an ``Analysis`` (or carries the wrong ``schema_version``).
In that case we log and return ``None`` so the caller treats it as a
cache miss and rebuilds from scratch — rather than crashing.

def _load_pyapplication_from_cache(self, cache_file: Path) -> PyApplication:
"""Load cached analysis from file.

Args:
cache_file: Path to the cache file

Returns:
PyApplication: The cached application data
Optional[Analysis]: The cached envelope, or ``None`` if the cache is
stale/incompatible and should be rebuilt.
"""
with cache_file.open('r') as f:
data = f.read()
return model_validate_json(PyApplication, data)

def _save_analysis_cache(self, app: PyApplication, cache_file: Path) -> None:
"""Save analysis to cache file.

try:
cached = model_validate_json(Analysis, data)
except Exception:
logger.info("stale/incompatible analysis cache — rebuilding")
return None
if getattr(cached, "schema_version", None) != "2.0.0":
logger.info("stale/incompatible analysis cache (schema_version) — rebuilding")
return None
return cached

def _save_analysis_cache(self, analysis: Analysis, cache_file: Path) -> None:
"""Save the v2 ``Analysis`` envelope to the cache file.

Args:
app: The PyApplication to cache
analysis: The Analysis envelope to cache
cache_file: Path to save the cache file
"""
# Ensure cache directory exists
cache_file.parent.mkdir(parents=True, exist_ok=True)

with cache_file.open('w') as f:
f.write(model_dump_json(app, indent=2))
f.write(model_dump_json(analysis, indent=2))

logger.info(f"Analysis cached to {cache_file}")

Expand Down Expand Up @@ -570,9 +582,9 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
if self.file_name is not None:
single_file = self.project_dir / self.file_name
logger.info(f"Analyzing single file: {single_file}")

# Check if file is in cache and unchanged
file_key = str(single_file)
file_key = str(single_file.relative_to(self.project_dir))
if file_key in cached_symbol_table and not self.rebuild_analysis:
# Compute file checksum to see if it changed
if self._file_unchanged(single_file, cached_symbol_table[file_key]):
Expand Down Expand Up @@ -622,7 +634,7 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]
# Separate files into cached and new/changed
files_to_process = []
for py_file in py_files:
file_key = str(py_file)
file_key = str(py_file.relative_to(self.project_dir))
if file_key in cached_symbol_table and not self.rebuild_analysis:
if self._file_unchanged(py_file, cached_symbol_table[file_key]):
# Use cached version
Expand Down Expand Up @@ -650,7 +662,7 @@ def _build_symbol_table(self, cached_symbol_table: Optional[Dict[str, PyModule]]

with ProgressBar(len(py_files), "Building symbol table") as progress:
for py_file in py_files:
file_key = str(py_file)
file_key = str(py_file.relative_to(self.project_dir))

# Check if file is cached and unchanged
if file_key in cached_symbol_table and not self.rebuild_analysis:
Expand Down
11 changes: 8 additions & 3 deletions codeanalyzer/neo4j/emit.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,8 @@
from codeanalyzer.neo4j.cypher import render_cypher
from codeanalyzer.neo4j.project import project
from codeanalyzer.options import AnalysisOptions
from codeanalyzer.schema import PyApplication
from codeanalyzer.schema import Analysis
from codeanalyzer.schema.assign_ids import assign_ids
from codeanalyzer.utils import logger


Expand All @@ -49,11 +50,15 @@ def emit_schema(output: Optional[Path]) -> None:
logger.info(f"Neo4j schema written to {output / 'schema.json'}")


def emit_neo4j(app: PyApplication, options: AnalysisOptions) -> None:
def emit_neo4j(analysis: Analysis, options: AnalysisOptions) -> None:
"""Project the analysis to a graph and write it: a live Bolt push when
``--neo4j-uri`` is set, otherwise a self-contained ``graph.cypher`` snapshot."""
app_name = options.app_name or Path(options.input).resolve().name
rows = project(app, app_name)
# ``assign_ids`` is idempotent: it stamps every module/class/callable with its
# canonical ``can://`` id and returns the ``signature -> id`` map the projection
# keys nodes on, so the JSON and Neo4j projections agree.
sig_to_id = assign_ids(analysis.application, app_name)
rows = project(analysis.application, app_name, sig_to_id)

if options.neo4j_uri:
cfg = BoltConfig(
Expand Down
58 changes: 33 additions & 25 deletions codeanalyzer/neo4j/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,34 +50,30 @@
from codeanalyzer.schema.py_schema import PyCallsite


def project(app: PyApplication, app_name: str) -> GraphRows:
def project(app: PyApplication, app_name: str, sig_to_id: dict) -> GraphRows:
b = RowBuilder()

app_ref = b.node(
["PyApplication"], "name", app_name, {"schema_version": SCHEMA_VERSION}
)

for file_key, mod in app.symbol_table.items():
mod_ref = b.node(
["PyModule"], "file_key", file_key, _module_props(mod, file_key)
)
mod_ref = b.node(["PyModule"], "id", mod.id, _module_props(mod, file_key))
b.edge("PY_HAS_MODULE", app_ref, mod_ref)
_project_module_body(b, file_key, mod_ref, mod)

# The aggregated :PY_CALLS twin. Endpoints listed in app.external_symbols become
# :PyExternal ghost nodes; the rest are declared :PySymbol nodes already emitted.
# :PyExternal ghost nodes; the rest are declared :PySymbol nodes already emitted
# (keyed by their can:// id, resolved through ``sig_to_id``).
externals = app.external_symbols or {}
for e in app.call_graph:
src = _call_endpoint(b, e.source, externals)
tgt = _call_endpoint(b, e.target, externals)
src = _call_endpoint(b, e.source, externals, sig_to_id)
tgt = _call_endpoint(b, e.target, externals, sig_to_id)
b.edge(
"PY_CALLS", src, tgt, _call_edge_props(e.weight, list(e.provenance or []))
)

# Level-3 CPG overlay (present only at -a 3): the same program_graphs IR
# projected as :PyCFGNode nodes and PY_-namespaced dependence edge types.
if app.program_graphs is not None:
_project_program_graphs(b, app)
# CPG overlay projection rebuilt on the v2 tree in Stage 3

return b.finish()

Expand Down Expand Up @@ -175,21 +171,27 @@ def _project_program_graphs(b: RowBuilder, app: PyApplication) -> None:
)


def _sym(signature: str) -> NodeRef:
return NodeRef("PySymbol", "signature", signature)
def _sym(can_id: str) -> NodeRef:
return NodeRef("PySymbol", "id", can_id)


def _call_endpoint(b: RowBuilder, signature: str, externals: dict) -> NodeRef:
"""A call-graph endpoint: a declared callable already emitted, or an external
symbol (imported library / builtin member) materialized as a :PyExternal ghost.
def _call_endpoint(
b: RowBuilder, signature: str, externals: dict, sig_to_id: dict
) -> NodeRef:
"""A call-graph endpoint: a declared callable already emitted (keyed by its
canonical ``can://`` id, resolved through ``sig_to_id``), or an external symbol
(imported library / builtin member) materialized as a :PyExternal ghost.

Classification is authoritative -- it comes from ``app.external_symbols``, not a
"present in the graph" heuristic -- so an imported module name (which exists only
as a :PyPackage) can never shadow the call target. A small fallback still
materializes an external for any endpoint that is neither declared nor listed."""
as a :PyPackage) can never shadow the call target. A declared endpoint resolves to
its ``can://`` id; anything neither declared nor listed falls back to a
signature-keyed :PyExternal ghost rather than raising."""
ext = externals.get(signature)
if ext is None and b.has_key("PySymbol", signature):
return _sym(signature)
if ext is None:
can_id = sig_to_id.get(signature)
if can_id is not None:
return _sym(can_id)
name = (
ext.name
if ext is not None
Expand Down Expand Up @@ -257,7 +259,7 @@ def _project_class(
b: RowBuilder, file_key: str, parent: NodeRef, parent_rel: str, cl: PyClass
) -> None:
ref = b.node(
["PySymbol", "PyClass"], "signature", cl.signature, _class_props(cl, file_key)
["PySymbol", "PyClass"], "id", cl.id, _class_props(cl, file_key)
)
b.edge(parent_rel, parent, ref)

Expand All @@ -277,8 +279,8 @@ def _project_callable(
) -> None:
ref = b.node(
["PySymbol", "PyCallable"],
"signature",
c.signature,
"id",
c.id,
_callable_props(c, file_key),
)
b.edge(owner_rel, owner, ref)
Expand Down Expand Up @@ -337,6 +339,8 @@ def _project_decorator(b: RowBuilder, on: NodeRef, decorator: str) -> None:
def _module_props(mod: PyModule, file_key: str) -> Props:
return prune(
{
"id": mod.id,
"file_key": file_key,
"module_name": mod.module_name,
"content_hash": mod.content_hash,
"last_modified": mod.last_modified,
Expand All @@ -349,8 +353,10 @@ def _module_props(mod: PyModule, file_key: str) -> Props:
def _class_props(cl: PyClass, file_key: str) -> Props:
return prune(
{
"id": cl.id,
"signature": cl.signature,
"name": cl.name,
"code": cl.code,
"code": getattr(cl, "code", None),
"base_classes": list(cl.base_classes or []),
"docstring": _docstring_of(cl.comments),
"start_line": cl.start_line,
Expand All @@ -363,11 +369,13 @@ def _class_props(cl: PyClass, file_key: str) -> Props:
def _callable_props(c: PyCallable, file_key: str) -> Props:
return prune(
{
"id": c.id,
"signature": c.signature,
"name": c.name,
"path": c.path,
"return_type": c.return_type,
"cyclomatic_complexity": c.cyclomatic_complexity,
"code": c.code,
"code": getattr(c, "code", None),
"code_start_line": c.code_start_line,
"start_line": c.start_line,
"end_line": c.end_line,
Expand Down
9 changes: 6 additions & 3 deletions codeanalyzer/neo4j/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,9 @@ class RelType:
NodeLabel(
"PyModule",
"PyModule",
"file_key",
"id",
{
"id": "string",
"file_key": "string",
"module_name": "string",
"content_hash": "string",
Expand All @@ -85,8 +86,9 @@ class RelType:
NodeLabel(
"PyClass",
"PySymbol",
"signature",
"id",
{
"id": "string",
"signature": "string",
"name": "string",
"code": "string",
Expand All @@ -99,8 +101,9 @@ class RelType:
NodeLabel(
"PyCallable",
"PySymbol",
"signature",
"id",
{
"id": "string",
"signature": "string",
"name": "string",
"path": "string",
Expand Down
Loading