Skip to content

Commit 715bf66

Browse files
committed
feat(entrypoints): wrapped post-pass skeleton wired into the analyzer (#27)
1 parent 011b6ad commit 715bf66

5 files changed

Lines changed: 107 additions & 1 deletion

File tree

codeanalyzer/core.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,12 @@ def analyze(self) -> Analysis:
636636
backfill_callees(app, sig_to_id)
637637
reidentify_call_graph(app, sig_to_id)
638638

639+
# Entrypoints: a post-pass over the built L1 tree (#27). Runs at every
640+
# level -- entrypoints are L1 data and must not vary with -a.
641+
from codeanalyzer.entrypoints import detect_entrypoints
642+
643+
detect_entrypoints(app, self.project_dir, self.options.entrypoint_rules)
644+
639645
# L3: intraprocedural dataflow (CFG/CDG/DDG) emitted onto the v2 tree.
640646
if self.analysis_level >= 3:
641647
from codeanalyzer.dataflow.builder import (
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from codeanalyzer.entrypoints.pipeline import detect_entrypoints
2+
3+
__all__ = ["detect_entrypoints"]
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""Entrypoint detection: a post-pass over the built L1 symbol table (#27).
2+
3+
Runs AFTER the symbol table exists so every view reference resolves as a
4+
lookup against ids that already exist. Additive metadata: a failure here
5+
loses flags, never the analysis.
6+
"""
7+
from __future__ import annotations
8+
9+
from pathlib import Path
10+
from typing import Iterable, Iterator
11+
12+
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass
13+
from codeanalyzer.utils import logger
14+
15+
16+
def detect_entrypoints(
17+
app: PyApplication, project_dir: Path, rule_paths: Iterable[Path] = ()
18+
) -> None:
19+
"""Populate ``entrypoints`` on every callable and class, in place."""
20+
try:
21+
_run_stages(app, project_dir, tuple(rule_paths))
22+
except Exception as exc: # noqa: BLE001 - additive pass must never abort analysis
23+
logger.warning("entrypoint detection failed: %s", exc)
24+
app.entrypoint_report.errors.append(str(exc))
25+
_derive_flags(app)
26+
27+
28+
def _run_stages(app: PyApplication, project_dir: Path, rule_paths: tuple) -> None:
29+
"""Stages 0-4. Empty until Task 5; the skeleton exists so the contract does."""
30+
return None
31+
32+
33+
def _derive_flags(app: PyApplication) -> None:
34+
for node in _walk(app):
35+
node.is_entrypoint = bool(node.entrypoints)
36+
37+
38+
def _walk(app: PyApplication) -> Iterator[object]:
39+
def walk_callable(c: PyCallable) -> Iterator[object]:
40+
yield c
41+
for inner in (c.callables or {}).values():
42+
yield from walk_callable(inner)
43+
for cls in (c.types or {}).values():
44+
yield from walk_class(cls)
45+
46+
def walk_class(k: PyClass) -> Iterator[object]:
47+
yield k
48+
for m in (k.callables or {}).values():
49+
yield from walk_callable(m)
50+
for inner in (k.types or {}).values():
51+
yield from walk_class(inner)
52+
53+
for mod in app.symbol_table.values():
54+
for fn in (mod.functions or {}).values():
55+
yield from walk_callable(fn)
56+
for cls in (mod.types or {}).values():
57+
yield from walk_class(cls)

codeanalyzer/options/options.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
from dataclasses import dataclass
22
from pathlib import Path
3-
from typing import Optional
3+
from typing import Optional, Tuple
44
from enum import Enum
55

66

@@ -65,3 +65,4 @@ class AnalysisOptions:
6565
pycg_shard_timeout: int = 120
6666
pycg_shard_strategy: ShardStrategy = ShardStrategy.JEDI
6767
pycg_max_iter: int = 50
68+
entrypoint_rules: Tuple[Path, ...] = ()

test/test_entrypoint_pipeline.py

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from pathlib import Path
2+
3+
from codeanalyzer.entrypoints.pipeline import detect_entrypoints
4+
from codeanalyzer.schema.py_schema import PyApplication
5+
6+
7+
def test_pass_is_a_noop_on_an_empty_application(tmp_path: Path):
8+
app = PyApplication(symbol_table={})
9+
detect_entrypoints(app, tmp_path)
10+
assert app.entrypoint_report.errors == []
11+
12+
13+
def test_pass_never_raises_and_records_the_failure(tmp_path: Path, monkeypatch):
14+
"""A finder crash must lose flags, not the analysis."""
15+
import codeanalyzer.entrypoints.pipeline as p
16+
17+
def boom(*a, **k):
18+
raise RuntimeError("finder exploded")
19+
20+
monkeypatch.setattr(p, "_run_stages", boom)
21+
app = PyApplication(symbol_table={})
22+
detect_entrypoints(app, tmp_path) # must not raise
23+
assert any("finder exploded" in e for e in app.entrypoint_report.errors)
24+
25+
26+
def test_derives_is_entrypoint_from_the_list(tmp_path: Path):
27+
from codeanalyzer.schema.py_schema import PyCallable, PyEntrypoint, PyModule
28+
29+
fn = PyCallable(name="f", path="a.py", signature="a.f")
30+
fn.entrypoints.append(
31+
PyEntrypoint(framework="flask", confidence="certain", rule="flask.route", ruleset="shipped")
32+
)
33+
app = PyApplication(
34+
symbol_table={
35+
"a.py": PyModule(file_path="a.py", module_name="a", functions={"f": fn})
36+
}
37+
)
38+
detect_entrypoints(app, tmp_path)
39+
assert fn.is_entrypoint is True

0 commit comments

Comments
 (0)