Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions .github/actions/codeclone/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ source under test. Remote consumers still install from PyPI.
For strict reproducibility, pin the full release tag:

```yaml
- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.1
- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.2
```

For long-lived workflows, `@v2` follows the latest compatible 2.x action
Expand Down Expand Up @@ -80,7 +80,7 @@ jobs:
| Input | Default | Purpose |
|-------------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------|
| `python-version` | `3.14` | Python version used to run the action |
| `package-version` | `2.0.1` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo |
| `package-version` | `2.0.2` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo |
| `path` | `.` | Project root to analyze |
| `json-path` | `.cache/codeclone/report.json` | JSON report output path |
| `sarif` | `true` | Generate SARIF and try to upload it |
Expand Down Expand Up @@ -145,7 +145,7 @@ Notes:
## Install policy

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

Explicit prerelease or smoke-test override:
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/codeclone/_action_impl.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@
from typing import Literal

COMMENT_MARKER = "<!-- codeclone-report -->"
DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.1"
DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.2"


@dataclass(frozen=True, slots=True)
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/codeclone/action.yml
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ inputs:
package-version:
description: "CodeClone version from PyPI for remote installs (ignored when the action runs from the checked-out CodeClone repo)"
required: false
default: "2.0.1"
default: "2.0.2"

path:
description: "Project root"
Expand Down
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -144,7 +144,7 @@ from another doc.** Current values (verified at write time):
|-----------------------------------|-----------------------------------|---------------|
| `BASELINE_SCHEMA_VERSION` | `codeclone/contracts/__init__.py` | `2.1` |
| `BASELINE_FINGERPRINT_VERSION` | `codeclone/contracts/__init__.py` | `1` |
| `CACHE_VERSION` | `codeclone/contracts/__init__.py` | `2.7` |
| `CACHE_VERSION` | `codeclone/contracts/__init__.py` | `2.8` |
| `REPORT_SCHEMA_VERSION` | `codeclone/contracts/__init__.py` | `2.11` |
| `METRICS_BASELINE_SCHEMA_VERSION` | `codeclone/contracts/__init__.py` | `1.2` |

Expand Down
35 changes: 35 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,40 @@
# Changelog

## Unreleased

`2.0.2` is a focused patch release for VS Code extension packaging metadata,
README link behavior, and dead-code runtime reachability precision.

### Enhancements

- Extend runtime reachability with exact Aiogram `Router`/`Dispatcher`
observer decorators, Starlette `BaseHTTPMiddleware.dispatch` hooks,
Flask/Blueprint routes, aiohttp `RouteTableDef` route decorators, FastAPI
route decorator factories, and SQLAlchemy `TypeDecorator` runtime hooks to
reduce false-positive dead-code findings without name-only heuristics.
- Exclude `node_modules` from the default Python scanner so vendored frontend
dependencies do not appear as project dead-code findings.

### Bug Fixes

- Fix HTML report PyCharm/IntelliJ source links so they preserve line
navigation when opening files from report tables.
- Fix README package badges so PyPI/status/download/Python-version links open
the PyPI project page instead of scrolling to the installation section.
- Treat `__all__` re-exports, PEP 562 lazy `_EXPORTS` modules, and guarded
dynamic `getattr(..., "method")` callable dispatch as dead-code reachability
evidence.

### Internal

- Bump cache schema to `2.8` so projects rebuild cached dead-code and runtime
reachability facts after the refined framework model.
- Bump the Python package and composite GitHub Action default install version to
`2.0.2`.
- Record the VS Code extension `0.2.7` metadata that matches the Marketplace
build carrying Coverage Join hotspot support and workspace-root
`coverage.xml` discovery.

## [2.0.1] - 2026-05-14

`2.0.1` is a focused stability release for dead-code precision and cache/report
Expand Down
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,7 +319,7 @@ Top-level keys: `report_schema_version`, `meta`, `inventory`, `findings`, `metri
{
"report_schema_version": "2.11",
"meta": {
"codeclone_version": "2.0.1",
"codeclone_version": "2.0.2",
"project_name": "...",
"scan_root": ".",
"...": "..."
Expand Down Expand Up @@ -473,7 +473,7 @@ Versions released before this change remain under their original license terms.
[benchmark-shield]: https://img.shields.io/github/actions/workflow/status/orenlab/codeclone/benchmark.yml?style=flat-square&label=benchmark

<!-- Links -->
[pypi-link]: #installation
[pypi-link]: https://pypi.org/project/codeclone/
[score-link]: #how-it-works
[license-link]: #license
[tests-link]: https://github.com/orenlab/codeclone/actions/workflows/tests.yml
Expand Down
155 changes: 149 additions & 6 deletions codeclone/analysis/_module_walk.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import ast
import tokenize
from collections.abc import Iterator
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Literal, NamedTuple

Expand Down Expand Up @@ -86,6 +87,8 @@ class _ModuleWalkState:
name_nodes: list[ast.Name] = field(default_factory=list)
attr_nodes: list[ast.Attribute] = field(default_factory=list)
exported_names: set[str] = field(default_factory=set)
lazy_export_bindings: dict[str, set[str]] = field(default_factory=dict)
has_module_getattr: bool = False
protocol_symbol_aliases: set[str] = field(default_factory=lambda: {"Protocol"})
protocol_module_aliases: set[str] = field(
default_factory=lambda: set(_PROTOCOL_MODULE_NAMES)
Expand Down Expand Up @@ -185,6 +188,21 @@ def _string_literals_from_export_value(value: ast.AST) -> tuple[str, ...]:
return ()


def _string_mapping_from_literal_dict(value: ast.AST) -> dict[str, str]:
if not isinstance(value, ast.Dict):
return {}
mapping: dict[str, str] = {}
for key, val in zip(value.keys, value.values, strict=True):
if (
isinstance(key, ast.Constant)
and isinstance(key.value, str)
and isinstance(val, ast.Constant)
and isinstance(val.value, str)
):
mapping[key.value] = val.value
return mapping


def _collect_all_export_node(node: ast.AST, state: _ModuleWalkState) -> None:
match node:
case ast.Assign(targets=targets, value=value):
Expand Down Expand Up @@ -216,11 +234,128 @@ def _collect_all_export_node(node: ast.AST, state: _ModuleWalkState) -> None:
pass


def _collect_lazy_export_node(node: ast.AST, state: _ModuleWalkState) -> None:
match node:
case ast.Assign(targets=targets, value=value):
names = {target.id for target in targets if isinstance(target, ast.Name)}
case ast.AnnAssign(target=ast.Name(id=name), value=value):
names = {name}
case (
ast.FunctionDef(name="__getattr__")
| ast.AsyncFunctionDef(name="__getattr__")
):
state.has_module_getattr = True
return
case _:
return
if "_EXPORTS" not in names or value is None:
return
for exported_name, module_path in _string_mapping_from_literal_dict(value).items():
state.lazy_export_bindings.setdefault(exported_name, set()).add(module_path)


def _collect_module_all_exports(tree: ast.AST, state: _ModuleWalkState) -> None:
if not isinstance(tree, ast.Module):
return
for statement in tree.body:
_collect_all_export_node(statement, state)
_collect_lazy_export_node(statement, state)


def _literal_getattr_name(value: ast.AST | None) -> str | None:
if not isinstance(value, ast.Call):
return None
if not isinstance(value.func, ast.Name) or value.func.id != "getattr":
return None
if len(value.args) < 2:
return None
attr_arg = value.args[1]
if not isinstance(attr_arg, ast.Constant) or not isinstance(attr_arg.value, str):
return None
if attr_arg.value.isidentifier():
return attr_arg.value
return None


def _iter_runtime_callable_scopes(
tree: ast.AST,
) -> Iterator[ast.FunctionDef | ast.AsyncFunctionDef]:
if not isinstance(tree, ast.Module):
return
stack = list(reversed(tree.body))
while stack:
node = stack.pop()
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef):
yield node
continue
if isinstance(node, ast.ClassDef):
stack.extend(reversed(node.body))


def _iter_scope_body_nodes(body: list[ast.stmt]) -> Iterator[ast.AST]:
stack: list[ast.AST] = list(reversed(body))
while stack:
node = stack.pop()
if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef | ast.ClassDef):
continue
yield node
stack.extend(reversed(list(ast.iter_child_nodes(node))))


def _dynamic_getattr_names_from_scope(
node: ast.FunctionDef | ast.AsyncFunctionDef,
) -> set[str]:
getattr_bindings: dict[str, str] = {}
callable_guards: set[str] = set()
called_locals: set[str] = set()
for scope_node in _iter_scope_body_nodes(node.body):
match scope_node:
case ast.Assign(targets=targets, value=value):
attr_name = _literal_getattr_name(value)
if attr_name is not None:
for target in targets:
if isinstance(target, ast.Name):
getattr_bindings[target.id] = attr_name
case ast.AnnAssign(target=ast.Name(id=name), value=value):
attr_name = _literal_getattr_name(value)
if attr_name is not None:
getattr_bindings[name] = attr_name
case ast.Call(
func=ast.Name(id="callable"),
args=[ast.Name(id=name), *_],
):
callable_guards.add(name)
case ast.Call(func=ast.Name(id=name)):
called_locals.add(name)
case _:
pass
return {
attr_name
for local_name, attr_name in getattr_bindings.items()
if local_name in callable_guards and local_name in called_locals
}


def _collect_dynamic_getattr_names(tree: ast.AST) -> set[str]:
names: set[str] = set()
for scope in _iter_runtime_callable_scopes(tree):
names.update(_dynamic_getattr_names_from_scope(scope))
return names


def _local_export_qualname(
*,
module_name: str,
exported_name: str,
functions_by_name: dict[str, str],
classes_by_name: dict[str, str],
) -> str | None:
local_qualname = functions_by_name.get(exported_name)
if local_qualname is None:
local_qualname = classes_by_name.get(exported_name)
if local_qualname is None:
return None
return f"{module_name}:{local_qualname}"


def _collect_import_from_node(
Expand Down Expand Up @@ -472,13 +607,19 @@ def _resolve_referenced_qualnames(
resolved.add(local_method_qualname)

for exported_name in state.exported_names:
local_qualname = top_level_function_by_name.get(exported_name)
if local_qualname is not None:
resolved.add(f"{module_name}:{local_qualname}")
local_export_qualname = _local_export_qualname(
module_name=module_name,
exported_name=exported_name,
functions_by_name=top_level_function_by_name,
classes_by_name=top_level_class_by_name,
)
if local_export_qualname is not None:
resolved.add(local_export_qualname)
continue
class_qualname = top_level_class_by_name.get(exported_name)
if class_qualname is not None:
resolved.add(f"{module_name}:{class_qualname}")
resolved.update(state.imported_symbol_bindings.get(exported_name, ()))
if state.has_module_getattr:
for module_path in state.lazy_export_bindings.get(exported_name, ()):
resolved.add(f"{module_path}:{exported_name}")

return frozenset(resolved)

Expand Down Expand Up @@ -524,6 +665,8 @@ def _collect_module_walk_data(
)
elif collect_referenced_names:
_collect_load_reference_node(node=node, state=state)
if collect_referenced_names:
state.referenced_names.update(_collect_dynamic_getattr_names(tree))

deps_sorted = tuple(
sorted(
Expand Down
Loading
Loading