diff --git a/.github/workflows/codeclone.yml b/.github/workflows/codeclone.yml index 392d3e3..d0566e4 100644 --- a/.github/workflows/codeclone.yml +++ b/.github/workflows/codeclone.yml @@ -19,7 +19,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@v6.0.2 with: fetch-depth: 0 diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 70fdff5..d07ce3f 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -40,7 +40,7 @@ jobs: - name: Run tests # Smoke CLI tests intentionally disable subprocess coverage collection # to avoid runner-specific flakiness while keeping parent-process coverage strict. - run: uv run pytest --cov=codeclone --cov-report=term-missing --cov-fail-under=98 + run: uv run pytest --cov=codeclone --cov-report=term-missing --cov-fail-under=99 - name: Verify baseline exists if: ${{ matrix.python-version == '3.13' }} diff --git a/.gitignore b/.gitignore index 44369ac..e3ad2eb 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,4 @@ site/ /.uv-cache/ /package-lock.json extensions/vscode-codeclone/node_modules +/coverage.xml diff --git a/AGENTS.md b/AGENTS.md index cdaccbc..16e579d 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -135,6 +135,22 @@ uv run pytest -q tests/test_codex_plugin.py ## 4) Baseline contract (v2, stable) +### Versioned constants (single source of truth) + +All schema/version constants live in `codeclone/contracts.py`. **Always read them from code, never copy +from another doc.** Current values (verified at write time): + +| Constant | Source | Current value | +|-----------------------------------|------------------------------|---------------| +| `BASELINE_SCHEMA_VERSION` | `codeclone/contracts.py` | `2.1` | +| `BASELINE_FINGERPRINT_VERSION` | `codeclone/contracts.py` | `1` | +| `CACHE_VERSION` | `codeclone/contracts.py` | `2.5` | +| `REPORT_SCHEMA_VERSION` | `codeclone/contracts.py` | `2.8` | +| `METRICS_BASELINE_SCHEMA_VERSION` | `codeclone/contracts.py` | `1.2` | + +When updating any doc that mentions a version, re-read `codeclone/contracts.py` first. Do not derive +versions from another document. + ### Baseline file structure (canonical) ```json @@ -144,7 +160,7 @@ uv run pytest -q tests/test_codex_plugin.py "name": "codeclone", "version": "X.Y.Z" }, - "schema_version": "2.0", + "schema_version": "2.1", "fingerprint_version": "1", "python_tag": "cp313", "created_at": "2026-02-08T14:20:15Z", @@ -163,8 +179,9 @@ uv run pytest -q tests/test_codex_plugin.py ### Rules - `schema_version` is **baseline schema**, not package version. -- Runtime writes baseline schema `2.0`. -- Runtime accepts baseline schema `1.x` and `2.x` for compatibility checks. +- Runtime writes baseline schema `2.1`. +- Runtime accepts baseline schema `1.0` and `2.0`–`2.1` (governed by + `_BASELINE_SCHEMA_MAX_MINOR_BY_MAJOR` in `codeclone/baseline.py`). - Compatibility is tied to: - `fingerprint_version` - `python_tag` @@ -358,8 +375,8 @@ Architecture is layered, but grounded in current code (not aspirational diagrams `codeclone/grouping.py`, `codeclone/scanner.py`) produces normalized structural facts and clone candidates. - **Domain/contracts layer** (`codeclone/models.py`, `codeclone/contracts.py`, `codeclone/errors.py`, `codeclone/domain/*.py`) defines typed entities and stable enums/constants used across layers. -- **Persistence contracts** (`codeclone/baseline.py`, `codeclone/cache.py`, `codeclone/metrics_baseline.py`) store - trusted comparison state and optimization state. +- **Persistence contracts** (`codeclone/baseline.py`, `codeclone/cache.py`, `codeclone/cache_io.py`, + `codeclone/metrics_baseline.py`) store trusted comparison state and optimization state. - **Canonical report + projections** (`codeclone/report/json_contract.py`, `codeclone/report/*.py`) converts analysis facts to deterministic, contract-shaped outputs. - **HTML/UI rendering** (`codeclone/html_report.py`, `codeclone/_html_report/*`, `codeclone/_html_*.py`, @@ -411,8 +428,12 @@ Use this map to route changes to the right owner module. deterministic. - `codeclone/baseline.py` — baseline schema/trust/integrity/compatibility contract; all baseline format changes go here with explicit contract process. -- `codeclone/cache.py` — cache schema/integrity/profile compatibility and serialization; cache remains +- `codeclone/cache.py` — cache schema/status/profile compatibility and high-level serialization policy; cache remains optimization-only. +- `codeclone/cache_io.py` — IO-layer helpers for the cache: atomic JSON read/write + (`read_json_document`, `write_json_document_atomically`), canonical JSON (`canonical_json`), and + HMAC signing/verification (`sign_cache_payload`, `verify_cache_payload_signature`); attribute these + functions to `cache_io.py`, not `cache.py`. - `codeclone/report/json_contract.py` — canonical report schema builder/integrity payload; any JSON contract shape change belongs here. - `codeclone/report/*.py` (other modules) — deterministic projections/format transforms ( @@ -529,7 +550,7 @@ Policy: ### Public / contract-sensitive surfaces - CLI flags, defaults, exit codes, and stable script-facing messages. -- Baseline schema/trust semantics/integrity compatibility (`2.0` baseline contract family). +- Baseline schema/trust semantics/integrity compatibility (`BASELINE_SCHEMA_VERSION` contract family). - Cache schema/status/profile compatibility/integrity (`CACHE_VERSION` contract family). - Canonical report JSON schema/payload semantics (`REPORT_SCHEMA_VERSION` contract family). - Documented report projections and their machine/user-facing semantics (HTML/Markdown/SARIF/Text). @@ -621,7 +642,68 @@ Avoid deep package hierarchies unless they clearly reduce coupling. --- -## 20) Minimal checklist for PRs (agents) +## 20) Agent safety rules + +These rules exist because of real incidents in this repo. They are non-negotiable. + +### Scope discipline + +- Touch only files directly related to your current task. +- Do not "clean up", reformat, or refactor code in files outside your task scope. +- Do not delete functions, classes, blocks, or whole files written by other contributors unless + deletion is the explicit goal of your task. +- If you discover unrelated issues, report them in your final message — do not fix them silently. +- Before starting work, run `git status` and review uncommitted/untracked changes. They may belong + to a parallel agent or to the maintainer; do not delete or overwrite them without explicit approval. + +### Documentation hygiene + +- Every doc claim about code (schema version, module path, function name, MCP tool count, exit code, + CLI flag) must be verified against the **current** code before writing or editing. +- Always read version constants from `codeclone/contracts.py` (see Section 4 table), never from + another doc. +- When updating a file that mentions schema versions, verify **every** version reference in that + file — not only the one you came to change. +- Do not remove narrative content from docs you did not author. Add or correct only. +- Do not replace a multi-section doc with a "pointer" stub unless the maintainer explicitly asks for it. +- Do not create new `*.md` design specs ("PROPOSED", "FUTURE", "RFC") inside `docs/`. Use the + maintainer's planning channel instead — orphaned specs become stale and misleading. + +### Audit completeness + +- When the maintainer asks to audit "all" of something, list every file you actually opened in your + final report. Selective audits silently skip the most error-prone files. +- Prefer parallel `Explore` agents partitioned by file group over a single sequential pass — + coverage is the contract, not effort. + +### Shared helpers + +- HTML/UI helpers (`_html_badges.py`, `_html_css.py`, `_html_js.py`, `_html_escape.py`, + `_html_report/_glossary.py`) are imported, not duplicated locally inside `_html_report/_sections/*`. + If you need a helper that doesn't exist, add it to the shared module. +- Glossary terms used in stat-card labels live in `codeclone/_html_report/_glossary.py`. Adding a + new label without a glossary entry is a contract gap. + +### Conflict avoidance + +- Do not force-push, `git reset --hard`, or `git checkout --` over uncommitted work without + explicit maintainer approval. +- If your changes conflict with recent commits or other agents' work, rebase or merge cleanly — + never silently drop the other side. +- Never use `--no-verify` to bypass pre-commit hooks; fix the underlying issue. + +### Verification before "done" + +- A task that touches HTML rendering is not complete until + `pytest tests/test_html_report.py -x -q` is green. +- A task that touches MCP is not complete until + `pytest tests/test_mcp_service.py tests/test_mcp_server.py -x -q` is green. +- A task that touches docs schema/version claims is not complete until you have grep'd the whole + file for *all* version-shaped strings and verified each against `codeclone/contracts.py`. + +--- + +## 21) Minimal checklist for PRs (agents) - [ ] Change is deterministic. - [ ] Contracts preserved or versioned. diff --git a/CHANGELOG.md b/CHANGELOG.md index f6a19f1..db54f6e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,46 @@ # Changelog -## [2.0.0b4] +## [2.0.0b5] - 2026-04-16 + +Expands the canonical contract with adoption, API-surface, and coverage-join layers; clarifies run interpretation +across MCP/HTML/clients; tightens MCP launcher/runtime behavior. + +### Contracts, metrics, and review surfaces + +- Report schema `2.8`: add `coverage_adoption`, `api_surface`, `coverage_join`, and optional + `clones.suppressed.*` (for `golden_fixture_paths`); separate coverage hotspots vs scope gaps. +- Baselines: clone `2.1`, metrics `1.2`; compact `api_surface` payload (`local_name` on disk, qualnames at runtime); + read-compatible with `2.0` / `1.1`. +- Add public/private visibility classification for public-symbol metrics (no clone/fingerprint changes). +- Add annotation/docstring adoption coverage: parameter, return, public docstrings, explicit `Any`. +- Add opt-in API surface inventory + baseline diff (snapshots, additions, breaking changes). +- Add coverage join (`--coverage`): per-function facts + findings for below-threshold or missing-in-scope functions; + current-run only (not baseline truth, no fingerprint impact). +- Add `golden_fixture_paths`: exclude matching clone groups from health/gates while keeping suppressed facts. +- Add gates: `--min-typing-coverage`, `--min-docstring-coverage`, `--fail-on-typing-regression`, + `--fail-on-docstring-regression`, `--fail-on-api-break`, `--fail-on-untested-hotspots`, `--coverage-min`. +- Surface adoption/API/coverage-join in MCP, CLI Metrics, report payloads, and HTML (Overview + Quality subtab). +- Preserve embedded metrics and optional `api_surface` in unified baselines. +- Cache `2.5`: make analysis-profile compatibility API-surface-aware; invalidate stale non-API warm caches; preserve parameter order; align warm/cold API diffs. + +### MCP, HTML, and client interpretation + +- Surface effective analysis profile in report meta, MCP summary/triage, and HTML subtitle. +- Add `health_scope`, `focus`, `new_by_source_kind` to MCP summary/triage. +- Make baseline mismatch explicit (python tags + no-valid-baseline signal). +- Surface `Coverage Join` facts and the optional `coverage` MCP help topic in + the VS Code extension when the connected server supports them. +- Prefer workspace-local launchers over `PATH` (Poetry fallback). +- Add `workspace_root` to force project `.venv` selection. + +### Safety and maintenance + +- Validate `git_diff_ref` as safe single-revision expressions. +- Replace segment digest `repr()` with canonical JSON bytes (determinism). +- Align CI coverage gate (`fail_under = 99`) and refresh `actions/checkout` pin. +- Refresh branch metadata/docs for `2.0.0b5`; update README badge to `89 (B)`. + +## [2.0.0b4] - 2026-04-05 ### MCP server diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6a92890..af4bb11 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -138,10 +138,10 @@ CodeClone maintains several versioned schema contracts: | Schema | Current version | Owner | |------------------|-----------------|-------------------------------------| -| Baseline | `2.0` | `codeclone/baseline.py` | -| Report | `2.1` | `codeclone/report/json_contract.py` | -| Cache | `2.2` | `codeclone/cache.py` | -| Metrics baseline | `1.0` | `codeclone/metrics_baseline.py` | +| Baseline | `2.1` | `codeclone/baseline.py` | +| Report | `2.8` | `codeclone/report/json_contract.py` | +| Cache | `2.4` | `codeclone/cache_io.py` | +| Metrics baseline | `1.2` | `codeclone/metrics_baseline.py` | Any change to schema shape or semantics requires version review, documentation, and tests. @@ -209,6 +209,27 @@ uv run pytest -q tests/test_mcp_service.py tests/test_mcp_server.py --- +## Commit Messages + +Use the repository's existing **Conventional Commits** style: + +- format: `type(scope): imperative summary` +- keep `type` lowercase (`feat`, `fix`, `docs`, `chore`, ...) +- keep the summary short, imperative, and specific to the user-visible change +- use a narrow scope when it helps (`metrics`, `mcp,vscode`, `core,ci`, ...) +- split unrelated changes into separate commits instead of writing one broad summary + +Examples from the current history: + +- `fix(core,ci): harden git diff validation, make segment digests canonical, and align CI policy` +- `feat(metrics): add adoption and public API baselines with compact schema-aware storage` +- `chore(docs): align AGENTS and contract docs with current code` + +If a commit needs extra context, keep the subject line concise and explain the +rest in the commit body. + +--- + ## Code Style - Python **3.10 – 3.14** diff --git a/README.md b/README.md index e1ce0db..0284773 100644 --- a/README.md +++ b/README.md @@ -16,7 +16,7 @@ Tests Benchmark Python - codeclone 85 (B) + codeclone 89 (B) License

@@ -41,18 +41,17 @@ Live sample report: ## Features - **Clone detection** — function (CFG fingerprint), block (statement windows), and segment (report-only) clones -- **Structural findings** — duplicated branch families, clone guard/exit divergence and clone-cohort drift (report-only) -- **Quality metrics** — cyclomatic complexity, coupling (`CBO`), cohesion (`LCOM4`), dependency cycles, dead code, - health score, and report-only `Overloaded Modules` profiling -- **Baseline governance** — separates accepted **legacy** debt from **new regressions** and lets CI fail **only** on - what changed -- **Reports** — interactive HTML, deterministic JSON/TXT plus Markdown and SARIF projections from one canonical report -- **MCP server** — optional read-only surface for AI agents and IDEs, designed as a budget-aware guided control - surface for agentic development -- **VS Code extension** — preview native client for CodeClone MCP with triage-first structural review -- **Native client surfaces** — preview Claude Desktop bundle and Codex plugin over the same canonical MCP contract +- **Structural findings** — duplicated branch families, clone guard/exit divergence, and clone-cohort drift +- **Quality metrics** — cyclomatic complexity, coupling (CBO), cohesion (LCOM4), dependency cycles, 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 +- **Baseline governance** — separates accepted **legacy** debt from **new regressions**; CI fails only on what changed +- **Reports** — interactive HTML, JSON, Markdown, SARIF, and text from one canonical report +- **MCP server** — optional read-only surface for AI agents and IDEs +- **IDE & agent clients** — VS Code extension, Claude Desktop bundle, and Codex plugin over the same MCP contract - **CI-first** — deterministic output, stable ordering, exit code contract, pre-commit support -- **Fast** — incremental caching, parallel processing, warm-run optimization, and reproducible benchmark coverage +- **Fast** — incremental caching, parallel processing, warm-run optimization ## Quick Start @@ -141,8 +140,20 @@ codeclone . --fail-cycles --fail-dead-code # Regression detection vs baseline codeclone . --fail-on-new-metrics + +# Adoption and API governance +codeclone . --min-typing-coverage 80 --min-docstring-coverage 60 +codeclone . --fail-on-typing-regression --fail-on-docstring-regression +codeclone . --api-surface --update-metrics-baseline +codeclone . --fail-on-api-break + +# Coverage Join — fuse external Cobertura XML into the review +codeclone . --coverage coverage.xml --fail-on-untested-hotspots --coverage-min 50 ``` +Gate details: +[Metrics and quality gates](https://orenlab.github.io/codeclone/book/15-metrics-and-quality-gates/) + ### Pre-commit ```yaml @@ -161,7 +172,7 @@ repos: ## MCP Server Optional read-only MCP server for AI agents and IDE clients. -21 tools + 10 resources — never mutates source, baselines, or repo state. +Never mutates source, baselines, or repo state. ```bash uv tool install --pre "codeclone[mcp]" # or: uv pip install --pre "codeclone[mcp]" @@ -170,9 +181,7 @@ codeclone-mcp --transport stdio # local (Claude Code, Codex, Copilot, codeclone-mcp --transport streamable-http # remote / HTTP-only clients ``` -Docs: -[MCP usage guide](https://orenlab.github.io/codeclone/mcp/) -· +[MCP usage guide](https://orenlab.github.io/codeclone/mcp/) · [MCP interface contract](https://orenlab.github.io/codeclone/book/20-mcp-interface/) ### Native Client Surfaces @@ -185,6 +194,10 @@ Docs: All three are thin wrappers over the same `codeclone-mcp` contract — no second analysis engine. +[VS Code extension docs](https://orenlab.github.io/codeclone/book/21-vscode-extension/) · +[Claude Desktop docs](https://orenlab.github.io/codeclone/book/22-claude-desktop-bundle/) · +[Codex plugin docs](https://orenlab.github.io/codeclone/book/23-codex-plugin/) + ## Configuration CodeClone can load project-level configuration from `pyproject.toml`: @@ -194,6 +207,7 @@ CodeClone can load project-level configuration from `pyproject.toml`: min_loc = 10 min_stmt = 6 baseline = "codeclone.baseline.json" +golden_fixture_paths = ["tests/fixtures/golden_*"] skip_metrics = false quiet = false html_out = ".cache/codeclone/report.html" @@ -209,6 +223,9 @@ segment_min_stmt = 10 Precedence: CLI flags > `pyproject.toml` > built-in defaults. +Config reference: +[Config and defaults](https://orenlab.github.io/codeclone/book/04-config-and-defaults/) + ## Baseline Workflow Baselines capture the current duplication state. Once committed, they become the CI reference point. @@ -231,6 +248,8 @@ Full contract: [Baseline contract](https://orenlab.github.io/codeclone/book/06-b Contract errors (`2`) take precedence over gating failures (`3`). +Full policy: [Exit codes and failure policy](https://orenlab.github.io/codeclone/book/03-contracts-exit-codes/) + ## Reports | Format | Flag | Default path | @@ -241,49 +260,32 @@ Contract errors (`2`) take precedence over gating failures (`3`). | SARIF | `--sarif` | `.cache/codeclone/report.sarif` | | Text | `--text` | `.cache/codeclone/report.txt` | -All report formats are rendered from one canonical JSON report document. - -- `--open-html-report` opens the generated HTML report in the default browser and requires `--html`. -- `--timestamped-report-paths` appends a UTC timestamp to default report filenames for bare report flags such as - `--html` or `--json`. Explicit report paths are not rewritten. - -The docs site also includes live example HTML/JSON/SARIF reports generated from the current `codeclone` repository. - -Structural findings include: - -- `duplicated_branches` -- `clone_guard_exit_divergence` -- `clone_cohort_drift` - -### Inline Suppressions - -CodeClone keeps dead-code detection deterministic and static by default. When a symbol is intentionally -invoked through runtime dynamics (for example framework callbacks, plugin loading, or reflection), suppress -the known false positive explicitly at the declaration site: - -```python -# codeclone: ignore[dead-code] -def handle_exception(exc: Exception) -> None: - ... - - -class Middleware: # codeclone: ignore[dead-code] - ... -``` +All formats are rendered from one canonical JSON report. +`--open-html-report` opens the HTML in the default browser. +`--timestamped-report-paths` appends a UTC timestamp to default filenames. -Dynamic/runtime false positives are resolved via explicit inline suppressions, not via broad heuristics. +Report contract: [Report contract](https://orenlab.github.io/codeclone/book/08-report/) · +[HTML render](https://orenlab.github.io/codeclone/book/10-html-render/)
-Canonical JSON report shape (v2.3) +Canonical JSON report shape (v2.8) ```json { - "report_schema_version": "2.3", + "report_schema_version": "2.8", "meta": { - "codeclone_version": "2.0.0b4", + "codeclone_version": "2.0.0b5", "project_name": "...", "scan_root": ".", "report_mode": "full", + "analysis_profile": { + "min_loc": 10, + "min_stmt": 6, + "block_min_loc": 20, + "block_min_stmt": 8, + "segment_min_loc": 20, + "segment_min_stmt": 10 + }, "analysis_thresholds": { "design_findings": { "...": "..." @@ -337,8 +339,18 @@ Dynamic/runtime false positives are resolved via explicit inline suppressions, n } }, "metrics": { - "summary": {}, - "families": {} + "summary": { + "...": "...", + "coverage_adoption": { "...": "..." }, + "coverage_join": { "...": "..." }, + "api_surface": { "...": "..." } + }, + "families": { + "...": "...", + "coverage_adoption": { "...": "..." }, + "coverage_join": { "...": "..." }, + "api_surface": { "...": "..." } + } }, "derived": { "suggestions": [], @@ -370,11 +382,29 @@ Dynamic/runtime false positives are resolved via explicit inline suppressions, n } ``` -Canonical contract: [Report contract](https://orenlab.github.io/codeclone/book/08-report/) and -[Dead-code contract](https://orenlab.github.io/codeclone/book/16-dead-code-contract/) +Full contract: [Report contract](https://orenlab.github.io/codeclone/book/08-report/)
+## Inline Suppressions + +When a symbol is invoked through runtime dynamics (framework callbacks, plugin loading, reflection), +suppress the known false positive at the declaration site: + +```python +# codeclone: ignore[dead-code] +def handle_exception(exc: Exception) -> None: + ... + + +class Middleware: # codeclone: ignore[dead-code] + ... +``` + +Suppression contract: +[Inline suppressions](https://orenlab.github.io/codeclone/book/19-inline-suppressions/) · +[Dead-code contract](https://orenlab.github.io/codeclone/book/16-dead-code-contract/) + ## How It Works 1. **Parse** — Python source to AST @@ -390,18 +420,14 @@ CFG semantics: [CFG semantics](https://orenlab.github.io/codeclone/cfg/) ## Documentation -| Topic | Link | -|----------------------------|-----------------------------------------------------------------------------------------------------| -| Contract book (start here) | [Contracts and guarantees](https://orenlab.github.io/codeclone/book/00-intro/) | -| Exit codes | [Exit codes and failure policy](https://orenlab.github.io/codeclone/book/03-contracts-exit-codes/) | -| Configuration | [Config and defaults](https://orenlab.github.io/codeclone/book/04-config-and-defaults/) | -| Baseline contract | [Baseline contract](https://orenlab.github.io/codeclone/book/06-baseline/) | -| Cache contract | [Cache contract](https://orenlab.github.io/codeclone/book/07-cache/) | -| Report contract | [Report contract](https://orenlab.github.io/codeclone/book/08-report/) | -| Metrics & quality gates | [Metrics and quality gates](https://orenlab.github.io/codeclone/book/15-metrics-and-quality-gates/) | -| Dead code | [Dead-code contract](https://orenlab.github.io/codeclone/book/16-dead-code-contract/) | -| Docker benchmark contract | [Benchmarking contract](https://orenlab.github.io/codeclone/book/18-benchmarking/) | -| Determinism | [Determinism policy](https://orenlab.github.io/codeclone/book/12-determinism/) | +Full docs and contract book: [orenlab.github.io/codeclone](https://orenlab.github.io/codeclone/) + +Quick links: +[Baseline](https://orenlab.github.io/codeclone/book/06-baseline/) · +[Report](https://orenlab.github.io/codeclone/book/08-report/) · +[Metrics & gates](https://orenlab.github.io/codeclone/book/15-metrics-and-quality-gates/) · +[MCP](https://orenlab.github.io/codeclone/book/20-mcp-interface/) · +[CLI](https://orenlab.github.io/codeclone/book/09-cli/) ## Benchmarking Notes @@ -436,6 +462,7 @@ Versions released before this change remain under their original license terms. ## Links +- **Docs:** - **Issues:** - **PyPI:** - **Licenses:** [MPL-2.0](LICENSE) · [MIT docs](LICENSE-docs) diff --git a/benchmarks/__init__.py b/benchmarks/__init__.py new file mode 100644 index 0000000..9135843 --- /dev/null +++ b/benchmarks/__init__.py @@ -0,0 +1,5 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy diff --git a/benchmarks/run_benchmark.py b/benchmarks/run_benchmark.py index ba12356..b77c96b 100755 --- a/benchmarks/run_benchmark.py +++ b/benchmarks/run_benchmark.py @@ -25,6 +25,30 @@ from codeclone.baseline import current_python_tag BENCHMARK_SCHEMA_VERSION = "1.0" +BENCHMARK_NEUTRAL_ARGS: tuple[str, ...] = ( + "--no-fail-on-new", + "--no-fail-on-new-metrics", + "--no-fail-cycles", + "--no-fail-dead-code", + "--no-fail-on-typing-regression", + "--no-fail-on-docstring-regression", + "--no-fail-on-api-break", + "--no-fail-on-untested-hotspots", + "--fail-threshold", + "-1", + "--fail-complexity", + "-1", + "--fail-coupling", + "-1", + "--fail-cohesion", + "-1", + "--fail-health", + "-1", + "--min-typing-coverage", + "-1", + "--min-docstring-coverage", + "-1", +) @dataclass(frozen=True) @@ -139,6 +163,7 @@ def _run_cli_once( "-m", "codeclone.cli", str(target), + *BENCHMARK_NEUTRAL_ARGS, "--json", str(report_path), "--cache-path", @@ -176,6 +201,45 @@ def _run_cli_once( ) +def _validate_inventory_sample( + *, + scenario: Scenario, + measurement: RunMeasurement, +) -> None: + if measurement.files_found <= 0: + raise RuntimeError( + f"scenario {scenario.name} produced an empty inventory sample; " + "benchmark target is invalid" + ) + if measurement.files_skipped > 0: + raise RuntimeError( + f"scenario {scenario.name} skipped {measurement.files_skipped} files; " + "benchmark run is invalid" + ) + if scenario.mode == "cold": + if measurement.files_cached != 0: + raise RuntimeError( + f"cold scenario {scenario.name} unexpectedly used cache: " + f"cached={measurement.files_cached}" + ) + if measurement.files_analyzed <= 0: + raise RuntimeError( + f"cold scenario {scenario.name} analyzed no files: " + f"found={measurement.files_found} analyzed={measurement.files_analyzed}" + ) + return + if measurement.files_cached <= 0: + raise RuntimeError( + f"warm scenario {scenario.name} did not use cache: " + f"cached={measurement.files_cached}" + ) + if measurement.files_analyzed != 0: + raise RuntimeError( + f"warm scenario {scenario.name} analyzed files unexpectedly: " + f"analyzed={measurement.files_analyzed}" + ) + + def _scenario_result( *, scenario: Scenario, @@ -230,6 +294,7 @@ def _scenario_result( report_path=scenario_dir / f"run-report-{idx}.json", extra_args=scenario.extra_args, ) + _validate_inventory_sample(scenario=scenario, measurement=measurement) measurements.append(measurement) digests = sorted({m.digest for m in measurements}) diff --git a/benchmarks/run_docker_benchmark.sh b/benchmarks/run_docker_benchmark.sh index 7faf994..7a11fe7 100755 --- a/benchmarks/run_docker_benchmark.sh +++ b/benchmarks/run_docker_benchmark.sh @@ -2,7 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -IMAGE_TAG="${IMAGE_TAG:-codeclone-benchmark:2.0.0b4}" +IMAGE_TAG="${IMAGE_TAG:-codeclone-benchmark:2.0.0b5}" OUT_DIR="${OUT_DIR:-$ROOT_DIR/.cache/benchmarks}" OUTPUT_BASENAME="${OUTPUT_BASENAME:-codeclone-benchmark.json}" CPUSET="${CPUSET:-0}" diff --git a/codeclone.baseline.json b/codeclone.baseline.json index a2d9b88..b4656f8 100644 --- a/codeclone.baseline.json +++ b/codeclone.baseline.json @@ -2,28 +2,18 @@ "meta": { "generator": { "name": "codeclone", - "version": "2.0.0b4" + "version": "2.0.0b5" }, - "schema_version": "2.0", + "schema_version": "2.1", "fingerprint_version": "1", "python_tag": "cp313", - "created_at": "2026-04-04T18:57:08Z", - "payload_sha256": "691c6cedd10e2a51d6038780f3ae9dffe763356dd2aba742b3980f131b79f217", - "metrics_payload_sha256": "07e216e9a158e4dc56ad2ee6ca8069896cd17d39bdb0b8a3745ebd98627d0d25" + "created_at": "2026-04-13T13:10:37Z", + "payload_sha256": "07a383c1d0974593c83ac30430aec9b99d89fe50f640a9b3b433658e0bd029e8", + "metrics_payload_sha256": "122ee5d2d3dc2d4e9553b1d440c0314515dcb60cc79ada264b13c39c6ba18e04" }, "clones": { - "functions": [ - "efc8465229b381a3a50502d59d9539c0be3efe86|20-49" - ], - "blocks": [ - "3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5", - "3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|cb4fcbc1b2a65ec1346898fc0d660335e25d7cbc", - "8579659a9e8c9755a6d2f0b1d82dda8866fd243b|1912d2ee3c541cbf9e51f485348586afe1a00755|ee69aff0b7ea38927e5082ceef14115c805f6734|ee69aff0b7ea38927e5082ceef14115c805f6734", - "b4b5893be87edf98955f047cbf25ca755dc753b4|8579659a9e8c9755a6d2f0b1d82dda8866fd243b|1912d2ee3c541cbf9e51f485348586afe1a00755|ee69aff0b7ea38927e5082ceef14115c805f6734", - "b6ee70d0bd6ff4b593f127a137aed9ab41179145|cacc33d58f323481f65fed57873d1c840531859e|d60c0005a4c850c140378d1c82b81dde93a7ccab|d60c0005a4c850c140378d1c82b81dde93a7ccab", - "cacc33d58f323481f65fed57873d1c840531859e|d60c0005a4c850c140378d1c82b81dde93a7ccab|d60c0005a4c850c140378d1c82b81dde93a7ccab|b4b5893be87edf98955f047cbf25ca755dc753b4", - "ee69aff0b7ea38927e5082ceef14115c805f6734|fcd36b4275c94f1955fb55e1c1ca3c04c7c0bb26|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5|3c1b5cf24b4dfcd8e5736b735bfd3850940100d5" - ] + "functions": [], + "blocks": [] }, "metrics": { "max_complexity": 20, @@ -33,9 +23,9 @@ "max_cohesion": 3, "low_cohesion_classes": [], "dependency_cycles": [], - "dependency_max_depth": 10, + "dependency_max_depth": 11, "dead_code_items": [], - "health_score": 87, + "health_score": 89, "health_grade": "B" } } diff --git a/codeclone/_cli_args.py b/codeclone/_cli_args.py index 17a2a2f..7ad4c95 100644 --- a/codeclone/_cli_args.py +++ b/codeclone/_cli_args.py @@ -126,6 +126,7 @@ def build_parser(version: str) -> _ArgumentParser: block_min_stmt=DEFAULT_BLOCK_MIN_STMT, segment_min_loc=DEFAULT_SEGMENT_MIN_LOC, segment_min_stmt=DEFAULT_SEGMENT_MIN_STMT, + golden_fixture_paths=(), ) analysis_group.add_argument( "--processes", @@ -213,6 +214,18 @@ def build_parser(version: str) -> _ArgumentParser: flag="--ci", help_text=ui.HELP_CI, ) + _add_bool_optional_argument( + baselines_ci_group, + flag="--api-surface", + help_text=ui.HELP_API_SURFACE, + ) + baselines_ci_group.add_argument( + "--coverage", + dest="coverage_xml", + metavar="FILE", + default=None, + help=ui.HELP_COVERAGE, + ) quality_group = ap.add_argument_group("Quality gates") _add_bool_optional_argument( @@ -278,6 +291,47 @@ def build_parser(version: str) -> _ArgumentParser: metavar="SCORE_MIN", help=ui.HELP_FAIL_HEALTH, ) + _add_bool_optional_argument( + quality_group, + flag="--fail-on-typing-regression", + help_text=ui.HELP_FAIL_ON_TYPING_REGRESSION, + ) + _add_bool_optional_argument( + quality_group, + flag="--fail-on-docstring-regression", + help_text=ui.HELP_FAIL_ON_DOCSTRING_REGRESSION, + ) + _add_bool_optional_argument( + quality_group, + flag="--fail-on-api-break", + help_text=ui.HELP_FAIL_ON_API_BREAK, + ) + _add_bool_optional_argument( + quality_group, + flag="--fail-on-untested-hotspots", + help_text=ui.HELP_FAIL_ON_UNTESTED_HOTSPOTS, + ) + quality_group.add_argument( + "--min-typing-coverage", + type=int, + default=-1, + metavar="PERCENT", + help=ui.HELP_MIN_TYPING_COVERAGE, + ) + quality_group.add_argument( + "--min-docstring-coverage", + type=int, + default=-1, + metavar="PERCENT", + help=ui.HELP_MIN_DOCSTRING_COVERAGE, + ) + quality_group.add_argument( + "--coverage-min", + type=int, + default=50, + metavar="PERCENT", + help=ui.HELP_COVERAGE_MIN, + ) stages_group = ap.add_argument_group("Analysis stages") _add_bool_optional_argument( diff --git a/codeclone/_cli_baselines.py b/codeclone/_cli_baselines.py index f06839a..2be5a59 100644 --- a/codeclone/_cli_baselines.py +++ b/codeclone/_cli_baselines.py @@ -59,6 +59,10 @@ class _BaselineArgs(Protocol): skip_metrics: bool update_metrics_baseline: bool fail_on_new_metrics: bool + fail_on_typing_regression: bool + fail_on_docstring_regression: bool + fail_on_api_break: bool + api_surface: bool ci: bool @@ -274,7 +278,13 @@ def _metrics_mode_short_circuit( ) -> bool: if not args.skip_metrics: return False - if args.update_metrics_baseline or args.fail_on_new_metrics: + if ( + args.update_metrics_baseline + or args.fail_on_new_metrics + or args.fail_on_typing_regression + or args.fail_on_docstring_regression + or args.fail_on_api_break + ): console.print( ui.fmt_contract_error( "Metrics baseline operations require metrics analysis. " @@ -294,12 +304,12 @@ def _load_metrics_baseline_for_diff( shared_baseline_payload: dict[str, object] | None = None, ) -> None: if not metrics_baseline_exists: - if args.fail_on_new_metrics and not args.update_metrics_baseline: + if _metrics_baseline_gate_requested(args) and not args.update_metrics_baseline: state.failure_code = ExitCode.CONTRACT_ERROR console.print( ui.fmt_contract_error( - "Metrics baseline file is required for --fail-on-new-metrics. " - "Run codeclone . --update-metrics-baseline first." + "Metrics baseline file is required for metrics baseline-aware " + "gates. Run codeclone . --update-metrics-baseline first." ) ) return @@ -334,6 +344,11 @@ def _load_metrics_baseline_for_diff( state.loaded = True state.status = MetricsBaselineStatus.OK state.trusted_for_diff = True + _enforce_metrics_gate_schema_requirements( + args=args, + state=state, + console=console, + ) def _apply_metrics_baseline_untrusted_policy( @@ -345,7 +360,7 @@ def _apply_metrics_baseline_untrusted_policy( return state.loaded = False state.trusted_for_diff = False - if args.fail_on_new_metrics and not args.update_metrics_baseline: + if _metrics_baseline_gate_requested(args) and not args.update_metrics_baseline: state.failure_code = ExitCode.CONTRACT_ERROR @@ -371,6 +386,8 @@ def _update_metrics_baseline_if_requested( new_metrics_baseline = MetricsBaseline.from_project_metrics( project_metrics=project_metrics, path=metrics_baseline_path, + include_adoption=True, + include_api_surface=args.api_surface, ) try: new_metrics_baseline.save() @@ -392,3 +409,51 @@ def _update_metrics_baseline_if_requested( state.loaded = True state.status = MetricsBaselineStatus.OK state.trusted_for_diff = True + + +def _metrics_baseline_gate_requested(args: _BaselineArgs) -> bool: + return bool( + args.fail_on_new_metrics + or args.fail_on_typing_regression + or args.fail_on_docstring_regression + or args.fail_on_api_break + ) + + +def _enforce_metrics_gate_schema_requirements( + *, + args: _BaselineArgs, + state: _MetricsBaselineRuntime, + console: _PrinterLike, +) -> None: + baseline = state.baseline + needs_adoption_snapshot = bool( + args.fail_on_typing_regression or args.fail_on_docstring_regression + ) + if needs_adoption_snapshot and not getattr( + baseline, "has_coverage_adoption_snapshot", False + ): + state.loaded = False + state.trusted_for_diff = False + state.status = MetricsBaselineStatus.MISMATCH_SCHEMA_VERSION + state.failure_code = ExitCode.CONTRACT_ERROR + console.print( + ui.fmt_contract_error( + "Typing/docstring regression gates require a metrics baseline " + "that includes coverage adoption data. Run codeclone . " + "--update-metrics-baseline first." + ) + ) + return + if args.fail_on_api_break and baseline.api_surface_snapshot is None: + state.loaded = False + state.trusted_for_diff = False + state.status = MetricsBaselineStatus.MISMATCH_SCHEMA_VERSION + state.failure_code = ExitCode.CONTRACT_ERROR + console.print( + ui.fmt_contract_error( + "API break gating requires a metrics baseline with public API " + "surface data. Run codeclone . --api-surface " + "--update-metrics-baseline first." + ) + ) diff --git a/codeclone/_cli_config.py b/codeclone/_cli_config.py index 22efec1..b17ba43 100644 --- a/codeclone/_cli_config.py +++ b/codeclone/_cli_config.py @@ -12,6 +12,11 @@ from pathlib import Path from typing import TYPE_CHECKING, Final +from .golden_fixtures import ( + GoldenFixturePatternError, + normalize_golden_fixture_patterns, +) + if TYPE_CHECKING: import argparse from collections.abc import Mapping, Sequence @@ -25,6 +30,7 @@ class ConfigValidationError(ValueError): class _ConfigKeySpec: expected_type: type[object] allow_none: bool = False + expected_name: str | None = None _CONFIG_KEY_SPECS: Final[dict[str, _ConfigKeySpec]] = { @@ -50,11 +56,21 @@ class _ConfigKeySpec: "fail_dead_code": _ConfigKeySpec(bool), "fail_health": _ConfigKeySpec(int), "fail_on_new_metrics": _ConfigKeySpec(bool), + "api_surface": _ConfigKeySpec(bool), + "coverage_xml": _ConfigKeySpec(str, allow_none=True), + "fail_on_typing_regression": _ConfigKeySpec(bool), + "fail_on_docstring_regression": _ConfigKeySpec(bool), + "fail_on_api_break": _ConfigKeySpec(bool), + "fail_on_untested_hotspots": _ConfigKeySpec(bool), + "min_typing_coverage": _ConfigKeySpec(int), + "min_docstring_coverage": _ConfigKeySpec(int), + "coverage_min": _ConfigKeySpec(int), "update_metrics_baseline": _ConfigKeySpec(bool), "metrics_baseline": _ConfigKeySpec(str), "skip_metrics": _ConfigKeySpec(bool), "skip_dead_code": _ConfigKeySpec(bool), "skip_dependencies": _ConfigKeySpec(bool), + "golden_fixture_paths": _ConfigKeySpec(list, expected_name="list[str]"), "html_out": _ConfigKeySpec(str, allow_none=True), "json_out": _ConfigKeySpec(str, allow_none=True), "md_out": _ConfigKeySpec(str, allow_none=True), @@ -71,6 +87,7 @@ class _ConfigKeySpec: "cache_path", "baseline", "metrics_baseline", + "coverage_xml", "html_out", "json_out", "md_out", @@ -179,7 +196,7 @@ def _validate_config_value(*, key: str, value: object) -> object: return None raise ConfigValidationError( "Invalid value type for tool.codeclone." - f"{key}: expected {spec.expected_type.__name__}" + f"{key}: expected {spec.expected_name or spec.expected_type.__name__}" ) expected_type = spec.expected_type @@ -207,6 +224,8 @@ def _validate_config_value(*, key: str, value: object) -> object: expected_type=str, expected_name="str", ) + if expected_type is list: + return _validated_string_list(key=key, value=value) raise ConfigValidationError(f"Unsupported config key spec for tool.codeclone.{key}") @@ -228,6 +247,21 @@ def _validated_config_instance( ) +def _validated_string_list(*, key: str, value: object) -> tuple[str, ...]: + if not isinstance(value, list): + raise ConfigValidationError( + f"Invalid value type for tool.codeclone.{key}: expected list[str]" + ) + if not all(isinstance(item, str) for item in value): + raise ConfigValidationError( + f"Invalid value type for tool.codeclone.{key}: expected list[str]" + ) + try: + return normalize_golden_fixture_patterns(value) + except GoldenFixturePatternError as exc: + raise ConfigValidationError(str(exc)) from exc + + def _load_toml(path: Path) -> object: if sys.version_info >= (3, 11): import tomllib diff --git a/codeclone/_cli_gating.py b/codeclone/_cli_gating.py index 96b96e2..20ec538 100644 --- a/codeclone/_cli_gating.py +++ b/codeclone/_cli_gating.py @@ -18,12 +18,19 @@ class _GatingArgs(Protocol): ci: bool fail_on_new_metrics: bool + fail_on_typing_regression: bool + fail_on_docstring_regression: bool + fail_on_api_break: bool + fail_on_untested_hotspots: bool fail_complexity: int fail_coupling: int fail_cohesion: int fail_cycles: bool fail_dead_code: bool fail_health: int + min_typing_coverage: int + min_docstring_coverage: int + coverage_min: int fail_on_new: bool fail_threshold: int @@ -36,6 +43,21 @@ def _strip_terminal_period(text: str) -> str: return text[:-1] if text.endswith(".") else text +def _parse_two_part_metric_detail( + text: str, + *, + prefix: str, + right_label: str, +) -> str | None: + if not text.startswith(prefix): + return None + left_part, right_part = text[len(prefix) :].split(", ", maxsplit=1) + return ( + f"{left_part.rsplit('=', maxsplit=1)[1]} " + f"({right_label}={right_part.rsplit('=', maxsplit=1)[1]})" + ) + + def parse_metric_reason_entry(reason: str) -> tuple[str, str]: trimmed = _strip_terminal_period(reason) @@ -57,6 +79,26 @@ def tail(prefix: str) -> str: if trimmed.startswith("Health score regressed vs metrics baseline: delta="): return "health_delta", trimmed.rsplit("=", maxsplit=1)[1] + typing_detail = _parse_two_part_metric_detail( + trimmed, + prefix="Typing coverage regressed vs metrics baseline: ", + right_label="returns_delta", + ) + if typing_detail is not None: + return "typing_coverage_delta", typing_detail + if trimmed.startswith("Docstring coverage regressed vs metrics baseline: delta="): + return "docstring_coverage_delta", trimmed.rsplit("=", maxsplit=1)[1] + if trimmed.startswith("Public API breaking changes vs metrics baseline: "): + return "api_breaking_changes", tail( + "Public API breaking changes vs metrics baseline: " + ) + coverage_detail = _parse_two_part_metric_detail( + trimmed, + prefix="Coverage hotspots detected: ", + right_label="threshold", + ) + if coverage_detail is not None: + return "coverage_hotspots", coverage_detail if trimmed.startswith("Dependency cycles detected: "): return "dependency_cycles", tail("Dependency cycles detected: ").replace( @@ -73,15 +115,17 @@ def tail(prefix: str) -> str: ("Coupling threshold exceeded: ", "coupling_max"), ("Cohesion threshold exceeded: ", "cohesion_max"), ("Health score below threshold: ", "health_score"), + ("Typing coverage below threshold: ", "typing_coverage"), + ("Docstring coverage below threshold: ", "docstring_coverage"), ) for prefix, kind in threshold_prefixes: - if trimmed.startswith(prefix): - left_part, threshold_part = tail(prefix).split(", ") - return ( - kind, - f"{left_part.rsplit('=', maxsplit=1)[1]} " - f"(threshold={threshold_part.rsplit('=', maxsplit=1)[1]})", - ) + threshold_detail = _parse_two_part_metric_detail( + trimmed, + prefix=prefix, + right_label="threshold", + ) + if threshold_detail is not None: + return kind, threshold_detail return "detail", trimmed @@ -94,26 +138,55 @@ def policy_context(*, args: _GatingArgs, gate_kind: str) -> str: match gate_kind: case "metrics": parts = ( - "fail-on-new-metrics" if args.fail_on_new_metrics else None, - f"fail-complexity={args.fail_complexity}" - if args.fail_complexity >= 0 + "fail-on-new-metrics" + if bool(getattr(args, "fail_on_new_metrics", False)) + else None, + f"fail-complexity={getattr(args, 'fail_complexity', -1)}" + if int(getattr(args, "fail_complexity", -1)) >= 0 else None, - f"fail-coupling={args.fail_coupling}" - if args.fail_coupling >= 0 + f"fail-coupling={getattr(args, 'fail_coupling', -1)}" + if int(getattr(args, "fail_coupling", -1)) >= 0 else None, - f"fail-cohesion={args.fail_cohesion}" - if args.fail_cohesion >= 0 + f"fail-cohesion={getattr(args, 'fail_cohesion', -1)}" + if int(getattr(args, "fail_cohesion", -1)) >= 0 + else None, + "fail-cycles" if bool(getattr(args, "fail_cycles", False)) else None, + "fail-dead-code" + if bool(getattr(args, "fail_dead_code", False)) + else None, + f"fail-health={getattr(args, 'fail_health', -1)}" + if int(getattr(args, "fail_health", -1)) >= 0 + else None, + "fail-on-typing-regression" + if bool(getattr(args, "fail_on_typing_regression", False)) + else None, + "fail-on-docstring-regression" + if bool(getattr(args, "fail_on_docstring_regression", False)) + else None, + "fail-on-api-break" + if bool(getattr(args, "fail_on_api_break", False)) + else None, + "fail-on-untested-hotspots" + if bool(getattr(args, "fail_on_untested_hotspots", False)) + else None, + f"min-typing-coverage={getattr(args, 'min_typing_coverage', -1)}" + if int(getattr(args, "min_typing_coverage", -1)) >= 0 + else None, + f"min-docstring-coverage={getattr(args, 'min_docstring_coverage', -1)}" + if int(getattr(args, "min_docstring_coverage", -1)) >= 0 + else None, + f"coverage-min={getattr(args, 'coverage_min', -1)}" + if bool(getattr(args, "fail_on_untested_hotspots", False)) else None, - "fail-cycles" if args.fail_cycles else None, - "fail-dead-code" if args.fail_dead_code else None, - f"fail-health={args.fail_health}" if args.fail_health >= 0 else None, ) case "new-clones": - parts = ("fail-on-new" if args.fail_on_new else None,) + parts = ( + "fail-on-new" if bool(getattr(args, "fail_on_new", False)) else None, + ) case "threshold": parts = ( - f"fail-threshold={args.fail_threshold}" - if args.fail_threshold >= 0 + f"fail-threshold={getattr(args, 'fail_threshold', -1)}" + if int(getattr(args, "fail_threshold", -1)) >= 0 else None, ) case _: diff --git a/codeclone/_cli_meta.py b/codeclone/_cli_meta.py index f112d8d..ffa9245 100644 --- a/codeclone/_cli_meta.py +++ b/codeclone/_cli_meta.py @@ -23,6 +23,15 @@ from .metrics_baseline import MetricsBaseline +class AnalysisProfileMeta(TypedDict): + min_loc: int + min_stmt: int + block_min_loc: int + block_min_stmt: int + segment_min_loc: int + segment_min_stmt: int + + def _current_python_version() -> str: return f"{sys.version_info.major}.{sys.version_info.minor}" @@ -75,6 +84,7 @@ class ReportMeta(TypedDict): health_grade: str | None analysis_mode: str metrics_computed: list[str] + analysis_profile: AnalysisProfileMeta design_complexity_threshold: int design_coupling_threshold: int design_cohesion_threshold: int @@ -103,6 +113,12 @@ def _build_report_meta( health_grade: str | None, analysis_mode: str, metrics_computed: tuple[str, ...], + min_loc: int, + min_stmt: int, + block_min_loc: int, + block_min_stmt: int, + segment_min_loc: int, + segment_min_stmt: int, design_complexity_threshold: int = DEFAULT_REPORT_DESIGN_COMPLEXITY_THRESHOLD, design_coupling_threshold: int = DEFAULT_REPORT_DESIGN_COUPLING_THRESHOLD, design_cohesion_threshold: int = DEFAULT_REPORT_DESIGN_COHESION_THRESHOLD, @@ -149,6 +165,14 @@ def _build_report_meta( "health_grade": health_grade, "analysis_mode": analysis_mode, "metrics_computed": list(metrics_computed), + "analysis_profile": { + "min_loc": min_loc, + "min_stmt": min_stmt, + "block_min_loc": block_min_loc, + "block_min_stmt": block_min_stmt, + "segment_min_loc": segment_min_loc, + "segment_min_stmt": segment_min_stmt, + }, "design_complexity_threshold": design_complexity_threshold, "design_coupling_threshold": design_coupling_threshold, "design_cohesion_threshold": design_cohesion_threshold, diff --git a/codeclone/_cli_runtime.py b/codeclone/_cli_runtime.py index 616057b..28ca869 100644 --- a/codeclone/_cli_runtime.py +++ b/codeclone/_cli_runtime.py @@ -26,6 +26,7 @@ class _RuntimeArgs(Protocol): cache_path: str | None + coverage_xml: str | None max_baseline_size_mb: int max_cache_size_mb: int fail_threshold: int @@ -34,6 +35,14 @@ class _RuntimeArgs(Protocol): fail_cohesion: int fail_health: int fail_on_new_metrics: bool + fail_on_typing_regression: bool + fail_on_docstring_regression: bool + fail_on_api_break: bool + fail_on_untested_hotspots: bool + min_typing_coverage: int + min_docstring_coverage: int + coverage_min: int + api_surface: bool update_metrics_baseline: bool skip_metrics: bool fail_cycles: bool @@ -67,6 +76,12 @@ def validate_numeric_args(args: _RuntimeArgs) -> bool: or args.fail_coupling < -1 or args.fail_cohesion < -1 or args.fail_health < -1 + or args.min_typing_coverage < -1 + or args.min_typing_coverage > 100 + or args.min_docstring_coverage < -1 + or args.min_docstring_coverage > 100 + or args.coverage_min < 0 + or args.coverage_min > 100 ) ) @@ -80,7 +95,15 @@ def _metrics_flags_requested(args: _RuntimeArgs) -> bool: or args.fail_dead_code or args.fail_health >= 0 or args.fail_on_new_metrics + or args.fail_on_typing_regression + or args.fail_on_docstring_regression + or args.fail_on_api_break + or args.fail_on_untested_hotspots + or args.min_typing_coverage >= 0 + or args.min_docstring_coverage >= 0 + or args.api_surface or args.update_metrics_baseline + or bool(getattr(args, "coverage_xml", None)) ) @@ -117,6 +140,8 @@ def configure_metrics_mode( args.skip_dead_code = False if args.fail_cycles: args.skip_dependencies = False + if bool(getattr(args, "fail_on_api_break", False)): + args.api_surface = True def resolve_cache_path( @@ -155,6 +180,11 @@ def metrics_computed(args: _RuntimeArgs) -> tuple[str, ...]: computed.append("dependencies") if not args.skip_dead_code: computed.append("dead_code") + computed.append("coverage_adoption") + if bool(getattr(args, "api_surface", False)): + computed.append("api_surface") + if bool(getattr(args, "coverage_xml", None)): + computed.append("coverage_join") return tuple(computed) diff --git a/codeclone/_cli_summary.py b/codeclone/_cli_summary.py index 14de73c..5b849a8 100644 --- a/codeclone/_cli_summary.py +++ b/codeclone/_cli_summary.py @@ -30,6 +30,21 @@ class MetricsSnapshot: overloaded_modules_total: int = 0 overloaded_modules_population_status: str = "" overloaded_modules_top_score: float = 0.0 + adoption_param_permille: int | None = None + adoption_return_permille: int | None = None + adoption_docstring_permille: int | None = None + adoption_any_annotation_count: int = 0 + api_surface_enabled: bool = False + api_surface_modules: int = 0 + api_surface_public_symbols: int = 0 + api_surface_added: int = 0 + api_surface_breaking: int = 0 + coverage_join_status: str = "" + coverage_join_overall_permille: int = 0 + coverage_join_coverage_hotspots: int = 0 + coverage_join_scope_gap_hotspots: int = 0 + coverage_join_threshold_percent: int = 0 + coverage_join_source_label: str = "" @dataclass(frozen=True, slots=True) @@ -59,6 +74,7 @@ def _print_summary( func_clones_count: int, block_clones_count: int, segment_clones_count: int, + suppressed_golden_fixture_groups: int, suppressed_segment_groups: int, new_clones_count: int, ) -> None: @@ -79,6 +95,7 @@ def _print_summary( block=block_clones_count, segment=segment_clones_count, suppressed=suppressed_segment_groups, + fixture_excluded=suppressed_golden_fixture_groups, new=new_clones_count, ) ) @@ -109,6 +126,7 @@ def _print_summary( block=block_clones_count, segment=segment_clones_count, suppressed=suppressed_segment_groups, + fixture_excluded=suppressed_golden_fixture_groups, new=new_clones_count, ) ) @@ -139,6 +157,39 @@ def _print_metrics( overloaded_modules=metrics.overloaded_modules_candidates, ) ) + if ( + metrics.adoption_param_permille is not None + and metrics.adoption_return_permille is not None + and metrics.adoption_docstring_permille is not None + ): + console.print( + ui.fmt_summary_compact_adoption( + param_permille=metrics.adoption_param_permille, + return_permille=metrics.adoption_return_permille, + docstring_permille=metrics.adoption_docstring_permille, + any_annotation_count=metrics.adoption_any_annotation_count, + ) + ) + if metrics.api_surface_enabled: + console.print( + ui.fmt_summary_compact_api_surface( + public_symbols=metrics.api_surface_public_symbols, + modules=metrics.api_surface_modules, + added=metrics.api_surface_added, + breaking=metrics.api_surface_breaking, + ) + ) + if metrics.coverage_join_status: + console.print( + ui.fmt_summary_compact_coverage_join( + status=metrics.coverage_join_status, + overall_permille=metrics.coverage_join_overall_permille, + coverage_hotspots=metrics.coverage_join_coverage_hotspots, + scope_gap_hotspots=metrics.coverage_join_scope_gap_hotspots, + threshold_percent=metrics.coverage_join_threshold_percent, + source_label=metrics.coverage_join_source_label, + ) + ) else: from rich.rule import Rule @@ -165,6 +216,39 @@ def _print_metrics( suppressed=metrics.suppressed_dead_code_count, ) ) + if ( + metrics.adoption_param_permille is not None + and metrics.adoption_return_permille is not None + and metrics.adoption_docstring_permille is not None + ): + console.print( + ui.fmt_metrics_adoption( + param_permille=metrics.adoption_param_permille, + return_permille=metrics.adoption_return_permille, + docstring_permille=metrics.adoption_docstring_permille, + any_annotation_count=metrics.adoption_any_annotation_count, + ) + ) + if metrics.api_surface_enabled: + console.print( + ui.fmt_metrics_api_surface( + public_symbols=metrics.api_surface_public_symbols, + modules=metrics.api_surface_modules, + added=metrics.api_surface_added, + breaking=metrics.api_surface_breaking, + ) + ) + if metrics.coverage_join_status: + console.print( + ui.fmt_metrics_coverage_join( + status=metrics.coverage_join_status, + overall_permille=metrics.coverage_join_overall_permille, + coverage_hotspots=metrics.coverage_join_coverage_hotspots, + scope_gap_hotspots=metrics.coverage_join_scope_gap_hotspots, + threshold_percent=metrics.coverage_join_threshold_percent, + source_label=metrics.coverage_join_source_label, + ) + ) console.print( ui.fmt_metrics_overloaded_modules( candidates=metrics.overloaded_modules_candidates, diff --git a/codeclone/_git_diff.py b/codeclone/_git_diff.py new file mode 100644 index 0000000..d67f413 --- /dev/null +++ b/codeclone/_git_diff.py @@ -0,0 +1,44 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +import re +from typing import Final + +_SAFE_GIT_DIFF_REF_RE: Final[re.Pattern[str]] = re.compile( + r"^(?![-./])[A-Za-z0-9._/@{}^~+-]+$" +) + + +def validate_git_diff_ref(git_diff_ref: str) -> str: + """Validate a safe, single git revision expression for `git diff`. + + CodeClone intentionally accepts a conservative subset of git revision + syntax here: common branch names, tags, revision operators (`~`, `^`), + reflog selectors (`@{...}`), and dotted range expressions. Whitespace, + control characters, option-like prefixes, and unsupported punctuation are + rejected before any subprocess call. + """ + + if git_diff_ref != git_diff_ref.strip(): + raise ValueError( + "Invalid git diff ref " + f"{git_diff_ref!r}: surrounding whitespace is not allowed." + ) + if not git_diff_ref: + raise ValueError("Invalid git diff ref '': value must not be empty.") + if any(ch.isspace() or ord(ch) < 32 or ord(ch) == 127 for ch in git_diff_ref): + raise ValueError( + "Invalid git diff ref " + f"{git_diff_ref!r}: whitespace and control characters are not allowed." + ) + if not _SAFE_GIT_DIFF_REF_RE.fullmatch(git_diff_ref): + raise ValueError( + "Invalid git diff ref " + f"{git_diff_ref!r}: expected a safe revision expression." + ) + return git_diff_ref diff --git a/codeclone/_html_badges.py b/codeclone/_html_badges.py index 2ea9ee4..716d1ad 100644 --- a/codeclone/_html_badges.py +++ b/codeclone/_html_badges.py @@ -34,12 +34,16 @@ __all__ = [ "CHECK_CIRCLE_SVG", + "INFO_CIRCLE_SVG", + "_inline_empty", + "_micro_badges", "_quality_badge_html", "_render_chain_flow", "_short_label", "_source_kind_badge_html", "_stat_card", "_tab_empty", + "_tab_empty_info", ] _EFFORT_CSS: dict[str, str] = { @@ -57,6 +61,27 @@ "" ) +INFO_CIRCLE_SVG = ( + '' + '' + '' + '' + "" +) + + +def _micro_badges(*pairs: tuple[str, object]) -> str: + """Render compact label:value micro-badge pairs for stat card details.""" + return "".join( + f'' + f'{_escape_html(str(value))}' + f'{_escape_html(label)}' + for label, value in pairs + if value is not None and str(value) != "n/a" + ) + def _quality_badge_html(text: str) -> str: """Render a risk / severity / effort value as a styled badge.""" @@ -85,14 +110,86 @@ def _source_kind_badge_html(source_kind: str) -> str: ) -def _tab_empty(message: str) -> str: +_INLINE_EMPTY_ICONS: dict[str, str] = { + "good": ( + '' + ), + "neutral": ( + '' + ), +} + + +def _inline_empty(message: str, *, tone: str = "neutral") -> str: + """Compact single-row empty-state for inline/card contexts. + + Use for summary items, breakdown panels, and other small cards where a + full ``.tab-empty`` would be too heavy. + + *tone*: + - ``"good"`` — green check (positive: "nothing to report"). + - ``"neutral"`` — muted info dot (missing or unavailable data). + """ + tone_key = tone if tone in _INLINE_EMPTY_ICONS else "neutral" + icon = _INLINE_EMPTY_ICONS[tone_key] + return ( + f'
' + f"{icon}" + f'{_escape_html(message)}' + "
" + ) + + +def _tab_empty( + message: str, + *, + description: str | None = "Nothing to report - keep up the good work.", +) -> str: + desc_html = ( + f'
{_escape_html(description)}
' + if description + else "" + ) return ( '
' f"{CHECK_CIRCLE_SVG}" f'
{_escape_html(message)}
' - '
' - "Nothing to report - keep up the good work." + f"{desc_html}" "
" + ) + + +def _tab_empty_info( + message: str, + *, + description: str | None = None, + detail_html: str | None = None, +) -> str: + if detail_html: + desc_block = ( + f'
{detail_html}
' + ) + elif description: + desc_block = ( + f'
' + f"{_escape_html(description)}
" + ) + else: + desc_block = "" + return ( + '
' + f"{INFO_CIRCLE_SVG}" + f'
{_escape_html(message)}
' + f"{desc_block}" "
" ) diff --git a/codeclone/_html_css.py b/codeclone/_html_css.py index 64c7325..66a4609 100644 --- a/codeclone/_html_css.py +++ b/codeclone/_html_css.py @@ -14,40 +14,46 @@ _TOKENS_DARK = """\ :root{ - --font-sans:Inter,-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,sans-serif; + --font-sans:"Inter","Inter Variable",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Oxygen,Ubuntu,sans-serif; + --font-display:"Inter","Inter Variable",-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif; --font-mono:"JetBrains Mono",ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; - - /* surface — slate scale */ - --bg-body:#0f1117; - --bg-surface:#161822; - --bg-raised:#1c1f2e; - --bg-overlay:#232639; - --bg-subtle:#2a2d42; - - /* border */ - --border:#2e3248; - --border-strong:#3d4160; - - /* text */ - --text-primary:#e2e4ed; - --text-secondary:#a0a3b8; - --text-muted:#6b6f88; - - /* accent — indigo */ + --font-numeric:"JetBrains Mono",ui-monospace,SFMono-Regular,"SF Mono",Menlo,Consolas,monospace; + + /* Surface — chromatic grays tinted toward the indigo accent (hue 275). + Every surface shares the brand hue at tiny chroma so the UI feels like + one palette, not gray slate + a purple sticker. */ + --bg-body:oklch(16% 0.018 275); + --bg-surface:oklch(20% 0.022 275); + --bg-raised:oklch(24% 0.028 275); + --bg-overlay:oklch(29% 0.033 275); + --bg-subtle:oklch(34% 0.038 275); + + /* Border — same hue, higher chroma for legibility */ + --border:oklch(32% 0.035 275); + --border-strong:oklch(44% 0.045 275); + + /* Text — muted greys keep a trace of indigo so they feel alive */ + --text-primary:oklch(95% 0.010 275); + --text-secondary:oklch(74% 0.028 275); + --text-muted:oklch(58% 0.038 275); + + /* Accent — codeclone indigo (brand, unchanged) */ --accent-primary:#6366f1; --accent-hover:#818cf8; --accent-muted:color-mix(in oklch,#6366f1 25%,transparent); - - /* semantic */ - --success:#34d399; - --success-muted:color-mix(in oklch,#34d399 15%,transparent); - --warning:#fbbf24; - --warning-muted:color-mix(in oklch,#fbbf24 15%,transparent); - --error:#f87171; - --error-muted:color-mix(in oklch,#f87171 15%,transparent); - --danger:#f87171; - --info:#60a5fa; - --info-muted:color-mix(in oklch,#60a5fa 15%,transparent); + --accent-soft:oklch(30% 0.12 275); + + /* Semantic — brand-adjacent, hue-rotated so they read as siblings + of the indigo instead of raw Tailwind defaults */ + --success:oklch(74% 0.15 162); + --success-muted:color-mix(in oklch,oklch(74% 0.15 162) 18%,transparent); + --warning:oklch(80% 0.15 82); + --warning-muted:color-mix(in oklch,oklch(80% 0.15 82) 18%,transparent); + --error:oklch(70% 0.18 18); + --error-muted:color-mix(in oklch,oklch(70% 0.18 18) 18%,transparent); + --danger:oklch(70% 0.18 18); + --info:oklch(72% 0.13 238); + --info-muted:color-mix(in oklch,oklch(72% 0.13 238) 18%,transparent); /* elevation */ --shadow-sm:0 1px 2px rgba(0,0,0,.25); @@ -79,30 +85,37 @@ """ _TOKENS_LIGHT = """\ +/* Light palette — mirror of the dark one at higher lightness + lower chroma. + Every surface/border/text token still carries a trace of indigo hue 275 + so the whole theme feels like one family in both modes. */ @media(prefers-color-scheme:light){ :root:not([data-theme]){ - --bg-body:#f8f9fc;--bg-surface:#ffffff;--bg-raised:#f1f3f8;--bg-overlay:#e8eaf2;--bg-subtle:#dde0eb; - --border:#d4d7e3;--border-strong:#b8bdd0; - --text-primary:#1a1d2e;--text-secondary:#4b5068;--text-muted:#8589a0; + --bg-body:oklch(98.5% 0.006 275);--bg-surface:#ffffff; + --bg-raised:oklch(97% 0.010 275);--bg-overlay:oklch(93% 0.015 275);--bg-subtle:oklch(88% 0.020 275); + --border:oklch(88% 0.020 275);--border-strong:oklch(78% 0.028 275); + --text-primary:oklch(22% 0.040 275);--text-secondary:oklch(42% 0.048 275);--text-muted:oklch(58% 0.040 275); --accent-primary:#4f46e5;--accent-hover:#6366f1;--accent-muted:color-mix(in oklch,#4f46e5 12%,transparent); - --success:#059669;--success-muted:color-mix(in oklch,#059669 10%,transparent); - --warning:#d97706;--warning-muted:color-mix(in oklch,#d97706 10%,transparent); - --error:#dc2626;--error-muted:color-mix(in oklch,#dc2626 10%,transparent); - --danger:#dc2626;--info:#2563eb;--info-muted:color-mix(in oklch,#2563eb 10%,transparent); + --accent-soft:oklch(94% 0.045 275); + --success:oklch(52% 0.16 162);--success-muted:color-mix(in oklch,oklch(52% 0.16 162) 12%,transparent); + --warning:oklch(60% 0.15 65);--warning-muted:color-mix(in oklch,oklch(60% 0.15 65) 12%,transparent); + --error:oklch(55% 0.22 20);--error-muted:color-mix(in oklch,oklch(55% 0.22 20) 12%,transparent); + --danger:oklch(55% 0.22 20);--info:oklch(52% 0.18 238);--info-muted:color-mix(in oklch,oklch(52% 0.18 238) 12%,transparent); --shadow-sm:0 1px 2px rgba(0,0,0,.06);--shadow-md:0 2px 8px rgba(0,0,0,.08); --shadow-lg:0 4px 16px rgba(0,0,0,.1);--shadow-xl:0 8px 32px rgba(0,0,0,.12); color-scheme:light; } } [data-theme="light"]{ - --bg-body:#f8f9fc;--bg-surface:#ffffff;--bg-raised:#f1f3f8;--bg-overlay:#e8eaf2;--bg-subtle:#dde0eb; - --border:#d4d7e3;--border-strong:#b8bdd0; - --text-primary:#1a1d2e;--text-secondary:#4b5068;--text-muted:#8589a0; + --bg-body:oklch(98.5% 0.006 275);--bg-surface:#ffffff; + --bg-raised:oklch(97% 0.010 275);--bg-overlay:oklch(93% 0.015 275);--bg-subtle:oklch(88% 0.020 275); + --border:oklch(88% 0.020 275);--border-strong:oklch(78% 0.028 275); + --text-primary:oklch(22% 0.040 275);--text-secondary:oklch(42% 0.048 275);--text-muted:oklch(58% 0.040 275); --accent-primary:#4f46e5;--accent-hover:#6366f1;--accent-muted:color-mix(in oklch,#4f46e5 12%,transparent); - --success:#059669;--success-muted:color-mix(in oklch,#059669 10%,transparent); - --warning:#d97706;--warning-muted:color-mix(in oklch,#d97706 10%,transparent); - --error:#dc2626;--error-muted:color-mix(in oklch,#dc2626 10%,transparent); - --danger:#dc2626;--info:#2563eb;--info-muted:color-mix(in oklch,#2563eb 10%,transparent); + --accent-soft:oklch(94% 0.045 275); + --success:oklch(52% 0.16 162);--success-muted:color-mix(in oklch,oklch(52% 0.16 162) 12%,transparent); + --warning:oklch(60% 0.15 65);--warning-muted:color-mix(in oklch,oklch(60% 0.15 65) 12%,transparent); + --error:oklch(55% 0.22 20);--error-muted:color-mix(in oklch,oklch(55% 0.22 20) 12%,transparent); + --danger:oklch(55% 0.22 20);--info:oklch(52% 0.18 238);--info-muted:color-mix(in oklch,oklch(52% 0.18 238) 12%,transparent); --shadow-sm:0 1px 2px rgba(0,0,0,.06);--shadow-md:0 2px 8px rgba(0,0,0,.08); --shadow-lg:0 4px 16px rgba(0,0,0,.1);--shadow-xl:0 8px 32px rgba(0,0,0,.12); color-scheme:light; @@ -118,11 +131,21 @@ html{-webkit-text-size-adjust:100%;text-size-adjust:100%;-webkit-font-smoothing:antialiased; -moz-osx-font-smoothing:grayscale;scroll-behavior:smooth;scrollbar-gutter:stable} body{font-family:var(--font-sans);font-size:14px;line-height:1.6;color:var(--text-primary); - background:var(--bg-body);overflow-x:hidden} -code,pre,kbd{font-family:var(--font-mono);font-size:13px} + background:var(--bg-body);overflow-x:hidden; + /* Inter stylistic alternates: + zero — slashed zero (disambiguates 0 from O in metric values) + ss02 — disambiguation set (I/l/1/0 clear apart) + ss01 — open digits (open 4, 6, 9) + cv11 — single-story a (the "designer" look) + cv02/03/04 — open alternates for 4/6/3 + Together these give Inter its Vercel / Linear / Radix feel. */ + font-feature-settings:"zero","ss02","ss01","cv11","cv02","cv03","cv04"; + font-optical-sizing:auto} +code,pre,kbd{font-family:var(--font-mono);font-size:13px;font-feature-settings:normal} a{color:var(--accent-primary);text-decoration:none} a:hover{color:var(--accent-hover);text-decoration:underline} -h1,h2,h3,h4{font-weight:600;line-height:1.3;color:var(--text-primary)} +h1,h2,h3,h4{font-family:var(--font-display);font-weight:600;line-height:1.3;color:var(--text-primary); + letter-spacing:-0.01em} h1{font-size:1.5rem}h2{font-size:1.25rem}h3{font-size:1.1rem} ul,ol{list-style:none} button,input,select{font:inherit;color:inherit} @@ -162,6 +185,10 @@ font-weight:500;font-family:inherit;transition:all var(--dur-fast) var(--ease)} .theme-toggle:hover{color:var(--text-primary);background:var(--bg-raised);border-color:var(--border-strong)} .theme-toggle svg{width:16px;height:16px} +/* Adaptive icon: show sun in light mode, moon in dark mode. */ +.theme-icon{display:none} +:root[data-theme="light"] .theme-icon-sun{display:inline-flex} +:root[data-theme="dark"] .theme-icon-moon{display:inline-flex} /* Main tabs — full-width pill bar */ .main-tabs-wrap{position:sticky;top:var(--topbar-h);z-index:90;padding:var(--sp-3) 0 0; @@ -206,8 +233,29 @@ .btn-prov .prov-dot.dot-amber{background:var(--warning)} .btn-prov .prov-dot.dot-red{background:var(--error)} .btn-prov .prov-dot.dot-neutral{background:var(--text-muted)} + +/* Provenance pill — single compact status chip in topbar */ +.prov-pill{display:inline-flex;align-items:center;gap:var(--sp-1); + padding:var(--sp-1) var(--sp-3);background:none; + border:1px solid var(--border);border-radius:var(--radius-md);cursor:pointer; + color:var(--text-muted);transition:all var(--dur-fast) var(--ease); + font-size:.85rem;font-weight:500;font-family:inherit} +.prov-pill:hover{background:var(--bg-raised);border-color:var(--border-strong); + color:var(--text-primary)} +.prov-pill:focus-visible{outline:2px solid var(--accent-primary);outline-offset:1px} +.prov-pill-icon{flex-shrink:0;opacity:.75} +.prov-pill:hover .prov-pill-icon{opacity:1} +.prov-pill-label{font-size:.85rem;font-weight:500;white-space:nowrap} +.prov-pill--green .prov-pill-icon{color:var(--success)} +.prov-pill--green:hover{border-color:color-mix(in srgb,var(--success) 55%,var(--border))} +.prov-pill--amber .prov-pill-icon{color:var(--warning)} +.prov-pill--amber:hover{border-color:color-mix(in srgb,var(--warning) 55%,var(--border))} +.prov-pill--red .prov-pill-icon{color:var(--error)} +.prov-pill--red:hover{border-color:color-mix(in srgb,var(--error) 60%,var(--border))} +.prov-pill--neutral .prov-pill-icon{color:var(--text-muted)} .btn.ghost{background:none;border-color:transparent} .btn.ghost:hover{background:var(--bg-raised);border-color:var(--border)} +.btn.btn-icon{padding:var(--sp-1);min-width:28px;justify-content:center} .btn svg{width:14px;height:14px} /* Inputs */ @@ -263,11 +311,38 @@ .pagination{display:flex;align-items:center;gap:var(--sp-1)} .page-meta{font-size:.8rem;color:var(--text-muted);white-space:nowrap;min-width:100px;text-align:center} -/* Suggestions toolbar */ -.suggestions-toolbar{flex-direction:column;align-items:stretch} -.suggestions-toolbar-row{display:flex;flex-wrap:wrap;align-items:center;gap:var(--sp-2)} -.suggestions-toolbar-row--secondary{padding-top:var(--sp-2);border-top:1px solid var(--border)} -.suggestions-count-label{margin-left:auto;font-size:.8rem;color:var(--text-muted);font-weight:500} +/* Filters popover: one button collapses Context/Type/Spread/min-occ into a menu */ +.filters-popover{position:relative} +.filters-btn{display:inline-flex;align-items:center;gap:var(--sp-1);white-space:nowrap} +.filters-btn-ico{flex:none} +.filters-count{display:inline-flex;align-items:center;justify-content:center; + min-width:18px;height:18px;padding:0 5px;border-radius:999px; + background:var(--accent-primary);color:#fff;font-size:.68rem;font-weight:600; + line-height:1} +.filters-btn[aria-expanded="true"]{border-color:var(--accent-primary); + color:var(--accent-primary)} +.filters-menu{position:absolute;top:calc(100% + var(--sp-1));left:0;z-index:20; + min-width:240px;display:flex;flex-direction:column;gap:var(--sp-2); + padding:var(--sp-3);background:var(--bg-surface); + border:1px solid var(--border);border-radius:var(--radius-md); + box-shadow:var(--shadow-lg)} +.filters-menu[hidden]{display:none} +.filters-row{display:flex;align-items:center;gap:var(--sp-2)} +.filters-row .select{flex:1;min-width:0} +.filters-label{font-size:.75rem;color:var(--text-muted);min-width:60px; + font-weight:500} +.filters-row.inline-check{gap:var(--sp-2);font-size:.82rem;color:var(--text-secondary); + cursor:pointer} +.filters-row.inline-check input[type="checkbox"]{margin:0} + +/* Expand/collapse toggle — single button that flips state */ +.expand-toggle{white-space:nowrap} +.expand-toggle[data-expanded="true"]{background:var(--bg-overlay); + border-color:var(--border-strong)} + +/* Suggestions count pill (right side of the shared toolbar). */ +.suggestions-count-label{font-size:.8rem;color:var(--text-muted);font-weight:500; + font-variant-numeric:tabular-nums;white-space:nowrap} """ # --------------------------------------------------------------------------- @@ -277,9 +352,9 @@ _INSIGHT = """\ .insight-banner{padding:var(--sp-3) var(--sp-4);border-radius:var(--radius-md); margin-bottom:var(--sp-4);border-left:3px solid var(--border);background:none} -.insight-question{font-size:.72rem;font-weight:500;color:var(--text-muted); - text-transform:uppercase;letter-spacing:.04em;margin-bottom:2px} -.insight-answer{font-size:.82rem;color:var(--text-muted);line-height:1.5} +.insight-question{font-size:.78rem;font-weight:500;color:var(--text-muted); + text-transform:uppercase;letter-spacing:.03em;margin-bottom:2px} +.insight-answer{font-size:.82rem;color:var(--text-secondary);line-height:1.5} .insight-ok{border-left-color:var(--success);background:var(--success-muted)} .insight-warn{border-left-color:var(--warning);background:var(--warning-muted)} @@ -313,7 +388,8 @@ vertical-align:top} .table tr:last-child td{border-bottom:none} .table tr:hover td{background:var(--bg-raised)} -.table .col-name{font-weight:500;color:var(--text-primary)} +.table .col-name{font-weight:500;color:var(--text-primary);max-width:360px;overflow:hidden; + text-overflow:ellipsis;white-space:nowrap} .table .col-file,.table .col-path{color:var(--text-muted);max-width:240px;overflow:hidden; text-overflow:ellipsis;white-space:nowrap} .table .col-number,.table .col-num{font-variant-numeric:tabular-nums;text-align:right;white-space:nowrap} @@ -471,10 +547,10 @@ .overview-kpi-cards{display:grid;grid-template-columns:repeat(4,minmax(0,1fr)); gap:var(--sp-3);min-width:0} .overview-kpi-grid--with-health .meta-item{min-width:0} -.overview-kpi-grid--with-health .meta-item{min-height:108px} +.overview-kpi-grid--with-health .meta-item{min-height:0} .overview-kpi-cards .meta-item{display:grid;grid-template-rows:auto 1fr auto; - align-items:start;padding:var(--sp-3) var(--sp-4);gap:var(--sp-2);min-height:122px} -.overview-kpi-cards .meta-item .meta-label{font-size:.72rem;min-height:18px} + align-items:start;padding:var(--sp-3) var(--sp-4);gap:var(--sp-2);min-height:0} +.overview-kpi-cards .meta-item .meta-label{font-size:.75rem;min-height:18px} .overview-kpi-cards .meta-item .meta-value{display:flex;align-items:center; font-size:1.55rem;line-height:1;padding:var(--sp-1) 0} .overview-kpi-cards .kpi-detail{margin-top:0;gap:4px;align-self:end} @@ -515,8 +591,9 @@ transition:stroke-dashoffset 1s var(--ease)} .health-ring-label{position:absolute;inset:0;display:flex;flex-direction:column; align-items:center;justify-content:center} -.health-ring-score{font-size:1.75rem;font-weight:700;color:var(--text-primary); - font-variant-numeric:tabular-nums;line-height:1} +.health-ring-score{font-family:var(--font-numeric);font-size:1.85rem;font-weight:680; + color:var(--text-primary);font-variant-numeric:tabular-nums;line-height:1; + letter-spacing:-0.018em} .health-ring-grade{font-size:.72rem;font-weight:500;color:var(--text-muted);margin-top:3px} .health-ring-delta{font-size:.65rem;font-weight:600;margin-top:3px} .health-ring-delta--up{color:var(--success)} @@ -568,23 +645,27 @@ .badge-copy-btn--ok{color:var(--success)} /* KPI stat card */ -.meta-item{padding:var(--sp-2) var(--sp-3);background:var(--bg-surface);border:1px solid var(--border); - border-radius:var(--radius-md);display:flex;flex-direction:column;gap:2px; - transition:border-color var(--dur-fast) var(--ease);min-width:0} +.meta-item{padding:var(--sp-3) var(--sp-4);background:var(--bg-surface);border:1px solid var(--border); + border-radius:var(--radius-lg);display:flex;flex-direction:column;gap:var(--sp-1); + transition:border-color var(--dur-fast) var(--ease);min-width:0; + font-family:var(--font-mono)} .meta-item:hover{border-color:var(--border-strong)} -.meta-item .meta-label{font-size:.68rem;font-weight:500;color:var(--text-muted); - display:flex;align-items:center;gap:var(--sp-1)} -.meta-item .meta-value{font-size:1.35rem;font-weight:700;color:var(--text-primary); - font-variant-numeric:tabular-nums;line-height:1.2} +.meta-item .meta-label{font-size:.75rem;font-weight:500;color:var(--text-muted); + display:flex;align-items:center;gap:var(--sp-1);letter-spacing:.01em;line-height:1.35} +.meta-item .meta-value{font-family:var(--font-numeric);font-size:1.4rem;font-weight:680; + color:var(--text-primary);font-variant-numeric:tabular-nums;line-height:1.15; + letter-spacing:-0.01em} .meta-item .meta-value--good{color:var(--success)} .meta-item .meta-value--bad{color:var(--error)} .meta-item .meta-value--warn{color:var(--warning)} .meta-item .meta-value--muted{color:var(--text-muted)} .kpi-detail{display:flex;flex-wrap:wrap;gap:3px;margin-top:2px} -.kpi-micro{display:inline-flex;align-items:center;gap:2px;font-size:.62rem; +.kpi-detail code{font-size:.78rem} +.kpi-micro{display:inline-flex;align-items:center;gap:3px;font-size:.62rem; padding:1px 5px;border-radius:var(--radius-sm);background:var(--bg-raised); - white-space:nowrap;line-height:1.3} -.kpi-micro-val{font-weight:500;font-variant-numeric:tabular-nums;color:var(--text-muted)} + white-space:nowrap;line-height:1.3;font-family:inherit} +.kpi-micro-val{font-family:inherit;font-weight:500;font-variant-numeric:tabular-nums; + color:var(--text-muted)} .kpi-micro-lbl{font-weight:400;color:var(--text-muted);text-transform:lowercase} .kpi-micro--baselined{color:var(--success);font-weight:500;font-size:.6rem} .kpi-delta{font-size:.62rem;font-weight:700;margin-left:auto; @@ -597,12 +678,11 @@ color:var(--text-muted);cursor:help;position:relative;border:1.5px solid var(--border); opacity:.5;transition:opacity var(--dur-fast) var(--ease)} .kpi-help:hover{opacity:1} -.kpi-help:hover::after{content:attr(data-tip);position:absolute;top:calc(100% + 6px);left:50%; - transform:translateX(-50%);background:var(--bg-overlay);color:var(--text-primary); +.kpi-tooltip{position:fixed;z-index:9999;pointer-events:none; + background:var(--bg-overlay);color:var(--text-primary); padding:var(--sp-2) var(--sp-3);border-radius:var(--radius-md);font-size:.75rem;font-weight:400; white-space:normal;width:max-content;max-width:240px;line-height:1.4; - box-shadow:var(--shadow-md);z-index:100;pointer-events:none; - border:1px solid var(--border)} + box-shadow:var(--shadow-md);border:1px solid var(--border)} /* Tone variants */ .meta-item.tone-ok{border-left:3px solid var(--success)} @@ -610,8 +690,8 @@ .meta-item.tone-risk{border-left:3px solid var(--error)} /* Clusters */ -.overview-cluster{margin-bottom:var(--sp-4)} -.overview-cluster-header{margin-bottom:var(--sp-2)} +.overview-cluster{margin-bottom:var(--sp-5)} +.overview-cluster-header{margin-bottom:var(--sp-3)} .overview-cluster-copy{font-size:.82rem;color:var(--text-muted);margin-top:2px} .overview-cluster-empty{display:flex;flex-direction:column;align-items:center;gap:var(--sp-2); padding:var(--sp-5);text-align:center;color:var(--text-muted);font-size:.85rem} @@ -636,12 +716,16 @@ .overview-summary-grid{display:grid;gap:var(--sp-3);margin-bottom:var(--sp-3)} .overview-summary-grid--2col{grid-template-columns:repeat(auto-fit,minmax(280px,1fr))} .overview-summary-grid--3col{grid-template-columns:repeat(auto-fit,minmax(240px,1fr))} -.overview-summary-item{background:var(--bg-surface);border:1px solid var(--border); +.overview-summary-item{background:var(--bg-surface); + border:1px solid color-mix(in srgb,var(--border) 78%,transparent); border-radius:var(--radius-lg);padding:var(--sp-4)} .overview-summary-label{display:flex;align-items:center;gap:var(--sp-2); - font-size:.72rem;font-weight:700;text-transform:uppercase; - letter-spacing:.06em;color:var(--text-muted);margin-bottom:var(--sp-3); - padding-bottom:var(--sp-2);border-bottom:1px solid var(--border)} + font-size:.82rem;font-weight:700;text-transform:none; + letter-spacing:normal;color:var(--text-secondary);margin-bottom:var(--sp-3); + padding-bottom:var(--sp-2); + border-bottom:1px solid color-mix(in srgb,var(--border) 58%,transparent); + font-family:var(--font-display)} +.overview-summary-item > :not(.overview-summary-label){font-family:var(--font-mono)} .summary-icon{flex-shrink:0;opacity:.6} .summary-icon--risk{color:var(--warning)} .summary-icon--info{color:var(--accent-primary)} @@ -650,13 +734,32 @@ padding-left:var(--sp-3);position:relative;line-height:1.5} .overview-summary-list li::before{content:"\\2022";position:absolute;left:0;color:var(--text-muted)} .overview-summary-value{font-size:.85rem;color:var(--text-muted)} -.overview-fact-list{display:flex;flex-direction:column;gap:var(--sp-2);margin-top:var(--sp-3)} +/* Compact stat grid used inside overview-summary-item cards (Coverage Join). */ +.overview-stat-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(84px,1fr)); + gap:var(--sp-3);align-items:end} +.overview-stat{display:flex;flex-direction:column;gap:2px;min-width:0} +.overview-stat-value{font-family:var(--font-numeric);font-size:1.4rem;font-weight:680; + color:var(--text-primary);font-variant-numeric:tabular-nums;line-height:1.12; + letter-spacing:-0.01em} +.overview-stat-label{font-size:.68rem;font-weight:500;color:var(--text-muted); + text-transform:uppercase;letter-spacing:.04em} +.overview-stat-caption{margin-top:var(--sp-3);font-size:.72rem;color:var(--text-muted); + line-height:1.4} +.overview-stat-caption code{font-family:var(--font-mono);font-size:.68rem; + padding:1px 4px;border-radius:var(--radius-sm);background:var(--bg-raised); + color:var(--text-secondary)} +.overview-stat-row + .kpi-detail{margin-top:var(--sp-2)} +/* Fact-list: compact label ··· value rows inside overview-summary-item cards. */ +.overview-fact-list{display:flex;flex-direction:column;gap:var(--sp-2)} .overview-fact-row{display:flex;align-items:baseline;justify-content:space-between;gap:var(--sp-3); - font-size:.76rem;border-bottom:1px solid color-mix(in srgb,var(--border) 45%,transparent);padding-bottom:6px} -.overview-fact-row:last-child{border-bottom:none;padding-bottom:0} + font-size:.8rem;padding-bottom:6px;border-bottom:1px solid var(--border)} +.overview-fact-row:last-child{padding-bottom:0;border-bottom:none} .overview-fact-label{color:var(--text-muted)} -.overview-fact-value{color:var(--text-secondary);font-weight:600;font-variant-numeric:tabular-nums; - text-align:right} +.overview-fact-value{display:inline-flex;align-items:baseline;gap:6px; + color:var(--text-primary);font-weight:600;font-variant-numeric:tabular-nums;text-align:right} +.overview-fact-delta{font-size:.68rem;font-weight:400;color:var(--text-muted)} +.overview-fact-value--warn{color:var(--warning)} +.overview-fact-value--good{color:var(--success)} /* Source breakdown bars */ .breakdown-list{display:flex;flex-direction:column;gap:var(--sp-2)} .breakdown-row{display:grid;grid-template-columns:6.5rem 2rem 1fr;align-items:center;gap:var(--sp-2)} @@ -706,19 +809,22 @@ /* Health radar chart */ .health-radar{display:flex;justify-content:center;padding:var(--sp-3) 0} .health-radar svg{width:100%;max-width:520px;height:auto;overflow:visible} -.health-radar text{font-size:9px;font-family:var(--font-sans);fill:var(--text-muted)} +.health-radar text{font-size:10.5px;font-family:var(--font-mono);fill:var(--text-secondary); + font-weight:500} .health-radar .radar-score{font-weight:600;font-variant-numeric:tabular-nums;fill:var(--text-secondary)} .health-radar .radar-label--weak{fill:var(--error)} .health-radar .radar-label--weak .radar-score{fill:var(--error)} +.health-radar-legend{font-size:.78rem;color:var(--text-secondary);text-align:center; + margin-top:var(--sp-2);max-width:520px;margin-left:auto;margin-right:auto} /* Findings by family bars */ .families-list{display:flex;flex-direction:column;gap:var(--sp-2)} .families-row{display:grid;grid-template-columns:5.5rem 2rem 1fr auto;align-items:center;gap:var(--sp-2)} -.families-row--muted{opacity:.55} +.families-row--muted{opacity:.65} .families-label{font-size:.75rem;font-weight:500;color:var(--text-secondary);text-align:right} .families-count{font-size:.8rem;font-weight:600;font-variant-numeric:tabular-nums; color:var(--text-primary);text-align:right} .breakdown-bar-track{display:flex} -.breakdown-bar-fill--baselined{opacity:.35} +.breakdown-bar-fill--baselined{opacity:.5} .breakdown-bar-fill--new{border-radius:0 3px 3px 0} .families-delta{font-size:.65rem;font-weight:600;font-variant-numeric:tabular-nums;white-space:nowrap} .families-delta--ok{color:var(--success)} @@ -730,12 +836,12 @@ # --------------------------------------------------------------------------- _DEPENDENCIES = """\ -.dep-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); +.stat-cards,.dep-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(160px,1fr)); gap:var(--sp-2);margin-bottom:var(--sp-4)} -.dep-stats .meta-item{display:grid;grid-template-rows:auto 1fr auto;min-height:100px} -.dep-stats .meta-item .meta-label{font-size:.72rem;min-height:18px} -.dep-stats .meta-item .meta-value{display:flex;align-items:center} -.dep-stats .kpi-detail{margin-top:0;align-self:end} +.stat-cards .meta-item,.dep-stats .meta-item{display:grid;grid-template-rows:auto 1fr auto;min-height:100px} +.stat-cards .meta-item .meta-label,.dep-stats .meta-item .meta-label{font-size:.72rem;min-height:18px} +.stat-cards .meta-item .meta-value,.dep-stats .meta-item .meta-value{display:flex;align-items:center} +.stat-cards .kpi-detail,.dep-stats .kpi-detail{margin-top:0;align-self:end} .dep-graph-wrap{overflow:hidden;margin-bottom:var(--sp-4);border:1px solid var(--border); border-radius:var(--radius-lg);background:var(--bg-surface);padding:var(--sp-4)} .dep-graph-svg{width:100%;height:auto;max-height:520px} @@ -774,11 +880,12 @@ # --------------------------------------------------------------------------- _NOVELTY = """\ -.global-novelty{margin-bottom:var(--sp-4);padding:var(--sp-4) var(--sp-5); - background:var(--bg-raised);border:1px solid var(--border);border-radius:var(--radius-lg)} -.global-novelty-head{display:flex;align-items:center;gap:var(--sp-4);flex-wrap:wrap} -.global-novelty-head h2{font-size:1rem;white-space:nowrap} -.novelty-tabs{display:flex;gap:var(--sp-2)} +/* Slim inline baseline-split bar (replaces the old boxed section chrome). */ +.novelty-bar{display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap; + margin-bottom:var(--sp-3);padding:var(--sp-2) 0; + border-bottom:1px solid var(--border)} +.novelty-bar-tabs{display:inline-flex;gap:var(--sp-1)} +.novelty-bar-note{font-size:.78rem;color:var(--text-muted);line-height:1.4} .novelty-tab{transition:all var(--dur-fast) var(--ease)} .novelty-tab.active{background:var(--accent-primary);color:white;border-color:var(--accent-primary)} .novelty-tab[data-novelty-state="good"]{color:var(--success);border-color:var(--success);background:var(--success-muted)} @@ -787,7 +894,6 @@ .novelty-tab[data-novelty-state="bad"].active{background:var(--error);color:white;border-color:var(--error)} .novelty-count{font-size:.72rem;font-weight:600;background:rgba(255,255,255,.15);padding:0 var(--sp-1); border-radius:var(--radius-sm);margin-left:var(--sp-1)} -.novelty-note{font-size:.8rem;color:var(--text-muted);margin-top:var(--sp-2)} /* Hidden by novelty filter */ .group[data-novelty-hidden="true"]{display:none} @@ -962,36 +1068,37 @@ _META_PANEL = """\ /* Provenance section cards */ -.prov-section{margin-bottom:var(--sp-3);background:var(--bg-raised); - border-radius:var(--radius-md);padding:var(--sp-3) var(--sp-3) var(--sp-2); - border:1px solid color-mix(in srgb,var(--border) 50%,transparent)} +.prov-section{margin-bottom:var(--sp-3);background:var(--bg-surface); + border-radius:var(--radius-md);padding:var(--sp-3) var(--sp-4) var(--sp-2); + border:1px solid var(--border); + box-shadow:0 1px 2px color-mix(in srgb,var(--text-primary) 3%,transparent)} .prov-section:last-child{margin-bottom:0} -.prov-section-title{font-size:.65rem;font-weight:700;text-transform:uppercase;letter-spacing:.08em; - color:var(--text-muted);margin:0 0 var(--sp-2);padding:0;border:none; - display:flex;align-items:center;gap:var(--sp-1)} -.prov-section-title svg{width:12px;height:12px;opacity:.5;flex-shrink:0} +.prov-section-title{font-size:.66rem;font-weight:700;text-transform:uppercase;letter-spacing:.09em; + color:var(--text-secondary);margin:0 calc(-1*var(--sp-4)) var(--sp-2); + padding:0 var(--sp-4) var(--sp-2);border:none; + border-bottom:1px solid color-mix(in srgb,var(--border) 60%,transparent); + display:flex;align-items:center;gap:6px} +.prov-section-title svg{width:13px;height:13px;opacity:.7;flex-shrink:0; + color:var(--accent-primary)} .prov-table{width:100%;border-collapse:collapse;font-size:.8rem} -.prov-table tr:not(:last-child){border-bottom:1px solid color-mix(in srgb,var(--border) 30%,transparent)} -.prov-table tr:hover{background:color-mix(in srgb,var(--accent-primary) 4%,transparent)} -.prov-td-label{padding:5px 0;color:var(--text-muted);white-space:nowrap;width:40%; - vertical-align:top;font-weight:500;font-size:.78rem} -.prov-td-value{padding:5px 0 5px var(--sp-2);color:var(--text-primary);word-break:break-all; - font-family:var(--font-mono);font-size:.72rem} - -/* Boolean check/cross badges */ -.meta-bool{font-size:.68rem;font-weight:600;padding:2px var(--sp-2);border-radius:var(--radius-sm); - display:inline-flex;align-items:center;gap:3px} -.meta-bool-true{background:var(--success-muted);color:var(--success)} -.meta-bool-false{background:var(--error-muted);color:var(--error)} +.prov-table tr:not(:last-child){border-bottom:1px solid color-mix(in srgb,var(--border) 25%,transparent)} +.prov-table tr:hover{background:color-mix(in srgb,var(--accent-primary) 3%,transparent)} +.prov-td-label{padding:6px 0;color:var(--text-muted);white-space:nowrap;width:40%; + vertical-align:top;font-weight:500;font-size:.76rem;letter-spacing:.002em} +.prov-td-value{padding:6px 0 6px var(--sp-2);color:var(--text-primary);word-break:break-all; + font-family:var(--font-mono);font-size:.72rem;vertical-align:top} /* Provenance summary badges */ .prov-summary{display:flex;flex-wrap:wrap;align-items:center;gap:6px; padding:var(--sp-2) var(--sp-4);border-top:1px solid var(--border)} .prov-badge{display:inline-flex;align-items:center;gap:4px;font-size:.68rem; padding:2px var(--sp-2);border-radius:var(--radius-sm);background:var(--bg-raised); - white-space:nowrap;line-height:1.3;border:1px solid color-mix(in srgb,var(--border) 55%,transparent)} -.prov-badge-val{font-weight:600;font-variant-numeric:tabular-nums} + white-space:nowrap;line-height:1.3;border:1px solid color-mix(in srgb,var(--border) 55%,transparent); + font-family:var(--font-mono);letter-spacing:.005em} +.prov-badge-val{font-weight:600;font-variant-numeric:tabular-nums;color:var(--text-primary)} .prov-badge-lbl{font-weight:400;color:var(--text-muted);text-transform:lowercase} +.prov-badge--inline{padding:2px 8px} +.prov-badge--inline .prov-badge-val{font-weight:500} .prov-badge--green{background:var(--success-muted);border-color:color-mix(in srgb,var(--success) 20%,transparent)} .prov-badge--green .prov-badge-val{color:var(--success)} .prov-badge--red{background:var(--error-muted);border-color:color-mix(in srgb,var(--error) 20%,transparent)} @@ -1001,6 +1108,57 @@ .prov-badge--neutral{background:var(--bg-overlay);border-color:color-mix(in srgb,var(--border) 75%,transparent)} .prov-badge--neutral .prov-badge-val{color:var(--text-secondary)} .prov-explain{font-size:.62rem;color:var(--text-muted);margin-left:auto;font-style:italic} + +/* Truncated long values (paths, sha256) in provenance table */ +.prov-mono-trunc{font-family:var(--font-mono);font-size:.72rem;color:var(--text-primary); + background:var(--bg-body);padding:2px 6px;border-radius:var(--radius-sm); + border:1px solid color-mix(in srgb,var(--border) 45%,transparent); + white-space:nowrap;overflow:hidden;text-overflow:ellipsis; + max-width:100%;vertical-align:middle;letter-spacing:.01em} +.prov-td-value:has(.prov-mono-trunc){display:flex;align-items:center;gap:var(--sp-1);flex-wrap:nowrap; + min-width:0} +.prov-copy-btn{display:inline-flex;align-items:center;justify-content:center; + width:22px;height:22px;padding:0;background:none;border:1px solid transparent; + border-radius:var(--radius-sm);color:var(--text-muted);cursor:pointer; + transition:all var(--dur-fast) var(--ease);flex-shrink:0} +.prov-copy-btn:hover{color:var(--text-primary);background:var(--bg-overlay); + border-color:color-mix(in srgb,var(--border) 70%,transparent)} +.prov-copy-btn:focus-visible{outline:2px solid var(--accent-primary);outline-offset:1px} +.prov-copy-btn--ok{color:var(--success);background:var(--success-muted); + border-color:color-mix(in srgb,var(--success) 30%,transparent)} +.prov-copy-btn svg{width:12px;height:12px} +""" + + +# --------------------------------------------------------------------------- +# Shared micro-interactions +# --------------------------------------------------------------------------- + +_MICRO_INTERACTIONS = """\ +/* Shared card micro-interactions */ +.meta-item,.overview-row,.overview-summary-item,.group,.suggestion-card,.sf-card,.prov-section{ + --card-hover-accent:var(--accent-primary); + --card-outline:color-mix(in oklch,var(--card-hover-accent) 24%,transparent); + --card-hover-shadow: + 0 10px 24px color-mix(in srgb,var(--card-hover-accent) 8%,transparent), + var(--shadow-md); + transform:translateY(0); + transition:transform var(--dur-fast) var(--ease), + border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease)} +@media (hover:hover) and (pointer:fine){ + .meta-item:hover,.overview-row:hover,.overview-summary-item:hover,.group:hover,.suggestion-card:hover,.sf-card:hover,.prov-section:hover{ + transform:translateY(-2px); + border-color:color-mix(in oklch,var(--card-hover-accent) 22%,var(--border-strong)); + box-shadow:0 0 0 1px var(--card-outline),var(--card-hover-shadow)} +} +@media (prefers-reduced-motion:reduce){ + .meta-item,.overview-row,.overview-summary-item,.group,.suggestion-card,.sf-card,.prov-section{ + transition:border-color var(--dur-fast) var(--ease), + box-shadow var(--dur-fast) var(--ease)} + .meta-item:hover,.overview-row:hover,.overview-summary-item:hover,.group:hover,.suggestion-card:hover,.sf-card:hover,.prov-section:hover{ + transform:none} +} """ # --------------------------------------------------------------------------- @@ -1015,10 +1173,26 @@ .empty-card h2{margin-bottom:var(--sp-2)} .empty-card p{color:var(--text-secondary);font-size:.9rem} .tab-empty{display:flex;flex-direction:column;align-items:center;justify-content:center; - padding:var(--sp-10);text-align:center} + padding:var(--sp-10);text-align:center;font-family:var(--font-sans)} .tab-empty-icon{color:var(--text-muted);opacity:.4;margin-bottom:var(--sp-3);width:48px;height:48px} -.tab-empty-title{font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--sp-1)} -.tab-empty-desc{font-size:.85rem;color:var(--text-muted);max-width:320px} +.tab-empty-title{font-size:1rem;font-weight:600;color:var(--text-primary);margin-bottom:var(--sp-1); + font-family:var(--font-display)} +.tab-empty-desc{font-size:.85rem;color:var(--text-muted);max-width:320px;font-family:var(--font-sans)} +.tab-empty-desc-detail{text-align:left;max-width:520px;font-size:.8rem;word-break:break-word; + font-family:var(--font-sans)} +.tab-empty-reason{display:block;margin-top:var(--sp-1);font-size:.75rem;color:var(--text-muted); + opacity:.7;word-break:break-all;font-family:var(--font-mono, monospace)} + +/* Inline empty state — compact stacked variant for cards/summary items. + No background/border — sits inside its parent card. Icon color carries tone. */ +.inline-empty{display:flex;flex-direction:column;align-items:center;justify-content:center; + gap:var(--sp-2);padding:var(--sp-4) var(--sp-3);min-height:72px; + color:var(--text-muted);font-size:.82rem;font-weight:500; + text-align:center;letter-spacing:.005em;line-height:1.4;font-family:var(--font-sans)} +.inline-empty-icon{flex-shrink:0;opacity:.5;color:var(--text-muted)} +.inline-empty-text{max-width:260px} +.inline-empty--good .inline-empty-icon{color:var(--success);opacity:.7} +.inline-empty--neutral .inline-empty-icon{color:var(--text-muted);opacity:.5} """ # --------------------------------------------------------------------------- @@ -1065,13 +1239,42 @@ font-family:var(--font-mono)} /* Provenance modal */ -dialog.prov-modal{max-width:660px;width:92vw;max-height:85vh} -.prov-modal-head{display:flex;align-items:center;justify-content:space-between; - padding:var(--sp-3) var(--sp-5);border-bottom:none;flex-shrink:0} -.prov-modal-head h2{font-size:1rem;font-weight:700;letter-spacing:-.01em} -.prov-modal-body{padding:0 var(--sp-4) var(--sp-4);overflow-y:auto;flex:1 1 auto} -.prov-modal .prov-summary{border-top:none;padding:0 var(--sp-5) var(--sp-3); - border-bottom:1px solid var(--border);flex-shrink:0} +dialog.prov-modal{max-width:720px;width:92vw;max-height:86vh;padding:0;overflow:hidden; + border-radius:var(--radius-lg)} +.prov-modal-body{padding:var(--sp-4) var(--sp-5) var(--sp-5);overflow-y:auto;flex:1 1 auto} +.prov-modal .prov-summary{padding:var(--sp-2) var(--sp-5) var(--sp-3); + border-top:none;border-bottom:1px solid var(--border);flex-shrink:0; + background:color-mix(in srgb,var(--bg-raised) 50%,transparent)} + +/* Provenance hero — status header at top of modal */ +.prov-hero{position:relative;display:flex;align-items:center;gap:var(--sp-4); + padding:var(--sp-4) var(--sp-5);flex-shrink:0; + border-bottom:1px solid var(--border); + background:linear-gradient(180deg, + color-mix(in srgb,var(--bg-raised) 55%,transparent) 0%, + var(--bg-surface) 100%)} +.prov-hero-badge{display:inline-flex;align-items:center;gap:7px; + padding:6px 12px 6px 10px;border-radius:999px;font-weight:700;font-size:.78rem; + letter-spacing:.005em;white-space:nowrap;flex-shrink:0; + border:1px solid var(--border);background:var(--bg-surface)} +.prov-hero-icon{flex-shrink:0} +.prov-hero-label{line-height:1} +.prov-hero--green .prov-hero-badge{color:var(--success); + background:color-mix(in srgb,var(--success) 10%,var(--bg-surface)); + border-color:color-mix(in srgb,var(--success) 45%,var(--border))} +.prov-hero--amber .prov-hero-badge{color:var(--warning); + background:color-mix(in srgb,var(--warning) 10%,var(--bg-surface)); + border-color:color-mix(in srgb,var(--warning) 45%,var(--border))} +.prov-hero--red .prov-hero-badge{color:var(--error); + background:color-mix(in srgb,var(--error) 10%,var(--bg-surface)); + border-color:color-mix(in srgb,var(--error) 50%,var(--border))} +.prov-hero--neutral .prov-hero-badge{color:var(--text-secondary)} +.prov-hero-text{display:flex;flex-direction:column;gap:2px;min-width:0;flex:1} +.prov-hero-title{font-size:1.02rem;font-weight:700;letter-spacing:-.01em; + color:var(--text-primary);margin:0;line-height:1.25} +.prov-hero-sub{font-size:.8rem;color:var(--text-secondary);margin:0;line-height:1.35; + overflow:hidden;text-overflow:ellipsis} +.prov-hero-close{flex-shrink:0;align-self:flex-start} """ @@ -1113,6 +1316,10 @@ .suggestion-facts{grid-template-columns:1fr} .sf-head{flex-direction:column;align-items:flex-start} .sf-meta{width:100%} + .dir-hotspot-head{flex-wrap:wrap;align-items:flex-start} + .dir-hotspot-detail{flex-wrap:wrap;align-items:flex-start} + .dir-hotspot-bar-track{width:min(148px,42%);min-width:96px} + .dir-hotspot-meta{width:100%} .container{padding:0 var(--sp-3)} .topbar{position:static} .topbar-inner{height:auto;padding:var(--sp-2) var(--sp-3);flex-direction:row; @@ -1124,9 +1331,10 @@ .brand-project-name{font-size:.78em;padding:0 3px} .brand-meta{display:none} .topbar-actions{flex-shrink:0;gap:var(--sp-1)} - .topbar-actions .btn-prov{font-size:0;gap:0;width:32px;height:32px; + .topbar-actions .prov-pill{font-size:0;gap:0;width:32px;height:32px; padding:0;align-items:center;justify-content:center} - .topbar-actions .btn-prov .prov-dot{width:10px;height:10px} + .topbar-actions .prov-pill-label{display:none} + .topbar-actions .prov-pill-icon{opacity:1} .theme-toggle{font-size:0;gap:0;width:32px;height:32px; padding:0;align-items:center;justify-content:center} .theme-toggle svg{width:16px;height:16px} @@ -1201,6 +1409,9 @@ .report-footer{margin-top:var(--sp-8);padding:var(--sp-4) 0;border-top:1px solid var(--border); text-align:center;font-size:.78rem;color:var(--text-muted)} .report-footer a{color:var(--accent-primary)} +.report-footer-main{display:block} +.report-footer-schemas{margin-top:var(--sp-1);font-size:.72rem;letter-spacing:.01em; + font-variant-numeric:tabular-nums;opacity:.85} """ @@ -1230,6 +1441,7 @@ _SUGGESTIONS, _STRUCTURAL, _META_PANEL, + _MICRO_INTERACTIONS, _EMPTY, _COUPLED, _MODAL, diff --git a/codeclone/_html_js.py b/codeclone/_html_js.py index 0d07299..0a59b38 100644 --- a/codeclone/_html_js.py +++ b/codeclone/_html_js.py @@ -15,6 +15,37 @@ _CORE = """\ const $=s=>document.querySelector(s); const $$=s=>[...document.querySelectorAll(s)]; + +/* Shared Filters popover wiring: one button opens a menu, outside-click + + Escape dismiss it. Reused by Clones (per-section) and Suggestions (global). */ +function wireFiltersPopover(toggleEl){ + if(!toggleEl)return; + const popover=toggleEl.parentElement; + if(!popover)return; + const menu=popover.querySelector('.filters-menu'); + if(!menu)return; + function setOpen(open){ + toggleEl.setAttribute('aria-expanded',open?'true':'false'); + if(open)menu.removeAttribute('hidden'); + else menu.setAttribute('hidden',''); + } + toggleEl.addEventListener('click',e=>{ + e.stopPropagation(); + setOpen(toggleEl.getAttribute('aria-expanded')!=='true'); + }); + document.addEventListener('click',e=>{ + if(toggleEl.getAttribute('aria-expanded')!=='true')return; + if(popover.contains(e.target))return; + setOpen(false); + }); + document.addEventListener('keydown',e=>{ + if(e.key!=='Escape')return; + if(toggleEl.getAttribute('aria-expanded')!=='true')return; + setOpen(false); + toggleEl.focus(); + }); +} +window.wireFiltersPopover=wireFiltersPopover; """ # --------------------------------------------------------------------------- @@ -26,15 +57,16 @@ const key='codeclone-theme'; const root=document.documentElement; const saved=localStorage.getItem(key); - if(saved)root.setAttribute('data-theme',saved); + // Always resolve + set data-theme so icon CSS selectors always match. + const initial=saved==='light'||saved==='dark' + ?saved + :(matchMedia('(prefers-color-scheme:dark)').matches?'dark':'light'); + root.setAttribute('data-theme',initial); const btn=$('.theme-toggle'); if(!btn)return; btn.addEventListener('click',()=>{ - const has=root.getAttribute('data-theme'); - const isDark=has?has==='dark' - :matchMedia('(prefers-color-scheme:light)').matches?false:true; - const next=isDark?'light':'dark'; + const next=root.getAttribute('data-theme')==='dark'?'light':'dark'; root.setAttribute('data-theme',next); localStorage.setItem(key,next); }); @@ -124,6 +156,23 @@ function isAll(v){return !v||v==='all'} + function activeFilterCount(){ + let n=0; + if(!isAll(sourceKindFilter?.value))n++; + if(!isAll(cloneTypeFilter?.value))n++; + if(!isAll(spreadFilter?.value))n++; + if(minOccCheck?.checked)n++; + return n; + } + + function updateFiltersBadge(){ + const badge=sec.querySelector('[data-filters-count="'+id+'"]'); + if(!badge)return; + const n=activeFilterCount(); + if(n>0){badge.hidden=false;badge.textContent=String(n)} + else{badge.hidden=true;badge.textContent='0'} + } + function applyFilters(){ const q=(searchInput?.value||'').toLowerCase().trim(); const sk=sourceKindFilter?.value||''; @@ -142,6 +191,7 @@ if(minOcc&&parseInt(g.dataset.groupArity||'0',10)<4)show=false; g.style.display=show?'':'none'; }); + updateFiltersBadge(); page=1; paginate(); } @@ -187,19 +237,33 @@ const vis=visible();const tp=Math.max(1,Math.ceil(vis.length/pageSize)); if(page{ - groups.forEach(g=>{ - const body=g.querySelector('.group-body');if(body)body.classList.remove('expanded'); - const toggle=g.querySelector('.group-toggle');if(toggle)toggle.classList.remove('expanded'); - })}); - if(expBtn)expBtn.addEventListener('click',()=>{ - groups.filter(g=>g.style.display!=='none').forEach(g=>{ - const body=g.querySelector('.group-body');if(body)body.classList.add('expanded'); - const toggle=g.querySelector('.group-toggle');if(toggle)toggle.classList.add('expanded'); - })}); + // Expand/collapse toggle (single button, flips state) + const expandToggle=sec.querySelector('[data-expand-toggle="'+id+'"]'); + if(expandToggle){ + expandToggle.addEventListener('click',()=>{ + const expanded=expandToggle.dataset.expanded==='true'; + const target=!expanded; + const scope=target + ? groups.filter(g=>g.style.display!=='none') + : groups; + scope.forEach(g=>{ + const body=g.querySelector('.group-body'); + const toggle=g.querySelector('.group-toggle'); + if(target){ + if(body)body.classList.add('expanded'); + if(toggle)toggle.classList.add('expanded'); + }else{ + if(body)body.classList.remove('expanded'); + if(toggle)toggle.classList.remove('expanded'); + } + }); + expandToggle.dataset.expanded=target?'true':'false'; + expandToggle.textContent=target?'Collapse all':'Expand all'; + }); + } + + // Filters popover (shared helper handles open/close + dismiss) + wireFiltersPopover(sec.querySelector('[data-filters-toggle="'+id+'"]')); // Initial applyFilters(); @@ -323,6 +387,23 @@ const spSel=$('[data-suggestions-spread]'); const actCheck=$('[data-suggestions-actionable]'); const countLabel=$('[data-suggestions-count]'); + const filtersBadge=$('[data-filters-count="suggestions"]'); + + function activeFilterCount(){ + let n=0; + [sevSel,catSel,famSel,skSel,spSel].forEach(el=>{ + if(el&&el.value)n++; + }); + if(actCheck?.checked)n++; + return n; + } + + function updateFiltersBadge(){ + if(!filtersBadge)return; + const n=activeFilterCount(); + if(n>0){filtersBadge.hidden=false;filtersBadge.textContent=String(n)} + else{filtersBadge.hidden=true;filtersBadge.textContent='0'} + } function apply(){ const sev=sevSel?.value||''; @@ -344,10 +425,17 @@ if(!hide)shown++; }); if(countLabel)countLabel.textContent=shown+' shown'; + updateFiltersBadge(); } [sevSel,catSel,famSel,skSel,spSel].forEach(el=>{if(el)el.addEventListener('change',apply)}); if(actCheck)actCheck.addEventListener('change',apply); + + // Popover wiring (shared helper) + wireFiltersPopover($('[data-filters-toggle="suggestions"]')); + + // Initial + apply(); })(); """ @@ -466,7 +554,24 @@ const closeBtn=dlg.querySelector('[data-prov-close]'); if(openBtn)openBtn.addEventListener('click',()=>dlg.showModal()); if(closeBtn)closeBtn.addEventListener('click',()=>dlg.close()); - dlg.addEventListener('click',e=>{if(e.target===dlg)dlg.close()}); + dlg.addEventListener('click',function(e){ + if(e.target===dlg){dlg.close();return} + var copyBtn=e.target.closest('[data-prov-copy]'); + if(!copyBtn)return; + e.stopPropagation(); + var payload=copyBtn.getAttribute('data-prov-copy')||''; + if(!payload||!navigator.clipboard)return; + navigator.clipboard.writeText(payload).then(function(){ + copyBtn.classList.add('prov-copy-btn--ok'); + var original=copyBtn.innerHTML; + copyBtn.innerHTML=''; + setTimeout(function(){ + copyBtn.classList.remove('prov-copy-btn--ok'); + copyBtn.innerHTML=original; + },1400); + }); + }); })(); (function initFindingWhy(){ var dlg=$('#finding-why-modal'); @@ -670,6 +775,42 @@ })(); """ +# --------------------------------------------------------------------------- +# Tooltips (fixed-position, escapes overflow containers) +# --------------------------------------------------------------------------- + +_TOOLTIPS = """\ +(function initTooltips(){ + let tip=null; + function show(e){ + const el=e.target; + const text=el.getAttribute('data-tip'); + if(!text)return; + tip=document.createElement('div'); + tip.className='kpi-tooltip'; + tip.textContent=text; + document.body.appendChild(tip); + const r=el.getBoundingClientRect(); + const tw=tip.offsetWidth; + const th=tip.offsetHeight; + let left=r.left+r.width/2-tw/2; + let top=r.bottom+6; + if(left<4)left=4; + if(left+tw>window.innerWidth-4)left=window.innerWidth-tw-4; + if(top+th>window.innerHeight-4){top=r.top-th-6} + tip.style.left=left+'px'; + tip.style.top=top+'px'; + } + function hide(){if(tip){tip.remove();tip=null}} + document.addEventListener('mouseenter',function(e){ + if(e.target.matches('.kpi-help[data-tip]'))show(e); + },true); + document.addEventListener('mouseleave',function(e){ + if(e.target.matches('.kpi-help[data-tip]'))hide(); + },true); +})(); +""" + # --------------------------------------------------------------------------- # Public API # --------------------------------------------------------------------------- @@ -692,6 +833,7 @@ _SCOPE_COUNTERS, _LAZY_HIGHLIGHT, _IDE_LINKS, + _TOOLTIPS, ) diff --git a/codeclone/_html_report/_assemble.py b/codeclone/_html_report/_assemble.py index 8884d7f..13f4964 100644 --- a/codeclone/_html_report/_assemble.py +++ b/codeclone/_html_report/_assemble.py @@ -26,7 +26,7 @@ from ._sections._coupling import render_quality_panel from ._sections._dead_code import render_dead_code_panel from ._sections._dependencies import render_dependencies_panel -from ._sections._meta import render_meta_panel +from ._sections._meta import build_topbar_provenance_summary, render_meta_panel from ._sections._overview import render_overview_panel from ._sections._structural import render_structural_panel from ._sections._suggestions import render_suggestions_panel @@ -107,6 +107,15 @@ def build_html_report( structural_count = len( tuple(normalize_structural_findings(ctx.structural_findings)) ) + coverage_join_summary = _as_mapping( + _as_mapping(ctx.metrics_map.get("coverage_join")).get("summary") + ) + coverage_review_items = ( + _as_int(coverage_join_summary.get("coverage_hotspots")) + + _as_int(coverage_join_summary.get("scope_gap_hotspots")) + if str(coverage_join_summary.get("status", "")).strip() == "ok" + else 0 + ) quality_issues = ( _as_int(_as_mapping(ctx.complexity_map.get("summary")).get("high_risk")) + _as_int(_as_mapping(ctx.coupling_map.get("summary")).get("high_risk")) @@ -114,6 +123,7 @@ def build_html_report( + _as_int( _as_mapping(ctx.overloaded_modules_map.get("summary")).get("candidates") ) + + coverage_review_items ) def _tab_badge(count: int) -> str: @@ -187,23 +197,10 @@ def _tab_badge(count: int) -> str: ) panels_html = "".join(tab_panels) - # -- Provenance dot color -- - _bl_verified = _meta_pick( - ctx.meta.get("baseline_payload_sha256_verified"), - ctx.baseline_meta.get("payload_sha256_verified"), + # -- Provenance summary for topbar pill -- + prov_status_label, prov_status_color, prov_tooltip = ( + build_topbar_provenance_summary(ctx) ) - _bl_loaded = _meta_pick( - ctx.meta.get("baseline_loaded"), - ctx.baseline_meta.get("loaded"), - ) - if _bl_verified: - prov_dot_cls = "dot-green" - elif _bl_loaded is True and _bl_verified is not True: - prov_dot_cls = "dot-red" - elif _bl_loaded is False or _bl_loaded is None: - prov_dot_cls = "dot-amber" - else: - prov_dot_cls = "dot-neutral" # -- IDE picker menu -- ide_options = [ @@ -236,21 +233,56 @@ def _tab_badge(count: int) -> str: f'aria-haspopup="true" title="Open in IDE">{ICONS["ide"]}' 'IDE' f'
' - f'' - f'" + f'" + f'" "" ) # -- Footer -- version = str(ctx.meta.get("codeclone_version", __version__)) + _report_schema = ctx.report_schema_version + _baseline_schema = _meta_pick( + ctx.meta.get("baseline_schema_version"), + ctx.baseline_meta.get("schema_version"), + ) + _cache_schema = _meta_pick( + ctx.meta.get("cache_schema_version"), + ctx.cache_meta.get("schema_version"), + ) + _schema_parts: list[str] = [] + if _report_schema: + _schema_parts.append(f"Report schema {_escape_html(str(_report_schema))}") + if _baseline_schema: + _schema_parts.append(f"Baseline schema {_escape_html(str(_baseline_schema))}") + if _cache_schema: + _schema_parts.append(f"Cache schema {_escape_html(str(_cache_schema))}") + _schema_line = ( + f'' + if _schema_parts + else "" + ) footer_html = ( '" ) diff --git a/codeclone/_html_report/_components.py b/codeclone/_html_report/_components.py index 3a87292..7a9fcae 100644 --- a/codeclone/_html_report/_components.py +++ b/codeclone/_html_report/_components.py @@ -12,7 +12,7 @@ from typing import Literal from .._coerce import as_int as _as_int -from .._html_badges import _source_kind_badge_html +from .._html_badges import _inline_empty, _source_kind_badge_html from .._html_escape import _escape_html from ._icons import section_icon_html @@ -60,6 +60,9 @@ def overview_cluster_header(title: str, subtitle: str | None = None) -> str: "top candidates": ("quality", "summary-icon summary-icon--info"), "more candidates": ("quality", "summary-icon summary-icon--info"), "health profile": ("health-profile", "summary-icon summary-icon--info"), + "adoption coverage": ("coverage-adoption", "summary-icon summary-icon--info"), + "public api surface": ("api-surface", "summary-icon summary-icon--info"), + "coverage join": ("quality", "summary-icon summary-icon--info"), } @@ -86,7 +89,7 @@ def overview_source_breakdown_html(breakdown: Mapping[str, object]) -> str: ) rows = [(kind, count) for kind, count in sorted_items if count > 0] if not rows: - return '
n/a
' + return _inline_empty("No source data available", tone="neutral") total = sum(c for _, c in rows) parts: list[str] = [] diff --git a/codeclone/_html_report/_glossary.py b/codeclone/_html_report/_glossary.py index 70b7428..e48d4f0 100644 --- a/codeclone/_html_report/_glossary.py +++ b/codeclone/_html_report/_glossary.py @@ -48,6 +48,47 @@ "edges": "Total number of import relationships between modules", "max depth": "Longest chain of transitive imports", "cycles": "Number of circular import dependencies detected", + # Complexity stat cards + "high-risk functions": ( + "Functions with cyclomatic complexity above the high-risk threshold" + ), + "max cc": "Highest cyclomatic complexity value among all analyzed functions", + "avg cc": "Average cyclomatic complexity across all analyzed functions", + "deep nesting": ( + "Functions with nesting depth exceeding recommended threshold (> 4)" + ), + # Coupling stat cards + "high-coupling classes": "Classes with CBO above the high-risk threshold", + "max cbo": "Highest Coupling Between Objects value among all classes", + "avg cbo": "Average CBO across all analyzed classes", + "medium risk": "Items at medium risk level — worth reviewing but not critical", + # Cohesion stat cards + "low-cohesion classes": ( + "Classes with LCOM4 > 1, indicating multiple responsibilities" + ), + "max lcom4": "Highest Lack of Cohesion value among all classes", + "high risk": "Items at high risk level requiring attention", + # Overloaded module stat cards + "overloaded": ( + "Modules exceeding acceptable thresholds for size, complexity, or coupling" + ), + "critical": "Items with critical status requiring immediate attention", + "max score": "Highest overload score among all modules", + "avg loc": "Average lines of code per module", + # Dead code stat cards + "candidates": "Total dead code candidates detected by static analysis", + "high confidence": "Dead code items detected with high or critical confidence", + "suppressed": "Dead code candidates excluded by suppression rules", + "hit rate": "Percentage of high-confidence items among all candidates", + # Clone stat cards + "clone groups": "Distinct duplication patterns, each containing 2+ code fragments", + "instances": "Total duplicated code fragments across all groups", + "new groups": "Clone groups not present in the previous baseline", + "high spread": "Clone groups spanning multiple files", + # Suggestion stat cards + "total suggestions": "Total actionable improvement suggestions generated", + "warning": "Suggestions with warning severity worth reviewing", + "easy wins": "Actionable suggestions with low estimated effort", } diff --git a/codeclone/_html_report/_icons.py b/codeclone/_html_report/_icons.py index 64c4cd7..87b68c2 100644 --- a/codeclone/_html_report/_icons.py +++ b/codeclone/_html_report/_icons.py @@ -55,10 +55,25 @@ def _svg_with_class(size: int, sw: str, body: str, *, class_name: str = "") -> s "2.5", '', ), - "theme": _svg( + "theme_moon": _svg_with_class( 16, "2", '', + class_name="theme-icon theme-icon-moon", + ), + "theme_sun": _svg_with_class( + 16, + "2", + '' + '' + '' + '' + '' + '' + '' + '' + '', + class_name="theme-icon theme-icon-sun", ), "check": _svg( 48, @@ -173,6 +188,16 @@ def _svg_with_class(size: int, sw: str, body: str, *, class_name: str = "") -> s '' '', ), + "coverage-adoption": ( + "2", + '' + '', + ), + "api-surface": ( + "2", + '' + '', + ), } diff --git a/codeclone/_html_report/_sections/_clones.py b/codeclone/_html_report/_sections/_clones.py index f60e363..65ab657 100644 --- a/codeclone/_html_report/_sections/_clones.py +++ b/codeclone/_html_report/_sections/_clones.py @@ -12,7 +12,7 @@ from typing import TYPE_CHECKING, Literal from ... import _coerce -from ..._html_badges import _source_kind_badge_html +from ..._html_badges import _micro_badges, _source_kind_badge_html, _stat_card from ..._html_data_attrs import _build_data_attrs from ..._html_escape import _escape_html from ..._html_filters import CLONE_TYPE_OPTIONS, SPREAD_OPTIONS, _render_select @@ -27,7 +27,9 @@ from ...report.json_contract import clone_group_id from ...report.suggestions import classify_clone_type from .._components import Tone, insight_block +from .._glossary import glossary_tip from .._icons import ICONS +from .._tables import render_rows_table from .._tabs import render_split_tabs if TYPE_CHECKING: @@ -35,8 +37,15 @@ from .._context import ReportContext _as_int = _coerce.as_int +_as_mapping = _coerce.as_mapping +_as_sequence = _coerce.as_sequence _HEX_SET = frozenset("0123456789abcdefABCDEF") +_SUPPRESSED_KIND_LABELS = { + "function": "Function", + "block": "Block", + "segment": "Segment", +} def _looks_like_hash(text: str) -> bool: @@ -169,49 +178,153 @@ def _render_group_explanation(meta: Mapping[str, object]) -> str: return f'
{"".join(parts)}{note}
' +def _flatten_suppressed_clone_groups( + ctx: ReportContext, +) -> tuple[Mapping[str, object], ...]: + findings = _as_mapping(ctx.report_document.get("findings")) + groups = _as_mapping(findings.get("groups")) + clones = _as_mapping(groups.get("clones")) + suppressed = _as_mapping(clones.get("suppressed")) + flattened: list[Mapping[str, object]] = [] + for bucket_key in ("functions", "blocks", "segments"): + for group in _as_sequence(suppressed.get(bucket_key)): + group_mapping = _as_mapping(group) + if group_mapping: + flattened.append(group_mapping) + return tuple(flattened) + + +def _suppressed_group_label( + group: Mapping[str, object], + ctx: ReportContext, +) -> tuple[str, str]: + items = _as_sequence(group.get("items")) + first_item = _as_mapping(items[0]) if items else {} + filepath = str(first_item.get("filepath", "")) + qualname = str(first_item.get("qualname", "")) + label = ctx.bare_qualname(qualname, filepath) or ctx.relative_path(filepath) + if not label: + label = str(group.get("id", "")) + return label, filepath + + +def _render_suppressed_clone_panel( + ctx: ReportContext, + groups: Sequence[Mapping[str, object]], +) -> str: + rows: list[tuple[str, str, str, str, str, str, str]] = [] + for group in groups[:200]: + label, filepath = _suppressed_group_label(group, ctx) + matched_patterns = ", ".join( + str(pattern).strip() + for pattern in _as_sequence(group.get("matched_patterns")) + if str(pattern).strip() + ) + suppression_rule = str(group.get("suppression_rule", "")).strip() + suppression_source = str(group.get("suppression_source", "")).strip() + rule_text = suppression_rule + if suppression_source: + rule_text = ( + f"{rule_text}@{suppression_source}" if rule_text else suppression_source + ) + rows.append( + ( + _SUPPRESSED_KIND_LABELS.get( + str(group.get("clone_kind", "")).strip().lower(), + "Clone", + ), + label, + filepath, + str(group.get("clone_type", "")), + str(group.get("count", "")), + rule_text, + matched_patterns or "-", + ) + ) + return render_rows_table( + headers=("Kind", "Group", "File", "Type", "Occurrences", "Rule", "Pattern"), + rows=rows, + empty_message="No suppressed clone groups.", + ctx=ctx, + ) + + def _render_section_toolbar( section_id: str, section_title: str, group_count: int, ) -> str: - return ( - f'