Skip to content

Commit 9986974

Browse files
committed
fix(entrypoints): clear stale records before each detection pass (#27)
detect_entrypoints extended entrypoints onto whatever list a node already had. On a warm cache, core.py reuses the same cached PyModule/PyCallable objects when a file is unchanged, so a second run appended duplicate PyEntrypoint records onto the reused nodes. _run_stages now opens with a single full-app clear before Stage 0 detection, rather than clearing per-node during the walk -- an inline clear would erase entrypoints_from_bases records that are written onto a method before _walk_module visits that method directly. Adds a unit test that runs the pass twice over the same PyApplication and asserts the record list is unchanged, and fixes an existing test that relied on hand-seeding entrypoints (no longer valid once the pass owns and clears the list on every run).
1 parent 083abce commit 9986974

2 files changed

Lines changed: 53 additions & 5 deletions

File tree

codeanalyzer/entrypoints/pipeline.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,19 @@ def _run_stages(app: PyApplication, project_dir: Path, rules: RuleSet) -> None:
4444
base like ``APIView`` only resolves via that module's own
4545
``from rest_framework.views import APIView``), so this walks module by
4646
module rather than the whole app flat, building one resolver per module.
47+
48+
Clears every node's ``entrypoints`` first: on a warm cache,
49+
``_build_symbol_table`` reuses the SAME cached ``PyModule``/``PyCallable``
50+
objects when a file is unchanged, so without this clear a second run
51+
would ``extend`` onto records already written by the first run and
52+
duplicate them. A single full clear up front (rather than clearing each
53+
node as it's visited) avoids wiping ``entrypoints_from_bases`` records
54+
that ``_walk_module`` writes onto a method before visiting that method
55+
directly.
4756
"""
57+
for node in _walk(app):
58+
node.entrypoints = []
59+
4860
app.entrypoint_report.rulesets = list(rules.rulesets)
4961
frameworks = detected_frameworks(app, project_dir, rules)
5062
app.entrypoint_report.frameworks_detected = sorted(frameworks)

test/test_entrypoint_pipeline.py

Lines changed: 41 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,22 @@ def boom(*a, **k):
2727

2828

2929
def test_derives_is_entrypoint_from_the_list(tmp_path: Path):
30-
from codeanalyzer.schema.py_schema import PyCallable, PyEntrypoint, PyModule
30+
"""`entrypoints` is written entirely by the pass itself (it clears and
31+
rebuilds the list every run, see the duplication regression test below)
32+
-- so drive this through a real decorator match rather than hand-seeding
33+
the list, and check `_derive_flags` sets the boolean from the result."""
34+
from codeanalyzer.schema.py_schema import PyCallable, PyDecorator, PyImport, PyModule
3135

3236
fn = PyCallable(name="f", path="a.py", signature="a.f")
33-
fn.entrypoints.append(
34-
PyEntrypoint(framework="flask", confidence="certain", rule="flask.route", ruleset="shipped")
35-
)
37+
fn.decorators.append(PyDecorator(name="route", qualified_name="flask.Flask.route"))
3638
app = PyApplication(
3739
symbol_table={
38-
"a.py": PyModule(file_path="a.py", module_name="a", functions={"f": fn})
40+
"a.py": PyModule(
41+
file_path="a.py",
42+
module_name="a",
43+
functions={"f": fn},
44+
imports=[PyImport(module="flask", name="Flask")],
45+
)
3946
}
4047
)
4148
detect_entrypoints(app, tmp_path)
@@ -156,3 +163,32 @@ def test_dotted_base_class_resolves_through_a_module_import(tmp_path: Path):
156163
)
157164
detect_entrypoints(app, tmp_path)
158165
assert cls.is_entrypoint is True
166+
167+
168+
def test_running_the_pass_twice_does_not_duplicate_entrypoints(tmp_path: Path):
169+
"""#27 regression: on a warm cache, `_build_symbol_table` reuses the SAME
170+
cached PyModule/PyCallable objects across runs. `detect_entrypoints` must
171+
be safe to call again on that same PyApplication without appending
172+
duplicate PyEntrypoint records onto the reused nodes."""
173+
from codeanalyzer.schema.py_schema import PyCallable, PyDecorator, PyImport, PyModule
174+
175+
fn = PyCallable(name="f", path="a.py", signature="a.f")
176+
fn.decorators.append(PyDecorator(name="route", qualified_name="flask.Flask.route"))
177+
app = PyApplication(
178+
symbol_table={
179+
"a.py": PyModule(
180+
file_path="a.py",
181+
module_name="a",
182+
functions={"f": fn},
183+
imports=[PyImport(module="flask", name="Flask")],
184+
)
185+
}
186+
)
187+
188+
detect_entrypoints(app, tmp_path)
189+
first = [e.model_dump() for e in fn.entrypoints]
190+
assert len(first) == 1
191+
192+
detect_entrypoints(app, tmp_path)
193+
second = [e.model_dump() for e in fn.entrypoints]
194+
assert second == first

0 commit comments

Comments
 (0)