codeanalyzer-python 0.3.1__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 +51 -4
- codeanalyzer/core.py +153 -82
- 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/bolt.py +19 -4
- codeanalyzer/neo4j/cypher.py +9 -3
- codeanalyzer/neo4j/emit.py +8 -3
- codeanalyzer/neo4j/project.py +241 -60
- codeanalyzer/neo4j/rows.py +18 -15
- codeanalyzer/neo4j/schema.py +43 -7
- codeanalyzer/options/options.py +4 -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 +141 -30
- 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/symbol_table_builder.py +29 -10
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.0.dist-info}/METADATA +230 -43
- codeanalyzer_python-1.0.0.dist-info/RECORD +59 -0
- codeanalyzer_python-0.3.1.dist-info/RECORD +0 -39
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.0.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.0.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.0.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py
CHANGED
|
@@ -114,11 +114,32 @@ def main(
|
|
|
114
114
|
typer.Option(
|
|
115
115
|
"-a",
|
|
116
116
|
"--analysis-level",
|
|
117
|
-
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).",
|
|
118
120
|
min=1,
|
|
119
|
-
max=
|
|
121
|
+
max=4,
|
|
120
122
|
),
|
|
121
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,
|
|
122
143
|
using_ray: Annotated[
|
|
123
144
|
bool,
|
|
124
145
|
typer.Option("--ray/--no-ray", help="Enable Ray for distributed analysis."),
|
|
@@ -243,6 +264,30 @@ def main(
|
|
|
243
264
|
),
|
|
244
265
|
] = 50,
|
|
245
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
|
+
|
|
246
291
|
options = AnalysisOptions(
|
|
247
292
|
input=input,
|
|
248
293
|
output=output,
|
|
@@ -254,6 +299,8 @@ def main(
|
|
|
254
299
|
neo4j_password=neo4j_password,
|
|
255
300
|
neo4j_database=neo4j_database,
|
|
256
301
|
analysis_level=analysis_level,
|
|
302
|
+
graphs=",".join(selected_graphs),
|
|
303
|
+
graph_field_depth=graph_field_depth,
|
|
257
304
|
using_ray=using_ray,
|
|
258
305
|
rebuild_analysis=rebuild_analysis,
|
|
259
306
|
skip_tests=skip_tests,
|
|
@@ -310,7 +357,7 @@ def main(
|
|
|
310
357
|
|
|
311
358
|
emit_neo4j(artifacts, options)
|
|
312
359
|
elif options.output is None:
|
|
313
|
-
print(model_dump_json(artifacts,
|
|
360
|
+
print(model_dump_json(artifacts, exclude_none=True))
|
|
314
361
|
else:
|
|
315
362
|
options.output.mkdir(parents=True, exist_ok=True)
|
|
316
363
|
_write_output(artifacts, options.output, options.format)
|
|
@@ -321,7 +368,7 @@ def _write_output(artifacts, output_dir: Path, format: OutputFormat):
|
|
|
321
368
|
if format == OutputFormat.JSON:
|
|
322
369
|
output_file = output_dir / "analysis.json"
|
|
323
370
|
# Use Pydantic's model_dump_json() for compact output
|
|
324
|
-
json_str = model_dump_json(artifacts, indent=None)
|
|
371
|
+
json_str = model_dump_json(artifacts, indent=None, exclude_none=True)
|
|
325
372
|
with output_file.open("w") as f:
|
|
326
373
|
f.write(json_str)
|
|
327
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,
|
|
@@ -49,7 +54,7 @@ def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, s
|
|
|
49
54
|
try:
|
|
50
55
|
py_file = Path(py_file)
|
|
51
56
|
symbol_table_builder = SymbolTableBuilder(project_dir, virtualenv)
|
|
52
|
-
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)
|
|
53
58
|
except Exception as e:
|
|
54
59
|
console.log(f"❌ Failed to process {py_file}: {e}")
|
|
55
60
|
raise SymbolTableBuilderRayError(f"Ray processing error for {py_file}: {e}")
|
|
@@ -371,67 +376,52 @@ class Codeanalyzer:
|
|
|
371
376
|
shutil.rmtree(self.cache_dir)
|
|
372
377
|
|
|
373
378
|
@staticmethod
|
|
374
|
-
def
|
|
375
|
-
"""
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
the signature
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
def walk_callable(c):
|
|
382
|
-
declared.add(c.signature)
|
|
383
|
-
for ic in (c.inner_callables or {}).values():
|
|
384
|
-
walk_callable(ic)
|
|
385
|
-
for cl in (c.inner_classes or {}).values():
|
|
386
|
-
walk_class(cl)
|
|
387
|
-
|
|
388
|
-
def walk_class(cl):
|
|
389
|
-
declared.add(cl.signature)
|
|
390
|
-
for m in (cl.methods or {}).values():
|
|
391
|
-
walk_callable(m)
|
|
392
|
-
for ic in (cl.inner_classes or {}).values():
|
|
393
|
-
walk_class(ic)
|
|
394
|
-
|
|
395
|
-
for mod in symbol_table.values():
|
|
396
|
-
for c in (mod.functions or {}).values():
|
|
397
|
-
walk_callable(c)
|
|
398
|
-
for cl in (mod.classes or {}).values():
|
|
399
|
-
walk_class(cl)
|
|
400
|
-
|
|
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)."""
|
|
401
386
|
externals: Dict[str, PyExternalSymbol] = {}
|
|
402
|
-
for edge in call_graph:
|
|
403
|
-
for sig in (edge.
|
|
404
|
-
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:
|
|
405
390
|
continue
|
|
406
|
-
module, name = sig.rsplit(".", 1) if "." in sig else (
|
|
407
|
-
|
|
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
|
+
)
|
|
408
398
|
return externals
|
|
409
399
|
|
|
410
|
-
def analyze(self) ->
|
|
411
|
-
"""Analyze the project and return
|
|
412
|
-
|
|
400
|
+
def analyze(self) -> Analysis:
|
|
401
|
+
"""Analyze the project and return the v2 ``Analysis`` envelope.
|
|
402
|
+
|
|
413
403
|
Uses caching to avoid re-analyzing unchanged files.
|
|
414
404
|
"""
|
|
415
405
|
cache_file = self.cache_dir / "analysis_cache.json"
|
|
416
406
|
|
|
417
407
|
# Try to load existing cached analysis
|
|
418
|
-
|
|
408
|
+
cached = None
|
|
419
409
|
if not self.rebuild_analysis and cache_file.exists():
|
|
420
410
|
try:
|
|
421
|
-
|
|
422
|
-
|
|
411
|
+
cached = self._load_pyapplication_from_cache(cache_file)
|
|
412
|
+
if cached is not None:
|
|
413
|
+
logger.info("Loaded cached analysis")
|
|
423
414
|
except Exception as e:
|
|
424
415
|
logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
|
|
425
|
-
|
|
416
|
+
cached = None
|
|
426
417
|
|
|
427
|
-
if
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
cached_pyapplication = None
|
|
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
|
|
432
422
|
|
|
433
423
|
# Build symbol table from cached application if available (if no available, the build a new one)
|
|
434
|
-
symbol_table = self._build_symbol_table(
|
|
424
|
+
symbol_table = self._build_symbol_table(cached.application.symbol_table if cached else {})
|
|
435
425
|
|
|
436
426
|
resolve_unresolved_constructors(symbol_table)
|
|
437
427
|
|
|
@@ -449,17 +439,11 @@ class Codeanalyzer:
|
|
|
449
439
|
|
|
450
440
|
call_graph = filter_external_edges(call_graph, symbol_table)
|
|
451
441
|
|
|
452
|
-
# Classify call-graph endpoints that are not declared in the symbol table
|
|
453
|
-
# (imported library / builtin members) once, so the JSON and Neo4j backends
|
|
454
|
-
# share one authoritative external-symbol set.
|
|
455
|
-
external_symbols = self._compute_external_symbols(symbol_table, call_graph)
|
|
456
|
-
|
|
457
442
|
# Recreate pyapplication
|
|
458
443
|
app = (
|
|
459
444
|
PyApplication.builder()
|
|
460
445
|
.symbol_table(symbol_table)
|
|
461
446
|
.call_graph(call_graph)
|
|
462
|
-
.external_symbols(external_symbols)
|
|
463
447
|
.build()
|
|
464
448
|
)
|
|
465
449
|
|
|
@@ -470,52 +454,139 @@ class Codeanalyzer:
|
|
|
470
454
|
|
|
471
455
|
# Single choke point for provenance: every produced app (fresh symbol
|
|
472
456
|
# table or reused-from-cache) passes through here before being cached
|
|
473
|
-
# or returned, so
|
|
474
|
-
# even when the symbol table itself came from the on-disk cache.
|
|
475
|
-
|
|
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).
|
|
476
460
|
app.repository = repository_info(self.project_dir)
|
|
477
461
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
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
|
|
482
527
|
|
|
483
528
|
@staticmethod
|
|
484
|
-
def _cache_analyzer_matches(
|
|
529
|
+
def _cache_analyzer_matches(cached: Optional[Analysis], current_version: str) -> bool:
|
|
485
530
|
"""A cache written by another analyzer version (or before versions were
|
|
486
531
|
recorded) may lack fields the current models populate — pydantic fills
|
|
487
|
-
silent defaults, which would masquerade as analyzed absence.
|
|
532
|
+
silent defaults, which would masquerade as analyzed absence. The
|
|
533
|
+
analyzer identity lives on the envelope (keystone home)."""
|
|
488
534
|
return (
|
|
489
|
-
|
|
490
|
-
and
|
|
491
|
-
and
|
|
535
|
+
cached is not None
|
|
536
|
+
and cached.analyzer is not None
|
|
537
|
+
and cached.analyzer.version == current_version
|
|
492
538
|
)
|
|
493
539
|
|
|
494
|
-
def _load_pyapplication_from_cache(self, cache_file: Path) ->
|
|
495
|
-
"""Load cached
|
|
496
|
-
|
|
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
|
+
|
|
497
549
|
Args:
|
|
498
550
|
cache_file: Path to the cache file
|
|
499
|
-
|
|
551
|
+
|
|
500
552
|
Returns:
|
|
501
|
-
|
|
553
|
+
Optional[Analysis]: The cached envelope, or ``None`` if the cache is
|
|
554
|
+
stale/incompatible and should be rebuilt.
|
|
502
555
|
"""
|
|
503
556
|
with cache_file.open('r') as f:
|
|
504
557
|
data = f.read()
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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
|
+
|
|
510
581
|
Args:
|
|
511
|
-
|
|
582
|
+
analysis: The Analysis envelope to cache
|
|
512
583
|
cache_file: Path to save the cache file
|
|
513
584
|
"""
|
|
514
585
|
# Ensure cache directory exists
|
|
515
586
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
-
|
|
587
|
+
|
|
517
588
|
with cache_file.open('w') as f:
|
|
518
|
-
f.write(model_dump_json(
|
|
589
|
+
f.write(model_dump_json(analysis, indent=2))
|
|
519
590
|
|
|
520
591
|
logger.info(f"Analysis cached to {cache_file}")
|
|
521
592
|
|
|
@@ -583,9 +654,9 @@ class Codeanalyzer:
|
|
|
583
654
|
if self.file_name is not None:
|
|
584
655
|
single_file = self.project_dir / self.file_name
|
|
585
656
|
logger.info(f"Analyzing single file: {single_file}")
|
|
586
|
-
|
|
657
|
+
|
|
587
658
|
# Check if file is in cache and unchanged
|
|
588
|
-
file_key = str(single_file)
|
|
659
|
+
file_key = str(single_file.relative_to(self.project_dir))
|
|
589
660
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
590
661
|
# Compute file checksum to see if it changed
|
|
591
662
|
if self._file_unchanged(single_file, cached_symbol_table[file_key]):
|
|
@@ -635,7 +706,7 @@ class Codeanalyzer:
|
|
|
635
706
|
# Separate files into cached and new/changed
|
|
636
707
|
files_to_process = []
|
|
637
708
|
for py_file in py_files:
|
|
638
|
-
file_key = str(py_file)
|
|
709
|
+
file_key = str(py_file.relative_to(self.project_dir))
|
|
639
710
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
640
711
|
if self._file_unchanged(py_file, cached_symbol_table[file_key]):
|
|
641
712
|
# Use cached version
|
|
@@ -663,7 +734,7 @@ class Codeanalyzer:
|
|
|
663
734
|
|
|
664
735
|
with ProgressBar(len(py_files), "Building symbol table") as progress:
|
|
665
736
|
for py_file in py_files:
|
|
666
|
-
file_key = str(py_file)
|
|
737
|
+
file_key = str(py_file.relative_to(self.project_dir))
|
|
667
738
|
|
|
668
739
|
# Check if file is cached and unchanged
|
|
669
740
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
@@ -699,7 +770,7 @@ class Codeanalyzer:
|
|
|
699
770
|
"""Build PyCG-resolved call edges.
|
|
700
771
|
|
|
701
772
|
Runs PyCG's iterative name-pointer analysis over the whole project
|
|
702
|
-
and returns edges with ``
|
|
773
|
+
and returns edges with ``prov=["pycg"]``. Falls back to an
|
|
703
774
|
empty list and logs a warning on any failure so the caller can
|
|
704
775
|
continue with Jedi-only edges.
|
|
705
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
|