codeanalyzer-python 0.3.1__py3-none-any.whl → 1.0.1__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 +86 -4
- codeanalyzer/core.py +175 -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 +77 -16
- codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
- codeanalyzer/syntactic_analysis/symbol_table_builder.py +65 -27
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/METADATA +248 -61
- codeanalyzer_python-1.0.1.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.1.dist-info}/WHEEL +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/entry_points.txt +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/licenses/LICENSE +0 -0
- {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/licenses/NOTICE +0 -0
codeanalyzer/__main__.py
CHANGED
|
@@ -1,9 +1,40 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
1
3
|
from importlib.metadata import version as _pkg_version, PackageNotFoundError
|
|
2
4
|
from pathlib import Path
|
|
3
5
|
from typing import Optional, Annotated
|
|
4
6
|
|
|
5
7
|
import typer
|
|
6
8
|
|
|
9
|
+
|
|
10
|
+
def _pin_hash_seed() -> None:
|
|
11
|
+
"""Re-exec once with ``PYTHONHASHSEED=0`` unless the caller pinned one.
|
|
12
|
+
|
|
13
|
+
PyCG's capped fixpoint (``--pycg-max-iter``) iterates hash-ordered sets
|
|
14
|
+
keyed on module/access-path strings, so an unpinned per-interpreter hash
|
|
15
|
+
seed makes the emitted L2+ call graph vary run to run (issue #99). The
|
|
16
|
+
seed cannot be set after interpreter start, hence the exec. Export
|
|
17
|
+
PYTHONHASHSEED (any value) to opt out or pin a different seed.
|
|
18
|
+
|
|
19
|
+
Only fires when this process really is the CLI (canpy / python -m
|
|
20
|
+
codeanalyzer): in-process invocations — e.g. Typer's CliRunner in the
|
|
21
|
+
test suite, or a host app calling the callback — must never have their
|
|
22
|
+
own process exec'd out from under them."""
|
|
23
|
+
if os.environ.get("PYTHONHASHSEED") is not None:
|
|
24
|
+
return
|
|
25
|
+
argv0 = os.path.basename(sys.argv[0]) if sys.argv else ""
|
|
26
|
+
is_cli = argv0 in ("canpy", "codeanalyzer") or sys.argv[0].endswith(
|
|
27
|
+
os.path.join("codeanalyzer", "__main__.py")
|
|
28
|
+
)
|
|
29
|
+
if not is_cli:
|
|
30
|
+
return
|
|
31
|
+
env = dict(os.environ, PYTHONHASHSEED="0")
|
|
32
|
+
os.execvpe(
|
|
33
|
+
sys.executable,
|
|
34
|
+
[sys.executable, "-m", "codeanalyzer", *sys.argv[1:]],
|
|
35
|
+
env,
|
|
36
|
+
)
|
|
37
|
+
|
|
7
38
|
from codeanalyzer.core import Codeanalyzer
|
|
8
39
|
from codeanalyzer.utils import _set_log_level, logger
|
|
9
40
|
from codeanalyzer.config import OutputFormat
|
|
@@ -114,11 +145,32 @@ def main(
|
|
|
114
145
|
typer.Option(
|
|
115
146
|
"-a",
|
|
116
147
|
"--analysis-level",
|
|
117
|
-
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call
|
|
148
|
+
help="Analysis depth: 1=symbol table+Jedi call graph, 2=+PyCG call "
|
|
149
|
+
"graph, 3=+native intraprocedural dataflow (CFG/PDG), "
|
|
150
|
+
"4=+interprocedural SDG (param/summary edges, alias-aware DDG).",
|
|
118
151
|
min=1,
|
|
119
|
-
max=
|
|
152
|
+
max=4,
|
|
120
153
|
),
|
|
121
154
|
] = 1,
|
|
155
|
+
graphs: Annotated[
|
|
156
|
+
str,
|
|
157
|
+
typer.Option(
|
|
158
|
+
"--graphs",
|
|
159
|
+
help="Level 3+ only: comma-separated program-graph sections to emit "
|
|
160
|
+
"(cfg, dfg, pdg, sdg). Default: cfg,dfg,pdg. `dfg` emits the PDG's data "
|
|
161
|
+
"edges only; `sdg` requires -a 4.",
|
|
162
|
+
),
|
|
163
|
+
] = "cfg,dfg,pdg",
|
|
164
|
+
graph_field_depth: Annotated[
|
|
165
|
+
int,
|
|
166
|
+
typer.Option(
|
|
167
|
+
"--graph-field-depth",
|
|
168
|
+
help="Level 3 only: k-limit on access-path depth (x.f.g.h with "
|
|
169
|
+
"k=3 becomes x.f.g.*). Mandatory bound — it is what guarantees "
|
|
170
|
+
"the interprocedural fixpoint terminates.",
|
|
171
|
+
min=1,
|
|
172
|
+
),
|
|
173
|
+
] = 3,
|
|
122
174
|
using_ray: Annotated[
|
|
123
175
|
bool,
|
|
124
176
|
typer.Option("--ray/--no-ray", help="Enable Ray for distributed analysis."),
|
|
@@ -243,6 +295,34 @@ def main(
|
|
|
243
295
|
),
|
|
244
296
|
] = 50,
|
|
245
297
|
):
|
|
298
|
+
# Determinism: pin the interpreter hash seed before any analysis (no-op
|
|
299
|
+
# when PYTHONHASHSEED is already set; --version exits before this).
|
|
300
|
+
_pin_hash_seed()
|
|
301
|
+
|
|
302
|
+
# Flag validation (strict: unrecognized values error out, never fall back).
|
|
303
|
+
selected_graphs = [g.strip() for g in graphs.split(",") if g.strip()]
|
|
304
|
+
from codeanalyzer.dataflow.builder import VALID_GRAPHS
|
|
305
|
+
|
|
306
|
+
unknown_graphs = [g for g in selected_graphs if g not in VALID_GRAPHS]
|
|
307
|
+
if unknown_graphs:
|
|
308
|
+
logger.error(
|
|
309
|
+
f"Unrecognized --graphs value(s): {', '.join(unknown_graphs)} "
|
|
310
|
+
f"(valid: {', '.join(VALID_GRAPHS)})."
|
|
311
|
+
)
|
|
312
|
+
raise typer.Exit(code=2)
|
|
313
|
+
if not selected_graphs:
|
|
314
|
+
logger.error("--graphs requires at least one of: " + ", ".join(VALID_GRAPHS))
|
|
315
|
+
raise typer.Exit(code=2)
|
|
316
|
+
if "sdg" in selected_graphs and analysis_level < 4:
|
|
317
|
+
logger.error("--graphs sdg requires -a 4 (interprocedural SDG).")
|
|
318
|
+
raise typer.Exit(code=2)
|
|
319
|
+
if analysis_level < 3 and graphs != "cfg,dfg,pdg":
|
|
320
|
+
logger.error("--graphs is a level-3 option; pass -a 3 to emit program graphs.")
|
|
321
|
+
raise typer.Exit(code=2)
|
|
322
|
+
if analysis_level < 3 and graph_field_depth != 3:
|
|
323
|
+
logger.error("--graph-field-depth is a level-3 option; pass -a 3.")
|
|
324
|
+
raise typer.Exit(code=2)
|
|
325
|
+
|
|
246
326
|
options = AnalysisOptions(
|
|
247
327
|
input=input,
|
|
248
328
|
output=output,
|
|
@@ -254,6 +334,8 @@ def main(
|
|
|
254
334
|
neo4j_password=neo4j_password,
|
|
255
335
|
neo4j_database=neo4j_database,
|
|
256
336
|
analysis_level=analysis_level,
|
|
337
|
+
graphs=",".join(selected_graphs),
|
|
338
|
+
graph_field_depth=graph_field_depth,
|
|
257
339
|
using_ray=using_ray,
|
|
258
340
|
rebuild_analysis=rebuild_analysis,
|
|
259
341
|
skip_tests=skip_tests,
|
|
@@ -310,7 +392,7 @@ def main(
|
|
|
310
392
|
|
|
311
393
|
emit_neo4j(artifacts, options)
|
|
312
394
|
elif options.output is None:
|
|
313
|
-
print(model_dump_json(artifacts,
|
|
395
|
+
print(model_dump_json(artifacts, exclude_none=True))
|
|
314
396
|
else:
|
|
315
397
|
options.output.mkdir(parents=True, exist_ok=True)
|
|
316
398
|
_write_output(artifacts, options.output, options.format)
|
|
@@ -321,7 +403,7 @@ def _write_output(artifacts, output_dir: Path, format: OutputFormat):
|
|
|
321
403
|
if format == OutputFormat.JSON:
|
|
322
404
|
output_file = output_dir / "analysis.json"
|
|
323
405
|
# Use Pydantic's model_dump_json() for compact output
|
|
324
|
-
json_str = model_dump_json(artifacts, indent=None)
|
|
406
|
+
json_str = model_dump_json(artifacts, indent=None, exclude_none=True)
|
|
325
407
|
with output_file.open("w") as f:
|
|
326
408
|
f.write(json_str)
|
|
327
409
|
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,
|
|
@@ -32,6 +37,22 @@ from codeanalyzer.utils import ProgressBar
|
|
|
32
37
|
from codeanalyzer.options import AnalysisOptions
|
|
33
38
|
from codeanalyzer.provenance import analyzer_info, repository_info
|
|
34
39
|
|
|
40
|
+
def _ensure_ray() -> None:
|
|
41
|
+
"""Initialize Ray with the driver's pinned hash seed in the workers.
|
|
42
|
+
|
|
43
|
+
An implicit auto-init would not carry PYTHONHASHSEED into worker
|
|
44
|
+
interpreters, so PyCG shards (and Jedi inference) run there with random
|
|
45
|
+
set-iteration order and the emitted edges vary run to run (issue #99)."""
|
|
46
|
+
if not ray.is_initialized():
|
|
47
|
+
ray.init(
|
|
48
|
+
runtime_env={
|
|
49
|
+
"env_vars": {
|
|
50
|
+
"PYTHONHASHSEED": os.environ.get("PYTHONHASHSEED", "0")
|
|
51
|
+
}
|
|
52
|
+
},
|
|
53
|
+
)
|
|
54
|
+
|
|
55
|
+
|
|
35
56
|
@ray.remote
|
|
36
57
|
def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, str], virtualenv: Union[Path, str, None]) -> Dict[str, PyModule]:
|
|
37
58
|
"""Processes files in the project directory using Ray for distributed processing.
|
|
@@ -49,7 +70,7 @@ def _process_file_with_ray(py_file: Union[Path, str], project_dir: Union[Path, s
|
|
|
49
70
|
try:
|
|
50
71
|
py_file = Path(py_file)
|
|
51
72
|
symbol_table_builder = SymbolTableBuilder(project_dir, virtualenv)
|
|
52
|
-
module_map[str(py_file)] = symbol_table_builder.build_pymodule_from_file(py_file)
|
|
73
|
+
module_map[str(py_file.relative_to(Path(project_dir)))] = symbol_table_builder.build_pymodule_from_file(py_file)
|
|
53
74
|
except Exception as e:
|
|
54
75
|
console.log(f"❌ Failed to process {py_file}: {e}")
|
|
55
76
|
raise SymbolTableBuilderRayError(f"Ray processing error for {py_file}: {e}")
|
|
@@ -371,67 +392,52 @@ class Codeanalyzer:
|
|
|
371
392
|
shutil.rmtree(self.cache_dir)
|
|
372
393
|
|
|
373
394
|
@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
|
-
|
|
395
|
+
def _home_external_symbols(app, app_id, sig_to_id):
|
|
396
|
+
"""Home every call-graph endpoint that is not a declared class/callable
|
|
397
|
+
onto a ``can://…/@external/<module>/<name>`` id (the keystone edge-endpoint
|
|
398
|
+
id home). Registers each homed id in ``sig_to_id`` so callee backfill and
|
|
399
|
+
call-graph re-identity map the dotted signature to it, and returns the
|
|
400
|
+
id-keyed external-symbol map. ``name``/``module`` are derived from the
|
|
401
|
+
signature (best effort: split on the last dot)."""
|
|
401
402
|
externals: Dict[str, PyExternalSymbol] = {}
|
|
402
|
-
for edge in call_graph:
|
|
403
|
-
for sig in (edge.
|
|
404
|
-
if sig in
|
|
403
|
+
for edge in app.call_graph:
|
|
404
|
+
for sig in (edge.src, edge.dst):
|
|
405
|
+
if sig in sig_to_id:
|
|
405
406
|
continue
|
|
406
|
-
module, name = sig.rsplit(".", 1) if "." in sig else (
|
|
407
|
-
|
|
407
|
+
module, name = sig.rsplit(".", 1) if "." in sig else (None, sig)
|
|
408
|
+
ext_id = f"{app_id}/@external/{module}/{name}" if module else \
|
|
409
|
+
f"{app_id}/@external/{name}"
|
|
410
|
+
sig_to_id[sig] = ext_id
|
|
411
|
+
externals[ext_id] = PyExternalSymbol(
|
|
412
|
+
id=ext_id, name=name, module=module
|
|
413
|
+
)
|
|
408
414
|
return externals
|
|
409
415
|
|
|
410
|
-
def analyze(self) ->
|
|
411
|
-
"""Analyze the project and return
|
|
412
|
-
|
|
416
|
+
def analyze(self) -> Analysis:
|
|
417
|
+
"""Analyze the project and return the v2 ``Analysis`` envelope.
|
|
418
|
+
|
|
413
419
|
Uses caching to avoid re-analyzing unchanged files.
|
|
414
420
|
"""
|
|
415
421
|
cache_file = self.cache_dir / "analysis_cache.json"
|
|
416
422
|
|
|
417
423
|
# Try to load existing cached analysis
|
|
418
|
-
|
|
424
|
+
cached = None
|
|
419
425
|
if not self.rebuild_analysis and cache_file.exists():
|
|
420
426
|
try:
|
|
421
|
-
|
|
422
|
-
|
|
427
|
+
cached = self._load_pyapplication_from_cache(cache_file)
|
|
428
|
+
if cached is not None:
|
|
429
|
+
logger.info("Loaded cached analysis")
|
|
423
430
|
except Exception as e:
|
|
424
431
|
logger.warning(f"Failed to load cache: {e}. Rebuilding analysis.")
|
|
425
|
-
|
|
432
|
+
cached = None
|
|
426
433
|
|
|
427
|
-
if
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
cached_pyapplication = None
|
|
434
|
+
if not self._cache_analyzer_matches(cached, analyzer_info(self.analysis_level).version):
|
|
435
|
+
if cached is not None:
|
|
436
|
+
logger.info("Analysis cache written by a different analyzer version; rebuilding.")
|
|
437
|
+
cached = None
|
|
432
438
|
|
|
433
439
|
# Build symbol table from cached application if available (if no available, the build a new one)
|
|
434
|
-
symbol_table = self._build_symbol_table(
|
|
440
|
+
symbol_table = self._build_symbol_table(cached.application.symbol_table if cached else {})
|
|
435
441
|
|
|
436
442
|
resolve_unresolved_constructors(symbol_table)
|
|
437
443
|
|
|
@@ -448,18 +454,17 @@ class Codeanalyzer:
|
|
|
448
454
|
call_graph = merge_edges(call_graph, pycg_edges)
|
|
449
455
|
|
|
450
456
|
call_graph = filter_external_edges(call_graph, symbol_table)
|
|
451
|
-
|
|
452
|
-
#
|
|
453
|
-
# (
|
|
454
|
-
#
|
|
455
|
-
|
|
457
|
+
# Canonical edge order: backend iteration order (PyCG dicts, Counter
|
|
458
|
+
# insertion) is not a contract — sort so identical edge SETS always
|
|
459
|
+
# serialize identically (issue #99 determinism gate), and so the
|
|
460
|
+
# external-symbol homing below assigns ids in a stable order.
|
|
461
|
+
call_graph.sort(key=lambda e: (e.src, e.dst))
|
|
456
462
|
|
|
457
463
|
# Recreate pyapplication
|
|
458
464
|
app = (
|
|
459
465
|
PyApplication.builder()
|
|
460
466
|
.symbol_table(symbol_table)
|
|
461
467
|
.call_graph(call_graph)
|
|
462
|
-
.external_symbols(external_symbols)
|
|
463
468
|
.build()
|
|
464
469
|
)
|
|
465
470
|
|
|
@@ -470,52 +475,139 @@ class Codeanalyzer:
|
|
|
470
475
|
|
|
471
476
|
# Single choke point for provenance: every produced app (fresh symbol
|
|
472
477
|
# 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
|
-
|
|
478
|
+
# or returned, so repository provenance always reflects *this* checkout
|
|
479
|
+
# even when the symbol table itself came from the on-disk cache. The
|
|
480
|
+
# analyzer identity rides the envelope below (keystone home).
|
|
476
481
|
app.repository = repository_info(self.project_dir)
|
|
477
482
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
483
|
+
app_name = self.options.app_name or self.project_dir.name
|
|
484
|
+
sig_to_id = assign_ids(app, app_name)
|
|
485
|
+
# Home call-graph endpoints that are not declared in the symbol table
|
|
486
|
+
# (imported library / builtin members) onto @external ids once, so the
|
|
487
|
+
# JSON and Neo4j backends share one authoritative external-symbol set
|
|
488
|
+
# and every edge endpoint joins the id space (no dangling endpoints).
|
|
489
|
+
app.external_symbols = self._home_external_symbols(app, app.id, sig_to_id)
|
|
490
|
+
populate_l1_body(app)
|
|
491
|
+
if self.analysis_level >= 2:
|
|
492
|
+
backfill_callees(app, sig_to_id)
|
|
493
|
+
reidentify_call_graph(app, sig_to_id)
|
|
494
|
+
|
|
495
|
+
# L3: intraprocedural dataflow (CFG/CDG/DDG) emitted onto the v2 tree.
|
|
496
|
+
if self.analysis_level >= 3:
|
|
497
|
+
from codeanalyzer.dataflow.builder import (
|
|
498
|
+
build_function_pdgs,
|
|
499
|
+
emit_l3_body,
|
|
500
|
+
)
|
|
501
|
+
from codeanalyzer.dataflow.syntactic import SyntacticOracle
|
|
502
|
+
|
|
503
|
+
infos, _func_asts = build_function_pdgs(
|
|
504
|
+
app,
|
|
505
|
+
k=self.options.graph_field_depth,
|
|
506
|
+
oracle_factory=lambda c, fast: SyntacticOracle(),
|
|
507
|
+
)
|
|
508
|
+
emit_l3_body(app, infos, sig_to_id, set(self.options.graphs.split(",")))
|
|
509
|
+
|
|
510
|
+
# L4: interprocedural dataflow (param vertices + summary + param_in/out)
|
|
511
|
+
# layered on top of the L3 syntactic overlay (L3 ⊆ L4). Scalpel is the
|
|
512
|
+
# primary may-alias oracle, with the type-based total fallback.
|
|
513
|
+
if self.analysis_level >= 4:
|
|
514
|
+
from codeanalyzer.dataflow.builder import (
|
|
515
|
+
_base_types,
|
|
516
|
+
build_program_graphs,
|
|
517
|
+
emit_ddg_pointsto_delta,
|
|
518
|
+
emit_l4,
|
|
519
|
+
)
|
|
520
|
+
from codeanalyzer.dataflow.scalpel_oracle import make_alias_oracle
|
|
521
|
+
|
|
522
|
+
ir = build_program_graphs(
|
|
523
|
+
app,
|
|
524
|
+
k=self.options.graph_field_depth,
|
|
525
|
+
oracle_factory=lambda c, fast: make_alias_oracle(
|
|
526
|
+
c, fast, _base_types(c)
|
|
527
|
+
),
|
|
528
|
+
)
|
|
529
|
+
emit_l4(app, ir, sig_to_id)
|
|
530
|
+
# Semantic ddg delta: the alias-derived def-use edges the real
|
|
531
|
+
# oracle adds beyond the L3 syntactic set, tagged prov=["points-to"].
|
|
532
|
+
# ``infos`` are the syntactic (L3) PDGs from the >=3 block above.
|
|
533
|
+
emit_ddg_pointsto_delta(app, infos, ir, sig_to_id)
|
|
534
|
+
|
|
535
|
+
# Build the v2 envelope, then persist it (the cache stores the full
|
|
536
|
+
# ``Analysis`` envelope so a reused cache round-trips schema_version).
|
|
537
|
+
# k_limit is an L3+ envelope key: below the dataflow levels it stays
|
|
538
|
+
# None and exclude_none drops it from the payload.
|
|
539
|
+
analysis = Analysis(
|
|
540
|
+
max_level=self.analysis_level,
|
|
541
|
+
k_limit=self.options.graph_field_depth if self.analysis_level >= 3 else None,
|
|
542
|
+
analyzer=analyzer_info(self.analysis_level),
|
|
543
|
+
application=app,
|
|
544
|
+
)
|
|
545
|
+
self._save_analysis_cache(analysis, cache_file)
|
|
546
|
+
|
|
547
|
+
return analysis
|
|
482
548
|
|
|
483
549
|
@staticmethod
|
|
484
|
-
def _cache_analyzer_matches(
|
|
550
|
+
def _cache_analyzer_matches(cached: Optional[Analysis], current_version: str) -> bool:
|
|
485
551
|
"""A cache written by another analyzer version (or before versions were
|
|
486
552
|
recorded) may lack fields the current models populate — pydantic fills
|
|
487
|
-
silent defaults, which would masquerade as analyzed absence.
|
|
553
|
+
silent defaults, which would masquerade as analyzed absence. The
|
|
554
|
+
analyzer identity lives on the envelope (keystone home)."""
|
|
488
555
|
return (
|
|
489
|
-
|
|
490
|
-
and
|
|
491
|
-
and
|
|
556
|
+
cached is not None
|
|
557
|
+
and cached.analyzer is not None
|
|
558
|
+
and cached.analyzer.version == current_version
|
|
492
559
|
)
|
|
493
560
|
|
|
494
|
-
def _load_pyapplication_from_cache(self, cache_file: Path) ->
|
|
495
|
-
"""Load cached
|
|
496
|
-
|
|
561
|
+
def _load_pyapplication_from_cache(self, cache_file: Path) -> Optional[Analysis]:
|
|
562
|
+
"""Load a cached v2 ``Analysis`` envelope from file.
|
|
563
|
+
|
|
564
|
+
A cache written by an older (v1) analyzer stored a bare
|
|
565
|
+
``PyApplication`` with no ``schema_version``; such a payload no longer
|
|
566
|
+
validates as an ``Analysis`` (or carries the wrong ``schema_version``).
|
|
567
|
+
In that case we log and return ``None`` so the caller treats it as a
|
|
568
|
+
cache miss and rebuilds from scratch — rather than crashing.
|
|
569
|
+
|
|
497
570
|
Args:
|
|
498
571
|
cache_file: Path to the cache file
|
|
499
|
-
|
|
572
|
+
|
|
500
573
|
Returns:
|
|
501
|
-
|
|
574
|
+
Optional[Analysis]: The cached envelope, or ``None`` if the cache is
|
|
575
|
+
stale/incompatible and should be rebuilt.
|
|
502
576
|
"""
|
|
503
577
|
with cache_file.open('r') as f:
|
|
504
578
|
data = f.read()
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
579
|
+
try:
|
|
580
|
+
cached = model_validate_json(Analysis, data)
|
|
581
|
+
except Exception:
|
|
582
|
+
logger.info("stale/incompatible analysis cache — rebuilding")
|
|
583
|
+
return None
|
|
584
|
+
if getattr(cached, "schema_version", None) != "2.0.0":
|
|
585
|
+
logger.info("stale/incompatible analysis cache (schema_version) — rebuilding")
|
|
586
|
+
return None
|
|
587
|
+
# The cache keys only on file hash/mtime/size, not on level, so a cache
|
|
588
|
+
# built at a different analysis_level would leak higher-level body/edge
|
|
589
|
+
# content (or omit content when the cached level is lower). Reject the
|
|
590
|
+
# mismatch and force a full rebuild at the requested level.
|
|
591
|
+
if cached.max_level != self.analysis_level:
|
|
592
|
+
logger.info(
|
|
593
|
+
f"cache built at level {cached.max_level} != requested "
|
|
594
|
+
f"{self.analysis_level} — rebuilding"
|
|
595
|
+
)
|
|
596
|
+
return None
|
|
597
|
+
return cached
|
|
598
|
+
|
|
599
|
+
def _save_analysis_cache(self, analysis: Analysis, cache_file: Path) -> None:
|
|
600
|
+
"""Save the v2 ``Analysis`` envelope to the cache file.
|
|
601
|
+
|
|
510
602
|
Args:
|
|
511
|
-
|
|
603
|
+
analysis: The Analysis envelope to cache
|
|
512
604
|
cache_file: Path to save the cache file
|
|
513
605
|
"""
|
|
514
606
|
# Ensure cache directory exists
|
|
515
607
|
cache_file.parent.mkdir(parents=True, exist_ok=True)
|
|
516
|
-
|
|
608
|
+
|
|
517
609
|
with cache_file.open('w') as f:
|
|
518
|
-
f.write(model_dump_json(
|
|
610
|
+
f.write(model_dump_json(analysis, indent=2))
|
|
519
611
|
|
|
520
612
|
logger.info(f"Analysis cached to {cache_file}")
|
|
521
613
|
|
|
@@ -583,9 +675,9 @@ class Codeanalyzer:
|
|
|
583
675
|
if self.file_name is not None:
|
|
584
676
|
single_file = self.project_dir / self.file_name
|
|
585
677
|
logger.info(f"Analyzing single file: {single_file}")
|
|
586
|
-
|
|
678
|
+
|
|
587
679
|
# Check if file is in cache and unchanged
|
|
588
|
-
file_key = str(single_file)
|
|
680
|
+
file_key = str(single_file.relative_to(self.project_dir))
|
|
589
681
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
590
682
|
# Compute file checksum to see if it changed
|
|
591
683
|
if self._file_unchanged(single_file, cached_symbol_table[file_key]):
|
|
@@ -635,7 +727,7 @@ class Codeanalyzer:
|
|
|
635
727
|
# Separate files into cached and new/changed
|
|
636
728
|
files_to_process = []
|
|
637
729
|
for py_file in py_files:
|
|
638
|
-
file_key = str(py_file)
|
|
730
|
+
file_key = str(py_file.relative_to(self.project_dir))
|
|
639
731
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
640
732
|
if self._file_unchanged(py_file, cached_symbol_table[file_key]):
|
|
641
733
|
# Use cached version
|
|
@@ -645,6 +737,7 @@ class Codeanalyzer:
|
|
|
645
737
|
|
|
646
738
|
# Process only new/changed files with Ray
|
|
647
739
|
if files_to_process:
|
|
740
|
+
_ensure_ray()
|
|
648
741
|
futures = [_process_file_with_ray.remote(py_file, self.project_dir, str(self.virtualenv) if self.virtualenv else None) for py_file in files_to_process]
|
|
649
742
|
|
|
650
743
|
with ProgressBar(len(futures), "Building symbol table (parallel)") as progress:
|
|
@@ -663,7 +756,7 @@ class Codeanalyzer:
|
|
|
663
756
|
|
|
664
757
|
with ProgressBar(len(py_files), "Building symbol table") as progress:
|
|
665
758
|
for py_file in py_files:
|
|
666
|
-
file_key = str(py_file)
|
|
759
|
+
file_key = str(py_file.relative_to(self.project_dir))
|
|
667
760
|
|
|
668
761
|
# Check if file is cached and unchanged
|
|
669
762
|
if file_key in cached_symbol_table and not self.rebuild_analysis:
|
|
@@ -699,7 +792,7 @@ class Codeanalyzer:
|
|
|
699
792
|
"""Build PyCG-resolved call edges.
|
|
700
793
|
|
|
701
794
|
Runs PyCG's iterative name-pointer analysis over the whole project
|
|
702
|
-
and returns edges with ``
|
|
795
|
+
and returns edges with ``prov=["pycg"]``. Falls back to an
|
|
703
796
|
empty list and logs a warning on any failure so the caller can
|
|
704
797
|
continue with Jedi-only edges.
|
|
705
798
|
|
|
@@ -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
|