Skip to content

Commit 9cbb34a

Browse files
committed
fix(entrypoints): per-rule ruleset origin and import-resolved base matching (#27)
Attribute each PyEntrypoint's ruleset to the rule's own load origin instead of the last-loaded ruleset, and resolve written base-class names against the owning module's import table so bases: rules match real, idiomatically imported code instead of only fully-qualified spellings.
1 parent f7f181e commit 9cbb34a

6 files changed

Lines changed: 172 additions & 36 deletions

File tree

codeanalyzer/entrypoints/matching.py

Lines changed: 4 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def _methods_of(dec, spec: Optional[Dict[str, Any]]) -> List[str]:
113113

114114

115115
def entrypoints_from_decorators(
116-
node, framework: str, rules: Iterable["DecoratorRule"], ruleset: str
116+
node, framework: str, rules: Iterable["DecoratorRule"]
117117
) -> List[PyEntrypoint]:
118118
out: List[PyEntrypoint] = []
119119
for dec in getattr(node, "decorators", []) or []:
@@ -125,7 +125,7 @@ def entrypoints_from_decorators(
125125
framework=framework,
126126
confidence=rule.confidence,
127127
rule=rule.id,
128-
ruleset=ruleset,
128+
ruleset=rule.origin,
129129
evidence=dec.qualified_name,
130130
route=_route_of(dec, rule.route),
131131
http_methods=_methods_of(dec, rule.methods),
@@ -138,7 +138,6 @@ def entrypoints_from_bases(
138138
cls,
139139
framework: str,
140140
rules: Iterable["BaseRule"],
141-
ruleset: str,
142141
resolve: Callable[[str], Optional[str]],
143142
) -> Tuple[List[PyEntrypoint], Dict[str, List[PyEntrypoint]]]:
144143
"""Records for a routed class and for the methods the framework dispatches.
@@ -161,7 +160,7 @@ def entrypoints_from_bases(
161160
framework=framework,
162161
confidence=rule.confidence,
163162
rule=rule.id,
164-
ruleset=ruleset,
163+
ruleset=rule.origin,
165164
evidence=cls.signature,
166165
)
167166
)
@@ -174,7 +173,7 @@ def entrypoints_from_bases(
174173
framework=framework,
175174
confidence=rule.confidence,
176175
rule=f"{rule.id}.dispatch",
177-
ruleset=ruleset,
176+
ruleset=rule.origin,
178177
evidence=cls.signature,
179178
http_methods=[name.upper()] if name in _HTTP_VERBS else [],
180179
via=cls.id or None,

codeanalyzer/entrypoints/pipeline.py

Lines changed: 55 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,12 @@
77
from __future__ import annotations
88

99
from pathlib import Path
10-
from typing import Iterable, Iterator
10+
from typing import Dict, Iterable, Iterator
1111

1212
from codeanalyzer.entrypoints.detect import detected_frameworks
1313
from codeanalyzer.entrypoints.matching import entrypoints_from_bases, entrypoints_from_decorators
1414
from codeanalyzer.entrypoints.rules import RuleSet, load_rules
15-
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass
15+
from codeanalyzer.schema.py_schema import PyApplication, PyCallable, PyClass, PyModule
1616
from codeanalyzer.utils import logger
1717

1818

@@ -38,35 +38,62 @@ def detect_entrypoints(
3838

3939
def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None:
4040
"""Stages 0-4. Stage 0 (framework detection) and Stage 3 (decorator and
41-
base-class matching) exist so far."""
41+
base-class matching) exist so far.
42+
43+
Base-class resolution needs the OWNING MODULE's import table (a written
44+
base like ``APIView`` only resolves via that module's own
45+
``from rest_framework.views import APIView``), so this walks module by
46+
module rather than the whole app flat, building one resolver per module.
47+
"""
4248
app.entrypoint_report.rulesets = list(rules.rulesets)
4349
frameworks = detected_frameworks(app, project_dir, rules)
4450
app.entrypoint_report.frameworks_detected = sorted(frameworks)
4551

46-
ruleset_name = rules.rulesets[-1] if len(rules.rulesets) > 1 else "shipped"
47-
for name in sorted(frameworks):
48-
fw = rules.frameworks[name]
49-
for node in _walk(app):
50-
node.entrypoints.extend(
51-
entrypoints_from_decorators(node, name, fw.decorators, ruleset_name)
52-
)
53-
if isinstance(node, PyClass) and fw.bases:
54-
class_eps, method_eps = entrypoints_from_bases(
55-
node, name, fw.bases, ruleset_name, lambda b: b
56-
)
57-
node.entrypoints.extend(class_eps)
58-
for method_name, eps in method_eps.items():
59-
target = (node.callables or {}).get(method_name)
60-
if target is not None:
61-
target.entrypoints.extend(eps)
52+
names = sorted(frameworks)
53+
for mod in app.symbol_table.values():
54+
resolve = _base_resolver(mod)
55+
for node in _walk_module(mod):
56+
for name in names:
57+
fw = rules.frameworks[name]
58+
node.entrypoints.extend(entrypoints_from_decorators(node, name, fw.decorators))
59+
if isinstance(node, PyClass) and fw.bases:
60+
class_eps, method_eps = entrypoints_from_bases(
61+
node, name, fw.bases, resolve
62+
)
63+
node.entrypoints.extend(class_eps)
64+
for method_name, eps in method_eps.items():
65+
target = (node.callables or {}).get(method_name)
66+
if target is not None:
67+
target.entrypoints.extend(eps)
68+
69+
70+
def _base_resolver(mod: PyModule):
71+
"""A per-module ``resolve`` callable for ``entrypoints_from_bases``, built
72+
from the module's own import table -- exact data already on the node,
73+
never a Jedi guess. Covers ``from x.y import Z[ as W]`` and
74+
``import x.y[ as z]``, plus a dotted base (``views.APIView``) whose head
75+
is the imported name. A base the import table has no mapping for is
76+
returned unchanged -- under-approximate rather than guess.
77+
"""
78+
aliases: Dict[str, str] = {}
79+
for imp in mod.imports or []:
80+
original = imp.alias or imp.name
81+
aliases[imp.name] = imp.module if imp.module == original else f"{imp.module}.{original}"
82+
83+
def resolve(written: str) -> str:
84+
head, _, rest = written.partition(".")
85+
target = aliases.get(head)
86+
return f"{target}.{rest}" if target and rest else (target or written)
87+
88+
return resolve
6289

6390

6491
def _derive_flags(app: PyApplication) -> None:
6592
for node in _walk(app):
6693
node.is_entrypoint = bool(node.entrypoints)
6794

6895

69-
def _walk(app: PyApplication) -> Iterator[object]:
96+
def _walk_module(mod: PyModule) -> Iterator[object]:
7097
def walk_callable(c: PyCallable) -> Iterator[object]:
7198
yield c
7299
for inner in (c.callables or {}).values():
@@ -81,8 +108,12 @@ def walk_class(k: PyClass) -> Iterator[object]:
81108
for inner in (k.types or {}).values():
82109
yield from walk_class(inner)
83110

111+
for fn in (mod.functions or {}).values():
112+
yield from walk_callable(fn)
113+
for cls in (mod.types or {}).values():
114+
yield from walk_class(cls)
115+
116+
117+
def _walk(app: PyApplication) -> Iterator[object]:
84118
for mod in app.symbol_table.values():
85-
for fn in (mod.functions or {}).values():
86-
yield from walk_callable(fn)
87-
for cls in (mod.types or {}).values():
88-
yield from walk_class(cls)
119+
yield from _walk_module(mod)

codeanalyzer/entrypoints/rules.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ class DecoratorRule:
3131
confidence: str = "certain"
3232
route: Optional[Dict[str, Any]] = None
3333
methods: Optional[Dict[str, Any]] = None
34+
origin: str = "shipped"
3435

3536

3637
@dataclass
@@ -40,6 +41,7 @@ class BaseRule:
4041
confidence: str = "certain"
4142
transitive: bool = False
4243
dispatch: List[str] = field(default_factory=list)
44+
origin: str = "shipped"
4345

4446

4547
@dataclass
@@ -134,6 +136,7 @@ def _decorator_rule(raw: Dict[str, Any], origin: str) -> DecoratorRule:
134136
confidence=_confidence(raw, origin),
135137
route=raw.get("route"),
136138
methods=raw.get("methods"),
139+
origin=origin,
137140
)
138141

139142

@@ -144,4 +147,5 @@ def _base_rule(raw: Dict[str, Any], origin: str) -> BaseRule:
144147
confidence=_confidence(raw, origin),
145148
transitive=bool(raw.get("transitive", False)),
146149
dispatch=list(raw.get("dispatch") or []),
150+
origin=origin,
147151
)

test/test_entrypoint_bases.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,26 +21,26 @@ def _cls(*methods: str, bases=("rest_framework.views.APIView",)) -> PyClass:
2121

2222
def test_class_is_flagged_and_only_defined_methods_dispatch():
2323
cls = _cls("get") # defines get, not post
24-
class_eps, method_eps = entrypoints_from_bases(cls, "drf", [RULE], "shipped", lambda b: b)
24+
class_eps, method_eps = entrypoints_from_bases(cls, "drf", [RULE], lambda b: b)
2525
assert len(class_eps) == 1
2626
assert list(method_eps) == ["get"], "no phantom post entrypoint"
2727

2828

2929
def test_methods_point_back_at_the_routed_class_via():
3030
cls = _cls("get")
3131
cls.id = "can://python/app/a.py/V"
32-
_, method_eps = entrypoints_from_bases(cls, "drf", [RULE], "shipped", lambda b: b)
32+
_, method_eps = entrypoints_from_bases(cls, "drf", [RULE], lambda b: b)
3333
assert method_eps["get"][0].via == "can://python/app/a.py/V"
3434

3535

3636
def test_transitive_base_resolves_one_hop():
3737
cls = _cls("get", bases=("app.BaseView",))
3838
resolve = {"app.BaseView": "rest_framework.views.APIView"}.get
39-
class_eps, _ = entrypoints_from_bases(cls, "drf", [RULE], "shipped", resolve)
39+
class_eps, _ = entrypoints_from_bases(cls, "drf", [RULE], resolve)
4040
assert len(class_eps) == 1
4141

4242

4343
def test_unrelated_class_is_not_flagged():
4444
cls = _cls("get", bases=("object",))
45-
class_eps, method_eps = entrypoints_from_bases(cls, "drf", [RULE], "shipped", lambda b: b)
45+
class_eps, method_eps = entrypoints_from_bases(cls, "drf", [RULE], lambda b: b)
4646
assert class_eps == [] and method_eps == {}

test/test_entrypoint_decorators.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@ def test_route_and_methods_are_extracted():
3333
route={"from": "positional", "index": 0},
3434
methods={"from": "keyword", "name": "methods", "default": ["GET"]},
3535
)
36-
(ep,) = entrypoints_from_decorators(fn, "flask", [rule], "shipped")
36+
(ep,) = entrypoints_from_decorators(fn, "flask", [rule])
3737
assert ep.route == "/products"
3838
assert ep.http_methods == ["POST"]
3939
assert ep.rule == "flask.route" and ep.ruleset == "shipped"
@@ -51,7 +51,7 @@ def test_verb_comes_from_the_matched_suffix():
5151
route={"from": "positional", "index": 0},
5252
methods={"from": "match_suffix"},
5353
)
54-
(ep,) = entrypoints_from_decorators(fn, "fastapi", [rule], "shipped")
54+
(ep,) = entrypoints_from_decorators(fn, "fastapi", [rule])
5555
assert ep.http_methods == ["POST"]
5656

5757

@@ -60,4 +60,4 @@ def test_unresolved_decorator_never_matches():
6060
fn = PyCallable(name="h", path="a.py", signature="a.h")
6161
fn.decorators.append(PyDecorator(name="app.route", qualified_name=None))
6262
rule = DecoratorRule(id="flask.route", match="flask.Flask.route")
63-
assert entrypoints_from_decorators(fn, "flask", [rule], "shipped") == []
63+
assert entrypoints_from_decorators(fn, "flask", [rule]) == []

test/test_entrypoint_pipeline.py

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,3 +54,105 @@ def test_malformed_user_rules_file_raises_instead_of_being_swallowed(tmp_path: P
5454
detect_entrypoints(app, tmp_path, rule_paths=(bad_rules,))
5555

5656
assert app.entrypoint_report.errors == []
57+
58+
59+
def test_ruleset_provenance_distinguishes_shipped_from_user_rules(tmp_path: Path):
60+
"""A shipped rule's record must say "shipped" even when a user rules
61+
file is also loaded -- the ruleset field exists so someone debugging a
62+
surprising flag can find which file produced it."""
63+
from codeanalyzer.schema.py_schema import PyCallable, PyDecorator, PyImport, PyModule
64+
65+
user_rules = tmp_path / "user.yml"
66+
user_rules.write_text(
67+
"frameworks:\n"
68+
" inhouse:\n"
69+
" detect: [inhouse]\n"
70+
" decorators:\n"
71+
" - id: inhouse.handler\n"
72+
" match: 'inhouse.app.handler'\n"
73+
)
74+
75+
shipped_fn = PyCallable(name="f", path="a.py", signature="a.f")
76+
shipped_fn.decorators.append(PyDecorator(name="app.route", qualified_name="flask.Flask.route"))
77+
user_fn = PyCallable(name="g", path="a.py", signature="a.g")
78+
user_fn.decorators.append(PyDecorator(name="handler", qualified_name="inhouse.app.handler"))
79+
80+
app = PyApplication(
81+
symbol_table={
82+
"a.py": PyModule(
83+
file_path="a.py",
84+
module_name="a",
85+
functions={"f": shipped_fn, "g": user_fn},
86+
imports=[
87+
PyImport(module="flask", name="Flask"),
88+
PyImport(module="inhouse", name="app"),
89+
],
90+
)
91+
}
92+
)
93+
detect_entrypoints(app, tmp_path, rule_paths=(user_rules,))
94+
95+
assert shipped_fn.entrypoints[0].ruleset == "shipped"
96+
assert user_fn.entrypoints[0].ruleset == f"user:{user_rules}"
97+
98+
99+
def test_direct_base_class_is_flagged_when_the_import_resolves_it(tmp_path: Path):
100+
"""``class V(APIView)`` under ``from rest_framework.views import APIView``
101+
is the idiomatic spelling -- base_classes stores the written name
102+
``"APIView"``, and it must resolve via the module's own import table."""
103+
from codeanalyzer.schema.py_schema import PyClass, PyImport, PyModule
104+
105+
cls = PyClass(name="V", signature="a.V", base_classes=["APIView"])
106+
app = PyApplication(
107+
symbol_table={
108+
"a.py": PyModule(
109+
file_path="a.py",
110+
module_name="a",
111+
types={"a.V": cls},
112+
imports=[PyImport(module="rest_framework.views", name="APIView")],
113+
)
114+
}
115+
)
116+
detect_entrypoints(app, tmp_path)
117+
assert cls.is_entrypoint is True
118+
119+
120+
def test_direct_base_class_is_not_flagged_without_the_import(tmp_path: Path):
121+
"""``rest_framework`` is imported (so the drf framework gate passes) but
122+
``APIView`` itself is never imported into this module -- the written
123+
"APIView" base has nothing to resolve against and must not be flagged."""
124+
from codeanalyzer.schema.py_schema import PyClass, PyImport, PyModule
125+
126+
cls = PyClass(name="V", signature="a.V", base_classes=["APIView"])
127+
app = PyApplication(
128+
symbol_table={
129+
"a.py": PyModule(
130+
file_path="a.py",
131+
module_name="a",
132+
types={"a.V": cls},
133+
imports=[PyImport(module="rest_framework", name="serializers")],
134+
)
135+
}
136+
)
137+
detect_entrypoints(app, tmp_path)
138+
assert cls.is_entrypoint is False
139+
140+
141+
def test_dotted_base_class_resolves_through_a_module_import(tmp_path: Path):
142+
"""``class V(views.APIView)`` under ``from rest_framework import views``
143+
-- the dotted base's head ("views") is the imported name."""
144+
from codeanalyzer.schema.py_schema import PyClass, PyImport, PyModule
145+
146+
cls = PyClass(name="V", signature="a.V", base_classes=["views.APIView"])
147+
app = PyApplication(
148+
symbol_table={
149+
"a.py": PyModule(
150+
file_path="a.py",
151+
module_name="a",
152+
types={"a.V": cls},
153+
imports=[PyImport(module="rest_framework", name="views")],
154+
)
155+
}
156+
)
157+
detect_entrypoints(app, tmp_path)
158+
assert cls.is_entrypoint is True

0 commit comments

Comments
 (0)