From f452807fc3e54fb8e5eb5c1a2b7ac365b5b0f44a Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Tue, 19 May 2026 15:22:09 +0500 Subject: [PATCH 1/2] chore(release): prepare 2.0.2 docs and lockfile --- CHANGELOG.md | 2 +- docs/book/08-report.md | 4 ++-- docs/book/16-dead-code-contract.md | 20 ++++++++++++++++++++ docs/book/appendix/b-schema-layouts.md | 11 ++++++----- uv.lock | 6 +++--- 5 files changed, 32 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1fb8c6b..6a30ee7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## [2.0.2] - 2026-05-19 `2.0.2` is a focused patch release for VS Code extension packaging metadata, README link behavior, and dead-code runtime reachability precision. diff --git a/docs/book/08-report.md b/docs/book/08-report.md index ac48bed..4fc8e20 100644 --- a/docs/book/08-report.md +++ b/docs/book/08-report.md @@ -2,8 +2,8 @@ ## Purpose -Define the canonical report contract in `2.0.1`: report schema `2.11` plus -deterministic text/Markdown/SARIF/HTML projections. +Define the canonical report contract for the current 2.0 release line: report +schema `2.11` plus deterministic text/Markdown/SARIF/HTML projections. ## Public surface diff --git a/docs/book/16-dead-code-contract.md b/docs/book/16-dead-code-contract.md index efa9b49..b3f2b51 100644 --- a/docs/book/16-dead-code-contract.md +++ b/docs/book/16-dead-code-contract.md @@ -60,6 +60,13 @@ Refs: - A top-level symbol listed in a literal `__all__` export is not dead. This is resolved to exact module-level function/class qualnames and does not mark same-named methods live. +- A symbol re-exported through a literal `__all__` entry and an exact + `from module import Symbol` binding is resolved back to the imported + canonical qualname. +- A symbol exposed through a PEP 562 lazy-export module is resolved when the + module has a module-level `__getattr__`, a literal `_EXPORTS` mapping, and a + matching literal `__all__` entry. Dynamic or non-literal export maps are not + interpreted. - A symbol referenced by package metadata entry points is not dead when `[project.scripts]`, `[project.gui-scripts]`, `[project.entry-points.*]`, or `[tool.poetry.scripts]` resolves to an exact known candidate qualname. Unique @@ -67,6 +74,10 @@ Refs: ambiguous matches are ignored. - A symbol referenced only by qualified-name suffix (without canonical module match) downgrades confidence to `medium`. +- A method name observed through guarded dynamic lookup is treated as a + referenced local name only when the same callable scope contains all three + pieces of evidence: `getattr(obj, "method", ...)`, `callable(local)` guard, + and a subsequent call through that same local binding. - 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 @@ -123,7 +134,10 @@ Refs: | Symbol used only from tests | Remains actionable dead-code candidate | | Symbol used through import alias / module alias | Matched via canonical qualname usage | | Symbol exported through literal `__all__` | Matched via exact module-level qualname | +| Symbol re-exported through literal `__all__` | Matched via exact imported qualname | +| Symbol exposed through literal lazy `_EXPORTS` | Matched via exact lazy-export qualname | | Symbol exposed through package entry point | Matched via exact/unique project qualname | +| Guarded `getattr(obj, "method")` callable dispatch | Method name becomes runtime reference | | Symbol registered through a supported runtime edge | Candidate skipped as runtime-reachable | | `--fail-dead-code` with high-confidence dead items | Gating failure, exit `3` | @@ -136,6 +150,9 @@ Refs: not suppress arbitrary same-named local decorators. - Package entry-point liveness reads only local project metadata and ignores invalid, dynamic, or ambiguous entry-point references. +- Lazy export and guarded dynamic `getattr` handling require literal AST + evidence and same-scope call evidence; CodeClone does not execute import + hooks or infer arbitrary dynamic dispatch. - Candidate and result ordering is deterministic. Refs: @@ -162,6 +179,9 @@ Refs: - `tests/test_extractor.py::test_dead_code_uses_cli_and_task_registration_reachability` - `tests/test_extractor.py::test_extract_collects_referenced_qualnames_for_import_aliases` - `tests/test_extractor.py::test_extract_collects_referenced_qualnames_for_module_all_exports` +- `tests/test_extractor.py::test_extract_resolves_public_reexports_to_source_symbols` +- `tests/test_extractor.py::test_extract_treats_guarded_dynamic_getattr_call_as_runtime_reference` +- `tests/test_extractor.py::test_extract_ignores_uncalled_dynamic_getattr_probe` - `tests/test_extractor.py::test_collect_dead_candidates_skips_protocol_and_stub_like_symbols` - `tests/test_extractor.py::test_collect_dead_candidates_skips_pydantic_hooks_and_dataclass_post_init` - `tests/test_core_branch_coverage.py::test_project_entrypoints_mark_exact_and_unique_layout_symbols_live` diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index 7c23ccb..8a0ef55 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -2,14 +2,15 @@ ## Purpose -Compact structural layouts for baseline/cache/report contracts in `2.0.1`. +Compact structural layouts for baseline/cache/report contracts in the current +2.0 release line. ## Baseline schema (`2.1`) ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.1" }, + "generator": { "name": "codeclone", "version": "2.0.2" }, "schema_version": "2.1", "fingerprint_version": "1", "python_tag": "cp314", @@ -60,7 +61,7 @@ Notes: ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.1" }, + "generator": { "name": "codeclone", "version": "2.0.2" }, "schema_version": "1.2", "python_tag": "cp314", "created_at": "2026-03-11T00:00:00Z", @@ -156,7 +157,7 @@ Notes: { "report_schema_version": "2.11", "meta": { - "codeclone_version": "2.0.1", + "codeclone_version": "2.0.2", "project_name": "codeclone", "scan_root": ".", "analysis_mode": "full", @@ -515,7 +516,7 @@ Notes: "tool": { "driver": { "name": "codeclone", - "version": "2.0.1", + "version": "2.0.2", "rules": [ { "id": "CCLONE001", diff --git a/uv.lock b/uv.lock index 16d52e8..7421c36 100644 --- a/uv.lock +++ b/uv.lock @@ -1909,9 +1909,9 @@ wheels = [ [[package]] name = "zipp" -version = "3.23.1" +version = "4.1.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/30/21/093488dfc7cc8964ded15ab726fad40f25fd3d788fd741cc1c5a17d78ee8/zipp-3.23.1.tar.gz", hash = "sha256:32120e378d32cd9714ad503c1d024619063ec28aad2248dc6672ad13edfa5110", size = 25965, upload-time = "2026-04-13T23:21:46.6Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b9/d8/eab98a517c14134c0b2eb4e2387bc5f457334293ec5d2dd3857ec2966802/zipp-4.1.0.tar.gz", hash = "sha256:4cb57381f544315db7688e976e922a2b18cdb513d21cc194eb42232ba2a3e602", size = 26214, upload-time = "2026-05-18T20:08:57.967Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/08/8a/0861bec20485572fbddf3dfba2910e38fe249796cb73ecdeb74e07eeb8d3/zipp-3.23.1-py3-none-any.whl", hash = "sha256:0b3596c50a5c700c9cb40ba8d86d9f2cc4807e9bedb06bcdf7fac85633e444dc", size = 10378, upload-time = "2026-04-13T23:21:45.386Z" }, + { url = "https://files.pythonhosted.org/packages/3a/13/547360d81e6d88d58492968ffda9f9542854f11310ee556fef14260cc886/zipp-4.1.0-py3-none-any.whl", hash = "sha256:25ad4e16390cd314347dd8f1de67a2ac538ae658ed4ab9db16029c07c188e97f", size = 10238, upload-time = "2026-05-18T20:08:57.045Z" }, ] From 28b05ed06937d194422808a348437c660a2ae290 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Tue, 19 May 2026 16:01:13 +0500 Subject: [PATCH 2/2] fix(cli): show 2.0.2 dead-code migration note --- CHANGELOG.md | 3 ++ codeclone/surfaces/cli/tips.py | 75 ++++++++++++++++++++++--------- codeclone/ui_messages/__init__.py | 18 ++++++-- docs/book/09-cli.md | 10 +++-- tests/test_cli_inprocess.py | 28 +++++++++--- tests/test_cli_unit.py | 67 ++++++++++++++++++++++----- 6 files changed, 156 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6a30ee7..7c3ee8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,6 +24,9 @@ README link behavior, and dead-code runtime reachability precision. - Treat `__all__` re-exports, PEP 562 lazy `_EXPORTS` modules, and guarded dynamic `getattr(..., "method")` callable dispatch as dead-code reachability evidence. +- Show a one-time interactive CLI migration note when a trusted `2.0.1` + baseline is analyzed by `2.0.2`, clarifying that fewer dead-code findings are + expected after the refined reachability model. ### Internal diff --git a/codeclone/surfaces/cli/tips.py b/codeclone/surfaces/cli/tips.py index a74efd4..2ca3434 100644 --- a/codeclone/surfaces/cli/tips.py +++ b/codeclone/surfaces/cli/tips.py @@ -10,7 +10,7 @@ import sys from collections.abc import Mapping from pathlib import Path -from typing import TextIO +from typing import NamedTuple, TextIO from packaging.version import InvalidVersion, Version @@ -20,16 +20,16 @@ from .types import PrinterLike _VSCODE_EXTENSION_TIP_KEY = "vscode_extension" -_DEAD_CODE_REACHABILITY_MIGRATION_TIP_KEY = ( +_DEAD_CODE_REACHABILITY_2_0_1_MIGRATION_TIP_KEY = ( "dead_code_reachability_2_0_1_migration_shown" ) +_DEAD_CODE_REACHABILITY_2_0_2_MIGRATION_TIP_KEY = ( + "dead_code_reachability_2_0_2_migration_shown" +) _TIPS_SCHEMA_VERSION = 1 _VSCODE_EXTENSION_URL = ( "https://marketplace.visualstudio.com/items?itemName=orenlab.codeclone" ) -_DEAD_CODE_REACHABILITY_BASELINE_MIN = Version("2.0.0b1") -_DEAD_CODE_REACHABILITY_BASELINE_MAX = Version("2.0.0") -_DEAD_CODE_REACHABILITY_CURRENT_MIN = Version("2.0.1") _CI_ENV_KEYS: tuple[str, ...] = ( "CI", "GITHUB_ACTIONS", @@ -44,6 +44,35 @@ ) +class _DeadCodeReachabilityMigration(NamedTuple): + tip_key: str + baseline_min: Version + baseline_max: Version + current_min: Version + target_version: str + + +_DEAD_CODE_REACHABILITY_MIGRATIONS: tuple[ + _DeadCodeReachabilityMigration, + ..., +] = ( + _DeadCodeReachabilityMigration( + tip_key=_DEAD_CODE_REACHABILITY_2_0_2_MIGRATION_TIP_KEY, + baseline_min=Version("2.0.1"), + baseline_max=Version("2.0.1"), + current_min=Version("2.0.2"), + target_version="2.0.2", + ), + _DeadCodeReachabilityMigration( + tip_key=_DEAD_CODE_REACHABILITY_2_0_1_MIGRATION_TIP_KEY, + baseline_min=Version("2.0.0b1"), + baseline_max=Version("2.0.0"), + current_min=Version("2.0.1"), + target_version="2.0.1", + ), +) + + def _tips_state_path(cache_path: Path) -> Path: return cache_path.parent / "tips.json" @@ -165,24 +194,25 @@ def _tip_context_allowed( return _stream_is_tty(stream) -def _dead_code_reachability_migration_applies( +def _dead_code_reachability_migration( *, baseline_generator_version: str | None, codeclone_version: str, -) -> bool: +) -> _DeadCodeReachabilityMigration | None: if not baseline_generator_version: - return False + return None try: baseline_version = Version(baseline_generator_version) current_version = Version(codeclone_version) except InvalidVersion: - return False - return ( - _DEAD_CODE_REACHABILITY_BASELINE_MIN - <= baseline_version - <= _DEAD_CODE_REACHABILITY_BASELINE_MAX - and current_version >= _DEAD_CODE_REACHABILITY_CURRENT_MIN - ) + return None + for migration in _DEAD_CODE_REACHABILITY_MIGRATIONS: + if ( + migration.baseline_min <= baseline_version <= migration.baseline_max + and current_version >= migration.current_min + ): + return migration + return None def maybe_print_vscode_extension_tip( @@ -239,10 +269,11 @@ def maybe_print_dead_code_reachability_migration_note( ) -> bool: if not baseline_trusted_for_diff: return False - if not _dead_code_reachability_migration_applies( + migration = _dead_code_reachability_migration( baseline_generator_version=baseline_generator_version, codeclone_version=codeclone_version, - ): + ) + if migration is None: return False effective_environ = os.environ if environ is None else environ @@ -258,16 +289,20 @@ def maybe_print_dead_code_reachability_migration_note( state = _load_tips_state(state_path) if _tip_was_shown( state, - tip_key=_DEAD_CODE_REACHABILITY_MIGRATION_TIP_KEY, + tip_key=migration.tip_key, ): return False - console.print(ui.fmt_dead_code_reachability_migration_note()) + console.print( + ui.fmt_dead_code_reachability_migration_note( + target_version=migration.target_version, + ) + ) try: _remember_tip_shown( path=state_path, state=state, - tip_key=_DEAD_CODE_REACHABILITY_MIGRATION_TIP_KEY, + tip_key=migration.tip_key, ) except OSError: return True diff --git a/codeclone/ui_messages/__init__.py b/codeclone/ui_messages/__init__.py index e7ed53b..fa94daf 100644 --- a/codeclone/ui_messages/__init__.py +++ b/codeclone/ui_messages/__init__.py @@ -360,12 +360,19 @@ "navigation.\n" "[dim]{url}[/dim]" ) -NOTE_DEAD_CODE_REACHABILITY_MIGRATION = ( +NOTE_DEAD_CODE_REACHABILITY_2_0_1_MIGRATION = ( "\n[dim]Note:[/dim] Dead-code reachability was refined in 2.0.1 for " "common Python frameworks.\n" "[dim]Fewer dead-code findings after upgrading from 2.0.0 are expected: " "this usually means reduced false positives, not weaker detection.[/dim]" ) +NOTE_DEAD_CODE_REACHABILITY_2_0_2_MIGRATION = ( + "\n[dim]Note:[/dim] Dead-code reachability was refined again in 2.0.2.\n" + "[dim]Fewer dead-code findings after upgrading from 2.0.1 are expected: " + "framework hooks, public exports, and guarded dynamic dispatch now produce " + "fewer false positives, not weaker detection.[/dim]" +) +NOTE_DEAD_CODE_REACHABILITY_MIGRATION = NOTE_DEAD_CODE_REACHABILITY_2_0_1_MIGRATION _RICH_MARKUP_TAG_RE = re.compile(r"\[/?[a-zA-Z][a-zA-Z0-9_ .#:-]*]") @@ -445,8 +452,13 @@ def fmt_vscode_extension_tip(*, url: str) -> str: return TIP_VSCODE_EXTENSION.format(url=url) -def fmt_dead_code_reachability_migration_note() -> str: - return NOTE_DEAD_CODE_REACHABILITY_MIGRATION +def fmt_dead_code_reachability_migration_note( + *, + target_version: str = "2.0.1", +) -> str: + if target_version == "2.0.2": + return NOTE_DEAD_CODE_REACHABILITY_2_0_2_MIGRATION + return NOTE_DEAD_CODE_REACHABILITY_2_0_1_MIGRATION def fmt_legacy_cache_warning(*, legacy_path: Path, new_path: Path) -> str: diff --git a/docs/book/09-cli.md b/docs/book/09-cli.md index 81e8265..3d9fc9f 100644 --- a/docs/book/09-cli.md +++ b/docs/book/09-cli.md @@ -56,10 +56,12 @@ Refs: after summary output. The hint is suppressed in `--quiet`, CI, and non-TTY contexts, and is tracked per CodeClone version next to the resolved project cache path. -- In interactive non-CI runs, the CLI may print a one-time migration note when - a trusted baseline from the `2.0.0` line is analyzed by `2.0.1` or newer. The - note explains expected dead-code count reductions from the refined framework - reachability model and is remembered next to the resolved project cache path. +- In interactive non-CI runs, the CLI may print one-time migration notes when a + trusted baseline was produced by a release whose dead-code reachability model + is known to be narrower than the current version, such as `2.0.0` -> `2.0.1` + or `2.0.1` -> `2.0.2`. Notes explain expected dead-code count reductions from + refined reachability evidence and are remembered next to the resolved project + cache path. - Changed-scope review uses: - `--changed-only` - `--diff-against` diff --git a/tests/test_cli_inprocess.py b/tests/test_cli_inprocess.py index 315923e..cbc70ee 100644 --- a/tests/test_cli_inprocess.py +++ b/tests/test_cli_inprocess.py @@ -2338,16 +2338,34 @@ def test_cli_shows_vscode_extension_tip_once_per_version( assert "VS Code detected" not in second_out +@pytest.mark.parametrize( + ("generator_version", "expected_message", "expected_tip_key"), + [ + ( + "2.0.0", + "Dead-code reachability was refined in 2.0.1", + "dead_code_reachability_2_0_1_migration_shown", + ), + ( + "2.0.1", + "Dead-code reachability was refined again in 2.0.2", + "dead_code_reachability_2_0_2_migration_shown", + ), + ], +) def test_cli_shows_dead_code_reachability_migration_note_once( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], + generator_version: str, + expected_message: str, + expected_tip_key: str, ) -> None: _write_default_source(tmp_path) baseline_path = _write_baseline( tmp_path / "baseline.json", python_version=_current_py_minor(), - generator_version="2.0.0", + generator_version=generator_version, ) tips_path = tmp_path / ".cache" / "codeclone" / "tips.json" @@ -2371,14 +2389,12 @@ def test_cli_shows_dead_code_reachability_migration_note_once( _assert_after_summary( first_out, "Note:", - "Dead-code reachability was refined in 2.0.1", + expected_message, "not weaker detection", ) state = json.loads(tips_path.read_text("utf-8")) - assert ( - state["tips"]["dead_code_reachability_2_0_1_migration_shown"]["shown"] is True - ) + assert state["tips"][expected_tip_key]["shown"] is True _run_parallel_main( monkeypatch, @@ -2392,7 +2408,7 @@ def test_cli_shows_dead_code_reachability_migration_note_once( ) second_out = capsys.readouterr().out - assert "Dead-code reachability was refined in 2.0.1" not in second_out + assert expected_message not in second_out def test_cli_update_baseline_skips_version_check( diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index d92d13d..49fd70a 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -346,9 +346,12 @@ def test_cli_vscode_extension_tip_respects_context_gates( ("2.0.0b7", "2.0.1", True), ("2.0.0", "2.0.1", True), ("2.0.0", "2.1.0", True), + ("2.0.1", "2.0.2", True), + ("2.0.1", "2.1.0", True), ("2.0.0b0", "2.0.1", False), ("1.4.4", "2.0.1", False), ("2.0.1", "2.0.1", False), + ("2.0.2", "2.0.2", False), ("dev-build", "2.0.1", False), ("2.0.0", "dev-build", False), (None, "2.0.1", False), @@ -360,27 +363,69 @@ def test_cli_dead_code_reachability_migration_note_version_gate( expected: bool, ) -> None: assert ( - cli_tips._dead_code_reachability_migration_applies( + cli_tips._dead_code_reachability_migration( baseline_generator_version=baseline_version, codeclone_version=current_version, ) - is expected - ) + is not None + ) is expected +@pytest.mark.parametrize( + ( + "baseline_version", + "current_version", + "expected_message", + "expected_tip_key", + "preexisting_tip_keys", + ), + [ + ( + "2.0.0b7", + "2.0.1", + "Dead-code reachability was refined in 2.0.1", + "dead_code_reachability_2_0_1_migration_shown", + (), + ), + ( + "2.0.1", + "2.0.2", + "Dead-code reachability was refined again in 2.0.2", + "dead_code_reachability_2_0_2_migration_shown", + ("dead_code_reachability_2_0_1_migration_shown",), + ), + ], +) def test_cli_dead_code_reachability_migration_note_uses_one_shot_cache( tmp_path: Path, + baseline_version: str, + current_version: str, + expected_message: str, + expected_tip_key: str, + preexisting_tip_keys: tuple[str, ...], ) -> None: printer = _RecordingPrinter() args = SimpleNamespace(quiet=False, ci=False) cache_path = tmp_path / ".cache" / "codeclone" / "cache.json" + tips_path = cache_path.parent / "tips.json" + if preexisting_tip_keys: + tips_path.parent.mkdir(parents=True) + tips_path.write_text( + json.dumps( + { + "schema_version": 1, + "tips": {key: {"shown": True} for key in preexisting_tip_keys}, + } + ), + "utf-8", + ) shown = cli_tips.maybe_print_dead_code_reachability_migration_note( args=args, console=printer, - codeclone_version="2.0.1", + codeclone_version=current_version, cache_path=cache_path, - baseline_generator_version="2.0.0b7", + baseline_generator_version=baseline_version, baseline_trusted_for_diff=True, environ={}, stream=_TTYStream(is_tty=True), @@ -388,21 +433,19 @@ def test_cli_dead_code_reachability_migration_note_uses_one_shot_cache( assert shown is True assert len(printer.lines) == 1 - assert "Dead-code reachability was refined in 2.0.1" in printer.lines[0] + assert expected_message in printer.lines[0] assert "not weaker detection" in printer.lines[0] - tips_path = cache_path.parent / "tips.json" state = json.loads(tips_path.read_text("utf-8")) - assert ( - state["tips"]["dead_code_reachability_2_0_1_migration_shown"]["shown"] is True - ) + for tip_key in (*preexisting_tip_keys, expected_tip_key): + assert state["tips"][tip_key]["shown"] is True shown_again = cli_tips.maybe_print_dead_code_reachability_migration_note( args=args, console=printer, - codeclone_version="2.0.1", + codeclone_version=current_version, cache_path=cache_path, - baseline_generator_version="2.0.0b7", + baseline_generator_version=baseline_version, baseline_trusted_for_diff=True, environ={}, stream=_TTYStream(is_tty=True),