Skip to content

Commit 58b01b0

Browse files
authored
Fix dead-code reachability for framework runtime patterns (#31)
* fix(release): align 2.0.2 and vscode 0.2.7 metadata * fix(dead-code): extend runtime reachability coverage * fix(cache): invalidate stale reachability facts * chore(skill): clarifying instructions in skills * fix(dead-code): cover framework reachability edge cases * fix(html): preserve JetBrains line navigation * fix(dead-code): honor public exports and guarded dynamic lookup
1 parent 286520a commit 58b01b0

33 files changed

Lines changed: 1242 additions & 142 deletions

.github/actions/codeclone/README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@ source under test. Remote consumers still install from PyPI.
3838
For strict reproducibility, pin the full release tag:
3939
4040
```yaml
41-
- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.1
41+
- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.2
4242
```
4343
4444
For long-lived workflows, `@v2` follows the latest compatible 2.x action
@@ -80,7 +80,7 @@ jobs:
8080
| Input | Default | Purpose |
8181
|-------------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------|
8282
| `python-version` | `3.14` | Python version used to run the action |
83-
| `package-version` | `2.0.1` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo |
83+
| `package-version` | `2.0.2` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo |
8484
| `path` | `.` | Project root to analyze |
8585
| `json-path` | `.cache/codeclone/report.json` | JSON report output path |
8686
| `sarif` | `true` | Generate SARIF and try to upload it |
@@ -145,7 +145,7 @@ Notes:
145145
## Install policy
146146

147147
Released action tags pin the PyPI package version in action metadata. For
148-
example, `@v2.0.1` installs `codeclone==2.0.1` unless you override
148+
example, `@v2.0.2` installs `codeclone==2.0.2` unless you override
149149
`package-version`.
150150

151151
Explicit prerelease or smoke-test override:

.github/actions/codeclone/_action_impl.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from typing import Literal
2626

2727
COMMENT_MARKER = "<!-- codeclone-report -->"
28-
DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.1"
28+
DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.2"
2929

3030

3131
@dataclass(frozen=True, slots=True)

.github/actions/codeclone/action.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ inputs:
1818
package-version:
1919
description: "CodeClone version from PyPI for remote installs (ignored when the action runs from the checked-out CodeClone repo)"
2020
required: false
21-
default: "2.0.1"
21+
default: "2.0.2"
2222

2323
path:
2424
description: "Project root"

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ from another doc.** Current values (verified at write time):
144144
|-----------------------------------|-----------------------------------|---------------|
145145
| `BASELINE_SCHEMA_VERSION` | `codeclone/contracts/__init__.py` | `2.1` |
146146
| `BASELINE_FINGERPRINT_VERSION` | `codeclone/contracts/__init__.py` | `1` |
147-
| `CACHE_VERSION` | `codeclone/contracts/__init__.py` | `2.7` |
147+
| `CACHE_VERSION` | `codeclone/contracts/__init__.py` | `2.8` |
148148
| `REPORT_SCHEMA_VERSION` | `codeclone/contracts/__init__.py` | `2.11` |
149149
| `METRICS_BASELINE_SCHEMA_VERSION` | `codeclone/contracts/__init__.py` | `1.2` |
150150

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,40 @@
11
# Changelog
22

3+
## Unreleased
4+
5+
`2.0.2` is a focused patch release for VS Code extension packaging metadata,
6+
README link behavior, and dead-code runtime reachability precision.
7+
8+
### Enhancements
9+
10+
- Extend runtime reachability with exact Aiogram `Router`/`Dispatcher`
11+
observer decorators, Starlette `BaseHTTPMiddleware.dispatch` hooks,
12+
Flask/Blueprint routes, aiohttp `RouteTableDef` route decorators, FastAPI
13+
route decorator factories, and SQLAlchemy `TypeDecorator` runtime hooks to
14+
reduce false-positive dead-code findings without name-only heuristics.
15+
- Exclude `node_modules` from the default Python scanner so vendored frontend
16+
dependencies do not appear as project dead-code findings.
17+
18+
### Bug Fixes
19+
20+
- Fix HTML report PyCharm/IntelliJ source links so they preserve line
21+
navigation when opening files from report tables.
22+
- Fix README package badges so PyPI/status/download/Python-version links open
23+
the PyPI project page instead of scrolling to the installation section.
24+
- Treat `__all__` re-exports, PEP 562 lazy `_EXPORTS` modules, and guarded
25+
dynamic `getattr(..., "method")` callable dispatch as dead-code reachability
26+
evidence.
27+
28+
### Internal
29+
30+
- Bump cache schema to `2.8` so projects rebuild cached dead-code and runtime
31+
reachability facts after the refined framework model.
32+
- Bump the Python package and composite GitHub Action default install version to
33+
`2.0.2`.
34+
- Record the VS Code extension `0.2.7` metadata that matches the Marketplace
35+
build carrying Coverage Join hotspot support and workspace-root
36+
`coverage.xml` discovery.
37+
338
## [2.0.1] - 2026-05-14
439

540
`2.0.1` is a focused stability release for dead-code precision and cache/report

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -319,7 +319,7 @@ Top-level keys: `report_schema_version`, `meta`, `inventory`, `findings`, `metri
319319
{
320320
"report_schema_version": "2.11",
321321
"meta": {
322-
"codeclone_version": "2.0.1",
322+
"codeclone_version": "2.0.2",
323323
"project_name": "...",
324324
"scan_root": ".",
325325
"...": "..."
@@ -473,7 +473,7 @@ Versions released before this change remain under their original license terms.
473473
[benchmark-shield]: https://img.shields.io/github/actions/workflow/status/orenlab/codeclone/benchmark.yml?style=flat-square&label=benchmark
474474

475475
<!-- Links -->
476-
[pypi-link]: #installation
476+
[pypi-link]: https://pypi.org/project/codeclone/
477477
[score-link]: #how-it-works
478478
[license-link]: #license
479479
[tests-link]: https://github.com/orenlab/codeclone/actions/workflows/tests.yml

codeclone/analysis/_module_walk.py

Lines changed: 149 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88

99
import ast
1010
import tokenize
11+
from collections.abc import Iterator
1112
from dataclasses import dataclass, field
1213
from typing import TYPE_CHECKING, Literal, NamedTuple
1314

@@ -86,6 +87,8 @@ class _ModuleWalkState:
8687
name_nodes: list[ast.Name] = field(default_factory=list)
8788
attr_nodes: list[ast.Attribute] = field(default_factory=list)
8889
exported_names: set[str] = field(default_factory=set)
90+
lazy_export_bindings: dict[str, set[str]] = field(default_factory=dict)
91+
has_module_getattr: bool = False
8992
protocol_symbol_aliases: set[str] = field(default_factory=lambda: {"Protocol"})
9093
protocol_module_aliases: set[str] = field(
9194
default_factory=lambda: set(_PROTOCOL_MODULE_NAMES)
@@ -185,6 +188,21 @@ def _string_literals_from_export_value(value: ast.AST) -> tuple[str, ...]:
185188
return ()
186189

187190

191+
def _string_mapping_from_literal_dict(value: ast.AST) -> dict[str, str]:
192+
if not isinstance(value, ast.Dict):
193+
return {}
194+
mapping: dict[str, str] = {}
195+
for key, val in zip(value.keys, value.values, strict=True):
196+
if (
197+
isinstance(key, ast.Constant)
198+
and isinstance(key.value, str)
199+
and isinstance(val, ast.Constant)
200+
and isinstance(val.value, str)
201+
):
202+
mapping[key.value] = val.value
203+
return mapping
204+
205+
188206
def _collect_all_export_node(node: ast.AST, state: _ModuleWalkState) -> None:
189207
match node:
190208
case ast.Assign(targets=targets, value=value):
@@ -216,11 +234,128 @@ def _collect_all_export_node(node: ast.AST, state: _ModuleWalkState) -> None:
216234
pass
217235

218236

237+
def _collect_lazy_export_node(node: ast.AST, state: _ModuleWalkState) -> None:
238+
match node:
239+
case ast.Assign(targets=targets, value=value):
240+
names = {target.id for target in targets if isinstance(target, ast.Name)}
241+
case ast.AnnAssign(target=ast.Name(id=name), value=value):
242+
names = {name}
243+
case (
244+
ast.FunctionDef(name="__getattr__")
245+
| ast.AsyncFunctionDef(name="__getattr__")
246+
):
247+
state.has_module_getattr = True
248+
return
249+
case _:
250+
return
251+
if "_EXPORTS" not in names or value is None:
252+
return
253+
for exported_name, module_path in _string_mapping_from_literal_dict(value).items():
254+
state.lazy_export_bindings.setdefault(exported_name, set()).add(module_path)
255+
256+
219257
def _collect_module_all_exports(tree: ast.AST, state: _ModuleWalkState) -> None:
220258
if not isinstance(tree, ast.Module):
221259
return
222260
for statement in tree.body:
223261
_collect_all_export_node(statement, state)
262+
_collect_lazy_export_node(statement, state)
263+
264+
265+
def _literal_getattr_name(value: ast.AST | None) -> str | None:
266+
if not isinstance(value, ast.Call):
267+
return None
268+
if not isinstance(value.func, ast.Name) or value.func.id != "getattr":
269+
return None
270+
if len(value.args) < 2:
271+
return None
272+
attr_arg = value.args[1]
273+
if not isinstance(attr_arg, ast.Constant) or not isinstance(attr_arg.value, str):
274+
return None
275+
if attr_arg.value.isidentifier():
276+
return attr_arg.value
277+
return None
278+
279+
280+
def _iter_runtime_callable_scopes(
281+
tree: ast.AST,
282+
) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
283+
if not isinstance(tree, ast.Module):
284+
return
285+
stack = list(reversed(tree.body))
286+
while stack:
287+
node = stack.pop()
288+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
289+
yield node
290+
continue
291+
if isinstance(node, ast.ClassDef):
292+
stack.extend(reversed(node.body))
293+
294+
295+
def _iter_scope_body_nodes(body: list[ast.stmt]) -> Iterator[ast.AST]:
296+
stack: list[ast.AST] = list(reversed(body))
297+
while stack:
298+
node = stack.pop()
299+
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
300+
continue
301+
yield node
302+
stack.extend(reversed(list(ast.iter_child_nodes(node))))
303+
304+
305+
def _dynamic_getattr_names_from_scope(
306+
node: ast.FunctionDef | ast.AsyncFunctionDef,
307+
) -> set[str]:
308+
getattr_bindings: dict[str, str] = {}
309+
callable_guards: set[str] = set()
310+
called_locals: set[str] = set()
311+
for scope_node in _iter_scope_body_nodes(node.body):
312+
match scope_node:
313+
case ast.Assign(targets=targets, value=value):
314+
attr_name = _literal_getattr_name(value)
315+
if attr_name is not None:
316+
for target in targets:
317+
if isinstance(target, ast.Name):
318+
getattr_bindings[target.id] = attr_name
319+
case ast.AnnAssign(target=ast.Name(id=name), value=value):
320+
attr_name = _literal_getattr_name(value)
321+
if attr_name is not None:
322+
getattr_bindings[name] = attr_name
323+
case ast.Call(
324+
func=ast.Name(id="callable"),
325+
args=[ast.Name(id=name), *_],
326+
):
327+
callable_guards.add(name)
328+
case ast.Call(func=ast.Name(id=name)):
329+
called_locals.add(name)
330+
case _:
331+
pass
332+
return {
333+
attr_name
334+
for local_name, attr_name in getattr_bindings.items()
335+
if local_name in callable_guards and local_name in called_locals
336+
}
337+
338+
339+
def _collect_dynamic_getattr_names(tree: ast.AST) -> set[str]:
340+
names: set[str] = set()
341+
for scope in _iter_runtime_callable_scopes(tree):
342+
names.update(_dynamic_getattr_names_from_scope(scope))
343+
return names
344+
345+
346+
def _local_export_qualname(
347+
*,
348+
module_name: str,
349+
exported_name: str,
350+
functions_by_name: dict[str, str],
351+
classes_by_name: dict[str, str],
352+
) -> str | None:
353+
local_qualname = functions_by_name.get(exported_name)
354+
if local_qualname is None:
355+
local_qualname = classes_by_name.get(exported_name)
356+
if local_qualname is None:
357+
return None
358+
return f"{module_name}:{local_qualname}"
224359

225360

226361
def _collect_import_from_node(
@@ -472,13 +607,19 @@ def _resolve_referenced_qualnames(
472607
resolved.add(local_method_qualname)
473608

474609
for exported_name in state.exported_names:
475-
local_qualname = top_level_function_by_name.get(exported_name)
476-
if local_qualname is not None:
477-
resolved.add(f"{module_name}:{local_qualname}")
610+
local_export_qualname = _local_export_qualname(
611+
module_name=module_name,
612+
exported_name=exported_name,
613+
functions_by_name=top_level_function_by_name,
614+
classes_by_name=top_level_class_by_name,
615+
)
616+
if local_export_qualname is not None:
617+
resolved.add(local_export_qualname)
478618
continue
479-
class_qualname = top_level_class_by_name.get(exported_name)
480-
if class_qualname is not None:
481-
resolved.add(f"{module_name}:{class_qualname}")
619+
resolved.update(state.imported_symbol_bindings.get(exported_name, ()))
620+
if state.has_module_getattr:
621+
for module_path in state.lazy_export_bindings.get(exported_name, ()):
622+
resolved.add(f"{module_path}:{exported_name}")
482623

483624
return frozenset(resolved)
484625

@@ -524,6 +665,8 @@ def _collect_module_walk_data(
524665
)
525666
elif collect_referenced_names:
526667
_collect_load_reference_node(node=node, state=state)
668+
if collect_referenced_names:
669+
state.referenced_names.update(_collect_dynamic_getattr_names(tree))
527670

528671
deps_sorted = tuple(
529672
sorted(

0 commit comments

Comments
 (0)