diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index 80e478f..94841e9 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -16,7 +16,7 @@ body: attributes: label: CodeClone version description: Output of `codeclone --version` - placeholder: "2.0.0b4" + placeholder: "2.0.0" validations: required: true diff --git a/.github/ISSUE_TEMPLATE/cfg_semantics.yml b/.github/ISSUE_TEMPLATE/cfg_semantics.yml index a070429..527f05c 100644 --- a/.github/ISSUE_TEMPLATE/cfg_semantics.yml +++ b/.github/ISSUE_TEMPLATE/cfg_semantics.yml @@ -15,7 +15,7 @@ body: id: version attributes: label: CodeClone version - placeholder: "2.0.0b4" + placeholder: "2.0.0" - type: textarea id: scenario diff --git a/.github/ISSUE_TEMPLATE/false_positive.yml b/.github/ISSUE_TEMPLATE/false_positive.yml index acaa38a..22c3ab2 100644 --- a/.github/ISSUE_TEMPLATE/false_positive.yml +++ b/.github/ISSUE_TEMPLATE/false_positive.yml @@ -15,7 +15,7 @@ body: id: version attributes: label: CodeClone version - placeholder: "2.0.0b4" + placeholder: "2.0.0" validations: required: true diff --git a/.github/ISSUE_TEMPLATE/mcp_server.yml b/.github/ISSUE_TEMPLATE/mcp_server.yml index e897c51..f4bd362 100644 --- a/.github/ISSUE_TEMPLATE/mcp_server.yml +++ b/.github/ISSUE_TEMPLATE/mcp_server.yml @@ -17,7 +17,7 @@ body: attributes: label: CodeClone version description: Output of `codeclone --version` - placeholder: "2.0.0b4" + placeholder: "2.0.0" validations: required: true diff --git a/.github/actions/codeclone/README.md b/.github/actions/codeclone/README.md index aa05a22..e2f09d2 100644 --- a/.github/actions/codeclone/README.md +++ b/.github/actions/codeclone/README.md @@ -30,13 +30,19 @@ source under test. Remote consumers still install from PyPI. ## Basic usage ```yaml -- uses: orenlab/codeclone/.github/actions/codeclone@main +- uses: orenlab/codeclone/.github/actions/codeclone@v2 with: fail-on-new: "true" ``` -For released references, prefer pinning to a major version tag such as `@v2` -or to an immutable commit SHA. +For strict reproducibility, pin the full release tag: + +```yaml +- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.0 +``` + +For long-lived workflows, `@v2` follows the latest compatible 2.x action +metadata. ## PR workflow example @@ -61,7 +67,7 @@ jobs: with: fetch-depth: 0 - - uses: orenlab/codeclone/.github/actions/codeclone@main + - uses: orenlab/codeclone/.github/actions/codeclone@v2 with: fail-on-new: "true" fail-health: "60" @@ -74,7 +80,7 @@ jobs: | Input | Default | Purpose | |-------------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------| | `python-version` | `3.14` | Python version used to run the action | -| `package-version` | `""` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo | +| `package-version` | `2.0.0` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo | | `path` | `.` | Project root to analyze | | `json-path` | `.cache/codeclone/report.json` | JSON report output path | | `sarif` | `true` | Generate SARIF and try to upload it | @@ -136,26 +142,27 @@ Notes: - if you only want gating and JSON output, you can disable `sarif` and `pr-comment` -## Stable vs prerelease installs +## Install policy + +Released action tags pin the PyPI package version in action metadata. For +example, `@v2.0.0` installs `codeclone==2.0.0` unless you override +`package-version`. -Stable: +Explicit prerelease or smoke-test override: ```yaml with: - package-version: "" + package-version: "" ``` -Explicit prerelease: +Local/self-repo validation: ```yaml -with: - package-version: "2.0.0b4" +- uses: ./.github/actions/codeclone ``` -Local/self-repo validation: - - `uses: ./.github/actions/codeclone` installs CodeClone from the checked-out - repository source, so beta branches and unreleased commits do not depend on + repository source, so release branches and unreleased commits do not depend on PyPI publication. ## Notes and limitations diff --git a/.github/actions/codeclone/_action_impl.py b/.github/actions/codeclone/_action_impl.py index b4d52b9..9cfa729 100644 --- a/.github/actions/codeclone/_action_impl.py +++ b/.github/actions/codeclone/_action_impl.py @@ -4,6 +4,17 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Den Rozhnovskiy +"""GitHub Action helpers for running CodeClone and rendering PR feedback. + +This module is intentionally small and dependency-free. It builds the CodeClone +CLI invocation from action inputs, executes the analyzer, writes GitHub Actions +outputs, and renders a compact Markdown review comment from the canonical JSON +report. + +Public functions and dataclasses are used by the action entrypoint and should +remain stable. +""" + from __future__ import annotations import json @@ -14,10 +25,13 @@ from typing import Literal COMMENT_MARKER = "" +DEFAULT_CODECLONE_PACKAGE_VERSION = "2.0.0" @dataclass(frozen=True, slots=True) class ActionInputs: + """Normalized GitHub Action inputs used to build a CodeClone invocation.""" + path: str json_path: str sarif: bool @@ -39,6 +53,8 @@ class ActionInputs: @dataclass(frozen=True, slots=True) class RunResult: + """Result of a CodeClone CLI execution inside the action runtime.""" + exit_code: int json_path: str json_exists: bool @@ -48,15 +64,51 @@ class RunResult: @dataclass(frozen=True, slots=True) class InstallTarget: + """Resolved package requirement used by the action installer.""" + requirement: str - source: Literal["repo", "pypi-version", "pypi-latest"] + source: Literal["repo", "pypi-version", "pypi-default"] + + +@dataclass(frozen=True, slots=True) +class _PrCommentContext: + """Typed internal view over canonical report fields used by PR rendering.""" + + clone_summary: dict[str, object] + families: dict[str, object] + complexity: dict[str, object] + coupling: dict[str, object] + cohesion: dict[str, object] + dependencies: dict[str, object] + dead_code: dict[str, object] + overloaded_modules: dict[str, object] + coverage_join: dict[str, object] + security_surfaces: dict[str, object] + api_surface: dict[str, object] + health_score: int + health_grade: str + baseline_status: str + cache_label: str + codeclone_version: str def parse_bool(value: str) -> bool: + """Parse GitHub Action boolean input values. + + GitHub Action inputs arrive as strings. CodeClone action booleans are true + only when the normalized value is exactly ``"true"``. + """ + return value.strip().lower() == "true" def parse_optional_int(value: str) -> int | None: + """Parse optional integer action input values. + + Empty strings and ``-1`` are treated as unset values because GitHub Action + inputs do not have native nullable integer types. + """ + normalized = value.strip() if normalized in {"", "-1"}: return None @@ -64,43 +116,39 @@ def parse_optional_int(value: str) -> int | None: def build_codeclone_args(inputs: ActionInputs) -> list[str]: - args = [inputs.path, "--json", inputs.json_path] - if inputs.sarif: - args.extend(["--sarif", inputs.sarif_path]) - if inputs.no_progress: - args.append("--no-progress") - if inputs.fail_on_new: - args.append("--fail-on-new") - if inputs.fail_on_new_metrics: - args.append("--fail-on-new-metrics") - if inputs.fail_threshold is not None: - args.extend(["--fail-threshold", str(inputs.fail_threshold)]) - if inputs.fail_complexity is not None: - args.extend(["--fail-complexity", str(inputs.fail_complexity)]) - if inputs.fail_coupling is not None: - args.extend(["--fail-coupling", str(inputs.fail_coupling)]) - if inputs.fail_cohesion is not None: - args.extend(["--fail-cohesion", str(inputs.fail_cohesion)]) - if inputs.fail_cycles: - args.append("--fail-cycles") - if inputs.fail_dead_code: - args.append("--fail-dead-code") - if inputs.fail_health is not None: - args.extend(["--fail-health", str(inputs.fail_health)]) - if inputs.baseline_path.strip(): - args.extend(["--baseline", inputs.baseline_path]) - if inputs.metrics_baseline_path.strip(): - args.extend(["--metrics-baseline", inputs.metrics_baseline_path]) - if inputs.extra_args.strip(): - args.extend(shlex.split(inputs.extra_args)) + """Build CodeClone CLI arguments from normalized action inputs. + + The returned list intentionally excludes the executable name. Extra + arguments are parsed with :mod:`shlex` so quoted values behave like shell + arguments without invoking a shell. + """ + + args: list[str] = [inputs.path, "--json", inputs.json_path] + + for value, flag in _valued_codeclone_options(inputs): + if value is not None: + args.extend([flag, str(value)]) + + for enabled, flag in _boolean_codeclone_flags(inputs): + if enabled: + args.append(flag) + + extra_args = inputs.extra_args.strip() + if extra_args: + args.extend(shlex.split(extra_args)) + return args def ensure_parent_dir(path_text: str) -> None: + """Create the parent directory for an output path when needed.""" + Path(path_text).parent.mkdir(parents=True, exist_ok=True) def write_outputs(path: str, values: dict[str, str]) -> None: + """Append GitHub Action output values to ``GITHUB_OUTPUT``.""" + with open(path, "a", encoding="utf-8") as handle: for key, value in values.items(): handle.write(f"{key}={value}\n") @@ -113,8 +161,17 @@ def resolve_install_target( workspace: str, package_version: str, ) -> InstallTarget: + """Resolve whether the action should install CodeClone from repo or PyPI. + + When the action itself is executed from the same checkout as the workspace, + installing from the repository keeps local action smoke tests honest. + Otherwise the action installs either the explicitly requested PyPI version + or the stable default package version. + """ + action_root = Path(action_path).resolve().parents[2] workspace_root = Path(workspace).resolve() + if action_root == workspace_root: return InstallTarget(requirement=str(action_root), source="repo") @@ -124,112 +181,100 @@ def resolve_install_target( requirement=f"codeclone=={normalized_version}", source="pypi-version", ) - return InstallTarget(requirement="codeclone", source="pypi-latest") + + return InstallTarget( + requirement=f"codeclone=={DEFAULT_CODECLONE_PACKAGE_VERSION}", + source="pypi-default", + ) def run_codeclone(inputs: ActionInputs) -> RunResult: + """Run CodeClone and return output artifact status. + + The action treats analyzer timeouts as internal execution errors and maps + them to CodeClone's internal-error exit code ``5``. + """ + ensure_parent_dir(inputs.json_path) if inputs.sarif: ensure_parent_dir(inputs.sarif_path) + argv = ["codeclone", *build_codeclone_args(inputs)] + try: - completed = subprocess.run(argv, check=False, timeout=600) + completed = subprocess.run(argv, check=False, timeout=600, shell=False) except subprocess.TimeoutExpired: print("::error::CodeClone analysis timed out after 10 minutes") - return RunResult( - exit_code=5, - json_path=inputs.json_path, - json_exists=Path(inputs.json_path).exists(), - sarif_path=inputs.sarif_path, - sarif_exists=inputs.sarif and Path(inputs.sarif_path).exists(), - ) - return RunResult( - exit_code=completed.returncode, - json_path=inputs.json_path, - json_exists=Path(inputs.json_path).exists(), - sarif_path=inputs.sarif_path, - sarif_exists=inputs.sarif and Path(inputs.sarif_path).exists(), - ) - - -def _mapping(value: object) -> dict[str, object]: - return value if isinstance(value, dict) else {} - + return _run_result_from_paths(exit_code=5, inputs=inputs) -def _int(value: object, default: int = 0) -> int: - return value if isinstance(value, int) else default - - -def _str(value: object, default: str = "") -> str: - return value if isinstance(value, str) else default + return _run_result_from_paths(exit_code=completed.returncode, inputs=inputs) def render_pr_comment(report: dict[str, object], *, exit_code: int) -> str: - meta = _mapping(report.get("meta")) - findings = _mapping(report.get("findings")) - findings_summary = _mapping(findings.get("summary")) - clone_summary = _mapping(findings_summary.get("clones")) - families = _mapping(findings_summary.get("families")) - metrics = _mapping(report.get("metrics")) - metrics_summary = _mapping(metrics.get("summary")) - health = _mapping(metrics_summary.get("health")) - baseline = _mapping(meta.get("baseline")) - cache = _mapping(meta.get("cache")) - - health_score = _int(health.get("score"), default=-1) - health_grade = _str(health.get("grade"), default="?") - baseline_status = _str(baseline.get("status"), default="unknown") - cache_used = bool(cache.get("used")) - codeclone_version = _str(meta.get("codeclone_version"), default="?") + """Render a compact Markdown PR review comment from a canonical report.""" + + ctx = _build_pr_comment_context(report) + rows = _build_pr_comment_rows(ctx) + focus = _review_focus( + exit_code=exit_code, + clone_summary=ctx.clone_summary, + dependencies=ctx.dependencies, + coverage_join=ctx.coverage_join, + security_surfaces=ctx.security_surfaces, + overloaded_modules=ctx.overloaded_modules, + ) - status_icon = "white_check_mark" - status_label = "Passed" - if exit_code == 3: - status_icon = "x" - status_label = "Failed (gating)" - elif exit_code != 0: - status_icon = "warning" - status_label = "Error" + status_icon, status_label = _status_label(exit_code) lines = [ COMMENT_MARKER, - "## :microscope: CodeClone Report", + "## CodeClone Review", "", - "| Metric | Value |", - "|--------|-------|", - f"| Health | **{health_score}/100 ({health_grade})** |", - f"| Status | :{status_icon}: {status_label} |", - f"| Baseline | `{baseline_status}` |", - f"| Cache | `{'used' if cache_used else 'not used'}` |", - f"| Version | `{codeclone_version}` |", + ( + f"**{status_icon} {status_label}** · " + f"Health **{ctx.health_score}/100 ({ctx.health_grade})** · " + f"Baseline `{ctx.baseline_status}` · " + f"Cache `{ctx.cache_label}` · " + f"CodeClone `{ctx.codeclone_version}`" + ), "", - "### Findings", - "```text", - _clone_summary_line(clone_summary=clone_summary, families=families), - f"Structural: {_int(families.get('structural'))}", - f"Dead code: {_int(families.get('dead_code'))}", - f"Design: {_int(families.get('design'))}", - "```", + "### Review snapshot", + "| Area | Signal | Review note |", + "|------|--------|-------------|", + *[ + f"| {_table_cell(area)} | {_table_cell(signal)} | {_table_cell(note)} |" + for area, signal, note in rows + ], "", - ":robot: Generated by " + "### Review focus", + *[f"- {item}" for item in focus], + "", + "Security Surfaces are report-only capability inventory, " + "not vulnerability claims. Generated by " 'CodeClone', ] return "\n".join(lines) def write_step_summary(path: str, body: str) -> None: + """Append Markdown content to ``GITHUB_STEP_SUMMARY``.""" + with open(path, "a", encoding="utf-8") as handle: handle.write(body) handle.write("\n") def load_report(path: str) -> dict[str, object]: + """Load a CodeClone JSON report and return an empty mapping on bad shape.""" + with open(path, encoding="utf-8") as handle: loaded = json.load(handle) return loaded if isinstance(loaded, dict) else {} def build_inputs_from_env(env: dict[str, str]) -> ActionInputs: + """Build normalized action inputs from the GitHub Actions environment.""" + return ActionInputs( path=env["INPUT_PATH"], json_path=env["INPUT_JSON_PATH"], @@ -251,13 +296,358 @@ def build_inputs_from_env(env: dict[str, str]) -> ActionInputs: ) -def _clone_summary_line( +def _valued_codeclone_options( + inputs: ActionInputs, +) -> tuple[tuple[object | None, str], ...]: + """Return valued CLI options in deterministic output order.""" + + return ( + (inputs.sarif_path if inputs.sarif else None, "--sarif"), + (inputs.fail_threshold, "--fail-threshold"), + (inputs.fail_complexity, "--fail-complexity"), + (inputs.fail_coupling, "--fail-coupling"), + (inputs.fail_cohesion, "--fail-cohesion"), + (inputs.fail_health, "--fail-health"), + (inputs.baseline_path.strip() or None, "--baseline"), + (inputs.metrics_baseline_path.strip() or None, "--metrics-baseline"), + ) + + +def _boolean_codeclone_flags(inputs: ActionInputs) -> tuple[tuple[bool, str], ...]: + """Return boolean CLI flags in deterministic output order.""" + + return ( + (inputs.no_progress, "--no-progress"), + (inputs.fail_on_new, "--fail-on-new"), + (inputs.fail_on_new_metrics, "--fail-on-new-metrics"), + (inputs.fail_cycles, "--fail-cycles"), + (inputs.fail_dead_code, "--fail-dead-code"), + ) + + +def _run_result_from_paths(*, exit_code: int, inputs: ActionInputs) -> RunResult: + """Build a run result from expected output paths.""" + + json_path = Path(inputs.json_path) + sarif_path = Path(inputs.sarif_path) + + return RunResult( + exit_code=exit_code, + json_path=inputs.json_path, + json_exists=json_path.exists(), + sarif_path=inputs.sarif_path, + sarif_exists=inputs.sarif and sarif_path.exists(), + ) + + +def _mapping(value: object) -> dict[str, object]: + """Return ``value`` when it is a JSON object, otherwise an empty mapping.""" + + return value if isinstance(value, dict) else {} + + +def _int(value: object, default: int = 0) -> int: + """Return an integer JSON value or a default.""" + + return value if isinstance(value, int) else default + + +def _str(value: object, default: str = "") -> str: + """Return a string JSON value or a default.""" + + return value if isinstance(value, str) else default + + +def _float(value: object, default: float = 0.0) -> float: + """Return a numeric JSON value as float or a default.""" + + if isinstance(value, int | float): + return float(value) + return default + + +def _one_decimal(value: object) -> str: + """Format a numeric JSON value with one decimal place.""" + + return f"{_float(value):.1f}" + + +def _percent_from_permille(value: object) -> str: + """Format a permille JSON value as a percentage string.""" + + return f"{_float(value) / 10.0:.1f}%" + + +def _table_cell(value: object) -> str: + """Escape Markdown table cell separators and newlines.""" + + text = str(value) + return text.replace("|", "\\|").replace("\n", " ") + + +def _status_label(exit_code: int) -> tuple[str, str]: + """Map CodeClone exit codes to PR comment status labels.""" + + if exit_code == 0: + return ":white_check_mark:", "Passed" + if exit_code == 3: + return ":x:", "Failed (gating)" + if exit_code == 2: + return ":warning:", "Contract error" + return ":warning:", "Error" + + +def _build_pr_comment_context(report: dict[str, object]) -> _PrCommentContext: + """Extract the report fields needed for PR comment rendering.""" + + meta = _mapping(report.get("meta")) + findings = _mapping(report.get("findings")) + findings_summary = _mapping(findings.get("summary")) + metrics = _mapping(report.get("metrics")) + metrics_summary = _mapping(metrics.get("summary")) + + health = _mapping(metrics_summary.get("health")) + baseline = _mapping(meta.get("baseline")) + cache = _mapping(meta.get("cache")) + + return _PrCommentContext( + clone_summary=_mapping(findings_summary.get("clones")), + families=_mapping(findings_summary.get("families")), + complexity=_mapping(metrics_summary.get("complexity")), + coupling=_mapping(metrics_summary.get("coupling")), + cohesion=_mapping(metrics_summary.get("cohesion")), + dependencies=_mapping(metrics_summary.get("dependencies")), + dead_code=_mapping(metrics_summary.get("dead_code")), + overloaded_modules=_mapping(metrics_summary.get("overloaded_modules")), + coverage_join=_mapping(metrics_summary.get("coverage_join")), + security_surfaces=_mapping(metrics_summary.get("security_surfaces")), + api_surface=_mapping(metrics_summary.get("api_surface")), + health_score=_int(health.get("score"), default=-1), + health_grade=_str(health.get("grade"), default="?"), + baseline_status=_str(baseline.get("status"), default="unknown"), + cache_label="hit" if bool(cache.get("used")) else "miss", + codeclone_version=_str(meta.get("codeclone_version"), default="?"), + ) + + +def _build_pr_comment_rows(ctx: _PrCommentContext) -> list[tuple[str, str, str]]: + """Build the fixed PR comment review snapshot rows.""" + + coverage_signal, coverage_note = _format_coverage_join_row(ctx.coverage_join) + security_signal, security_note = _format_security_surfaces_row( + ctx.security_surfaces + ) + api_signal, api_note = _format_api_surface_row(ctx.api_surface) + + return [ + ( + "Clones", + _clone_signal_line( + clone_summary=ctx.clone_summary, + families=ctx.families, + ), + ( + "review new groups before merge" + if _int(ctx.clone_summary.get("new")) + else "no new clone debt reported" + ), + ), + ( + "Quality", + _format_quality_signal( + complexity=ctx.complexity, + coupling=ctx.coupling, + cohesion=ctx.cohesion, + overloaded_modules=ctx.overloaded_modules, + ), + "structural metric snapshot", + ), + ( + "Dependencies", + _format_dependencies_signal(ctx.dependencies), + ( + "acyclic" + if _int(ctx.dependencies.get("cycles")) == 0 + else "cycle review needed" + ), + ), + ("Coverage Join", coverage_signal, coverage_note), + ("Security Surfaces", security_signal, security_note), + ("API Surface", api_signal, api_note), + ( + "Dead code", + _format_dead_code_signal(ctx.dead_code), + ( + "clean" + if _int(ctx.dead_code.get("high_confidence")) == 0 + else "review candidates" + ), + ), + ] + + +def _format_quality_signal( + *, + complexity: dict[str, object], + coupling: dict[str, object], + cohesion: dict[str, object], + overloaded_modules: dict[str, object], +) -> str: + """Format the quality row signal.""" + + return ( + f"CC max {_int(complexity.get('max'))}, " + f"CBO max {_int(coupling.get('max'))}, " + f"LCOM4 max {_int(cohesion.get('max'))}, " + f"overloaded {_int(overloaded_modules.get('candidates'))}" + ) + + +def _format_dependencies_signal(dependencies: dict[str, object]) -> str: + """Format the dependency profile row signal.""" + + return ( + f"avg {_one_decimal(dependencies.get('avg_depth'))}, " + f"p95 {_int(dependencies.get('p95_depth'))}, " + f"max {_int(dependencies.get('max_depth'))}, " + f"cycles {_int(dependencies.get('cycles'))}" + ) + + +def _format_coverage_join_row(coverage_join: dict[str, object]) -> tuple[str, str]: + """Format the Coverage Join review row.""" + + if not coverage_join: + return "not joined", "no coverage.xml facts in this report" + + coverage_status = _str(coverage_join.get("status"), default="") + signal = ( + f"{_percent_from_permille(coverage_join.get('overall_permille'))} overall, " + f"{_int(coverage_join.get('coverage_hotspots'))} hotspots, " + f"{_int(coverage_join.get('scope_gap_hotspots'))} scope gaps" + ) + note = ( + "joined with coverage.xml" + if coverage_status == "ok" + else f"not joined: {_str(coverage_join.get('invalid_reason'), 'unknown')}" + ) + return signal, note + + +def _format_security_surfaces_row( + security_surfaces: dict[str, object], +) -> tuple[str, str]: + """Format the Security Surfaces review row.""" + + security_items = _int(security_surfaces.get("items")) + signal = ( + f"{security_items} surfaces, " + f"{_int(security_surfaces.get('category_count'))} categories, " + f"{_int(security_surfaces.get('production'))} production" + ) + note = ( + "report-only boundary inventory" + if security_items + else "no security surfaces reported" + ) + return signal, note + + +def _format_api_surface_row(api_surface: dict[str, object]) -> tuple[str, str]: + """Format the API Surface review row.""" + + api_enabled = bool(api_surface.get("enabled")) + signal = ( + f"{_int(api_surface.get('public_symbols'))} symbols, " + f"{_int(api_surface.get('modules'))} modules" + if api_enabled + else "disabled" + ) + note = ( + f"{_int(api_surface.get('breaking'))} breaking, " + f"{_int(api_surface.get('added'))} added" + if api_enabled + else "not part of this run" + ) + return signal, note + + +def _format_dead_code_signal(dead_code: dict[str, object]) -> str: + """Format the dead-code row signal.""" + + return ( + f"{_int(dead_code.get('high_confidence'))} high-confidence, " + f"{_int(dead_code.get('suppressed'))} suppressed" + ) + + +def _review_focus( + *, + exit_code: int, + clone_summary: dict[str, object], + dependencies: dict[str, object], + coverage_join: dict[str, object], + security_surfaces: dict[str, object], + overloaded_modules: dict[str, object], +) -> list[str]: + """Build focused follow-up suggestions for the PR comment.""" + + items: list[str] = [] + + if exit_code == 3: + items.append("CI gates failed; start with rows marked as gating-sensitive.") + elif exit_code == 2: + items.append( + "Contract error; check baseline/config trust before reviewing metrics." + ) + + new_clones = _int(clone_summary.get("new")) + if new_clones: + items.append(f"Review {new_clones} new clone group(s) before merge.") + + cycles = _int(dependencies.get("cycles")) + if cycles: + items.append( + f"Inspect {cycles} dependency cycle(s); cycles are hard structural risk." + ) + + coverage_hotspots = _int(coverage_join.get("coverage_hotspots")) + scope_gaps = _int(coverage_join.get("scope_gap_hotspots")) + if coverage_hotspots or scope_gaps: + items.append( + f"Use Coverage Join for {coverage_hotspots} coverage hotspot(s) " + f"and {scope_gaps} scope gap(s)." + ) + + production_surfaces = _int(security_surfaces.get("production")) + if production_surfaces: + items.append( + f"Treat {production_surfaces} production security surface(s) as " + "review-first boundary code when touched." + ) + + overloaded = _int(overloaded_modules.get("candidates")) + if overloaded: + items.append( + f"Review {overloaded} overloaded module candidate(s) " + "when they intersect this PR." + ) + + if not items: + items.append("No focused review pressure reported by the canonical summary.") + + return items + + +def _clone_signal_line( *, clone_summary: dict[str, object], families: dict[str, object], ) -> str: + """Format the clone summary row signal.""" + return ( - f"Clones: {_int(families.get('clones'))} " - f"({_int(clone_summary.get('new'))} new, " - f"{_int(clone_summary.get('known'))} known)" + f"{_int(families.get('clones'))} total, " + f"{_int(clone_summary.get('new'))} new, " + f"{_int(clone_summary.get('known'))} known" ) diff --git a/.github/actions/codeclone/action.yml b/.github/actions/codeclone/action.yml index 2d0d1f1..f397473 100644 --- a/.github/actions/codeclone/action.yml +++ b/.github/actions/codeclone/action.yml @@ -7,7 +7,7 @@ author: OrenLab branding: icon: copy - color: blue + color: purple inputs: python-version: @@ -18,7 +18,7 @@ inputs: package-version: description: "CodeClone version from PyPI for remote installs (ignored when the action runs from the checked-out CodeClone repo)" required: false - default: "" + default: "2.0.0" path: description: "Project root" diff --git a/.github/actions/codeclone/render_pr_comment.py b/.github/actions/codeclone/render_pr_comment.py index f08668e..79ec0be 100644 --- a/.github/actions/codeclone/render_pr_comment.py +++ b/.github/actions/codeclone/render_pr_comment.py @@ -4,9 +4,19 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Den Rozhnovskiy +"""Render the CodeClone GitHub Action PR comment from a JSON report. + +This entrypoint is intentionally small: it reads action/runtime paths from the +environment, renders the Markdown comment from the canonical JSON report, writes +the comment body file, and exposes GitHub Action outputs for later workflow +steps. +""" + from __future__ import annotations import os +from dataclasses import dataclass +from pathlib import Path from _action_impl import ( load_report, @@ -16,43 +26,76 @@ ) +@dataclass(frozen=True, slots=True) +class _CommentRuntime: + """Environment-derived runtime paths for PR comment rendering.""" + + report_path: Path + output_path: Path + exit_code: int + github_output: str | None + step_summary: str | None + + def main() -> int: - report_path = os.environ["REPORT_PATH"] - output_path = os.environ["COMMENT_OUTPUT_PATH"] - exit_code = int(os.environ["ANALYSIS_EXIT_CODE"]) - - if not os.path.exists(report_path): - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - write_outputs( - github_output, - { - "comment-exists": "false", - "comment-body-path": output_path, - }, - ) + """Render a PR comment when a CodeClone report exists.""" + + runtime = _comment_runtime_from_env(os.environ) + + if not runtime.report_path.exists(): + _write_comment_outputs(runtime, comment_exists=False) return 0 - body = render_pr_comment(load_report(report_path), exit_code=exit_code) - with open(output_path, "w", encoding="utf-8") as handle: - handle.write(body) - handle.write("\n") - - step_summary = os.environ.get("GITHUB_STEP_SUMMARY") - if step_summary: - write_step_summary(step_summary, body) - - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - write_outputs( - github_output, - { - "comment-exists": "true", - "comment-body-path": output_path, - }, - ) + body = render_pr_comment( + load_report(str(runtime.report_path)), + exit_code=runtime.exit_code, + ) + _write_comment_body(runtime.output_path, body) + + if runtime.step_summary: + write_step_summary(runtime.step_summary, body) + + _write_comment_outputs(runtime, comment_exists=True) return 0 +def _comment_runtime_from_env(env: os._Environ[str]) -> _CommentRuntime: + """Build comment-rendering runtime from GitHub Action environment values.""" + + return _CommentRuntime( + report_path=Path(env["REPORT_PATH"]), + output_path=Path(env["COMMENT_OUTPUT_PATH"]), + exit_code=int(env["ANALYSIS_EXIT_CODE"]), + github_output=env.get("GITHUB_OUTPUT"), + step_summary=env.get("GITHUB_STEP_SUMMARY"), + ) + + +def _write_comment_body(path: Path, body: str) -> None: + """Write the rendered Markdown comment body.""" + + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(f"{body}\n", encoding="utf-8") + + +def _write_comment_outputs( + runtime: _CommentRuntime, + *, + comment_exists: bool, +) -> None: + """Expose PR comment metadata through ``GITHUB_OUTPUT`` when available.""" + + if not runtime.github_output: + return + + write_outputs( + runtime.github_output, + { + "comment-exists": "true" if comment_exists else "false", + "comment-body-path": str(runtime.output_path), + }, + ) + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/.github/actions/codeclone/run_codeclone.py b/.github/actions/codeclone/run_codeclone.py index b253289..81339a2 100644 --- a/.github/actions/codeclone/run_codeclone.py +++ b/.github/actions/codeclone/run_codeclone.py @@ -4,29 +4,52 @@ # SPDX-License-Identifier: MPL-2.0 # Copyright (c) 2026 Den Rozhnovskiy +"""Run CodeClone inside the GitHub Action runtime. + +This entrypoint normalizes GitHub Action inputs from the environment, executes +CodeClone, and exposes artifact paths plus analyzer exit status through +GITHUB_OUTPUT. The process itself returns 0 so later workflow steps can +decide how to handle the analyzer result. +""" + from __future__ import annotations import os -from _action_impl import build_inputs_from_env, run_codeclone, write_outputs +from _action_impl import RunResult, build_inputs_from_env, run_codeclone, write_outputs def main() -> int: + """Run CodeClone and publish action outputs.""" + result = run_codeclone(build_inputs_from_env(dict(os.environ))) - github_output = os.environ.get("GITHUB_OUTPUT") - if github_output: - write_outputs( - github_output, - { - "exit-code": str(result.exit_code), - "json-path": result.json_path, - "json-exists": str(result.json_exists).lower(), - "sarif-path": result.sarif_path, - "sarif-exists": str(result.sarif_exists).lower(), - }, - ) + _write_run_outputs(github_output=os.environ.get("GITHUB_OUTPUT"), result=result) return 0 +def _write_run_outputs(*, github_output: str | None, result: RunResult) -> None: + """Expose CodeClone run metadata through GITHUB_OUTPUT when available.""" + + if not github_output: + return + + write_outputs( + github_output, + { + "exit-code": str(result.exit_code), + "json-path": result.json_path, + "json-exists": _bool_output(result.json_exists), + "sarif-path": result.sarif_path, + "sarif-exists": _bool_output(result.sarif_exists), + }, + ) + + +def _bool_output(value: bool) -> str: + """Format a boolean value for GitHub Action outputs.""" + + return str(value).lower() + + if __name__ == "__main__": raise SystemExit(main()) diff --git a/AGENTS.md b/AGENTS.md index a645b79..8f4190f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -61,10 +61,10 @@ Key artifacts: - `.cache/codeclone/cache.json` — analysis cache (integrity-checked) - `.cache/codeclone/report.html|report.json|report.md|report.sarif|report.txt` — reports - `codeclone-mcp` — optional read-only MCP server (install via `codeclone[mcp]`) -- `extensions/vscode-codeclone/` — preview VS Code extension as a native, read-only IDE client over `codeclone-mcp` -- `extensions/claude-desktop-codeclone/` — preview Claude Desktop `.mcpb` bundle as a local install wrapper over +- `extensions/vscode-codeclone/` — stable VS Code extension as a native, read-only IDE client over `codeclone-mcp` +- `extensions/claude-desktop-codeclone/` — stable Claude Desktop `.mcpb` bundle as a local install wrapper over `codeclone-mcp` -- `plugins/codeclone/` + `.agents/plugins/marketplace.json` — preview Codex plugin as a native local discovery layer +- `plugins/codeclone/` + `.agents/plugins/marketplace.json` — stable Codex plugin as a native local discovery layer over `codeclone-mcp`, with a bundled CodeClone review skill - MCP runs are in-memory only; review markers are session-local and must never leak into baseline/cache/report artifacts @@ -109,7 +109,7 @@ smoke: ```bash cd extensions/vscode-codeclone -vsce package --pre-release --out /tmp/codeclone.vsix +vsce package --out /tmp/codeclone.vsix ``` If you touched the Claude Desktop bundle surface, also run: @@ -463,11 +463,11 @@ Use this map to route changes to the right owner module. - `codeclone/ui_messages/*` — CLI text/marker/help constants and formatter helpers. Keep message policy centralized. - `docs/`, `mkdocs.yml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py` — docs-site source, publication workflow, and live sample-report generation; keep published docs aligned with code contracts. -- `extensions/vscode-codeclone/*` — preview VS Code extension surface; keep it baseline-aware, triage-first, +- `extensions/vscode-codeclone/*` — stable VS Code extension surface; keep it baseline-aware, triage-first, source-first, and faithful to MCP/canonical report semantics rather than building a second analyzer or report model. -- `extensions/claude-desktop-codeclone/*` — preview Claude Desktop bundle surface; keep it local-stdio-only, +- `extensions/claude-desktop-codeclone/*` — stable Claude Desktop bundle surface; keep it local-stdio-only, launcher-focused, and faithful to `codeclone-mcp` rather than re-implementing MCP semantics in the bundle layer. -- `plugins/codeclone/*`, `.agents/plugins/marketplace.json` — preview Codex plugin surface; keep it Codex-native, +- `plugins/codeclone/*`, `.agents/plugins/marketplace.json` — stable Codex plugin surface; keep it Codex-native, conservative-first, skills-guided, and faithful to `codeclone-mcp` rather than inventing plugin-only analysis logic. - `tests/` — executable specification: architecture rules, contracts, goldens, invariants, regressions. diff --git a/CHANGELOG.md b/CHANGELOG.md index 7665c44..cd46c27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Changelog +## [2.0.0] - 2026-04-30 + +`2.0.0` promotes the completed 2.0 release line to the stable public contract. + +### Release + +- Mark the Python package as stable (`2.0.0`) while keeping the established baseline, cache, report, and metrics + baseline schemas unchanged. +- Make stable install guidance the default across README, docs, MCP guides, and local integration surfaces; prerelease + installs remain available only as explicit version pins. +- Align VS Code, Claude Desktop, and Codex integration metadata with the final CodeClone 2.0 MCP package. +- Preserve the 2.0 behavior set: canonical package layout, adaptive dependency depth profiling, Coverage Join, + report-only Security Surfaces, read-only MCP, and native IDE/agent projections. + ## [2.0.0b7] - 2026-04-28 `2.0.0b7` is a beta hotfix for packaging-only issues found after the `2.0.0b6` publish. diff --git a/README.md b/README.md index 047d97e..4272b1b 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,5 @@
+ -
-

- Structural code quality analysis for Python -

+

Structural code quality analysis for Python

-

- PyPI - Downloads - Tests - Benchmark - Python - codeclone 90 (A) - License -

+

+ PyPI + Status + Downloads + Python + codeclone 90 (A) + License +

+ +

+ Tests + Benchmark +

+ + --- @@ -42,8 +46,8 @@ Live sample report: [orenlab.github.io/codeclone/examples/report/](https://orenlab.github.io/codeclone/examples/report/) > [!NOTE] -> This README and docs site track the in-development `v2.0.x` line from `main`. -> For the latest stable CodeClone documentation (`v1.4.4`), see the +> This README and docs site document the CodeClone `2.0` release line. +> For the previous `1.4.x` line, see the > [`v1.4.4` README](https://github.com/orenlab/codeclone/blob/v1.4.4/README.md) > and the > [`v1.4.4` docs tree](https://github.com/orenlab/codeclone/tree/v1.4.4/docs). @@ -66,7 +70,7 @@ Live sample report: ## Quick Start ```bash -uv tool install codeclone # use --pre for beta +uv tool install codeclone codeclone . # analyze codeclone . --html # HTML report @@ -112,17 +116,22 @@ codeclone . --ci
What --ci enables -The --ci preset equals --fail-on-new --no-color --quiet. + +The `--ci` preset equals `--fail-on-new --no-color --quiet`. When a trusted metrics baseline is loaded, CI mode also enables ---fail-on-new-metrics. +`--fail-on-new-metrics`.
+> [!TIP] +> Run `codeclone . --update-baseline` once after install to establish your CI reference point. +> Commit the baseline file — it becomes the contract CI enforces on every push. + ### GitHub Action CodeClone also ships a composite GitHub Action for PR and CI workflows: ```yaml -- uses: orenlab/codeclone/.github/actions/codeclone@main +- uses: orenlab/codeclone/.github/actions/codeclone@v2 with: fail-on-new: "true" sarif: "true" @@ -185,9 +194,9 @@ Triage-first MCP server for AI agents and IDE clients, built on the same canonic contract: never mutates source, baselines, or repo state. ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" # or -uv pip install --pre "codeclone[mcp]" +uv pip install "codeclone[mcp]" # local stdio clients codeclone-mcp --transport stdio @@ -196,6 +205,11 @@ codeclone-mcp --transport stdio codeclone-mcp --transport streamable-http ``` +> [!WARNING] +> Analysis tools require an absolute repository root. Relative roots such as `.` are rejected. +> Keep `stdio` as the default transport for local IDE and agent clients; HTTP exposure beyond +> loopback requires explicit `--allow-remote`. + [MCP usage guide](https://orenlab.github.io/codeclone/mcp/) · [MCP interface contract](https://orenlab.github.io/codeclone/book/20-mcp-interface/) @@ -289,7 +303,7 @@ Report contract: [Report contract](https://orenlab.github.io/codeclone/book/08-r { "report_schema_version": "2.10", "meta": { - "codeclone_version": "2.0.0b6", + "codeclone_version": "2.0.0", "project_name": "...", "scan_root": ".", "report_mode": "full", @@ -434,13 +448,39 @@ Suppression contract: ## How It Works -1. **Parse** — Python source to AST -2. **Normalize** — canonical structure (robust to renaming, formatting) -3. **CFG** — per-function control flow graph -4. **Fingerprint** — stable hash computation -5. **Group** — function, block, and segment clone groups -6. **Metrics** — complexity, coupling, cohesion, dependencies, dead code, health -7. **Gate** — baseline comparison, threshold checks +
+Pipeline overview + +``` +Python source + │ + ▼ + Parse ──────── AST per file + │ + ▼ + Normalize ───── canonical structure (rename/format-resistant) + │ + ▼ + CFG ─────────── per-function control flow graph + │ + ▼ + Fingerprint ──── stable hash per function / block / segment + │ + ▼ + Group ────────── clone groups + structural findings + │ + ▼ + Metrics ─────── complexity · coupling · cohesion · dependencies + dead code · adoption · security surfaces · health + │ + ▼ + Gate ────────── baseline diff · threshold checks · CI exit codes + │ + ▼ + Report ─────── HTML · JSON · Markdown · SARIF · text +``` + +
Architecture: [Architecture narrative](https://orenlab.github.io/codeclone/architecture/) · CFG semantics: [CFG semantics](https://orenlab.github.io/codeclone/cfg/) @@ -491,5 +531,7 @@ Versions released before this change remain under their original license terms. - **Docs:** - **Issues:** +- **Discussions:** - **PyPI:** -- **Licenses:** [MPL-2.0](https://github.com/orenlab/codeclone/blob/main/LICENSE) · [MIT docs](https://github.com/orenlab/codeclone/blob/main/LICENSE-MIT) · [Scope map](https://github.com/orenlab/codeclone/blob/main/LICENSES.md) +- **Licenses: + ** [MPL-2.0](https://github.com/orenlab/codeclone/blob/main/LICENSE) · [MIT docs](https://github.com/orenlab/codeclone/blob/main/LICENSE-MIT) · [Scope map](https://github.com/orenlab/codeclone/blob/main/LICENSES.md) diff --git a/codeclone/report/html/widgets/icons.py b/codeclone/report/html/widgets/icons.py index 87b68c2..12dde5c 100644 --- a/codeclone/report/html/widgets/icons.py +++ b/codeclone/report/html/widgets/icons.py @@ -27,15 +27,15 @@ def _svg_with_class(size: int, sw: str, body: str, *, class_name: str = "") -> s BRAND_LOGO = ( - '

+ + + + CodeClone + +

+ +

+ Structural code quality analysis for Python +

+ +

+ PyPI + Tests + Benchmark + Python +

+ +CodeClone provides deterministic structural code quality analysis for Python. +It detects architectural duplication, computes quality metrics, and enforces +CI gates with baseline-aware governance: known technical debt stays accepted, +new regressions stay visible. + +The same analysis pipeline powers CLI reports, CI checks, the MCP server, and +native IDE/agent clients. + +- Documentation: +- Live sample report: +- Source: +- Issues: + +## Features + +- Clone detection: function, block, and report-only segment clones. +- Structural findings: duplicated branch families, clone guard/exit divergence, + and clone-cohort drift. +- Quality metrics: complexity, coupling, cohesion, dependency cycles, adaptive + dependency depth, dead code, health score, and overloaded-module profiling. +- Coverage Join: combines Cobertura XML with CodeClone units to surface + coverage hotspots and scope gaps. +- Security Surfaces: report-only inventory of security-relevant boundaries and + sensitive capabilities. It does not claim vulnerabilities. +- Baseline governance: separates accepted legacy debt from new regressions. +- Reports: HTML, JSON, Markdown, SARIF, and text from one report payload. +- MCP control surface: read-only agent/IDE interface over the same pipeline. +- Native clients: VS Code extension, Claude Desktop bundle, and Codex plugin. + +## Quick Start + +```bash +uv tool install codeclone + +codeclone . # analyze +codeclone . --html # write HTML report +codeclone . --html --open-html-report +codeclone . --json --md --sarif --text +codeclone . --ci # CI mode +``` + +Run without installing: + +```bash +uvx codeclone@latest . +``` + +## CI Workflow + +```bash +# 1. Generate and commit the baseline +codeclone . --update-baseline + +# 2. Enforce it in CI +codeclone . --ci +``` + +`--ci` enables baseline-aware gating and exits with deterministic status codes: + +| Code | Meaning | +|------|---------| +| `0` | Success | +| `2` | Contract error, such as an untrusted baseline or invalid config | +| `3` | Gating failure, such as new clones or failed metric thresholds | +| `5` | Internal error | + +## Reports + +```bash +codeclone . --html +codeclone . --json +codeclone . --md +codeclone . --sarif +codeclone . --text +``` + +All report formats are rendered from the same deterministic report payload. +The HTML report is intended for human review; JSON, SARIF, Markdown, and text +are intended for automation and CI surfaces. + +Report contract: + + +## MCP and Native Clients + +Install the optional MCP runtime when you want CodeClone in AI agents or IDEs: + +```bash +uv tool install "codeclone[mcp]" + +codeclone-mcp --transport stdio +``` + +The MCP server is read-only by contract. It does not mutate source files, +baselines, cache, or repository state. + +Client surfaces: + +| Surface | Link | +|---------|------| +| VS Code extension | | +| Claude Desktop bundle | | +| Codex plugin | | + +MCP docs: + + +## Configuration + +CodeClone reads project configuration from `pyproject.toml`: + +```toml +[tool.codeclone] +baseline = "codeclone.baseline.json" +min_loc = 10 +min_stmt = 6 +block_min_loc = 20 +block_min_stmt = 8 +fail_on_new = true +fail_cycles = true +fail_dead_code = true +fail_health = 80 +``` + +Precedence is deterministic: + +```text +CLI flags > pyproject.toml > built-in defaults +``` + +Config reference: + + +## License + +- Code: MPL-2.0 (`LICENSE`) +- Documentation and docs-site content: MIT (`LICENSE-MIT`) + +License scope map: + diff --git a/docs/README.md b/docs/README.md index 47fc996..7e56b44 100644 --- a/docs/README.md +++ b/docs/README.md @@ -4,8 +4,8 @@ This site is built with MkDocs and published to [orenlab.github.io/codeclone](https://orenlab.github.io/codeclone/). !!! note "Version Notice" - This site currently documents the in-development `v2.0.x` line from `main`. - For the latest stable CodeClone documentation (`v1.4.4`), see the + This site documents the CodeClone `2.0` release line. + For the previous `1.4.x` line, see the [`v1.4.4` README](https://github.com/orenlab/codeclone/blob/v1.4.4/README.md) and the [`v1.4.4` docs tree](https://github.com/orenlab/codeclone/tree/v1.4.4/docs). diff --git a/docs/assets/codeclone-wordmark-dark.svg b/docs/assets/codeclone-wordmark-dark.svg index 6716ada..0ee0ba8 100644 --- a/docs/assets/codeclone-wordmark-dark.svg +++ b/docs/assets/codeclone-wordmark-dark.svg @@ -1,17 +1,10 @@ - - - - - - - CodeClone + + + + + + + CodeClone diff --git a/docs/assets/codeclone-wordmark.svg b/docs/assets/codeclone-wordmark.svg index d2f8c96..4d234c1 100644 --- a/docs/assets/codeclone-wordmark.svg +++ b/docs/assets/codeclone-wordmark.svg @@ -1,17 +1,10 @@ - - - - - - - CodeClone + + + + + + + CodeClone diff --git a/docs/book/04-config-and-defaults.md b/docs/book/04-config-and-defaults.md index 9365533..6197453 100644 --- a/docs/book/04-config-and-defaults.md +++ b/docs/book/04-config-and-defaults.md @@ -187,7 +187,7 @@ Dependency depth config note: CLI or `pyproject.toml` option. - Dependency depth now uses an internal adaptive profile based on `avg_depth`, `p95_depth`, and `max_depth` for the internal module graph. -- There is no user-facing knob to tune that model in `2.0.0b6`. +- There is no user-facing knob to tune that model in `2.0.0`. Metrics baseline path selection contract: diff --git a/docs/book/08-report.md b/docs/book/08-report.md index 82b1cf5..814dc22 100644 --- a/docs/book/08-report.md +++ b/docs/book/08-report.md @@ -2,7 +2,7 @@ ## Purpose -Define the canonical report contract in `2.0.0b6`: report schema `2.10` plus +Define the canonical report contract in `2.0.0`: report schema `2.10` plus deterministic text/Markdown/SARIF/HTML projections. ## Public surface diff --git a/docs/book/20-mcp-interface.md b/docs/book/20-mcp-interface.md index a1775a6..a54070b 100644 --- a/docs/book/20-mcp-interface.md +++ b/docs/book/20-mcp-interface.md @@ -2,15 +2,15 @@ ## Purpose -Define the current public MCP surface in the `2.0` beta line. +Define the current public MCP surface in the CodeClone `2.0` release line. The MCP layer is optional, read-only, and built on the same canonical pipeline/report contracts as the CLI. It does not create a second analysis engine or a second persistence model. !!! note "Read-only integration contract" -MCP surfaces the same canonical report and run state as the CLI and HTML -report. It must not mutate source, baseline, cache, or report artifacts. + MCP surfaces the same canonical report and run state as the CLI and HTML + report. It must not mutate source, baseline, cache, or report artifacts. ## Public surface @@ -45,9 +45,9 @@ Current server characteristics: or `off` !!! warning "Absolute roots and remote exposure" -Analysis tools require an absolute repository root, and HTTP exposure -beyond loopback is intentionally explicit. Keep `stdio` as the default for -local IDE and agent clients. + Analysis tools require an absolute repository root, and HTTP exposure + beyond loopback is intentionally explicit. Keep `stdio` as the default for + local IDE and agent clients. ## Tools @@ -66,7 +66,7 @@ second, then drill into one finding or one hotspot family. | `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Production-first first-pass view over one stored run. | | `help` | `topic`, `detail` | Bounded workflow/contract guidance for supported MCP topics. | | `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Run-to-run delta view over findings and health; returns `incomparable` when roots/settings differ. | -| `evaluate_gates` | `run_id`, gate flags, threshold overrides, `coverage_min` | Preview CI/gating decisions against a stored run without mutating process or repo state. | +| `evaluate_gates` | `run_id`, gate flags, threshold overrides, `coverage_min` | Evaluate CI/gating decisions against a stored run without mutating process or repo state. | ### Report and finding projection tools diff --git a/docs/book/21-vscode-extension.md b/docs/book/21-vscode-extension.md index 14b2c5c..44d72f2 100644 --- a/docs/book/21-vscode-extension.md +++ b/docs/book/21-vscode-extension.md @@ -135,7 +135,7 @@ The extension runs as a workspace extension and requires: - local filesystem access - local git access for changed-files review - a local `codeclone-mcp` launcher, or an explicitly configured launcher -- CodeClone `2.0.0b4` or newer +- CodeClone `2.0.0` or newer In `auto` mode, launcher resolution prefers the current workspace virtualenv before `PATH`. Runtime and version-mismatch messages identify that resolved launcher source. @@ -181,7 +181,7 @@ For this reason: ## Non-guarantees -- Exact view grouping and copy may evolve between beta releases. +- Exact view grouping and copy may evolve between extension releases. - Internal client-side caching and view-model shaping may evolve as long as the extension remains faithful to MCP and canonical report semantics. - Explorer decoration styling, review-loop polish, and other non-contract UI diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md index 7595832..155e594 100644 --- a/docs/book/appendix/b-schema-layouts.md +++ b/docs/book/appendix/b-schema-layouts.md @@ -2,14 +2,14 @@ ## Purpose -Compact structural layouts for baseline/cache/report contracts in `2.0.0b6`. +Compact structural layouts for baseline/cache/report contracts in `2.0.0`. ## Baseline schema (`2.1`) ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.0b6" }, + "generator": { "name": "codeclone", "version": "2.0.0" }, "schema_version": "2.1", "fingerprint_version": "1", "python_tag": "cp314", @@ -60,7 +60,7 @@ Notes: ```json { "meta": { - "generator": { "name": "codeclone", "version": "2.0.0b6" }, + "generator": { "name": "codeclone", "version": "2.0.0" }, "schema_version": "1.2", "python_tag": "cp314", "created_at": "2026-03-11T00:00:00Z", @@ -153,7 +153,7 @@ Notes: { "report_schema_version": "2.10", "meta": { - "codeclone_version": "2.0.0b6", + "codeclone_version": "2.0.0", "project_name": "codeclone", "scan_root": ".", "analysis_mode": "full", @@ -503,7 +503,7 @@ Notes: "tool": { "driver": { "name": "codeclone", - "version": "2.0.0b6", + "version": "2.0.0", "rules": [ { "id": "CCLONE001", diff --git a/docs/claude-desktop-bundle.md b/docs/claude-desktop-bundle.md index c742616..0887363 100644 --- a/docs/claude-desktop-bundle.md +++ b/docs/claude-desktop-bundle.md @@ -23,14 +23,14 @@ The bundle prefers the current workspace launcher first: ```bash uv venv -uv pip install --python .venv/bin/python --pre "codeclone[mcp]" +uv pip install --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` Global fallback: ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" codeclone-mcp --help ``` diff --git a/docs/codex-plugin.md b/docs/codex-plugin.md index 6f6d5e8..0150418 100644 --- a/docs/codex-plugin.md +++ b/docs/codex-plugin.md @@ -18,14 +18,14 @@ Repo-local discovery via `.agents/plugins/marketplace.json`. ```bash uv venv -uv pip install --python .venv/bin/python --pre "codeclone[mcp]" +uv pip install --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` Global fallback: ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" codeclone-mcp --help ``` diff --git a/docs/mcp.md b/docs/mcp.md index 06a76b8..bdeda02 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -17,13 +17,13 @@ Works with any MCP-capable client regardless of backend model. === "Standalone tool" ```bash title="Install the MCP launcher as a standalone tool" - uv tool install --pre "codeclone[mcp]" + uv tool install "codeclone[mcp]" ``` === "Existing environment" ```bash title="Install the MCP extra into the current environment" - uv pip install --pre "codeclone[mcp]" + uv pip install "codeclone[mcp]" ``` ## Quick client setup @@ -103,7 +103,7 @@ Run retention is bounded: default `4`, max `10` (`--history-limit`). If a tool request omits `processes`, MCP defers process-count policy to the core CodeClone runtime. -Current `b6` MCP surface: `21` tools, `7` fixed resources, and `3` +Current CodeClone `2.0` MCP surface: `21` tools, `7` fixed resources, and `3` run-scoped URI templates. ## Tool surface @@ -121,7 +121,7 @@ run-scoped URI templates. | `get_remediation` | Remediation payload for one finding | | `list_hotspots` | Priority-ranked hotspot views; preferred before broad listing | | `get_report_section` | Read report sections; `metrics_detail` is paginated with family/path filters | -| `evaluate_gates` | Preview CI gating decisions | +| `evaluate_gates` | Evaluate CI gating decisions | | `check_clones` | Clone findings only; narrower than `list_findings` | | `check_complexity` | Complexity hotspots only | | `check_coupling` | Coupling hotspots only | @@ -344,13 +344,13 @@ If `codeclone-mcp` is not on `PATH`, use an absolute path to the launcher. ## Troubleshooting -| Problem | Fix | -|-----------------------------------------------------------|-------------------------------------------------------------------------------------| -| `CodeClone MCP support requires the optional 'mcp' extra` | `uv tool install --pre "codeclone[mcp]"` or `uv pip install --pre "codeclone[mcp]"` | -| Client cannot find `codeclone-mcp` | `uv tool install --pre "codeclone[mcp]"` or use an absolute launcher path | -| Client only accepts remote MCP | Use `streamable-http` transport | -| Agent reads stale results | Call `analyze_repository` again; `latest` always points to the most recent run | -| `changed_paths` rejected | Pass a `list[str]` of repo-relative paths, not a comma-separated string | +| Problem | Fix | +|-----------------------------------------------------------|--------------------------------------------------------------------------------| +| `CodeClone MCP support requires the optional 'mcp' extra` | `uv tool install "codeclone[mcp]"` or `uv pip install "codeclone[mcp]"` | +| Client cannot find `codeclone-mcp` | `uv tool install "codeclone[mcp]"` or use an absolute launcher path | +| Client only accepts remote MCP | Use `streamable-http` transport | +| Agent reads stale results | Call `analyze_repository` again; `latest` always points to the most recent run | +| `changed_paths` rejected | Pass a `list[str]` of repo-relative paths, not a comma-separated string | ## See also diff --git a/docs/terms-of-use.md b/docs/terms-of-use.md index 82604b4..34b08b1 100644 --- a/docs/terms-of-use.md +++ b/docs/terms-of-use.md @@ -39,7 +39,7 @@ you build and secure that deployment separately. ## Support and updates -CodeClone integrations may evolve during the `2.0.x` beta line. Published docs, +CodeClone integrations may evolve during the `2.x` release line. Published docs, tests, and changelog entries define the intended contract surface for each release. diff --git a/docs/vscode-extension.md b/docs/vscode-extension.md index d488d7a..edcd050 100644 --- a/docs/vscode-extension.md +++ b/docs/vscode-extension.md @@ -1,7 +1,6 @@ # VS Code Extension -CodeClone ships a preview VS Code extension in -`extensions/vscode-codeclone/`. +CodeClone ships a stable VS Code extension in `extensions/vscode-codeclone/`. It is a native IDE surface over `codeclone-mcp` and is designed for baseline-aware, triage-first structural review inside the editor. @@ -29,21 +28,21 @@ It does not create a second truth model and it does not mutate the repository. The extension needs a local `codeclone-mcp` launcher. -Minimum supported CodeClone version: `2.0.0b4`. +Minimum supported CodeClone version: `2.0.0`. In `auto` mode, it checks the current workspace virtualenv before falling back to `PATH`. Runtime and version-mismatch messages identify that resolved launcher source. -Recommended install for the preview extension: +Recommended install: ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" ``` If you want the launcher inside the current environment instead: ```bash -uv pip install --pre "codeclone[mcp]" +uv pip install "codeclone[mcp]" ``` Verify the launcher: diff --git a/extensions/claude-desktop-codeclone/README.md b/extensions/claude-desktop-codeclone/README.md index 37ee769..83da9e9 100644 --- a/extensions/claude-desktop-codeclone/README.md +++ b/extensions/claude-desktop-codeclone/README.md @@ -20,14 +20,14 @@ Recommended workspace-local setup: ```bash uv venv -uv pip install --python .venv/bin/python --pre "codeclone[mcp]" +uv pip install --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` Global fallback: ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" codeclone-mcp --help ``` diff --git a/extensions/claude-desktop-codeclone/manifest.json b/extensions/claude-desktop-codeclone/manifest.json index ccdf403..ae51e65 100644 --- a/extensions/claude-desktop-codeclone/manifest.json +++ b/extensions/claude-desktop-codeclone/manifest.json @@ -2,7 +2,7 @@ "manifest_version": "0.3", "name": "codeclone", "display_name": "CodeClone", - "version": "2.0.0-b6.0", + "version": "2.0.0", "description": "Baseline-aware structural review for Claude Desktop through a local CodeClone MCP launcher.", "long_description": "CodeClone for Claude Desktop wraps the local codeclone-mcp launcher as an MCP bundle. It keeps Claude on the same canonical MCP surface used by the CLI, HTML report, VS Code extension, and Codex plugin — read-only, baseline-aware, local stdio only.", "author": { @@ -64,7 +64,7 @@ }, { "name": "evaluate_gates", - "description": "Preview CI gating decisions for the current run." + "description": "Evaluate CI gating decisions for the current run." }, { "name": "generate_pr_summary", diff --git a/extensions/claude-desktop-codeclone/media/icon.png b/extensions/claude-desktop-codeclone/media/icon.png index b8bf9fd..31388ba 100644 Binary files a/extensions/claude-desktop-codeclone/media/icon.png and b/extensions/claude-desktop-codeclone/media/icon.png differ diff --git a/extensions/claude-desktop-codeclone/package-lock.json b/extensions/claude-desktop-codeclone/package-lock.json index 0e6d72b..4056196 100644 --- a/extensions/claude-desktop-codeclone/package-lock.json +++ b/extensions/claude-desktop-codeclone/package-lock.json @@ -1,12 +1,12 @@ { "name": "@orenlab/codeclone-claude-desktop", - "version": "2.0.0-b6.0", + "version": "2.0.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@orenlab/codeclone-claude-desktop", - "version": "2.0.0-b6.0", + "version": "2.0.0", "license": "MPL-2.0", "engines": { "node": ">=20.0.0" diff --git a/extensions/claude-desktop-codeclone/package.json b/extensions/claude-desktop-codeclone/package.json index 5abfc93..dd3847c 100644 --- a/extensions/claude-desktop-codeclone/package.json +++ b/extensions/claude-desktop-codeclone/package.json @@ -1,6 +1,6 @@ { "name": "@orenlab/codeclone-claude-desktop", - "version": "2.0.0-b6.0", + "version": "2.0.0", "private": true, "description": "Claude Desktop MCP bundle wrapper for the local CodeClone MCP launcher.", "license": "MPL-2.0", diff --git a/extensions/vscode-codeclone/CHANGELOG.md b/extensions/vscode-codeclone/CHANGELOG.md index 4afa1af..ed9708d 100644 --- a/extensions/vscode-codeclone/CHANGELOG.md +++ b/extensions/vscode-codeclone/CHANGELOG.md @@ -1,5 +1,10 @@ # Change Log +## 0.2.6 + +- align setup guidance with the stable CodeClone `2.0.0` MCP package +- require CodeClone `2.0.0` or newer for the final 2.0 release line + ## 0.2.5 - pin the packaging toolchain to `@vscode/vsce@2.25.0` to remove the vulnerable transitive `uuid<14` chain from the diff --git a/extensions/vscode-codeclone/README.md b/extensions/vscode-codeclone/README.md index d585d95..4774cda 100644 --- a/extensions/vscode-codeclone/README.md +++ b/extensions/vscode-codeclone/README.md @@ -9,8 +9,6 @@ creating a second truth model. The extension stays read-only with respect to repository state and uses the same canonical report semantics as the CLI, HTML report, and MCP server. -This extension is published as a preview for the current `2.0.x` beta line. - ## What it is for CodeClone inside VS Code is designed for: @@ -42,21 +40,21 @@ report inside the sidebar. CodeClone for VS Code needs a local `codeclone-mcp` launcher. -Minimum supported CodeClone version: `2.0.0b4`. +Minimum supported CodeClone version: `2.0.0`. In `auto` mode, the extension checks the current workspace virtualenv before falling back to `PATH`. Runtime and version-mismatch messages identify that resolved launcher source. -Recommended install for the preview extension: +Recommended install: ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" ``` If you want the launcher inside the current environment instead: ```bash -uv pip install --pre "codeclone[mcp]" +uv pip install "codeclone[mcp]" ``` Verify the launcher: diff --git a/extensions/vscode-codeclone/media/icon-source.svg b/extensions/vscode-codeclone/media/icon-source.svg index dd4d4d2..a813208 100644 --- a/extensions/vscode-codeclone/media/icon-source.svg +++ b/extensions/vscode-codeclone/media/icon-source.svg @@ -1,34 +1,17 @@ - - - + + + diff --git a/extensions/vscode-codeclone/media/icon.png b/extensions/vscode-codeclone/media/icon.png index b8bf9fd..31388ba 100644 Binary files a/extensions/vscode-codeclone/media/icon.png and b/extensions/vscode-codeclone/media/icon.png differ diff --git a/extensions/vscode-codeclone/package-lock.json b/extensions/vscode-codeclone/package-lock.json index 87a0aa7..cbc2d73 100644 --- a/extensions/vscode-codeclone/package-lock.json +++ b/extensions/vscode-codeclone/package-lock.json @@ -1,12 +1,12 @@ { "name": "codeclone", - "version": "0.2.5", + "version": "0.2.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codeclone", - "version": "0.2.5", + "version": "0.2.6", "license": "MPL-2.0", "devDependencies": { "@types/node": "^25.5.2", diff --git a/extensions/vscode-codeclone/package.json b/extensions/vscode-codeclone/package.json index 8fe0aaa..722efdc 100644 --- a/extensions/vscode-codeclone/package.json +++ b/extensions/vscode-codeclone/package.json @@ -2,8 +2,7 @@ "name": "codeclone", "displayName": "CodeClone", "description": "Baseline-aware, triage-first structural review for Python, powered by CodeClone MCP.", - "version": "0.2.5", - "preview": true, + "version": "0.2.6", "publisher": "orenlab", "license": "MPL-2.0", "repository": { diff --git a/extensions/vscode-codeclone/src/constants.js b/extensions/vscode-codeclone/src/constants.js index 675e033..749d524 100644 --- a/extensions/vscode-codeclone/src/constants.js +++ b/extensions/vscode-codeclone/src/constants.js @@ -11,7 +11,7 @@ const HELP_TOPICS = [ ]; const OPTIONAL_HELP_TOPICS = [ - {topic: "coverage", minimumVersion: "2.0.0b5"}, + {topic: "coverage", minimumVersion: "2.0.0"}, ]; const KNOWN_HELP_TOPICS = [ diff --git a/extensions/vscode-codeclone/src/renderers.js b/extensions/vscode-codeclone/src/renderers.js index 1ff42a2..e3aeb08 100644 --- a/extensions/vscode-codeclone/src/renderers.js +++ b/extensions/vscode-codeclone/src/renderers.js @@ -69,7 +69,7 @@ function renderSetupMarkdown() { "", `Minimum supported CodeClone version: \`${MINIMUM_SUPPORTED_CODECLONE_VERSION}\``, "", - "## Recommended install for the preview extension", + "## Recommended install", "", "```bash", PREVIEW_INSTALL_COMMAND, diff --git a/extensions/vscode-codeclone/src/support.js b/extensions/vscode-codeclone/src/support.js index f7117d3..04a9468 100644 --- a/extensions/vscode-codeclone/src/support.js +++ b/extensions/vscode-codeclone/src/support.js @@ -7,9 +7,8 @@ const STALE_REASON_WORKSPACE = "workspace changed after this run"; const ANALYSIS_PROFILE_DEFAULTS = "defaults"; const ANALYSIS_PROFILE_DEEPER_REVIEW = "deeperReview"; const ANALYSIS_PROFILE_CUSTOM = "custom"; -const MINIMUM_SUPPORTED_CODECLONE_VERSION = "2.0.0b4"; -const PREVIEW_INSTALL_COMMAND = - 'uv tool install --pre "codeclone[mcp]"'; +const MINIMUM_SUPPORTED_CODECLONE_VERSION = "2.0.0"; +const PREVIEW_INSTALL_COMMAND = 'uv tool install "codeclone[mcp]"'; const ANALYSIS_PROFILE_IDS = new Set([ ANALYSIS_PROFILE_DEFAULTS, ANALYSIS_PROFILE_DEEPER_REVIEW, diff --git a/extensions/vscode-codeclone/test/runArtifacts.test.js b/extensions/vscode-codeclone/test/runArtifacts.test.js index b5ab482..21c6e97 100644 --- a/extensions/vscode-codeclone/test/runArtifacts.test.js +++ b/extensions/vscode-codeclone/test/runArtifacts.test.js @@ -51,14 +51,14 @@ test("loadRunArtifacts starts MCP reads and git snapshot together", async () => assert.ok(resolveTriage); assert.ok(resolveMetrics); assert.ok(resolveReviewed); - resolveSummary({version: "2.0.0b6"}); + resolveSummary({version: "2.0.0"}); resolveTriage({hotspots: []}); resolveMetrics({summary: {health: {score: 90}}}); resolveReviewed({items: [{id: "f1"}]}); resolveGitSnapshot({head: "abc123"}); assert.deepEqual(await promise, { - summary: {version: "2.0.0b6"}, + summary: {version: "2.0.0"}, triage: {hotspots: []}, metricsSummary: {health: {score: 90}}, reviewedItems: [{id: "f1"}], diff --git a/extensions/vscode-codeclone/test/support.test.js b/extensions/vscode-codeclone/test/support.test.js index 5ca4f77..e3e5e69 100644 --- a/extensions/vscode-codeclone/test/support.test.js +++ b/extensions/vscode-codeclone/test/support.test.js @@ -146,13 +146,13 @@ test("launchSpecOrigin makes launcher provenance explicit", () => { test("unsupportedVersionMessage includes launcher provenance and next step", () => { assert.equal( - unsupportedVersionMessage("1.27.0", "2.0.0b4", { + unsupportedVersionMessage("1.27.0", "2.0.0", { command: "/workspace/repo/.venv/bin/codeclone-mcp", args: [], cwd: "/workspace/repo", source: "workspaceLocal", }), - "The local CodeClone MCP server is not supported. It reported version 1.27.0; this extension requires CodeClone >= 2.0.0b4. The extension resolved workspace-local launcher (/workspace/repo/.venv/bin/codeclone-mcp). Update that environment or set codeclone.mcp.command to a newer launcher." + "The local CodeClone MCP server is not supported. It reported version 1.27.0; this extension requires CodeClone >= 2.0.0. The extension resolved workspace-local launcher (/workspace/repo/.venv/bin/codeclone-mcp). Update that environment or set codeclone.mcp.command to a newer launcher." ); }); @@ -323,13 +323,10 @@ test("compareCodeCloneVersions keeps beta, rc, and final ordering", () => { }); test("minimum supported CodeClone version and install command stay aligned", () => { - assert.equal(MINIMUM_SUPPORTED_CODECLONE_VERSION, "2.0.0b4"); - assert.equal(isMinimumSupportedCodeCloneVersion("2.0.0b4"), true); + assert.equal(MINIMUM_SUPPORTED_CODECLONE_VERSION, "2.0.0"); + assert.equal(isMinimumSupportedCodeCloneVersion("2.0.0"), true); assert.equal(isMinimumSupportedCodeCloneVersion("2.0.1"), true); - assert.equal(isMinimumSupportedCodeCloneVersion("2.0.0b3"), false); + assert.equal(isMinimumSupportedCodeCloneVersion("2.0.0rc2"), false); assert.equal(isMinimumSupportedCodeCloneVersion("1.27.0"), false); - assert.equal( - PREVIEW_INSTALL_COMMAND, - 'uv tool install --pre "codeclone[mcp]"' - ); + assert.equal(PREVIEW_INSTALL_COMMAND, 'uv tool install "codeclone[mcp]"'); }); diff --git a/mkdocs.yml b/mkdocs.yml index a941452..7f77407 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -6,6 +6,8 @@ repo_name: orenlab/codeclone docs_dir: docs edit_uri: blob/main/docs/ strict: true +exclude_docs: | + README-pypi.md theme: name: material diff --git a/plugins/codeclone/.codex-plugin/plugin.json b/plugins/codeclone/.codex-plugin/plugin.json index 2f737fa..0d19993 100644 --- a/plugins/codeclone/.codex-plugin/plugin.json +++ b/plugins/codeclone/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "codeclone", - "version": "2.0.0-b6.0", + "version": "2.0.0", "description": "Baseline-aware structural code quality analysis for Codex through the local CodeClone MCP server.", "author": { "name": "Den Rozhnovskiy", @@ -41,7 +41,7 @@ "Run a changed-files CodeClone review for my current diff.", "Check CodeClone health and explain what to fix first." ], - "brandColor": "#58A6FF", + "brandColor": "#6366f1", "composerIcon": "./assets/icon.png", "logo": "./assets/logo.png" } diff --git a/plugins/codeclone/README.md b/plugins/codeclone/README.md index ba96331..aa03b9c 100644 --- a/plugins/codeclone/README.md +++ b/plugins/codeclone/README.md @@ -36,7 +36,7 @@ Recommended workspace-local setup: ```bash uv venv -uv pip install --python .venv/bin/python --pre "codeclone[mcp]" +uv pip install --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` @@ -45,7 +45,7 @@ If your workspace uses Poetry, install CodeClone into that Poetry environment. Global fallback: ```bash -uv tool install --pre "codeclone[mcp]" +uv tool install "codeclone[mcp]" codeclone-mcp --help ``` diff --git a/plugins/codeclone/assets/icon.png b/plugins/codeclone/assets/icon.png index b8bf9fd..31388ba 100644 Binary files a/plugins/codeclone/assets/icon.png and b/plugins/codeclone/assets/icon.png differ diff --git a/plugins/codeclone/assets/logo.png b/plugins/codeclone/assets/logo.png index b8bf9fd..31388ba 100644 Binary files a/plugins/codeclone/assets/logo.png and b/plugins/codeclone/assets/logo.png differ diff --git a/pyproject.toml b/pyproject.toml index a8b11a2..eff1f0b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,9 +4,9 @@ build-backend = "setuptools.build_meta" [project] name = "codeclone" -version = "2.0.0b7" +version = "2.0.0" description = "Structural code quality analysis for Python" -readme = { file = "README.md", content-type = "text/markdown" } +readme = { file = "docs/README-pypi.md", content-type = "text/markdown" } license = "MPL-2.0 AND MIT" license-files = ["LICENSE", "LICENSE-MIT"] @@ -39,7 +39,7 @@ keywords = [ ] classifiers = [ - "Development Status :: 4 - Beta", + "Development Status :: 5 - Production/Stable", "Intended Audience :: Developers", "Topic :: Software Development :: Quality Assurance", "Topic :: Software Development :: Testing", diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index a525cee..ebd5a31 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -17,7 +17,7 @@ def test_codex_plugin_manifest_is_consistent() -> None: assert isinstance(manifest, dict) assert manifest["name"] == plugin_root.name assert manifest["name"] == "codeclone" - assert manifest["version"] == "2.0.0-b6.0" + assert manifest["version"] == "2.0.0" assert manifest["skills"] == "./skills/" assert manifest["mcpServers"] == "./.mcp.json" assert manifest["license"] == "MPL-2.0" @@ -135,7 +135,7 @@ def test_codex_plugin_readme_and_docs_exist() -> None: assert "The plugin prefers a workspace launcher first" in readme_text assert "the current Poetry environment launcher" in readme_text assert "without relying on `sh -lc`" in readme_text - assert 'uv tool install --pre "codeclone[mcp]"' in readme_text + assert 'uv tool install "codeclone[mcp]"' in readme_text assert (root / "docs" / "codex-plugin.md").is_file() assert (root / "docs" / "terms-of-use.md").is_file() diff --git a/tests/test_github_action_helpers.py b/tests/test_github_action_helpers.py index 4e34a53..0198265 100644 --- a/tests/test_github_action_helpers.py +++ b/tests/test_github_action_helpers.py @@ -7,6 +7,7 @@ from __future__ import annotations import importlib.util +import re import sys from pathlib import Path from types import ModuleType @@ -100,7 +101,7 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None: action_impl = _load_action_impl() report = { "meta": { - "codeclone_version": "2.0.0b4", + "codeclone_version": "2.0.0", "baseline": {"status": "ok"}, "cache": {"used": True}, }, @@ -123,7 +124,36 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None: "health": { "score": 81, "grade": "B", - } + }, + "complexity": {"max": 20, "high_risk": 0}, + "coupling": {"max": 10, "high_risk": 0}, + "cohesion": {"max": 3, "low_cohesion": 0}, + "dependencies": { + "avg_depth": 4.0, + "p95_depth": 13, + "max_depth": 16, + "cycles": 0, + }, + "dead_code": {"high_confidence": 0, "suppressed": 2}, + "overloaded_modules": {"candidates": 13}, + "coverage_join": { + "status": "ok", + "overall_permille": 994, + "coverage_hotspots": 1, + "scope_gap_hotspots": 2, + }, + "security_surfaces": { + "items": 58, + "category_count": 4, + "production": 28, + }, + "api_surface": { + "enabled": True, + "public_symbols": 2119, + "modules": 208, + "breaking": 0, + "added": 0, + }, } }, } @@ -134,14 +164,19 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None: body, ( "", - "CodeClone Report", + "CodeClone Review", + "Review snapshot", "**81/100 (B)**", - ":x: Failed (gating)", - "Clones: 8 (1 new, 7 known)", - "Structural: 15", - "Dead code: 0", - "Design: 3", - "`2.0.0b4`", + "**:x: Failed (gating)**", + "8 total, 1 new, 7 known", + "CC max 20, CBO max 10, LCOM4 max 3, overloaded 13", + "avg 4.0, p95 13, max 16, cycles 0", + "99.4% overall, 1 hotspots, 2 scope gaps", + "58 surfaces, 4 categories, 28 production", + "2119 symbols, 208 modules", + "CI gates failed; start with rows marked as gating-sensitive.", + "Security Surfaces are report-only capability inventory", + "`2.0.0`", ), ) @@ -156,7 +191,7 @@ def test_resolve_install_target_uses_repo_source_for_local_action_checkout( target = _resolve_install_target( action_path=action_path, workspace=repo_root, - package_version="2.0.0b4", + package_version="2.0.0", ) assert target.source == "repo" @@ -173,9 +208,9 @@ def test_resolve_install_target_uses_pypi_for_remote_checkout(tmp_path: Path) -> pinned = _resolve_install_target( action_path=action_path, workspace=workspace_root, - package_version="2.0.0b4", + package_version="2.0.0", ) - latest = _resolve_install_target( + default = _resolve_install_target( action_path=action_path, workspace=workspace_root, package_version="", @@ -184,11 +219,25 @@ def test_resolve_install_target_uses_pypi_for_remote_checkout(tmp_path: Path) -> assert ( pinned.source, pinned.requirement, - latest.source, - latest.requirement, + default.source, + default.requirement, ) == ( "pypi-version", - "codeclone==2.0.0b4", - "pypi-latest", - "codeclone", + "codeclone==2.0.0", + "pypi-default", + "codeclone==2.0.0", ) + + +def test_action_default_package_version_tracks_release_version() -> None: + action_impl = _load_action_impl() + action_metadata = Path(".github/actions/codeclone/action.yml").read_text( + encoding="utf-8" + ) + pyproject = Path("pyproject.toml").read_text(encoding="utf-8") + version_match = re.search(r'^version = "([^"]+)"$', pyproject, re.MULTILINE) + assert version_match is not None + version = version_match.group(1) + + assert version == action_impl.DEFAULT_CODECLONE_PACKAGE_VERSION + assert f'default: "{version}"' in action_metadata diff --git a/uv.lock b/uv.lock index f352091..92df669 100644 --- a/uv.lock +++ b/uv.lock @@ -282,7 +282,7 @@ wheels = [ [[package]] name = "codeclone" -version = "2.0.0b7" +version = "2.0.0" source = { editable = "." } dependencies = [ { name = "orjson" }, @@ -631,7 +631,7 @@ name = "importlib-metadata" version = "9.0.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "zipp" }, + { name = "zipp", marker = "python_full_version < '3.15'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/a9/01/15bb152d77b21318514a96f43af312635eb2500c96b55398d020c93d86ea/importlib_metadata-9.0.0.tar.gz", hash = "sha256:a4f57ab599e6a2e3016d7595cfd72eb4661a5106e787a95bcc90c7105b831efc", size = 56405, upload-time = "2026-03-20T06:42:56.999Z" } wheels = [