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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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

Expand Down
75 changes: 55 additions & 20 deletions codeclone/surfaces/cli/tips.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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",
Expand All @@ -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"

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down
18 changes: 15 additions & 3 deletions codeclone/ui_messages/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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_ .#:-]*]")

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions docs/book/08-report.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
10 changes: 6 additions & 4 deletions docs/book/09-cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
20 changes: 20 additions & 0 deletions docs/book/16-dead-code-contract.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,13 +60,24 @@ 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
suffix matches are allowed only for common `src.<package>` style layouts;
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
Expand Down Expand Up @@ -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` |

Expand All @@ -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:
Expand All @@ -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`
Expand Down
11 changes: 6 additions & 5 deletions docs/book/appendix/b-schema-layouts.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -515,7 +516,7 @@ Notes:
"tool": {
"driver": {
"name": "codeclone",
"version": "2.0.1",
"version": "2.0.2",
"rules": [
{
"id": "CCLONE001",
Expand Down
28 changes: 22 additions & 6 deletions tests/test_cli_inprocess.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"

Expand All @@ -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,
Expand All @@ -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(
Expand Down
Loading
Loading