codeanalyzer-python 0.3.0__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- codeanalyzer/__main__.py +77 -4
- codeanalyzer/core.py +174 -72
- codeanalyzer/dataflow/__init__.py +35 -0
- codeanalyzer/dataflow/access_paths.py +563 -0
- codeanalyzer/dataflow/alias.py +93 -0
- codeanalyzer/dataflow/builder.py +688 -0
- codeanalyzer/dataflow/cfg.py +605 -0
- codeanalyzer/dataflow/defuse.py +113 -0
- codeanalyzer/dataflow/dominance.py +140 -0
- codeanalyzer/dataflow/identity.py +91 -0
- codeanalyzer/dataflow/pdg.py +100 -0
- codeanalyzer/dataflow/scalpel_oracle.py +269 -0
- codeanalyzer/dataflow/scc.py +91 -0
- codeanalyzer/dataflow/sdg.py +424 -0
- codeanalyzer/dataflow/slicing.py +93 -0
- codeanalyzer/dataflow/summaries.py +217 -0
- codeanalyzer/dataflow/syntactic.py +26 -0
- codeanalyzer/neo4j/__init__.py +1 -1
- codeanalyzer/neo4j/bolt.py +19 -4
- codeanalyzer/neo4j/cypher.py +9 -3
- codeanalyzer/neo4j/emit.py +10 -5
- codeanalyzer/neo4j/project.py +307 -60
- codeanalyzer/neo4j/rows.py +18 -15
- codeanalyzer/neo4j/schema.py +297 -15
- codeanalyzer/options/options.py +4 -0
- codeanalyzer/provenance.py +61 -0
- codeanalyzer/schema/__init__.py +19 -0
- codeanalyzer/schema/assign_ids.py +37 -0
- codeanalyzer/schema/call_graph_ids.py +12 -0
- codeanalyzer/schema/ids.py +23 -0
- codeanalyzer/schema/l1_body.py +29 -0
- codeanalyzer/schema/l2_callees.py +36 -0
- codeanalyzer/schema/py_schema.py +175 -26
- codeanalyzer/semantic_analysis/call_graph.py +24 -27
- codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +10 -10
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
- codeanalyzer/syntactic_analysis/import_resolver.py +67 -0
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +103 -20
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/METADATA +233 -43
- codeanalyzer_python-1.0.0.dist-info/RECORD +59 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/WHEEL +1 -1
- codeanalyzer/neo4j/catalog.py +0 -245
- codeanalyzer_python-0.3.0.dist-info/RECORD +0 -38
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.3.0.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
1
2
|
from pathlib import Path
|
|
2
3
|
from typing import Optional, Annotated
|
|
3
4
|
|
|
@@ -10,7 +11,32 @@ from codeanalyzer.schema import model_dump_json
|
|
|
10
11
|
from codeanalyzer.options import AnalysisOptions, EmitTarget, ShardStrategy
|
|
11
12
|
|
|
12
13
|
|
|
14
|
+
def _version_callback(value: bool) -> None:
|
|
15
|
+
"""Print the installed ``codeanalyzer-python`` version and exit.
|
|
16
|
+
|
|
17
|
+
Eager so it fires before the rest of the CLI callback (no -i/--input
|
|
18
|
+
required). Reads the version from package metadata so it always reflects
|
|
19
|
+
what is actually installed rather than a hardcoded string."""
|
|
20
|
+
if not value:
|
|
21
|
+
return
|
|
22
|
+
try:
|
|
23
|
+
installed = _pkg_version("codeanalyzer-python")
|
|
24
|
+
except PackageNotFoundError:
|
|
25
|
+
installed = "unknown"
|
|
26
|
+
typer.echo(f"canpy {installed}")
|
|
27
|
+
raise typer.Exit()
|
|
28
|
+
|
|
29
|
+
|
|
13
30
|
def main(
|
|
31
|
+
version: Annotated[
|
|
32
|
+
Optional[bool],
|
|
33
|
+
typer.Option(
|
|
34
|
+
"--version",
|
|
35
|
+
help="Show the canpy version and exit.",
|
|
36
|
+
callback=_version_callback,
|
|
37
|
+
is_eager=True,
|
|
38
|
+
),
|
|
39
|
+
] = None,
|
|
14
40
|
input: Annotated[
|
|
15
41
|
Optional[Path],
|
|
16
42
|
typer.Option(
|
|
@@ -88,11 +114,32 @@ def main(
|
|
|
88
114
|
typer.Option(
|
|
89
115
|
"-a",
|
|
90
116
|
"--analysis-level",
|
|
91
|
-
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call
|
|
117
|
+
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call "
|
|
118
|
+
"graph, 3=+native intraprocedural dataflow (CFG/PDG), "
|
|
119
|
+
"4=+interprocedural SDG (param/summary edges, alias-aware DDG).",
|
|
92
120
|
min=1,
|
|
93
|
-
max=
|
|
121
|
+
max=4,
|
|
94
122
|
),
|
|
95
123
|
] = 1,
|
|
124
|
+
graphs: Annotated[
|
|
125
|
+
str,
|
|
126
|
+
typer.Option(
|
|
127
|
+
"--graphs",
|
|
128
|
+
help="Level 3+ only: comma-separated program-graph sections to emit "
|
|
129
|
+
"(cfg, dfg, pdg, sdg). Default: cfg,dfg,pdg. `dfg` emits the PDG's data "
|
|
130
|
+
"edges only; `sdg` requires -a 4.",
|
|
131
|
+
),
|
|
132
|
+
] = "cfg,dfg,pdg",
|
|
133
|
+
graph_field_depth: Annotated[
|
|
134
|
+
int,
|
|
135
|
+
typer.Option(
|
|
136
|
+
"--graph-field-depth",
|
|
137
|
+
help="Level 3 only: k-limit on access-path depth (x.f.g.h with "
|
|
138
|
+
"k=3 becomes x.f.g.*). Mandatory bound — it is what guarantees "
|
|
139
|
+
"the interprocedural fixpoint terminates.",
|
|
140
|
+
min=1,
|
|
141
|
+
),
|
|
142
|
+
] = 3,
|
|
96
143
|
using_ray: Annotated[
|
|
97
144
|
bool,
|
|
98
145
|
typer.Option("--ray/--no-ray", help="Enable Ray for distributed analysis."),
|
|
@@ -217,6 +264,30 @@ def main(
|
|
|
217
264
|
),
|
|
218
265
|
] = 50,
|
|
219
266
|
):
|
|
267
|
+
# Flag validation (strict: unrecognized values error out, never fall back).
|
|
268
|
+
selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
|
|
269
|
+
from codeanalyzer.dataflow.builder import VALID_GRAPHS
|
|
270
|
+
|
|
271
|
+
unknown_graphs = [g for g in selected_graphs if g not in VALID_GRAPHS]
|
|
272
|
+
if unknown_graphs:
|
|
273
|
+
logger.error(
|
|
274
|
+
f"Unrecognized --graphs value(s): {', '.join(unknown_graphs)} "
|
|
275
|
+
f"(valid: {', '.join(VALID_GRAPHS)})."
|
|
276
|
+
)
|
|
277
|
+
raise typer.Exit(code=2)
|
|
278
|
+
if not selected_graphs:
|
|
279
|
+
logger.error("--graphs requires at least one of: " + ", ".join(VALID_GRAPHS))
|
|
280
|
+
raise typer.Exit(code=2)
|
|
281
|
+
if "sdg" in selected_graphs and analysis_level < 4:
|
|
282
|
+
logger.error("--graphs sdg requires -a 4 (interprocedural SDG).")
|
|
283
|
+
raise typer.Exit(code=2)
|
|
284
|
+
if analysis_level < 3 and graphs != "cfg,dfg,pdg":
|
|
285
|
+
logger.error("--graphs is a level-3 option; pass -a 3 to emit program graphs.")
|
|
286
|
+
raise typer.Exit(code=2)
|
|
287
|
+
if analysis_level < 3 and graph_field_depth != 3:
|
|
288
|
+
logger.error("--graph-field-depth is a level-3 option; pass -a 3.")
|
|
289
|
+
raise typer.Exit(code=2)
|
|
290
|
+
|
|
220
291
|
options = AnalysisOptions(
|
|
221
292
|
input=input,
|
|
222
293
|
output=output,
|
|
@@ -228,6 +299,8 @@ def main(
|
|
|
228
299
|
neo4j_password=neo4j_password,
|
|
229
300
|
neo4j_database=neo4j_database,
|
|
230
301
|
analysis_level=analysis_level,
|
|
302
|
+
graphs=",".join(selected_graphs),
|
|
303
|
+
graph_field_depth=graph_field_depth,
|
|
231
304
|
using_ray=using_ray,
|
|
232
305
|
rebuild_analysis=rebuild_analysis,
|
|
233
306
|
skip_tests=skip_tests,
|
|
@@ -284,7 +357,7 @@ def main(
|
|
|
284
357
|
|
|
285
358
|
emit_neo4j(artifacts, options)
|
|
286
359
|
elif options.output is None:
|
|
287
|
-
print(model_dump_json(artifacts,
|
|
360
|
+
print(model_dump_json(artifacts, exclude_none=True))
|
|
288
361
|
else:
|
|
289
362
|
options.output.mkdir(parents=True, exist_ok=True)
|
|
290
363
|
_write_output(artifacts, options.output, options.format)
|
|
@@ -295,7 +368,7 @@ def _write_output(artifacts, output_dir: Path, format: OutputFormat):
|
|
|
295
368
|
if format == OutputFormat.JSON:
|
|
296
369
|
output_file = output_dir / "analysis.json"
|
|
297
370
|
# Use Pydantic's model_dump_json() for compact output
|
|
298
|
-
json_str = model_dump_json(artifacts, indent=None)
|
|
371
|
+
json_str = model_dump_json(artifacts, indent=None, exclude_none=True)
|
|
299
372
|
with output_file.open("w") as f:
|
|
300
373
|
f.write(json_str)
|
|
301
374
|
logger.info(f"Analysis saved to {output_file}")
|
codeanalyzer/core.py
CHANGED
|
@@ -11,12 +11,17 @@ import time
|
|
|
11
11
|
import ray
|
|
12
12
|
from codeanalyzer.utils import logger
|
|
13
13
|
from codeanalyzer.schema import (
|
|
14
|
+
Analysis,
|
|
14
15
|
PyApplication,
|
|
15
16
|
PyExternalSymbol,
|
|
16
17
|
PyModule,
|
|
17
18
|
model_dump_json,
|
|
18
19
|
model_validate_json,
|
|
19
20
|
)
|
|
21
|
+
from codeanalyzer.schema.assign_ids import assign_ids
|
|
22
|
+
from codeanalyzer.schema.l1_body import populate_l1_body
|
|
23
|
+
from codeanalyzer.schema.l2_callees import backfill_callees
|
|
24
|
+
from codeanalyzer.schema.call_graph_ids import reidentify_call_graph
|
|
20
25
|
from codeanalyzer.schema.py_schema import PyCallEdge
|
|
21
26
|
from codeanalyzer.semantic_analysis.call_graph import (
|
|
22
27
|
filter_external_edges,
|
|
@@ -26,9 +31,11 @@ from codeanalyzer.semantic_analysis.call_graph import (
|
|
|
26
31
|
)
|
|
27
32
|
from codeanalyzer.semantic_analysis.pycg import PyCG, PyCGExceptions
|
|
28
33
|
from codeanalyzer.syntactic_analysis.exceptions import SymbolTableBuilderRayError
|
|
34
|
+
from codeanalyzer.syntactic_analysis.import_resolver import resolve_imports
|
|
29
35
|
from codeanalyzer.syntactic_analysis.symbol_table_builder import SymbolTableBuilder
|
|
30
36
|
from codeanalyzer.utils import ProgressBar
|
|
31
37
|
from codeanalyzer.options import AnalysisOptions
|
|
38
|
+
from codeanalyzer.provenance import analyzer_info, repository_info
|
|
32
39
|
|
|
33
40
|
@ray.remote
|
|
34
41
|
def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, str], virtualenv: Union[Path, str, None]) -> Dict[str, PyModule]:
|
|
@@ -47,7 +54,7 @@ def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, s
|
|
|
47
54
|
try:
|
|
48
55
|
py_file = Path(py_file)
|
|
49
56
|
symbol_table_builder = SymbolTableBuilder(project_dir, virtualenv)
|
|
50
|
-
module_map[str(py_file)] = symbol_table_builder.build_pymodule_from_file(py_file)
|
|
57
|
+
module_map[str(py_file.relative_to(Path(project_dir)))] = symbol_table_builder.build_pymodule_from_file(py_file)
|
|
51
58
|
except Exception as e:
|
|
52
59
|
console.log(f"❌ Failed to process {py_file}: {e}")
|
|
53
60
|
raise SymbolTableBuilderRayError(f"Ray processing error for {py_file}: {e}")
|
|
@@ -369,61 +376,52 @@ class Codeanalyzer:
|
|
|
369
376
|
shutil.rmtree(self.cache_dir)
|
|
370
377
|
|
|
371
378
|
@staticmethod
|
|
372
|
-
def
|
|
373
|
-
"""
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
the signature
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
def walk_callable(c):
|
|
380
|
-
declared.add(c.signature)
|
|
381
|
-
for ic in (c.inner_callables or {}).values():
|
|
382
|
-
walk_callable(ic)
|
|
383
|
-
for cl in (c.inner_classes or {}).values():
|
|
384
|
-
walk_class(cl)
|
|
385
|
-
|
|
386
|
-
def walk_class(cl):
|
|
387
|
-
declared.add(cl.signature)
|
|
388
|
-
for m in (cl.methods or {}).values():
|
|
389
|
-
walk_callable(m)
|
|
390
|
-
for ic in (cl.inner_classes or {}).values():
|
|
391
|
-
walk_class(ic)
|
|
392
|
-
|
|
393
|
-
for mod in symbol_table.values():
|
|
394
|
-
for c in (mod.functions or {}).values():
|
|
395
|
-
walk_callable(c)
|
|
396
|
-
for cl in (mod.classes or {}).values():
|
|
397
|
-
walk_class(cl)
|
|
398
|
-
|
|
379
|
+
def _home_external_symbols(app, app_id, sig_to_id):
|
|
380
|
+
"""Home every call-graph endpoint that is not a declared class/callable
|
|
381
|
+
onto a ``can://…/@external/<module>/<name>`` id (the keystone edge-endpoint
|
|
382
|
+
id home). Registers each homed id in ``sig_to_id`` so callee backfill and
|
|
383
|
+
call-graph re-identity map the dotted signature to it, and returns the
|
|
384
|
+
id-keyed external-symbol map. ``name``/``module`` are derived from the
|
|
385
|
+
signature (best effort: split on the last dot)."""
|
|
399
386
|
externals: Dict[str, PyExternalSymbol] = {}
|
|
400
|
-
for edge in call_graph:
|
|
401
|
-
for sig in (edge.
|
|
402
|
-
if sig in
|
|
387
|
+
for edge in app.call_graph:
|
|
388
|
+
for sig in (edge.src, edge.dst):
|
|
389
|
+
if sig in sig_to_id:
|
|
403
390
|
continue
|
|
404
|
-
module, name = sig.rsplit(".", 1) if "." in sig else (
|
|
405
|
-
|
|
391
|
+
module, name = sig.rsplit(".", 1) if "." in sig else (None, sig)
|
|
392
|
+
ext_id = f"{app_id}/@external/{module}/{name}" if module else \
|
|
393
|
+
f"{app_id}/@external/{name}"
|
|
394
|
+
sig_to_id[sig] = ext_id
|
|
395
|
+
externals[ext_id] = PyExternalSymbol(
|
|
396
|
+
id=ext_id, name=name, module=module
|
|
397
|
+
)
|
|
406
398
|
return externals
|
|
407
399
|
|
|
408
|
-
def analyze(self) ->
|
|
409
|
-
"""Analyze the project and return
|
|
410
|
-
|
|
400
|
+
def analyze(self) -> Analysis:
|
|
401
|
+
"""Analyze the project and return the v2 ``Analysis`` envelope.
|
|
402
|
+
|
|
411
403
|
Uses caching to avoid re-analyzing unchanged files.
|
|
412
404
|
"""
|
|
413
405
|
cache_file = self.cache_dir / "analysis_cache.json"
|
|
414
|
-
|
|
415
|
-
# Try to load existing cached analysis
|
|
416
|
-
|
|
406
|
+
|
|
407
|
+
# Try to load existing cached analysis
|
|
408
|
+
cached = None
|
|
417
409
|
if not self.rebuild_analysis and cache_file.exists():
|
|
418
410
|
try:
|
|
419
|
-
|
|
420
|
-
|
|
411
|
+
cached = self._load_pyapplication_from_cache(cache_file)
|
|
412
|
+
if cached is not None:
|
|
413
|
+
logger.info("Loaded cached analysis")
|
|
421
414
|
except Exception as e:
|
|
422
415
|
logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
|
|
423
|
-
|
|
416
|
+
cached = None
|
|
417
|
+
|
|
418
|
+
if not self._cache_analyzer_matches(cached, analyzer_info(self.analysis_level).version):
|
|
419
|
+
if cached is not None:
|
|
420
|
+
logger.info("Analysis cache written by a different analyzer version; rebuilding.")
|
|
421
|
+
cached = None
|
|
424
422
|
|
|
425
423
|
# Build symbol table from cached application if available (if no available, the build a new one)
|
|
426
|
-
symbol_table = self._build_symbol_table(
|
|
424
|
+
symbol_table = self._build_symbol_table(cached.application.symbol_table if cached else {})
|
|
427
425
|
|
|
428
426
|
resolve_unresolved_constructors(symbol_table)
|
|
429
427
|
|
|
@@ -441,50 +439,154 @@ class Codeanalyzer:
|
|
|
441
439
|
|
|
442
440
|
call_graph = filter_external_edges(call_graph, symbol_table)
|
|
443
441
|
|
|
444
|
-
# Classify call-graph endpoints that are not declared in the symbol table
|
|
445
|
-
# (imported library / builtin members) once, so the JSON and Neo4j backends
|
|
446
|
-
# share one authoritative external-symbol set.
|
|
447
|
-
external_symbols = self._compute_external_symbols(symbol_table, call_graph)
|
|
448
|
-
|
|
449
442
|
# Recreate pyapplication
|
|
450
443
|
app = (
|
|
451
444
|
PyApplication.builder()
|
|
452
445
|
.symbol_table(symbol_table)
|
|
453
446
|
.call_graph(call_graph)
|
|
454
|
-
.external_symbols(external_symbols)
|
|
455
447
|
.build()
|
|
456
448
|
)
|
|
457
|
-
|
|
458
|
-
# Save to cache
|
|
459
|
-
self._save_analysis_cache(app, cache_file)
|
|
460
|
-
|
|
461
|
-
return app
|
|
462
449
|
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
450
|
+
# Every run re-resolves import spellings against the analyzed module
|
|
451
|
+
# set -- pure and cheap; cached modules from older caches default to
|
|
452
|
+
# resolved_module=None and get stamped here (issue #82).
|
|
453
|
+
resolve_imports(app, self.project_dir)
|
|
454
|
+
|
|
455
|
+
# Single choke point for provenance: every produced app (fresh symbol
|
|
456
|
+
# table or reused-from-cache) passes through here before being cached
|
|
457
|
+
# or returned, so repository provenance always reflects *this* checkout
|
|
458
|
+
# even when the symbol table itself came from the on-disk cache. The
|
|
459
|
+
# analyzer identity rides the envelope below (keystone home).
|
|
460
|
+
app.repository = repository_info(self.project_dir)
|
|
461
|
+
|
|
462
|
+
app_name = self.options.app_name or self.project_dir.name
|
|
463
|
+
sig_to_id = assign_ids(app, app_name)
|
|
464
|
+
# Home call-graph endpoints that are not declared in the symbol table
|
|
465
|
+
# (imported library / builtin members) onto @external ids once, so the
|
|
466
|
+
# JSON and Neo4j backends share one authoritative external-symbol set
|
|
467
|
+
# and every edge endpoint joins the id space (no dangling endpoints).
|
|
468
|
+
app.external_symbols = self._home_external_symbols(app, app.id, sig_to_id)
|
|
469
|
+
populate_l1_body(app)
|
|
470
|
+
if self.analysis_level >= 2:
|
|
471
|
+
backfill_callees(app, sig_to_id)
|
|
472
|
+
reidentify_call_graph(app, sig_to_id)
|
|
473
|
+
|
|
474
|
+
# L3: intraprocedural dataflow (CFG/CDG/DDG) emitted onto the v2 tree.
|
|
475
|
+
if self.analysis_level >= 3:
|
|
476
|
+
from codeanalyzer.dataflow.builder import (
|
|
477
|
+
build_function_pdgs,
|
|
478
|
+
emit_l3_body,
|
|
479
|
+
)
|
|
480
|
+
from codeanalyzer.dataflow.syntactic import SyntacticOracle
|
|
481
|
+
|
|
482
|
+
infos, _func_asts = build_function_pdgs(
|
|
483
|
+
app,
|
|
484
|
+
k=self.options.graph_field_depth,
|
|
485
|
+
oracle_factory=lambda c, fast: SyntacticOracle(),
|
|
486
|
+
)
|
|
487
|
+
emit_l3_body(app, infos, sig_to_id, set(self.options.graphs.split(",")))
|
|
488
|
+
|
|
489
|
+
# L4: interprocedural dataflow (param vertices + summary + param_in/out)
|
|
490
|
+
# layered on top of the L3 syntactic overlay (L3 ⊆ L4). Scalpel is the
|
|
491
|
+
# primary may-alias oracle, with the type-based total fallback.
|
|
492
|
+
if self.analysis_level >= 4:
|
|
493
|
+
from codeanalyzer.dataflow.builder import (
|
|
494
|
+
_base_types,
|
|
495
|
+
build_program_graphs,
|
|
496
|
+
emit_ddg_pointsto_delta,
|
|
497
|
+
emit_l4,
|
|
498
|
+
)
|
|
499
|
+
from codeanalyzer.dataflow.scalpel_oracle import make_alias_oracle
|
|
500
|
+
|
|
501
|
+
ir = build_program_graphs(
|
|
502
|
+
app,
|
|
503
|
+
k=self.options.graph_field_depth,
|
|
504
|
+
oracle_factory=lambda c, fast: make_alias_oracle(
|
|
505
|
+
c, fast, _base_types(c)
|
|
506
|
+
),
|
|
507
|
+
)
|
|
508
|
+
emit_l4(app, ir, sig_to_id)
|
|
509
|
+
# Semantic ddg delta: the alias-derived def-use edges the real
|
|
510
|
+
# oracle adds beyond the L3 syntactic set, tagged prov=["points-to"].
|
|
511
|
+
# ``infos`` are the syntactic (L3) PDGs from the >=3 block above.
|
|
512
|
+
emit_ddg_pointsto_delta(app, infos, ir, sig_to_id)
|
|
513
|
+
|
|
514
|
+
# Build the v2 envelope, then persist it (the cache stores the full
|
|
515
|
+
# ``Analysis`` envelope so a reused cache round-trips schema_version).
|
|
516
|
+
# k_limit is an L3+ envelope key: below the dataflow levels it stays
|
|
517
|
+
# None and exclude_none drops it from the payload.
|
|
518
|
+
analysis = Analysis(
|
|
519
|
+
max_level=self.analysis_level,
|
|
520
|
+
k_limit=self.options.graph_field_depth if self.analysis_level >= 3 else None,
|
|
521
|
+
analyzer=analyzer_info(self.analysis_level),
|
|
522
|
+
application=app,
|
|
523
|
+
)
|
|
524
|
+
self._save_analysis_cache(analysis, cache_file)
|
|
525
|
+
|
|
526
|
+
return analysis
|
|
527
|
+
|
|
528
|
+
@staticmethod
|
|
529
|
+
def _cache_analyzer_matches(cached: Optional[Analysis], current_version: str) -> bool:
|
|
530
|
+
"""A cache written by another analyzer version (or before versions were
|
|
531
|
+
recorded) may lack fields the current models populate — pydantic fills
|
|
532
|
+
silent defaults, which would masquerade as analyzed absence. The
|
|
533
|
+
analyzer identity lives on the envelope (keystone home)."""
|
|
534
|
+
return (
|
|
535
|
+
cached is not None
|
|
536
|
+
and cached.analyzer is not None
|
|
537
|
+
and cached.analyzer.version == current_version
|
|
538
|
+
)
|
|
539
|
+
|
|
540
|
+
def _load_pyapplication_from_cache(self, cache_file: Path) -> Optional[Analysis]:
|
|
541
|
+
"""Load a cached v2 ``Analysis`` envelope from file.
|
|
542
|
+
|
|
543
|
+
A cache written by an older (v1) analyzer stored a bare
|
|
544
|
+
``PyApplication`` with no ``schema_version``; such a payload no longer
|
|
545
|
+
validates as an ``Analysis`` (or carries the wrong ``schema_version``).
|
|
546
|
+
In that case we log and return ``None`` so the caller treats it as a
|
|
547
|
+
cache miss and rebuilds from scratch — rather than crashing.
|
|
548
|
+
|
|
466
549
|
Args:
|
|
467
550
|
cache_file: Path to the cache file
|
|
468
|
-
|
|
551
|
+
|
|
469
552
|
Returns:
|
|
470
|
-
|
|
553
|
+
Optional[Analysis]: The cached envelope, or ``None`` if the cache is
|
|
554
|
+
stale/incompatible and should be rebuilt.
|
|
471
555
|
"""
|
|
472
556
|
with cache_file.open('r') as f:
|
|
473
557
|
data = f.read()
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
558
|
+
try:
|
|
559
|
+
cached = model_validate_json(Analysis, data)
|
|
560
|
+
except Exception:
|
|
561
|
+
logger.info("stale/incompatible analysis cache — rebuilding")
|
|
562
|
+
return None
|
|
563
|
+
if getattr(cached, "schema_version", None) != "2.0.0":
|
|
564
|
+
logger.info("stale/incompatible analysis cache (schema_version) — rebuilding")
|
|
565
|
+
return None
|
|
566
|
+
# The cache keys only on file hash/mtime/size, not on level, so a cache
|
|
567
|
+
# built at a different analysis_level would leak higher-level body/edge
|
|
568
|
+
# content (or omit content when the cached level is lower). Reject the
|
|
569
|
+
# mismatch and force a full rebuild at the requested level.
|
|
570
|
+
if cached.max_level != self.analysis_level:
|
|
571
|
+
logger.info(
|
|
572
|
+
f"cache built at level {cached.max_level} != requested "
|
|
573
|
+
f"{self.analysis_level} — rebuilding"
|
|
574
|
+
)
|
|
575
|
+
return None
|
|
576
|
+
return cached
|
|
577
|
+
|
|
578
|
+
def _save_analysis_cache(self, analysis: Analysis, cache_file: Path) -> None:
|
|
579
|
+
"""Save the v2 ``Analysis`` envelope to the cache file.
|
|
580
|
+
|
|
479
581
|
Args:
|
|
480
|
-
|
|
582
|
+
analysis: The Analysis envelope to cache
|
|
481
583
|
cache_file: Path to save the cache file
|
|
482
584
|
"""
|
|
483
585
|
# Ensure cache directory exists
|
|
484
586
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
485
|
-
|
|
587
|
+
|
|
486
588
|
with cache_file.open('w') as f:
|
|
487
|
-
f.write(model_dump_json(
|
|
589
|
+
f.write(model_dump_json(analysis, indent=2))
|
|
488
590
|
|
|
489
591
|
logger.info(f"Analysis cached to {cache_file}")
|
|
490
592
|
|
|
@@ -552,9 +654,9 @@ class Codeanalyzer:
|
|
|
552
654
|
if self.file_name is not None:
|
|
553
655
|
single_file = self.project_dir / self.file_name
|
|
554
656
|
logger.info(f"Analyzing single file: {single_file}")
|
|
555
|
-
|
|
657
|
+
|
|
556
658
|
# Check if file is in cache and unchanged
|
|
557
|
-
file_key = str(single_file)
|
|
659
|
+
file_key = str(single_file.relative_to(self.project_dir))
|
|
558
660
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
559
661
|
# Compute file checksum to see if it changed
|
|
560
662
|
if self._file_unchanged(single_file, cached_symbol_table[file_key]):
|
|
@@ -604,7 +706,7 @@ class Codeanalyzer:
|
|
|
604
706
|
# Separate files into cached and new/changed
|
|
605
707
|
files_to_process = []
|
|
606
708
|
for py_file in py_files:
|
|
607
|
-
file_key = str(py_file)
|
|
709
|
+
file_key = str(py_file.relative_to(self.project_dir))
|
|
608
710
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
609
711
|
if self._file_unchanged(py_file, cached_symbol_table[file_key]):
|
|
610
712
|
# Use cached version
|
|
@@ -632,7 +734,7 @@ class Codeanalyzer:
|
|
|
632
734
|
|
|
633
735
|
with ProgressBar(len(py_files), "Building symbol table") as progress:
|
|
634
736
|
for py_file in py_files:
|
|
635
|
-
file_key = str(py_file)
|
|
737
|
+
file_key = str(py_file.relative_to(self.project_dir))
|
|
636
738
|
|
|
637
739
|
# Check if file is cached and unchanged
|
|
638
740
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
@@ -668,7 +770,7 @@ class Codeanalyzer:
|
|
|
668
770
|
"""Build PyCG-resolved call edges.
|
|
669
771
|
|
|
670
772
|
Runs PyCG's iterative name-pointer analysis over the whole project
|
|
671
|
-
and returns edges with ``
|
|
773
|
+
and returns edges with ``prov=["pycg"]``. Falls back to an
|
|
672
774
|
empty list and logs a warning on any failure so the caller can
|
|
673
775
|
continue with Jedi-only edges.
|
|
674
776
|
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
################################################################################
|
|
2
|
+
# Copyright IBM Corporation 2025
|
|
3
|
+
#
|
|
4
|
+
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
# you may not use this file except in compliance with the License.
|
|
6
|
+
# You may obtain a copy of the License at
|
|
7
|
+
#
|
|
8
|
+
# http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
#
|
|
10
|
+
# Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
# See the License for the specific language governing permissions and
|
|
14
|
+
# limitations under the License.
|
|
15
|
+
################################################################################
|
|
16
|
+
|
|
17
|
+
"""Level-3 native dataflow graphs: CFG, PDG (CDG + DDG), and the SDG.
|
|
18
|
+
|
|
19
|
+
One pass per module, mirroring the construction ladder:
|
|
20
|
+
|
|
21
|
+
- :mod:`cfg` — stage 1, exceptional statement-level CFG per callable;
|
|
22
|
+
- :mod:`dominance` — stage 2, post-dominators and control dependence;
|
|
23
|
+
- :mod:`access_paths` — stage 3a, the k-limited access-path variable model;
|
|
24
|
+
- :mod:`defuse` — stage 3b, reaching definitions → DDG edges;
|
|
25
|
+
- :mod:`alias` — stage 5, the type-based may-alias oracle (MVP stub);
|
|
26
|
+
- :mod:`scc` — stage 5, Tarjan SCC condensation of the call graph;
|
|
27
|
+
- :mod:`summaries` — stage 6, bottom-up formal-in → formal-out summaries;
|
|
28
|
+
- :mod:`sdg` — stage 7, parameter nodes and CALL/PARAM_IN/PARAM_OUT/SUMMARY
|
|
29
|
+
edges;
|
|
30
|
+
- :mod:`slicing` — stage 8, the two-phase context-sensitive backward slice;
|
|
31
|
+
- :mod:`builder` — the orchestrator ``build_program_graphs`` wired into
|
|
32
|
+
``Codeanalyzer.analyze`` at ``-a 3``.
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
from codeanalyzer.dataflow.cfg import build_cfg # noqa: F401
|