From 8662a6218da838fba925508d4f408da499058341 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Wed, 13 May 2026 15:36:19 +0500 Subject: [PATCH 01/10] chore(docs): update pipeline overview diagram --- docs/assets/codeclone-pipeline.svg | 148 ++++++++++++++--------------- 1 file changed, 70 insertions(+), 78 deletions(-) diff --git a/docs/assets/codeclone-pipeline.svg b/docs/assets/codeclone-pipeline.svg index faba265..830505b 100644 --- a/docs/assets/codeclone-pipeline.svg +++ b/docs/assets/codeclone-pipeline.svg @@ -1,39 +1,42 @@ - + CodeClone analysis pipeline - Deterministic structural analysis pipeline with canonical report, baseline-aware gating, and multi-surface - outputs + Deterministic CodeClone pipeline from source discovery and cache reuse to canonical report, gates, and + human or agent-facing projections. @@ -43,93 +46,82 @@ - - - Input / control + + + Source + config + files · CLI · pyproject - - Detection pipeline + - - Analysis / canonical data + + Discover + cache + sorted scan · compatible facts - - CI / governance + - + + Process misses + parallel or sequential workers - - - Python source + - + + Extract facts + AST/CFG · clone units + refs · reachability · security/API - - - Parse - AST per file + - + + Group + policy + clones · findings · suppressions - - - Normalize - canonical form · rename-safe + - + + Project metrics + health · deps · dead code + coverage join · suggestions - - - CFG - control-flow graph + - + + Canonical report + one JSON truth · facts only - - - Fingerprint - stable hash (fn / block / segment) + + + Cache + optimization only + never analysis truth + - + + + Updated cache + fresh file facts + stats · reachability + surfaces · signals + - - - Group - clone groups · structural findings + + + Baselines + trusted diff state + clone + metrics snapshots - + + Gates + diff · metrics · exit code - - - Metrics - complexity · coupling · cohesion · dead code + + Projections + CLI · HTML · MCP · IDE + JSON · MD · SARIF · TXT - - - - - Canonical Report - single source of truth (JSON) - - - - - - Gate - baseline diff · CI decision - - - - - - Surfaces - CLI · HTML · IDE · MCP · CI - - - - - Baseline - trusted reference state - + + + + One canonical report powers local review, CI, IDE clients, and agent workflows. From fb08fa1efabc57d3d47834dd2921f6c03c51582b Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Wed, 13 May 2026 22:29:52 +0500 Subject: [PATCH 02/10] chore(release): prepare 2.0.1 package metadata --- .github/actions/codeclone/README.md | 2 +- .github/actions/codeclone/_action_impl.py | 2 +- .github/actions/codeclone/action.yml | 2 +- CHANGELOG.md | 5 ++++- pyproject.toml | 2 +- uv.lock | 20 ++++++++++---------- 6 files changed, 18 insertions(+), 15 deletions(-) diff --git a/.github/actions/codeclone/README.md b/.github/actions/codeclone/README.md index e2f09d2..8f3cf8a 100644 --- a/.github/actions/codeclone/README.md +++ b/.github/actions/codeclone/README.md @@ -80,7 +80,7 @@ jobs: | Input | Default | Purpose | |-------------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------| | `python-version` | `3.14` | Python version used to run the action | -| `package-version` | `2.0.0` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo | +| `package-version` | `2.0.1` | 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 | diff --git a/.github/actions/codeclone/_action_impl.py b/.github/actions/codeclone/_action_impl.py index 59d17e4..3bbbe56 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.1b1" +DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.1" @dataclass(frozen=True, slots=True) diff --git a/.github/actions/codeclone/action.yml b/.github/actions/codeclone/action.yml index 5b6622a..65df75e 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.1b1" + default: "2.0.1" path: description: "Project root" diff --git a/CHANGELOG.md b/CHANGELOG.md index d25d3a3..727b16b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ # Changelog -## [Unreleased] +## [2.0.1] - 2026-05-13 + +`2.0.1` is a focused stability release for dead-code precision and cache/report +contract parity after the 2.0 line. ### Dead code diff --git a/pyproject.toml b/pyproject.toml index d0319d2..2b90bc2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codeclone" -version = "2.0.1b1" +version = "2.0.1" 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 d8b4c88..27c52fb 100644 --- a/uv.lock +++ b/uv.lock @@ -320,7 +320,7 @@ wheels = [ [[package]] name = "codeclone" -version = "2.0.1b1" +version = "2.0.1" source = { editable = "." } dependencies = [ { name = "orjson" }, @@ -657,11 +657,11 @@ wheels = [ [[package]] name = "idna" -version = "3.14" +version = "3.15" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } +sdist = { url = "https://files.pythonhosted.org/packages/82/77/7b3966d0b9d1d31a36ddf1746926a11dface89a83409bf1483f0237aa758/idna-3.15.tar.gz", hash = "sha256:ca962446ea538f7092a95e057da437618e886f4d349216d2b1e294abfdb65fdc", size = 199245, upload-time = "2026-05-12T22:45:57.011Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, + { url = "https://files.pythonhosted.org/packages/d2/23/408243171aa9aaba178d3e2559159c24c1171a641aa83b67bdd3394ead8e/idna-3.15-py3-none-any.whl", hash = "sha256:048adeaf8c2d788c40fee287673ccaa74c24ffd8dcf09ffa555a2fbb59f10ac8", size = 72340, upload-time = "2026-05-12T22:45:55.733Z" }, ] [[package]] @@ -1382,15 +1382,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.3.0" +version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/ae/e0/cc5a8653e9a24f6cf84768f05064aa8ed5a83dcefd5e2a043db14a1c5f44/python_discovery-1.3.0.tar.gz", hash = "sha256:d098f1e86be5d45fe4d14bf1029294aabbd332f4321179dec85e76cddce834b0", size = 63925, upload-time = "2026-05-05T14:38:39.769Z" } +sdist = { url = "https://files.pythonhosted.org/packages/48/60/e88788207d81e46362cfbef0d4aaf4c0f49efc3c12d4c3fa3f542c34ebec/python_discovery-1.3.1.tar.gz", hash = "sha256:62f6db28064c9613e7ca76cb3f00c38c839a07c31c00dfe7ed0986493d2150a6", size = 68011, upload-time = "2026-05-12T20:53:36.336Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/30/d4/24d543ab8b8158b7f5a97113c831205f5c900c92c8762b1e7f44b7ea0405/python_discovery-1.3.0-py3-none-any.whl", hash = "sha256:441d9ced3dfce36e113beb35ca302c71c7ef06f3c0f9c227a0b9bb3bd49b9e9f", size = 33124, upload-time = "2026-05-05T14:38:38.539Z" }, + { url = "https://files.pythonhosted.org/packages/b7/6f/a05a317a66fee0aad270011461f1a63a453ed12471249f172f7d2e2bc7b4/python_discovery-1.3.1-py3-none-any.whl", hash = "sha256:ed188687ebb3b82c01a17cd5ac62fc94d9f6487a7f1a0f9dfe89753fec91039c", size = 33185, upload-time = "2026-05-12T20:53:34.969Z" }, ] [[package]] @@ -1745,15 +1745,15 @@ wheels = [ [[package]] name = "sse-starlette" -version = "3.4.3" +version = "3.4.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, { name = "starlette" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/64/13/3cafb96bceb02949f265bbdf1cbcea2810271ae709e4aa35e980f90c07fd/sse_starlette-3.4.3.tar.gz", hash = "sha256:a7f6d87cf482cf38b911c31075811c7f8b4efbada8ac9d5199a8e239fed513c9", size = 35247, upload-time = "2026-05-11T17:23:41.987Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f7/2b/58abc2d1fd397e7dde08e947e05c884d8ef2f78d5e2588c17a12d42d6994/sse_starlette-3.4.4.tar.gz", hash = "sha256:07e0fa0460138baf25cdd5fb28683472c3995dc1642225191b3832d62526bcb0", size = 31819, upload-time = "2026-05-12T17:37:17.019Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/a4/c888212b19dd432110d4a78dbc5e6c1bc7476e5fff2f2df2ea9f298b0003/sse_starlette-3.4.3-py3-none-any.whl", hash = "sha256:bf8a90d76192062f01b55095593606bfc7edd0e3ad481339a6e16e7890bc9367", size = 16514, upload-time = "2026-05-11T17:23:40.352Z" }, + { url = "https://files.pythonhosted.org/packages/dc/67/805710444ea8cc75fbf70b920ed431a560c4bf9c57f7d5a3117213189399/sse_starlette-3.4.4-py3-none-any.whl", hash = "sha256:3f4dd50d8aed2771a091f3a83000323fc3844541c16b4fe585ae2420cc6df973", size = 16514, upload-time = "2026-05-12T17:37:15.601Z" }, ] [[package]] From 97b77777a18a5ecf18720b51268e994e2654b150 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Wed, 13 May 2026 22:31:33 +0500 Subject: [PATCH 03/10] fix(vscode): align hotspot counts with report semantics --- docs/book/21-vscode-extension.md | 5 +- extensions/vscode-codeclone/.vscodeignore | 3 + extensions/vscode-codeclone/CHANGELOG.md | 2 + extensions/vscode-codeclone/README.md | 244 ++++------ extensions/vscode-codeclone/package.json | 59 +++ extensions/vscode-codeclone/src/constants.js | 1 + extensions/vscode-codeclone/src/extension.js | 426 +++++++++++++++++- extensions/vscode-codeclone/src/formatters.js | 152 +++++++ extensions/vscode-codeclone/src/renderers.js | 50 +- .../vscode-codeclone/src/runArtifacts.js | 10 +- extensions/vscode-codeclone/src/runtime.js | 40 ++ .../vscode-codeclone/test/formatters.test.js | 101 +++++ .../vscode-codeclone/test/manifest.test.js | 5 + .../vscode-codeclone/test/renderers.test.js | 49 ++ .../test/runArtifacts.test.js | 15 +- .../vscode-codeclone/test/runtime.test.js | 16 +- 16 files changed, 996 insertions(+), 182 deletions(-) diff --git a/docs/book/21-vscode-extension.md b/docs/book/21-vscode-extension.md index 44d72f2..5dd50f3 100644 --- a/docs/book/21-vscode-extension.md +++ b/docs/book/21-vscode-extension.md @@ -89,7 +89,10 @@ The extension currently supports: plus report-only `Security Surfaces` when MCP exposes `metrics.security_surfaces` - review queues for new regressions, production hotspots, changed-scope - findings, and report-only `Security Surfaces` / `Overloaded Modules` + findings, `Coverage Join` review items, and report-only `Security Surfaces` / + `Overloaded Modules` +- optional Coverage Join input through `codeclone.analysis.coverageXml`, with + workspace-root `coverage.xml` auto-detected when present - source reveal, peek, canonical finding detail, remediation detail, and session-local reviewed markers - bounded MCP help topics inside the IDE, with the optional `coverage` topic on diff --git a/extensions/vscode-codeclone/.vscodeignore b/extensions/vscode-codeclone/.vscodeignore index 2d4c73f..3ce4831 100644 --- a/extensions/vscode-codeclone/.vscodeignore +++ b/extensions/vscode-codeclone/.vscodeignore @@ -1,4 +1,7 @@ .vscode/** +.cache/** +.coverage* +coverage.xml .DS_Store DESIGN.md esbuild.config.mjs diff --git a/extensions/vscode-codeclone/CHANGELOG.md b/extensions/vscode-codeclone/CHANGELOG.md index ed9708d..7d82828 100644 --- a/extensions/vscode-codeclone/CHANGELOG.md +++ b/extensions/vscode-codeclone/CHANGELOG.md @@ -4,6 +4,8 @@ - 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/README.md b/extensions/vscode-codeclone/README.md index 4774cda..992274c 100644 --- a/extensions/vscode-codeclone/README.md +++ b/extensions/vscode-codeclone/README.md @@ -1,229 +1,159 @@ # CodeClone for VS Code -CodeClone for VS Code is a native IDE surface for `codeclone-mcp`. +[![License](https://img.shields.io/github/license/orenlab/codeclone?style=flat-square&color=6366f1)](LICENSE) +[![Requires CodeClone](https://img.shields.io/badge/requires-codeclone_%3E%3D2.0.0-6366f1?style=flat-square)](https://orenlab.github.io/codeclone/) -Marketplace: [CodeClone for VS Code](https://marketplace.visualstudio.com/items?itemName=orenlab.codeclone) +Native VS Code surface for [codeclone-mcp](https://orenlab.github.io/codeclone/mcp/). +Brings baseline-aware structural analysis into the editor — triage-first, read-only, +and driven by the same canonical report as the CLI and HTML output. -It brings CodeClone's baseline-aware structural analysis into the editor without -creating a second truth model. The extension stays read-only with respect to -repository state and uses the same canonical report semantics as the CLI, HTML -report, and MCP server. +> **Not a linter panel.** CodeClone for VS Code is designed for structural review and +> refactoring flow, not diagnostics or Problems integration. -## What it is for +--- -CodeClone inside VS Code is designed for: +## Features -- triage-first structural review -- changed-files review against the current diff -- conservative first-pass analysis with an explicit deeper-review follow-up -- baseline-aware distinction between known debt and new regressions -- guided drill-down from hotspot to source, finding detail, and remediation -- report-only review of security-relevant boundaries without turning them into vulnerability claims -- lightweight code navigation without turning the sidebar into a second report app +- **Hotspots view** — new regressions, production hotspots, and changed-files findings + at a glance; report-only Security Surfaces and Overloaded Modules kept visually separate +- **Baseline-aware** — distinguishes known debt from new regressions against the stored baseline +- **Changed-files review** — `Review Changes` scopes analysis to the current diff via a configurable git ref +- **Coverage Join** — integrates `coverage.xml` to surface untested hotspots when available +- **Source-first navigation** — `Reveal Source` opens the exact location; `Next / Previous Hotspot` + steps through active targets in the editor +- **Lightweight decorations** — Explorer file decorations and CodeLens appear only where relevant; + no sidebar duplication of the HTML report +- **`Open in HTML Report`** — explicit bridge to the full report when a fresh local `report.html` exists -It is not a generic linter panel and it does not try to duplicate the HTML -report inside the sidebar. +--- -## Product principles +## Requirements -- **Canonical-report-first**: IDE views are projections over the same report - truth exposed by CodeClone. -- **Baseline-aware**: the extension prefers new and relevant findings over - broad full-repository listing. -- **Triage-first**: the default path is review, not enumeration. -- **Read-only**: the extension does not edit source files, baselines, caches, - or report artifacts. -- **Guided**: the extension should make the cheapest useful path the most - obvious path. +- VS Code `1.85+` +- Python workspace (trusted) +- `codeclone-mcp` launcher (`codeclone >= 2.0.0`) -## Install - -CodeClone for VS Code needs a local `codeclone-mcp` launcher. +--- -Minimum supported CodeClone version: `2.0.0`. +## Install -In `auto` mode, the extension checks the current workspace virtualenv before -falling back to `PATH`. Runtime and version-mismatch messages identify that resolved launcher source. +Install the `codeclone-mcp` launcher before enabling the extension. -Recommended install: +**Recommended (global tool via uv):** ```bash uv tool install "codeclone[mcp]" ``` -If you want the launcher inside the current environment instead: +**Current environment only:** ```bash uv pip install "codeclone[mcp]" ``` -Verify the launcher: +**Verify:** ```bash codeclone-mcp --help ``` -## First run +In `auto` mode the extension checks the current workspace virtualenv first, +then falls back to `PATH`. Version-mismatch messages identify the resolved launcher source. + +--- + +## Getting started 1. Open a trusted Python workspace. -2. Open the `CodeClone` view container. -3. Run `Analyze Workspace`. -4. Use `Review Priorities` or `Review Changes` as the first pass. -5. If the first pass looks clean but you want smaller repeated units, open - `Set Analysis Depth`. +2. Open the **CodeClone** view container. +3. Run **Analyze Workspace**. +4. Start with **Review Priorities** or **Review Changes** as the first pass. +5. To tune sensitivity, open **Set Analysis Depth**. + +If the launcher is missing, use **Open Setup Help** from the view or the command palette. -If the local launcher is missing, use `Open Setup Help` from the view or command -palette. +--- -## Main surfaces +## Main views ### Overview -Compact repository health, current run state, baseline drift, and next-best +Compact repository health, current run state, baseline drift, and the next recommended review action. ### Hotspots -The main operational view. It focuses on: +The primary operational view. Surfaces: -- new regressions -- production hotspots -- changed-files findings -- report-only Security Surfaces +- new regressions and production hotspots +- changed-files findings against the configured diff ref +- Coverage Join items when `coverage.xml` is available +- report-only Security Surfaces (boundary inventory, not vulnerability claims) - report-only Overloaded Module candidates -Focus mode is explicit and persisted per workspace. The extension favors -`Recommended` by default and keeps report-only candidates visually separate from -findings. +Focus mode is explicit and persisted per workspace; `Recommended` is the default. ### Runs & Session -Bounded MCP session state: - -- local server availability -- current run identity -- reviewed findings -- help topics - -Reviewed markers are session-local only and do not mutate the repository or the -canonical report. +Bounded MCP session state: server availability, current run identity, reviewed findings, +and help topics. Reviewed markers are session-local and do not mutate the repository or report. -### Editor interaction +--- -- `Reveal Source` is the default review action for findings -- active review targets can be stepped with `Next Hotspot` / `Previous Hotspot` -- review-relevant files receive lightweight Explorer decorations -- CodeLens and editor-title actions appear only when the current editor matches - the active review target -- `Open in HTML Report` is available as an explicit bridge, not as the primary - review surface - -## Interaction model - -The extension is intentionally code-centered: - -- findings prefer `Reveal Source` as the default review action -- source locations are opened in the editor and softly highlighted -- deeper actions stay explicit: - - `Open Finding` - - `Show Remediation` - - `Mark Reviewed` - -This keeps the extension focused on review and refactoring flow instead of -opening raw JSON-like details by default. +## Settings -## Product decisions +| Setting | Default | Scope | Description | +|-------------------------------------|----------------|----------|-----------------------------------------------------------------------------------------------------| +| `codeclone.mcp.command` | `auto` | Machine | Launcher used to start the local CodeClone server. `auto` checks workspace virtualenv, then `PATH`. | +| `codeclone.mcp.args` | `[]` | Machine | Extra arguments passed to the launcher. | +| `codeclone.analysis.cachePolicy` | — | Resource | Default cache policy for analysis requests. Can differ per workspace or folder. | +| `codeclone.analysis.changedDiffRef` | — | Resource | Git revision used by **Review Changes**. | +| `codeclone.analysis.profile` | `conservative` | Resource | Analysis sensitivity. Use `deeper` or `custom` only as deliberate follow-ups. | +| `codeclone.analysis.minLoc` | — | Resource | Function/block/segment thresholds — active only when profile is `custom`. | +| `codeclone.analysis.coverageXml` | — | Resource | Path to `coverage.xml`. Auto-detects workspace-root file when unset. | +| `codeclone.ui.showStatusBar` | `true` | Window | Show or hide the workspace-level status bar item. | -- **Native VS Code first**: tree views, status bar, file decorations, and - editor actions come before any richer custom surface. -- **No second truth model**: health, findings, and drift come from CodeClone - MCP and canonical report semantics only. -- **Source-first**: review should move you to code before it opens deeper - detail. -- **Report-only separation**: `Overloaded Modules` are visible but intentionally kept - outside findings, gates, and health. `Security Surfaces` follow the same rule - and stay framed as review-sensitive boundary inventory rather than - vulnerability proof. -- **Limited Restricted Mode**: the extension keeps setup/onboarding available in - untrusted workspaces, but local analysis and MCP stay disabled until trust is - granted. +--- -## Current limits +## Limitations -- The extension does not run background analysis on every save. -- It does not populate VS Code Problems or try to behave like a linter. +- No background analysis on save; no VS Code Problems / diagnostics integration. - Reviewed markers are session-local only. -- `Open in HTML Report` only uses a local `report.html` when one already exists - and looks fresh enough for the current run. +- `Open in HTML Report` requires a local `report.html` that is fresh for the current run. - Virtual workspaces are not supported. -## Settings - -### `codeclone.mcp.command` - -Launcher used to start the local CodeClone server. Leave it as `auto` for the -default behavior. This is a machine-scoped setting, so it belongs in user or -remote settings rather than workspace settings. +--- -### `codeclone.mcp.args` +## Trust model -Extra arguments passed to the configured launcher. This is also machine-scoped. +The extension accesses local filesystem and git state to run structural analysis. +Untrusted workspaces are supported in a limited setup/onboarding mode only; +full analysis and MCP are disabled until workspace trust is granted. -### `codeclone.analysis.cachePolicy` +--- -Default cache policy for analysis requests. Analysis settings are resource-scoped, -so they can differ per workspace or folder. +## Design decisions -### `codeclone.analysis.changedDiffRef` +- **No second truth model** — health, findings, and drift come exclusively from + `codeclone-mcp` and canonical report semantics. +- **Read-only** — the extension never edits source files, baselines, caches, or report artifacts. +- **Report-only separation** — Security Surfaces and Overloaded Modules are visible but + intentionally excluded from findings, gates, and health scoring. +- **Source-first** — the default review action moves you to code before opening deeper detail. -Git revision used by `Review Changes`. +--- -### `codeclone.analysis.profile` - -Keeps the default conservative pass explicit and exposes `Deeper review` or -`Custom` only as deliberate higher-sensitivity follow-ups. - -### `codeclone.analysis.minLoc` and related threshold settings - -Function, block, and segment thresholds used only when -`codeclone.analysis.profile` is set to `custom`. - -### `codeclone.ui.showStatusBar` - -Show or hide the workspace-level status bar item for the current VS Code window. - -## Trust and workspace model - -This extension runs structural analysis against the current repository and uses -local filesystem and git state. For that reason: - -- untrusted workspaces are supported only in a limited onboarding/setup mode -- virtual workspaces are not supported -- the extension runs as a workspace extension - -## Source of truth - -The extension is a client over `codeclone-mcp`. - -It does not: - -- recompute findings independently -- redefine health semantics -- mutate the repository -- rewrite baselines or reports - -If you need the contract-level documentation behind the extension behavior, see: +## Documentation - [CodeClone documentation](https://orenlab.github.io/codeclone/) - [MCP usage guide](https://orenlab.github.io/codeclone/mcp/) - [MCP interface contract](https://orenlab.github.io/codeclone/book/20-mcp-interface/) -## Development +--- -Open this folder in VS Code and press `F5` to run an Extension Development -Host. +## Development -Useful local checks: +Open this folder in VS Code and press `F5` to launch an Extension Development Host. ```bash node --check src/support.js diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 722efdc..4f7a3c7 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -78,6 +78,9 @@ "onCommand:codeclone.openSetupHelp", "onCommand:codeclone.openOverloadedModule", "onCommand:codeclone.copyOverloadedModuleBrief", + "onCommand:codeclone.openCoverageJoin", + "onCommand:codeclone.reviewCoverageJoin", + "onCommand:codeclone.copyCoverageJoinBrief", "onCommand:codeclone.openSecuritySurface", "onCommand:codeclone.copySecuritySurfaceBrief", "onCommand:codeclone.manageWorkspaceTrust" @@ -317,6 +320,23 @@ "category": "CodeClone", "icon": "$(copy)" }, + { + "command": "codeclone.openCoverageJoin", + "title": "Open Coverage Join Detail", + "category": "CodeClone", + "icon": "$(beaker)" + }, + { + "command": "codeclone.reviewCoverageJoin", + "title": "Review Coverage Join Item", + "category": "CodeClone" + }, + { + "command": "codeclone.copyCoverageJoinBrief", + "title": "Copy Coverage Review Brief", + "category": "CodeClone", + "icon": "$(copy)" + }, { "command": "codeclone.openSecuritySurface", "title": "Open Security Surface Detail", @@ -404,6 +424,18 @@ "command": "codeclone.reviewOverloadedModule", "when": "false" }, + { + "command": "codeclone.openCoverageJoin", + "when": "false" + }, + { + "command": "codeclone.copyCoverageJoinBrief", + "when": "false" + }, + { + "command": "codeclone.reviewCoverageJoin", + "when": "false" + }, { "command": "codeclone.openSecuritySurface", "when": "false" @@ -585,11 +617,26 @@ "when": "viewItem == codeclone.overloadedModule", "group": "navigation@1" }, + { + "command": "codeclone.openCoverageJoin", + "when": "viewItem == codeclone.coverageJoin", + "group": "inline@1" + }, + { + "command": "codeclone.copyCoverageJoinBrief", + "when": "viewItem == codeclone.coverageJoin", + "group": "navigation@1" + }, { "command": "codeclone.openSecuritySurface", "when": "viewItem == codeclone.securitySurface", "group": "inline@1" }, + { + "command": "codeclone.copyCoverageJoinBrief", + "when": "editorTextFocus && codeclone.activeReviewTargetVisibleInEditor && codeclone.activeReviewTargetIsCoverageJoin", + "group": "secondary@4" + }, { "command": "codeclone.copySecuritySurfaceBrief", "when": "viewItem == codeclone.securitySurface", @@ -710,6 +757,18 @@ "default": "HEAD", "description": "Git revision used for changed-files analysis." }, + "codeclone.analysis.coverageXml": { + "type": "string", + "scope": "resource", + "default": "", + "description": "Optional workspace-local Cobertura coverage.xml path passed to CodeClone Coverage Join. Leave empty to auto-detect coverage.xml in the workspace root." + }, + "codeclone.analysis.autoDetectCoverageXml": { + "type": "boolean", + "scope": "resource", + "default": true, + "description": "When coverageXml is empty, automatically pass workspace-root coverage.xml to CodeClone if the file exists." + }, "codeclone.analysis.profile": { "type": "string", "enum": [ diff --git a/extensions/vscode-codeclone/src/constants.js b/extensions/vscode-codeclone/src/constants.js index 749d524..74f71b3 100644 --- a/extensions/vscode-codeclone/src/constants.js +++ b/extensions/vscode-codeclone/src/constants.js @@ -23,6 +23,7 @@ const HOTSPOT_GROUPS = [ {id: "newRegressions", label: "New Regressions", icon: "diff-added"}, {id: "productionHotspots", label: "Production Hotspots", icon: "target"}, {id: "changedFiles", label: "Changed Files", icon: "git-commit"}, + {id: "coverageJoin", label: "Coverage Join", icon: "beaker"}, {id: "securitySurfaces", label: "Security Surfaces", icon: "shield"}, {id: "overloadedModules", label: "Overloaded Modules", icon: "symbol-module"}, ]; diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js index 5d30ab9..74a2e93 100644 --- a/extensions/vscode-codeclone/src/extension.js +++ b/extensions/vscode-codeclone/src/extension.js @@ -21,6 +21,7 @@ const { capitalize, compactDecimal, coverageJoinPayload, + coverageJoinReviewItemCount, decimal, emptyReviewArtifacts, findingIcon, @@ -32,8 +33,12 @@ const { formatCacheSummary, formatCoverageJoinMeasuredUnits, formatCoverageJoinPercent, + formatCoverageJoinLocation, + formatCoverageJoinReviewSignal, formatCoverageJoinStatus, formatCoverageJoinSummary, + formatOverloadedModuleStatus, + formatOverloadedModulesSummary, formatKind, formatNovelty, formatRunScope, @@ -42,10 +47,15 @@ const { formatSeverity, formatSourceKindSummary, humanizeIdentifier, + isCoverageJoinReviewItem, + isOverloadedModuleCandidate, isSpecificFocusMode, normalizeFindingLocations, normalizeRelativePath, number, + overloadedModuleCandidateCount, + qualityReviewItemCount, + reportReviewItemCount, reviewTargetKey, safeArray, safeObject, @@ -57,6 +67,7 @@ const { const {CodeCloneMcpClient, MCPClientError} = require("./mcpClient"); const { markdownBulletList, + renderCoverageJoinMarkdown, renderFindingMarkdown, renderOverloadedModuleMarkdown, renderSecuritySurfaceMarkdown, @@ -80,6 +91,7 @@ const { looksLikeCodeCloneRepo, pathExists, readFileHead, + resolveCoverageXmlPath, sameGitSnapshot, } = require("./runtime"); const { @@ -347,6 +359,15 @@ class CodeCloneController { vscode.commands.registerCommand("codeclone.reviewOverloadedModule", (node) => this.reviewOverloadedModule(node) ), + vscode.commands.registerCommand("codeclone.openCoverageJoin", (node) => + this.openCoverageJoin(node) + ), + vscode.commands.registerCommand("codeclone.copyCoverageJoinBrief", (node) => + this.copyCoverageJoinBrief(node) + ), + vscode.commands.registerCommand("codeclone.reviewCoverageJoin", (node) => + this.reviewCoverageJoin(node) + ), vscode.commands.registerCommand("codeclone.openSecuritySurface", (node) => this.openSecuritySurface(node) ), @@ -862,10 +883,13 @@ class CodeCloneController { safeArray(securitySurfacesResponse.items), safeArray(coverageJoinResponse.items) ); + const normalizedCoverageJoinItems = + safeArray(coverageJoinResponse.items).filter(isCoverageJoinReviewItem); state.reviewArtifacts = { newRegressions: safeArray(newRegressionsResponse.items), productionHotspots: safeArray(productionHotspotsResponse.items), changedFiles: safeArray(changedFilesResponse.items), + coverageJoin: normalizedCoverageJoinItems, overloadedModules: safeArray(overloadedModulesResponse.items), securitySurfaces: normalizedSecuritySurfaces, }; @@ -1067,6 +1091,14 @@ class CodeCloneController { const config = vscode.workspace.getConfiguration("codeclone", folder.uri); const cachePolicy = config.get("analysis.cachePolicy", "reuse"); const diffRef = config.get("analysis.changedDiffRef", "HEAD"); + const coverageXmlPath = await resolveCoverageXmlPath( + folder.uri.fsPath, + config.get("analysis.coverageXml", ""), + config.get("analysis.autoDetectCoverageXml", true) + ); + const coverageOverride = coverageXmlPath + ? {coverage_xml: coverageXmlPath} + : {}; const analysisSettings = this.configuredAnalysisSettings(folder); const title = changedMode ? `CodeClone: Analyzing changed files in ${folder.name}` @@ -1091,11 +1123,13 @@ class CodeCloneController { root: folder.uri.fsPath, git_diff_ref: diffRef, cache_policy: cachePolicy, + ...coverageOverride, ...analysisSettings.overrides, }) : await this.client.callTool("analyze_repository", { root: folder.uri.fsPath, cache_policy: cachePolicy, + ...coverageOverride, ...analysisSettings.overrides, }); const runId = String(analysisPayload.run_id); @@ -1182,6 +1216,7 @@ class CodeCloneController { !candidate || candidate.nodeType === "overloadedModule" || candidate.nodeType === "securitySurface" || + candidate.nodeType === "coverageJoin" || !candidate.findingId ) { return null; @@ -1201,6 +1236,18 @@ class CodeCloneController { return candidate; } + activeCoverageJoinTarget(node) { + const candidate = node || this.activeReviewTarget; + if ( + !candidate || + candidate.nodeType !== "coverageJoin" || + !safeObject(candidate.item).path + ) { + return null; + } + return candidate; + } + activeSecuritySurfaceTarget(node) { const candidate = node || this.activeReviewTarget; if ( @@ -1230,6 +1277,11 @@ class CodeCloneController { (location) => location.absolutePath === fsPath ); } + if (target.nodeType === "coverageJoin") { + return safeArray(target.locations).some( + (location) => location.absolutePath === fsPath + ); + } return safeArray(target.locations).some( (location) => location.absolutePath === fsPath ); @@ -1327,6 +1379,8 @@ class CodeCloneController { return safeArray(artifacts.productionHotspots); case "changedFiles": return safeArray(artifacts.changedFiles); + case "coverageJoin": + return safeArray(artifacts.coverageJoin); case "overloadedModules": return safeArray(artifacts.overloadedModules); case "securitySurfaces": @@ -1336,7 +1390,29 @@ class CodeCloneController { } } + overloadedModulesSummary(state) { + return safeObject(safeObject(state?.metricsSummary).overloaded_modules); + } + + overloadedModuleCandidateItems(state) { + return this.reviewArtifactItems(state, "overloadedModules").filter( + isOverloadedModuleCandidate + ); + } + reviewArtifactCount(state, groupId) { + if (groupId === "overloadedModules") { + return overloadedModuleCandidateCount( + this.overloadedModulesSummary(state), + this.reviewArtifactItems(state, "overloadedModules") + ); + } + if (groupId === "coverageJoin") { + return coverageJoinReviewItemCount( + coverageJoinPayload(state?.metricsSummary), + this.reviewArtifactItems(state, "coverageJoin") + ); + } return this.reviewArtifactItems(state, groupId).length; } @@ -1453,14 +1529,18 @@ class CodeCloneController { } buildOverloadedModuleNode(state, item) { + const status = formatOverloadedModuleStatus(item); + const pathLabel = item.path || item.relative_path || item.module || "(unknown)"; return { nodeType: "overloadedModule", workspaceKey: state.folder.uri.toString(), runId: state.currentRunId, item, - label: item.path, - description: `${decimal(item.score)} · ${item.source_kind} · report-only`, - tooltip: `${item.module} · ${number(item.loc)} LOC · ${item.total_deps} deps`, + label: pathLabel, + description: `${status} · ${decimal(item.score)} · ${item.source_kind} · report-only`, + tooltip: + `${item.module} · ${status}\n` + + `${number(item.loc)} LOC · ${item.total_deps} deps`, icon: new vscode.ThemeIcon("symbol-module"), contextValue: "codeclone.overloadedModule", command: { @@ -1478,6 +1558,76 @@ class CodeCloneController { }; } + toCoverageJoinNodes(state, items) { + return items.map((item) => this.buildCoverageJoinNode(state, item)); + } + + coverageJoinLocations(state, item) { + return [ + { + path: String(item.path || ""), + line: + typeof item.start_line === "number" && !Number.isNaN(item.start_line) + ? item.start_line + : null, + end_line: + typeof item.end_line === "number" && !Number.isNaN(item.end_line) + ? item.end_line + : null, + symbol: item.qualname ? String(item.qualname) : null, + absolutePath: + resolveWorkspacePath( + state.folder.uri.fsPath, + String(item.path || "") + ) || "", + }, + ].filter((location) => location.absolutePath); + } + + hydrateCoverageJoinNode(state, node) { + const locations = + safeArray(node.locations).length > 0 + ? safeArray(node.locations) + : this.coverageJoinLocations(state, safeObject(node.item)); + return { + ...node, + nodeType: "coverageJoin", + locations, + }; + } + + buildCoverageJoinNode(state, item) { + const locationLabel = formatCoverageJoinLocation(item); + const locations = this.coverageJoinLocations(state, item); + return { + nodeType: "coverageJoin", + workspaceKey: state.folder.uri.toString(), + runId: state.currentRunId, + item, + label: locationLabel, + description: `${formatCoverageJoinReviewSignal(item)} · ${item.risk || "low"} risk`, + tooltip: + `${item.qualname || "(unknown)"}\n` + + `Coverage: ${formatCoverageJoinReviewSignal(item)}`, + icon: new vscode.ThemeIcon("beaker"), + contextValue: "codeclone.coverageJoin", + locations, + command: { + command: "codeclone.reviewCoverageJoin", + title: "Review Coverage Join Item", + arguments: [ + { + workspaceKey: state.folder.uri.toString(), + runId: state.currentRunId, + item, + nodeType: "coverageJoin", + locations, + }, + ], + }, + }; + } + toSecuritySurfaceNodes(state, items) { return items.map((item) => this.buildSecuritySurfaceNode(state, item)); } @@ -1552,12 +1702,13 @@ class CodeCloneController { const artifacts = safeObject(state.reviewArtifacts); const groupIds = this.hotspotFocusMode === "recommended" - ? ["changedFiles", "newRegressions", "productionHotspots"] + ? ["changedFiles", "newRegressions", "productionHotspots", "coverageJoin"] : this.hotspotFocusMode === "all" ? [ "changedFiles", "newRegressions", "productionHotspots", + "coverageJoin", "securitySurfaces", "overloadedModules", ] @@ -1568,7 +1719,21 @@ class CodeCloneController { if (groupId === "overloadedModules") { for (const node of this.toOverloadedModuleNodes( state, - safeArray(artifacts.overloadedModules) + this.overloadedModuleCandidateItems(state) + )) { + const key = reviewTargetKey(node); + if (!key || seen.has(key)) { + continue; + } + seen.add(key); + queue.push(node); + } + continue; + } + if (groupId === "coverageJoin") { + for (const node of this.toCoverageJoinNodes( + state, + this.reviewArtifactItems(state, "coverageJoin") )) { const key = reviewTargetKey(node); if (!key || seen.has(key)) { @@ -1614,13 +1779,17 @@ class CodeCloneController { ) ) { return [ + ...this.toCoverageJoinNodes( + state, + this.reviewArtifactItems(state, "coverageJoin") + ), ...this.toSecuritySurfaceNodes( state, safeArray(artifacts.securitySurfaces) ), ...this.toOverloadedModuleNodes( state, - safeArray(artifacts.overloadedModules) + this.overloadedModuleCandidateItems(state) ), ]; } @@ -1671,6 +1840,10 @@ class CodeCloneController { await this.revealSecuritySurfaceSource(nextNode); return; } + if (nextNode.nodeType === "coverageJoin") { + await this.revealCoverageJoinSource(nextNode); + return; + } await this.revealFindingSource(nextNode); } @@ -1732,6 +1905,8 @@ class CodeCloneController { if (picked) { if (picked.node.nodeType === "overloadedModule") { await this.reviewOverloadedModule(picked.node); + } else if (picked.node.nodeType === "coverageJoin") { + await this.reviewCoverageJoin(picked.node); } else if (picked.node.nodeType === "securitySurface") { await this.reviewSecuritySurface(picked.node); } else { @@ -2194,6 +2369,32 @@ class CodeCloneController { ); } + async revealCoverageJoinSource(node) { + const activeNode = this.activeCoverageJoinTarget(node); + if (!activeNode) { + return; + } + const state = this.states.get(activeNode.workspaceKey); + if (!state) { + return; + } + const resolved = this.hydrateCoverageJoinNode(state, activeNode); + this.setActiveReviewTarget(resolved); + const location = firstNormalizedLocation(state.folder, resolved.locations); + if (!location || !location.path) { + await vscode.window.showInformationMessage( + "This Coverage Join item does not expose a source location." + ); + return; + } + await this.revealWorkspacePath( + state.folder, + location.path, + location.line ?? undefined, + location.end_line ?? undefined + ); + } + /** * @param {any} folder * @param {string} relativePath @@ -2395,6 +2596,98 @@ class CodeCloneController { await this.showMarkdownDocument(renderSecuritySurfaceMarkdown(resolved.item)); } + async openCoverageJoin(node) { + const activeNode = this.activeCoverageJoinTarget(node); + if (!activeNode) { + return; + } + const state = this.states.get(activeNode.workspaceKey); + if (!state) { + return; + } + const resolved = this.hydrateCoverageJoinNode(state, activeNode); + this.setActiveReviewTarget(resolved); + await this.showMarkdownDocument(renderCoverageJoinMarkdown(resolved.item)); + } + + async reviewCoverageJoin(node) { + const activeNode = this.activeCoverageJoinTarget(node); + if (!activeNode) { + return; + } + const state = this.states.get(activeNode.workspaceKey); + if (!state) { + return; + } + const resolved = this.hydrateCoverageJoinNode(state, activeNode); + this.setActiveReviewTarget(resolved); + const picked = await vscode.window.showQuickPick( + [ + { + label: "Reveal source", + description: "Recommended", + action: "reveal", + }, + { + label: "Show Coverage Join detail", + description: "Open joined coverage summary", + action: "detail", + }, + { + label: "Copy coverage review brief", + description: "AI handoff", + action: "brief", + }, + ], + { + title: "Review Coverage Join Item", + placeHolder: `What do you want to do with ${formatCoverageJoinLocation(resolved.item)}?`, + } + ); + if (!picked) { + return; + } + if (picked.action === "reveal") { + await this.revealCoverageJoinSource(resolved); + return; + } + if (picked.action === "brief") { + await this.copyCoverageJoinBrief(resolved); + return; + } + await this.openCoverageJoin(resolved); + } + + async copyCoverageJoinBrief(node) { + const activeNode = this.activeCoverageJoinTarget(node); + if (!activeNode) { + return; + } + const state = this.states.get(activeNode.workspaceKey); + if (!state) { + return; + } + const resolved = this.hydrateCoverageJoinNode(state, activeNode); + this.setActiveReviewTarget(resolved); + const item = resolved.item; + const lines = [ + "# CodeClone Coverage Join Brief", + "", + `Repository: ${state.folder.name || "unknown"}`, + `Location: ${formatCoverageJoinLocation(item)}`, + `Function: ${item.qualname || "(unknown)"}`, + `Review signal: ${formatCoverageJoinReviewSignal(item)}`, + `Risk: ${item.risk || "low"}`, + `CC: ${number(item.cyclomatic_complexity || 0)}`, + "", + "Treat this as joined coverage review context. Verify coverage before refactoring structurally risky code.", + ]; + await vscode.env.clipboard.writeText(lines.join("\n")); + await vscode.window.showInformationMessage( + `Copied coverage review brief for ${formatCoverageJoinLocation(item)}.` + ); + } + async reviewSecuritySurface(node) { const activeNode = this.activeSecuritySurfaceTarget(node); if (!activeNode) { @@ -2623,6 +2916,38 @@ class CodeCloneController { }), ]; } + if (target.nodeType === "coverageJoin") { + const state = this.states.get(target.workspaceKey); + if (!state) { + return []; + } + const location = firstNormalizedLocation(state.folder, target.locations); + if (!location || location.absolutePath !== document.uri.fsPath) { + return []; + } + const startLine = Math.max(Number(location.line || 1) - 1, 0); + const range = new vscode.Range(startLine, 0, startLine, 0); + return [ + new vscode.CodeLens(range, { + command: "codeclone.previousReviewItem", + title: "$(arrow-up) Previous hotspot", + }), + new vscode.CodeLens(range, { + command: "codeclone.nextReviewItem", + title: "$(arrow-down) Next hotspot", + }), + new vscode.CodeLens(range, { + command: "codeclone.openCoverageJoin", + title: "$(beaker) Coverage detail", + arguments: [target], + }), + new vscode.CodeLens(range, { + command: "codeclone.copyCoverageJoinBrief", + title: "$(copy) Copy coverage brief", + arguments: [target], + }), + ]; + } const state = this.states.get(target.workspaceKey); if (!state) { return []; @@ -2696,12 +3021,15 @@ class CodeCloneController { changed: this.reviewArtifactCount(state, "changedFiles"), new: this.reviewArtifactCount(state, "newRegressions"), production: this.reviewArtifactCount(state, "productionHotspots"), + coverageJoin: this.reviewArtifactCount(state, "coverageJoin"), securitySurfaces: this.reviewArtifactCount(state, "securitySurfaces"), overloadedModules: this.reviewArtifactCount(state, "overloadedModules"), }; const baselineDrift = this.baselineDrift(state); const coverageJoin = coverageJoinPayload(state.metricsSummary); const securitySurfaces = securitySurfacesPayload(state.metricsSummary); + const overloadedModules = this.overloadedModulesSummary(state); + const overloadedModuleRows = this.reviewArtifactItems(state, "overloadedModules"); if (!node) { const sections = [ { @@ -2747,13 +3075,12 @@ class CodeCloneController { icon: new vscode.ThemeIcon("git-commit"), }); } - if (safeObject(state.metricsSummary).overloaded_modules) { - const overloadedModules = safeObject(state.metricsSummary).overloaded_modules; + if (Object.keys(overloadedModules).length > 0) { sections.push({ nodeType: "section", id: "overview.god", label: "Overloaded Modules", - description: `${overloadedModules.candidates} candidates · top ${decimal(overloadedModules.top_score)} (report-only)`, + description: `${formatOverloadedModulesSummary(overloadedModules, overloadedModuleRows)} · top ${decimal(overloadedModules.top_score)}`, icon: new vscode.ThemeIcon("symbol-module"), }); } @@ -2773,7 +3100,7 @@ class CodeCloneController { id: "overview.coverageJoin", label: "Coverage Join", description: formatCoverageJoinSummary(coverageJoin), - icon: new vscode.ThemeIcon("shield"), + icon: new vscode.ThemeIcon("beaker"), }); } return sections; @@ -2915,16 +3242,15 @@ class CodeCloneController { ]; } if (node.id === "overview.god") { - const overloadedModules = safeObject(state.metricsSummary).overloaded_modules; return [ this.detailNode("Candidates", number(overloadedModules.candidates)), - this.detailNode("Ranked modules", number(overloadedModules.total)), + this.detailNode("Analyzed modules", number(overloadedModules.total)), this.detailNode("Top score", decimal(overloadedModules.top_score)), this.detailNode("Average score", decimal(overloadedModules.average_score)), this.detailNode("Population", String(overloadedModules.population_status)), this.detailNode( "Review surface", - `${number(reviewCounts.overloadedModules)} visible in Hotspots` + formatOverloadedModulesSummary(overloadedModules, overloadedModuleRows) ), ]; } @@ -3190,6 +3516,12 @@ class CodeCloneController { this.reviewArtifactItems(state, "changedFiles") ); break; + case "coverageJoin": + nodes = this.toCoverageJoinNodes( + state, + this.reviewArtifactItems(state, "coverageJoin") + ); + break; case "overloadedModules": nodes = this.toOverloadedModuleNodes( state, @@ -3284,10 +3616,15 @@ class CodeCloneController { return state.changedSummary ? `${this.reviewArtifactCount(state, "changedFiles")} visible · ${state.changedSummary.verdict}` : "not analyzed"; + case "coverageJoin": + return `${this.reviewArtifactCount(state, "coverageJoin")} review`; case "securitySurfaces": return `${this.reviewArtifactCount(state, "securitySurfaces")} report-only`; case "overloadedModules": - return `${this.reviewArtifactCount(state, "overloadedModules")} report-only`; + return formatOverloadedModulesSummary( + this.overloadedModulesSummary(state), + this.reviewArtifactItems(state, "overloadedModules") + ); default: return ""; } @@ -3301,6 +3638,8 @@ class CodeCloneController { return "No production hotspots are visible."; case "changedFiles": return "No findings touching changed files are visible."; + case "coverageJoin": + return "No Coverage Join review items are visible."; case "securitySurfaces": return "No report-only Security Surfaces are visible."; case "overloadedModules": @@ -3332,6 +3671,8 @@ class CodeCloneController { return this.hotspotFocusMode === "changed"; } return specificMode || this.reviewArtifactCount(state, "changedFiles") > 0; + case "coverageJoin": + return specificMode || this.reviewArtifactCount(state, "coverageJoin") > 0; case "securitySurfaces": return specificMode || this.reviewArtifactCount(state, "securitySurfaces") > 0; case "overloadedModules": @@ -3466,6 +3807,18 @@ class CodeCloneController { item.command = node.command; break; } + case "coverageJoin": { + item = new vscode.TreeItem( + node.label, + vscode.TreeItemCollapsibleState.None + ); + item.description = node.description; + item.tooltip = node.tooltip; + item.iconPath = node.icon; + item.contextValue = "codeclone.coverageJoin"; + item.command = node.command; + break; + } case "securitySurface": { item = new vscode.TreeItem( node.label, @@ -3549,8 +3902,11 @@ class CodeCloneController { this.reviewArtifactCount(state, "productionHotspots") ); const changedCount = Number(this.reviewArtifactCount(state, "changedFiles")); + const coverageJoinCount = Number( + this.reviewArtifactCount(state, "coverageJoin") + ); const actionableCount = Math.max( - newCount + productionCount, + newCount + productionCount + coverageJoinCount, changedCount ); const securitySurfaceCount = Number( @@ -3559,6 +3915,20 @@ class CodeCloneController { const overloadedModuleCount = Number( this.reviewArtifactCount(state, "overloadedModules") ); + const qualityCount = Number( + qualityReviewItemCount( + state?.metricsSummary, + safeObject(state?.reviewArtifacts) + ) + ); + const reportCount = Number( + reportReviewItemCount( + state?.latestSummary, + state?.metricsSummary, + state?.latestTriage, + safeObject(state?.reviewArtifacts) + ) + ); const reportOnlyCount = securitySurfaceCount + overloadedModuleCount; let badgeValue = 0; let badgeTooltip = ""; @@ -3575,6 +3945,10 @@ class CodeCloneController { badgeValue = changedCount; badgeTooltip = `${changedCount} changed-files review items are visible in Hotspots`; break; + case "coverageJoin": + badgeValue = coverageJoinCount; + badgeTooltip = `${coverageJoinCount} Coverage Join review items are visible in Hotspots`; + break; case "reportOnly": badgeValue = reportOnlyCount; badgeTooltip = reportOnlyCount > 0 @@ -3582,10 +3956,21 @@ class CodeCloneController { : "No report-only review items are visible in Hotspots"; break; default: - badgeValue = actionableCount > 0 ? actionableCount : reportOnlyCount; + badgeValue = + reportCount > 0 + ? reportCount + : qualityCount > 0 + ? qualityCount + : actionableCount > 0 + ? actionableCount + : reportOnlyCount; badgeTooltip = - actionableCount > 0 - ? `${actionableCount} review items need attention` + reportCount > 0 + ? `${reportCount} report review items are visible in CodeClone` + : qualityCount > 0 + ? `${qualityCount} Quality review items are visible in Hotspots` + : actionableCount > 0 + ? `${actionableCount} review items need attention` : `${reportOnlyCount} report-only review items are visible in Hotspots`; break; } @@ -3656,6 +4041,11 @@ class CodeCloneController { "codeclone.activeReviewTargetIsOverloadedModule", Boolean(activeTarget && activeTarget.nodeType === "overloadedModule") ); + void vscode.commands.executeCommand( + "setContext", + "codeclone.activeReviewTargetIsCoverageJoin", + Boolean(activeTarget && activeTarget.nodeType === "coverageJoin") + ); void vscode.commands.executeCommand( "setContext", "codeclone.activeReviewTargetIsSecuritySurface", diff --git a/extensions/vscode-codeclone/src/formatters.js b/extensions/vscode-codeclone/src/formatters.js index dac0226..55e544c 100644 --- a/extensions/vscode-codeclone/src/formatters.js +++ b/extensions/vscode-codeclone/src/formatters.js @@ -98,6 +98,33 @@ function coverageJoinPayload(metricsSummary) { return safeObject(safeObject(metricsSummary).coverage_join); } +function isCoverageJoinReviewItem(item) { + const entry = safeObject(item); + return Boolean( + entry.coverage_review_item || + entry.coverage_hotspot || + entry.scope_gap_hotspot + ); +} + +function coverageJoinReviewItemCount(summary, items) { + const entry = safeObject(summary); + if ( + Object.prototype.hasOwnProperty.call(entry, "coverage_hotspots") || + Object.prototype.hasOwnProperty.call(entry, "scope_gap_hotspots") + ) { + return ( + (finiteInteger(entry.coverage_hotspots) ?? 0) + + (finiteInteger(entry.scope_gap_hotspots) ?? 0) + ); + } + return safeArray(items).filter(isCoverageJoinReviewItem).length; +} + +function metricSummaryCount(metricsSummary, family, key) { + return finiteInteger(safeObject(safeObject(metricsSummary)[family])[key]) ?? 0; +} + function securitySurfacesPayload(metricsSummary) { return safeObject(safeObject(metricsSummary).security_surfaces); } @@ -177,6 +204,21 @@ function formatCoverageJoinSummary(payload) { return parts.join(" · "); } +function formatCoverageJoinLocation(item) { + return formatSecuritySurfaceLocation(item); +} + +function formatCoverageJoinReviewSignal(item) { + const entry = safeObject(item); + if (entry.scope_gap_hotspot) { + return "scope gap"; + } + if (entry.coverage_hotspot) { + return "low coverage"; + } + return String(entry.coverage_status || "measured").replace(/_/g, " "); +} + function formatRunScope(value) { return value === "changed" ? "changed files" : "workspace"; } @@ -277,6 +319,18 @@ function reviewTargetKey(target) { return `security:${pathValue}:${lineValue}:${qualnameValue}:${capabilityValue}`; } } + if (target.nodeType === "coverageJoin") { + const item = safeObject(target.item); + const pathValue = String(item.path || "").trim(); + const qualnameValue = String(item.qualname || "").trim(); + const lineValue = + typeof item.start_line === "number" && !Number.isNaN(item.start_line) + ? item.start_line + : 0; + if (pathValue) { + return `coverage:${pathValue}:${lineValue}:${qualnameValue}`; + } + } if (target.findingId) { return `finding:${String(target.findingId)}`; } @@ -324,6 +378,7 @@ function emptyReviewArtifacts() { newRegressions: [], productionHotspots: [], changedFiles: [], + coverageJoin: [], overloadedModules: [], securitySurfaces: [], }; @@ -370,6 +425,93 @@ function formatSecuritySurfaceReviewSignal(item) { return `${scopeText} · exact evidence`; } +function finiteInteger(value) { + if (typeof value === "number" && Number.isFinite(value)) { + return Math.max(0, Math.trunc(value)); + } + const parsed = Number.parseInt(String(value || ""), 10); + return Number.isFinite(parsed) ? Math.max(0, parsed) : null; +} + +function formatOverloadedModuleStatus(item) { + const entry = safeObject(item); + const rawStatus = String(entry.candidate_status || "").trim().toLowerCase(); + switch (rawStatus) { + case "candidate": + return "candidate"; + case "ranked_only": + return "ranked-only"; + case "non_candidate": + return "non-candidate"; + default: + return "ranked"; + } +} + +function isOverloadedModuleCandidate(item) { + return formatOverloadedModuleStatus(item) === "candidate"; +} + +function overloadedModuleCandidateCount(summary, items) { + const candidateCount = finiteInteger(safeObject(summary).candidates); + if (candidateCount !== null) { + return candidateCount; + } + return safeArray(items).filter(isOverloadedModuleCandidate).length; +} + +function qualityReviewItemCount(metricsSummary, artifacts = {}) { + const summary = safeObject(metricsSummary); + const artifactItems = safeObject(artifacts); + const securitySurfaces = securitySurfacesPayload(summary); + const securitySurfaceCount = + finiteInteger(securitySurfaces.items) ?? + safeArray(artifactItems.securitySurfaces).length; + return ( + metricSummaryCount(summary, "complexity", "high_risk") + + metricSummaryCount(summary, "coupling", "high_risk") + + metricSummaryCount(summary, "cohesion", "low_cohesion") + + overloadedModuleCandidateCount( + safeObject(summary.overloaded_modules), + safeArray(artifactItems.overloadedModules) + ) + + coverageJoinReviewItemCount( + coverageJoinPayload(summary), + safeArray(artifactItems.coverageJoin) + ) + + securitySurfaceCount + ); +} + +function reportReviewItemCount(latestSummary, metricsSummary, triage, artifacts = {}) { + const summary = safeObject(latestSummary); + const metrics = safeObject(metricsSummary); + const findings = safeObject(summary.findings); + const byFamily = safeObject(findings.by_family); + const deadCode = safeObject(metrics.dead_code); + const dependencies = safeObject(metrics.dependencies); + const suggestions = safeObject(safeObject(triage).suggestions); + return ( + (finiteInteger(byFamily.clones) ?? 0) + + qualityReviewItemCount(metrics, artifacts) + + (finiteInteger(dependencies.cycles) ?? 0) + + (finiteInteger(deadCode.high_confidence) ?? 0) + + (finiteInteger(suggestions.total) ?? 0) + + (finiteInteger(byFamily.structural) ?? 0) + ); +} + +function formatOverloadedModulesSummary(summary, items) { + const rows = safeArray(items); + const payload = safeObject(summary); + const candidates = overloadedModuleCandidateCount(payload, rows); + const visible = rows.length; + if (visible > 0 && visible !== candidates) { + return `${number(candidates)} candidates · top ${number(visible)} ranked`; + } + return `${number(candidates)} candidates`; +} + /** * @param {unknown} value * @returns {FindingLocation[]} @@ -460,10 +602,15 @@ module.exports = { formatBooleanWord, formatCacheSummary, coverageJoinPayload, + coverageJoinReviewItemCount, formatCoverageJoinMeasuredUnits, formatCoverageJoinPercent, + formatCoverageJoinLocation, + formatCoverageJoinReviewSignal, formatCoverageJoinStatus, formatCoverageJoinSummary, + formatOverloadedModuleStatus, + formatOverloadedModulesSummary, formatSecuritySurfaceLocation, formatSecuritySurfaceReviewSignal, formatKind, @@ -472,11 +619,16 @@ module.exports = { formatSeverity, formatSourceKindSummary, humanizeIdentifier, + isCoverageJoinReviewItem, + isOverloadedModuleCandidate, isSpecificFocusMode, normalizeFindingLocations, normalizeLocations, normalizeRelativePath, number, + overloadedModuleCandidateCount, + qualityReviewItemCount, + reportReviewItemCount, reviewTargetKey, safeArray, safeObject, diff --git a/extensions/vscode-codeclone/src/renderers.js b/extensions/vscode-codeclone/src/renderers.js index e3aeb08..9b25489 100644 --- a/extensions/vscode-codeclone/src/renderers.js +++ b/extensions/vscode-codeclone/src/renderers.js @@ -11,7 +11,10 @@ const { decimal, formatBaselineTags, formatBaselineState, + formatCoverageJoinLocation, + formatCoverageJoinReviewSignal, formatKind, + formatOverloadedModuleStatus, formatSecuritySurfaceLocation, formatSecuritySurfaceReviewSignal, formatSeverity, @@ -260,11 +263,13 @@ function renderTriageMarkdown(state) { function renderOverloadedModuleMarkdown(item) { const reasons = safeArray(item.candidate_reasons); + const status = formatOverloadedModuleStatus(item); const lines = [ - "# Overloaded Module Candidate", + "# Overloaded Module", "", `- Path: \`${item.path}\``, `- Module: \`${item.module}\``, + `- Status: ${status}`, `- Source kind: ${item.source_kind || "unknown"}`, `- Score: ${decimal(item.score)}`, `- LOC: ${number(item.loc)}`, @@ -279,10 +284,52 @@ function renderOverloadedModuleMarkdown(item) { ]; if (reasons.length > 0) { lines.push("", "## Candidate reasons", markdownBulletList(reasons)); + } else if (status !== "candidate") { + lines.push( + "", + "## Review guidance", + markdownBulletList([ + "This ranked row is report-only context, not an overloaded-module candidate.", + "Use it as surrounding signal after reviewing candidate rows first.", + ]) + ); } return lines.join("\n"); } +function renderCoverageJoinMarkdown(item) { + const location = formatCoverageJoinLocation(item); + const coveragePercent = + typeof item.coverage_permille === "number" && !Number.isNaN(item.coverage_permille) + ? `${compactDecimal(item.coverage_permille / 10)}%` + : "n/a"; + const guidance = [ + "Treat this as joined coverage review context, not as a clone or structural finding.", + ]; + if (item.scope_gap_hotspot) { + guidance.push( + "The callable is in CodeClone analysis but not mapped from coverage.xml; verify whether tests exercise it under another path or add focused coverage." + ); + } else if (item.coverage_hotspot) { + guidance.push( + "The callable is structurally risky and below the configured coverage threshold; inspect or add focused tests before refactoring." + ); + } + return [ + "# Coverage Join Review Item", + "", + `- Location: \`${location}\``, + `- Function: \`${item.qualname || "(unknown)"}\``, + `- Review signal: ${formatCoverageJoinReviewSignal(item)}`, + `- Risk: ${item.risk || "low"}`, + `- CC: ${number(item.cyclomatic_complexity || 0)}`, + `- Coverage: ${coveragePercent}`, + "", + "## Review guidance", + markdownBulletList(guidance), + ].join("\n"); +} + function renderSecuritySurfaceMarkdown(item) { const entry = safeObject(item); const location = formatSecuritySurfaceLocation(entry); @@ -337,6 +384,7 @@ function renderSecuritySurfaceMarkdown(item) { module.exports = { markdownBulletList, renderFindingMarkdown, + renderCoverageJoinMarkdown, renderOverloadedModuleMarkdown, renderSecuritySurfaceMarkdown, renderHelpMarkdown, diff --git a/extensions/vscode-codeclone/src/runArtifacts.js b/extensions/vscode-codeclone/src/runArtifacts.js index a30421e..f715426 100644 --- a/extensions/vscode-codeclone/src/runArtifacts.js +++ b/extensions/vscode-codeclone/src/runArtifacts.js @@ -31,10 +31,18 @@ async function loadRunArtifacts( captureGitSnapshot(folder), ]); + const metricsSummary = {...(metrics.summary || metrics)}; + if (summary.coverage_join && !metricsSummary.coverage_join) { + metricsSummary.coverage_join = summary.coverage_join; + } + if (summary.security_surfaces && !metricsSummary.security_surfaces) { + metricsSummary.security_surfaces = summary.security_surfaces; + } + return { summary, triage, - metricsSummary: metrics.summary || metrics, + metricsSummary, reviewedItems: arrayItems(reviewed.items), gitSnapshot, }; diff --git a/extensions/vscode-codeclone/src/runtime.js b/extensions/vscode-codeclone/src/runtime.js index bdc9a56..7e7b705 100644 --- a/extensions/vscode-codeclone/src/runtime.js +++ b/extensions/vscode-codeclone/src/runtime.js @@ -56,6 +56,45 @@ async function pathExists(filePath) { } } +function workspaceLocalPath(rootPath, candidatePath) { + const root = String(rootPath || "").trim(); + const candidate = String(candidatePath || "").trim(); + if (!root || !candidate) { + return null; + } + const resolved = path.isAbsolute(candidate) + ? path.resolve(candidate) + : path.resolve(root, candidate); + const relativeToRoot = path.relative(root, resolved); + if ( + relativeToRoot === "" || + (!relativeToRoot.startsWith("..") && !path.isAbsolute(relativeToRoot)) + ) { + return resolved; + } + return null; +} + +async function resolveCoverageXmlPath( + rootPath, + configuredPath = "", + autoDetect = true, + exists = pathExists +) { + const configured = String(configuredPath || "").trim(); + if (configured) { + return workspaceLocalPath(rootPath, configured); + } + if (!autoDetect) { + return null; + } + const detected = workspaceLocalPath(rootPath, "coverage.xml"); + if (!detected) { + return null; + } + return (await exists(detected)) ? detected : null; +} + async function looksLikeCodeCloneRepo(folderPath) { const [hasPyproject, hasLegacyServer, hasSurfaceServer] = await Promise.all([ pathExists(path.join(folderPath, "pyproject.toml")), @@ -81,5 +120,6 @@ module.exports = { looksLikeCodeCloneRepo, pathExists, readFileHead, + resolveCoverageXmlPath, sameGitSnapshot, }; diff --git a/extensions/vscode-codeclone/test/formatters.test.js b/extensions/vscode-codeclone/test/formatters.test.js index 14293e1..8478ff0 100644 --- a/extensions/vscode-codeclone/test/formatters.test.js +++ b/extensions/vscode-codeclone/test/formatters.test.js @@ -20,12 +20,22 @@ moduleInternals._load = function patchedLoad(request, parent, isMain) { const { coverageJoinPayload, + coverageJoinReviewItemCount, formatCoverageJoinMeasuredUnits, formatCoverageJoinPercent, + formatCoverageJoinLocation, + formatCoverageJoinReviewSignal, formatCoverageJoinStatus, formatCoverageJoinSummary, + formatOverloadedModuleStatus, + formatOverloadedModulesSummary, formatSecuritySurfaceLocation, formatSecuritySurfaceReviewSignal, + isCoverageJoinReviewItem, + isOverloadedModuleCandidate, + overloadedModuleCandidateCount, + qualityReviewItemCount, + reportReviewItemCount, securitySurfacesPayload, } = require("../src/formatters"); @@ -45,6 +55,7 @@ test("coverage join formatters render joined summary from canonical metrics fact assert.equal(formatCoverageJoinPercent(payload), "99.3%"); assert.equal(formatCoverageJoinMeasuredUnits(payload), "556 / 1,364"); assert.equal(formatCoverageJoinSummary(payload), "99.3% overall · 0 hotspots · 1 scope gap"); + assert.equal(coverageJoinReviewItemCount(payload, []), 1); }); test("coverage join formatters keep invalid or unavailable states explicit", () => { @@ -69,6 +80,27 @@ test("coverage join payload normalizes missing or null metrics family entries", }); }); +test("coverage join item formatters expose review-ready locations and signals", () => { + const hotspot = { + path: "pkg/service.py", + start_line: 12, + end_line: 16, + coverage_hotspot: true, + }; + const scopeGap = { + path: "pkg/service.py", + start_line: 44, + scope_gap_hotspot: true, + }; + + assert.equal(formatCoverageJoinLocation(hotspot), "pkg/service.py:12-16"); + assert.equal(formatCoverageJoinReviewSignal(hotspot), "low coverage"); + assert.equal(formatCoverageJoinReviewSignal(scopeGap), "scope gap"); + assert.equal(isCoverageJoinReviewItem(hotspot), true); + assert.equal(isCoverageJoinReviewItem({coverage_status: "measured"}), false); + assert.equal(coverageJoinReviewItemCount({}, [hotspot, scopeGap]), 2); +}); + test("security surfaces formatters keep summary payloads and review cues explicit", () => { assert.deepEqual(securitySurfacesPayload(undefined), {}); assert.deepEqual(securitySurfacesPayload({}), {}); @@ -109,3 +141,72 @@ test("security surfaces formatters keep summary payloads and review cues explici "Module · capability present" ); }); + +test("report badge count matches canonical report tab semantics", () => { + const latestSummary = { + findings: { + by_family: { + clones: 2, + structural: 3, + }, + }, + }; + const metricsSummary = { + complexity: {high_risk: 4}, + coupling: {high_risk: 5}, + cohesion: {low_cohesion: 6}, + overloaded_modules: {candidates: 11}, + coverage_join: { + status: "ok", + coverage_hotspots: 0, + scope_gap_hotspots: 1, + }, + security_surfaces: {items: 59}, + dependencies: {cycles: 7}, + dead_code: {high_confidence: 8}, + }; + const triage = { + suggestions: {total: 9}, + }; + + assert.equal(qualityReviewItemCount(metricsSummary, {}), 86); + assert.equal(reportReviewItemCount(latestSummary, metricsSummary, triage, {}), 115); +}); + +test("report badge count keeps quality fallback when coverage summary is absent", () => { + const metricsSummary = { + overloaded_modules: {}, + security_surfaces: {}, + }; + const artifacts = { + overloadedModules: [ + {candidate_status: "candidate"}, + {candidate_status: "non_candidate"}, + ], + coverageJoin: [{scope_gap_hotspot: true}], + securitySurfaces: [{}, {}, {}], + }; + + assert.equal(qualityReviewItemCount(metricsSummary, artifacts), 5); + assert.equal(reportReviewItemCount({}, metricsSummary, {}, artifacts), 5); +}); + +test("overloaded modules formatters count candidates, not visible rows", () => { + const rows = [ + {candidate_status: "candidate"}, + {candidate_status: "non_candidate"}, + {candidate_status: "ranked_only"}, + ]; + + assert.equal(formatOverloadedModuleStatus(rows[0]), "candidate"); + assert.equal(formatOverloadedModuleStatus(rows[1]), "non-candidate"); + assert.equal(formatOverloadedModuleStatus(rows[2]), "ranked-only"); + assert.equal(isOverloadedModuleCandidate(rows[0]), true); + assert.equal(isOverloadedModuleCandidate(rows[1]), false); + assert.equal(overloadedModuleCandidateCount({candidates: 11}, rows), 11); + assert.equal(overloadedModuleCandidateCount({}, rows), 1); + assert.equal( + formatOverloadedModulesSummary({candidates: 11, total: 287}, rows), + "11 candidates · top 3 ranked" + ); +}); diff --git a/extensions/vscode-codeclone/test/manifest.test.js b/extensions/vscode-codeclone/test/manifest.test.js index 2af2c91..815e7f8 100644 --- a/extensions/vscode-codeclone/test/manifest.test.js +++ b/extensions/vscode-codeclone/test/manifest.test.js @@ -67,6 +67,11 @@ test("configuration settings declare explicit scopes that match their usage", () assert.equal(properties["codeclone.mcp.args"].scope, "machine"); assert.equal(properties["codeclone.analysis.cachePolicy"].scope, "resource"); assert.equal(properties["codeclone.analysis.changedDiffRef"].scope, "resource"); + assert.equal(properties["codeclone.analysis.coverageXml"].scope, "resource"); + assert.equal( + properties["codeclone.analysis.autoDetectCoverageXml"].scope, + "resource" + ); assert.equal(properties["codeclone.analysis.profile"].scope, "resource"); assert.equal(properties["codeclone.analysis.minLoc"].scope, "resource"); assert.equal(properties["codeclone.analysis.minStmt"].scope, "resource"); diff --git a/extensions/vscode-codeclone/test/renderers.test.js b/extensions/vscode-codeclone/test/renderers.test.js index 023ea0b..6110b25 100644 --- a/extensions/vscode-codeclone/test/renderers.test.js +++ b/extensions/vscode-codeclone/test/renderers.test.js @@ -23,7 +23,9 @@ const { formatBaselineTags, } = require("../src/formatters"); const { + renderCoverageJoinMarkdown, renderSecuritySurfaceMarkdown, + renderOverloadedModuleMarkdown, renderTriageMarkdown, } = require("../src/renderers"); @@ -109,3 +111,50 @@ test("renderSecuritySurfaceMarkdown keeps report-only security posture explicit" assert.match(markdown, /not as a vulnerability claim/); assert.match(markdown, /Coverage Join marks this callable as a scope gap/); }); + +test("renderOverloadedModuleMarkdown does not call non-candidates candidates", () => { + const markdown = renderOverloadedModuleMarkdown({ + path: "pkg/large.py", + module: "pkg.large", + candidate_status: "non_candidate", + source_kind: "production", + score: 0.83, + loc: 500, + callable_count: 12, + complexity_total: 42, + complexity_max: 9, + fan_in: 4, + fan_out: 7, + total_deps: 11, + import_edges: 8, + reimport_edges: 1, + reimport_ratio: 0.125, + instability: 0.63, + hub_balance: 0.72, + candidate_reasons: [], + }); + + assert.match(markdown, /# Overloaded Module/); + assert.match(markdown, /Status: non-candidate/); + assert.doesNotMatch(markdown, /# Overloaded Module Candidate/); + assert.match(markdown, /not an overloaded-module candidate/); +}); + +test("renderCoverageJoinMarkdown explains joined coverage review context", () => { + const markdown = renderCoverageJoinMarkdown({ + path: "pkg/service.py", + start_line: 12, + end_line: 18, + qualname: "pkg.service:run", + cyclomatic_complexity: 11, + risk: "medium", + coverage_permille: 420, + coverage_hotspot: true, + }); + + assert.match(markdown, /# Coverage Join Review Item/); + assert.match(markdown, /Location: `pkg\/service.py:12-18`/); + assert.match(markdown, /Review signal: low coverage/); + assert.match(markdown, /Coverage: 42%/); + assert.match(markdown, /joined coverage review context/); +}); diff --git a/extensions/vscode-codeclone/test/runArtifacts.test.js b/extensions/vscode-codeclone/test/runArtifacts.test.js index 21c6e97..83cf26a 100644 --- a/extensions/vscode-codeclone/test/runArtifacts.test.js +++ b/extensions/vscode-codeclone/test/runArtifacts.test.js @@ -51,16 +51,25 @@ test("loadRunArtifacts starts MCP reads and git snapshot together", async () => assert.ok(resolveTriage); assert.ok(resolveMetrics); assert.ok(resolveReviewed); - resolveSummary({version: "2.0.0"}); + resolveSummary({ + version: "2.0.0", + coverage_join: {status: "ok", scope_gap_hotspots: 1}, + }); resolveTriage({hotspots: []}); resolveMetrics({summary: {health: {score: 90}}}); resolveReviewed({items: [{id: "f1"}]}); resolveGitSnapshot({head: "abc123"}); assert.deepEqual(await promise, { - summary: {version: "2.0.0"}, + summary: { + version: "2.0.0", + coverage_join: {status: "ok", scope_gap_hotspots: 1}, + }, triage: {hotspots: []}, - metricsSummary: {health: {score: 90}}, + metricsSummary: { + health: {score: 90}, + coverage_join: {status: "ok", scope_gap_hotspots: 1}, + }, reviewedItems: [{id: "f1"}], gitSnapshot: {head: "abc123"}, }); diff --git a/extensions/vscode-codeclone/test/runtime.test.js b/extensions/vscode-codeclone/test/runtime.test.js index 27d17fe..d0ad931 100644 --- a/extensions/vscode-codeclone/test/runtime.test.js +++ b/extensions/vscode-codeclone/test/runtime.test.js @@ -6,7 +6,7 @@ const fs = require("node:fs"); const os = require("node:os"); const path = require("node:path"); -const {looksLikeCodeCloneRepo} = require("../src/runtime"); +const {looksLikeCodeCloneRepo, resolveCoverageXmlPath} = require("../src/runtime"); test("looksLikeCodeCloneRepo accepts the current MCP surface layout", async () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "codeclone-vscode-runtime-")); @@ -41,3 +41,17 @@ test("looksLikeCodeCloneRepo rejects non-CodeClone workspaces", async () => { assert.equal(await looksLikeCodeCloneRepo(root), false); }); }); + +test("resolveCoverageXmlPath keeps coverage input workspace-local and auto-detectable", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "codeclone-vscode-runtime-")); + const coverageXml = path.join(root, "coverage.xml"); + fs.writeFileSync(coverageXml, "\n"); + + assert.equal(await resolveCoverageXmlPath(root, "", true), coverageXml); + assert.equal( + await resolveCoverageXmlPath(root, "reports/coverage.xml", true, async () => false), + path.join(root, "reports", "coverage.xml") + ); + assert.equal(await resolveCoverageXmlPath(root, "", false), null); + assert.equal(await resolveCoverageXmlPath(root, "../coverage.xml", true), null); +}); From bf9447dd9c2bbe1e2084285f7cdaa769bb7b7674 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Wed, 13 May 2026 22:33:20 +0500 Subject: [PATCH 04/10] fix(mcp): derive baseline help from contract version --- codeclone/surfaces/mcp/_session_shared.py | 6 ++++-- tests/test_mcp_service.py | 8 +++++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/codeclone/surfaces/mcp/_session_shared.py b/codeclone/surfaces/mcp/_session_shared.py index e4ac758..97be996 100644 --- a/codeclone/surfaces/mcp/_session_shared.py +++ b/codeclone/surfaces/mcp/_session_shared.py @@ -39,6 +39,7 @@ DEFAULT_SEGMENT_MIN_STMT, ) from ...contracts import ( + BASELINE_SCHEMA_VERSION, DEFAULT_COVERAGE_MIN, DEFAULT_JSON_REPORT_PATH, DEFAULT_REPORT_DESIGN_COHESION_THRESHOLD, @@ -539,8 +540,9 @@ class MCPHelpTopicSpec: ), key_points=( ( - "Canonical baseline schema is v2.0 with meta and clone keys; " - "metrics may be embedded for unified flows." + f"Canonical baseline schema is v{BASELINE_SCHEMA_VERSION} " + "with meta and clone keys; metrics may be embedded for " + "unified flows." ), ( "Compatibility depends on generator identity, supported " diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 742e3a9..40ed9ef 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -29,7 +29,7 @@ from codeclone.baseline.metrics_baseline import MetricsBaseline from codeclone.cache.store import Cache from codeclone.config.pyproject_loader import ConfigValidationError -from codeclone.contracts import REPORT_SCHEMA_VERSION +from codeclone.contracts import BASELINE_SCHEMA_VERSION, REPORT_SCHEMA_VERSION from codeclone.models import MetricsDiff from codeclone.surfaces.mcp.service import CodeCloneMCPService from codeclone.surfaces.mcp.session import ( @@ -582,6 +582,12 @@ def test_mcp_service_help_covers_analysis_profiles() -> None: def test_mcp_service_help_validates_topic_and_detail() -> None: service = CodeCloneMCPService(history_limit=4) + baseline_help = service.get_help(topic="baseline") + assert any( + f"v{BASELINE_SCHEMA_VERSION}" in point + for point in cast("list[str]", baseline_help["key_points"]) + ) + with pytest.raises(MCPServiceContractError, match="Invalid value for topic"): service.get_help(topic="gates") From 3500c31d7ca053ac1232b1fd4a6e505d4ff2a23e Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 14 May 2026 13:18:48 +0500 Subject: [PATCH 05/10] chore(docs): alignment of documentation to the current codebase --- .github/actions/codeclone/README.md | 4 ++-- README.md | 3 ++- docs/README-pypi.md | 1 + docs/book/00-intro.md | 24 ++++++++++++------------ docs/book/01-architecture-map.md | 2 +- docs/book/02-terminology.md | 2 +- docs/book/04-config-and-defaults.md | 3 ++- docs/book/05-core-pipeline.md | 2 +- docs/book/11-security-model.md | 6 +++--- docs/book/12-determinism.md | 2 +- docs/book/16-dead-code-contract.md | 4 ++-- docs/book/22-claude-desktop-bundle.md | 4 ++-- docs/book/23-codex-plugin.md | 2 +- docs/book/appendix/b-schema-layouts.md | 10 +++++----- docs/vscode-extension.md | 6 ++++++ 15 files changed, 42 insertions(+), 33 deletions(-) diff --git a/.github/actions/codeclone/README.md b/.github/actions/codeclone/README.md index 8f3cf8a..baf3e9d 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.0 +- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.1 ``` For long-lived workflows, `@v2` follows the latest compatible 2.x action @@ -145,7 +145,7 @@ Notes: ## Install policy Released action tags pin the PyPI package version in action metadata. For -example, `@v2.0.0` installs `codeclone==2.0.0` unless you override +example, `@v2.0.1` installs `codeclone==2.0.1` unless you override `package-version`. Explicit prerelease or smoke-test override: diff --git a/README.md b/README.md index e0b67c1..ebea7cb 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Live sample report: [orenlab.github.io/codeclone/examples/report/](https://orenl profile, dead code, health score, and overloaded-module profiling - **Adoption & API** — type/docstring annotation coverage, public API surface inventory and baseline diff - **Coverage Join** — fuse external Cobertura XML into the current run to surface coverage hotspots and scope gaps +- **Security Surfaces** — report-only inventory of security-relevant capability boundaries without vulnerability claims **Surfaces & integrations** @@ -318,7 +319,7 @@ Top-level keys: `report_schema_version`, `meta`, `inventory`, `findings`, `metri { "report_schema_version": "2.11", "meta": { - "codeclone_version": "2.0.0", + "codeclone_version": "2.0.1", "project_name": "...", "scan_root": ".", "...": "..." diff --git a/docs/README-pypi.md b/docs/README-pypi.md index 6ea3d91..62f01eb 100644 --- a/docs/README-pypi.md +++ b/docs/README-pypi.md @@ -53,6 +53,7 @@ deterministic facts. - **Quality metrics** — cyclomatic complexity, coupling (CBO), cohesion (LCOM4), dependency cycles, adaptive depth profile, dead code, health score, and overloaded-module profiling - **Adoption & API** — type/docstring annotation coverage, public API surface inventory and baseline diff - **Coverage Join** — fuse external Cobertura XML into the current run to surface coverage hotspots and scope gaps +- **Security Surfaces** — report-only inventory of security-relevant capability boundaries without vulnerability claims **Surfaces & integrations** - **MCP control surface** — triage-first agent and IDE interface over the same canonical pipeline; read-only by contract diff --git a/docs/book/00-intro.md b/docs/book/00-intro.md index 5d53853..67c1f93 100644 --- a/docs/book/00-intro.md +++ b/docs/book/00-intro.md @@ -61,7 +61,7 @@ Refs: Refs: -- `codeclone/scanner.py:iter_py_files` +- `codeclone/scanner/__init__.py:iter_py_files` - `codeclone/report/document/builder.py:build_report_document` - `codeclone/baseline/trust.py:_compute_payload_sha256` - `codeclone/cache/integrity.py:canonical_json` @@ -83,21 +83,21 @@ Refs: ## Recommended reading paths - CI contract path: - [03-contracts-exit-codes.md](03-contracts-exit-codes.md) -> - [06-baseline.md](06-baseline.md) -> - [07-cache.md](07-cache.md) -> - [08-report.md](08-report.md) -> + [03-contracts-exit-codes.md](03-contracts-exit-codes.md) → + [06-baseline.md](06-baseline.md) → + [07-cache.md](07-cache.md) → + [08-report.md](08-report.md) → [09-cli.md](09-cli.md) - Metrics governance path: - [04-config-and-defaults.md](04-config-and-defaults.md) -> - [15-health-score.md](15-health-score.md) -> - [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) -> - [16-dead-code-contract.md](16-dead-code-contract.md) -> - [19-inline-suppressions.md](19-inline-suppressions.md) -> + [04-config-and-defaults.md](04-config-and-defaults.md) → + [15-health-score.md](15-health-score.md) → + [15-metrics-and-quality-gates.md](15-metrics-and-quality-gates.md) → + [16-dead-code-contract.md](16-dead-code-contract.md) → + [19-inline-suppressions.md](19-inline-suppressions.md) → [17-suggestions-and-clone-typing.md](17-suggestions-and-clone-typing.md) - Determinism and compatibility path: - [12-determinism.md](12-determinism.md) -> + [12-determinism.md](12-determinism.md) → [14-compatibility-and-versioning.md](14-compatibility-and-versioning.md) - Benchmarking path: - [12-determinism.md](12-determinism.md) -> + [12-determinism.md](12-determinism.md) → [18-benchmarking.md](18-benchmarking.md) diff --git a/docs/book/01-architecture-map.md b/docs/book/01-architecture-map.md index 61d66a7..e1c354b 100644 --- a/docs/book/01-architecture-map.md +++ b/docs/book/01-architecture-map.md @@ -103,7 +103,7 @@ Refs: Refs: -- `codeclone/scanner.py:iter_py_files` +- `codeclone/scanner/__init__.py:iter_py_files` - `codeclone/report/document/integrity.py:_build_integrity_payload` - `codeclone/baseline/trust.py:_compute_payload_sha256` - `codeclone/cache/integrity.py:canonical_json` diff --git a/docs/book/02-terminology.md b/docs/book/02-terminology.md index fe3f22b..95f4aab 100644 --- a/docs/book/02-terminology.md +++ b/docs/book/02-terminology.md @@ -35,7 +35,7 @@ Refs: - `codeclone/blocks/__init__.py` - `codeclone/baseline/trust.py:current_python_tag` - `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- `codeclone/scanner.py:classify_source_kind` +- `codeclone/paths/__init__.py:classify_source_kind` - `codeclone/metrics/health.py:compute_health` - `codeclone/report/document/_common.py:_design_findings_thresholds_payload` - `codeclone/report/suggestions.py:generate_suggestions` diff --git a/docs/book/04-config-and-defaults.md b/docs/book/04-config-and-defaults.md index 6197453..cfa3dcf 100644 --- a/docs/book/04-config-and-defaults.md +++ b/docs/book/04-config-and-defaults.md @@ -187,7 +187,8 @@ Dependency depth config note: CLI or `pyproject.toml` option. - Dependency depth now uses an internal adaptive profile based on `avg_depth`, `p95_depth`, and `max_depth` for the internal module graph. -- There is no user-facing knob to tune that model in `2.0.0`. +- There is no user-facing knob to tune that model in the current `2.x` release + line. Metrics baseline path selection contract: diff --git a/docs/book/05-core-pipeline.md b/docs/book/05-core-pipeline.md index 903b505..8c0d509 100644 --- a/docs/book/05-core-pipeline.md +++ b/docs/book/05-core-pipeline.md @@ -99,7 +99,7 @@ Refs: Refs: -- `codeclone/scanner.py:iter_py_files` +- `codeclone/scanner/__init__.py:iter_py_files` - `codeclone/findings/clones/grouping.py:build_groups` - `codeclone/report/document/integrity.py:_build_integrity_payload` diff --git a/docs/book/11-security-model.md b/docs/book/11-security-model.md index ac9d3e7..5a34a19 100644 --- a/docs/book/11-security-model.md +++ b/docs/book/11-security-model.md @@ -6,7 +6,7 @@ Describe implemented protections and explicit security boundaries. ## Public surface -- Scanner path validation: `codeclone/scanner.py:iter_py_files` +- Scanner path validation: `codeclone/scanner/__init__.py:iter_py_files` - File read and parser limits: `codeclone/core/worker.py:process_file`, `codeclone/analysis/parser.py:_parse_limits` - Baseline/cache validation: `codeclone/baseline/*`, `codeclone/cache/*` @@ -39,8 +39,8 @@ Security-relevant input classes: Refs: - `codeclone/analysis/parser.py:_parse_with_limits` -- `codeclone/scanner.py:SENSITIVE_DIRS` -- `codeclone/scanner.py:iter_py_files` +- `codeclone/scanner/__init__.py:SENSITIVE_DIRS` +- `codeclone/scanner/__init__.py:iter_py_files` - `codeclone/report/html/primitives/escape.py:_escape_html` ## Invariants (MUST) diff --git a/docs/book/12-determinism.md b/docs/book/12-determinism.md index 6209a41..c67fe45 100644 --- a/docs/book/12-determinism.md +++ b/docs/book/12-determinism.md @@ -6,7 +6,7 @@ Document deterministic behavior and canonicalization controls. ## Public surface -- Sorted file traversal: `codeclone/scanner.py` +- Sorted file traversal: `codeclone/scanner/__init__.py` - Canonical report construction: `codeclone/report/document/*` - Deterministic text projection: `codeclone/report/renderers/text.py` - Baseline hashing: `codeclone/baseline/trust.py` diff --git a/docs/book/16-dead-code-contract.md b/docs/book/16-dead-code-contract.md index eabf617..5a9200a 100644 --- a/docs/book/16-dead-code-contract.md +++ b/docs/book/16-dead-code-contract.md @@ -7,7 +7,7 @@ Define dead-code liveness rules, canonical symbol-usage boundaries, and gating s ## Public surface - Dead-code detection core: `codeclone/metrics/dead_code.py:find_unused` -- Test-path classifier: `codeclone/paths.py:is_test_filepath` +- Test-path classifier: `codeclone/paths/__init__.py:is_test_filepath` - Inline suppression parser/binder: `codeclone/analysis/suppressions.py` - Extraction of referenced names/candidates: `codeclone/analysis/units.py:extract_units_and_stats_from_source` @@ -137,7 +137,7 @@ Refs: Refs: -- `codeclone/paths.py:is_test_filepath` +- `codeclone/paths/__init__.py:is_test_filepath` - `codeclone/metrics/dead_code.py:_is_dunder` - `codeclone/metrics/dead_code.py:find_unused` diff --git a/docs/book/22-claude-desktop-bundle.md b/docs/book/22-claude-desktop-bundle.md index db1d176..bea5501 100644 --- a/docs/book/22-claude-desktop-bundle.md +++ b/docs/book/22-claude-desktop-bundle.md @@ -68,8 +68,8 @@ The wrapper: 4. launches the child process with `shell: false` 5. proxies stdio until shutdown -The wrapper may auto-discover a few common global install locations, but it is -now prefers: +The wrapper may auto-discover a few common global install locations, but it now +prefers: - a workspace-local `.venv` - the active Poetry environment for the current workspace diff --git a/docs/book/23-codex-plugin.md b/docs/book/23-codex-plugin.md index 082f634..5e36abd 100644 --- a/docs/book/23-codex-plugin.md +++ b/docs/book/23-codex-plugin.md @@ -57,7 +57,7 @@ The plugin currently provides: The plugin surface is additive: - `.mcp.json` contributes a local stdio MCP server definition -- `scripts/launch_mcp.py` resolves the local launcher without shell wrapping +- `plugins/codeclone/scripts/launch_mcp.py` resolves the local launcher without shell wrapping - that launcher prefers a workspace `.venv`, then a Poetry env, then `PATH` - the skills contribute workflow guidance and starter prompts - `README.md` documents local usage and boundaries inside the repository tree diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index 45c652a..281eb35 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -2,14 +2,14 @@ ## Purpose -Compact structural layouts for baseline/cache/report contracts in `2.0.0`. +Compact structural layouts for baseline/cache/report contracts in `2.0.1`. ## Baseline schema (`2.1`) ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.0" }, + "generator": { "name": "codeclone", "version": "2.0.1" }, "schema_version": "2.1", "fingerprint_version": "1", "python_tag": "cp314", @@ -60,7 +60,7 @@ Notes: ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.0" }, + "generator": { "name": "codeclone", "version": "2.0.1" }, "schema_version": "1.2", "python_tag": "cp314", "created_at": "2026-03-11T00:00:00Z", @@ -156,7 +156,7 @@ Notes: { "report_schema_version": "2.11", "meta": { - "codeclone_version": "2.0.0", + "codeclone_version": "2.0.1", "project_name": "codeclone", "scan_root": ".", "analysis_mode": "full", @@ -515,7 +515,7 @@ Notes: "tool": { "driver": { "name": "codeclone", - "version": "2.0.0", + "version": "2.0.1", "rules": [ { "id": "CCLONE001", diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index edcd050..0c3ca7b 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -149,6 +149,12 @@ the local MCP launcher. explicit and exposes `Deeper review` and `Custom` as deliberate follow-ups - `codeclone.analysis.cachePolicy` and the threshold settings below are resource-scoped, so they can vary by workspace or folder +- `codeclone.analysis.changedDiffRef` selects the git revision used by + changed-files review +- `codeclone.analysis.coverageXml` passes an explicit Cobertura XML path to + Coverage Join +- `codeclone.analysis.autoDetectCoverageXml` passes workspace-root + `coverage.xml` when present and `coverageXml` is empty - `codeclone.analysis.minLoc` - `codeclone.analysis.minStmt` - `codeclone.analysis.blockMinLoc` From fc5f857fe9bb76c39693ba4281a056a67be27c1d Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 14 May 2026 13:23:10 +0500 Subject: [PATCH 06/10] chore(ci): updating the base image to the current tag --- benchmarks/Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/benchmarks/Dockerfile b/benchmarks/Dockerfile index c747fc4..cc3e6ca 100644 --- a/benchmarks/Dockerfile +++ b/benchmarks/Dockerfile @@ -1,6 +1,6 @@ # syntax=docker/dockerfile:1.7 -FROM python:3.14.3-slim-bookworm +FROM python:3.14.5-slim-bookworm ENV PYTHONDONTWRITEBYTECODE=1 \ PYTHONUNBUFFERED=1 \ From f8f83d892f24bf02eebb299096c62db683931f7e Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 14 May 2026 13:46:21 +0500 Subject: [PATCH 07/10] chore(ci): updating versions of action --- .github/workflows/benchmark.yml | 2 +- .github/workflows/publish.yml | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 259556f..ed74cd9 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -219,7 +219,7 @@ jobs: - name: Upload benchmark artifact if: env.BENCH_ENABLED == '1' - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: codeclone-benchmark-${{ matrix.label }} path: ${{ env.BENCH_JSON }} diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 73e561c..39af718 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -67,7 +67,7 @@ jobs: run: uv run --with twine twine check dist/* - name: Upload distributions - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: python-package-distributions path: dist/ @@ -83,7 +83,7 @@ jobs: id-token: write steps: - name: Download distributions - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ @@ -107,7 +107,7 @@ jobs: id-token: write steps: - name: Download distributions - uses: actions/download-artifact@v5 + uses: actions/download-artifact@v8 with: name: python-package-distributions path: dist/ From 11b1497f33487d627ed253180e5ec595dc0713f6 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 14 May 2026 14:05:56 +0500 Subject: [PATCH 08/10] feat(cli): add dead-code migration note --- CHANGELOG.md | 3 + codeclone/surfaces/cli/tips.py | 131 +++++++++++++++++++++++++++-- codeclone/surfaces/cli/workflow.py | 11 +++ codeclone/ui_messages/__init__.py | 10 +++ docs/book/09-cli.md | 4 + pyproject.toml | 1 + tests/test_cli_inprocess.py | 72 +++++++++++++++- tests/test_cli_unit.py | 118 ++++++++++++++++++++++++++ uv.lock | 14 +-- 9 files changed, 350 insertions(+), 14 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 727b16b..3a449fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,9 @@ contract parity after the 2.0 line. - Treat `typing.Protocol` and `typing_extensions.Protocol` declarations, including generic `Protocol[T]`, as type-only contracts so structural interfaces do not produce false-positive dead-code findings. +- Show a one-time interactive CLI migration note for projects upgrading from + the 2.0.0 line when the refined reachability model may reduce dead-code + findings. - Bump cache schema to `2.7` and report schema to `2.11` to carry reachability facts for cold/warm parity and report explainability. diff --git a/codeclone/surfaces/cli/tips.py b/codeclone/surfaces/cli/tips.py index 7b2e22c..a74efd4 100644 --- a/codeclone/surfaces/cli/tips.py +++ b/codeclone/surfaces/cli/tips.py @@ -12,16 +12,24 @@ from pathlib import Path from typing import TextIO +from packaging.version import InvalidVersion, Version + from ... import ui_messages as ui from ...utils.json_io import read_json_object, write_json_document_atomically from .attrs import bool_attr from .types import PrinterLike _VSCODE_EXTENSION_TIP_KEY = "vscode_extension" +_DEAD_CODE_REACHABILITY_MIGRATION_TIP_KEY = ( + "dead_code_reachability_2_0_1_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", @@ -91,6 +99,16 @@ def _tip_last_shown_version(state: Mapping[str, object], *, tip_key: str) -> str return "" +def _tip_was_shown(state: Mapping[str, object], *, tip_key: str) -> bool: + tips = state.get("tips") + if not isinstance(tips, dict): + return False + entry = tips.get(tip_key) + if not isinstance(entry, dict): + return False + return entry.get("shown") is True + + def _remember_tip_version( *, path: Path, @@ -113,6 +131,60 @@ def _remember_tip_version( ) +def _remember_tip_shown( + *, + path: Path, + state: Mapping[str, object], + tip_key: str, +) -> None: + tips = state.get("tips") + updated_tips = dict(tips) if isinstance(tips, dict) else {} + updated_tips[tip_key] = {"shown": True} + write_json_document_atomically( + path, + { + "schema_version": _TIPS_SCHEMA_VERSION, + "tips": updated_tips, + }, + sort_keys=True, + indent=True, + trailing_newline=True, + ) + + +def _tip_context_allowed( + *, + args: object, + environ: Mapping[str, str], + stream: TextIO, +) -> bool: + if bool_attr(args, "quiet") or bool_attr(args, "ci"): + return False + if _is_ci_environment(environ): + return False + return _stream_is_tty(stream) + + +def _dead_code_reachability_migration_applies( + *, + baseline_generator_version: str | None, + codeclone_version: str, +) -> bool: + if not baseline_generator_version: + return False + 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 + ) + + def maybe_print_vscode_extension_tip( *, args: object, @@ -124,11 +196,11 @@ def maybe_print_vscode_extension_tip( ) -> bool: effective_environ = os.environ if environ is None else environ effective_stream = sys.stdout if stream is None else stream - if bool_attr(args, "quiet") or bool_attr(args, "ci"): - return False - if _is_ci_environment(effective_environ): - return False - if not _stream_is_tty(effective_stream): + if not _tip_context_allowed( + args=args, + environ=effective_environ, + stream=effective_stream, + ): return False if not _is_vscode_environment(effective_environ): return False @@ -154,6 +226,55 @@ def maybe_print_vscode_extension_tip( return True +def maybe_print_dead_code_reachability_migration_note( + *, + args: object, + console: PrinterLike, + codeclone_version: str, + cache_path: Path, + baseline_generator_version: str | None, + baseline_trusted_for_diff: bool, + environ: Mapping[str, str] | None = None, + stream: TextIO | None = None, +) -> bool: + if not baseline_trusted_for_diff: + return False + if not _dead_code_reachability_migration_applies( + baseline_generator_version=baseline_generator_version, + codeclone_version=codeclone_version, + ): + return False + + effective_environ = os.environ if environ is None else environ + effective_stream = sys.stdout if stream is None else stream + if not _tip_context_allowed( + args=args, + environ=effective_environ, + stream=effective_stream, + ): + return False + + state_path = _tips_state_path(cache_path) + state = _load_tips_state(state_path) + if _tip_was_shown( + state, + tip_key=_DEAD_CODE_REACHABILITY_MIGRATION_TIP_KEY, + ): + return False + + console.print(ui.fmt_dead_code_reachability_migration_note()) + try: + _remember_tip_shown( + path=state_path, + state=state, + tip_key=_DEAD_CODE_REACHABILITY_MIGRATION_TIP_KEY, + ) + except OSError: + return True + return True + + __all__ = [ + "maybe_print_dead_code_reachability_migration_note", "maybe_print_vscode_extension_tip", ] diff --git a/codeclone/surfaces/cli/workflow.py b/codeclone/surfaces/cli/workflow.py index 56698a7..bed4dd4 100644 --- a/codeclone/surfaces/cli/workflow.py +++ b/codeclone/surfaces/cli/workflow.py @@ -114,6 +114,9 @@ resolve_changed_clone_gate = cli_post_run.resolve_changed_clone_gate warn_new_clones_without_fail = cli_post_run.warn_new_clones_without_fail maybe_print_vscode_extension_tip = cli_tips.maybe_print_vscode_extension_tip +maybe_print_dead_code_reachability_migration_note = ( + cli_tips.maybe_print_dead_code_reachability_migration_note +) _report_path_origins = cli_reports_output._report_path_origins _resolve_output_paths = cli_reports_output._resolve_output_paths @@ -522,6 +525,14 @@ def _main_impl() -> None: notice_new_clones_count=notice_new_clones_count, console=_console(), ) + maybe_print_dead_code_reachability_migration_note( + args=args, + console=_console(), + codeclone_version=__version__, + cache_path=cache_path, + baseline_generator_version=baseline_state.baseline.generator_version, + baseline_trusted_for_diff=baseline_state.trusted_for_diff, + ) maybe_print_vscode_extension_tip( args=args, console=_console(), diff --git a/codeclone/ui_messages/__init__.py b/codeclone/ui_messages/__init__.py index edec8eb..4faa398 100644 --- a/codeclone/ui_messages/__init__.py +++ b/codeclone/ui_messages/__init__.py @@ -360,6 +360,12 @@ "navigation.\n" "[dim]{url}[/dim]" ) +NOTE_DEAD_CODE_REACHABILITY_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]" +) _RICH_MARKUP_TAG_RE = re.compile(r"\[/?[a-zA-Z][a-zA-Z0-9_ .#:-]*]") @@ -439,6 +445,10 @@ 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_legacy_cache_warning(*, legacy_path: Path, new_path: Path) -> str: return WARN_LEGACY_CACHE.format(legacy_path=legacy_path, new_path=new_path) diff --git a/docs/book/09-cli.md b/docs/book/09-cli.md index 9c8c517..81e8265 100644 --- a/docs/book/09-cli.md +++ b/docs/book/09-cli.md @@ -56,6 +56,10 @@ 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. - Changed-scope review uses: - `--changed-only` - `--diff-against` diff --git a/pyproject.toml b/pyproject.toml index 2b90bc2..fa550e8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -21,6 +21,7 @@ requires-python = ">=3.10" dependencies = [ "orjson>=3.11.9", + "packaging>=24.0", "pygments>=2.20.0", "rich>=15.0.0", "tomli>=2.0.1; python_version < '3.11'", diff --git a/tests/test_cli_inprocess.py b/tests/test_cli_inprocess.py index 9ceaab2..315923e 100644 --- a/tests/test_cli_inprocess.py +++ b/tests/test_cli_inprocess.py @@ -210,6 +210,12 @@ def _assert_parallel_cli_exit( _assert_cli_exit(monkeypatch, args, expected_code=expected_code) +def _assert_after_summary(output: str, marker: str, *expected_parts: str) -> None: + for expected_part in expected_parts: + assert expected_part in output + assert output.index("Summary") < output.index(marker) + + def _write_python_module( directory: Path, filename: str, @@ -2316,9 +2322,12 @@ def test_cli_shows_vscode_extension_tip_once_per_version( _run_parallel_main(monkeypatch, [str(tmp_path), "--no-progress", "--no-color"]) first_out = capsys.readouterr().out - assert "VS Code detected" in first_out - assert "marketplace.visualstudio.com" in first_out - assert first_out.index("Summary") < first_out.index("Tip:") + _assert_after_summary( + first_out, + "Tip:", + "VS Code detected", + "marketplace.visualstudio.com", + ) state = json.loads(tips_path.read_text("utf-8")) assert state["tips"]["vscode_extension"]["last_shown_version"] == __version__ @@ -2329,6 +2338,63 @@ def test_cli_shows_vscode_extension_tip_once_per_version( assert "VS Code detected" not in second_out +def test_cli_shows_dead_code_reachability_migration_note_once( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[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", + ) + tips_path = tmp_path / ".cache" / "codeclone" / "tips.json" + + monkeypatch.delenv("CI", raising=False) + monkeypatch.delenv("GITHUB_ACTIONS", raising=False) + monkeypatch.delenv("TERM_PROGRAM", raising=False) + monkeypatch.setattr(cli_tips, "_stream_is_tty", lambda _stream: True) + + _run_parallel_main( + monkeypatch, + [ + str(tmp_path), + "--baseline", + str(baseline_path), + "--no-progress", + "--no-color", + ], + ) + first_out = capsys.readouterr().out + + _assert_after_summary( + first_out, + "Note:", + "Dead-code reachability was refined in 2.0.1", + "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 + ) + + _run_parallel_main( + monkeypatch, + [ + str(tmp_path), + "--baseline", + str(baseline_path), + "--no-progress", + "--no-color", + ], + ) + second_out = capsys.readouterr().out + + assert "Dead-code reachability was refined in 2.0.1" not in second_out + + def test_cli_update_baseline_skips_version_check( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index a84e97e..d92d13d 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -339,6 +339,124 @@ def test_cli_vscode_extension_tip_respects_context_gates( assert printer.lines == [] +@pytest.mark.parametrize( + ("baseline_version", "current_version", "expected"), + [ + ("2.0.0b1", "2.0.1", True), + ("2.0.0b7", "2.0.1", True), + ("2.0.0", "2.0.1", True), + ("2.0.0", "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), + ("dev-build", "2.0.1", False), + ("2.0.0", "dev-build", False), + (None, "2.0.1", False), + ], +) +def test_cli_dead_code_reachability_migration_note_version_gate( + baseline_version: str | None, + current_version: str, + expected: bool, +) -> None: + assert ( + cli_tips._dead_code_reachability_migration_applies( + baseline_generator_version=baseline_version, + codeclone_version=current_version, + ) + is expected + ) + + +def test_cli_dead_code_reachability_migration_note_uses_one_shot_cache( + tmp_path: Path, +) -> None: + printer = _RecordingPrinter() + args = SimpleNamespace(quiet=False, ci=False) + cache_path = tmp_path / ".cache" / "codeclone" / "cache.json" + + shown = cli_tips.maybe_print_dead_code_reachability_migration_note( + args=args, + console=printer, + codeclone_version="2.0.1", + cache_path=cache_path, + baseline_generator_version="2.0.0b7", + baseline_trusted_for_diff=True, + environ={}, + stream=_TTYStream(is_tty=True), + ) + + assert shown is True + assert len(printer.lines) == 1 + assert "Dead-code reachability was refined in 2.0.1" 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 + ) + + shown_again = cli_tips.maybe_print_dead_code_reachability_migration_note( + args=args, + console=printer, + codeclone_version="2.0.1", + cache_path=cache_path, + baseline_generator_version="2.0.0b7", + baseline_trusted_for_diff=True, + environ={}, + stream=_TTYStream(is_tty=True), + ) + + assert shown_again is False + assert len(printer.lines) == 1 + + +@pytest.mark.parametrize( + ("args", "env", "isatty", "trusted", "baseline_version", "current_version"), + [ + (SimpleNamespace(quiet=True, ci=False), {}, True, True, "2.0.0", "2.0.1"), + (SimpleNamespace(quiet=False, ci=True), {}, True, True, "2.0.0", "2.0.1"), + ( + SimpleNamespace(quiet=False, ci=False), + {"CI": "1"}, + True, + True, + "2.0.0", + "2.0.1", + ), + (SimpleNamespace(quiet=False, ci=False), {}, False, True, "2.0.0", "2.0.1"), + (SimpleNamespace(quiet=False, ci=False), {}, True, False, "2.0.0", "2.0.1"), + (SimpleNamespace(quiet=False, ci=False), {}, True, True, "2.0.1", "2.0.1"), + (SimpleNamespace(quiet=False, ci=False), {}, True, True, "2.0.0", "2.0.0"), + ], +) +def test_cli_dead_code_reachability_migration_note_respects_gates( + tmp_path: Path, + args: SimpleNamespace, + env: dict[str, str], + isatty: bool, + trusted: bool, + baseline_version: str, + current_version: str, +) -> None: + printer = _RecordingPrinter() + + shown = cli_tips.maybe_print_dead_code_reachability_migration_note( + args=args, + console=printer, + codeclone_version=current_version, + cache_path=tmp_path / ".cache" / "codeclone" / "cache.json", + baseline_generator_version=baseline_version, + baseline_trusted_for_diff=trusted, + environ=env, + stream=_TTYStream(is_tty=isatty), + ) + + assert shown is False + assert printer.lines == [] + + def test_cli_module_main_guard(monkeypatch: pytest.MonkeyPatch) -> None: monkeypatch.setattr(sys, "argv", ["codeclone", "--help"]) with pytest.raises(SystemExit) as exc: diff --git a/uv.lock b/uv.lock index 27c52fb..42a6264 100644 --- a/uv.lock +++ b/uv.lock @@ -324,6 +324,7 @@ version = "2.0.1" source = { editable = "." } dependencies = [ { name = "orjson" }, + { name = "packaging" }, { name = "pygments" }, { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, @@ -351,6 +352,7 @@ requires-dist = [ { name = "mcp", marker = "extra == 'mcp'", specifier = ">=1.27.0,<2" }, { name = "mypy", marker = "extra == 'dev'", specifier = ">=1.20.1" }, { name = "orjson", specifier = ">=3.11.9" }, + { name = "packaging", specifier = ">=24.0" }, { name = "pre-commit", marker = "extra == 'dev'", specifier = ">=4.5.1" }, { name = "pygments", specifier = ">=2.20.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.0.3" }, @@ -1536,7 +1538,7 @@ wheels = [ [[package]] name = "requests" -version = "2.34.0" +version = "2.34.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -1544,9 +1546,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/b8/7a707d60fea4c49094e40262cc0e2ca6c768cca21587e34d3f705afec47e/requests-2.34.0.tar.gz", hash = "sha256:7d62fe92f50eb82c529b0916bb445afa1531a566fc8f35ffdc64446e771b856a", size = 142436, upload-time = "2026-05-11T19:29:51.717Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/e6/e300fce5fe83c30520607a015dabd985df3251e188d234bfe9492e17a389/requests-2.34.0-py3-none-any.whl", hash = "sha256:917520a21b767485ce7c588f4ebb917c436b24a31231b44228715eaeb5a52c60", size = 73021, upload-time = "2026-05-11T19:29:49.923Z" }, + { 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" }, ] [[package]] @@ -1889,7 +1891,7 @@ wheels = [ [[package]] name = "virtualenv" -version = "21.3.2" +version = "21.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -1898,9 +1900,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/e1/665267cea4767debd19f584667a9197c2098b5e7f67a502da9f3a086ab37/virtualenv-21.3.2.tar.gz", hash = "sha256:3ecda97894a6fc1c53106356f488690e5c86278c1f693f3fc0805ac85a513686", size = 7613810, upload-time = "2026-05-12T14:44:18.01Z" } +sdist = { url = "https://files.pythonhosted.org/packages/15/ba/1f6e8c957e4932be060dcdc482d339c12e0216351478add3645cdaa53c05/virtualenv-21.3.3.tar.gz", hash = "sha256:f5bda277e553b1c2b3c1a8debfc30496e1288cc93ce6b7b71b3280047e317328", size = 7613784, upload-time = "2026-05-13T18:01:30.19Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/20/5b/885f479093f6627669d39b57bc3d4e674da532e1a4b247d473a61d8d2118/virtualenv-21.3.2-py3-none-any.whl", hash = "sha256:c58ea748fa50bb2a4367da5ba3d30b02458ed40b4ea888faad94021f3309f764", size = 7594558, upload-time = "2026-05-12T14:44:15.193Z" }, + { url = "https://files.pythonhosted.org/packages/f4/34/a9dbe051de88a63eb7408ea66630bac38e72f7f6077d4be58737106860d9/virtualenv-21.3.3-py3-none-any.whl", hash = "sha256:7d5987d8369e098e41406efb780a3d4ca79280097293899e351a6407ee153ab3", size = 7594554, upload-time = "2026-05-13T18:01:27.815Z" }, ] [[package]] From 8837ed6d9c2a2afbbf59f6214602c8c318428790 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 14 May 2026 14:14:59 +0500 Subject: [PATCH 09/10] fix(cli): refresh banner subtitle --- codeclone/ui_messages/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/codeclone/ui_messages/__init__.py b/codeclone/ui_messages/__init__.py index 4faa398..e7ed53b 100644 --- a/codeclone/ui_messages/__init__.py +++ b/codeclone/ui_messages/__init__.py @@ -38,7 +38,7 @@ HEALTH_GRADE_F, ) -BANNER_SUBTITLE = "Structural code analysis" +BANNER_SUBTITLE = "Structural review layer" MARKER_CONTRACT_ERROR = "[error]CONTRACT ERROR:[/error]" MARKER_INTERNAL_ERROR = "[error]INTERNAL ERROR:[/error]" From c2177851c3236144e8d75a3837474e132d459f94 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Thu, 14 May 2026 14:32:25 +0500 Subject: [PATCH 10/10] chore(release): update 2.0.1 changelog date --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3a449fe..77ceb5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## [2.0.1] - 2026-05-13 +## [2.0.1] - 2026-05-14 `2.0.1` is a focused stability release for dead-code precision and cache/report contract parity after the 2.0 line.