Skip to content

Commit fadf43f

Browse files
committed
feat(entrypoints): stage 0 framework detection gate (#27)
Loads entrypoint rules outside the detection pass's broad exception handler so a malformed --entrypoint-rules file is a hard configuration error (RulesError) rather than a swallowed detection failure.
1 parent 07cb739 commit fadf43f

4 files changed

Lines changed: 128 additions & 5 deletions

File tree

codeanalyzer/entrypoints/detect.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
"""Stage 0: which frameworks is this project actually using? (#27)
2+
3+
Gates every later stage, so a project without Celery never pays for Celery
4+
rules and cannot false-positive on a locally-defined ``shared_task``. A
5+
package counts as present if first-party source imports it OR the dependency
6+
manifest names it -- either is sufficient, since an import may be dynamic.
7+
"""
8+
from __future__ import annotations
9+
10+
import re
11+
from pathlib import Path
12+
from typing import Set
13+
14+
from codeanalyzer.entrypoints.rules import RuleSet
15+
from codeanalyzer.schema.py_schema import PyApplication
16+
17+
_REQ = re.compile(r"^\s*['\"]?([A-Za-z0-9_.\-]+)")
18+
_DEPS_ARRAY = re.compile(r"dependencies\s*=\s*\[(.*?)\]", re.DOTALL)
19+
_PKG = re.compile(r"['\"]([A-Za-z0-9][A-Za-z0-9_.\-]*)")
20+
21+
22+
def detected_frameworks(app: PyApplication, project_dir: Path, rules: RuleSet) -> Set[str]:
23+
present = _imported_packages(app) | _manifest_packages(project_dir)
24+
return {
25+
name
26+
for name, fw in rules.frameworks.items()
27+
if any(pkg in present for pkg in (fw.detect or [name]))
28+
}
29+
30+
31+
def _imported_packages(app: PyApplication) -> Set[str]:
32+
out: Set[str] = set()
33+
for mod in app.symbol_table.values():
34+
for imp in mod.imports or []:
35+
# `from flask import Flask` puts the package in `module`, not `name`.
36+
# Prefer `module`; fall back to `name` for a bare `import flask`.
37+
spelling = (getattr(imp, "module", "") or getattr(imp, "name", "") or "")
38+
spelling = spelling.lstrip(".")
39+
if spelling:
40+
out.add(spelling.split(".", 1)[0])
41+
return out
42+
43+
44+
def _manifest_packages(project_dir: Path) -> Set[str]:
45+
out: Set[str] = set()
46+
pyproject = project_dir / "pyproject.toml"
47+
if pyproject.exists():
48+
# PEP 621 `[project] dependencies = [...]` -- single- or multi-line.
49+
m = _DEPS_ARRAY.search(pyproject.read_text())
50+
if m:
51+
for pm in _PKG.finditer(m.group(1)):
52+
out.add(pm.group(1).split("[", 1)[0].lower())
53+
requirements = project_dir / "requirements.txt"
54+
if requirements.exists():
55+
for line in requirements.read_text().splitlines():
56+
m = _REQ.match(line)
57+
if m:
58+
out.add(m.group(1).split("[", 1)[0].lower())
59+
return out

codeanalyzer/entrypoints/pipeline.py

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,37 @@
99
from pathlib import Path
1010
from typing import Iterable, Iterator
1111

12+
from codeanalyzer.entrypoints.detect import detected_frameworks
13+
from codeanalyzer.entrypoints.rules import RuleSet, load_rules
1214
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass
1315
from codeanalyzer.utils import logger
1416

1517

1618
def detect_entrypoints(
1719
app: PyApplication, project_dir: Path, rule_paths: Iterable[Path] = ()
1820
) -> None:
19-
"""Populate ``entrypoints`` on every callable and class, in place."""
21+
"""Populate ``entrypoints`` on every callable and class, in place.
22+
23+
Loading the rules is a CONFIGURATION step, not a detection step: a
24+
malformed user rules file is a hard error that must stop the run before
25+
analysis starts, so ``load_rules`` runs outside (and before) the
26+
try/except below. Everything after that -- the actual framework
27+
detection -- is best-effort and must never abort the analysis.
28+
"""
29+
rules = load_rules(rule_paths)
2030
try:
21-
_run_stages(app, project_dir, tuple(rule_paths))
31+
_run_stages(app, project_dir, rules)
2232
except Exception as exc: # noqa: BLE001 - additive pass must never abort analysis
2333
logger.warning("entrypoint detection failed: %s", exc)
2434
app.entrypoint_report.errors.append(str(exc))
2535
_derive_flags(app)
2636

2737

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
38+
def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None:
39+
"""Stages 0-4. Only stage 0 (framework detection) exists so far."""
40+
app.entrypoint_report.rulesets = list(rules.rulesets)
41+
frameworks = detected_frameworks(app, project_dir, rules)
42+
app.entrypoint_report.frameworks_detected = sorted(frameworks)
3143

3244

3345
def _derive_flags(app: PyApplication) -> None:

test/test_entrypoint_detect.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
from pathlib import Path
2+
3+
from codeanalyzer.entrypoints.detect import detected_frameworks
4+
from codeanalyzer.entrypoints.rules import load_rules
5+
from codeanalyzer.schema.py_schema import PyApplication, PyImport, PyModule
6+
7+
8+
def _app(*modules: str) -> PyApplication:
9+
return PyApplication(
10+
symbol_table={
11+
"a.py": PyModule(
12+
file_path="a.py",
13+
module_name="a",
14+
imports=[PyImport(module=m, name=m.split(".")[-1]) for m in modules],
15+
)
16+
}
17+
)
18+
19+
20+
def test_framework_detected_from_an_import(tmp_path: Path):
21+
got = detected_frameworks(_app("flask"), tmp_path, load_rules())
22+
assert "flask" in got
23+
24+
25+
def test_absent_framework_is_not_detected(tmp_path: Path):
26+
got = detected_frameworks(_app("os"), tmp_path, load_rules())
27+
assert "celery" not in got
28+
29+
30+
def test_manifest_entry_alone_is_sufficient(tmp_path: Path):
31+
(tmp_path / "pyproject.toml").write_text(
32+
'[project]\nname = "x"\ndependencies = ["celery>=5"]\n'
33+
)
34+
got = detected_frameworks(_app("os"), tmp_path, load_rules())
35+
assert "celery" in got

test/test_entrypoint_pipeline.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
from pathlib import Path
22

3+
import pytest
4+
35
from codeanalyzer.entrypoints.pipeline import detect_entrypoints
6+
from codeanalyzer.entrypoints.rules import RulesError
47
from codeanalyzer.schema.py_schema import PyApplication
58

69

@@ -37,3 +40,17 @@ def test_derives_is_entrypoint_from_the_list(tmp_path: Path):
3740
)
3841
detect_entrypoints(app, tmp_path)
3942
assert fn.is_entrypoint is True
43+
44+
45+
def test_malformed_user_rules_file_raises_instead_of_being_swallowed(tmp_path: Path):
46+
"""A bad --entrypoint-rules file is a CONFIGURATION error, not a detection
47+
failure: it must stop the run via RulesError, not land quietly in
48+
entrypoint_report.errors like a finder crash would."""
49+
bad_rules = tmp_path / "bad_rules.yml"
50+
bad_rules.write_text("frameworks: not-a-mapping\n")
51+
app = PyApplication(symbol_table={})
52+
53+
with pytest.raises(RulesError):
54+
detect_entrypoints(app, tmp_path, rule_paths=(bad_rules,))
55+
56+
assert app.entrypoint_report.errors == []

0 commit comments

Comments
 (0)