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.
Files changed (42) hide show
  1. codeanalyzer/__main__.py +86 -4
  2. codeanalyzer/core.py +175 -82
  3. codeanalyzer/dataflow/__init__.py +35 -0
  4. codeanalyzer/dataflow/access_paths.py +563 -0
  5. codeanalyzer/dataflow/alias.py +93 -0
  6. codeanalyzer/dataflow/builder.py +688 -0
  7. codeanalyzer/dataflow/cfg.py +605 -0
  8. codeanalyzer/dataflow/defuse.py +113 -0
  9. codeanalyzer/dataflow/dominance.py +140 -0
  10. codeanalyzer/dataflow/identity.py +91 -0
  11. codeanalyzer/dataflow/pdg.py +100 -0
  12. codeanalyzer/dataflow/scalpel_oracle.py +269 -0
  13. codeanalyzer/dataflow/scc.py +91 -0
  14. codeanalyzer/dataflow/sdg.py +424 -0
  15. codeanalyzer/dataflow/slicing.py +93 -0
  16. codeanalyzer/dataflow/summaries.py +217 -0
  17. codeanalyzer/dataflow/syntactic.py +26 -0
  18. codeanalyzer/neo4j/bolt.py +19 -4
  19. codeanalyzer/neo4j/cypher.py +9 -3
  20. codeanalyzer/neo4j/emit.py +8 -3
  21. codeanalyzer/neo4j/project.py +241 -60
  22. codeanalyzer/neo4j/rows.py +18 -15
  23. codeanalyzer/neo4j/schema.py +43 -7
  24. codeanalyzer/options/options.py +4 -0
  25. codeanalyzer/schema/__init__.py +19 -0
  26. codeanalyzer/schema/assign_ids.py +37 -0
  27. codeanalyzer/schema/call_graph_ids.py +12 -0
  28. codeanalyzer/schema/ids.py +23 -0
  29. codeanalyzer/schema/l1_body.py +29 -0
  30. codeanalyzer/schema/l2_callees.py +36 -0
  31. codeanalyzer/schema/py_schema.py +141 -30
  32. codeanalyzer/semantic_analysis/call_graph.py +24 -27
  33. codeanalyzer/semantic_analysis/pycg/pycg_analysis.py +77 -16
  34. codeanalyzer/semantic_analysis/pycg/shard_planner.py +7 -7
  35. codeanalyzer/syntactic_analysis/symbol_table_builder.py +65 -27
  36. {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/METADATA +248 -61
  37. codeanalyzer_python-1.0.1.dist-info/RECORD +59 -0
  38. codeanalyzer_python-0.3.1.dist-info/RECORD +0 -39
  39. {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/WHEEL +0 -0
  40. {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/entry_points.txt +0 -0
  41. {codeanalyzer_python-0.3.1.dist-info → codeanalyzer_python-1.0.1.dist-info}/licenses/LICENSE +0 -0
  42. {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 graph.",
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=2,
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, separators=(",", ":")))
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 _compute_external_symbols(symbol_table, call_graph):
375
- """Build the external-symbol map: every call-graph endpoint whose signature
376
- is not a declared class/callable in the symbol table is an external (an
377
- imported library or builtin member). ``name``/``module`` are derived from
378
- the signature (best effort: split on the last dot)."""
379
- declared = set()
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.source, edge.target):
404
- if sig in declared or sig in externals:
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 (sig, sig)
407
- externals[sig] = PyExternalSymbol(name=name, module=module)
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) -> PyApplication:
411
- """Analyze the project and return a PyApplication with symbol table.
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
- cached_pyapplication = None
424
+ cached = None
419
425
  if not self.rebuild_analysis and cache_file.exists():
420
426
  try:
421
- cached_pyapplication = self._load_pyapplication_from_cache(cache_file)
422
- logger.info("Loaded cached analysis")
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
- cached_pyapplication = None
432
+ cached = None
426
433
 
427
- if cached_pyapplication is not None and not self._cache_analyzer_matches(
428
- cached_pyapplication, analyzer_info(self.analysis_level).version
429
- ):
430
- logger.info("Analysis cache written by a different analyzer version; rebuilding.")
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(cached_pyapplication.symbol_table if cached_pyapplication else {})
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
- # 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)
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 analyzer/repository always reflect *this* run/checkout
474
- # even when the symbol table itself came from the on-disk cache.
475
- app.analyzer = analyzer_info(self.analysis_level)
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
- # Save to cache
479
- self._save_analysis_cache(app, cache_file)
480
-
481
- return app
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(cached_app: Optional[PyApplication], current_version: str) -> bool:
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
- cached_app is not None
490
- and cached_app.analyzer is not None
491
- and cached_app.analyzer.version == current_version
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) -> PyApplication:
495
- """Load cached analysis from file.
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
- PyApplication: The cached application data
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
- return model_validate_json(PyApplication, data)
506
-
507
- def _save_analysis_cache(self, app: PyApplication, cache_file: Path) -> None:
508
- """Save analysis to cache file.
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
- app: The PyApplication to cache
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(app, indent=2))
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 ``provenance=["pycg"]``. Falls back to an
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