From 76065e1769559418df06b16ef5b1e7fd866085ba Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 18 May 2026 15:38:35 +0500 Subject: [PATCH 1/7] fix(release): align 2.0.2 and vscode 0.2.7 metadata --- .github/actions/codeclone/README.md | 6 +++--- .github/actions/codeclone/_action_impl.py | 2 +- .github/actions/codeclone/action.yml | 2 +- CHANGELOG.md | 15 +++++++++++++++ README.md | 4 ++-- extensions/vscode-codeclone/CHANGELOG.md | 7 +++++-- extensions/vscode-codeclone/package-lock.json | 4 ++-- extensions/vscode-codeclone/package.json | 2 +- pyproject.toml | 2 +- uv.lock | 2 +- 10 files changed, 32 insertions(+), 14 deletions(-) diff --git a/.github/actions/codeclone/README.md b/.github/actions/codeclone/README.md index baf3e9d..5b8e183 100644 --- a/.github/actions/codeclone/README.md +++ b/.github/actions/codeclone/README.md @@ -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 @@ -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 | @@ -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: diff --git a/.github/actions/codeclone/_action_impl.py b/.github/actions/codeclone/_action_impl.py index 3bbbe56..bfb2d36 100644 --- a/.github/actions/codeclone/_action_impl.py +++ b/.github/actions/codeclone/_action_impl.py @@ -25,7 +25,7 @@ from typing import Literal COMMENT_MARKER = "" -DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.1" +DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.2" @dataclass(frozen=True, slots=True) diff --git a/.github/actions/codeclone/action.yml b/.github/actions/codeclone/action.yml index 65df75e..eff91fd 100644 --- a/.github/actions/codeclone/action.yml +++ b/.github/actions/codeclone/action.yml @@ -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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 77ceb5e..84550ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [2.0.2] - 2026-05-15 + +`2.0.2` is a focused patch release for VS Code extension packaging metadata +and README link behavior. + +### Packaging + +- 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. +- Fix README package badges so PyPI/status/download/Python-version links open + the PyPI project page instead of scrolling to the installation section. + ## [2.0.1] - 2026-05-14 `2.0.1` is a focused stability release for dead-code precision and cache/report diff --git a/README.md b/README.md index ebea7cb..b2f2585 100644 --- a/README.md +++ b/README.md @@ -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": ".", "...": "..." @@ -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 -[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 diff --git a/extensions/vscode-codeclone/CHANGELOG.md b/extensions/vscode-codeclone/CHANGELOG.md index 7d82828..3fe9bd2 100644 --- a/extensions/vscode-codeclone/CHANGELOG.md +++ b/extensions/vscode-codeclone/CHANGELOG.md @@ -1,11 +1,14 @@ # Change Log +## 0.2.7 + +- surface Coverage Join review items in Hotspots when coverage data is available +- auto-detect workspace-root `coverage.xml` or use `codeclone.analysis.coverageXml` + ## 0.2.6 - align setup guidance with the stable CodeClone `2.0.0` MCP package - require CodeClone `2.0.0` or newer for the final 2.0 release line -- surface Coverage Join review items in Hotspots when coverage data is available -- auto-detect workspace-root `coverage.xml` or use `codeclone.analysis.coverageXml` ## 0.2.5 diff --git a/extensions/vscode-codeclone/package-lock.json b/extensions/vscode-codeclone/package-lock.json index cbc2d73..b65a76c 100644 --- a/extensions/vscode-codeclone/package-lock.json +++ b/extensions/vscode-codeclone/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeclone", - "version": "0.2.6", + "version": "0.2.7", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeclone", - "version": "0.2.6", + "version": "0.2.7", "license": "MPL-2.0", "devDependencies": { "@types/node": "^25.5.2", diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 4f7a3c7..fdce31b 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -2,7 +2,7 @@ "name": "codeclone", "displayName": "CodeClone", "description": "Baseline-aware, triage-first structural review for Python, powered by CodeClone MCP.", - "version": "0.2.6", + "version": "0.2.7", "publisher": "orenlab", "license": "MPL-2.0", "repository": { diff --git a/pyproject.toml b/pyproject.toml index fa550e8..66e9a97 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codeclone" -version = "2.0.1" +version = "2.0.2" description = "A structural review layer for Python — baseline-aware, deterministic, built for CI and AI agents" readme = { file = "docs/README-pypi.md", content-type = "text/markdown" } license = "MPL-2.0 AND MIT" diff --git a/uv.lock b/uv.lock index 42a6264..d00c5f1 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ wheels = [ [[package]] name = "codeclone" -version = "2.0.1" +version = "2.0.2" source = { editable = "." } dependencies = [ { name = "orjson" }, From 314948d3306546f3cb425af111b4bda7a784cc32 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 18 May 2026 15:54:16 +0500 Subject: [PATCH 2/7] fix(dead-code): extend runtime reachability coverage --- CHANGELOG.md | 7 + codeclone/analysis/reachability.py | 200 +++++++++++++++++++++++++---- codeclone/cache/entries.py | 5 +- codeclone/models.py | 3 + docs/book/16-dead-code-contract.md | 7 +- pyproject.toml | 2 +- tests/test_cache.py | 3 + tests/test_extractor.py | 147 +++++++++++++++++++++ uv.lock | 144 +++++++++++---------- 9 files changed, 422 insertions(+), 96 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 84550ad..d645fbf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ `2.0.2` is a focused patch release for VS Code extension packaging metadata and README link behavior. +### Dead code + +- Extend runtime reachability with exact Aiogram `Router`/`Dispatcher` + observer decorators, Starlette `BaseHTTPMiddleware.dispatch` hooks, + Flask/Blueprint routes, and aiohttp `RouteTableDef` route decorators to + reduce false-positive dead-code findings without name-only heuristics. + ### Packaging - Bump the Python package and composite GitHub Action default install version to diff --git a/codeclone/analysis/reachability.py b/codeclone/analysis/reachability.py index dff0fa8..4551f76 100644 --- a/codeclone/analysis/reachability.py +++ b/codeclone/analysis/reachability.py @@ -35,6 +35,52 @@ "websocket", "websocket_route", } +_AIOGRAM_OBSERVER_METHODS = { + "business_connection", + "business_message", + "callback_query", + "channel_post", + "chat_boost", + "chat_join_request", + "chat_member", + "chosen_inline_result", + "deleted_business_messages", + "edited_business_message", + "edited_channel_post", + "edited_message", + "error", + "inline_query", + "message", + "message_reaction", + "message_reaction_count", + "my_chat_member", + "poll", + "poll_answer", + "pre_checkout_query", + "purchased_paid_media", + "shipping_query", +} +_AIOHTTP_ROUTE_METHODS = { + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "route", + "view", +} +_FLASK_ROUTE_METHODS = { + "delete", + "get", + "head", + "options", + "patch", + "post", + "put", + "route", +} _FASTAPI_DEPENDENCY_SYMBOLS = { "fastapi.Depends", "fastapi.Security", @@ -64,6 +110,12 @@ "ThreadLocalSingleton", } _DI_PROVIDER_SYMBOLS = {f"{_DI_PROVIDER_PREFIX}{name}" for name in _DI_PROVIDER_NAMES} +_STARLETTE_BASE_HTTP_MIDDLEWARE = "starlette.middleware.base.BaseHTTPMiddleware" +_RUNTIME_REGISTRATION_METHODS = { + "add_routes": ("aiohttp_app", "first_arg"), + "include_router": ("fastapi_app", "include_router"), + "register_blueprint": ("flask_app", "first_arg"), +} @dataclass(frozen=True, slots=True) @@ -200,12 +252,7 @@ def visit_AnnAssign(self, node: ast.AnnAssign) -> None: def visit_Call(self, node: ast.Call) -> None: symbol = _resolve_symbol(node.func, self._aliases) - match node.func: - case ast.Attribute(value=ast.Name(id=owner), attr="include_router"): - if self.objects.get(owner) == "fastapi_app": - self._collect_include_router_arg(node) - case _: - pass + self._collect_runtime_registration(node) if symbol == "fastapi.FastAPI.include_router": self._collect_include_router_arg(node) self.generic_visit(node) @@ -240,15 +287,50 @@ def _collect_include_router_arg(self, node: ast.Call) -> None: if router is not None: self.included_routers.add(router) + def _collect_runtime_registration(self, node: ast.Call) -> None: + match node.func: + case ast.Attribute(value=ast.Name(id=owner), attr=method): + expected = _RUNTIME_REGISTRATION_METHODS.get(method) + case _: + return + if expected is None: + return + expected_kind, collector = expected + if self.objects.get(owner) != expected_kind: + return + if collector == "include_router": + self._collect_include_router_arg(node) + else: + self._collect_first_arg_object(node) + + def _collect_first_arg_object(self, node: ast.Call) -> None: + if not node.args: + return + target = _dotted_name(node.args[0]) + if target is not None: + self.included_routers.add(target) + def _runtime_object_kind(self, value: ast.AST) -> _RuntimeObjectKind | None: if not isinstance(value, ast.Call): return None symbol = _resolve_symbol(value.func, self._aliases) match symbol: + case "aiogram.Dispatcher": + return "aiogram_dispatcher" + case "aiogram.Router": + return "aiogram_router" + case "aiohttp.web.Application": + return "aiohttp_app" + case "aiohttp.web.RouteTableDef": + return "aiohttp_routes" case "fastapi.FastAPI": return "fastapi_app" case "fastapi.APIRouter": return "fastapi_router" + case "flask.Blueprint": + return "flask_blueprint" + case "flask.Flask": + return "flask_app" case "starlette.applications.Starlette": return "starlette_app" case "starlette.routing.Router": @@ -351,6 +433,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def visit_ClassDef(self, node: ast.ClassDef) -> None: self._handle_dependency_injector_container(node) + self._handle_starlette_base_http_middleware(node) self.generic_visit(node) def visit_Assign(self, node: ast.Assign) -> None: @@ -390,24 +473,15 @@ def _route_registration(self, decorator: ast.AST) -> _RouteRegistration | None: func = call.func if call is not None else decorator match func: case ast.Attribute(value=ast.Name(id=obj_name), attr=method): - if method not in _FASTAPI_ROUTE_METHODS: - return None obj_kind = self._runtime_objects.get(obj_name) - if obj_kind not in { - "fastapi_app", - "fastapi_router", - "starlette_app", - "starlette_router", - }: - return None - framework: RuntimeReachabilityFramework = ( - "starlette" if obj_kind.startswith("starlette") else "fastapi" - ) - confidence: RuntimeReachabilityConfidence = ( - "high" - if obj_kind.endswith("_app") or obj_name in self._included_routers - else "medium" + route = self._decorator_route_registration( + obj_name=obj_name, + obj_kind=obj_kind, + method=method, ) + if route is None: + return None + framework, confidence = route return _RouteRegistration( framework=framework, confidence=confidence, @@ -417,6 +491,61 @@ def _route_registration(self, decorator: ast.AST) -> _RouteRegistration | None: case _: return None + def _decorator_route_registration( + self, + *, + obj_name: str, + obj_kind: _RuntimeObjectKind | None, + method: str, + ) -> tuple[RuntimeReachabilityFramework, RuntimeReachabilityConfidence] | None: + match obj_kind: + case "aiogram_dispatcher": + framework: RuntimeReachabilityFramework = "aiogram" + route_methods = _AIOGRAM_OBSERVER_METHODS + high_when = True + case "aiogram_router": + framework = "aiogram" + route_methods = _AIOGRAM_OBSERVER_METHODS + high_when = False + case "aiohttp_routes": + framework = "aiohttp" + route_methods = _AIOHTTP_ROUTE_METHODS + high_when = False + case "flask_app": + framework = "flask" + route_methods = _FLASK_ROUTE_METHODS + high_when = True + case "flask_blueprint": + framework = "flask" + route_methods = _FLASK_ROUTE_METHODS + high_when = False + case "fastapi_app" | "fastapi_router": + framework = "fastapi" + route_methods = _FASTAPI_ROUTE_METHODS + high_when = obj_kind == "fastapi_app" + case "starlette_app" | "starlette_router": + framework = "starlette" + route_methods = _FASTAPI_ROUTE_METHODS + high_when = obj_kind == "starlette_app" + case _: + return None + if method not in route_methods: + return None + return framework, self._registration_confidence( + obj_name, + high_when=high_when, + ) + + def _registration_confidence( + self, + obj_name: str, + *, + high_when: bool = False, + ) -> RuntimeReachabilityConfidence: + if high_when or obj_name in self._included_routers: + return "high" + return "medium" + def _handle_cli_or_task_decorator( self, target: _Target, @@ -648,6 +777,33 @@ def _django_view_target(self, node: ast.AST) -> _Target | None: case _: return self._target_from_expr(node) + def _handle_starlette_base_http_middleware(self, node: ast.ClassDef) -> None: + if not any( + _resolve_symbol(base, self._aliases) == _STARLETTE_BASE_HTTP_MIDDLEWARE + for base in node.bases + ): + return + class_target = self._class_targets.get(id(node)) + class_qualname = ( + class_target.qualname.split(":", 1)[-1] + if class_target is not None + else node.name + ) + for method in self._methods_by_class.get(class_qualname, []): + if method.qualname.rsplit(".", 1)[-1] != "dispatch": + continue + self._emit( + target=method, + framework="starlette", + edge_kind="registers_handler", + confidence="medium", + evidence="Starlette BaseHTTPMiddleware dispatch hook", + evidence_symbol="BaseHTTPMiddleware.dispatch", + source_qualname=class_target.qualname + if class_target is not None + else f"{self._module_name}:{node.name}", + ) + def _handle_dependency_injector_container(self, node: ast.ClassDef) -> None: if not any( self._is_dependency_injector_container_base(base) for base in node.bases diff --git a/codeclone/cache/entries.py b/codeclone/cache/entries.py index 54ea29a..ccf5453 100644 --- a/codeclone/cache/entries.py +++ b/codeclone/cache/entries.py @@ -333,7 +333,10 @@ def _as_security_surface_evidence_kind(value: object) -> str | None: def _as_runtime_reachability_framework(value: object) -> str | None: match value: case ( - "celery" + "aiogram" + | "aiohttp" + | "flask" + | "celery" | "click" | "dependency_injector" | "django" diff --git a/codeclone/models.py b/codeclone/models.py index 04c7ac9..b0536cb 100644 --- a/codeclone/models.py +++ b/codeclone/models.py @@ -120,11 +120,14 @@ class DeadCandidate: RuntimeReachabilityFramework = Literal[ + "aiogram", + "aiohttp", "celery", "click", "dependency_injector", "django", "fastapi", + "flask", "starlette", "typer", ] diff --git a/docs/book/16-dead-code-contract.md b/docs/book/16-dead-code-contract.md index 5a9200a..0050717 100644 --- a/docs/book/16-dead-code-contract.md +++ b/docs/book/16-dead-code-contract.md @@ -71,7 +71,9 @@ Refs: observes a deterministic edge from modern Python runtime surfaces: FastAPI/Starlette route and dependency registration, including `Annotated[..., Depends(...)]` and `Annotated[..., Security(...)]` route - parameters, Django URL patterns, Dependency Injector providers, Typer/Click + parameters, Starlette `BaseHTTPMiddleware.dispatch` hooks, Aiogram router + observer decorators, Flask/Blueprint routes, aiohttp `RouteTableDef` + decorators, Django URL patterns, Dependency Injector providers, Typer/Click commands, and Celery tasks. - Runtime reachability facts are evidence, not a full call graph. High- and medium-confidence facts prevent false dead-code findings; low-confidence @@ -149,6 +151,9 @@ Refs: - `tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[suppression_binding_scoped_to_target]` - `tests/test_extractor.py::test_dead_code_uses_fastapi_route_and_dependency_reachability` - `tests/test_extractor.py::test_dead_code_uses_fastapi_annotated_dependency_reachability` +- `tests/test_extractor.py::test_dead_code_uses_aiogram_router_observer_reachability` +- `tests/test_extractor.py::test_dead_code_uses_flask_and_aiohttp_route_reachability` +- `tests/test_extractor.py::test_dead_code_uses_starlette_base_http_middleware_dispatch_hook` - `tests/test_extractor.py::test_dead_code_uses_django_urlpattern_reachability` - `tests/test_extractor.py::test_dead_code_uses_dependency_injector_provider_reachability` - `tests/test_extractor.py::test_dead_code_uses_cli_and_task_registration_reachability` diff --git a/pyproject.toml b/pyproject.toml index 66e9a97..3be92df 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -73,7 +73,7 @@ dev = [ "build>=1.4.3", "twine>=6.2.0", "mypy>=1.20.1", - "ruff>=0.15.12", + "ruff>=0.15.13", "pre-commit>=4.5.1", ] diff --git a/tests/test_cache.py b/tests/test_cache.py index 443abcb..0ee089a 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -399,7 +399,10 @@ def test_security_surface_cache_helpers_reject_invalid_values() -> None: def test_runtime_reachability_cache_helpers_reject_invalid_values() -> None: + assert _as_runtime_reachability_framework("aiogram") == "aiogram" + assert _as_runtime_reachability_framework("aiohttp") == "aiohttp" assert _as_runtime_reachability_framework("fastapi") == "fastapi" + assert _as_runtime_reachability_framework("flask") == "flask" assert _as_runtime_reachability_framework("broken") is None assert _as_runtime_reachability_edge_kind("registers_handler") == ( "registers_handler" diff --git a/tests/test_extractor.py b/tests/test_extractor.py index c042598..7857cf0 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -1487,6 +1487,153 @@ def websocket_endpoint(): ].confidence == ("high") +def test_dead_code_uses_aiogram_router_observer_reachability() -> None: + source = """ +from aiogram import F, Dispatcher, Router +from aiogram.filters import Command + +router = Router() +dp = Dispatcher() +not_router = object() + +def get_router(): + return router + +@router.message(Command("start")) +async def cmd_start(message): + return message + +@router.callback_query(F.data.startswith("docker:container:")) +async def show_container_detail(callback): + return callback + +@dp.inline_query() +async def inline_search(query): + return query + +@not_router.message() +async def fake_message(message): + return message + +def orphan(): + return 1 +""" + + assert _dead_qualnames_from_source(source) == ( + "pkg.mod:get_router", + "pkg.mod:fake_message", + "pkg.mod:orphan", + ) + observed = { + fact.target_qualname: (fact.framework, fact.evidence_symbol, fact.confidence) + for fact in _runtime_reachability_from_source(source) + } + assert { + key: observed[key] + for key in ( + "pkg.mod:cmd_start", + "pkg.mod:show_container_detail", + "pkg.mod:inline_search", + ) + } == { + "pkg.mod:cmd_start": ("aiogram", "router.message", "medium"), + "pkg.mod:show_container_detail": ( + "aiogram", + "router.callback_query", + "medium", + ), + "pkg.mod:inline_search": ("aiogram", "dp.inline_query", "high"), + } + assert "pkg.mod:fake_message" not in observed + + +def test_dead_code_uses_flask_and_aiohttp_route_reachability() -> None: + source = """ +from aiohttp import web +from flask import Blueprint, Flask + +app = Flask(__name__) +bp = Blueprint("api", __name__) +aio_routes = web.RouteTableDef() +aio_app = web.Application() +other = object() + +app.register_blueprint(bp) +aio_app.add_routes(aio_routes) + +@app.route("/") +def flask_index(): + return "ok" + +@bp.get("/items") +def flask_items(): + return "items" + +@aio_routes.post("/items") +async def aio_items(request): + return request + +@other.route("/") +def fake_route(): + return "fake" + +def orphan(): + return 1 +""" + + assert _dead_qualnames_from_source(source) == ( + "pkg.mod:fake_route", + "pkg.mod:orphan", + ) + observed = { + fact.target_qualname: (fact.framework, fact.confidence) + for fact in _runtime_reachability_from_source(source) + } + assert observed == { + "pkg.mod:flask_index": ("flask", "high"), + "pkg.mod:flask_items": ("flask", "high"), + "pkg.mod:aio_items": ("aiohttp", "high"), + } + + +def test_dead_code_uses_starlette_base_http_middleware_dispatch_hook() -> None: + source = """ +from starlette.middleware.base import BaseHTTPMiddleware as MiddlewareBase + +class SecurityAuditMiddleware(MiddlewareBase): + async def dispatch(self, request, call_next): + return await call_next(request) + + async def helper(self): + return None + +class PlainMiddleware: + async def dispatch(self, request, call_next): + return await call_next(request) + +def orphan(): + return 1 +""" + + assert _dead_qualnames_from_source(source) == ( + "pkg.mod:SecurityAuditMiddleware", + "pkg.mod:SecurityAuditMiddleware.helper", + "pkg.mod:PlainMiddleware", + "pkg.mod:PlainMiddleware.dispatch", + "pkg.mod:orphan", + ) + facts = _runtime_reachability_from_source(source) + by_target = {fact.target_qualname: fact for fact in facts} + assert by_target["pkg.mod:SecurityAuditMiddleware.dispatch"].framework == ( + "starlette" + ) + assert ( + by_target["pkg.mod:SecurityAuditMiddleware.dispatch"].evidence_symbol + == "BaseHTTPMiddleware.dispatch" + ) + assert "pkg.mod:PlainMiddleware.dispatch" not in by_target + + def test_runtime_reachability_ignores_type_checking_only_frameworks() -> None: source = """ from typing import TYPE_CHECKING diff --git a/uv.lock b/uv.lock index d00c5f1..16d52e8 100644 --- a/uv.lock +++ b/uv.lock @@ -31,40 +31,42 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a9/9d/912fefab0e30aee6a3af8a62bbea4a81b29afa4ba2c973d31170620a26de/ast_serialize-0.3.0.tar.gz", hash = "sha256:1bc3ca09a63a021376527c4e938deedd11d11d675ce850e6f9c7487f5889992b", size = 60689, upload-time = "2026-04-30T23:24:48.104Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6f/57/a54d4de491d6cdd7a4e4b0952cc3ca9f60dcefa7b5fb48d6d492debe1649/ast_serialize-0.3.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:3a867927df59f76a18dc1d874a0b2c079b42c58972dca637905576deb0912e14", size = 1182966, upload-time = "2026-04-30T23:23:57.376Z" }, - { url = "https://files.pythonhosted.org/packages/ee/9e/a5db014bb0f91b209236b57c429389e31290c0093532b8436d577699b2fa/ast_serialize-0.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a6fb063bf040abf8321e7b8113a0554eda445ffc508aa51287f8808886a5ae22", size = 1171316, upload-time = "2026-04-30T23:23:59.63Z" }, - { url = "https://files.pythonhosted.org/packages/15/59/fd55133e478c4326f60a11df02573bf7ccb2ac685810b50f1803d0f68053/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5075cd8482573d743586779e5f9b652a015e37d4e95132d7e5a9bc5c8f483d8f", size = 1232234, upload-time = "2026-04-30T23:24:01.168Z" }, - { url = "https://files.pythonhosted.org/packages/cc/79/0ca1d26357ecb4a697d74d00b73ef3137f24c140424125393a0de820eb09/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:41560b27794f4553b0f77811e9fb325b77db4a2b39018d437e09932275306e66", size = 1233437, upload-time = "2026-04-30T23:24:03.151Z" }, - { url = "https://files.pythonhosted.org/packages/53/3e/7078ec94dd6e124b8e028ac77016a4f13c83fa1c145790f2e68f3816998b/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:b967c01ca74909c5d90e0fe4393401e2cc5da5ebd9a6262a19e45ffd3757dec8", size = 1440188, upload-time = "2026-04-30T23:24:04.717Z" }, - { url = "https://files.pythonhosted.org/packages/21/16/cca7195ef55a012f8013c3442afa91d287a0a36dcf88b480b262475135b3/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:424ebb8f46cd993f7cec4009d119312d8433dd90e6b0df0499cd2c91bdcc5af9", size = 1254211, upload-time = "2026-04-30T23:24:06.18Z" }, - { url = "https://files.pythonhosted.org/packages/a0/0f/f3d4dfae67dee6580534361a6343367d34217e7d25cff858bd1d8f03b8ed/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d14b1d566b56e2ee70b11fec1de7e0b94ec7cd83717ec7d189967841a361190e", size = 1255973, upload-time = "2026-04-30T23:24:07.772Z" }, - { url = "https://files.pythonhosted.org/packages/14/41/55fbfe02c42f40fbe3e74eda167d977d555ff720ce1abfa08515236efd88/ast_serialize-0.3.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7ba30b18735f047ec11103d1ab92f4789cf1fea1e0dc89b04a2f5a0632fd79de", size = 1298629, upload-time = "2026-04-30T23:24:09.4Z" }, - { url = "https://files.pythonhosted.org/packages/28/36/7d2501cacc7989fb8504aa9da2a2022a174200a59d4e6639de4367a57fdd/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e6ea0754cb7b0f682ebb005ffb0d18f8d17993490d9c289863cd69cacc4ab8df", size = 1408435, upload-time = "2026-04-30T23:24:11.013Z" }, - { url = "https://files.pythonhosted.org/packages/03/e7/54e3b469c3fa0bf9cd532fa643d1d33b73303f8d70beac3e366b68dd64b7/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:a0c5aa1073a5ba7b2abaa4b54abe8b8d75c4d1e2d54a2ff70b0ca6222fea5728", size = 1508174, upload-time = "2026-04-30T23:24:12.635Z" }, - { url = "https://files.pythonhosted.org/packages/b5/2a/9b9621865b02c60539e26d9b114a312b4fa46aa703e33e79317174bfea21/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:4e52650d834c1ea7791969a361de2c54c13b2fb4c519ec79445fa8b9021a147d", size = 1502354, upload-time = "2026-04-30T23:24:14.186Z" }, - { url = "https://files.pythonhosted.org/packages/34/dd/f138bc5c43b0c414fdd12eefe15677839323078b6e75301ad7f96cd26d45/ast_serialize-0.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:15bd6af3f136c61dae27805eb6b8f3269e85a545c4c27ffe9e530ead78d2b36d", size = 1450504, upload-time = "2026-04-30T23:24:16.076Z" }, - { url = "https://files.pythonhosted.org/packages/68/cf/97ef9e1c315601db74365955c8edd3292e3055500d6317602815dbdf08ae/ast_serialize-0.3.0-cp314-cp314t-win32.whl", hash = "sha256:d188bfe37b674b49708497683051d4b571366a668799c9b8e8a94513694969d9", size = 1058662, upload-time = "2026-04-30T23:24:17.535Z" }, - { url = "https://files.pythonhosted.org/packages/f8/d6/e2c3483c31580fdb623f92ad38d2f856cde4b9205a3e6bd84760f3de7d82/ast_serialize-0.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:5832c2fdf8f8a6cf682b4cfcf677f5eaf39b4ddbc490f5480cfccdd1e7ce8fa1", size = 1100349, upload-time = "2026-04-30T23:24:18.992Z" }, - { url = "https://files.pythonhosted.org/packages/ab/89/29abcb1fe18a429cda60c6e0bbd1d6e90499339842a2f548d7567542357e/ast_serialize-0.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:670f177188d128fb7f9f15b5ad0e1b553d22c34e3f584dcb83eb8077600437f0", size = 1072895, upload-time = "2026-04-30T23:24:20.706Z" }, - { url = "https://files.pythonhosted.org/packages/bc/93/72abad83966ed6235647c9f956417dc1e17e997696388521910e3d1fa3f4/ast_serialize-0.3.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:2ec2fafa5e4313cc8feed96e436ebe19ac7bc6fa41fbc2827e826c48b9e4c3a9", size = 1190024, upload-time = "2026-04-30T23:24:22.486Z" }, - { url = "https://files.pythonhosted.org/packages/85/4f/eb88584b2f0234e581762011208ca203252bf6c98e59b4769daa571f3576/ast_serialize-0.3.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef6d3c08b7b4cd29b48410338e134764a00e76d25841eb02c1084e868c888ecc", size = 1178633, upload-time = "2026-04-30T23:24:24.35Z" }, - { url = "https://files.pythonhosted.org/packages/56/51/cf1ec1ff3e616373d0dcbd5fad502e0029dc541f13ab642259762a7d127f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3d841424f41b886e98044abc80769c14a956e6e5ccd5fb5b0d9f5ead72be18a4", size = 1241351, upload-time = "2026-04-30T23:24:25.987Z" }, - { url = "https://files.pythonhosted.org/packages/0d/44/68fcf50478cf1093f2d423f034ae06453122c8b415d8e21a44668eca485d/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d21453734ad39367ede5d37efe4f59f830ce1c09f432fc72a90e368f77a4a3e7", size = 1239582, upload-time = "2026-04-30T23:24:27.808Z" }, - { url = "https://files.pythonhosted.org/packages/9d/c1/a6c9fa284eceb5fc6f21347e968445a051d7ca2c4d34e6a04314646dbcee/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:f5e110cdce2a347e1dd987529c88ef54d26f67848dce3eba1b3b2cc2cf085c94", size = 1448853, upload-time = "2026-04-30T23:24:29.534Z" }, - { url = "https://files.pythonhosted.org/packages/23/5f/8ad3829a09e4e8c5328a53ce7d4711d660944e3e164c5f6abcc2c8f27167/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3b6e23a98e57560a055f5c4b68700a0fd5ce483d2814c23140b3638c7f5d1e61", size = 1262204, upload-time = "2026-04-30T23:24:31.482Z" }, - { url = "https://files.pythonhosted.org/packages/25/13/44aa28d97f10e25247e8576b5f6b2795d4fa1a80acc88acc942c508d06f7/ast_serialize-0.3.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c1c9e763d70293d65ce1e1ea8c943140c68d0953f0268c7ee0998f2e07f77dd0", size = 1266458, upload-time = "2026-04-30T23:24:33.088Z" }, - { url = "https://files.pythonhosted.org/packages/d8/58/b3a8be3777cd3744324fd5cec0d80d37cd96fc7cbb0fb010e03dff1e870f/ast_serialize-0.3.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4388a1796c228f1ce5c391426f7d21a0003ad3b47f677dbeded9bd1a85c7209f", size = 1308700, upload-time = "2026-04-30T23:24:34.657Z" }, - { url = "https://files.pythonhosted.org/packages/13/03/f8312d6b57f5471a9dc7946f22b8798a1fc296d38c25766223aacadec42c/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:5283cdcc0c64c3d8b9b688dc6aaa012d9c0cf1380a7f774a6bae6a1c01b3205a", size = 1416724, upload-time = "2026-04-30T23:24:36.562Z" }, - { url = "https://files.pythonhosted.org/packages/50/5d/13fc3789a7abac00559da2e2e9f386db4612aa1f84fc53d09bf714c37545/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:f5ef88cc5842a5d7a6ac09dc0d5fc2c98f5d276c1f076f866d55047ce886785b", size = 1515441, upload-time = "2026-04-30T23:24:38.018Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b9/7ab43fc7a23b1f970281093228f5f79bed6edeed7a3e672bde6d7a832a58/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cc14bf402bdc0978594ecce783793de2c7470cd4f5cd7eb286ca97ed8ff7cba9", size = 1510522, upload-time = "2026-04-30T23:24:39.798Z" }, - { url = "https://files.pythonhosted.org/packages/56/ec/d75fc2b788d319f1fad77c14156896f31afdfc68af85b505e5bdebcb9592/ast_serialize-0.3.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:11eae0cf1b7b3e0678133cc2daa974ea972caf02eb4b3aa062af6fa9acd52c57", size = 1460917, upload-time = "2026-04-30T23:24:41.305Z" }, - { url = "https://files.pythonhosted.org/packages/95/74/f99c81193a2725911e1911ae567ed27c2f2419332c7f3537366f9d238cac/ast_serialize-0.3.0-cp39-abi3-win32.whl", hash = "sha256:2db3dd99de5e6a5a11d7dda73de8750eb6e5baaf25245adf7bdcfe64b6108ae2", size = 1067804, upload-time = "2026-04-30T23:24:43.091Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/76af00c47daa151e89f98ae21fbbcb2840aaa9f5766579c4da76a3c57188/ast_serialize-0.3.0-cp39-abi3-win_amd64.whl", hash = "sha256:a2cd125adccf7969470621905d302750cd25951f22ea430d9a25b7be031e5549", size = 1105561, upload-time = "2026-04-30T23:24:44.578Z" }, - { url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" }, +version = "0.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, + { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, + { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, + { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, + { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, + { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, + { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, + { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, + { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, + { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, + { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, + { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, + { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, + { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, + { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, + { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, + { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, + { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, + { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, + { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, + { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, + { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, + { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, + { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, + { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, + { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, + { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, + { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, ] [[package]] @@ -308,14 +310,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.3" +version = "8.4.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" } +sdist = { url = "https://files.pythonhosted.org/packages/23/e4/796662cd90cf80e3a363c99db2b88e0e394b988a575f60a17e16440cd011/click-8.4.0.tar.gz", hash = "sha256:638f1338fe1235c8f4e008e4a8a254fb5c5fbdcbb40ece3c9142ebb78e792973", size = 350843, upload-time = "2026-05-17T00:47:58.425Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" }, + { url = "https://files.pythonhosted.org/packages/ee/ae/8e92f8058baf87f6c7d86ee7e457668690195cc77efedb8d3797a06e3940/click-8.4.0-py3-none-any.whl", hash = "sha256:40c50b7c6c6adac2823d411041ec84f3f103f1b280d5e9ce0d7f998995832f81", size = 116147, upload-time = "2026-05-17T00:47:56.842Z" }, ] [[package]] @@ -358,7 +360,7 @@ requires-dist = [ { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" }, { name = "rich", specifier = ">=15.0.0" }, - { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.12" }, + { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.13" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=6.2.0" }, ] @@ -713,14 +715,14 @@ wheels = [ [[package]] name = "jaraco-functools" -version = "4.4.0" +version = "4.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "more-itertools" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0f/27/056e0638a86749374d6f57d0b0db39f29509cce9313cf91bdc0ac4d91084/jaraco_functools-4.4.0.tar.gz", hash = "sha256:da21933b0417b89515562656547a77b4931f98176eb173644c0d35032a33d6bb", size = 19943, upload-time = "2025-12-21T09:29:43.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/36/cf/ea4ef2920830dea3f5ab2ea4da6fb67724e6dca80ee2553788c3607243d0/jaraco_functools-4.5.0.tar.gz", hash = "sha256:3bb5665ea4a020cf78a7040e89154c77edadb3ca74f366479669c5999aa70b03", size = 20272, upload-time = "2026-05-15T21:34:10.025Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/c4/813bb09f0985cb21e959f21f2464169eca882656849adf727ac7bb7e1767/jaraco_functools-4.4.0-py3-none-any.whl", hash = "sha256:9eec1e36f45c818d9bf307c8948eb03b2b56cd44087b3cdc989abca1f20b9176", size = 10481, upload-time = "2025-12-21T09:29:42.27Z" }, + { url = "https://files.pythonhosted.org/packages/96/9a/982e48afcffcd727a9144506720ffd4224b6b7e355c98641866f38b7c043/jaraco_functools-4.5.0-py3-none-any.whl", hash = "sha256:79ce39246eddbde4b3a03b77ea5f0f7878dc669b166a66cf3fa8e266aa3fa2f4", size = 10594, upload-time = "2026-05-15T21:34:08.595Z" }, ] [[package]] @@ -1406,11 +1408,11 @@ wheels = [ [[package]] name = "python-multipart" -version = "0.0.28" +version = "0.0.29" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/82/54/a85eb421fbdd5007bc5af39d0f4ed9fa609e0fedbfdc2adcf0b34526870e/python_multipart-0.0.28.tar.gz", hash = "sha256:8550da197eac0f7ab748961fc9509b999fa2662ea25cef857f05249f6893c0f8", size = 45314, upload-time = "2026-05-10T11:05:16.596Z" } +sdist = { url = "https://files.pythonhosted.org/packages/4e/fe/70bd71a6738b09a0bdf6480ca6436b167469ca4578b2a0efbe390b4b0e70/python_multipart-0.0.29.tar.gz", hash = "sha256:643e93849196645e2dbdd81a0f8829a23123ad7f797a84a364c6fb3563f18904", size = 45678, upload-time = "2026-05-17T17:29:47.654Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/a2/43bbc5860b5034e2af4ef99a0e04d726ff329c43e192ef3abaa8d7ecfce5/python_multipart-0.0.28-py3-none-any.whl", hash = "sha256:10faac07eb966c3f48dc415f9dee46c04cb10d58d30a35677db8027c825ed9b6", size = 29438, upload-time = "2026-05-10T11:05:15.052Z" }, + { url = "https://files.pythonhosted.org/packages/8f/cb/769cfc37177252872a45a71f3fbdde9d51b471a3f3c14bfe95dde3407386/python_multipart-0.0.29-py3-none-any.whl", hash = "sha256:2ddcc971cef266225f54f552d8fa10bcfbb1f14446caec199060daac59ff2d69", size = 29640, upload-time = "2026-05-17T17:29:45.69Z" }, ] [[package]] @@ -1538,7 +1540,7 @@ wheels = [ [[package]] name = "requests" -version = "2.34.1" +version = "2.34.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1546,9 +1548,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/24/36/7180e7f077c38108945dbbdf60fe04db681c3feb6e96419f8c6dc8723741/requests-2.34.1.tar.gz", hash = "sha256:0fc5669f2b69704449fe1552360bd2a73a54512dfd03e65529157f1513322beb", size = 142783, upload-time = "2026-05-13T19:20:24.662Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/15/5a/4a949d170476de3c04ac036b5466422fbcbf348a917d8042eedf2cac7d1b/requests-2.34.1-py3-none-any.whl", hash = "sha256:bf38a3ff993960d3dd819c08862c40b3c703306eb7c744fcd9f4ddbb95b548f0", size = 73085, upload-time = "2026-05-13T19:20:22.827Z" }, + { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, ] [[package]] @@ -1709,27 +1711,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/99/43/3291f1cc9106f4c63bdce7a8d0df5047fe8422a75b091c16b5e9355e0b11/ruff-0.15.12.tar.gz", hash = "sha256:ecea26adb26b4232c0c2ca19ccbc0083a68344180bba2a600605538ce51a40a6", size = 4643852, upload-time = "2026-04-24T18:17:14.305Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c3/6e/e78ffb61d4686f3d96ba3df2c801161843746dcbcbb17a1e927d4829312b/ruff-0.15.12-py3-none-linux_armv6l.whl", hash = "sha256:f86f176e188e94d6bdbc09f09bfd9dc729059ad93d0e7390b5a73efe19f8861c", size = 10640713, upload-time = "2026-04-24T18:17:22.841Z" }, - { url = "https://files.pythonhosted.org/packages/ae/08/a317bc231fb9e7b93e4ef3089501e51922ff88d6936ce5cf870c4fe55419/ruff-0.15.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:e3bcd123364c3770b8e1b7baaf343cc99a35f197c5c6e8af79015c666c423a6c", size = 11069267, upload-time = "2026-04-24T18:17:30.105Z" }, - { url = "https://files.pythonhosted.org/packages/aa/a4/f828e9718d3dce1f5f11c39c4f65afd32783c8b2aebb2e3d259e492c47bd/ruff-0.15.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:fe87510d000220aa1ed530d4448a7c696a0cae1213e5ec30e5874287b66557b5", size = 10397182, upload-time = "2026-04-24T18:17:07.177Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/3310fc6d1b5e1fdea22bf3b1b807c7e187b581021b0d7d4514cccdb5fb71/ruff-0.15.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:84a1630093121375a3e2a95b4a6dc7b59e2b4ee76216e32d81aae550a832d002", size = 10758012, upload-time = "2026-04-24T18:16:55.759Z" }, - { url = "https://files.pythonhosted.org/packages/11/c1/a606911aee04c324ddaa883ae418f3569792fd3c4a10c50e0dd0a2311e1e/ruff-0.15.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fb129f40f114f089ebe0ca56c0d251cf2061b17651d464bb6478dc01e69f11f5", size = 10447479, upload-time = "2026-04-24T18:16:51.677Z" }, - { url = "https://files.pythonhosted.org/packages/9d/68/4201e8444f0894f21ab4aeeaee68aa4f10b51613514a20d80bd628d57e88/ruff-0.15.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b0c862b172d695db7598426b8af465e7e9ac00a3ea2a3630ee67eb82e366aaa6", size = 11234040, upload-time = "2026-04-24T18:17:16.529Z" }, - { url = "https://files.pythonhosted.org/packages/34/ff/8a6d6cf4ccc23fd67060874e832c18919d1557a0611ebef03fdb01fff11e/ruff-0.15.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2849ea9f3484c3aca43a82f484210370319e7170df4dfe4843395ddf6c57bc33", size = 12087377, upload-time = "2026-04-24T18:17:04.944Z" }, - { url = "https://files.pythonhosted.org/packages/85/f6/c669cf73f5152f623d34e69866a46d5e6185816b19fcd5b6dd8a2d299922/ruff-0.15.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e77c7e51c07fe396826d5969a5b846d9cd4c402535835fb6e21ce8b28fef847", size = 11367784, upload-time = "2026-04-24T18:17:25.409Z" }, - { url = "https://files.pythonhosted.org/packages/e8/39/c61d193b8a1daaa8977f7dea9e8d8ba866e02ea7b65d32f6861693aa4c12/ruff-0.15.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83b2f4f2f3b1026b5fb449b467d9264bf22067b600f7b6f41fc5958909f449d0", size = 11344088, upload-time = "2026-04-24T18:17:12.258Z" }, - { url = "https://files.pythonhosted.org/packages/c2/8d/49afab3645e31e12c590acb6d3b5b69d7aab5b81926dbaf7461f9441f37a/ruff-0.15.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:9ba3b8f1afd7e2e43d8943e55f249e13f9682fde09711644a6e7290eb4f3e339", size = 11271770, upload-time = "2026-04-24T18:17:02.457Z" }, - { url = "https://files.pythonhosted.org/packages/46/06/33f41fe94403e2b755481cdfb9b7ef3e4e0ed031c4581124658d935d52b4/ruff-0.15.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:e852ba9fdc890655e1d78f2df1499efbe0e54126bd405362154a75e2bde159c5", size = 10719355, upload-time = "2026-04-24T18:17:27.648Z" }, - { url = "https://files.pythonhosted.org/packages/0d/59/18aa4e014debbf559670e4048e39260a85c7fcee84acfd761ac01e7b8d35/ruff-0.15.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:dd8aed930da53780d22fc70bdf84452c843cf64f8cb4eb38984319c24c5cd5fd", size = 10462758, upload-time = "2026-04-24T18:17:32.347Z" }, - { url = "https://files.pythonhosted.org/packages/25/e7/cc9f16fd0f3b5fddcbd7ec3d6ae30c8f3fde1047f32a4093a98d633c6570/ruff-0.15.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:01da3988d225628b709493d7dc67c3b9b12c0210016b08690ef9bd27970b262b", size = 10953498, upload-time = "2026-04-24T18:17:20.674Z" }, - { url = "https://files.pythonhosted.org/packages/72/7a/a9ba7f98c7a575978698f4230c5e8cc54bbc761af34f560818f933dafa0c/ruff-0.15.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:9cae0f92bd5700d1213188b31cd3bdd2b315361296d10b96b8e2337d3d11f53e", size = 11447765, upload-time = "2026-04-24T18:17:09.755Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f9/0ae446942c846b8266059ad8a30702a35afae55f5cdc54c5adf8d7afdc27/ruff-0.15.12-py3-none-win32.whl", hash = "sha256:d0185894e038d7043ba8fd6aee7499ece6462dc0ea9f1e260c7451807c714c20", size = 10657277, upload-time = "2026-04-24T18:17:18.591Z" }, - { url = "https://files.pythonhosted.org/packages/33/f1/9614e03e1cdcbf9437570b5400ced8a720b5db22b28d8e0f1bda429f660d/ruff-0.15.12-py3-none-win_amd64.whl", hash = "sha256:c87a162d61ab3adca47c03f7f717c68672edec7d1b5499e652331780fe74950d", size = 11837758, upload-time = "2026-04-24T18:17:00.113Z" }, - { url = "https://files.pythonhosted.org/packages/c0/98/6beb4b351e472e5f4c4613f7c35a5290b8be2497e183825310c4c3a3984b/ruff-0.15.12-py3-none-win_arm64.whl", hash = "sha256:a538f7a82d061cee7be55542aca1d86d1393d55d81d4fcc314370f4340930d4f", size = 11120821, upload-time = "2026-04-24T18:16:57.979Z" }, +version = "0.15.13" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/24/21/a7d5c126d5b557715ef81098f3db2fe20f622a039ff2e626af28d674ab80/ruff-0.15.13.tar.gz", hash = "sha256:f9d89f17f7ba7fb2ed42921f0df75da797a9a5d71bc39049e2c687cf2baf44b7", size = 4678180, upload-time = "2026-05-14T13:44:37.869Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c6/61/11d458dc6ac22504fd8e237b29dfd40504c7fbbcc8930402cfe51a8e63ed/ruff-0.15.13-py3-none-linux_armv6l.whl", hash = "sha256:444b580fc72fd6887e650acd3e575e18cdc79dbcf42fb4030b491057921f61f8", size = 10738279, upload-time = "2026-05-14T13:44:18.7Z" }, + { url = "https://files.pythonhosted.org/packages/86/ca/caa871ee7be718c45256fada4e16a218ee3e33f0c4a46b729a60a24912e6/ruff-0.15.13-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:6590d009e7cb7ebf36f83dbdd44a3fa48a0994ff6f1cdc1b08006abe58f98dc7", size = 11124798, upload-time = "2026-05-14T13:44:06.427Z" }, + { url = "https://files.pythonhosted.org/packages/d3/19/43f5f2e568dddde567fc41f8471f9432c09563e19d3e617a48cfa52f8f0a/ruff-0.15.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:1c26d2f66163deeb6e08d8b39fbbe983ce3c71cea06a6d7591cfd1421793c629", size = 10460761, upload-time = "2026-05-14T13:44:04.375Z" }, + { url = "https://files.pythonhosted.org/packages/99/df/cf938cd6de3003178f03ad7c1ea2a6c099468c03a35037985070b37e76be/ruff-0.15.13-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dbd6f94b434f896308e4d57fb7bfde0d02b99f7a64b3bdab0fdfa6a864203a5", size = 10804451, upload-time = "2026-05-14T13:44:25.221Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/5d0973129b154ded2225729169d7068f26b467760b146493fde138415f23/ruff-0.15.13-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bf3259f3be4d181bda591da5db2571aed6853c6a048157756448020bc6c5cd22", size = 10534285, upload-time = "2026-05-14T13:44:08.888Z" }, + { url = "https://files.pythonhosted.org/packages/1f/e3/6b999bbc66cd51e5f073842bc2a3995e99c5e0e72e16b15e7261f7abf57a/ruff-0.15.13-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ae9c17e5eb4430c154e76abc25d79a318190f5a997f38fb6b114416c5319ffc9", size = 11312063, upload-time = "2026-05-14T13:44:11.274Z" }, + { url = "https://files.pythonhosted.org/packages/af/5a/642639e9f5db04f1e97fbd6e091c6fd20725bdf072fb114d00eefb9e6eb8/ruff-0.15.13-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e2e39bff6c341f4b577a21b801326fab0b11847f48fcaa83f00a113c9b3cb55", size = 12183079, upload-time = "2026-05-14T13:44:01.634Z" }, + { url = "https://files.pythonhosted.org/packages/19/4c/7585735f6b53b0f12de13618b2f7d250a844f018822efc899df2e7b8295f/ruff-0.15.13-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e8d9a8e08013542e94d3220bc5b62cc3e5ef87c5f74bff367d3fac14fab013e6", size = 11440833, upload-time = "2026-05-14T13:43:59.043Z" }, + { url = "https://files.pythonhosted.org/packages/e8/31/bf1a0803d077e679cfeee5f2f67290a0fa79c7385b5d9a8c17b9db2c48f0/ruff-0.15.13-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cc411dfebe5eebe55ce041c6ae080eb7668955e866daa2fbb16692a784f1c4ca", size = 11434486, upload-time = "2026-05-14T13:44:27.761Z" }, + { url = "https://files.pythonhosted.org/packages/e1/4e/62c9b999875d4f14db80f277c030578f5e249c9852d65b7ac7ad0b43c041/ruff-0.15.13-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:768494eb08b9cee54e2fd27969966f74db5a57f6eaa7a90fcb3306af34dfc4bd", size = 11385189, upload-time = "2026-05-14T13:44:13.704Z" }, + { url = "https://files.pythonhosted.org/packages/fc/89/7e959047a104df3eb12863447c110140191fc5b6c4f379ea2e803fcdb0e4/ruff-0.15.13-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:fb75f9a3a7e42ffe117d734494e6c5e5cb3565d66e12612cb63d0e572a41a5b6", size = 10781380, upload-time = "2026-05-14T13:43:56.734Z" }, + { url = "https://files.pythonhosted.org/packages/ff/52/5fd18f3b88cab63e88aa11516b3b4e1e5f720e5c330f8dbe5c26210f41f8/ruff-0.15.13-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:8cb74dd33bb2f6613faf7fc03b660053b5ac4f80e706d5788c6335e2a8048d51", size = 10540605, upload-time = "2026-05-14T13:44:20.748Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e0/9e35f338990d3e41a82875ff7053ffe97541dae81c9d02143177f381d572/ruff-0.15.13-py3-none-musllinux_1_2_i686.whl", hash = "sha256:7ef823f817fcd191dc934e984be9cf4094f808effa16f2542ad8e821ba02bbf2", size = 11036554, upload-time = "2026-05-14T13:44:16.256Z" }, + { url = "https://files.pythonhosted.org/packages/c2/13/070fb048c24080fba188f66371e2a92785be257ad02242066dc7255ac6e9/ruff-0.15.13-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:f345a13937bd7f09f6f5d19fa0721b0c103e00e7f62bc67089a8e5e037719e0b", size = 11528133, upload-time = "2026-05-14T13:44:22.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/8c/b1e1666aef7fc6555094d73ae6cd981701781ae85b97ceefc0eebd0b4668/ruff-0.15.13-py3-none-win32.whl", hash = "sha256:4044f94208b3b05ba0fc4a4abd0558cf4d6459bd18325eead7fd8cc66f909b41", size = 10721455, upload-time = "2026-05-14T13:44:35.697Z" }, + { url = "https://files.pythonhosted.org/packages/ab/a6/870a3e8a50590bb92be184ad928c2922f088b00d9dc5c5ec7b924ee08c22/ruff-0.15.13-py3-none-win_amd64.whl", hash = "sha256:7064884d442b7d477b4e7473d12da7f08851d2b1982763c5d3f388a19468a1a4", size = 11900409, upload-time = "2026-05-14T13:44:30.389Z" }, + { url = "https://files.pythonhosted.org/packages/9b/36/9c015cd052fca743dae8cb2aeb16b551444787467db42ceab0fc968865af/ruff-0.15.13-py3-none-win_arm64.whl", hash = "sha256:2471da9bd1068c8c064b5fd9c0c4b6dddffd6369cb1cd68b29993b1709ff1b21", size = 11179336, upload-time = "2026-05-14T13:44:33.026Z" }, ] [[package]] @@ -1877,16 +1879,16 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.46.0" +version = "0.47.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1f/93/041fca8274050e40e6791f267d82e0e2e27dd165627bd640d3e0e378d877/uvicorn-0.46.0.tar.gz", hash = "sha256:fb9da0926999cc6cb22dc7cd71a94a632f078e6ae47ff683c5c420750fb7413d", size = 88758, upload-time = "2026-04-23T07:16:00.151Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/b1/8e7077a8641086aea449e1b5752a570f1b5906c64e0a33cd6d93b63a066b/uvicorn-0.47.0.tar.gz", hash = "sha256:7c9a0ea1a9414106bbab7324609c162d8fa0cdcdcb703060987269d77c7bb533", size = 90582, upload-time = "2026-05-14T18:16:54.455Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/31/a3/5b1562db76a5a488274b2332a97199b32d0442aca0ed193697fd47786316/uvicorn-0.46.0-py3-none-any.whl", hash = "sha256:bbebbcbed972d162afca128605223022bedd345b7bc7855ce66deb31487a9048", size = 70926, upload-time = "2026-04-23T07:15:58.355Z" }, + { url = "https://files.pythonhosted.org/packages/15/41/ac2dfdbc1f60c7af4f994c7a335cfa7040c01642b605d65f611cecc2a1e4/uvicorn-0.47.0-py3-none-any.whl", hash = "sha256:2c5715bc12d1892d84752049f400cd1c3cb018514967fdfeb97640443a6a9432", size = 71301, upload-time = "2026-05-14T18:16:51.762Z" }, ] [[package]] From 24c5e72605550ca198bd943f51afb948290ad068 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 18 May 2026 16:24:10 +0500 Subject: [PATCH 3/7] fix(cache): invalidate stale reachability facts --- AGENTS.md | 2 +- CHANGELOG.md | 6 +++-- codeclone/contracts/__init__.py | 2 +- docs/README.md | 2 +- docs/book/07-cache.md | 4 ++-- docs/book/13-testing-as-spec.md | 2 +- docs/book/14-compatibility-and-versioning.md | 4 ++-- docs/book/appendix/b-schema-layouts.md | 4 ++-- tests/test_cache.py | 2 +- tests/test_extractor.py | 25 ++++++++++++++++++++ 10 files changed, 40 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 988d59f..4b28547 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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` | diff --git a/CHANGELOG.md b/CHANGELOG.md index d645fbf..2803e4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,8 @@ ## [2.0.2] - 2026-05-15 -`2.0.2` is a focused patch release for VS Code extension packaging metadata -and README link behavior. +`2.0.2` is a focused patch release for VS Code extension packaging metadata, +README link behavior, and dead-code runtime reachability precision. ### Dead code @@ -11,6 +11,8 @@ and README link behavior. observer decorators, Starlette `BaseHTTPMiddleware.dispatch` hooks, Flask/Blueprint routes, and aiohttp `RouteTableDef` route decorators to reduce false-positive dead-code findings without name-only heuristics. +- Bump cache schema to `2.8` so projects rebuild cached dead-code and runtime + reachability facts after the refined framework model. ### Packaging diff --git a/codeclone/contracts/__init__.py b/codeclone/contracts/__init__.py index 6ad1396..f7ff9e5 100644 --- a/codeclone/contracts/__init__.py +++ b/codeclone/contracts/__init__.py @@ -12,7 +12,7 @@ BASELINE_SCHEMA_VERSION: Final = "2.1" BASELINE_FINGERPRINT_VERSION: Final = "1" -CACHE_VERSION: Final = "2.7" +CACHE_VERSION: Final = "2.8" REPORT_SCHEMA_VERSION: Final = "2.11" METRICS_BASELINE_SCHEMA_VERSION: Final = "1.2" diff --git a/docs/README.md b/docs/README.md index 7b3d415..bbd2b6e 100644 --- a/docs/README.md +++ b/docs/README.md @@ -75,7 +75,7 @@ The Contracts Book defines: - [Config and defaults](book/04-config-and-defaults.md) - [Core pipeline and invariants](book/05-core-pipeline.md) - [Baseline contract (schema v2.1)](book/06-baseline.md) -- [Cache contract (schema v2.7)](book/07-cache.md) +- [Cache contract (schema v2.8)](book/07-cache.md) - [Report contract (schema v2.11)](book/08-report.md) ### Interfaces diff --git a/docs/book/07-cache.md b/docs/book/07-cache.md index 923f198..f38fd33 100644 --- a/docs/book/07-cache.md +++ b/docs/book/07-cache.md @@ -2,7 +2,7 @@ ## Purpose -Define cache schema `2.7`, integrity verification, stale-entry pruning, and +Define cache schema `2.8`, integrity verification, stale-entry pruning, and fail-open behavior. ## Public surface @@ -17,7 +17,7 @@ fail-open behavior. ## Data model -On-disk schema (`v == "2.7"`): +On-disk schema (`v == "2.8"`): - top-level: `v`, `payload`, `sig` - `payload` keys: `py`, `fp`, `ap`, `files`, optional `sr` diff --git a/docs/book/13-testing-as-spec.md b/docs/book/13-testing-as-spec.md index e32ee5c..5a1009d 100644 --- a/docs/book/13-testing-as-spec.md +++ b/docs/book/13-testing-as-spec.md @@ -36,7 +36,7 @@ The following matrix is treated as executable contract: | Contract | Tests | |--------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| | Baseline schema/integrity/compat gates | `tests/test_baseline.py` | -| Cache v2.7 fail-open + status mapping + API-surface-aware reuse + runtime-reachability/security-surface persistence + API signature order preservation | `tests/test_cache.py`, `tests/test_cli_inprocess.py::test_cli_reports_cache_too_large_respects_max_size_flag`, `tests/test_cli_inprocess.py::test_cli_public_api_breaking_count_stable_across_warm_cache`, `tests/test_cli_inprocess.py::test_cli_api_surface_ignores_non_api_warm_cache` | +| Cache v2.8 fail-open + status mapping + API-surface-aware reuse + runtime-reachability/security-surface persistence + API signature order preservation | `tests/test_cache.py`, `tests/test_cli_inprocess.py::test_cli_reports_cache_too_large_respects_max_size_flag`, `tests/test_cli_inprocess.py::test_cli_public_api_breaking_count_stable_across_warm_cache`, `tests/test_cli_inprocess.py::test_cli_api_surface_ignores_non_api_warm_cache` | | Exit code categories and markers | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py` | | Report schema v2.11 canonical/derived/integrity + JSON/TXT/MD/SARIF projections | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py` | | HTML render-only explainability + escaping | `tests/test_html_report.py` | diff --git a/docs/book/14-compatibility-and-versioning.md b/docs/book/14-compatibility-and-versioning.md index 570ff8b..0310cc1 100644 --- a/docs/book/14-compatibility-and-versioning.md +++ b/docs/book/14-compatibility-and-versioning.md @@ -24,7 +24,7 @@ Current contract versions: - `BASELINE_SCHEMA_VERSION = "2.1"` - `BASELINE_FINGERPRINT_VERSION = "1"` -- `CACHE_VERSION = "2.7"` +- `CACHE_VERSION = "2.8"` - `REPORT_SCHEMA_VERSION = "2.11"` - `METRICS_BASELINE_SCHEMA_VERSION = "1.2"` @@ -48,7 +48,7 @@ Operational compatibility rules: - runtime accepts clone baseline `1.0`, `2.0`, and `2.1` - runtime writes standalone metrics-baseline schema `1.2` - runtime accepts standalone metrics-baseline `1.1` and `1.2` -- runtime writes cache schema `2.7` +- runtime writes cache schema `2.8` - MCP does not define a separate schema constant; tool/resource semantics are package-versioned public surface Baseline regeneration is required when: diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index 281eb35..7c23ccb 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -91,11 +91,11 @@ Notes: } ``` -## Cache schema (`2.7`) +## Cache schema (`2.8`) ```json { - "v": "2.7", + "v": "2.8", "payload": { "py": "cp314", "fp": "1", diff --git a/tests/test_cache.py b/tests/test_cache.py index 0ee089a..ea71909 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -938,7 +938,7 @@ def test_cache_version_mismatch_warns(tmp_path: Path) -> None: assert loaded.cache_schema_version == "0.0" -@pytest.mark.parametrize("version", ["0.0", "2.2"]) +@pytest.mark.parametrize("version", ["0.0", "2.2", "2.7"]) def test_cache_v_field_version_mismatch_warns(tmp_path: Path, version: str) -> None: cache_path = tmp_path / "cache.json" cache = Cache(cache_path) diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 7857cf0..54761e8 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -2190,6 +2190,31 @@ def helper(self) -> None: ) +def test_dead_code_skips_pydantic_field_validators_without_direct_calls() -> None: + source = """ +from typing import Any +from pydantic import BaseModel, Field, field_validator + +class AllowedAction(BaseModel): + name: str = Field(min_length=1, max_length=64) + parameters: dict[str, Any] | None = Field(default=None) + + @field_validator("name") + @classmethod + def normalize_name(cls, value: str) -> str: + return value.strip() + + @field_validator("parameters") + @classmethod + def validate_parameters( + cls, + value: dict[str, Any] | None, + ) -> dict[str, Any] | None: + return value +""" + assert _dead_qualnames_from_source(source) == ("pkg.mod:AllowedAction",) + + def test_dead_code_keeps_explicitly_inherited_abc_base_live() -> None: source = """ from abc import ABC, abstractmethod From cb93b09a0333ec40e09f9e07e4f40cc43093f2fb Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 18 May 2026 16:35:03 +0500 Subject: [PATCH 4/7] chore(skill): clarifying instructions in skills --- plugins/codeclone/skills/codeclone-hotspots/SKILL.md | 2 ++ plugins/codeclone/skills/codeclone-review/SKILL.md | 2 ++ 2 files changed, 4 insertions(+) diff --git a/plugins/codeclone/skills/codeclone-hotspots/SKILL.md b/plugins/codeclone/skills/codeclone-hotspots/SKILL.md index b4e503b..fdc8604 100644 --- a/plugins/codeclone/skills/codeclone-hotspots/SKILL.md +++ b/plugins/codeclone/skills/codeclone-hotspots/SKILL.md @@ -50,6 +50,8 @@ analyze_repository → evaluate_gates - Use MCP tools only when invoked through the CodeClone plugin. - If no latest MCP run exists, call `analyze_repository` yourself before reading `latest/*` resources. - Use default thresholds — this is a quick check, not an exploratory deep-dive. +- For `check_*` tools, use `detail_level="summary"`, `"normal"`, or + `"full"` only. `compact` is valid only for `help(detail="compact")`. - One tool call is better than three when answering a simple question. - Summarize concisely — the user wants a snapshot, not a report. - Do not fall back to CLI or local report files. diff --git a/plugins/codeclone/skills/codeclone-review/SKILL.md b/plugins/codeclone/skills/codeclone-review/SKILL.md index 9868ea0..9bff67e 100644 --- a/plugins/codeclone/skills/codeclone-review/SKILL.md +++ b/plugins/codeclone/skills/codeclone-review/SKILL.md @@ -67,6 +67,8 @@ If the default pass looks clean: ## Tool preferences - Prefer `list_hotspots` or `check_*` before broad `list_findings`. +- For finding/list/check tools, use `detail_level="summary"`, `"normal"`, or + `"full"` only. `compact` is valid only for `help(detail="compact")`. - Use `get_finding` / `get_remediation` for one finding — not `detail_level=full` on lists. - Use `"production-only"` / `source_kind` filters to cut test noise. - Use `get_report_section(section="metrics")` for adoption, API-surface, or Coverage Join facts. From 7191b7a94663a9e5fa3143311d25264c578efb0b Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 18 May 2026 20:27:12 +0500 Subject: [PATCH 5/7] fix(dead-code): cover framework reachability edge cases --- CHANGELOG.md | 5 +- codeclone/analysis/reachability.py | 316 +++++++++++++++++++++++------ codeclone/cache/entries.py | 2 + codeclone/core/discovery_cache.py | 7 +- codeclone/models.py | 2 + codeclone/scanner/__init__.py | 1 + docs/book/05-core-pipeline.md | 5 + docs/book/16-dead-code-contract.md | 13 +- tests/test_cache.py | 2 + tests/test_extractor.py | 65 ++++++ tests/test_pipeline_metrics.py | 43 ++++ tests/test_scanner_extra.py | 16 ++ 12 files changed, 408 insertions(+), 69 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2803e4e..df149ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,11 @@ README link behavior, and dead-code runtime reachability precision. - Extend runtime reachability with exact Aiogram `Router`/`Dispatcher` observer decorators, Starlette `BaseHTTPMiddleware.dispatch` hooks, - Flask/Blueprint routes, and aiohttp `RouteTableDef` route decorators to + 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. - Bump cache schema to `2.8` so projects rebuild cached dead-code and runtime reachability facts after the refined framework model. diff --git a/codeclone/analysis/reachability.py b/codeclone/analysis/reachability.py index 4551f76..81c04cb 100644 --- a/codeclone/analysis/reachability.py +++ b/codeclone/analysis/reachability.py @@ -111,6 +111,25 @@ } _DI_PROVIDER_SYMBOLS = {f"{_DI_PROVIDER_PREFIX}{name}" for name in _DI_PROVIDER_NAMES} _STARLETTE_BASE_HTTP_MIDDLEWARE = "starlette.middleware.base.BaseHTTPMiddleware" +_SQLALCHEMY_TYPE_DECORATOR_SYMBOLS = { + "sqlalchemy.TypeDecorator", + "sqlalchemy.sql.type_api.TypeDecorator", + "sqlalchemy.types.TypeDecorator", +} +_SQLALCHEMY_TYPE_DECORATOR_HOOKS = { + "bind_expression", + "coerce_compared_value", + "column_expression", + "compare_values", + "load_dialect_impl", + "process_bind_param", + "process_literal_param", + "process_result_value", +} +_TYPING_CAST_SYMBOLS = { + "typing.cast", + "typing_extensions.cast", +} _RUNTIME_REGISTRATION_METHODS = { "add_routes": ("aiohttp_app", "first_arg"), "include_router": ("fastapi_app", "include_router"), @@ -130,10 +149,18 @@ class _Target: class _RouteRegistration: framework: RuntimeReachabilityFramework confidence: RuntimeReachabilityConfidence + evidence: str evidence_symbol: str source_qualname: str +@dataclass(frozen=True, slots=True) +class _RouteDecoratorFactory: + obj_name: str + obj_kind: _RuntimeObjectKind + method: str + + @dataclass(frozen=True, slots=True) class _ProviderRegistration: target: _Target @@ -141,6 +168,64 @@ class _ProviderRegistration: evidence_symbol: str +def _registration_confidence( + obj_name: str, + *, + included_routers: set[str], + high_when: bool = False, +) -> RuntimeReachabilityConfidence: + if high_when or obj_name in included_routers: + return "high" + return "medium" + + +def _route_registration_for_runtime_object( + *, + obj_name: str, + obj_kind: _RuntimeObjectKind | None, + method: str, + included_routers: set[str], +) -> tuple[RuntimeReachabilityFramework, RuntimeReachabilityConfidence] | None: + match obj_kind: + case "aiogram_dispatcher": + framework: RuntimeReachabilityFramework = "aiogram" + route_methods = _AIOGRAM_OBSERVER_METHODS + high_when = True + case "aiogram_router": + framework = "aiogram" + route_methods = _AIOGRAM_OBSERVER_METHODS + high_when = False + case "aiohttp_routes": + framework = "aiohttp" + route_methods = _AIOHTTP_ROUTE_METHODS + high_when = False + case "flask_app": + framework = "flask" + route_methods = _FLASK_ROUTE_METHODS + high_when = True + case "flask_blueprint": + framework = "flask" + route_methods = _FLASK_ROUTE_METHODS + high_when = False + case "fastapi_app" | "fastapi_router": + framework = "fastapi" + route_methods = _FASTAPI_ROUTE_METHODS + high_when = obj_kind == "fastapi_app" + case "starlette_app" | "starlette_router": + framework = "starlette" + route_methods = _FASTAPI_ROUTE_METHODS + high_when = obj_kind == "starlette_app" + case _: + return None + if method not in route_methods: + return None + return framework, _registration_confidence( + obj_name, + included_routers=included_routers, + high_when=high_when, + ) + + def _is_type_checking_guard(test: ast.AST) -> bool: match test: case ast.Name(id="TYPE_CHECKING"): @@ -219,12 +304,20 @@ def visit_ImportFrom(self, node: ast.ImportFrom) -> None: class _RuntimeBindingVisitor(ast.NodeVisitor): - __slots__ = ("_aliases", "included_routers", "objects") + __slots__ = ( + "_aliases", + "_scope_depth", + "included_routers", + "objects", + "route_decorator_factories", + ) def __init__(self, aliases: dict[str, str]) -> None: self._aliases = aliases + self._scope_depth = 0 self.objects: dict[str, _RuntimeObjectKind] = {} self.included_routers: set[str] = set() + self.route_decorator_factories: dict[str, _RouteDecoratorFactory] = {} def visit_If(self, node: ast.If) -> None: if _is_type_checking_guard(node.test): @@ -258,12 +351,29 @@ def visit_Call(self, node: ast.Call) -> None: self.generic_visit(node) def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._collect_click_group_binding(node) - self.generic_visit(node) + if self._scope_depth == 0: + self._collect_click_group_binding(node) + self._collect_route_decorator_factory(node) + self._visit_nested_scope(node) def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._collect_click_group_binding(node) - self.generic_visit(node) + if self._scope_depth == 0: + self._collect_click_group_binding(node) + self._collect_route_decorator_factory(node) + self._visit_nested_scope(node) + + def visit_ClassDef(self, node: ast.ClassDef) -> None: + self._visit_nested_scope(node) + + def _visit_nested_scope( + self, + node: ast.ClassDef | ast.FunctionDef | ast.AsyncFunctionDef, + ) -> None: + self._scope_depth += 1 + try: + self.generic_visit(node) + finally: + self._scope_depth -= 1 def _collect_click_group_binding( self, @@ -276,6 +386,85 @@ def _collect_click_group_binding( if symbol in {"click.group", "click.Group"}: self.objects[node.name] = "click_group" + def _collect_route_decorator_factory( + self, + node: ast.FunctionDef | ast.AsyncFunctionDef, + ) -> None: + local_route_aliases: dict[str, _RouteDecoratorFactory] = {} + for statement in node.body: + alias = self._route_method_assignment(statement) + if alias is not None: + name, factory = alias + local_route_aliases[name] = factory + continue + returned_factory = self._returned_route_factory( + statement, local_route_aliases + ) + if returned_factory is not None: + self.route_decorator_factories[node.name] = returned_factory + return + + def _route_method_assignment( + self, + statement: ast.stmt, + ) -> tuple[str, _RouteDecoratorFactory] | None: + match statement: + case ast.Assign(targets=[ast.Name(id=name), *_], value=value): + factory = self._route_method_reference(value) + case ast.AnnAssign(target=ast.Name(id=name), value=value): + factory = ( + self._route_method_reference(value) if value is not None else None + ) + case _: + return None + if factory is None: + return None + return name, factory + + def _returned_route_factory( + self, + statement: ast.stmt, + local_route_aliases: dict[str, _RouteDecoratorFactory], + ) -> _RouteDecoratorFactory | None: + match statement: + case ast.Return(value=ast.Call(func=ast.Name(id=name))): + return local_route_aliases.get(name) + case ast.Return(value=ast.Call(func=func)): + return self._route_method_reference(func) + case _: + return None + + def _route_method_reference(self, value: ast.AST) -> _RouteDecoratorFactory | None: + if ( + isinstance(value, ast.Call) + and _resolve_symbol(value.func, self._aliases) in _TYPING_CAST_SYMBOLS + ): + if len(value.args) < 2: + return None + return self._route_method_reference(value.args[1]) + match value: + case ast.Attribute(value=ast.Name(id=obj_name), attr=method): + obj_kind = self.objects.get(obj_name) + if obj_kind is None: + return None + if ( + _route_registration_for_runtime_object( + obj_name=obj_name, + obj_kind=obj_kind, + method=method, + included_routers=self.included_routers, + ) + is None + ): + return None + return _RouteDecoratorFactory( + obj_name=obj_name, + obj_kind=obj_kind, + method=method, + ) + case _: + return None + def _collect_include_router_arg(self, node: ast.Call) -> None: if node.args: router = _dotted_name(node.args[0]) @@ -354,6 +543,7 @@ class _RuntimeReachabilityVisitor(ast.NodeVisitor): "_included_routers", "_methods_by_class", "_module_name", + "_route_decorator_factories", "_runtime_objects", "_seen", "_targets_by_name", @@ -369,12 +559,14 @@ def __init__( aliases: dict[str, str], runtime_objects: dict[str, _RuntimeObjectKind], included_routers: set[str], + route_decorator_factories: dict[str, _RouteDecoratorFactory], ) -> None: self._module_name = module_name self._filepath = filepath self._aliases = aliases self._runtime_objects = runtime_objects self._included_routers = included_routers + self._route_decorator_factories = route_decorator_factories self._function_targets: dict[int, _Target] = {} self._class_targets: dict[int, _Target] = {} self._methods_by_class: dict[str, list[_Target]] = {} @@ -434,6 +626,7 @@ def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: def visit_ClassDef(self, node: ast.ClassDef) -> None: self._handle_dependency_injector_container(node) self._handle_starlette_base_http_middleware(node) + self._handle_sqlalchemy_type_decorator(node) self.generic_visit(node) def visit_Assign(self, node: ast.Assign) -> None: @@ -456,7 +649,7 @@ def _handle_callable(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None framework=route.framework, edge_kind="registers_handler", confidence=route.confidence, - evidence="route decorator", + evidence=route.evidence, evidence_symbol=route.evidence_symbol, source_qualname=route.source_qualname, ) @@ -474,10 +667,11 @@ def _route_registration(self, decorator: ast.AST) -> _RouteRegistration | None: match func: case ast.Attribute(value=ast.Name(id=obj_name), attr=method): obj_kind = self._runtime_objects.get(obj_name) - route = self._decorator_route_registration( + route = _route_registration_for_runtime_object( obj_name=obj_name, obj_kind=obj_kind, method=method, + included_routers=self._included_routers, ) if route is None: return None @@ -485,67 +679,33 @@ def _route_registration(self, decorator: ast.AST) -> _RouteRegistration | None: return _RouteRegistration( framework=framework, confidence=confidence, + evidence="route decorator", evidence_symbol=f"{obj_name}.{method}", source_qualname=f"{self._module_name}:{obj_name}", ) + case ast.Name(id=factory_name): + factory = self._route_decorator_factories.get(factory_name) + if factory is None: + return None + route = _route_registration_for_runtime_object( + obj_name=factory.obj_name, + obj_kind=factory.obj_kind, + method=factory.method, + included_routers=self._included_routers, + ) + if route is None: + return None + framework, confidence = route + return _RouteRegistration( + framework=framework, + confidence=confidence, + evidence="route decorator factory", + evidence_symbol=factory_name, + source_qualname=f"{self._module_name}:{factory_name}", + ) case _: return None - def _decorator_route_registration( - self, - *, - obj_name: str, - obj_kind: _RuntimeObjectKind | None, - method: str, - ) -> tuple[RuntimeReachabilityFramework, RuntimeReachabilityConfidence] | None: - match obj_kind: - case "aiogram_dispatcher": - framework: RuntimeReachabilityFramework = "aiogram" - route_methods = _AIOGRAM_OBSERVER_METHODS - high_when = True - case "aiogram_router": - framework = "aiogram" - route_methods = _AIOGRAM_OBSERVER_METHODS - high_when = False - case "aiohttp_routes": - framework = "aiohttp" - route_methods = _AIOHTTP_ROUTE_METHODS - high_when = False - case "flask_app": - framework = "flask" - route_methods = _FLASK_ROUTE_METHODS - high_when = True - case "flask_blueprint": - framework = "flask" - route_methods = _FLASK_ROUTE_METHODS - high_when = False - case "fastapi_app" | "fastapi_router": - framework = "fastapi" - route_methods = _FASTAPI_ROUTE_METHODS - high_when = obj_kind == "fastapi_app" - case "starlette_app" | "starlette_router": - framework = "starlette" - route_methods = _FASTAPI_ROUTE_METHODS - high_when = obj_kind == "starlette_app" - case _: - return None - if method not in route_methods: - return None - return framework, self._registration_confidence( - obj_name, - high_when=high_when, - ) - - def _registration_confidence( - self, - obj_name: str, - *, - high_when: bool = False, - ) -> RuntimeReachabilityConfidence: - if high_when or obj_name in self._included_routers: - return "high" - return "medium" - def _handle_cli_or_task_decorator( self, target: _Target, @@ -804,6 +964,37 @@ def _handle_starlette_base_http_middleware(self, node: ast.ClassDef) -> None: else f"{self._module_name}:{node.name}", ) + def _handle_sqlalchemy_type_decorator(self, node: ast.ClassDef) -> None: + if not any( + _resolve_symbol(base, self._aliases) in _SQLALCHEMY_TYPE_DECORATOR_SYMBOLS + for base in node.bases + ): + return + class_target = self._class_targets.get(id(node)) + class_qualname = ( + class_target.qualname.split(":", 1)[-1] + if class_target is not None + else node.name + ) + source_qualname = ( + class_target.qualname + if class_target is not None + else f"{self._module_name}:{node.name}" + ) + for method in self._methods_by_class.get(class_qualname, []): + method_name = method.qualname.rsplit(".", 1)[-1] + if method_name not in _SQLALCHEMY_TYPE_DECORATOR_HOOKS: + continue + self._emit( + target=method, + framework="sqlalchemy", + edge_kind="runtime_hook", + confidence="medium", + evidence="SQLAlchemy TypeDecorator hook", + evidence_symbol=f"TypeDecorator.{method_name}", + source_qualname=source_qualname, + ) + def _handle_dependency_injector_container(self, node: ast.ClassDef) -> None: if not any( self._is_dependency_injector_container_base(base) for base in node.bases @@ -947,6 +1138,7 @@ def collect_runtime_reachability( aliases=alias_visitor.aliases, runtime_objects=binding_visitor.objects, included_routers=binding_visitor.included_routers, + route_decorator_factories=binding_visitor.route_decorator_factories, ) visitor.visit(tree) return tuple( diff --git a/codeclone/cache/entries.py b/codeclone/cache/entries.py index ccf5453..67d33c2 100644 --- a/codeclone/cache/entries.py +++ b/codeclone/cache/entries.py @@ -341,6 +341,7 @@ def _as_runtime_reachability_framework(value: object) -> str | None: | "dependency_injector" | "django" | "fastapi" + | "sqlalchemy" | "starlette" | "typer" ): @@ -357,6 +358,7 @@ def _as_runtime_reachability_edge_kind(value: object) -> str | None: | "registers_command" | "registers_handler" | "registers_task" + | "runtime_hook" ): return value case _: diff --git a/codeclone/core/discovery_cache.py b/codeclone/core/discovery_cache.py index 60761a8..0b63f9b 100644 --- a/codeclone/core/discovery_cache.py +++ b/codeclone/core/discovery_cache.py @@ -182,11 +182,15 @@ def _runtime_reachability_framework( ) -> RuntimeReachabilityFramework | None: match value: case ( - "celery" + "aiogram" + | "aiohttp" + | "celery" | "click" | "dependency_injector" | "django" | "fastapi" + | "flask" + | "sqlalchemy" | "starlette" | "typer" ): @@ -205,6 +209,7 @@ def _runtime_reachability_edge_kind( | "registers_command" | "registers_handler" | "registers_task" + | "runtime_hook" ): return value case _: diff --git a/codeclone/models.py b/codeclone/models.py index b0536cb..5475cb9 100644 --- a/codeclone/models.py +++ b/codeclone/models.py @@ -128,6 +128,7 @@ class DeadCandidate: "django", "fastapi", "flask", + "sqlalchemy", "starlette", "typer", ] @@ -137,6 +138,7 @@ class DeadCandidate: "registers_command", "registers_handler", "registers_task", + "runtime_hook", ] RuntimeReachabilityConfidence = Literal["high", "medium", "low"] RuntimeReachabilityTargetKind = Literal["function", "class", "method"] diff --git a/codeclone/scanner/__init__.py b/codeclone/scanner/__init__.py index 8f05ffc..4d89478 100644 --- a/codeclone/scanner/__init__.py +++ b/codeclone/scanner/__init__.py @@ -21,6 +21,7 @@ ".venv", "venv", "__pycache__", + "node_modules", "site-packages", "migrations", "alembic", diff --git a/docs/book/05-core-pipeline.md b/docs/book/05-core-pipeline.md index 8c0d509..55da44f 100644 --- a/docs/book/05-core-pipeline.md +++ b/docs/book/05-core-pipeline.md @@ -61,6 +61,10 @@ Refs: not affect health, gates, or suggestions. - Test-path liveness references are filtered both on fresh extraction and on cache decode. +- Default discovery skips generated/dependency directories such as `.git`, + virtualenvs, `site-packages`, `node_modules`, migrations, `dist`, and + `build`; users can still pass explicit scanner excludes for project-specific + layouts. Refs: @@ -106,6 +110,7 @@ Refs: ## Locked by tests - `tests/test_scanner_extra.py::test_iter_py_files_deterministic_sorted_order` +- `tests/test_scanner_extra.py::test_iter_py_files_excludes_node_modules` - `tests/test_cli_inprocess.py::test_cli_summary_cache_miss_metrics` - `tests/test_cli_inprocess.py::test_cli_unreadable_source_fails_in_ci_with_contract_error` - `tests/test_extractor.py::test_parse_limits_triggers_timeout` diff --git a/docs/book/16-dead-code-contract.md b/docs/book/16-dead-code-contract.md index 0050717..efa9b49 100644 --- a/docs/book/16-dead-code-contract.md +++ b/docs/book/16-dead-code-contract.md @@ -70,11 +70,12 @@ Refs: - Runtime framework registration facts can mark a symbol live when the extractor observes a deterministic edge from modern Python runtime surfaces: FastAPI/Starlette route and dependency registration, including - `Annotated[..., Depends(...)]` and `Annotated[..., Security(...)]` route - parameters, Starlette `BaseHTTPMiddleware.dispatch` hooks, Aiogram router - observer decorators, Flask/Blueprint routes, aiohttp `RouteTableDef` - decorators, Django URL patterns, Dependency Injector providers, Typer/Click - commands, and Celery tasks. + typed route decorator factories, `Annotated[..., Depends(...)]` and + `Annotated[..., Security(...)]` route parameters, Starlette + `BaseHTTPMiddleware.dispatch` hooks, Aiogram router observer decorators, + Flask/Blueprint routes, aiohttp `RouteTableDef` decorators, Django URL + patterns, Dependency Injector providers, Typer/Click commands, Celery tasks, + and SQLAlchemy `TypeDecorator` runtime hooks. - Runtime reachability facts are evidence, not a full call graph. High- and medium-confidence facts prevent false dead-code findings; low-confidence facts, if introduced later, must remain report-only until explicitly wired. @@ -151,9 +152,11 @@ Refs: - `tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[suppression_binding_scoped_to_target]` - `tests/test_extractor.py::test_dead_code_uses_fastapi_route_and_dependency_reachability` - `tests/test_extractor.py::test_dead_code_uses_fastapi_annotated_dependency_reachability` +- `tests/test_extractor.py::test_dead_code_uses_fastapi_route_decorator_factory_reachability` - `tests/test_extractor.py::test_dead_code_uses_aiogram_router_observer_reachability` - `tests/test_extractor.py::test_dead_code_uses_flask_and_aiohttp_route_reachability` - `tests/test_extractor.py::test_dead_code_uses_starlette_base_http_middleware_dispatch_hook` +- `tests/test_extractor.py::test_dead_code_uses_sqlalchemy_type_decorator_runtime_hooks` - `tests/test_extractor.py::test_dead_code_uses_django_urlpattern_reachability` - `tests/test_extractor.py::test_dead_code_uses_dependency_injector_provider_reachability` - `tests/test_extractor.py::test_dead_code_uses_cli_and_task_registration_reachability` diff --git a/tests/test_cache.py b/tests/test_cache.py index ea71909..fe40076 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -403,10 +403,12 @@ def test_runtime_reachability_cache_helpers_reject_invalid_values() -> None: assert _as_runtime_reachability_framework("aiohttp") == "aiohttp" assert _as_runtime_reachability_framework("fastapi") == "fastapi" assert _as_runtime_reachability_framework("flask") == "flask" + assert _as_runtime_reachability_framework("sqlalchemy") == "sqlalchemy" assert _as_runtime_reachability_framework("broken") is None assert _as_runtime_reachability_edge_kind("registers_handler") == ( "registers_handler" ) + assert _as_runtime_reachability_edge_kind("runtime_hook") == "runtime_hook" assert _as_runtime_reachability_edge_kind("broken") is None assert _as_runtime_reachability_confidence("medium") == "medium" assert _as_runtime_reachability_confidence("broken") is None diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 54761e8..9e96d10 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -1401,6 +1401,7 @@ def test_runtime_reachability_internal_guards_stay_safe() -> None: }, runtime_objects={}, included_routers=set(), + route_decorator_factories={}, ) tree = ast.parse( """ @@ -1487,6 +1488,36 @@ def websocket_endpoint(): ].confidence == ("high") +def test_dead_code_uses_fastapi_route_decorator_factory_reachability() -> None: + source = """ +from typing import cast +from fastapi import APIRouter, status + +router = APIRouter() + +def _typed_get(*args: object, **kwargs: object): + route_get = cast(object, router.get) + return route_get(*args, **kwargs) + +@_typed_get("", status_code=status.HTTP_200_OK) +async def get_system_metrics(): + return {} + +def orphan(): + return 1 +""" + + assert _dead_qualnames_from_source(source) == ("pkg.mod:orphan",) + facts = _runtime_reachability_from_source(source) + by_target = {fact.target_qualname: fact for fact in facts} + + assert by_target["pkg.mod:get_system_metrics"].framework == "fastapi" + assert by_target["pkg.mod:get_system_metrics"].evidence == ( + "route decorator factory" + ) + assert by_target["pkg.mod:get_system_metrics"].evidence_symbol == "_typed_get" + + def test_dead_code_uses_aiogram_router_observer_reachability() -> None: source = """ from aiogram import F, Dispatcher, Router @@ -1634,6 +1665,40 @@ def orphan(): assert "pkg.mod:PlainMiddleware.dispatch" not in by_target +def test_dead_code_uses_sqlalchemy_type_decorator_runtime_hooks() -> None: + source = """ +from sqlalchemy import JSON +from sqlalchemy.types import TypeDecorator + +class OrjsonJSON(TypeDecorator[object]): + impl = JSON + cache_ok = True + + def process_bind_param(self, value, dialect): + return value + + def process_result_value(self, value, dialect): + return value + + def helper(self): + return None +""" + + dead = set(_dead_qualnames_from_source(source)) + + assert "pkg.mod:OrjsonJSON.process_bind_param" not in dead + assert "pkg.mod:OrjsonJSON.process_result_value" not in dead + assert "pkg.mod:OrjsonJSON.helper" in dead + facts = _runtime_reachability_from_source(source) + by_target = {fact.target_qualname: fact for fact in facts} + assert by_target["pkg.mod:OrjsonJSON.process_bind_param"].framework == ( + "sqlalchemy" + ) + assert by_target["pkg.mod:OrjsonJSON.process_result_value"].edge_kind == ( + "runtime_hook" + ) + + def test_runtime_reachability_ignores_type_checking_only_frameworks() -> None: source = """ from typing import TYPE_CHECKING diff --git a/tests/test_pipeline_metrics.py b/tests/test_pipeline_metrics.py index bcd1d3d..72626cc 100644 --- a/tests/test_pipeline_metrics.py +++ b/tests/test_pipeline_metrics.py @@ -44,6 +44,8 @@ _public_symbol_from_cache_dict, _public_symbol_kind, _risk_level, + _runtime_reachability_edge_kind, + _runtime_reachability_framework, _security_surface_category, _security_surface_classification_mode, _security_surface_evidence_kind, @@ -1125,6 +1127,47 @@ def test_discovery_cache_security_surface_helpers_accept_and_reject( assert helper("broken") is None +@pytest.mark.parametrize( + ("helper", "accepted"), + ( + ( + _runtime_reachability_framework, + ( + "aiogram", + "aiohttp", + "celery", + "click", + "dependency_injector", + "django", + "fastapi", + "flask", + "sqlalchemy", + "starlette", + "typer", + ), + ), + ( + _runtime_reachability_edge_kind, + ( + "declares_dependency", + "provides", + "registers_command", + "registers_handler", + "registers_task", + "runtime_hook", + ), + ), + ), +) +def test_discovery_cache_runtime_reachability_helpers_accept_and_reject( + helper: Callable[[object], object | None], + accepted: tuple[str, ...], +) -> None: + for value in accepted: + assert helper(value) == value + assert helper("broken") is None + + def test_discovery_cache_parsers_reject_invalid_rows_and_skip_invalid_entries() -> None: assert _api_param_spec_from_cache_dict([]) is None assert ( diff --git a/tests/test_scanner_extra.py b/tests/test_scanner_extra.py index e00bff7..fbb54b9 100644 --- a/tests/test_scanner_extra.py +++ b/tests/test_scanner_extra.py @@ -57,6 +57,22 @@ def test_iter_py_files_excludes(tmp_path: Path) -> None: assert str(skip) not in files +def test_iter_py_files_excludes_node_modules(tmp_path: Path) -> None: + src = tmp_path / "src" + src.mkdir() + good = src / "app.py" + good.write_text("x = 1\n", "utf-8") + vendored = tmp_path / "frontend" / "node_modules" / "flatted" / "python" + vendored.mkdir(parents=True) + vendored_file = vendored / "flatted.py" + vendored_file.write_text("def stringify(value):\n return value\n", "utf-8") + + files = list(iter_py_files(str(tmp_path))) + + assert str(good) in files + assert str(vendored_file) not in files + + def test_iter_py_files_deterministic_sorted_order(tmp_path: Path) -> None: z_file = tmp_path / "z.py" z_file.write_text("z = 1\n", "utf-8") From c3494822a3b0cf5879b24d2bb31105617dcab9d4 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 18 May 2026 20:46:18 +0500 Subject: [PATCH 6/7] fix(html): preserve JetBrains line navigation --- CHANGELOG.md | 2 ++ codeclone/report/html/assets/js.py | 26 ++++++++++++++++++++------ tests/test_html_report.py | 20 ++++++++++++++++++++ 3 files changed, 42 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index df149ae..4734001 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,8 @@ README link behavior, and dead-code runtime reachability precision. dependencies do not appear as project dead-code findings. - Bump cache schema to `2.8` so projects rebuild cached dead-code and runtime reachability facts after the refined framework model. +- Fix HTML report PyCharm/IntelliJ source links so they preserve line + navigation when opening files from report tables. ### Packaging diff --git a/codeclone/report/html/assets/js.py b/codeclone/report/html/assets/js.py index 0a59b38..aec07d0 100644 --- a/codeclone/report/html/assets/js.py +++ b/codeclone/report/html/assets/js.py @@ -692,19 +692,33 @@ return abs; } + function lineNo(value){ + var n=parseInt(value,10); + return Number.isFinite(n)&&n>0?n:1; + } + + function jetbrainsReferencePath(f,l){ + // JetBrains parses the path query value as "path/to/file.py:line". + // Keep path separators literal; fully encoded slashes can open the file + // while preventing the trailing line reference from being recognized. + return encodeURIComponent(relPath(f)) + .replace(/%2F/gi,'/') + .replace(/%3A/gi,':')+':'+lineNo(l); + } + const SCHEMES={ pycharm:{label:'PyCharm', - url:function(f,l){return 'jetbrains://pycharm/navigate/reference?project='+encodeURIComponent(projectName)+'&path='+encodeURIComponent(relPath(f))+':'+l}}, + url:function(f,l){return 'jetbrains://pycharm/navigate/reference?project='+encodeURIComponent(projectName)+'&path='+jetbrainsReferencePath(f,l)}}, idea:{label:'IntelliJ IDEA', - url:function(f,l){return 'jetbrains://idea/navigate/reference?project='+encodeURIComponent(projectName)+'&path='+encodeURIComponent(relPath(f))+':'+l}}, + url:function(f,l){return 'jetbrains://idea/navigate/reference?project='+encodeURIComponent(projectName)+'&path='+jetbrainsReferencePath(f,l)}}, vscode:{label:'VS Code', - url:function(f,l){return 'vscode://file'+f+':'+l}}, + url:function(f,l){return 'vscode://file'+f+':'+lineNo(l)}}, cursor:{label:'Cursor', - url:function(f,l){return 'cursor://file'+f+':'+l}}, + url:function(f,l){return 'cursor://file'+f+':'+lineNo(l)}}, fleet:{label:'Fleet', - url:function(f,l){return 'fleet://open?file='+encodeURIComponent(f)+'&line='+l}}, + url:function(f,l){return 'fleet://open?file='+encodeURIComponent(f)+'&line='+lineNo(l)}}, zed:{label:'Zed', - url:function(f,l){return 'zed://file'+f+':'+l}}, + url:function(f,l){return 'zed://file'+f+':'+lineNo(l)}}, '': {label:'None',url:null} }; diff --git a/tests/test_html_report.py b/tests/test_html_report.py index 7f72e61..076a03c 100644 --- a/tests/test_html_report.py +++ b/tests/test_html_report.py @@ -3467,6 +3467,26 @@ def test_html_report_uses_jetbrains_mono_for_stat_card_content() -> None: ) +def test_html_report_jetbrains_links_preserve_path_separators_for_line_navigation() -> ( + None +): + html = build_html_report( + func_groups={}, + block_groups={}, + segment_groups={}, + ) + + _assert_html_contains( + html, + "function jetbrainsReferencePath(f,l)", + ".replace(/%2F/gi,'/')", + "+':'+lineNo(l);", + "'jetbrains://pycharm/navigate/reference?project='", + "'&path='+jetbrainsReferencePath(f,l)", + ) + assert "encodeURIComponent(relPath(f))+':'+l" not in html + + def test_html_report_uses_jetbrains_mono_for_health_radar_labels() -> None: html = build_html_report( func_groups={}, From b34bb00c4dbdb2705838aceb70834897e872d886 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Tue, 19 May 2026 12:46:28 +0500 Subject: [PATCH 7/7] fix(dead-code): honor public exports and guarded dynamic lookup --- CHANGELOG.md | 20 ++- codeclone/analysis/_module_walk.py | 155 +++++++++++++++++++++- tests/test_extractor.py | 201 +++++++++++++++++++++++++++++ 3 files changed, 363 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4734001..1fb8c6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,11 @@ # Changelog -## [2.0.2] - 2026-05-15 +## Unreleased `2.0.2` is a focused patch release for VS Code extension packaging metadata, README link behavior, and dead-code runtime reachability precision. -### Dead code +### Enhancements - Extend runtime reachability with exact Aiogram `Router`/`Dispatcher` observer decorators, Starlette `BaseHTTPMiddleware.dispatch` hooks, @@ -14,20 +14,26 @@ README link behavior, and dead-code runtime reachability precision. 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. -- Bump cache schema to `2.8` so projects rebuild cached dead-code and runtime - reachability facts after the refined framework model. + +### 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. -### Packaging +### 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. -- Fix README package badges so PyPI/status/download/Python-version links open - the PyPI project page instead of scrolling to the installation section. ## [2.0.1] - 2026-05-14 diff --git a/codeclone/analysis/_module_walk.py b/codeclone/analysis/_module_walk.py index 9678ffe..268a9ad 100644 --- a/codeclone/analysis/_module_walk.py +++ b/codeclone/analysis/_module_walk.py @@ -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 @@ -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) @@ -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): @@ -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( @@ -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) @@ -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( diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 9e96d10..0cb9433 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -26,6 +26,7 @@ from codeclone.models import ( BlockUnit, ClassMetrics, + FileMetrics, ModuleDep, RuntimeReachabilityFact, SegmentUnit, @@ -116,6 +117,43 @@ def _dead_qualnames_from_source( return tuple(item.qualname for item in dead) +def _file_metrics_from_source( + source: str, + *, + filepath: str, + module_name: str, +) -> FileMetrics: + _, _, _, _, file_metrics, _ = units_mod.extract_units_and_stats_from_source( + source=source, + filepath=filepath, + module_name=module_name, + cfg=NormalizationConfig(), + min_loc=1, + min_stmt=1, + ) + return file_metrics + + +def _dead_qualnames_from_metrics( + definitions: FileMetrics, + *references: FileMetrics, +) -> set[str]: + referenced_names: set[str] = set() + referenced_qualnames: set[str] = set() + for file_metrics in (definitions, *references): + referenced_names.update(file_metrics.referenced_names) + referenced_qualnames.update(file_metrics.referenced_qualnames) + + return { + item.qualname + for item in find_unused( + definitions=definitions.dead_candidates, + referenced_names=frozenset(referenced_names), + referenced_qualnames=frozenset(referenced_qualnames), + ) + } + + def _runtime_reachability_from_source( source: str, *, @@ -2078,6 +2116,7 @@ def test_extract_collects_referenced_qualnames_for_module_all_exports() -> None: source = """ __all__ = ["PublicClass"] + ("public_func",) __all__: list[str] = ["TypedPublic"] +__all__: tuple[str, ...] __all__ += ["AugmentedPublic"] __all__.append("AlsoPublic") __all__.extend(["exported_later"]) @@ -2141,6 +2180,168 @@ def NestedInvalid(): assert state.exported_names == set() +def test_module_walk_export_and_dynamic_getattr_helpers_cover_safe_edges() -> None: + invalid_exports = cast( + ast.Assign, + ast.parse('_EXPORTS = {"Good": "pkg.good", 42: "bad", "Bad": object()}').body[ + 0 + ], + ) + assert module_walk_mod._string_mapping_from_literal_dict(ast.Pass()) == {} + assert module_walk_mod._string_mapping_from_literal_dict(invalid_exports.value) == { + "Good": "pkg.good" + } + assert module_walk_mod._literal_getattr_name(ast.Pass()) is None + assert ( + module_walk_mod._literal_getattr_name( + ast.parse("getattr(obj)", mode="eval").body + ) + is None + ) + assert ( + module_walk_mod._literal_getattr_name( + ast.parse('getattr(obj, "not-valid")', mode="eval").body + ) + is None + ) + assert module_walk_mod._collect_dynamic_getattr_names(ast.Pass()) == set() + + tree = ast.parse( + """ +class Runtime: + def dispatch(self) -> object | None: + first = second = getattr(self, "multi_lookup", None) + annotated: object = getattr(self, "annotated_lookup", None) + ignored = getattr(self, "not-valid", None) + if callable(first): + first() + if callable(annotated): + annotated() + + def nested() -> None: + hidden = getattr(self, "nested_lookup", None) + if callable(hidden): + hidden() + + class Inner: + def method(self) -> None: + inner = getattr(self, "inner_lookup", None) + if callable(inner): + inner() + + return second +""" + ) + assert module_walk_mod._collect_dynamic_getattr_names(tree) == { + "annotated_lookup", + "multi_lookup", + } + + +def test_extract_resolves_public_reexports_to_source_symbols() -> None: + sources = { + "common": ( + "pkg/common.py", + "pkg.common", + """ +class MetricValueDTO: + pass +""", + ), + "reexport": ( + "pkg/__init__.py", + "pkg", + """ +from pkg.common import MetricValueDTO + +__all__ = ["MetricValueDTO"] +""", + ), + "handlers": ( + "pkg/handlers.py", + "pkg.handlers", + """ +class ListContainersHandler: + pass +""", + ), + "lazy_exports": ( + "pkg/__init__.py", + "pkg", + """ +__all__ = ["ListContainersHandler"] + +_EXPORTS = { + "ListContainersHandler": "pkg.handlers", +} + +def __getattr__(name: str): + module_path = _EXPORTS.get(name) + if module_path is None: + raise AttributeError(name) + module = __import__(module_path, fromlist=[name]) + value = getattr(module, name) + globals()[name] = value + return value +""", + ), + } + metrics = { + name: _file_metrics_from_source( + source=source, + filepath=filepath, + module_name=module_name, + ) + for name, (filepath, module_name, source) in sources.items() + } + + dead_reexports = _dead_qualnames_from_metrics( + metrics["common"], + metrics["reexport"], + ) + dead_lazy = _dead_qualnames_from_metrics( + metrics["handlers"], + metrics["lazy_exports"], + ) + assert "pkg.common:MetricValueDTO" not in dead_reexports + assert "pkg.handlers:ListContainersHandler" not in dead_lazy + + +def test_extract_treats_guarded_dynamic_getattr_call_as_runtime_reference() -> None: + source = """ +class Repository: + def find_latest_artifact_by_format(self, expected_format: str) -> object | None: + return None + +class BootstrapService: + def __init__(self, repository: object) -> None: + self._repository = repository + + async def bootstrap(self) -> object | None: + finder = getattr(self._repository, "find_latest_artifact_by_format", None) + if not callable(finder): + return None + return await finder("onnx") +""" + dead = set(_dead_qualnames_from_source(source)) + assert "pkg.mod:Repository.find_latest_artifact_by_format" not in dead + + +def test_extract_ignores_uncalled_dynamic_getattr_probe() -> None: + source = """ +class Repository: + def find_latest_artifact_by_format(self, expected_format: str) -> object | None: + return None + +class BootstrapService: + def probe(self) -> bool: + finder = getattr(self, "find_latest_artifact_by_format", None) + return callable(finder) +""" + dead = set(_dead_qualnames_from_source(source)) + assert "pkg.mod:Repository.find_latest_artifact_by_format" in dead + + def test_collect_dead_candidates_skips_protocol_and_stub_like_symbols() -> None: src = """ from abc import abstractmethod