From e37a39dabdfeb3f2114d7541951cfccfd0a23b68 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sun, 8 Feb 2026 22:17:50 +0500 Subject: [PATCH 01/30] feat(core): stabilize baseline v1 contract and report explainability --- codeclone.baseline.json | 28 +- codeclone/_cli_meta.py | 13 +- codeclone/_cli_paths.py | 4 +- codeclone/_report_blocks.py | 93 ++++ codeclone/_report_explain.py | 244 ++++++++++ codeclone/_report_serialize.py | 9 +- codeclone/baseline.py | 634 +++++++++++++++++++----- codeclone/cache.py | 17 +- codeclone/cli.py | 293 +++-------- codeclone/contracts.py | 26 + codeclone/errors.py | 2 +- codeclone/html_report.py | 194 +++++++- codeclone/report.py | 5 + codeclone/templates.py | 67 ++- codeclone/ui_messages.py | 57 +-- pyproject.toml | 2 +- tests/test_baseline.py | 854 +++++++++++++++++++++++---------- tests/test_cache.py | 10 +- tests/test_cli_inprocess.py | 385 ++++++++++----- tests/test_cli_unit.py | 25 +- tests/test_html_report.py | 271 ++++++++++- tests/test_report.py | 210 +++++++- tests/test_report_explain.py | 215 +++++++++ tests/test_security.py | 2 + uv.lock | 196 ++++---- 25 files changed, 2942 insertions(+), 914 deletions(-) create mode 100644 codeclone/_report_blocks.py create mode 100644 codeclone/_report_explain.py create mode 100644 codeclone/contracts.py create mode 100644 tests/test_report_explain.py diff --git a/codeclone.baseline.json b/codeclone.baseline.json index 7dafea0..c57492d 100644 --- a/codeclone.baseline.json +++ b/codeclone.baseline.json @@ -1,10 +1,20 @@ { - "functions": [], - "blocks": [], - "python_version": "3.13", - "baseline_version": "1.3.0", - "schema_version": 1, - "generator": "codeclone", - "payload_sha256": "92e80b05c857b796bb452de9e62985a1568874da468bc671998133975c94397a", - "created_at": "2026-02-08T09:54:31+00:00" -} \ No newline at end of file + "meta": { + "generator": { + "name": "codeclone", + "version": "1.4.0" + }, + "schema_version": "1.0", + "fingerprint_version": "1", + "python_tag": "cp313", + "created_at": "2026-02-08T17:14:12Z", + "payload_sha256": "77767150a39a80be72a7d71ad15deeb7f6635d016c0304abd6bcbd879f5ffa47" + }, + "clones": { + "functions": [], + "blocks": [ + "0e8579f84e518d186950d012c9944a40cb872332|0e8579f84e518d186950d012c9944a40cb872332|0e8579f84e518d186950d012c9944a40cb872332|0e8579f84e518d186950d012c9944a40cb872332", + "d60c0005a4c850c140378d1c82b81dde93a7ccab|20dee3217a8e68da799c7abe861a17ab18466b3d|a3d9246201e0126294acc3eadd75bf6a0bd5f35e|38625db4ce1fdc33b450d82f7ae8cedf790fbeae" + ] + } +} diff --git a/codeclone/_cli_meta.py b/codeclone/_cli_meta.py index fe6a04e..5dc342d 100644 --- a/codeclone/_cli_meta.py +++ b/codeclone/_cli_meta.py @@ -19,6 +19,13 @@ def _current_python_version() -> str: return f"{sys.version_info.major}.{sys.version_info.minor}" +def _current_python_tag() -> str: + impl = sys.implementation.name + major, minor = sys.version_info[:2] + prefix = "cp" if impl == "cpython" else impl[:2] + return f"{prefix}{major}{minor}" + + def _build_report_meta( *, codeclone_version: str, @@ -32,10 +39,12 @@ def _build_report_meta( return { "codeclone_version": codeclone_version, "python_version": _current_python_version(), + "python_tag": _current_python_tag(), "baseline_path": str(baseline_path), - "baseline_version": baseline.baseline_version, + "baseline_fingerprint_version": baseline.fingerprint_version, "baseline_schema_version": baseline.schema_version, - "baseline_python_version": baseline.python_version, + "baseline_python_tag": baseline.python_tag, + "baseline_generator_version": baseline.generator_version, "baseline_loaded": baseline_loaded, "baseline_status": baseline_status, "cache_path": str(cache_path), diff --git a/codeclone/_cli_paths.py b/codeclone/_cli_paths.py index 4dcd72f..72d8a60 100644 --- a/codeclone/_cli_paths.py +++ b/codeclone/_cli_paths.py @@ -14,6 +14,8 @@ from rich.console import Console +from .contracts import ExitCode + def expand_path(p: str) -> Path: return Path(p).expanduser().resolve() @@ -32,5 +34,5 @@ def _validate_output_path( console.print( invalid_message(label=label, path=out, expected_suffix=expected_suffix) ) - sys.exit(2) + sys.exit(ExitCode.CONTRACT_ERROR) return out.resolve() diff --git a/codeclone/_report_blocks.py b/codeclone/_report_blocks.py new file mode 100644 index 0000000..a1247ea --- /dev/null +++ b/codeclone/_report_blocks.py @@ -0,0 +1,93 @@ +""" +CodeClone — AST and CFG-based code clone detector for Python +focused on architectural duplication. + +Copyright (c) 2026 Den Rozhnovskiy +Licensed under the MIT License. +""" + +from __future__ import annotations + +from typing import Any + +from ._report_types import GroupItem, GroupMap + + +def _coerce_positive_int(value: Any) -> int | None: + try: + integer = int(value) + except (TypeError, ValueError): + return None + return integer if integer > 0 else None + + +def _block_item_sort_key(item: GroupItem) -> tuple[str, str, int, int]: + start_line = _coerce_positive_int(item.get("start_line")) or 0 + end_line = _coerce_positive_int(item.get("end_line")) or 0 + return ( + str(item.get("filepath", "")), + str(item.get("qualname", "")), + start_line, + end_line, + ) + + +def _merge_block_items(items: list[GroupItem]) -> list[GroupItem]: + """ + Merge overlapping/adjacent block windows into maximal ranges per function. + """ + if not items: + return [] + + sorted_items = sorted(items, key=_block_item_sort_key) + merged: list[GroupItem] = [] + current: GroupItem | None = None + + for item in sorted_items: + start_line = _coerce_positive_int(item.get("start_line")) + end_line = _coerce_positive_int(item.get("end_line")) + if start_line is None or end_line is None or end_line < start_line: + continue + + if current is None: + current = dict(item) + current["start_line"] = start_line + current["end_line"] = end_line + current["size"] = max(1, end_line - start_line + 1) + continue + + same_owner = str(current.get("filepath", "")) == str( + item.get("filepath", "") + ) and str(current.get("qualname", "")) == str(item.get("qualname", "")) + if same_owner and start_line <= int(current["end_line"]) + 1: + current["end_line"] = max(int(current["end_line"]), end_line) + current["size"] = max( + 1, int(current["end_line"]) - int(current["start_line"]) + 1 + ) + continue + + merged.append(current) + current = dict(item) + current["start_line"] = start_line + current["end_line"] = end_line + current["size"] = max(1, end_line - start_line + 1) + + if current is not None: + merged.append(current) + + return merged + + +def prepare_block_report_groups(block_groups: GroupMap) -> GroupMap: + """ + Convert sliding block windows into maximal merged regions for reporting. + Block hash keys remain unchanged. + """ + prepared: GroupMap = {} + for key, items in block_groups.items(): + merged = _merge_block_items(items) + if merged: + prepared[key] = merged + else: + prepared[key] = sorted(items, key=_block_item_sort_key) + return prepared diff --git a/codeclone/_report_explain.py b/codeclone/_report_explain.py new file mode 100644 index 0000000..2a8523a --- /dev/null +++ b/codeclone/_report_explain.py @@ -0,0 +1,244 @@ +""" +CodeClone — AST and CFG-based code clone detector for Python +focused on architectural duplication. + +Copyright (c) 2026 Den Rozhnovskiy +Licensed under the MIT License. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +from ._report_types import GroupItem, GroupMap + + +def _signature_parts(group_key: str) -> list[str]: + return [part for part in group_key.split("|") if part] + + +def _looks_like_test_path(filepath: str) -> bool: + normalized = filepath.replace("\\", "/").lower() + filename = normalized.rsplit("/", maxsplit=1)[-1] + return "/tests/" in f"/{normalized}/" or filename.startswith("test_") + + +def _parsed_file_tree( + filepath: str, *, ast_cache: dict[str, ast.AST | None] +) -> ast.AST | None: + if filepath in ast_cache: + return ast_cache[filepath] + + try: + source = Path(filepath).read_text("utf-8") + tree = ast.parse(source, filename=filepath) + except (OSError, SyntaxError): + tree = None + ast_cache[filepath] = tree + return tree + + +def _is_assert_like_stmt(stmt: ast.stmt) -> bool: + if isinstance(stmt, ast.Assert): + return True + if isinstance(stmt, ast.Expr): + value = stmt.value + if isinstance(value, ast.Constant) and isinstance(value.value, str): + return True + if isinstance(value, ast.Call): + func = value.func + if isinstance(func, ast.Name): + return func.id.lower().startswith("assert") + if isinstance(func, ast.Attribute): + return func.attr.lower().startswith("assert") + return False + + +def _assert_range_stats( + *, + filepath: str, + start_line: int, + end_line: int, + ast_cache: dict[str, ast.AST | None], + range_cache: dict[tuple[str, int, int], tuple[int, int, int]], +) -> tuple[int, int, int]: + cache_key = (filepath, start_line, end_line) + if cache_key in range_cache: + return range_cache[cache_key] + + tree = _parsed_file_tree(filepath, ast_cache=ast_cache) + if tree is None: + range_cache[cache_key] = (0, 0, 0) + return 0, 0, 0 + + stmts = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.stmt) + and int(getattr(node, "lineno", 0)) >= start_line + and int(getattr(node, "end_lineno", 0)) <= end_line + ] + if not stmts: + range_cache[cache_key] = (0, 0, 0) + return 0, 0, 0 + + ordered_stmts = sorted( + stmts, + key=lambda stmt: ( + int(getattr(stmt, "lineno", 0)), + int(getattr(stmt, "end_lineno", 0)), + int(getattr(stmt, "col_offset", 0)), + int(getattr(stmt, "end_col_offset", 0)), + type(stmt).__name__, + ), + ) + + total = len(ordered_stmts) + assert_like = 0 + max_consecutive = 0 + current_consecutive = 0 + for stmt in ordered_stmts: + if _is_assert_like_stmt(stmt): + assert_like += 1 + current_consecutive += 1 + if current_consecutive > max_consecutive: + max_consecutive = current_consecutive + else: + current_consecutive = 0 + + stats = (total, assert_like, max_consecutive) + range_cache[cache_key] = stats + return stats + + +def _is_assert_only_range( + *, + filepath: str, + start_line: int, + end_line: int, + ast_cache: dict[str, ast.AST | None], + range_cache: dict[tuple[str, int, int], tuple[int, int, int]], +) -> bool: + total, assert_like, _ = _assert_range_stats( + filepath=filepath, + start_line=start_line, + end_line=end_line, + ast_cache=ast_cache, + range_cache=range_cache, + ) + return total > 0 and total == assert_like + + +def _base_block_facts(group_key: str) -> dict[str, str]: + signature_parts = _signature_parts(group_key) + window_size = max(1, len(signature_parts)) + repeated_signature = len(signature_parts) > 1 and all( + part == signature_parts[0] for part in signature_parts + ) + facts: dict[str, str] = { + "match_rule": "normalized_sliding_window", + "block_size": str(window_size), + "signature_kind": "stmt_hash_sequence", + "merged_regions": "true", + } + if repeated_signature: + facts["pattern"] = "repeated_stmt_hash" + facts["pattern_display"] = f"{signature_parts[0][:12]} x{window_size}" + return facts + + +def _enrich_with_assert_facts( + *, + facts: dict[str, str], + items: list[GroupItem], + ast_cache: dict[str, ast.AST | None], + range_cache: dict[tuple[str, int, int], tuple[int, int, int]], +) -> None: + assert_only = True + test_like_paths = True + total_statements = 0 + assert_statements = 0 + max_consecutive_asserts = 0 + + if not items: + assert_only = False + test_like_paths = False + + for item in items: + filepath = str(item.get("filepath", "")) + start_line = int(item.get("start_line", 0)) + end_line = int(item.get("end_line", 0)) + + range_total = 0 + range_assert = 0 + range_max_consecutive = 0 + if filepath and start_line > 0 and end_line > 0: + range_total, range_assert, range_max_consecutive = _assert_range_stats( + filepath=filepath, + start_line=start_line, + end_line=end_line, + ast_cache=ast_cache, + range_cache=range_cache, + ) + total_statements += range_total + assert_statements += range_assert + max_consecutive_asserts = max( + max_consecutive_asserts, range_max_consecutive + ) + + if ( + not filepath + or start_line <= 0 + or end_line <= 0 + or not _is_assert_only_range( + filepath=filepath, + start_line=start_line, + end_line=end_line, + ast_cache=ast_cache, + range_cache=range_cache, + ) + ): + assert_only = False + + if not filepath or not _looks_like_test_path(filepath): + test_like_paths = False + + if total_statements > 0: + ratio = round((assert_statements / total_statements) * 100) + facts["assert_ratio"] = f"{ratio}%" + facts["consecutive_asserts"] = str(max_consecutive_asserts) + + if assert_only: + facts["hint"] = "assert_only" + facts["hint_confidence"] = "deterministic" + if facts.get("pattern") == "repeated_stmt_hash" and test_like_paths: + facts["hint_context"] = "likely_test_boilerplate" + facts["hint_note"] = ( + "This block clone consists entirely of assert-only statements. " + "This often occurs in test suites." + ) + + +def build_block_group_facts(block_groups: GroupMap) -> dict[str, dict[str, str]]: + """ + Build deterministic explainability facts for block clone groups. + + This is the source of truth for report-level block explanations. + Renderers (HTML/TXT/JSON) should only display these facts. + """ + ast_cache: dict[str, ast.AST | None] = {} + range_cache: dict[tuple[str, int, int], tuple[int, int, int]] = {} + facts_by_group: dict[str, dict[str, str]] = {} + + for group_key, items in block_groups.items(): + facts = _base_block_facts(group_key) + _enrich_with_assert_facts( + facts=facts, + items=items, + ast_cache=ast_cache, + range_cache=range_cache, + ) + facts_by_group[group_key] = facts + + return facts_by_group diff --git a/codeclone/_report_serialize.py b/codeclone/_report_serialize.py index 54dcef5..1eea6fe 100644 --- a/codeclone/_report_serialize.py +++ b/codeclone/_report_serialize.py @@ -133,11 +133,14 @@ def to_text_report( f"CodeClone version: {_format_meta_text_value(meta.get('codeclone_version'))}", f"Python version: {_format_meta_text_value(meta.get('python_version'))}", f"Baseline path: {_format_meta_text_value(meta.get('baseline_path'))}", - f"Baseline version: {_format_meta_text_value(meta.get('baseline_version'))}", + "Baseline fingerprint version: " + f"{_format_meta_text_value(meta.get('baseline_fingerprint_version'))}", "Baseline schema version: " f"{_format_meta_text_value(meta.get('baseline_schema_version'))}", - "Baseline Python version: " - f"{_format_meta_text_value(meta.get('baseline_python_version'))}", + "Baseline Python tag: " + f"{_format_meta_text_value(meta.get('baseline_python_tag'))}", + "Baseline generator version: " + f"{_format_meta_text_value(meta.get('baseline_generator_version'))}", f"Baseline loaded: {_format_meta_text_value(meta.get('baseline_loaded'))}", f"Baseline status: {_format_meta_text_value(meta.get('baseline_status'))}", ] diff --git a/codeclone/baseline.py b/codeclone/baseline.py index 4e0894f..e541134 100644 --- a/codeclone/baseline.py +++ b/codeclone/baseline.py @@ -11,29 +11,97 @@ import hashlib import hmac import json +import os +import re +import sys from collections.abc import Mapping from datetime import datetime, timezone +from enum import Enum from pathlib import Path -from typing import Any +from typing import Any, Final from . import __version__ +from .contracts import ( + BASELINE_FINGERPRINT_VERSION, + BASELINE_SCHEMA_VERSION, +) from .errors import BaselineValidationError -BASELINE_SCHEMA_VERSION = 1 -MAX_BASELINE_SIZE_BYTES = 5 * 1024 * 1024 BASELINE_GENERATOR = "codeclone" +BASELINE_SCHEMA_MAJOR = 1 +BASELINE_SCHEMA_MAX_MINOR = 0 +MAX_BASELINE_SIZE_BYTES = 5 * 1024 * 1024 + + +class BaselineStatus(str, Enum): + OK = "ok" + MISSING = "missing" + TOO_LARGE = "too_large" + INVALID_JSON = "invalid_json" + INVALID_TYPE = "invalid_type" + MISSING_FIELDS = "missing_fields" + MISMATCH_SCHEMA_VERSION = "mismatch_schema_version" + MISMATCH_FINGERPRINT_VERSION = "mismatch_fingerprint_version" + MISMATCH_PYTHON_VERSION = "mismatch_python_version" + GENERATOR_MISMATCH = "generator_mismatch" + INTEGRITY_MISSING = "integrity_missing" + INTEGRITY_FAILED = "integrity_failed" + + +BASELINE_UNTRUSTED_STATUSES: Final[frozenset[BaselineStatus]] = frozenset( + { + BaselineStatus.TOO_LARGE, + BaselineStatus.INVALID_JSON, + BaselineStatus.INVALID_TYPE, + BaselineStatus.MISSING_FIELDS, + BaselineStatus.MISMATCH_SCHEMA_VERSION, + BaselineStatus.MISMATCH_FINGERPRINT_VERSION, + BaselineStatus.MISMATCH_PYTHON_VERSION, + BaselineStatus.GENERATOR_MISMATCH, + BaselineStatus.INTEGRITY_MISSING, + BaselineStatus.INTEGRITY_FAILED, + } +) + + +def coerce_baseline_status( + raw_status: str | BaselineStatus | None, +) -> BaselineStatus: + if isinstance(raw_status, BaselineStatus): + return raw_status + if isinstance(raw_status, str): + try: + return BaselineStatus(raw_status) + except ValueError: + return BaselineStatus.INVALID_TYPE + return BaselineStatus.INVALID_TYPE + + +_TOP_LEVEL_KEYS = {"meta", "clones"} +_META_REQUIRED_KEYS = { + "generator", + "schema_version", + "fingerprint_version", + "python_tag", + "created_at", + "payload_sha256", +} +_CLONES_REQUIRED_KEYS = {"functions", "blocks"} +_FUNCTION_ID_RE = re.compile(r"^[0-9a-f]{40}\|(?:\d+-\d+|\d+\+)$") +_BLOCK_ID_RE = re.compile(r"^[0-9a-f]{40}\|[0-9a-f]{40}\|[0-9a-f]{40}\|[0-9a-f]{40}$") class Baseline: __slots__ = ( - "baseline_version", "blocks", "created_at", + "fingerprint_version", "functions", "generator", + "generator_version", "path", "payload_sha256", - "python_version", + "python_tag", "schema_version", ) @@ -41,104 +109,208 @@ def __init__(self, path: str | Path): self.path = Path(path) self.functions: set[str] = set() self.blocks: set[str] = set() - self.python_version: str | None = None - self.baseline_version: str | None = None - self.schema_version: int | None = None self.generator: str | None = None - self.payload_sha256: str | None = None + self.schema_version: str | None = None + self.fingerprint_version: str | None = None + self.python_tag: str | None = None self.created_at: str | None = None + self.payload_sha256: str | None = None + self.generator_version: str | None = None def load(self, *, max_size_bytes: int | None = None) -> None: - if not self.path.exists(): - return - size_limit = ( - MAX_BASELINE_SIZE_BYTES if max_size_bytes is None else max_size_bytes - ) - try: - size = self.path.stat().st_size + exists = self.path.exists() except OSError as e: raise BaselineValidationError( - f"Cannot stat baseline file at {self.path}: {e}" + f"Cannot stat baseline file at {self.path}: {e}", + status=BaselineStatus.INVALID_TYPE, ) from e + if not exists: + return + + size_limit = ( + MAX_BASELINE_SIZE_BYTES if max_size_bytes is None else max_size_bytes + ) + size = _safe_stat_size(self.path) if size > size_limit: raise BaselineValidationError( "Baseline file is too large " - f"({size} bytes, max {size_limit} bytes) at {self.path}", - status="too_large", + f"({size} bytes, max {size_limit} bytes) at {self.path}. " + "Increase --max-baseline-size-mb or regenerate baseline.", + status=BaselineStatus.TOO_LARGE, ) - try: - data = json.loads(self.path.read_text("utf-8")) - except json.JSONDecodeError as e: + payload = _load_json_object(self.path) + if _is_legacy_baseline_payload(payload): raise BaselineValidationError( - f"Corrupted baseline file at {self.path}: {e}" - ) from e + "Baseline format is legacy (<=1.3.x) and must be regenerated. " + "Please run --update-baseline.", + status=BaselineStatus.MISSING_FIELDS, + ) - if not isinstance(data, dict): + _validate_top_level_structure(payload, path=self.path) + + meta_obj = payload.get("meta") + clones_obj = payload.get("clones") + if not isinstance(meta_obj, dict): + raise BaselineValidationError( + f"Invalid baseline schema at {self.path}: 'meta' must be object", + status=BaselineStatus.INVALID_TYPE, + ) + if not isinstance(clones_obj, dict): raise BaselineValidationError( - f"Baseline payload must be an object at {self.path}" + f"Invalid baseline schema at {self.path}: 'clones' must be object", + status=BaselineStatus.INVALID_TYPE, ) - functions = _require_str_list(data, "functions", path=self.path) - blocks = _require_str_list(data, "blocks", path=self.path) - python_version = _optional_str(data, "python_version", path=self.path) - baseline_version = _optional_str(data, "baseline_version", path=self.path) - schema_version = _optional_int(data, "schema_version", path=self.path) - generator = _optional_str_loose(data, "generator") - payload_sha256 = _optional_str_loose(data, "payload_sha256") - created_at = _optional_str(data, "created_at", path=self.path) + _validate_required_keys(meta_obj, _META_REQUIRED_KEYS, path=self.path) + _validate_required_keys(clones_obj, _CLONES_REQUIRED_KEYS, path=self.path) + _validate_exact_clone_keys(clones_obj, path=self.path) + + generator, generator_version = _parse_generator_meta(meta_obj, path=self.path) + schema_version = _require_semver_str(meta_obj, "schema_version", path=self.path) + fingerprint_version = _require_str( + meta_obj, "fingerprint_version", path=self.path + ) + python_tag = _require_python_tag(meta_obj, "python_tag", path=self.path) + created_at = _require_utc_iso8601_z(meta_obj, "created_at", path=self.path) + payload_sha256 = _require_str(meta_obj, "payload_sha256", path=self.path) + + function_ids = _require_sorted_unique_ids( + clones_obj, + "functions", + pattern=_FUNCTION_ID_RE, + path=self.path, + ) + block_ids = _require_sorted_unique_ids( + clones_obj, + "blocks", + pattern=_BLOCK_ID_RE, + path=self.path, + ) - self.functions = set(functions) - self.blocks = set(blocks) - self.python_version = python_version - self.baseline_version = baseline_version - self.schema_version = schema_version self.generator = generator - self.payload_sha256 = payload_sha256 + self.schema_version = schema_version + self.fingerprint_version = fingerprint_version + self.python_tag = python_tag self.created_at = created_at + self.payload_sha256 = payload_sha256 + self.generator_version = generator_version + self.functions = set(function_ids) + self.blocks = set(block_ids) def save(self) -> None: self.path.parent.mkdir(parents=True, exist_ok=True) - now_utc = datetime.now(timezone.utc).replace(microsecond=0).isoformat() - self.path.write_text( - json.dumps( - _baseline_payload( - self.functions, - self.blocks, - self.python_version, - self.baseline_version, - self.schema_version, - self.generator, - now_utc, - ), - indent=2, - ensure_ascii=False, - ), - "utf-8", - ) - - def is_legacy_format(self) -> bool: - return self.baseline_version is None or self.schema_version is None + payload = _baseline_payload( + functions=self.functions, + blocks=self.blocks, + generator=self.generator, + schema_version=self.schema_version, + fingerprint_version=self.fingerprint_version, + python_tag=self.python_tag, + generator_version=self.generator_version, + created_at=self.created_at, + ) + _atomic_write_json(self.path, payload) - def verify_integrity(self) -> None: - if self.is_legacy_format(): - return + def verify_compatibility(self, *, current_python_tag: str) -> None: if self.generator != BASELINE_GENERATOR: raise BaselineValidationError( "Baseline generator mismatch: expected 'codeclone'.", - status="generator_mismatch", + status=BaselineStatus.GENERATOR_MISMATCH, + ) + if self.schema_version is None: + raise BaselineValidationError( + "Baseline schema version is missing.", + status=BaselineStatus.MISSING_FIELDS, + ) + if self.fingerprint_version is None: + raise BaselineValidationError( + "Baseline fingerprint version is missing.", + status=BaselineStatus.MISSING_FIELDS, + ) + if self.python_tag is None: + raise BaselineValidationError( + "Baseline python_tag is missing.", + status=BaselineStatus.MISSING_FIELDS, + ) + + schema_major, schema_minor, _ = _parse_semver( + self.schema_version, key="schema_version", path=self.path + ) + if schema_major != BASELINE_SCHEMA_MAJOR: + raise BaselineValidationError( + "Baseline schema version mismatch: " + f"baseline={self.schema_version}, " + f"supported_major={BASELINE_SCHEMA_MAJOR}.", + status=BaselineStatus.MISMATCH_SCHEMA_VERSION, ) + if schema_minor > BASELINE_SCHEMA_MAX_MINOR: + raise BaselineValidationError( + "Baseline schema version is newer than supported: " + f"baseline={self.schema_version}, " + f"max=1.{BASELINE_SCHEMA_MAX_MINOR}.", + status=BaselineStatus.MISMATCH_SCHEMA_VERSION, + ) + if self.fingerprint_version != BASELINE_FINGERPRINT_VERSION: + raise BaselineValidationError( + "Baseline fingerprint version mismatch: " + f"baseline={self.fingerprint_version}, " + f"expected={BASELINE_FINGERPRINT_VERSION}.", + status=BaselineStatus.MISMATCH_FINGERPRINT_VERSION, + ) + if self.python_tag != current_python_tag: + raise BaselineValidationError( + "Baseline python tag mismatch: " + f"baseline={self.python_tag}, current={current_python_tag}.", + status=BaselineStatus.MISMATCH_PYTHON_VERSION, + ) + self.verify_integrity() + + def verify_integrity(self) -> None: if not isinstance(self.payload_sha256, str): raise BaselineValidationError( "Baseline integrity payload hash is missing.", - status="integrity_missing", + status=BaselineStatus.INTEGRITY_MISSING, ) - expected = _compute_payload_sha256(self.functions, self.blocks) + if len(self.payload_sha256) != 64: + raise BaselineValidationError( + "Baseline integrity payload hash is missing.", + status=BaselineStatus.INTEGRITY_MISSING, + ) + try: + int(self.payload_sha256, 16) + except ValueError as e: + raise BaselineValidationError( + "Baseline integrity payload hash is missing.", + status=BaselineStatus.INTEGRITY_MISSING, + ) from e + if self.schema_version is None: + raise BaselineValidationError( + "Baseline schema version is missing for integrity validation.", + status=BaselineStatus.MISSING_FIELDS, + ) + if self.fingerprint_version is None: + raise BaselineValidationError( + "Baseline fingerprint version is missing for integrity validation.", + status=BaselineStatus.MISSING_FIELDS, + ) + if self.python_tag is None: + raise BaselineValidationError( + "Baseline python_tag is missing for integrity validation.", + status=BaselineStatus.MISSING_FIELDS, + ) + expected = _compute_payload_sha256( + functions=self.functions, + blocks=self.blocks, + schema_version=self.schema_version, + fingerprint_version=self.fingerprint_version, + python_tag=self.python_tag, + ) if not hmac.compare_digest(self.payload_sha256, expected): raise BaselineValidationError( "Baseline integrity check failed: payload_sha256 mismatch.", - status="integrity_failed", + status=BaselineStatus.INTEGRITY_FAILED, ) @staticmethod @@ -146,18 +318,22 @@ def from_groups( func_groups: Mapping[str, object], block_groups: Mapping[str, object], path: str | Path = "", - python_version: str | None = None, - baseline_version: str | None = None, - schema_version: int | None = None, + schema_version: str | None = None, + fingerprint_version: str | None = None, + python_tag: str | None = None, + generator_version: str | None = None, ) -> Baseline: - bl = Baseline(path) - bl.functions = set(func_groups.keys()) - bl.blocks = set(block_groups.keys()) - bl.python_version = python_version - bl.baseline_version = baseline_version - bl.schema_version = schema_version - bl.generator = BASELINE_GENERATOR - return bl + baseline = Baseline(path) + baseline.functions = set(func_groups.keys()) + baseline.blocks = set(block_groups.keys()) + baseline.generator = BASELINE_GENERATOR + baseline.schema_version = schema_version or BASELINE_SCHEMA_VERSION + baseline.fingerprint_version = ( + fingerprint_version or BASELINE_FINGERPRINT_VERSION + ) + baseline.python_tag = python_tag or _current_python_tag() + baseline.generator_version = generator_version or __version__ + return baseline def diff( self, func_groups: Mapping[str, object], block_groups: Mapping[str, object] @@ -167,39 +343,197 @@ def diff( return new_funcs, new_blocks +def _atomic_write_json(path: Path, payload: dict[str, Any]) -> None: + tmp_path = path.with_name(f"{path.name}.tmp") + data = json.dumps(payload, indent=2, ensure_ascii=False) + "\n" + with tmp_path.open("wb") as tmp_file: + tmp_file.write(data.encode("utf-8")) + tmp_file.flush() + os.fsync(tmp_file.fileno()) + os.replace(tmp_path, path) + + +def _safe_stat_size(path: Path) -> int: + try: + return path.stat().st_size + except OSError as e: + raise BaselineValidationError( + f"Cannot stat baseline file at {path}: {e}", + status=BaselineStatus.INVALID_TYPE, + ) from e + + +def _load_json_object(path: Path) -> dict[str, Any]: + try: + raw = path.read_text("utf-8") + except OSError as e: + raise BaselineValidationError( + f"Cannot read baseline file at {path}: {e}", + status=BaselineStatus.INVALID_JSON, + ) from e + try: + data = json.loads(raw) + except json.JSONDecodeError as e: + raise BaselineValidationError( + f"Corrupted baseline file at {path}: {e}", + status=BaselineStatus.INVALID_JSON, + ) from e + if not isinstance(data, dict): + raise BaselineValidationError( + f"Baseline payload must be an object at {path}", + status=BaselineStatus.INVALID_TYPE, + ) + return data + + +def _validate_top_level_structure(payload: dict[str, Any], *, path: Path) -> None: + keys = set(payload.keys()) + missing = _TOP_LEVEL_KEYS - keys + extra = keys - _TOP_LEVEL_KEYS + if missing: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: missing top-level keys: " + f"{', '.join(sorted(missing))}", + status=BaselineStatus.MISSING_FIELDS, + ) + if extra: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: unexpected top-level keys: " + f"{', '.join(sorted(extra))}", + status=BaselineStatus.INVALID_TYPE, + ) + + +def _validate_required_keys( + obj: dict[str, Any], required: set[str], *, path: Path +) -> None: + missing = required - set(obj.keys()) + if missing: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: missing required fields: " + f"{', '.join(sorted(missing))}", + status=BaselineStatus.MISSING_FIELDS, + ) + + +def _validate_exact_clone_keys(clones: dict[str, Any], *, path: Path) -> None: + keys = set(clones.keys()) + extra = keys - _CLONES_REQUIRED_KEYS + if extra: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: unexpected clone keys: " + f"{', '.join(sorted(extra))}", + status=BaselineStatus.INVALID_TYPE, + ) + + +def _is_legacy_baseline_payload(payload: dict[str, Any]) -> bool: + return "functions" in payload and "blocks" in payload + + +def _parse_generator_meta( + meta_obj: dict[str, Any], *, path: Path +) -> tuple[str, str | None]: + raw_generator = meta_obj.get("generator") + + if isinstance(raw_generator, str): + generator_version = _optional_str(meta_obj, "generator_version", path=path) + if generator_version is None: + # Legacy alias for baselines produced before generator_version rename. + generator_version = _optional_str(meta_obj, "codeclone_version", path=path) + return raw_generator, generator_version + + if isinstance(raw_generator, dict): + allowed_keys = {"name", "version"} + extra = set(raw_generator.keys()) - allowed_keys + if extra: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: unexpected generator keys: " + f"{', '.join(sorted(extra))}", + status=BaselineStatus.INVALID_TYPE, + ) + generator_name = _require_str(raw_generator, "name", path=path) + generator_version = _optional_str(raw_generator, "version", path=path) + + if generator_version is None: + generator_version = _optional_str(meta_obj, "generator_version", path=path) + if generator_version is None: + generator_version = _optional_str( + meta_obj, "codeclone_version", path=path + ) + + return generator_name, generator_version + + raise BaselineValidationError( + f"Invalid baseline schema at {path}: 'generator' must be string or object", + status=BaselineStatus.INVALID_TYPE, + ) + + def _baseline_payload( + *, functions: set[str], blocks: set[str], - python_version: str | None, - baseline_version: str | None, - schema_version: int | None, generator: str | None, + schema_version: str | None, + fingerprint_version: str | None, + python_tag: str | None, + generator_version: str | None, created_at: str | None, ) -> dict[str, Any]: - payload: dict[str, Any] = _canonical_payload(functions, blocks) - if python_version: - payload["python_version"] = python_version - payload["baseline_version"] = baseline_version or __version__ - payload["schema_version"] = ( - schema_version if schema_version is not None else BASELINE_SCHEMA_VERSION + resolved_generator = generator or BASELINE_GENERATOR + resolved_schema = schema_version or BASELINE_SCHEMA_VERSION + resolved_fingerprint = fingerprint_version or BASELINE_FINGERPRINT_VERSION + resolved_python_tag = python_tag or _current_python_tag() + resolved_generator_version = generator_version or __version__ + resolved_created_at = created_at or _utc_now_z() + + sorted_functions = sorted(functions) + sorted_blocks = sorted(blocks) + payload_sha256 = _compute_payload_sha256( + functions=set(sorted_functions), + blocks=set(sorted_blocks), + schema_version=resolved_schema, + fingerprint_version=resolved_fingerprint, + python_tag=resolved_python_tag, ) - payload["generator"] = generator or BASELINE_GENERATOR - payload["payload_sha256"] = _compute_payload_sha256(functions, blocks) - if created_at: - payload["created_at"] = created_at - return payload - -def _canonical_payload(functions: set[str], blocks: set[str]) -> dict[str, list[str]]: return { - "functions": sorted(functions), - "blocks": sorted(blocks), + "meta": { + "generator": { + "name": resolved_generator, + "version": resolved_generator_version, + }, + "schema_version": resolved_schema, + "fingerprint_version": resolved_fingerprint, + "python_tag": resolved_python_tag, + "created_at": resolved_created_at, + "payload_sha256": payload_sha256, + }, + "clones": { + "functions": sorted_functions, + "blocks": sorted_blocks, + }, } -def _compute_payload_sha256(functions: set[str], blocks: set[str]) -> str: +def _compute_payload_sha256( + *, + functions: set[str], + blocks: set[str], + schema_version: str, + fingerprint_version: str, + python_tag: str, +) -> str: + canonical = { + "blocks": sorted(blocks), + "fingerprint_version": fingerprint_version, + "functions": sorted(functions), + "python_tag": python_tag, + "schema_version": schema_version, + } serialized = json.dumps( - _canonical_payload(functions, blocks), + canonical, sort_keys=True, separators=(",", ":"), ensure_ascii=False, @@ -207,39 +541,107 @@ def _compute_payload_sha256(functions: set[str], blocks: set[str]) -> str: return hashlib.sha256(serialized.encode("utf-8")).hexdigest() -def _require_str_list(data: dict[str, Any], key: str, *, path: Path) -> list[str]: - value = data.get(key) - if not isinstance(value, list) or not all(isinstance(v, str) for v in value): +def _current_python_tag() -> str: + impl = sys.implementation.name + major, minor = sys.version_info[:2] + prefix = "cp" if impl == "cpython" else impl[:2] + return f"{prefix}{major}{minor}" + + +def _utc_now_z() -> str: + return ( + datetime.now(timezone.utc).replace(microsecond=0).strftime("%Y-%m-%dT%H:%M:%SZ") + ) + + +def _require_str(obj: dict[str, Any], key: str, *, path: Path) -> str: + value = obj.get(key) + if not isinstance(value, str): raise BaselineValidationError( - f"Invalid baseline schema at {path}: '{key}' must be list[str]" + f"Invalid baseline schema at {path}: '{key}' must be string", + status=BaselineStatus.INVALID_TYPE, ) return value -def _optional_str(data: dict[str, Any], key: str, *, path: Path) -> str | None: - value = data.get(key) +def _optional_str(obj: dict[str, Any], key: str, *, path: Path) -> str | None: + value = obj.get(key) if value is None: return None if not isinstance(value, str): raise BaselineValidationError( - f"Invalid baseline schema at {path}: '{key}' must be string" + f"Invalid baseline schema at {path}: '{key}' must be string", + status=BaselineStatus.INVALID_TYPE, ) return value -def _optional_int(data: dict[str, Any], key: str, *, path: Path) -> int | None: - value = data.get(key) - if value is None: - return None - if not isinstance(value, int): +def _require_semver_str(obj: dict[str, Any], key: str, *, path: Path) -> str: + value = _require_str(obj, key, path=path) + _parse_semver(value, key=key, path=path) + return value + + +def _parse_semver(value: str, *, key: str, path: Path) -> tuple[int, int, int]: + parts = value.split(".") + if len(parts) not in {2, 3} or not all(part.isdigit() for part in parts): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' must be semver string", + status=BaselineStatus.INVALID_TYPE, + ) + if len(parts) == 2: + major, minor = int(parts[0]), int(parts[1]) + patch = 0 + else: + major, minor, patch = int(parts[0]), int(parts[1]), int(parts[2]) + return major, minor, patch + + +def _require_python_tag(obj: dict[str, Any], key: str, *, path: Path) -> str: + value = _require_str(obj, key, path=path) + if not re.fullmatch(r"[a-z]{2}\d{2,3}", value): raise BaselineValidationError( - f"Invalid baseline schema at {path}: '{key}' must be integer" + f"Invalid baseline schema at {path}: '{key}' must look like 'cp313'", + status=BaselineStatus.INVALID_TYPE, ) return value -def _optional_str_loose(data: dict[str, Any], key: str) -> str | None: - value = data.get(key) - if isinstance(value, str): - return value - return None +def _require_utc_iso8601_z(obj: dict[str, Any], key: str, *, path: Path) -> str: + value = _require_str(obj, key, path=path) + try: + datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ") + except ValueError as e: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' must be UTC ISO-8601 with Z", + status=BaselineStatus.INVALID_TYPE, + ) from e + return value + + +def _require_sorted_unique_ids( + obj: dict[str, Any], key: str, *, pattern: re.Pattern[str], path: Path +) -> list[str]: + value = obj.get(key) + if not isinstance(value, list): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' must be list[str]", + status=BaselineStatus.INVALID_TYPE, + ) + if not all(isinstance(item, str) for item in value): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' must be list[str]", + status=BaselineStatus.INVALID_TYPE, + ) + values = list(value) + if values != sorted(values) or len(values) != len(set(values)): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' must be sorted and unique", + status=BaselineStatus.INVALID_TYPE, + ) + if not all(pattern.fullmatch(item) for item in values): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' has invalid id format", + status=BaselineStatus.INVALID_TYPE, + ) + return values diff --git a/codeclone/cache.py b/codeclone/cache.py index 566e82e..5373f1a 100644 --- a/codeclone/cache.py +++ b/codeclone/cache.py @@ -22,6 +22,7 @@ from .blocks import BlockUnit, SegmentUnit from .extractor import Unit +from .contracts import CACHE_VERSION from .errors import CacheError OS_NAME = os.name @@ -77,11 +78,11 @@ class CacheData(TypedDict): class Cache: __slots__ = ("data", "load_warning", "max_size_bytes", "path", "secret") - CACHE_VERSION = "1.1" + _CACHE_VERSION = CACHE_VERSION def __init__(self, path: str | Path, *, max_size_bytes: int | None = None): self.path = Path(path) - self.data: CacheData = {"version": self.CACHE_VERSION, "files": {}} + self.data: CacheData = {"version": self._CACHE_VERSION, "files": {}} self.secret = self._load_secret() self.load_warning: str | None = None self.max_size_bytes = ( @@ -125,7 +126,7 @@ def load(self) -> None: "Cache file too large " f"({size} bytes, max {self.max_size_bytes}); ignoring cache." ) - self.data = {"version": self.CACHE_VERSION, "files": {}} + self.data = {"version": self._CACHE_VERSION, "files": {}} return raw = json.loads(self.path.read_text("utf-8")) @@ -141,21 +142,21 @@ def load(self) -> None: and hmac.compare_digest(stored_sig, expected_sig) ): self.load_warning = "Cache signature mismatch; ignoring cache." - self.data = {"version": self.CACHE_VERSION, "files": {}} + self.data = {"version": self._CACHE_VERSION, "files": {}} return - if data.get("version") != self.CACHE_VERSION: + if data.get("version") != self._CACHE_VERSION: self.load_warning = ( "Cache version mismatch " f"(found {data.get('version')}); ignoring cache." ) - self.data = {"version": self.CACHE_VERSION, "files": {}} + self.data = {"version": self._CACHE_VERSION, "files": {}} return # Basic structure check if not isinstance(data.get("files"), dict): self.load_warning = "Cache format invalid; ignoring cache." - self.data = {"version": self.CACHE_VERSION, "files": {}} + self.data = {"version": self._CACHE_VERSION, "files": {}} return self.data = cast(CacheData, cast(object, data)) @@ -163,7 +164,7 @@ def load(self) -> None: except (json.JSONDecodeError, ValueError): self.load_warning = "Cache corrupted; ignoring cache." - self.data = {"version": self.CACHE_VERSION, "files": {}} + self.data = {"version": self._CACHE_VERSION, "files": {}} def save(self) -> None: try: diff --git a/codeclone/cli.py b/codeclone/cli.py index 0ef5832..eea90a9 100644 --- a/codeclone/cli.py +++ b/codeclone/cli.py @@ -21,24 +21,31 @@ from . import __version__ from . import ui_messages as ui from ._cli_args import build_parser -from ._cli_meta import _build_report_meta as _build_report_meta_impl -from ._cli_meta import _current_python_version as _current_python_version_impl -from ._cli_paths import _validate_output_path as _validate_output_path_impl -from ._cli_paths import expand_path as _expand_path_impl -from ._cli_summary import _build_summary_rows as _build_summary_rows_impl -from ._cli_summary import _build_summary_table as _build_summary_table_impl -from ._cli_summary import _print_summary as _print_summary_impl -from ._cli_summary import _summary_value_style as _summary_value_style_impl -from .baseline import BASELINE_SCHEMA_VERSION, Baseline +from ._cli_meta import _build_report_meta, _current_python_tag +from ._cli_paths import _validate_output_path +from ._cli_summary import _print_summary +from .baseline import ( + BASELINE_UNTRUSTED_STATUSES, + Baseline, + BaselineStatus, + coerce_baseline_status, +) from .cache import Cache, CacheEntry, FileStat, file_stat_signature +from .contracts import ( + BASELINE_FINGERPRINT_VERSION, + BASELINE_SCHEMA_VERSION, + ExitCode, +) from .errors import BaselineValidationError, CacheError from .extractor import extract_units_from_source from .html_report import build_html_report from .normalize import NormalizationConfig from .report import ( + build_block_group_facts, build_block_groups, build_groups, build_segment_groups, + prepare_block_report_groups, prepare_segment_report_groups, to_json_report, to_text_report, @@ -68,26 +75,6 @@ def _make_console(*, no_color: bool) -> Console: MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB BATCH_SIZE = 100 -_VALID_BASELINE_STATUSES = { - "ok", - "missing", - "legacy", - "invalid", - "mismatch_version", - "mismatch_schema", - "mismatch_python", - "generator_mismatch", - "integrity_missing", - "integrity_failed", - "too_large", -} -_UNTRUSTED_BASELINE_STATUSES = { - "invalid", - "too_large", - "generator_mismatch", - "integrity_missing", - "integrity_failed", -} @dataclass(slots=True) @@ -103,10 +90,6 @@ class ProcessingResult: stat: FileStat | None = None -def expand_path(p: str) -> Path: - return _expand_path_impl(p) - - def process_file( filepath: str, root: str, @@ -190,101 +173,6 @@ def print_banner() -> None: ) -def _validate_output_path(path: str, *, expected_suffix: str, label: str) -> Path: - return _validate_output_path_impl( - path, - expected_suffix=expected_suffix, - label=label, - console=console, - invalid_message=ui.fmt_invalid_output_extension, - ) - - -def _current_python_version() -> str: - return _current_python_version_impl() - - -def _build_report_meta( - *, - baseline_path: Path, - baseline: Baseline, - baseline_loaded: bool, - baseline_status: str, - cache_path: Path, - cache_used: bool, -) -> dict[str, Any]: - return _build_report_meta_impl( - codeclone_version=__version__, - baseline_path=baseline_path, - baseline=baseline, - baseline_loaded=baseline_loaded, - baseline_status=baseline_status, - cache_path=cache_path, - cache_used=cache_used, - ) - - -def _summary_value_style(*, label: str, value: int) -> str: - return _summary_value_style_impl(label=label, value=value) - - -def _build_summary_rows( - *, - files_found: int, - files_analyzed: int, - cache_hits: int, - files_skipped: int, - func_clones_count: int, - block_clones_count: int, - segment_clones_count: int, - suppressed_segment_groups: int, - new_clones_count: int, -) -> list[tuple[str, int]]: - return _build_summary_rows_impl( - files_found=files_found, - files_analyzed=files_analyzed, - cache_hits=cache_hits, - files_skipped=files_skipped, - func_clones_count=func_clones_count, - block_clones_count=block_clones_count, - segment_clones_count=segment_clones_count, - suppressed_segment_groups=suppressed_segment_groups, - new_clones_count=new_clones_count, - ) - - -def _build_summary_table(rows: list[tuple[str, int]]) -> Any: - return _build_summary_table_impl(rows) - - -def _print_summary( - *, - quiet: bool, - files_found: int, - files_analyzed: int, - cache_hits: int, - files_skipped: int, - func_clones_count: int, - block_clones_count: int, - segment_clones_count: int, - suppressed_segment_groups: int, - new_clones_count: int, -) -> None: - _print_summary_impl( - console=console, - quiet=quiet, - files_found=files_found, - files_analyzed=files_analyzed, - cache_hits=cache_hits, - files_skipped=files_skipped, - func_clones_count=func_clones_count, - block_clones_count=block_clones_count, - segment_clones_count=segment_clones_count, - suppressed_segment_groups=suppressed_segment_groups, - new_clones_count=new_clones_count, - ) - - def main() -> None: ap = build_parser(__version__) @@ -308,7 +196,7 @@ def main() -> None: if args.max_baseline_size_mb < 0 or args.max_cache_size_mb < 0: console.print("[error]Size limits must be non-negative integers (MB).[/error]") - sys.exit(1) + sys.exit(ExitCode.CLI_ERROR) if not args.quiet: print_banner() @@ -317,10 +205,10 @@ def main() -> None: root_path = Path(args.root).resolve() if not root_path.exists(): console.print(ui.ERR_ROOT_NOT_FOUND.format(path=root_path)) - sys.exit(1) + sys.exit(ExitCode.CLI_ERROR) except Exception as e: console.print(ui.ERR_INVALID_ROOT_PATH.format(error=e)) - sys.exit(1) + sys.exit(ExitCode.CLI_ERROR) if not args.quiet: console.print(ui.fmt_scanning_root(root_path)) @@ -330,15 +218,27 @@ def main() -> None: text_out_path: Path | None = None if args.html_out: html_out_path = _validate_output_path( - args.html_out, expected_suffix=".html", label="HTML" + args.html_out, + expected_suffix=".html", + label="HTML", + console=console, + invalid_message=ui.fmt_invalid_output_extension, ) if args.json_out: json_out_path = _validate_output_path( - args.json_out, expected_suffix=".json", label="JSON" + args.json_out, + expected_suffix=".json", + label="JSON", + console=console, + invalid_message=ui.fmt_invalid_output_extension, ) if args.text_out: text_out_path = _validate_output_path( - args.text_out, expected_suffix=".txt", label="text" + args.text_out, + expected_suffix=".txt", + label="text", + console=console, + invalid_message=ui.fmt_invalid_output_extension, ) # Initialize Cache @@ -466,7 +366,7 @@ def _safe_future_result(future: Any) -> tuple[ProcessingResult | None, str | Non files_to_process.append(fp) except Exception as e: console.print(ui.ERR_SCAN_FAILED.format(error=e)) - sys.exit(1) + sys.exit(ExitCode.CLI_ERROR) total_files = len(files_to_process) failed_files = [] @@ -650,6 +550,8 @@ def process_sequential(with_progress: bool) -> None: console.print(ui.fmt_cache_save_failed(e)) # Reporting + block_groups_report = prepare_block_report_groups(block_groups) + block_group_facts = build_block_group_facts(block_groups_report) func_clones_count = len(func_groups) block_clones_count = len(block_groups) segment_clones_count = len(segment_groups) @@ -662,118 +564,71 @@ def process_sequential(with_progress: bool) -> None: baseline = Baseline(baseline_path) baseline_exists = baseline_path.exists() baseline_loaded = False - baseline_status = "missing" - baseline_failure_code: int | None = None + baseline_status = BaselineStatus.MISSING + baseline_failure_code: ExitCode | None = None baseline_trusted_for_diff = False if baseline_exists: try: baseline.load(max_size_bytes=args.max_baseline_size_mb * 1024 * 1024) except BaselineValidationError as e: - baseline_status = ( - e.status if e.status in _VALID_BASELINE_STATUSES else "invalid" - ) + baseline_status = coerce_baseline_status(e.status) if not args.update_baseline: console.print(ui.fmt_invalid_baseline(e)) if args.fail_on_new: - baseline_failure_code = 2 + baseline_failure_code = ExitCode.CONTRACT_ERROR else: console.print(ui.WARN_BASELINE_IGNORED) else: - baseline_loaded = True - baseline_status = "ok" - baseline_trusted_for_diff = True if not args.update_baseline: - if baseline.is_legacy_format(): - baseline_status = "legacy" - console.print(ui.fmt_baseline_version_missing(__version__)) - baseline_failure_code = 2 - baseline_trusted_for_diff = False + try: + baseline.verify_compatibility( + current_python_tag=_current_python_tag() + ) + except BaselineValidationError as e: + baseline_status = coerce_baseline_status(e.status) + console.print(ui.fmt_invalid_baseline(e)) + if args.fail_on_new: + baseline_failure_code = ExitCode.CONTRACT_ERROR + else: + console.print(ui.WARN_BASELINE_IGNORED) else: - if baseline.baseline_version != __version__: - assert baseline.baseline_version is not None - baseline_status = "mismatch_version" - console.print( - ui.fmt_baseline_version_mismatch( - baseline_version=baseline.baseline_version, - current_version=__version__, - ) - ) - baseline_failure_code = 2 - baseline_trusted_for_diff = False - if baseline.schema_version != BASELINE_SCHEMA_VERSION: - assert baseline.schema_version is not None - if baseline_status == "ok": - baseline_status = "mismatch_schema" - console.print( - ui.fmt_baseline_schema_mismatch( - baseline_schema=baseline.schema_version, - current_schema=BASELINE_SCHEMA_VERSION, - ) - ) - baseline_failure_code = 2 - baseline_trusted_for_diff = False - if baseline.python_version: - current_version = _current_python_version() - if baseline.python_version != current_version: - if baseline_status == "ok": - baseline_status = "mismatch_python" - console.print( - ui.fmt_baseline_python_mismatch( - baseline_python=baseline.python_version, - current_python=current_version, - ) - ) - if args.fail_on_new: - console.print(ui.ERR_BASELINE_SAME_PYTHON_REQUIRED) - baseline_failure_code = 2 - baseline_trusted_for_diff = False - if baseline_status == "ok": - try: - baseline.verify_integrity() - except BaselineValidationError as e: - status = ( - e.status - if e.status in _VALID_BASELINE_STATUSES - else "invalid" - ) - baseline_status = status - console.print(ui.fmt_invalid_baseline(e)) - baseline_trusted_for_diff = False - if args.fail_on_new: - baseline_failure_code = 2 - else: - console.print(ui.WARN_BASELINE_IGNORED) - if baseline_status in _UNTRUSTED_BASELINE_STATUSES: - baseline_loaded = False - baseline_trusted_for_diff = False + baseline_loaded = True + baseline_status = BaselineStatus.OK + baseline_trusted_for_diff = True else: if not args.update_baseline: console.print(ui.fmt_path(ui.WARN_BASELINE_MISSING, baseline_path)) + if baseline_status in BASELINE_UNTRUSTED_STATUSES: + baseline_loaded = False + baseline_trusted_for_diff = False + if args.update_baseline: new_baseline = Baseline.from_groups( func_groups, block_groups, path=baseline_path, - python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - baseline_version=__version__, + python_tag=_current_python_tag(), + fingerprint_version=BASELINE_FINGERPRINT_VERSION, schema_version=BASELINE_SCHEMA_VERSION, + generator_version=__version__, ) new_baseline.save() console.print(ui.fmt_path(ui.SUCCESS_BASELINE_UPDATED, baseline_path)) baseline = new_baseline baseline_loaded = True - baseline_status = "ok" + baseline_status = BaselineStatus.OK baseline_trusted_for_diff = True # When updating, we don't fail on new, we just saved the new state. # But we might still want to print the summary. report_meta = _build_report_meta( + codeclone_version=__version__, baseline_path=baseline_path, baseline=baseline, baseline_loaded=baseline_loaded, - baseline_status=baseline_status, + baseline_status=baseline_status.value, cache_path=cache_path.resolve(), cache_used=cache.load_warning is None, ) @@ -786,6 +641,7 @@ def process_sequential(with_progress: bool) -> None: new_clones_count = len(new_func) + len(new_block) _print_summary( + console=console, quiet=args.quiet, files_found=files_found, files_analyzed=files_analyzed, @@ -817,8 +673,9 @@ def _print_output_notice(message: str) -> None: out.write_text( build_html_report( func_groups=func_groups, - block_groups=block_groups, + block_groups=block_groups_report, segment_groups=segment_groups, + block_group_facts=block_group_facts, report_meta=report_meta, title="CodeClone Report", context_lines=3, @@ -833,7 +690,12 @@ def _print_output_notice(message: str) -> None: out = json_out_path out.parent.mkdir(parents=True, exist_ok=True) out.write_text( - to_json_report(func_groups, block_groups, segment_groups, report_meta), + to_json_report( + func_groups, + block_groups_report, + segment_groups, + report_meta, + ), "utf-8", ) _print_output_notice(ui.fmt_path(ui.INFO_JSON_REPORT_SAVED, out)) @@ -845,7 +707,7 @@ def _print_output_notice(message: str) -> None: to_text_report( meta=report_meta, func_groups=func_groups, - block_groups=block_groups, + block_groups=block_groups_report, segment_groups=segment_groups, ), "utf-8", @@ -853,6 +715,7 @@ def _print_output_notice(message: str) -> None: _print_output_notice(ui.fmt_path(ui.INFO_TEXT_REPORT_SAVED, out)) if baseline_failure_code is not None: + console.print(ui.ERR_BASELINE_GATING_REQUIRES_TRUSTED) sys.exit(baseline_failure_code) # Exit Codes @@ -880,12 +743,12 @@ def _print_output_notice(message: str) -> None: console.print(f"\n{ui.FAIL_NEW_DETAIL_BLOCK}") for h in sorted(new_block): console.print(f"- {h}") - sys.exit(3) + sys.exit(ExitCode.GATING_FAILURE) if 0 <= args.fail_threshold < (func_clones_count + block_clones_count): total = func_clones_count + block_clones_count console.print(ui.fmt_fail_threshold(total=total, threshold=args.fail_threshold)) - sys.exit(2) + sys.exit(ExitCode.GATING_FAILURE) if not args.update_baseline and not args.fail_on_new and new_clones_count > 0: console.print(ui.WARN_NEW_CLONES_WITHOUT_FAIL) diff --git a/codeclone/contracts.py b/codeclone/contracts.py new file mode 100644 index 0000000..0fb7bfe --- /dev/null +++ b/codeclone/contracts.py @@ -0,0 +1,26 @@ +""" +CodeClone — AST and CFG-based code clone detector for Python +focused on architectural duplication. + +Copyright (c) 2026 Den Rozhnovskiy +Licensed under the MIT License. +""" + +from __future__ import annotations + +from enum import IntEnum +from typing import Final + +BASELINE_SCHEMA_VERSION: Final = "1.0" +BASELINE_FINGERPRINT_VERSION: Final = "1" + +CACHE_VERSION: Final = "1.1" +REPORT_SCHEMA_VERSION: Final = "1.0" + + +class ExitCode(IntEnum): + SUCCESS = 0 + CLI_ERROR = 1 + CONTRACT_ERROR = 2 + GATING_FAILURE = 3 + INTERNAL_ERROR = 5 diff --git a/codeclone/errors.py b/codeclone/errors.py index 11e32b8..87b8ffa 100644 --- a/codeclone/errors.py +++ b/codeclone/errors.py @@ -36,6 +36,6 @@ class BaselineValidationError(BaselineSchemaError): __slots__ = ("status",) - def __init__(self, message: str, *, status: str = "invalid") -> None: + def __init__(self, message: str, *, status: str = "invalid_type") -> None: super().__init__(message) self.status = status diff --git a/codeclone/html_report.py b/codeclone/html_report.py index c7ddf36..7842051 100644 --- a/codeclone/html_report.py +++ b/codeclone/html_report.py @@ -46,12 +46,25 @@ def build_html_report( func_groups: dict[str, list[dict[str, Any]]], block_groups: dict[str, list[dict[str, Any]]], segment_groups: dict[str, list[dict[str, Any]]], + block_group_facts: dict[str, dict[str, str]], report_meta: dict[str, Any] | None = None, title: str = "CodeClone Report", context_lines: int = 3, max_snippet_lines: int = 220, ) -> str: file_cache = _FileCache() + resolved_block_group_facts = block_group_facts + + def _path_basename(value: Any) -> str | None: + if not isinstance(value, str): + return None + text = value.strip() + if not text: + return None + normalized = text.replace("\\", "/").rstrip("/") + if not normalized: + return None + return normalized.rsplit("/", maxsplit=1)[-1] func_sorted = sorted( func_groups.items(), key=lambda kv: (*_group_sort_key(kv[1]), kv[0]) @@ -139,6 +152,127 @@ def _svg_icon(size: int, stroke_width: str, body: str) -> str: # Section renderer # ---------------------------- + def _group_basis_label(section_id: str) -> str: + basis_labels = { + "functions": "strict match: CFG fingerprint + LOC bucket", + "blocks": ( + "strict match: normalized 4-statement block signature (merged ranges)" + ), + "segments": "strict match: segment hash inside one function", + } + return basis_labels[section_id] + + def _display_group_key( + section_id: str, group_key: str, block_meta: dict[str, str] | None = None + ) -> str: + if section_id != "blocks": + return group_key + + if block_meta and block_meta.get("pattern_display"): + return str(block_meta["pattern_display"]) + + return group_key + + def _block_group_explanation_meta( + section_id: str, group_key: str + ) -> dict[str, str]: + if section_id != "blocks": + return {} + + raw = resolved_block_group_facts.get(group_key, {}) + return {str(k): str(v) for k, v in raw.items() if v is not None} + + def _render_group_explanation(meta: dict[str, Any]) -> str: + if not meta: + return "" + + explain_items: list[tuple[str, str]] = [] + if meta.get("match_rule"): + explain_items.append( + (f"match_rule: {meta['match_rule']}", "group-explain-item") + ) + if meta.get("block_size"): + explain_items.append( + (f"block_size: {meta['block_size']}", "group-explain-item") + ) + if meta.get("signature_kind"): + explain_items.append( + (f"signature_kind: {meta['signature_kind']}", "group-explain-item") + ) + if meta.get("merged_regions"): + explain_items.append( + (f"merged_regions: {meta['merged_regions']}", "group-explain-item") + ) + if meta.get("pattern") == "repeated_stmt_hash": + pattern_display = str(meta.get("pattern_display", "")).strip() + if pattern_display: + explain_items.append( + ( + f"pattern: repeated_stmt_hash ({pattern_display})", + "group-explain-item", + ) + ) + else: + explain_items.append( + ("pattern: repeated_stmt_hash", "group-explain-item") + ) + + if meta.get("hint") == "assert_only": + explain_items.append( + ("hint: assert-only block", "group-explain-item group-explain-warn") + ) + explain_items.append( + ( + "hint_confidence: deterministic", + "group-explain-item group-explain-muted", + ) + ) + if meta.get("assert_ratio"): + explain_items.append( + ( + f"assert_ratio: {meta['assert_ratio']}", + "group-explain-item group-explain-muted", + ) + ) + if meta.get("consecutive_asserts"): + explain_items.append( + ( + f"consecutive_asserts: {meta['consecutive_asserts']}", + "group-explain-item group-explain-muted", + ) + ) + if meta.get("hint_context") == "likely_test_boilerplate": + explain_items.append( + ( + "likely test boilerplate / repeated asserts", + "group-explain-item group-explain-muted", + ) + ) + + attrs = { + "data-match-rule": str(meta.get("match_rule", "")), + "data-block-size": str(meta.get("block_size", "")), + "data-signature-kind": str(meta.get("signature_kind", "")), + "data-merged-regions": str(meta.get("merged_regions", "")), + "data-pattern": str(meta.get("pattern", "")), + "data-hint": str(meta.get("hint", "")), + "data-hint-confidence": str(meta.get("hint_confidence", "")), + "data-assert-ratio": str(meta.get("assert_ratio", "")), + "data-consecutive-asserts": str(meta.get("consecutive_asserts", "")), + } + attr_html = " ".join( + f'{key}="{_escape_attr(value)}"' for key, value in attrs.items() if value + ) + parts = [ + f'{_escape_html(text)}' + for text, css_class in explain_items + ] + note = "" + if isinstance(meta.get("hint_note"), str): + note_text = _escape_html(str(meta["hint_note"])) + note = f'

{note_text}

' + return f'
{"".join(parts)}{note}
' + def render_section( section_id: str, section_title: str, @@ -211,12 +345,35 @@ def render_section( search_parts.append(str(it.get("filepath", ""))) search_blob = " ".join(search_parts).lower() search_blob_escaped = _escape_attr(search_blob) + basis_label = _group_basis_label(section_id) + block_meta = _block_group_explanation_meta(section_id, gkey) + display_key = _display_group_key(section_id, gkey, block_meta) + group_explain_html = _render_group_explanation(block_meta) + block_group_attrs = "" + if block_meta: + attrs = { + "data-match-rule": block_meta.get("match_rule"), + "data-block-size": block_meta.get("block_size"), + "data-signature-kind": block_meta.get("signature_kind"), + "data-merged-regions": block_meta.get("merged_regions"), + "data-pattern": block_meta.get("pattern"), + "data-hint": block_meta.get("hint"), + "data-hint-confidence": block_meta.get("hint_confidence"), + "data-assert-ratio": block_meta.get("assert_ratio"), + "data-consecutive-asserts": block_meta.get("consecutive_asserts"), + } + block_group_attrs = " ".join( + f'{name}="{_escape_attr(value)}"' + for name, value in attrs.items() + if value + ) + block_group_attrs = f" {block_group_attrs}" out.append( f'
' + f'data-search="{search_blob_escaped}"{block_group_attrs}>' ) out.append( @@ -228,8 +385,11 @@ def render_section( f'{len(items)} items' "
" '
' + f'' + f"{_escape_html(basis_label)}" f'' - f"{_escape_html(gkey)}" + f"{_escape_html(display_key)}" + f"{group_explain_html}" "
" "" ) @@ -309,13 +469,16 @@ def render_section( ) meta = dict(report_meta or {}) + baseline_path_value = meta.get("baseline_path") meta_rows: list[tuple[str, Any]] = [ ("CodeClone", meta.get("codeclone_version", __version__)), ("Python", meta.get("python_version")), - ("Baseline", meta.get("baseline_path")), - ("Baseline version", meta.get("baseline_version")), + ("Baseline file", _path_basename(baseline_path_value)), + ("Baseline path", baseline_path_value), + ("Baseline fingerprint", meta.get("baseline_fingerprint_version")), ("Baseline schema", meta.get("baseline_schema_version")), - ("Baseline Python", meta.get("baseline_python_version")), + ("Baseline Python tag", meta.get("baseline_python_tag")), + ("Baseline generator version", meta.get("baseline_generator_version")), ("Baseline loaded", meta.get("baseline_loaded")), ("Baseline status", meta.get("baseline_status")), ] @@ -331,10 +494,21 @@ def render_section( f'{_escape_attr(meta.get("codeclone_version", __version__))}"' ), f'data-python-version="{_escape_attr(meta.get("python_version"))}"', - f'data-baseline-path="{_escape_attr(meta.get("baseline_path"))}"', - f'data-baseline-version="{_escape_attr(meta.get("baseline_version"))}"', + f'data-baseline-file="{_escape_attr(_path_basename(baseline_path_value))}"', + f'data-baseline-path="{_escape_attr(baseline_path_value)}"', + ( + 'data-baseline-fingerprint-version="' + f'{_escape_attr(meta.get("baseline_fingerprint_version"))}"' + ), f'data-baseline-schema-version="{_escape_attr(meta.get("baseline_schema_version"))}"', - f'data-baseline-python-version="{_escape_attr(meta.get("baseline_python_version"))}"', + ( + 'data-baseline-python-tag="' + f'{_escape_attr(meta.get("baseline_python_tag"))}"' + ), + ( + 'data-baseline-generator-version="' + f'{_escape_attr(meta.get("baseline_generator_version"))}"' + ), f'data-baseline-loaded="{_escape_attr(_meta_display(meta.get("baseline_loaded")))}"', f'data-baseline-status="{_escape_attr(meta.get("baseline_status"))}"', f'data-cache-path="{_escape_attr(meta.get("cache_path"))}"', @@ -343,13 +517,13 @@ def render_section( ) def _meta_row_class(label: str) -> str: - if label in {"Baseline", "Cache path"}: + if label in {"Baseline path", "Cache path"}: return "meta-row meta-row-wide" return "meta-row" def _is_path_field(label: str) -> bool: """Check if field contains a file path.""" - return label in {"Baseline", "Cache path"} + return label in {"Baseline path", "Cache path"} def _is_bool_field(label: str) -> bool: """Check if field contains a boolean value.""" diff --git a/codeclone/report.py b/codeclone/report.py index 99fa6d2..29d975d 100644 --- a/codeclone/report.py +++ b/codeclone/report.py @@ -8,6 +8,8 @@ from __future__ import annotations +from ._report_blocks import _merge_block_items, prepare_block_report_groups +from ._report_explain import build_block_group_facts from ._report_grouping import build_block_groups, build_groups, build_segment_groups from ._report_segments import ( _CONTROL_FLOW_STMTS, @@ -43,11 +45,14 @@ "_assign_targets_attribute_only", "_collect_file_functions", "_format_meta_text_value", + "_merge_block_items", "_merge_segment_items", "_segment_statements", + "build_block_group_facts", "build_block_groups", "build_groups", "build_segment_groups", + "prepare_block_report_groups", "prepare_segment_report_groups", "to_json", "to_json_report", diff --git a/codeclone/templates.py b/codeclone/templates.py index 3880e1a..4a94f8a 100644 --- a/codeclone/templates.py +++ b/codeclone/templates.py @@ -734,6 +734,53 @@ justify-content: flex-end; } +.group-basis { + display: block; + font-size: var(--text-xs); + color: var(--text-muted); + border: 1px dashed var(--border-subtle); + border-radius: var(--radius-sm); + padding: 3px 8px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: min(44vw, 560px); +} + +.group-explain { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 6px; +} + +.group-explain-item { + display: inline-block; + font-size: var(--text-xs); + color: var(--text-muted); + border: 1px solid var(--border-subtle); + border-radius: var(--radius-sm); + padding: 2px 6px; + white-space: nowrap; +} + +.group-explain-note { + margin: 4px 0 0; + width: 100%; + font-size: var(--text-xs); + color: var(--text-muted); +} + +.group-explain-warn { + color: var(--warning); + border-color: var(--warning); + background: var(--warning-subtle); +} + +.group-explain-muted { + color: var(--text-tertiary); +} + .gkey { display: block; font-family: var(--font-mono); @@ -763,6 +810,18 @@ .gkey { max-width: 100%; } + + .group-basis { + max-width: 100%; + } + + .group-explain { + justify-content: flex-start; + } + + .group-explain-item { + white-space: normal; + } } .chev { @@ -2114,10 +2173,12 @@ return { codeclone_version: textAttr('data-codeclone-version'), python_version: textAttr('data-python-version'), + baseline_file: textAttr('data-baseline-file'), baseline_path: textAttr('data-baseline-path'), - baseline_version: textAttr('data-baseline-version'), - baseline_schema_version: intAttr('data-baseline-schema-version'), - baseline_python_version: textAttr('data-baseline-python-version'), + baseline_fingerprint_version: textAttr('data-baseline-fingerprint-version'), + baseline_schema_version: textAttr('data-baseline-schema-version'), + baseline_python_tag: textAttr('data-baseline-python-tag'), + baseline_generator_version: textAttr('data-baseline-generator-version'), baseline_loaded: boolAttr('data-baseline-loaded'), baseline_status: textAttr('data-baseline-status'), cache_path: textAttr('data-cache-path'), diff --git a/codeclone/ui_messages.py b/codeclone/ui_messages.py index a777deb..1187574 100644 --- a/codeclone/ui_messages.py +++ b/codeclone/ui_messages.py @@ -89,22 +89,20 @@ "{error}\n" "Please regenerate the baseline with --update-baseline." ) -ERR_BASELINE_VERSION_MISMATCH = "[error]Baseline version mismatch.[/error]" -ERR_BASELINE_SCHEMA_MISMATCH = "[error]Baseline schema version mismatch.[/error]" -WARN_BASELINE_PYTHON_MISMATCH = "[warning]Baseline Python version mismatch.[/warning]" -ERR_BASELINE_SAME_PYTHON_REQUIRED = ( - "[error]Baseline checks require the same Python version to " - "ensure deterministic results. Please regenerate the " - "baseline using the current interpreter.[/error]" -) +ACTION_UPDATE_BASELINE = "Run: codeclone . --update-baseline" WARN_BASELINE_MISSING = ( "[warning]Baseline file not found at: [bold]{path}[/bold][/warning]\n" "[dim]Comparing against an empty baseline. " - "Use --update-baseline to create it.[/dim]" + "Use --update-baseline to create it.[/dim]\n" + f"[dim]{ACTION_UPDATE_BASELINE}[/dim]" ) WARN_BASELINE_IGNORED = ( "[warning]Baseline is not trusted for this run and will be ignored.[/warning]\n" - "[dim]Comparison will proceed against an empty baseline.[/dim]" + "[dim]Comparison will proceed against an empty baseline.[/dim]\n" + f"[dim]{ACTION_UPDATE_BASELINE}[/dim]" +) +ERR_BASELINE_GATING_REQUIRES_TRUSTED = ( + f"[error]CI requires a trusted baseline.[/error]\n{ACTION_UPDATE_BASELINE}" ) SUCCESS_BASELINE_UPDATED = "✔ Baseline updated: {path}" @@ -185,45 +183,6 @@ def fmt_invalid_baseline(error: object) -> str: return ERR_INVALID_BASELINE.format(error=error) -def fmt_baseline_version_missing(current_version: str) -> str: - return ( - f"{ERR_BASELINE_VERSION_MISMATCH}\n" - "Baseline version missing (legacy baseline format).\n" - f"Current version: {current_version}.\n" - "Please regenerate the baseline with --update-baseline." - ) - - -def fmt_baseline_version_mismatch( - *, baseline_version: str, current_version: str -) -> str: - return ( - f"{ERR_BASELINE_VERSION_MISMATCH}\n" - "Baseline was generated with CodeClone " - f"{baseline_version}.\n" - f"Current version: {current_version}.\n" - "Please regenerate the baseline with --update-baseline." - ) - - -def fmt_baseline_schema_mismatch(*, baseline_schema: int, current_schema: int) -> str: - return ( - f"{ERR_BASELINE_SCHEMA_MISMATCH}\n" - f"Baseline schema: {baseline_schema}. " - f"Current schema: {current_schema}.\n" - "Please regenerate the baseline with --update-baseline." - ) - - -def fmt_baseline_python_mismatch(*, baseline_python: str, current_python: str) -> str: - return ( - f"{WARN_BASELINE_PYTHON_MISMATCH}\n" - "Baseline was generated with Python " - f"{baseline_python}.\n" - f"Current interpreter: Python {current_python}." - ) - - def fmt_path(template: str, path: Path) -> str: return template.format(path=path) diff --git a/pyproject.toml b/pyproject.toml index 4de9503..33378ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "codeclone" -version = "1.3.0" +version = "1.4.0" description = "AST and CFG-based code clone detector for Python focused on architectural duplication" readme = { file = "README.md", content-type = "text/markdown" } license = { text = "MIT" } diff --git a/tests/test_baseline.py b/tests/test_baseline.py index 9c98489..480bbb4 100644 --- a/tests/test_baseline.py +++ b/tests/test_baseline.py @@ -1,296 +1,654 @@ import json +import sys from pathlib import Path import pytest import codeclone.baseline as baseline_mod -from codeclone.baseline import Baseline -from codeclone.errors import BaselineSchemaError +from codeclone.baseline import Baseline, BaselineStatus, coerce_baseline_status +from codeclone.contracts import BASELINE_FINGERPRINT_VERSION, BASELINE_SCHEMA_VERSION +from codeclone.errors import BaselineValidationError + + +def _python_tag() -> str: + impl = sys.implementation.name + prefix = "cp" if impl == "cpython" else impl[:2] + major, minor = sys.version_info[:2] + return f"{prefix}{major}{minor}" + + +def _func_id() -> str: + return f"{'a' * 40}|0-19" + + +def _block_id() -> str: + return "|".join(["a" * 40, "b" * 40, "c" * 40, "d" * 40]) + + +def _trusted_payload( + *, + functions: list[str] | None = None, + blocks: list[str] | None = None, + schema_version: str = BASELINE_SCHEMA_VERSION, + fingerprint_version: str = BASELINE_FINGERPRINT_VERSION, + python_tag: str | None = None, + created_at: str | None = "2026-02-08T11:43:16Z", + generator_version: str = "1.4.0", +) -> dict[str, object]: + payload = baseline_mod._baseline_payload( + functions=set(functions or [_func_id()]), + blocks=set(blocks or [_block_id()]), + generator="codeclone", + schema_version=schema_version, + fingerprint_version=fingerprint_version, + python_tag=python_tag or _python_tag(), + generator_version=generator_version, + created_at=created_at, + ) + return payload + + +def _write_payload(path: Path, payload: dict[str, object]) -> None: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), "utf-8") def test_baseline_diff() -> None: baseline = Baseline("dummy") baseline.functions = {"f1"} baseline.blocks = {"b1"} - - func_groups: dict[str, object] = {"f1": [], "f2": []} - block_groups: dict[str, object] = {"b1": [], "b2": []} - - new_func, new_block = baseline.diff(func_groups, block_groups) - + new_func, new_block = baseline.diff({"f1": [], "f2": []}, {"b1": [], "b2": []}) assert new_func == {"f2"} assert new_block == {"b2"} -def test_baseline_io(tmp_path: Path) -> None: - f = tmp_path / "baseline.json" - bl = Baseline(f) - bl.functions = {"f1", "f2"} - bl.blocks = {"b1"} - bl.save() - - assert f.exists() - content = json.loads(f.read_text("utf-8")) - assert content["functions"] == ["f1", "f2"] - assert content["blocks"] == ["b1"] - assert "python_version" not in content - assert "baseline_version" in content - assert "schema_version" in content - assert content["generator"] == "codeclone" - assert isinstance(content["payload_sha256"], str) - assert isinstance(content["created_at"], str) - - bl2 = Baseline(f) - bl2.load() - bl2.verify_integrity() - assert bl2.functions == {"f1", "f2"} - assert bl2.blocks == {"b1"} - assert isinstance(bl2.baseline_version, str) - assert bl2.schema_version == 1 +@pytest.mark.parametrize( + ("raw_status", "expected"), + [ + (BaselineStatus.OK, BaselineStatus.OK), + ("ok", BaselineStatus.OK), + ("not-a-status", BaselineStatus.INVALID_TYPE), + (None, BaselineStatus.INVALID_TYPE), + ], +) +def test_coerce_baseline_status( + raw_status: str | BaselineStatus | None, expected: BaselineStatus +) -> None: + assert coerce_baseline_status(raw_status) == expected + + +def test_baseline_roundtrip_v1(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + baseline = Baseline(baseline_path) + baseline.functions = {_func_id()} + baseline.blocks = {_block_id()} + baseline.save() + + payload = json.loads(baseline_path.read_text("utf-8")) + assert set(payload.keys()) == {"meta", "clones"} + assert set(payload["meta"].keys()) >= { + "generator", + "schema_version", + "fingerprint_version", + "python_tag", + "created_at", + "payload_sha256", + } + assert set(payload["clones"].keys()) == {"functions", "blocks"} + assert payload["meta"]["schema_version"] == BASELINE_SCHEMA_VERSION + assert payload["meta"]["fingerprint_version"] == BASELINE_FINGERPRINT_VERSION + assert payload["meta"]["python_tag"] == _python_tag() + assert isinstance(payload["meta"]["payload_sha256"], str) + + loaded = Baseline(baseline_path) + loaded.load() + loaded.verify_compatibility(current_python_tag=_python_tag()) + assert loaded.functions == {_func_id()} + assert loaded.blocks == {_block_id()} + + +def test_baseline_save_atomic(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + baseline = Baseline(baseline_path) + baseline.functions = {_func_id()} + baseline.blocks = {_block_id()} + baseline.save() + assert baseline_path.exists() + assert not (tmp_path / "baseline.json.tmp").exists() def test_baseline_load_missing(tmp_path: Path) -> None: - f = tmp_path / "non_existent.json" - bl = Baseline(f) - bl.load() - assert bl.functions == set() - assert bl.blocks == set() - + baseline = Baseline(tmp_path / "missing.json") + baseline.load() + assert baseline.functions == set() + assert baseline.blocks == set() -def test_baseline_load_corrupted(tmp_path: Path) -> None: - f = tmp_path / "corrupt.json" - f.write_text("{invalid json", "utf-8") - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="Corrupted baseline file"): - bl.load() - -def test_baseline_load_non_object_payload(tmp_path: Path) -> None: - f = tmp_path / "not_object.json" - f.write_text("[]", "utf-8") - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="must be an object"): - bl.load() +def test_baseline_load_too_large( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload()) + monkeypatch.setattr(baseline_mod, "MAX_BASELINE_SIZE_BYTES", 1) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="too large") as exc: + baseline.load() + assert exc.value.status == "too_large" def test_baseline_load_stat_error( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - f = tmp_path / "baseline.json" - f.write_text(json.dumps({"functions": [], "blocks": []}), "utf-8") + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload()) original_exists = Path.exists - original_stat = Path.stat - def _exists(self: Path) -> bool: - if self == f: - return True + def _boom_exists(self: Path) -> bool: + if self == baseline_path: + raise OSError("blocked") return original_exists(self) - def _boom(self: Path, *args: object, **kwargs: object) -> object: - if self == f: - raise OSError("blocked") - return original_stat(self) + monkeypatch.setattr(Path, "exists", _boom_exists) + baseline = Baseline(baseline_path) + with pytest.raises( + BaselineValidationError, match="Cannot stat baseline file" + ) as exc: + baseline.load() + assert exc.value.status == "invalid_type" + - monkeypatch.setattr(Path, "exists", _exists) - monkeypatch.setattr(Path, "stat", _boom) - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="Cannot stat baseline file"): - bl.load() +def test_baseline_load_invalid_json(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text("{broken json", "utf-8") + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="Corrupted baseline file") as exc: + baseline.load() + assert exc.value.status == "invalid_json" -def test_baseline_load_invalid_schema(tmp_path: Path) -> None: - f = tmp_path / "invalid.json" - f.write_text( - json.dumps({"functions": ["f1"], "blocks": [1], "schema_version": 1}), +def test_baseline_load_non_object_payload(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text("[]", "utf-8") + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="must be an object") as exc: + baseline.load() + assert exc.value.status == "invalid_type" + + +def test_baseline_load_legacy_payload(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text( + json.dumps({"functions": [], "blocks": [], "baseline_version": "1.3.0"}), "utf-8", ) - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="Invalid baseline schema"): - bl.load() - - -def test_baseline_load_invalid_created_at_type(tmp_path: Path) -> None: - f = tmp_path / "invalid_created_at.json" - f.write_text( - json.dumps( - { - "functions": [], - "blocks": [], - "baseline_version": "1.3.0", - "schema_version": 1, - "created_at": 123, - } - ), - "utf-8", + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="legacy") as exc: + baseline.load() + assert exc.value.status == "missing_fields" + + +def test_baseline_load_missing_top_level_key(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, {"meta": {}}) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="missing top-level keys") as exc: + baseline.load() + assert exc.value.status == "missing_fields" + + +def test_baseline_load_extra_top_level_key(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + assert isinstance(payload, dict) + payload["extra"] = 1 + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + with pytest.raises( + BaselineValidationError, match="unexpected top-level keys" + ) as exc: + baseline.load() + assert exc.value.status == "invalid_type" + + +def test_baseline_load_meta_and_clones_must_be_objects(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, {"meta": [], "clones": {}}) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="'meta' must be object"): + baseline.load() + _write_payload(baseline_path, {"meta": {}, "clones": []}) + with pytest.raises(BaselineValidationError, match="'clones' must be object"): + baseline.load() + + +def test_baseline_load_missing_required_meta_fields(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload( + baseline_path, + {"meta": {"generator": "codeclone"}, "clones": {"functions": [], "blocks": []}}, ) - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="'created_at' must be string"): - bl.load() - - -def test_baseline_load_invalid_schema_version_type(tmp_path: Path) -> None: - f = tmp_path / "invalid_schema_version.json" - f.write_text( - json.dumps( - { - "functions": [], - "blocks": [], - "baseline_version": "1.3.0", - "schema_version": "1", - } - ), - "utf-8", + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="missing required fields") as exc: + baseline.load() + assert exc.value.status == "missing_fields" + + +def test_baseline_load_missing_required_clone_fields(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + assert isinstance(payload, dict) + payload["clones"] = {"functions": [_func_id()]} + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="missing required fields") as exc: + baseline.load() + assert exc.value.status == "missing_fields" + + +def test_baseline_load_unexpected_clone_fields(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + assert isinstance(payload, dict) + clones = payload["clones"] + assert isinstance(clones, dict) + clones["segments"] = [] + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="unexpected clone keys") as exc: + baseline.load() + assert exc.value.status == "invalid_type" + + +@pytest.mark.parametrize( + ("container", "field", "value", "error_match"), + [ + ("meta", "generator", 1, "'generator' must be string"), + ("meta", "schema_version", "x", "schema_version"), + ("meta", "fingerprint_version", 1, "'fingerprint_version' must be string"), + ("meta", "python_tag", "3.13", "python_tag"), + ("meta", "created_at", "2026-02-08T11:43:16+00:00", "created_at"), + ("meta", "payload_sha256", 1, "payload_sha256"), + ("clones", "functions", "x", "functions"), + ("clones", "blocks", "x", "blocks"), + ], +) +def test_baseline_type_matrix( + tmp_path: Path, + container: str, + field: str, + value: object, + error_match: str, +) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + target = payload[container] + assert isinstance(target, dict) + target[field] = value + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match=error_match) as exc: + baseline.load() + assert exc.value.status == "invalid_type" + + +def test_baseline_id_lists_must_be_sorted_and_unique(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + clones = payload["clones"] + assert isinstance(clones, dict) + clones["functions"] = [_func_id(), _func_id()] + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="sorted and unique") as exc: + baseline.load() + assert exc.value.status == "invalid_type" + + payload = _trusted_payload() + clones = payload["clones"] + assert isinstance(clones, dict) + clones["functions"] = [f"{'b' * 40}|0-19", _func_id()] + _write_payload(baseline_path, payload) + with pytest.raises(BaselineValidationError, match="sorted and unique") as exc2: + baseline.load() + assert exc2.value.status == "invalid_type" + + +def test_baseline_id_format_validation(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload(functions=["bad-id"]) + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + with pytest.raises(BaselineValidationError, match="invalid id format") as exc: + baseline.load() + assert exc.value.status == "invalid_type" + + payload = _trusted_payload(blocks=["bad-block-id"]) + _write_payload(baseline_path, payload) + with pytest.raises(BaselineValidationError, match="invalid id format") as exc2: + baseline.load() + assert exc2.value.status == "invalid_type" + + +def test_baseline_verify_generator_mismatch(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + assert isinstance(payload, dict) + meta = payload["meta"] + assert isinstance(meta, dict) + meta["generator"] = "eviltool" + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises(BaselineValidationError, match="generator mismatch") as exc: + baseline.verify_compatibility(current_python_tag=_python_tag()) + assert exc.value.status == "generator_mismatch" + + +def test_baseline_verify_schema_too_new(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload(schema_version="1.1")) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises(BaselineValidationError, match="newer than supported") as exc: + baseline.verify_compatibility(current_python_tag=_python_tag()) + assert exc.value.status == "mismatch_schema_version" + + +def test_baseline_verify_fingerprint_mismatch(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload(fingerprint_version="2")) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises( + BaselineValidationError, match="fingerprint version mismatch" + ) as exc: + baseline.verify_compatibility(current_python_tag=_python_tag()) + assert exc.value.status == "mismatch_fingerprint_version" + + +def test_baseline_verify_python_tag_mismatch(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload(python_tag="cp999")) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises(BaselineValidationError, match="python tag mismatch") as exc: + baseline.verify_compatibility(current_python_tag=_python_tag()) + assert exc.value.status == "mismatch_python_version" + + +def test_baseline_verify_integrity_missing(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + assert isinstance(payload, dict) + meta = payload["meta"] + assert isinstance(meta, dict) + meta["payload_sha256"] = "zz" + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises(BaselineValidationError, match="payload hash is missing") as exc: + baseline.verify_integrity() + assert exc.value.status == "integrity_missing" + + +def test_baseline_verify_integrity_mismatch(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload() + assert isinstance(payload, dict) + clones = payload["clones"] + assert isinstance(clones, dict) + clones["functions"] = [_func_id(), f"{'b' * 40}|0-19"] + _write_payload(baseline_path, payload) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises(BaselineValidationError, match="payload_sha256 mismatch") as exc: + baseline.verify_integrity() + assert exc.value.status == "integrity_failed" + + +def test_baseline_hash_canonical_determinism() -> None: + hash_a = baseline_mod._compute_payload_sha256( + functions={"a" * 40 + "|0-19", "b" * 40 + "|0-19"}, + blocks={_block_id()}, + schema_version="1.0", + fingerprint_version="1", + python_tag="cp313", ) - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="'schema_version' must be integer"): - bl.load() + hash_b = baseline_mod._compute_payload_sha256( + functions={"b" * 40 + "|0-19", "a" * 40 + "|0-19"}, + blocks={_block_id()}, + schema_version="1.0", + fingerprint_version="1", + python_tag="cp313", + ) + assert hash_a == hash_b -def test_baseline_load_too_large( - tmp_path: Path, monkeypatch: pytest.MonkeyPatch -) -> None: - f = tmp_path / "large.json" - f.write_text(json.dumps({"functions": [], "blocks": []}), "utf-8") - monkeypatch.setattr(baseline_mod, "MAX_BASELINE_SIZE_BYTES", 1) - bl = Baseline(f) - with pytest.raises(BaselineSchemaError, match="too large"): - bl.load() - - -def test_baseline_integrity_missing_payload(tmp_path: Path) -> None: - f = tmp_path / "missing_payload.json" - f.write_text( - json.dumps( - { - "functions": ["f1"], - "blocks": ["b1"], - "baseline_version": "1.3.0", - "schema_version": 1, - "generator": "codeclone", - } - ), - "utf-8", - ) - bl = Baseline(f) - bl.load() - with pytest.raises(BaselineSchemaError, match="payload hash is missing"): - bl.verify_integrity() - - -def test_baseline_integrity_generator_mismatch(tmp_path: Path) -> None: - f = tmp_path / "generator_mismatch.json" - bl = Baseline(f) - bl.functions = {"f1"} - bl.blocks = {"b1"} - bl.save() - payload = json.loads(f.read_text("utf-8")) - payload["generator"] = "evil" - f.write_text(json.dumps(payload), "utf-8") - bl2 = Baseline(f) - bl2.load() - with pytest.raises(BaselineSchemaError, match="generator mismatch"): - bl2.verify_integrity() - - -def test_baseline_integrity_generator_wrong_type(tmp_path: Path) -> None: - f = tmp_path / "generator_wrong_type.json" - bl = Baseline(f) - bl.functions = {"f1"} - bl.blocks = {"b1"} - bl.save() - payload = json.loads(f.read_text("utf-8")) - payload["generator"] = 123 - f.write_text(json.dumps(payload), "utf-8") - bl2 = Baseline(f) - bl2.load() - with pytest.raises(BaselineSchemaError, match="generator mismatch"): - bl2.verify_integrity() - - -def test_baseline_integrity_payload_mismatch(tmp_path: Path) -> None: - f = tmp_path / "tampered.json" - bl = Baseline(f) - bl.functions = {"f1"} - bl.blocks = {"b1"} - bl.save() - payload = json.loads(f.read_text("utf-8")) - payload["functions"] = ["tampered"] - f.write_text(json.dumps(payload), "utf-8") - bl2 = Baseline(f) - bl2.load() - with pytest.raises(BaselineSchemaError, match="payload_sha256 mismatch"): - bl2.verify_integrity() - - -def test_baseline_integrity_payload_wrong_type(tmp_path: Path) -> None: - f = tmp_path / "payload_wrong_type.json" - bl = Baseline(f) - bl.functions = {"f1"} - bl.blocks = {"b1"} - bl.save() - payload = json.loads(f.read_text("utf-8")) - payload["payload_sha256"] = 1 - f.write_text(json.dumps(payload), "utf-8") - bl2 = Baseline(f) - bl2.load() - with pytest.raises(BaselineSchemaError, match="payload hash is missing"): - bl2.verify_integrity() - - -def test_baseline_verify_integrity_skips_legacy(tmp_path: Path) -> None: - f = tmp_path / "legacy.json" - f.write_text(json.dumps({"functions": [], "blocks": []}), "utf-8") - bl = Baseline(f) - bl.load() - bl.verify_integrity() - - -def test_baseline_from_groups() -> None: - func_groups: dict[str, object] = {"f1": [], "f2": []} - block_groups: dict[str, object] = {"b1": []} - bl = Baseline.from_groups( - func_groups, - block_groups, - path="custom.json", - baseline_version="1.3.0", - schema_version=1, +def test_baseline_from_groups_defaults() -> None: + baseline = Baseline.from_groups( + {"a" * 40 + "|0-19": []}, + {_block_id(): []}, + path="baseline.json", ) + assert baseline.path == Path("baseline.json") + assert baseline.schema_version == BASELINE_SCHEMA_VERSION + assert baseline.fingerprint_version == BASELINE_FINGERPRINT_VERSION + assert baseline.python_tag == _python_tag() + assert baseline.generator == "codeclone" + + +def test_baseline_verify_schema_major_mismatch(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload(schema_version="2.0")) + baseline = Baseline(baseline_path) + baseline.load() + with pytest.raises(BaselineValidationError, match="schema version mismatch") as exc: + baseline.verify_compatibility(current_python_tag=_python_tag()) + assert exc.value.status == "mismatch_schema_version" + + +@pytest.mark.parametrize( + ("attr", "match_text"), + [ + ("schema_version", "schema version is missing"), + ("fingerprint_version", "fingerprint version is missing"), + ("python_tag", "python_tag is missing"), + ], +) +def test_baseline_verify_compatibility_missing_fields( + tmp_path: Path, attr: str, match_text: str +) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload()) + baseline = Baseline(baseline_path) + baseline.load() + setattr(baseline, attr, None) + with pytest.raises(BaselineValidationError, match=match_text) as exc: + baseline.verify_compatibility(current_python_tag=_python_tag()) + assert exc.value.status == "missing_fields" + + +def test_baseline_verify_integrity_payload_not_string(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload()) + baseline = Baseline(baseline_path) + baseline.load() + baseline.payload_sha256 = None + with pytest.raises(BaselineValidationError, match="payload hash is missing") as exc: + baseline.verify_integrity() + assert exc.value.status == "integrity_missing" + + +def test_baseline_verify_integrity_payload_non_hex(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload()) + baseline = Baseline(baseline_path) + baseline.load() + baseline.payload_sha256 = "g" * 64 + with pytest.raises(BaselineValidationError, match="payload hash is missing") as exc: + baseline.verify_integrity() + assert exc.value.status == "integrity_missing" + + +@pytest.mark.parametrize( + ("attr", "match_text"), + [ + ("schema_version", "schema version is missing for integrity"), + ("fingerprint_version", "fingerprint version is missing for integrity"), + ("python_tag", "python_tag is missing for integrity"), + ], +) +def test_baseline_verify_integrity_missing_context_fields( + tmp_path: Path, attr: str, match_text: str +) -> None: + baseline_path = tmp_path / "baseline.json" + _write_payload(baseline_path, _trusted_payload()) + baseline = Baseline(baseline_path) + baseline.load() + setattr(baseline, attr, None) + with pytest.raises(BaselineValidationError, match=match_text) as exc: + baseline.verify_integrity() + assert exc.value.status == "missing_fields" - assert bl.functions == {"f1", "f2"} - assert bl.blocks == {"b1"} - assert bl.path == Path("custom.json") - assert bl.baseline_version == "1.3.0" - assert bl.schema_version == 1 +def test_baseline_safe_stat_size_oserror( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "baseline.json" -def test_baseline_python_version_roundtrip(tmp_path: Path) -> None: - f = tmp_path / "baseline.json" - bl = Baseline(f) - bl.functions = {"f1"} - bl.blocks = {"b1"} - bl.python_version = "3.13" - bl.save() + def _boom_stat(self: Path) -> object: + if self == path: + raise OSError("blocked") + return object() - content = json.loads(f.read_text("utf-8")) - assert content["python_version"] == "3.13" - assert "baseline_version" in content - assert content["schema_version"] == 1 + monkeypatch.setattr(Path, "stat", _boom_stat) + with pytest.raises( + BaselineValidationError, match="Cannot stat baseline file" + ) as exc: + baseline_mod._safe_stat_size(path) + assert exc.value.status == "invalid_type" - bl2 = Baseline(f) - bl2.load() - assert bl2.python_version == "3.13" - assert isinstance(bl2.baseline_version, str) - assert bl2.schema_version == 1 +def test_baseline_load_json_read_error( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + path = tmp_path / "baseline.json" + path.write_text("{}", "utf-8") -def test_baseline_payload_without_created_at() -> None: - payload = baseline_mod._baseline_payload( - {"f1"}, - {"b1"}, - python_version=None, - baseline_version="1.3.0", - schema_version=1, - generator="codeclone", - created_at=None, + def _boom_read(self: Path, *_args: object, **_kwargs: object) -> str: + if self == path: + raise OSError("blocked") + return "{}" + + monkeypatch.setattr(Path, "read_text", _boom_read) + with pytest.raises( + BaselineValidationError, match="Cannot read baseline file" + ) as exc: + baseline_mod._load_json_object(path) + assert exc.value.status == "invalid_json" + + +def test_baseline_optional_str_paths(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + assert baseline_mod._optional_str({}, "generator_version", path=path) is None + with pytest.raises( + BaselineValidationError, + match="'generator_version' must be string", + ) as exc: + baseline_mod._optional_str( + {"generator_version": 1}, + "generator_version", + path=path, + ) + assert exc.value.status == "invalid_type" + + +def test_baseline_load_legacy_codeclone_version_alias(tmp_path: Path) -> None: + baseline_path = tmp_path / "baseline.json" + payload = _trusted_payload(generator_version="1.4.0") + meta = payload["meta"] + assert isinstance(meta, dict) + generator = meta.get("generator") + assert isinstance(generator, dict) + # Simulate pre-rename baseline metadata key. + meta["codeclone_version"] = generator.pop("version") + _write_payload(baseline_path, payload) + + baseline = Baseline(baseline_path) + baseline.load() + assert baseline.generator_version == "1.4.0" + + +def test_parse_generator_meta_string_legacy_alias(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + name, version = baseline_mod._parse_generator_meta( + { + "generator": "codeclone", + "codeclone_version": "1.4.0", + }, + path=path, + ) + assert name == "codeclone" + assert version == "1.4.0" + + +def test_parse_generator_meta_string_prefers_generator_version(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + name, version = baseline_mod._parse_generator_meta( + { + "generator": "codeclone", + "generator_version": "1.4.2", + "codeclone_version": "1.4.0", + }, + path=path, + ) + assert name == "codeclone" + assert version == "1.4.2" + + +def test_parse_generator_meta_object_top_level_fallback(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + name, version = baseline_mod._parse_generator_meta( + { + "generator": {"name": "codeclone"}, + "generator_version": "1.4.1", + }, + path=path, ) - assert "created_at" not in payload + assert name == "codeclone" + assert version == "1.4.1" + + +def test_parse_generator_meta_rejects_extra_generator_keys(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + with pytest.raises( + BaselineValidationError, match="unexpected generator keys" + ) as exc: + baseline_mod._parse_generator_meta( + {"generator": {"name": "codeclone", "version": "1.4.0", "extra": "x"}}, + path=path, + ) + assert exc.value.status == "invalid_type" + + +def test_baseline_parse_semver_three_parts(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + assert baseline_mod._parse_semver("1.2.3", key="schema_version", path=path) == ( + 1, + 2, + 3, + ) + + +def test_baseline_require_sorted_unique_ids_non_string(tmp_path: Path) -> None: + path = tmp_path / "baseline.json" + with pytest.raises( + BaselineValidationError, + match="'functions' must be list\\[str\\]", + ) as exc: + baseline_mod._require_sorted_unique_ids( + {"functions": [1]}, + "functions", + pattern=baseline_mod._FUNCTION_ID_RE, + path=path, + ) + assert exc.value.status == "invalid_type" diff --git a/tests/test_cache.py b/tests/test_cache.py index 160c9bb..086e664 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -83,7 +83,7 @@ def test_cache_signature_mismatch_warns(tmp_path: Path) -> None: loaded.load() assert loaded.load_warning is not None assert "signature" in loaded.load_warning - assert loaded.data["version"] == Cache.CACHE_VERSION + assert loaded.data["version"] == Cache._CACHE_VERSION assert loaded.data["files"] == {} @@ -101,19 +101,19 @@ def test_cache_version_mismatch_warns(tmp_path: Path) -> None: loaded.load() assert loaded.load_warning is not None assert "version" in loaded.load_warning - assert loaded.data["version"] == Cache.CACHE_VERSION + assert loaded.data["version"] == Cache._CACHE_VERSION assert loaded.data["files"] == {} def test_cache_too_large_warns(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: cache_path = tmp_path / "cache.json" - cache_path.write_text(json.dumps({"version": Cache.CACHE_VERSION, "files": {}})) + cache_path.write_text(json.dumps({"version": Cache._CACHE_VERSION, "files": {}})) monkeypatch.setattr(cache_mod, "MAX_CACHE_SIZE_BYTES", 1) cache = Cache(cache_path) cache.load() assert cache.load_warning is not None assert "too large" in cache.load_warning - assert cache.data["version"] == Cache.CACHE_VERSION + assert cache.data["version"] == Cache._CACHE_VERSION assert cache.data["files"] == {} @@ -350,7 +350,7 @@ def test_cache_load_corrupted_json(tmp_path: Path) -> None: def test_cache_load_invalid_files_type(tmp_path: Path) -> None: cache_path = tmp_path / "cache.json" cache = Cache(cache_path) - data = {"version": cache.CACHE_VERSION, "files": []} + data = {"version": cache._CACHE_VERSION, "files": []} signature = cache._sign_data(data) cache_path.write_text(json.dumps({**data, "_signature": signature}), "utf-8") cache.load() diff --git a/tests/test_cli_inprocess.py b/tests/test_cli_inprocess.py index e49a232..c32729a 100644 --- a/tests/test_cli_inprocess.py +++ b/tests/test_cli_inprocess.py @@ -1,6 +1,5 @@ from __future__ import annotations -import hashlib import json import re import sys @@ -13,8 +12,8 @@ import codeclone.baseline as baseline from codeclone import __version__, cli -from codeclone.baseline import BASELINE_SCHEMA_VERSION from codeclone.cache import Cache, file_stat_signature +from codeclone.contracts import BASELINE_FINGERPRINT_VERSION, BASELINE_SCHEMA_VERSION from codeclone.errors import CacheError @@ -141,35 +140,73 @@ def _baseline_payload( functions: list[str] | None = None, blocks: list[str] | None = None, python_version: str | None = None, + python_tag: str | None = None, + fingerprint_version: str | None = None, baseline_version: str | None = None, - schema_version: int | None = None, + schema_version: object | None = None, include_version_schema: bool = True, generator: str | None = "codeclone", + generator_version: str | None = None, payload_sha256: str | None = None, ) -> dict[str, object]: - function_list = [] if functions is None else functions - block_list = [] if blocks is None else blocks - payload: dict[str, object] = {"functions": function_list, "blocks": block_list} - if python_version is not None: - payload["python_version"] = python_version + function_list = sorted([] if functions is None else functions) + block_list = sorted([] if blocks is None else blocks) if include_version_schema: - payload["baseline_version"] = baseline_version or __version__ - payload["schema_version"] = ( + meta_fingerprint = ( + fingerprint_version or baseline_version or BASELINE_FINGERPRINT_VERSION + ) + meta_schema = ( BASELINE_SCHEMA_VERSION if schema_version is None else schema_version ) - if generator is not None: - payload["generator"] = generator - canonical = json.dumps( - {"functions": function_list, "blocks": block_list}, - sort_keys=True, - separators=(",", ":"), - ensure_ascii=False, - ) - payload["payload_sha256"] = ( - payload_sha256 - if payload_sha256 is not None - else hashlib.sha256(canonical.encode("utf-8")).hexdigest() - ) + impl = sys.implementation.name + prefix = "cp" if impl == "cpython" else impl[:2] + default_tag = f"{prefix}{sys.version_info.major}{sys.version_info.minor}" + version_tag: str | None = None + if python_version: + ver_match = re.fullmatch(r"(\d+)\.(\d+)(?:\.\d+)?", python_version.strip()) + if ver_match: + version_tag = f"{prefix}{ver_match.group(1)}{ver_match.group(2)}" + meta_python_tag = python_tag or version_tag or default_tag + meta_generator_version = generator_version or __version__ + + hash_value: str | None + if ( + isinstance(meta_fingerprint, str) + and isinstance(meta_schema, str) + and isinstance(meta_python_tag, str) + and payload_sha256 is None + ): + hash_value = baseline._compute_payload_sha256( + functions=set(function_list), + blocks=set(block_list), + schema_version=meta_schema, + fingerprint_version=meta_fingerprint, + python_tag=meta_python_tag, + ) + else: + hash_value = payload_sha256 + + meta: dict[str, object] = { + "generator": { + "name": generator if generator is not None else "codeclone", + "version": meta_generator_version, + }, + "schema_version": meta_schema, + "fingerprint_version": meta_fingerprint, + "python_tag": meta_python_tag, + "created_at": "2026-02-08T11:43:16Z", + "payload_sha256": hash_value if hash_value is not None else "x" * 64, + } + return { + "meta": meta, + "clones": {"functions": function_list, "blocks": block_list}, + } + + payload: dict[str, object] = {"functions": function_list, "blocks": block_list} + if baseline_version is not None: + payload["baseline_version"] = baseline_version + if schema_version is not None: + payload["schema_version"] = schema_version return payload @@ -179,10 +216,13 @@ def _write_baseline( functions: list[str] | None = None, blocks: list[str] | None = None, python_version: str | None = None, + python_tag: str | None = None, + fingerprint_version: str | None = None, baseline_version: str | None = None, - schema_version: int | None = None, + schema_version: object | None = None, include_version_schema: bool = True, generator: str | None = "codeclone", + generator_version: str | None = None, payload_sha256: str | None = None, ) -> Path: path.write_text( @@ -191,10 +231,13 @@ def _write_baseline( functions=functions, blocks=blocks, python_version=python_version, + python_tag=python_tag, + fingerprint_version=fingerprint_version, baseline_version=baseline_version, schema_version=schema_version, include_version_schema=include_version_schema, generator=generator, + generator_version=generator_version, payload_sha256=payload_sha256, ) ), @@ -241,8 +284,12 @@ def _assert_baseline_failure_meta( _run_main(monkeypatch, args) out = capsys.readouterr().out assert expected_message in out - if not strict_fail: + if strict_fail: + assert "CI requires a trusted baseline" in out + assert "Run: codeclone . --update-baseline" in out + else: assert "Baseline is not trusted for this run and will be ignored" in out + assert "Run: codeclone . --update-baseline" in out payload_out = json.loads(json_out.read_text("utf-8")) meta = payload_out["meta"] assert meta["baseline_status"] == expected_status @@ -748,8 +795,9 @@ def test_cli_reports_include_audit_metadata_ok( meta = payload["meta"] assert meta["baseline_status"] == "ok" assert meta["baseline_loaded"] is True - assert meta["baseline_version"] == __version__ + assert meta["baseline_fingerprint_version"] == BASELINE_FINGERPRINT_VERSION assert meta["baseline_schema_version"] == BASELINE_SCHEMA_VERSION + assert meta["baseline_generator_version"] == __version__ assert meta["baseline_path"] == str(baseline_path.resolve()) assert "function_clones" in payload assert "block_clones" in payload @@ -788,11 +836,11 @@ def test_cli_reports_include_audit_metadata_missing_baseline( meta = payload["meta"] assert meta["baseline_status"] == "missing" assert meta["baseline_loaded"] is False - assert meta["baseline_version"] is None + assert meta["baseline_fingerprint_version"] is None assert meta["baseline_schema_version"] is None -def test_cli_reports_include_audit_metadata_version_mismatch( +def test_cli_reports_include_audit_metadata_fingerprint_mismatch( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -806,26 +854,24 @@ def test_cli_reports_include_audit_metadata_version_mismatch( ) json_out = tmp_path / "report.json" _patch_parallel(monkeypatch) - with pytest.raises(SystemExit) as exc: - _run_main( - monkeypatch, - [ - str(tmp_path), - "--baseline", - str(baseline_path), - "--json", - str(json_out), - "--no-progress", - ], - ) - assert exc.value.code == 2 + _run_main( + monkeypatch, + [ + str(tmp_path), + "--baseline", + str(baseline_path), + "--json", + str(json_out), + "--no-progress", + ], + ) out = capsys.readouterr().out - assert "Baseline version mismatch" in out + assert "fingerprint version mismatch" in out payload = json.loads(json_out.read_text("utf-8")) meta = payload["meta"] - assert meta["baseline_status"] == "mismatch_version" - assert meta["baseline_loaded"] is True - assert meta["baseline_version"] == "0.0.0" + assert meta["baseline_status"] == "mismatch_fingerprint_version" + assert meta["baseline_loaded"] is False + assert meta["baseline_fingerprint_version"] == "0.0.0" def test_cli_reports_include_audit_metadata_schema_mismatch( @@ -838,30 +884,28 @@ def test_cli_reports_include_audit_metadata_schema_mismatch( baseline_path = _write_baseline( tmp_path / "baseline.json", python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - schema_version=999, + schema_version="1.1", ) json_out = tmp_path / "report.json" _patch_parallel(monkeypatch) - with pytest.raises(SystemExit) as exc: - _run_main( - monkeypatch, - [ - str(tmp_path), - "--baseline", - str(baseline_path), - "--json", - str(json_out), - "--no-progress", - ], - ) - assert exc.value.code == 2 + _run_main( + monkeypatch, + [ + str(tmp_path), + "--baseline", + str(baseline_path), + "--json", + str(json_out), + "--no-progress", + ], + ) out = capsys.readouterr().out - assert "Baseline schema version mismatch" in out + assert "schema version is newer than supported" in out payload = json.loads(json_out.read_text("utf-8")) meta = payload["meta"] - assert meta["baseline_status"] == "mismatch_schema" - assert meta["baseline_loaded"] is True - assert meta["baseline_schema_version"] == 999 + assert meta["baseline_status"] == "mismatch_schema_version" + assert meta["baseline_loaded"] is False + assert meta["baseline_schema_version"] == "1.1" def test_cli_reports_include_audit_metadata_python_mismatch( @@ -892,13 +936,12 @@ def test_cli_reports_include_audit_metadata_python_mismatch( ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline Python version mismatch" in out - assert "Baseline checks require the same Python version" in out + assert "python tag mismatch" in out payload = json.loads(json_out.read_text("utf-8")) meta = payload["meta"] - assert meta["baseline_status"] == "mismatch_python" - assert meta["baseline_loaded"] is True - assert meta["baseline_python_version"] == "0.0" + assert meta["baseline_status"] == "mismatch_python_version" + assert meta["baseline_loaded"] is False + assert meta["baseline_python_tag"] == "cp00" def test_cli_reports_include_audit_metadata_invalid_baseline( @@ -928,7 +971,7 @@ def test_cli_reports_include_audit_metadata_invalid_baseline( assert "Baseline is not trusted for this run and will be ignored" in out payload = json.loads(json_out.read_text("utf-8")) meta = payload["meta"] - assert meta["baseline_status"] == "invalid" + assert meta["baseline_status"] == "invalid_json" assert meta["baseline_loaded"] is False @@ -941,11 +984,103 @@ def test_cli_reports_include_audit_metadata_legacy_baseline( src.write_text("def f():\n return 1\n", "utf-8") baseline_path = tmp_path / "baseline.json" baseline_path.write_text( - json.dumps({"functions": [], "blocks": [], "python_version": "3.13"}), + json.dumps( + { + "functions": [], + "blocks": [], + "python_version": "3.13", + "schema_version": BASELINE_SCHEMA_VERSION, + } + ), "utf-8", ) json_out = tmp_path / "report.json" _patch_parallel(monkeypatch) + _run_main( + monkeypatch, + [ + str(tmp_path), + "--baseline", + str(baseline_path), + "--json", + str(json_out), + "--no-progress", + ], + ) + out = capsys.readouterr().out + assert "legacy" in out + payload = json.loads(json_out.read_text("utf-8")) + meta = payload["meta"] + assert meta["baseline_status"] == "missing_fields" + assert meta["baseline_loaded"] is False + + +def test_cli_legacy_baseline_normal_mode_ignored_and_exit_zero( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + src = tmp_path / "a.py" + src.write_text( + "def f():\n return 1\n\n\ndef g():\n return 1\n", + "utf-8", + ) + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text( + json.dumps( + { + "functions": [], + "blocks": [], + "python_version": "3.13", + "schema_version": BASELINE_SCHEMA_VERSION, + } + ), + "utf-8", + ) + + _patch_parallel(monkeypatch) + _run_main( + monkeypatch, + [ + str(tmp_path), + "--baseline", + str(baseline_path), + "--min-loc", + "1", + "--min-stmt", + "1", + "--no-progress", + "--no-color", + ], + ) + out = capsys.readouterr().out + assert "legacy (<=1.3.x)" in out + assert "Baseline is not trusted for this run and will be ignored" in out + assert "Comparison will proceed against an empty baseline" in out + assert "Run: codeclone . --update-baseline" in out + assert "New clones detected but --fail-on-new not set." in out + + +def test_cli_legacy_baseline_fail_on_new_fails_fast_exit_2( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + src = tmp_path / "a.py" + src.write_text("def f():\n return 1\n", "utf-8") + baseline_path = tmp_path / "baseline.json" + baseline_path.write_text( + json.dumps( + { + "functions": [], + "blocks": [], + "python_version": "3.13", + "schema_version": BASELINE_SCHEMA_VERSION, + } + ), + "utf-8", + ) + _patch_parallel(monkeypatch) with pytest.raises(SystemExit) as exc: _run_main( monkeypatch, @@ -953,19 +1088,16 @@ def test_cli_reports_include_audit_metadata_legacy_baseline( str(tmp_path), "--baseline", str(baseline_path), - "--json", - str(json_out), + "--fail-on-new", "--no-progress", ], ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "legacy baseline format" in out - assert "payload_sha256" not in out - payload = json.loads(json_out.read_text("utf-8")) - meta = payload["meta"] - assert meta["baseline_status"] == "legacy" - assert meta["baseline_loaded"] is True + assert "legacy (<=1.3.x)" in out + assert "Invalid baseline file" in out + assert "CI requires a trusted baseline" in out + assert "Run: codeclone . --update-baseline" in out def test_cli_reports_include_audit_metadata_integrity_failed( @@ -978,10 +1110,11 @@ def test_cli_reports_include_audit_metadata_integrity_failed( baseline_path = _write_baseline( tmp_path / "baseline.json", python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - functions=["f1"], ) tampered = json.loads(baseline_path.read_text("utf-8")) - tampered["functions"] = ["tampered"] + clones = tampered["clones"] + assert isinstance(clones, dict) + clones["functions"] = [f"{'a' * 40}|0-19"] baseline_path.write_text(json.dumps(tampered), "utf-8") json_out = tmp_path / "report.json" @@ -1043,12 +1176,12 @@ def test_cli_reports_include_audit_metadata_generator_mismatch( @pytest.mark.parametrize( ("field", "bad_value", "expected_message", "expected_status"), [ - ("generator", 123, "generator mismatch", "generator_mismatch"), + ("generator", 123, "'generator' must be string", "invalid_type"), ( "payload_sha256", 1, - "integrity payload hash is missing", - "integrity_missing", + "'payload_sha256' must be string", + "invalid_type", ), ], ) @@ -1061,11 +1194,16 @@ def test_cli_reports_include_audit_metadata_integrity_field_type_errors( expected_message: str, expected_status: str, ) -> None: + def _mutate(payload: dict[str, object]) -> None: + meta = payload.get("meta") + assert isinstance(meta, dict) + meta[field] = bad_value + _assert_baseline_failure_meta( tmp_path=tmp_path, monkeypatch=monkeypatch, capsys=capsys, - mutate_payload=lambda payload: payload.__setitem__(field, bad_value), + mutate_payload=_mutate, expected_message=expected_message, expected_status=expected_status, ) @@ -1082,7 +1220,9 @@ def test_cli_reports_include_audit_metadata_integrity_missing( payload = _baseline_payload( python_version=f"{sys.version_info.major}.{sys.version_info.minor}", ) - del payload["payload_sha256"] + meta = payload["meta"] + assert isinstance(meta, dict) + del meta["payload_sha256"] baseline_path.write_text(json.dumps(payload), "utf-8") json_out = tmp_path / "report.json" _patch_parallel(monkeypatch) @@ -1098,11 +1238,11 @@ def test_cli_reports_include_audit_metadata_integrity_missing( ], ) out = capsys.readouterr().out - assert "integrity payload hash is missing" in out + assert "missing required fields" in out assert "Baseline is not trusted for this run and will be ignored" in out payload_out = json.loads(json_out.read_text("utf-8")) meta = payload_out["meta"] - assert meta["baseline_status"] == "integrity_missing" + assert meta["baseline_status"] == "missing_fields" assert meta["baseline_loaded"] is False @@ -1173,7 +1313,9 @@ def f2(): capsys.readouterr() payload = json.loads(baseline_path.read_text("utf-8")) - payload["generator"] = "not-codeclone" + meta = payload["meta"] + assert isinstance(meta, dict) + meta["generator"] = "not-codeclone" baseline_path.write_text(json.dumps(payload), "utf-8") json_out = tmp_path / "report.json" _run_main( @@ -1205,15 +1347,15 @@ def f2(): ("generator", "not-codeclone", "generator mismatch", "generator_mismatch"), ( "payload_sha256", - "00", + "0" * 64, "integrity check failed", "integrity_failed", ), ( "payload_sha256", None, - "integrity payload hash is missing", - "integrity_missing", + "missing required fields", + "missing_fields", ), ], ) @@ -1227,10 +1369,12 @@ def test_cli_untrusted_baseline_fails_in_ci( expected_status: str, ) -> None: def _mutate(payload: dict[str, object]) -> None: + meta = payload["meta"] + assert isinstance(meta, dict) if bad_value is None: - payload.pop(field, None) + meta.pop(field, None) else: - payload[field] = bad_value + meta[field] = bad_value _assert_baseline_failure_meta( tmp_path=tmp_path, @@ -1271,7 +1415,7 @@ def test_cli_invalid_baseline_fails_in_ci( out = capsys.readouterr().out assert "Invalid baseline file" in out payload = json.loads(json_out.read_text("utf-8")) - assert payload["meta"]["baseline_status"] == "invalid" + assert payload["meta"]["baseline_status"] == "invalid_json" assert payload["meta"]["baseline_loaded"] is False @@ -1547,8 +1691,13 @@ def test_cli_update_baseline_with_invalid_existing_file( assert "Baseline updated" in out assert "Invalid baseline file" not in out payload = json.loads(baseline_path.read_text("utf-8")) - assert payload.get("baseline_version") == __version__ - assert payload.get("schema_version") == BASELINE_SCHEMA_VERSION + meta = payload["meta"] + assert isinstance(meta, dict) + assert meta.get("fingerprint_version") == BASELINE_FINGERPRINT_VERSION + assert meta.get("schema_version") == BASELINE_SCHEMA_VERSION + generator = meta.get("generator") + assert isinstance(generator, dict) + assert generator.get("version") == __version__ def test_cli_baseline_missing_warning( @@ -1571,6 +1720,7 @@ def test_cli_baseline_missing_warning( ) out = capsys.readouterr().out assert "Baseline file not found" in out + assert "Run: codeclone . --update-baseline" in out def test_cli_new_clones_warning( @@ -1629,11 +1779,11 @@ def test_cli_baseline_python_version_mismatch_warns( ], ) out = capsys.readouterr().out - assert "Baseline was generated with Python 0.0." in out - assert "Current interpreter: Python" in out + assert "python tag mismatch" in out + assert "will be ignored" in out -def test_cli_baseline_version_mismatch_fails( +def test_cli_baseline_fingerprint_mismatch_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1654,15 +1804,16 @@ def test_cli_baseline_version_mismatch_fails( str(tmp_path), "--baseline", str(baseline_path), + "--ci", "--no-progress", ], ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline version mismatch" in out + assert "fingerprint version mismatch" in out -def test_cli_baseline_version_missing_fails( +def test_cli_baseline_missing_fields_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1689,12 +1840,13 @@ def test_cli_baseline_version_missing_fails( str(tmp_path), "--baseline", str(baseline_path), + "--ci", "--no-progress", ], ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline version missing" in out + assert "legacy (<=1.3.x)" in out def test_cli_baseline_schema_version_mismatch_fails( @@ -1708,7 +1860,7 @@ def test_cli_baseline_schema_version_mismatch_fails( _write_baseline( baseline_path, python_version=f"{sys.version_info.major}.{sys.version_info.minor}", - schema_version=999, + schema_version="1.1", ) _patch_parallel(monkeypatch) with pytest.raises(SystemExit) as exc: @@ -1718,15 +1870,16 @@ def test_cli_baseline_schema_version_mismatch_fails( str(tmp_path), "--baseline", str(baseline_path), + "--ci", "--no-progress", ], ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline schema version mismatch" in out + assert "schema version is newer than supported" in out -def test_cli_baseline_version_and_schema_mismatch_status_prefers_version( +def test_cli_baseline_schema_and_fingerprint_mismatch_status_prefers_schema( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1737,7 +1890,7 @@ def test_cli_baseline_version_and_schema_mismatch_status_prefers_version( tmp_path / "baseline.json", python_version=f"{sys.version_info.major}.{sys.version_info.minor}", baseline_version="0.0.0", - schema_version=999, + schema_version="1.1", ) json_out = tmp_path / "report.json" _patch_parallel(monkeypatch) @@ -1750,18 +1903,19 @@ def test_cli_baseline_version_and_schema_mismatch_status_prefers_version( str(baseline_path), "--json", str(json_out), + "--ci", "--no-progress", ], ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline version mismatch" in out - assert "Baseline schema version mismatch" in out + assert "schema version is newer than supported" in out + assert "fingerprint version mismatch" not in out payload = json.loads(json_out.read_text("utf-8")) - assert payload["meta"]["baseline_status"] == "mismatch_version" + assert payload["meta"]["baseline_status"] == "mismatch_schema_version" -def test_cli_baseline_version_and_python_mismatch_status_prefers_version( +def test_cli_baseline_fingerprint_and_python_mismatch_status_prefers_fingerprint( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], @@ -1784,15 +1938,16 @@ def test_cli_baseline_version_and_python_mismatch_status_prefers_version( str(baseline_path), "--json", str(json_out), + "--ci", "--no-progress", ], ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline version mismatch" in out - assert "Baseline Python version mismatch" in out + assert "fingerprint version mismatch" in out + assert "Python version mismatch" not in out payload = json.loads(json_out.read_text("utf-8")) - assert payload["meta"]["baseline_status"] == "mismatch_version" + assert payload["meta"]["baseline_status"] == "mismatch_fingerprint_version" def test_cli_baseline_python_version_mismatch_fails( @@ -1818,7 +1973,7 @@ def test_cli_baseline_python_version_mismatch_fails( ) assert exc.value.code == 2 out = capsys.readouterr().out - assert "Baseline checks require the same Python version" in out + assert "python tag mismatch" in out def test_cli_negative_size_limits_fail_fast( @@ -1860,7 +2015,7 @@ def f2(): "--no-progress", ], ) - assert exc.value.code == 2 + assert exc.value.code == 3 def test_cli_main_fail_on_new(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index a9c3693..859a3c6 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -6,10 +6,12 @@ import pytest from rich.text import Text +import codeclone._cli_summary as cli_summary import codeclone.cli as cli from codeclone import __version__ from codeclone import ui_messages as ui -from codeclone.cli import expand_path, process_file +from codeclone._cli_paths import expand_path +from codeclone.cli import process_file from codeclone.normalize import NormalizationConfig @@ -119,22 +121,26 @@ def test_cli_help_text_consistency( def test_summary_value_style_mapping() -> None: - assert cli._summary_value_style(label=ui.SUMMARY_LABEL_FUNCTION, value=0) == "dim" assert ( - cli._summary_value_style(label=ui.SUMMARY_LABEL_FUNCTION, value=2) + cli_summary._summary_value_style(label=ui.SUMMARY_LABEL_FUNCTION, value=0) + == "dim" + ) + assert ( + cli_summary._summary_value_style(label=ui.SUMMARY_LABEL_FUNCTION, value=2) == "bold green" ) assert ( - cli._summary_value_style(label=ui.SUMMARY_LABEL_SUPPRESSED, value=1) == "yellow" + cli_summary._summary_value_style(label=ui.SUMMARY_LABEL_SUPPRESSED, value=1) + == "yellow" ) assert ( - cli._summary_value_style(label=ui.SUMMARY_LABEL_NEW_BASELINE, value=3) + cli_summary._summary_value_style(label=ui.SUMMARY_LABEL_NEW_BASELINE, value=3) == "bold red" ) def test_build_summary_table_rows_and_styles() -> None: - rows = cli._build_summary_rows( + rows = cli_summary._build_summary_rows( files_found=2, files_analyzed=0, cache_hits=2, @@ -145,7 +151,7 @@ def test_build_summary_table_rows_and_styles() -> None: suppressed_segment_groups=1, new_clones_count=1, ) - table = cli._build_summary_table(rows) + table = cli_summary._build_summary_table(rows) assert table.title == ui.SUMMARY_TITLE assert table.columns[0]._cells == [label for label, _ in rows] value_cells = table.columns[1]._cells @@ -157,7 +163,7 @@ def test_build_summary_table_rows_and_styles() -> None: def test_build_summary_rows_order() -> None: - rows = cli._build_summary_rows( + rows = cli_summary._build_summary_rows( files_found=1, files_analyzed=1, cache_hits=0, @@ -186,7 +192,8 @@ def test_print_summary_invariant_warning( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str] ) -> None: monkeypatch.setattr(cli, "console", cli._make_console(no_color=True)) - cli._print_summary( + cli_summary._print_summary( + console=cli.console, quiet=False, files_found=1, files_analyzed=0, diff --git a/tests/test_html_report.py b/tests/test_html_report.py index 431d088..7153cfe 100644 --- a/tests/test_html_report.py +++ b/tests/test_html_report.py @@ -1,6 +1,7 @@ import importlib import json from pathlib import Path +from typing import Any import pytest @@ -11,10 +12,34 @@ _pygments_css, _render_code_block, _try_pygments, - build_html_report, pairwise, ) -from codeclone.report import to_json_report +from codeclone.html_report import ( + build_html_report as _core_build_html_report, +) +from codeclone.report import build_block_group_facts, to_json_report + + +def build_html_report( + *, + func_groups: dict[str, list[dict[str, Any]]], + block_groups: dict[str, list[dict[str, Any]]], + segment_groups: dict[str, list[dict[str, Any]]], + block_group_facts: dict[str, dict[str, str]] | None = None, + **kwargs: Any, +) -> str: + resolved_block_group_facts = ( + block_group_facts + if block_group_facts is not None + else build_block_group_facts(block_groups) + ) + return _core_build_html_report( + func_groups=func_groups, + block_groups=block_groups, + segment_groups=segment_groups, + block_group_facts=resolved_block_group_facts, + **kwargs, + ) def test_html_report_empty() -> None: @@ -26,6 +51,15 @@ def test_html_report_empty() -> None: assert "No code clones detected" in html +def test_html_report_requires_block_group_facts_argument() -> None: + with pytest.raises(TypeError): + _core_build_html_report( + func_groups={}, + block_groups={}, + segment_groups={}, + ) # type: ignore[call-arg] + + def test_html_report_generation(tmp_path: Path) -> None: f1 = tmp_path / "a.py" f1.write_text("def f1():\n pass\n", "utf-8") @@ -80,6 +114,212 @@ def test_html_report_group_and_item_metadata_attrs(tmp_path: Path) -> None: assert 'data-end-line="2"' in html +def test_html_report_block_group_includes_match_basis_and_compact_key() -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + html = build_html_report( + func_groups={}, + block_groups={ + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": __file__, + "start_line": 1, + "end_line": 4, + } + ] + }, + segment_groups={}, + ) + assert 'data-match-rule="normalized_sliding_window"' in html + assert 'data-block-size="4"' in html + assert 'data-signature-kind="stmt_hash_sequence"' in html + assert 'data-merged-regions="true"' in html + assert 'data-pattern="repeated_stmt_hash"' in html + compact = f'{repeated[:12]} x4' + assert compact in html + + +def test_html_report_block_group_includes_assert_only_explanation( + tmp_path: Path, +) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_repeated_asserts.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + html = build_html_report( + func_groups={}, + block_groups={ + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + ] + }, + segment_groups={}, + ) + assert 'data-hint="assert_only"' in html + assert 'data-hint-confidence="deterministic"' in html + assert 'data-assert-ratio="100%"' in html + assert 'data-consecutive-asserts="4"' in html + assert "assert_ratio: 100%" in html + assert "consecutive_asserts: 4" in html + assert "This block clone consists entirely of assert-only statements." in html + + +def test_html_report_uses_core_block_group_facts(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_repeated_asserts.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + html = build_html_report( + func_groups={}, + block_groups={ + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + ] + }, + segment_groups={}, + block_group_facts={ + group_key: { + "match_rule": "core_contract", + "block_size": "99", + "signature_kind": "core_signature", + "merged_regions": "false", + "hint": "assert_only", + "hint_confidence": "deterministic", + "assert_ratio": "7%", + "consecutive_asserts": "1", + "hint_note": "Facts are owned by core.", + } + }, + ) + assert 'data-match-rule="core_contract"' in html + assert 'data-block-size="99"' in html + assert 'data-signature-kind="core_signature"' in html + assert 'data-merged-regions="false"' in html + assert 'data-assert-ratio="7%"' in html + assert 'data-consecutive-asserts="1"' in html + assert "assert_ratio: 7%" in html + assert "consecutive_asserts: 1" in html + assert "Facts are owned by core." in html + + +def test_html_report_respects_sparse_core_block_facts(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_repeated_asserts.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + html = build_html_report( + func_groups={}, + block_groups={ + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + ] + }, + segment_groups={}, + report_meta={ + "baseline_path": " ", + "cache_path": "/", + "baseline_status": "ok", + }, + block_group_facts={ + group_key: { + "match_rule": "core_sparse", + "pattern": "repeated_stmt_hash", + "hint": "assert_only", + "hint_confidence": "deterministic", + } + }, + ) + assert 'data-match-rule="core_sparse"' in html + assert "pattern: repeated_stmt_hash" in html + assert 'data-block-size="' not in html + assert 'data-signature-kind="' not in html + assert "assert_ratio:" not in html + assert "consecutive_asserts:" not in html + + +def test_html_report_handles_root_only_baseline_path() -> None: + html = build_html_report( + func_groups={}, + block_groups={}, + segment_groups={}, + report_meta={"baseline_path": "/", "cache_path": "/"}, + ) + assert 'data-baseline-file=""' in html + + +def test_html_report_explanation_without_match_rule(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_repeated_asserts.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + html = build_html_report( + func_groups={}, + block_groups={ + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + ] + }, + segment_groups={}, + block_group_facts={ + group_key: { + "hint": "assert_only", + "hint_confidence": "deterministic", + } + }, + ) + assert 'data-hint="assert_only"' in html + assert "match_rule:" not in html + + def test_html_report_command_palette_full_actions_present() -> None: html = build_html_report(func_groups={}, block_groups={}, segment_groups={}) assert "Export as PDF" in html @@ -112,21 +352,31 @@ def test_html_report_includes_provenance_metadata(tmp_path: Path) -> None: "codeclone_version": "1.3.0", "python_version": "3.13", "baseline_path": "/repo/codeclone.baseline.json", - "baseline_version": "1.3.0", + "baseline_fingerprint_version": "1", "baseline_schema_version": 1, "baseline_python_version": "3.13", + "baseline_generator_version": "1.4.0", "baseline_loaded": True, "baseline_status": "ok", "cache_path": "/repo/.cache/codeclone/cache.json", "cache_used": True, }, ) - assert "Report Provenance" in html - assert "CodeClone" in html - assert "Baseline schema" in html - assert 'data-baseline-status="ok"' in html - assert "/repo/codeclone.baseline.json" in html - assert 'data-cache-used="true"' in html + expected = [ + "Report Provenance", + "CodeClone", + "Baseline file", + "Baseline path", + "Baseline schema", + "Baseline generator version", + "codeclone.baseline.json", + 'data-baseline-status="ok"', + 'data-baseline-file="codeclone.baseline.json"', + "/repo/codeclone.baseline.json", + 'data-cache-used="true"', + ] + for token in expected: + assert token in html def test_html_report_escapes_meta_and_title(tmp_path: Path) -> None: @@ -570,9 +820,6 @@ def test_render_code_block_truncates_and_fallback( def test_pygments_css_get_style_defs_error(monkeypatch: pytest.MonkeyPatch) -> None: class _Fmt: - def __init__(self, *args: object, **kwargs: object) -> None: - return None - def get_style_defs(self, _selector: str) -> str: raise RuntimeError("nope") diff --git a/tests/test_report.py b/tests/test_report.py index 0f16b59..9c96453 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -7,9 +7,12 @@ import codeclone.report as report_mod from codeclone.report import ( + GroupMap, + build_block_group_facts, build_block_groups, build_groups, build_segment_groups, + prepare_block_report_groups, prepare_segment_report_groups, to_json, to_json_report, @@ -40,6 +43,191 @@ def test_block_groups_require_multiple_functions() -> None: assert len(groups) == 1 +def test_prepare_block_report_groups_merges_to_maximal_regions() -> None: + groups = { + "h": [ + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:f", + "start_line": 20, + "end_line": 23, + "size": 4, + }, + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:f", + "start_line": 10, + "end_line": 13, + "size": 4, + }, + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:f", + "start_line": 13, + "end_line": 16, + "size": 4, + }, + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:g", + "start_line": 10, + "end_line": 13, + "size": 4, + }, + ] + } + + prepared = prepare_block_report_groups(groups) + items = prepared["h"] + assert len(items) == 3 + + assert items[0]["qualname"] == "mod:f" + assert items[0]["start_line"] == 10 + assert items[0]["end_line"] == 16 + assert items[0]["size"] == 7 + + assert items[1]["qualname"] == "mod:f" + assert items[1]["start_line"] == 20 + assert items[1]["end_line"] == 23 + assert items[1]["size"] == 4 + + assert items[2]["qualname"] == "mod:g" + assert items[2]["start_line"] == 10 + assert items[2]["end_line"] == 13 + assert items[2]["size"] == 4 + + +def test_prepare_block_report_groups_skips_invalid_ranges() -> None: + groups = { + "h": [ + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:f", + "start_line": "bad", + "end_line": 13, + "size": 4, + }, + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:f", + "start_line": 30, + "end_line": 33, + "size": 4, + }, + ] + } + prepared = prepare_block_report_groups(groups) + assert len(prepared["h"]) == 1 + assert prepared["h"][0]["start_line"] == 30 + assert prepared["h"][0]["end_line"] == 33 + + +def test_prepare_block_report_groups_all_invalid_ranges_fallback_sorted() -> None: + groups: GroupMap = { + "h": [ + { + "block_hash": "h", + "filepath": "b.py", + "qualname": "mod:f", + "start_line": "bad", + "end_line": 13, + "size": 4, + }, + { + "block_hash": "h", + "filepath": "a.py", + "qualname": "mod:f", + "start_line": None, + "end_line": 1, + "size": 4, + }, + ] + } + prepared = prepare_block_report_groups(groups) + items = prepared["h"] + assert len(items) == 2 + assert items[0]["filepath"] == "a.py" + assert items[1]["filepath"] == "b.py" + + +def test_prepare_block_report_groups_handles_empty_item_list() -> None: + groups: GroupMap = {"h": []} + prepared = prepare_block_report_groups(groups) + assert prepared["h"] == [] + + +def test_build_block_group_facts_assert_only(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_repeated_asserts.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + ] + } + ) + group = facts[group_key] + assert group["match_rule"] == "normalized_sliding_window" + assert group["block_size"] == "4" + assert group["signature_kind"] == "stmt_hash_sequence" + assert group["merged_regions"] == "true" + assert group["pattern"] == "repeated_stmt_hash" + assert group["pattern_display"] == f"{repeated[:12]} x4" + assert group["hint"] == "assert_only" + assert group["hint_confidence"] == "deterministic" + assert group["assert_ratio"] == "100%" + assert group["consecutive_asserts"] == "4" + + +def test_build_block_group_facts_deterministic_item_order(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_repeated_asserts.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + item_a = { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + item_b = { + "qualname": "pkg.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 5, + } + facts_a = build_block_group_facts({group_key: [item_a, item_b]}) + facts_b = build_block_group_facts({group_key: [item_b, item_a]}) + assert facts_a == facts_b + + def test_report_output_formats() -> None: groups = { "k1": [ @@ -72,9 +260,10 @@ def test_report_output_formats() -> None: "codeclone_version": "1.3.0", "python_version": "3.13", "baseline_path": "/tmp/codeclone.baseline.json", - "baseline_version": "1.3.0", + "baseline_fingerprint_version": "1", "baseline_schema_version": 1, "baseline_python_version": "3.13", + "baseline_generator_version": "1.4.0", "baseline_loaded": True, "baseline_status": "ok", "cache_path": "/tmp/cache.json", @@ -89,13 +278,16 @@ def test_report_output_formats() -> None: segment_groups={}, ) - assert "group_count" in json_out - assert '"meta"' in report_out - assert '"function_clones"' in report_out - assert '"baseline_schema_version": 1' in report_out - assert "REPORT METADATA" in text_out - assert "Baseline schema version: 1" in text_out - assert "Clone group #1" in text_out + expected_json = ["group_count"] + expected_report = ['"meta"', '"function_clones"', '"baseline_schema_version": 1'] + expected_text = ["REPORT METADATA", "Baseline schema version: 1", "Clone group #1"] + + for token in expected_json: + assert token in json_out + for token in expected_report: + assert token in report_out + for token in expected_text: + assert token in text_out def test_report_json_deterministic_group_order() -> None: @@ -614,7 +806,7 @@ def test_segment_helpers_cover_edge_cases(tmp_path: Path) -> None: class Dummy: body = None - dummy = cast(ast.FunctionDef, Dummy()) + dummy = cast(ast.FunctionDef, cast(object, Dummy())) assert report_mod._segment_statements(dummy, 1, 2) == [] func = ast.parse("def f():\n x = 1\n").body[0] diff --git a/tests/test_report_explain.py b/tests/test_report_explain.py new file mode 100644 index 0000000..4a88067 --- /dev/null +++ b/tests/test_report_explain.py @@ -0,0 +1,215 @@ +from pathlib import Path + +from codeclone._report_explain import build_block_group_facts + + +def test_build_block_group_facts_handles_missing_file() -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": "/definitely/missing/file.py", + "start_line": 10, + "end_line": 20, + } + ] + } + ) + group = facts[group_key] + assert group["match_rule"] == "normalized_sliding_window" + assert group["pattern"] == "repeated_stmt_hash" + assert "hint" not in group + assert "assert_ratio" not in group + + +def test_build_block_group_facts_handles_syntax_error_file(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + broken = tmp_path / "broken.py" + broken.write_text("def f(:\n pass\n", "utf-8") + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(broken), + "start_line": 1, + "end_line": 2, + } + ] + } + ) + assert "hint" not in facts[group_key] + + +def test_build_block_group_facts_assert_detection_with_calls(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_calls.py" + test_file.write_text( + "def f(checker):\n" + ' "doc"\n' + " assert_ok(checker)\n" + " checker.assert_ready(checker)\n", + "utf-8", + ) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "tests.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 4, + } + ] + } + ) + group = facts[group_key] + assert group["hint"] == "assert_only" + assert group["assert_ratio"] == "100%" + assert group["consecutive_asserts"] == "3" + + +def test_build_block_group_facts_non_assert_breaks_hint(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "test_mixed.py" + test_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " check(html)\n" + " assert 'b' in html\n", + "utf-8", + ) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "tests.mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 4, + } + ] + } + ) + group = facts[group_key] + assert "hint" not in group + assert group["assert_ratio"] == "67%" + assert group["consecutive_asserts"] == "1" + + +def test_build_block_group_facts_non_repeated_signature_has_no_pattern() -> None: + group_key = ( + "0e8579f84e518d186950d012c9944a40cb872332|" + "1e8579f84e518d186950d012c9944a40cb872332|" + "2e8579f84e518d186950d012c9944a40cb872332|" + "3e8579f84e518d186950d012c9944a40cb872332" + ) + facts = build_block_group_facts({group_key: []}) + group = facts[group_key] + assert group["block_size"] == "4" + assert "pattern" not in group + + +def test_build_block_group_facts_handles_empty_stmt_range(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "module.py" + test_file.write_text("def f():\n return 1\n", "utf-8") + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "mod:f", + "filepath": str(test_file), + "start_line": 100, + "end_line": 200, + } + ] + } + ) + group = facts[group_key] + assert "assert_ratio" not in group + assert "hint" not in group + + +def test_build_block_group_facts_non_assert_call_shapes(tmp_path: Path) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + test_file = tmp_path / "module.py" + test_file.write_text( + "def f(checker, x):\n checker.validate(x)\n (lambda y: y)(x)\n x\n", + "utf-8", + ) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "mod:f", + "filepath": str(test_file), + "start_line": 2, + "end_line": 4, + } + ] + } + ) + group = facts[group_key] + assert group["assert_ratio"] == "0%" + assert group["consecutive_asserts"] == "0" + assert "hint" not in group + + +def test_build_block_group_facts_invalid_item_disables_assert_hint() -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "mod:f", + "filepath": "", + "start_line": 0, + "end_line": 0, + } + ] + } + ) + group = facts[group_key] + assert "hint" not in group + assert "assert_ratio" not in group + + +def test_build_block_group_facts_assert_only_without_test_context( + tmp_path: Path, +) -> None: + repeated = "0e8579f84e518d186950d012c9944a40cb872332" + group_key = "|".join([repeated] * 4) + prod_file = tmp_path / "module.py" + prod_file.write_text( + "def f(html):\n" + " assert 'a' in html\n" + " assert 'b' in html\n" + " assert 'c' in html\n" + " assert 'd' in html\n", + "utf-8", + ) + facts = build_block_group_facts( + { + group_key: [ + { + "qualname": "pkg.mod:f", + "filepath": str(prod_file), + "start_line": 2, + "end_line": 5, + } + ] + } + ) + group = facts[group_key] + assert group["hint"] == "assert_only" + assert "hint_context" not in group diff --git a/tests/test_security.py b/tests/test_security.py index 56d0b00..9bf64a1 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -9,6 +9,7 @@ from codeclone.errors import ValidationError from codeclone.html_report import build_html_report from codeclone.normalize import NormalizationConfig +from codeclone.report import build_block_group_facts from codeclone.scanner import iter_py_files @@ -73,6 +74,7 @@ def test_html_report_escapes_user_content(tmp_path: Path) -> None: func_groups=func_groups, block_groups={}, segment_groups={}, + block_group_facts=build_block_group_facts({}), title="Security", ) assert "" not in html diff --git a/uv.lock b/uv.lock index 611e943..ed9612e 100644 --- a/uv.lock +++ b/uv.lock @@ -189,7 +189,7 @@ wheels = [ [[package]] name = "codeclone" -version = "1.3.0" +version = "1.4.0" source = { editable = "." } dependencies = [ { name = "pygments" }, @@ -232,101 +232,101 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.2" +version = "7.13.3" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ad/49/349848445b0e53660e258acbcc9b0d014895b6739237920886672240f84b/coverage-7.13.2.tar.gz", hash = "sha256:044c6951ec37146b72a50cc81ef02217d27d4c3640efd2640311393cbbf143d3", size = 826523, upload-time = "2026-01-25T13:00:04.889Z" } +sdist = { url = "https://files.pythonhosted.org/packages/11/43/3e4ac666cc35f231fa70c94e9f38459299de1a152813f9d2f60fc5f3ecaf/coverage-7.13.3.tar.gz", hash = "sha256:f7f6182d3dfb8802c1747eacbfe611b669455b69b7c037484bb1efbbb56711ac", size = 826832, upload-time = "2026-02-03T14:02:30.944Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/2d/63e37369c8e81a643afe54f76073b020f7b97ddbe698c5c944b51b0a2bc5/coverage-7.13.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:f4af3b01763909f477ea17c962e2cca8f39b350a4e46e3a30838b2c12e31b81b", size = 218842, upload-time = "2026-01-25T12:57:15.3Z" }, - { url = "https://files.pythonhosted.org/packages/57/06/86ce882a8d58cbcb3030e298788988e618da35420d16a8c66dac34f138d0/coverage-7.13.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:36393bd2841fa0b59498f75466ee9bdec4f770d3254f031f23e8fd8e140ffdd2", size = 219360, upload-time = "2026-01-25T12:57:17.572Z" }, - { url = "https://files.pythonhosted.org/packages/cd/84/70b0eb1ee19ca4ef559c559054c59e5b2ae4ec9af61398670189e5d276e9/coverage-7.13.2-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9cc7573518b7e2186bd229b1a0fe24a807273798832c27032c4510f47ffdb896", size = 246123, upload-time = "2026-01-25T12:57:19.087Z" }, - { url = "https://files.pythonhosted.org/packages/35/fb/05b9830c2e8275ebc031e0019387cda99113e62bb500ab328bb72578183b/coverage-7.13.2-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ca9566769b69a5e216a4e176d54b9df88f29d750c5b78dbb899e379b4e14b30c", size = 247930, upload-time = "2026-01-25T12:57:20.929Z" }, - { url = "https://files.pythonhosted.org/packages/81/aa/3f37858ca2eed4f09b10ca3c6ddc9041be0a475626cd7fd2712f4a2d526f/coverage-7.13.2-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9c9bdea644e94fd66d75a6f7e9a97bb822371e1fe7eadae2cacd50fcbc28e4dc", size = 249804, upload-time = "2026-01-25T12:57:22.904Z" }, - { url = "https://files.pythonhosted.org/packages/b6/b3/c904f40c56e60a2d9678a5ee8df3d906d297d15fb8bec5756c3b0a67e2df/coverage-7.13.2-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5bd447332ec4f45838c1ad42268ce21ca87c40deb86eabd59888859b66be22a5", size = 246815, upload-time = "2026-01-25T12:57:24.314Z" }, - { url = "https://files.pythonhosted.org/packages/41/91/ddc1c5394ca7fd086342486440bfdd6b9e9bda512bf774599c7c7a0081e0/coverage-7.13.2-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:7c79ad5c28a16a1277e1187cf83ea8dafdcc689a784228a7d390f19776db7c31", size = 247843, upload-time = "2026-01-25T12:57:26.544Z" }, - { url = "https://files.pythonhosted.org/packages/87/d2/cdff8f4cd33697883c224ea8e003e9c77c0f1a837dc41d95a94dd26aad67/coverage-7.13.2-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:76e06ccacd1fb6ada5d076ed98a8c6f66e2e6acd3df02819e2ee29fd637b76ad", size = 245850, upload-time = "2026-01-25T12:57:28.507Z" }, - { url = "https://files.pythonhosted.org/packages/f5/42/e837febb7866bf2553ab53dd62ed52f9bb36d60c7e017c55376ad21fbb05/coverage-7.13.2-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:49d49e9a5e9f4dc3d3dac95278a020afa6d6bdd41f63608a76fa05a719d5b66f", size = 246116, upload-time = "2026-01-25T12:57:30.16Z" }, - { url = "https://files.pythonhosted.org/packages/09/b1/4a3f935d7df154df02ff4f71af8d61298d713a7ba305d050ae475bfbdde2/coverage-7.13.2-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:ed2bce0e7bfa53f7b0b01c722da289ef6ad4c18ebd52b1f93704c21f116360c8", size = 246720, upload-time = "2026-01-25T12:57:32.165Z" }, - { url = "https://files.pythonhosted.org/packages/e1/fe/538a6fd44c515f1c5197a3f078094cbaf2ce9f945df5b44e29d95c864bff/coverage-7.13.2-cp310-cp310-win32.whl", hash = "sha256:1574983178b35b9af4db4a9f7328a18a14a0a0ce76ffaa1c1bacb4cc82089a7c", size = 221465, upload-time = "2026-01-25T12:57:33.511Z" }, - { url = "https://files.pythonhosted.org/packages/5e/09/4b63a024295f326ec1a40ec8def27799300ce8775b1cbf0d33b1790605c4/coverage-7.13.2-cp310-cp310-win_amd64.whl", hash = "sha256:a360a8baeb038928ceb996f5623a4cd508728f8f13e08d4e96ce161702f3dd99", size = 222397, upload-time = "2026-01-25T12:57:34.927Z" }, - { url = "https://files.pythonhosted.org/packages/6c/01/abca50583a8975bb6e1c59eff67ed8e48bb127c07dad5c28d9e96ccc09ec/coverage-7.13.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:060ebf6f2c51aff5ba38e1f43a2095e087389b1c69d559fde6049a4b0001320e", size = 218971, upload-time = "2026-01-25T12:57:36.953Z" }, - { url = "https://files.pythonhosted.org/packages/eb/0e/b6489f344d99cd1e5b4d5e1be52dfd3f8a3dc5112aa6c33948da8cabad4e/coverage-7.13.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c1ea8ca9db5e7469cd364552985e15911548ea5b69c48a17291f0cac70484b2e", size = 219473, upload-time = "2026-01-25T12:57:38.934Z" }, - { url = "https://files.pythonhosted.org/packages/17/11/db2f414915a8e4ec53f60b17956c27f21fb68fcf20f8a455ce7c2ccec638/coverage-7.13.2-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:b780090d15fd58f07cf2011943e25a5f0c1c894384b13a216b6c86c8a8a7c508", size = 249896, upload-time = "2026-01-25T12:57:40.365Z" }, - { url = "https://files.pythonhosted.org/packages/80/06/0823fe93913663c017e508e8810c998c8ebd3ec2a5a85d2c3754297bdede/coverage-7.13.2-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:88a800258d83acb803c38175b4495d293656d5fac48659c953c18e5f539a274b", size = 251810, upload-time = "2026-01-25T12:57:42.045Z" }, - { url = "https://files.pythonhosted.org/packages/61/dc/b151c3cc41b28cdf7f0166c5fa1271cbc305a8ec0124cce4b04f74791a18/coverage-7.13.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6326e18e9a553e674d948536a04a80d850a5eeefe2aae2e6d7cf05d54046c01b", size = 253920, upload-time = "2026-01-25T12:57:44.026Z" }, - { url = "https://files.pythonhosted.org/packages/2d/35/e83de0556e54a4729a2b94ea816f74ce08732e81945024adee46851c2264/coverage-7.13.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:59562de3f797979e1ff07c587e2ac36ba60ca59d16c211eceaa579c266c5022f", size = 250025, upload-time = "2026-01-25T12:57:45.624Z" }, - { url = "https://files.pythonhosted.org/packages/39/67/af2eb9c3926ce3ea0d58a0d2516fcbdacf7a9fc9559fe63076beaf3f2596/coverage-7.13.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:27ba1ed6f66b0e2d61bfa78874dffd4f8c3a12f8e2b5410e515ab345ba7bc9c3", size = 251612, upload-time = "2026-01-25T12:57:47.713Z" }, - { url = "https://files.pythonhosted.org/packages/26/62/5be2e25f3d6c711d23b71296f8b44c978d4c8b4e5b26871abfc164297502/coverage-7.13.2-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:8be48da4d47cc68754ce643ea50b3234557cbefe47c2f120495e7bd0a2756f2b", size = 249670, upload-time = "2026-01-25T12:57:49.378Z" }, - { url = "https://files.pythonhosted.org/packages/b3/51/400d1b09a8344199f9b6a6fc1868005d766b7ea95e7882e494fa862ca69c/coverage-7.13.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:2a47a4223d3361b91176aedd9d4e05844ca67d7188456227b6bf5e436630c9a1", size = 249395, upload-time = "2026-01-25T12:57:50.86Z" }, - { url = "https://files.pythonhosted.org/packages/e0/36/f02234bc6e5230e2f0a63fd125d0a2093c73ef20fdf681c7af62a140e4e7/coverage-7.13.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c6f141b468740197d6bd38f2b26ade124363228cc3f9858bd9924ab059e00059", size = 250298, upload-time = "2026-01-25T12:57:52.287Z" }, - { url = "https://files.pythonhosted.org/packages/b0/06/713110d3dd3151b93611c9cbfc65c15b4156b44f927fced49ac0b20b32a4/coverage-7.13.2-cp311-cp311-win32.whl", hash = "sha256:89567798404af067604246e01a49ef907d112edf2b75ef814b1364d5ce267031", size = 221485, upload-time = "2026-01-25T12:57:53.876Z" }, - { url = "https://files.pythonhosted.org/packages/16/0c/3ae6255fa1ebcb7dec19c9a59e85ef5f34566d1265c70af5b2fc981da834/coverage-7.13.2-cp311-cp311-win_amd64.whl", hash = "sha256:21dd57941804ae2ac7e921771a5e21bbf9aabec317a041d164853ad0a96ce31e", size = 222421, upload-time = "2026-01-25T12:57:55.433Z" }, - { url = "https://files.pythonhosted.org/packages/b5/37/fabc3179af4d61d89ea47bd04333fec735cd5e8b59baad44fed9fc4170d7/coverage-7.13.2-cp311-cp311-win_arm64.whl", hash = "sha256:10758e0586c134a0bafa28f2d37dd2cdb5e4a90de25c0fc0c77dabbad46eca28", size = 221088, upload-time = "2026-01-25T12:57:57.41Z" }, - { url = "https://files.pythonhosted.org/packages/46/39/e92a35f7800222d3f7b2cbb7bbc3b65672ae8d501cb31801b2d2bd7acdf1/coverage-7.13.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f106b2af193f965d0d3234f3f83fc35278c7fb935dfbde56ae2da3dd2c03b84d", size = 219142, upload-time = "2026-01-25T12:58:00.448Z" }, - { url = "https://files.pythonhosted.org/packages/45/7a/8bf9e9309c4c996e65c52a7c5a112707ecdd9fbaf49e10b5a705a402bbb4/coverage-7.13.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78f45d21dc4d5d6bd29323f0320089ef7eae16e4bef712dff79d184fa7330af3", size = 219503, upload-time = "2026-01-25T12:58:02.451Z" }, - { url = "https://files.pythonhosted.org/packages/87/93/17661e06b7b37580923f3f12406ac91d78aeed293fb6da0b69cc7957582f/coverage-7.13.2-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fae91dfecd816444c74531a9c3d6ded17a504767e97aa674d44f638107265b99", size = 251006, upload-time = "2026-01-25T12:58:04.059Z" }, - { url = "https://files.pythonhosted.org/packages/12/f0/f9e59fb8c310171497f379e25db060abef9fa605e09d63157eebec102676/coverage-7.13.2-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:264657171406c114787b441484de620e03d8f7202f113d62fcd3d9688baa3e6f", size = 253750, upload-time = "2026-01-25T12:58:05.574Z" }, - { url = "https://files.pythonhosted.org/packages/e5/b1/1935e31add2232663cf7edd8269548b122a7d100047ff93475dbaaae673e/coverage-7.13.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae47d8dcd3ded0155afbb59c62bd8ab07ea0fd4902e1c40567439e6db9dcaf2f", size = 254862, upload-time = "2026-01-25T12:58:07.647Z" }, - { url = "https://files.pythonhosted.org/packages/af/59/b5e97071ec13df5f45da2b3391b6cdbec78ba20757bc92580a5b3d5fa53c/coverage-7.13.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8a0b33e9fd838220b007ce8f299114d406c1e8edb21336af4c97a26ecfd185aa", size = 251420, upload-time = "2026-01-25T12:58:09.309Z" }, - { url = "https://files.pythonhosted.org/packages/3f/75/9495932f87469d013dc515fb0ce1aac5fa97766f38f6b1a1deb1ee7b7f3a/coverage-7.13.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b3becbea7f3ce9a2d4d430f223ec15888e4deb31395840a79e916368d6004cce", size = 252786, upload-time = "2026-01-25T12:58:10.909Z" }, - { url = "https://files.pythonhosted.org/packages/6a/59/af550721f0eb62f46f7b8cb7e6f1860592189267b1c411a4e3a057caacee/coverage-7.13.2-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f819c727a6e6eeb8711e4ce63d78c620f69630a2e9d53bc95ca5379f57b6ba94", size = 250928, upload-time = "2026-01-25T12:58:12.449Z" }, - { url = "https://files.pythonhosted.org/packages/9b/b1/21b4445709aae500be4ab43bbcfb4e53dc0811c3396dcb11bf9f23fd0226/coverage-7.13.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4f7b71757a3ab19f7ba286e04c181004c1d61be921795ee8ba6970fd0ec91da5", size = 250496, upload-time = "2026-01-25T12:58:14.047Z" }, - { url = "https://files.pythonhosted.org/packages/ba/b1/0f5d89dfe0392990e4f3980adbde3eb34885bc1effb2dc369e0bf385e389/coverage-7.13.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:b7fc50d2afd2e6b4f6f2f403b70103d280a8e0cb35320cbbe6debcda02a1030b", size = 252373, upload-time = "2026-01-25T12:58:15.976Z" }, - { url = "https://files.pythonhosted.org/packages/01/c9/0cf1a6a57a9968cc049a6b896693faa523c638a5314b1fc374eb2b2ac904/coverage-7.13.2-cp312-cp312-win32.whl", hash = "sha256:292250282cf9bcf206b543d7608bda17ca6fc151f4cbae949fc7e115112fbd41", size = 221696, upload-time = "2026-01-25T12:58:17.517Z" }, - { url = "https://files.pythonhosted.org/packages/4d/05/d7540bf983f09d32803911afed135524570f8c47bb394bf6206c1dc3a786/coverage-7.13.2-cp312-cp312-win_amd64.whl", hash = "sha256:eeea10169fac01549a7921d27a3e517194ae254b542102267bef7a93ed38c40e", size = 222504, upload-time = "2026-01-25T12:58:19.115Z" }, - { url = "https://files.pythonhosted.org/packages/15/8b/1a9f037a736ced0a12aacf6330cdaad5008081142a7070bc58b0f7930cbc/coverage-7.13.2-cp312-cp312-win_arm64.whl", hash = "sha256:2a5b567f0b635b592c917f96b9a9cb3dbd4c320d03f4bf94e9084e494f2e8894", size = 221120, upload-time = "2026-01-25T12:58:21.334Z" }, - { url = "https://files.pythonhosted.org/packages/a7/f0/3d3eac7568ab6096ff23791a526b0048a1ff3f49d0e236b2af6fb6558e88/coverage-7.13.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:ed75de7d1217cf3b99365d110975f83af0528c849ef5180a12fd91b5064df9d6", size = 219168, upload-time = "2026-01-25T12:58:23.376Z" }, - { url = "https://files.pythonhosted.org/packages/a3/a6/f8b5cfeddbab95fdef4dcd682d82e5dcff7a112ced57a959f89537ee9995/coverage-7.13.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:97e596de8fa9bada4d88fde64a3f4d37f1b6131e4faa32bad7808abc79887ddc", size = 219537, upload-time = "2026-01-25T12:58:24.932Z" }, - { url = "https://files.pythonhosted.org/packages/7b/e6/8d8e6e0c516c838229d1e41cadcec91745f4b1031d4db17ce0043a0423b4/coverage-7.13.2-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:68c86173562ed4413345410c9480a8d64864ac5e54a5cda236748031e094229f", size = 250528, upload-time = "2026-01-25T12:58:26.567Z" }, - { url = "https://files.pythonhosted.org/packages/8e/78/befa6640f74092b86961f957f26504c8fba3d7da57cc2ab7407391870495/coverage-7.13.2-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7be4d613638d678b2b3773b8f687537b284d7074695a43fe2fbbfc0e31ceaed1", size = 253132, upload-time = "2026-01-25T12:58:28.251Z" }, - { url = "https://files.pythonhosted.org/packages/9d/10/1630db1edd8ce675124a2ee0f7becc603d2bb7b345c2387b4b95c6907094/coverage-7.13.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d7f63ce526a96acd0e16c4af8b50b64334239550402fb1607ce6a584a6d62ce9", size = 254374, upload-time = "2026-01-25T12:58:30.294Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1d/0d9381647b1e8e6d310ac4140be9c428a0277330991e0c35bdd751e338a4/coverage-7.13.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:406821f37f864f968e29ac14c3fccae0fec9fdeba48327f0341decf4daf92d7c", size = 250762, upload-time = "2026-01-25T12:58:32.036Z" }, - { url = "https://files.pythonhosted.org/packages/43/e4/5636dfc9a7c871ee8776af83ee33b4c26bc508ad6cee1e89b6419a366582/coverage-7.13.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ee68e5a4e3e5443623406b905db447dceddffee0dceb39f4e0cd9ec2a35004b5", size = 252502, upload-time = "2026-01-25T12:58:33.961Z" }, - { url = "https://files.pythonhosted.org/packages/02/2a/7ff2884d79d420cbb2d12fed6fff727b6d0ef27253140d3cdbbd03187ee0/coverage-7.13.2-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2ee0e58cca0c17dd9c6c1cdde02bb705c7b3fbfa5f3b0b5afeda20d4ebff8ef4", size = 250463, upload-time = "2026-01-25T12:58:35.529Z" }, - { url = "https://files.pythonhosted.org/packages/91/c0/ba51087db645b6c7261570400fc62c89a16278763f36ba618dc8657a187b/coverage-7.13.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:6e5bbb5018bf76a56aabdb64246b5288d5ae1b7d0dd4d0534fe86df2c2992d1c", size = 250288, upload-time = "2026-01-25T12:58:37.226Z" }, - { url = "https://files.pythonhosted.org/packages/03/07/44e6f428551c4d9faf63ebcefe49b30e5c89d1be96f6a3abd86a52da9d15/coverage-7.13.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a55516c68ef3e08e134e818d5e308ffa6b1337cc8b092b69b24287bf07d38e31", size = 252063, upload-time = "2026-01-25T12:58:38.821Z" }, - { url = "https://files.pythonhosted.org/packages/c2/67/35b730ad7e1859dd57e834d1bc06080d22d2f87457d53f692fce3f24a5a9/coverage-7.13.2-cp313-cp313-win32.whl", hash = "sha256:5b20211c47a8abf4abc3319d8ce2464864fa9f30c5fcaf958a3eed92f4f1fef8", size = 221716, upload-time = "2026-01-25T12:58:40.484Z" }, - { url = "https://files.pythonhosted.org/packages/0d/82/e5fcf5a97c72f45fc14829237a6550bf49d0ab882ac90e04b12a69db76b4/coverage-7.13.2-cp313-cp313-win_amd64.whl", hash = "sha256:14f500232e521201cf031549fb1ebdfc0a40f401cf519157f76c397e586c3beb", size = 222522, upload-time = "2026-01-25T12:58:43.247Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/25d7b2f946d239dd2d6644ca2cc060d24f97551e2af13b6c24c722ae5f97/coverage-7.13.2-cp313-cp313-win_arm64.whl", hash = "sha256:9779310cb5a9778a60c899f075a8514c89fa6d10131445c2207fc893e0b14557", size = 221145, upload-time = "2026-01-25T12:58:45Z" }, - { url = "https://files.pythonhosted.org/packages/9e/f7/080376c029c8f76fadfe43911d0daffa0cbdc9f9418a0eead70c56fb7f4b/coverage-7.13.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:e64fa5a1e41ce5df6b547cbc3d3699381c9e2c2c369c67837e716ed0f549d48e", size = 219861, upload-time = "2026-01-25T12:58:46.586Z" }, - { url = "https://files.pythonhosted.org/packages/42/11/0b5e315af5ab35f4c4a70e64d3314e4eec25eefc6dec13be3a7d5ffe8ac5/coverage-7.13.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b01899e82a04085b6561eb233fd688474f57455e8ad35cd82286463ba06332b7", size = 220207, upload-time = "2026-01-25T12:58:48.277Z" }, - { url = "https://files.pythonhosted.org/packages/b2/0c/0874d0318fb1062117acbef06a09cf8b63f3060c22265adaad24b36306b7/coverage-7.13.2-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:838943bea48be0e2768b0cf7819544cdedc1bbb2f28427eabb6eb8c9eb2285d3", size = 261504, upload-time = "2026-01-25T12:58:49.904Z" }, - { url = "https://files.pythonhosted.org/packages/83/5e/1cd72c22ecb30751e43a72f40ba50fcef1b7e93e3ea823bd9feda8e51f9a/coverage-7.13.2-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:93d1d25ec2b27e90bcfef7012992d1f5121b51161b8bffcda756a816cf13c2c3", size = 263582, upload-time = "2026-01-25T12:58:51.582Z" }, - { url = "https://files.pythonhosted.org/packages/9b/da/8acf356707c7a42df4d0657020308e23e5a07397e81492640c186268497c/coverage-7.13.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93b57142f9621b0d12349c43fc7741fe578e4bc914c1e5a54142856cfc0bf421", size = 266008, upload-time = "2026-01-25T12:58:53.234Z" }, - { url = "https://files.pythonhosted.org/packages/41/41/ea1730af99960309423c6ea8d6a4f1fa5564b2d97bd1d29dda4b42611f04/coverage-7.13.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f06799ae1bdfff7ccb8665d75f8291c69110ba9585253de254688aa8a1ccc6c5", size = 260762, upload-time = "2026-01-25T12:58:55.372Z" }, - { url = "https://files.pythonhosted.org/packages/22/fa/02884d2080ba71db64fdc127b311db60e01fe6ba797d9c8363725e39f4d5/coverage-7.13.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:7f9405ab4f81d490811b1d91c7a20361135a2df4c170e7f0b747a794da5b7f23", size = 263571, upload-time = "2026-01-25T12:58:57.52Z" }, - { url = "https://files.pythonhosted.org/packages/d2/6b/4083aaaeba9b3112f55ac57c2ce7001dc4d8fa3fcc228a39f09cc84ede27/coverage-7.13.2-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f9ab1d5b86f8fbc97a5b3cd6280a3fd85fef3b028689d8a2c00918f0d82c728c", size = 261200, upload-time = "2026-01-25T12:58:59.255Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d2/aea92fa36d61955e8c416ede9cf9bf142aa196f3aea214bb67f85235a050/coverage-7.13.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:f674f59712d67e841525b99e5e2b595250e39b529c3bda14764e4f625a3fa01f", size = 260095, upload-time = "2026-01-25T12:59:01.066Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ae/04ffe96a80f107ea21b22b2367175c621da920063260a1c22f9452fd7866/coverage-7.13.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c6cadac7b8ace1ba9144feb1ae3cb787a6065ba6d23ffc59a934b16406c26573", size = 262284, upload-time = "2026-01-25T12:59:02.802Z" }, - { url = "https://files.pythonhosted.org/packages/1c/7a/6f354dcd7dfc41297791d6fb4e0d618acb55810bde2c1fd14b3939e05c2b/coverage-7.13.2-cp313-cp313t-win32.whl", hash = "sha256:14ae4146465f8e6e6253eba0cccd57423e598a4cb925958b240c805300918343", size = 222389, upload-time = "2026-01-25T12:59:04.563Z" }, - { url = "https://files.pythonhosted.org/packages/8d/d5/080ad292a4a3d3daf411574be0a1f56d6dee2c4fdf6b005342be9fac807f/coverage-7.13.2-cp313-cp313t-win_amd64.whl", hash = "sha256:9074896edd705a05769e3de0eac0a8388484b503b68863dd06d5e473f874fd47", size = 223450, upload-time = "2026-01-25T12:59:06.677Z" }, - { url = "https://files.pythonhosted.org/packages/88/96/df576fbacc522e9fb8d1c4b7a7fc62eb734be56e2cba1d88d2eabe08ea3f/coverage-7.13.2-cp313-cp313t-win_arm64.whl", hash = "sha256:69e526e14f3f854eda573d3cf40cffd29a1a91c684743d904c33dbdcd0e0f3e7", size = 221707, upload-time = "2026-01-25T12:59:08.363Z" }, - { url = "https://files.pythonhosted.org/packages/55/53/1da9e51a0775634b04fcc11eb25c002fc58ee4f92ce2e8512f94ac5fc5bf/coverage-7.13.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:387a825f43d680e7310e6f325b2167dd093bc8ffd933b83e9aa0983cf6e0a2ef", size = 219213, upload-time = "2026-01-25T12:59:11.909Z" }, - { url = "https://files.pythonhosted.org/packages/46/35/b3caac3ebbd10230fea5a33012b27d19e999a17c9285c4228b4b2e35b7da/coverage-7.13.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f0d7fea9d8e5d778cd5a9e8fc38308ad688f02040e883cdc13311ef2748cb40f", size = 219549, upload-time = "2026-01-25T12:59:13.638Z" }, - { url = "https://files.pythonhosted.org/packages/76/9c/e1cf7def1bdc72c1907e60703983a588f9558434a2ff94615747bd73c192/coverage-7.13.2-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e080afb413be106c95c4ee96b4fffdc9e2fa56a8bbf90b5c0918e5c4449412f5", size = 250586, upload-time = "2026-01-25T12:59:15.808Z" }, - { url = "https://files.pythonhosted.org/packages/ba/49/f54ec02ed12be66c8d8897270505759e057b0c68564a65c429ccdd1f139e/coverage-7.13.2-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a7fc042ba3c7ce25b8a9f097eb0f32a5ce1ccdb639d9eec114e26def98e1f8a4", size = 253093, upload-time = "2026-01-25T12:59:17.491Z" }, - { url = "https://files.pythonhosted.org/packages/fb/5e/aaf86be3e181d907e23c0f61fccaeb38de8e6f6b47aed92bf57d8fc9c034/coverage-7.13.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d0ba505e021557f7f8173ee8cd6b926373d8653e5ff7581ae2efce1b11ef4c27", size = 254446, upload-time = "2026-01-25T12:59:19.752Z" }, - { url = "https://files.pythonhosted.org/packages/28/c8/a5fa01460e2d75b0c853b392080d6829d3ca8b5ab31e158fa0501bc7c708/coverage-7.13.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7de326f80e3451bd5cc7239ab46c73ddb658fe0b7649476bc7413572d36cd548", size = 250615, upload-time = "2026-01-25T12:59:21.928Z" }, - { url = "https://files.pythonhosted.org/packages/86/0b/6d56315a55f7062bb66410732c24879ccb2ec527ab6630246de5fe45a1df/coverage-7.13.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:abaea04f1e7e34841d4a7b343904a3f59481f62f9df39e2cd399d69a187a9660", size = 252452, upload-time = "2026-01-25T12:59:23.592Z" }, - { url = "https://files.pythonhosted.org/packages/30/19/9bc550363ebc6b0ea121977ee44d05ecd1e8bf79018b8444f1028701c563/coverage-7.13.2-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:9f93959ee0c604bccd8e0697be21de0887b1f73efcc3aa73a3ec0fd13feace92", size = 250418, upload-time = "2026-01-25T12:59:25.392Z" }, - { url = "https://files.pythonhosted.org/packages/1f/53/580530a31ca2f0cc6f07a8f2ab5460785b02bb11bdf815d4c4d37a4c5169/coverage-7.13.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:13fe81ead04e34e105bf1b3c9f9cdf32ce31736ee5d90a8d2de02b9d3e1bcb82", size = 250231, upload-time = "2026-01-25T12:59:27.888Z" }, - { url = "https://files.pythonhosted.org/packages/e2/42/dd9093f919dc3088cb472893651884bd675e3df3d38a43f9053656dca9a2/coverage-7.13.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:d6d16b0f71120e365741bca2cb473ca6fe38930bc5431c5e850ba949f708f892", size = 251888, upload-time = "2026-01-25T12:59:29.636Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a6/0af4053e6e819774626e133c3d6f70fae4d44884bfc4b126cb647baee8d3/coverage-7.13.2-cp314-cp314-win32.whl", hash = "sha256:9b2f4714bb7d99ba3790ee095b3b4ac94767e1347fe424278a0b10acb3ff04fe", size = 221968, upload-time = "2026-01-25T12:59:31.424Z" }, - { url = "https://files.pythonhosted.org/packages/c4/cc/5aff1e1f80d55862442855517bb8ad8ad3a68639441ff6287dde6a58558b/coverage-7.13.2-cp314-cp314-win_amd64.whl", hash = "sha256:e4121a90823a063d717a96e0a0529c727fb31ea889369a0ee3ec00ed99bf6859", size = 222783, upload-time = "2026-01-25T12:59:33.118Z" }, - { url = "https://files.pythonhosted.org/packages/de/20/09abafb24f84b3292cc658728803416c15b79f9ee5e68d25238a895b07d9/coverage-7.13.2-cp314-cp314-win_arm64.whl", hash = "sha256:6873f0271b4a15a33e7590f338d823f6f66f91ed147a03938d7ce26efd04eee6", size = 221348, upload-time = "2026-01-25T12:59:34.939Z" }, - { url = "https://files.pythonhosted.org/packages/b6/60/a3820c7232db63be060e4019017cd3426751c2699dab3c62819cdbcea387/coverage-7.13.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:f61d349f5b7cd95c34017f1927ee379bfbe9884300d74e07cf630ccf7a610c1b", size = 219950, upload-time = "2026-01-25T12:59:36.624Z" }, - { url = "https://files.pythonhosted.org/packages/fd/37/e4ef5975fdeb86b1e56db9a82f41b032e3d93a840ebaf4064f39e770d5c5/coverage-7.13.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a43d34ce714f4ca674c0d90beb760eb05aad906f2c47580ccee9da8fe8bfb417", size = 220209, upload-time = "2026-01-25T12:59:38.339Z" }, - { url = "https://files.pythonhosted.org/packages/54/df/d40e091d00c51adca1e251d3b60a8b464112efa3004949e96a74d7c19a64/coverage-7.13.2-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bff1b04cb9d4900ce5c56c4942f047dc7efe57e2608cb7c3c8936e9970ccdbee", size = 261576, upload-time = "2026-01-25T12:59:40.446Z" }, - { url = "https://files.pythonhosted.org/packages/c5/44/5259c4bed54e3392e5c176121af9f71919d96dde853386e7730e705f3520/coverage-7.13.2-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6ae99e4560963ad8e163e819e5d77d413d331fd00566c1e0856aa252303552c1", size = 263704, upload-time = "2026-01-25T12:59:42.346Z" }, - { url = "https://files.pythonhosted.org/packages/16/bd/ae9f005827abcbe2c70157459ae86053971c9fa14617b63903abbdce26d9/coverage-7.13.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e79a8c7d461820257d9aa43716c4efc55366d7b292e46b5b37165be1d377405d", size = 266109, upload-time = "2026-01-25T12:59:44.073Z" }, - { url = "https://files.pythonhosted.org/packages/a2/c0/8e279c1c0f5b1eaa3ad9b0fb7a5637fc0379ea7d85a781c0fe0bb3cfc2ab/coverage-7.13.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:060ee84f6a769d40c492711911a76811b4befb6fba50abb450371abb720f5bd6", size = 260686, upload-time = "2026-01-25T12:59:45.804Z" }, - { url = "https://files.pythonhosted.org/packages/b2/47/3a8112627e9d863e7cddd72894171c929e94491a597811725befdcd76bce/coverage-7.13.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3bca209d001fd03ea2d978f8a4985093240a355c93078aee3f799852c23f561a", size = 263568, upload-time = "2026-01-25T12:59:47.929Z" }, - { url = "https://files.pythonhosted.org/packages/92/bc/7ea367d84afa3120afc3ce6de294fd2dcd33b51e2e7fbe4bbfd200f2cb8c/coverage-7.13.2-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:6b8092aa38d72f091db61ef83cb66076f18f02da3e1a75039a4f218629600e04", size = 261174, upload-time = "2026-01-25T12:59:49.717Z" }, - { url = "https://files.pythonhosted.org/packages/33/b7/f1092dcecb6637e31cc2db099581ee5c61a17647849bae6b8261a2b78430/coverage-7.13.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4a3158dc2dcce5200d91ec28cd315c999eebff355437d2765840555d765a6e5f", size = 260017, upload-time = "2026-01-25T12:59:51.463Z" }, - { url = "https://files.pythonhosted.org/packages/2b/cd/f3d07d4b95fbe1a2ef0958c15da614f7e4f557720132de34d2dc3aa7e911/coverage-7.13.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:3973f353b2d70bd9796cc12f532a05945232ccae966456c8ed7034cb96bbfd6f", size = 262337, upload-time = "2026-01-25T12:59:53.407Z" }, - { url = "https://files.pythonhosted.org/packages/e0/db/b0d5b2873a07cb1e06a55d998697c0a5a540dcefbf353774c99eb3874513/coverage-7.13.2-cp314-cp314t-win32.whl", hash = "sha256:79f6506a678a59d4ded048dc72f1859ebede8ec2b9a2d509ebe161f01c2879d3", size = 222749, upload-time = "2026-01-25T12:59:56.316Z" }, - { url = "https://files.pythonhosted.org/packages/e5/2f/838a5394c082ac57d85f57f6aba53093b30d9089781df72412126505716f/coverage-7.13.2-cp314-cp314t-win_amd64.whl", hash = "sha256:196bfeabdccc5a020a57d5a368c681e3a6ceb0447d153aeccc1ab4d70a5032ba", size = 223857, upload-time = "2026-01-25T12:59:58.201Z" }, - { url = "https://files.pythonhosted.org/packages/44/d4/b608243e76ead3a4298824b50922b89ef793e50069ce30316a65c1b4d7ef/coverage-7.13.2-cp314-cp314t-win_arm64.whl", hash = "sha256:69269ab58783e090bfbf5b916ab3d188126e22d6070bbfc93098fdd474ef937c", size = 221881, upload-time = "2026-01-25T13:00:00.449Z" }, - { url = "https://files.pythonhosted.org/packages/d2/db/d291e30fdf7ea617a335531e72294e0c723356d7fdde8fba00610a76bda9/coverage-7.13.2-py3-none-any.whl", hash = "sha256:40ce1ea1e25125556d8e76bd0b61500839a07944cc287ac21d5626f3e620cad5", size = 210943, upload-time = "2026-01-25T13:00:02.388Z" }, + { url = "https://files.pythonhosted.org/packages/ab/07/1c8099563a8a6c389a31c2d0aa1497cee86d6248bb4b9ba5e779215db9f9/coverage-7.13.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:0b4f345f7265cdbdb5ec2521ffff15fa49de6d6c39abf89fc7ad68aa9e3a55f0", size = 219143, upload-time = "2026-02-03T13:59:40.459Z" }, + { url = "https://files.pythonhosted.org/packages/69/39/a892d44af7aa092cab70e0cc5cdbba18eeccfe1d6930695dab1742eef9e9/coverage-7.13.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:96c3be8bae9d0333e403cc1a8eb078a7f928b5650bae94a18fb4820cc993fb9b", size = 219663, upload-time = "2026-02-03T13:59:41.951Z" }, + { url = "https://files.pythonhosted.org/packages/9a/25/9669dcf4c2bb4c3861469e6db20e52e8c11908cf53c14ec9b12e9fd4d602/coverage-7.13.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d6f4a21328ea49d38565b55599e1c02834e76583a6953e5586d65cb1efebd8f8", size = 246424, upload-time = "2026-02-03T13:59:43.418Z" }, + { url = "https://files.pythonhosted.org/packages/f3/68/d9766c4e298aca62ea5d9543e1dd1e4e1439d7284815244d8b7db1840bfb/coverage-7.13.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fc970575799a9d17d5c3fafd83a0f6ccf5d5117cdc9ad6fbd791e9ead82418b0", size = 248228, upload-time = "2026-02-03T13:59:44.816Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e2/eea6cb4a4bd443741adf008d4cccec83a1f75401df59b6559aca2bdd9710/coverage-7.13.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ff33b652b3556b05e204ae20793d1f872161b0fa5ec8a9ac76f8430e152ed6", size = 250103, upload-time = "2026-02-03T13:59:46.271Z" }, + { url = "https://files.pythonhosted.org/packages/db/77/664280ecd666c2191610842177e2fab9e5dbdeef97178e2078fed46a3d2c/coverage-7.13.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7df8759ee57b9f3f7b66799b7660c282f4375bef620ade1686d6a7b03699e75f", size = 247107, upload-time = "2026-02-03T13:59:48.53Z" }, + { url = "https://files.pythonhosted.org/packages/2b/df/2a672eab99e0d0eba52d8a63e47dc92245eee26954d1b2d3c8f7d372151f/coverage-7.13.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:f45c9bcb16bee25a798ccba8a2f6a1251b19de6a0d617bb365d7d2f386c4e20e", size = 248143, upload-time = "2026-02-03T13:59:50.027Z" }, + { url = "https://files.pythonhosted.org/packages/a5/dc/a104e7a87c13e57a358b8b9199a8955676e1703bb372d79722b54978ae45/coverage-7.13.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:318b2e4753cbf611061e01b6cc81477e1cdfeb69c36c4a14e6595e674caadb56", size = 246148, upload-time = "2026-02-03T13:59:52.025Z" }, + { url = "https://files.pythonhosted.org/packages/2b/89/e113d3a58dc20b03b7e59aed1e53ebc9ca6167f961876443e002b10e3ae9/coverage-7.13.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:24db3959de8ee394eeeca89ccb8ba25305c2da9a668dd44173394cbd5aa0777f", size = 246414, upload-time = "2026-02-03T13:59:53.859Z" }, + { url = "https://files.pythonhosted.org/packages/3f/60/a3fd0a6e8d89b488396019a2268b6a1f25ab56d6d18f3be50f35d77b47dc/coverage-7.13.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:be14d0622125edef21b3a4d8cd2d138c4872bf6e38adc90fd92385e3312f406a", size = 247023, upload-time = "2026-02-03T13:59:55.454Z" }, + { url = "https://files.pythonhosted.org/packages/19/fa/de4840bb939dbb22ba0648a6d8069fa91c9cf3b3fca8b0d1df461e885b3d/coverage-7.13.3-cp310-cp310-win32.whl", hash = "sha256:53be4aab8ddef18beb6188f3a3fdbf4d1af2277d098d4e618be3a8e6c88e74be", size = 221751, upload-time = "2026-02-03T13:59:57.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/87/233ff8b7ef62fb63f58c78623b50bef69681111e0c4d43504f422d88cda4/coverage-7.13.3-cp310-cp310-win_amd64.whl", hash = "sha256:bfeee64ad8b4aae3233abb77eb6b52b51b05fa89da9645518671b9939a78732b", size = 222686, upload-time = "2026-02-03T13:59:58.825Z" }, + { url = "https://files.pythonhosted.org/packages/ec/09/1ac74e37cf45f17eb41e11a21854f7f92a4c2d6c6098ef4a1becb0c6d8d3/coverage-7.13.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:5907605ee20e126eeee2abe14aae137043c2c8af2fa9b38d2ab3b7a6b8137f73", size = 219276, upload-time = "2026-02-03T14:00:00.296Z" }, + { url = "https://files.pythonhosted.org/packages/2e/cb/71908b08b21beb2c437d0d5870c4ec129c570ca1b386a8427fcdb11cf89c/coverage-7.13.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a88705500988c8acad8b8fd86c2a933d3aa96bec1ddc4bc5cb256360db7bbd00", size = 219776, upload-time = "2026-02-03T14:00:02.414Z" }, + { url = "https://files.pythonhosted.org/packages/09/85/c4f3dd69232887666a2c0394d4be21c60ea934d404db068e6c96aa59cd87/coverage-7.13.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7bbb5aa9016c4c29e3432e087aa29ebee3f8fda089cfbfb4e6d64bd292dcd1c2", size = 250196, upload-time = "2026-02-03T14:00:04.197Z" }, + { url = "https://files.pythonhosted.org/packages/9c/cc/560ad6f12010344d0778e268df5ba9aa990aacccc310d478bf82bf3d302c/coverage-7.13.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0c2be202a83dde768937a61cdc5d06bf9fb204048ca199d93479488e6247656c", size = 252111, upload-time = "2026-02-03T14:00:05.639Z" }, + { url = "https://files.pythonhosted.org/packages/f0/66/3193985fb2c58e91f94cfbe9e21a6fdf941e9301fe2be9e92c072e9c8f8c/coverage-7.13.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f45e32ef383ce56e0ca099b2e02fcdf7950be4b1b56afaab27b4ad790befe5b", size = 254217, upload-time = "2026-02-03T14:00:07.738Z" }, + { url = "https://files.pythonhosted.org/packages/c5/78/f0f91556bf1faa416792e537c523c5ef9db9b1d32a50572c102b3d7c45b3/coverage-7.13.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6ed2e787249b922a93cd95c671cc9f4c9797a106e81b455c83a9ddb9d34590c0", size = 250318, upload-time = "2026-02-03T14:00:09.224Z" }, + { url = "https://files.pythonhosted.org/packages/6f/aa/fc654e45e837d137b2c1f3a2cc09b4aea1e8b015acd2f774fa0f3d2ddeba/coverage-7.13.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:05dd25b21afffe545e808265897c35f32d3e4437663923e0d256d9ab5031fb14", size = 251909, upload-time = "2026-02-03T14:00:10.712Z" }, + { url = "https://files.pythonhosted.org/packages/73/4d/ab53063992add8a9ca0463c9d92cce5994a29e17affd1c2daa091b922a93/coverage-7.13.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:46d29926349b5c4f1ea4fca95e8c892835515f3600995a383fa9a923b5739ea4", size = 249971, upload-time = "2026-02-03T14:00:12.402Z" }, + { url = "https://files.pythonhosted.org/packages/29/25/83694b81e46fcff9899694a1b6f57573429cdd82b57932f09a698f03eea5/coverage-7.13.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:fae6a21537519c2af00245e834e5bf2884699cc7c1055738fd0f9dc37a3644ad", size = 249692, upload-time = "2026-02-03T14:00:13.868Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ef/d68fc304301f4cb4bf6aefa0045310520789ca38dabdfba9dbecd3f37919/coverage-7.13.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:c672d4e2f0575a4ca2bf2aa0c5ced5188220ab806c1bb6d7179f70a11a017222", size = 250597, upload-time = "2026-02-03T14:00:15.461Z" }, + { url = "https://files.pythonhosted.org/packages/8d/85/240ad396f914df361d0f71e912ddcedb48130c71b88dc4193fe3c0306f00/coverage-7.13.3-cp311-cp311-win32.whl", hash = "sha256:fcda51c918c7a13ad93b5f89a58d56e3a072c9e0ba5c231b0ed81404bf2648fb", size = 221773, upload-time = "2026-02-03T14:00:17.462Z" }, + { url = "https://files.pythonhosted.org/packages/2f/71/165b3a6d3d052704a9ab52d11ea64ef3426745de517dda44d872716213a7/coverage-7.13.3-cp311-cp311-win_amd64.whl", hash = "sha256:d1a049b5c51b3b679928dd35e47c4a2235e0b6128b479a7596d0ef5b42fa6301", size = 222711, upload-time = "2026-02-03T14:00:19.449Z" }, + { url = "https://files.pythonhosted.org/packages/51/d0/0ddc9c5934cdd52639c5df1f1eb0fdab51bb52348f3a8d1c7db9c600d93a/coverage-7.13.3-cp311-cp311-win_arm64.whl", hash = "sha256:79f2670c7e772f4917895c3d89aad59e01f3dbe68a4ed2d0373b431fad1dcfba", size = 221377, upload-time = "2026-02-03T14:00:20.968Z" }, + { url = "https://files.pythonhosted.org/packages/94/44/330f8e83b143f6668778ed61d17ece9dc48459e9e74669177de02f45fec5/coverage-7.13.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ed48b4170caa2c4420e0cd27dc977caaffc7eecc317355751df8373dddcef595", size = 219441, upload-time = "2026-02-03T14:00:22.585Z" }, + { url = "https://files.pythonhosted.org/packages/08/e7/29db05693562c2e65bdf6910c0af2fd6f9325b8f43caf7a258413f369e30/coverage-7.13.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8f2adf4bcffbbec41f366f2e6dffb9d24e8172d16e91da5799c9b7ed6b5716e6", size = 219801, upload-time = "2026-02-03T14:00:24.186Z" }, + { url = "https://files.pythonhosted.org/packages/90/ae/7f8a78249b02b0818db46220795f8ac8312ea4abd1d37d79ea81db5cae81/coverage-7.13.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:01119735c690786b6966a1e9f098da4cd7ca9174c4cfe076d04e653105488395", size = 251306, upload-time = "2026-02-03T14:00:25.798Z" }, + { url = "https://files.pythonhosted.org/packages/62/71/a18a53d1808e09b2e9ebd6b47dad5e92daf4c38b0686b4c4d1b2f3e42b7f/coverage-7.13.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:8bb09e83c603f152d855f666d70a71765ca8e67332e5829e62cb9466c176af23", size = 254051, upload-time = "2026-02-03T14:00:27.474Z" }, + { url = "https://files.pythonhosted.org/packages/4a/0a/eb30f6455d04c5a3396d0696cad2df0269ae7444bb322f86ffe3376f7bf9/coverage-7.13.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b607a40cba795cfac6d130220d25962931ce101f2f478a29822b19755377fb34", size = 255160, upload-time = "2026-02-03T14:00:29.024Z" }, + { url = "https://files.pythonhosted.org/packages/7b/7e/a45baac86274ce3ed842dbb84f14560c673ad30535f397d89164ec56c5df/coverage-7.13.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:44f14a62f5da2e9aedf9080e01d2cda61df39197d48e323538ec037336d68da8", size = 251709, upload-time = "2026-02-03T14:00:30.641Z" }, + { url = "https://files.pythonhosted.org/packages/c0/df/dd0dc12f30da11349993f3e218901fdf82f45ee44773596050c8f5a1fb25/coverage-7.13.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:debf29e0b157769843dff0981cc76f79e0ed04e36bb773c6cac5f6029054bd8a", size = 253083, upload-time = "2026-02-03T14:00:32.14Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/fc764c8389a8ce95cb90eb97af4c32f392ab0ac23ec57cadeefb887188d3/coverage-7.13.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:824bb95cd71604031ae9a48edb91fd6effde669522f960375668ed21b36e3ec4", size = 251227, upload-time = "2026-02-03T14:00:34.721Z" }, + { url = "https://files.pythonhosted.org/packages/dd/ca/d025e9da8f06f24c34d2da9873957cfc5f7e0d67802c3e34d0caa8452130/coverage-7.13.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:8f1010029a5b52dc427c8e2a8dbddb2303ddd180b806687d1acd1bb1d06649e7", size = 250794, upload-time = "2026-02-03T14:00:36.278Z" }, + { url = "https://files.pythonhosted.org/packages/45/c7/76bf35d5d488ec8f68682eb8e7671acc50a6d2d1c1182de1d2b6d4ffad3b/coverage-7.13.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:cd5dee4fd7659d8306ffa79eeaaafd91fa30a302dac3af723b9b469e549247e0", size = 252671, upload-time = "2026-02-03T14:00:38.368Z" }, + { url = "https://files.pythonhosted.org/packages/bf/10/1921f1a03a7c209e1cb374f81a6b9b68b03cdb3ecc3433c189bc90e2a3d5/coverage-7.13.3-cp312-cp312-win32.whl", hash = "sha256:f7f153d0184d45f3873b3ad3ad22694fd73aadcb8cdbc4337ab4b41ea6b4dff1", size = 221986, upload-time = "2026-02-03T14:00:40.442Z" }, + { url = "https://files.pythonhosted.org/packages/3c/7c/f5d93297f8e125a80c15545edc754d93e0ed8ba255b65e609b185296af01/coverage-7.13.3-cp312-cp312-win_amd64.whl", hash = "sha256:03a6e5e1e50819d6d7436f5bc40c92ded7e484e400716886ac921e35c133149d", size = 222793, upload-time = "2026-02-03T14:00:42.106Z" }, + { url = "https://files.pythonhosted.org/packages/43/59/c86b84170015b4555ebabca8649bdf9f4a1f737a73168088385ed0f947c4/coverage-7.13.3-cp312-cp312-win_arm64.whl", hash = "sha256:51c4c42c0e7d09a822b08b6cf79b3c4db8333fffde7450da946719ba0d45730f", size = 221410, upload-time = "2026-02-03T14:00:43.726Z" }, + { url = "https://files.pythonhosted.org/packages/81/f3/4c333da7b373e8c8bfb62517e8174a01dcc373d7a9083698e3b39d50d59c/coverage-7.13.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:853c3d3c79ff0db65797aad79dee6be020efd218ac4510f15a205f1e8d13ce25", size = 219468, upload-time = "2026-02-03T14:00:45.829Z" }, + { url = "https://files.pythonhosted.org/packages/d6/31/0714337b7d23630c8de2f4d56acf43c65f8728a45ed529b34410683f7217/coverage-7.13.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f75695e157c83d374f88dcc646a60cb94173304a9258b2e74ba5a66b7614a51a", size = 219839, upload-time = "2026-02-03T14:00:47.407Z" }, + { url = "https://files.pythonhosted.org/packages/12/99/bd6f2a2738144c98945666f90cae446ed870cecf0421c767475fcf42cdbe/coverage-7.13.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:2d098709621d0819039f3f1e471ee554f55a0b2ac0d816883c765b14129b5627", size = 250828, upload-time = "2026-02-03T14:00:49.029Z" }, + { url = "https://files.pythonhosted.org/packages/6f/99/97b600225fbf631e6f5bfd3ad5bcaf87fbb9e34ff87492e5a572ff01bbe2/coverage-7.13.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:16d23d6579cf80a474ad160ca14d8b319abaa6db62759d6eef53b2fc979b58c8", size = 253432, upload-time = "2026-02-03T14:00:50.655Z" }, + { url = "https://files.pythonhosted.org/packages/5f/5c/abe2b3490bda26bd4f5e3e799be0bdf00bd81edebedc2c9da8d3ef288fa8/coverage-7.13.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:00d34b29a59d2076e6f318b30a00a69bf63687e30cd882984ed444e753990cc1", size = 254672, upload-time = "2026-02-03T14:00:52.757Z" }, + { url = "https://files.pythonhosted.org/packages/31/ba/5d1957c76b40daff53971fe0adb84d9c2162b614280031d1d0653dd010c1/coverage-7.13.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ab6d72bffac9deb6e6cb0f61042e748de3f9f8e98afb0375a8e64b0b6e11746b", size = 251050, upload-time = "2026-02-03T14:00:54.332Z" }, + { url = "https://files.pythonhosted.org/packages/69/dc/dffdf3bfe9d32090f047d3c3085378558cb4eb6778cda7de414ad74581ed/coverage-7.13.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:e129328ad1258e49cae0123a3b5fcb93d6c2fa90d540f0b4c7cdcdc019aaa3dc", size = 252801, upload-time = "2026-02-03T14:00:56.121Z" }, + { url = "https://files.pythonhosted.org/packages/87/51/cdf6198b0f2746e04511a30dc9185d7b8cdd895276c07bdb538e37f1cd50/coverage-7.13.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:2213a8d88ed35459bda71597599d4eec7c2ebad201c88f0bfc2c26fd9b0dd2ea", size = 250763, upload-time = "2026-02-03T14:00:58.719Z" }, + { url = "https://files.pythonhosted.org/packages/d7/1a/596b7d62218c1d69f2475b69cc6b211e33c83c902f38ee6ae9766dd422da/coverage-7.13.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:00dd3f02de6d5f5c9c3d95e3e036c3c2e2a669f8bf2d3ceb92505c4ce7838f67", size = 250587, upload-time = "2026-02-03T14:01:01.197Z" }, + { url = "https://files.pythonhosted.org/packages/f7/46/52330d5841ff660f22c130b75f5e1dd3e352c8e7baef5e5fef6b14e3e991/coverage-7.13.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f9bada7bc660d20b23d7d312ebe29e927b655cf414dadcdb6335a2075695bd86", size = 252358, upload-time = "2026-02-03T14:01:02.824Z" }, + { url = "https://files.pythonhosted.org/packages/36/8a/e69a5be51923097ba7d5cff9724466e74fe486e9232020ba97c809a8b42b/coverage-7.13.3-cp313-cp313-win32.whl", hash = "sha256:75b3c0300f3fa15809bd62d9ca8b170eb21fcf0100eb4b4154d6dc8b3a5bbd43", size = 222007, upload-time = "2026-02-03T14:01:04.876Z" }, + { url = "https://files.pythonhosted.org/packages/0a/09/a5a069bcee0d613bdd48ee7637fa73bc09e7ed4342b26890f2df97cc9682/coverage-7.13.3-cp313-cp313-win_amd64.whl", hash = "sha256:a2f7589c6132c44c53f6e705e1a6677e2b7821378c22f7703b2cf5388d0d4587", size = 222812, upload-time = "2026-02-03T14:01:07.296Z" }, + { url = "https://files.pythonhosted.org/packages/3d/4f/d62ad7dfe32f9e3d4a10c178bb6f98b10b083d6e0530ca202b399371f6c1/coverage-7.13.3-cp313-cp313-win_arm64.whl", hash = "sha256:123ceaf2b9d8c614f01110f908a341e05b1b305d6b2ada98763b9a5a59756051", size = 221433, upload-time = "2026-02-03T14:01:09.156Z" }, + { url = "https://files.pythonhosted.org/packages/04/b2/4876c46d723d80b9c5b695f1a11bf5f7c3dabf540ec00d6edc076ff025e6/coverage-7.13.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:cc7fd0f726795420f3678ac82ff882c7fc33770bd0074463b5aef7293285ace9", size = 220162, upload-time = "2026-02-03T14:01:11.409Z" }, + { url = "https://files.pythonhosted.org/packages/fc/04/9942b64a0e0bdda2c109f56bda42b2a59d9d3df4c94b85a323c1cae9fc77/coverage-7.13.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:d358dc408edc28730aed5477a69338e444e62fba0b7e9e4a131c505fadad691e", size = 220510, upload-time = "2026-02-03T14:01:13.038Z" }, + { url = "https://files.pythonhosted.org/packages/5a/82/5cfe1e81eae525b74669f9795f37eb3edd4679b873d79d1e6c1c14ee6c1c/coverage-7.13.3-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:5d67b9ed6f7b5527b209b24b3df9f2e5bf0198c1bbf99c6971b0e2dcb7e2a107", size = 261801, upload-time = "2026-02-03T14:01:14.674Z" }, + { url = "https://files.pythonhosted.org/packages/0b/ec/a553d7f742fd2cd12e36a16a7b4b3582d5934b496ef2b5ea8abeb10903d4/coverage-7.13.3-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:59224bfb2e9b37c1335ae35d00daa3a5b4e0b1a20f530be208fff1ecfa436f43", size = 263882, upload-time = "2026-02-03T14:01:16.343Z" }, + { url = "https://files.pythonhosted.org/packages/e1/58/8f54a2a93e3d675635bc406de1c9ac8d551312142ff52c9d71b5e533ad45/coverage-7.13.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae9306b5299e31e31e0d3b908c66bcb6e7e3ddca143dea0266e9ce6c667346d3", size = 266306, upload-time = "2026-02-03T14:01:18.02Z" }, + { url = "https://files.pythonhosted.org/packages/1a/be/e593399fd6ea1f00aee79ebd7cc401021f218d34e96682a92e1bae092ff6/coverage-7.13.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:343aaeb5f8bb7bcd38620fd7bc56e6ee8207847d8c6103a1e7b72322d381ba4a", size = 261051, upload-time = "2026-02-03T14:01:19.757Z" }, + { url = "https://files.pythonhosted.org/packages/5c/e5/e9e0f6138b21bcdebccac36fbfde9cf15eb1bbcea9f5b1f35cd1f465fb91/coverage-7.13.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b2182129f4c101272ff5f2f18038d7b698db1bf8e7aa9e615cb48440899ad32e", size = 263868, upload-time = "2026-02-03T14:01:21.487Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bf/de72cfebb69756f2d4a2dde35efcc33c47d85cd3ebdf844b3914aac2ef28/coverage-7.13.3-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:94d2ac94bd0cc57c5626f52f8c2fffed1444b5ae8c9fc68320306cc2b255e155", size = 261498, upload-time = "2026-02-03T14:01:23.097Z" }, + { url = "https://files.pythonhosted.org/packages/f2/91/4a2d313a70fc2e98ca53afd1c8ce67a89b1944cd996589a5b1fe7fbb3e5c/coverage-7.13.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:65436cde5ecabe26fb2f0bf598962f0a054d3f23ad529361326ac002c61a2a1e", size = 260394, upload-time = "2026-02-03T14:01:24.949Z" }, + { url = "https://files.pythonhosted.org/packages/40/83/25113af7cf6941e779eb7ed8de2a677865b859a07ccee9146d4cc06a03e3/coverage-7.13.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:db83b77f97129813dbd463a67e5335adc6a6a91db652cc085d60c2d512746f96", size = 262579, upload-time = "2026-02-03T14:01:26.703Z" }, + { url = "https://files.pythonhosted.org/packages/1e/19/a5f2b96262977e82fb9aabbe19b4d83561f5d063f18dde3e72f34ffc3b2f/coverage-7.13.3-cp313-cp313t-win32.whl", hash = "sha256:dfb428e41377e6b9ba1b0a32df6db5409cb089a0ed1d0a672dc4953ec110d84f", size = 222679, upload-time = "2026-02-03T14:01:28.553Z" }, + { url = "https://files.pythonhosted.org/packages/81/82/ef1747b88c87a5c7d7edc3704799ebd650189a9158e680a063308b6125ef/coverage-7.13.3-cp313-cp313t-win_amd64.whl", hash = "sha256:5badd7e596e6b0c89aa8ec6d37f4473e4357f982ce57f9a2942b0221cd9cf60c", size = 223740, upload-time = "2026-02-03T14:01:30.776Z" }, + { url = "https://files.pythonhosted.org/packages/1c/4c/a67c7bb5b560241c22736a9cb2f14c5034149ffae18630323fde787339e4/coverage-7.13.3-cp313-cp313t-win_arm64.whl", hash = "sha256:989aa158c0eb19d83c76c26f4ba00dbb272485c56e452010a3450bdbc9daafd9", size = 221996, upload-time = "2026-02-03T14:01:32.495Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b3/677bb43427fed9298905106f39c6520ac75f746f81b8f01104526a8026e4/coverage-7.13.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c6f6169bbdbdb85aab8ac0392d776948907267fcc91deeacf6f9d55f7a83ae3b", size = 219513, upload-time = "2026-02-03T14:01:34.29Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/290046e3bbf8986cdb7366a42dab3440b9983711eaff044a51b11006c67b/coverage-7.13.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2f5e731627a3d5ef11a2a35aa0c6f7c435867c7ccbc391268eb4f2ca5dbdcc10", size = 219850, upload-time = "2026-02-03T14:01:35.984Z" }, + { url = "https://files.pythonhosted.org/packages/ea/2b/ab41f10345ba2e49d5e299be8663be2b7db33e77ac1b85cd0af985ea6406/coverage-7.13.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:9db3a3285d91c0b70fab9f39f0a4aa37d375873677efe4e71e58d8321e8c5d39", size = 250886, upload-time = "2026-02-03T14:01:38.287Z" }, + { url = "https://files.pythonhosted.org/packages/72/2d/b3f6913ee5a1d5cdd04106f257e5fac5d048992ffc2d9995d07b0f17739f/coverage-7.13.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:06e49c5897cb12e3f7ecdc111d44e97c4f6d0557b81a7a0204ed70a8b038f86f", size = 253393, upload-time = "2026-02-03T14:01:40.118Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f6/b1f48810ffc6accf49a35b9943636560768f0812330f7456aa87dc39aff5/coverage-7.13.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fb25061a66802df9fc13a9ba1967d25faa4dae0418db469264fd9860a921dde4", size = 254740, upload-time = "2026-02-03T14:01:42.413Z" }, + { url = "https://files.pythonhosted.org/packages/57/d0/e59c54f9be0b61808f6bc4c8c4346bd79f02dd6bbc3f476ef26124661f20/coverage-7.13.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:99fee45adbb1caeb914da16f70e557fb7ff6ddc9e4b14de665bd41af631367ef", size = 250905, upload-time = "2026-02-03T14:01:44.163Z" }, + { url = "https://files.pythonhosted.org/packages/d5/f7/5291bcdf498bafbee3796bb32ef6966e9915aebd4d0954123c8eae921c32/coverage-7.13.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:318002f1fd819bdc1651c619268aa5bc853c35fa5cc6d1e8c96bd9cd6c828b75", size = 252753, upload-time = "2026-02-03T14:01:45.974Z" }, + { url = "https://files.pythonhosted.org/packages/a0/a9/1dcafa918c281554dae6e10ece88c1add82db685be123e1b05c2056ff3fb/coverage-7.13.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:71295f2d1d170b9977dc386d46a7a1b7cbb30e5405492529b4c930113a33f895", size = 250716, upload-time = "2026-02-03T14:01:48.844Z" }, + { url = "https://files.pythonhosted.org/packages/44/bb/4ea4eabcce8c4f6235df6e059fbc5db49107b24c4bdffc44aee81aeca5a8/coverage-7.13.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5b1ad2e0dc672625c44bc4fe34514602a9fd8b10d52ddc414dc585f74453516c", size = 250530, upload-time = "2026-02-03T14:01:50.793Z" }, + { url = "https://files.pythonhosted.org/packages/6d/31/4a6c9e6a71367e6f923b27b528448c37f4e959b7e4029330523014691007/coverage-7.13.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:b2beb64c145593a50d90db5c7178f55daeae129123b0d265bdb3cbec83e5194a", size = 252186, upload-time = "2026-02-03T14:01:52.607Z" }, + { url = "https://files.pythonhosted.org/packages/27/92/e1451ef6390a4f655dc42da35d9971212f7abbbcad0bdb7af4407897eb76/coverage-7.13.3-cp314-cp314-win32.whl", hash = "sha256:3d1aed4f4e837a832df2f3b4f68a690eede0de4560a2dbc214ea0bc55aabcdb4", size = 222253, upload-time = "2026-02-03T14:01:55.071Z" }, + { url = "https://files.pythonhosted.org/packages/8a/98/78885a861a88de020c32a2693487c37d15a9873372953f0c3c159d575a43/coverage-7.13.3-cp314-cp314-win_amd64.whl", hash = "sha256:9f9efbbaf79f935d5fbe3ad814825cbce4f6cdb3054384cb49f0c0f496125fa0", size = 223069, upload-time = "2026-02-03T14:01:56.95Z" }, + { url = "https://files.pythonhosted.org/packages/eb/fb/3784753a48da58a5337972abf7ca58b1fb0f1bda21bc7b4fae992fd28e47/coverage-7.13.3-cp314-cp314-win_arm64.whl", hash = "sha256:31b6e889c53d4e6687ca63706148049494aace140cffece1c4dc6acadb70a7b3", size = 221633, upload-time = "2026-02-03T14:01:58.758Z" }, + { url = "https://files.pythonhosted.org/packages/40/f9/75b732d9674d32cdbffe801ed5f770786dd1c97eecedef2125b0d25102dc/coverage-7.13.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c5e9787cec750793a19a28df7edd85ac4e49d3fb91721afcdc3b86f6c08d9aa8", size = 220243, upload-time = "2026-02-03T14:02:01.109Z" }, + { url = "https://files.pythonhosted.org/packages/cf/7e/2868ec95de5a65703e6f0c87407ea822d1feb3619600fbc3c1c4fa986090/coverage-7.13.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:e5b86db331c682fd0e4be7098e6acee5e8a293f824d41487c667a93705d415ca", size = 220515, upload-time = "2026-02-03T14:02:02.862Z" }, + { url = "https://files.pythonhosted.org/packages/7d/eb/9f0d349652fced20bcaea0f67fc5777bd097c92369f267975732f3dc5f45/coverage-7.13.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:edc7754932682d52cf6e7a71806e529ecd5ce660e630e8bd1d37109a2e5f63ba", size = 261874, upload-time = "2026-02-03T14:02:04.727Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a5/6619bc4a6c7b139b16818149a3e74ab2e21599ff9a7b6811b6afde99f8ec/coverage-7.13.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:d3a16d6398666510a6886f67f43d9537bfd0e13aca299688a19daa84f543122f", size = 264004, upload-time = "2026-02-03T14:02:06.634Z" }, + { url = "https://files.pythonhosted.org/packages/29/b7/90aa3fc645a50c6f07881fca4fd0ba21e3bfb6ce3a7078424ea3a35c74c9/coverage-7.13.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:303d38b19626c1981e1bb067a9928236d88eb0e4479b18a74812f05a82071508", size = 266408, upload-time = "2026-02-03T14:02:09.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/55/08bb2a1e4dcbae384e638f0effef486ba5987b06700e481691891427d879/coverage-7.13.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:284e06eadfe15ddfee2f4ee56631f164ef897a7d7d5a15bca5f0bb88889fc5ba", size = 260977, upload-time = "2026-02-03T14:02:11.755Z" }, + { url = "https://files.pythonhosted.org/packages/9b/76/8bd4ae055a42d8fb5dd2230e5cf36ff2e05f85f2427e91b11a27fea52ed7/coverage-7.13.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d401f0864a1d3198422816878e4e84ca89ec1c1bf166ecc0ae01380a39b888cd", size = 263868, upload-time = "2026-02-03T14:02:13.565Z" }, + { url = "https://files.pythonhosted.org/packages/e3/f9/ba000560f11e9e32ec03df5aa8477242c2d95b379c99ac9a7b2e7fbacb1a/coverage-7.13.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:3f379b02c18a64de78c4ccdddf1c81c2c5ae1956c72dacb9133d7dd7809794ab", size = 261474, upload-time = "2026-02-03T14:02:16.069Z" }, + { url = "https://files.pythonhosted.org/packages/90/4b/4de4de8f9ca7af4733bfcf4baa440121b7dbb3856daf8428ce91481ff63b/coverage-7.13.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:7a482f2da9086971efb12daca1d6547007ede3674ea06e16d7663414445c683e", size = 260317, upload-time = "2026-02-03T14:02:17.996Z" }, + { url = "https://files.pythonhosted.org/packages/05/71/5cd8436e2c21410ff70be81f738c0dddea91bcc3189b1517d26e0102ccb3/coverage-7.13.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:562136b0d401992118d9b49fbee5454e16f95f85b120a4226a04d816e33fe024", size = 262635, upload-time = "2026-02-03T14:02:20.405Z" }, + { url = "https://files.pythonhosted.org/packages/e7/f8/2834bb45bdd70b55a33ec354b8b5f6062fc90e5bb787e14385903a979503/coverage-7.13.3-cp314-cp314t-win32.whl", hash = "sha256:ca46e5c3be3b195098dd88711890b8011a9fa4feca942292bb84714ce5eab5d3", size = 223035, upload-time = "2026-02-03T14:02:22.323Z" }, + { url = "https://files.pythonhosted.org/packages/26/75/f8290f0073c00d9ae14056d2b84ab92dff21d5370e464cb6cb06f52bf580/coverage-7.13.3-cp314-cp314t-win_amd64.whl", hash = "sha256:06d316dbb3d9fd44cca05b2dbcfbef22948493d63a1f28e828d43e6cc505fed8", size = 224142, upload-time = "2026-02-03T14:02:24.143Z" }, + { url = "https://files.pythonhosted.org/packages/03/01/43ac78dfea8946c4a9161bbc034b5549115cb2b56781a4b574927f0d141a/coverage-7.13.3-cp314-cp314t-win_arm64.whl", hash = "sha256:299d66e9218193f9dc6e4880629ed7c4cd23486005166247c283fb98531656c3", size = 222166, upload-time = "2026-02-03T14:02:26.005Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fb/70af542d2d938c778c9373ce253aa4116dbe7c0a5672f78b2b2ae0e1b94b/coverage-7.13.3-py3-none-any.whl", hash = "sha256:90a8af9dba6429b2573199622d72e0ebf024d6276f16abce394ad4d181bb0910", size = 211237, upload-time = "2026-02-03T14:02:27.986Z" }, ] [package.optional-dependencies] @@ -424,14 +424,14 @@ wheels = [ [[package]] name = "id" -version = "1.5.0" +version = "1.6.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "requests" }, + { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/22/11/102da08f88412d875fa2f1a9a469ff7ad4c874b0ca6fed0048fe385bdb3d/id-1.5.0.tar.gz", hash = "sha256:292cb8a49eacbbdbce97244f47a97b4c62540169c976552e497fd57df0734c1d", size = 15237, upload-time = "2024-12-04T19:53:05.575Z" } +sdist = { url = "https://files.pythonhosted.org/packages/6d/04/c2156091427636080787aac190019dc64096e56a23b7364d3c1764ee3a06/id-1.6.1.tar.gz", hash = "sha256:d0732d624fb46fd4e7bc4e5152f00214450953b9e772c182c1c22964def1a069", size = 18088, upload-time = "2026-02-04T16:19:41.26Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9f/cb/18326d2d89ad3b0dd143da971e77afd1e6ca6674f1b1c3df4b6bec6279fc/id-1.5.0-py3-none-any.whl", hash = "sha256:f1434e1cef91f2cbb8a4ec64663d5a23b9ed43ef44c4c957d02583d61714c658", size = 13611, upload-time = "2024-12-04T19:53:03.02Z" }, + { url = "https://files.pythonhosted.org/packages/42/77/de194443bf38daed9452139e960c632b0ef9f9a5dd9ce605fdf18ca9f1b1/id-1.6.1-py3-none-any.whl", hash = "sha256:f5ec41ed2629a508f5d0988eda142e190c9c6da971100612c4de9ad9f9b237ca", size = 14689, upload-time = "2026-02-04T16:19:40.051Z" }, ] [[package]] From 0f0bd88e2d6746acfa8c6fd9a6c4c5249206dee5 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sun, 8 Feb 2026 22:18:21 +0500 Subject: [PATCH 02/30] docs: align 1.4.0 changelog and contracts --- CHANGELOG.md | 53 ++++++++++++++++++++++++++++++++++++ README.md | 64 +++++++++++++++++++++++++++++--------------- SECURITY.md | 9 +++++-- docs/architecture.md | 39 +++++++++++++++------------ 4 files changed, 125 insertions(+), 40 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 48213a3..e55e504 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,58 @@ # Changelog +## [1.4.0] - 2026-02-08 + +### Overview + +This release stabilizes the baseline contract for long-term CI use without changing +clone-detection algorithms. + +### Baseline Contract Stabilization + +- Baseline schema moved to a stable v1 contract with strict top-level + `meta` + `clones` objects. +- `meta` fields are now explicit and versioned: + `generator`, `schema_version`, `fingerprint_version`, + `python_tag`, `created_at`, `payload_sha256` (`generator.name` / `generator.version`). +- `clones` currently stores only deterministic baseline keys: + `functions`, `blocks`. +- Compatibility no longer depends on CodeClone patch/minor version. + Baseline regeneration is required when `fingerprint_version` changes. +- Added deterministic compatibility checks and statuses: + `mismatch_schema_version`, `mismatch_fingerprint_version`, + `mismatch_python_version`, `missing_fields`, `invalid_json`, `invalid_type`. +- Legacy 1.3 baseline files are treated as untrusted (`missing_fields`) with explicit + regeneration guidance. + +### Integrity & IO Hardening + +- Baseline integrity hash now uses canonical payload: + `functions`, `blocks`, `python_tag`, + `fingerprint_version`, `schema_version`. +- Baseline writes are now atomic (`*.tmp` + `os.replace`) for CI/interruption safety. +- Baseline and cache size guards remain configurable: + `--max-baseline-size-mb`, `--max-cache-size-mb`. + +### CLI & Reporting Behavior + +- Trusted/untrusted baseline behavior is deterministic: + normal mode ignores untrusted baseline with warning and compares against empty baseline; + gating mode (`--fail-on-new`/`--ci`) fails fast with exit code `2`. +- Report metadata (HTML/TXT/JSON) now exposes baseline audit fields: + `baseline_fingerprint_version`, + `baseline_schema_version`, `baseline_python_tag`, + `baseline_generator_version`, `baseline_loaded`, `baseline_status`. +- Block-clone explainability is now core-owned: + Python report layer generates facts/hints (`match_rule`, `signature_kind`, + `assert_ratio`, `consecutive_asserts`), HTML only renders them. +- HTML report API now expects precomputed `block_group_facts` from core (no UI-side semantics). + +### Testing + +- Expanded baseline validation matrix tests (types, missing fields, legacy, size limits, + compatibility mismatches, integrity mismatch, canonical hash determinism). +- Full quality gates pass with `ruff`, `mypy`, and `pytest` at 100% coverage. + ## [1.3.0] - 2026-02-08 ### Overview diff --git a/README.md b/README.md index 7dea74c..f0f255a 100644 --- a/README.md +++ b/README.md @@ -158,23 +158,30 @@ codeclone --version All report formats include provenance metadata for auditability: -`codeclone_version`, `python_version`, `baseline_path`, `baseline_version`, -`baseline_schema_version`, `baseline_python_version`, `baseline_loaded`, -`baseline_status` (and cache metadata when available). +`codeclone_version`, `python_version`, `baseline_path`, +`baseline_fingerprint_version`, +`baseline_schema_version`, `baseline_python_tag`, +`baseline_generator_version`, `baseline_loaded`, `baseline_status` +(and cache metadata when available). + +Explainability contract: +- Facts are computed in Python core/report layer. +- HTML UI only renders those facts (no semantic recomputation in UI). baseline_status values: - `ok` - `missing` -- `legacy` -- `invalid` -- `mismatch_version` -- `mismatch_schema` -- `mismatch_python` +- `too_large` +- `invalid_json` +- `invalid_type` +- `missing_fields` +- `mismatch_schema_version` +- `mismatch_fingerprint_version` +- `mismatch_python_version` - `generator_mismatch` - `integrity_missing` - `integrity_failed` -- `too_large` --- @@ -190,18 +197,29 @@ codeclone . --update-baseline Commit the generated baseline file to the repository. -Baselines are versioned. If CodeClone is upgraded, regenerate the baseline to keep -CI deterministic and explainable. +Baseline compatibility is tied to `fingerprint_version` (not `codeclone_version`): + +- patch/minor releases can reuse the same baseline, +- regenerate baseline only when `fingerprint_version` changes, +- baseline file remains tamper-evident via `payload_sha256`. -Baseline format in 1.3+ is tamper-evident (generator, payload_sha256) and validated -before baseline comparison. +Baseline v1 contract schema: +`meta.generator.name`, `meta.generator.version`, `meta.schema_version`, +`meta.fingerprint_version`, `meta.python_tag`, `meta.created_at`, +`meta.payload_sha256`, +`clones.functions`, `clones.blocks`. 2. Trusted vs untrusted baseline behavior Baseline states considered untrusted: -- `invalid` - `too_large` +- `invalid_json` +- `invalid_type` +- `missing_fields` +- `mismatch_schema_version` +- `mismatch_fingerprint_version` +- `mismatch_python_version` - `generator_mismatch` - `integrity_missing` - `integrity_failed` @@ -233,6 +251,12 @@ Behavior: `--fail-on-new` / `--ci` exits with a non-zero code when new clones are detected. +Exit codes: + +- `0` success +- `2` invalid arguments or untrusted baseline in gating mode +- `3` gating failure (`--fail-on-new` new clones or `--fail-threshold` exceeded) + --- ### Cache @@ -256,15 +280,13 @@ deterministically. --- -## Python Version Consistency for Baseline Checks - -Due to inherent differences in Python’s AST between interpreter versions, baseline -generation and verification must be performed using the same Python version. +## Python Tag Consistency for Baseline Checks -This ensures deterministic and reproducible clone detection results. +Baseline compatibility is pinned to `python_tag` (for example `cp313`) to keep +AST/fingerprint behavior reproducible across runs. -CI checks therefore pin baseline verification to a single Python version, while the -test matrix continues to validate compatibility across Python 3.10–3.14. +Patch-level interpreter updates do not invalidate the baseline as long as +the `python_tag` stays the same. --- diff --git a/SECURITY.md b/SECURITY.md index 080e1ef..21fb87a 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -9,7 +9,8 @@ The following versions currently receive security updates: | Version | Supported | |---------|-----------| -| 1.3.x | Yes | +| 1.4.x | Yes | +| 1.3.x | No | | 1.2.x | No | | 1.1.x | No | | 1.0.x | No | @@ -38,11 +39,15 @@ Additional safeguards: - HTML report content is escaped in both text and attribute contexts to prevent script injection. - Reports are static and do not execute analyzed code. +- Report explainability fields are generated in Python core; UI is rendering-only and does not infer semantics. - Scanner traversal is root-confined and prevents symlink-based path escape. - Baseline files are schema/type validated with size limits and tamper-evident integrity fields - (`generator`, `payload_sha256` for v1.3+). + (`generator`, `payload_sha256` for v1 baseline contract). - Baseline integrity is tamper-evident (audit signal), not tamper-proof cryptographic signing. An actor who can rewrite baseline content and recompute `payload_sha256` can still alter it. +- Baseline hash excludes non-semantic metadata (`created_at`, `generator.version`) and + covers canonical payload (`functions`, `blocks`, `python_tag`, + `fingerprint_version`, `schema_version`). - In `--fail-on-new` / `--ci`, untrusted baseline states fail fast; otherwise baseline is ignored with explicit warning and comparison proceeds against an empty baseline. - Cache files are HMAC-signed (constant-time comparison), size-limited, and ignored on mismatch. diff --git a/docs/architecture.md b/docs/architecture.md index 2722a19..25ac706 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -136,12 +136,19 @@ All report formats include provenance metadata: - `codeclone_version` - `python_version` - `baseline_path` -- `baseline_version` +- `baseline_fingerprint_version` - `baseline_schema_version` -- `baseline_python_version` +- `baseline_python_tag` +- `baseline_generator_version` - `baseline_loaded` - `baseline_status` - (`ok | missing | legacy | invalid | mismatch_version | mismatch_schema | mismatch_python | generator_mismatch | integrity_missing | integrity_failed | too_large`) + (`ok | missing | too_large | invalid_json | invalid_type | missing_fields | mismatch_schema_version | mismatch_fingerprint_version | mismatch_python_version | generator_mismatch | integrity_missing | integrity_failed`) + +Explainability contract (v1): + +- Explainability facts are produced only by Python core/report layer. +- HTML/JS renderer is display-only and must not recalculate metrics or introduce new semantics. +- UI can format, filter, and highlight facts, but cannot invent new hints. --- @@ -150,18 +157,18 @@ All report formats include provenance metadata: Baseline comparison allows CI to fail **only on new clones**, enabling gradual architectural improvement. -Baseline files are **versioned**. The baseline stores the CodeClone version and schema -version used to generate it. Mismatches result in a hard stop and require regeneration. -Baseline format in 1.3+ is tamper-evident (`generator`, `payload_sha256`) and validated -before baseline diffing. +Baseline files use a stable v1 contract. Compatibility is tied to +`fingerprint_version` (normalize/CFG/hash pipeline), not package patch/minor version. +Regeneration is required when `fingerprint_version` changes. +Baseline integrity is tamper-evident via canonical `payload_sha256`. Baseline validation order is deterministic: 1. size guard (before JSON parse), 2. JSON parse and root object/type checks, -3. legacy/version/schema policy checks, -4. Python version policy check, -5. integrity checks (`generator`, `payload_sha256`) for v1.3+ baseline format only. +3. required fields and type checks, +4. compatibility checks (`generator`, `schema_version`, `fingerprint_version`, `python_tag`), +5. integrity checks (`payload_sha256`). Baseline loading is strict: schema/type violations, integrity failures, generator mismatch, or oversized files are treated as untrusted input. @@ -170,15 +177,13 @@ Outside gating mode, untrusted baseline is ignored with warning and comparison p against an empty baseline. Baseline size guard is configurable via `--max-baseline-size-mb`. -## Python Version Consistency for Baseline Checks - -Due to inherent differences in Python’s AST between interpreter versions, baseline -generation and verification must be performed using the same Python version. +## Python Tag Consistency for Baseline Checks -This ensures deterministic and reproducible clone detection results. +Due to inherent AST differences across interpreter builds, baseline compatibility +is pinned to `python_tag` (for example `cp313`). -CI checks therefore pin baseline verification to a single Python version, while the -test matrix continues to validate compatibility across Python 3.10–3.14. +This preserves deterministic and reproducible clone detection results while allowing +patch updates within the same interpreter tag. --- From aefe7e30b571aee2d8d045a22b912feea21a17b3 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Sun, 8 Feb 2026 22:22:23 +0500 Subject: [PATCH 03/30] test(ui): add block clone fixture module --- tests/test_ui_block_clone_fixture.py | 97 ++++++++++++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 tests/test_ui_block_clone_fixture.py diff --git a/tests/test_ui_block_clone_fixture.py b/tests/test_ui_block_clone_fixture.py new file mode 100644 index 0000000..8edd2e6 --- /dev/null +++ b/tests/test_ui_block_clone_fixture.py @@ -0,0 +1,97 @@ +""" +Temporary manual fixture for HTML explainability demo. + +This module intentionally contains repetitive assert-only functions so that +CodeClone reports a deterministic block clone with assert-only hints. +It is safe to delete after visual verification. +""" + +from __future__ import annotations + + +def fixture_block_clone_case_one(html: str) -> None: + if not html: + raise ValueError("case_one requires non-empty html") + + assert "m01" in html + assert "m02" in html + assert "m03" in html + assert "m04" in html + assert "m05" in html + assert "m06" in html + assert "m07" in html + assert "m08" in html + assert "m09" in html + assert "m10" in html + assert "m11" in html + assert "m12" in html + assert "m13" in html + assert "m14" in html + assert "m15" in html + assert "m16" in html + assert "m17" in html + assert "m18" in html + assert "m19" in html + assert "m20" in html + assert "m21" in html + assert "m22" in html + assert "m23" in html + assert "m24" in html + assert "m25" in html + assert "m26" in html + assert "m27" in html + assert "m28" in html + assert "m29" in html + assert "m30" in html + assert "m31" in html + assert "m32" in html + assert "m33" in html + assert "m34" in html + assert "m35" in html + assert "m36" in html + + assert html.startswith("<") + + +def fixture_block_clone_case_two(html: str) -> None: + marker = len(html) + assert marker >= 0 + + assert "m01" in html + assert "m02" in html + assert "m03" in html + assert "m04" in html + assert "m05" in html + assert "m06" in html + assert "m07" in html + assert "m08" in html + assert "m09" in html + assert "m10" in html + assert "m11" in html + assert "m12" in html + assert "m13" in html + assert "m14" in html + assert "m15" in html + assert "m16" in html + assert "m17" in html + assert "m18" in html + assert "m19" in html + assert "m20" in html + assert "m21" in html + assert "m22" in html + assert "m23" in html + assert "m24" in html + assert "m25" in html + assert "m26" in html + assert "m27" in html + assert "m28" in html + assert "m29" in html + assert "m30" in html + assert "m31" in html + assert "m32" in html + assert "m33" in html + assert "m34" in html + assert "m35" in html + assert "m36" in html + + assert html.endswith(">") From f9e0736f53ef13e1d5f0d176f162896d59e0d926 Mon Sep 17 00:00:00 2001 From: Den Rozhnovskiy Date: Mon, 9 Feb 2026 00:28:22 +0500 Subject: [PATCH 04/30] fix(ui): restore trustworthy report UX and harden explainability rendering --- codeclone.baseline.json | 2 +- codeclone/_report_explain.py | 10 + codeclone/html_report.py | 333 ++-- codeclone/templates.py | 2889 +++++++++++++++++----------------- tests/test_html_report.py | 77 +- tests/test_report.py | 3 + tests/test_report_explain.py | 27 + 7 files changed, 1779 insertions(+), 1562 deletions(-) diff --git a/codeclone.baseline.json b/codeclone.baseline.json index c57492d..2cb31cd 100644 --- a/codeclone.baseline.json +++ b/codeclone.baseline.json @@ -7,7 +7,7 @@ "schema_version": "1.0", "fingerprint_version": "1", "python_tag": "cp313", - "created_at": "2026-02-08T17:14:12Z", + "created_at": "2026-02-08T18:03:16Z", "payload_sha256": "77767150a39a80be72a7d71ad15deeb7f6635d016c0304abd6bcbd879f5ffa47" }, "clones": { diff --git a/codeclone/_report_explain.py b/codeclone/_report_explain.py index 2a8523a..e7a2a21 100644 --- a/codeclone/_report_explain.py +++ b/codeclone/_report_explain.py @@ -239,6 +239,16 @@ def build_block_group_facts(block_groups: GroupMap) -> dict[str, dict[str, str]] ast_cache=ast_cache, range_cache=range_cache, ) + group_arity = len(items) + peer_count = max(0, group_arity - 1) + facts["group_arity"] = str(group_arity) + facts["instance_peer_count"] = str(peer_count) + if group_arity > 2: + facts["group_compare_note"] = ( + f"N-way group: each block matches {peer_count} peers in this group." + ) + if facts.get("hint") == "assert_only": + facts["group_display_name"] = "assert pattern block" facts_by_group[group_key] = facts return facts_by_group diff --git a/codeclone/html_report.py b/codeclone/html_report.py index 7842051..1535802 100644 --- a/codeclone/html_report.py +++ b/codeclone/html_report.py @@ -221,12 +221,13 @@ def _render_group_explanation(meta: dict[str, Any]) -> str: explain_items.append( ("hint: assert-only block", "group-explain-item group-explain-warn") ) - explain_items.append( - ( - "hint_confidence: deterministic", - "group-explain-item group-explain-muted", + if meta.get("hint_confidence"): + explain_items.append( + ( + f"hint_confidence: {meta['hint_confidence']}", + "group-explain-item group-explain-muted", + ) ) - ) if meta.get("assert_ratio"): explain_items.append( ( @@ -282,50 +283,73 @@ def render_section( if not groups: return "" - # build group DOM with data-search (for fast client-side search) + def _block_group_name(display_key: str, meta: dict[str, str]) -> str: + if meta.get("group_display_name"): + return str(meta["group_display_name"]) + if len(display_key) > 56: + return f"{display_key[:24]}...{display_key[-16:]}" + return display_key + + def _group_name(display_key: str, meta: dict[str, str]) -> str: + if section_id == "blocks": + return _block_group_name(display_key, meta) + return display_key + + def _item_span_size(item: dict[str, Any]) -> int: + start_line = int(item.get("start_line", 0)) + end_line = int(item.get("end_line", 0)) + return max(0, end_line - start_line + 1) + + def _group_span_size(items: list[dict[str, Any]]) -> int: + return max((_item_span_size(item) for item in items), default=0) + out: list[str] = [ f'
', - '
', + '
', f"

{_escape_html(section_title)} " - f'' + f'' f"{len(groups)} groups

", + "
", f""" -