diff --git a/.github/actions/codeclone/README.md b/.github/actions/codeclone/README.md index 28ea25fc..c82c978d 100644 --- a/.github/actions/codeclone/README.md +++ b/.github/actions/codeclone/README.md @@ -39,7 +39,7 @@ source under test. Remote consumers still install from PyPI. For strict reproducibility, pin the full release tag: ```yaml -- uses: orenlab/codeclone/.github/actions/codeclone@v2.0.2 +- uses: orenlab/codeclone/.github/actions/codeclone@v2.1.0a1 ``` For long-lived workflows, `@v2` follows the latest compatible 2.x action @@ -81,7 +81,7 @@ jobs: | Input | Default | Purpose | |-------------------------|---------------------------------|-------------------------------------------------------------------------------------------------------------------| | `python-version` | `3.14` | Python version used to run the action | -| `package-version` | `2.0.2` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo | +| `package-version` | `2.1.0a1` | CodeClone version from PyPI for remote installs; ignored when the action runs from the checked-out CodeClone repo | | `path` | `.` | Project root to analyze | | `json-path` | `.codeclone/report.json` | JSON report output path | | `sarif` | `true` | Generate SARIF and try to upload it | @@ -146,7 +146,7 @@ Notes: ## Install policy Released action tags pin the PyPI package version in action metadata. For -example, `@v2.0.2` installs `codeclone==2.0.2` unless you override +example, `@v2.1.0a1` installs `codeclone==2.1.0a1` unless you override `package-version`. Explicit prerelease or smoke-test override: diff --git a/.github/actions/codeclone/_action_impl.py b/.github/actions/codeclone/_action_impl.py index 692f8a28..839d121d 100644 --- a/.github/actions/codeclone/_action_impl.py +++ b/.github/actions/codeclone/_action_impl.py @@ -22,7 +22,7 @@ import subprocess from dataclasses import dataclass from pathlib import Path -from typing import Literal +from typing import Literal, TypeGuard COMMENT_MARKER = "" DEFAULT_CODECLONE_PACKAGE_VERSION = "2.1.0a1" @@ -340,10 +340,16 @@ def _run_result_from_paths(*, exit_code: int, inputs: ActionInputs) -> RunResult ) +def _is_json_object(value: object) -> TypeGuard[dict[str, object]]: + """Return true for JSON object values decoded from report payloads.""" + + return isinstance(value, dict) + + def _mapping(value: object) -> dict[str, object]: """Return ``value`` when it is a JSON object, otherwise an empty mapping.""" - return value if isinstance(value, dict) else {} + return value if _is_json_object(value) else {} def _int(value: object, default: int = 0) -> int: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c17738f..5bf56f2e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -7,7 +7,6 @@ repos: - id: check-merge-conflict - id: end-of-file-fixer - id: trailing-whitespace - - id: check-added-large-files - id: check-toml - id: check-yaml - id: check-json @@ -69,3 +68,11 @@ repos: pass_filenames: false files: ^docs/.*\.md$ stages: [ pre-commit ] + + - id: ty-prod-scope + name: Ty check production scope + entry: bash -c 'uv run ty check codeclone .github/actions benchmarks' + language: system + pass_filenames: false + always_run: true + stages: [ pre-commit ] diff --git a/AGENTS.md b/AGENTS.md index 9fec7fa1..e4415bb4 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -107,6 +107,10 @@ Key state and surfaces: files, baselines, canonical/generated reports, and analysis cache; explicit controller, audit, memory, projection, and observability contracts may write only their documented bounded local state (install via `codeclone[mcp]`) +- `codeclone setup` — lazy-loaded CLI readiness surface (`status`, `doctor`, + `plan`, `apply`, `wizard`); capability snapshots and bounded `pyproject.toml` / + `.gitignore` merges only — not MCP change control and never writes baselines, + cache, or canonical reports - `extensions/vscode-codeclone/` — stable VS Code extension as a native, read-only IDE client over `codeclone-mcp` - `extensions/claude-desktop-codeclone/` — stable Claude Desktop `.mcpb` bundle as a local install wrapper over `codeclone-mcp` @@ -122,13 +126,14 @@ Key state and surfaces: baseline/cache/report artifacts. Optional audit trail is passive evidence state and must not affect canonical report digests, baseline trust, cache compatibility, or finding identity. -- `docs/`, `zensical.toml`, `.github/workflows/docs.yml` — published documentation site and docs build pipeline +- `docs/`, `zensical.toml`, `.github/workflows/docs.yml` — docs site in migration (published + contract pages **TBD**); build pipeline and sample-report generation remain active --- ## 3) Validation stages -The installed `pre-commit` stage runs hygiene checks, Ruff, Mypy, +The installed `pre-commit` stage runs hygiene checks, Ruff, Mypy, Ty (production scope), baseline-aware `codeclone . --ci`, and the docs admonition fixer: ```bash @@ -153,12 +158,29 @@ Hooks may rewrite files. Inspect `git diff` again afterward. Never use If you touched baseline/cache/report contracts or CLI/MCP audit surfaces, also exercise the CLI audit path (`--audit` / `codeclone/surfaces/cli/audit.py`) or the relevant audit/MCP tests. + +If you touched the setup readiness CLI surface (`codeclone setup`, pyproject writer, +setup plan/apply/wizard), also run: + +```bash +uv run pytest -q tests/test_cli_setup.py tests/test_pyproject_writer.py +``` + If you touched `docs/`, `zensical.toml`, docs publishing workflow, or sample-report generation, also run: ```bash uv run --with zensical==0.0.46 zensical build --clean --strict ``` +Published contract-page routing is **TBD** during the docs migration; still run the build when +`docs/` or the pipeline changes. + +If you touched Corpus Analytics (`codeclone/analytics/*`, `codeclone analytics` CLI), also run: + +```bash +uv run pytest -q tests/test_analytics_*.py tests/test_config_analytics.py +``` + If you touched the MCP surface, also run: ```bash @@ -259,10 +281,13 @@ doc.** Current central values (verified at write time): | `BASELINE_SCHEMA_VERSION` | `2.1` | | `BASELINE_FINGERPRINT_VERSION` | `1` | | `CACHE_VERSION` | `2.10` | -| `REPORT_SCHEMA_VERSION` | `2.11` | +| `REPORT_SCHEMA_VERSION` | `2.12` | | `METRICS_BASELINE_SCHEMA_VERSION` | `1.2` | | `ENGINEERING_MEMORY_SCHEMA_VERSION` | `1.7` | -| `SEMANTIC_INDEX_FORMAT_VERSION` | `2` | +| `SEMANTIC_INDEX_FORMAT_VERSION` | `3` | +| `SEMANTIC_PROJECTION_REVISION_VERSION` | `1` | +| `MEMORY_PROJECTION_VERSION` | `memory-v1` | +| `AUDIT_PROJECTION_VERSION` | `audit-v1` | | `PATCH_TRAIL_SCHEMA_VERSION` | `1` | | `PLATFORM_OBSERVABILITY_SCHEMA_VERSION` | `1.1` | | `TRAJECTORY_PROJECTION_VERSION` | `trajectory-v3` | @@ -281,11 +306,16 @@ doc.** Current central values (verified at write time): Subsystem-local wire versions (not in `contracts/__init__.py`): -| Constant | Value | Owner | -|----------------------------|-------|-----------------------------------------------------| -| `AUDIT_EVENT_CORE_VERSION` | `2` | `codeclone/audit/events.py` | -| `CONTEXT_CONTRACT_VERSION` | `1` | `codeclone/surfaces/mcp/_implementation_context.py` | -| `CALL_RESOLUTION_VERSION` | `1` | `codeclone/surfaces/mcp/_implementation_context.py` | +| Constant | Value | Owner | +|------------------------------------------|-----------------------|-----------------------------------------------------| +| `AUDIT_EVENT_CORE_VERSION` | `2` | `codeclone/audit/events.py` | +| `CONTEXT_CONTRACT_VERSION` | `1` | `codeclone/surfaces/mcp/_implementation_context.py` | +| `CALL_RESOLUTION_VERSION` | `1` | `codeclone/surfaces/mcp/_implementation_context.py` | +| `CONTEXT_GOVERNANCE_CONTRACT_VERSION` | `1.0` | `codeclone/surfaces/mcp/_context_governance.py` | +| `CONTEXT_GOVERNANCE_DIGEST_VERSION` | `1` | `codeclone/surfaces/mcp/_context_governance.py` | +| `CONTEXT_GOVERNANCE_ESTIMATOR` | `utf8_bytes_div_4_v1` | `codeclone/surfaces/mcp/_context_governance.py` | +| `RECEIPT_VERSION` | `1` | `codeclone/surfaces/mcp/_review_receipt.py` | +| `BLAST_ARTIFACT_DETAIL_CONTRACT_VERSION` | `1` | `codeclone/surfaces/mcp/_blast_radius.py` | When updating any doc that mentions a version, re-read `codeclone/contracts/__init__.py` first. Do not derive versions from another document. @@ -512,15 +542,16 @@ Changed-scope flags are contract-sensitive: - `--diff-against` requires `--changed-only`. - `--paths-from-git-diff` implies `--changed-only`. -Controller and workspace query flags (terminal-only; see `docs/book/11-cli.md` and -`tests/fixtures/contract_snapshots/cli_help.txt`): +Controller and workspace query flags (terminal-only; authoritative sources: +`tests/fixtures/contract_snapshots/cli_help.txt`, `codeclone/config/spec.py`; +published CLI docs **TBD** during migration): - `--blast-radius`, `--patch-verify`, `--strictness` — patch/blast-radius query - `--session-stats`, `--audit`, `--audit-json` — workspace/audit query (read-only; `--audit` requires `audit_enabled=true` in effective config) -Full flag inventory and combination rules: `docs/book/11-cli.md`, -`docs/book/10-config-and-defaults.md`. +Full flag inventory and combination rules: `codeclone/config/spec.py` and +`tests/fixtures/contract_snapshots/cli_help.txt` (published docs **TBD**). If you introduce a new exit reason, document it and add tests. @@ -561,6 +592,9 @@ Before cutting a release: Observability authorize edits or override canonical report facts. - Don’t describe agent review, receipts, or automated checks as the mandatory human review required for merge. +- Don’t conflate `codeclone setup apply` with MCP change control — setup never + declares intent, never returns `edit_allowed`, and must not write baselines, + analysis cache, or canonical reports. --- @@ -570,14 +604,20 @@ Architecture is layered, but grounded in current code (not aspirational diagrams - **Structural Change Controller** (`codeclone/surfaces/mcp/_session_workflow_mixin.py`, intent/blast-radius/patch-contract/receipt helpers under - `codeclone/surfaces/mcp/`, `codeclone/analysis/blast_radius.py`, - `codeclone/budget/*`) owns pre-edit scope authorization, deterministic blast - radius, patch verification, claim validation, and review receipts over - canonical report facts. + `codeclone/surfaces/mcp/`, `codeclone/workspace_intent/*`, + `codeclone/analysis/blast_radius.py`, `codeclone/budget/*`) owns pre-edit scope + authorization, deterministic blast radius, patch verification, claim validation, + and review receipts over canonical report facts. - **CLI entry + orchestration surface** (`codeclone/main.py`, `codeclone/surfaces/cli/*`, `codeclone/ui_messages/*`) owns argument parsing, runtime/config resolution, summaries, report writes, and exit routing. User-facing copy lives in `ui_messages/` submodules (`help`, `labels`, `runtime`, - `markers`, `formatters`, `controller`, `styling`). + `markers`, `formatters`, `controller`, `styling`, `setup`). +- **Setup readiness CLI** (`codeclone/surfaces/cli/setup/*`, lazy-loaded from + `workflow.main()` when `sys.argv[1] == "setup"`) owns capability discovery, + `SetupSnapshot` / `SetupPlan` projections, Rich human rendering, bounded + `pyproject.toml` round-trip merges (`codeclone/config/pyproject_writer.py`), and + optional `.gitignore` append. Runs on base install without `surfaces.mcp.*`; + apply is not Structural Change Controller authorization. - **Config layer** (`codeclone/config/*`) is the single source of truth for option specs, parser construction, `pyproject.toml` loading, and CLI > pyproject > defaults resolution. - **Core orchestration** (`codeclone/core/*`) owns bootstrap → discovery → worker processing → project metrics → @@ -585,8 +625,9 @@ Architecture is layered, but grounded in current code (not aspirational diagrams - **Analysis layer** (`codeclone/analysis/*`, `codeclone/blocks/*`, `codeclone/paths/*`, `codeclone/qualnames/*`, `codeclone/scanner/*`) parses source, normalizes AST/CFG facts, extracts units, and prepares deterministic analysis inputs. -- **Clone/finding derivation layer** (`codeclone/findings/*`, `codeclone/metrics/*`) groups clones and computes - structural and quality signals from already-extracted facts. +- **Clone/finding derivation layer** (`codeclone/findings/*`, `codeclone/metrics/*`, + `codeclone/meta_markers/*`) groups clones and computes structural and quality + signals from already-extracted facts. - **Domain/contracts layer** (`codeclone/models.py`, `codeclone/contracts/*`, `codeclone/domain/*`) defines typed entities, enums, schema/version constants, and typed exceptions used across layers. - **Persistence contracts** (`codeclone/baseline/*`, `codeclone/cache/*`) store trusted comparison state and @@ -609,6 +650,10 @@ Architecture is layered, but grounded in current code (not aspirational diagrams operation/span telemetry, normalized SQL fingerprints, bounded query projections, and self-contained JSON/HTML diagnostics for CodeClone development. It is never repository quality truth or a gate input. +- **Corpus Analytics** (`codeclone/analytics/*`, lazy-loaded `codeclone analytics` + CLI route in `codeclone/surfaces/cli/analytics.py`) owns the corpus clustering + store, export/representation contracts, and maintainer analytics projections. + It is separate from Engineering Memory and repository-quality gates. - **Controller insights** (`codeclone/controller_insights/*`) owns shared session-stat and audit-trail projections used by CLI and IDE-only MCP tools. - **Audit trail** (`codeclone/audit/*`) stores optional passive evidence (SQLite by default via @@ -616,7 +661,8 @@ Architecture is layered, but grounded in current code (not aspirational diagrams cache compatibility, or finding identity. - **Patch budget helpers** (`codeclone/budget/*`) provide shared budget estimation for CLI/MCP patch-verify flows. - **Documentation/publishing surface** (`docs/`, `zensical.toml`, `.github/workflows/docs.yml`, - `scripts/build_docs_example_report.py`) publishes contract docs and the live sample report. + `scripts/build_docs_example_report.py`) builds the docs site and sample report. + Published contract-page inventory is **TBD** during migration. - **Developer/release scripts** (`scripts/lint_admonitions.py`, `scripts/sync_integrations.py`, `scripts/integration_dist/*`, `scripts/launch_mcp`) provide docs hygiene, storefront synchronization, and @@ -660,6 +706,9 @@ Non-negotiable interpretation: analyzer, MCP server, or truth path. - The Cursor plugin is a local discovery and guidance surface over `codeclone-mcp` and must not introduce a second analyzer, MCP server, or truth path. +- `codeclone setup` is a human onboarding CLI over canonical config/audit/memory + **probes**; it is not an MCP surface and does not grant edit permission for + governed repository patches. ## 13) Module map @@ -670,7 +719,12 @@ Use this map to route changes to the right owner module. graph core shared by CLI/MCP controller projections; keep it independent from MCP session policy. - `codeclone/surfaces/cli/workflow.py` — top-level CLI orchestration and exit routing. Add CLI control flow here, not - in `main.py`. + in `main.py`; lazy-load `memory`, `analytics`, `observability`, and `setup` argv routes here only. +- `codeclone/surfaces/cli/setup/*` — setup readiness CLI (`status`, `doctor`, `plan`, `apply`, `wizard`); discover/plan + engines, Rich renderers, wizard hub. Must not import `codeclone.surfaces.mcp.*`. +- `codeclone/config/pyproject_writer.py` — round-trip `[tool.codeclone]` merge via `tomlkit` for setup apply. +- `codeclone/utils/atomic_write.py` — shared atomic file replace for setup apply and pyproject writer. +- `codeclone/paths/gitignore.py` — gitignore coverage helpers shared by setup plan/apply and tips. - `codeclone/surfaces/cli/*` — CLI support slices (startup, runtime, execution, post-run handling, summaries, reports, changed-scope logic, baseline state, audit rendering, console helpers). Keep them orchestration/UX-focused. - `codeclone/config/*` — parser construction, option specs/defaults, pyproject loading, config resolution. Do not @@ -682,6 +736,7 @@ Use this map to route changes to the right owner module. CLI/report/baseline UX. - `codeclone/scanner/*` — Python file discovery helpers and module-name resolution used by core discovery. - `codeclone/findings/clones/grouping.py` + `codeclone/blocks/*` — clone grouping and block/segment mechanics. +- `codeclone/meta_markers/*` — meta-marker derivation used by metrics/report joins. - `codeclone/findings/structural/detectors.py` — structural finding extraction/normalization policy; keep it factual and deterministic. - `codeclone/metrics/*` — metric computations and dead-code/dependency/health logic; change metric math and thresholds @@ -709,6 +764,11 @@ Use this map to route changes to the right owner module. retrieval, semantic sidecar, governance, trajectories, Patch Trail, Experiences, and projection jobs. Memory mutations go through explicit memory tools/workflows only — never the general source-edit workflow. +- `codeclone/workspace_intent/*` — ephemeral workspace intent registry/schema and + coordination helpers shared by MCP, CLI, and plugins. Coordination state only, + never analysis truth. +- `codeclone/analytics/*` — Corpus Analytics store, clustering, export, and + reporting. CLI entry: `codeclone/surfaces/cli/analytics.py`. - `codeclone/surfaces/mcp/service.py` — typed, in-process MCP service over the current pipeline/report contracts; keep source/baseline/report/cache access read-only. Local mutations are limited to documented controller, memory, projection, audit, and @@ -738,7 +798,8 @@ Use this map to route changes to the right owner module. explainability, overview, security, chrome, text/markdown/sarif projections, gate prefixes). - `docs/`, `zensical.toml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py` — docs-site source, - publication workflow, and live sample-report generation; keep published docs aligned with code contracts. + publication workflow, and live sample-report generation. Published contract-page routing is **TBD** during + migration; keep code/tests/CHANGELOG aligned with contracts. - `scripts/lint_admonitions.py` — deterministic MkDocs admonition/details indentation validator/fixer used by pre-commit. - `scripts/sync_integrations.py` + `scripts/integration_dist/*` — guarded @@ -817,27 +878,34 @@ Prefer explicit inline suppressions for runtime/dynamic false positives instead If you change a contract-sensitive zone, route docs/tests/approval deliberately. +Published contract documentation is **TBD** during the docs-site migration (`docs/`). +Until replacement pages land, prioritize **code**, **tests**, `CHANGELOG.md`, +`README.md`, and surface-local READMEs/plugin skills over legacy `docs/book/**` +or `docs/guide/**` paths. + | Change zone | Must update docs | Must update tests | Explicit approval required when | Contract-change trigger | |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------| -| Baseline schema/trust/integrity (`codeclone/baseline/clone_baseline.py`, `codeclone/baseline/trust.py`) | `docs/book/07-baseline.md`, `docs/book/24-compatibility-and-versioning.md`, `docs/book/appendix/b-schema-layouts.md`, `CHANGELOG.md` | `tests/test_baseline.py`, CI/CLI behavior tests (`tests/test_cli_inprocess.py`, `tests/test_cli_unit.py`) | schema/trust semantics, compatibility windows, payload integrity logic change | baseline key layout/status semantics/compat rules change | -| Cache schema/profile/integrity (`codeclone/cache/store.py`, `codeclone/cache/versioning.py`, `codeclone/cache/integrity.py`) | `docs/book/08-cache.md`, `docs/book/appendix/b-schema-layouts.md`, `CHANGELOG.md` | `tests/test_cache.py`, pipeline/CLI cache integration tests | cache schema/status/profile compatibility semantics change | cache payload/version/status semantics change | -| Canonical report JSON shape (`codeclone/report/document/*`, report projections) | `docs/book/05-report.md` (+ `docs/book/06-html-render.md` if rendering contract impacted), `docs/sarif.md` when SARIF changes, `CHANGELOG.md` | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py`, relevant report-format tests | finding/meta/summary schema changes | stable JSON fields/meaning/order guarantees change | -| CLI flags/help/exit behavior (`codeclone/main.py`, `codeclone/surfaces/cli/*`, `codeclone/config/*`, `codeclone/contracts/*`) | `docs/book/11-cli.md`, `docs/book/09-exit-codes.md`, `README.md`, `CHANGELOG.md` | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py`, `tests/test_cli_smoke.py` | exit-code semantics, script-facing behavior, flag contracts change | user-visible CLI contract changes | -| Structural Change Controller (intent, blast radius, patch contract, hygiene, claims, receipts, Patch Trail) | `docs/book/12-structural-change-controller/`, `docs/guide/change-control/`, `docs/book/14-claim-guard.md`, MCP/plugin guidance, `README.md`, `CHANGELOG.md` | Controller/intent/verification/claim/receipt tests in `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, `tests/test_verification_profile.py`, `tests/test_patch_trail_*.py`, plus tool-schema snapshots when payloads change | edit authorization, scope/hygiene, verification profile, claim semantics, receipt or Patch Trail contract changes | workflow tool payloads, status transitions, permission signals, verification/receipt schemas change | -| Fingerprint-adjacent analysis (`codeclone/analysis/units.py`, `codeclone/analysis/_module_walk.py`, `codeclone/analysis/cfg.py`, `codeclone/analysis/normalizer.py`, `codeclone/findings/clones/grouping.py`) | `docs/book/03-core-pipeline.md`, `docs/book/04-cfg-semantics.md`, `docs/book/24-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_fingerprint.py`, `tests/test_extractor.py`, `tests/test_cfg.py`, golden tests (`tests/test_detector_golden.py`, `tests/test_golden_v2.py`) | always (see Section 1.6) | clone identity / NEW-vs-KNOWN / fingerprint inputs change | -| Suppression semantics/reporting (`codeclone/analysis/suppressions.py`, `codeclone/analysis/_module_walk.py` dead-code wiring, report/UI counters) | `docs/book/19-inline-suppressions.md`, `docs/book/17-dead-code-contract.md`, `docs/book/05-report.md`, and interface docs if surfaced (`09-cli`, `10-html-render`) | `tests/test_suppressions.py`, `tests/test_extractor.py`, `tests/test_metrics_modules.py`, `tests/test_pipeline_metrics.py`, report/html/cli tests | declaration scope semantics, rule effect, or contract-visible counters/fields change | suppression changes alter active finding output or contract-visible report payload | -| MCP interface (`codeclone/surfaces/mcp/*`, packaging extra/launcher) | `README.md`, `docs/book/25-mcp-interface/`, `docs/guide/mcp/`, `docs/book/02-architecture-map.md`, `docs/book/24-compatibility-and-versioning.md`, `CHANGELOG.md` | `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, `tests/fixtures/contract_snapshots/mcp_tool_schemas.json`, plus CLI/package tests if launcher/install semantics change | tool/resource shapes, workflow tool payloads, repository-read-only semantics, optional-dependency packaging behavior change | public MCP tool names, workflow tool payloads, resource URIs, launcher/install behavior, or response semantics change | -| Engineering Memory, semantic retrieval, trajectories, Experiences, projection jobs (`codeclone/memory/*`, `codeclone/config/memory*.py`) | `docs/book/13-engineering-memory/`, trajectory/Experience guides, `docs/book/25-mcp-interface/`, `docs/book/11-cli.md`, plugin skills, `CHANGELOG.md` | Applicable `tests/test_memory_*.py`, `tests/test_semantic_*.py`, projection/trajectory/Experience tests, MCP memory tests, and tool-schema snapshots when payloads change | schema/governance transitions, retrieval/fusion semantics, trajectory quality, Experience promotion, or worker lifecycle change | memory/semantic/projection versions, SQLite DDL, CLI/MCP payloads, ranking/filter/governance semantics change | -| Platform Observability (`codeclone/observability/*`, CLI trace, MCP bounded slicer, worker instrumentation) | `docs/book/26-platform-observability.md`, `docs/guide/observability/diagnostics.md`, config/MCP docs, `CHANGELOG.md` when user-visible | `tests/test_observability_*.py`, plus worker/memory/MCP tests for changed instrumentation boundaries | privacy/trust boundary, persisted schema, correlation, payload-size, SQL fingerprint, or public projection changes | `PLATFORM_OBSERVABILITY_SCHEMA_VERSION`, CLI/MCP section payloads, persistence or collection semantics change | -| Controller audit and insights (`codeclone/audit/*`, `codeclone/controller_insights/*`, CLI/MCP session/audit surfaces) | Controller, CLI/config, retention, MCP, and integration docs; `CHANGELOG.md` when public | `tests/test_audit_*.py`, `tests/test_controller_insights.py`, CLI/MCP projection tests | audit event core/schema, retention, token/payload footprint, or shared collector semantics change | audit schema/event core, `--audit`/`--session-stats`, IDE-only insight payloads change | -| VS Code extension surface (`extensions/vscode-codeclone/*`) | `README.md`, `docs/guide/integrations/vscode/setup.md`, `docs/book/integrations/vs-code-extension.md`, `docs/book/02-architecture-map.md`, `docs/index.md`, `CHANGELOG.md` | `node --check extensions/vscode-codeclone/src/support.js`, `node --check extensions/vscode-codeclone/src/mcpClient.js`, `node --check extensions/vscode-codeclone/src/extension.js`, `node --test extensions/vscode-codeclone/test/*.test.js`, plus local extension-host smoke and package smoke when surface/manifest/assets change | command/view UX, trust/runtime model, source-first review flow, or packaging metadata change | documented commands/views/setup/trust behavior, packaged assets, or publish metadata change | -| Claude Desktop bundle surface (`extensions/claude-desktop-codeclone/*`) | `docs/guide/integrations/claude-desktop/setup.md`, `docs/book/integrations/claude-desktop-bundle.md`, `docs/guide/mcp/`, `docs/book/02-architecture-map.md`, `docs/index.md`, `CHANGELOG.md` | `node --check extensions/claude-desktop-codeclone/server/index.js`, `node --check extensions/claude-desktop-codeclone/src/launcher.js`, `node --check extensions/claude-desktop-codeclone/scripts/build-mcpb.mjs`, `node --test extensions/claude-desktop-codeclone/test/*.test.js`, plus `.mcpb` build smoke | bundle install/runtime model, launcher UX, local-stdio constraints, or bundle metadata change | documented Claude Desktop install/setup/runtime behavior or packaged bundle semantics change | -| Claude Code plugin surface (`plugins/claude-code-codeclone/*`, `scripts/integration_dist/marketplace.claude-code.json`) | `docs/guide/integrations/claude-code/setup.md`, `docs/book/integrations/claude-code-plugin.md`, `docs/guide/mcp/`, `docs/book/02-architecture-map.md`, `docs/index.md`, `CHANGELOG.md` | `python3 -m json.tool plugins/claude-code-codeclone/.claude-plugin/plugin.json`, `python3 -m json.tool plugins/claude-code-codeclone/.mcp.json`, `python3 -m json.tool scripts/integration_dist/marketplace.claude-code.json`, `claude plugin validate plugins/claude-code-codeclone`, `tests/test_claude_code_plugin.py` | plugin discovery/runtime model, bundled MCP config, bundled skill behavior, launcher behavior, or marketplace metadata change | documented Claude Code install/discovery/runtime behavior or plugin manifest/marketplace semantics change | -| Codex plugin surface (`plugins/codeclone/*`, `.agents/plugins/marketplace.json`) | `docs/guide/integrations/codex/setup.md`, `docs/book/integrations/codex-plugin.md`, `docs/guide/mcp/`, `docs/book/02-architecture-map.md`, `docs/index.md`, `CHANGELOG.md` | `python3 -m json.tool plugins/codeclone/.codex-plugin/plugin.json`, `python3 -m json.tool plugins/codeclone/.mcp.json`, `python3 -m json.tool .agents/plugins/marketplace.json`, `tests/test_codex_plugin.py` | plugin discovery/runtime model, bundled MCP config, bundled skill behavior, or plugin metadata change | documented Codex plugin install/discovery/runtime behavior or plugin manifest/marketplace semantics change | -| Cursor plugin surface (`plugins/cursor-codeclone/*`) | `docs/guide/integrations/cursor/install-and-skills.md`, `docs/book/integrations/cursor-plugin.md`, `docs/guide/mcp/`, `docs/book/02-architecture-map.md`, `docs/index.md`, `CHANGELOG.md` | `tests/test_cursor_plugin.py`, `tests/test_cursor_plugin_hooks.py` | plugin discovery/runtime model, bundled MCP config, bundled skill/rule/hook behavior, or plugin metadata change | documented Cursor plugin install/discovery/runtime behavior or plugin manifest semantics change | -| GitHub Action surface (`.github/actions/codeclone/*`) | Action README, main README/getting-started/CI docs, `CHANGELOG.md` when user-visible | `tests/test_github_action_helpers.py`, shell/action smoke for changed workflow behavior | input interpolation, command construction, timeout, output, or exit behavior changes | public action inputs/outputs/runtime behavior changes | -| Storefront sync and distribution overlays (`scripts/sync_integrations.py`, `scripts/integration_dist/*`, launcher copy rules) | `docs/releasing.md`, affected integration docs/READMEs, `CHANGELOG.md` when publish behavior changes | `tests/test_sync_integrations.py`, then target-native package/test smoke after sync | deletion/copy boundary, target layout, launcher override, denylist, manifest provenance, or dirty-source policy changes | distribution layout, copied source set, `SYNC_MANIFEST.json`, storefront launcher/metadata semantics change | -| Docs site / sample report publication (`docs/`, `zensical.toml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py`) | `docs/index.md`, `docs/publishing.md`, `docs/examples/report.md`, and any contract pages surfaced by the change, `CHANGELOG.md` when user-visible behavior changes | `zensical build --clean --strict`, sample-report generation smoke path, and relevant report/html tests if generated examples or embeds change | published docs navigation, sample-report generation, or Pages workflow semantics change | published documentation behavior or sample-report generation contract changes | +| Baseline schema/trust/integrity (`codeclone/baseline/clone_baseline.py`, `codeclone/baseline/trust.py`) | `CHANGELOG.md`; published contract pages **TBD** | `tests/test_baseline.py`, CI/CLI behavior tests (`tests/test_cli_inprocess.py`, `tests/test_cli_unit.py`) | schema/trust semantics, compatibility windows, payload integrity logic change | baseline key layout/status semantics/compat rules change | +| Cache schema/profile/integrity (`codeclone/cache/store.py`, `codeclone/cache/versioning.py`, `codeclone/cache/integrity.py`) | `CHANGELOG.md`; published contract pages **TBD** | `tests/test_cache.py`, pipeline/CLI cache integration tests | cache schema/status/profile compatibility semantics change | cache payload/version/status semantics change | +| Canonical report JSON shape (`codeclone/report/document/*`, report projections) | `CHANGELOG.md`; published contract pages **TBD** | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py`, relevant report-format tests | finding/meta/summary schema changes | stable JSON fields/meaning/order guarantees change | +| CLI flags/help/exit behavior (`codeclone/main.py`, `codeclone/surfaces/cli/*`, `codeclone/config/*`, `codeclone/contracts/*`) | `README.md`, `CHANGELOG.md`; verify against `codeclone/config/spec.py` and `tests/fixtures/contract_snapshots/cli_help.txt`; published contract pages **TBD** | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py`, `tests/test_cli_smoke.py` | exit-code semantics, script-facing behavior, flag contracts change | user-visible CLI contract changes | +| Setup readiness CLI (`codeclone/surfaces/cli/setup/*`, `codeclone/config/pyproject_writer.py`, `codeclone/paths/gitignore.py`, `codeclone/ui_messages/setup.py`) | `README.md`, plugin `codeclone-setup` skill, `CHANGELOG.md`; published contract pages **TBD** | `tests/test_cli_setup.py`, `tests/test_pyproject_writer.py`, golden `tests/fixtures/contract_snapshots/setup_snapshot_v1.json` when wire schema changes | lazy-load boundary (I-07), snapshot/plan JSON shape, apply bounded-write semantics, wizard TTY contract, exit codes on apply | `SetupSnapshot` / `SetupPlan` / apply result payloads, probe matrix, or pyproject merge behavior change | +| Structural Change Controller (intent, blast radius, patch contract, hygiene, claims, receipts, Patch Trail) | `README.md`, MCP/plugin skills, `CHANGELOG.md`; published contract pages **TBD** | Controller/intent/verification/claim/receipt tests in `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, `tests/test_verification_profile.py`, `tests/test_patch_trail_*.py`, plus tool-schema snapshots when payloads change | edit authorization, scope/hygiene, verification profile, claim semantics, receipt or Patch Trail contract changes | workflow tool payloads, status transitions, permission signals, verification/receipt schemas change | +| Fingerprint-adjacent analysis (`codeclone/analysis/units.py`, `codeclone/analysis/_module_walk.py`, `codeclone/analysis/cfg.py`, `codeclone/analysis/normalizer.py`, `codeclone/findings/clones/grouping.py`) | `CHANGELOG.md`; published contract pages **TBD** | `tests/test_fingerprint.py`, `tests/test_extractor.py`, `tests/test_cfg.py`, golden tests (`tests/test_detector_golden.py`, `tests/test_golden_v2.py`) | always (see Section 1.6) | clone identity / NEW-vs-KNOWN / fingerprint inputs change | +| Suppression semantics/reporting (`codeclone/analysis/suppressions.py`, `codeclone/analysis/_module_walk.py` dead-code wiring, report/UI counters) | `CHANGELOG.md`; published contract pages **TBD** | `tests/test_suppressions.py`, `tests/test_extractor.py`, `tests/test_metrics_modules.py`, `tests/test_pipeline_metrics.py`, report/html/cli tests | declaration scope semantics, rule effect, or contract-visible counters/fields change | suppression changes alter active finding output or contract-visible report payload | +| MCP interface (`codeclone/surfaces/mcp/*`, packaging extra/launcher) | `README.md`, `CHANGELOG.md`; published contract pages **TBD** | `tests/test_mcp_service.py`, `tests/test_mcp_server.py`, `tests/fixtures/contract_snapshots/mcp_tool_schemas.json`, plus CLI/package tests if launcher/install semantics change | tool/resource shapes, workflow tool payloads, repository-read-only semantics, optional-dependency packaging behavior change | public MCP tool names, workflow tool payloads, resource URIs, launcher/install behavior, or response semantics change | +| Engineering Memory, semantic retrieval, trajectories, Experiences, projection jobs (`codeclone/memory/*`, `codeclone/config/memory*.py`) | plugin skills, `CHANGELOG.md`; published contract pages **TBD** | Applicable `tests/test_memory_*.py`, `tests/test_semantic_*.py`, projection/trajectory/Experience tests, MCP memory tests, and tool-schema snapshots when payloads change | schema/governance transitions, retrieval/fusion semantics, trajectory quality, Experience promotion, or worker lifecycle change | memory/semantic/projection versions, SQLite DDL, CLI/MCP payloads, ranking/filter/governance semantics change | +| Platform Observability (`codeclone/observability/*`, CLI trace, MCP bounded slicer, worker instrumentation) | `CHANGELOG.md` when user-visible; published contract pages **TBD** | `tests/test_observability_*.py`, plus worker/memory/MCP tests for changed instrumentation boundaries | privacy/trust boundary, persisted schema, correlation, payload-size, SQL fingerprint, or public projection changes | `PLATFORM_OBSERVABILITY_SCHEMA_VERSION`, CLI/MCP section payloads, persistence or collection semantics change | +| Controller audit and insights (`codeclone/audit/*`, `codeclone/controller_insights/*`, CLI/MCP session/audit surfaces) | `CHANGELOG.md` when public; published contract pages **TBD** | `tests/test_audit_*.py`, `tests/test_controller_insights.py`, CLI/MCP projection tests | audit event core/schema, retention, token/payload footprint, or shared collector semantics change | audit schema/event core, `--audit`/`--session-stats`, IDE-only insight payloads change | +| Corpus Analytics (`codeclone/analytics/*`, `codeclone/surfaces/cli/analytics.py`) | `CHANGELOG.md`; published contract pages **TBD** | `tests/test_analytics_*.py`, `tests/test_config_analytics.py` | store/export/representation contract semantics change | corpus schema/export/representation versions, CLI payloads, or clustering semantics change | +| VS Code extension surface (`extensions/vscode-codeclone/*`) | `README.md`, extension README, `CHANGELOG.md`; published docs **TBD** | `node --check extensions/vscode-codeclone/src/support.js`, `node --check extensions/vscode-codeclone/src/mcpClient.js`, `node --check extensions/vscode-codeclone/src/extension.js`, `node --test extensions/vscode-codeclone/test/*.test.js`, plus local extension-host smoke and package smoke when surface/manifest/assets change | command/view UX, trust/runtime model, source-first review flow, or packaging metadata change | documented commands/views/setup/trust behavior, packaged assets, or publish metadata change | +| Claude Desktop bundle surface (`extensions/claude-desktop-codeclone/*`) | extension README, `CHANGELOG.md`; published docs **TBD** | `node --check extensions/claude-desktop-codeclone/server/index.js`, `node --check extensions/claude-desktop-codeclone/src/launcher.js`, `node --check extensions/claude-desktop-codeclone/scripts/build-mcpb.mjs`, `node --test extensions/claude-desktop-codeclone/test/*.test.js`, plus `.mcpb` build smoke | bundle install/runtime model, launcher UX, local-stdio constraints, or bundle metadata change | documented Claude Desktop install/setup/runtime behavior or packaged bundle semantics change | +| Claude Code plugin surface (`plugins/claude-code-codeclone/*`, `scripts/integration_dist/marketplace.claude-code.json`) | plugin README, `CHANGELOG.md`; published docs **TBD** | `python3 -m json.tool plugins/claude-code-codeclone/.claude-plugin/plugin.json`, `python3 -m json.tool plugins/claude-code-codeclone/.mcp.json`, `python3 -m json.tool scripts/integration_dist/marketplace.claude-code.json`, `claude plugin validate plugins/claude-code-codeclone`, `tests/test_claude_code_plugin.py` | plugin discovery/runtime model, bundled MCP config, bundled skill behavior, launcher behavior, or marketplace metadata change | documented Claude Code install/discovery/runtime behavior or plugin manifest/marketplace semantics change | +| Codex plugin surface (`plugins/codeclone/*`, `.agents/plugins/marketplace.json`) | plugin README, `CHANGELOG.md`; published docs **TBD** | `python3 -m json.tool plugins/codeclone/.codex-plugin/plugin.json`, `python3 -m json.tool plugins/codeclone/.mcp.json`, `python3 -m json.tool .agents/plugins/marketplace.json`, `tests/test_codex_plugin.py` | plugin discovery/runtime model, bundled MCP config, bundled skill behavior, or plugin metadata change | documented Codex plugin install/discovery/runtime behavior or plugin manifest/marketplace semantics change | +| Cursor plugin surface (`plugins/cursor-codeclone/*`) | plugin README, `CHANGELOG.md`; published docs **TBD** | `tests/test_cursor_plugin.py`, `tests/test_cursor_plugin_hooks.py` | plugin discovery/runtime model, bundled MCP config, bundled skill/rule/hook behavior, or plugin metadata change | documented Cursor plugin install/discovery/runtime behavior or plugin manifest semantics change | +| GitHub Action surface (`.github/actions/codeclone/*`) | Action README, `README.md`, `CHANGELOG.md` when user-visible; published docs **TBD** | `tests/test_github_action_helpers.py`, shell/action smoke for changed workflow behavior | input interpolation, command construction, timeout, output, or exit behavior changes | public action inputs/outputs/runtime behavior changes | +| Storefront sync and distribution overlays (`scripts/sync_integrations.py`, `scripts/integration_dist/*`, launcher copy rules) | affected integration READMEs, `CHANGELOG.md` when publish behavior changes; published docs **TBD** | `tests/test_sync_integrations.py`, then target-native package/test smoke after sync | deletion/copy boundary, target layout, launcher override, denylist, manifest provenance, or dirty-source policy changes | distribution layout, copied source set, `SYNC_MANIFEST.json`, storefront launcher/metadata semantics change | +| Docs site / sample report publication (`docs/`, `zensical.toml`, `.github/workflows/docs.yml`, `scripts/build_docs_example_report.py`) | `docs/index.md`, `docs/examples/report.md`, and any pages touched; published routing **TBD**; `CHANGELOG.md` when user-visible behavior changes | `zensical build --clean --strict`, sample-report generation smoke path, and relevant report/html tests if generated examples or embeds change | published docs navigation, sample-report generation, or Pages workflow semantics change | published documentation behavior or sample-report generation contract changes | Golden rule: do not “fix” failures by snapshot refresh unless the underlying contract change is intentional, documented, and approved. @@ -880,6 +948,10 @@ Policy: - Structural Change Controller intent, permission, scope/hygiene, blast-radius, verification-profile, claim, receipt, and Patch Trail semantics. - CLI flags, defaults, exit codes, and stable script-facing messages. +- `codeclone setup` argv route: subcommands, `--json` stdout projections + (`setup_snapshot`, `setup_plan`, apply result), lazy-load isolation, bounded + apply scope (`pyproject.toml`, `.gitignore` only), and wizard TTY semantics. + Not MCP; no `edit_allowed`. - Baseline schema/trust semantics/integrity compatibility (`BASELINE_SCHEMA_VERSION` contract family). - Cache schema/status/profile compatibility/integrity (`CACHE_VERSION` contract family). - Canonical report JSON schema/payload semantics (`REPORT_SCHEMA_VERSION` contract family). @@ -901,6 +973,8 @@ Policy: storefront-sync install/runtime/package semantics. - Documented finding families/kinds/ids and suppression-facing report fields. - Metrics baseline schema/compatibility where used by CI/gating. +- Corpus Analytics store/export/representation contracts and `codeclone analytics` CLI behavior. +- Workspace intent registry wire schema and coordination semantics (`codeclone/workspace_intent/*`). - Benchmark schema/outputs if consumed as a reproducible contract surface. ### Internal implementation surfaces @@ -1011,6 +1085,8 @@ These rules exist because of real incidents in this repo. They are non-negotiabl ### Documentation hygiene +- During the docs-site migration, do not cite deleted `docs/book/**` or `docs/guide/**` + paths; mark published contract pages **TBD** and verify claims against code and tests. - Every doc claim about code (schema version, module path, function name, MCP tool count, exit code, CLI flag) must be verified against the **current** code before writing or editing. - Always read version constants from `codeclone/contracts/__init__.py` (see Section 4 table), never from @@ -1055,6 +1131,8 @@ These rules exist because of real incidents in this repo. They are non-negotiabl `pytest tests/test_mcp_service.py tests/test_mcp_server.py -x -q` is green. - A task that touches docs schema/version claims is not complete until you have grep'd the whole file for *all* version-shaped strings and verified each against `codeclone/contracts/__init__.py`. + During the docs migration, defer broken `docs/book/**` and `docs/guide/**` paths unless a + maintainer assigns a replacement page (**TBD**). --- diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a5f150..5fc823ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,22 +27,34 @@ evidence, platform self-observability, and broader IDE/agent integration. tracking, and reviewed-state persistence. - **Native agent and IDE integrations** for VS Code, Claude Desktop, Claude Code, Codex, and Cursor, including governance, audit, memory, trajectory, and structural-review workflows. +- **`codeclone setup`** — lazy-loaded CLI readiness surface (`status`, `doctor`, `plan`, `apply`, `wizard`) with + capability-aware snapshots, read-only diff preview, and bounded `pyproject.toml` / `.gitignore` merges (no MCP + intent, no baseline or report writes). - Expanded controller, memory, trajectory, analytics, semantic-search, observability, blast-radius, patch-verification, - and diagnostic CLI/MCP surfaces. + and diagnostic CLI/MCP surfaces. The default MCP server surface is now **38 tools** (**40** when VS Code enables the + IDE governance channel). - Reorganized documentation into a contract-focused 00–26 book with unified integration guidance and explicit edition tiers. - MCP schemas now include parameter descriptions, deterministic `next_tool` guidance, token-budget tracking, workspace hygiene warnings, and documentation-contract linting. +- MCP response governance now advertises `context_governance` metadata for bounded agent replies. Workflow, memory, and + implementation-context responses preserve mandatory control facts inline, compact recoverable evidence under + `partial_enforce`, disclose omitted lanes, and expose exact drill-down through durable receipt, Patch Trail, blast + artifact, memory continuation, and implementation-context page retrieval. ### Contract changes - Cache schema advanced to **2.9** for the rebuildable per-function relationship-fact projection and to **2.10** for intra-module, class-method, and receiver-aware call resolution. - Engineering Memory schema advanced to **1.7** for trajectory and Patch Trail evidence. +- Semantic index format advanced to **3** for LanceDB rows with `source_revision`; existing semantic sidecars should be + rebuilt. - Corpus Analytics store schema advanced to **1.2**. - Corpus Analytics JSON export schema advanced through **1.2** and **1.3**. - Corpus Analytics representation contract advanced to **3**. - Corpus Analytics control-plane contract introduced at **1.0**. +- MCP response governance contract introduced at **1.0** with deterministic `utf8_bytes_div_4_v1` context-unit + estimation and explicit `observe` / `partial_enforce` modes. - `derived.module_map` and `derived.review_queue` remain report-only projections excluded from the integrity digest; they add no analysis pass, metrics family, or report schema bump. - Live Implementation Context relationship facts remain off the canonical report and do not change canonical report diff --git a/CLAUDE.md b/CLAUDE.md index be0e8b72..f5edeba5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,156 +2,161 @@ ## Identity -CodeClone: deterministic structural controller for Python. -Full architecture, contracts, and agent playbook → `AGENTS.md`. -Code is the implementation source of truth. If docs and code diverge, -follow code for implementation decisions and report the divergence. +CodeClone is a deterministic structural controller for Python. Full architecture, contracts, and the agent playbook live +in `AGENTS.md`. + +Code is the implementation source of truth. If docs and code diverge, follow code for implementation decisions and +report the divergence. ## Default role -**Specs and validation only.** Do not edit production code unless the -user explicitly permits it for a specific task. "Реализуй" / "Implement" -is explicit permission. "Проверь" / "Validate" is not. +Default role: **specs and validation only**. + +Do not edit production code unless the user explicitly permits edits for a specific task. “Реализуй” / “Implement” is +explicit permission. “Проверь” / “Validate” is not. -When permitted to edit code, follow the change control workflow below. -Applies to any tracked file in the target repository. Task type (coverage, CI, -docs-only) does not skip `start`. Spec edits count too. When CodeClone MCP is -available, read the bundled **`codeclone-change-control`** skill for the full -pipeline (tool tiers, decision tables, profiles). +When permitted to edit any tracked repository file — specs, tests, documentation, CI, coverage fixes, or docs-only +changes included — follow the change-control workflow. Task type never skips `start`. When CodeClone MCP is available, +read the bundled `codeclone-change-control` skill for the full pipeline (tool tiers, decision tables, profiles). -## Change control workflow +## Change-control workflow -The protocol below is mandatory, but the visible workflow depends on the -patch type: +The protocol is mandatory. The derived verification profile controls which steps are required, but `start` is always +required before edits. -- **Python structural / governance config**: full before/after workflow. -- **Documentation-only**: lightweight verify; no after-run required when the - controller derives `documentation_only`. -- **Blocked follow-up**: queue intent behind foreign active; promote before - editing. -- **Read-only / spec validation**: no edit workflow unless repository files - change. +Profiles: -Do not skip, replace, reorder, or approximate the required steps for the -derived workflow profile. Steps explicitly marked as optional or -profile-dependent may be skipped only under the stated conditions. -If a required MCP call fails or is unavailable, stop and report the blocker -instead of continuing as a normal edit. +| Profile | Workflow | +|---------------------------------------|------------------------------------------------------------------------------------------------------------------------| +| Python structural / governance config | `analyze` → `start` → `get_relevant_memory` → edit → `analyze` → memory write if required → `finish(after_run_id=...)` | +| Documentation-only / non-Python | `analyze` → `start` → `get_relevant_memory` → edit → memory write if required → `finish(changed_files=[...])` | +| Blocked follow-up | queue behind foreign active intent, then promote before editing | +| Read-only / spec validation | no edit workflow unless repository files change | -Before editing any repository files: +Do not skip, replace, reorder, or approximate required steps. Optional/profile-dependent steps may be skipped only under +their stated conditions. If a required MCP call fails or is unavailable, stop and report the blocker instead of +continuing as a normal edit. + +### Before editing 1. `analyze_repository(root="")` - — if a valid recent run for the same absolute root already exists, skip + - Skip only when a valid recent run for the same absolute root already exists. 2. `start_controlled_change(root="", scope={...}, intent="...")` - — returns blast radius, budget, workspace state, intent_id - — if `status: "needs_analysis"`, run `analyze_repository` first - — if `status: "queued"`, do not edit; wait for promotion - — if `concurrent_intents` non-empty without queue, narrow scope or ask - — if start blocks only because declared scope is already dirty and you are - resuming known WIP with no foreign overlap, retry with - `dirty_scope_policy="continue_own_wip"`; finish must still prove scope -3. `get_relevant_memory(root=..., scope=... or intent_id=...)` — after - `edit_allowed=true` (see **Memory-aware workflow**) -4. Edit within declared scope only -5. `analyze_repository(root="")` - — after-run; required for Python structural and governance config changes. - May be skipped for documentation-only and other non-Python patches when - `finish` can verify from changed-file evidence -6. **Engineering Memory write (MCP) when required** — before step 7, not in chat - (see **Before `finish`: incident / complexity memory**) -7. `finish_controlled_change(intent_id=..., changed_files=[...], after_run_id=...)` - — returns scope check, verification, receipt, and clears intent - — finish **reconciles evidence with the start-time dirty snapshot and the - full git tree**: under-reported in-scope dirty → - `finish_block_reason: missing_evidence`; live foreign in-scope overlap → - `foreign_dirty_overlap`; unattributed out-of-scope dirt blocks finish only - when `CODECLONE_STRICT_FINISH` is truthy (`finish_block_reason: - own_unscoped_dirty`). Otherwise new/modified/unknown unattributed out-of-scope - dirt and unchanged preexisting unscoped dirty are **advisory** — finish may - return `accepted_with_external_changes`. Foreign active/stale dirty outside - your scope → `foreign_attributed_outside_scope` (ignored). **Recoverable** - (dead PID) intents do not grant foreign attribution - — if `status: "unverified"`, the intent stays active; follow `next_step` - (e.g. run `analyze_repository` with a **new** run_id — identical before/after - runs return `after_run_not_new` for Python structural patches), then call - `finish` again on the **same `intent_id`** with the missing evidence - — if `status: "violated"` (scope), the intent stays active; either - remove out-of-scope changes and retry `finish`, or expand scope via - `start_controlled_change` with a wider scope - — if `user_action_required: true`, stop and escalate to the user - — `auto_clear=true` by default; intent cleared only on accepted - -Workflow profiles determine which steps are needed: - -- **Python structural / governance config**: - `analyze` → `start` → `get_relevant_memory` → edit → `analyze` → - `record_candidate` (if required) → `finish(after_run_id=...)` -- **Documentation-only / non-Python**: - `analyze` → `start` → `get_relevant_memory` → edit → - `record_candidate` (if required) → `finish(changed_files=[...])` - For `non_python_patch`, report controller-stated limitations and do not - present the result as full structural verification. - -### Memory-aware workflow - -Engineering Memory is a local SQLite store of evidence-linked repository facts. -Full playbook: `docs/book/13-engineering-memory/index.md`. MCP help: -`help(topic="engineering_memory")`. - -**Chat is not memory.** Text in this conversation is ephemeral (context shrink, -new session, new MCP process). Anything the next agent must remember belongs in -Engineering Memory via MCP — never “I noted it in the summary” alone. - -**Bootstrap:** default `mcp_sync_policy=bootstrap_if_missing` auto-creates the -store from the latest MCP run on `get_relevant_memory`. Explicit refresh: -`manage_engineering_memory(action="refresh_from_run")`. CLI `memory init` remains -for CI/offline. Human approve still required for agent drafts (VS Code Memory -view — not MCP, not `codeclone memory approve`). + - Returns blast radius, budget, workspace state, and `intent_id`. + - If `status: "needs_analysis"`, run `analyze_repository` first. + - If `status: "queued"`, do not edit; wait for promotion. + - If `concurrent_intents` is non-empty without queue, narrow scope or ask. + - If start blocks only because declared scope is already dirty and this is resumed known WIP with no foreign + overlap, retry with `dirty_scope_policy="continue_own_wip"`; finish must still prove scope. +3. Edit only after `status == "active"` and `edit_allowed == true`. +4. `get_relevant_memory(root="", scope=... or intent_id=...)` + - `root` is required; `intent_id` alone fails MCP validation. +5. Edit within declared scope only. + +### After editing + +1. Run `analyze_repository(root="")` after edits for Python structural and governance config changes. + - Required after-run must have a new `run_id`; identical before/after runs fail Python structural verification with + `after_run_not_new`. + - May be skipped for documentation-only and other non-Python patches when `finish` can verify from changed-file + evidence. +2. Before finish, perform mandatory incident/complexity/decision memory check. +3. Call `finish_controlled_change(intent_id=..., changed_files=[...], after_run_id=...)`. + - Returns scope check, verification, receipt, and clears accepted intents. + - `auto_clear=true` by default; intent clears only on accepted outcomes. + - If `status: "unverified"`, the intent stays active. Follow `next_step`, then call `finish` again on the same + `intent_id` with missing evidence. + - If `status: "violated"`, the intent stays active. Remove out-of-scope changes and retry `finish`, or expand scope + via `start_controlled_change`. + - If `user_action_required: true`, stop and escalate. + +### Finish evidence reconciliation + +`finish` reconciles evidence with the start-time dirty snapshot and the full git tree. + +Blocking reasons: + +- under-reported in-scope dirty → `finish_block_reason: missing_evidence` +- live foreign in-scope overlap → `foreign_dirty_overlap` +- unattributed out-of-scope dirt blocks only when `CODECLONE_STRICT_FINISH` is truthy → `own_unscoped_dirty` + +Advisory/non-blocking cases: + +- new/modified/unknown unattributed out-of-scope dirt and unchanged preexisting unscoped dirty are advisory unless + strict finish applies; finish may return `accepted_with_external_changes` +- foreign active/stale dirty outside scope → `foreign_attributed_outside_scope` and is ignored +- recoverable/dead-PID intents do not grant foreign attribution + +If `reason=workspace_hygiene`, read `finish_block_reason`; do not bypass with atomic verify. Advisory hygiene fields +such as `new_unattributed_unscoped_dirty` may appear in `external_changes` or `accepted_with_external_changes` but are +not block reasons. + +## Engineering Memory + +Engineering Memory is a local SQLite store of evidence-linked repository facts. MCP help: +`help(topic="engineering_memory")`. Published contract pages are TBD during docs-site migration; verify behavior against +code and tests. + +**Chat is not memory.** Conversation text is ephemeral across context shrink, new sessions, and new MCP processes. +Durable facts the next agent must know belong in Engineering Memory via MCP, not only in chat or compact summaries. + +### Bootstrap + +Default `mcp_sync_policy=bootstrap_if_missing` auto-creates the store from the latest MCP run on `get_relevant_memory`. + +Explicit refresh: `manage_engineering_memory(action="refresh_from_run")`. + +CLI `memory init` remains for CI/offline. Human approval is still required for agent drafts through the VS Code Memory +view, not MCP and not `codeclone memory approve`. + +### Read memory after start After `start_controlled_change` returns `edit_allowed: true`: 1. Call `get_relevant_memory(root="", scope=... or intent_id=...)`. - **`root` is required** — `intent_id` alone fails MCP validation. -2. Read contract warnings, stale decisions, and `contradiction_note` alerts -3. Use `query_engineering_memory(mode=for_path)` or `mode=search` for drill-down -4. Do NOT ignore stale memory warnings — they indicate changed context -5. Do NOT treat `draft`, `inferred`, or excluded stale records as established facts -6. If memory contains a `contradiction_note` for your scope, surface it to - the user before editing - -**Scope and token hygiene:** never use project root as memory scope. Compress -`record_candidate` statements to one durable fact (target ≤300 chars; -`validate_claims` warns above 500; hard limit 1000). List responses default to -compact previews. Treat `records[]`, `experiences[]`, and `trajectories[]` as -separate evidence lanes: records = asserted knowledge, trajectories = episodic -workflow evidence, experiences = advisory patterns, `coverage` = visibility -metadata. **Scores are lane-local — never compare `relevance_score` across -lanes; `for_path` and plain (non-semantic) search are unranked.** -`subject_count` / `subjects_truncated` means more subjects exist, not that -evidence disappeared. Use `mode=get` or `detail_level=full` for complete -subjects, agent facets, trajectory contracts, steps, evidence ids, and -payloads; `patch_trail_summary` rides each trajectory (never duplicated at the -payload root). - -### Before `finish`: incident / complexity memory (MANDATORY) - -**Do not call `finish_controlled_change` until this check passes.** - -If the edit cycle involved **any** of the following, you **must** write at least -one durable note through MCP **before** step 7 (`finish`): - -| Trigger | Examples | -|----------------|--------------------------------------------------------------------------------------------------------------| -| **Incident** | verify/hygiene surprise, `unverified`/`violated` recovery, workaround, blocked step, foreign intent friction | -| **Complexity** | non-obvious root cause, multi-file debug, near `do_not_touch`, acted on stale/contradiction memory | -| **Decision** | tradeoff, integration quirk, “next agent must not repeat X” | - -**Skip** only trivial edits (typo, one obvious line, nothing to relearn). - -**How to write (MCP only):** +2. Read contract warnings, stale decisions, and `contradiction_note` alerts. +3. Use `query_engineering_memory(mode=for_path)` or `mode=search` for drill-down. +4. Do not ignore stale memory warnings. +5. Do not treat `draft`, `inferred`, or excluded stale records as established facts. +6. If memory contains a `contradiction_note` for scope, surface it to the user before editing. + +### Memory scope and token hygiene + +- Never use project root as memory scope. +- Compress `record_candidate` statements to one durable fact: target ≤300 chars; `validate_claims` warns above 500; hard + limit 1000. +- List responses default to compact previews. +- Treat `records[]`, `experiences[]`, and `trajectories[]` as separate evidence lanes: + - `records` = asserted knowledge + - `trajectories` = episodic workflow evidence + - `experiences` = advisory patterns + - `coverage` = visibility metadata +- Scores are lane-local. Never compare `relevance_score` across lanes. +- `for_path` and plain non-semantic search are unranked. +- `subject_count` / `subjects_truncated` means more subjects exist, not that evidence disappeared. +- Use `mode=get` or `detail_level=full` for complete subjects, agent facets, trajectory contracts, steps, evidence ids, + and payloads. +- `patch_trail_summary` rides each trajectory and is never duplicated at the payload root. + +### Mandatory memory write before finish + +Do not call `finish_controlled_change` after a non-trivial edit cycle until this check passes. + +Write at least one durable MCP note before finish if any trigger fired: + +| Trigger | Examples | +|------------|--------------------------------------------------------------------------------------------------------------| +| Incident | verify/hygiene surprise, `unverified`/`violated` recovery, workaround, blocked step, foreign intent friction | +| Complexity | non-obvious root cause, multi-file debug, near `do_not_touch`, acted on stale/contradiction memory | +| Decision | tradeoff, integration quirk, “next agent must not repeat X” | + +Skip only trivial edits: typo, one obvious line, nothing to relearn. + +Write through MCP only: ```text manage_engineering_memory( + root="", action=record_candidate, record_type=risk_note | change_rationale, statement="", @@ -159,218 +164,174 @@ manage_engineering_memory( ) ``` -Or batch several notes with `finish_controlled_change(..., propose_memory=true)`. -Optional: `manage_engineering_memory(action=validate_claims, text=...)` on -`claims_text` before finish. +Other allowed memory writes: -| Other writes | Tool | -|----------------------------------|----------------------------------------------------------| -| During edit (stable observation) | `record_candidate` (same as above) | -| After accepted patch | `finish(..., propose_memory=true)` → `memory_candidates` | +| Timing | Tool | +|---------------------------------|--------------------------------------------------------------------------------| +| During edit, stable observation | `record_candidate` | +| Before finish, batch notes | `finish_controlled_change(..., propose_memory=true)` | +| Optional pre-finish claim check | `manage_engineering_memory(action=validate_claims, text=...)` on `claims_text` | +| After accepted patch | `finish(..., propose_memory=true)` → `memory_candidates` | -Agents **cannot** call `approve` / `reject` / `archive` via MCP. Ask the user to -use the CodeClone VS Code **Memory** view to promote drafts. +Agents cannot call `approve`, `reject`, or `archive` via MCP. Ask the user to promote drafts in the CodeClone VS Code +Memory view. Memory cannot authorize edits, expand scope, or override findings. -Queue/promote workflow (when `start` returns `status: "queued"`): +## Queue / promote workflow + +When `start` returns `status: "queued"`: + +1. `start_controlled_change(on_conflict="queue")` → `status: "queued"`. +2. Wait for the foreign intent to clear. +3. `manage_change_intent(action="promote", intent_id=...)`. +4. Edit only after promote returns `status: "active"`. +5. If `before_run_evicted`, re-analyze and re-start. -1. `start_controlled_change(on_conflict="queue")` → `status: "queued"` -2. Wait for foreign intent to clear -3. `manage_change_intent(action="promote", intent_id=...)` - — edit only after promote returns `status: "active"` - — if `before_run_evicted`: re-analyze and re-start +Live foreign intent means stop, not kill. Never suggest killing a process without explicit user confirmation that the +PID is abandoned. -### Atomic workflow (fallback) +## Atomic workflow fallback -When `start_controlled_change` / `finish_controlled_change` are unavailable, -use the atomic path in the change-control skill. Do not mix primary and atomic -verification in one cycle. +When `start_controlled_change` / `finish_controlled_change` are unavailable, use the atomic path in the change-control +skill. -### Rules +Rules: -- Prefer `start_controlled_change` / `finish_controlled_change` over - the atomic workflow. Use atomic tools only for queue/promote/recover - or when the workflow tools are unavailable. -- Do not mix workflow and atomic verification paths in the same edit - cycle. Queue/promote/recover operations via `manage_change_intent` - are allowed alongside workflow tools because workflow tools do not +- Prefer `start_controlled_change` / `finish_controlled_change`. +- Use atomic tools only for queue/promote/recover or when workflow tools are unavailable. +- Do not mix workflow and atomic verification paths in one edit cycle. +- Queue/promote/recover via `manage_change_intent` may be used alongside workflow tools because workflow tools do not expose those administrative transitions. -- `start_controlled_change` does not run analysis. Ensure a valid run - exists before calling it. -- `finish_controlled_change` does not run analysis. For Python - structural and governance config changes, run `analyze_repository` - after editing and pass `after_run_id`. -- MUST NOT edit files without declaring intent first — including `tests/**/*.py`. -- MUST NOT silently expand scope. If the fix requires files outside the - declared scope, stop before editing them. Expand scope only after user - approval unless the user already explicitly allowed expansion. Call - `start_controlled_change` again with the expanded scope to get a fresh - intent with updated blast radius and budget. Continue only when the - expanded intent is active. Do not edit extra files based on blast-radius - context alone. -- MUST NOT edit while intent is `queued`. Promote first. + +## Required rules + +- `start_controlled_change` does not run analysis; ensure a valid run exists before calling it. +- `finish_controlled_change` does not run analysis; for Python structural and governance config changes, run + `analyze_repository` after editing and pass `after_run_id`. +- MUST NOT edit files without declaring intent first, including `tests/**/*.py`. +- MUST NOT edit unless `start_controlled_change` returned `status == "active"` and `edit_allowed == true`. +- MUST NOT edit while intent is `queued`; promote first. +- MUST NOT silently expand scope. If files outside declared scope are needed, stop before editing them. Expand only + after user approval unless the user already explicitly allowed expansion. Call `start_controlled_change` again with + expanded scope and continue only when the expanded intent is active. +- Do not edit extra files based on blast-radius context alone. - `do_not_touch` is a hard boundary. `review_context` is context, not a ban. - Do not update baselines, analysis cache, or generated reports. -- When `finish` or verify returns a `next_step` hint, follow it — do not - invent a different recovery path. -- CodeClone findings are the source of truth — do not reinterpret. -- If `finish_controlled_change` returns `status: "unverified"` or - `"violated"`, do not claim the patch is verified. -- Leaving an active or recoverable own intent behind is a blocked cleanup, not - a completed task. -- Live foreign intent means **stop**, not kill. Never suggest killing - a process without explicit user confirmation that the PID is abandoned. -- **MUST NOT** call `finish_controlled_change` after a non-trivial cycle without - `record_candidate` (or `propose_memory=true`) when incident/complexity/decision - triggers applied — chat summaries do not count. -- **MUST NOT** treat assistant chat text as Engineering Memory. - -### User escalation policy - -Run routine controller steps automatically. Queue blocked follow-up work -automatically when it can wait — do not ask before queueing. +- When `finish` or verify returns `next_step`, follow it. +- CodeClone findings are the source of truth; do not reinterpret. +- If `finish_controlled_change` returns `status: "unverified"` or `"violated"`, do not claim verification. +- Leaving an active or recoverable own intent behind is blocked cleanup, not completion. +- MUST NOT call `finish_controlled_change` after a non-trivial cycle without `record_candidate` or `propose_memory=true` + when incident/complexity/decision triggers applied. +- MUST NOT treat assistant chat text as Engineering Memory. + +## User escalation policy + +Run routine controller steps automatically. Queue blocked follow-up work automatically when it can wait; do not ask +before queueing. Ask the user only when: -- scope expansion is required and was not already explicitly allowed by - the user; -- a `do_not_touch` path must be touched; -- a live foreign intent overlaps and queue is not appropriate; -- patch contract returned `violated`, or returned `unverified` and the - agent cannot execute the deterministic `next_step`; -- baseline, analysis cache, canonical reports, or generated state would - be modified; -- recovery or reset of another agent's intent is needed. +- scope expansion is required and was not already explicitly allowed +- a `do_not_touch` path must be touched +- live foreign intent overlaps and queue is not appropriate +- patch contract returned `violated` +- patch contract returned `unverified` and the deterministic `next_step` cannot be executed +- baseline, analysis cache, canonical reports, or generated state would be modified +- recovery or reset of another agent’s intent is needed Routine controller work is automatic. Boundary decisions require the user. -**Edit permission (MCP workflow):** do not edit unless -`start_controlled_change` returned `status == "active"` **and** -`edit_allowed == true`. Workflow `status: "blocked"` is not persisted -registry lifecycle — clear abandoned blocked intents via -`manage_change_intent(action="clear")`. Finish `reason=workspace_hygiene` means -evidence/scope/git/start snapshot disagree — read `finish_block_reason` -(`missing_evidence`, `foreign_dirty_overlap`, or `own_unscoped_dirty` only -under `CODECLONE_STRICT_FINISH`). Advisory hygiene fields such as -`new_unattributed_unscoped_dirty` are not block reasons — they may appear in -`external_changes` or `accepted_with_external_changes`. Widen scope, fix -evidence, reconcile the tree, or coordinate foreign in-scope overlap. Do not -bypass with atomic verify. - -### Completion gate - -Do not say "done", "implemented", "validated", "verified", "ready", or -equivalent unless all of these are true: - -1. `finish_controlled_change` returned `status: "accepted"` (or - `"accepted_with_external_changes"`); OR, in the atomic fallback - workflow, `manage_change_intent(action="check")` returned `clean` or - `expanded`, `check_patch_contract(mode="verify")` returned `accepted`, - and `manage_change_intent(action="clear")` succeeded; -2. `scope_check.status` is `"clean"` or `"expanded"`; -3. `intent_cleared` is `true` in the finish response; OR - `manage_change_intent(action="clear")` succeeded; -4. if `claims` is present in the finish response and `claims.valid` is - `false`, report the warnings — do not suppress; -5. claim validation was handled by `finish_controlled_change` when - `review_text` was provided and `claim_validation_recommended` was - `true`; for atomic workflow, final summary claims passed - `validate_review_claims` with `patch_health_delta` from verify unless - `claim_validation_recommended` was explicitly `false`. - -If status is `accepted_with_external_changes`, report the external-change -advisory instead of presenting the patch as fully clean. - -If any item cannot be completed, report `BLOCKED` or `UNVERIFIED`, include the -`intent_id`, and state the exact missing step. Do not present the work as -finished. - -### Verification profiles - -The controller derives a **verification profile** from actual changed files. -The profile determines which structural checks apply. The agent does not choose -the profile — it is computed by `finish_controlled_change` (through -`check_patch_contract(mode="verify")` internally), or directly by -`check_patch_contract(mode="verify")` in the atomic workflow. - -| Profile | When | `after_run` required | Structural checks | -|-------------------------|-------------------------------------------------------------------------------------------------------|----------------------|-------------------| -| `python_structural` | any `.py` / `.pyi` touched | yes | all | -| `governance_config` | config files only (pyproject.toml, CI, Dockerfile…) | yes | not applicable | -| `documentation_only` | only docs files (`.md`, `.rst`, LICENSE…) | no | not applicable | -| `non_python_patch` | other files, no Python / docs | no | not applicable | -| `state_artifact_change` | CodeClone state artifacts touched (`codeclone.baseline.json`, `.codeclone/**`, `.cache/codeclone/**`) | no (violated) | not applicable | - -Key rules: - -- **`start` is always required** before edit; lighter profiles only affect - after-run / verify, not intent declaration. -- If **any** Python source, governance configuration, baseline, cache, or - generated state files were touched, the lightweight path is not accepted. -- Documentation-only patches can verify without `after_run_id` - when `changed_files` or `diff_ref` evidence is provided. -- Other non-Python patches may verify without `after_run_id`, but only - with controller-reported limitations. Do not present this as full - structural verification. -- The agent MUST NOT claim which profile applies — CodeClone decides. -- Receipts use "not applicable" for skipped structural checks, never "passed". +Workflow `status: "blocked"` is not persisted registry lifecycle; clear abandoned blocked intents via +`manage_change_intent(action="clear")`. + +## Completion gate + +Do not say “done”, “implemented”, “validated”, “verified”, “ready”, or equivalents unless all conditions hold: + +1. `finish_controlled_change` returned `status: "accepted"` or `"accepted_with_external_changes"`; or, in atomic + fallback, `manage_change_intent(action="check")` returned `clean` or `expanded`, + `check_patch_contract(mode="verify")` returned `accepted`, and `manage_change_intent(action="clear")` succeeded. +2. `scope_check.status` is `"clean"` or `"expanded"`. +3. `intent_cleared` is `true`; or atomic `manage_change_intent(action="clear")` succeeded. +4. If `claims` is present and `claims.valid` is `false`, report the warnings. +5. Claim validation was handled by `finish_controlled_change` when `review_text` was provided and + `claim_validation_recommended` was `true`; for atomic workflow, final summary claims passed `validate_review_claims` + with `patch_health_delta` from verify unless `claim_validation_recommended` was explicitly `false`. + +If status is `accepted_with_external_changes`, report the external-change advisory instead of presenting the patch as +fully clean. + +If any item cannot be completed, report `BLOCKED` or `UNVERIFIED`, include `intent_id`, and state the exact missing +step. + +## Verification profiles + +The controller derives verification profile from actual changed files. The agent does not choose or claim the profile. +`finish_controlled_change` computes it through `check_patch_contract(mode="verify")`; atomic workflow calls +`check_patch_contract(mode="verify")` directly. + +| Profile | When | `after_run` required | Structural checks | +|-------------------------|----------------------------------------------------------------------------------------------|---------------------:|-------------------| +| `python_structural` | any `.py` / `.pyi` touched | yes | all | +| `governance_config` | config files only: `pyproject.toml`, CI, Dockerfile, etc. | yes | not applicable | +| `documentation_only` | only docs files: `.md`, `.rst`, `LICENSE`, etc. | no | not applicable | +| `non_python_patch` | other files, no Python/docs | no | not applicable | +| `state_artifact_change` | CodeClone state artifacts: `codeclone.baseline.json`, `.codeclone/**`, `.cache/codeclone/**` | no; violated | not applicable | + +Rules: + +- `start` is always required before edits; lighter profiles affect after-run/verify only. +- If any Python source, governance configuration, baseline, cache, or generated state file was touched, lightweight path + is not accepted. +- Documentation-only patches can verify without `after_run_id` when `changed_files` or `diff_ref` evidence is provided. +- Other non-Python patches may verify without `after_run_id`, but only with controller-reported limitations; do not + present as full structural verification. +- Receipts use “not applicable” for skipped structural checks, never “passed”. - Claim Guard may reject or warn on claims that exceed the derived profile. - For documentation-only patches, "no Python files touched" is allowed; - "no structural regressions" requires structural evidence from an after-run. -- `novelty="known"` is baseline-relative, not patch-relative. Do not infer that - a patch did not introduce/reintroduce a finding from baseline novelty alone; - patch-local regression claims require clean before-run to after-run evidence. +- For documentation-only patches, “no Python files touched” is allowed; “no structural regressions” requires structural + evidence from an after-run. +- `novelty="known"` is baseline-relative, not patch-relative. Do not infer that a patch did not introduce/reintroduce a + finding from baseline novelty alone; patch-local regression claims require clean before-run to after-run evidence. -### When to skip +## When to skip edit workflow -- Read-only tasks (analysis, validation, research) -- CodeClone MCP not available and the task is read-only. For repository - edits that require change control, stop and report the blocker +- Read-only tasks: analysis, validation, research - User explicitly says analysis-only +- CodeClone MCP is unavailable and the task is read-only +- For repository edits that require change control when CodeClone MCP is unavailable, stop and report the blocker ## Spec writing discipline -Specs are disposable implementation briefs, not documentation. -They are deleted after implementation and validation. +Specs are disposable implementation briefs, not documentation. They are deleted after implementation and validation. -### Invariants +Invariants: -- **One model per decision.** If the spec describes alternative - approaches, choose one and close the others. Never leave two - incompatible paths in the same section. -- **Verify against code.** Every function signature, data model, and - behavior claim in the spec must be verified against current code - before writing. Read the source, do not assume. -- **No aspirational APIs.** If a function doesn't exist yet, say so. - Do not describe it as if it does. -- **Decision table for state machines.** If the spec introduces states - or classifications, provide an exhaustive decision table. Every - input combination must map to exactly one output. -- **Dependency direction explicit.** List what each new file imports - and what imports it. Verify against the architecture rules in - `AGENTS.md` §14. +- **One model per decision.** Choose one approach and close alternatives; never leave incompatible paths in one section. +- **Verify against code.** Verify every function signature, data model, and behavior claim against current source before + writing. +- **No aspirational APIs.** If a function does not exist yet, say so; do not describe it as existing. +- **Decision table for state machines.** Exhaustively map every input combination to exactly one output. +- **Dependency direction explicit.** List imports for each new file and what imports it; verify against `AGENTS.md` §14. -### Self-check before delivery +Self-check before delivery: -Before presenting a spec, verify: - -1. Are there two conflicting approaches in the same spec? → pick one. -2. Does every code snippet match the actual codebase API? → read source. -3. Is every state transition deterministic? → write the decision table. -4. Can the implementer follow this without interpreting ambiguity? → if - unclear, it's wrong. +1. Conflicting approaches? Pick one. +2. Code snippets match actual APIs? Read source. +3. State transitions deterministic? Write decision table. +4. Implementer can follow without ambiguity? If unclear, it is wrong. ## Validation discipline -When validating an implementation against a spec: +When validating implementation against a spec: -1. Read all implementation files (not just grep). +1. Read all implementation files, not just grep results. 2. Cross-reference every spec requirement against code. -3. Run the relevant tests: `uv run pytest -q `. +3. Run relevant tests: `uv run pytest -q `. 4. Run `uv run pre-commit run --all-files` if the user asks to commit. 5. Check MCP tool visibility if a new tool was added. -6. Report: conformant / improved / divergent / missing — with evidence. +6. Report `conformant` / `improved` / `divergent` / `missing` with evidence. ## Verification commands @@ -389,28 +350,66 @@ See `AGENTS.md` §3 for surface-specific commands. ## Hard boundaries -- Never update golden snapshots merely to "fix" tests. Snapshot updates - require explicit user approval and a contract/schema change rationale. -- Never change fingerprint semantics without `FINGERPRINT_VERSION` review. +- Never update golden snapshots merely to “fix” tests; snapshot updates require explicit user approval and a + contract/schema change rationale. +- Never change fingerprint semantics without `BASELINE_FINGERPRINT_VERSION` review. - Never make base `codeclone` depend on MCP runtime packages. -- Never let MCP mutate baselines, source files, canonical reports, or - analysis cache. Ephemeral coordination state (workspace intents) and - audit trail under `.codeclone/` are allowed only through the +- Never let MCP mutate baselines, source files, canonical reports, or analysis cache. +- Ephemeral coordination state (workspace intents) and audit trail under `.codeclone/` are allowed only through controller and audit contracts. - Never iterate sets/dicts without sorting when output order matters. -- Never introduce `Any` in core/domain code without narrowing it immediately. -- Never create `*.md` specs inside `docs/` — use `specs/` directory. -- Version constants live in `codeclone/contracts/__init__.py` — always - read from there, never copy from another doc. +- Never introduce `Any` in core/domain code without immediately narrowing it. +- Never create `*.md` specs inside `docs/`; use `specs/`. +- Version constants live in `codeclone/contracts/__init__.py`; always read from there, never copy from another doc. + +## Compact instructions + +When compacting, preserve only task-critical state. + +### Universal + +- current task goal and explicit non-goals +- modified files and why each was modified +- passing/failing test commands and exact remaining failures +- user corrections and rejected approaches, including rejection reasons +- unresolved blockers and next safe action + +### Change-control state: highest priority + +- active intent: `intent_id`, lifecycle status (`active` / `queued` / `unverified` / `violated` / `recoverable`), + declared scope, `edit_allowed`, before/after `run_id`s +- expected verification profile (`python_structural` / `governance_config` / `documentation_only` / `non_python_patch`) + and whether `after_run_id` is still required +- finish outcome: `accepted` / `accepted_with_external_changes` / `BLOCKED` / `UNVERIFIED`; if not accepted, exact + missing step +- foreign intents or queue position currently blocking work +- pending MCP memory writes: incident/complexity/decision triggers that fired but have not yet been recorded with + `record_candidate` or `propose_memory=true` + +### Contracts + +Preserve whether the current task touches a versioned contract, especially `codeclone/contracts/__init__.py` or +`BASELINE_FINGERPRINT_VERSION`, and that it must not change without review. Do not preserve the full contract list; it +lives in code. + +### Do not preserve + +- full logs when failure summaries are enough +- repeated explanations +- obsolete failed attempts unless they explain a current constraint + +Compact summaries are still ephemeral. Durable facts, including rejected approaches the next agent must not repeat, must +be written to Engineering Memory through MCP. ## Commit style -``` +```text feat(scope): short imperative description Optional body with context. ``` -Scopes: `mcp`, `cli`, `core`, `baseline`, `cache`, `report`, `html`, -`metrics`, `docs`, `vscode`, `codex`, `claude-desktop`. +Scopes: `mcp`, `cli`, `core`, `baseline`, `cache`, `report`, `html`, `metrics`, `memory`, `observability`, `analytics`, +`docs`, `vscode`, `codex`, `claude-desktop`, `claude-code`, `cursor`. + Prefixes: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7b26eb6c..e8a6ce6b 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -206,10 +206,10 @@ At the time this document was updated, the main contracts were: | Baseline schema | `2.1` | `codeclone/baseline/` | | Baseline fingerprint | `1` | `codeclone/contracts/__init__.py` | | Analysis cache | `2.10` | `codeclone/cache/` | -| Canonical report | `2.11` | `codeclone/report/document/` | +| Canonical report | `2.12` | `codeclone/report/document/` | | Metrics baseline | `1.2` | `codeclone/baseline/` | | Engineering Memory | `1.7` | `codeclone/memory/` | -| Semantic index format | `2` | `codeclone/memory/semantic/` | +| Semantic index format | `3` | `codeclone/memory/semantic/` | | Platform Observability | `1.1` | `codeclone/observability/` | Any schema shape or semantic change requires version review, tests, and diff --git a/README.md b/README.md index e8a9a0a9..39de5d21 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ srcset="https://raw.githubusercontent.com/orenlab/codeclone/main/docs/assets/codeclone-wordmark.svg" > CodeClone — Structural Change Controller for AI-assisted Python development @@ -33,11 +33,11 @@ --- -> [!NOTE] -> This README documents the unreleased **CodeClone 2.1 alpha line**. -> The basic analysis commands below work with the current stable release, +> [!IMPORTANT] +> Sections marked **`2.1 alpha`** require the +> [CodeClone 2.1 prerelease](https://pypi.org/project/codeclone/#history). +> Everything else works with the current stable release, > [CodeClone 2.0.2](https://github.com/orenlab/codeclone/tree/v2.0.2). -> Agent change control and Engineering Memory require the 2.1 prerelease. ## What is CodeClone? @@ -47,13 +47,45 @@ Before an agent edits code, CodeClone records the intended change, maps the stru explicit edit boundaries. After the edit, it compares the real patch with the declared scope, verifies structural regressions, and leaves an auditable review receipt. -CodeClone makes silent scope expansion visible before it disappears inside an otherwise reasonable-looking diff. +CodeClone does not generate or rewrite source files, and it does not ask an LLM to decide whether a structural change +is safe. Every finding and every gate comes from deterministic repository facts shared across agents, human reviewers, +IDEs, reports, and CI. -CodeClone does not ask an LLM to decide whether a structural change is safe. It uses deterministic repository facts -shared across agents, human reviewers, IDEs, reports, and CI. +| Capability | What it provides | +|------------------------------------------------|--------------------------------------------------------------------------------------------------------------| +| **Canonical structural analysis** | One deterministic report: clones, complexity, coupling, cohesion, dead code, API inventory, coverage joins | +| **Baseline-aware governance** | Records accepted legacy debt and separates it from regressions introduced by the current change | +| **One report, many surfaces** | CLI, HTML, JSON, Markdown, SARIF, MCP, IDE integrations, and GitHub Actions from one canonical payload | +| **Structural Change Controller** — `2.1 alpha` | Intent-first change control, blast radius, explicit edit boundaries, patch verification, and review receipts | +| **Engineering Memory** — `2.1 alpha` | Local, typed, evidence-linked project knowledge and reusable histories of prior controlled changes | +| **Agent coordination** — `2.1 alpha` | Conflict-safe multi-agent intents, queues, recovery, and workspace hygiene | + +CodeClone requires no hosted service or cloud account. Analysis state, controller state, Engineering Memory, and +trajectories are stored locally. + +## Why intent comes before the diff + +Most review tools begin after the patch already exists. CodeClone begins earlier: + +```text +task request + → declared intent + → structural blast radius + → explicit boundary + → actual patch + → deterministic verification +``` + +Agent scope expansion can look reasonable in the final diff. A narrow task may quietly spread into shared helpers, +tests, configuration, public APIs, or unrelated modules. + +By the time that expansion reaches the final diff, it already looks intentional. CodeClone catches it at the declared +boundary instead — by comparing what the agent said it would change with what it actually changed. ## Quick start +Requires Python 3.10 or newer. + ### 1. Analyze a repository Run the stable release without installing anything: @@ -68,6 +100,24 @@ Prefer a browsable report? Generate the HTML view and open it: uvx codeclone@latest . --html --open-html-report ``` +
+ + + + CodeClone HTML report — structural health, clone findings, and review priorities + +
+ Once you use it regularly, install it as a local tool: ```bash @@ -90,103 +140,10 @@ from findings that were already present, so agents and reviewers can focus on wh Updating the baseline is an explicit governance action. Do not regenerate it merely to make a failing check pass. -### 3. Set up CodeClone for your AI agent (2.1 alpha) - -Install the prerelease MCP server: - -```bash -uv tool install --prerelease allow "codeclone[mcp]" -codeclone-mcp --transport stdio -``` +### 3. Connect your AI agent -Then wire it into your client: - -| Client | Setup | -|----------------|--------------------------------------------------------------------------------------------------------| -| VS Code | [Extension setup](https://orenlab.github.io/codeclone/guide/integrations/vscode/setup/) | -| Cursor | [Plugin and skills](https://orenlab.github.io/codeclone/guide/integrations/cursor/install-and-skills/) | -| Claude Code | [Plugin setup](https://orenlab.github.io/codeclone/guide/integrations/claude-code/setup/) | -| Codex | [Plugin setup](https://orenlab.github.io/codeclone/guide/integrations/codex/setup/) | -| Claude Desktop | [Bundle setup](https://orenlab.github.io/codeclone/guide/integrations/claude-desktop/setup/) | - -Every client uses the same MCP interface and the same canonical structural facts. - -## How the controlled-change workflow works - -For an agent, the normal workflow is: - -```text -analyze → start → edit → finish -``` - -### Analyze - -CodeClone builds one canonical structural report for the repository and compares it with the accepted baseline. - -### Start - -`start_controlled_change`: - -- records the agent's intent; -- maps structural blast radius; -- separates editable paths from review context and do-not-touch boundaries; -- exposes the regression budget relative to the accepted baseline; -- returns the authoritative `edit_allowed` result. - -### Edit - -The agent writes the code. CodeClone does not generate or rewrite source files. - -Where the host supports hooks, integrations can stop edits unless `edit_allowed=true`. - -### Finish - -`finish_controlled_change`: - -- resolves the actual changed files; -- checks declared scope against the real patch; -- verifies structural changes; -- validates optional review claims; -- records Patch Trail evidence; -- produces an auditable review receipt. - -The result is not an AI opinion about the patch. It is a deterministic comparison between declared intent, repository -structure, the accepted baseline, and the actual change. - -[Read the Structural Change Controller guide](https://orenlab.github.io/codeclone/book/12-structural-change-controller/) - -## What you get - -| Capability | What it provides | -|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------| -| **Structural Change Controller** | Intent-first change control, blast radius, explicit edit boundaries, patch verification, and review receipts | -| **Canonical structural analysis** | Clone detection, complexity, coupling, cohesion, dependency cycles, dead code, API inventory, coverage joins, and structural health | -| **Baseline-aware governance** | Records accepted legacy debt and separates it from regressions introduced by the current change | -| **Engineering Memory** | Local, typed, evidence-linked project knowledge and reusable histories of prior controlled changes | -| **Agent coordination** | Lease-bound intents, queues, conflicts, recovery, and workspace hygiene | -| **One report, many surfaces** | CLI, HTML, JSON, Markdown, SARIF, MCP, IDE integrations, and GitHub Actions from one canonical payload | - -CodeClone requires no hosted service or cloud account. Analysis state, controller state, Engineering Memory, and -trajectories are stored locally. - -## Why intent comes before the diff - -Most review tools begin after the patch already exists. CodeClone begins earlier: - -```text -task request - → declared intent - → structural blast radius - → explicit boundary - → actual patch - → deterministic verification -``` - -Agent scope expansion can look reasonable in the final diff. A narrow task may quietly spread into shared helpers, -tests, configuration, public APIs, or unrelated modules. - -By the time that expansion reaches the final diff, it already looks intentional. CodeClone catches it at the declared -boundary instead — by comparing what the agent said it would change with what it actually changed. +Continue to [Agent change control](#agent-change-control--21-alpha) below to install the MCP control surface and wire +CodeClone into Claude Code, Cursor, VS Code, Codex, or Claude Desktop. ## One canonical structural report @@ -241,7 +198,86 @@ existing repository to be clean first. [Metrics and quality gates](https://orenlab.github.io/codeclone/book/16-metrics-and-quality-gates/) · [GitHub Action documentation](https://orenlab.github.io/codeclone/getting-started/#github-action) -## Engineering Memory +## How CodeClone differs + +Linters check style and correctness file by file. Clone detectors report duplication and stop there. Hosted review +bots ask a model for an opinion about a finished diff. + +CodeClone combines CFG-based clone detection, multi-metric baseline governance, and a read-only MCP control surface in +one local-first, open-source package — and applies them **before** the edit happens, not only after. Structural facts +are computed deterministically, so the same input always produces the same verdict, in the terminal, in CI, and inside +your agent's loop. + +## Agent change control — `2.1 alpha` + +### Install the MCP control surface + +```bash +uv tool install --prerelease allow "codeclone[mcp]" +codeclone-mcp --transport stdio +``` + +Before gating agents or CI, confirm `[tool.codeclone]` and local gitignore hygiene: + +```bash +codeclone setup status +codeclone setup plan +codeclone setup apply # or: codeclone setup wizard +``` + +See [Repository setup and readiness](https://orenlab.github.io/codeclone/guide/setup/readiness-and-apply/). + +### Wire it into your client + +| Client | Setup | +|----------------|--------------------------------------------------------------------------------------------------------| +| VS Code | [Extension setup](https://orenlab.github.io/codeclone/guide/integrations/vscode/setup/) | +| Cursor | [Plugin and skills](https://orenlab.github.io/codeclone/guide/integrations/cursor/install-and-skills/) | +| Claude Code | [Plugin setup](https://orenlab.github.io/codeclone/guide/integrations/claude-code/setup/) | +| Codex | [Plugin setup](https://orenlab.github.io/codeclone/guide/integrations/codex/setup/) | +| Claude Desktop | [Bundle setup](https://orenlab.github.io/codeclone/guide/integrations/claude-desktop/setup/) | + +Every client uses the same MCP interface and the same canonical structural facts. + +### The controlled-change workflow + +For an agent, the normal workflow is: + +```text +analyze → start → edit → finish +``` + +**Analyze.** CodeClone builds one canonical structural report for the repository and compares it with the accepted +baseline. + +**Start.** `start_controlled_change`: + +- records the agent's intent; +- maps structural blast radius; +- separates editable paths from review context and do-not-touch boundaries; +- exposes the regression budget relative to the accepted baseline; +- returns the authoritative `edit_allowed` result. + +**Edit.** The agent writes the code. CodeClone does not generate or rewrite source files. Where the host supports +hooks, integrations can stop edits unless `edit_allowed=true`. + +**Finish.** `finish_controlled_change`: + +- resolves the actual changed files; +- checks declared scope against the real patch; +- verifies structural changes; +- validates optional review claims; +- records Patch Trail evidence; +- produces an auditable review receipt. + + + +The result is not an AI opinion about the patch. It is a deterministic comparison between declared intent, repository +structure, the accepted baseline, and the actual change. + +[Read the Structural Change Controller guide](https://orenlab.github.io/codeclone/book/12-structural-change-controller/) + +## Engineering Memory — `2.1 alpha` Engineering Memory gives agents durable, repository-specific context without treating model output as project truth. @@ -260,8 +296,8 @@ codeclone memory init --root . codeclone memory search "baseline schema" --match all ``` -Memory can guide an agent. It cannot authorize edits, override blast radius, change a gate, or replace canonical report -facts. +Memory can guide an agent. It cannot authorize edits, override blast radius, change a gate, or replace canonical +report facts. [Engineering Memory documentation](https://orenlab.github.io/codeclone/book/13-engineering-memory/) · [Trajectories and Experiences](https://orenlab.github.io/codeclone/guide/memory/trajectories-and-experiences/) @@ -277,7 +313,12 @@ facts. - `stdio` is the recommended transport for local clients. - Remote HTTP exposure requires explicit `--allow-remote`. -## Development setup +## Contributing + +Bug reports, feature discussions, and pull requests are welcome — start with +[Issues](https://github.com/orenlab/codeclone/issues) or +[Discussions](https://github.com/orenlab/codeclone/discussions), or join the +[Discord](https://discord.com/invite/U72KmRvpUx). Run the repository version from source: @@ -288,8 +329,6 @@ uv sync --all-extras uv run codeclone . ``` -CodeClone 2.1 requires Python 3.10 or newer. - ## Documentation **[orenlab.github.io/codeclone](https://orenlab.github.io/codeclone/)** @@ -322,10 +361,9 @@ See [LICENSES.md](https://github.com/orenlab/codeclone/blob/main/LICENSES.md) fo [tests-shield]: https://img.shields.io/github/actions/workflow/status/orenlab/codeclone/tests.yml?branch=main&style=flat-square&label=tests [benchmark-shield]: https://img.shields.io/github/actions/workflow/status/orenlab/codeclone/benchmark.yml?branch=main&style=flat-square&label=benchmark [discord-shield]: https://img.shields.io/badge/Discord-Join%20community-5865F2?style=flat-square&logo=discord&logoColor=white - [pypi-link]: https://pypi.org/project/codeclone/ -[license-link]: #license +[license-link]: https://github.com/orenlab/codeclone/blob/main/LICENSES.md [tests-link]: https://github.com/orenlab/codeclone/actions/workflows/tests.yml [benchmark-link]: https://github.com/orenlab/codeclone/actions/workflows/benchmark.yml [discord-link]: https://discord.com/invite/U72KmRvpUx diff --git a/benchmarks/mcp_token_budget.py b/benchmarks/mcp_token_budget.py index f0586012..d7026477 100644 --- a/benchmarks/mcp_token_budget.py +++ b/benchmarks/mcp_token_budget.py @@ -71,7 +71,7 @@ def _analyze_repository_small() -> dict[str, object]: "run_id": "abc12345", "focus": "repository", "version": "2.1.0a1", - "schema": "2.11", + "schema": "2.12", "mode": "full", "baseline": { "loaded": True, @@ -200,7 +200,7 @@ def _review_receipt() -> dict[str, object]: "verdict": "clean", "provenance": { "digest": "a" * 64, - "schema_version": "2.11", + "schema_version": "2.12", "baseline_trust": "ok", "run_id": "abc12345", "root": "/repo", diff --git a/benchmarks/run_benchmark.py b/benchmarks/run_benchmark.py index c8037e04..cc9bc4fb 100755 --- a/benchmarks/run_benchmark.py +++ b/benchmarks/run_benchmark.py @@ -20,7 +20,7 @@ from datetime import datetime, timezone from pathlib import Path from statistics import fmean, median, pstdev -from typing import Literal, cast +from typing import Literal, TypeGuard, cast from codeclone import __version__ as codeclone_version from codeclone.baseline import current_python_tag @@ -707,9 +707,19 @@ def _median_for(name: str) -> float | None: return comparisons +def _is_json_object(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) + + +def _require_json_object(value: object, *, message: str) -> dict[str, object]: + if _is_json_object(value): + return value + raise RuntimeError(message) + + def _load_benchmark_payload(path: Path) -> dict[str, object]: payload_obj: object = json.loads(path.read_text(encoding="utf-8")) - if not isinstance(payload_obj, dict): + if not _is_json_object(payload_obj): raise RuntimeError(f"benchmark payload is not an object: {path}") return payload_obj @@ -721,12 +731,13 @@ def _scenario_medians(payload: Mapping[str, object]) -> dict[str, float]: medians: dict[str, float] = {} for item in scenarios_obj: - if not isinstance(item, dict): + if not _is_json_object(item): raise RuntimeError("benchmark scenario entry is not an object") name = item.get("name") - stats = item.get("stats_seconds") - if not isinstance(name, str) or not isinstance(stats, dict): + stats_obj = item.get("stats_seconds") + if not isinstance(name, str) or not _is_json_object(stats_obj): raise RuntimeError("benchmark scenario entry is missing name/stats_seconds") + stats = stats_obj median = stats.get("median") if not isinstance(median, (int, float)): raise RuntimeError(f"benchmark scenario {name} is missing median timing") @@ -960,10 +971,14 @@ def main() -> int: print("startup probes:") for probe in startup_probe_results: name = str(probe["name"]) - stats = probe["stats_seconds"] - cpu_stats = probe["child_cpu_stats_seconds"] - assert isinstance(stats, dict) - assert isinstance(cpu_stats, dict) + stats = _require_json_object( + probe["stats_seconds"], + message="startup probe stats_seconds is not an object", + ) + cpu_stats = _require_json_object( + probe["child_cpu_stats_seconds"], + message="startup probe child_cpu_stats_seconds is not an object", + ) print( f"- {name:22s} median={_as_float(stats['median']):.4f}s " f"first={_as_float(probe['first_seconds']):.4f}s " @@ -971,18 +986,26 @@ def main() -> int: ) for scenario in scenario_results: name = str(scenario["name"]) - stats = scenario["stats_seconds"] - cpu_stats = scenario["child_cpu_stats_seconds"] - inventory = scenario["inventory_sample"] - exit_counts = scenario["exit_code_counts"] - assert isinstance(stats, dict) - assert isinstance(cpu_stats, dict) - assert isinstance(inventory, dict) - assert isinstance(exit_counts, dict) - median_s = float(stats["median"]) - p95_s = float(stats["p95"]) - stdev_s = float(stats["stdev"]) - cpu_median_s = float(cpu_stats["median"]) + stats = _require_json_object( + scenario["stats_seconds"], + message="scenario stats_seconds is not an object", + ) + cpu_stats = _require_json_object( + scenario["child_cpu_stats_seconds"], + message="scenario child_cpu_stats_seconds is not an object", + ) + inventory = _require_json_object( + scenario["inventory_sample"], + message="scenario inventory_sample is not an object", + ) + exit_counts = _require_json_object( + scenario["exit_code_counts"], + message="scenario exit_code_counts is not an object", + ) + median_s = _as_float(stats["median"]) + p95_s = _as_float(stats["p95"]) + stdev_s = _as_float(stats["stdev"]) + cpu_median_s = _as_float(cpu_stats["median"]) print( f"- {name:20s} median={median_s:.4f}s " f"p95={p95_s:.4f}s stdev={stdev_s:.4f}s " diff --git a/codeclone/analysis/cfg.py b/codeclone/analysis/cfg.py index 5da19331..0804c27b 100644 --- a/codeclone/analysis/cfg.py +++ b/codeclone/analysis/cfg.py @@ -8,7 +8,7 @@ import ast from dataclasses import dataclass -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, TypeVar from ..meta_markers import CFG_META_PREFIX from .cfg_model import CFG, Block @@ -19,6 +19,7 @@ __all__ = ["CFG", "CFGBuilder"] TryStar = getattr(ast, "TryStar", ast.Try) +_AstNodeT = TypeVar("_AstNodeT", bound=ast.AST) @dataclass(slots=True) @@ -31,6 +32,34 @@ def _meta_expr(value: str) -> ast.Expr: return ast.Expr(value=ast.Name(id=f"{CFG_META_PREFIX}{value}", ctx=ast.Load())) +def _list_of_ast(value: object, item_type: type[_AstNodeT]) -> list[_AstNodeT] | None: + if not isinstance(value, list): + return None + result: list[_AstNodeT] = [] + for item in value: + if not isinstance(item, item_type): + return None + result.append(item) + return result + + +def _try_star_parts( + stmt: ast.stmt, +) -> ( + tuple[list[ast.stmt], list[ast.ExceptHandler], list[ast.stmt], list[ast.stmt]] + | None +): + if TryStar is ast.Try or not isinstance(stmt, TryStar): + return None + body = _list_of_ast(getattr(stmt, "body", None), ast.stmt) + handlers = _list_of_ast(getattr(stmt, "handlers", None), ast.ExceptHandler) + orelse = _list_of_ast(getattr(stmt, "orelse", None), ast.stmt) + finalbody = _list_of_ast(getattr(stmt, "finalbody", None), ast.stmt) + if body is None or handlers is None or orelse is None or finalbody is None: + return None + return body, handlers, orelse, finalbody + + # ========================= # CFG Builder # ========================= @@ -104,13 +133,6 @@ def _visit(self, stmt: ast.stmt) -> None: orelse=stmt.orelse, finalbody=stmt.finalbody, ) - case _ if TryStar is not None and isinstance(stmt, TryStar): - self._visit_try( - body=stmt.body, - handlers=stmt.handlers, - orelse=stmt.orelse, - finalbody=stmt.finalbody, - ) case ast.With() | ast.AsyncWith(): self._visit_with(stmt) @@ -119,6 +141,16 @@ def _visit(self, stmt: ast.stmt) -> None: self._visit_match(stmt) case _: + try_star_parts = _try_star_parts(stmt) + if try_star_parts is not None: + body, handlers, orelse, finalbody = try_star_parts + self._visit_try( + body=body, + handlers=handlers, + orelse=orelse, + finalbody=finalbody, + ) + return self.current.statements.append(stmt) # ---------- Control Flow ---------- diff --git a/codeclone/analysis/parser.py b/codeclone/analysis/parser.py index f8bbbb5e..409f8bea 100644 --- a/codeclone/analysis/parser.py +++ b/codeclone/analysis/parser.py @@ -13,13 +13,26 @@ import signal import tokenize from contextlib import contextmanager -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Protocol, runtime_checkable from ..contracts.errors import ParseError if TYPE_CHECKING: from collections.abc import Iterator, Mapping + +class _ResourceUsage(Protocol): + ru_utime: float + ru_stime: float + + +@runtime_checkable +class _ResourceModule(Protocol): + RUSAGE_SELF: int + + def getrusage(self, who: int, /) -> _ResourceUsage: ... + + PARSE_TIMEOUT_SECONDS = 5 @@ -33,10 +46,10 @@ class _ParseTimeoutError(Exception): def _consumed_cpu_seconds(resource_module: object) -> float: """Return consumed CPU seconds for the current process.""" + if not isinstance(resource_module, _ResourceModule): + return 0.0 try: - usage = resource_module.getrusage( # type: ignore[attr-defined] - resource_module.RUSAGE_SELF # type: ignore[attr-defined] - ) + usage = resource_module.getrusage(resource_module.RUSAGE_SELF) return float(usage.ru_utime) + float(usage.ru_stime) except Exception: return 0.0 diff --git a/codeclone/analytics/corpus/adapters/intent_historical.py b/codeclone/analytics/corpus/adapters/intent_historical.py index 73af3345..d652d0eb 100644 --- a/codeclone/analytics/corpus/adapters/intent_historical.py +++ b/codeclone/analytics/corpus/adapters/intent_historical.py @@ -37,6 +37,7 @@ ) from ....utils.json_io import json_text from ...agent_labels import map_agent_family +from ...mapping import copy_str_key_mapping from ..keys import ( representation_key, representation_version_for_kind, @@ -443,24 +444,28 @@ def materialize_corpus_item( def _materialized_metadata(item: HistoricalIntentSourceItem) -> dict[str, object]: metadata = dict(item.metadata) provenance = { - key: dict(value) if isinstance(value, dict) else value + key: _str_key_mapping(value) if isinstance(value, Mapping) else value for key, value in item.provenance.items() } - trajectory = provenance.get("trajectory") - if not isinstance(trajectory, dict): + trajectory = _str_key_mapping(provenance.get("trajectory")) + if not trajectory: trajectory = {} - provenance["trajectory"] = trajectory trajectory["selected"] = trajectory.get("selected_trajectory_id") is not None - patch_trail = provenance.get("patch_trail") - if not isinstance(patch_trail, dict): + provenance["trajectory"] = trajectory + patch_trail = _str_key_mapping(provenance.get("patch_trail")) + if not patch_trail: patch_trail = {} - provenance["patch_trail"] = patch_trail patch_trail["present"] = patch_trail.get("digest") is not None + provenance["patch_trail"] = patch_trail provenance["registry_overlay"] = {"present": item.registry_overlay is not None} metadata["provenance"] = provenance return metadata +def _str_key_mapping(value: object) -> dict[str, object]: + return copy_str_key_mapping(value) + + def default_source_schema_versions() -> dict[str, str]: return { "audit": AUDIT_SCHEMA_VERSION, diff --git a/codeclone/analytics/export/json_export.py b/codeclone/analytics/export/json_export.py index 4f7b5df2..bf6eef57 100644 --- a/codeclone/analytics/export/json_export.py +++ b/codeclone/analytics/export/json_export.py @@ -22,6 +22,7 @@ EmbeddingItemRecord, ) from ..exceptions import AnalyticsWorkflowError +from ..mapping import copy_str_key_mapping from ..report.interpret import ( INTERPRETATION_CONTRACT_VERSION, build_profile_summary, @@ -186,8 +187,7 @@ def _single_export_sweep_candidates( snapshot=snapshot, run=candidate, ) - run_payload = projection.get("run") - result.append(dict(run_payload) if isinstance(run_payload, Mapping) else {}) + result.append(_str_key_mapping(projection.get("run"))) return result @@ -207,6 +207,10 @@ def _full_projection_payload( } +def _str_key_mapping(value: object) -> dict[str, object]: + return copy_str_key_mapping(value) + + def _snapshot_dict(snapshot: CorpusSnapshotRecord) -> dict[str, object]: return { "snapshot_id": snapshot.snapshot_id, diff --git a/codeclone/analytics/integrity.py b/codeclone/analytics/integrity.py index 10736552..34adce0a 100644 --- a/codeclone/analytics/integrity.py +++ b/codeclone/analytics/integrity.py @@ -11,7 +11,6 @@ from collections import Counter, defaultdict from collections.abc import Collection, Mapping, Sequence from dataclasses import dataclass -from typing import cast from ..contracts import CORPUS_EMBEDDING_CONTRACT_VERSION from .clustering.models import NOISE_LABEL @@ -26,6 +25,7 @@ ) from .corpus.keys import membership_digest from .exceptions import AnalyticsWorkflowError +from .mapping import copy_str_key_mapping as _copy_str_key_mapping from .store.protocols import CorpusStore, VectorGenerationStore from .store.vectors_lancedb import vector_digest, vector_row_key @@ -175,7 +175,7 @@ def load_validated_snapshot_vectors( vector = row["vector"] if not isinstance(vector, list): raise AnalyticsWorkflowError(f"invalid vector payload for {item_id}") - typed_vector = [float(value) for value in vector] + typed_vector = _float_vector(vector, item_id=item_id) if len(typed_vector) != generation.dimensions: raise AnalyticsWorkflowError( f"vector dimension mismatch for {item_id}: " @@ -316,7 +316,11 @@ def _decode_validity_json( ) -> _ValidityJsonContext: effective, effective_ok = _json_object(run.effective_parameters_json) raw_manifest = effective.get("algorithm_manifest") if effective_ok else None - manifest = raw_manifest.copy() if isinstance(raw_manifest, dict) else None + manifest = ( + _copy_str_key_mapping(raw_manifest) + if isinstance(raw_manifest, Mapping) + else None + ) shapes = [ _json_object(snapshot.source_stores_json)[1], _json_object(snapshot.source_schema_versions_json)[1], @@ -581,7 +585,8 @@ def _diagnostic_numbers_are_finite(diagnostics: Mapping[str, object]) -> bool: distributions = diagnostics.get("metadata_distributions") if not isinstance(distributions, Mapping): return True - for values in _mapping_values(distributions): + normalized_distributions = _copy_str_key_mapping(distributions) + for values in _mapping_values(normalized_distributions): for cell in _mapping_values(values): for field in ("numerator", "denominator", "rate"): if not _persisted_number_is_finite(cell.get(field)): @@ -590,11 +595,13 @@ def _diagnostic_numbers_are_finite(diagnostics: Mapping[str, object]) -> bool: def _mapping_values(value: Mapping[str, object]) -> tuple[Mapping[str, object], ...]: - return tuple( - cast(Mapping[str, object], item) - for item in value.values() - if isinstance(item, Mapping) - ) + mappings: list[Mapping[str, object]] = [] + for item in value.values(): + if not isinstance(item, Mapping): + continue + normalized = _copy_str_key_mapping(item) + mappings.append(normalized) + return tuple(mappings) def _persisted_number_is_finite(value: object) -> bool: @@ -603,6 +610,15 @@ def _persisted_number_is_finite(value: object) -> bool: return math.isfinite(value) +def _float_vector(vector: Sequence[object], *, item_id: str) -> list[float]: + typed_vector: list[float] = [] + for value in vector: + if isinstance(value, bool) or not isinstance(value, str | int | float): + raise AnalyticsWorkflowError(f"invalid vector payload for {item_id}") + typed_vector.append(float(value)) + return typed_vector + + def _invariant_sort_key(code: str) -> tuple[int, str]: digits = "".join(character for character in code if character.isdigit()) suffix = code[len(digits) + 1 :] if digits else code diff --git a/codeclone/analytics/mapping.py b/codeclone/analytics/mapping.py new file mode 100644 index 00000000..852ccf0d --- /dev/null +++ b/codeclone/analytics/mapping.py @@ -0,0 +1,22 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +from collections.abc import Mapping + +from .exceptions import AnalyticsWorkflowError + + +def copy_str_key_mapping(value: object) -> dict[str, object]: + if not isinstance(value, Mapping): + return {} + mapping: dict[str, object] = {} + for key, item in value.items(): + if not isinstance(key, str): + raise AnalyticsWorkflowError("analytics mapping contains non-string key") + mapping[key] = item + return mapping diff --git a/codeclone/analytics/profiles/loader.py b/codeclone/analytics/profiles/loader.py index 6cc89da1..3601b0bf 100644 --- a/codeclone/analytics/profiles/loader.py +++ b/codeclone/analytics/profiles/loader.py @@ -11,7 +11,7 @@ from dataclasses import asdict from importlib.resources import files from pathlib import Path -from typing import Literal, cast +from typing import Literal from pydantic import ( BaseModel, @@ -104,10 +104,7 @@ def _method_axis( ) -> tuple[Literal["eom", "leaf"], ...]: if not value: raise ValueError("cluster_selection_method must not be empty") - return cast( - "tuple[Literal['eom', 'leaf'], ...]", - tuple(sorted(set(value))), - ) + return tuple(sorted(set(value))) class _SuitabilityModel(BaseModel): diff --git a/codeclone/analytics/report/html.py b/codeclone/analytics/report/html.py index dac4e5ce..b5e4b748 100644 --- a/codeclone/analytics/report/html.py +++ b/codeclone/analytics/report/html.py @@ -13,6 +13,7 @@ from ..clustering.models import NOISE_LABEL from ..contracts import ClusteringRunRecord, CorpusSnapshotRecord from ..exceptions import AnalyticsWorkflowError +from ..mapping import copy_str_key_mapping from ..store.sqlite import SqliteCorpusAnalyticsStore from .interpret import ( build_profile_summary, @@ -532,13 +533,13 @@ def _display_metadata_value(value: object) -> str: def _mapping(value: object) -> dict[str, object]: - return dict(value) if isinstance(value, Mapping) else {} + return copy_str_key_mapping(value) def _mapping_list(value: object) -> list[dict[str, object]]: if not isinstance(value, list): return [] - return [dict(item) for item in value if isinstance(item, Mapping)] + return [_mapping(item) for item in value if isinstance(item, Mapping)] def _escaped(value: object) -> str: diff --git a/codeclone/analytics/report/interpret.py b/codeclone/analytics/report/interpret.py index c78fa7ea..4fc9e5b3 100644 --- a/codeclone/analytics/report/interpret.py +++ b/codeclone/analytics/report/interpret.py @@ -37,6 +37,7 @@ ) from ..exceptions import AnalyticsWorkflowError from ..integrity import PartitionValidityAssessment, assess_partition_validity +from ..mapping import copy_str_key_mapping from ..metrics.partition_metrics import ( RunPartitionMetrics, compute_run_partition_metrics, @@ -664,7 +665,7 @@ def _provenance_completeness( for item in items: metadata = _json_mapping(item.metadata_json) provenance = metadata.get("provenance") - provenance_map = provenance if isinstance(provenance, Mapping) else {} + provenance_map = _mapping(provenance) if ( _provenance_presence( provenance_map, @@ -736,8 +737,10 @@ def _registry_overlay_presence( ) -> bool | None: # Slice 1 only established non-null overlay content as positive evidence. value = provenance.get("registry_overlay") - if isinstance(value, Mapping) and isinstance(value.get("present"), bool): - return bool(value["present"]) + if isinstance(value, Mapping): + present = value.get("present") + if isinstance(present, bool): + return present if item.registry_overlay_json is None: return None overlay = _json_mapping_or_none(item.registry_overlay_json) @@ -1114,20 +1117,20 @@ def _json_string_list(text: str) -> list[str]: def _mapping(value: object) -> dict[str, object]: - return dict(value) if isinstance(value, Mapping) else {} + return copy_str_key_mapping(value) def _mapping_list(value: object) -> list[dict[str, object]]: if not isinstance(value, list): return [] - return [dict(item) for item in value if isinstance(item, Mapping)] + return [_mapping(item) for item in value if isinstance(item, Mapping)] def _mapping_cells(value: object) -> list[tuple[str, dict[str, object]]]: if not isinstance(value, Mapping): return [] return [ - (str(key), dict(cell)) + (str(key), _mapping(cell)) for key, cell in sorted(value.items()) if isinstance(cell, Mapping) ] diff --git a/codeclone/analytics/store/sqlite.py b/codeclone/analytics/store/sqlite.py index 314d6c79..f4d7f0f5 100644 --- a/codeclone/analytics/store/sqlite.py +++ b/codeclone/analytics/store/sqlite.py @@ -11,25 +11,45 @@ from collections.abc import Sequence from dataclasses import replace from pathlib import Path +from typing import TypeVar from ..contracts import ( ActiveSelectionResult, ClusterAssignmentRecord, ClusteringRunRecord, + ClusteringRunStatus, ClusterSummaryRecord, CorpusItemRecord, + CorpusLane, CorpusSnapshotRecord, EmbeddingGenerationRecord, EmbeddingItemRecord, ProfileAssessmentRecord, ProfileBatchRecord, ProfileBatchRunRecord, + ProfileBatchStatus, ProfileManifestSnapshotRecord, RunSelectionRecord, ) from ..exceptions import AnalyticsStoreError from ..schema import open_analytics_db, open_analytics_db_readonly +_LiteralT = TypeVar("_LiteralT", bound=str) + +_CORPUS_LANES: dict[str, CorpusLane] = {"intent": "intent"} +_CLUSTERING_RUN_STATUSES: dict[str, ClusteringRunStatus] = { + "pending": "pending", + "running": "running", + "completed": "completed", + "failed": "failed", +} +_PROFILE_BATCH_STATUSES: dict[str, ProfileBatchStatus] = { + "running": "running", + "completed": "completed", + "completed_partial": "completed_partial", + "failed": "failed", +} + class SqliteCorpusAnalyticsStore: """SQLite implementation of CorpusStore.""" @@ -51,6 +71,7 @@ def insert_snapshot( snapshot: CorpusSnapshotRecord, items: Sequence[CorpusItemRecord], ) -> None: + _validate_snapshot_literals(snapshot) self._conn.execute( """ INSERT INTO corpus_snapshots ( @@ -197,6 +218,7 @@ def list_embedding_items( return tuple(_embedding_item_from_row(row) for row in rows) def insert_clustering_run(self, run: ClusteringRunRecord) -> None: + run = _validate_clustering_run_literals(run) self._conn.execute( """ INSERT INTO clustering_runs ( @@ -225,6 +247,7 @@ def insert_clustering_run(self, run: ClusteringRunRecord) -> None: ) def update_clustering_run(self, run: ClusteringRunRecord) -> None: + run = _validate_clustering_run_literals(run) self._conn.execute( """ UPDATE clustering_runs SET @@ -352,6 +375,7 @@ def get_profile_manifest_snapshot( return _profile_manifest_snapshot_from_row(row) if row is not None else None def insert_profile_batch(self, record: ProfileBatchRecord) -> None: + record = _validate_profile_batch_literals(record) self._conn.execute( """ INSERT INTO profile_batches ( @@ -368,6 +392,7 @@ def insert_profile_batch(self, record: ProfileBatchRecord) -> None: ) def finalize_profile_batch(self, record: ProfileBatchRecord) -> None: + record = _validate_profile_batch_literals(record) cursor = self._conn.execute( """ UPDATE profile_batches SET @@ -773,10 +798,62 @@ def close(self) -> None: self._conn.close() +def _literal_value( + value: object, + *, + field: str, + allowed: dict[str, _LiteralT], +) -> _LiteralT: + if isinstance(value, str): + literal = allowed.get(value) + if literal is not None: + return literal + raise AnalyticsStoreError(f"Invalid corpus analytics {field}: {value!r}") + + +def _literal_from_row( + row: sqlite3.Row, + column: str, + *, + field: str, + allowed: dict[str, _LiteralT], +) -> _LiteralT: + return _literal_value(row[column], field=field, allowed=allowed) + + +def _validate_snapshot_literals( + snapshot: CorpusSnapshotRecord, +) -> CorpusSnapshotRecord: + _literal_value(snapshot.lane, field="lane", allowed=_CORPUS_LANES) + return snapshot + + +def _validate_clustering_run_literals( + run: ClusteringRunRecord, +) -> ClusteringRunRecord: + _literal_value( + run.status, + field="clustering_run.status", + allowed=_CLUSTERING_RUN_STATUSES, + ) + return run + + +def _validate_profile_batch_literals( + batch: ProfileBatchRecord, +) -> ProfileBatchRecord: + _literal_value( + batch.status, + field="profile_batch.status", + allowed=_PROFILE_BATCH_STATUSES, + ) + return batch + + def _snapshot_from_row(row: sqlite3.Row) -> CorpusSnapshotRecord: return CorpusSnapshotRecord( snapshot_id=str(row["snapshot_id"]), - lane=str(row["lane"]), # type: ignore[arg-type] + lane=_literal_from_row(row, "lane", field="lane", allowed=_CORPUS_LANES), representation_kind=str(row["representation_kind"]), representation_version=str(row["representation_version"]), source_stores_json=str(row["source_stores_json"]), @@ -845,7 +922,12 @@ def _run_from_row(row: sqlite3.Row) -> ClusteringRunRecord: run_digest=str(row["run_digest"]), recommended_by_heuristic=bool(int(row["recommended_by_heuristic"])), selected_by_maintainer=bool(int(row["selected_by_maintainer"])), - status=str(row["status"]), # type: ignore[arg-type] + status=_literal_from_row( + row, + "status", + field="clustering_run.status", + allowed=_CLUSTERING_RUN_STATUSES, + ), created_at_utc=str(row["created_at_utc"]), finished_at_utc=_optional_str(row["finished_at_utc"]), error_message=_optional_str(row["error_message"]), @@ -921,7 +1003,12 @@ def _profile_batch_from_row(row: sqlite3.Row) -> ProfileBatchRecord: candidate_space_digest=str(row["candidate_space_digest"]), started_at_utc=str(row["started_at_utc"]), finished_at_utc=_optional_str(row["finished_at_utc"]), - status=str(row["status"]), # type: ignore[arg-type] + status=_literal_from_row( + row, + "status", + field="profile_batch.status", + allowed=_PROFILE_BATCH_STATUSES, + ), candidate_count_planned=int(row["candidate_count_planned"]), candidate_count_succeeded=int(row["candidate_count_succeeded"]), candidate_count_failed=int(row["candidate_count_failed"]), diff --git a/codeclone/analytics/store/vectors_lancedb.py b/codeclone/analytics/store/vectors_lancedb.py index 509d3ceb..6c44172e 100644 --- a/codeclone/analytics/store/vectors_lancedb.py +++ b/codeclone/analytics/store/vectors_lancedb.py @@ -148,7 +148,7 @@ def write_vectors( if not isinstance(vector, list): msg = "vector must be a list of floats" raise TypeError(msg) - float_vector = [float(value) for value in vector] + float_vector = _float_vector(vector) if len(float_vector) != self._dimension: raise AnalyticsStoreError( f"vector dimension mismatch: actual={len(float_vector)}, " @@ -191,7 +191,7 @@ def read_vectors( ).items(): vector = row.get("vector") if isinstance(vector, list): - loaded[item_id] = [float(value) for value in vector] + loaded[item_id] = _float_vector(vector) return loaded def read_vector_rows( @@ -232,7 +232,7 @@ def read_vector_rows( loaded[item_id] = { "vector_row_key": str(row.get("vector_row_key", "")), "vector_digest": str(row.get("vector_digest", "")), - "vector": [float(value) for value in vector], + "vector": _float_vector(vector), } return loaded @@ -272,4 +272,14 @@ def _sql_literal(value: str) -> str: return "'" + value.replace("'", "''") + "'" +def _float_vector(vector: Sequence[object]) -> list[float]: + float_vector: list[float] = [] + for value in vector: + if isinstance(value, bool) or not isinstance(value, str | int | float): + msg = "vector must be a list of floats" + raise TypeError(msg) + float_vector.append(float(value)) + return float_vector + + __all__ = ["AnalyticsVectorStore", "vector_digest", "vector_row_key"] diff --git a/codeclone/audit/analysis_completed.py b/codeclone/audit/analysis_completed.py index c1a6bb53..5f1c24cf 100644 --- a/codeclone/audit/analysis_completed.py +++ b/codeclone/audit/analysis_completed.py @@ -201,7 +201,9 @@ def _findings_summary(summary: Mapping[str, object]) -> Mapping[str, object]: def _mapping(value: object) -> Mapping[str, object]: - return value if isinstance(value, Mapping) else {} + if not isinstance(value, Mapping): + return {} + return {key: item for key, item in value.items() if isinstance(key, str)} def _sequence(value: object) -> tuple[object, ...]: diff --git a/codeclone/audit/events.py b/codeclone/audit/events.py index 52801a7c..b5118be0 100644 --- a/codeclone/audit/events.py +++ b/codeclone/audit/events.py @@ -13,7 +13,7 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from pathlib import Path, PurePosixPath -from typing import Final, Literal, cast +from typing import Final, Literal AuditSeverity = Literal["info", "warn", "error"] AuditPayloadMode = Literal["off", "compact", "full"] @@ -21,6 +21,17 @@ AuditSurface = Literal["mcp", "cli", "hook", "ide", "ci", "unknown"] AUDIT_EVENT_CORE_VERSION: Final = "2" +_AUDIT_SURFACE_VALUES: tuple[AuditSurface, ...] = ( + "mcp", + "cli", + "hook", + "ide", + "ci", + "unknown", +) +_AUDIT_SURFACE_BY_VALUE: Mapping[str, AuditSurface] = { + value: value for value in _AUDIT_SURFACE_VALUES +} EVENT_INTENT_DECLARED = "intent.declared" EVENT_INTENT_QUEUED = "intent.queued" @@ -175,12 +186,14 @@ def normalize_audit_surface( payload: Mapping[str, object] | None = None, ) -> AuditSurface: if isinstance(surface, str): - normalized = surface.strip().lower() - if normalized in KNOWN_AUDIT_SURFACES: - return cast(AuditSurface, normalized) + normalized = _AUDIT_SURFACE_BY_VALUE.get(surface.strip().lower()) + if normalized is not None: + return normalized payload_source = _payload_source(payload) - if payload_source in {ANALYSIS_SOURCE_MCP, ANALYSIS_SOURCE_CLI}: - return payload_source + if payload_source == ANALYSIS_SOURCE_MCP: + return "mcp" + if payload_source == ANALYSIS_SOURCE_CLI: + return "cli" return "unknown" @@ -646,7 +659,7 @@ def _patch_trail_event_core_facts( def _patch_trail_counts(payload: Mapping[str, object]) -> Mapping[str, object]: counts = payload.get("counts") if isinstance(counts, Mapping): - return counts + return _mapping(counts) return { "declared": len(_sequence(payload.get("declared_files"))), "changed": len(_sequence(payload.get("changed_files"))), @@ -684,7 +697,7 @@ def _claim_event_core_facts( citations: list[dict[str, object]] = [] for raw in _sequence(payload.get("validated_citations")): if isinstance(raw, Mapping): - entry = _validated_citation_entry(raw) + entry = _validated_citation_entry(_mapping(raw)) if entry is not None: citations.append(entry) if citations: @@ -752,7 +765,9 @@ def _sequence_field_count(payload: Mapping[str, object], key: str) -> int: def _mapping(value: object) -> Mapping[str, object]: - return value if isinstance(value, Mapping) else {} + if not isinstance(value, Mapping): + return {} + return {key: item for key, item in value.items() if isinstance(key, str)} def _sequence(value: object) -> Sequence[object]: diff --git a/codeclone/audit/reader.py b/codeclone/audit/reader.py index c47c1c6a..a0a737ba 100644 --- a/codeclone/audit/reader.py +++ b/codeclone/audit/reader.py @@ -1139,7 +1139,9 @@ def _int_or_none(value: object) -> int | None: def _mapping(value: object) -> dict[str, object]: - return value if isinstance(value, dict) else {} + if not isinstance(value, dict): + return {} + return {key: item for key, item in value.items() if isinstance(key, str)} def _analysis_payload_from_json(value: object) -> dict[str, object]: diff --git a/codeclone/baseline/_metrics_baseline_validation.py b/codeclone/baseline/_metrics_baseline_validation.py index 21f15a9f..75b6448c 100644 --- a/codeclone/baseline/_metrics_baseline_validation.py +++ b/codeclone/baseline/_metrics_baseline_validation.py @@ -143,6 +143,24 @@ def _require_str(payload: dict[str, object], key: str, *, path: Path) -> str: ) +def _require_object(value: object, *, label: str, path: Path) -> dict[str, object]: + if not isinstance(value, dict): + raise BaselineValidationError( + f"Invalid metrics baseline schema at {path}: {label} must be object", + status=MetricsBaselineStatus.INVALID_TYPE, + ) + + result: dict[str, object] = {} + for raw_key, item in value.items(): + if not isinstance(raw_key, str): + raise BaselineValidationError( + f"Invalid metrics baseline schema at {path}: {label} keys must be str", + status=MetricsBaselineStatus.INVALID_TYPE, + ) + result[raw_key] = item + return result + + def _extract_metrics_payload_sha256( payload: dict[str, object], *, @@ -201,18 +219,33 @@ def _require_str_list( *, path: Path, ) -> list[str]: - value = payload.get(key) + return _require_str_list_value( + payload.get(key), + error_message=f"{key!r} must be list[str]", + path=path, + ) + + +def _require_str_list_value( + value: object, + *, + error_message: str, + path: Path, +) -> list[str]: if not isinstance(value, list): raise BaselineValidationError( - f"Invalid metrics baseline schema at {path}: {key!r} must be list[str]", - status=MetricsBaselineStatus.INVALID_TYPE, - ) - if not all(isinstance(item, str) for item in value): - raise BaselineValidationError( - f"Invalid metrics baseline schema at {path}: {key!r} must be list[str]", + f"Invalid metrics baseline schema at {path}: {error_message}", status=MetricsBaselineStatus.INVALID_TYPE, ) - return value + result: list[str] = [] + for item in value: + if not isinstance(item, str): + raise BaselineValidationError( + f"Invalid metrics baseline schema at {path}: {error_message}", + status=MetricsBaselineStatus.INVALID_TYPE, + ) + result.append(item) + return result def _parse_cycles( @@ -230,19 +263,12 @@ def _parse_cycles( cycles: list[tuple[str, ...]] = [] for cycle in value: - if not isinstance(cycle, list): - raise BaselineValidationError( - "Invalid metrics baseline schema at " - f"{path}: {key!r} cycle item must be list[str]", - status=MetricsBaselineStatus.INVALID_TYPE, - ) - if not all(isinstance(item, str) for item in cycle): - raise BaselineValidationError( - "Invalid metrics baseline schema at " - f"{path}: {key!r} cycle item must be list[str]", - status=MetricsBaselineStatus.INVALID_TYPE, - ) - cycles.append(tuple(cycle)) + cycle_items = _require_str_list_value( + cycle, + error_message=f"{key!r} cycle item must be list[str]", + path=path, + ) + cycles.append(tuple(cycle_items)) return tuple(sorted(set(cycles))) @@ -267,6 +293,7 @@ def _parse_generator( return generator, version_value if isinstance(generator, dict): + generator = _require_object(generator, label="generator", path=path) allowed_keys = {"name", "version"} extra = set(generator.keys()) - allowed_keys if extra: @@ -304,16 +331,8 @@ def _require_embedded_clone_baseline_payload( ) -> tuple[dict[str, object], dict[str, object]]: meta_obj = payload.get("meta") clones_obj = payload.get("clones") - if not isinstance(meta_obj, dict): - raise BaselineValidationError( - f"Invalid baseline schema at {path}: 'meta' must be object", - status=MetricsBaselineStatus.INVALID_TYPE, - ) - if not isinstance(clones_obj, dict): - raise BaselineValidationError( - f"Invalid baseline schema at {path}: 'clones' must be object", - status=MetricsBaselineStatus.INVALID_TYPE, - ) + meta_obj = _require_object(meta_obj, label="'meta'", path=path) + clones_obj = _require_object(clones_obj, label="'clones'", path=path) _require_str(meta_obj, "payload_sha256", path=path) _require_str(meta_obj, "python_tag", path=path) _require_str(meta_obj, "created_at", path=path) @@ -512,6 +531,7 @@ def _parse_api_surface_snapshot( "api surface module must be object", status=MetricsBaselineStatus.INVALID_TYPE, ) + raw_module = _require_object(raw_module, label="api surface module", path=path) module = _require_str(raw_module, "module", path=path) wire_filepath = _require_str(raw_module, "filepath", path=path) filepath = runtime_filepath_from_wire(wire_filepath, root=root) @@ -531,6 +551,11 @@ def _parse_api_surface_snapshot( "api surface symbol must be object", status=MetricsBaselineStatus.INVALID_TYPE, ) + raw_symbol = _require_object( + raw_symbol, + label="api surface symbol", + path=path, + ) local_name = _optional_require_str(raw_symbol, "local_name", path=path) legacy_qualname = _optional_require_str(raw_symbol, "qualname", path=path) if local_name is None and legacy_qualname is None: @@ -564,6 +589,7 @@ def _parse_api_surface_snapshot( "api param must be object", status=MetricsBaselineStatus.INVALID_TYPE, ) + raw_param = _require_object(raw_param, label="api param", path=path) name = _require_str(raw_param, "name", path=path) param_kind = _require_str(raw_param, "kind", path=path) has_default = raw_param.get("has_default") diff --git a/codeclone/baseline/clone_baseline.py b/codeclone/baseline/clone_baseline.py index 74222329..c57c4fac 100644 --- a/codeclone/baseline/clone_baseline.py +++ b/codeclone/baseline/clone_baseline.py @@ -117,16 +117,8 @@ def load( 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=_trust.BaselineStatus.INVALID_TYPE, - ) - if not isinstance(clones_obj, dict): - raise BaselineValidationError( - f"Invalid baseline schema at {self.path}: 'clones' must be object", - status=_trust.BaselineStatus.INVALID_TYPE, - ) + meta_obj = _require_object(meta_obj, label="'meta'", path=self.path) + clones_obj = _require_object(clones_obj, label="'clones'", 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) @@ -211,6 +203,8 @@ def save(self) -> None: if preserved_metrics_hash is not None: meta_obj = payload.get("meta") if isinstance(meta_obj, dict): + meta_obj = _require_object(meta_obj, label="'meta'", path=self.path) + payload["meta"] = meta_obj meta_obj["metrics_payload_sha256"] = preserved_metrics_hash if preserved_api_surface_hash is not None: meta_obj["api_surface_payload_sha256"] = ( @@ -412,6 +406,36 @@ def _validate_top_level_structure(payload: dict[str, object], *, path: Path) -> ) +def _require_object(value: object, *, label: str, path: Path) -> dict[str, object]: + if not isinstance(value, dict): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: {label} must be object", + status=_trust.BaselineStatus.INVALID_TYPE, + ) + + result: dict[str, object] = {} + for raw_key, item in value.items(): + if not isinstance(raw_key, str): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: {label} keys must be str", + status=_trust.BaselineStatus.INVALID_TYPE, + ) + result[raw_key] = item + return result + + +def _optional_object( + value: object, + *, + label: str, + path: Path, +) -> dict[str, object] | None: + try: + return _require_object(value, label=label, path=path) + except BaselineValidationError: + return None + + def _validate_required_keys( obj: dict[str, object], required: set[str], *, path: Path ) -> None: @@ -453,14 +477,18 @@ def _preserve_embedded_metrics( return None, None, None, None metrics_obj = payload.get("metrics") api_surface_obj = payload.get("api_surface") - preserved_api_surface = ( - dict(api_surface_obj) if isinstance(api_surface_obj, dict) else None + preserved_api_surface = _optional_object( + api_surface_obj, + label="'api_surface'", + path=path, ) - if not isinstance(metrics_obj, dict): + metrics_obj = _optional_object(metrics_obj, label="'metrics'", path=path) + if metrics_obj is None: return None, None, preserved_api_surface, None meta_obj = payload.get("meta") - if not isinstance(meta_obj, dict): - return dict(metrics_obj), None, preserved_api_surface, None + meta_obj = _optional_object(meta_obj, label="'meta'", path=path) + if meta_obj is None: + return metrics_obj, None, preserved_api_surface, None metrics_hash = meta_obj.get("metrics_payload_sha256") api_surface_hash = meta_obj.get("api_surface_payload_sha256") normalized_api_surface_hash = ( @@ -468,13 +496,13 @@ def _preserve_embedded_metrics( ) if not isinstance(metrics_hash, str): return ( - dict(metrics_obj), + metrics_obj, None, preserved_api_surface, normalized_api_surface_hash, ) return ( - dict(metrics_obj), + metrics_obj, metrics_hash, preserved_api_surface, normalized_api_surface_hash, diff --git a/codeclone/baseline/metrics_baseline.py b/codeclone/baseline/metrics_baseline.py index 2aecf3cf..25f14ee4 100644 --- a/codeclone/baseline/metrics_baseline.py +++ b/codeclone/baseline/metrics_baseline.py @@ -49,6 +49,7 @@ _parse_generator, _parse_snapshot, _require_embedded_clone_baseline_payload, + _require_object, _require_str, _resolve_embedded_schema_version, _validate_exact_keys, @@ -87,18 +88,29 @@ def probe_metrics_baseline_section(path: Path) -> MetricsBaselineSectionProbe: has_metrics_section=True, payload=None, ) - if not isinstance(raw_payload, dict): + payload = _probe_json_object(raw_payload, path=path) + if payload is None: return MetricsBaselineSectionProbe( has_metrics_section=True, payload=None, ) - payload = dict(raw_payload) return MetricsBaselineSectionProbe( has_metrics_section=("metrics" in payload), payload=payload, ) +def _probe_json_object(value: object, *, path: Path) -> dict[str, object] | None: + try: + return _require_object( + value, + label="metrics baseline payload", + path=path, + ) + except BaselineValidationError: + return None + + class MetricsBaseline: __slots__ = ( "api_surface_payload_sha256", @@ -179,18 +191,8 @@ def load( meta_obj = payload.get("meta") metrics_obj = payload.get("metrics") - if not isinstance(meta_obj, dict): - raise BaselineValidationError( - f"Invalid metrics baseline schema at {self.path}: " - "'meta' must be object", - status=MetricsBaselineStatus.INVALID_TYPE, - ) - if not isinstance(metrics_obj, dict): - raise BaselineValidationError( - f"Invalid metrics baseline schema at {self.path}: " - "'metrics' must be object", - status=MetricsBaselineStatus.INVALID_TYPE, - ) + meta_obj = _require_object(meta_obj, label="'meta'", path=self.path) + metrics_obj = _require_object(metrics_obj, label="'metrics'", path=self.path) _validate_required_keys(meta_obj, _META_REQUIRED_KEYS, path=self.path) _validate_required_keys(metrics_obj, _METRICS_REQUIRED_KEYS, path=self.path) @@ -243,12 +245,7 @@ def save(self) -> None: api_surface_root=self.path.parent, ) payload_meta = payload.get("meta") - if not isinstance(payload_meta, dict): - raise BaselineValidationError( - f"Invalid metrics baseline schema at {self.path}: " - "'meta' must be object", - status=MetricsBaselineStatus.INVALID_TYPE, - ) + payload_meta = _require_object(payload_meta, label="'meta'", path=self.path) payload_metrics_hash = _require_str( payload_meta, "payload_sha256", diff --git a/codeclone/baseline/trust.py b/codeclone/baseline/trust.py index fa8179c7..3e764212 100644 --- a/codeclone/baseline/trust.py +++ b/codeclone/baseline/trust.py @@ -22,7 +22,7 @@ from ..utils.json_io import read_json_object as _read_json_object if TYPE_CHECKING: - from collections.abc import Collection + from collections.abc import Collection, Sequence BASELINE_GENERATOR = "codeclone" _BASELINE_SCHEMA_MAX_MINOR_BY_MAJOR = {1: 0, 2: 1} @@ -117,16 +117,17 @@ def _parse_generator_meta( return raw_generator, generator_version if isinstance(raw_generator, dict): + generator_obj = _generator_object(raw_generator, path=path) allowed_keys = {"name", "version"} - extra = set(raw_generator.keys()) - allowed_keys + extra = set(generator_obj.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) + generator_name = _require_str(generator_obj, "name", path=path) + generator_version = _optional_str(generator_obj, "version", path=path) if generator_version is None: generator_version = _optional_str(meta_obj, "generator_version", path=path) @@ -143,6 +144,24 @@ def _parse_generator_meta( ) +def _generator_object(value: object, *, path: Path) -> dict[str, object]: + if not isinstance(value, dict): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: 'generator' must be object", + status=BaselineStatus.INVALID_TYPE, + ) + + invalid_keys = [key for key in value if not isinstance(key, str)] + if invalid_keys: + raise BaselineValidationError( + f"Invalid baseline schema at {path}: 'generator' keys must be str", + status=BaselineStatus.INVALID_TYPE, + ) + return { + raw_key: item for raw_key, item in value.items() if isinstance(raw_key, str) + } + + def _compute_payload_sha256( *, functions: Collection[str], @@ -261,12 +280,7 @@ def _require_sorted_unique_ids( 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) + values = _require_str_items(value, key=key, path=path) if values != sorted(values) or len(values) != len(set(values)): raise BaselineValidationError( f"Invalid baseline schema at {path}: '{key}' must be sorted and unique", @@ -280,6 +294,16 @@ def _require_sorted_unique_ids( return values +def _require_str_items(value: Sequence[object], *, key: str, path: Path) -> list[str]: + values = [item for item in value if isinstance(item, str)] + if len(values) != len(value): + raise BaselineValidationError( + f"Invalid baseline schema at {path}: '{key}' must be list[str]", + status=BaselineStatus.INVALID_TYPE, + ) + return values + + __all__ = [ "BASELINE_GENERATOR", "BASELINE_UNTRUSTED_STATUSES", diff --git a/codeclone/cache/_canonicalize.py b/codeclone/cache/_canonicalize.py index 02432e5a..30c6bf22 100644 --- a/codeclone/cache/_canonicalize.py +++ b/codeclone/cache/_canonicalize.py @@ -45,8 +45,10 @@ SegmentDict, SourceStatsDict, StructuralFindingGroupDict, + StructuralFindingOccurrenceDict, UnitDict, ) +from .integrity import as_str_dict as _as_str_key_dict _ValidatedItemT = TypeVar("_ValidatedItemT") @@ -55,6 +57,18 @@ def _is_str_item(value: object) -> TypeGuard[str]: return isinstance(value, str) +def _as_str_value_dict(value: object) -> dict[str, str] | None: + mapping = _as_str_key_dict(value) + if mapping is None: + return None + result: dict[str, str] = {} + for key, item in mapping.items(): + if not isinstance(item, str): + return None + result[key] = item + return result + + def _as_file_stat_dict(value: object) -> FileStat | None: if not _is_file_stat_dict(value): return None @@ -137,6 +151,86 @@ def _as_typed_string_list(value: object) -> list[str] | None: return _as_typed_list(value, predicate=_is_str_item) +def _as_structural_occurrence_dict( + value: object, +) -> StructuralFindingOccurrenceDict | None: + item = _as_str_key_dict(value) + if item is None: + return None + qualname = item.get("qualname") + start = item.get("start") + end = item.get("end") + if ( + not isinstance(qualname, str) + or not isinstance(start, int) + or not isinstance(end, int) + ): + return None + return StructuralFindingOccurrenceDict(qualname=qualname, start=start, end=end) + + +def _as_typed_structural_occurrence_list( + value: object, +) -> list[StructuralFindingOccurrenceDict] | None: + if not isinstance(value, list): + return None + items: list[StructuralFindingOccurrenceDict] = [] + for raw_item in value: + item = _as_structural_occurrence_dict(raw_item) + if item is None: + return None + items.append(item) + return items + + +def _as_structural_group_dict(value: object) -> StructuralFindingGroupDict | None: + group = _as_str_key_dict(value) + if group is None: + return None + finding_kind = group.get("finding_kind") + finding_key = group.get("finding_key") + signature = _as_str_value_dict(group.get("signature")) + raw_items = group.get("items") + if ( + not isinstance(finding_kind, str) + or not isinstance(finding_key, str) + or signature is None + ): + return None + items = _as_typed_structural_occurrence_list(raw_items) + if items is None: + return None + return StructuralFindingGroupDict( + finding_kind=finding_kind, + finding_key=finding_key, + signature=signature, + items=items, + ) + + +def _as_typed_structural_finding_list( + value: object, +) -> list[StructuralFindingGroupDict] | None: + if not isinstance(value, list): + return None + groups: list[StructuralFindingGroupDict] = [] + for raw_group in value: + group = _as_structural_group_dict(raw_group) + if group is None: + return None + groups.append(group) + return groups + + +def _decode_structural_findings_section( + value: object, +) -> tuple[bool, list[StructuralFindingGroupDict] | None]: + if value is None: + return True, None + structural_findings = _as_typed_structural_finding_list(value) + return structural_findings is not None, structural_findings + + def _as_module_typing_coverage_dict( value: object, ) -> ModuleTypingCoverageDict | None: @@ -167,7 +261,8 @@ def _normalized_optional_string_list(value: object) -> list[str] | None: def _is_canonical_cache_entry(value: object) -> TypeGuard[CacheEntry]: - return isinstance(value, dict) and _has_cache_entry_container_shape(value) + entry = _as_str_key_dict(value) + return entry is not None and _has_cache_entry_container_shape(entry) def _has_cache_entry_container_shape(entry: Mapping[str, object]) -> bool: @@ -256,6 +351,9 @@ def _decode_optional_cache_sections( function_relationship_facts_raw = _as_typed_function_relationship_facts_list( entry.get("function_relationship_facts", []) ) + structural_findings_valid, typed_structural_findings = ( + _decode_structural_findings_section(entry.get("structural_findings")) + ) if ( class_metrics_raw is None or module_deps_raw is None @@ -267,6 +365,7 @@ def _decode_optional_cache_sections( or runtime_reachability_raw is None or security_surfaces_raw is None or function_relationship_facts_raw is None + or not structural_findings_valid ): return None typing_coverage_raw = _as_module_typing_coverage_dict(entry.get("typing_coverage")) @@ -275,10 +374,6 @@ def _decode_optional_cache_sections( ) api_surface_raw = _as_module_api_surface_dict(entry.get("api_surface")) source_stats = _as_source_stats_dict(entry.get("source_stats")) - structural_findings = entry.get("structural_findings") - typed_structural_findings = ( - structural_findings if isinstance(structural_findings, list) else None - ) return ( class_metrics_raw, module_deps_raw, diff --git a/codeclone/cache/_validators.py b/codeclone/cache/_validators.py index 04638278..a67de8a9 100644 --- a/codeclone/cache/_validators.py +++ b/codeclone/cache/_validators.py @@ -6,7 +6,7 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Sequence from typing import TypeGuard from .entries import ( @@ -318,11 +318,13 @@ def _is_string_list(value: object) -> TypeGuard[list[str]]: def _has_typed_fields( - value: Mapping[str, object], + value: object, *, string_keys: Sequence[str], int_keys: Sequence[str], ) -> bool: + if not isinstance(value, dict): + return False return all(isinstance(value.get(key), str) for key in string_keys) and all( isinstance(value.get(key), int) for key in int_keys ) diff --git a/codeclone/cache/_wire_encode.py b/codeclone/cache/_wire_encode.py index 965f7a64..03ae006c 100644 --- a/codeclone/cache/_wire_encode.py +++ b/codeclone/cache/_wire_encode.py @@ -177,7 +177,7 @@ def _encode_dead_candidates(entry: CacheEntry, wire: dict[str, object]) -> None: if dead_candidates: encoded_dead_candidates: list[list[object]] = [] for candidate in dead_candidates: - encoded = [ + encoded: list[object] = [ candidate["qualname"], candidate["local_name"], candidate["start_line"], diff --git a/codeclone/cache/entries.py b/codeclone/cache/entries.py index c8466a1e..eed31073 100644 --- a/codeclone/cache/entries.py +++ b/codeclone/cache/entries.py @@ -312,126 +312,120 @@ def _as_risk_literal(value: object) -> Literal["low", "medium", "high"] | None: def _as_relationship_kind(value: object) -> Literal["call", "reference"] | None: - match value: - case "call" | "reference": - return value - case _: - return None + if value == "call": + return "call" + if value == "reference": + return "reference" + return None def _as_relationship_resolution_status( value: object, ) -> Literal["resolved", "unresolved"] | None: - match value: - case "resolved" | "unresolved": - return value - case _: - return None + if value == "resolved": + return "resolved" + if value == "unresolved": + return "unresolved" + return None def _as_relationship_origin_lane( value: object, ) -> Literal["production", "test"] | None: - match value: - case "production" | "test": - return value - case _: - return None + if value == "production": + return "production" + if value == "test": + return "test" + return None def _as_security_surface_category(value: object) -> str | None: - match value: - case ( - "archive_extraction" - | "crypto_transport" - | "database_boundary" - | "deserialization" - | "dynamic_execution" - | "dynamic_loading" - | "filesystem_mutation" - | "identity_token" - | "network_boundary" - | "process_boundary" - ): - return value - case _: - return None + if not isinstance(value, str): + return None + if value in { + "archive_extraction", + "crypto_transport", + "database_boundary", + "deserialization", + "dynamic_execution", + "dynamic_loading", + "filesystem_mutation", + "identity_token", + "network_boundary", + "process_boundary", + }: + return value + return None def _as_security_surface_location_scope(value: object) -> str | None: - match value: - case "module" | "class" | "callable": - return value - case _: - return None + if isinstance(value, str) and value in {"module", "class", "callable"}: + return value + return None def _as_security_surface_classification_mode(value: object) -> str | None: - match value: - case "exact_builtin" | "exact_call" | "exact_import": - return value - case _: - return None + if isinstance(value, str) and value in { + "exact_builtin", + "exact_call", + "exact_import", + }: + return value + return None def _as_security_surface_evidence_kind(value: object) -> str | None: - match value: - case "builtin" | "call" | "import": - return value - case _: - return None + if isinstance(value, str) and value in {"builtin", "call", "import"}: + return value + return None def _as_runtime_reachability_framework(value: object) -> str | None: - match value: - case ( - "aiogram" - | "aiohttp" - | "flask" - | "celery" - | "click" - | "dependency_injector" - | "django" - | "fastapi" - | "pydantic" - | "sqlalchemy" - | "starlette" - | "typer" - ): - return value - case _: - return None + if not isinstance(value, str): + return None + if value in { + "aiogram", + "aiohttp", + "flask", + "celery", + "click", + "dependency_injector", + "django", + "fastapi", + "pydantic", + "sqlalchemy", + "starlette", + "typer", + }: + return value + return None def _as_runtime_reachability_edge_kind(value: object) -> str | None: - match value: - case ( - "declares_dependency" - | "provides" - | "registers_command" - | "registers_handler" - | "registers_task" - | "runtime_hook" - ): - return value - case _: - return None + if not isinstance(value, str): + return None + if value in { + "declares_dependency", + "provides", + "registers_command", + "registers_handler", + "registers_task", + "runtime_hook", + }: + return value + return None def _as_runtime_reachability_confidence(value: object) -> str | None: - match value: - case "high" | "medium" | "low": - return value - case _: - return None + if isinstance(value, str) and value in {"high", "medium", "low"}: + return value + return None def _as_runtime_reachability_target_kind(value: object) -> str | None: - match value: - case "function" | "class" | "method": - return value - case _: - return None + if isinstance(value, str) and value in {"function", "class", "method"}: + return value + return None def _new_optional_metrics_payload() -> tuple[ diff --git a/codeclone/cache/integrity.py b/codeclone/cache/integrity.py index 977dafaa..7dda3cca 100644 --- a/codeclone/cache/integrity.py +++ b/codeclone/cache/integrity.py @@ -11,7 +11,8 @@ from collections.abc import Mapping from pathlib import Path -from ..utils.json_io import json_text as _json_text +import orjson + from ..utils.json_io import read_json_document as _read_json_document from ..utils.json_io import ( write_json_document_atomically as _write_json_document_atomically, @@ -27,24 +28,28 @@ def as_int_or_none(value: object) -> int | None: def as_object_list(value: object) -> list[object] | None: - return value if isinstance(value, list) else None + if not isinstance(value, list): + return None + return list(value) def as_str_dict(value: object) -> dict[str, object] | None: if not isinstance(value, dict): return None - if not all(isinstance(key, str) for key in value): - return None - return value + result: dict[str, object] = {} + for raw_key, item in value.items(): + if not isinstance(raw_key, str): + return None + result[raw_key] = item + return result def canonical_json(data: object) -> str: - return _json_text(data, sort_keys=True) + return _canonical_json_bytes(data).decode("utf-8") def sign_cache_payload(data: Mapping[str, object]) -> str: - canonical = canonical_json(data) - return hashlib.sha256(canonical.encode("utf-8")).hexdigest() + return hashlib.sha256(_canonical_json_bytes(data)).hexdigest() def verify_cache_payload_signature( @@ -54,6 +59,10 @@ def verify_cache_payload_signature( return hmac.compare_digest(signature, sign_cache_payload(payload)) +def _canonical_json_bytes(data: object) -> bytes: + return orjson.dumps(data, option=orjson.OPT_SORT_KEYS) + + def read_json_document(path: Path, *, max_bytes: int | None = None) -> object: if max_bytes is None: return _read_json_document(path) diff --git a/codeclone/cache/store.py b/codeclone/cache/store.py index 03659d58..0878fe24 100644 --- a/codeclone/cache/store.py +++ b/codeclone/cache/store.py @@ -32,6 +32,7 @@ StructuralFindingGroup, Unit, ) +from ..observability import span from ._canonicalize import ( _as_file_stat_dict, _as_typed_block_list, @@ -134,6 +135,7 @@ class Cache: __slots__ = ( "_canonical_runtime_paths", "_dirty", + "_write_enabled", "analysis_profile", "cache_schema_version", "data", @@ -162,9 +164,11 @@ def __init__( segment_min_loc: int = DEFAULT_SEGMENT_MIN_LOC, segment_min_stmt: int = DEFAULT_SEGMENT_MIN_STMT, collect_api_surface: bool = False, + write_enabled: bool = True, ): self.path = Path(path) self.root = _resolve_root(root) + self._write_enabled = write_enabled self.fingerprint_version = BASELINE_FINGERPRINT_VERSION self.analysis_profile: AnalysisProfile = { "min_loc": min_loc, @@ -190,7 +194,7 @@ def __init__( MAX_CACHE_SIZE_BYTES if max_size_bytes is None else max_size_bytes ) self.segment_report_projection: SegmentReportProjection | None = None - self._dirty: bool = True + self._dirty: bool = write_enabled def _detect_legacy_secret_warning(self) -> str | None: secret_path = self.path.parent / LEGACY_CACHE_SECRET_FILENAME @@ -264,14 +268,22 @@ def _reject_version_mismatch(self, version: str) -> CacheData | None: ) def load(self) -> None: - try: - exists = self.path.exists() - except OSError as exc: - self._ignore_cache( - f"Cache unreadable; ignoring cache: {exc}", - status=CacheStatus.UNREADABLE, - ) - return + size: int | None = None + with span(name="cache.stat") as stat_span: + try: + exists = self.path.exists() + except OSError as exc: + self._ignore_cache( + f"Cache unreadable; ignoring cache: {exc}", + status=CacheStatus.UNREADABLE, + ) + return + if exists: + try: + size = self.path.stat().st_size + stat_span.set_counter("cache_file_bytes", size) + except OSError: + pass if not exists: self._set_load_warning(None) @@ -282,7 +294,10 @@ def load(self) -> None: return try: - size = self.path.stat().st_size + if size is None: + with span(name="cache.stat") as stat_span: + size = self.path.stat().st_size + stat_span.set_counter("cache_file_bytes", size) if size > self.max_size_bytes: self._ignore_cache( "Cache file too large " @@ -291,7 +306,9 @@ def load(self) -> None: ) return - raw_obj = read_json_document(self.path, max_bytes=self.max_size_bytes) + with span(name="cache.read_json") as read_span: + read_span.set_counter("cache_file_bytes", size) + raw_obj = read_json_document(self.path, max_bytes=self.max_size_bytes) parsed = self._load_and_validate(raw_obj) if parsed is None: return @@ -312,94 +329,105 @@ def load(self) -> None: ) def _load_and_validate(self, raw_obj: object) -> CacheData | None: - raw = _as_str_dict(raw_obj) - if raw is None: - return self._reject_invalid_cache_format() - - legacy_version = _as_str(raw.get("version")) - if legacy_version is not None: - return self._reject_version_mismatch(legacy_version) - - version = _as_str(raw.get("v")) - if version is None: - return self._reject_invalid_cache_format() - - if version != self._CACHE_VERSION: - return self._reject_version_mismatch(version) - - sig = _as_str(raw.get("sig")) - payload = _as_str_dict(raw.get("payload")) - if sig is None or payload is None: - return self._reject_invalid_cache_format(schema_version=version) - - if not verify_cache_payload_signature(payload, sig): - return self._reject_cache_load( - "Cache signature mismatch; ignoring cache.", - status=CacheStatus.INTEGRITY_FAILED, - schema_version=version, - ) + with span(name="cache.validate_envelope"): + raw = _as_str_dict(raw_obj) + if raw is None: + return self._reject_invalid_cache_format() - runtime_tag = current_python_tag() - py_tag = _as_str(payload.get("py")) - if py_tag is None: - return self._reject_invalid_cache_format(schema_version=version) - - if py_tag != runtime_tag: - return self._reject_cache_load( - "Cache python tag mismatch " - f"(found {py_tag}, expected {runtime_tag}); ignoring cache.", - status=CacheStatus.PYTHON_TAG_MISMATCH, - schema_version=version, - ) + legacy_version = _as_str(raw.get("version")) + if legacy_version is not None: + return self._reject_version_mismatch(legacy_version) - fp_version = _as_str(payload.get("fp")) - if fp_version is None: - return self._reject_invalid_cache_format(schema_version=version) - - if fp_version != self.fingerprint_version: - return self._reject_cache_load( - "Cache fingerprint version mismatch " - f"(found {fp_version}, expected {self.fingerprint_version}); " - "ignoring cache.", - status=CacheStatus.FINGERPRINT_MISMATCH, - schema_version=version, - ) + version = _as_str(raw.get("v")) + if version is None: + return self._reject_invalid_cache_format() - analysis_profile = _as_analysis_profile(payload.get("ap")) - if analysis_profile is None: - return self._reject_invalid_cache_format(schema_version=version) - - if analysis_profile != self.analysis_profile: - return self._reject_cache_load( - "Cache analysis profile mismatch " - f"(found min_loc={analysis_profile['min_loc']}, " - f"min_stmt={analysis_profile['min_stmt']}, " - "collect_api_surface=" - f"{str(analysis_profile['collect_api_surface']).lower()}; " - f"expected min_loc={self.analysis_profile['min_loc']}, " - f"min_stmt={self.analysis_profile['min_stmt']}, " - "collect_api_surface=" - f"{str(self.analysis_profile['collect_api_surface']).lower()}); " - "ignoring cache.", - status=CacheStatus.ANALYSIS_PROFILE_MISMATCH, - schema_version=version, - ) + if version != self._CACHE_VERSION: + return self._reject_version_mismatch(version) - files_dict = _as_str_dict(payload.get("files")) - if files_dict is None: - return self._reject_invalid_cache_format(schema_version=version) + sig = _as_str(raw.get("sig")) + payload = _as_str_dict(raw.get("payload")) + if sig is None or payload is None: + return self._reject_invalid_cache_format(schema_version=version) - parsed_files: dict[str, CacheEntry] = {} - for wire_path, file_entry_obj in files_dict.items(): - runtime_path = runtime_filepath_from_wire(wire_path, root=self.root) - parsed_entry = self._decode_entry(file_entry_obj, runtime_path) - if parsed_entry is None: + if not verify_cache_payload_signature(payload, sig): + return self._reject_cache_load( + "Cache signature mismatch; ignoring cache.", + status=CacheStatus.INTEGRITY_FAILED, + schema_version=version, + ) + + runtime_tag = current_python_tag() + py_tag = _as_str(payload.get("py")) + if py_tag is None: return self._reject_invalid_cache_format(schema_version=version) - parsed_files[runtime_path] = _canonicalize_cache_entry(parsed_entry) - self.segment_report_projection = decode_segment_report_projection( - payload.get("sr"), - root=self.root, - ) + + if py_tag != runtime_tag: + return self._reject_cache_load( + "Cache python tag mismatch " + f"(found {py_tag}, expected {runtime_tag}); ignoring cache.", + status=CacheStatus.PYTHON_TAG_MISMATCH, + schema_version=version, + ) + + fp_version = _as_str(payload.get("fp")) + if fp_version is None: + return self._reject_invalid_cache_format(schema_version=version) + + if fp_version != self.fingerprint_version: + return self._reject_cache_load( + "Cache fingerprint version mismatch " + f"(found {fp_version}, expected {self.fingerprint_version}); " + "ignoring cache.", + status=CacheStatus.FINGERPRINT_MISMATCH, + schema_version=version, + ) + + analysis_profile = _as_analysis_profile(payload.get("ap")) + if analysis_profile is None: + return self._reject_invalid_cache_format(schema_version=version) + + if analysis_profile != self.analysis_profile: + return self._reject_cache_load( + "Cache analysis profile mismatch " + f"(found min_loc={analysis_profile['min_loc']}, " + f"min_stmt={analysis_profile['min_stmt']}, " + "collect_api_surface=" + f"{str(analysis_profile['collect_api_surface']).lower()}; " + f"expected min_loc={self.analysis_profile['min_loc']}, " + f"min_stmt={self.analysis_profile['min_stmt']}, " + "collect_api_surface=" + f"{str(self.analysis_profile['collect_api_surface']).lower()}); " + "ignoring cache.", + status=CacheStatus.ANALYSIS_PROFILE_MISMATCH, + schema_version=version, + ) + + files_dict = _as_str_dict(payload.get("files")) + if files_dict is None: + return self._reject_invalid_cache_format(schema_version=version) + segment_projection_obj = payload.get("sr") + payload.pop("files", None) + payload.clear() + raw.clear() + + parsed_files: dict[str, CacheEntry] = {} + with span(name="cache.decode_entries") as decode_span: + decode_span.set_counter("cache_entries", len(files_dict)) + while files_dict: + wire_path = next(iter(files_dict)) + file_entry_obj = files_dict.pop(wire_path) + runtime_path = runtime_filepath_from_wire(wire_path, root=self.root) + parsed_entry = self._decode_entry(file_entry_obj, runtime_path) + if parsed_entry is None: + return self._reject_invalid_cache_format(schema_version=version) + parsed_files[runtime_path] = _canonicalize_cache_entry(parsed_entry) + decode_span.set_counter("decoded_entries", len(parsed_files)) + with span(name="cache.segment_projection"): + self.segment_report_projection = decode_segment_report_projection( + segment_projection_obj, + root=self.root, + ) self.cache_schema_version = version return CacheData( @@ -411,6 +439,8 @@ def _load_and_validate(self, raw_obj: object) -> CacheData | None: ) def save(self) -> None: + if not self._write_enabled: + return if not self._dirty: return try: @@ -452,6 +482,16 @@ def save(self) -> None: except OSError as exc: raise CacheError(f"Failed to save cache: {exc}") from exc + def release_loaded_entries(self, *, allow_dirty: bool = False) -> int: + if self._dirty and not allow_dirty: + return 0 + with span(name="cache.release_entries") as release_span: + released = len(self.data["files"]) + self.data["files"] = {} + self._canonical_runtime_paths.clear() + release_span.set_counter("released_entries", released) + return released + @staticmethod def _decode_entry(value: object, filepath: str) -> CacheEntry | None: return _decode_wire_file_entry(value, filepath) @@ -565,6 +605,8 @@ def put_file_entry( structural_findings: list[StructuralFindingGroup] | None = None, function_relationship_facts: Sequence[FunctionRelationshipFacts] | None = None, ) -> None: + if not self._write_enabled: + return runtime_path = runtime_filepath_from_wire( wire_filepath_from_runtime(filepath, root=self.root), root=self.root, @@ -685,6 +727,8 @@ def put_file_entry( ) def prune_file_entries(self, existing_filepaths: Collection[str]) -> int: + if not self._write_enabled: + return 0 keep_runtime_paths = { runtime_filepath_from_wire( wire_filepath_from_runtime(filepath, root=self.root), diff --git a/codeclone/config/argparse_builder.py b/codeclone/config/argparse_builder.py index 4b02d043..bff5aa5d 100644 --- a/codeclone/config/argparse_builder.py +++ b/codeclone/config/argparse_builder.py @@ -7,12 +7,38 @@ import argparse import sys -from typing import NoReturn +from collections.abc import Callable, Iterable +from typing import NoReturn, Protocol, TypeVar, overload from .. import ui_messages as ui from ..contracts import ExitCode, cli_help_epilog from .spec import ARGUMENT_GROUP_TITLES, DEFAULTS_BY_DEST, OPTIONS, OptionSpec +_NamespaceT = TypeVar("_NamespaceT") + + +class _TextWriter(Protocol): + def write(self, text: str, /) -> object: ... + + +def _handle_interactive_help( + argv: tuple[str, ...], + *, + on_error: Callable[[str], NoReturn], +) -> None: + from ..surfaces.cli.ui.help_presenter import ( + help_flag_present, + interactive_help_requested, + ) + + if not interactive_help_requested(argv): + return + if not help_flag_present(argv): + on_error("--interactive-help must be used with --help") + from ..surfaces.cli.ui.help_tour import run_interactive_help_tour + + raise SystemExit(run_interactive_help_tour()) + class _ArgumentParser(argparse.ArgumentParser): def error(self, message: str) -> NoReturn: @@ -22,6 +48,45 @@ def error(self, message: str) -> NoReturn: f"CONTRACT ERROR: {message}\n", ) + def print_help(self, file: _TextWriter | None = None) -> None: + from ..surfaces.cli.ui.help_presenter import print_static_help_mascot + + print_static_help_mascot(file=file) + super().print_help(file=file) + + @overload + def parse_args( + self, + args: Iterable[str] | None = None, + namespace: None = None, + ) -> argparse.Namespace: ... + + @overload + def parse_args( + self, + args: Iterable[str] | None, + namespace: _NamespaceT, + ) -> _NamespaceT: ... + + @overload + def parse_args( + self, + *, + namespace: _NamespaceT, + ) -> _NamespaceT: ... + + def parse_args( + self, + args: Iterable[str] | None = None, + namespace: _NamespaceT | None = None, + ) -> argparse.Namespace | _NamespaceT: + argv = tuple(args) if args is not None else tuple(sys.argv[1:]) + _handle_interactive_help(argv, on_error=self.error) + super_args = argv if args is not None else None + if namespace is None: + return super().parse_args(super_args) + return super().parse_args(super_args, namespace) + class _HelpFormatter(argparse.RawTextHelpFormatter): """Product-oriented help formatter extension point.""" @@ -42,52 +107,78 @@ def _add_option( ) return - argument_kwargs: dict[str, object] = {"help": option.help_text} - if option.cli_kind == "value": - argument_kwargs.update( - dest=option.dest, - nargs=option.nargs, - const=option.const, - metavar=option.metavar, - ) - if option.value_type is not None: - argument_kwargs["type"] = option.value_type + if option.value_type is None: + group.add_argument( + *option.flags, + dest=option.dest, + nargs=option.nargs, + const=option.const, + metavar=option.metavar, + help=option.help_text, + ) + return + if option.value_type is int: + group.add_argument( + *option.flags, + dest=option.dest, + nargs=option.nargs, + const=option.const, + metavar=option.metavar, + type=int, + help=option.help_text, + ) + return + raise RuntimeError(f"Unsupported CLI option value type: {option.value_type}") elif option.cli_kind == "optional_path": - argument_kwargs.update( + group.add_argument( + *option.flags, dest=option.dest, nargs="?", const=option.const, metavar=option.metavar or "FILE", + help=option.help_text, ) + return elif option.cli_kind == "bool_optional": - argument_kwargs.update( + group.add_argument( + *option.flags, action=argparse.BooleanOptionalAction, default=argparse.SUPPRESS, + help=option.help_text, ) + return elif option.cli_kind in {"store_true", "store_false"}: - argument_kwargs.update( + group.add_argument( + *option.flags, dest=option.dest, action=option.cli_kind, default=argparse.SUPPRESS, + help=option.help_text, ) + return elif option.cli_kind == "help": - argument_kwargs["action"] = "help" + group.add_argument(*option.flags, action="help", help=option.help_text) + return elif option.cli_kind == "version": - argument_kwargs.update( + group.add_argument( + *option.flags, action="version", version=ui.version_output(version), + help=option.help_text, ) + return else: raise RuntimeError(f"Unsupported CLI option kind: {option.cli_kind}") - group.add_argument(*option.flags, **argument_kwargs) # type: ignore[arg-type] - def build_parser(version: str) -> _ArgumentParser: parser = _ArgumentParser( prog="codeclone", - description="Structural code quality analysis for Python.", + description=( + "Deterministic Structural Change Controller for AI-assisted " + "Python development." + ), add_help=False, formatter_class=_HelpFormatter, epilog=cli_help_epilog(), diff --git a/codeclone/config/intent_registry.py b/codeclone/config/intent_registry.py index d01855d9..03c5da05 100644 --- a/codeclone/config/intent_registry.py +++ b/codeclone/config/intent_registry.py @@ -9,7 +9,7 @@ import os from dataclasses import dataclass from pathlib import Path -from typing import Final +from typing import Final, TypeGuard from ..utils.repo_paths import ( PathOutsideRepoError, @@ -24,7 +24,7 @@ MIN_INTENT_REGISTRY_RETENTION_DAYS, IntentRegistryBackend, ) -from .pyproject_loader import _load_toml +from .pyproject_loader import _load_toml, copy_str_key_table INTENT_REGISTRY_BACKENDS: Final[frozenset[str]] = frozenset({"file", "sqlite"}) _VALID_DB_SUFFIXES: Final[frozenset[str]] = frozenset({".sqlite3", ".db"}) @@ -77,7 +77,9 @@ def resolve_intent_registry_backend( raise IntentRegistryConfigError( f"intent_registry_backend must be one of: {expected}" ) - return backend # type: ignore[return-value] + if _is_intent_registry_backend(backend): + return backend + raise AssertionError("unreachable validated intent registry backend") def resolve_intent_registry_db_path(*, root_path: Path, value: object) -> Path: @@ -109,6 +111,10 @@ def resolve_intent_registry_db_path(*, root_path: Path, value: object) -> Path: raise IntentRegistryConfigError(f"invalid intent_registry_path: {exc}") from exc +def _is_intent_registry_backend(value: str) -> TypeGuard[IntentRegistryBackend]: + return value in INTENT_REGISTRY_BACKENDS + + def resolve_intent_registry_config(root: Path) -> IntentRegistryConfig: root_path = root.resolve() config_path = root_path / "pyproject.toml" @@ -123,7 +129,10 @@ def resolve_intent_registry_config(root: Path) -> IntentRegistryConfig: if isinstance(tool, dict): section = tool.get("codeclone") if isinstance(section, dict): - config = dict(section) + config = copy_str_key_table( + section, + key="tool.codeclone", + ) backend = resolve_intent_registry_backend( config.get("intent_registry_backend"), env_value=os.environ.get("CODECLONE_INTENT_REGISTRY_BACKEND"), diff --git a/codeclone/config/memory.py b/codeclone/config/memory.py index 0a993c34..e33d6483 100644 --- a/codeclone/config/memory.py +++ b/codeclone/config/memory.py @@ -9,6 +9,7 @@ import os from dataclasses import dataclass, field from pathlib import Path +from typing import TypeGuard from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator @@ -57,7 +58,7 @@ MEMORY_CONFIG_DEFAULTS, SEMANTIC_NESTED_TABLE_KEY, ) -from .pyproject_loader import load_pyproject_config +from .pyproject_loader import copy_str_key_table, load_pyproject_config _VALID_BACKENDS = frozenset({"sqlite", "postgres"}) _VALID_MCP_SYNC_POLICIES = frozenset( @@ -232,6 +233,51 @@ def _memory_choice(value: object, *, key: str, valid: frozenset[str]) -> str: return raw +def _is_memory_backend(value: str) -> TypeGuard[MemoryBackend]: + return value in _VALID_BACKENDS + + +def _is_mcp_sync_policy(value: str) -> TypeGuard[MemoryMcpSyncPolicy]: + return value in _VALID_MCP_SYNC_POLICIES + + +def _is_projection_rebuild_policy( + value: str, +) -> TypeGuard[MemoryProjectionRebuildPolicy]: + return value in _VALID_PROJECTION_REBUILD_POLICIES + + +def _memory_backend(value: object) -> MemoryBackend: + raw = _memory_choice(value, key="backend", valid=_VALID_BACKENDS) + if _is_memory_backend(raw): + return raw + raise AssertionError("unreachable validated memory backend") + + +def _memory_mcp_sync_policy(value: object) -> MemoryMcpSyncPolicy: + raw = _memory_choice( + value, + key="mcp_sync_policy", + valid=_VALID_MCP_SYNC_POLICIES, + ) + if _is_mcp_sync_policy(raw): + return raw + raise AssertionError("unreachable validated memory MCP sync policy") + + +def _memory_projection_rebuild_policy( + value: object, +) -> MemoryProjectionRebuildPolicy: + raw = _memory_choice( + value, + key="projection_rebuild_policy", + valid=_VALID_PROJECTION_REBUILD_POLICIES, + ) + if _is_projection_rebuild_policy(raw): + return raw + raise AssertionError("unreachable validated memory projection rebuild policy") + + def _format_nested_memory_config_error( *, section: str, @@ -248,7 +294,11 @@ def _format_nested_memory_config_error( def _resolve_ingest_config(raw: object) -> IngestConfig: - data: dict[str, object] = dict(raw) if isinstance(raw, dict) else {} + data = ( + copy_str_key_table(raw, key="tool.codeclone.memory.ingest") + if isinstance(raw, dict) + else {} + ) try: return IngestConfig.model_validate(data) except ValidationError as exc: @@ -258,7 +308,11 @@ def _resolve_ingest_config(raw: object) -> IngestConfig: def _resolve_semantic_config(raw: object, *, root_path: Path) -> SemanticConfig: - data: dict[str, object] = dict(raw) if isinstance(raw, dict) else {} + data = ( + copy_str_key_table(raw, key="tool.codeclone.memory.semantic") + if isinstance(raw, dict) + else {} + ) for env_var, field_name in _SEMANTIC_ENV_OVERRIDES.items(): env_value = os.environ.get(env_var) if env_value is not None: @@ -312,32 +366,19 @@ def resolve_memory_config( memory_obj = loaded.get("memory") merged: dict[str, object] = dict(MEMORY_CONFIG_DEFAULTS) if isinstance(memory_obj, dict): - merged.update(memory_obj) - - backend_raw = _memory_choice( - merged["backend"], - key="backend", - valid=_VALID_BACKENDS, - ) + merged.update(copy_str_key_table(memory_obj, key="tool.codeclone.memory")) - policy_raw = _memory_choice( - merged["mcp_sync_policy"], - key="mcp_sync_policy", - valid=_VALID_MCP_SYNC_POLICIES, + backend = _memory_backend(merged["backend"]) + mcp_sync_policy = _memory_mcp_sync_policy(merged["mcp_sync_policy"]) + env_projection_policy = os.environ.get(MEMORY_ENV_PROJECTION_REBUILD_POLICY) + projection_policy_value: object = ( + env_projection_policy + if env_projection_policy is not None + else merged["projection_rebuild_policy"] ) - - projection_policy_raw = _memory_choice( - merged["projection_rebuild_policy"], - key="projection_rebuild_policy", - valid=_VALID_PROJECTION_REBUILD_POLICIES, + projection_rebuild_policy = _memory_projection_rebuild_policy( + projection_policy_value ) - env_projection_policy = os.environ.get(MEMORY_ENV_PROJECTION_REBUILD_POLICY) - if env_projection_policy is not None: - projection_policy_raw = _memory_choice( - env_projection_policy, - key="projection_rebuild_policy", - valid=_VALID_PROJECTION_REBUILD_POLICIES, - ) env_db_path = os.environ.get(MEMORY_ENV_DB_PATH) db_path_raw: object = env_db_path if env_db_path is not None else merged["db_path"] @@ -348,7 +389,7 @@ def resolve_memory_config( ) return MemoryConfig( - backend=backend_raw, # type: ignore[arg-type] + backend=backend, db_path=db_path_value, active_retention_days=_memory_int( merged["active_retention_days"], key="active_retention_days" @@ -388,8 +429,8 @@ def resolve_memory_config( merged["git_hotspot_min_changes"], key="git_hotspot_min_changes", ), - mcp_sync_policy=policy_raw, # type: ignore[arg-type] - projection_rebuild_policy=projection_policy_raw, # type: ignore[arg-type] + mcp_sync_policy=mcp_sync_policy, + projection_rebuild_policy=projection_rebuild_policy, projection_rebuild_running_timeout_seconds=_memory_int( merged["projection_rebuild_running_timeout_seconds"], key="projection_rebuild_running_timeout_seconds", diff --git a/codeclone/config/pyproject_loader.py b/codeclone/config/pyproject_loader.py index 2b81aaaa..4ad15ab5 100644 --- a/codeclone/config/pyproject_loader.py +++ b/codeclone/config/pyproject_loader.py @@ -261,7 +261,11 @@ def _validate_nested_ingest_table( "Invalid pyproject payload at " f"{config_path}: 'tool.codeclone.memory.ingest' must be object" ) - return dict(ingest_obj) + return copy_str_key_table( + key="tool.codeclone.memory.ingest", + value=ingest_obj, + config_path=config_path, + ) def _validate_nested_semantic_table( @@ -277,7 +281,11 @@ def _validate_nested_semantic_table( "Invalid pyproject payload at " f"{config_path}: 'tool.codeclone.memory.semantic' must be object" ) - return dict(semantic_obj) + return copy_str_key_table( + key="tool.codeclone.memory.semantic", + value=semantic_obj, + config_path=config_path, + ) def normalize_path_config_value( @@ -320,16 +328,40 @@ def _validated_string_list(*, key: str, value: object) -> tuple[str, ...]: raise ConfigValidationError( f"Invalid value type for tool.codeclone.{key}: expected list[str]" ) - if not all(isinstance(item, str) for item in value): - raise ConfigValidationError( - f"Invalid value type for tool.codeclone.{key}: expected list[str]" - ) + string_values: list[str] = [] + for item in value: + if not isinstance(item, str): + raise ConfigValidationError( + f"Invalid value type for tool.codeclone.{key}: expected list[str]" + ) + string_values.append(item) try: - return normalize_golden_fixture_patterns(value) + return normalize_golden_fixture_patterns(string_values) except GoldenFixturePatternError as exc: raise ConfigValidationError(str(exc)) from exc +def copy_str_key_table( + value: object, + *, + key: str, + config_path: Path | None = None, +) -> dict[str, object]: + source = ( + f"pyproject payload at {config_path}" if config_path else "pyproject payload" + ) + if not isinstance(value, dict): + raise ConfigValidationError(f"Invalid {source}: '{key}' must be object") + result: dict[str, object] = {} + for item_key, item_value in value.items(): + if not isinstance(item_key, str): + raise ConfigValidationError( + f"Invalid {source}: '{key}' must contain only string keys" + ) + result[item_key] = item_value + return result + + def _load_toml(path: Path) -> object: if sys.version_info >= (3, 11): import tomllib diff --git a/codeclone/config/pyproject_writer.py b/codeclone/config/pyproject_writer.py new file mode 100644 index 00000000..13f1ce7e --- /dev/null +++ b/codeclone/config/pyproject_writer.py @@ -0,0 +1,264 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Round-trip pyproject.toml writer for ``[tool.codeclone]`` merges.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Any, cast + +from ..utils.atomic_write import write_text_atomically +from .analytics_specs import ANALYTICS_NESTED_TABLE_KEY +from .memory_specs import MEMORY_NESTED_TABLE_KEY +from .pyproject_loader import ( + ConfigValidationError, + load_pyproject_config, + normalize_path_config_value, + open_repo_config, + validate_config_value, +) +from .spec import CONFIG_KEY_SPECS + +if TYPE_CHECKING: + from collections.abc import Mapping + + from tomlkit.items import Table as TomlTable + from tomlkit.toml_document import TOMLDocument +else: + TOMLDocument = object + TomlTable = object + + +class PyprojectWriterError(ValueError): + """Raised when a pyproject merge or write cannot be completed safely.""" + + +@dataclass(frozen=True, slots=True) +class PyprojectWriteResult: + config_path: Path + changed_keys: tuple[str, ...] + created_section: bool + dry_run: bool + preview_text: str | None = None + + +def read_pyproject_document(root_path: Path) -> TOMLDocument: + """Parse ``pyproject.toml`` with tomlkit, preserving comments and layout.""" + + config_path = root_path / "pyproject.toml" + if not config_path.is_file(): + raise PyprojectWriterError(f"pyproject.toml not found under {root_path}") + + tomlkit = _load_tomlkit() + with open_repo_config(root_path) as handle: + text = handle.read().decode("utf-8") + return cast(TOMLDocument, tomlkit.parse(text)) + + +def validate_tool_codeclone_updates( + *, + root_path: Path, + updates: Mapping[str, object], +) -> dict[str, object]: + """Validate top-level ``tool.codeclone`` keys using the loader contract.""" + + if not updates: + return {} + + nested_keys = {MEMORY_NESTED_TABLE_KEY, ANALYTICS_NESTED_TABLE_KEY} + nested_requested = sorted(set(updates.keys()) & nested_keys) + if nested_requested: + joined = ", ".join(nested_requested) + raise PyprojectWriterError( + "Nested tool.codeclone tables are not supported by the writer yet: " + f"{joined}" + ) + + unknown = sorted(set(updates.keys()) - set(CONFIG_KEY_SPECS)) + if unknown: + raise PyprojectWriterError( + "Unknown key(s) in tool.codeclone merge: " + ", ".join(unknown) + ) + + validated: dict[str, object] = {} + for key in sorted(updates.keys()): + try: + value = validate_config_value(key=key, value=updates[key]) + except ConfigValidationError as exc: + raise PyprojectWriterError(str(exc)) from exc + validated[key] = normalize_path_config_value( + key=key, + value=value, + root_path=root_path, + ) + return validated + + +def apply_tool_codeclone_updates( + document: TOMLDocument, + validated_updates: Mapping[str, object], +) -> tuple[str, ...]: + """Apply validated updates to an in-memory tomlkit document.""" + + if not validated_updates: + return () + + codeclone_table, _ = _ensure_tool_codeclone_table(document) + changed: list[str] = [] + for key in sorted(validated_updates.keys()): + new_value = validated_updates[key] + if codeclone_table.get(key) == new_value: + continue + codeclone_table[key] = new_value + changed.append(key) + return tuple(changed) + + +def serialize_pyproject_document(document: TOMLDocument) -> str: + """Serialize a tomlkit document with a trailing newline.""" + + tomlkit = _load_tomlkit() + text = str(tomlkit.dumps(document)) + if not text.endswith("\n"): + text += "\n" + return text + + +def write_pyproject_text_atomically(config_path: Path, text: str) -> None: + """Write pyproject text via temp file + ``os.replace``.""" + + try: + write_text_atomically(config_path, text) + except OSError as exc: + raise PyprojectWriterError(str(exc)) from exc + + +def merge_tool_codeclone( + root_path: Path, + updates: Mapping[str, object], + *, + dry_run: bool = False, +) -> PyprojectWriteResult: + """Merge validated ``tool.codeclone`` keys and optionally write the file.""" + + config_path = root_path / "pyproject.toml" + validated = validate_tool_codeclone_updates(root_path=root_path, updates=updates) + if not validated: + return PyprojectWriteResult( + config_path=config_path, + changed_keys=(), + created_section=False, + dry_run=dry_run, + ) + + current = load_pyproject_config(root_path) + pending = { + key: value for key, value in validated.items() if current.get(key) != value + } + if not pending: + return PyprojectWriteResult( + config_path=config_path, + changed_keys=(), + created_section=False, + dry_run=dry_run, + ) + + document = read_pyproject_document(root_path) + _, created_section = _ensure_tool_codeclone_table(document) + changed_keys = apply_tool_codeclone_updates(document, pending) + if not changed_keys: + return PyprojectWriteResult( + config_path=config_path, + changed_keys=(), + created_section=False, + dry_run=dry_run, + ) + + preview_text = serialize_pyproject_document(document) + if dry_run: + return PyprojectWriteResult( + config_path=config_path, + changed_keys=changed_keys, + created_section=created_section, + dry_run=True, + preview_text=preview_text, + ) + + _validate_pyproject_text_before_write(root_path=root_path, text=preview_text) + write_pyproject_text_atomically(config_path, preview_text) + + return PyprojectWriteResult( + config_path=config_path, + changed_keys=changed_keys, + created_section=created_section, + dry_run=False, + ) + + +def _validate_pyproject_text_before_write(*, root_path: Path, text: str) -> None: + """Validate serialized pyproject text before it is installed.""" + + tomlkit = _load_tomlkit() + try: + payload = tomlkit.parse(text) + load_pyproject_config(root_path, load_toml=lambda _path: payload) + except (ConfigValidationError, ValueError) as exc: + raise PyprojectWriterError( + "Merged pyproject.toml failed validation before write" + ) from exc + + +def _ensure_tool_codeclone_table(document: TOMLDocument) -> tuple[TomlTable, bool]: + tomlkit = _load_tomlkit() + created = False + + tool_raw = document.get("tool") + if tool_raw is None: + tool = tomlkit.table() + document["tool"] = tool + created = True + elif not isinstance(tool_raw, tomlkit.items.Table): + raise PyprojectWriterError("Invalid pyproject.toml: 'tool' must be a table") + else: + tool = tool_raw + + codeclone_raw = tool.get("codeclone") + if codeclone_raw is None: + codeclone = tomlkit.table() + tool["codeclone"] = codeclone + created = True + elif not isinstance(codeclone_raw, tomlkit.items.Table): + raise PyprojectWriterError( + "Invalid pyproject.toml: 'tool.codeclone' must be a table" + ) + else: + codeclone = codeclone_raw + + return cast(TomlTable, codeclone), created + + +def _load_tomlkit() -> Any: # Any: lazy tomlkit import boundary + try: + import tomlkit as tomlkit_module + except ImportError as exc: + raise PyprojectWriterError( + "tomlkit is required for pyproject writes; install codeclone dependencies." + ) from exc + return tomlkit_module + + +__all__ = [ + "PyprojectWriteResult", + "PyprojectWriterError", + "apply_tool_codeclone_updates", + "merge_tool_codeclone", + "read_pyproject_document", + "serialize_pyproject_document", + "validate_tool_codeclone_updates", + "write_pyproject_text_atomically", +] diff --git a/codeclone/config/spec.py b/codeclone/config/spec.py index 6413427a..cae5c927 100644 --- a/codeclone/config/spec.py +++ b/codeclone/config/spec.py @@ -805,6 +805,14 @@ def _option( flags=("-h", "--help"), help_text="Show this help message and exit.", ), + _option( + dest="interactive_help", + group="General", + cli_kind="store_true", + flags=("--interactive-help",), + default=False, + help_text=ui.HELP_INTERACTIVE, + ), _option( dest="version", group="General", diff --git a/codeclone/contracts/__init__.py b/codeclone/contracts/__init__.py index 7aa6ee97..ecf60d74 100644 --- a/codeclone/contracts/__init__.py +++ b/codeclone/contracts/__init__.py @@ -13,7 +13,7 @@ BASELINE_FINGERPRINT_VERSION: Final = "1" CACHE_VERSION: Final = "2.10" -REPORT_SCHEMA_VERSION: Final = "2.11" +REPORT_SCHEMA_VERSION: Final = "2.12" METRICS_BASELINE_SCHEMA_VERSION: Final = "1.2" ENGINEERING_MEMORY_SCHEMA_VERSION: Final = "1.7" # Semantic retrieval index. Derived, rebuildable sidecar — NOT diff --git a/codeclone/controller_insights/session_stats.py b/codeclone/controller_insights/session_stats.py index 1e041182..9f43e81a 100644 --- a/codeclone/controller_insights/session_stats.py +++ b/codeclone/controller_insights/session_stats.py @@ -378,7 +378,9 @@ def _mapping_at( if not isinstance(current, dict): return None current = current.get(key) - return current if isinstance(current, dict) else None + if not isinstance(current, dict): + return None + return {key: item for key, item in current.items() if isinstance(key, str)} def _string_field(payload: Mapping[str, object] | None, key: str) -> str | None: diff --git a/codeclone/core/discovery.py b/codeclone/core/discovery.py index eab28dc3..8287e800 100644 --- a/codeclone/core/discovery.py +++ b/codeclone/core/discovery.py @@ -7,8 +7,13 @@ from __future__ import annotations from collections.abc import Mapping, Sequence -from typing import cast +from ..cache._validators import _is_relationship_record_dict +from ..cache.entries import ( + _as_relationship_kind, + _as_relationship_origin_lane, + _as_relationship_resolution_status, +) from ..cache.store import Cache, file_stat_signature from ..models import ( ClassMetrics, @@ -19,10 +24,7 @@ ModuleDep, ModuleDocstringCoverage, ModuleTypingCoverage, - RelationshipKind, - RelationshipOriginLane, RelationshipRecord, - RelationshipResolutionStatus, RuntimeReachabilityFact, SecuritySurface, StructuralFindingGroup, @@ -47,6 +49,27 @@ from .discovery_cache import usable_cached_source_stats as _usable_cached_source_stats +def _decode_cached_relationship_record(value: object) -> RelationshipRecord | None: + if not _is_relationship_record_dict(value): + return None + relation_kind = _as_relationship_kind(value["relation_kind"]) + resolution_status = _as_relationship_resolution_status(value["resolution_status"]) + origin_lane = _as_relationship_origin_lane(value["origin_lane"]) + if relation_kind is None or resolution_status is None or origin_lane is None: + return None + return RelationshipRecord( + relation_kind=relation_kind, + resolution_status=resolution_status, + origin_lane=origin_lane, + source_qualname=value["source_qualname"], + target_qualname=value["target_qualname"], + path=value["path"], + line=value["line"], + expression=value["expression"], + resolution_rule=value["resolution_rule"], + ) + + def _decode_cached_function_relationship_facts( rows: Sequence[Mapping[str, object]], ) -> list[FunctionRelationshipFacts]: @@ -63,21 +86,10 @@ def _decode_cached_function_relationship_facts( if not isinstance(relationships, list) or not isinstance(source_qualname, str): continue records = tuple( - RelationshipRecord( - relation_kind=cast(RelationshipKind, record["relation_kind"]), - resolution_status=cast( - RelationshipResolutionStatus, record["resolution_status"] - ), - origin_lane=cast(RelationshipOriginLane, record["origin_lane"]), - source_qualname=str(record["source_qualname"]), - target_qualname=cast("str | None", record["target_qualname"]), - path=str(record["path"]), - line=cast(int, record["line"]), - expression=cast("str | None", record["expression"]), - resolution_rule=cast("str | None", record["resolution_rule"]), - ) - for record in relationships - if isinstance(record, Mapping) + record + for raw_record in relationships + if (record := _decode_cached_relationship_record(raw_record)) is not None + and record.source_qualname == source_qualname ) facts.append( FunctionRelationshipFacts( diff --git a/codeclone/core/discovery_cache.py b/codeclone/core/discovery_cache.py index 0b63f9b5..032a3eeb 100644 --- a/codeclone/core/discovery_cache.py +++ b/codeclone/core/discovery_cache.py @@ -130,19 +130,26 @@ def _dead_candidate_kind(value: object) -> _DeadCandidateKind | None: def _security_surface_category(value: object) -> SecuritySurfaceCategory | None: match value: - case ( - "archive_extraction" - | "crypto_transport" - | "database_boundary" - | "deserialization" - | "dynamic_execution" - | "dynamic_loading" - | "filesystem_mutation" - | "identity_token" - | "network_boundary" - | "process_boundary" - ): - return value + case "archive_extraction": + return "archive_extraction" + case "crypto_transport": + return "crypto_transport" + case "database_boundary": + return "database_boundary" + case "deserialization": + return "deserialization" + case "dynamic_execution": + return "dynamic_execution" + case "dynamic_loading": + return "dynamic_loading" + case "filesystem_mutation": + return "filesystem_mutation" + case "identity_token": + return "identity_token" + case "network_boundary": + return "network_boundary" + case "process_boundary": + return "process_boundary" case _: return None @@ -151,8 +158,12 @@ def _security_surface_location_scope( value: object, ) -> SecuritySurfaceLocationScope | None: match value: - case "module" | "class" | "callable": - return value + case "module": + return "module" + case "class": + return "class" + case "callable": + return "callable" case _: return None @@ -161,8 +172,12 @@ def _security_surface_classification_mode( value: object, ) -> SecuritySurfaceClassificationMode | None: match value: - case "exact_builtin" | "exact_call" | "exact_import": - return value + case "exact_builtin": + return "exact_builtin" + case "exact_call": + return "exact_call" + case "exact_import": + return "exact_import" case _: return None @@ -171,8 +186,12 @@ def _security_surface_evidence_kind( value: object, ) -> SecuritySurfaceEvidenceKind | None: match value: - case "builtin" | "call" | "import": - return value + case "builtin": + return "builtin" + case "call": + return "call" + case "import": + return "import" case _: return None @@ -181,20 +200,28 @@ def _runtime_reachability_framework( value: object, ) -> RuntimeReachabilityFramework | None: match value: - case ( - "aiogram" - | "aiohttp" - | "celery" - | "click" - | "dependency_injector" - | "django" - | "fastapi" - | "flask" - | "sqlalchemy" - | "starlette" - | "typer" - ): - return value + case "aiogram": + return "aiogram" + case "aiohttp": + return "aiohttp" + case "celery": + return "celery" + case "click": + return "click" + case "dependency_injector": + return "dependency_injector" + case "django": + return "django" + case "fastapi": + return "fastapi" + case "flask": + return "flask" + case "sqlalchemy": + return "sqlalchemy" + case "starlette": + return "starlette" + case "typer": + return "typer" case _: return None @@ -203,15 +230,18 @@ def _runtime_reachability_edge_kind( value: object, ) -> RuntimeReachabilityEdgeKind | None: match value: - case ( - "declares_dependency" - | "provides" - | "registers_command" - | "registers_handler" - | "registers_task" - | "runtime_hook" - ): - return value + case "declares_dependency": + return "declares_dependency" + case "provides": + return "provides" + case "registers_command": + return "registers_command" + case "registers_handler": + return "registers_handler" + case "registers_task": + return "registers_task" + case "runtime_hook": + return "runtime_hook" case _: return None @@ -220,8 +250,12 @@ def _runtime_reachability_confidence( value: object, ) -> RuntimeReachabilityConfidence | None: match value: - case "high" | "medium" | "low": - return value + case "high": + return "high" + case "medium": + return "medium" + case "low": + return "low" case _: return None @@ -230,8 +264,12 @@ def _runtime_reachability_target_kind( value: object, ) -> RuntimeReachabilityTargetKind | None: match value: - case "function" | "class" | "method": - return value + case "function": + return "function" + case "class": + return "class" + case "method": + return "method" case _: return None diff --git a/codeclone/core/pipeline.py b/codeclone/core/pipeline.py index f0acd5a3..2d1925dc 100644 --- a/codeclone/core/pipeline.py +++ b/codeclone/core/pipeline.py @@ -71,8 +71,10 @@ def _artifact_dead_items( value: object, default: tuple[DeadItem, ...], ) -> tuple[DeadItem, ...]: - if isinstance(value, tuple) and all(isinstance(item, DeadItem) for item in value): - return value + if isinstance(value, tuple): + dead_items = tuple(item for item in value if isinstance(item, DeadItem)) + if len(dead_items) == len(value): + return dead_items return default @@ -197,6 +199,7 @@ def analyze( segment_groups_raw = segment_split.active_groups segment_groups_raw_digest = _segment_groups_digest(segment_groups_raw) cached_projection = discovery.cached_segment_report_projection + segment_groups: dict[str, list[dict[str, object]]] if ( cached_projection is not None and cached_projection.get("digest") == segment_groups_raw_digest diff --git a/codeclone/core/worker.py b/codeclone/core/worker.py index 39474be0..bed76880 100644 --- a/codeclone/core/worker.py +++ b/codeclone/core/worker.py @@ -139,6 +139,26 @@ def process_file( ) +def _call_process_file( + process_callable: Callable[..., FileProcessResult], + filepath: str, + root: str, + cfg: NormalizationConfig, + min_loc: int, + min_stmt: int, + *, + supported_kwargs: dict[str, object], +) -> FileProcessResult: + return process_callable( + filepath, + root, + cfg, + min_loc, + min_stmt, + **supported_kwargs, + ) + + def _invoke_process_file( filepath: str, root: str, @@ -176,13 +196,14 @@ def _invoke_process_file( for key, value in optional_kwargs.items() if key in supported_names } - return process_callable( + return _call_process_file( + process_callable, filepath, root, cfg, min_loc, min_stmt, - **supported_kwargs, + supported_kwargs=supported_kwargs, ) diff --git a/codeclone/memory/embedding/fastembed_provider.py b/codeclone/memory/embedding/fastembed_provider.py index e67d042b..a0b44576 100644 --- a/codeclone/memory/embedding/fastembed_provider.py +++ b/codeclone/memory/embedding/fastembed_provider.py @@ -7,6 +7,7 @@ from __future__ import annotations import importlib +import numbers from collections.abc import Callable, Iterable, Mapping, Sequence from pathlib import Path from typing import Protocol, cast @@ -51,6 +52,18 @@ def _encoding_length(encoding: object) -> int: return 0 +def _encoding_token_ids(encoding: object) -> list[int]: + ids = getattr(encoding, "ids", ()) + if not isinstance(ids, Iterable) or isinstance(ids, str | bytes | bytearray): + return [] + token_ids: list[int] = [] + for token_id in ids: + if isinstance(token_id, bool) or not isinstance(token_id, int): + return [] + token_ids.append(token_id) + return token_ids + + def _tokenizer_encode_ops( tokenizer: object, ) -> ( @@ -285,7 +298,7 @@ def chunk_text(self, text: str) -> tuple[str, ...]: _verify_chunk_passage_input(encode, text, model_max_tokens=max_length) return (text,) content_encoding = encode(text, add_special_tokens=False) - content_ids = list(getattr(content_encoding, "ids", ())) + content_ids = _encoding_token_ids(content_encoding) payload_budget = _chunk_payload_token_budget( encode, model_max_tokens=max_length, @@ -380,7 +393,19 @@ def _coerce_vector(raw_vector: object) -> list[float]: raise MemorySemanticUnavailableError( "fastembed returned a non-iterable embedding vector" ) - return [float(value) for value in raw_vector] + vector: list[float] = [] + for value in raw_vector: + # Real fastembed output is a numpy float32 array; iterating it yields + # numpy.float32 scalars, which are NOT Python `float`/`int` subclasses + # but ARE registered as numbers.Real. numbers.Real also excludes str + # (avoiding float("abc") surfacing a bare ValueError), while bool is + # excluded explicitly so True/False are not coerced to 1.0/0.0. + if isinstance(value, bool) or not isinstance(value, numbers.Real): + raise MemorySemanticUnavailableError( + "fastembed returned a non-numeric embedding vector" + ) + vector.append(float(value)) + return vector __all__ = ["FastEmbedEmbeddingProvider", "known_model_max_tokens"] diff --git a/codeclone/memory/enums.py b/codeclone/memory/enums.py index 63e33d63..2a1d8023 100644 --- a/codeclone/memory/enums.py +++ b/codeclone/memory/enums.py @@ -8,6 +8,8 @@ from typing import Literal +from pydantic import TypeAdapter, ValidationError + MemoryRecordType = Literal[ "module_role", "contract_note", @@ -110,7 +112,248 @@ IngestionRunStatus = Literal["running", "completed", "failed", "partial"] +MEMORY_RECORD_TYPE_VALUES: tuple[MemoryRecordType, ...] = ( + "module_role", + "contract_note", + "test_anchor", + "document_link", + "risk_note", + "public_surface", + "contradiction_note", + "architecture_decision", + "change_rationale", + "protocol_rule", + "stale_marker", + "human_note", +) +MEMORY_STATUS_VALUES: tuple[MemoryStatus, ...] = ( + "draft", + "active", + "historical", + "stale", + "superseded", + "rejected", + "archived", +) +MEMORY_CONFIDENCE_VALUES: tuple[MemoryConfidence, ...] = ( + "inferred", + "supported", + "verified", +) +MEMORY_ORIGIN_VALUES: tuple[MemoryOrigin, ...] = ("system", "agent", "human") +MEMORY_INGEST_SOURCE_VALUES: tuple[MemoryIngestSource, ...] = ( + "analysis", + "contract", + "doc", + "test", + "git", + "receipt", + "audit", + "agent", + "human", + "snapshot", +) +SUBJECT_KIND_VALUES: tuple[SubjectKind, ...] = ( + "path", + "symbol", + "module", + "package", + "test", + "doc", + "contract", + "mcp_tool", + "mcp_resource", + "cli_option", + "report_field", + "baseline_schema", + "cache_schema", + "config_key", + "plugin_surface", +) +SUBJECT_RELATION_VALUES: tuple[SubjectRelation, ...] = ( + "about", + "owns", + "tests", + "documents", + "depends_on", + "imports", + "exports", +) +EVIDENCE_KIND_VALUES: tuple[EvidenceKind, ...] = ( + "code", + "test", + "doc", + "spec", + "receipt", + "git_commit", + "report", + "baseline", + "cache", + "audit_event", + "trajectory", + "external_url", +) +LINK_RELATION_VALUES: tuple[LinkRelation, ...] = ( + "supersedes", + "depends_on", + "contradicts", + "explains", + "implements", + "tests", + "documents", + "deprecates", + "related_to", + "implicit_coupling", +) +INGESTION_MODE_VALUES: tuple[IngestionMode, ...] = ("init", "refresh") +INGESTION_RUN_STATUS_VALUES: tuple[IngestionRunStatus, ...] = ( + "running", + "completed", + "failed", + "partial", +) + +_MEMORY_RECORD_TYPE_ADAPTER: TypeAdapter[MemoryRecordType] = TypeAdapter( + MemoryRecordType +) +_MEMORY_STATUS_ADAPTER: TypeAdapter[MemoryStatus] = TypeAdapter(MemoryStatus) +_MEMORY_CONFIDENCE_ADAPTER: TypeAdapter[MemoryConfidence] = TypeAdapter( + MemoryConfidence +) +_MEMORY_ORIGIN_ADAPTER: TypeAdapter[MemoryOrigin] = TypeAdapter(MemoryOrigin) +_MEMORY_INGEST_SOURCE_ADAPTER: TypeAdapter[MemoryIngestSource] = TypeAdapter( + MemoryIngestSource +) +_SUBJECT_KIND_ADAPTER: TypeAdapter[SubjectKind] = TypeAdapter(SubjectKind) +_SUBJECT_RELATION_ADAPTER: TypeAdapter[SubjectRelation] = TypeAdapter(SubjectRelation) +_EVIDENCE_KIND_ADAPTER: TypeAdapter[EvidenceKind] = TypeAdapter(EvidenceKind) +_LINK_RELATION_ADAPTER: TypeAdapter[LinkRelation] = TypeAdapter(LinkRelation) +_INGESTION_MODE_ADAPTER: TypeAdapter[IngestionMode] = TypeAdapter(IngestionMode) +_INGESTION_RUN_STATUS_ADAPTER: TypeAdapter[IngestionRunStatus] = TypeAdapter( + IngestionRunStatus +) + + +def _choices(values: tuple[str, ...]) -> str: + return ", ".join(repr(value) for value in values) + + +def _literal_error(field: str, value: object, values: tuple[str, ...]) -> ValueError: + return ValueError( + f"Invalid Engineering Memory {field}: {value!r}. " + f"Allowed values: {_choices(values)}." + ) + + +def validate_memory_record_type( + value: object, *, field: str = "record_type" +) -> MemoryRecordType: + try: + return _MEMORY_RECORD_TYPE_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, MEMORY_RECORD_TYPE_VALUES) from exc + + +def validate_memory_status( + value: object, *, field: str = "record_status" +) -> MemoryStatus: + try: + return _MEMORY_STATUS_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, MEMORY_STATUS_VALUES) from exc + + +def validate_memory_confidence( + value: object, *, field: str = "record_confidence" +) -> MemoryConfidence: + try: + return _MEMORY_CONFIDENCE_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, MEMORY_CONFIDENCE_VALUES) from exc + + +def validate_memory_origin( + value: object, *, field: str = "record_origin" +) -> MemoryOrigin: + try: + return _MEMORY_ORIGIN_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, MEMORY_ORIGIN_VALUES) from exc + + +def validate_memory_ingest_source( + value: object, *, field: str = "record_ingest_source" +) -> MemoryIngestSource: + try: + return _MEMORY_INGEST_SOURCE_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, MEMORY_INGEST_SOURCE_VALUES) from exc + + +def validate_subject_kind(value: object, *, field: str = "subject_kind") -> SubjectKind: + try: + return _SUBJECT_KIND_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, SUBJECT_KIND_VALUES) from exc + + +def validate_subject_relation( + value: object, *, field: str = "subject_relation" +) -> SubjectRelation: + try: + return _SUBJECT_RELATION_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, SUBJECT_RELATION_VALUES) from exc + + +def validate_evidence_kind( + value: object, *, field: str = "evidence_kind" +) -> EvidenceKind: + try: + return _EVIDENCE_KIND_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, EVIDENCE_KIND_VALUES) from exc + + +def validate_link_relation( + value: object, *, field: str = "link_relation" +) -> LinkRelation: + try: + return _LINK_RELATION_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, LINK_RELATION_VALUES) from exc + + +def validate_ingestion_mode( + value: object, *, field: str = "ingestion_mode" +) -> IngestionMode: + try: + return _INGESTION_MODE_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, INGESTION_MODE_VALUES) from exc + + +def validate_ingestion_run_status( + value: object, *, field: str = "ingestion_run_status" +) -> IngestionRunStatus: + try: + return _INGESTION_RUN_STATUS_ADAPTER.validate_python(value) + except ValidationError as exc: + raise _literal_error(field, value, INGESTION_RUN_STATUS_VALUES) from exc + + __all__ = [ + "EVIDENCE_KIND_VALUES", + "INGESTION_MODE_VALUES", + "INGESTION_RUN_STATUS_VALUES", + "LINK_RELATION_VALUES", + "MEMORY_CONFIDENCE_VALUES", + "MEMORY_INGEST_SOURCE_VALUES", + "MEMORY_ORIGIN_VALUES", + "MEMORY_RECORD_TYPE_VALUES", + "MEMORY_STATUS_VALUES", + "SUBJECT_KIND_VALUES", + "SUBJECT_RELATION_VALUES", "EvidenceKind", "IngestionMode", "IngestionRunStatus", @@ -122,4 +365,15 @@ "MemoryStatus", "SubjectKind", "SubjectRelation", + "validate_evidence_kind", + "validate_ingestion_mode", + "validate_ingestion_run_status", + "validate_link_relation", + "validate_memory_confidence", + "validate_memory_ingest_source", + "validate_memory_origin", + "validate_memory_record_type", + "validate_memory_status", + "validate_subject_kind", + "validate_subject_relation", ] diff --git a/codeclone/memory/experience/store.py b/codeclone/memory/experience/store.py index 3895ca9e..0e1e54cf 100644 --- a/codeclone/memory/experience/store.py +++ b/codeclone/memory/experience/store.py @@ -12,7 +12,7 @@ import sqlite3 from collections.abc import Callable, Sequence -from typing import TypeVar +from typing import TypeGuard, TypeVar from ...utils.iterutils import chunked from .models import ( @@ -302,9 +302,13 @@ def _row_to_evidence(row: sqlite3.Row) -> ExperienceEvidence: ) +def _is_experience_facet_kind(value: str) -> TypeGuard[ExperienceFacetKind]: + return value in ("agent_family", "analysis_profile", "intent_class") + + def _facet_kind(value: str) -> ExperienceFacetKind: - if value in ("agent_family", "analysis_profile", "intent_class"): - return value # type: ignore[return-value] + if _is_experience_facet_kind(value): + return value msg = f"unknown experience facet kind: {value!r}" raise ValueError(msg) @@ -339,9 +343,13 @@ def _row_to_experience( ) +def _is_experience_status(value: str) -> TypeGuard[ExperienceStatus]: + return value in ("active", "dormant") + + def _status(value: str) -> ExperienceStatus: - if value in ("active", "dormant"): - return value # type: ignore[return-value] + if _is_experience_status(value): + return value msg = f"unknown experience status: {value!r}" raise ValueError(msg) diff --git a/codeclone/memory/governance.py b/codeclone/memory/governance.py index e45bf013..b7c32ec1 100644 --- a/codeclone/memory/governance.py +++ b/codeclone/memory/governance.py @@ -18,7 +18,7 @@ DEFAULT_MEMORY_TARGET_STATEMENT_CHARS, ) from ..report.meta import current_report_timestamp_utc -from .enums import MemoryRecordType +from .enums import MemoryRecordType, validate_memory_record_type from .exceptions import MemoryCapacityError, MemoryContractError from .identity import make_identity_key from .models import ( @@ -109,6 +109,14 @@ "receipt/spec/docs." ) + +def _validate_candidate_record_type(record_type: MemoryRecordType) -> MemoryRecordType: + try: + return validate_memory_record_type(record_type) + except ValueError as exc: + raise MemoryContractError(str(exc)) from exc + + _VS_CODE_CHANNEL_RE = re.compile(r"\bvs\s*code\b|\bvscode\b", re.IGNORECASE) _HUMAN_GOVERNANCE_MARKERS = ( "human", @@ -468,6 +476,7 @@ def record_candidate( max_candidates: int, max_statement_chars: int = DEFAULT_MEMORY_MAX_STATEMENT_CHARS, ) -> MemoryRecord: + record_type = _validate_candidate_record_type(record_type) stripped = statement.strip() if not stripped: raise MemoryContractError("Candidate statement must not be empty.") @@ -561,6 +570,7 @@ def promote_experience( Idempotent: re-promoting the same experience is rejected. The experience keeps informing agents advisorily whether or not it is ever promoted. """ + record_type = _validate_candidate_record_type(record_type) experience = store.find_experience(experience_id) if experience is None or experience.project_id != project.id: raise MemoryContractError(f"Experience not found: {experience_id}") diff --git a/codeclone/memory/ide_governance.py b/codeclone/memory/ide_governance.py index 1f21a432..fc935d08 100644 --- a/codeclone/memory/ide_governance.py +++ b/codeclone/memory/ide_governance.py @@ -12,7 +12,7 @@ import time from dataclasses import dataclass, field from pathlib import Path -from typing import Literal, NoReturn +from typing import Literal, NoReturn, TypeGuard from ..contracts import IDE_GOVERNANCE_PROTOCOL_VERSION from .exceptions import MemoryContractError @@ -299,11 +299,15 @@ def _find_project_record( return record +def _is_governance_decision(value: str) -> TypeGuard[GovernanceDecision]: + return value in {"approve", "reject", "archive"} + + def _validate_decision(decision: str) -> GovernanceDecision: normalized = decision.strip().lower() - if normalized not in {"approve", "reject", "archive"}: + if not _is_governance_decision(normalized): _raise_memory_contract(f"Unknown governance decision: {decision!r}") - return normalized # type: ignore[return-value] + return normalized def _validate_record_for_decision( diff --git a/codeclone/memory/ingest/extractors.py b/codeclone/memory/ingest/extractors.py index 4e961f6a..94cf6c18 100644 --- a/codeclone/memory/ingest/extractors.py +++ b/codeclone/memory/ingest/extractors.py @@ -19,6 +19,7 @@ from ...report.meta import current_report_timestamp_utc from ...utils.coerce import as_mapping, as_sequence from ..display import format_document_link_statement +from ..enums import MemoryConfidence from ..identity import make_identity_key from ..models import ( MemoryEvidence, @@ -136,7 +137,7 @@ def _append_path_risk_note( discriminator: str, statement: str, payload: Mapping[str, object], - confidence: str, + confidence: MemoryConfidence, ) -> None: identity = make_identity_key( type="risk_note", @@ -152,7 +153,7 @@ def _append_path_risk_note( identity_key=identity, type="risk_note", status="active", - confidence=confidence, # type: ignore[arg-type] + confidence=confidence, origin="system", ingest_source="analysis", statement=statement, diff --git a/codeclone/memory/ingest/receipts.py b/codeclone/memory/ingest/receipts.py index 9da1861e..81116726 100644 --- a/codeclone/memory/ingest/receipts.py +++ b/codeclone/memory/ingest/receipts.py @@ -10,6 +10,7 @@ from contextlib import suppress from ...report.meta import current_report_timestamp_utc +from ..enums import MemoryRecordType, validate_memory_record_type from ..governance import record_candidate from ..models import MemoryProject, generate_memory_id from ..sqlite_store import SqliteEngineeringMemoryStore @@ -19,7 +20,7 @@ def _try_append_text_candidate( store: SqliteEngineeringMemoryStore, *, project: MemoryProject, - record_type: str, + record_type: MemoryRecordType, text: object, subject_path: str | None, created_by: str, @@ -29,10 +30,11 @@ def _try_append_text_candidate( if not isinstance(text, str) or not text.strip() or not subject_path: return None try: + canonical_type = validate_memory_record_type(record_type) record = record_candidate( store, project=project, - record_type=record_type, # type: ignore[arg-type] + record_type=canonical_type, statement=text.strip()[:max_statement_chars], subject_path=subject_path, created_by=created_by, diff --git a/codeclone/memory/jobs/staleness.py b/codeclone/memory/jobs/staleness.py index db99235a..13d3db9e 100644 --- a/codeclone/memory/jobs/staleness.py +++ b/codeclone/memory/jobs/staleness.py @@ -18,6 +18,7 @@ from ...audit.schema import open_audit_db_readonly from ...audit.validation import DEFAULT_AUDIT_PATH, resolve_audit_path from ...config.memory import MemoryConfig +from ...utils.payload_narrow import is_payload_dict from ..models import MemoryProject from ..trajectory.models import TRAJECTORY_PROJECTION_VERSION from .store import canonical_stimulus_json, latest_done_projection_job @@ -126,7 +127,7 @@ def parse_stimulus_json(raw: str | None) -> dict[str, object]: parsed = orjson.loads(raw) except orjson.JSONDecodeError: return {} - return dict(parsed) if isinstance(parsed, dict) else {} + return parsed if is_payload_dict(parsed) else {} def last_applied_stimulus( @@ -139,8 +140,8 @@ def last_applied_stimulus( return None result = parse_stimulus_json(job.result_json) applied = result.get("applied_stimulus") - if isinstance(applied, dict): - return dict(applied) + if is_payload_dict(applied): + return applied return parse_stimulus_json(job.stimulus_json) diff --git a/codeclone/memory/jobs/store.py b/codeclone/memory/jobs/store.py index 153855b4..6dbf5365 100644 --- a/codeclone/memory/jobs/store.py +++ b/codeclone/memory/jobs/store.py @@ -12,7 +12,7 @@ import uuid from collections.abc import Mapping from dataclasses import dataclass -from typing import cast +from typing import TypeVar from ...report.meta import current_report_timestamp_utc from ...utils.json_io import json_text @@ -25,6 +25,31 @@ ) PROJECTION_BUNDLE_KIND: ProjectionJobKind = "projection_bundle" +_LiteralT = TypeVar("_LiteralT", bound=str) + +_JOB_KIND_VALUES: tuple[ProjectionJobKind, ...] = ("projection_bundle",) +_JOB_STATUS_VALUES: tuple[ProjectionJobStatus, ...] = ( + "pending", + "running", + "done", + "failed", + "skipped", +) +_JOB_TRIGGER_VALUES: tuple[ProjectionJobTrigger, ...] = ( + "auto", + "explicit", + "mcp_finish", + "cli", +) +_JOB_KINDS: Mapping[str, ProjectionJobKind] = { + value: value for value in _JOB_KIND_VALUES +} +_JOB_STATUSES: Mapping[str, ProjectionJobStatus] = { + value: value for value in _JOB_STATUS_VALUES +} +_JOB_TRIGGERS: Mapping[str, ProjectionJobTrigger] = { + value: value for value in _JOB_TRIGGER_VALUES +} @dataclass(frozen=True, slots=True) @@ -48,13 +73,43 @@ def _new_job_id() -> str: return f"projjob-{uuid.uuid4().hex}" +def _literal_from_row( + row: sqlite3.Row, + column: str, + *, + field: str, + allowed: Mapping[str, _LiteralT], +) -> _LiteralT: + value = row[column] + if isinstance(value, str): + literal = allowed.get(value) + if literal is not None: + return literal + raise ValueError(f"Invalid Engineering Memory projection job {field}: {value!r}") + + def _row_to_record(row: sqlite3.Row) -> ProjectionJobRecord: return ProjectionJobRecord( id=str(row["id"]), project_id=str(row["project_id"]), - job_kind=cast(ProjectionJobKind, row["job_kind"]), - status=cast(ProjectionJobStatus, row["status"]), - trigger=cast(ProjectionJobTrigger, row["trigger"]), + job_kind=_literal_from_row( + row, + "job_kind", + field="job_kind", + allowed=_JOB_KINDS, + ), + status=_literal_from_row( + row, + "status", + field="status", + allowed=_JOB_STATUSES, + ), + trigger=_literal_from_row( + row, + "trigger", + field="trigger", + allowed=_JOB_TRIGGERS, + ), requested_at_utc=str(row["requested_at_utc"]), started_at_utc=row["started_at_utc"], finished_at_utc=row["finished_at_utc"], diff --git a/codeclone/memory/models.py b/codeclone/memory/models.py index e17f667d..60974ee2 100644 --- a/codeclone/memory/models.py +++ b/codeclone/memory/models.py @@ -7,10 +7,12 @@ from __future__ import annotations import uuid +from collections.abc import Mapping from dataclasses import dataclass, field -from typing import Literal +from typing import Annotated, Literal import orjson +from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator from ..contracts import ENGINEERING_MEMORY_SCHEMA_VERSION from ..utils.json_io import json_text @@ -30,6 +32,9 @@ from .identity import make_identity_key UpsertAction = Literal["created", "updated", "unchanged", "skipped"] +NonEmptyStr = Annotated[str, Field(min_length=1)] +NonNegativeInt = Annotated[int, Field(ge=0)] +PositiveInt = Annotated[int, Field(ge=1)] def generate_memory_id(*, prefix: str = "mem") -> str: @@ -166,6 +171,304 @@ class UpsertResult: revision_written: bool = False +class _StrictMemoryInput(BaseModel): + model_config = ConfigDict(strict=True, extra="forbid") + + +class _MemoryProjectInput(_StrictMemoryInput): + id: NonEmptyStr + root: NonEmptyStr + git_remote: str | None + git_branch: str | None + git_head: str | None + python_tag: str | None + created_at_utc: NonEmptyStr + updated_at_utc: NonEmptyStr + + +class _MemoryRecordInput(_StrictMemoryInput): + id: NonEmptyStr + project_id: NonEmptyStr + identity_key: NonEmptyStr + type: MemoryRecordType + status: MemoryStatus + confidence: MemoryConfidence + origin: MemoryOrigin + ingest_source: MemoryIngestSource + statement: NonEmptyStr + summary: str | None + payload: dict[str, object] | None + created_at_utc: NonEmptyStr + updated_at_utc: NonEmptyStr + last_verified_at_utc: str | None + expires_at_utc: str | None + created_by: NonEmptyStr + verified_by: str | None + approved_by: str | None + approved_at_utc: str | None + report_digest: str | None + code_fingerprint: str | None + stale_reason: str | None + created_on_branch: str | None + created_at_commit: str | None + verified_on_branch: str | None + verified_at_commit: str | None + schema_version: NonEmptyStr + + @field_validator("schema_version") + @classmethod + def _validate_schema_version(cls, value: str) -> str: + if value != ENGINEERING_MEMORY_SCHEMA_VERSION: + raise ValueError( + "schema_version must match ENGINEERING_MEMORY_SCHEMA_VERSION" + ) + return value + + +class _MemorySubjectInput(_StrictMemoryInput): + id: NonEmptyStr + memory_id: NonEmptyStr + subject_kind: SubjectKind + subject_key: NonEmptyStr + relation: SubjectRelation + + +class _MemoryEvidenceInput(_StrictMemoryInput): + id: NonEmptyStr + memory_id: NonEmptyStr + evidence_kind: EvidenceKind + ref: NonEmptyStr + locator: str | None + quote: str | None + digest: str | None + created_at_utc: NonEmptyStr + + +class _MemoryLinkInput(_StrictMemoryInput): + id: NonEmptyStr + project_id: NonEmptyStr + from_memory_id: NonEmptyStr + to_memory_id: NonEmptyStr + relation: LinkRelation + created_by: NonEmptyStr + created_at_utc: NonEmptyStr + + +class _IngestionRunInput(_StrictMemoryInput): + id: NonEmptyStr + project_id: NonEmptyStr + mode: IngestionMode + started_at_utc: NonEmptyStr + finished_at_utc: str | None + status: IngestionRunStatus + analysis_fingerprint: str | None + report_digest: str | None + branch: str | None + commit: str | None + records_created: NonNegativeInt + records_updated: NonNegativeInt + records_marked_stale: NonNegativeInt + candidates_created: NonNegativeInt + contradictions_found: NonNegativeInt + message: str | None + + +class _MemoryRevisionInput(_StrictMemoryInput): + id: NonEmptyStr + memory_id: NonEmptyStr + revision_number: PositiveInt + previous_statement: str | None + new_statement: NonEmptyStr + previous_payload: dict[str, object] | None + new_payload: dict[str, object] | None + reason: str | None + changed_by: NonEmptyStr + changed_at_utc: NonEmptyStr + branch: str | None + commit: str | None + + +def _error_location(error: Mapping[str, object]) -> str: + location = error.get("loc") + if not isinstance(location, tuple) or not location: + return "" + return ".".join(str(part) for part in location) + + +def _raise_validation_error(entity: str, exc: ValidationError) -> None: + first = exc.errors()[0] + location = _error_location(first) + message = first.get("msg", "invalid input") + raise ValueError( + f"Invalid Engineering Memory {entity}: {location}: {message}" + ) from exc + + +def _validate_input( + model: type[_StrictMemoryInput], + payload: Mapping[str, object], + *, + entity: str, +) -> None: + try: + model.model_validate(payload) + except ValidationError as exc: + _raise_validation_error(entity, exc) + + +def validate_memory_project(project: MemoryProject) -> MemoryProject: + _validate_input( + _MemoryProjectInput, + { + "id": project.id, + "root": project.root, + "git_remote": project.git_remote, + "git_branch": project.git_branch, + "git_head": project.git_head, + "python_tag": project.python_tag, + "created_at_utc": project.created_at_utc, + "updated_at_utc": project.updated_at_utc, + }, + entity="project", + ) + return project + + +def validate_memory_record(record: MemoryRecord) -> MemoryRecord: + _validate_input( + _MemoryRecordInput, + { + "id": record.id, + "project_id": record.project_id, + "identity_key": record.identity_key, + "type": record.type, + "status": record.status, + "confidence": record.confidence, + "origin": record.origin, + "ingest_source": record.ingest_source, + "statement": record.statement, + "summary": record.summary, + "payload": record.payload, + "created_at_utc": record.created_at_utc, + "updated_at_utc": record.updated_at_utc, + "last_verified_at_utc": record.last_verified_at_utc, + "expires_at_utc": record.expires_at_utc, + "created_by": record.created_by, + "verified_by": record.verified_by, + "approved_by": record.approved_by, + "approved_at_utc": record.approved_at_utc, + "report_digest": record.report_digest, + "code_fingerprint": record.code_fingerprint, + "stale_reason": record.stale_reason, + "created_on_branch": record.created_on_branch, + "created_at_commit": record.created_at_commit, + "verified_on_branch": record.verified_on_branch, + "verified_at_commit": record.verified_at_commit, + "schema_version": record.schema_version, + }, + entity="record", + ) + return record + + +def validate_memory_subject(subject: MemorySubject) -> MemorySubject: + _validate_input( + _MemorySubjectInput, + { + "id": subject.id, + "memory_id": subject.memory_id, + "subject_kind": subject.subject_kind, + "subject_key": subject.subject_key, + "relation": subject.relation, + }, + entity="subject", + ) + return subject + + +def validate_memory_evidence(evidence: MemoryEvidence) -> MemoryEvidence: + _validate_input( + _MemoryEvidenceInput, + { + "id": evidence.id, + "memory_id": evidence.memory_id, + "evidence_kind": evidence.evidence_kind, + "ref": evidence.ref, + "locator": evidence.locator, + "quote": evidence.quote, + "digest": evidence.digest, + "created_at_utc": evidence.created_at_utc, + }, + entity="evidence", + ) + return evidence + + +def validate_memory_link(link: MemoryLink) -> MemoryLink: + _validate_input( + _MemoryLinkInput, + { + "id": link.id, + "project_id": link.project_id, + "from_memory_id": link.from_memory_id, + "to_memory_id": link.to_memory_id, + "relation": link.relation, + "created_by": link.created_by, + "created_at_utc": link.created_at_utc, + }, + entity="link", + ) + return link + + +def validate_ingestion_run(run: IngestionRun) -> IngestionRun: + _validate_input( + _IngestionRunInput, + { + "id": run.id, + "project_id": run.project_id, + "mode": run.mode, + "started_at_utc": run.started_at_utc, + "finished_at_utc": run.finished_at_utc, + "status": run.status, + "analysis_fingerprint": run.analysis_fingerprint, + "report_digest": run.report_digest, + "branch": run.branch, + "commit": run.commit, + "records_created": run.records_created, + "records_updated": run.records_updated, + "records_marked_stale": run.records_marked_stale, + "candidates_created": run.candidates_created, + "contradictions_found": run.contradictions_found, + "message": run.message, + }, + entity="ingestion_run", + ) + return run + + +def validate_memory_revision(revision: MemoryRevision) -> MemoryRevision: + _validate_input( + _MemoryRevisionInput, + { + "id": revision.id, + "memory_id": revision.memory_id, + "revision_number": revision.revision_number, + "previous_statement": revision.previous_statement, + "new_statement": revision.new_statement, + "previous_payload": revision.previous_payload, + "new_payload": revision.new_payload, + "reason": revision.reason, + "changed_by": revision.changed_by, + "changed_at_utc": revision.changed_at_utc, + "branch": revision.branch, + "commit": revision.commit, + }, + entity="revision", + ) + return revision + + @dataclass class RecordBatch: records: list[MemoryRecord] = field(default_factory=list) @@ -219,4 +522,11 @@ def parse_payload_json(text: str | None) -> dict[str, object] | None: "make_identity_key", "parse_payload_json", "payload_json_text", + "validate_ingestion_run", + "validate_memory_evidence", + "validate_memory_link", + "validate_memory_project", + "validate_memory_record", + "validate_memory_revision", + "validate_memory_subject", ] diff --git a/codeclone/memory/retrieval/continuation.py b/codeclone/memory/retrieval/continuation.py index c1ade62b..48ae6e9d 100644 --- a/codeclone/memory/retrieval/continuation.py +++ b/codeclone/memory/retrieval/continuation.py @@ -10,8 +10,8 @@ import base64 import hashlib -from collections.abc import Mapping, Sequence -from typing import Final +from collections.abc import Callable, Mapping, Sequence +from typing import Final, TypeGuard import orjson @@ -177,24 +177,24 @@ def decode_memory_continuation_cursor(cursor: str) -> dict[str, object]: def resolve_memory_continuation_request( cursor_payload: Mapping[str, object], *, - resolve_request: object | None = None, + resolve_request: Callable[[str], object] | None = None, ) -> dict[str, object] | None: """Resolve the full projection request from a decoded cursor payload.""" version = str(cursor_payload.get("cursor_version", "")) if version == MEMORY_CONTINUATION_CURSOR_VERSION_LEGACY: request = cursor_payload.get("request") - return dict(request) if isinstance(request, Mapping) else None + return dict(request) if _is_object_mapping(request) else None request_digest = cursor_payload.get("request_digest") if not isinstance(request_digest, Mapping): return None digest_value = request_digest.get("value") if not isinstance(digest_value, str) or not digest_value.strip(): return None - if not callable(resolve_request): + if resolve_request is None: return None resolved = resolve_request(digest_value) - return dict(resolved) if isinstance(resolved, Mapping) else None + return dict(resolved) if _is_object_mapping(resolved) else None def _validate_cursor_request_binding(payload: Mapping[str, object]) -> None: @@ -288,6 +288,10 @@ def _padded_base64(value: str) -> bytes: return f"{value}{padding}".encode("ascii") +def _is_object_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) + + def _digest(payload: object) -> dict[str, str]: raw = orjson.dumps(payload, option=orjson.OPT_SORT_KEYS) return { diff --git a/codeclone/memory/retrieval/service.py b/codeclone/memory/retrieval/service.py index b42b7d58..af737f97 100644 --- a/codeclone/memory/retrieval/service.py +++ b/codeclone/memory/retrieval/service.py @@ -6,15 +6,23 @@ from __future__ import annotations -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from pathlib import PurePosixPath -from typing import TYPE_CHECKING, Literal, cast +from typing import TYPE_CHECKING, Literal from ...config.memory_defaults import DEFAULT_MEMORY_STATEMENT_PREVIEW_CHARS from ...contracts import SEMANTIC_INDEX_FORMAT_VERSION from ...observability import is_observability_enabled, record_counter, span from ..embedding import embed_query -from ..enums import LinkRelation, MemoryConfidence, MemoryRecordType, MemoryStatus +from ..enums import ( + LinkRelation, + MemoryConfidence, + MemoryRecordType, + MemoryStatus, + validate_memory_confidence, + validate_memory_record_type, + validate_memory_status, +) from ..exceptions import MemoryContractError, MemorySemanticUnavailableError from ..experience.models import Experience from ..models import MemoryEvidence, MemoryQuery, MemoryRecord, MemorySubject @@ -829,7 +837,7 @@ def get_memory_projection_page( project_id: str, cursor: str, page_size: int = DEFAULT_MEMORY_CONTINUATION_PAGE_SIZE, - resolve_request: object | None = None, + resolve_request: Callable[[str], object] | None = None, ) -> dict[str, object]: """Return a digest-bound continuation page for a memory retrieval lane.""" @@ -1010,9 +1018,14 @@ def _memory_retrieval_continuation( def _string_list(payload: Mapping[str, object], key: str) -> list[str]: value = payload.get(key) - if not isinstance(value, list) or not all(isinstance(item, str) for item in value): + if not isinstance(value, list): raise MemoryContractError(f"memory continuation request {key} is invalid") - return list(value) + result: list[str] = [] + for item in value: + if not isinstance(item, str): + raise MemoryContractError(f"memory continuation request {key} is invalid") + result.append(item) + return result def _load_patch_trails_for_trajectories( @@ -1023,6 +1036,16 @@ def _load_patch_trails_for_trajectories( return store.load_trajectory_patch_trails(trajectory_ids) +_MEMORY_FILTER_KEYS = ( + "types", + "statuses", + "confidences", + "match_mode", + "include_routine", +) +_MEMORY_FILTER_KEY_SET = frozenset(_MEMORY_FILTER_KEYS) + + def _parse_filters( filters: Mapping[str, object] | None, ) -> tuple[ @@ -1039,22 +1062,52 @@ def _parse_filters( include_routine = False if filters is None: return (), (), (), match_mode, include_routine + unknown = sorted(str(key) for key in filters if key not in _MEMORY_FILTER_KEY_SET) + if unknown: + raise MemoryContractError( + f"Unknown memory filter key(s): {', '.join(unknown)}. " + f"Allowed keys: {', '.join(_MEMORY_FILTER_KEYS)}." + ) raw_types = filters.get("types") if isinstance(raw_types, list): - types.extend(cast(MemoryRecordType, str(item)) for item in raw_types) + try: + types.extend(validate_memory_record_type(item) for item in raw_types) + except ValueError as exc: + raise MemoryContractError(str(exc)) from exc + elif raw_types is not None: + raise MemoryContractError("memory filter types must be a list of strings.") raw_statuses = filters.get("statuses") if isinstance(raw_statuses, list): - statuses.extend(cast(MemoryStatus, str(item)) for item in raw_statuses) + try: + statuses.extend(validate_memory_status(item) for item in raw_statuses) + except ValueError as exc: + raise MemoryContractError(str(exc)) from exc + elif raw_statuses is not None: + raise MemoryContractError("memory filter statuses must be a list of strings.") raw_confidences = filters.get("confidences") if isinstance(raw_confidences, list): - confidences.extend( - cast(MemoryConfidence, str(item)) for item in raw_confidences + try: + confidences.extend( + validate_memory_confidence(item) for item in raw_confidences + ) + except ValueError as exc: + raise MemoryContractError(str(exc)) from exc + elif raw_confidences is not None: + raise MemoryContractError( + "memory filter confidences must be a list of strings." ) raw_match = filters.get("match_mode") - if raw_match in {"all", "any"}: - match_mode = cast(SearchMatchMode, raw_match) - if bool(filters.get("include_routine")): - include_routine = True + if raw_match == "all": + match_mode = "all" + elif raw_match == "any": + match_mode = "any" + elif raw_match is not None: + raise MemoryContractError("memory filter match_mode must be 'any' or 'all'.") + raw_include_routine = filters.get("include_routine") + if isinstance(raw_include_routine, bool): + include_routine = raw_include_routine + elif raw_include_routine is not None: + raise MemoryContractError("memory filter include_routine must be boolean.") return ( tuple(types), tuple(statuses), diff --git a/codeclone/memory/schema.py b/codeclone/memory/schema.py index 512ea69b..eed1a389 100644 --- a/codeclone/memory/schema.py +++ b/codeclone/memory/schema.py @@ -244,7 +244,12 @@ def open_memory_db_readonly(path: Path) -> sqlite3.Connection: """Open an existing engineering-memory database without allowing writes.""" from ..observability.sqlite_access import open_instrumented_sqlite_db_readonly - return open_instrumented_sqlite_db_readonly(path, validate_schema=ensure_schema) + conn = open_instrumented_sqlite_db_readonly( + path, + validate_schema=validate_schema_readonly, + ) + conn.row_factory = sqlite3.Row + return conn def ensure_schema(conn: sqlite3.Connection) -> None: @@ -252,6 +257,8 @@ def ensure_schema(conn: sqlite3.Connection) -> None: if current is None: create_schema_v1(conn) return + from .schema_migrate import reconcile_memory_record_schema_versions + if current != ENGINEERING_MEMORY_SCHEMA_VERSION: from .schema_migrate import migrate_memory_schema @@ -262,6 +269,18 @@ def ensure_schema(conn: sqlite3.Connection) -> None: "Unsupported engineering memory schema version: " f"{current!r}. Expected {ENGINEERING_MEMORY_SCHEMA_VERSION!r}." ) + reconcile_memory_record_schema_versions(conn) + + +def validate_schema_readonly(conn: sqlite3.Connection) -> None: + """Validate an existing memory database without initializing or migrating.""" + + current = get_meta(conn, "schema_version") + if current != ENGINEERING_MEMORY_SCHEMA_VERSION: + raise MemorySchemaError( + "Engineering Memory requires writable schema initialization or migration: " + f"found {current!r}, expected {ENGINEERING_MEMORY_SCHEMA_VERSION!r}." + ) def create_schema_v1(conn: sqlite3.Connection) -> None: @@ -288,6 +307,7 @@ def create_schema_v1(conn: sqlite3.Connection) -> None: __all__ = [ + "MemorySchemaError", "create_schema_v1", "create_trajectory_schema", "ensure_schema", @@ -295,4 +315,5 @@ def create_schema_v1(conn: sqlite3.Connection) -> None: "open_memory_db", "open_memory_db_readonly", "set_meta", + "validate_schema_readonly", ] diff --git a/codeclone/memory/schema_migrate.py b/codeclone/memory/schema_migrate.py index 15ee624b..a50acbdb 100644 --- a/codeclone/memory/schema_migrate.py +++ b/codeclone/memory/schema_migrate.py @@ -8,11 +8,61 @@ import sqlite3 +import orjson + from ..report.meta import current_report_timestamp_utc +from .enums import ( + validate_memory_confidence, + validate_memory_ingest_source, + validate_memory_origin, + validate_memory_record_type, + validate_memory_status, +) from .exceptions import MemorySchemaError +from .models import MemoryRecord, validate_memory_record from .schema_fts import CREATE_MEMORY_RECORDS_FTS_SQL from .schema_meta import get_meta, set_meta +_SUPPORTED_LEGACY_RECORD_SCHEMA_VERSIONS = ( + "1.0", + "1.1", + "1.2", + "1.3", + "1.4", + "1.5", + "1.6", +) +_RECORD_SCHEMA_RECONCILE_SAVEPOINT = "memory_record_schema_reconcile" +_RECORD_COLUMNS = ( + "id", + "project_id", + "identity_key", + "type", + "status", + "confidence", + "origin", + "ingest_source", + "statement", + "summary", + "payload_json", + "created_at_utc", + "updated_at_utc", + "last_verified_at_utc", + "expires_at_utc", + "created_by", + "verified_by", + "approved_by", + "approved_at_utc", + "report_digest", + "code_fingerprint", + "stale_reason", + "created_on_branch", + "created_at_commit", + "verified_on_branch", + "verified_at_commit", + "schema_version", +) + def migrate_memory_schema(conn: sqlite3.Connection) -> None: from ..contracts import ENGINEERING_MEMORY_SCHEMA_VERSION @@ -53,6 +103,148 @@ def migrate_memory_schema(conn: sqlite3.Connection) -> None: raise MemorySchemaError(msg) +def reconcile_memory_record_schema_versions(conn: sqlite3.Connection) -> int: + """Promote supported legacy record rows only after current-contract validation. + + The per-row ``memory_records.schema_version`` is treated as the record format + discriminator. Supported legacy rows are decoded, normalized to the current + canonical record shape, and validated before their discriminator is updated. + Unsupported or malformed rows stay on their original version and therefore + remain quarantined by canonical read filters. + """ + + from ..contracts import ENGINEERING_MEMORY_SCHEMA_VERSION + + rows = _supported_legacy_record_rows(conn) + if not rows: + return 0 + + migrated = 0 + conn.execute(f"SAVEPOINT {_RECORD_SCHEMA_RECONCILE_SAVEPOINT}") + try: + for row in rows: + candidate = _current_record_from_supported_legacy_row(row) + if candidate is None: + continue + _write_current_record_schema_version( + conn, + record_id=candidate.id, + legacy_version=row["schema_version"], + current_version=ENGINEERING_MEMORY_SCHEMA_VERSION, + ) + migrated += 1 + except BaseException: + conn.execute(f"ROLLBACK TO {_RECORD_SCHEMA_RECONCILE_SAVEPOINT}") + conn.execute(f"RELEASE {_RECORD_SCHEMA_RECONCILE_SAVEPOINT}") + raise + conn.execute(f"RELEASE {_RECORD_SCHEMA_RECONCILE_SAVEPOINT}") + return migrated + + +def _supported_legacy_record_rows( + conn: sqlite3.Connection, +) -> list[dict[str, object]]: + placeholders = ", ".join("?" for _ in _SUPPORTED_LEGACY_RECORD_SCHEMA_VERSIONS) + rows = conn.execute( + "SELECT " + + ", ".join(_RECORD_COLUMNS) + + f" FROM memory_records WHERE schema_version IN ({placeholders}) " + "ORDER BY project_id ASC, identity_key ASC, id ASC", + _SUPPORTED_LEGACY_RECORD_SCHEMA_VERSIONS, + ).fetchall() + return [dict(zip(_RECORD_COLUMNS, row, strict=True)) for row in rows] + + +def _current_record_from_supported_legacy_row( + row: dict[str, object], +) -> MemoryRecord | None: + from ..contracts import ENGINEERING_MEMORY_SCHEMA_VERSION + + try: + record = MemoryRecord( + id=_required_text(row, "id"), + project_id=_required_text(row, "project_id"), + identity_key=_required_text(row, "identity_key"), + type=validate_memory_record_type(_required_text(row, "type")), + status=validate_memory_status(_required_text(row, "status")), + confidence=validate_memory_confidence(_required_text(row, "confidence")), + origin=validate_memory_origin(_required_text(row, "origin")), + ingest_source=validate_memory_ingest_source( + _required_text(row, "ingest_source") + ), + statement=_required_text(row, "statement"), + summary=_optional_text(row, "summary"), + payload=_strict_payload_json(row["payload_json"]), + created_at_utc=_required_text(row, "created_at_utc"), + updated_at_utc=_required_text(row, "updated_at_utc"), + last_verified_at_utc=_optional_text(row, "last_verified_at_utc"), + expires_at_utc=_optional_text(row, "expires_at_utc"), + created_by=_required_text(row, "created_by"), + verified_by=_optional_text(row, "verified_by"), + approved_by=_optional_text(row, "approved_by"), + approved_at_utc=_optional_text(row, "approved_at_utc"), + report_digest=_optional_text(row, "report_digest"), + code_fingerprint=_optional_text(row, "code_fingerprint"), + stale_reason=_optional_text(row, "stale_reason"), + created_on_branch=_optional_text(row, "created_on_branch"), + created_at_commit=_optional_text(row, "created_at_commit"), + verified_on_branch=_optional_text(row, "verified_on_branch"), + verified_at_commit=_optional_text(row, "verified_at_commit"), + schema_version=ENGINEERING_MEMORY_SCHEMA_VERSION, + ) + return validate_memory_record(record) + except (KeyError, TypeError, ValueError, orjson.JSONDecodeError): + return None + + +def _strict_payload_json(value: object) -> dict[str, object] | None: + if value is None: + return None + if not isinstance(value, str): + raise TypeError("payload_json must be text or NULL") + if not value.strip(): + return None + loaded = orjson.loads(value) + if not isinstance(loaded, dict): + raise ValueError("payload_json must decode to an object") + if not all(isinstance(key, str) for key in loaded): + raise ValueError("payload_json object keys must be strings") + return loaded + + +def _required_text(row: dict[str, object], key: str) -> str: + value = row[key] + if not isinstance(value, str) or not value: + raise ValueError(f"{key} must be non-empty text") + return value + + +def _optional_text(row: dict[str, object], key: str) -> str | None: + value = row[key] + if value is None: + return None + if not isinstance(value, str): + raise ValueError(f"{key} must be text or NULL") + return value + + +def _write_current_record_schema_version( + conn: sqlite3.Connection, + *, + record_id: str, + legacy_version: object, + current_version: str, +) -> None: + conn.execute( + """ + UPDATE memory_records + SET schema_version=? + WHERE id=? AND schema_version=? + """, + (current_version, record_id, legacy_version), + ) + + def _migrate_1_0_to_1_1(conn: sqlite3.Connection) -> None: conn.execute(CREATE_MEMORY_RECORDS_FTS_SQL) now = current_report_timestamp_utc() @@ -162,4 +354,4 @@ def _migrate_1_6_to_1_7(conn: sqlite3.Connection) -> None: _record_schema_migration(conn, "1.7") -__all__ = ["migrate_memory_schema"] +__all__ = ["migrate_memory_schema", "reconcile_memory_record_schema_versions"] diff --git a/codeclone/memory/semantic/projection_probe.py b/codeclone/memory/semantic/projection_probe.py index 7da4a46f..c9a98063 100644 --- a/codeclone/memory/semantic/projection_probe.py +++ b/codeclone/memory/semantic/projection_probe.py @@ -8,7 +8,7 @@ from collections.abc import Sequence from dataclasses import dataclass -from typing import Literal, TypedDict +from typing import Literal, TypedDict, TypeGuard from ..embedding.length import ( LengthDistribution, @@ -188,10 +188,14 @@ def _append_probe_sample( ) +def _is_semantic_lane(name: str) -> TypeGuard[SemanticLane]: + return name in {"memory", "audit", "trajectory"} + + def _lane_name(name: str) -> SemanticLane: - if name not in {"memory", "audit", "trajectory"}: + if not _is_semantic_lane(name): raise ValueError(f"unknown semantic lane: {name}") - return name # type: ignore[return-value] + return name def _lane_payload( diff --git a/codeclone/memory/sqlite_store.py b/codeclone/memory/sqlite_store.py index 7bda8bd1..6c53c39b 100644 --- a/codeclone/memory/sqlite_store.py +++ b/codeclone/memory/sqlite_store.py @@ -10,11 +10,34 @@ from collections.abc import Iterator, Mapping, Sequence from contextlib import contextmanager, suppress from pathlib import Path -from typing import cast +from typing import TypeVar +from ..contracts import ENGINEERING_MEMORY_SCHEMA_VERSION from ..report.meta import current_report_timestamp_utc from ..utils.iterutils import chunked -from .enums import LinkRelation +from .enums import ( + EVIDENCE_KIND_VALUES, + LINK_RELATION_VALUES, + MEMORY_CONFIDENCE_VALUES, + MEMORY_INGEST_SOURCE_VALUES, + MEMORY_ORIGIN_VALUES, + MEMORY_RECORD_TYPE_VALUES, + MEMORY_STATUS_VALUES, + SUBJECT_KIND_VALUES, + SUBJECT_RELATION_VALUES, + EvidenceKind, + LinkRelation, + MemoryConfidence, + MemoryIngestSource, + MemoryOrigin, + MemoryRecordType, + MemoryStatus, + SubjectKind, + SubjectRelation, + validate_memory_confidence, + validate_memory_record_type, + validate_memory_status, +) from .experience.models import Experience from .locks import memory_init_lock from .models import ( @@ -32,6 +55,13 @@ generate_memory_id, parse_payload_json, payload_json_text, + validate_ingestion_run, + validate_memory_evidence, + validate_memory_link, + validate_memory_project, + validate_memory_record, + validate_memory_revision, + validate_memory_subject, ) from .schema import get_meta, open_memory_db, set_meta from .search_index import ( @@ -48,6 +78,51 @@ ) _SQLITE_IN_QUERY_BATCH = 500 +_SqliteParam = str | int | float | bytes | None +_LiteralT = TypeVar("_LiteralT", bound=str) + +_MEMORY_RECORD_TYPES: Mapping[str, MemoryRecordType] = { + value: value for value in MEMORY_RECORD_TYPE_VALUES +} +_MEMORY_STATUSES: Mapping[str, MemoryStatus] = { + value: value for value in MEMORY_STATUS_VALUES +} +_MEMORY_CONFIDENCES: Mapping[str, MemoryConfidence] = { + value: value for value in MEMORY_CONFIDENCE_VALUES +} +_MEMORY_ORIGINS: Mapping[str, MemoryOrigin] = { + value: value for value in MEMORY_ORIGIN_VALUES +} +_MEMORY_INGEST_SOURCES: Mapping[str, MemoryIngestSource] = { + value: value for value in MEMORY_INGEST_SOURCE_VALUES +} +_SUBJECT_KINDS: Mapping[str, SubjectKind] = { + value: value for value in SUBJECT_KIND_VALUES +} +_SUBJECT_RELATIONS: Mapping[str, SubjectRelation] = { + value: value for value in SUBJECT_RELATION_VALUES +} +_EVIDENCE_KINDS: Mapping[str, EvidenceKind] = { + value: value for value in EVIDENCE_KIND_VALUES +} +_LINK_RELATIONS: Mapping[str, LinkRelation] = { + value: value for value in LINK_RELATION_VALUES +} + + +def _literal_from_row( + row: sqlite3.Row, + column: str, + *, + field: str, + allowed: Mapping[str, _LiteralT], +) -> _LiteralT: + value = row[column] + if isinstance(value, str): + literal = allowed.get(value) + if literal is not None: + return literal + raise ValueError(f"Invalid Engineering Memory {field}: {value!r}") class SqliteEngineeringMemoryStore: @@ -62,6 +137,7 @@ def db_path(self) -> Path: return self._db_path def initialize(self, project: MemoryProject) -> None: + project = validate_memory_project(project) now = current_report_timestamp_utc() self._conn.execute( """ @@ -287,6 +363,7 @@ def connection(self) -> sqlite3.Connection: return self._conn def write_record(self, record: MemoryRecord) -> None: + record = validate_memory_record(record) self._insert_record(record) self._conn.commit() @@ -312,6 +389,7 @@ def _commit_upsert_result( def upsert_record( self, record: MemoryRecord, *, commit: bool = True ) -> UpsertResult: + record = validate_memory_record(record) existing = self.find_by_identity_key(record.project_id, record.identity_key) now = current_report_timestamp_utc() if existing is not None and ( @@ -421,18 +499,26 @@ def upsert_record( ) def find_record(self, record_id: str) -> MemoryRecord | None: + clauses = ["id=?"] + params: list[_SqliteParam] = [record_id] + _append_canonical_record_filters(clauses, params) + where = " AND ".join(clauses) row = self._conn.execute( - "SELECT * FROM memory_records WHERE id=?", - (record_id,), + f"SELECT * FROM memory_records WHERE {where}", + params, ).fetchone() if row is None: return None return _record_from_row(row) def find_by_identity_key(self, project_id: str, key: str) -> MemoryRecord | None: + clauses = ["project_id=?", "identity_key=?"] + params: list[_SqliteParam] = [project_id, key] + _append_canonical_record_filters(clauses, params) + where = " AND ".join(clauses) row = self._conn.execute( - "SELECT * FROM memory_records WHERE project_id=? AND identity_key=?", - (project_id, key), + f"SELECT * FROM memory_records WHERE {where}", + params, ).fetchone() if row is None: return None @@ -440,7 +526,8 @@ def find_by_identity_key(self, project_id: str, key: str) -> MemoryRecord | None def query_records(self, query: MemoryQuery) -> Sequence[MemoryRecord]: clauses = ["project_id=?"] - params: list[object] = [query.project_id] + params: list[_SqliteParam] = [query.project_id] + _append_canonical_record_filters(clauses, params) if query.types: placeholders = ", ".join("?" for _ in query.types) clauses.append(f"type IN ({placeholders})") @@ -484,9 +571,19 @@ def list_subjects_for_memory(self, memory_id: str) -> list[MemorySubject]: MemorySubject( id=str(row["id"]), memory_id=str(row["memory_id"]), - subject_kind=str(row["subject_kind"]), # type: ignore[arg-type] + subject_kind=_literal_from_row( + row, + "subject_kind", + field="subject_kind", + allowed=_SUBJECT_KINDS, + ), subject_key=str(row["subject_key"]), - relation=str(row["relation"]), # type: ignore[arg-type] + relation=_literal_from_row( + row, + "relation", + field="subject_relation", + allowed=_SUBJECT_RELATIONS, + ), ) for row in rows ] @@ -517,9 +614,19 @@ def list_subjects_for_memories( MemorySubject( id=str(row["id"]), memory_id=memory_id, - subject_kind=str(row["subject_kind"]), # type: ignore[arg-type] + subject_kind=_literal_from_row( + row, + "subject_kind", + field="subject_kind", + allowed=_SUBJECT_KINDS, + ), subject_key=str(row["subject_key"]), - relation=str(row["relation"]), # type: ignore[arg-type] + relation=_literal_from_row( + row, + "relation", + field="subject_relation", + allowed=_SUBJECT_RELATIONS, + ), ) ) return grouped @@ -539,7 +646,12 @@ def list_evidence_for_memory(self, memory_id: str) -> list[MemoryEvidence]: MemoryEvidence( id=str(row["id"]), memory_id=str(row["memory_id"]), - evidence_kind=str(row["evidence_kind"]), # type: ignore[arg-type] + evidence_kind=_literal_from_row( + row, + "evidence_kind", + field="evidence_kind", + allowed=_EVIDENCE_KINDS, + ), ref=str(row["ref"]), locator=str(row["locator"]) if row["locator"] is not None else None, quote=str(row["quote"]) if row["quote"] is not None else None, @@ -589,6 +701,14 @@ def search_records( limit: int = 100, match_mode: SearchMatchMode = "any", ) -> list[MemoryRecord]: + types = tuple(validate_memory_record_type(value) for value in types) + statuses = tuple(validate_memory_status(value) for value in statuses) + confidences = tuple(validate_memory_confidence(value) for value in confidences) + if match_mode != "any" and match_mode != "all": + raise ValueError( + "Invalid Engineering Memory search_match_mode: " + f"{match_mode!r}. Allowed values: 'any', 'all'." + ) if self._fts_available(): ranked = self._search_records_fts( project_id=project_id, @@ -689,7 +809,8 @@ def _search_records_fts( "memory_records_fts MATCH ?", "memory_records_fts.project_id = ?", ] - params: list[object] = [match_expr, project_id] + params: list[_SqliteParam] = [match_expr, project_id] + _append_canonical_record_filters(clauses, params, prefix="memory_records.") _append_search_filters( clauses, params, @@ -730,7 +851,8 @@ def _search_records_like( if not tokens: return [] clauses = ["project_id=?"] - params: list[object] = [project_id] + params: list[_SqliteParam] = [project_id] + _append_canonical_record_filters(clauses, params) token_clauses: list[str] = [] for token in tokens: token_clauses.append( @@ -760,6 +882,7 @@ def _search_records_like( return [_record_from_row(row) for row in rows] def write_subject(self, subject: MemorySubject, *, commit: bool = True) -> None: + subject = validate_memory_subject(subject) existing = self._conn.execute( """ SELECT id FROM memory_subjects @@ -813,6 +936,7 @@ def prune_duplicate_subjects(self, *, commit: bool = True) -> int: return removed def write_evidence(self, evidence: MemoryEvidence) -> None: + evidence = validate_memory_evidence(evidence) self._conn.execute( """ INSERT OR REPLACE INTO memory_evidence( @@ -833,6 +957,7 @@ def write_evidence(self, evidence: MemoryEvidence) -> None: ) def write_link(self, link: MemoryLink) -> None: + link = validate_memory_link(link) self._conn.execute( """ INSERT OR REPLACE INTO memory_links( @@ -884,7 +1009,12 @@ def list_links_for_records( project_id=str(row["project_id"]), from_memory_id=str(row["from_memory_id"]), to_memory_id=str(row["to_memory_id"]), - relation=cast(LinkRelation, str(row["relation"])), + relation=_literal_from_row( + row, + "relation", + field="link_relation", + allowed=_LINK_RELATIONS, + ), created_by=str(row["created_by"]), created_at_utc=str(row["created_at_utc"]), ) @@ -892,6 +1022,7 @@ def list_links_for_records( ] def write_ingestion_run(self, run: IngestionRun) -> None: + run = validate_ingestion_run(run) self._conn.execute( """ INSERT OR REPLACE INTO memory_ingestion_runs( @@ -923,6 +1054,7 @@ def write_ingestion_run(self, run: IngestionRun) -> None: self._conn.commit() def write_revision(self, revision: MemoryRevision) -> None: + revision = validate_memory_revision(revision) self._conn.execute( """ INSERT OR REPLACE INTO memory_revisions( @@ -955,6 +1087,7 @@ def _update_lifecycle_status( stale_reason: str | None, commit: bool, ) -> None: + status = validate_memory_status(status) now = current_report_timestamp_utc() self._conn.execute( """ @@ -999,20 +1132,20 @@ def list_records_for_project( statuses: tuple[str, ...] = (), limit: int = 10000, ) -> list[MemoryRecord]: + clauses = ["project_id=?"] + params: list[_SqliteParam] = [project_id] + _append_canonical_record_filters(clauses, params) if statuses: + statuses = tuple(validate_memory_status(status) for status in statuses) placeholders = ", ".join("?" for _ in statuses) - rows = self._conn.execute( - f"SELECT * FROM memory_records WHERE project_id=? " - f"AND status IN ({placeholders}) " - "ORDER BY updated_at_utc DESC, id ASC LIMIT ?", - (project_id, *statuses, limit), - ).fetchall() - else: - rows = self._conn.execute( - "SELECT * FROM memory_records WHERE project_id=? " - "ORDER BY updated_at_utc DESC, id ASC LIMIT ?", - (project_id, limit), - ).fetchall() + clauses.append(f"status IN ({placeholders})") + params.extend(statuses) + where = " AND ".join(clauses) + rows = self._conn.execute( + f"SELECT * FROM memory_records WHERE {where} " + "ORDER BY updated_at_utc DESC, id ASC LIMIT ?", + (*params, limit), + ).fetchall() return [_record_from_row(row) for row in rows] def update_record_status( @@ -1025,6 +1158,7 @@ def update_record_status( stale_reason: str | None = None, commit: bool = True, ) -> None: + status = validate_memory_status(status) now = current_report_timestamp_utc() self._conn.execute( """ @@ -1040,6 +1174,7 @@ def update_record_status( self._conn.commit() def count_records_by_status(self, project_id: str, status: str) -> int: + status = validate_memory_status(status) row = self._conn.execute( "SELECT COUNT(*) FROM memory_records WHERE project_id=? AND status=?", (project_id, status), @@ -1053,6 +1188,7 @@ def delete_records_older_than( updated_before_utc: str, commit: bool = True, ) -> int: + status = validate_memory_status(status) rows = self._conn.execute( "SELECT id FROM memory_records WHERE status=? AND updated_at_utc < ?", (status, updated_before_utc), @@ -1157,6 +1293,7 @@ def exclusive_init_lock(self) -> Iterator[None]: yield def _insert_record(self, record: MemoryRecord) -> None: + record = validate_memory_record(record) self._conn.execute( """ INSERT INTO memory_records( @@ -1214,7 +1351,7 @@ def _next_revision_number(self, memory_id: str) -> int: def _append_in_filter( clauses: list[str], - params: list[object], + params: list[_SqliteParam], values: Sequence[str], column: str, ) -> None: @@ -1225,9 +1362,34 @@ def _append_in_filter( params.extend(values) +def _append_canonical_record_filters( + clauses: list[str], + params: list[_SqliteParam], + *, + prefix: str = "", +) -> None: + clauses.append(f"{prefix}schema_version=?") + params.append(ENGINEERING_MEMORY_SCHEMA_VERSION) + _append_in_filter(clauses, params, MEMORY_RECORD_TYPE_VALUES, f"{prefix}type") + _append_in_filter(clauses, params, MEMORY_STATUS_VALUES, f"{prefix}status") + _append_in_filter( + clauses, + params, + MEMORY_CONFIDENCE_VALUES, + f"{prefix}confidence", + ) + _append_in_filter(clauses, params, MEMORY_ORIGIN_VALUES, f"{prefix}origin") + _append_in_filter( + clauses, + params, + MEMORY_INGEST_SOURCE_VALUES, + f"{prefix}ingest_source", + ) + + def _append_confidence_filter( clauses: list[str], - params: list[object], + params: list[_SqliteParam], confidences: Sequence[str], *, via_subquery: bool, @@ -1248,7 +1410,7 @@ def _append_confidence_filter( def _append_search_filters( clauses: list[str], - params: list[object], + params: list[_SqliteParam], types: Sequence[str], statuses: Sequence[str], confidences: Sequence[str], @@ -1281,66 +1443,101 @@ def _record_content_equal(left: MemoryRecord, right: MemoryRecord) -> bool: def _record_from_row(row: sqlite3.Row) -> MemoryRecord: payload = parse_payload_json(row["payload_json"]) - return MemoryRecord( - id=str(row["id"]), - project_id=str(row["project_id"]), - identity_key=str(row["identity_key"]), - type=str(row["type"]), # type: ignore[arg-type] - status=str(row["status"]), # type: ignore[arg-type] - confidence=str(row["confidence"]), # type: ignore[arg-type] - origin=str(row["origin"]), # type: ignore[arg-type] - ingest_source=str(row["ingest_source"]), # type: ignore[arg-type] - statement=str(row["statement"]), - summary=str(row["summary"]) if row["summary"] is not None else None, - payload=payload, - created_at_utc=str(row["created_at_utc"]), - updated_at_utc=str(row["updated_at_utc"]), - last_verified_at_utc=( - str(row["last_verified_at_utc"]) - if row["last_verified_at_utc"] is not None - else None - ), - expires_at_utc=( - str(row["expires_at_utc"]) if row["expires_at_utc"] is not None else None - ), - created_by=str(row["created_by"]), - verified_by=str(row["verified_by"]) if row["verified_by"] is not None else None, - approved_by=str(row["approved_by"]) if row["approved_by"] is not None else None, - approved_at_utc=( - str(row["approved_at_utc"]) if row["approved_at_utc"] is not None else None - ), - report_digest=( - str(row["report_digest"]) if row["report_digest"] is not None else None - ), - code_fingerprint=( - str(row["code_fingerprint"]) - if row["code_fingerprint"] is not None - else None - ), - stale_reason=( - str(row["stale_reason"]) if row["stale_reason"] is not None else None - ), - created_on_branch=( - str(row["created_on_branch"]) - if row["created_on_branch"] is not None - else None - ), - created_at_commit=( - str(row["created_at_commit"]) - if row["created_at_commit"] is not None - else None - ), - verified_on_branch=( - str(row["verified_on_branch"]) - if row["verified_on_branch"] is not None - else None - ), - verified_at_commit=( - str(row["verified_at_commit"]) - if row["verified_at_commit"] is not None - else None - ), - schema_version=str(row["schema_version"]), + return validate_memory_record( + MemoryRecord( + id=str(row["id"]), + project_id=str(row["project_id"]), + identity_key=str(row["identity_key"]), + type=_literal_from_row( + row, + "type", + field="record_type", + allowed=_MEMORY_RECORD_TYPES, + ), + status=_literal_from_row( + row, + "status", + field="record_status", + allowed=_MEMORY_STATUSES, + ), + confidence=_literal_from_row( + row, + "confidence", + field="record_confidence", + allowed=_MEMORY_CONFIDENCES, + ), + origin=_literal_from_row( + row, + "origin", + field="record_origin", + allowed=_MEMORY_ORIGINS, + ), + ingest_source=_literal_from_row( + row, + "ingest_source", + field="record_ingest_source", + allowed=_MEMORY_INGEST_SOURCES, + ), + statement=str(row["statement"]), + summary=str(row["summary"]) if row["summary"] is not None else None, + payload=payload, + created_at_utc=str(row["created_at_utc"]), + updated_at_utc=str(row["updated_at_utc"]), + last_verified_at_utc=( + str(row["last_verified_at_utc"]) + if row["last_verified_at_utc"] is not None + else None + ), + expires_at_utc=( + str(row["expires_at_utc"]) + if row["expires_at_utc"] is not None + else None + ), + created_by=str(row["created_by"]), + verified_by=( + str(row["verified_by"]) if row["verified_by"] is not None else None + ), + approved_by=( + str(row["approved_by"]) if row["approved_by"] is not None else None + ), + approved_at_utc=( + str(row["approved_at_utc"]) + if row["approved_at_utc"] is not None + else None + ), + report_digest=( + str(row["report_digest"]) if row["report_digest"] is not None else None + ), + code_fingerprint=( + str(row["code_fingerprint"]) + if row["code_fingerprint"] is not None + else None + ), + stale_reason=( + str(row["stale_reason"]) if row["stale_reason"] is not None else None + ), + created_on_branch=( + str(row["created_on_branch"]) + if row["created_on_branch"] is not None + else None + ), + created_at_commit=( + str(row["created_at_commit"]) + if row["created_at_commit"] is not None + else None + ), + verified_on_branch=( + str(row["verified_on_branch"]) + if row["verified_on_branch"] is not None + else None + ), + verified_at_commit=( + str(row["verified_at_commit"]) + if row["verified_at_commit"] is not None + else None + ), + schema_version=str(row["schema_version"]), + ) ) diff --git a/codeclone/memory/trajectory/export_context.py b/codeclone/memory/trajectory/export_context.py index eeb2638a..562d2cdf 100644 --- a/codeclone/memory/trajectory/export_context.py +++ b/codeclone/memory/trajectory/export_context.py @@ -9,6 +9,7 @@ import re import sqlite3 from collections.abc import Mapping, Sequence +from typing import TypeGuard import orjson @@ -191,7 +192,7 @@ def extract_trajectory_citations(trajectory: Trajectory) -> list[dict[str, objec def _event_core_facts(event_core_json: str) -> Mapping[str, object] | None: core = _load_event_core(event_core_json) facts = core.get("facts") - return facts if isinstance(facts, Mapping) else None + return facts if _is_object_mapping(facts) else None def _citation_items_from_facts( @@ -200,7 +201,7 @@ def _citation_items_from_facts( raw_citations = facts.get("citations") if not isinstance(raw_citations, list): return [] - return [item for item in raw_citations if isinstance(item, Mapping)] + return [item for item in raw_citations if _is_object_mapping(item)] def _append_trajectory_citation( @@ -466,7 +467,11 @@ def _load_event_core(event_core_json: str) -> Mapping[str, object]: loaded = orjson.loads(event_core_json) except orjson.JSONDecodeError: return {} - return loaded if isinstance(loaded, Mapping) else {} + return loaded if _is_object_mapping(loaded) else {} + + +def _is_object_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) def _preview_text(value: str) -> str: diff --git a/codeclone/memory/trajectory/patch_trail.py b/codeclone/memory/trajectory/patch_trail.py index 68af6c20..37bb94dc 100644 --- a/codeclone/memory/trajectory/patch_trail.py +++ b/codeclone/memory/trajectory/patch_trail.py @@ -9,6 +9,7 @@ import hashlib from collections.abc import Mapping, Sequence from dataclasses import dataclass +from typing import TypeGuard from ...contracts import PATCH_TRAIL_SCHEMA_VERSION from ...utils.json_io import json_text @@ -384,7 +385,11 @@ def _string_tuple(value: object) -> tuple[str, ...]: def _mapping(value: object) -> dict[str, object]: - return dict(value) if isinstance(value, Mapping) else {} + return dict(value) if _is_object_mapping(value) else {} + + +def _is_object_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) def _optional_str(value: object) -> str | None: diff --git a/codeclone/memory/trajectory/projector.py b/codeclone/memory/trajectory/projector.py index ea3c6ba0..be2bf67d 100644 --- a/codeclone/memory/trajectory/projector.py +++ b/codeclone/memory/trajectory/projector.py @@ -8,6 +8,7 @@ import hashlib from collections.abc import Iterable, Mapping, Sequence +from typing import TypeGuard import orjson @@ -420,7 +421,7 @@ def _path_subjects_from_cores( untouched: set[str] = set() for core in cores: facts = core.get("facts") - if not isinstance(facts, Mapping): + if not _is_object_mapping(facts): continue about.update(_facts_path_list(facts, "scope_paths")) about.update(_facts_path_list(facts, "declared_scope_paths")) @@ -445,6 +446,10 @@ def _facts_path_list(facts: Mapping[str, object], key: str) -> tuple[str, ...]: return tuple(sorted(set(paths))) +def _is_object_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) + + def _summary( *, workflow_id: str, diff --git a/codeclone/memory/trajectory/store.py b/codeclone/memory/trajectory/store.py index c1421452..4e3ca99f 100644 --- a/codeclone/memory/trajectory/store.py +++ b/codeclone/memory/trajectory/store.py @@ -32,9 +32,12 @@ TRAJECTORY_PROJECTION_VERSION, Trajectory, TrajectoryEvidence, + TrajectoryLabel, TrajectoryListItem, + TrajectoryOutcome, TrajectoryProjectionResult, TrajectoryProjectionRun, + TrajectoryQualityTier, TrajectoryStep, TrajectorySubject, ) @@ -86,6 +89,92 @@ def _group_rows_by_trajectory_id( return grouped +def _trajectory_outcome(value: object) -> TrajectoryOutcome: + match value: + case "accepted": + return "accepted" + case "accepted_with_external_changes": + return "accepted_with_external_changes" + case "violated": + return "violated" + case "blocked": + return "blocked" + case "abandoned": + return "abandoned" + case "partial": + return "partial" + raise ValueError(f"Invalid Engineering Memory trajectory outcome: {value!r}") + + +def _trajectory_quality_tier(value: object) -> TrajectoryQualityTier: + match value: + case "corrected": + return "corrected" + case "verified": + return "verified" + case "incident": + return "incident" + case "partial": + return "partial" + case "routine": + return "routine" + raise ValueError(f"Invalid Engineering Memory trajectory quality_tier: {value!r}") + + +def _trajectory_label(value: object) -> TrajectoryLabel: + match value: + case "analysis_observed": + return "analysis_observed" + case "baseline_abuse_detected": + return "baseline_abuse_detected" + case "change_control_workflow": + return "change_control_workflow" + case "claim_guard_failed": + return "claim_guard_failed" + case "claim_validated": + return "claim_validated" + case "external_changes_accepted": + return "external_changes_accepted" + case "foreign_conflict_seen": + return "foreign_conflict_seen" + case "hook_blocked": + return "hook_blocked" + case "memory_used": + return "memory_used" + case "patch_trail_recorded": + return "patch_trail_recorded" + case "queue_used": + return "queue_used" + case "receipt_issued": + return "receipt_issued" + case "recovered": + return "recovered" + case "scope_clean": + return "scope_clean" + case "scope_expanded": + return "scope_expanded" + case "verified_finish": + return "verified_finish" + raise ValueError(f"Invalid Engineering Memory trajectory label: {value!r}") + + +def _labels_from_json(labels_json: str) -> tuple[TrajectoryLabel, ...]: + raw = orjson.loads(labels_json) + if not isinstance(raw, list): + raise ValueError( + "Invalid Engineering Memory trajectory labels_json: expected list" + ) + return tuple(_trajectory_label(item) for item in raw) + + +def _validate_trajectory_literals(trajectory: Trajectory) -> Trajectory: + _trajectory_outcome(trajectory.outcome) + _trajectory_quality_tier(trajectory.quality_tier) + for label in trajectory.labels: + _trajectory_label(label) + return trajectory + + def _project_and_upsert_workflow( conn: sqlite3.Connection, *, @@ -296,6 +385,7 @@ def rebuild_trajectories_incremental( def upsert_trajectory(conn: sqlite3.Connection, trajectory: Trajectory) -> str: + trajectory = _validate_trajectory_literals(trajectory) existing = conn.execute( "SELECT trajectory_digest FROM memory_trajectories WHERE id=?", (trajectory.id,), @@ -495,8 +585,8 @@ def list_trajectories( TrajectoryListItem( id=str(row["id"]), workflow_id=str(row["workflow_id"]), - outcome=str(row["outcome"]), - quality_tier=str(row["quality_tier"]), + outcome=_trajectory_outcome(row["outcome"]), + quality_tier=_trajectory_quality_tier(row["quality_tier"]), quality_score=int(row["quality_score"]), event_count=int(row["event_count"]), started_at_utc=str(row["started_at_utc"]), @@ -627,10 +717,10 @@ def _row_to_trajectory( first_run_id=_optional_text(row["first_run_id"]), last_run_id=_optional_text(row["last_run_id"]), report_digest=_optional_text(row["report_digest"]), - outcome=str(row["outcome"]), # type: ignore[arg-type] - quality_tier=str(row["quality_tier"]), # type: ignore[arg-type] + outcome=_trajectory_outcome(row["outcome"]), + quality_tier=_trajectory_quality_tier(row["quality_tier"]), quality_score=int(row["quality_score"]), - labels=tuple(orjson.loads(str(row["labels_json"]))), + labels=_labels_from_json(str(row["labels_json"])), summary=str(row["summary"]), trajectory_digest=str(row["trajectory_digest"]), source_event_stream_digest=str(row["source_event_stream_digest"]), diff --git a/codeclone/observability/query.py b/codeclone/observability/query.py index 3ac58bec..7571e928 100644 --- a/codeclone/observability/query.py +++ b/codeclone/observability/query.py @@ -17,17 +17,18 @@ from __future__ import annotations -from collections.abc import Callable +import sqlite3 +from collections.abc import Callable, Iterator from pathlib import Path from ..config.observability import resolve_observability_config from .runtime import DB_COUNTER_VERSION from .store.reader import build_trace_view, open_observability_store_readonly -from .views import AggregatesView, OperationView, TraceView +from .views import AggregatesView, OperationView, SpanView, TraceView _DETAIL_LEVELS = ("compact", "normal", "full") _LIMIT_MIN = 1 -_LIMIT_MAX = 50 +_LIMIT_MAX = 100 _LIMIT_DEFAULT = 10 _COMPACT_ROWS = 5 _CHAIN_CHILD_CAP = 12 @@ -52,6 +53,10 @@ "pipeline", "analysis_phase_cost", ) +# Per-object detail sections: full per-span fields for one operation / span by id, +# parity with the HTML trace. They consume operation_id / span_id and support +# detail_level=full (aggregate sections downgrade full to normal). +_DETAIL_SECTIONS = ("operation_detail", "span_detail") def _round1(value: float | None) -> float | None: @@ -75,14 +80,13 @@ def _envelope(section: str, detail_level: str, window: str) -> dict[str, object] } -def _resolve_detail(detail_level: str, warnings: list[str]) -> str: +def _resolve_detail(detail_level: str, section: str, warnings: list[str]) -> str: if detail_level not in _DETAIL_LEVELS: warnings.append(f"unknown detail_level {detail_level!r}; using compact") return "compact" - if detail_level == "full": - # No aggregate section supports full; only operation_detail/span_detail - # (a future phase) do. Downgrade rather than error so an agent never - # stalls mid-diagnosis. + if detail_level == "full" and section not in _DETAIL_SECTIONS: + # Only the per-object detail sections support full; aggregate sections + # downgrade rather than error so an agent never stalls mid-diagnosis. warnings.append( "full detail is only available for operation_detail/span_detail; " "downgraded to normal" @@ -101,12 +105,19 @@ def _clamp_limit(limit: int, warnings: list[str]) -> int: return limit -def _ignored_parameters(operation_id: str | None, span_id: str | None) -> list[str]: - # P1 has only aggregate sections; the by-id selectors are not consumed yet. +def _ignored_parameters( + section: str, operation_id: str | None, span_id: str | None +) -> list[str]: + # operation_detail consumes operation_id; span_detail consumes span_id; every + # aggregate section ignores both. Echo the unused selector so the caller knows. + consumed = { + "operation_detail": "operation_id", + "span_detail": "span_id", + }.get(section) ignored = [] - if operation_id is not None: + if operation_id is not None and consumed != "operation_id": ignored.append("operation_id") - if span_id is not None: + if span_id is not None and consumed != "span_id": ignored.append("span_id") return ignored @@ -117,15 +128,16 @@ def _absent_status() -> str: return "no_store" if resolve_observability_config().enabled else "disabled" -def _build_trace(conn: object, window: str) -> TraceView: +def _build_trace(conn: sqlite3.Connection, window: str) -> TraceView: if window == "latest": - return build_trace_view(conn) # type: ignore[arg-type] - return build_trace_view(conn, correlation_id=window) # type: ignore[arg-type] + return build_trace_view(conn) + return build_trace_view(conn, correlation_id=window) def _slow_operations(agg: AggregatesView, cap: int) -> list[dict[str, object]]: return [ { + "operation_id": op.operation_id, "operation": op.name, "surface": op.surface, "duration_ms": round(op.duration_ms, 1), @@ -138,6 +150,8 @@ def _slow_operations(agg: AggregatesView, cap: int) -> list[dict[str, object]]: def _memory_pipeline_cost(agg: AggregatesView, cap: int) -> list[dict[str, object]]: return [ { + "span_id": s.span_id, + "operation_id": s.operation_id, "span": s.name, "operation": s.operation_name, "duration_ms": round(s.duration_ms, 1), @@ -186,6 +200,8 @@ def _costly_noops(agg: AggregatesView, cap: int) -> list[dict[str, object]]: noops = [s for s in agg.semantic_costs if s.no_op] return [ { + "span_id": s.span_id, + "operation_id": s.operation_id, "span": s.name, "operation": s.operation_name, "duration_ms": round(s.duration_ms, 1), @@ -303,6 +319,7 @@ def _chain_peak_rss_absolute(op: OperationView) -> float | None: def _correlated_chains(trace: TraceView, cap: int) -> list[dict[str, object]]: return [ { + "operation_id": root.operation_id, "root": root.name, "children": _chain_descendant_names(root)[:_CHAIN_CHILD_CAP], "duration_ms": round(root.duration_ms, 1), @@ -313,6 +330,73 @@ def _correlated_chains(trace: TraceView, cap: int) -> list[dict[str, object]]: ] +def _span_row(span: SpanView) -> dict[str, object]: + return { + "span_id": span.span_id, + "name": span.name, + "duration_ms": round(span.duration_ms, 1), + "span_status": span.status, + "parent_span_id": span.parent_span_id, + "reason_kind": span.reason_kind, + "started_at_utc": span.started_at_utc, + "rss_mb": _round1(span.rss_mb), + "peak_rss_mb": _round1(span.peak_rss_mb), + "rss_delta_mb": _round1(span.rss_delta_mb), + "peak_rss_delta_mb": _round1(span.peak_rss_delta_mb), + "counters": dict(span.counters), + "db_fingerprints": dict(span.db_fingerprints), + } + + +def _iter_operations(ops: tuple[OperationView, ...]) -> Iterator[OperationView]: + for op in ops: + yield op + yield from _iter_operations(op.children) + + +def _operation_detail_body( + trace: TraceView, operation_id: str, cap: int +) -> dict[str, object]: + op = next( + ( + candidate + for candidate in _iter_operations(trace.operation_tree) + if candidate.operation_id == operation_id + ), + None, + ) + if op is None: + return {"status": "not_found", "operation_id": operation_id, "spans": []} + return { + "status": "ok", + "operation_id": op.operation_id, + "name": op.name, + "surface": op.surface, + "duration_ms": round(op.duration_ms, 1), + "op_status": op.status, + "rss_mb": _round1(op.rss_mb), + "peak_rss_mb": _round1(op.peak_rss_mb), + "rss_delta_mb": _round1(op.rss_delta_mb), + "peak_rss_delta_mb": _round1(op.peak_rss_delta_mb), + "cpu_user_ms": _round1(op.cpu_user_ms), + "cpu_system_ms": _round1(op.cpu_system_ms), + "span_count": len(op.spans), + "spans": [_span_row(span) for span in op.spans[:cap]], + } + + +def _span_detail_body(trace: TraceView, span_id: str) -> dict[str, object]: + for op in _iter_operations(trace.operation_tree): + for span in op.spans: + if span.span_id == span_id: + row = _span_row(span) + row["status"] = "ok" + row["operation_id"] = op.operation_id + row["operation_name"] = op.name + return row + return {"status": "not_found", "span_id": span_id} + + def _memory_diagnostic(agg: AggregatesView) -> dict[str, object] | None: span = agg.peak_memory_span if span is None: @@ -418,6 +502,20 @@ def _summary_body(trace: TraceView) -> dict[str, object]: return body +def _aggregate_status(agg: AggregatesView) -> str: + return "ok" if agg.operation_count else "empty" + + +def _apply_aggregate_status( + response: dict[str, object], + *, + section: str, + agg: AggregatesView, +) -> None: + if section in _AGGREGATE_SECTIONS: + response["status"] = _aggregate_status(agg) + + def _recommended_next_sections( section: str, agg: AggregatesView ) -> list[dict[str, object]]: @@ -478,25 +576,44 @@ def query_platform_observability( data — an absent store yields an inert ``disabled``/``no_store`` envelope. """ warnings: list[str] = [] - detail = _resolve_detail(detail_level, warnings) + detail = _resolve_detail(detail_level, section, warnings) clamped = _clamp_limit(limit, warnings) - row_cap = clamped if detail == "normal" else min(clamped, _COMPACT_ROWS) + row_cap = min(clamped, _COMPACT_ROWS) if detail == "compact" else clamped response = _envelope(section, detail, window) if detail != detail_level: response["requested_detail_level"] = detail_level - ignored = _ignored_parameters(operation_id, span_id) + ignored = _ignored_parameters(section, operation_id, span_id) if ignored: response["ignored_parameters"] = ignored - if section not in _AGGREGATE_SECTIONS: + if section not in _AGGREGATE_SECTIONS and section not in _DETAIL_SECTIONS: response["status"] = "invalid_section" response["error"] = f"unknown section {section!r}" - response["available_sections"] = list(_AGGREGATE_SECTIONS) + response["available_sections"] = [ + *_AGGREGATE_SECTIONS, + *_DETAIL_SECTIONS, + ] response["rows"] = [] return _finalize(response, warnings) - conn = open_observability_store_readonly(Path(root)) + if section == "operation_detail" and not operation_id: + response["status"] = "invalid_selector" + response["error"] = "operation_detail requires operation_id" + response["spans"] = [] + return _finalize(response, warnings) + if section == "span_detail" and not span_id: + response["status"] = "invalid_selector" + response["error"] = "span_detail requires span_id" + return _finalize(response, warnings) + + try: + conn = open_observability_store_readonly(Path(root)) + except RuntimeError as exc: + response["status"] = "incompatible_schema" + response["error"] = str(exc) + response["rows"] = [] + return _finalize(response, warnings) if conn is None: response["status"] = _absent_status() response["rows"] = [] @@ -507,7 +624,14 @@ def query_platform_observability( conn.close() agg = trace.aggregates - if section == "summary": + _apply_aggregate_status(response, section=section, agg=agg) + if section == "operation_detail": + assert operation_id is not None + response.update(_operation_detail_body(trace, operation_id, row_cap)) + elif section == "span_detail": + assert span_id is not None + response.update(_span_detail_body(trace, span_id)) + elif section == "summary": response.update(_summary_body(trace)) elif section == "agent_context": response.update(_agent_context_body(agg, row_cap)) diff --git a/codeclone/observability/runtime.py b/codeclone/observability/runtime.py index 17d3ad3e..af1de7ff 100644 --- a/codeclone/observability/runtime.py +++ b/codeclone/observability/runtime.py @@ -18,17 +18,21 @@ import sqlite3 import time import uuid -from collections.abc import Iterable, Iterator, Mapping, Sequence +from collections.abc import Iterable, Iterator from contextlib import contextmanager from contextvars import ContextVar from datetime import datetime, timezone from pathlib import Path +from typing import TYPE_CHECKING from ..config.observability import ObservabilityConfig, resolve_observability_config from .db_fingerprint import fingerprint_sql from .models import OperationRecord, ProfileSample, SpanRecord from .reason_kind import ReasonKind +if TYPE_CHECKING: + from sqlite3 import _Parameters as _SqlParams + # Bound how many distinct SQL shapes a span persists; the diagnostic value is in # the few high-count statements, not the long tail. _DB_FINGERPRINT_TOP_N = 8 @@ -515,9 +519,6 @@ def record_counter(key: str, value: int = 1) -> None: span_handle.add_counter(key, value) -_SqlParams = Sequence[object] | Mapping[str, object] - - class _CountingConnection(sqlite3.Connection): """``sqlite3.Connection`` that counts logical statements on the active span. @@ -529,14 +530,12 @@ class _CountingConnection(sqlite3.Connection): outside a span, so connection open (pragmas, schema) is not attributed. """ - def execute( # type: ignore[override] - self, sql: str, parameters: _SqlParams = () - ) -> sqlite3.Cursor: + def execute(self, sql: str, parameters: _SqlParams = (), /) -> sqlite3.Cursor: _record_db_statement(sql, rows=1) return super().execute(sql, parameters) - def executemany( # type: ignore[override] - self, sql: str, parameters: Iterable[_SqlParams] + def executemany( + self, sql: str, parameters: Iterable[_SqlParams], / ) -> sqlite3.Cursor: materialized = list(parameters) _record_db_statement(sql, rows=len(materialized)) diff --git a/codeclone/observability/store/reader.py b/codeclone/observability/store/reader.py index 9b3000e8..bba2409a 100644 --- a/codeclone/observability/store/reader.py +++ b/codeclone/observability/store/reader.py @@ -17,7 +17,6 @@ from dataclasses import dataclass from datetime import datetime from pathlib import Path -from typing import cast import orjson @@ -26,6 +25,7 @@ PHASE_VOLUME_COUNTER_SUFFIXES, ) from ...contracts import PLATFORM_OBSERVABILITY_SCHEMA_VERSION +from ...utils.sqlite_store import open_sqlite_db_readonly from ..db_fingerprint import describe_fingerprint from ..views import ( AgentTokenRow, @@ -44,7 +44,7 @@ WaterfallGroup, WaterfallRow, ) -from .schema import observability_store_path +from .schema import observability_store_path, validate_observability_schema _DEFAULT_WINDOW = 20 @@ -69,7 +69,7 @@ def open_observability_store_readonly(root: Path) -> sqlite3.Connection | None: path = observability_store_path(root) if not path.is_file(): return None - conn = sqlite3.connect(str(path)) + conn = open_sqlite_db_readonly(path, validate_schema=validate_observability_schema) conn.row_factory = sqlite3.Row return conn @@ -82,13 +82,20 @@ def _percentile(values: list[float], q: float) -> float: return ordered[min(index, len(ordered) - 1)] -def _parse_counters(raw: object) -> dict[str, int]: +def _parse_counters( + raw: str | bytes | bytearray | memoryview | None, +) -> dict[str, int]: if not raw: return {} - parsed = orjson.loads(cast("str", raw)) - return ( - {str(k): int(v) for k, v in parsed.items()} if isinstance(parsed, dict) else {} - ) + parsed = orjson.loads(raw) + if not isinstance(parsed, dict): + return {} + counters: dict[str, int] = {} + for key, value in parsed.items(): + if isinstance(value, bool) or not isinstance(value, str | int | float): + continue + counters[str(key)] = int(value) + return counters def _optional_float(row: sqlite3.Row, key: str) -> float | None: diff --git a/codeclone/observability/store/schema.py b/codeclone/observability/store/schema.py index 63814f50..b43947e7 100644 --- a/codeclone/observability/store/schema.py +++ b/codeclone/observability/store/schema.py @@ -17,8 +17,10 @@ from pathlib import Path from ...contracts import PLATFORM_OBSERVABILITY_SCHEMA_VERSION +from ...utils.sqlite_store import get_meta_value, open_sqlite_db _OBSERVABILITY_DB_RELATIVE = ".codeclone/db/platform_observability.sqlite3" +_SCHEMA_META_KEY = "schema_version" _SCHEMA = """ CREATE TABLE IF NOT EXISTS platform_meta ( @@ -126,7 +128,43 @@ def _ensure_operation_columns(conn: sqlite3.Connection) -> None: _ensure_peak_rss_columns(conn, table="platform_operations", existing=existing) +def _schema_version_key(value: str) -> tuple[int, ...] | None: + parts: list[int] = [] + for part in value.split("."): + if not part.isdigit(): + return None + parts.append(int(part)) + return tuple(parts) if parts else None + + +def validate_observability_schema(conn: sqlite3.Connection) -> None: + stored = get_meta_value( + conn, + meta_table="platform_meta", + key=_SCHEMA_META_KEY, + ) + if stored is None: + return + stored_key = _schema_version_key(stored) + current_key = _schema_version_key(PLATFORM_OBSERVABILITY_SCHEMA_VERSION) + if stored_key is None or current_key is None: + msg = ( + "Unsupported Platform Observability schema version " + f"{stored!r}; current supported version is " + f"{PLATFORM_OBSERVABILITY_SCHEMA_VERSION!r}." + ) + raise RuntimeError(msg) + if stored_key > current_key: + msg = ( + "Platform Observability store schema " + f"{stored!r} is newer than this CodeClone build supports " + f"({PLATFORM_OBSERVABILITY_SCHEMA_VERSION!r})." + ) + raise RuntimeError(msg) + + def create_observability_schema(conn: sqlite3.Connection) -> None: + validate_observability_schema(conn) conn.executescript(_SCHEMA) _ensure_span_columns(conn) _ensure_operation_columns(conn) @@ -139,14 +177,12 @@ def create_observability_schema(conn: sqlite3.Connection) -> None: def open_observability_store(path: Path) -> sqlite3.Connection: """Open (creating the parent dir + schema) the observability store.""" - path.parent.mkdir(parents=True, exist_ok=True) - conn = sqlite3.connect(str(path)) - create_observability_schema(conn) - return conn + return open_sqlite_db(path, ensure_schema=create_observability_schema) __all__ = [ "create_observability_schema", "observability_store_path", "open_observability_store", + "validate_observability_schema", ] diff --git a/codeclone/paths/gitignore.py b/codeclone/paths/gitignore.py index 78fec300..364f8819 100644 --- a/codeclone/paths/gitignore.py +++ b/codeclone/paths/gitignore.py @@ -11,6 +11,7 @@ from pathlib import Path from typing import Final +from ..utils.atomic_write import write_text_atomically from .workspace import WORKSPACE_DIR_NAME _COVERING_PATTERN_CORES: Final[frozenset[str]] = frozenset( @@ -49,15 +50,9 @@ def gitignore_pattern_covers_codeclone_cache(pattern: str) -> bool: if not normalized or normalized.startswith("!"): return False core = normalized.lstrip("/").rstrip("/") - if core in _COVERING_PATTERN_CORES: - return True - return core.endswith( - ( - ".codeclone", - ".codeclone/**", - ".cache/codeclone", - ".cache/codeclone/**", - ) + return any( + core == covering_core or core.endswith(f"/{covering_core}") + for covering_core in _COVERING_PATTERN_CORES ) @@ -85,13 +80,31 @@ def gitignore_codeclone_cache_tip_payload() -> dict[str, object]: } +def append_gitignore_line(before_text: str, line: str) -> str: + """Append one gitignore entry with deterministic trailing newline.""" + + if not before_text: + return f"{line}\n" + if before_text.endswith("\n"): + return f"{before_text}{line}\n" + return f"{before_text}\n{line}\n" + + +def write_gitignore_text_atomically(gitignore_path: Path, text: str) -> None: + """Write ``.gitignore`` text via temp file + ``os.replace``.""" + + write_text_atomically(gitignore_path, text) + + __all__ = [ "GITIGNORE_CODECLONE_CACHE_MESSAGE", "GITIGNORE_CODECLONE_CACHE_SUGGESTED_ENTRY", "GITIGNORE_CODECLONE_CACHE_TIP_ID", "WORKSPACE_HYGIENE_CATEGORY", + "append_gitignore_line", "gitignore_codeclone_cache_tip_payload", "gitignore_pattern_covers_codeclone_cache", "normalize_gitignore_pattern", "repo_gitignore_covers_codeclone_cache", + "write_gitignore_text_atomically", ] diff --git a/codeclone/report/document/derived.py b/codeclone/report/document/derived.py index 23f209fa..6c0a98dd 100644 --- a/codeclone/report/document/derived.py +++ b/codeclone/report/document/derived.py @@ -340,7 +340,7 @@ def _build_derived_overview( def _representative_location_rows( suggestion: Suggestion, ) -> list[dict[str, object]]: - rows = [ + rows: list[dict[str, object]] = [ { "relative_path": ( location.relative_path @@ -537,7 +537,7 @@ def _safe_relative_path(item: Mapping[str, object]) -> str: def _finding_representative_rows( group: Mapping[str, object], ) -> list[dict[str, object]]: - rows = [ + rows: list[dict[str, object]] = [ { "relative_path": _safe_relative_path(item), "start_line": _as_int(item.get("start_line")), diff --git a/codeclone/report/document/integrity.py b/codeclone/report/document/integrity.py index 5360ef8f..2664e8de 100644 --- a/codeclone/report/document/integrity.py +++ b/codeclone/report/document/integrity.py @@ -23,6 +23,7 @@ def _canonical_integrity_payload( canonical_meta = { str(key): value for key, value in meta.items() if str(key) != "runtime" } + canonical_inventory = _canonical_inventory(inventory) def _strip_noncanonical(value: object) -> object: if isinstance(value, Mapping): @@ -41,12 +42,36 @@ def _strip_noncanonical(value: object) -> object: return { "report_schema_version": report_schema_version, "meta": canonical_meta, - "inventory": inventory, + "inventory": canonical_inventory, "findings": _strip_noncanonical(findings), "metrics": metrics, } +def _canonical_inventory(inventory: Mapping[str, object]) -> dict[str, object]: + """Return inventory facts without local cache execution provenance.""" + + canonical: dict[str, object] = {} + for key, value in inventory.items(): + key_text = str(key) + if key_text == "cache": + continue + if key_text == "files" and isinstance(value, Mapping): + canonical[key_text] = { + str(file_key): file_value + for file_key, file_value in value.items() + if str(file_key) + not in { + "analyzed", + "cached", + "source_io_skipped", + } + } + continue + canonical[key_text] = value + return canonical + + def _build_integrity_payload( *, report_schema_version: str, @@ -69,7 +94,7 @@ def _build_integrity_payload( payload_sha = sha256(canonical_json).hexdigest() return { "canonicalization": { - "version": "1", + "version": "2", "scope": "canonical_only", "sections": [ "report_schema_version", @@ -78,6 +103,14 @@ def _build_integrity_payload( "findings", "metrics", ], + "excluded": [ + "meta.runtime", + "inventory.cache", + "inventory.files.analyzed", + "inventory.files.cached", + "inventory.files.source_io_skipped", + "findings.*.display_facts", + ], }, "digest": { "verified": True, diff --git a/codeclone/report/overview.py b/codeclone/report/overview.py index b0fe2142..c1b4f26d 100644 --- a/codeclone/report/overview.py +++ b/codeclone/report/overview.py @@ -321,7 +321,7 @@ def _row_sort_key(row: _DirectoryBucketRow) -> tuple[int, int, int, str]: finding_groups = len(row.finding_ids) affected_items = row.affected_items files = len(row.files) - item = { + item: dict[str, object] = { "path": row.path, "finding_groups": finding_groups, "affected_items": affected_items, @@ -646,7 +646,7 @@ def _metric_summary_count( summary = metric_map.get("summary") if not isinstance(summary, Mapping): return 0 - return int(summary.get(summary_key, summary.get(fallback_key, 0))) + return _as_int(summary.get(summary_key, summary.get(fallback_key, 0))) def _top_risks( diff --git a/codeclone/surfaces/cli/console.py b/codeclone/surfaces/cli/console.py index d9a827af..f5fa2462 100644 --- a/codeclone/surfaces/cli/console.py +++ b/codeclone/surfaces/cli/console.py @@ -41,6 +41,10 @@ "error": "bold red", "success": "bold green", "dim": "dim", + "codeclone.primary": "cyan", + "codeclone.muted": "dim", + "codeclone.success": "bold green", + "codeclone.attention": "yellow", } _RICH_MARKUP_TAG_RE = re.compile(r"\[/?[a-zA-Z][a-zA-Z0-9_ .#:-]*]") diff --git a/codeclone/surfaces/cli/execution.py b/codeclone/surfaces/cli/execution.py index 177f7fb7..f7a467ff 100644 --- a/codeclone/surfaces/cli/execution.py +++ b/codeclone/surfaces/cli/execution.py @@ -52,6 +52,13 @@ def _save_cache_after_analysis( require_status_console(printer).print( ui.fmt_cli_runtime_warning(ui.fmt_cache_save_failed(exc)) ) + _release_cache_entries(cache, allow_dirty=False) + + +def _release_cache_entries(cache: object, *, allow_dirty: bool) -> None: + release = getattr(cache, "release_loaded_entries", None) + if callable(release): + release(allow_dirty=allow_dirty) _StageT = TypeVar("_StageT") @@ -227,6 +234,8 @@ def _require_rich_console(value: object) -> RichConsole: cache_update_segment_projection_fn=cache_update_segment_projection_fn, printer=printer, ) + else: + _release_cache_entries(cache, allow_dirty=True) else: analysis_result = analyze_fn( boot=boot, @@ -240,6 +249,8 @@ def _require_rich_console(value: object) -> RichConsole: cache_update_segment_projection_fn=cache_update_segment_projection_fn, printer=printer, ) + else: + _release_cache_entries(cache, allow_dirty=True) coverage_join = getattr(analysis_result, "coverage_join", None) if ( diff --git a/codeclone/surfaces/cli/memory.py b/codeclone/surfaces/cli/memory.py index ffd3a535..4569f422 100644 --- a/codeclone/surfaces/cli/memory.py +++ b/codeclone/surfaces/cli/memory.py @@ -65,6 +65,13 @@ shutdown, span, ) +from ...utils.coerce import as_int +from ...utils.payload_narrow import ( + dict_items_from_list, + is_payload_dict, + mapping_items_from_list, + nested_payload_dict, +) from .memory_analysis import load_report_for_memory_init from .memory_render import ( memory_console, @@ -615,13 +622,10 @@ def _run_search( store.close() payload = result.get("payload") - if not isinstance(payload, dict): + if not is_payload_dict(payload): console.print("Memory search returned an unexpected payload.") return int(ExitCode.INTERNAL_ERROR) - records = payload.get("records") - if not isinstance(records, list): - records = [] - typed_records = [item for item in records if isinstance(item, dict)] + typed_records = mapping_items_from_list(payload.get("records")) render_search_results(console=console, query=str(args.query), records=typed_records) _print_semantic_advisory(console, result.get("semantic")) return int(ExitCode.SUCCESS) @@ -672,8 +676,8 @@ def _run_stale( finally: store.close() payload = result.get("payload") - records = payload.get("records") if isinstance(payload, dict) else None - typed_records = [item for item in (records or []) if isinstance(item, dict)] + records = payload.get("records") if is_payload_dict(payload) else None + typed_records = mapping_items_from_list(records) render_stale_records(console=console, records=typed_records) return int(ExitCode.SUCCESS) @@ -944,8 +948,8 @@ def _run_trajectory_search( finally: store.close() payload = result.get("payload") - trajectories = payload.get("trajectories") if isinstance(payload, dict) else None - typed = [item for item in (trajectories or []) if isinstance(item, dict)] + trajectories = payload.get("trajectories") if is_payload_dict(payload) else None + typed = dict_items_from_list(trajectories) render_trajectory_search_results( console=console, query=str(args.query), @@ -981,7 +985,7 @@ def _run_trajectory_agents( finally: store.close() payload = result.get("payload") - if not isinstance(payload, dict): + if not is_payload_dict(payload): console.print("Unexpected trajectory agents payload.") return int(ExitCode.INTERNAL_ERROR) if bool(getattr(args, "json", False)): @@ -1013,7 +1017,7 @@ def _run_trajectory_anomalies( finally: store.close() payload = result.get("payload") - if not isinstance(payload, dict): + if not is_payload_dict(payload): console.print("Unexpected trajectory anomalies payload.") return int(ExitCode.INTERNAL_ERROR) if bool(getattr(args, "json", False)): @@ -1045,34 +1049,35 @@ def _run_trajectory_dashboard( finally: store.close() payload = result.get("payload") - if not isinstance(payload, dict): + if not is_payload_dict(payload): console.print("Unexpected trajectory dashboard payload.") return int(ExitCode.INTERNAL_ERROR) if bool(getattr(args, "json", False)): console.print(json.dumps(payload, indent=2, sort_keys=True)) return int(ExitCode.SUCCESS) - status = payload.get("status") - if isinstance(status, dict): + status_raw = payload.get("status") + if is_payload_dict(status_raw): + status = status_raw latest = status.get("latest_projection") render_trajectory_status( console=console, enabled=config.trajectories_enabled, - count=int(status.get("trajectory_count", 0)), + count=as_int(status.get("trajectory_count", 0)), latest_run=None, ) - if isinstance(latest, dict) and latest.get("finished_at_utc"): + if is_payload_dict(latest) and latest.get("finished_at_utc"): console.print( f" latest projection finished: {latest.get('finished_at_utc')}", markup=False, ) - agents = payload.get("agents") - if isinstance(agents, dict): + agents_raw = payload.get("agents") + if is_payload_dict(agents_raw): console.print("") - render_trajectory_agents(console=console, payload=agents) - anomalies = payload.get("anomalies") - if isinstance(anomalies, dict): + render_trajectory_agents(console=console, payload=agents_raw) + anomalies_raw = payload.get("anomalies") + if is_payload_dict(anomalies_raw): console.print("") - render_trajectory_anomalies(console=console, payload=anomalies) + render_trajectory_anomalies(console=console, payload=anomalies_raw) return int(ExitCode.SUCCESS) @@ -1357,7 +1362,7 @@ def _run_semantic_probe( f"Semantic projection probe unavailable: {reason}.", ) lanes_obj = payload.get("lanes") - if not isinstance(lanes_obj, dict): + if not is_payload_dict(lanes_obj): return _semantic_unavailable( console, "Semantic projection probe returned invalid payload." ) @@ -1366,18 +1371,17 @@ def _run_semantic_probe( console.print(f" estimator: {payload.get('estimator')}") console.print(f" model_max_tokens: {payload.get('model_max_tokens')}") for lane in ("memory", "audit", "trajectory"): - stats = lanes.get(lane, {}) - if not stats: + stats_raw = lanes.get(lane, {}) + if not is_payload_dict(stats_raw): continue - chars = stats.get("chars", {}) - tokens = stats.get("tokens", {}) - overflow = stats.get("token_overflow", {}) - truncation = stats.get("truncation", {}) - raw_tokens = tokens.get("raw", {}) if isinstance(tokens, dict) else {} - effective_tokens = ( - tokens.get("effective", {}) if isinstance(tokens, dict) else {} - ) - console.print(f" {lane}: {stats.get('documents', 0)} documents") + stats = stats_raw + chars = nested_payload_dict(stats.get("chars")) + tokens = nested_payload_dict(stats.get("tokens")) + overflow = nested_payload_dict(stats.get("token_overflow")) + truncation = nested_payload_dict(stats.get("truncation")) + raw_tokens = nested_payload_dict(tokens.get("raw")) + effective_tokens = nested_payload_dict(tokens.get("effective")) + console.print(f" {lane}: {as_int(stats.get('documents', 0))} documents") console.print( " chars p50/p95/max: " f"{chars.get('p50')}/{chars.get('p95')}/{chars.get('max')}" diff --git a/codeclone/surfaces/cli/memory_analysis.py b/codeclone/surfaces/cli/memory_analysis.py index bdb96392..315613ac 100644 --- a/codeclone/surfaces/cli/memory_analysis.py +++ b/codeclone/surfaces/cli/memory_analysis.py @@ -32,7 +32,7 @@ from . import runtime as cli_runtime from . import startup as cli_startup from . import state as cli_state -from .console import PlainConsole +from .console import PlainConsole, _rich_progress_symbols from .types import require_status_console ReportSource = Literal["explicit_report", "trusted_cache", "fresh_analysis"] @@ -45,18 +45,6 @@ class LoadedMemoryReport: rejected_cache_reason: str | None = None -def _rich_progress_symbols() -> tuple[type, type, type, type, type]: - from rich.progress import ( - BarColumn, - Progress, - SpinnerColumn, - TextColumn, - TimeElapsedColumn, - ) - - return Progress, SpinnerColumn, TextColumn, BarColumn, TimeElapsedColumn - - def load_report_for_memory_init( *, root_path: Path, @@ -226,6 +214,7 @@ def run_memory_analysis_report(*, root_path: Path) -> dict[str, object]: __all__ = [ "LoadedMemoryReport", "ReportSource", + "_rich_progress_symbols", "load_report_for_memory_init", "run_memory_analysis_report", ] diff --git a/codeclone/surfaces/cli/memory_render.py b/codeclone/surfaces/cli/memory_render.py index 90b54431..753e9a71 100644 --- a/codeclone/surfaces/cli/memory_render.py +++ b/codeclone/surfaces/cli/memory_render.py @@ -14,6 +14,7 @@ from ...memory.models import MemoryRecord from ...memory.status_report import MemoryStatusReport from ...memory.vacuum import VacuumReport +from ...utils.payload_narrow import is_record_mapping from .console import make_query_console, rich_panel_symbols, supports_rich_console from .types import PrinterLike @@ -28,16 +29,22 @@ _MemoryRowBuilder = Callable[[int, object, object], Sequence[object]] +def _record_table_columns( + *specs: tuple[str, _ColumnKwargs], +) -> Sequence[tuple[str, _ColumnKwargs]]: + return specs + + def _table_add_column( table: object, title: str, kwargs: _ColumnKwargs, ) -> None: - table.add_column(title, **kwargs) # type: ignore[attr-defined] + getattr(table, "add_column")(title, **kwargs) # noqa: B009 def _table_add_row(table: object, cells: Sequence[object]) -> None: - table.add_row(*cells) # type: ignore[attr-defined] + getattr(table, "add_row")(*cells) # noqa: B009 def memory_console() -> PrinterLike: @@ -59,7 +66,7 @@ def render_search_results( f"[dim]{_count_label(len(records), 'result')}[/dim]" ), records=records, - columns=( + columns=_record_table_columns( ("#", {"style": "dim", "justify": "right", "no_wrap": True}), ("Type", {"style": "cyan", "no_wrap": True}), ("Status", {"no_wrap": True}), @@ -88,7 +95,7 @@ def render_path_results( f"[dim]{_count_label(len(records), 'record')}[/dim]" ), records=mapped, - columns=( + columns=_record_table_columns( ("#", {"style": "dim", "justify": "right", "no_wrap": True}), ("Type", {"style": "cyan", "no_wrap": True}), ("Status", {"no_wrap": True}), @@ -166,7 +173,7 @@ def render_stale_records( subtitle=f"[dim]{_count_label(len(records), 'record')}[/dim]", border_style="yellow", records=records, - columns=( + columns=_record_table_columns( ("#", {"style": "dim", "justify": "right", "no_wrap": True}), ("Type", {"style": "cyan", "no_wrap": True}), ("Reason", {"style": "yellow", "no_wrap": True}), @@ -223,7 +230,7 @@ def render_draft_candidates( subtitle=f"[dim]{_count_label(len(records), 'draft')}[/dim]", border_style="magenta", records=records, - columns=( + columns=_record_table_columns( ("#", {"style": "dim", "justify": "right", "no_wrap": True}), ("ID", {"style": "dim", "no_wrap": True}), ("Type", {"style": "cyan", "no_wrap": True}), @@ -304,7 +311,7 @@ def _search_row( item: object, text_cls: type[RichText], ) -> Sequence[object]: - mapping = item if isinstance(item, Mapping) else {} + mapping: Mapping[str, object] = item if is_record_mapping(item) else {} record_type = str(mapping.get("type", "?")) status = str(mapping.get("status", "?")) return ( @@ -320,7 +327,7 @@ def _stale_row( item: object, _text_cls: type[RichText], ) -> Sequence[object]: - mapping = item if isinstance(item, Mapping) else {} + mapping: Mapping[str, object] = item if is_record_mapping(item) else {} return ( str(index), str(mapping.get("type", "?")), diff --git a/codeclone/surfaces/cli/observability.py b/codeclone/surfaces/cli/observability.py index 114cd5b3..99c0e611 100644 --- a/codeclone/surfaces/cli/observability.py +++ b/codeclone/surfaces/cli/observability.py @@ -15,6 +15,7 @@ import argparse from pathlib import Path +from ... import ui_messages as ui from ...contracts import ExitCode from ...observability.render_html import render_trace_html from ...observability.render_json import render_trace_json @@ -57,7 +58,11 @@ def observability_main(argv: list[str]) -> int: return int(ExitCode.CONTRACT_ERROR) root = Path(args.root).resolve() - conn = open_observability_store_readonly(root) + try: + conn = open_observability_store_readonly(root) + except RuntimeError as exc: + print(ui.fmt_internal_error(exc)) + return int(ExitCode.INTERNAL_ERROR) if conn is None: return _report_missing_store(root) try: diff --git a/codeclone/surfaces/cli/runtime.py b/codeclone/surfaces/cli/runtime.py index d65fe81c..5c7eaffc 100644 --- a/codeclone/surfaces/cli/runtime.py +++ b/codeclone/surfaces/cli/runtime.py @@ -8,6 +8,7 @@ import sys from pathlib import Path +from typing import Protocol from ... import ui_messages as ui from ...cache.store import Cache, resolve_cache_status @@ -18,6 +19,19 @@ from .types import PrinterLike, require_status_console +class _ConfigureMetricsModeHook(Protocol): + def __call__( + self, + *, + args: object, + metrics_baseline_exists: bool, + ) -> None: ... + + +class _PrintBannerHook(Protocol): + def __call__(self, *, root: Path) -> None: ... + + def validate_numeric_args(args: object) -> bool: return bool( not ( @@ -152,8 +166,8 @@ def prepare_metrics_mode_and_ui( baseline_exists: bool, metrics_baseline_path: Path, metrics_baseline_exists: bool, - configure_metrics_mode: object, - print_banner: object, + configure_metrics_mode: _ConfigureMetricsModeHook | None, + print_banner: _PrintBannerHook | None, ) -> None: if ( bool_attr(args, "update_baseline") @@ -161,7 +175,7 @@ def prepare_metrics_mode_and_ui( and not bool_attr(args, "update_metrics_baseline") ): set_bool_attr(args, "update_metrics_baseline", True) - if callable(configure_metrics_mode): + if configure_metrics_mode is not None: configure_metrics_mode( args=args, metrics_baseline_exists=metrics_baseline_exists, @@ -176,7 +190,7 @@ def prepare_metrics_mode_and_ui( if bool_attr(args, "quiet"): set_bool_attr(args, "no_progress", True) return - if callable(print_banner): + if print_banner is not None: print_banner(root=root_path) diff --git a/codeclone/surfaces/cli/setup/__init__.py b/codeclone/surfaces/cli/setup/__init__.py new file mode 100644 index 00000000..4cbb3e8f --- /dev/null +++ b/codeclone/surfaces/cli/setup/__init__.py @@ -0,0 +1,9 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from .main import setup_main + +__all__ = ["setup_main"] diff --git a/codeclone/surfaces/cli/setup/engine/__init__.py b/codeclone/surfaces/cli/setup/engine/__init__.py new file mode 100644 index 00000000..f8e48c68 --- /dev/null +++ b/codeclone/surfaces/cli/setup/engine/__init__.py @@ -0,0 +1,7 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Setup discovery engine (read-only probes).""" diff --git a/codeclone/surfaces/cli/setup/engine/apply.py b/codeclone/surfaces/cli/setup/engine/apply.py new file mode 100644 index 00000000..c2242c1d --- /dev/null +++ b/codeclone/surfaces/cli/setup/engine/apply.py @@ -0,0 +1,275 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Bounded setup apply engine for plan actions.""" + +from __future__ import annotations + +from collections.abc import Callable, Mapping +from pathlib import Path +from typing import Literal, TypeGuard + +from .....config.pyproject_writer import PyprojectWriterError, merge_tool_codeclone +from .....paths.gitignore import ( + append_gitignore_line, + repo_gitignore_covers_codeclone_cache, + write_gitignore_text_atomically, +) +from .plan import build_setup_plan + +ApplyStatus = Literal[ + "noop", "preview", "applied", "partial", "failed", "blocked", "stale_plan" +] +ActionApplyStatus = Literal["applied", "preview", "skipped", "failed"] + + +def apply_setup_plan( + root_path: Path, + *, + dry_run: bool = False, + expected_plan_id: str | None = None, +) -> dict[str, object]: + """Recompute the current plan and apply ready actions in sorted order. + + When ``expected_plan_id`` is supplied, the recomputed plan must match it or the + apply is refused with ``status="stale_plan"`` (guards against the repository + changing between a ``setup plan`` preview and ``setup apply``). + """ + + plan = build_setup_plan(root_path) + if expected_plan_id and str(plan.get("plan_id", "")) != expected_plan_id: + return _build_apply_result( + plan, + status="stale_plan", + dry_run=dry_run, + results=(), + ) + ready_actions = _ready_actions(plan) + if not ready_actions: + return _build_apply_result( + plan, + status=_noop_or_blocked_status(plan), + dry_run=dry_run, + results=(), + ) + + results: list[dict[str, object]] = [] + for action in ready_actions: + result = _apply_action(root_path, action, dry_run=dry_run) + results.append(result) + if result["status"] == "failed": + status: ApplyStatus = "partial" if _any_applied(results) else "failed" + if dry_run: + status = "preview" + return _build_apply_result( + plan, + status=status, + dry_run=dry_run, + results=tuple(results), + ) + + status = "preview" if dry_run else "applied" + return _build_apply_result( + plan, + status=status, + dry_run=dry_run, + results=tuple(results), + ) + + +def _ready_actions(plan: Mapping[str, object]) -> list[dict[str, object]]: + raw = plan.get("actions") + if not isinstance(raw, list): + return [] + ready = [ + item for item in raw if _is_action_item(item) and item.get("status") == "ready" + ] + ready.sort(key=lambda item: str(item.get("id", ""))) + return ready + + +def _is_action_item(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) + + +def _noop_or_blocked_status(plan: Mapping[str, object]) -> ApplyStatus: + if plan.get("status") == "blocked": + return "blocked" + return "noop" + + +def _build_apply_result( + plan: Mapping[str, object], + *, + status: ApplyStatus, + dry_run: bool, + results: tuple[dict[str, object], ...], +) -> dict[str, object]: + return { + "schema_version": "1", + "projection_kind": "setup_apply", + "recomputation": True, + "root": plan.get("root"), + "head_commit": plan.get("head_commit"), + "plan_id": plan.get("plan_id"), + "plan_status": plan.get("status"), + "status": status, + "dry_run": dry_run, + "results": list(results), + } + + +def _apply_action( + root_path: Path, + action: Mapping[str, object], + *, + dry_run: bool, +) -> dict[str, object]: + action_id = str(action.get("id", "")) + kind = str(action.get("kind", "")) + handler = _ACTION_HANDLERS.get(kind) + if handler is None: + return { + "id": action_id, + "kind": kind, + "path": action.get("path", ""), + "status": "failed", + "message": f"Unsupported plan action kind: {kind}", + } + return handler(root_path, action, dry_run) + + +def _apply_pyproject_merge( + root_path: Path, + action: Mapping[str, object], + dry_run: bool, +) -> dict[str, object]: + action_id = str(action.get("id", "")) + updates_raw = action.get("updates") + if not isinstance(updates_raw, dict): + return _failed_result( + action_id, + "pyproject_merge", + "pyproject.toml", + "missing updates", + ) + + updates = {str(key): value for key, value in updates_raw.items()} + try: + result = merge_tool_codeclone(root_path, updates, dry_run=dry_run) + except PyprojectWriterError as exc: + return _failed_result(action_id, "pyproject_merge", "pyproject.toml", str(exc)) + + if not result.changed_keys: + return { + "id": action_id, + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "skipped", + "message": "already satisfied", + "changed_keys": [], + } + + status: ActionApplyStatus = "preview" if dry_run else "applied" + return { + "id": action_id, + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": status, + "message": "", + "changed_keys": list(result.changed_keys), + "created_section": result.created_section, + } + + +def _apply_gitignore_append( + root_path: Path, + action: Mapping[str, object], + dry_run: bool, +) -> dict[str, object]: + action_id = str(action.get("id", "")) + lines_raw = action.get("lines") + if not isinstance(lines_raw, list) or not lines_raw: + return _failed_result( + action_id, + "gitignore_append", + ".gitignore", + "missing lines", + ) + + line = str(lines_raw[0]) + gitignore_path = root_path / ".gitignore" + before_text = "" + if gitignore_path.is_file(): + try: + before_text = gitignore_path.read_text(encoding="utf-8") + except OSError as exc: + return _failed_result(action_id, "gitignore_append", ".gitignore", str(exc)) + + after_text = append_gitignore_line(before_text, line) + if before_text == after_text: + return { + "id": action_id, + "kind": "gitignore_append", + "path": ".gitignore", + "status": "skipped", + "message": "already satisfied", + "lines": [line], + } + + if not dry_run: + try: + write_gitignore_text_atomically(gitignore_path, after_text) + except OSError as exc: + return _failed_result(action_id, "gitignore_append", ".gitignore", str(exc)) + if not repo_gitignore_covers_codeclone_cache(root_path): + return _failed_result( + action_id, + "gitignore_append", + ".gitignore", + "post-write verification failed", + ) + + status: ActionApplyStatus = "preview" if dry_run else "applied" + return { + "id": action_id, + "kind": "gitignore_append", + "path": ".gitignore", + "status": status, + "message": "", + "lines": [line], + } + + +def _failed_result( + action_id: str, + kind: str, + path: str, + message: str, +) -> dict[str, object]: + return { + "id": action_id, + "kind": kind, + "path": path, + "status": "failed", + "message": message, + } + + +def _any_applied(results: list[dict[str, object]]) -> bool: + return any(item.get("status") in {"applied", "preview"} for item in results) + + +_ACTION_HANDLERS: dict[ + str, + Callable[[Path, Mapping[str, object], bool], dict[str, object]], +] = { + "pyproject_merge": _apply_pyproject_merge, + "gitignore_append": _apply_gitignore_append, +} + + +__all__ = ["apply_setup_plan"] diff --git a/codeclone/surfaces/cli/setup/engine/capabilities.py b/codeclone/surfaces/cli/setup/engine/capabilities.py new file mode 100644 index 00000000..d84b8aa4 --- /dev/null +++ b/codeclone/surfaces/cli/setup/engine/capabilities.py @@ -0,0 +1,214 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Static capability registry for setup readiness (§8.1).""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from pathlib import Path +from typing import Final, Literal + +from .....baseline import BaselineStatus +from .....config.pyproject_loader import ConfigValidationError +from .....memory.status_report import MemoryStatusReport + +Availability = Literal[ + "built_in", + "optional_extra", + "external_tool", + "unsupported", +] +CapabilityGroup = Literal[ + "core_analysis", + "governed_agent_workflows", + "project_knowledge", + "team_and_release", +] + +GROUP_ORDER: Final[tuple[CapabilityGroup, ...]] = ( + "core_analysis", + "governed_agent_workflows", + "project_knowledge", + "team_and_release", +) + +SETUP_EXTRA_NAMES: Final[tuple[str, ...]] = ( + "analytics", + "coverage-xml", + "mcp", + "perf", + "semantic-fastembed", + "semantic-lancedb", + "semantic-local", + "token-bench", +) + +InstallationAxis = Literal["installed", "missing", "unknown"] +ConfigurationAxis = Literal["configured", "unconfigured", "invalid", "not_required"] +RuntimeAxis = Literal["verified", "unavailable", "not_verified", "not_required"] + + +@dataclass(slots=True) +class CapabilityAxes: + installation: InstallationAxis = "installed" + configuration: ConfigurationAxis = "not_required" + runtime: RuntimeAxis = "not_required" + evidence: list[str] = field(default_factory=list) + + +@dataclass(slots=True) +class DiscoverContext: + root_path: Path + config: dict[str, object] + config_error: ConfigValidationError | None + has_codeclone_section: bool + baseline_path: Path + baseline_status: BaselineStatus | None + head_commit: str | None + install_extras: dict[str, str] + mcp_installed: bool + memory_report: MemoryStatusReport | None = None + audit_enabled: bool = False + audit_db_exists: bool = False + audit_summary_events: int = 0 + gitignore_covers_cache: bool = False + client_config_present: bool = False + + +@dataclass(frozen=True, slots=True) +class ProbedCapability: + meta: CapabilityMeta + axes: CapabilityAxes + + +@dataclass(frozen=True, slots=True) +class CapabilityMeta: + id: str + group: CapabilityGroup + availability: Availability + requires_config: bool = False + requires_runtime_proof: bool = False + optional_extra_name: str | None = None + + +CAPABILITY_REGISTRY: Final[tuple[CapabilityMeta, ...]] = ( + CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + requires_config=True, + ), + CapabilityMeta( + id="baseline", + group="core_analysis", + availability="built_in", + requires_config=True, + ), + CapabilityMeta( + id="reports", + group="core_analysis", + availability="built_in", + ), + CapabilityMeta( + id="mcp_runtime", + group="governed_agent_workflows", + availability="optional_extra", + optional_extra_name="mcp", + requires_runtime_proof=True, + ), + CapabilityMeta( + id="controlled_change", + group="governed_agent_workflows", + availability="optional_extra", + optional_extra_name="mcp", + requires_config=True, + ), + CapabilityMeta( + id="audit_and_intents", + group="governed_agent_workflows", + availability="built_in", + requires_config=True, + requires_runtime_proof=True, + ), + CapabilityMeta( + id="engineering_memory", + group="project_knowledge", + availability="built_in", + requires_config=True, + requires_runtime_proof=True, + ), + CapabilityMeta( + id="semantic_retrieval", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="semantic-local", + requires_config=True, + ), + CapabilityMeta( + id="coverage_evidence", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="coverage-xml", + ), + CapabilityMeta( + id="analytics_cockpit", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="analytics", + ), + CapabilityMeta( + id="ci_policy", + group="team_and_release", + availability="built_in", + requires_config=True, + ), + CapabilityMeta( + id="github_workflow", + group="team_and_release", + availability="external_tool", + requires_config=True, + ), + CapabilityMeta( + id="pre_commit_hook", + group="team_and_release", + availability="external_tool", + requires_config=True, + ), + CapabilityMeta( + id="workspace_hygiene", + group="team_and_release", + availability="built_in", + requires_config=True, + ), +) + + +def sorted_capabilities() -> tuple[CapabilityMeta, ...]: + group_rank = {group: index for index, group in enumerate(GROUP_ORDER)} + return tuple( + sorted( + CAPABILITY_REGISTRY, + key=lambda item: (group_rank[item.group], item.id), + ) + ) + + +__all__ = [ + "CAPABILITY_REGISTRY", + "GROUP_ORDER", + "SETUP_EXTRA_NAMES", + "Availability", + "CapabilityAxes", + "CapabilityGroup", + "CapabilityMeta", + "ConfigurationAxis", + "DiscoverContext", + "InstallationAxis", + "ProbedCapability", + "RuntimeAxis", + "sorted_capabilities", +] diff --git a/codeclone/surfaces/cli/setup/engine/discover.py b/codeclone/surfaces/cli/setup/engine/discover.py new file mode 100644 index 00000000..81a1fd0f --- /dev/null +++ b/codeclone/surfaces/cli/setup/engine/discover.py @@ -0,0 +1,715 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Read-only capability probes for setup readiness (§8.5).""" + +from __future__ import annotations + +import importlib.util +import os +import sys +from pathlib import Path +from typing import Literal + +from ..... import __version__ +from .....analytics.capabilities import check_capability +from .....audit.reader import read_audit_summary +from .....audit.validation import DEFAULT_AUDIT_PATH, resolve_audit_path +from .....baseline import ( + Baseline, + BaselineStatus, + coerce_baseline_status, + current_python_tag, +) +from .....baseline.metrics_baseline import probe_metrics_baseline_section +from .....config.memory import resolve_memory_config +from .....config.pyproject_loader import ConfigValidationError, load_pyproject_config +from .....contracts import DEFAULT_BASELINE_PATH, DEFAULT_MAX_BASELINE_SIZE_MB +from .....contracts.errors import BaselineValidationError +from .....memory.project import read_git_provenance, resolve_memory_db_path +from .....memory.status_report import build_memory_status_report +from .....paths.gitignore import repo_gitignore_covers_codeclone_cache +from ...startup import resolve_runtime_path_arg +from .capabilities import ( + SETUP_EXTRA_NAMES, + CapabilityAxes, + CapabilityMeta, + DiscoverContext, + ProbedCapability, + sorted_capabilities, +) +from .rollup import compute_maturity, finalize_capabilities + +InstallationAxis = Literal["installed", "missing", "unknown"] +ConfigurationAxis = Literal["configured", "unconfigured", "invalid", "not_required"] +RuntimeAxis = Literal["verified", "unavailable", "not_verified", "not_required"] + + +def build_discover_context(root_path: Path) -> DiscoverContext: + """Build read-only discovery context for setup projections.""" + + return _build_context(root_path.resolve()) + + +def build_setup_snapshot(root_path: Path) -> dict[str, object]: + return build_setup_snapshot_from_context(build_discover_context(root_path)) + + +def build_setup_snapshot_from_context(ctx: DiscoverContext) -> dict[str, object]: + probed = [_probe_capability(meta, ctx) for meta in sorted_capabilities()] + capabilities = finalize_capabilities(probed, ctx) + return { + "schema_version": "1", + "projection_kind": "setup_snapshot", + "recomputation": True, + "root": str(ctx.root_path), + "head_commit": ctx.head_commit, + "runtime": { + "python_tag": current_python_tag(), + "codeclone_version": __version__, + }, + "install": { + "base": "installed", + "extras": ctx.install_extras, + }, + "capabilities": capabilities, + "maturity": compute_maturity( + {str(item["id"]): item for item in capabilities}, + memory_db_exists=_memory_db_exists(ctx), + ), + } + + +def _build_context(root_path: Path) -> DiscoverContext: + config_error: ConfigValidationError | None = None + config: dict[str, object] = {} + has_section = _tool_codeclone_section_present(root_path) + try: + config = load_pyproject_config(root_path) + except ConfigValidationError as exc: + config_error = exc + + baseline_raw = str(config.get("baseline", DEFAULT_BASELINE_PATH)) + baseline_path = resolve_runtime_path_arg( + root_path=root_path, + raw_path=baseline_raw, + from_cli=False, + ) + baseline_status = _probe_baseline_status(baseline_path) + + git = read_git_provenance(root_path) + head_commit = git.head if git.available else None + + install_extras = { + extra_name: ("installed" if _extra_installed(extra_name) else "missing") + for extra_name in SETUP_EXTRA_NAMES + } + mcp_installed = install_extras["mcp"] == "installed" + + memory_report = None + if config_error is None: + memory_config = resolve_memory_config(root_path, pyproject_config=config) + db_path = resolve_memory_db_path(root_path, memory_config) + memory_report = build_memory_status_report( + root_path=root_path, + db_path=db_path, + backend=memory_config.backend, + ) + + audit_enabled = bool(config.get("audit_enabled")) if config_error is None else False + audit_db_exists = False + audit_summary_events = 0 + if config_error is None and audit_enabled: + try: + audit_path = resolve_audit_path( + root_path=root_path, + value=str(config.get("audit_path", DEFAULT_AUDIT_PATH)), + ) + audit_db_exists = audit_path.is_file() + if audit_db_exists: + summary = read_audit_summary(db_path=audit_path, limit=1) + audit_summary_events = summary.total_events + except OSError: + audit_db_exists = False + except Exception: + audit_db_exists = False + audit_summary_events = 0 + + return DiscoverContext( + root_path=root_path, + config=config, + config_error=config_error, + has_codeclone_section=has_section, + baseline_path=baseline_path, + baseline_status=baseline_status, + head_commit=head_commit, + install_extras=install_extras, + mcp_installed=mcp_installed, + memory_report=memory_report, + audit_enabled=audit_enabled, + audit_db_exists=audit_db_exists, + audit_summary_events=audit_summary_events, + gitignore_covers_cache=repo_gitignore_covers_codeclone_cache(root_path), + client_config_present=_client_config_present(root_path), + ) + + +def _probe_capability(meta: CapabilityMeta, ctx: DiscoverContext) -> ProbedCapability: + probe_fn = _PROBE_BY_ID.get(meta.id, _probe_unknown) + axes = probe_fn(ctx) + axes.evidence.sort() + return ProbedCapability(meta=meta, axes=axes) + + +def _probe_unknown(ctx: DiscoverContext) -> CapabilityAxes: + del ctx + return CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=["probe:unknown:capability"], + ) + + +def _probe_analysis(ctx: DiscoverContext) -> CapabilityAxes: + evidence: list[str] = ["probe:import:codeclone.core"] + installation: InstallationAxis = "installed" + try: + if importlib.util.find_spec("codeclone.core") is None: + installation = "unknown" + except (ImportError, ModuleNotFoundError, ValueError): + installation = "unknown" + + if ctx.config_error is not None: + return CapabilityAxes( + installation=installation, + configuration="invalid", + runtime="not_required", + evidence=[*evidence, "probe:pyproject:tool.codeclone"], + ) + + if not ctx.has_codeclone_section: + return CapabilityAxes( + installation=installation, + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:pyproject:tool.codeclone"], + ) + + return CapabilityAxes( + installation=installation, + configuration="configured", + runtime="not_required", + evidence=[*evidence, "probe:pyproject:tool.codeclone"], + ) + + +def _probe_baseline(ctx: DiscoverContext) -> CapabilityAxes: + evidence = ["probe:path:baseline"] + if ctx.baseline_path.is_symlink(): + return CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[*evidence, "probe:path:baseline:symlink"], + ) + if not ctx.baseline_path.exists(): + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:path:baseline:missing"], + ) + if ctx.baseline_status is None: + # File present but could not be read (permissions / IO error): fail closed. + return CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[*evidence, "probe:path:baseline:unreadable"], + ) + if ctx.baseline_status is BaselineStatus.OK: + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=[*evidence, "probe:baseline:trust"], + ) + + # Present but untrusted/corrupt: attention (baseline never hard-blocks core + # analysis), with the precise status carried on evidence for doctor output. + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[ + *evidence, + "probe:baseline:trust", + f"probe:baseline:status:{ctx.baseline_status.value}", + ], + ) + + +def _probe_reports(ctx: DiscoverContext) -> CapabilityAxes: + del ctx + return CapabilityAxes( + installation="installed", + configuration="not_required", + runtime="not_required", + evidence=[], + ) + + +def _probe_mcp_runtime(ctx: DiscoverContext) -> CapabilityAxes: + if ctx.mcp_installed: + return CapabilityAxes( + installation="installed", + configuration="not_required", + runtime="not_verified", + evidence=["probe:find_spec:mcp"], + ) + return CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=["probe:find_spec:mcp"], + ) + + +def _probe_controlled_change(ctx: DiscoverContext) -> CapabilityAxes: + if not ctx.mcp_installed: + return CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=["probe:find_spec:mcp"], + ) + if ctx.client_config_present: + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_verified", + evidence=["probe:find_spec:mcp", "probe:client:mcp_config"], + ) + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_verified", + evidence=["probe:find_spec:mcp", "probe:client:mcp_config:missing"], + ) + + +def _probe_audit_and_intents(ctx: DiscoverContext) -> CapabilityAxes: + evidence = ["probe:audit:enabled"] + if ctx.config_error is not None: + return CapabilityAxes( + installation="installed", + configuration="invalid", + runtime="not_required", + evidence=evidence, + ) + if not ctx.audit_enabled: + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=evidence, + ) + + evidence.append("probe:audit:db") + if not ctx.audit_db_exists: + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=evidence, + ) + + evidence.append("probe:audit:summary") + # DB present and enabled: distinguish a populated trail (runtime-verified) from + # an empty one (configured but nothing recorded yet). + has_events = ctx.audit_summary_events > 0 + runtime: RuntimeAxis = "verified" if has_events else "not_verified" + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime=runtime, + evidence=evidence, + ) + + +def _probe_engineering_memory(ctx: DiscoverContext) -> CapabilityAxes: + evidence = ["probe:memory:status"] + report = ctx.memory_report + if report is None: + # No report is only produced when pyproject config failed to load. + return CapabilityAxes( + installation="installed", + configuration="invalid" if ctx.config_error else "unconfigured", + runtime="not_verified", + evidence=evidence, + ) + if not report.db_exists: + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_verified", + evidence=evidence, + ) + # Store present: verified only when it actually holds records. + runtime: RuntimeAxis = "verified" if report.record_count > 0 else "not_verified" + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime=runtime, + evidence=[*evidence, "probe:memory:records"], + ) + + +def _probe_semantic_retrieval(ctx: DiscoverContext) -> CapabilityAxes: + status = check_capability("embed") + evidence = ["probe:capability:embed"] + if not status.available: + return CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=evidence, + ) + + report = ctx.memory_report + if report is None or not report.db_exists: + # Packages present but semantic retrieval needs a memory store to operate. + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_verified", + evidence=[*evidence, "probe:memory:store:missing"], + ) + + memory_config = resolve_memory_config(ctx.root_path, pyproject_config=ctx.config) + configuration: ConfigurationAxis = ( + "configured" if memory_config.semantic.enabled else "unconfigured" + ) + return CapabilityAxes( + installation="installed", + configuration=configuration, + runtime="not_verified", + evidence=[*evidence, "probe:memory:semantic"], + ) + + +def _probe_coverage_evidence(ctx: DiscoverContext) -> CapabilityAxes: + del ctx + if importlib.util.find_spec("defusedxml") is None: + return CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=["probe:find_spec:defusedxml"], + ) + return CapabilityAxes( + installation="installed", + configuration="not_required", + runtime="not_verified", + evidence=["probe:find_spec:defusedxml"], + ) + + +def _probe_analytics_cockpit(ctx: DiscoverContext) -> CapabilityAxes: + del ctx + status = check_capability("full") + evidence = ["probe:capability:analytics:full"] + if status.available: + return CapabilityAxes( + installation="installed", + configuration="not_required", + runtime="not_verified", + evidence=evidence, + ) + return CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=evidence, + ) + + +def _probe_ci_policy(ctx: DiscoverContext) -> CapabilityAxes: + evidence = ["probe:pyproject:ci_flags"] + if ctx.config_error is not None: + return CapabilityAxes( + installation="installed", + configuration="invalid", + runtime="not_required", + evidence=evidence, + ) + + ci_like = any( + bool(ctx.config.get(flag)) + for flag in ("ci", "fail_on_new", "fail_on_new_metrics") + ) + if not ci_like: + # No CI gating flags set. This is an opt-in team feature, so report it as + # not-yet-configured (attention) rather than claiming it is "configured". + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:pyproject:ci_flags:absent"], + ) + + if ctx.baseline_status is not BaselineStatus.OK: + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:baseline:trust"], + ) + + metrics_probe = probe_metrics_baseline_section(ctx.baseline_path) + if metrics_probe.has_metrics_section and metrics_probe.payload is None: + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:metrics_baseline:section"], + ) + + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=evidence, + ) + + +def _probe_github_workflow(ctx: DiscoverContext) -> CapabilityAxes: + workflows_dir = ctx.root_path / ".github" / "workflows" + evidence = ["probe:file:.github/workflows"] + if not workflows_dir.is_dir(): + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:file:.github/workflows:missing"], + ) + markers = ("codeclone", "orenlab/codeclone") + saw_unreadable = False + for path in sorted(workflows_dir.glob("*")): + if path.suffix not in {".yml", ".yaml"}: + continue + text, existed = _safe_read_text(path) + if text is None: + saw_unreadable = saw_unreadable or existed + continue + lowered = text.lower() + if any(marker in lowered for marker in markers): + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=[*evidence, f"probe:file:{path.name}"], + ) + if saw_unreadable: + return CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[*evidence, "probe:file:.github/workflows:unreadable"], + ) + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:file:.github/workflows:not_found"], + ) + + +def _probe_pre_commit_hook(ctx: DiscoverContext) -> CapabilityAxes: + config_path = ctx.root_path / ".pre-commit-config.yaml" + evidence = ["probe:file:.pre-commit-config.yaml"] + text, existed = _safe_read_text(config_path) + if text is None: + if existed: + return CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[*evidence, "probe:file:.pre-commit-config.yaml:unreadable"], + ) + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:file:.pre-commit-config.yaml:missing"], + ) + if "codeclone" in text.lower(): + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=evidence, + ) + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:file:.pre-commit-config.yaml:not_found"], + ) + + +def _probe_workspace_hygiene(ctx: DiscoverContext) -> CapabilityAxes: + evidence = ["probe:gitignore:codeclone_cache"] + if ctx.gitignore_covers_cache: + return CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=evidence, + ) + return CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[*evidence, "probe:gitignore:codeclone_cache:missing"], + ) + + +_PROBE_BY_ID = { + "analysis": _probe_analysis, + "baseline": _probe_baseline, + "reports": _probe_reports, + "mcp_runtime": _probe_mcp_runtime, + "controlled_change": _probe_controlled_change, + "audit_and_intents": _probe_audit_and_intents, + "engineering_memory": _probe_engineering_memory, + "semantic_retrieval": _probe_semantic_retrieval, + "coverage_evidence": _probe_coverage_evidence, + "analytics_cockpit": _probe_analytics_cockpit, + "ci_policy": _probe_ci_policy, + "github_workflow": _probe_github_workflow, + "pre_commit_hook": _probe_pre_commit_hook, + "workspace_hygiene": _probe_workspace_hygiene, +} + + +def _probe_baseline_status(baseline_path: Path) -> BaselineStatus | None: + if not baseline_path.exists(): + return BaselineStatus.MISSING + baseline = Baseline(baseline_path) + try: + baseline.load( + max_size_bytes=DEFAULT_MAX_BASELINE_SIZE_MB * 1024 * 1024, + ) + baseline.verify_compatibility(current_python_tag=current_python_tag()) + except BaselineValidationError as exc: + return coerce_baseline_status(exc.status) + except OSError: + # Unreadable file (permissions / IO): unknown, not "invalid JSON". + return None + return BaselineStatus.OK + + +def _extra_installed(extra_name: str) -> bool: + if extra_name == "mcp": + return importlib.util.find_spec("mcp") is not None + if extra_name == "coverage-xml": + return importlib.util.find_spec("defusedxml") is not None + if extra_name == "perf": + return importlib.util.find_spec("psutil") is not None + if extra_name == "token-bench": + return importlib.util.find_spec("tiktoken") is not None + if extra_name == "analytics": + return check_capability("full").available + if extra_name == "semantic-local": + return check_capability("embed").available + if extra_name == "semantic-fastembed": + return importlib.util.find_spec("fastembed") is not None + if extra_name == "semantic-lancedb": + return importlib.util.find_spec("lancedb") is not None + return False + + +def _tool_codeclone_section_present(root_path: Path) -> bool: + config_path = root_path / "pyproject.toml" + if not config_path.is_file(): + return False + if sys.version_info >= (3, 11): + import tomllib + + loader = tomllib.load + else: + try: + tomli_module = importlib.import_module("tomli") + except ModuleNotFoundError: + return False + load_fn = getattr(tomli_module, "load", None) + if not callable(load_fn): + return False + loader = load_fn + + try: + with config_path.open("rb") as handle: + payload = loader(handle) + except OSError: + return False + except ValueError: + return False + + if not isinstance(payload, dict): + return False + tool_obj = payload.get("tool") + if not isinstance(tool_obj, dict): + return False + return "codeclone" in tool_obj + + +def _client_config_present(root_path: Path) -> bool: + for candidate in (root_path / ".cursor" / "mcp.json", root_path / ".mcp.json"): + if candidate.is_file() and not candidate.is_symlink(): + return True + vscode_settings = root_path / ".vscode" / "settings.json" + text, _existed = _safe_read_text(vscode_settings) + if text is None: + return False + lowered = text.lower() + # Match the MCP server key names, not any incidental "codeclone" substring + # (a path or comment) that would falsely mark the client as configured. + return "codeclone-mcp" in lowered or '"codeclone"' in lowered + + +def _safe_read_text(path: Path) -> tuple[str | None, bool]: + """Read a regular file under the repo root without following symlinks. + + Returns ``(text, existed)``. ``text`` is ``None`` when the path is absent, is + a symlink (refused), or cannot be read; ``existed`` is ``True`` whenever the + path is present on disk (including a refused symlink or an unreadable file), + which lets callers distinguish "missing" from "present but unknown". + """ + + if path.is_symlink(): + return None, True + if not path.is_file(): + return None, False + try: + fd = os.open(path, os.O_RDONLY | getattr(os, "O_NOFOLLOW", 0)) + with open(fd, encoding="utf-8") as handle: + return handle.read(), True + except OSError: + return None, True + + +def _memory_db_exists(ctx: DiscoverContext) -> bool: + report = ctx.memory_report + return bool(report is not None and report.db_exists) + + +__all__ = [ + "build_discover_context", + "build_setup_snapshot", + "build_setup_snapshot_from_context", +] diff --git a/codeclone/surfaces/cli/setup/engine/plan.py b/codeclone/surfaces/cli/setup/engine/plan.py new file mode 100644 index 00000000..413a9cb6 --- /dev/null +++ b/codeclone/surfaces/cli/setup/engine/plan.py @@ -0,0 +1,268 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Read-only setup plan projection (pyproject and gitignore previews).""" + +from __future__ import annotations + +import difflib +import hashlib +from pathlib import Path +from typing import Literal, TypeGuard + +from .....config.pyproject_loader import open_repo_config +from .....config.pyproject_writer import PyprojectWriterError, merge_tool_codeclone +from .....contracts import DEFAULT_BASELINE_PATH +from .....paths.gitignore import ( + GITIGNORE_CODECLONE_CACHE_SUGGESTED_ENTRY, + append_gitignore_line, +) +from .....utils.json_io import json_text +from .capabilities import DiscoverContext +from .discover import build_discover_context + +PlanStatus = Literal["empty", "ready", "blocked"] + + +def build_setup_plan(root_path: Path) -> dict[str, object]: + """Build a deterministic read-only plan from current readiness state.""" + + ctx = build_discover_context(root_path) + blockers = _collect_blockers(ctx) + actions = _derive_actions(ctx, blockers) + status = _derive_plan_status(actions, blockers) + payload = { + "schema_version": "1", + "projection_kind": "setup_plan", + "recomputation": True, + "root": str(ctx.root_path), + "head_commit": ctx.head_commit, + "status": status, + "blockers": blockers, + "actions": actions, + "read_only": True, + } + payload["plan_id"] = _compute_plan_id(payload) + return payload + + +def _collect_blockers(ctx: DiscoverContext) -> list[dict[str, object]]: + blockers: list[dict[str, object]] = [] + if ctx.config_error is not None: + blockers.append( + { + "kind": "invalid_pyproject", + "reason": str(ctx.config_error), + } + ) + config_path = ctx.root_path / "pyproject.toml" + if not config_path.is_file(): + blockers.append( + { + "kind": "missing_pyproject", + "reason": "pyproject.toml is required for tool.codeclone merges", + } + ) + return blockers + + +def _derive_actions( + ctx: DiscoverContext, + blockers: list[dict[str, object]], +) -> list[dict[str, object]]: + actions: list[dict[str, object]] = [] + pyproject_blocked = any( + item["kind"] in {"invalid_pyproject", "missing_pyproject"} for item in blockers + ) + if not pyproject_blocked: + if not ctx.has_codeclone_section: + _append_pyproject_action( + actions, + ctx, + capability_id="analysis", + updates={"baseline": DEFAULT_BASELINE_PATH}, + ) + elif not ctx.audit_enabled: + _append_pyproject_action( + actions, + ctx, + capability_id="audit_and_intents", + updates={"audit_enabled": True}, + ) + + gitignore_action = _plan_gitignore_append(ctx) + if gitignore_action is not None: + actions.append(gitignore_action) + + actions.sort(key=lambda item: str(item["id"])) + return actions + + +def _append_pyproject_action( + actions: list[dict[str, object]], + ctx: DiscoverContext, + *, + capability_id: str, + updates: dict[str, object], +) -> None: + action = _plan_pyproject_merge( + ctx, + capability_id=capability_id, + updates=updates, + ) + if action is not None: + actions.append(action) + + +def _plan_pyproject_merge( + ctx: DiscoverContext, + *, + capability_id: str, + updates: dict[str, object], +) -> dict[str, object] | None: + action_id = f"pyproject_merge:{capability_id}" + try: + result = merge_tool_codeclone(ctx.root_path, updates, dry_run=True) + except PyprojectWriterError as exc: + return { + "id": action_id, + "capability_id": capability_id, + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "blocked", + "block_reason": str(exc), + "updates": {}, + "changed_keys": [], + "created_section": False, + "preview": {"unified_diff": ""}, + } + + if not result.changed_keys: + return None + + before_text = _read_pyproject_text(ctx.root_path) + preview_text = result.preview_text or "" + return { + "id": action_id, + "capability_id": capability_id, + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "ready", + "block_reason": "", + "updates": {key: updates[key] for key in result.changed_keys}, + "changed_keys": list(result.changed_keys), + "created_section": result.created_section, + "preview": { + "unified_diff": _unified_diff( + before_text, + preview_text, + "pyproject.toml", + ), + }, + } + + +def _plan_gitignore_append(ctx: DiscoverContext) -> dict[str, object] | None: + if ctx.gitignore_covers_cache: + return None + + path = ctx.root_path / ".gitignore" + before_text = "" + if path.is_file(): + try: + before_text = path.read_text(encoding="utf-8") + except OSError as exc: + return { + "id": "gitignore_append:workspace_hygiene", + "capability_id": "workspace_hygiene", + "kind": "gitignore_append", + "path": ".gitignore", + "status": "blocked", + "block_reason": str(exc), + "lines": [GITIGNORE_CODECLONE_CACHE_SUGGESTED_ENTRY], + "preview": {"unified_diff": ""}, + } + + line = GITIGNORE_CODECLONE_CACHE_SUGGESTED_ENTRY + after_text = append_gitignore_line(before_text, line) + if before_text == after_text: + return None + + return { + "id": "gitignore_append:workspace_hygiene", + "capability_id": "workspace_hygiene", + "kind": "gitignore_append", + "path": ".gitignore", + "status": "ready", + "block_reason": "", + "lines": [line], + "preview": { + "unified_diff": _unified_diff(before_text, after_text, ".gitignore"), + }, + } + + +def _derive_plan_status( + actions: list[dict[str, object]], + blockers: list[dict[str, object]], +) -> PlanStatus: + ready_actions = [item for item in actions if item.get("status") == "ready"] + if ready_actions: + return "ready" + blocked_actions = [item for item in actions if item.get("status") == "blocked"] + if blockers or blocked_actions: + return "blocked" + return "empty" + + +def _compute_plan_id(payload: dict[str, object]) -> str: + raw_actions = payload.get("actions", []) + action_items: list[dict[str, object]] = [] + if isinstance(raw_actions, list): + action_items = [item for item in raw_actions if _is_action_item(item)] + canonical = { + "head_commit": payload.get("head_commit"), + "root": payload.get("root"), + "blockers": payload.get("blockers"), + "actions": [ + {key: value for key, value in action.items() if key != "preview"} + for action in action_items + ], + } + digest_input = json_text(canonical, sort_keys=True, trailing_newline=False) + return hashlib.sha256(digest_input.encode("utf-8")).hexdigest()[:16] + + +def _is_action_item(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) + + +def _read_pyproject_text(root_path: Path) -> str: + config_path = root_path / "pyproject.toml" + if not config_path.is_file(): + return "" + with open_repo_config(root_path) as handle: + return handle.read().decode("utf-8") + + +def _unified_diff(before_text: str, after_text: str, filename: str) -> str: + before_lines = before_text.splitlines(keepends=True) + after_lines = after_text.splitlines(keepends=True) + if not before_lines and not after_text: + return "" + if not before_lines: + before_lines = [""] + diff_lines = difflib.unified_diff( + before_lines, + after_lines, + fromfile=f"a/{filename}", + tofile=f"b/{filename}", + lineterm="", + ) + return "".join(f"{item}\n" for item in diff_lines) + + +__all__ = ["build_setup_plan"] diff --git a/codeclone/surfaces/cli/setup/engine/rollup.py b/codeclone/surfaces/cli/setup/engine/rollup.py new file mode 100644 index 00000000..5d534ca4 --- /dev/null +++ b/codeclone/surfaces/cli/setup/engine/rollup.py @@ -0,0 +1,495 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Readiness derivation (R1-R9) and maturity rollup. + +``derive_readiness`` is the single authority for the ``readiness`` axis: it is a +pure function of capability metadata plus the detected axes. Presentation only +supplies human ``reason``/``recommended_action`` copy for the already-derived +readiness and must never change it. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import Literal + +from .....analytics.capabilities import check_capability, install_hint +from .....baseline import BaselineStatus +from .....paths.gitignore import gitignore_codeclone_cache_tip_payload +from .....ui_messages import setup as setup_ui +from .capabilities import ( + CapabilityAxes, + CapabilityMeta, + DiscoverContext, + ProbedCapability, +) + +Readiness = Literal["ready", "attention", "blocked", "optional", "not_applicable"] +Guidance = tuple[str, str] +GuidanceHandler = Callable[ + [CapabilityMeta, CapabilityAxes, Readiness, DiscoverContext], + Guidance, +] + +_UNVERIFIED_RUNTIME: frozenset[str] = frozenset({"unavailable", "not_verified"}) +_CI_FLAGS: tuple[str, ...] = ("ci", "fail_on_new", "fail_on_new_metrics") + + +def finalize_capabilities( + probed: list[ProbedCapability], + ctx: DiscoverContext, +) -> list[dict[str, object]]: + finalized: list[dict[str, object]] = [] + for item in probed: + readiness = derive_readiness(item.meta, item.axes) + reason, action = describe_capability(item.meta, item.axes, readiness, ctx) + finalized.append( + { + "id": item.meta.id, + "label": setup_ui.CAPABILITY_LABELS[item.meta.id], + "group": item.meta.group, + "availability": item.meta.availability, + "installation": item.axes.installation, + "configuration": item.axes.configuration, + "runtime": item.axes.runtime, + "readiness": readiness, + "reason": reason, + "evidence": list(item.axes.evidence), + "recommended_action": action, + } + ) + return finalized + + +def compute_maturity( + capabilities: dict[str, dict[str, object]], + *, + memory_db_exists: bool, +) -> dict[str, bool]: + def readiness(cap_id: str) -> str: + return str(capabilities[cap_id]["readiness"]) + + return { + "connected": readiness("analysis") != "blocked", + "governed": readiness("controlled_change") in {"ready", "optional"} + and readiness("audit_and_intents") != "blocked", + "evidence_backed": readiness("engineering_memory") in {"ready", "attention"} + and memory_db_exists, + "team_ready": all( + readiness(cap_id) != "blocked" + for cap_id in ( + "github_workflow", + "pre_commit_hook", + "workspace_hygiene", + ) + ), + "release_ready": readiness("baseline") == "ready" + and readiness("ci_policy") in {"ready", "attention"}, + } + + +# --------------------------------------------------------------------------- +# Readiness authority (§10.4.3 decision table, first match wins) +# --------------------------------------------------------------------------- + + +def derive_readiness(meta: CapabilityMeta, axes: CapabilityAxes) -> Readiness: + # R1 + if meta.availability == "unsupported": + return "not_applicable" + # R2 — optional extra not installed is optional, never blocked (I-02). + if meta.availability == "optional_extra" and axes.installation == "missing": + return "optional" + # R2a — installed optional extra with bad config is attention, never blocked. + if ( + meta.availability == "optional_extra" + and axes.installation != "missing" + and axes.configuration == "invalid" + ): + return "attention" + # R3 — optional extra with an ambiguous install probe (fail closed). + if meta.availability == "optional_extra" and axes.installation == "unknown": + return "attention" + # R4 — non-optional capability with an ambiguous install probe (fail closed). + if axes.installation == "unknown" and meta.availability != "optional_extra": + return "attention" + # R5 — required capability with invalid config is blocked. + if axes.configuration == "invalid" and meta.availability in { + "built_in", + "external_tool", + }: + return "blocked" + # R6 — capability that needs config but is unconfigured. + if axes.configuration == "unconfigured" and meta.requires_config: + return "attention" + # R7 — capability that needs runtime proof but is not verified. + if meta.requires_runtime_proof and axes.runtime in _UNVERIFIED_RUNTIME: + return "attention" + # R8 — every axis satisfied. + if _axes_satisfied(meta, axes): + return "ready" + # R9 — conservative default. + return "attention" + + +def _axes_satisfied(meta: CapabilityMeta, axes: CapabilityAxes) -> bool: + if axes.installation == "unknown": + return False + if axes.configuration == "invalid": + return False + if meta.requires_config and axes.configuration == "unconfigured": + return False + return not (meta.requires_runtime_proof and axes.runtime in _UNVERIFIED_RUNTIME) + + +# --------------------------------------------------------------------------- +# Presentation: reason + recommended_action for an already-derived readiness. +# Handlers never return a different readiness. +# --------------------------------------------------------------------------- + + +def describe_capability( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + handler = _GUIDANCE_BY_ID.get(meta.id, _describe_generic) + return handler(meta, axes, readiness, ctx) + + +def _describe_generic( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del ctx + if readiness == "ready": + return ("", "") + if readiness == "optional": + return (setup_ui.REASON_OPTIONAL_EXTRA_MISSING, _optional_install_hint(meta)) + if readiness == "not_applicable": + return (setup_ui.REASON_NOT_APPLICABLE, "") + if axes.installation == "unknown": + return (setup_ui.REASON_UNKNOWN_PROBE, "") + return (setup_ui.REASON_ATTENTION_GENERIC, "") + + +def _describe_analysis( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, readiness, ctx + if axes.installation == "unknown": + return (setup_ui.REASON_ANALYSIS_CORE_MISSING, "") + if axes.configuration == "invalid": + return (setup_ui.REASON_ANALYSIS_INVALID_CONFIG, setup_ui.ACTION_FIX_PYPROJECT) + if axes.configuration == "unconfigured": + return ( + setup_ui.REASON_ANALYSIS_UNCONFIGURED, + "Add a [tool.codeclone] section to pyproject.toml.", + ) + return ("", "") + + +def _describe_baseline( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, readiness + name = ctx.baseline_path.name + if axes.installation == "unknown": + return ( + setup_ui.REASON_BASELINE_UNREADABLE, + f"Check permissions on {name}.", + ) + status = ctx.baseline_status + if status is BaselineStatus.OK: + return ("", "") + if status is not None and status.name != "MISSING": + if _baseline_corrupt(status): + return ( + setup_ui.REASON_BASELINE_CORRUPT, + f"Regenerate the baseline at {name}.", + ) + return ( + setup_ui.REASON_BASELINE_UNTRUSTED, + f"Regenerate or repair the baseline at {name}.", + ) + return ( + setup_ui.REASON_BASELINE_MISSING, + f"Create a trusted baseline at {name}.", + ) + + +def _describe_mcp_runtime( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, readiness, ctx + if axes.installation == "missing": + return (setup_ui.REASON_REQUIRES_MCP_EXTRA, setup_ui.MCP_INSTALL_HINT) + return ( + setup_ui.REASON_MCP_INSTALLED_NOT_VERIFIED, + setup_ui.MCP_CONFIGURE_CLIENT_HINT, + ) + + +def _describe_controlled_change( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, ctx + if axes.installation == "missing": + return (setup_ui.REASON_REQUIRES_MCP_EXTRA, setup_ui.MCP_UV_INSTALL_HINT) + if readiness == "ready": + return ("", "") + return ( + setup_ui.REASON_CONTROLLED_CHANGE_NO_CLIENT, + setup_ui.MCP_CONFIGURE_CLIENT_HINT, + ) + + +def _describe_audit( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta + if axes.configuration == "invalid": + return (setup_ui.REASON_ANALYSIS_INVALID_CONFIG, setup_ui.ACTION_FIX_PYPROJECT) + if not ctx.audit_enabled: + return ( + setup_ui.REASON_AUDIT_DISABLED, + "Set audit_enabled = true under [tool.codeclone] to record controller " + "events.", + ) + if not ctx.audit_db_exists: + return ( + setup_ui.REASON_AUDIT_DB_MISSING, + "Run a governed MCP workflow to create the audit database.", + ) + if readiness == "ready": + return (setup_ui.REASON_AUDIT_READY, "") + return ( + setup_ui.REASON_AUDIT_EMPTY, + "Run a governed MCP change to record the first audit event.", + ) + + +def _describe_engineering_memory( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta + if axes.configuration == "invalid": + return (setup_ui.REASON_ANALYSIS_INVALID_CONFIG, setup_ui.ACTION_FIX_PYPROJECT) + if readiness == "ready": + return ("", "") + report = ctx.memory_report + if report is not None and report.db_exists: + return ( + setup_ui.REASON_MEMORY_EMPTY, + "Record engineering memory via governed MCP changes to populate the store.", + ) + return ( + setup_ui.REASON_MEMORY_MISSING, + "Run codeclone memory init after analysis to create the store.", + ) + + +def _describe_semantic_retrieval( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta + if axes.installation == "missing": + return (setup_ui.REASON_SEMANTIC_OPTIONAL, "uv sync --extra semantic-local") + if readiness == "ready": + return ("", "") + report = ctx.memory_report + if report is None or not report.db_exists: + return ( + setup_ui.REASON_SEMANTIC_NO_STORE, + "Initialize a memory store, then enable semantic retrieval.", + ) + return ( + setup_ui.REASON_SEMANTIC_DISABLED, + "Enable semantic memory under [tool.codeclone.memory].", + ) + + +def _describe_coverage_evidence( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, readiness, ctx + if axes.installation == "missing": + return (setup_ui.REASON_COVERAGE_OPTIONAL, "uv sync --extra coverage-xml") + return ("", "") + + +def _describe_analytics_cockpit( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, readiness, ctx + if axes.installation == "missing": + status = check_capability("full") + return ( + setup_ui.REASON_ANALYTICS_OPTIONAL, + install_hint(status.missing_packages), + ) + return ("", "") + + +def _describe_ci_policy( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta + if axes.configuration == "invalid": + return (setup_ui.REASON_ANALYSIS_INVALID_CONFIG, setup_ui.ACTION_FIX_PYPROJECT) + if readiness == "ready": + return ("", "") + if not _ci_flags_set(ctx): + return ( + setup_ui.REASON_CI_NOT_ENABLED, + "Enable ci / fail_on_new under [tool.codeclone] to gate changes in CI.", + ) + return ( + setup_ui.REASON_CI_BASELINE_ATTENTION, + "Ensure baseline trust before enabling CI gates.", + ) + + +def _describe_external_file( + axes: CapabilityAxes, + readiness: Readiness, + *, + unreadable_reason: str, + missing_reason: str, + missing_action: str, +) -> Guidance: + """Shared guidance for external-tool capabilities detected via a repo file.""" + + if axes.installation == "unknown": + return (unreadable_reason, "") + if readiness == "ready": + return ("", "") + return (missing_reason, missing_action) + + +def _describe_github_workflow( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, ctx + return _describe_external_file( + axes, + readiness, + unreadable_reason=setup_ui.REASON_GITHUB_WORKFLOW_UNREADABLE, + missing_reason=setup_ui.REASON_GITHUB_WORKFLOW_MISSING, + missing_action="Add a GitHub Actions workflow that runs codeclone in CI.", + ) + + +def _describe_pre_commit_hook( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, ctx + return _describe_external_file( + axes, + readiness, + unreadable_reason=setup_ui.REASON_PRE_COMMIT_UNREADABLE, + missing_reason=setup_ui.REASON_PRE_COMMIT_MISSING, + missing_action="Add a pre-commit hook entry for codeclone.", + ) + + +def _describe_workspace_hygiene( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: Readiness, + ctx: DiscoverContext, +) -> Guidance: + del meta, axes, ctx + if readiness == "ready": + return ("", "") + tip = gitignore_codeclone_cache_tip_payload() + return (setup_ui.REASON_WORKSPACE_HYGIENE, str(tip.get("message", ""))) + + +_GUIDANCE_BY_ID: dict[str, GuidanceHandler] = { + "analysis": _describe_analysis, + "analytics_cockpit": _describe_analytics_cockpit, + "audit_and_intents": _describe_audit, + "baseline": _describe_baseline, + "ci_policy": _describe_ci_policy, + "controlled_change": _describe_controlled_change, + "coverage_evidence": _describe_coverage_evidence, + "engineering_memory": _describe_engineering_memory, + "github_workflow": _describe_github_workflow, + "mcp_runtime": _describe_mcp_runtime, + "pre_commit_hook": _describe_pre_commit_hook, + "semantic_retrieval": _describe_semantic_retrieval, + "workspace_hygiene": _describe_workspace_hygiene, +} + + +def _optional_install_hint(meta: CapabilityMeta) -> str: + if meta.optional_extra_name == "mcp": + return setup_ui.MCP_INSTALL_HINT + if meta.optional_extra_name == "analytics": + return install_hint(check_capability("full").missing_packages) + if meta.optional_extra_name == "coverage-xml": + return "uv sync --extra coverage-xml" + if meta.optional_extra_name == "semantic-local": + return "uv sync --extra semantic-local" + return "" + + +def _baseline_corrupt(status: BaselineStatus) -> bool: + name = status.name + return name == "INVALID_JSON" or "SCHEMA" in name or "CORRUPT" in name + + +def _ci_flags_set(ctx: DiscoverContext) -> bool: + return any(bool(ctx.config.get(flag)) for flag in _CI_FLAGS) + + +__all__ = [ + "compute_maturity", + "derive_readiness", + "describe_capability", + "finalize_capabilities", +] diff --git a/codeclone/surfaces/cli/setup/main.py b/codeclone/surfaces/cli/setup/main.py new file mode 100644 index 00000000..76a0e141 --- /dev/null +++ b/codeclone/surfaces/cli/setup/main.py @@ -0,0 +1,259 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""``codeclone setup`` CLI entry (readiness projection and bounded apply).""" + +from __future__ import annotations + +import argparse +import sys +from collections.abc import Callable +from pathlib import Path + +from ....contracts import ExitCode +from ....ui_messages import setup as setup_ui +from ....utils.json_io import json_text +from ..console import make_query_console +from ..types import PrinterLike +from .engine.apply import apply_setup_plan +from .engine.discover import build_setup_snapshot +from .engine.plan import build_setup_plan +from .render import ( + render_setup_apply, + render_setup_doctor, + render_setup_plan, + render_setup_status, +) +from .wizard import run_setup_wizard + +SetupCommand = str +PayloadBuilder = Callable[[Path], dict[str, object]] +PayloadRenderer = Callable[[PrinterLike, dict[str, object]], None] + +_APPLY_FAILURE_STATUS: frozenset[str] = frozenset({"failed", "partial"}) +_APPLY_CONTRACT_STATUS: frozenset[str] = frozenset({"blocked", "stale_plan"}) + +# argparse attribute -> usage message when the flag is used outside `apply`. +_APPLY_ONLY_FLAGS: tuple[tuple[str, str], ...] = ( + ("dry_run", setup_ui.SETUP_DRY_RUN_ONLY_APPLY), + ("yes", setup_ui.SETUP_YES_ONLY_APPLY), + ("plan_id", setup_ui.SETUP_PLAN_ID_ONLY_APPLY), +) + + +def setup_main(argv: list[str]) -> int: + parser = _build_parser() + args = parser.parse_args(argv) + root_path = Path(args.root).expanduser().resolve() + command = args.command or "status" + + usage_error = _precheck_error(command, root_path, args) + if usage_error is not None: + print(usage_error, file=sys.stderr) + return int(ExitCode.CONTRACT_ERROR) + + try: + return _dispatch(command, root_path, args) + except Exception as exc: + print(f"Setup failed: {exc}", file=sys.stderr) + return int(ExitCode.INTERNAL_ERROR) + + +def _precheck_error( + command: SetupCommand, + root_path: Path, + args: argparse.Namespace, +) -> str | None: + if not root_path.is_dir(): + return f"Repository root does not exist: {root_path}" + if command != "apply": + for attr, message in _APPLY_ONLY_FLAGS: + if getattr(args, attr): + return message + if args.json and command == "wizard": + return setup_ui.SETUP_WIZARD_JSON_UNSUPPORTED + return None + + +def _dispatch( + command: SetupCommand, + root_path: Path, + args: argparse.Namespace, +) -> int: + if command == "wizard": + return run_setup_wizard(root_path) + if command == "apply": + return _run_apply(root_path, args) + + payload = _PAYLOAD_BUILDERS[command](root_path) + if args.json: + _write_json_stdout(payload) + else: + _render_payload(command, payload) + return int(ExitCode.SUCCESS) + + +def _run_apply(root_path: Path, args: argparse.Namespace) -> int: + expected_plan_id = args.plan_id or None + if not args.dry_run and not args.yes: + confirmed_plan_id, gate_exit = _confirmation_gate(root_path) + if gate_exit is not None: + return gate_exit + if expected_plan_id is None: + expected_plan_id = confirmed_plan_id + + result = apply_setup_plan( + root_path, + dry_run=args.dry_run, + expected_plan_id=expected_plan_id, + ) + status = str(result.get("status", "")) + if args.json: + _write_json_stdout(result) + elif status == "stale_plan": + print(setup_ui.SETUP_APPLY_STALE_PLAN, file=sys.stderr) + else: + _render_payload("apply", result) + return _exit_code_for_apply(status) + + +def _confirmation_gate(root_path: Path) -> tuple[str | None, int | None]: + """Preview the plan and confirm before an interactive apply. + + Returns ``(plan_id, None)`` when the caller may proceed with the confirmed + plan. Otherwise returns ``(None, exit_code)``: refuse without a TTY + (``CONTRACT_ERROR``) or an operator decline (``SUCCESS``, nothing written). + """ + + confirmed = _confirm_apply(root_path) + if isinstance(confirmed, str): + return confirmed, None + if confirmed is None: + message, stream, code = ( + setup_ui.SETUP_APPLY_CONFIRM_REQUIRED, + sys.stderr, + ExitCode.CONTRACT_ERROR, + ) + else: + message, stream, code = ( + setup_ui.SETUP_APPLY_ABORTED, + sys.stdout, + ExitCode.SUCCESS, + ) + print(message, file=stream) + return None, int(code) + + +def _confirm_apply(root_path: Path) -> str | bool | None: + """Preview the plan and ask for confirmation on a TTY. + + Returns the confirmed ``plan_id`` when the operator accepts, ``False`` when + they decline, or ``None`` when no interactive terminal is available (caller + must refuse without ``--yes``). + """ + + if not (sys.stdin.isatty() and sys.stdout.isatty()): + return None + console = make_query_console() + plan = build_setup_plan(root_path) + render_setup_plan(console=console, plan=plan) + reply = input(f"{setup_ui.SETUP_APPLY_CONFIRM_PROMPT} [y/N] ").strip().lower() + if reply in {"y", "yes"}: + return str(plan.get("plan_id", "")) + return False + + +def _exit_code_for_apply(status: str) -> int: + if status in _APPLY_FAILURE_STATUS: + return int(ExitCode.INTERNAL_ERROR) + if status in _APPLY_CONTRACT_STATUS: + return int(ExitCode.CONTRACT_ERROR) + return int(ExitCode.SUCCESS) + + +def _write_json_stdout(payload: dict[str, object]) -> None: + sys.stdout.write( + json_text( + payload, + sort_keys=True, + indent=True, + trailing_newline=True, + ) + ) + + +def _render_payload(command: SetupCommand, payload: dict[str, object]) -> None: + console = make_query_console() + _PAYLOAD_RENDERERS[command](console, payload) + + +def _build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(prog="codeclone setup") + parser.add_argument( + "command", + nargs="?", + choices=_COMMANDS, + default="status", + help="Readiness view or action (default: status).", + ) + parser.add_argument( + "--json", + action="store_true", + help="Emit setup projection JSON to stdout (status/doctor/plan/apply).", + ) + parser.add_argument( + "--dry-run", + action="store_true", + help="For apply: preview writes without modifying files.", + ) + parser.add_argument( + "-y", + "--yes", + action="store_true", + help="For apply: skip the confirmation prompt (required non-interactively).", + ) + parser.add_argument( + "--plan-id", + default="", + help="For apply: only proceed if the recomputed plan matches this id.", + ) + parser.add_argument( + "--root", + default=".", + help="Repository root path.", + ) + return parser + + +_COMMANDS: tuple[SetupCommand, ...] = ("status", "doctor", "plan", "apply", "wizard") + +_PAYLOAD_BUILDERS: dict[SetupCommand, PayloadBuilder] = { + "status": build_setup_snapshot, + "doctor": build_setup_snapshot, + "plan": build_setup_plan, +} + +_PAYLOAD_RENDERERS: dict[SetupCommand, PayloadRenderer] = { + "status": lambda console, payload: render_setup_status( + console=console, + snapshot=payload, + ), + "doctor": lambda console, payload: render_setup_doctor( + console=console, + snapshot=payload, + ), + "plan": lambda console, payload: render_setup_plan( + console=console, + plan=payload, + ), + "apply": lambda console, payload: render_setup_apply( + console=console, + result=payload, + ), +} + + +__all__ = ["setup_main"] diff --git a/codeclone/surfaces/cli/setup/render.py b/codeclone/surfaces/cli/setup/render.py new file mode 100644 index 00000000..e40e7f16 --- /dev/null +++ b/codeclone/surfaces/cli/setup/render.py @@ -0,0 +1,411 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Rich/plain renderers for setup readiness snapshots.""" + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TYPE_CHECKING, TypeGuard + +from ....ui_messages import setup as setup_ui +from ..console import rich_panel_symbols, supports_rich_console +from ..types import PrinterLike +from .engine.capabilities import GROUP_ORDER + +if TYPE_CHECKING: + from rich.rule import Rule as RichRule + from rich.table import Table as RichTable + + +def render_setup_status( + *, console: PrinterLike, snapshot: Mapping[str, object] +) -> None: + if supports_rich_console(console): + _render_status_rich(console=console, snapshot=snapshot) + return + _render_status_plain(console=console, snapshot=snapshot) + + +def render_setup_doctor( + *, console: PrinterLike, snapshot: Mapping[str, object] +) -> None: + if supports_rich_console(console): + _render_doctor_rich(console=console, snapshot=snapshot) + return + _render_doctor_plain(console=console, snapshot=snapshot) + + +def render_setup_plan(*, console: PrinterLike, plan: Mapping[str, object]) -> None: + if supports_rich_console(console): + _render_plan_rich(console=console, plan=plan) + return + _render_plan_plain(console=console, plan=plan) + + +def _render_status_rich(console: PrinterLike, snapshot: Mapping[str, object]) -> None: + _, _panel_cls, rule_cls, table_cls, _ = rich_panel_symbols() + runtime = _mapping(snapshot.get("runtime")) + install = _mapping(snapshot.get("install")) + console.print(setup_ui.SETUP_STATUS_TITLE) + console.print() + console.print( + rule_cls(title="Readiness", style="dim", characters="\u2500"), + ) + console.print( + f" [dim]Root:[/dim] {snapshot.get('root')} " + f"[dim]Python:[/dim] {runtime.get('python_tag')} " + f"[dim]CodeClone:[/dim] {runtime.get('codeclone_version')} " + f"[dim]{setup_ui.SETUP_STATUS_BASE_LABEL}:[/dim] {install.get('base')}" + ) + commit = snapshot.get("head_commit") or "\u2014" + console.print( + f" [dim]Schema:[/dim] {snapshot.get('schema_version')} " + f"[dim]Recomputation:[/dim] {snapshot.get('recomputation')} " + f"[dim]Commit:[/dim] {commit}" + ) + maturity = _mapping(snapshot.get("maturity")) + console.print( + " [dim]Maturity:[/dim] " + f"connected={maturity.get('connected')} " + f"governed={maturity.get('governed')} " + f"evidence={maturity.get('evidence_backed')} " + f"team={maturity.get('team_ready')} " + f"release={maturity.get('release_ready')}" + ) + console.print() + for group in GROUP_ORDER: + rows = [item for item in _capabilities(snapshot) if item.get("group") == group] + if not rows: + continue + console.print(setup_ui.GROUP_LABELS[group]) + table = table_cls(show_header=True, header_style="bold") + table.add_column("Capability", style="bold") + table.add_column("Availability") + table.add_column("Readiness") + table.add_column("Reason") + table.add_column("Next step") + for row in rows: + table.add_row( + str(row.get("label", "")), + _availability_label(row.get("availability")), + str(row.get("readiness", "")), + str(row.get("reason", "")) or "-", + str(row.get("recommended_action", "")) or "-", + ) + console.print(table) + console.print() + + +def _render_doctor_rich(console: PrinterLike, snapshot: Mapping[str, object]) -> None: + _, panel_cls, rule_cls, _table_cls, _ = rich_panel_symbols() + _render_status_rich(console, snapshot) + console.print( + rule_cls( + title=setup_ui.SETUP_DOCTOR_PROBES_HEADER, + style="dim", + characters="\u2500", + ) + ) + for row in _capabilities(snapshot): + console.print( + panel_cls( + _doctor_body(row), + title=str(row.get("label", row.get("id", ""))), + ) + ) + + +def _doctor_body(row: Mapping[str, object]) -> str: + evidence = row.get("evidence") + evidence_text = ", ".join(evidence) if _is_string_list(evidence) else "" + reason = str(row.get("reason", "")) + action = str(row.get("recommended_action", "")) + return ( + f"id={row.get('id')} availability={row.get('availability')} " + f"readiness={row.get('readiness')}\n" + f"installation={row.get('installation')} " + f"configuration={row.get('configuration')} " + f"runtime={row.get('runtime')}\n" + f"cause={reason or '-'}\n" + f"{setup_ui.SETUP_DOCTOR_PROBES_LABEL}={evidence_text or '-'}\n" + f"action={action or '-'}" + ) + + +def _render_status_plain(console: PrinterLike, snapshot: Mapping[str, object]) -> None: + runtime = _mapping(snapshot.get("runtime")) + install = _mapping(snapshot.get("install")) + console.print(setup_ui.SETUP_STATUS_TITLE) + console.print(f"root: {snapshot.get('root')}") + console.print( + f"python: {runtime.get('python_tag')} " + f"codeclone: {runtime.get('codeclone_version')} " + f"base: {install.get('base')} " + f"schema: {snapshot.get('schema_version')}" + ) + for row in _capabilities(snapshot): + reason = str(row.get("reason", "")) + action = str(row.get("recommended_action", "")) + availability = _availability_label(row.get("availability")) + line = f"{row.get('label')}: {row.get('readiness')} ({availability})" + if reason: + line = f"{line} — {reason}" + if action: + line = f"{line} → {action}" + console.print(line) + + +def _render_doctor_plain(console: PrinterLike, snapshot: Mapping[str, object]) -> None: + console.print(setup_ui.SETUP_DOCTOR_TITLE) + _render_status_plain(console, snapshot) + for row in _capabilities(snapshot): + evidence = row.get("evidence") + if _is_string_list(evidence) and evidence: + label = setup_ui.SETUP_DOCTOR_PROBES_LABEL + console.print(f" {label}[{row.get('id')}]: {', '.join(evidence)}") + + +def _availability_label(availability: object) -> str: + return setup_ui.AVAILABILITY_LABELS.get(str(availability), str(availability)) + + +def _render_plan_rich(console: PrinterLike, plan: Mapping[str, object]) -> None: + _, panel_cls, rule_cls, table_cls, _ = rich_panel_symbols() + _print_setup_rich_header( + console, + title=setup_ui.SETUP_PLAN_TITLE, + rule_title="Plan summary", + rule_cls=rule_cls, + root=plan.get("root"), + status=plan.get("status"), + plan_id=plan.get("plan_id"), + ) + console.print(f" [dim]{setup_ui.SETUP_PLAN_READ_ONLY_NOTE}[/dim]") + console.print() + + blockers = plan.get("blockers") + if isinstance(blockers, list) and blockers: + console.print(setup_ui.SETUP_PLAN_BLOCKED) + for blocker in blockers: + if isinstance(blocker, Mapping): + console.print(f" - {blocker.get('kind')}: {blocker.get('reason', '')}") + console.print() + + actions = _plan_actions(plan) + if not actions: + console.print(setup_ui.SETUP_PLAN_EMPTY) + return + + _print_kind_path_status_table( + console, + table_cls, + actions, + status_column="Status", + ) + console.print() + + for action in actions: + preview = action.get("preview") + diff = "" + if isinstance(preview, Mapping): + diff = str(preview.get("unified_diff", "")) + if not diff: + continue + title = f"{action.get('kind')} → {action.get('path')}" + console.print(panel_cls(diff.rstrip(), title=title)) + + +def _render_plan_plain(console: PrinterLike, plan: Mapping[str, object]) -> None: + console.print(setup_ui.SETUP_PLAN_TITLE) + console.print(f"status: {plan.get('status')} plan_id: {plan.get('plan_id')}") + console.print(setup_ui.SETUP_PLAN_READ_ONLY_NOTE) + actions = _plan_actions(plan) + if not actions: + console.print(setup_ui.SETUP_PLAN_EMPTY) + return + for action in actions: + console.print( + f"{action.get('kind')} {action.get('path')}: {action.get('status')}" + ) + preview = action.get("preview") + if isinstance(preview, Mapping): + diff = str(preview.get("unified_diff", "")).strip() + if diff: + console.print(diff) + + +def render_setup_apply(*, console: PrinterLike, result: Mapping[str, object]) -> None: + if supports_rich_console(console): + _render_apply_rich(console=console, result=result) + return + _render_apply_plain(console=console, result=result) + + +def _render_apply_rich(console: PrinterLike, result: Mapping[str, object]) -> None: + _, _panel_cls, rule_cls, table_cls, _ = rich_panel_symbols() + _print_setup_rich_header( + console, + title=setup_ui.SETUP_APPLY_TITLE, + rule_title="Apply summary", + rule_cls=rule_cls, + root=result.get("root"), + status=result.get("status"), + plan_id=result.get("plan_id"), + ) + if result.get("dry_run"): + console.print(" [dim]Dry run — no files were modified.[/dim]") + console.print() + + status = str(result.get("status", "")) + if status == "blocked": + console.print(setup_ui.SETUP_APPLY_BLOCKED) + return + results = _apply_results(result) + if not results: + console.print(setup_ui.SETUP_APPLY_NOOP) + return + + _print_kind_path_status_table( + console, + table_cls, + results, + status_column="Result", + ) + + +def _render_apply_plain(console: PrinterLike, result: Mapping[str, object]) -> None: + console.print(setup_ui.SETUP_APPLY_TITLE) + console.print( + f"status: {result.get('status')} plan_id: {result.get('plan_id')} " + f"dry_run: {result.get('dry_run')}" + ) + if str(result.get("status", "")) == "blocked": + console.print(setup_ui.SETUP_APPLY_BLOCKED) + return + for row in _apply_results(result): + message = str(row.get("message", "")) + suffix = f" — {message}" if message else "" + console.print( + f"{row.get('kind')} {row.get('path')}: {row.get('status')}{suffix}" + ) + + +def _print_setup_rich_header( + console: PrinterLike, + *, + title: str, + rule_title: str, + rule_cls: type[RichRule], + root: object, + status: object, + plan_id: object, +) -> None: + console.print(title) + console.print() + console.print( + rule_cls(title=rule_title, style="dim", characters="\u2500"), + ) + console.print( + f" [dim]Root:[/dim] {root} " + f"[dim]Status:[/dim] {status} " + f"[dim]Plan id:[/dim] {plan_id}" + ) + + +def _print_kind_path_status_table( + console: PrinterLike, + table_cls: type[RichTable], + rows: list[Mapping[str, object]], + *, + status_column: str, +) -> None: + table = table_cls(show_header=True, header_style="bold") + table.add_column("Action") + table.add_column("Target") + table.add_column(status_column) + for row in rows: + table.add_row( + str(row.get("kind", "")), + str(row.get("path", "")), + str(row.get("status", "")), + ) + console.print(table) + + +def _apply_results(result: Mapping[str, object]) -> list[Mapping[str, object]]: + return _mapping_rows(result.get("results")) + + +def _plan_actions(plan: Mapping[str, object]) -> list[Mapping[str, object]]: + return _mapping_rows(plan.get("actions")) + + +def snapshot_capabilities( + snapshot: Mapping[str, object], +) -> list[Mapping[str, object]]: + return _mapping_rows(snapshot.get("capabilities")) + + +def render_setup_capability_table( + console: PrinterLike, + rows: list[Mapping[str, object]], +) -> None: + if supports_rich_console(console): + _, _panel_cls, _rule_cls, table_cls, _ = rich_panel_symbols() + table = table_cls(show_header=True, header_style="bold") + table.add_column("Capability") + table.add_column("Readiness") + table.add_column("Reason") + for row in rows: + reason = str(row.get("reason", "")) + table.add_row( + str(row.get("label", "")), + str(row.get("readiness", "")), + reason or "-", + ) + console.print(table) + return + for row in rows: + reason = str(row.get("reason", "")) + line = f"{row.get('label')}: {row.get('readiness')}" + if reason: + line = f"{line} — {reason}" + console.print(line) + + +def _capabilities(snapshot: Mapping[str, object]) -> list[Mapping[str, object]]: + return snapshot_capabilities(snapshot) + + +def _mapping(value: object) -> Mapping[str, object]: + if _is_mapping(value): + return value + return {} + + +def _mapping_rows(value: object) -> list[Mapping[str, object]]: + if not isinstance(value, list): + return [] + return [item for item in value if _is_mapping(item)] + + +def _is_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) + + +def _is_string_list(value: object) -> TypeGuard[list[str]]: + return isinstance(value, list) and all(isinstance(item, str) for item in value) + + +__all__ = [ + "render_setup_apply", + "render_setup_capability_table", + "render_setup_doctor", + "render_setup_plan", + "render_setup_status", + "snapshot_capabilities", +] diff --git a/codeclone/surfaces/cli/setup/wizard.py b/codeclone/surfaces/cli/setup/wizard.py new file mode 100644 index 00000000..f31fb382 --- /dev/null +++ b/codeclone/surfaces/cli/setup/wizard.py @@ -0,0 +1,309 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Interactive setup wizard: hub navigation and guided plan/apply.""" + +from __future__ import annotations + +import sys +from collections.abc import Callable, Mapping +from dataclasses import dataclass +from pathlib import Path +from typing import Final, cast + +from ....contracts import ExitCode +from ....ui_messages import setup as setup_ui +from ..console import make_query_console, rich_panel_symbols, supports_rich_console +from ..types import PrinterLike +from .engine.apply import apply_setup_plan +from .engine.capabilities import GROUP_ORDER, CapabilityGroup +from .engine.discover import build_setup_snapshot +from .engine.plan import build_setup_plan +from .render import ( + render_setup_apply, + render_setup_capability_table, + render_setup_doctor, + render_setup_plan, + render_setup_status, + snapshot_capabilities, +) + +_HUB_QUIT: Final = "0" +_HUB_GUIDED: Final = "g" +_HUB_DOCTOR: Final = "d" +_GROUP_CHOICES: Final[tuple[str, ...]] = tuple( + str(index + 1) for index in range(len(GROUP_ORDER)) +) +_PLAN_GUIDANCE: Final[dict[str, tuple[str, ExitCode]]] = { + "blocked": (setup_ui.SETUP_WIZARD_GUIDED_BLOCKED, ExitCode.CONTRACT_ERROR), + "empty": (setup_ui.SETUP_WIZARD_GUIDED_EMPTY, ExitCode.SUCCESS), +} +_APPLY_FAILURE_STATUS: Final[frozenset[str]] = frozenset({"failed", "partial"}) + + +@dataclass(frozen=True, slots=True) +class WizardPrompts: + ask_choice: Callable[[str, list[str]], str] + confirm: Callable[[str, bool], bool] + + +def run_setup_wizard( + root_path: Path, + *, + console: PrinterLike | None = None, + prompts: WizardPrompts | None = None, +) -> int: + """Run the interactive hub session until the operator quits.""" + + if not _interactive_terminal_available(): + print(setup_ui.SETUP_WIZARD_TTY_REQUIRED, file=sys.stderr) + return int(ExitCode.CONTRACT_ERROR) + + resolved_console = console or make_query_console() + if not supports_rich_console(resolved_console): + print(setup_ui.SETUP_WIZARD_RICH_REQUIRED, file=sys.stderr) + return int(ExitCode.CONTRACT_ERROR) + + resolved_prompts = prompts or _default_wizard_prompts(resolved_console) + while True: + snapshot = build_setup_snapshot(root_path) + _render_hub_header(resolved_console, snapshot) + choice = resolved_prompts.ask_choice( + setup_ui.SETUP_WIZARD_PROMPT, + list(_hub_choices()), + ) + exit_code = _process_hub_choice( + choice, + root_path=root_path, + console=resolved_console, + snapshot=snapshot, + prompts=resolved_prompts, + ) + if exit_code is not None: + return exit_code + + +def _process_hub_choice( + choice: str, + *, + root_path: Path, + console: PrinterLike, + snapshot: Mapping[str, object], + prompts: WizardPrompts, +) -> int | None: + if choice == _HUB_QUIT: + return int(ExitCode.SUCCESS) + if choice == _HUB_GUIDED: + guided_exit = _run_guided_setup( + root_path, + console=console, + prompts=prompts, + ) + return None if guided_exit == int(ExitCode.SUCCESS) else guided_exit + if choice == _HUB_DOCTOR: + render_setup_doctor(console=console, snapshot=snapshot) + return None + group = _group_for_choice(choice) + if group is not None: + _render_sphere(console, snapshot, group=group) + return None + + +def _run_guided_setup( + root_path: Path, + *, + console: PrinterLike, + prompts: WizardPrompts, +) -> int: + plan = build_setup_plan(root_path) + console.print() + render_setup_plan(console=console, plan=plan) + plan_exit = _guided_plan_exit(console, str(plan.get("status", ""))) + if plan_exit is not None: + return plan_exit + if not prompts.confirm(setup_ui.SETUP_WIZARD_CONFIRM_APPLY, False): + console.print(setup_ui.SETUP_WIZARD_APPLY_SKIPPED) + return int(ExitCode.SUCCESS) + + # Bind apply to the plan the operator just confirmed so a concurrent repo + # change between preview and apply is refused rather than silently applied. + result = apply_setup_plan( + root_path, + expected_plan_id=str(plan.get("plan_id", "")), + ) + console.print() + render_setup_apply(console=console, result=result) + apply_exit = _apply_result_exit(str(result.get("status", ""))) + if apply_exit is not None: + return apply_exit + + console.print() + console.print(setup_ui.SETUP_WIZARD_UPDATED_READINESS) + render_setup_status( + console=console, + snapshot=build_setup_snapshot(root_path), + ) + return int(ExitCode.SUCCESS) + + +def _guided_plan_exit(console: PrinterLike, status: str) -> int | None: + guidance = _PLAN_GUIDANCE.get(status) + if guidance is None: + return None + message, exit_code = guidance + console.print(message) + return int(exit_code) + + +def _apply_result_exit(status: str) -> int | None: + if status in _APPLY_FAILURE_STATUS: + return int(ExitCode.INTERNAL_ERROR) + if status in {"blocked", "stale_plan"}: + return int(ExitCode.CONTRACT_ERROR) + return None + + +def _interactive_terminal_available() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _default_wizard_prompts(console: PrinterLike) -> WizardPrompts: + from rich.console import Console + from rich.prompt import Confirm, Prompt + + if not isinstance(console, Console): + raise RuntimeError(setup_ui.SETUP_WIZARD_RICH_REQUIRED) + + rich_console = console + + def ask_choice(message: str, choices: list[str]) -> str: + return str( + Prompt.ask( + message, + choices=choices, + show_choices=False, + console=rich_console, + ) + ) + + def confirm(message: str, default: bool) -> bool: + return bool( + Confirm.ask( + message, + default=default, + console=rich_console, + ) + ) + + return WizardPrompts(ask_choice=ask_choice, confirm=confirm) + + +def _hub_choices() -> tuple[str, ...]: + return (*_GROUP_CHOICES, _HUB_GUIDED, _HUB_DOCTOR, _HUB_QUIT) + + +def _group_for_choice(choice: str) -> CapabilityGroup | None: + if choice not in _GROUP_CHOICES: + return None + return GROUP_ORDER[int(choice) - 1] + + +def _render_hub_header(console: PrinterLike, snapshot: Mapping[str, object]) -> None: + _, _panel_cls, rule_cls, table_cls, _ = rich_panel_symbols() + console.print() + console.print(setup_ui.SETUP_WIZARD_TITLE) + console.print( + rule_cls( + title=setup_ui.SETUP_WIZARD_HUB_RULE, style="dim", characters="\u2500" + ), + ) + maturity = _mapping(snapshot.get("maturity")) + console.print( + " [dim]Root:[/dim] " + f"{snapshot.get('root')} " + "[dim]Maturity:[/dim] " + f"connected={maturity.get('connected')} " + f"governed={maturity.get('governed')} " + f"evidence={maturity.get('evidence_backed')} " + f"team={maturity.get('team_ready')} " + f"release={maturity.get('release_ready')}" + ) + console.print() + table = table_cls(show_header=True, header_style="bold") + table.add_column("#") + table.add_column("Sphere") + table.add_column("Summary") + for index, group in enumerate(GROUP_ORDER, start=1): + table.add_row( + str(index), setup_ui.GROUP_LABELS[group], _group_summary(snapshot, group) + ) + table.add_row( + _HUB_GUIDED, + setup_ui.SETUP_WIZARD_GUIDED_LABEL, + setup_ui.SETUP_WIZARD_GUIDED_HINT, + ) + table.add_row( + _HUB_DOCTOR, + setup_ui.SETUP_WIZARD_DOCTOR_LABEL, + setup_ui.SETUP_WIZARD_DOCTOR_HINT, + ) + table.add_row( + _HUB_QUIT, setup_ui.SETUP_WIZARD_QUIT_LABEL, setup_ui.SETUP_WIZARD_QUIT_HINT + ) + console.print(table) + + +def _render_sphere( + console: PrinterLike, + snapshot: Mapping[str, object], + *, + group: CapabilityGroup, +) -> None: + _, _panel_cls, rule_cls, _table_cls, _ = rich_panel_symbols() + console.print() + sphere_title = ( + f"{setup_ui.GROUP_LABELS[group]} — {setup_ui.SETUP_WIZARD_SPHERE_RULE}" + ) + console.print( + rule_cls( + title=sphere_title, + style="dim", + characters="\u2500", + ), + ) + rows = [ + item for item in snapshot_capabilities(snapshot) if item.get("group") == group + ] + if not rows: + console.print(setup_ui.SETUP_WIZARD_SPHERE_EMPTY) + return + render_setup_capability_table(console, rows) + + +def _group_summary(snapshot: Mapping[str, object], group: CapabilityGroup) -> str: + rows = [ + item for item in snapshot_capabilities(snapshot) if item.get("group") == group + ] + if not rows: + return setup_ui.SETUP_WIZARD_SPHERE_EMPTY + counts: dict[str, int] = {} + for row in rows: + readiness = str(row.get("readiness", "unknown")) + counts[readiness] = counts.get(readiness, 0) + 1 + parts = [ + f"{readiness}={count}" + for readiness, count in sorted(counts.items(), key=lambda item: item[0]) + ] + return ", ".join(parts) + + +def _mapping(value: object) -> Mapping[str, object]: + if isinstance(value, Mapping): + return cast("Mapping[str, object]", value) + return {} + + +__all__ = ["WizardPrompts", "run_setup_wizard"] diff --git a/codeclone/surfaces/cli/ui/__init__.py b/codeclone/surfaces/cli/ui/__init__.py new file mode 100644 index 00000000..2486869d --- /dev/null +++ b/codeclone/surfaces/cli/ui/__init__.py @@ -0,0 +1,50 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""CLI presentation layer: mascot, interactive help tour, progress presenter.""" + +from .help_presenter import ( + help_flag_present, + interactive_help_requested, + print_static_help_mascot, + static_help_mascot_lines, +) +from .help_tour import HelpTourStep, run_interactive_help_tour +from .mascot import Aster, mascot_use_unicode +from .mascot_frames import ( + AsterAnimation, + AsterFrame, + AsterState, + animation_frames_for_kind, + animation_frames_for_state, + graph_pulse_animation_frames, + resolve_animation, +) +from .progress_presenter import ProgressPresenter +from .tour_panel import TourStatsLines, build_step_panel +from .typewriter import TypewriterPanel + +__all__ = [ + "Aster", + "AsterAnimation", + "AsterFrame", + "AsterState", + "HelpTourStep", + "ProgressPresenter", + "TourStatsLines", + "TypewriterPanel", + "animation_frames_for_kind", + "animation_frames_for_state", + "build_step_panel", + "graph_pulse_animation_frames", + "help_flag_present", + "interactive_help_requested", + "mascot_use_unicode", + "print_static_help_mascot", + "resolve_animation", + "run_interactive_help_tour", + "static_help_mascot_lines", +] diff --git a/codeclone/surfaces/cli/ui/help_presenter.py b/codeclone/surfaces/cli/ui/help_presenter.py new file mode 100644 index 00000000..5154cea9 --- /dev/null +++ b/codeclone/surfaces/cli/ui/help_presenter.py @@ -0,0 +1,52 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Static help banner for ``codeclone --help``.""" + +from __future__ import annotations + +import sys +from collections.abc import Sequence +from typing import Protocol + +from ....ui_messages import help as help_ui +from .mascot import Aster, mascot_use_unicode +from .mascot_frames import AsterState + + +class TextWriter(Protocol): + def write(self, text: str, /) -> object: ... + + +def help_flag_present(argv: Sequence[str]) -> bool: + return any(token in {"-h", "--help"} for token in argv) + + +def interactive_help_requested(argv: Sequence[str]) -> bool: + return "--interactive-help" in argv + + +def static_help_mascot_lines(*, use_unicode: bool | None = None) -> tuple[str, ...]: + unicode = mascot_use_unicode() if use_unicode is None else use_unicode + aster = Aster(AsterState.IDLE, use_unicode=unicode) + lines = list(aster.plain_lines()) + lines.append(help_ui.HELP_MASCOT_TAGLINE) + return tuple(lines) + + +def print_static_help_mascot(*, file: TextWriter | None = None) -> None: + target = file if file is not None else sys.stdout + for line in static_help_mascot_lines(): + print(line, file=target) + print("", file=target) + + +__all__ = [ + "help_flag_present", + "interactive_help_requested", + "print_static_help_mascot", + "static_help_mascot_lines", +] diff --git a/codeclone/surfaces/cli/ui/help_tour.py b/codeclone/surfaces/cli/ui/help_tour.py new file mode 100644 index 00000000..5e09bb6e --- /dev/null +++ b/codeclone/surfaces/cli/ui/help_tour.py @@ -0,0 +1,351 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Interactive CodeClone tour for ``codeclone --help --interactive-help``. + +Each animated step uses a distinct ``AsterAnimation`` loop in the default tour. +""" + +from __future__ import annotations + +import sys +import time +from collections.abc import Callable, Sequence +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from ....ui_messages import help as help_ui +from ..console import make_query_console, supports_rich_console +from ..types import PrinterLike +from .mascot import Aster, mascot_use_unicode +from .mascot_frames import AsterAnimation, AsterState, resolve_animation +from .progress_presenter import ProgressPresenter +from .tour_panel import build_step_panel + +if TYPE_CHECKING: + from rich.console import Console as RichConsole + +_TICK_INTERVAL = 0.05 +_FRAME_INTERVAL = 0.14 +_CHAR_INTERVAL = 0.022 +_MIN_READ_PAUSE = 3.0 +_CURSOR_BLINK_INTERVAL = 0.48 +_READ_BONUS_PER_100_CHARS = 1.0 +_READ_BONUS_CAP = 2.0 + +_DEMO_STATS_SCAN: tuple[str, ...] = ( + "Files 741 / 741", + "Parsed 741", + "Callables 8,665", +) + +_DEMO_STATS_SUCCESS: tuple[str, ...] = ( + "741 files · 249,108 LOC · 10 known · 0 new", + "HTML report: .codeclone/report.html", +) + +_DEMO_STATS_REGRESSION: tuple[str, ...] = ( + "NEW clones: 2 · health delta: -3", + "Review: .codeclone/report.html", +) + + +@dataclass(frozen=True, slots=True) +class HelpTourStep: + state: AsterState + title: str + body: str + animate: bool = False + animation: AsterAnimation | None = None + stats: tuple[str, ...] = () + + +_DEFAULT_STEPS: tuple[HelpTourStep, ...] = ( + HelpTourStep( + AsterState.IDLE, + help_ui.HELP_TOUR_STEP_INTRO_TITLE, + help_ui.HELP_TOUR_STEP_INTRO_BODY, + ), + HelpTourStep( + AsterState.SCANNING, + help_ui.HELP_TOUR_STEP_PIPELINE_TITLE, + help_ui.HELP_TOUR_STEP_PIPELINE_BODY, + animate=True, + animation=AsterAnimation.SCANNING, + stats=_DEMO_STATS_SCAN, + ), + HelpTourStep( + AsterState.SCANNING, + help_ui.HELP_TOUR_STEP_CLONES_TITLE, + help_ui.HELP_TOUR_STEP_CLONES_BODY, + animate=True, + animation=AsterAnimation.GRAPH_PULSE, + ), + HelpTourStep( + AsterState.CACHE, + help_ui.HELP_TOUR_STEP_CACHE_TITLE, + help_ui.HELP_TOUR_STEP_CACHE_BODY, + animate=True, + animation=AsterAnimation.CACHE_CHAIN, + ), + HelpTourStep( + AsterState.DEPENDENCIES, + help_ui.HELP_TOUR_STEP_DEPENDENCIES_TITLE, + help_ui.HELP_TOUR_STEP_DEPENDENCIES_BODY, + animate=True, + animation=AsterAnimation.DEPENDENCIES, + ), + HelpTourStep( + AsterState.BLAST_RADIUS, + help_ui.HELP_TOUR_STEP_METRICS_TITLE, + help_ui.HELP_TOUR_STEP_METRICS_BODY, + animate=True, + animation=AsterAnimation.BLAST_RIPPLE, + ), + HelpTourStep( + AsterState.REPORTING, + help_ui.HELP_TOUR_STEP_REPORTS_TITLE, + help_ui.HELP_TOUR_STEP_REPORTS_BODY, + animate=True, + animation=AsterAnimation.REPORTING, + ), + HelpTourStep( + AsterState.ATTENTION, + help_ui.HELP_TOUR_STEP_BASELINE_TITLE, + help_ui.HELP_TOUR_STEP_BASELINE_BODY, + ), + HelpTourStep( + AsterState.CONTROL, + help_ui.HELP_TOUR_STEP_CONTROLLER_TITLE, + help_ui.HELP_TOUR_STEP_CONTROLLER_BODY, + animate=True, + animation=AsterAnimation.CONTROL_GATE, + ), + HelpTourStep( + AsterState.IDLE, + help_ui.HELP_TOUR_STEP_MEMORY_TITLE, + help_ui.HELP_TOUR_STEP_MEMORY_BODY, + animate=True, + animation=AsterAnimation.ORIGIN_HUB, + ), + HelpTourStep( + AsterState.ANALYSIS, + help_ui.HELP_TOUR_STEP_INTEGRATIONS_TITLE, + help_ui.HELP_TOUR_STEP_INTEGRATIONS_BODY, + animate=True, + animation=AsterAnimation.ANALYSIS_BRANCH, + ), + HelpTourStep( + AsterState.SUCCESS, + help_ui.HELP_TOUR_STEP_SUCCESS_TITLE, + help_ui.HELP_TOUR_STEP_SUCCESS_BODY, + stats=_DEMO_STATS_SUCCESS, + ), + HelpTourStep( + AsterState.ATTENTION, + help_ui.HELP_TOUR_STEP_REGRESSION_TITLE, + help_ui.HELP_TOUR_STEP_REGRESSION_BODY, + stats=_DEMO_STATS_REGRESSION, + ), + HelpTourStep( + AsterState.BLOCKED, + help_ui.HELP_TOUR_STEP_BLOCKED_TITLE, + help_ui.HELP_TOUR_STEP_BLOCKED_BODY, + animate=True, + animation=AsterAnimation.BLOCKED_SPLIT, + ), + HelpTourStep( + AsterState.SUCCESS, + help_ui.HELP_TOUR_STEP_NEXT_TITLE, + help_ui.HELP_TOUR_STEP_NEXT_BODY, + ), +) + + +def _interactive_terminal_available() -> bool: + return sys.stdin.isatty() and sys.stdout.isatty() + + +def _rich_console_or_none(console: PrinterLike) -> RichConsole | None: + if not supports_rich_console(console): + return None + from rich.console import Console as RichConsoleType + + if isinstance(console, RichConsoleType): + return console + return None + + +def _read_pause_for(text: str, *, min_pause: float) -> float: + bonus = min(_READ_BONUS_CAP, (len(text) / 100.0) * _READ_BONUS_PER_100_CHARS) + return min_pause + bonus + + +def _show_frame( + presenter: ProgressPresenter, + step: HelpTourStep, + *, + frame_lines: tuple[str, ...] | None, + visible_chars: int, + cursor_on: bool, + use_unicode: bool, +) -> None: + presenter.set_mascot(step.state, message=step.title, frame_lines=frame_lines) + presenter.set_extra( + build_step_panel( + body=step.body, + visible_chars=visible_chars, + cursor_on=cursor_on, + use_unicode=use_unicode, + stats=step.stats, + ) + ) + + +def _run_rich_step( + presenter: ProgressPresenter, + step: HelpTourStep, + *, + use_unicode: bool, + sleep: Callable[[float], None], + tick_interval: float, + frame_interval: float, + char_interval: float, + min_read_pause: float, + cursor_blink_interval: float, +) -> None: + anim_frames = resolve_animation( + step.state, + animation=step.animation, + animate=step.animate, + use_unicode=use_unicode, + ) + read_pause = _read_pause_for(step.body, min_pause=min_read_pause) + total_chars = len(step.body) + elapsed = 0.0 + typed = 0 + anim_index = 0 + cursor_on = True + typing_done_at: float | None = None + since_anim = 0.0 + since_char = 0.0 + since_blink = 0.0 + + while True: + frame_lines = None + if anim_frames: + frame_lines = anim_frames[anim_index % len(anim_frames)] + + _show_frame( + presenter, + step, + frame_lines=frame_lines, + visible_chars=typed, + cursor_on=cursor_on, + use_unicode=use_unicode, + ) + + sleep(tick_interval) + elapsed += tick_interval + + if anim_frames: + since_anim += tick_interval + if since_anim >= frame_interval: + anim_index += 1 + since_anim = 0.0 + + if typed < total_chars: + since_char += tick_interval + if since_char >= char_interval: + typed += 1 + since_char = 0.0 + + if typed >= total_chars: + if typing_done_at is None: + typing_done_at = elapsed + since_blink += tick_interval + if since_blink >= cursor_blink_interval: + cursor_on = not cursor_on + since_blink = 0.0 + if elapsed - typing_done_at >= read_pause: + break + + +def _render_plain_tour( + steps: Sequence[HelpTourStep], + printer: PrinterLike, + *, + sleep: Callable[[float], None], + plain_step_pause: float, +) -> None: + for step in steps: + aster = Aster(step.state, message=step.title, use_unicode=False) + for line in aster.plain_lines(): + printer.print(line) + for stat in step.stats: + printer.print(stat) + printer.print(step.body) + printer.print("") + if plain_step_pause > 0: + sleep(plain_step_pause) + + +def run_interactive_help_tour( + *, + console: PrinterLike | None = None, + steps: Sequence[HelpTourStep] = _DEFAULT_STEPS, + sleep: Callable[[float], None] = time.sleep, + tick_interval: float = _TICK_INTERVAL, + frame_interval: float = _FRAME_INTERVAL, + char_interval: float = _CHAR_INTERVAL, + min_read_pause: float = _MIN_READ_PAUSE, + cursor_blink_interval: float = _CURSOR_BLINK_INTERVAL, + plain_step_pause: float = 1.5, +) -> int: + if console is None: + console = make_query_console() + + rich_console = _rich_console_or_none(console) + interactive_available = _interactive_terminal_available() + if not interactive_available or rich_console is None: + plain_pause = plain_step_pause if interactive_available else 0.0 + _render_plain_tour(steps, console, sleep=sleep, plain_step_pause=plain_pause) + return 0 + + use_unicode = mascot_use_unicode( + no_color=bool(getattr(rich_console, "no_color", False)), + ) + + presenter = ProgressPresenter(rich_console) + first = steps[0] + _show_frame( + presenter, + first, + frame_lines=None, + visible_chars=0, + cursor_on=True, + use_unicode=use_unicode, + ) + with presenter.live_context(refresh_per_second=12, transient=False) as live: + presenter.bind_live(live) + for step in steps: + _run_rich_step( + presenter, + step, + use_unicode=use_unicode, + sleep=sleep, + tick_interval=tick_interval, + frame_interval=frame_interval, + char_interval=char_interval, + min_read_pause=min_read_pause, + cursor_blink_interval=cursor_blink_interval, + ) + presenter.clear_live() + + return 0 + + +__all__ = ["HelpTourStep", "run_interactive_help_tour"] diff --git a/codeclone/surfaces/cli/ui/mascot.py b/codeclone/surfaces/cli/ui/mascot.py new file mode 100644 index 00000000..f5bbcaa7 --- /dev/null +++ b/codeclone/surfaces/cli/ui/mascot.py @@ -0,0 +1,86 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Rich renderable for the CodeClone CLI mascot.""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .mascot_frames import AsterFrame, AsterState, frame_for_state + +if TYPE_CHECKING: + from rich.console import Console, ConsoleOptions, RenderResult + + +def mascot_use_unicode(*, no_color: bool = False) -> bool: + if no_color or os.environ.get("NO_COLOR"): + return False + encoding = getattr(sys.stdout, "encoding", None) or "utf-8" + try: + "●".encode(encoding) + except (LookupError, UnicodeEncodeError): + return False + return True + + +@dataclass(frozen=True, slots=True) +class Aster: + state: AsterState + message: str | None = None + frame_lines: tuple[str, ...] | None = None + style: str | None = None + use_unicode: bool = True + min_frame_lines: int = 0 + + def resolved_frame(self) -> AsterFrame: + base = frame_for_state(self.state, use_unicode=self.use_unicode) + lines = self.frame_lines if self.frame_lines is not None else base.lines + message = self.message if self.message is not None else base.message + style = self.style if self.style is not None else base.style + return AsterFrame(lines=lines, message=message, style=style) + + def _display_lines(self, lines: tuple[str, ...]) -> tuple[str, ...]: + stripped = tuple(line.rstrip() for line in lines) + if self.min_frame_lines <= len(stripped): + return stripped + missing = self.min_frame_lines - len(stripped) + top_padding = missing // 2 + bottom_padding = missing - top_padding + return ("",) * top_padding + stripped + ("",) * bottom_padding + + def plain_lines(self) -> tuple[str, ...]: + frame = self.resolved_frame() + lines = self._display_lines(frame.lines) + body = f"{frame.message} CodeClone · Structural Change Controller" + if len(lines) >= 2: + middle = lines[1] + padded = f"{middle} {body}" if len(middle) < 24 else body + return (lines[0], padded.rstrip(), *lines[2:]) + return (*lines, body) + + def __rich_console__( + self, + console: Console, + options: ConsoleOptions, + ) -> RenderResult: + from rich.text import Text + + frame = self.resolved_frame() + lines = self._display_lines(frame.lines) + message_line = len(lines) // 2 + for index, line in enumerate(lines): + text = Text(line, style=frame.style) + if index == message_line: + text.append(" ") + text.append(frame.message, style="codeclone.muted") + yield text + + +__all__ = ["Aster", "mascot_use_unicode"] diff --git a/codeclone/surfaces/cli/ui/mascot_frames.py b/codeclone/surfaces/cli/ui/mascot_frames.py new file mode 100644 index 00000000..4cc96192 --- /dev/null +++ b/codeclone/surfaces/cli/ui/mascot_frames.py @@ -0,0 +1,406 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy +# ruff: noqa: RUF001 + +"""CodeClone CLI mascot frame catalog and animation sequences. + +Canonical loops include ``SCANNING`` tree growth and ``GRAPH_PULSE`` node pulse. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class AsterState(str, Enum): + IDLE = "idle" + SCANNING = "scanning" + ANALYSIS = "analysis" + CACHE = "cache" + DEPENDENCIES = "dependencies" + BLAST_RADIUS = "blast_radius" + CONTROL = "control" + REPORTING = "reporting" + SUCCESS = "success" + ATTENTION = "attention" + BLOCKED = "blocked" + + +class AsterAnimation(str, Enum): + """Named animation loops decoupled from mascot state.""" + + NONE = "none" + SCANNING = "scanning" + ANALYSIS_BRANCH = "analysis_branch" + GRAPH_PULSE = "graph_pulse" + CACHE_CHAIN = "cache_chain" + DEPENDENCIES = "dependencies" + BLAST_RIPPLE = "blast_ripple" + CONTROL_GATE = "control_gate" + ORIGIN_HUB = "origin_hub" + REPORTING = "reporting" + BLOCKED_SPLIT = "blocked_split" + + +@dataclass(frozen=True, slots=True) +class AsterFrame: + lines: tuple[str, ...] + message: str + style: str = "codeclone.primary" + + +_FRAMES_UNICODE: dict[AsterState, AsterFrame] = { + AsterState.IDLE: AsterFrame( + lines=(" ● ", " ╱│╲ ", " ○ ○ ○ "), + message="Deterministic structural change control.", + ), + AsterState.SCANNING: AsterFrame( + lines=(" ● ", " ╱│╲ ", " ○ ○ ○ "), + message="Mapping repository structure…", + ), + AsterState.ANALYSIS: AsterFrame( + lines=(" ◉ ", " ╱│╲ ", " ○ ○ ○ ", " ╱╲ ╱╲ "), + message="Running structural analysis…", + ), + AsterState.CACHE: AsterFrame( + lines=(" ", " ●─○─○ ", " "), + message="Reusing structural facts…", + ), + AsterState.DEPENDENCIES: AsterFrame( + lines=( + " ○ ", + " ╱│╲ ", + " ○─●─○ ", + " ╲│╱ ", + " ○ ", + ), + message="Following dependencies…", + ), + AsterState.BLAST_RADIUS: AsterFrame( + lines=(" · · · ", "· ◉ ·", " · · · "), + message="Mapping blast radius…", + ), + AsterState.CONTROL: AsterFrame( + lines=(" ┌─────┐ ", " │ ◉ │ ", " └─────┘ "), + message="Controlled change active.", + ), + AsterState.REPORTING: AsterFrame( + lines=(" ┌───┐ ", " │ ● │ ", " └───┘ "), + message="Compiling canonical report…", + ), + AsterState.SUCCESS: AsterFrame( + lines=(" ● ", " ╲│╱ ", " ✓ "), + message="Analysis complete.", + style="codeclone.success", + ), + AsterState.ATTENTION: AsterFrame( + lines=(" ● ", " ╱│ ", " ! "), + message="Review required.", + style="codeclone.attention", + ), + AsterState.BLOCKED: AsterFrame( + lines=(" ◉ ", " ╱■ ", " STOP "), + message="Boundary held.", + style="codeclone.attention", + ), +} + +_FRAMES_ASCII: dict[AsterState, AsterFrame] = { + AsterState.IDLE: AsterFrame( + lines=(" o ", " /|\\ ", "o o o"), + message="Deterministic structural change control.", + ), + AsterState.SCANNING: AsterFrame( + lines=(" o ", " /|\\ ", "o o o"), + message="Mapping repository structure...", + ), + AsterState.ANALYSIS: AsterFrame( + lines=(" O ", " /|\\ ", "o o o", " /\\/\\ "), + message="Running structural analysis...", + ), + AsterState.CACHE: AsterFrame( + lines=(" ", " o-o-o ", " "), + message="Reusing structural facts...", + ), + AsterState.DEPENDENCIES: AsterFrame( + lines=(" o ", " /|\\ ", "o-o-o", " \\|/ ", " o "), + message="Following dependencies...", + ), + AsterState.BLAST_RADIUS: AsterFrame( + lines=(" . . . ", ". O .", " . . . "), + message="Mapping blast radius...", + ), + AsterState.CONTROL: AsterFrame( + lines=(" +-----+ ", " | O | ", " +-----+ "), + message="Controlled change active.", + ), + AsterState.REPORTING: AsterFrame( + lines=(" +---+ ", " | o | ", " +---+ "), + message="Compiling canonical report...", + ), + AsterState.SUCCESS: AsterFrame( + lines=(" o ", " \\|/ ", " v "), + message="Analysis complete.", + style="codeclone.success", + ), + AsterState.ATTENTION: AsterFrame( + lines=(" o ", " /| ", " ! "), + message="Review required.", + style="codeclone.attention", + ), + AsterState.BLOCKED: AsterFrame( + lines=(" O ", " /# ", " STOP"), + message="Boundary held.", + style="codeclone.attention", + ), +} + +_SCANNING_ANIMATION_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ● ", " ╱│ ", " ○ "), + (" ● ", " ╱│╲ ", " ○ ○ "), + (" ● ", " │╲ ", " ○ "), + (" ● ", " ╱│╲ ", " ○ ○ ○ "), +) + +_SCANNING_ANIMATION_ASCII: tuple[tuple[str, ...], ...] = ( + (" o ", " /| ", " o "), + (" o ", " /|\\ ", " o o "), + (" o ", " |\\ ", " o "), + (" o ", " /|\\ ", "o o o"), +) + +_ANALYSIS_BRANCH_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ◉ ", " ╱│╲ ", " ○ ○ ○ "), + (" ◉ ", " ╱│╲ ", " ○ ○ ○ ", " ╱╲ ╱╲ "), + (" ◉ ", " ╱│╲ ", " ● ○ ● ", " ╱╲ ╱╲ "), + (" ◉ ", " ╱│╲ ", " ○ ○ ○ ", " ╱╲ ╱╲ "), +) + +_ANALYSIS_BRANCH_ASCII: tuple[tuple[str, ...], ...] = ( + (" O ", " /|\\ ", "o o o"), + (" O ", " /|\\ ", "o o o", " /\\/\\ "), + (" O ", " /|\\ ", "O o O", " /\\/\\ "), + (" O ", " /|\\ ", "o o o", " /\\/\\ "), +) + +_GRAPH_PULSE_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ●─○─○─○ ",), + (" ○─●─○─○ ",), + (" ○─○─●─○ ",), + (" ○─○─○─● ",), +) + +_GRAPH_PULSE_ASCII: tuple[tuple[str, ...], ...] = ( + (" o-o-o-o ",), + (" o-O-o-o ",), + (" o-o-O-o ",), + (" o-o-o-O ",), +) + +_CACHE_ANIMATION_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ", " ●─○─○ ", " "), + (" ", " ○─●─○ ", " "), + (" ", " ○─○─● ", " "), + (" ", " ●─○─○ ", " "), +) + +_CACHE_ANIMATION_ASCII: tuple[tuple[str, ...], ...] = ( + (" ", " o-o-o ", " "), + (" ", " o-O-o ", " "), + (" ", " o-o-O ", " "), + (" ", " o-o-o ", " "), +) + +_DEPENDENCIES_ANIMATION_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ○ ", " ╱│╲ ", " ○─●─○ ", " ╲│╱ ", " ○ "), + (" ○ ", " ╱│╲ ", " ●─○─○ ", " ╲│╱ ", " ○ "), + (" ○ ", " ╱│╲ ", " ○─○─● ", " ╲│╱ ", " ○ "), + (" ○ ", " ╱│╲ ", " ○─●─○ ", " ╲│╱ ", " ○ "), +) + +_DEPENDENCIES_ANIMATION_ASCII: tuple[tuple[str, ...], ...] = ( + (" o ", " /|\\ ", "o-o-o", " \\|/ ", " o "), + (" o ", " /|\\ ", "O-o-o", " \\|/ ", " o "), + (" o ", " /|\\ ", "o-o-O", " \\|/ ", " o "), + (" o ", " /|\\ ", "o-o-o", " \\|/ ", " o "), +) + +_REPORTING_ANIMATION_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ┌───┐ ", " │ ● │ ", " └───┘ "), + (" ┌───┐ ", " │ ○ │ ", " └───┘ "), + (" ┌───┐ ", " │ ● │ ", " └───┘ "), + (" ┌───┐ ", " │ ○ │ ", " └───┘ "), +) + +_REPORTING_ANIMATION_ASCII: tuple[tuple[str, ...], ...] = ( + (" +---+ ", " | o | ", " +---+ "), + (" +---+ ", " | | ", " +---+ "), + (" +---+ ", " | o | ", " +---+ "), + (" +---+ ", " | | ", " +---+ "), +) + +_BLOCKED_SPLIT_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ◉─○ ○─◉ ", " ╲│ │╱ ", " ○ ○ "), + (" ○─◉ ◉─○ ", " │╱ ╲│ ", " ○ ○ "), +) + +_BLOCKED_SPLIT_ASCII: tuple[tuple[str, ...], ...] = ( + (" O-o o-O ", " \\| |/ ", " o o "), + (" o-O O-o ", " |/ \\| ", " o o "), +) + +_CONTROL_GATE_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ┌─────┐ ", " │ ◉ │ ", " └─────┘ "), + (" ┌─────┐ ", " │ ● │ ", " └─────┘ "), + (" ╔═════╗ ", " ║ ◉ ║ ", " ╚═════╝ "), + (" ┌─────┐ ", " │ ◉ │ ", " └─────┘ "), +) + +_CONTROL_GATE_ASCII: tuple[tuple[str, ...], ...] = ( + (" +-----+ ", " | O | ", " +-----+ "), + (" +-----+ ", " | o | ", " +-----+ "), + (" +=====+ ", " | O | ", " +=====+ "), + (" +-----+ ", " | O | ", " +-----+ "), +) + +_BLAST_RIPPLE_UNICODE: tuple[tuple[str, ...], ...] = ( + (" · · · ", "· ◉ ·", " · · · "), + ("· · ", " · ◉ · ", "· · "), + (" · · ", " ◉ ", " · · "), + (" · · · ", "· ◉ ·", " · · · "), +) + +_BLAST_RIPPLE_ASCII: tuple[tuple[str, ...], ...] = ( + (" . . . ", ". O .", " . . . "), + (". . ", " . O . ", ". . "), + (" . . ", " O ", " . . "), + (" . . . ", ". O .", " . . . "), +) + +_ORIGIN_HUB_UNICODE: tuple[tuple[str, ...], ...] = ( + (" ● ", " ╱│╲ ", " ○ ◉ ○ ", " ╵ "), + (" ◉ ", " ╱│╲ ", " ○ ○ ○ ", " ╵ "), + (" ● ", " ╱ │ ╲ ", " ○ ● ○ ", " ╵ "), + (" ● ", " ╱│╲ ", " ○ ◉ ○ ", " ● "), +) + +_ORIGIN_HUB_ASCII: tuple[tuple[str, ...], ...] = ( + (" o ", " /|\\ ", "o O o", " | "), + (" O ", " /|\\ ", "o o o", " | "), + (" o ", " / | \\ ", "o O o", " | "), + (" o ", " /|\\ ", "o O o", " o "), +) + +_ANIMATIONS_UNICODE: dict[AsterAnimation, tuple[tuple[str, ...], ...]] = { + AsterAnimation.SCANNING: _SCANNING_ANIMATION_UNICODE, + AsterAnimation.ANALYSIS_BRANCH: _ANALYSIS_BRANCH_UNICODE, + AsterAnimation.GRAPH_PULSE: _GRAPH_PULSE_UNICODE, + AsterAnimation.CACHE_CHAIN: _CACHE_ANIMATION_UNICODE, + AsterAnimation.DEPENDENCIES: _DEPENDENCIES_ANIMATION_UNICODE, + AsterAnimation.BLAST_RIPPLE: _BLAST_RIPPLE_UNICODE, + AsterAnimation.CONTROL_GATE: _CONTROL_GATE_UNICODE, + AsterAnimation.ORIGIN_HUB: _ORIGIN_HUB_UNICODE, + AsterAnimation.REPORTING: _REPORTING_ANIMATION_UNICODE, + AsterAnimation.BLOCKED_SPLIT: _BLOCKED_SPLIT_UNICODE, +} + +_ANIMATIONS_ASCII: dict[AsterAnimation, tuple[tuple[str, ...], ...]] = { + AsterAnimation.SCANNING: _SCANNING_ANIMATION_ASCII, + AsterAnimation.ANALYSIS_BRANCH: _ANALYSIS_BRANCH_ASCII, + AsterAnimation.GRAPH_PULSE: _GRAPH_PULSE_ASCII, + AsterAnimation.CACHE_CHAIN: _CACHE_ANIMATION_ASCII, + AsterAnimation.DEPENDENCIES: _DEPENDENCIES_ANIMATION_ASCII, + AsterAnimation.BLAST_RIPPLE: _BLAST_RIPPLE_ASCII, + AsterAnimation.CONTROL_GATE: _CONTROL_GATE_ASCII, + AsterAnimation.ORIGIN_HUB: _ORIGIN_HUB_ASCII, + AsterAnimation.REPORTING: _REPORTING_ANIMATION_ASCII, + AsterAnimation.BLOCKED_SPLIT: _BLOCKED_SPLIT_ASCII, +} + +_STATE_DEFAULT_ANIMATION: dict[AsterState, AsterAnimation] = { + AsterState.SCANNING: AsterAnimation.SCANNING, + AsterState.ANALYSIS: AsterAnimation.ANALYSIS_BRANCH, + AsterState.CACHE: AsterAnimation.CACHE_CHAIN, + AsterState.DEPENDENCIES: AsterAnimation.DEPENDENCIES, + AsterState.BLAST_RADIUS: AsterAnimation.BLAST_RIPPLE, + AsterState.CONTROL: AsterAnimation.CONTROL_GATE, + AsterState.REPORTING: AsterAnimation.REPORTING, + AsterState.BLOCKED: AsterAnimation.BLOCKED_SPLIT, +} + + +def frame_for_state(state: AsterState, *, use_unicode: bool = True) -> AsterFrame: + catalog = _FRAMES_UNICODE if use_unicode else _FRAMES_ASCII + return catalog[state] + + +def scanning_animation_frames( + *, + use_unicode: bool = True, +) -> tuple[tuple[str, ...], ...]: + if use_unicode: + return _SCANNING_ANIMATION_UNICODE + return _SCANNING_ANIMATION_ASCII + + +def graph_pulse_animation_frames( + *, + use_unicode: bool = True, +) -> tuple[tuple[str, ...], ...]: + if use_unicode: + return _GRAPH_PULSE_UNICODE + return _GRAPH_PULSE_ASCII + + +def animation_frames_for_kind( + kind: AsterAnimation, + *, + use_unicode: bool = True, +) -> tuple[tuple[str, ...], ...] | None: + if kind is AsterAnimation.NONE: + return None + catalog = _ANIMATIONS_UNICODE if use_unicode else _ANIMATIONS_ASCII + return catalog.get(kind) + + +def animation_frames_for_state( + state: AsterState, + *, + use_unicode: bool = True, +) -> tuple[tuple[str, ...], ...] | None: + kind = _STATE_DEFAULT_ANIMATION.get(state) + if kind is None: + return None + return animation_frames_for_kind(kind, use_unicode=use_unicode) + + +def resolve_animation( + state: AsterState, + *, + animation: AsterAnimation | None, + animate: bool, + use_unicode: bool = True, +) -> tuple[tuple[str, ...], ...] | None: + if not animate: + return None + kind = animation if animation is not None else _STATE_DEFAULT_ANIMATION.get(state) + if kind is None or kind is AsterAnimation.NONE: + return None + return animation_frames_for_kind(kind, use_unicode=use_unicode) + + +__all__ = [ + "AsterAnimation", + "AsterFrame", + "AsterState", + "animation_frames_for_kind", + "animation_frames_for_state", + "frame_for_state", + "graph_pulse_animation_frames", + "resolve_animation", + "scanning_animation_frames", +] diff --git a/codeclone/surfaces/cli/ui/progress_presenter.py b/codeclone/surfaces/cli/ui/progress_presenter.py new file mode 100644 index 00000000..32cc3da6 --- /dev/null +++ b/codeclone/surfaces/cli/ui/progress_presenter.py @@ -0,0 +1,97 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Single owner for Live + mascot + progress renderables during CLI analysis. + +``AsterAnimation`` in ``mascot_frames`` selects phase loops (scanning, graph pulse, +dependencies, reporting) for help tour and future analysis progress wiring. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING + +from .mascot import Aster, mascot_use_unicode +from .mascot_frames import AsterState + +if TYPE_CHECKING: + from rich.console import Console, RenderableType + from rich.live import Live + +_MASCOT_FRAME_HEIGHT = 5 + + +@dataclass +class ProgressPresenter: + """Coordinates mascot state with a shared Rich Live container.""" + + console: Console + mascot_state: AsterState = AsterState.SCANNING + mascot_message: str | None = None + mascot_frame_lines: tuple[str, ...] | None = None + extra_renderables: tuple[RenderableType, ...] = field(default_factory=tuple) + _live: Live | None = field(default=None, init=False, repr=False) + + def set_mascot( + self, + state: AsterState, + *, + message: str | None = None, + frame_lines: tuple[str, ...] | None = None, + ) -> None: + self.mascot_state = state + self.mascot_message = message + self.mascot_frame_lines = frame_lines + self.refresh() + + def set_extra(self, *renderables: RenderableType) -> None: + self.extra_renderables = renderables + self.refresh() + + def build_renderable(self) -> RenderableType: + from rich.console import Group + + mascot = Aster( + self.mascot_state, + message=self.mascot_message, + frame_lines=self.mascot_frame_lines, + use_unicode=mascot_use_unicode( + no_color=bool(getattr(self.console, "no_color", False)), + ), + min_frame_lines=_MASCOT_FRAME_HEIGHT, + ) + if self.extra_renderables: + return Group(mascot, *self.extra_renderables) + return mascot + + def refresh(self) -> None: + if self._live is not None: + self._live.update(self.build_renderable(), refresh=True) + + def live_context( + self, + *, + refresh_per_second: float = 8, + transient: bool = False, + ) -> Live: + from rich.live import Live + + return Live( + self.build_renderable(), + console=self.console, + refresh_per_second=refresh_per_second, + transient=transient, + ) + + def bind_live(self, live: Live) -> None: + self._live = live + + def clear_live(self) -> None: + self._live = None + + +__all__ = ["ProgressPresenter"] diff --git a/codeclone/surfaces/cli/ui/tour_panel.py b/codeclone/surfaces/cli/ui/tour_panel.py new file mode 100644 index 00000000..3ef0c2ad --- /dev/null +++ b/codeclone/surfaces/cli/ui/tour_panel.py @@ -0,0 +1,70 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Combined tour panels: typewriter body plus optional metric lines.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +from .typewriter import TypewriterPanel + +if TYPE_CHECKING: + from rich.console import Console, ConsoleOptions, RenderableType, RenderResult + +TOUR_STATS_HEIGHT = 2 +TOUR_BODY_PANEL_HEIGHT = 8 + + +@dataclass(frozen=True, slots=True) +class TourStatsLines: + """Compact metric lines shown under the mascot during a tour step.""" + + lines: tuple[str, ...] + style: str = "codeclone.muted" + height: int = TOUR_STATS_HEIGHT + + def __rich_console__( + self, + console: Console, + options: ConsoleOptions, + ) -> RenderResult: + from rich.text import Text + + visible = self.lines[: self.height] + padded = visible + ("",) * max(0, self.height - len(visible)) + for line in padded: + yield Text(line, style=self.style) + + +def build_step_panel( + *, + body: str, + visible_chars: int, + cursor_on: bool, + use_unicode: bool, + stats: tuple[str, ...] = (), +) -> RenderableType: + from rich.console import Group + + stats_block = TourStatsLines(lines=stats) + typewriter = TypewriterPanel( + text=body, + visible_chars=visible_chars, + cursor_on=cursor_on, + use_unicode=use_unicode, + height=TOUR_BODY_PANEL_HEIGHT, + ) + return Group(stats_block, typewriter) + + +__all__ = [ + "TOUR_BODY_PANEL_HEIGHT", + "TOUR_STATS_HEIGHT", + "TourStatsLines", + "build_step_panel", +] diff --git a/codeclone/surfaces/cli/ui/typewriter.py b/codeclone/surfaces/cli/ui/typewriter.py new file mode 100644 index 00000000..b247927d --- /dev/null +++ b/codeclone/surfaces/cli/ui/typewriter.py @@ -0,0 +1,66 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Typewriter text panel with blinking cursor for interactive CLI tours.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from rich.console import Console, ConsoleOptions, RenderResult + + +@dataclass(frozen=True, slots=True) +class TypewriterPanel: + """Panel that reveals ``text`` progressively with an optional blinking cursor.""" + + text: str + visible_chars: int + cursor_visible: bool = True + cursor_on: bool = True + use_unicode: bool = True + border_style: str = "codeclone.primary" + text_style: str = "codeclone.muted" + cursor_style: str = "codeclone.primary" + height: int | None = None + + @property + def _cursor_char(self) -> str: + if not self.cursor_visible: + return "" + if not self.cursor_on: + return " " + return "▌" if self.use_unicode else "|" + + def _render_parts(self) -> tuple[str, str]: + clipped = max(0, min(self.visible_chars, len(self.text))) + body = self.text[:clipped] + cursor = self._cursor_char if self.cursor_visible else "" + return body, cursor + + def __rich_console__( + self, + console: Console, + options: ConsoleOptions, + ) -> RenderResult: + from rich.panel import Panel + from rich.text import Text + + body, cursor = self._render_parts() + content = Text(body, style=self.text_style) + if cursor: + content.append(cursor, style=self.cursor_style) + yield Panel( + content, + border_style=self.border_style, + height=self.height, + padding=(0, 1), + ) + + +__all__ = ["TypewriterPanel"] diff --git a/codeclone/surfaces/cli/workflow.py b/codeclone/surfaces/cli/workflow.py index 822bd168..94b23ff8 100644 --- a/codeclone/surfaces/cli/workflow.py +++ b/codeclone/surfaces/cli/workflow.py @@ -9,7 +9,7 @@ import sys import time from pathlib import Path -from typing import Protocol +from typing import Protocol, TypeGuard from ... import __version__ from ... import ui_messages as ui @@ -33,7 +33,7 @@ from ...core.reporting import gate, report from ...models import MetricsDiff from ...observability import bootstrap as start_observability -from ...observability import operation +from ...observability import operation, span from ...report.html import build_html_report from . import baseline_state as cli_baseline_state from . import changed_scope as cli_changed_scope @@ -481,184 +481,193 @@ def _main_impl() -> None: validate_numeric_args_fn=_validate_numeric_args, printer=_console(), ) - baseline_inputs = _resolve_baseline_inputs( - ap=ap, - args=args, - root_path=root_path, - baseline_path_from_args=baseline_path_from_args, - metrics_path_from_args=metrics_path_from_args, - probe_metrics_baseline_section_fn=_probe_metrics_baseline_section, - printer=_console(), - ) - prepare_metrics_mode_and_ui( - args=args, - root_path=root_path, - baseline_path=baseline_inputs.baseline_path, - baseline_exists=baseline_inputs.baseline_exists, - metrics_baseline_path=baseline_inputs.metrics_baseline_path, - metrics_baseline_exists=baseline_inputs.metrics_baseline_exists, - configure_metrics_mode=_configure_metrics_mode, - print_banner=print_banner, - ) - - output_paths = _resolve_output_paths( - args, - report_path_origins=report_path_origins, - report_generated_at_utc=report_generated_at_utc, - ) - _validate_report_ui_flags(args=args, output_paths=output_paths) - _validate_controller_query_flags( - args=args, - report_outputs_requested=bool( - output_paths.html - or output_paths.json - or output_paths.md - or output_paths.sarif - or output_paths.text - or bool_attr(args, "open_html_report") - or bool_attr(args, "timestamped_report_paths") - ), - strictness_explicit=strictness_explicit, - ) - cache_path = _resolve_cache_path( - root_path=root_path, - args=args, - from_args=cache_path_from_args, - ) - - cache = Cache( - cache_path, - root=root_path, - max_size_bytes=args.max_cache_size_mb * 1024 * 1024, - min_loc=args.min_loc, - min_stmt=args.min_stmt, - block_min_loc=args.block_min_loc, - block_min_stmt=args.block_min_stmt, - segment_min_loc=args.segment_min_loc, - segment_min_stmt=args.segment_min_stmt, - collect_api_surface=bool(args.api_surface), - ) - cache.load() - if cache.load_warning: - _console().print(ui.fmt_cli_runtime_warning(cache.load_warning)) - - boot = bootstrap( - args=args, - root=root_path, - output_paths=output_paths, - cache_path=cache_path, - ) - # Freeze the env-resolved observability decision for this CLI process - # (default OFF) so the core pipeline stage spans attach to a cli.analyze op. + # Freeze the env-resolved observability decision for this CLI process (default + # OFF) before baseline/cache work so the whole cli.analyze operation is + # measured; span()/operation() are inert when disabled. start_observability(resolve_observability_config(), root=root_path) with operation(name="cli.analyze", surface="cli"): + with span(name="pipeline.baseline"): + baseline_inputs = _resolve_baseline_inputs( + ap=ap, + args=args, + root_path=root_path, + baseline_path_from_args=baseline_path_from_args, + metrics_path_from_args=metrics_path_from_args, + probe_metrics_baseline_section_fn=_probe_metrics_baseline_section, + printer=_console(), + ) + prepare_metrics_mode_and_ui( + args=args, + root_path=root_path, + baseline_path=baseline_inputs.baseline_path, + baseline_exists=baseline_inputs.baseline_exists, + metrics_baseline_path=baseline_inputs.metrics_baseline_path, + metrics_baseline_exists=baseline_inputs.metrics_baseline_exists, + configure_metrics_mode=_configure_metrics_mode, + print_banner=print_banner, + ) + + output_paths = _resolve_output_paths( + args, + report_path_origins=report_path_origins, + report_generated_at_utc=report_generated_at_utc, + ) + _validate_report_ui_flags(args=args, output_paths=output_paths) + _validate_controller_query_flags( + args=args, + report_outputs_requested=bool( + output_paths.html + or output_paths.json + or output_paths.md + or output_paths.sarif + or output_paths.text + or bool_attr(args, "open_html_report") + or bool_attr(args, "timestamped_report_paths") + ), + strictness_explicit=strictness_explicit, + ) + cache_path = _resolve_cache_path( + root_path=root_path, + args=args, + from_args=cache_path_from_args, + ) + + with span(name="pipeline.cache_load"): + cache = Cache( + cache_path, + root=root_path, + max_size_bytes=args.max_cache_size_mb * 1024 * 1024, + min_loc=args.min_loc, + min_stmt=args.min_stmt, + block_min_loc=args.block_min_loc, + block_min_stmt=args.block_min_stmt, + segment_min_loc=args.segment_min_loc, + segment_min_stmt=args.segment_min_stmt, + collect_api_surface=bool(args.api_surface), + ) + cache.load() + if cache.load_warning: + _console().print(ui.fmt_cli_runtime_warning(cache.load_warning)) + + with span(name="pipeline.bootstrap"): + boot = bootstrap( + args=args, + root=root_path, + output_paths=output_paths, + cache_path=cache_path, + ) discovery_result, processing_result, analysis_result = _run_analysis_stages( args=args, boot=boot, cache=cache, ) - source_read_contract_failure = ( - bool(processing_result.source_read_failures) - and gating_mode_enabled(args) - and not args.update_baseline - ) - shared_baseline_payload = ( - baseline_inputs.shared_baseline_payload - if baseline_inputs.metrics_baseline_path == baseline_inputs.baseline_path - else None - ) - baseline_state = _resolve_clone_baseline_state( - args=args, - baseline_path=baseline_inputs.baseline_path, - baseline_exists=baseline_inputs.baseline_exists, - analysis=analysis_result, - shared_baseline_payload=shared_baseline_payload, - ) - metrics_baseline_state = _resolve_metrics_baseline_state( - args=args, - metrics_baseline_path=baseline_inputs.metrics_baseline_path, - metrics_baseline_exists=baseline_inputs.metrics_baseline_exists, - clone_baseline_state=baseline_state, - baseline_updated_path=baseline_state.updated_path, - analysis=analysis_result, - shared_baseline_payload=shared_baseline_payload, - ) - - cache_status, cache_schema_version = _resolve_cache_status(cache) - report_meta = cli_meta_mod.build_cli_report_meta( - codeclone_version=__version__, - scan_root=root_path, - baseline_path=baseline_inputs.baseline_path, - baseline_state=baseline_state, - cache_path=resolve_report_cache_path(cache_path), - cache_status=cache_status, - cache_schema_version=cache_schema_version, - processing_result=processing_result, - metrics_baseline_path=baseline_inputs.metrics_baseline_path, - metrics_baseline_state=metrics_baseline_state, - analysis_result=analysis_result, - args=args, - metrics_computed=_metrics_computed(args), - analysis_started_at_utc=analysis_started_at_utc, - report_generated_at_utc=report_generated_at_utc, - ) - - diff_context = _build_diff_context( - analysis=analysis_result, - baseline_path=baseline_inputs.baseline_path, - baseline_state=baseline_state, - metrics_baseline_state=metrics_baseline_state, - ) - summary_counts = build_summary_counts( - discovery_result=discovery_result, - processing_result=processing_result, - ) - if not _controller_query_mode(args): - _print_summary( - console=_console(), - quiet=args.quiet, - files_found=discovery_result.files_found, - files_analyzed=processing_result.files_analyzed, - cache_hits=discovery_result.cache_hits, - files_skipped=processing_result.files_skipped, - analyzed_lines=summary_counts["analyzed_lines"], - analyzed_functions=summary_counts["analyzed_functions"], - analyzed_methods=summary_counts["analyzed_methods"], - analyzed_classes=summary_counts["analyzed_classes"], - func_clones_count=analysis_result.func_clones_count, - block_clones_count=analysis_result.block_clones_count, - segment_clones_count=analysis_result.segment_clones_count, - suppressed_golden_fixture_groups=len( - getattr(analysis_result, "suppressed_clone_groups", ()) - ), - suppressed_segment_groups=analysis_result.suppressed_segment_groups, - new_clones_count=diff_context.new_clones_count, + source_read_contract_failure = ( + bool(processing_result.source_read_failures) + and gating_mode_enabled(args) + and not args.update_baseline + ) + shared_baseline_payload = ( + baseline_inputs.shared_baseline_payload + if baseline_inputs.metrics_baseline_path == baseline_inputs.baseline_path + else None ) - print_metrics_if_available( + baseline_state = _resolve_clone_baseline_state( args=args, + baseline_path=baseline_inputs.baseline_path, + baseline_exists=baseline_inputs.baseline_exists, analysis=analysis_result, - metrics_diff=diff_context.metrics_diff, - api_surface_diff_available=diff_context.api_surface_diff_available, - console=_console(), - build_metrics_snapshot_fn=build_metrics_snapshot, - print_metrics_fn=_print_metrics, + shared_baseline_payload=shared_baseline_payload, + ) + metrics_baseline_state = _resolve_metrics_baseline_state( + args=args, + metrics_baseline_path=baseline_inputs.metrics_baseline_path, + metrics_baseline_exists=baseline_inputs.metrics_baseline_exists, + clone_baseline_state=baseline_state, + baseline_updated_path=baseline_state.updated_path, + analysis=analysis_result, + shared_baseline_payload=shared_baseline_payload, ) - report_artifacts = report( - boot=boot, - discovery=discovery_result, - processing=processing_result, - analysis=analysis_result, - report_meta=report_meta, - new_func=diff_context.new_func, - new_block=diff_context.new_block, - html_builder=build_html_report, - metrics_diff=diff_context.metrics_diff, - coverage_adoption_diff_available=diff_context.coverage_adoption_diff_available, - api_surface_diff_available=diff_context.api_surface_diff_available, - include_report_document=bool(changed_paths) or _controller_query_mode(args), - ) + cache_status, cache_schema_version = _resolve_cache_status(cache) + report_meta = cli_meta_mod.build_cli_report_meta( + codeclone_version=__version__, + scan_root=root_path, + baseline_path=baseline_inputs.baseline_path, + baseline_state=baseline_state, + cache_path=resolve_report_cache_path(cache_path), + cache_status=cache_status, + cache_schema_version=cache_schema_version, + processing_result=processing_result, + metrics_baseline_path=baseline_inputs.metrics_baseline_path, + metrics_baseline_state=metrics_baseline_state, + analysis_result=analysis_result, + args=args, + metrics_computed=_metrics_computed(args), + analysis_started_at_utc=analysis_started_at_utc, + report_generated_at_utc=report_generated_at_utc, + ) + + diff_context = _build_diff_context( + analysis=analysis_result, + baseline_path=baseline_inputs.baseline_path, + baseline_state=baseline_state, + metrics_baseline_state=metrics_baseline_state, + ) + summary_counts = build_summary_counts( + discovery_result=discovery_result, + processing_result=processing_result, + ) + if not _controller_query_mode(args): + _print_summary( + console=_console(), + quiet=args.quiet, + files_found=discovery_result.files_found, + files_analyzed=processing_result.files_analyzed, + cache_hits=discovery_result.cache_hits, + files_skipped=processing_result.files_skipped, + analyzed_lines=summary_counts["analyzed_lines"], + analyzed_functions=summary_counts["analyzed_functions"], + analyzed_methods=summary_counts["analyzed_methods"], + analyzed_classes=summary_counts["analyzed_classes"], + func_clones_count=analysis_result.func_clones_count, + block_clones_count=analysis_result.block_clones_count, + segment_clones_count=analysis_result.segment_clones_count, + suppressed_golden_fixture_groups=len( + getattr(analysis_result, "suppressed_clone_groups", ()) + ), + suppressed_segment_groups=analysis_result.suppressed_segment_groups, + new_clones_count=diff_context.new_clones_count, + ) + print_metrics_if_available( + args=args, + analysis=analysis_result, + metrics_diff=diff_context.metrics_diff, + api_surface_diff_available=diff_context.api_surface_diff_available, + console=_console(), + build_metrics_snapshot_fn=build_metrics_snapshot, + print_metrics_fn=_print_metrics, + ) + + with span(name="pipeline.report"): + report_artifacts = report( + boot=boot, + discovery=discovery_result, + processing=processing_result, + analysis=analysis_result, + report_meta=report_meta, + new_func=diff_context.new_func, + new_block=diff_context.new_block, + html_builder=build_html_report, + metrics_diff=diff_context.metrics_diff, + coverage_adoption_diff_available=( + diff_context.coverage_adoption_diff_available + ), + api_surface_diff_available=diff_context.api_surface_diff_available, + include_report_document=( + bool(changed_paths) or _controller_query_mode(args) + ), + ) _emit_cli_analysis_completed_if_enabled( args=args, root_path=root_path, @@ -770,7 +779,7 @@ def _emit_cli_analysis_completed_if_enabled( ) -> None: if not bool(getattr(args, "audit_enabled", False)): return - if not isinstance(report_document, dict): + if not _is_report_document(report_document): return digest = _report_digest_from_document(report_document) if not digest: @@ -795,6 +804,10 @@ def _emit_cli_analysis_completed_if_enabled( return None +def _is_report_document(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) + + def _report_digest_from_document(report_document: dict[str, object]) -> str: integrity = report_document.get("integrity") if not isinstance(integrity, dict): @@ -806,6 +819,10 @@ def _report_digest_from_document(report_document: dict[str, object]) -> str: def main() -> None: + if len(sys.argv) > 1 and sys.argv[1] == "setup": + from .setup import setup_main + + raise SystemExit(setup_main(sys.argv[2:])) if len(sys.argv) > 1 and sys.argv[1] == "analytics": from .analytics import analytics_main diff --git a/codeclone/surfaces/mcp/_claim_guard.py b/codeclone/surfaces/mcp/_claim_guard.py index 13e320c7..47641d3e 100644 --- a/codeclone/surfaces/mcp/_claim_guard.py +++ b/codeclone/surfaces/mcp/_claim_guard.py @@ -12,6 +12,7 @@ from typing import Final, Literal from ...utils import coerce as _coerce +from ...utils.payload_narrow import is_record_mapping from .messages import claims as claim_msgs MAX_REVIEW_CLAIM_TEXT_CHARS: Final = 50_000 @@ -600,7 +601,7 @@ def _extract_qualnames_from_finding( qualnames: set[str] = set() _collect_qualname_fields(finding, qualnames) for item in _as_sequence(finding.get("items")): - if isinstance(item, Mapping): + if is_record_mapping(item): _collect_qualname_fields(item, qualnames) if finding_id.startswith("dead_code:"): _, _, remainder = finding_id.partition(":") diff --git a/codeclone/surfaces/mcp/_context_governance.py b/codeclone/surfaces/mcp/_context_governance.py index 35d852ce..f358b01d 100644 --- a/codeclone/surfaces/mcp/_context_governance.py +++ b/codeclone/surfaces/mcp/_context_governance.py @@ -20,6 +20,8 @@ import orjson +from ...utils.payload_narrow import is_record_mapping + CONTEXT_GOVERNANCE_CONTRACT_VERSION: Final = "1.0" CONTEXT_GOVERNANCE_DIGEST_VERSION: Final = "1" CONTEXT_GOVERNANCE_ESTIMATOR: Final = "utf8_bytes_div_4_v1" @@ -313,8 +315,7 @@ def _attach_context_governance( "capabilities": passive_context_capabilities(), "drill_down": passive_drill_down_reachability(), } - if evidence_omitted: - context_governance["omitted"] = dict(evidence_omitted) + _attach_omission_context(result, context_governance, evidence_omitted) context_governance.update(response_context) result["context_governance"] = context_governance governance = result["context_governance"] @@ -325,6 +326,135 @@ def _attach_context_governance( return result +def _attach_omission_context( + payload: dict[str, object], + context_governance: dict[str, object], + evidence_omitted: Mapping[str, object] | None, +) -> None: + if not evidence_omitted: + return + context_governance["omitted"] = dict(evidence_omitted) + continuation = _continuation_payload(payload, evidence_omitted) + if continuation: + payload["_continuation"] = continuation + + +def _continuation_payload( + payload: Mapping[str, object], + evidence_omitted: Mapping[str, object], +) -> dict[str, object] | None: + lanes = [ + lane_payload + for lane, omission in evidence_omitted.items() + for lane_payload in ( + _continuation_lane( + payload, + lane=str(lane), + omission=omission, + ), + ) + if lane_payload + ] + if not lanes: + return None + return { + "required": True, + "lanes": lanes, + } + + +def _continuation_lane( + payload: Mapping[str, object], + *, + lane: str, + omission: object, +) -> dict[str, object] | None: + if not is_record_mapping(omission): + return None + omission_mapping = omission + lane_payload: dict[str, object] = { + "lane": lane, + "reason": str(omission_mapping.get("reason", "omitted_by_response_budget")), + } + for key in ("shown", "total", "omitted", "facet", "offset", "limit"): + if key in omission_mapping: + lane_payload[key] = omission_mapping[key] + + retrieval = _as_mapping_or_none(omission_mapping.get("retrieval")) + if retrieval is not None: + _merge_simple_keys( + lane_payload, + retrieval, + ( + "tool", + "route", + "run_id", + "artifact_id", + "blast_artifact_id", + "projection_digest", + "context_projection_digest", + "patch_trail_digest", + "receipt_digest", + "facet", + "offset", + "page_size", + ), + ) + + drill_down = _as_mapping_or_none(omission_mapping.get("drill_down")) + if drill_down is not None: + _merge_simple_keys( + lane_payload, + drill_down, + ( + "tool", + "route", + "cursor_path", + "snapshot_identity", + "facet", + "offset", + "page_size", + ), + ) + cursor_path = str(drill_down.get("cursor_path", "")).strip() + if cursor_path: + cursor = _resolve_dotted_path(payload, cursor_path) + if cursor not in (None, ""): + lane_payload["cursor"] = cursor + + return lane_payload + + +def _merge_simple_keys( + target: dict[str, object], + source: Mapping[str, object], + keys: tuple[str, ...], +) -> None: + for key in keys: + value = source.get(key) + if value not in (None, "", [], {}): + target[key] = value + + +def _as_mapping_or_none(value: object) -> Mapping[str, object] | None: + if is_record_mapping(value): + return value + return None + + +def _resolve_dotted_path(payload: Mapping[str, object], dotted_path: str) -> object: + current: object = payload + for raw_part in dotted_path.split("."): + part = raw_part.strip() + if not part: + return None + if isinstance(current, Mapping): + current = current.get(part) + continue + return None + return current + + def attach_finish_context_governance( payload: Mapping[str, object], *, diff --git a/codeclone/surfaces/mcp/_implementation_context.py b/codeclone/surfaces/mcp/_implementation_context.py index c280be91..ca8a9f03 100644 --- a/codeclone/surfaces/mcp/_implementation_context.py +++ b/codeclone/surfaces/mcp/_implementation_context.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass, field from pathlib import Path -from typing import Final +from typing import Final, cast import orjson @@ -660,6 +660,14 @@ def build_implementation_context( "failed_files": failed_files, } unavailable_facets = sorted(include_set - IMPLEMENTED_CONTEXT_FACETS) + budget_summary: dict[str, object] = { + "requested": budget, + "effective": entry_budget.limit, + "emitted": entry_budget.used, + "remaining": entry_budget.remaining, + "hard_cap": MAX_CONTEXT_TOTAL_ITEMS, + "safety": safety_summary, + } payload: dict[str, object] = { "status": ( "safety_context_overflow" @@ -674,14 +682,7 @@ def build_implementation_context( "subject": subject, "analysis": analysis, "structural_context": structural_context, - "budget_summary": { - "requested": budget, - "effective": entry_budget.limit, - "emitted": entry_budget.used, - "remaining": entry_budget.remaining, - "hard_cap": MAX_CONTEXT_TOTAL_ITEMS, - "safety": safety_summary, - }, + "budget_summary": budget_summary, "dataflow": { "writers": {"status": "not_available", "tier": "dataflow"}, "readers": {"status": "not_available", "tier": "dataflow"}, @@ -705,10 +706,8 @@ def build_implementation_context( payload["change_control"] = projected_change_control if unavailable_facets: payload["unavailable_facets"] = unavailable_facets - budget_summary = _as_mapping(payload["budget_summary"]) - if isinstance(budget_summary, dict): - budget_summary["emitted"] = entry_budget.used - budget_summary["remaining"] = entry_budget.remaining + budget_summary["emitted"] = entry_budget.used + budget_summary["remaining"] = entry_budget.remaining _attach_projection_digest( payload, analysis, @@ -873,18 +872,20 @@ def resolve_context_symbols( by_qualname: dict[str, list[dict[str, object]]] = {} for row in _unit_location_index(record): by_qualname.setdefault(str(row["qualname"]), []).append(row) - resolved = tuple( - { - "qualname": symbol, - "path": str(row["path"]), - "start_line": _as_int(row["start_line"]), - "end_line": _as_int(row.get("end_line")), - "tier": "structural", - "source": str(row["source"]), - } - for symbol in requested - for row in by_qualname.get(symbol, ()) - ) + resolved_rows: list[dict[str, object]] = [] + for symbol in requested: + resolved_rows.extend( + { + "qualname": symbol, + "path": str(row["path"]), + "start_line": _as_int(row["start_line"]), + "end_line": _as_int(row.get("end_line")), + "tier": "structural", + "source": str(row["source"]), + } + for row in by_qualname.get(symbol, ()) + ) + resolved = tuple(resolved_rows) unresolved = tuple(symbol for symbol in requested if symbol not in by_qualname) return resolved, unresolved @@ -1093,14 +1094,15 @@ def _append_related_relation( "path": path or None, "module": module, "source_kind": source_kind, - "relations": [], + "relations": list[dict[str, object]](), "relevance_rank": 3, }, ) - relations = row["relations"] - if not isinstance(relations, list): + relations_raw = row["relations"] + if not isinstance(relations_raw, list): return - normalized_relation = dict(relation) + relations = cast(list[dict[str, object]], relations_raw) + normalized_relation: dict[str, object] = dict(relation) if normalized_relation not in relations: relations.append(normalized_relation) relations.sort( diff --git a/codeclone/surfaces/mcp/_report_section.py b/codeclone/surfaces/mcp/_report_section.py index 7dbf4f80..ca343c9e 100644 --- a/codeclone/surfaces/mcp/_report_section.py +++ b/codeclone/surfaces/mcp/_report_section.py @@ -14,6 +14,7 @@ from ...domain.findings import FAMILY_CLONES, FAMILY_DEAD_CODE, FAMILY_STRUCTURAL from ...utils.coerce import as_mapping as _as_mapping from ...utils.coerce import as_sequence as _as_sequence +from ...utils.payload_narrow import is_record_mapping from ._session_shared import MCPServiceContractError from .payloads import paginate @@ -161,7 +162,7 @@ def require_mapping_section( section: str, ) -> Mapping[str, object]: payload = report_document.get(section) - if not isinstance(payload, Mapping): + if not is_record_mapping(payload): raise MCPServiceContractError( f"Report section '{section}' is not available in this run." ) diff --git a/codeclone/surfaces/mcp/_review_receipt.py b/codeclone/surfaces/mcp/_review_receipt.py index 394378bf..1dd931e2 100644 --- a/codeclone/surfaces/mcp/_review_receipt.py +++ b/codeclone/surfaces/mcp/_review_receipt.py @@ -13,6 +13,7 @@ from ...contracts import REPORT_SCHEMA_VERSION from ...utils.coerce import as_mapping as _as_mapping from ...utils.coerce import as_sequence as _as_sequence +from ...utils.payload_narrow import is_record_mapping from ._verification_profile import ( check_matrix, classify_patch, @@ -396,7 +397,7 @@ def _signed_delta(value: object) -> str: def _optional_mapping(value: object) -> Mapping[str, object] | None: - return value if isinstance(value, Mapping) else None + return value if is_record_mapping(value) else None def _mapping_rows(value: object) -> list[Mapping[str, object]]: diff --git a/codeclone/surfaces/mcp/_session_audit_artifact_mixin.py b/codeclone/surfaces/mcp/_session_audit_artifact_mixin.py index e4fa7e04..f019139e 100644 --- a/codeclone/surfaces/mcp/_session_audit_artifact_mixin.py +++ b/codeclone/surfaces/mcp/_session_audit_artifact_mixin.py @@ -36,7 +36,9 @@ ) from ...config.memory import resolve_memory_config from ...memory.project import resolve_memory_db_path, resolve_project_identity -from ...memory.sqlite_store import SqliteEngineeringMemoryStore +from ...memory.schema import open_memory_db_readonly +from ...memory.trajectory.store import find_trajectory_patch_trails_for_lookup +from ...utils.payload_narrow import is_record_mapping from ._context_governance import ( BLAST_ARTIFACT_RETRIEVAL_RESPONSE_PROJECTION_KIND, PATCH_TRAIL_RETRIEVAL_RESPONSE_PROJECTION_KIND, @@ -386,9 +388,10 @@ def _memory_patch_trail_lookup( if not db_path.exists(): return "not_found", 0, None project = resolve_project_identity(root_path) - store = SqliteEngineeringMemoryStore(db_path) + conn = open_memory_db_readonly(db_path) try: - payloads, malformed = store.find_trajectory_patch_trails_for_lookup( + payloads, malformed = find_trajectory_patch_trails_for_lookup( + conn, project_id=project.id, patch_trail_digest=patch_trail_digest, run_id=run_id, @@ -401,7 +404,8 @@ def _memory_patch_trail_lookup( if len(payloads) > 1: return "ambiguous", len(payloads), None if patch_trail_digest is not None and run_id is not None: - run_payloads, run_malformed = store.find_trajectory_patch_trails_for_lookup( + run_payloads, run_malformed = find_trajectory_patch_trails_for_lookup( + conn, project_id=project.id, run_id=run_id, ) @@ -412,14 +416,14 @@ def _memory_patch_trail_lookup( return "malformed_stored_patch_trail", 0, None return "not_found", 0, None finally: - store.close() + conn.close() def _stored_patch_trail_from_memory( row: Mapping[str, object], ) -> StoredPatchTrail | None: payload = row.get("payload") - if not isinstance(payload, Mapping): + if not is_record_mapping(payload): return None digest = str(row.get("patch_trail_digest", "")).strip() if not digest: @@ -431,7 +435,7 @@ def _stored_patch_trail_from_memory( verification_status=_str_or_none(payload.get("verification_status")), schema_version=_str_or_none(payload.get("schema_version")), created_at_utc=str(row.get("created_at_utc", "")).strip(), - payload=dict(payload), + payload=payload, ) @@ -488,8 +492,8 @@ def _stored_receipt_markdown(receipt: StoredReviewReceipt) -> str: if isinstance(content, str) and content: return content typed = receipt.payload.get("receipt") - if isinstance(typed, Mapping): - return render_receipt_markdown(dict(typed)) + if is_record_mapping(typed): + return render_receipt_markdown(typed) return "" diff --git a/codeclone/surfaces/mcp/_session_blast_radius_mixin.py b/codeclone/surfaces/mcp/_session_blast_radius_mixin.py index 39c01a61..408e2c19 100644 --- a/codeclone/surfaces/mcp/_session_blast_radius_mixin.py +++ b/codeclone/surfaces/mcp/_session_blast_radius_mixin.py @@ -7,6 +7,7 @@ from __future__ import annotations from collections.abc import Sequence +from typing import cast from . import _session_helpers as _helpers from ._blast_radius import ( @@ -19,16 +20,29 @@ blast_radius_to_payload, compute_blast_radius, ) +from ._session_finding_mixin import _MCPSessionFindingMixin, _StateLock from ._session_shared import ( CodeCloneMCPRunStore, MCPRunRecord, MCPServiceContractError, ) +MAX_BLAST_RADIUS_CACHE_ENTRIES = 64 + + +def _finding_session( + session: _MCPSessionBlastRadiusMixin, +) -> _MCPSessionFindingMixin: + return cast(_MCPSessionFindingMixin, session) + class _MCPSessionBlastRadiusMixin: _runs: CodeCloneMCPRunStore - _blast_radius_cache: dict[tuple[str, tuple[str, ...], str], BlastRadiusResult] + _state_lock: _StateLock + _blast_radius_cache: dict[ + tuple[str, tuple[str, ...], str, tuple[str, ...], tuple[str, ...]], + BlastRadiusResult, + ] def get_blast_radius( self, @@ -40,7 +54,7 @@ def get_blast_radius( ) -> dict[str, object]: record = self._runs.get(run_id) normalized_depth = self._validated_blast_radius_depth(depth) - normalized_files = self._normalize_changed_paths( + normalized_files = _finding_session(self)._normalize_changed_paths( root_path=record.root, paths=files, ) @@ -66,27 +80,37 @@ def _blast_radius_result( allowed_scope: Sequence[str] = (), ) -> BlastRadiusResult: normalized_files = tuple(sorted(set(files))) - cache_key = (record.run_id, normalized_files, depth) - cacheable = ( - not allowed_scope - and tuple(forbidden_patterns) == DEFAULT_DO_NOT_TOUCH_PATTERNS + default_forbidden = set(DEFAULT_DO_NOT_TOUCH_PATTERNS) + normalized_forbidden = tuple( + sorted(set(forbidden_patterns).difference(default_forbidden)) + ) + normalized_allowed_scope = tuple(sorted(set(allowed_scope))) + cache_key = ( + record.run_id, + normalized_files, + depth, + normalized_forbidden, + normalized_allowed_scope, ) - if cacheable: - with self._state_lock: - cached = self._blast_radius_cache.get(cache_key) + with self._state_lock: + cached = self._blast_radius_cache.get(cache_key) if cached is not None: - return cached + self._blast_radius_cache.pop(cache_key, None) + self._blast_radius_cache[cache_key] = cached + if cached is not None: + return cached result = compute_blast_radius( run_id=_helpers._short_run_id(record.run_id), report_document=record.report_document, files=normalized_files, depth=depth, - forbidden_patterns=forbidden_patterns, - allowed_scope=allowed_scope, + forbidden_patterns=normalized_forbidden, + allowed_scope=normalized_allowed_scope, ) - if cacheable: - with self._state_lock: - self._blast_radius_cache[cache_key] = result + with self._state_lock: + while len(self._blast_radius_cache) >= MAX_BLAST_RADIUS_CACHE_ENTRIES: + self._blast_radius_cache.pop(next(iter(self._blast_radius_cache))) + self._blast_radius_cache[cache_key] = result return result def _validated_blast_radius_depth(self, depth: str) -> BlastRadiusDepth: diff --git a/codeclone/surfaces/mcp/_session_claim_guard_mixin.py b/codeclone/surfaces/mcp/_session_claim_guard_mixin.py index 14df69f4..32baaeee 100644 --- a/codeclone/surfaces/mcp/_session_claim_guard_mixin.py +++ b/codeclone/surfaces/mcp/_session_claim_guard_mixin.py @@ -6,6 +6,8 @@ from __future__ import annotations +from typing import cast + from ...audit import EVENT_CLAIM_COMPLETED, EVENT_CLAIM_VIOLATED from ...metrics.registry import METRIC_FAMILIES from . import _session_helpers as _helpers @@ -14,11 +16,31 @@ validate_claims, validate_text_input, ) -from ._session_shared import MCPRunRecord, MCPServiceContractError +from ._session_finding_mixin import _MCPSessionFindingMixin +from ._session_intent_mixin import _MCPSessionIntentMixin +from ._session_shared import ( + CodeCloneMCPRunStore, + MCPRunRecord, + MCPServiceContractError, +) from ._verification_profile import classify_patch +def _intent_session( + session: _MCPSessionClaimGuardMixin, +) -> _MCPSessionIntentMixin: + return cast(_MCPSessionIntentMixin, session) + + +def _finding_session( + session: _MCPSessionClaimGuardMixin, +) -> _MCPSessionFindingMixin: + return cast(_MCPSessionFindingMixin, session) + + class _MCPSessionClaimGuardMixin: + _runs: CodeCloneMCPRunStore + def validate_review_claims( self, *, @@ -43,12 +65,13 @@ def validate_review_claims( ) result = {"run_id": _helpers._short_run_id(record.run_id), **payload} valid = bool(result.get("valid")) - self._audit_emit( + intent_session = _intent_session(self) + intent_session._audit_emit( root=record.root, event_type=EVENT_CLAIM_COMPLETED if valid else EVENT_CLAIM_VIOLATED, severity="info" if valid else "warn", run_id=_helpers._short_run_id(record.run_id), - report_digest=self._report_digest_value(record), + report_digest=intent_session._report_digest_value(record), status="valid" if valid else "violated", payload=result, ) @@ -60,10 +83,13 @@ def _claim_guard_context( *, patch_health_delta: int | None = None, ) -> ReportContext: - _canonical_to_short, short_to_canonical = self._finding_id_maps(record) + finding_session = _finding_session(self) + _canonical_to_short, short_to_canonical = finding_session._finding_id_maps( + record + ) findings = { canonical_id: dict(finding) - for finding in self._base_findings(record) + for finding in finding_session._base_findings(record) if (canonical_id := str(finding.get("id", "")).strip()) } changed_paths = list(record.changed_paths) @@ -81,7 +107,8 @@ def _claim_guard_context( if not family.gate_keys ) ), - has_comparison_run=self._previous_run_for_root(record) is not None, + has_comparison_run=finding_session._previous_run_for_root(record) + is not None, metric_families=frozenset(sorted(METRIC_FAMILIES)), verification_profile=profile_value, patch_health_delta=patch_health_delta, diff --git a/codeclone/surfaces/mcp/_session_context_mixin.py b/codeclone/surfaces/mcp/_session_context_mixin.py index d6f266a3..feaa9018 100644 --- a/codeclone/surfaces/mcp/_session_context_mixin.py +++ b/codeclone/surfaces/mcp/_session_context_mixin.py @@ -12,8 +12,9 @@ from copy import deepcopy from dataclasses import dataclass from pathlib import Path -from typing import Final, Protocol, cast +from typing import Final, Protocol, TypeGuard, cast +from ...utils.payload_narrow import is_payload_dict, is_record_mapping from ...utils.repo_paths import RepoPathError, resolve_repo_relative_path from . import _session_helpers as _helpers from ._blast_radius import BlastRadiusResult, blast_radius_to_payload @@ -295,16 +296,20 @@ def _shrink_context_lane(payload: dict[str, object], lane: _ContextLaneRef) -> N } +def _is_object_list(value: object) -> TypeGuard[list[object]]: + return isinstance(value, list) + + def _context_lane_container( payload: Mapping[str, object], lane: _ContextLaneRef, ) -> dict[str, object] | None: current: object = payload for key in lane[0]: - if not isinstance(current, Mapping): + if not is_record_mapping(current): return None current = current.get(key) - return current if isinstance(current, dict) else None + return current if is_payload_dict(current) else None def _context_lane_items( @@ -315,7 +320,9 @@ def _context_lane_items( if container is None: return None items = container.get(lane[1]) - return items if isinstance(items, list) else None + if not _is_object_list(items): + return None + return items def _context_omitted_key(lane: _ContextLaneRef) -> str: diff --git a/codeclone/surfaces/mcp/_session_finding_mixin.py b/codeclone/surfaces/mcp/_session_finding_mixin.py index 81033a52..575fa0ae 100644 --- a/codeclone/surfaces/mcp/_session_finding_mixin.py +++ b/codeclone/surfaces/mcp/_session_finding_mixin.py @@ -279,13 +279,13 @@ def _resolve_optional_root(self, root: str | None) -> Path | None: return None return _helpers._resolve_root(cleaned_root) - def _finding_id_maps( + def _finding_id_maps_for_findings( self, - record: MCPRunRecord, + findings: Sequence[Mapping[str, object]], ) -> tuple[dict[str, str], dict[str, str]]: canonical_ids = sorted( str(finding.get("id", "")) - for finding in self._base_findings(record) + for finding in findings if str(finding.get("id", "")) ) base_ids = { @@ -309,12 +309,21 @@ def _finding_id_maps( short_to_canonical[disambiguated] = canonical_id return canonical_to_short, short_to_canonical + def _finding_id_maps( + self, + record: MCPRunRecord, + ) -> tuple[dict[str, str], dict[str, str]]: + return self._finding_id_maps_for_findings(self._base_findings(record)) + def _short_finding_id( self, record: MCPRunRecord, canonical_id: str, + *, + canonical_to_short: Mapping[str, str] | None = None, ) -> str: - canonical_to_short, _short_to_canonical = self._finding_id_maps(record) + if canonical_to_short is None: + canonical_to_short, _short_to_canonical = self._finding_id_maps(record) return canonical_to_short.get(canonical_id, canonical_id) def _resolve_canonical_finding_id( @@ -359,7 +368,7 @@ def _base_findings(self, record: MCPRunRecord) -> list[dict[str, object]]: ], ] - def _query_findings( + def _ordered_finding_rows( self, *, record: MCPRunRecord, @@ -369,10 +378,14 @@ def _query_findings( source_kind: str | None = None, novelty: FindingNoveltyFilter = "all", sort_by: FindingSort = "default", - detail_level: DetailLevel = "normal", changed_paths: Sequence[str] = (), exclude_reviewed: bool = False, - ) -> list[dict[str, object]]: + ) -> tuple[ + list[dict[str, object]], + int, + dict[str, dict[str, object] | None], + dict[str, Mapping[str, object]], + ]: findings = self._base_findings(record) max_spread_value = max( (self._spread_value(finding) for finding in findings), @@ -400,33 +413,65 @@ def _query_findings( ) and (not exclude_reviewed or not self._finding_is_reviewed(record, finding)) ] - remediation_map = { - str(finding.get("id", "")): self._remediation_for_finding(record, finding) - for finding in filtered - } - priority_map = { - str(finding.get("id", "")): self._priority_score( - record, - finding, - remediation=remediation_map[str(finding.get("id", ""))], - max_spread_value=max_spread_value, - ) - for finding in filtered - } + remediation_map: dict[str, dict[str, object] | None] = {} + priority_map: dict[str, Mapping[str, object]] = {} + if sort_by == "priority": + for finding in filtered: + finding_id = str(finding.get("id", "")) + remediation = self._remediation_for_finding(record, finding) + remediation_map[finding_id] = remediation + priority_map[finding_id] = self._priority_score( + record, + finding, + remediation=remediation, + max_spread_value=max_spread_value, + ) ordered = self._sort_findings( record=record, findings=filtered, sort_by=sort_by, - priority_map=priority_map, + priority_map=priority_map or None, + ) + return ordered, max_spread_value, remediation_map, priority_map + + def _query_findings( + self, + *, + record: MCPRunRecord, + family: FindingFamilyFilter = "all", + category: str | None = None, + severity: str | None = None, + source_kind: str | None = None, + novelty: FindingNoveltyFilter = "all", + sort_by: FindingSort = "default", + detail_level: DetailLevel = "normal", + changed_paths: Sequence[str] = (), + exclude_reviewed: bool = False, + ) -> list[dict[str, object]]: + ordered, max_spread_value, remediation_map, priority_map = ( + self._ordered_finding_rows( + record=record, + family=family, + category=category, + severity=severity, + source_kind=source_kind, + novelty=novelty, + sort_by=sort_by, + changed_paths=changed_paths, + exclude_reviewed=exclude_reviewed, + ) ) + canonical_to_short, _short_to_canonical = self._finding_id_maps(record) return [ self._decorate_finding( record, finding, detail_level=detail_level, - remediation=remediation_map[str(finding.get("id", ""))], - priority_payload=priority_map[str(finding.get("id", ""))], + remediation=remediation_map.get(str(finding.get("id", ""))), + remediation_computed=str(finding.get("id", "")) in remediation_map, + priority_payload=priority_map.get(str(finding.get("id", ""))), max_spread_value=max_spread_value, + canonical_to_short=canonical_to_short, ) for finding in ordered ] @@ -484,12 +529,14 @@ def _decorate_finding( *, detail_level: DetailLevel, remediation: Mapping[str, object] | None = None, + remediation_computed: bool = False, priority_payload: Mapping[str, object] | None = None, max_spread_value: int | None = None, + canonical_to_short: Mapping[str, str] | None = None, ) -> dict[str, object]: resolved_remediation = ( remediation - if remediation is not None + if remediation_computed or remediation is not None else self._remediation_for_finding(record, finding) ) resolved_priority_payload = ( @@ -503,6 +550,14 @@ def _decorate_finding( ) ) payload = dict(finding) + canonical_id = str(finding.get("id", "")).strip() + short_finding_id = self._short_finding_id( + record, + canonical_id, + canonical_to_short=canonical_to_short, + ) + payload["canonical_id"] = canonical_id + payload["short_id"] = short_finding_id payload["priority_score"] = resolved_priority_payload["score"] payload["priority_factors"] = resolved_priority_payload["factors"] payload["locations"] = self._locations_for_finding( @@ -510,7 +565,8 @@ def _decorate_finding( finding, include_uri=detail_level == "full", ) - payload["html_anchor"] = f"finding-{finding.get('id', '')}" + payload["html_anchor"] = f"finding-{canonical_id}" + payload["novelty"] = self._finding_novelty(finding) if resolved_remediation is not None: payload["remediation"] = resolved_remediation return self._project_finding_detail( @@ -526,18 +582,29 @@ def _project_finding_detail( *, detail_level: DetailLevel, ) -> dict[str, object]: + canonical_id = str(finding.get("canonical_id") or finding.get("id", "")).strip() + short_finding_id = str( + finding.get("short_id") or self._short_finding_id(record, canonical_id) + ) + html_anchor = str( + finding.get("html_anchor") or f"finding-{canonical_id}" + ).strip() if detail_level == "full": full_payload = dict(finding) - full_payload["id"] = self._short_finding_id( - record, - str(finding.get("id", "")), - ) + full_payload["id"] = short_finding_id + full_payload["short_id"] = short_finding_id + full_payload["canonical_id"] = canonical_id + full_payload["html_anchor"] = html_anchor + full_payload["novelty"] = self._finding_novelty(finding) return full_payload payload: dict[str, object] = { - "id": self._short_finding_id(record, str(finding.get("id", ""))), + "id": short_finding_id, + "short_id": short_finding_id, + "canonical_id": canonical_id, + "html_anchor": html_anchor, "kind": _helpers._finding_kind_label(finding), "severity": str(finding.get("severity", "")), - "novelty": str(finding.get("novelty", "")), + "novelty": self._finding_novelty(finding), "scope": _helpers._finding_source_kind(finding), "count": _as_int(finding.get("count", 0), 0), "spread": dict(_helpers._as_mapping(finding.get("spread"))), @@ -635,7 +702,7 @@ def _matches_finding_filters( ).strip() if source_kind is not None and dominant_kind != source_kind: return False - return novelty == "all" or str(finding.get("novelty", "")).strip() == novelty + return novelty == "all" or self._finding_novelty(finding) == novelty def _finding_touches_paths( self, @@ -706,7 +773,7 @@ def _priority_score( 0.6, ), "novelty_weight": _NOVELTY_WEIGHT.get( - str(finding.get("novelty", "")), + self._finding_novelty(finding), 0.7, ), "runtime_weight": _RUNTIME_WEIGHT.get( @@ -765,6 +832,11 @@ def _spread_value(self, finding: Mapping[str, object]) -> int: count = _as_int(finding.get("count", 0), 0) return max(files, functions, count, 1) + @staticmethod + def _finding_novelty(finding: Mapping[str, object]) -> str: + novelty = str(finding.get("novelty", "")).strip() + return novelty or "known" + def _locations_for_finding( self, record: MCPRunRecord, @@ -823,7 +895,7 @@ def _remediation_for_finding( spread_functions = _as_int(getattr(suggestion, "spread_functions", 0), 0) title = str(getattr(suggestion, "title", "")).strip() severity = str(finding.get("severity", "")).strip() - novelty = str(finding.get("novelty", "known")).strip() + novelty = self._finding_novelty(finding) count = _as_int( getattr(suggestion, "fact_count", 0) or finding.get("count", 0) or 0, 0, @@ -875,30 +947,61 @@ def _hotspot_rows( changed_paths: Sequence[str], exclude_reviewed: bool, ) -> list[dict[str, object]]: + selection = self._hotspot_selection( + record=record, + kind=kind, + changed_paths=changed_paths, + exclude_reviewed=exclude_reviewed, + limit=None, + ) + return self._decorate_hotspot_selection( + record=record, + selection=selection, + detail_level=detail_level, + ) + + def _hotspot_selection( + self, + *, + record: MCPRunRecord, + kind: HotlistKind, + changed_paths: Sequence[str], + exclude_reviewed: bool, + limit: int | None, + ) -> tuple[ + list[dict[str, object]], + int, + int, + dict[str, dict[str, object] | None], + dict[str, Mapping[str, object]], + dict[str, str], + ]: findings = self._base_findings(record) finding_index = {str(finding.get("id", "")): finding for finding in findings} + canonical_to_short, _short_to_canonical = self._finding_id_maps_for_findings( + findings + ) max_spread_value = max( (self._spread_value(finding) for finding in findings), default=0, ) with self._state_lock: self._spread_max_cache[record.run_id] = max_spread_value - remediation_map = { - str(finding.get("id", "")): self._remediation_for_finding(record, finding) - for finding in findings - } - priority_map = { - str(finding.get("id", "")): self._priority_score( - record, - finding, - remediation=remediation_map[str(finding.get("id", ""))], - max_spread_value=max_spread_value, - ) - for finding in findings - } + remediation_map: dict[str, dict[str, object] | None] = {} + priority_map: dict[str, Mapping[str, object]] = {} derived = _helpers._as_mapping(record.report_document.get("derived")) hotlists = _helpers._as_mapping(derived.get("hotlists")) if kind == "highest_priority": + for finding in findings: + finding_id = str(finding.get("id", "")) + remediation = self._remediation_for_finding(record, finding) + remediation_map[finding_id] = remediation + priority_map[finding_id] = self._priority_score( + record, + finding, + remediation=remediation, + max_spread_value=max_spread_value, + ) ordered_ids = [ str(finding.get("id", "")) for finding in self._sort_findings( @@ -911,35 +1014,127 @@ def _hotspot_rows( else: hotlist_key = _HOTLIST_REPORT_KEYS.get(kind) if hotlist_key is None: - return [] + return ( + [], + 0, + max_spread_value, + remediation_map, + priority_map, + canonical_to_short, + ) ordered_ids = [ str(item) for item in _helpers._as_sequence(hotlists.get(hotlist_key)) if str(item) ] - rows: list[dict[str, object]] = [] + selected: list[dict[str, object]] = [] + total = 0 for finding_id in ordered_ids: - finding = finding_index.get(finding_id) - if finding is None or not self._include_hotspot_finding( + finding_row = finding_index.get(finding_id) + if finding_row is None or not self._include_hotspot_finding( record=record, - finding=finding, + finding=finding_row, changed_paths=changed_paths, exclude_reviewed=exclude_reviewed, ): continue - finding_id_key = str(finding.get("id", "")) + total += 1 + if limit is None or len(selected) < limit: + selected.append(dict(finding_row)) + return ( + selected, + total, + max_spread_value, + remediation_map, + priority_map, + canonical_to_short, + ) + + def _decorate_hotspot_selection( + self, + *, + record: MCPRunRecord, + selection: tuple[ + list[dict[str, object]], + int, + int, + dict[str, dict[str, object] | None], + dict[str, Mapping[str, object]], + dict[str, str], + ], + detail_level: DetailLevel, + ) -> list[dict[str, object]]: + ( + findings, + _total, + max_spread_value, + remediation_map, + priority_map, + canonical_to_short, + ) = selection + rows: list[dict[str, object]] = [] + for finding in findings: + finding_id = str(finding.get("id", "")) rows.append( self._decorate_finding( record, finding, detail_level=detail_level, - remediation=remediation_map[finding_id_key], - priority_payload=priority_map[finding_id_key], + remediation=remediation_map.get(finding_id), + remediation_computed=finding_id in remediation_map, + priority_payload=priority_map.get(finding_id), max_spread_value=max_spread_value, + canonical_to_short=canonical_to_short, ) ) return rows + def _hotspot_empty_reason( + self, + *, + record: MCPRunRecord, + kind: HotlistKind, + detail_level: DetailLevel, + changed_paths: Sequence[str], + exclude_reviewed: bool, + ) -> str: + findings = self._base_findings(record) + if not findings: + return "no_findings_in_run" + if changed_paths: + return "changed_paths_filter_excluded_all" + if exclude_reviewed: + rows_with_reviewed = self._hotspot_rows( + record=record, + kind=kind, + detail_level=detail_level, + changed_paths=(), + exclude_reviewed=False, + ) + if rows_with_reviewed: + return "all_items_reviewed" + if kind == "highest_priority": + return "no_ranked_findings" + + hotlist_key = _HOTLIST_REPORT_KEYS.get(kind) + if hotlist_key is None: + return "unsupported_hotlist_kind" + derived = _helpers._as_mapping(record.report_document.get("derived")) + hotlists = _helpers._as_mapping(derived.get("hotlists")) + hotlist_ids = [ + str(item) + for item in _helpers._as_sequence(hotlists.get(hotlist_key)) + if str(item) + ] + if not hotlist_ids: + return { + "most_actionable": "no_items_above_actionability_threshold", + "highest_spread": "no_spread_hotspots", + "production_hotspots": "no_production_hotspots", + "test_fixture_hotspots": "no_test_fixture_hotspots", + }.get(kind, "hotlist_unpopulated") + return "hotlist_items_filtered_or_unavailable" + def _granular_payload( self, *, @@ -1071,17 +1266,24 @@ def _triage_suggestion_rows(self, record: MCPRunRecord) -> list[dict[str, object ) for suggestion in record.suggestions } + canonical_to_short, short_to_canonical = self._finding_id_maps(record) rows: list[dict[str, object]] = [] for row in canonical_rows: canonical_finding_id = str(row.get("finding_id", "")) action = _helpers._as_mapping(row.get("action")) - try: + resolved_canonical_id = resolve_finding_id( + canonical_to_short=canonical_to_short, + short_to_canonical=short_to_canonical, + finding_id=canonical_finding_id, + ) + if resolved_canonical_id is None: + finding_id = _helpers._base_short_finding_id(canonical_finding_id) + else: finding_id = self._short_finding_id( record, - self._resolve_canonical_finding_id(record, canonical_finding_id), + resolved_canonical_id, + canonical_to_short=canonical_to_short, ) - except MCPFindingNotFoundError: - finding_id = _helpers._base_short_finding_id(canonical_finding_id) rows.append( { "id": f"suggestion:{finding_id}", @@ -1151,24 +1353,39 @@ def list_findings( 1, min(max_results if max_results is not None else limit, 200), ) - filtered = self._query_findings( - record=record, - family=validated_family, - category=category, - severity=validated_severity, - source_kind=source_kind, - novelty=validated_novelty, - sort_by=validated_sort, - detail_level=validated_detail, - changed_paths=paths_filter, - exclude_reviewed=exclude_reviewed, + ordered, max_spread_value, remediation_map, priority_map = ( + self._ordered_finding_rows( + record=record, + family=validated_family, + category=category, + severity=validated_severity, + source_kind=source_kind, + novelty=validated_novelty, + sort_by=validated_sort, + changed_paths=paths_filter, + exclude_reviewed=exclude_reviewed, + ) ) page = paginate( - filtered, + ordered, offset=offset, limit=normalized_limit, max_limit=200, ) + canonical_to_short, _short_to_canonical = self._finding_id_maps(record) + items = [ + self._decorate_finding( + record, + finding, + detail_level=validated_detail, + remediation=remediation_map.get(str(finding.get("id", ""))), + remediation_computed=str(finding.get("id", "")) in remediation_map, + priority_payload=priority_map.get(str(finding.get("id", ""))), + max_spread_value=max_spread_value, + canonical_to_short=canonical_to_short, + ) + for finding in page.items + ] return { "run_id": _helpers._short_run_id(record.run_id), "detail_level": validated_detail, @@ -1179,7 +1396,7 @@ def list_findings( "returned": len(page.items), "total": page.total, "next_offset": page.next_offset, - "items": page.items, + "items": items, } def get_finding( @@ -1195,17 +1412,18 @@ def get_finding( detail_level, _VALID_DETAIL_LEVELS, ) - canonical_id = self._resolve_canonical_finding_id(record, finding_id) - for finding in self._base_findings(record): - if str(finding.get("id")) == canonical_id: - return self._decorate_finding( - record, - finding, - detail_level=validated_detail, - ) - raise MCPFindingNotFoundError( - f"Finding id '{finding_id}' was not found in run " - f"'{_helpers._short_run_id(record.run_id)}'." + finding_payload, canonical_id = self._lookup_finding_detail( + record=record, + finding_id=finding_id, + detail_level=validated_detail, + ) + if finding_payload is not None: + return finding_payload + return self._finding_not_found_payload( + record=record, + finding_id=finding_id, + detail_level=validated_detail, + canonical_id=canonical_id, ) def _service_get_finding( @@ -1215,12 +1433,71 @@ def _service_get_finding( run_id: str | None = None, detail_level: DetailLevel = "normal", ) -> dict[str, object]: - return self.get_finding( + record = self._runs.get(run_id) + validated_detail = _helpers._validate_choice( + "detail_level", + detail_level, + _VALID_DETAIL_LEVELS, + ) + finding_payload, _canonical_id = self._lookup_finding_detail( + record=record, finding_id=finding_id, - run_id=run_id, - detail_level=detail_level, + detail_level=validated_detail, + ) + if finding_payload is not None: + return finding_payload + raise MCPFindingNotFoundError( + f"Finding id '{finding_id}' was not found in run " + f"'{_helpers._short_run_id(record.run_id)}'." ) + def _lookup_finding_detail( + self, + *, + record: MCPRunRecord, + finding_id: str, + detail_level: DetailLevel, + ) -> tuple[dict[str, object] | None, str | None]: + try: + canonical_id = self._resolve_canonical_finding_id(record, finding_id) + except MCPFindingNotFoundError: + return None, None + for finding in self._base_findings(record): + if str(finding.get("id")) == canonical_id: + return ( + self._decorate_finding( + record, + finding, + detail_level=detail_level, + ), + canonical_id, + ) + return None, canonical_id + + def _finding_not_found_payload( + self, + *, + record: MCPRunRecord, + finding_id: str, + detail_level: DetailLevel, + canonical_id: str | None = None, + ) -> dict[str, object]: + payload: dict[str, object] = { + "status": "not_found", + "run_id": _helpers._short_run_id(record.run_id), + "finding_id": finding_id, + "detail_level": detail_level, + "accepted_id_forms": ["short_id", "canonical_id"], + "next_tool": "list_hotspots", + "message": ( + "Finding id was not found in this MCP run. Use list_hotspots, " + "list_findings, or a focused check_* tool to obtain current ids." + ), + } + if canonical_id: + payload["canonical_id"] = canonical_id + return payload + def get_remediation( self, *, @@ -1284,28 +1561,62 @@ def list_hotspots( changed_paths=changed_paths, git_diff_ref=git_diff_ref, ) - rows = self._hotspot_rows( + normalized_limit = max( + 1, + min(max_results if max_results is not None else limit, 50), + ) + selection = self._hotspot_selection( record=record, kind=validated_kind, - detail_level=validated_detail, changed_paths=paths_filter, exclude_reviewed=exclude_reviewed, + limit=normalized_limit, ) - normalized_limit = max( - 1, - min(max_results if max_results is not None else limit, 50), + rows = self._decorate_hotspot_selection( + record=record, + selection=selection, + detail_level=validated_detail, ) - return { + payload: dict[str, object] = { "run_id": _helpers._short_run_id(record.run_id), "kind": validated_kind, "detail_level": validated_detail, "changed_paths": list(paths_filter), - "returned": min(len(rows), normalized_limit), - "total": len(rows), - "items": [ - dict(_helpers._as_mapping(item)) for item in rows[:normalized_limit] - ], + "returned": len(rows), + "total": selection[1], + "items": [dict(_helpers._as_mapping(item)) for item in rows], } + self._attach_hotspot_empty_reason( + payload, + record=record, + kind=validated_kind, + detail_level=validated_detail, + changed_paths=paths_filter, + exclude_reviewed=exclude_reviewed, + rows=rows, + ) + return payload + + def _attach_hotspot_empty_reason( + self, + payload: dict[str, object], + *, + record: MCPRunRecord, + kind: HotlistKind, + detail_level: DetailLevel, + changed_paths: Sequence[str], + exclude_reviewed: bool, + rows: Sequence[Mapping[str, object]], + ) -> None: + if rows: + return + payload["empty_reason"] = self._hotspot_empty_reason( + record=record, + kind=kind, + detail_level=detail_level, + changed_paths=changed_paths, + exclude_reviewed=exclude_reviewed, + ) def mark_finding_reviewed( self, diff --git a/codeclone/surfaces/mcp/_session_helpers.py b/codeclone/surfaces/mcp/_session_helpers.py index 895f1aad..eae26bca 100644 --- a/codeclone/surfaces/mcp/_session_helpers.py +++ b/codeclone/surfaces/mcp/_session_helpers.py @@ -28,6 +28,7 @@ ) from ...models import MetricsDiff from ...utils import coerce as _coerce +from ...utils.payload_narrow import is_record_mapping from ...utils.repo_paths import ( PathOutsideRepoError, RepoPathError, @@ -67,7 +68,6 @@ _disambiguated_clone_short_ids_payload, _disambiguated_short_finding_id_payload, _leaf_symbol_name_payload, - _load_report_document_payload, _suggestion_finding_id_payload, _summarize_metrics_diff, ) @@ -181,7 +181,7 @@ def _metrics_detail_family(value: str | None) -> MetricsDetailFamily | None: def _dict_rows(value: object) -> list[dict[str, object]]: if not isinstance(value, Sequence) or isinstance(value, (str, bytes, bytearray)): return [] - return [dict(item) for item in value if isinstance(item, Mapping)] + return [dict(item) for item in value if is_record_mapping(item)] def _string_rows(value: object) -> list[str]: @@ -633,6 +633,7 @@ def _build_cache( segment_min_loc=_as_int(args.segment_min_loc, DEFAULT_SEGMENT_MIN_LOC), segment_min_stmt=_as_int(args.segment_min_stmt, DEFAULT_SEGMENT_MIN_STMT), collect_api_surface=bool(getattr(args, "api_surface", False)), + write_enabled=False, ) if policy != "off": cache.load() @@ -654,10 +655,6 @@ def _metrics_computed(analysis_mode: AnalysisMode) -> tuple[str, ...]: ) -def _load_report_document(report_json: str) -> dict[str, object]: - return _load_report_document_payload(report_json) - - def _report_digest(report_document: Mapping[str, object]) -> str: integrity = _as_mapping(report_document.get("integrity")) digest = _as_mapping(integrity.get("digest")) diff --git a/codeclone/surfaces/mcp/_session_intent_mixin.py b/codeclone/surfaces/mcp/_session_intent_mixin.py index e31538d2..0f64cd0c 100644 --- a/codeclone/surfaces/mcp/_session_intent_mixin.py +++ b/codeclone/surfaces/mcp/_session_intent_mixin.py @@ -12,6 +12,7 @@ from datetime import datetime, timedelta, timezone from fnmatch import fnmatchcase from pathlib import Path +from typing import TYPE_CHECKING, cast from ...audit import ( EVENT_BLAST_RADIUS, @@ -42,6 +43,8 @@ normalize_expected_effects, normalize_intent_scope, ) +from ._session_blast_radius_mixin import _MCPSessionBlastRadiusMixin +from ._session_finding_mixin import _MCPSessionFindingMixin from ._session_shared import ( CodeCloneMCPRunStore, MCPRunNotFoundError, @@ -79,6 +82,10 @@ ) from .messages import intent as intent_msgs +if TYPE_CHECKING: + from ._session_finding_mixin import _StateLock + from ._workspace_hygiene import DirtySnapshot + @dataclass(frozen=True, slots=True) class _RecoveryTarget: @@ -96,6 +103,7 @@ class _RecoveryRun: class _MCPSessionIntentMixin: _runs: CodeCloneMCPRunStore _active_intents: dict[str, IntentRecord] + _state_lock: _StateLock _intent_sequence: int _agent_pid: int _agent_start_epoch: int @@ -124,13 +132,16 @@ def get_blast_radius( include: Sequence[str] | None = None, ) -> dict[str, object]: record = self._runs.get(run_id) - payload = super().get_blast_radius( + blast_radius_session = cast(_MCPSessionBlastRadiusMixin, super()) + payload = blast_radius_session.get_blast_radius( files=files, run_id=record.run_id, depth=depth, include=include, ) - normalized_payload = _helpers.coerce_object_dict(payload) + normalized_payload = _helpers.coerce_object_dict( + cast(Mapping[object, object], payload) + ) self._renew_lease_for_run(record=record) self._audit_emit( root=record.root, @@ -227,6 +238,7 @@ def _declare_change_intent( expected_effects: Sequence[str] | None, ttl_seconds: int | None, on_conflict: str | None = None, + dirty_snapshot: DirtySnapshot | None = None, ) -> dict[str, object]: record = self._runs.get(run_id) try: @@ -237,7 +249,8 @@ def _declare_change_intent( description = str(intent or "").strip() if not description: raise MCPServiceContractError("action='declare' requires intent text.") - blast = self._blast_radius_result( + blast_radius_session = cast(_MCPSessionBlastRadiusMixin, self) + blast = blast_radius_session._blast_radius_result( record=record, files=normalized_scope.allowed_paths, depth="direct", @@ -283,9 +296,7 @@ def _declare_change_intent( intent=record_payload, ttl_seconds=ttl, ) - from ._workspace_hygiene import collect_dirty_snapshot - - dirty_snapshot = collect_dirty_snapshot(record.root) + dirty_snapshot = _dirty_snapshot_for_declare(record.root, dirty_snapshot) workspace_record = replace( workspace_record, dirty_snapshot=dirty_snapshot.to_payload(), @@ -633,9 +644,15 @@ def _check_change_intent( ) return payload actual = ( - self._normalize_changed_paths(root_path=record.root, paths=changed_files) + cast(_MCPSessionFindingMixin, self)._normalize_changed_paths( + root_path=record.root, + paths=changed_files, + ) if changed_files - else self._git_diff_paths(root_path=record.root, git_diff_ref=str(diff_ref)) + else cast(_MCPSessionFindingMixin, self)._git_diff_paths( + root_path=record.root, + git_diff_ref=str(diff_ref), + ) ) check_result = self._intent_check_result(intent=active_intent, actual=actual) updated = replace( @@ -912,7 +929,9 @@ def _renew_change_intent( ) return payload - def _list_workspace_intents(self, *, root: str | None) -> dict[str, object]: + def _list_workspace_intents( + self, *, root: str | None, include_dirty_summary: bool = True + ) -> dict[str, object]: from ...config.intent_registry import intent_registry_summary from ._workspace_intents import list_workspace_intent_records_for_recovery @@ -941,11 +960,16 @@ def _list_workspace_intents(self, *, root: str | None) -> dict[str, object]: "total_agents": len({item.agent_pid for item in records}), "own_pid": self._agent_pid, "own_start_epoch": self._agent_start_epoch, - "workspace_dirty_summary": _helpers.workspace_dirty_summary_payload( - root=root_path - ), **intent_registry_summary(root_path), } + if include_dirty_summary: + # Skipped on the start path: start never surfaces this summary and it + # is absent from the start-replay registry digest, so computing it + # there is a redundant git rev-parse + status. The public + # list_workspace route keeps the default True to preserve contract. + payload["workspace_dirty_summary"] = ( + _helpers.workspace_dirty_summary_payload(root=root_path) + ) if recovery_available: payload["recovery_next_step"] = intent_msgs.RECOVERY_LIST_NEXT_STEP return payload @@ -1517,6 +1541,17 @@ def _intent_check_result( ) +def _dirty_snapshot_for_declare( + root: Path, + dirty_snapshot: DirtySnapshot | None, +) -> DirtySnapshot: + if dirty_snapshot is not None: + return dirty_snapshot + from ._workspace_hygiene import collect_dirty_snapshot + + return collect_dirty_snapshot(root) + + def _apply_blast_context( payload: dict[str, object], blast_payload: Mapping[str, object], diff --git a/codeclone/surfaces/mcp/_session_memory_mixin.py b/codeclone/surfaces/mcp/_session_memory_mixin.py index 8a9a42f7..50525cba 100644 --- a/codeclone/surfaces/mcp/_session_memory_mixin.py +++ b/codeclone/surfaces/mcp/_session_memory_mixin.py @@ -13,6 +13,7 @@ from ...audit.validation import DEFAULT_AUDIT_PATH, resolve_audit_path from ...config.memory import MemoryConfig, resolve_memory_config from ...memory.embedding import resolve_embedding_provider +from ...memory.enums import MemoryRecordType, validate_memory_record_type from ...memory.exceptions import ( MemoryCapacityError, MemoryContractError, @@ -44,6 +45,7 @@ resolve_semantic_index, ) from ...memory.sqlite_store import SqliteEngineeringMemoryStore +from ...utils.payload_narrow import is_payload_dict, is_record_mapping from . import _session_helpers as _helpers from ._context_governance import ( DEFAULT_RESPONSE_CONTEXT_UNIT_LIMIT, @@ -52,6 +54,7 @@ attach_passive_context_governance, ) from ._intent import IntentRecord +from ._session_blast_radius_mixin import _MCPSessionBlastRadiusMixin from ._session_shared import ( CodeCloneMCPRunStore, MCPRunNotFoundError, @@ -71,6 +74,10 @@ ) +def _blast_session(session: _MCPSessionMemoryMixin) -> _MCPSessionBlastRadiusMixin: + return cast(_MCPSessionBlastRadiusMixin, session) + + class _MCPSessionMemoryMixin: _runs: CodeCloneMCPRunStore _active_intents: dict[str, IntentRecord] @@ -241,7 +248,7 @@ def manage_engineering_memory( *, root: str, action: str, - record_type: str | None = None, + record_type: MemoryRecordType | None = None, statement: str | None = None, subject_path: str | None = None, text: str | None = None, @@ -444,7 +451,7 @@ def _manage_memory_record_candidate( *, project: MemoryProject, config: MemoryConfig, - record_type: str | None, + record_type: MemoryRecordType | None, statement: str | None, subject_path: str | None, ) -> dict[str, object]: @@ -454,10 +461,14 @@ def _manage_memory_record_candidate( raise MCPServiceContractError( "record_candidate requires record_type and statement." ) + try: + canonical_type = validate_memory_record_type(record_type) + except ValueError as exc: + raise MCPServiceContractError(str(exc)) from exc record = record_candidate( store, project=project, - record_type=record_type, # type: ignore[arg-type] + record_type=canonical_type, statement=statement, subject_path=subject_path, max_candidates=config.max_candidates, @@ -739,7 +750,7 @@ def _memory_blast_dependents( if record.root.resolve() != root_path.resolve(): return frozenset() try: - result = self._blast_radius_result( + result = _blast_session(self)._blast_radius_result( record=record, files=list(scope_paths), depth="direct", @@ -755,13 +766,14 @@ def _publish_memory_continuation_request( published = dict(payload) internal = published.pop("_memory_projection_request", None) project_id = published.get("project_id") - if isinstance(project_id, str) and isinstance(internal, Mapping): - digest = memory_projection_request_digest(internal) + if isinstance(project_id, str) and is_record_mapping(internal): + internal_mapping = internal + digest = memory_projection_request_digest(internal_mapping) digest_value = digest.get("value") - if isinstance(digest_value, str): + if isinstance(digest_value, str) and is_payload_dict(internal_mapping): self._memory_continuation_requests[ self._memory_continuation_request_key(project_id, digest_value) - ] = dict(internal) + ] = dict(internal_mapping) return published def _resolve_memory_continuation_request( @@ -904,7 +916,7 @@ def _memory_lane_items( for lane, _count_key in _MEMORY_RESPONSE_LANES: value = payload.get(lane) items = value if isinstance(value, list) else [] - lanes[lane] = [dict(item) for item in items if isinstance(item, Mapping)] + lanes[lane] = [dict(item) for item in items if is_payload_dict(item)] return lanes @@ -1009,7 +1021,7 @@ def _memory_continuation_lanes( return { str(lane): dict(lane_payload) for lane, lane_payload in lanes.items() - if isinstance(lane_payload, Mapping) + if is_payload_dict(lane_payload) } diff --git a/codeclone/surfaces/mcp/_session_patch_contract_mixin.py b/codeclone/surfaces/mcp/_session_patch_contract_mixin.py index 77a816cf..1cfc8d10 100644 --- a/codeclone/surfaces/mcp/_session_patch_contract_mixin.py +++ b/codeclone/surfaces/mcp/_session_patch_contract_mixin.py @@ -8,6 +8,8 @@ from collections.abc import Mapping, Sequence from fnmatch import fnmatchcase +from pathlib import Path +from typing import cast from ...audit import ( EVENT_BASELINE_ABUSE, @@ -32,6 +34,8 @@ budgets_for_strictness, detect_baseline_abuse, ) +from ._session_finding_mixin import _MCPSessionFindingMixin, _StateLock +from ._session_intent_mixin import _MCPSessionIntentMixin from ._session_shared import ( CodeCloneMCPRunStore, MCPGateRequest, @@ -39,6 +43,7 @@ MCPRunRecord, MCPServiceContractError, ) +from ._session_state_mixin import _MCPSessionStateMixin from ._verification_profile import ( ClassificationResult, VerificationProfile, @@ -51,9 +56,22 @@ MAX_WORSENED_ITEMS = 20 +def _intent_session(session: _MCPSessionPatchContractMixin) -> _MCPSessionIntentMixin: + return cast(_MCPSessionIntentMixin, session) + + +def _finding_session(session: _MCPSessionPatchContractMixin) -> _MCPSessionFindingMixin: + return cast(_MCPSessionFindingMixin, session) + + +def _state_session(session: _MCPSessionPatchContractMixin) -> _MCPSessionStateMixin: + return cast(_MCPSessionStateMixin, session) + + class _MCPSessionPatchContractMixin: _runs: CodeCloneMCPRunStore _active_intents: dict[str, IntentRecord] + _state_lock: _StateLock def check_patch_contract( self, @@ -93,8 +111,9 @@ def _patch_contract_budget( ) -> dict[str, object]: record = self._runs.get(run_id) intent = self._optional_intent(record=record, intent_id=intent_id) + intent_session = _intent_session(self) if intent is not None: - self._renew_lease_if_active(record=record, intent=intent) + intent_session._renew_lease_if_active(record=record, intent=intent) budgets = self._budgets_for_record(record=record, strictness=strictness) current_state = self._current_state(record) gate_preview = self._gate_preview(record=record, budgets=budgets) @@ -130,13 +149,13 @@ def _patch_contract_budget( if is_queued: payload["intent_status"] = "queued" payload["edit_allowed"] = False - self._audit_emit( + intent_session._audit_emit( root=record.root, event_type=EVENT_PATCH_BUDGET, severity="warn" if bool(gate_preview.get("would_fail")) else "info", run_id=_helpers._short_run_id(record.run_id), intent_id=intent.intent_id if intent is not None else None, - report_digest=self._report_digest_value(record), + report_digest=intent_session._report_digest_value(record), status="budget", payload=payload, ) @@ -168,8 +187,9 @@ def _patch_contract_verify( # ── 2. Resolve intent ─────────────────────────────────────── intent = self._optional_intent(record=before, intent_id=intent_id) + intent_session = _intent_session(self) if intent is not None: - self._renew_lease_if_active(record=before, intent=intent) + intent_session._renew_lease_if_active(record=before, intent=intent) # ── 2b. Queued intents cannot be verified ────────────────── if intent is not None and intent.status == IntentStatus.QUEUED: @@ -206,7 +226,10 @@ def _patch_contract_verify( ) # ── 7. Intent expiry check ────────────────────────────────── - if intent is not None and self._is_intent_expired(record=before, intent=intent): + if intent is not None and intent_session._is_intent_expired( + record=before, + intent=intent, + ): after = self._optional_after_run(after_run_id) return self._expired_patch_contract( before=before, @@ -329,7 +352,10 @@ def _optional_intent( intent_id: str | None, ) -> IntentRecord | None: if intent_id is not None: - _, intent = self._resolve_intent(run_id=None, intent_id=intent_id) + _, intent = _intent_session(self)._resolve_intent( + run_id=None, + intent_id=intent_id, + ) assert isinstance(intent, IntentRecord) return intent with self._state_lock: @@ -382,7 +408,7 @@ def _gate_preview( record: MCPRunRecord, budgets: PatchBudgets, ) -> dict[str, object]: - gate_result = self._evaluate_gate_snapshot( + gate_result = _state_session(self)._evaluate_gate_snapshot( record=record, request=self._gate_request(record=record, budgets=budgets), ) @@ -452,15 +478,12 @@ def _patch_changed_files( diff_ref: str | None, changed_files: Sequence[str] | None, ) -> tuple[str, ...]: - if changed_files: - return _helpers.coerce_repo_path_tuple( - self._normalize_changed_paths(root_path=after.root, paths=changed_files) - ) - if diff_ref is not None: - return _helpers.coerce_repo_path_tuple( - self._git_diff_paths(root_path=after.root, git_diff_ref=diff_ref) - ) - return tuple(after.changed_paths) + evidence = self._patch_changed_file_evidence( + root_path=after.root, + diff_ref=diff_ref, + changed_files=changed_files, + ) + return evidence if evidence is not None else tuple(after.changed_paths) def _patch_changed_files_flexible( self, @@ -486,17 +509,36 @@ def _patch_changed_files_flexible( ) except MCPRunNotFoundError: pass + evidence = self._patch_changed_file_evidence( + root_path=before.root, + diff_ref=diff_ref, + changed_files=changed_files, + ) + return evidence if evidence is not None else () + + def _patch_changed_file_evidence( + self, + *, + root_path: Path, + diff_ref: str | None, + changed_files: Sequence[str] | None, + ) -> tuple[str, ...] | None: + finding_session = _finding_session(self) if changed_files: return _helpers.coerce_repo_path_tuple( - self._normalize_changed_paths( - root_path=before.root, paths=changed_files + finding_session._normalize_changed_paths( + root_path=root_path, + paths=changed_files, ) ) if diff_ref is not None: return _helpers.coerce_repo_path_tuple( - self._git_diff_paths(root_path=before.root, git_diff_ref=diff_ref) + finding_session._git_diff_paths( + root_path=root_path, + git_diff_ref=diff_ref, + ) ) - return () + return None def _optional_after_run(self, after_run_id: str | None) -> MCPRunRecord | None: if after_run_id is None: @@ -542,13 +584,14 @@ def _state_artifact_violated( "claim_validation_recommended": False, "message": patch_msgs.STATE_ARTIFACT_VIOLATION_MESSAGE, } - self._audit_emit( + intent_session = _intent_session(self) + intent_session._audit_emit( root=before.root, event_type=EVENT_PATCH_VIOLATED, severity="warn", run_id=_helpers._short_run_id(before.run_id), intent_id=intent.intent_id if intent is not None else None, - report_digest=self._report_digest_value(before), + report_digest=intent_session._report_digest_value(before), status=PatchContractStatus.VIOLATED.value, payload=payload, ) @@ -595,13 +638,14 @@ def _profile_fast_path( violations=tuple(violations), ), } - self._audit_emit( + intent_session = _intent_session(self) + intent_session._audit_emit( root=before.root, event_type=EVENT_PATCH_VIOLATED, severity="warn", run_id=_helpers._short_run_id(before.run_id), intent_id=(intent.intent_id if intent is not None else None), - report_digest=self._report_digest_value(before), + report_digest=intent_session._report_digest_value(before), status=PatchContractStatus.VIOLATED.value, payload=payload, ) @@ -650,13 +694,14 @@ def _profile_fast_path( ), "message": profile_accepted_message(profile), } - self._audit_emit( + intent_session = _intent_session(self) + intent_session._audit_emit( root=before.root, event_type=EVENT_PATCH_VERIFIED, severity="info", run_id=_helpers._short_run_id(before.run_id), intent_id=(intent.intent_id if intent is not None else None), - report_digest=self._report_digest_value(before), + report_digest=intent_session._report_digest_value(before), status=status, payload=payload, ) @@ -674,7 +719,7 @@ def _full_structural_verify( actual_changed_files: tuple[str, ...], ) -> dict[str, object]: """Full structural verification path (before + after runs).""" - compare_payload = self.compare_runs( + compare_payload = _state_session(self).compare_runs( run_id_before=before.run_id, run_id_after=after.run_id, focus="all", @@ -798,26 +843,27 @@ def _full_structural_verify( if status == PatchContractStatus.VIOLATED.value else EVENT_PATCH_VERIFIED ) - audit_sequence = self._audit_emit( + intent_session = _intent_session(self) + audit_sequence = intent_session._audit_emit( root=after.root, event_type=event_type, severity="warn" if blocking_violations else "info", run_id=_helpers._short_run_id(after.run_id), intent_id=(intent.intent_id if intent is not None else None), - report_digest=self._report_digest_value(after), + report_digest=intent_session._report_digest_value(after), status=status, payload=payload, ) if audit_sequence is not None: payload["_audit_sequence"] = audit_sequence if bool(baseline_abuse.get("detected")): - self._audit_emit( + intent_session._audit_emit( root=after.root, event_type=EVENT_BASELINE_ABUSE, severity="error", run_id=_helpers._short_run_id(after.run_id), intent_id=(intent.intent_id if intent is not None else None), - report_digest=self._report_digest_value(after), + report_digest=intent_session._report_digest_value(after), status="detected", payload=payload, ) @@ -829,7 +875,10 @@ def _scope_check_payload( intent: IntentRecord, actual: Sequence[str], ) -> dict[str, object]: - check_result = self._intent_check_result(intent=intent, actual=actual) + check_result = _intent_session(self)._intent_check_result( + intent=intent, + actual=actual, + ) assert isinstance(check_result, IntentCheckResult) return check_result.to_payload() @@ -867,13 +916,14 @@ def _finding_path_index( record: MCPRunRecord, ) -> dict[str, frozenset[str]]: index: dict[str, frozenset[str]] = {} - for finding in self._base_findings(record): + finding_session = _finding_session(self) + for finding in finding_session._base_findings(record): finding_id = str(finding.get("id", "")).strip() if not finding_id: continue paths = self._finding_paths(finding) index[finding_id] = paths - index[self._short_finding_id(record, finding_id)] = paths + index[finding_session._short_finding_id(record, finding_id)] = paths return index def _finding_paths(self, finding: Mapping[str, object]) -> frozenset[str]: @@ -1170,13 +1220,14 @@ def _expired_patch_contract( "claim_validation_recommended": False, "message": patch_msgs.PATCH_CONTRACT_EXPIRED_MESSAGE, } - self._audit_emit( + intent_session = _intent_session(self) + intent_session._audit_emit( root=after.root, event_type=EVENT_PATCH_EXPIRED, severity="warn", run_id=_helpers._short_run_id(after.run_id), intent_id=intent.intent_id, - report_digest=self._report_digest_value(after), + report_digest=intent_session._report_digest_value(after), status=PatchContractStatus.EXPIRED.value, payload=payload, ) diff --git a/codeclone/surfaces/mcp/_session_review_receipt_mixin.py b/codeclone/surfaces/mcp/_session_review_receipt_mixin.py index e4571a4f..ab768d05 100644 --- a/codeclone/surfaces/mcp/_session_review_receipt_mixin.py +++ b/codeclone/surfaces/mcp/_session_review_receipt_mixin.py @@ -8,6 +8,7 @@ from collections import OrderedDict from collections.abc import Mapping, Sequence +from typing import cast from ...audit import ( EVENT_RECEIPT_CREATED, @@ -30,11 +31,39 @@ receipt_verdict, render_receipt_markdown, ) +from ._session_finding_mixin import _MCPSessionFindingMixin, _StateLock +from ._session_intent_mixin import _MCPSessionIntentMixin +from ._session_patch_contract_mixin import _MCPSessionPatchContractMixin from ._session_shared import ( CodeCloneMCPRunStore, MCPRunRecord, MCPServiceContractError, ) +from ._session_state_mixin import _MCPSessionStateMixin + + +def _intent_session( + session: _MCPSessionReviewReceiptMixin, +) -> _MCPSessionIntentMixin: + return cast(_MCPSessionIntentMixin, session) + + +def _patch_session( + session: _MCPSessionReviewReceiptMixin, +) -> _MCPSessionPatchContractMixin: + return cast(_MCPSessionPatchContractMixin, session) + + +def _finding_session( + session: _MCPSessionReviewReceiptMixin, +) -> _MCPSessionFindingMixin: + return cast(_MCPSessionFindingMixin, session) + + +def _state_session( + session: _MCPSessionReviewReceiptMixin, +) -> _MCPSessionStateMixin: + return cast(_MCPSessionStateMixin, session) class _MCPSessionReviewReceiptMixin: @@ -42,6 +71,7 @@ class _MCPSessionReviewReceiptMixin: _active_intents: dict[str, IntentRecord] _review_state: dict[str, OrderedDict[str, str | None]] _last_gate_results: dict[str, dict[str, object]] + _state_lock: _StateLock def create_review_receipt( self, @@ -112,7 +142,7 @@ def create_review_receipt( ), } if output_format == "json": - self._audit_emit( + _intent_session(self)._audit_emit( root=record.root, event_type=EVENT_RECEIPT_CREATED, severity="info", @@ -144,7 +174,7 @@ def create_review_receipt( "content": content, "receipt": receipt, } - self._audit_emit( + audit_sequence = _intent_session(self)._audit_emit( root=record.root, event_type=EVENT_RECEIPT_CREATED, severity="info", @@ -155,22 +185,25 @@ def create_review_receipt( payload=audit_payload, ) # Default response dedup: keep the human-complete markdown receipt - # content plus identity and omits the duplicate nested typed receipt — now - # durably retrievable post-clear via get_review_receipt. - return { + # content plus identity and omit the duplicate nested typed receipt. + response: dict[str, object] = { "run_id": short_run_id, "format": output_format, "receipt_version": RECEIPT_VERSION, "verdict": verdict, "receipt_digest": digest, "content": content, - "receipt_retrieval": { - "tool": "get_review_receipt", - "run_id": short_run_id, - "receipt_digest": digest["value"], - "format": "structured", - }, } + if audit_sequence is None: + response["receipt_retrieval_unavailable"] = "audit_write_failed" + return response + response["receipt_retrieval"] = { + "tool": "get_review_receipt", + "run_id": short_run_id, + "receipt_digest": digest["value"], + "format": "structured", + } + return response def _validated_receipt_format(self, value: str) -> str: if value not in VALID_RECEIPT_FORMATS: @@ -189,12 +222,15 @@ def _receipt_intent( intent_record: MCPRunRecord | None = None intent: IntentRecord | None if intent_id is not None: - intent_record, intent = self._resolve_intent( + intent_record, intent = _intent_session(self)._resolve_intent( run_id=None, intent_id=intent_id, ) else: - intent = self._optional_intent(record=record, intent_id=None) + intent = _patch_session(self)._optional_intent( + record=record, + intent_id=None, + ) if intent is not None and intent.run_id != record.run_id: intent_record = intent_record or self._runs.get(intent.run_id) if intent_record.root != record.root: @@ -221,11 +257,12 @@ def _receipt_changed_findings( ) -> list[dict[str, object]]: if not changed_paths: return [] - findings = self._base_findings(record) + finding_session = _finding_session(self) + findings = finding_session._base_findings(record) return [ finding for finding in findings - if self._finding_touches_paths( + if finding_session._finding_touches_paths( finding=finding, changed_paths=changed_paths, ) @@ -306,7 +343,8 @@ def _receipt_blast_radius( } def _reviewed_evidence(self, record: MCPRunRecord) -> dict[str, object]: - findings = self._base_findings(record) + finding_session = _finding_session(self) + findings = finding_session._base_findings(record) gate_relevant = [ finding for finding in findings @@ -322,10 +360,13 @@ def _reviewed_evidence(self, record: MCPRunRecord) -> dict[str, object]: finding = self._finding_by_id(record=record, canonical_id=canonical_id) if finding is None: continue - summary = self._finding_summary_card(record, finding) + summary = finding_session._finding_summary_card(record, finding) items.append( { - "finding_id": self._short_finding_id(record, canonical_id), + "finding_id": finding_session._short_finding_id( + record, + canonical_id, + ), "kind": str(summary.get("kind") or "finding"), "severity": str(summary.get("severity") or "info"), "note": note, @@ -343,7 +384,7 @@ def _finding_by_id( record: MCPRunRecord, canonical_id: str, ) -> dict[str, object] | None: - for finding in self._base_findings(record): + for finding in _finding_session(self)._base_findings(record): if isinstance(finding, dict) and str(finding.get("id", "")) == canonical_id: return finding return None @@ -362,7 +403,7 @@ def _receipt_structural_delta( "health_delta": None, "verdict": "not_applicable", } - previous = self._previous_run_for_root(record) + previous = _finding_session(self)._previous_run_for_root(record) if previous is None: return { "available": False, @@ -371,7 +412,7 @@ def _receipt_structural_delta( "health_delta": None, "verdict": "not_available", } - compare_payload = self.compare_runs( + compare_payload = _state_session(self).compare_runs( run_id_before=previous.run_id, run_id_after=record.run_id, focus="all", diff --git a/codeclone/surfaces/mcp/_session_shared.py b/codeclone/surfaces/mcp/_session_shared.py index 5cd25a2a..01c836f0 100644 --- a/codeclone/surfaces/mcp/_session_shared.py +++ b/codeclone/surfaces/mcp/_session_shared.py @@ -131,6 +131,7 @@ ComparisonFocus = Literal["all", "clones", "structural", "metrics"] PRSummaryFormat = Literal["markdown", "json"] HelpTopic = Literal[ + "overview", "workflow", "analysis_profile", "suppressions", @@ -144,6 +145,7 @@ "engineering_memory", "implementation_context", "verification_profiles", + "observability", ] HelpDetail = Literal["compact", "normal"] MetricsDetailFamily = Literal[ @@ -249,6 +251,7 @@ _VALID_PR_SUMMARY_FORMATS = frozenset({"markdown", "json"}) _VALID_HELP_TOPICS = frozenset( { + "overview", "workflow", "analysis_profile", "suppressions", diff --git a/codeclone/surfaces/mcp/_session_state_mixin.py b/codeclone/surfaces/mcp/_session_state_mixin.py index 78ad3add..89561889 100644 --- a/codeclone/surfaces/mcp/_session_state_mixin.py +++ b/codeclone/surfaces/mcp/_session_state_mixin.py @@ -921,7 +921,10 @@ class _MCPSessionStateMixin(_MCPSessionReportMixin): _review_state: dict[str, OrderedDict[str, str | None]] _last_gate_results: dict[str, dict[str, object]] _spread_max_cache: dict[str, int] - _blast_radius_cache: dict[tuple[str, tuple[str, ...], str], BlastRadiusResult] + _blast_radius_cache: dict[ + tuple[str, tuple[str, ...], str, tuple[str, ...], tuple[str, ...]], + BlastRadiusResult, + ] _context_projection_pages: dict[str, ContextProjectionArtifact] _memory_continuation_requests: dict[str, dict[str, object]] _active_intents: dict[str, IntentRecord] @@ -1096,12 +1099,7 @@ def get_report_section( offset=offset, limit=limit, ) - payload = report_document.get(validated_section) - if not isinstance(payload, Mapping): - raise MCPServiceContractError( - f"Report section '{validated_section}' is not available in this run." - ) - return dict(payload) + return dict(require_mapping_section(report_document, section=validated_section)) def get_production_triage( self, @@ -1122,12 +1120,17 @@ def get_production_triage( ) hotspot_limit = max(1, min(max_hotspots, 10)) suggestion_limit = max(1, min(max_suggestions, 10)) - production_hotspots = self._hotspot_rows( + production_hotspot_selection = self._hotspot_selection( record=record, kind="production_hotspots", - detail_level="summary", changed_paths=(), exclude_reviewed=False, + limit=hotspot_limit, + ) + production_hotspots = self._decorate_hotspot_selection( + record=record, + selection=production_hotspot_selection, + detail_level="summary", ) production_suggestions = [ dict(row) @@ -1156,11 +1159,10 @@ def get_production_triage( }, "top_hotspots": { "kind": "production_hotspots", - "available": len(production_hotspots), - "returned": min(len(production_hotspots), hotspot_limit), + "available": production_hotspot_selection[1], + "returned": len(production_hotspots), "items": [ - dict(_helpers._as_mapping(item)) - for item in production_hotspots[:hotspot_limit] + dict(_helpers._as_mapping(item)) for item in production_hotspots ], }, "suggestions": { @@ -1214,8 +1216,27 @@ def get_help( payload["anti_patterns"] = list(spec.anti_patterns) if validated_detail == "normal" and spec.warnings: payload["warnings"] = list(spec.warnings) + self._attach_help_topic_index(payload, topic=validated_topic) return payload + def _attach_help_topic_index( + self, + payload: dict[str, object], + *, + topic: HelpTopic, + ) -> None: + if topic != "overview": + return + payload["topics"] = [ + { + "topic": topic_name, + "summary": topic_spec.summary, + "recommended_tools": list(topic_spec.recommended_tools), + } + for topic_name, topic_spec in _HELP_TOPIC_SPECS.items() + if topic_name != "overview" + ] + def query_platform_observability( self, *, diff --git a/codeclone/surfaces/mcp/_session_workflow_mixin.py b/codeclone/surfaces/mcp/_session_workflow_mixin.py index 1cd433c2..414818d5 100644 --- a/codeclone/surfaces/mcp/_session_workflow_mixin.py +++ b/codeclone/surfaces/mcp/_session_workflow_mixin.py @@ -24,7 +24,7 @@ from collections.abc import Callable, Mapping, Sequence from copy import deepcopy from pathlib import Path -from typing import Final +from typing import Final, cast from ...audit.events import EVENT_BLAST_ARTIFACT_CREATED, EVENT_PATCH_TRAIL_COMPUTED from ...memory.trajectory.patch_trail import compute_patch_trail @@ -51,12 +51,19 @@ ) from ._patch_contract import PatchContractStatus from ._patch_trail_bridge import build_patch_trail_inputs +from ._session_blast_radius_mixin import _MCPSessionBlastRadiusMixin +from ._session_claim_guard_mixin import _MCPSessionClaimGuardMixin +from ._session_finding_mixin import _MCPSessionFindingMixin, _StateLock +from ._session_intent_mixin import _MCPSessionIntentMixin +from ._session_memory_mixin import _MCPSessionMemoryMixin +from ._session_patch_contract_mixin import _MCPSessionPatchContractMixin +from ._session_review_receipt_mixin import _MCPSessionReviewReceiptMixin from ._session_shared import ( CodeCloneMCPRunStore, MCPRunRecord, MCPServiceContractError, ) -from ._workspace_hygiene import WorkspaceHygieneResult +from ._workspace_hygiene import DirtySnapshot, WorkspaceHygieneResult from .messages import errors as err_msgs from .messages import workflow as workflow_msgs @@ -77,12 +84,45 @@ _FINISH_REDUCIBLE_LANES: Final[tuple[str, ...]] = ("receipt_content", "patch_trail") +def _intent_session(session: _MCPSessionWorkflowMixin) -> _MCPSessionIntentMixin: + return cast(_MCPSessionIntentMixin, session) + + +def _blast_session(session: _MCPSessionWorkflowMixin) -> _MCPSessionBlastRadiusMixin: + return cast(_MCPSessionBlastRadiusMixin, session) + + +def _patch_session(session: _MCPSessionWorkflowMixin) -> _MCPSessionPatchContractMixin: + return cast(_MCPSessionPatchContractMixin, session) + + +def _receipt_session( + session: _MCPSessionWorkflowMixin, +) -> _MCPSessionReviewReceiptMixin: + return cast(_MCPSessionReviewReceiptMixin, session) + + +def _finding_session(session: _MCPSessionWorkflowMixin) -> _MCPSessionFindingMixin: + return cast(_MCPSessionFindingMixin, session) + + +def _claim_session(session: _MCPSessionWorkflowMixin) -> _MCPSessionClaimGuardMixin: + return cast(_MCPSessionClaimGuardMixin, session) + + +def _memory_session(session: _MCPSessionWorkflowMixin) -> _MCPSessionMemoryMixin: + return cast(_MCPSessionMemoryMixin, session) + + class _MCPSessionWorkflowMixin: """Workflow orchestration over atomic change-control primitives.""" _runs: CodeCloneMCPRunStore _active_intents: dict[str, IntentRecord] _start_replay_cache: dict[str, dict[str, object]] + _state_lock: _StateLock + _agent_pid: int + _agent_start_epoch: int # ------------------------------------------------------------------ # start_controlled_change @@ -106,6 +146,9 @@ def start_controlled_change( validated_blast_detail = _validated_blast_radius_detail(blast_radius_detail) validated_dirty_scope_policy = _validated_dirty_scope_policy(dirty_scope_policy) root_path = _helpers._resolve_root(root) + intent_session = _intent_session(self) + blast_session = _blast_session(self) + patch_session = _patch_session(self) request_key = _start_replay_request_key( root_path=root_path, scope=scope, @@ -121,7 +164,11 @@ def start_controlled_change( ) # 1. Workspace check (lazy close inside list_workspace) - workspace_before = self._list_workspace_intents(root=root) + # Dirty summary is unused on the start path (not surfaced, not in the + # start-replay registry digest); skip its redundant git rev-parse+status. + workspace_before = intent_session._list_workspace_intents( + root=root, include_dirty_summary=False + ) # 2. Root-aware run resolution (not _runs.get(None) — multi-repo safe) record = self._latest_run_for_root(root_path) @@ -140,7 +187,12 @@ def start_controlled_change( ) ) - current_workspace_state_digest = _start_workspace_state_digest(root_path) + from ._workspace_hygiene import collect_dirty_snapshot + + workspace_state_snapshot = collect_dirty_snapshot(root_path) + current_workspace_state_digest = _start_workspace_state_digest_from_snapshot( + workspace_state_snapshot + ) registry_digest = _start_registry_digest(workspace_before) replay_payload = self._start_replay_payload( request_key=request_key, @@ -152,13 +204,14 @@ def start_controlled_change( return replay_payload # 3. Declare intent - declare_payload = self._declare_change_intent( + declare_payload = intent_session._declare_change_intent( run_id=record.run_id, scope=scope, intent=intent, expected_effects=expected_effects, ttl_seconds=ttl_seconds, on_conflict=on_conflict, + dirty_snapshot=workspace_state_snapshot, ) intent_id = str(declare_payload.get("intent_id", "")) @@ -166,7 +219,9 @@ def start_controlled_change( # Queued: no blast radius or budget if declare_status == IntentStatus.QUEUED.value: - workspace_after = self._list_workspace_intents(root=root) + workspace_after = intent_session._list_workspace_intents( + root=root, include_dirty_summary=False + ) queued_payload: dict[str, object] = { "intent_id": intent_id, "status": "queued", @@ -192,7 +247,9 @@ def start_controlled_change( ) # 4. Fresh workspace snapshot after declare - workspace_after = self._list_workspace_intents(root=root) + workspace_after = intent_session._list_workspace_intents( + root=root, include_dirty_summary=False + ) with self._state_lock: active_intent = self._active_intents.get(intent_id) @@ -204,6 +261,8 @@ def start_controlled_change( from ._workspace_hygiene import evaluate_scoped_hygiene from ._workspace_intent_store import get_workspace_intent_store + # Reuse the workspace-state snapshot already collected above instead of a + # second scoped git status+rev-parse; scoped paths are derived from it. hygiene = evaluate_scoped_hygiene( root=root_path, allowed_files=active_intent.scope.allowed_files, @@ -212,10 +271,11 @@ def start_controlled_change( own_pid=self._agent_pid, own_start_epoch=self._agent_start_epoch, own_intent_id=intent_id, + dirty_snapshot=workspace_state_snapshot, ) # 5. Blast radius (full payload, not just declare's subset) - blast_result = self._blast_radius_result( + blast_result = blast_session._blast_radius_result( record=record, files=active_intent.scope.allowed_paths, depth="direct", @@ -237,13 +297,13 @@ def start_controlled_change( source_tool="start_controlled_change", ) blast_artifact_ref = blast_artifact_reference(blast_artifact) - blast_artifact_audit_sequence = self._audit_emit( + blast_artifact_audit_sequence = intent_session._audit_emit( root=record.root, event_type=EVENT_BLAST_ARTIFACT_CREATED, severity="info", run_id=_helpers._short_run_id(record.run_id), intent_id=intent_id, - report_digest=self._report_digest_value(record), + report_digest=intent_session._report_digest_value(record), status=str(blast_payload.get("radius_level", "")), payload=blast_artifact, ) @@ -258,10 +318,10 @@ def start_controlled_change( ) # 7. Budget - budget_payload = self._patch_contract_budget( + budget_payload = patch_session._patch_contract_budget( run_id=record.run_id, intent_id=intent_id, - strictness=self._validated_strictness(strictness), + strictness=patch_session._validated_strictness(strictness), ) concurrent_intents = _as_conflict_list( @@ -334,7 +394,7 @@ def start_controlled_change( intent=active_intent, payload=payload, workspace_after=workspace_after, - workspace_state_digest=_start_workspace_state_digest(root_path), + workspace_state_digest=current_workspace_state_digest, scope_digest=context_governance_digest( "boundary_v1", active_intent.scope.to_payload() ), @@ -446,8 +506,10 @@ def finish_controlled_change( detail_level: str = "summary", patch_trail_detail: str = "summary", ) -> dict[str, object]: + intent_session = _intent_session(self) + patch_session = _patch_session(self) # 1. Resolve intent - record, active_intent = self._resolve_intent( + record, active_intent = intent_session._resolve_intent( run_id=None, intent_id=intent_id, ) @@ -479,8 +541,8 @@ def finish_controlled_change( from ._workspace_hygiene import ( dirty_snapshot_from_payload, + dirty_summary_from_snapshot, finish_hygiene_check, - workspace_dirty_summary, ) from ._workspace_intent_store import get_workspace_intent_store @@ -502,7 +564,10 @@ def finish_controlled_change( ) workspace_hygiene_after = { **finish_hygiene.to_payload(detail_level=detail_level), - "workspace_dirty_summary": workspace_dirty_summary(root=record.root), + # Reuse the single finish snapshot rather than a third git read. + "workspace_dirty_summary": dirty_summary_from_snapshot( + finish_hygiene.dirty_snapshot + ), } if finish_hygiene.blocks_finish: block_reason = finish_hygiene.finish_block_reason or "" @@ -537,7 +602,7 @@ def finish_controlled_change( ) # 3. Check (writes IntentRecord.check_result — required for receipt) - check_payload = self._check_change_intent( + check_payload = intent_session._check_change_intent( run_id=None, intent_id=intent_id, diff_ref=None, @@ -593,11 +658,11 @@ def finish_controlled_change( ) # 5. Verify (before_run_id auto-resolves from intent) - verify_payload = self._patch_contract_verify( + verify_payload = patch_session._patch_contract_verify( before_run_id=None, after_run_id=after_run_id, intent_id=intent_id, - strictness=self._validated_strictness(strictness), + strictness=patch_session._validated_strictness(strictness), diff_ref=None, changed_files=scope_files, ) @@ -659,7 +724,7 @@ def finish_controlled_change( receipt_error: str | None = None if create_receipt: try: - receipt_payload = self.create_review_receipt( + receipt_payload = _receipt_session(self).create_review_receipt( run_id=record.run_id, intent_id=intent_id, ) @@ -669,7 +734,7 @@ def finish_controlled_change( # 9. Auto-clear (only on accepted, only if receipt didn't fail) intent_cleared = False if auto_clear and verify_status in _ACCEPTED_STATUSES and receipt_error is None: - self._clear_change_intent(intent_id=intent_id) + intent_session._clear_change_intent(intent_id=intent_id) intent_cleared = True # External workspace changes (dirty outside the declared scope) are @@ -719,7 +784,8 @@ def finish_controlled_change( result["health_regression_advisory"] = health_regression_advisory if propose_memory and verify_status in _ACCEPTED_STATUSES: profile = verify_payload.get("verification_profile") - memory_hook = self.finish_propose_memory( + memory_session = _memory_session(self) + memory_hook = memory_session.finish_propose_memory( root_path=record.root, changed_files=resolved_files, claims_text=claims_text, @@ -729,8 +795,9 @@ def finish_controlled_change( if memory_hook: result.update(memory_hook) if verify_status in _ACCEPTED_STATUSES: - projection_hook = self.maybe_auto_enqueue_projection_rebuild( - root_path=record.root, + memory_session = _memory_session(self) + projection_hook = memory_session.maybe_auto_enqueue_projection_rebuild( + root_path=record.root ) if projection_hook is not None: result["projection_rebuild"] = projection_hook @@ -770,7 +837,8 @@ def _finish_patch_trail( } else "info" ) - patch_trail_audit_sequence = self._audit_emit( + intent_session = _intent_session(self) + patch_trail_audit_sequence = intent_session._audit_emit( root=record.root, event_type=EVENT_PATCH_TRAIL_COMPUTED, severity=severity, @@ -787,6 +855,13 @@ def _finish_patch_trail( evidence = dict(evidence_raw) evidence["patch_trail_audit_sequence"] = patch_trail_audit_sequence payload["evidence"] = evidence + else: + evidence_raw = payload.get("evidence", {}) + if isinstance(evidence_raw, Mapping): + evidence = dict(evidence_raw) + evidence.pop("patch_trail_audit_sequence", None) + payload["evidence"] = evidence + payload["retrieval_unavailable"] = "audit_write_failed" return payload # ------------------------------------------------------------------ @@ -818,9 +893,10 @@ def _resolve_changed_files_once( has_ref = diff_ref is not None and str(diff_ref).strip() != "" if has_files and has_ref: raise MCPServiceContractError(workflow_msgs.FINISH_EVIDENCE_XOR) + finding_session = _finding_session(self) if has_ref: return _require_non_empty_changed_evidence( - self._git_diff_paths( + finding_session._git_diff_paths( root_path=root_path, git_diff_ref=str(diff_ref), ) @@ -828,7 +904,7 @@ def _resolve_changed_files_once( if has_files: assert changed_files is not None return _require_non_empty_changed_evidence( - self._normalize_changed_paths( + finding_session._normalize_changed_paths( root_path=root_path, paths=changed_files, ) @@ -850,7 +926,7 @@ def _compute_transitive_summary( if not needs_transitive: return None - transitive_result = self._blast_radius_result( + transitive_result = _blast_session(self)._blast_radius_result( record=record, files=intent.scope.allowed_paths, depth="transitive", @@ -883,12 +959,10 @@ def _conditional_claim_validation( health_delta = structural_delta.get("health_delta") if isinstance(health_delta, int): patch_health_delta = health_delta - return _helpers.coerce_object_dict( - self.validate_review_claims( - text=claims_text, - run_id=record.run_id, - patch_health_delta=patch_health_delta, - ) + return _claim_session(self).validate_review_claims( + text=claims_text, + run_id=record.run_id, + patch_health_delta=patch_health_delta, ) @staticmethod @@ -997,7 +1071,11 @@ def _start_governance_omitted( blast_artifact = payload.get("blast_artifact") if isinstance(blast_artifact, Mapping): return { - "blast_radius": _start_blast_radius_omission_from_artifact(blast_artifact) + "blast_radius": _start_blast_radius_omission_from_artifact( + _helpers.coerce_object_dict( + cast(Mapping[object, object], blast_artifact) + ) + ) } return None @@ -1106,7 +1184,11 @@ def _workspace_summary_from_declare( def _as_conflict_list(value: object) -> list[dict[str, object]]: if not isinstance(value, list): return [] - return [item for item in value if isinstance(item, dict)] + return [ + _helpers.coerce_object_dict(cast(Mapping[object, object], item)) + for item in value + if isinstance(item, Mapping) + ] def _require_non_empty_changed_evidence(paths: Sequence[str]) -> tuple[str, ...]: @@ -1217,7 +1299,7 @@ def _budgeted_finish_response(payload: Mapping[str, object]) -> dict[str, object packed, response_budget_lanes=response_budget_lanes, ) - governed = attach_finish_context_governance( + governed = _attach_finish_governance( packed, evidence_omitted=omitted, ) @@ -1233,13 +1315,54 @@ def _budgeted_finish_response(payload: Mapping[str, object]) -> dict[str, object packed, response_budget_lanes=response_budget_lanes, ) - governed = attach_finish_context_governance( + governed = _attach_finish_governance( packed, evidence_omitted=omitted, ) return governed +def _attach_finish_governance( + payload: Mapping[str, object], + *, + evidence_omitted: Mapping[str, object] | None, +) -> dict[str, object]: + governed = attach_finish_context_governance( + payload, + evidence_omitted=evidence_omitted, + ) + blockers = _finish_retrieval_blockers(payload) + if blockers: + governance = governed.get("context_governance") + if isinstance(governance, dict): + enforcement_blocked = governance.get("enforcement_blocked") + if isinstance(enforcement_blocked, dict): + response_budget = enforcement_blocked.get("response_budget") + if isinstance(response_budget, list): + response_budget_values = cast(list[object], response_budget) + response_budget_values.extend(blockers) + return governed + + +def _finish_retrieval_blockers(payload: Mapping[str, object]) -> list[str]: + blockers: list[str] = [] + receipt = payload.get("receipt") + if ( + isinstance(receipt, Mapping) + and isinstance(receipt.get("content"), str) + and not _receipt_content_retrievable(payload) + ): + blockers.append("receipt_retrieval_unavailable") + patch_trail = payload.get("patch_trail") + if ( + isinstance(patch_trail, Mapping) + and str(patch_trail.get("patch_trail_digest", "")).strip() + and not _patch_trail_retrievable(payload) + ): + blockers.append("patch_trail_retrieval_unavailable") + return blockers + + def _next_reducible_finish_lane(payload: Mapping[str, object]) -> str | None: for lane in _FINISH_REDUCIBLE_LANES: if lane == "receipt_content" and _receipt_content_retrievable(payload): @@ -1258,7 +1381,9 @@ def _shrink_finish_lane(payload: dict[str, object], lane: str) -> None: if lane == "patch_trail": patch_trail = payload.get("patch_trail") if isinstance(patch_trail, Mapping): - payload["patch_trail"] = _compact_patch_trail_reference(patch_trail) + payload["patch_trail"] = _compact_patch_trail_reference( + _helpers.coerce_object_dict(cast(Mapping[object, object], patch_trail)) + ) def _finish_governance_omitted( @@ -1287,6 +1412,9 @@ def _receipt_content_retrievable(payload: Mapping[str, object]) -> bool: receipt = payload.get("receipt") if not isinstance(receipt, Mapping): return False + receipt_payload = _helpers.coerce_object_dict( + cast(Mapping[object, object], receipt) + ) content = receipt.get("content") if not isinstance(content, str) or not content: return False @@ -1294,7 +1422,7 @@ def _receipt_content_retrievable(payload: Mapping[str, object]) -> bool: if not isinstance(retrieval, Mapping): return False return retrieval.get("tool") == "get_review_receipt" and bool( - _receipt_digest_value(receipt) + _receipt_digest_value(receipt_payload) ) @@ -1302,7 +1430,10 @@ def _patch_trail_retrievable(payload: Mapping[str, object]) -> bool: patch_trail = payload.get("patch_trail") if not isinstance(patch_trail, Mapping): return False - if _is_compact_patch_trail_reference(patch_trail): + patch_trail_payload = _helpers.coerce_object_dict( + cast(Mapping[object, object], patch_trail) + ) + if _is_compact_patch_trail_reference(patch_trail_payload): return False digest = str(patch_trail.get("patch_trail_digest", "")).strip() if not digest: @@ -1319,7 +1450,10 @@ def _receipt_content_omission( receipt = payload.get("receipt") if not isinstance(receipt, Mapping): return None - digest = _receipt_digest_value(receipt) + receipt_payload = _helpers.coerce_object_dict( + cast(Mapping[object, object], receipt) + ) + digest = _receipt_digest_value(receipt_payload) if not digest: return None retrieval = receipt.get("receipt_retrieval") @@ -1620,15 +1754,15 @@ def _start_replay_request_key( return context_governance_digest("start_request_v1", payload)["value"] -def _start_workspace_state_digest(root_path: Path) -> dict[str, str]: - from ._workspace_hygiene import collect_dirty_snapshot - - snapshot = collect_dirty_snapshot(root_path).to_payload() +def _start_workspace_state_digest_from_snapshot( + snapshot: DirtySnapshot, +) -> dict[str, str]: + snapshot_payload = snapshot.to_payload() return context_governance_digest( "workspace_state_v1", { - "git_available": snapshot.get("git_available"), - "entries": snapshot.get("entries", {}), + "git_available": snapshot_payload.get("git_available"), + "entries": snapshot_payload.get("entries", {}), }, ) diff --git a/codeclone/surfaces/mcp/_workspace_hygiene.py b/codeclone/surfaces/mcp/_workspace_hygiene.py index 282b54a2..94a836c0 100644 --- a/codeclone/surfaces/mcp/_workspace_hygiene.py +++ b/codeclone/surfaces/mcp/_workspace_hygiene.py @@ -16,6 +16,7 @@ from pathlib import Path from typing import Final +from ...observability import record_counter, span from ._workspace_intent_lifecycle import ( WorkspaceIntentStatus, is_terminal_workspace_intent_status, @@ -274,65 +275,78 @@ def collect_dirty_paths( scoped_paths: Sequence[str] | None = None, ) -> DirtyPathsResult: """Collect repo-relative dirty paths from the git working tree.""" - if not _git_available(root): - return DirtyPathsResult(git_available=False, dirty_paths=()) - try: - completed = subprocess.run( - ["git", "status", "--porcelain"], - cwd=root, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): - return DirtyPathsResult(git_available=False, dirty_paths=()) - dirty = _dirty_paths_from_porcelain(completed.stdout) - if scoped_paths is not None: - scope_set = {_normalize_path(path) for path in scoped_paths if path.strip()} - dirty = tuple(sorted(path for path in dirty if _path_in_scope(path, scope_set))) - return DirtyPathsResult(git_available=True, dirty_paths=dirty) + with span(name="hygiene.collect_dirty_paths") as dirty_paths_span: + if not _git_available(root): + return DirtyPathsResult(git_available=False, dirty_paths=()) + try: + with span(name="hygiene.git.status", reason="dirty_paths"): + completed = subprocess.run( + ["git", "status", "--porcelain"], + cwd=root, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return DirtyPathsResult(git_available=False, dirty_paths=()) + dirty = _dirty_paths_from_porcelain(completed.stdout) + if scoped_paths is not None: + scope_set = {_normalize_path(path) for path in scoped_paths if path.strip()} + dirty = tuple( + sorted(path for path in dirty if _path_in_scope(path, scope_set)) + ) + dirty_paths_span.set_counter("dirty_paths", len(dirty)) + return DirtyPathsResult(git_available=True, dirty_paths=dirty) def collect_dirty_snapshot(root: Path) -> DirtySnapshot: """Collect full git dirty state with stable per-path digests when available.""" - captured_at = format_utc(utc_now()) - if not _git_available(root): - return DirtySnapshot( - git_available=False, - captured_at_utc=captured_at, - entries=(), - ) - try: - completed = subprocess.run( - ["git", "status", "--porcelain=v1"], - cwd=root, - check=True, - capture_output=True, - text=True, - timeout=30, - ) - except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + with span(name="hygiene.collect_dirty_snapshot") as snapshot_span: + captured_at = format_utc(utc_now()) + if not _git_available(root): + return DirtySnapshot( + git_available=False, + captured_at_utc=captured_at, + entries=(), + ) + try: + with span(name="hygiene.git.status", reason="dirty_snapshot"): + completed = subprocess.run( + ["git", "status", "--porcelain=v1"], + cwd=root, + check=True, + capture_output=True, + text=True, + timeout=30, + ) + except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): + return DirtySnapshot( + git_available=False, + captured_at_utc=captured_at, + entries=(), + ) + raw_entries = _dirty_entries_from_porcelain(completed.stdout) + with span(name="hygiene.dirty_entry_digests") as digest_span: + entries = tuple( + DirtySnapshotEntry( + path=path, + status_xy=status_xy, + digest=digest, + digest_status=digest_status, + ) + for path, status_xy in raw_entries + for digest, digest_status in ( + _dirty_entry_digest(root, path, status_xy), + ) + ) + digest_span.set_counter("dirty_entries", len(entries)) + snapshot_span.set_counter("dirty_paths", len(entries)) return DirtySnapshot( - git_available=False, + git_available=True, captured_at_utc=captured_at, - entries=(), - ) - entries = tuple( - DirtySnapshotEntry( - path=path, - status_xy=status_xy, - digest=digest, - digest_status=digest_status, + entries=tuple(sorted(entries, key=lambda entry: entry.path)), ) - for path, status_xy in _dirty_entries_from_porcelain(completed.stdout) - for digest, digest_status in (_dirty_entry_digest(root, path, status_xy),) - ) - return DirtySnapshot( - git_available=True, - captured_at_utc=captured_at, - entries=tuple(sorted(entries, key=lambda entry: entry.path)), - ) def dirty_snapshot_from_payload(payload: object) -> DirtySnapshot | None: @@ -395,6 +409,30 @@ def workspace_dirty_summary(*, root: Path) -> dict[str, object]: } +def dirty_summary_from_snapshot(snapshot: DirtySnapshot | None) -> dict[str, object]: + """Repo-level dirty summary derived from an existing finish snapshot. + + Same shape as workspace_dirty_summary, reusing the single finish-time + snapshot instead of a redundant git read. A missing or git-unavailable + snapshot yields the identical degraded envelope. + """ + if snapshot is None or not snapshot.git_available: + return { + "git_available": False, + "dirty_paths_count": 0, + "dirty_paths_sample": [], + "sample_truncated": False, + } + paths = snapshot.paths + sample, truncated = _bounded_sample(paths) + return { + "git_available": True, + "dirty_paths_count": len(paths), + "dirty_paths_sample": list(sample), + "sample_truncated": truncated, + } + + def _declared_scope_sets( allowed_files: Sequence[str], allowed_related: Sequence[str] | None, @@ -443,6 +481,36 @@ def _iter_foreign_intent_scope_matches( yield record, ownership.value, matched +def _scoped_dirty_result( + root: Path, + *, + evaluation_scope: set[str], + dirty_snapshot: DirtySnapshot | None, +) -> DirtyPathsResult: + """Scoped dirty paths, reusing a precomputed snapshot when available. + + When the caller already collected the full workspace-state snapshot (the + start path does this for the workspace_state digest), derive the scoped + paths from it instead of a second ``git status``+``rev-parse``. The result + is identical to ``collect_dirty_paths(root, scoped_paths=...)`` because both + come from the same porcelain parse: ``snapshot.paths`` is the sorted, + deduped, rename-split path set, then filtered by the same scope predicate. + """ + if dirty_snapshot is None: + return collect_dirty_paths( + root, + scoped_paths=tuple(sorted(evaluation_scope)) if evaluation_scope else None, + ) + if not dirty_snapshot.git_available: + return DirtyPathsResult(git_available=False, dirty_paths=()) + paths = dirty_snapshot.paths + if evaluation_scope: + paths = tuple( + sorted(path for path in paths if _path_in_scope(path, evaluation_scope)) + ) + return DirtyPathsResult(git_available=True, dirty_paths=paths) + + def evaluate_scoped_hygiene( *, root: Path, @@ -452,15 +520,22 @@ def evaluate_scoped_hygiene( own_pid: int, own_start_epoch: int, own_intent_id: str | None = None, + dirty_snapshot: DirtySnapshot | None = None, ) -> WorkspaceHygieneResult: - """Evaluate scoped hygiene for start/finish workflow responses.""" + """Evaluate scoped hygiene for start/finish workflow responses. + + ``dirty_snapshot`` lets the start path reuse the workspace-state snapshot it + already collected, avoiding a redundant scoped ``git status`` read. When it + is None the scoped paths are read fresh, preserving the standalone contract. + """ blocking_scope, _, evaluation_scope = _declared_scope_sets( allowed_files, allowed_related, ) - dirty_result = collect_dirty_paths( + dirty_result = _scoped_dirty_result( root, - scoped_paths=tuple(sorted(evaluation_scope)) if evaluation_scope else None, + evaluation_scope=evaluation_scope, + dirty_snapshot=dirty_snapshot, ) if not dirty_result.git_available: return WorkspaceHygieneResult( @@ -517,26 +592,38 @@ def finish_hygiene_check( strict_finish: bool | None = None, ) -> WorkspaceHygieneResult: """Finish-time hygiene gate against declared scope and git evidence.""" - hygiene = evaluate_scoped_hygiene( - root=root, - allowed_files=allowed_files, - allowed_related=allowed_related, + # Single finish-time tree read: this snapshot is the sole source of truth for + # finish workspace state. Git availability, the blocking-scope edit gate, and + # foreign overlaps are all derived from it (blocking scope is a subset of the + # full tree), replacing a redundant scoped read; the repo-level + # workspace_dirty_summary is likewise derived from this snapshot by the caller. + current_snapshot = collect_dirty_snapshot(root) + blocking_scope, related_scope, declared_scope = _declared_scope_sets( + allowed_files, + allowed_related, + ) + if not current_snapshot.git_available: + return WorkspaceHygieneResult( + git_available=False, + dirty_paths=(), + dirty_paths_in_scope=(), + dirty_paths_outside_scope=(), + foreign_dirty_overlaps=(), + blocks_edit=False, + ) + all_dirty_paths = current_snapshot.paths + dirty_in_blocking = tuple( + sorted(path for path in all_dirty_paths if _path_in_scope(path, blocking_scope)) + ) + foreign_dirty_overlaps = _foreign_dirty_overlaps( + dirty_paths=dirty_in_blocking, store=store, own_pid=own_pid, own_start_epoch=own_start_epoch, own_intent_id=own_intent_id, ) - if not hygiene.git_available: - return hygiene - current_snapshot = collect_dirty_snapshot(root) - if not current_snapshot.git_available: - return hygiene - all_dirty_paths = current_snapshot.paths + blocks_edit = bool(dirty_in_blocking) evidence = {_normalize_path(path) for path in resolved_files if path.strip()} - blocking_scope, related_scope, declared_scope = _declared_scope_sets( - allowed_files, - allowed_related, - ) dirty_in_declared = tuple( sorted(path for path in all_dirty_paths if _path_in_scope(path, declared_scope)) ) @@ -588,17 +675,17 @@ def finish_hygiene_check( # dirty_paths_outside_scope and the attribution detail). finish_block_reason = _finish_block_reason( unacknowledged=unacknowledged, - foreign_dirty_overlaps=hygiene.foreign_dirty_overlaps, + foreign_dirty_overlaps=foreign_dirty_overlaps, unattributed_unscoped=unattributed_unscoped, strict_finish=strict_finish, ) return WorkspaceHygieneResult( - git_available=hygiene.git_available, + git_available=True, dirty_paths=all_dirty_paths, dirty_paths_in_scope=dirty_in_declared, dirty_paths_outside_scope=dirty_outside_declared, - foreign_dirty_overlaps=hygiene.foreign_dirty_overlaps, - blocks_edit=hygiene.blocks_edit, + foreign_dirty_overlaps=foreign_dirty_overlaps, + blocks_edit=blocks_edit, unacknowledged_dirty_in_scope=unacknowledged, # Legacy alias retained for one contract cycle. These paths are # unattributed, not proven to be owned by the current agent. @@ -888,8 +975,25 @@ def _dirty_entry_digest( """Return a stable digest for the dirty content, or mark it unavailable.""" if status_xy == "??": return _untracked_file_digest(root, path) - cached = _git_diff_bytes(root, ["diff", "--cached", "--binary", "--", path]) - worktree = _git_diff_bytes(root, ["diff", "--binary", "--", path]) + # Porcelain XY already tells which side is guaranteed empty: X==' ' means the + # index matches HEAD (`git diff --cached` empty) and Y==' ' means the worktree + # matches the index (`git diff` empty). Skipping the guaranteed-empty side and + # substituting b"" is byte-identical to invoking git — which would return b"" — + # so the digest formula is unchanged while the common WIP case (unstaged edits) + # drops from two subprocess per path to one. Non-' ' codes (incl. unmerged UU) + # fall through to the diff to preserve the exact previous bytes. + index_status = status_xy[0] + worktree_status = status_xy[1] + cached = ( + _git_diff_bytes(root, ["diff", "--cached", "--binary", "--", path]) + if index_status != " " + else b"" + ) + worktree = ( + _git_diff_bytes(root, ["diff", "--binary", "--", path]) + if worktree_status != " " + else b"" + ) if cached is None or worktree is None: return None, "unavailable" digest = hashlib.sha256() @@ -904,6 +1008,7 @@ def _dirty_entry_digest( def _git_diff_bytes(root: Path, args: Sequence[str]) -> bytes | None: + record_counter("git_diff_invocations") try: completed = subprocess.run( ["git", *args], @@ -923,6 +1028,7 @@ def _git_diff_bytes(root: Path, args: Sequence[str]) -> bytes | None: def _untracked_file_digest(root: Path, path: str) -> tuple[str | None, str]: + record_counter("untracked_file_reads") target = (root / path).resolve() try: target.relative_to(root.resolve()) @@ -945,14 +1051,15 @@ def _untracked_file_digest(root: Path, path: str) -> tuple[str | None, str]: def _git_available(root: Path) -> bool: try: - completed = subprocess.run( - ["git", "rev-parse", "--is-inside-work-tree"], - cwd=root, - check=True, - capture_output=True, - text=True, - timeout=10, - ) + with span(name="hygiene.git.rev_parse"): + completed = subprocess.run( + ["git", "rev-parse", "--is-inside-work-tree"], + cwd=root, + check=True, + capture_output=True, + text=True, + timeout=10, + ) except (OSError, subprocess.CalledProcessError, subprocess.TimeoutExpired): return False return completed.stdout.strip().lower() == "true" @@ -1015,6 +1122,7 @@ def hygiene_blocks_start_edit( "collect_dirty_paths", "collect_dirty_snapshot", "dirty_snapshot_from_payload", + "dirty_summary_from_snapshot", "evaluate_scoped_hygiene", "finish_hygiene_check", "hygiene_blocks_start_edit", diff --git a/codeclone/surfaces/mcp/_workspace_intent_models.py b/codeclone/surfaces/mcp/_workspace_intent_models.py index 5cd94263..cb13fe19 100644 --- a/codeclone/surfaces/mcp/_workspace_intent_models.py +++ b/codeclone/surfaces/mcp/_workspace_intent_models.py @@ -408,9 +408,16 @@ def record_from_document(document: WorkspaceIntentDocument) -> WorkspaceIntentRe def signed_payload_dict_from_record(record: object) -> dict[str, object]: if not isinstance(record, WorkspaceIntentRecord): - msg = "record must be a WorkspaceIntentRecord" - raise TypeError(msg) - unsigned = record.unsigned_payload() + raise TypeError("record must be a WorkspaceIntentRecord") + raw_unsigned = record.unsigned_payload() + provisional = { + **raw_unsigned, + "integrity": {"payload_sha256": compute_intent_digest(raw_unsigned)}, + } + document = parse_workspace_document(provisional) + if document is None: + raise ValueError("record must contain a valid WorkspaceIntentRecord payload") + unsigned = unsigned_document_payload(document) return { **unsigned, "integrity": {"payload_sha256": compute_intent_digest(unsigned)}, diff --git a/codeclone/surfaces/mcp/_workspace_intent_store.py b/codeclone/surfaces/mcp/_workspace_intent_store.py index 5eecbc3b..b31190e9 100644 --- a/codeclone/surfaces/mcp/_workspace_intent_store.py +++ b/codeclone/surfaces/mcp/_workspace_intent_store.py @@ -205,7 +205,9 @@ def __init__(self, *, db_path: Path, retention_days: int) -> None: self._db_path = db_path self._retention_days = retention_days self._lock = threading.Lock() - self._conn = open_intent_registry_db(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + with workspace_registry_lock(self.registry_lock_path): + self._conn = open_intent_registry_db(db_path) @property def backend(self) -> str: @@ -407,11 +409,12 @@ def _load_all_records_unlocked(self) -> tuple[WorkspaceIntentRecord, ...]: ).fetchall() except sqlite3.Error: return () - return tuple( + records = tuple( record for record in (_record_from_json(row[0]) for row in rows) if record is not None ) + return tuple(sorted(records, key=record_sort_key)) def _fetch_record_unlocked( self, diff --git a/codeclone/surfaces/mcp/messages/help_topics.py b/codeclone/surfaces/mcp/messages/help_topics.py index c241490c..de3cd000 100644 --- a/codeclone/surfaces/mcp/messages/help_topics.py +++ b/codeclone/surfaces/mcp/messages/help_topics.py @@ -24,46 +24,94 @@ class MCPHelpTopicSpec: anti_patterns: tuple[str, ...] = () -MCP_BOOK_URL: Final = f"{DOCS_URL}book/" -MCP_GUIDE_URL: Final = f"{DOCS_URL}guide/mcp/" MCP_INTERFACE_DOC_LINK: Final[tuple[str, str]] = ( "MCP interface contract", - f"{MCP_BOOK_URL}25-mcp-interface/", + f"{DOCS_URL}concepts/mcp/", ) BASELINE_DOC_LINK: Final[tuple[str, str]] = ( "Baseline contract", - f"{MCP_BOOK_URL}07-baseline/", + f"{DOCS_URL}concepts/reports/", ) CONFIG_DOC_LINK: Final[tuple[str, str]] = ( "Config and defaults", - f"{MCP_BOOK_URL}10-config-and-defaults/", + f"{DOCS_URL}reference/configuration/", ) REPORT_DOC_LINK: Final[tuple[str, str]] = ( "Report contract", - f"{MCP_BOOK_URL}05-report/", + f"{DOCS_URL}reference/reports/", ) CLI_DOC_LINK: Final[tuple[str, str]] = ( "CLI contract", - f"{MCP_BOOK_URL}11-cli/", + f"{DOCS_URL}reference/cli/", ) PIPELINE_DOC_LINK: Final[tuple[str, str]] = ( "Core pipeline", - f"{MCP_BOOK_URL}03-core-pipeline/", + f"{DOCS_URL}concepts/structural-analysis/", ) SUPPRESSIONS_DOC_LINK: Final[tuple[str, str]] = ( "Inline suppressions contract", - f"{MCP_BOOK_URL}19-inline-suppressions/", + f"{DOCS_URL}reference/suppressions/", +) +MCP_GUIDE_DOC_LINK: Final[tuple[str, str]] = ( + "MCP usage guide", + f"{DOCS_URL}guides/agent-safe-change/", ) -MCP_GUIDE_DOC_LINK: Final[tuple[str, str]] = ("MCP usage guide", MCP_GUIDE_URL) CHANGE_CONTROL_DOC_LINK: Final[tuple[str, str]] = ( "Structural change controller", - f"{MCP_BOOK_URL}12-structural-change-controller/", + f"{DOCS_URL}concepts/controlled-change/", ) ENGINEERING_MEMORY_DOC_LINK: Final[tuple[str, str]] = ( "Engineering Memory", - f"{MCP_BOOK_URL}13-engineering-memory/", + f"{DOCS_URL}concepts/engineering-memory/", ) HELP_TOPIC_SPECS: Final[dict[str, MCPHelpTopicSpec]] = { + "overview": MCPHelpTopicSpec( + summary=( + "Index of CodeClone MCP workflows. Use it to choose the narrowest " + "tool before spending context on broad evidence." + ), + key_points=( + ( + "Start with analyze_repository or analyze_changed_paths, then " + "open get_run_summary or get_production_triage." + ), + ( + "Use list_hotspots or focused check_* tools before broad " + "list_findings calls." + ), + ( + "Budgeted responses disclose hidden evidence through " + "context_governance.omitted and the top-level _continuation " + "pointer." + ), + ( + "For edits, use start_controlled_change, retrieve scoped " + "memory/context, then finish_controlled_change." + ), + ), + recommended_tools=( + "analyze_repository", + "analyze_changed_paths", + "get_run_summary", + "get_production_triage", + "list_hotspots", + "get_implementation_context", + "get_relevant_memory", + "start_controlled_change", + "finish_controlled_change", + ), + doc_links=(MCP_INTERFACE_DOC_LINK, MCP_GUIDE_DOC_LINK), + warnings=( + ( + "The overview is an index, not a substitute for the specific " + "topic help that owns each contract." + ), + ), + anti_patterns=( + "Starting with broad list_findings when a hotlist or check_* tool fits.", + "Ignoring _continuation when context_governance.omitted is present.", + ), + ), "workflow": MCPHelpTopicSpec( summary=( "CodeClone MCP is triage-first and budget-aware. Start with a " @@ -656,7 +704,7 @@ class MCPHelpTopicSpec: ( "detail_level compact|normal; full is reserved for future " "by-id detail sections and downgrades to normal here. limit " - "clamps to [1, 50]." + "clamps to [1, 100]." ), ( "Anti-inference: this is CodeClone's runtime, not the user " diff --git a/codeclone/surfaces/mcp/messages/params.py b/codeclone/surfaces/mcp/messages/params.py index 974b0b1e..01292d64 100644 --- a/codeclone/surfaces/mcp/messages/params.py +++ b/codeclone/surfaces/mcp/messages/params.py @@ -11,6 +11,8 @@ from pydantic import Field +from ....memory.enums import MemoryRecordType + RootParam = Annotated[str, Field(description="Absolute repository root path.")] OptionalRootParam = Annotated[ str | None, @@ -258,6 +260,10 @@ description="Stored receipt output: structured (typed, default) or markdown.", ), ] +PatchTrailRetrievalFormatParam = Annotated[ + str, + Field(description="Stored patch-trail output: structured (typed, default)."), +] ReceiptDigestParam = Annotated[ str | None, Field( @@ -312,10 +318,10 @@ str, Field( description=( - "workflow, analysis_profile, suppressions, baseline, coverage, " - "latest_runs, review_state, changed_scope, change_control, " - "trust_boundaries, engineering_memory, implementation_context, " - "verification_profiles, observability" + "overview, workflow, analysis_profile, suppressions, baseline, " + "coverage, latest_runs, review_state, changed_scope, " + "change_control, trust_boundaries, engineering_memory, " + "implementation_context, verification_profiles, observability" ) ), ] @@ -402,7 +408,13 @@ Field(description="Optional hard cap on returned items."), ] FindingIdParam = Annotated[ - str, Field(description="Short or full canonical finding id.") + str, + Field( + description=( + "Short MCP finding id or full canonical finding id; get_finding " + "returns status=not_found for unknown ids." + ) + ), ] HotspotKindParam = Annotated[ str, @@ -601,7 +613,8 @@ "Optional filters: types, statuses, confidences, match_mode " "(any|all, search mode only), include_routine (trajectory_search, " "trajectory_anomalies, trajectory_agents, trajectory_dashboard; " - "default false excludes run:* routine workflows)." + "default false excludes run:* routine workflows). Unknown filter " + "keys are rejected with a typed contract error." ), ), ] @@ -730,7 +743,7 @@ Field(description="IDE attestation protocol version (currently 2)."), ] MemoryRecordTypeParam = Annotated[ - str | None, + MemoryRecordType | None, Field(description="Memory record type for record_candidate."), ] MemoryStatementParam = Annotated[ @@ -757,7 +770,9 @@ description=( "Telemetry section to project: summary | slow_operations | " "memory_pipeline_cost | db_cost | agent_context | mcp_tool_matrix | " - "correlated_chains | costly_noops | pipeline | analysis_phase_cost." + "correlated_chains | costly_noops | pipeline | analysis_phase_cost | " + "operation_detail (per-span detail for one operation_id) | " + "span_detail (one span_id)." ), ), ] @@ -772,7 +787,7 @@ ] ObservabilityLimitParam = Annotated[ int, - Field(description="Row cap per section; clamped to [1, 50], else 10."), + Field(description="Row cap per section; clamped to [1, 100], else 10."), ] ObservabilityWindowParam = Annotated[ str, @@ -780,9 +795,19 @@ ] ObservabilityOperationIdParam = Annotated[ str | None, - Field(description="Reserved for detail sections; echoed in ignored_parameters."), + Field( + description=( + "Selects the operation for section=operation_detail; echoed in " + "ignored_parameters for aggregate sections that do not consume it." + ), + ), ] ObservabilitySpanIdParam = Annotated[ str | None, - Field(description="Reserved for detail sections; echoed in ignored_parameters."), + Field( + description=( + "Selects the span for section=span_detail; echoed in " + "ignored_parameters for aggregate sections that do not consume it." + ), + ), ] diff --git a/codeclone/surfaces/mcp/messages/tools.py b/codeclone/surfaces/mcp/messages/tools.py index d956b5e2..9f381b6c 100644 --- a/codeclone/surfaces/mcp/messages/tools.py +++ b/codeclone/surfaces/mcp/messages/tools.py @@ -172,9 +172,10 @@ ) HELP: Final = ( - "Bounded workflow/contract guidance with doc links. compact adds " - "anti_patterns; normal adds warnings. Topics: workflow, analysis_profile, " - "suppressions, baseline, coverage, latest_runs, review_state, " + "Bounded workflow/contract guidance with doc links. topic=overview returns " + "a compact topic index. compact adds anti_patterns; normal adds warnings. " + "Topics: overview, workflow, analysis_profile, suppressions, baseline, " + "coverage, latest_runs, review_state, " "changed_scope, change_control, trust_boundaries, engineering_memory, " "implementation_context, verification_profiles, observability." ) @@ -219,7 +220,7 @@ "Return a single canonical finding group by short or full id. " "Normal detail is the default. Use this after list_hotspots, " "list_findings, or check_* instead of requesting larger lists at " - "higher detail." + "higher detail. Unknown ids return a structured status=not_found response." ) GET_REMEDIATION: Final = ( @@ -233,7 +234,9 @@ LIST_HOTSPOTS: Final = ( "Return one of the derived CodeClone hotlists for the latest or " "specified MCP run, using compact summary cards by default. Prefer " - "this for first-pass triage before broader list_findings calls." + "this for first-pass triage before broader list_findings calls. Empty " + "hotlists include empty_reason so agents know whether a filter or the " + "derived hotlist itself removed all entries." ) COMPARE_RUNS: Final = ( diff --git a/codeclone/surfaces/mcp/server.py b/codeclone/surfaces/mcp/server.py index 7432dacf..8f8f0fe1 100644 --- a/codeclone/surfaces/mcp/server.py +++ b/codeclone/surfaces/mcp/server.py @@ -16,7 +16,7 @@ from collections.abc import AsyncIterator, Callable, Mapping from contextlib import asynccontextmanager from pathlib import Path -from typing import TYPE_CHECKING, Literal, TypeVar +from typing import TYPE_CHECKING, Literal, Protocol, TypeVar, cast from ... import __version__ from ...config.observability import resolve_observability_config @@ -154,6 +154,7 @@ PatchModeParam, PatchTrailDetailParam, PatchTrailDigestParam, + PatchTrailRetrievalFormatParam, PathFilterParam, PrFormatParam, ProcessesParam, @@ -193,7 +194,7 @@ if TYPE_CHECKING: from mcp.server.fastmcp import FastMCP - from mcp.types import ToolAnnotations + from mcp.types import Annotations, Icon, ToolAnnotations DEFAULT_MCP_HOST = "127.0.0.1" DEFAULT_MCP_PORT = 8000 @@ -210,12 +211,18 @@ class MCPDependencyError(RuntimeError): MCPCallable = TypeVar("MCPCallable", bound=Callable[..., object]) +class _SignatureBearingToolWrapper(Protocol): + __signature__: inspect.Signature + + def __call__(self, *args: object, **kwargs: object) -> object: ... + + def _observability_session_id() -> str: """Stable per-process id grouping every operation from this MCP server.""" return f"mcp:{os.getpid()}:{int(time.time())}" -def _instrument_tool(func: Callable[..., object]) -> Callable[..., object]: +def _instrument_tool(func: MCPCallable) -> MCPCallable: """Wrap a registered MCP tool so each call records an observability operation with request/response payload sizes (bytes + context-unit estimates). @@ -246,7 +253,10 @@ def wrapper(*args: object, **kwargs: object) -> object: with span(name=f"mcp.{tool_name}"): result = func(*args, **kwargs) if payload_capture_enabled() and isinstance(result, Mapping): - response_bytes, response_context_units = measure_payload(result) + response_payload = cast("Mapping[str, object]", result) + response_bytes, response_context_units = measure_payload( + response_payload + ) op.set_response( response_bytes=response_bytes, response_tokens=response_context_units, @@ -255,10 +265,9 @@ def wrapper(*args: object, **kwargs: object) -> object: # eval_str resolves the tool's string annotations (PEP 563) into real types # so FastMCP/Pydantic build the same input schema as the unwrapped handler. - wrapper.__signature__ = inspect.signature( # type: ignore[attr-defined] - func, eval_str=True - ) - return wrapper + signature_wrapper = cast("_SignatureBearingToolWrapper", wrapper) + signature_wrapper.__signature__ = inspect.signature(func, eval_str=True) + return cast("MCPCallable", signature_wrapper) def _load_mcp_runtime() -> tuple[ @@ -381,8 +390,25 @@ async def _lifespan(_app: FastMCP) -> AsyncIterator[dict[str, object]]: # clientInfo (name/version) for workspace intent agent_label fields. service._fastmcp = mcp - def tool(*args: object, **kwargs: object) -> Callable[[MCPCallable], MCPCallable]: - decorator = mcp.tool(*args, **kwargs) # type: ignore[arg-type] + def tool( + name: str | None = None, + *, + title: str | None = None, + description: str | None = None, + annotations: ToolAnnotations | None = None, + icons: list[Icon] | None = None, + meta: dict[str, object] | None = None, + structured_output: bool | None = None, + ) -> Callable[[MCPCallable], MCPCallable]: + decorator = mcp.tool( + name=name, + title=title, + description=description, + annotations=annotations, + icons=icons, + meta=meta, + structured_output=structured_output, + ) def register(func: MCPCallable) -> MCPCallable: decorator(_instrument_tool(func)) @@ -391,10 +417,26 @@ def register(func: MCPCallable) -> MCPCallable: return register def resource( - *args: object, - **kwargs: object, + uri: str, + *, + name: str | None = None, + title: str | None = None, + description: str | None = None, + mime_type: str | None = None, + icons: list[Icon] | None = None, + annotations: Annotations | None = None, + meta: dict[str, object] | None = None, ) -> Callable[[MCPCallable], MCPCallable]: - decorator = mcp.resource(*args, **kwargs) # type: ignore[arg-type] + decorator = mcp.resource( + uri, + name=name, + title=title, + description=description, + mime_type=mime_type, + icons=icons, + annotations=annotations, + meta=meta, + ) def register(func: MCPCallable) -> MCPCallable: decorator(func) @@ -857,11 +899,13 @@ def get_patch_trail( root: RootParam, run_id: RunIdParam = None, patch_trail_digest: PatchTrailDigestParam = None, + format: PatchTrailRetrievalFormatParam = "structured", ) -> dict[str, object]: return service.get_patch_trail( root=root, run_id=run_id, patch_trail_digest=patch_trail_digest, + format=format, ) @tool( diff --git a/codeclone/surfaces/mcp/service.py b/codeclone/surfaces/mcp/service.py index 63167cb6..24fde53d 100644 --- a/codeclone/surfaces/mcp/service.py +++ b/codeclone/surfaces/mcp/service.py @@ -8,6 +8,7 @@ import inspect from typing import Protocol +from ...utils.payload_narrow import is_payload_dict from ._workspace_intents import safe_remove_own_intent from .session import ( DEFAULT_MCP_HISTORY_LIMIT, @@ -192,7 +193,7 @@ def __init__( def _run_dict(self, method_name: str, **params: object) -> dict[str, object]: bound = getattr(self._session_cls, method_name).__get__(self, type(self)) result = run_kw(bound, params) - if not isinstance(result, dict): + if not is_payload_dict(result): raise TypeError(f"MCP session method '{method_name}' must return a dict.") return result diff --git a/codeclone/surfaces/mcp/session.py b/codeclone/surfaces/mcp/session.py index 497be88e..55772344 100644 --- a/codeclone/surfaces/mcp/session.py +++ b/codeclone/surfaces/mcp/session.py @@ -9,7 +9,9 @@ import os import time from collections.abc import Mapping +from dataclasses import replace from pathlib import Path +from types import TracebackType from ...audit import AuditEvent, AuditWriter, repo_root_digest from ...audit.runtime import open_audit_writer_for_root @@ -32,6 +34,7 @@ from ._session_blast_radius_mixin import _MCPSessionBlastRadiusMixin from ._session_claim_guard_mixin import _MCPSessionClaimGuardMixin from ._session_context_mixin import _MCPSessionContextMixin +from ._session_finding_mixin import _StateLock from ._session_insights_mixin import _MCPSessionInsightsMixin from ._session_intent_mixin import _MCPSessionIntentMixin from ._session_memory_mixin import _MCPSessionMemoryMixin @@ -82,6 +85,32 @@ from ._workspace_drift import build_run_manifest from ._workspace_hygiene import collect_dirty_snapshot + +class _RuntimeStateLock: + """RLock adapter with a typing-friendly context manager surface.""" + + __slots__ = ("_lock",) + + def __init__(self) -> None: + self._lock = RLock() + + def __enter__(self) -> object: + return self._lock.__enter__() + + def __exit__( + self, + exc_type: type[BaseException] | None, + exc: BaseException | None, + tb: TracebackType | None, + ) -> bool | None: + self._lock.__exit__(exc_type, exc, tb) + return None + + +def _new_state_lock() -> _StateLock: + return _RuntimeStateLock() + + __all__ = [ "DEFAULT_MCP_HISTORY_LIMIT", "MAX_MCP_HISTORY_LIMIT", @@ -125,12 +154,12 @@ def __init__( self._ide_governance = IdeGovernanceSessionState( channel_enabled=ide_governance_channel ) - self._state_lock = RLock() + self._state_lock = _new_state_lock() self._review_state: dict[str, OrderedDict[str, str | None]] = {} self._last_gate_results: dict[str, dict[str, object]] = {} self._spread_max_cache: dict[str, int] = {} self._blast_radius_cache: dict[ - tuple[str, tuple[str, ...], str], + tuple[str, tuple[str, ...], str, tuple[str, ...], tuple[str, ...]], BlastRadiusResult, ] = {} self._context_projection_pages: dict[str, ContextProjectionArtifact] = {} @@ -222,6 +251,12 @@ def _audit_emit( ) ) except Exception: + try: + from ...observability import record_counter + + record_counter("audit.emit_dropped") + except Exception: + pass return None def _audit_writer_for_root(self, root: Path) -> AuditWriter: @@ -249,20 +284,22 @@ def analyze_repository(self, request: MCPAnalysisRequest) -> dict[str, object]: git_diff_ref=request.git_diff_ref, ) args = self._build_args(root_path=root_path, request=request) - ( - baseline_path, - baseline_exists, - metrics_baseline_path, - metrics_baseline_exists, - shared_baseline_payload, - ) = self._resolve_baseline_inputs(root_path=root_path, args=args) + with span(name="pipeline.baseline"): + ( + baseline_path, + baseline_exists, + metrics_baseline_path, + metrics_baseline_exists, + shared_baseline_payload, + ) = self._resolve_baseline_inputs(root_path=root_path, args=args) cache_path = _helpers._resolve_cache_path(root_path=root_path, args=args) - cache = _helpers._build_cache( - root_path=root_path, - args=args, - cache_path=cache_path, - policy=request.cache_policy, - ) + with span(name="pipeline.cache_load"): + cache = _helpers._build_cache( + root_path=root_path, + args=args, + cache_path=cache_path, + policy=request.cache_policy, + ) console = _BufferConsole() # Stage spans so mcp.analyze_repository carries the same discover/process/ @@ -413,21 +450,26 @@ def analyze_repository(self, request: MCPAnalysisRequest) -> dict[str, object]: analysis_result.project_metrics ) - report_artifacts = report( - boot=boot, - discovery=discovery_result, - processing=processing_result, - analysis=analysis_result, - report_meta=report_meta, - new_func=new_func, - new_block=new_block, - metrics_diff=metrics_diff, - ) - report_json = report_artifacts.json - if report_json is None: - raise MCPServiceError("CodeClone MCP expected a canonical JSON report.") - report_document = _helpers._load_report_document(report_json) - run_id = _helpers._report_digest(report_document) + cache.release_loaded_entries() + with span(name="pipeline.report"): + report_boot = replace(boot, output_paths=OutputPaths()) + report_artifacts = report( + boot=report_boot, + discovery=discovery_result, + processing=processing_result, + analysis=analysis_result, + report_meta=report_meta, + new_func=new_func, + new_block=new_block, + metrics_diff=metrics_diff, + include_report_document=True, + ) + report_document = report_artifacts.report_document + if report_document is None: + raise MCPServiceError( + "CodeClone MCP expected a canonical report document." + ) + run_id = _helpers._report_digest(report_document) warning_items = set(console.messages) baseline_warning = getattr(clone_baseline_state, "warning_message", None) diff --git a/codeclone/ui_messages/help.py b/codeclone/ui_messages/help.py index f6391812..09086ca9 100644 --- a/codeclone/ui_messages/help.py +++ b/codeclone/ui_messages/help.py @@ -212,3 +212,104 @@ "Print debug details for internal errors, including traceback and\n" "environment information." ) +HELP_INTERACTIVE = ( + "Open the guided CodeClone product tour.\n" + "Use together with --help in an interactive terminal." +) +HELP_MASCOT_TAGLINE = ( + "Run `codeclone --help --interactive-help` for a guided product tour." +) +HELP_TOUR_STEP_INTRO_TITLE = "CodeClone product tour" +HELP_TOUR_STEP_INTRO_BODY = ( + "CodeClone is a deterministic Structural Change Controller for AI-assisted\n" + "Python development. It starts before a diff exists: declare intent, map the\n" + "structural blast radius, bound the edit, verify the patch, and leave an\n" + "auditable receipt. Docs: https://orenlab.github.io/codeclone/" +) +HELP_TOUR_STEP_PIPELINE_TITLE = "One analysis, many projections" +HELP_TOUR_STEP_PIPELINE_BODY = ( + "The pipeline scans files, parses Python, normalizes structural facts,\n" + "builds fingerprints, derives clones and metrics, then emits one canonical\n" + "report. CLI, HTML, JSON, SARIF, MCP, and IDE clients project the same facts.\n" + "First run: `codeclone .`; HTML: `codeclone . --html --open-html-report`." +) +HELP_TOUR_STEP_CLONES_TITLE = "Fingerprinting structural clones" +HELP_TOUR_STEP_CLONES_BODY = ( + "Function, block, and segment clones are grouped from normalized AST facts.\n" + "Fingerprints stay stable across renames. NEW vs KNOWN is baseline-relative,\n" + "not a patch-local proof by itself.\n" + "Tune sensitivity with `--min-loc`, `--min-stmt`, and pyproject thresholds." +) +HELP_TOUR_STEP_CACHE_TITLE = "Reusing structural facts" +HELP_TOUR_STEP_CACHE_BODY = ( + "The integrity-checked cache under `.codeclone/cache.json` speeds repeat runs.\n" + "Cache is optimization only, never analysis truth. Reports record whether\n" + "cache was used; profile mismatch or invalid cache is ignored safely." +) +HELP_TOUR_STEP_DEPENDENCIES_TITLE = "Following dependency pressure" +HELP_TOUR_STEP_DEPENDENCIES_BODY = ( + "Module graphs surface cycles, coupling hotspots, and likely blast-radius\n" + "neighbors before a change. Query a focused impact view with\n" + "`codeclone --blast-radius path/to/file.py` after a normal analysis run." +) +HELP_TOUR_STEP_METRICS_TITLE = "Measuring project health" +HELP_TOUR_STEP_METRICS_BODY = ( + "Metrics cover cyclomatic complexity, class coupling/cohesion, dead code,\n" + "dependency cycles, typing/docstring adoption, and a composite health score.\n" + "Gate with `--fail-complexity`, `--fail-dead-code`, `--fail-health`, and more." +) +HELP_TOUR_STEP_REPORTS_TITLE = "Publishing the same evidence" +HELP_TOUR_STEP_REPORTS_BODY = ( + "The canonical report powers HTML triage, JSON, Markdown, SARIF 2.1, and text.\n" + "Export SARIF for GitHub code scanning. Browse the public sample report from\n" + "the documentation site. Default HTML path: `.codeclone/report.html`." +) +HELP_TOUR_STEP_BASELINE_TITLE = "Baseline-aware CI gating" +HELP_TOUR_STEP_BASELINE_BODY = ( + "`codeclone . --ci` fails on NEW clone findings vs a trusted baseline.\n" + "The metrics baseline can track API breaks and typing/docstring regressions.\n" + "The GitHub Action and `codeclone setup wizard` help align repository hygiene." +) +HELP_TOUR_STEP_CONTROLLER_TITLE = "Governed change control" +HELP_TOUR_STEP_CONTROLLER_BODY = ( + "For AI-assisted work, the controller starts before the diff: declare intent,\n" + "inspect blast radius, retrieve scoped memory, verify the patch, and leave a\n" + "receipt. MCP workflow: `start_controlled_change` and `finish_controlled_change`." +) +HELP_TOUR_STEP_MEMORY_TITLE = "Engineering Memory" +HELP_TOUR_STEP_MEMORY_BODY = ( + "Local evidence-linked memory: scoped retrieval, trajectories, Patch Trail,\n" + "and Experience patterns. Agents propose drafts; humans approve in VS Code.\n" + "Memory guides, but never grants edit permission." +) +HELP_TOUR_STEP_INTEGRATIONS_TITLE = "IDE and agent clients" +HELP_TOUR_STEP_INTEGRATIONS_BODY = ( + "Native surfaces: VS Code extension, Cursor/Codex/Claude Code plugins,\n" + "Claude Desktop bundle, and GitHub Action. They all use the same local\n" + "`codeclone-mcp` server and the same canonical analysis facts.\n" + 'Install MCP support with `pip install "codeclone[mcp]"`.' +) +HELP_TOUR_STEP_SUCCESS_TITLE = "Project health 91 / A" +HELP_TOUR_STEP_SUCCESS_BODY = ( + "A typical clean run ends with health grade, inventory summary, and report path.\n" + "Explore `--patch-verify` for budget checks and `--session-stats` for workspace\n" + "coordination when multiple agents share a repo." +) +HELP_TOUR_STEP_REGRESSION_TITLE = "2 new structural regressions" +HELP_TOUR_STEP_REGRESSION_BODY = ( + "When CI or `--ci` sees NEW clones or metric regressions, the run should stop\n" + "for review. Inspect HTML or JSON, fix the issue, or update the baseline only\n" + "after deliberate human inspection." +) +HELP_TOUR_STEP_BLOCKED_TITLE = "STOP: do-not-touch boundary" +HELP_TOUR_STEP_BLOCKED_BODY = ( + "The controller blocks edits on baselines, generated reports, and `.codeclone/`\n" + "state unless explicitly scoped. `do_not_touch` is a hard boundary; expand\n" + "scope deliberately via a fresh intent. Never bypass it silently." +) +HELP_TOUR_STEP_NEXT_TITLE = "Ready when you are" +HELP_TOUR_STEP_NEXT_BODY = ( + "Run `codeclone .`, open the documentation site, wire MCP where needed, try\n" + "`codeclone setup wizard`, and use `codeclone --help` for the complete flag\n" + "reference." +) diff --git a/codeclone/ui_messages/setup.py b/codeclone/ui_messages/setup.py new file mode 100644 index 00000000..f56629ca --- /dev/null +++ b/codeclone/ui_messages/setup.py @@ -0,0 +1,276 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Human copy for ``codeclone setup`` readiness projection.""" + +from __future__ import annotations + +from typing import Final + +SETUP_STATUS_TITLE: Final = "CodeClone setup readiness" +SETUP_DOCTOR_TITLE: Final = "CodeClone setup doctor" +SETUP_PLAN_TITLE: Final = "CodeClone setup plan" +SETUP_PLAN_EMPTY: Final = "No configuration changes recommended." +SETUP_PLAN_BLOCKED: Final = ( + "Plan blocked until pyproject.toml validation issues are resolved." +) +SETUP_PLAN_READ_ONLY_NOTE: Final = "Read-only preview — no files were modified." +SETUP_APPLY_TITLE: Final = "CodeClone setup apply" +SETUP_APPLY_NOOP: Final = "No plan actions were applied." +SETUP_APPLY_BLOCKED: Final = "Apply blocked — fix plan blockers and re-run setup plan." +SETUP_APPLY_CONFIRM_REQUIRED: Final = ( + "Refusing to modify files without confirmation. Re-run with --yes to apply " + "non-interactively, or use --dry-run to preview changes." +) +SETUP_APPLY_CONFIRM_PROMPT: Final = "Apply these configuration changes?" +SETUP_APPLY_ABORTED: Final = "Apply aborted — no files were modified." +SETUP_APPLY_STALE_PLAN: Final = ( + "Repository changed since the plan was computed. Re-run `codeclone setup plan` " + "and apply again." +) +SETUP_DRY_RUN_ONLY_APPLY: Final = "--dry-run is only valid for `codeclone setup apply`." +SETUP_YES_ONLY_APPLY: Final = "--yes is only valid for `codeclone setup apply`." +SETUP_PLAN_ID_ONLY_APPLY: Final = "--plan-id is only valid for `codeclone setup apply`." +SETUP_WIZARD_JSON_UNSUPPORTED: Final = ( + "The interactive wizard has no --json output; use `setup --json` or " + "`setup plan --json`." +) +SETUP_WIZARD_TITLE: Final = "CodeClone setup wizard" +SETUP_WIZARD_HUB_RULE: Final = "Capability hub" +SETUP_WIZARD_SPHERE_RULE: Final = "capability sphere" +SETUP_WIZARD_PROMPT: Final = "Select hub item" +SETUP_WIZARD_GUIDED_LABEL: Final = "Guided setup" +SETUP_WIZARD_GUIDED_HINT: Final = "Plan → confirm → apply → refresh readiness" +SETUP_WIZARD_DOCTOR_LABEL: Final = "Doctor" +SETUP_WIZARD_DOCTOR_HINT: Final = "Verbose probe diagnostics" +SETUP_WIZARD_QUIT_LABEL: Final = "Quit" +SETUP_WIZARD_QUIT_HINT: Final = "Exit the wizard" +SETUP_WIZARD_CONFIRM_APPLY: Final = "Apply the recommended configuration changes?" +SETUP_WIZARD_APPLY_SKIPPED: Final = "Apply skipped — no files were modified." +SETUP_WIZARD_GUIDED_BLOCKED: Final = ( + "Guided setup blocked until pyproject.toml validation issues are resolved." +) +SETUP_WIZARD_GUIDED_EMPTY: Final = "Guided setup found no changes to apply." +SETUP_WIZARD_UPDATED_READINESS: Final = "Updated readiness after apply:" +SETUP_WIZARD_SPHERE_EMPTY: Final = "No capabilities in this sphere." +SETUP_WIZARD_TTY_REQUIRED: Final = ( + "Interactive setup wizard requires a TTY. " + "Use `codeclone setup plan` and `codeclone setup apply` instead." +) +SETUP_WIZARD_RICH_REQUIRED: Final = ( + "Interactive setup wizard requires Rich console output." +) + +GROUP_LABELS: Final[dict[str, str]] = { + "core_analysis": "Core analysis", + "governed_agent_workflows": "Governed agent workflows", + "project_knowledge": "Project knowledge", + "team_and_release": "Team & release", +} + +CAPABILITY_LABELS: Final[dict[str, str]] = { + "analysis": "Repository analysis", + "baseline": "Baseline & trust", + "reports": "Reports", + "mcp_runtime": "MCP runtime", + "controlled_change": "Controlled changes", + "audit_and_intents": "Audit & intents", + "engineering_memory": "Engineering Memory", + "semantic_retrieval": "Semantic retrieval", + "coverage_evidence": "Coverage evidence", + "analytics_cockpit": "Cockpit / analytics", + "ci_policy": "CI policy", + "github_workflow": "GitHub workflow", + "pre_commit_hook": "pre-commit hook", + "workspace_hygiene": "Workspace hygiene", +} + +MCP_INSTALL_HINT: Final = ( + "CodeClone MCP support requires the optional 'mcp' extra. " + "Install it with: pip install 'codeclone[mcp]'" +) + +MCP_UV_INSTALL_HINT: Final = ( + 'uv tool install "codeclone[mcp]" then configure MCP in your client' +) + +MCP_CONFIGURE_CLIENT_HINT: Final = ( + "Configure and start the CodeClone MCP server in your IDE or agent client." +) + +ACTION_FIX_PYPROJECT: Final = "Fix the tool.codeclone entries in pyproject.toml." + +READINESS_LABELS: Final[dict[str, str]] = { + "ready": "ready", + "attention": "attention", + "blocked": "blocked", + "optional": "optional", + "not_applicable": "n/a", +} + +AVAILABILITY_LABELS: Final[dict[str, str]] = { + "built_in": "built-in", + "optional_extra": "extra", + "external_tool": "external", + "unsupported": "n/a", +} + +SETUP_STATUS_BASE_LABEL: Final = "Base" +SETUP_DOCTOR_PROBES_HEADER: Final = "Probe diagnostics" +SETUP_DOCTOR_PROBES_LABEL: Final = "probes/paths checked" + +REASON_REQUIRES_MCP_EXTRA: Final = "Requires codeclone[mcp]" +REASON_MCP_INSTALLED_NOT_VERIFIED: Final = ( + "MCP extra is installed; configure and verify your MCP client connection" +) +REASON_CONTROLLED_CHANGE_NO_CLIENT: Final = ( + "MCP runtime is available but no supported client MCP config was found" +) +REASON_AUDIT_DISABLED: Final = "Audit trail is disabled in pyproject configuration" +REASON_AUDIT_DB_MISSING: Final = ( + "Audit is enabled but the audit database does not exist yet" +) +REASON_AUDIT_EMPTY: Final = "Audit database exists but has recorded no events yet" +REASON_AUDIT_READY: Final = "" +REASON_BASELINE_MISSING: Final = "Baseline file is missing or not configured" +REASON_BASELINE_UNTRUSTED: Final = "Baseline exists but is not trusted for gating" +REASON_BASELINE_CORRUPT: Final = ( + "Baseline file is corrupt or has an incompatible schema" +) +REASON_BASELINE_UNREADABLE: Final = "Baseline file exists but could not be read" +REASON_ANALYSIS_CORE_MISSING: Final = "CodeClone core package could not be resolved" +REASON_ANALYSIS_INVALID_CONFIG: Final = ( + "Invalid tool.codeclone configuration in pyproject.toml" +) +REASON_ANALYSIS_UNCONFIGURED: Final = "No [tool.codeclone] section in pyproject.toml" +REASON_MEMORY_EMPTY: Final = "Engineering Memory store exists but has no records yet" +REASON_MEMORY_MISSING: Final = "Engineering Memory store has not been created yet" +REASON_SEMANTIC_OPTIONAL: Final = "Requires semantic optional extras" +REASON_SEMANTIC_NO_STORE: Final = ( + "Semantic packages installed but no Engineering Memory store to index" +) +REASON_SEMANTIC_DISABLED: Final = ( + "Semantic packages installed but semantic memory is not enabled in configuration" +) +REASON_COVERAGE_OPTIONAL: Final = "Requires codeclone[coverage-xml]" +REASON_ANALYTICS_OPTIONAL: Final = "Requires codeclone[analytics]" +REASON_CI_NOT_ENABLED: Final = ( + "CI gating is optional and is not currently enabled in configuration" +) +REASON_CI_BASELINE_ATTENTION: Final = ( + "CI-like gates are enabled but baseline trust or metrics section needs attention" +) +REASON_GITHUB_WORKFLOW_MISSING: Final = ( + "No GitHub Actions workflow referencing CodeClone found" +) +REASON_GITHUB_WORKFLOW_UNREADABLE: Final = ( + "A GitHub Actions workflow file could not be read" +) +REASON_PRE_COMMIT_MISSING: Final = "No pre-commit hook referencing CodeClone found" +REASON_PRE_COMMIT_UNREADABLE: Final = ".pre-commit-config.yaml could not be read" +REASON_WORKSPACE_HYGIENE: Final = ( + ".gitignore does not cover .codeclone/ workspace artifacts" +) +REASON_UNKNOWN_PROBE: Final = ( + "Probe state is ambiguous; re-run setup after fixing paths" +) +REASON_ATTENTION_GENERIC: Final = "Capability needs attention; see doctor for details" +REASON_NOT_APPLICABLE: Final = "Not applicable on this platform" +REASON_OPTIONAL_EXTRA_MISSING: Final = ( + "Optional capability is not installed in this environment" +) + +MATURITY_CONNECTED: Final = "connected" +MATURITY_GOVERNED: Final = "governed" +MATURITY_EVIDENCE: Final = "evidence_backed" +MATURITY_TEAM: Final = "team_ready" +MATURITY_RELEASE: Final = "release_ready" + +__all__ = [ + "ACTION_FIX_PYPROJECT", + "AVAILABILITY_LABELS", + "CAPABILITY_LABELS", + "GROUP_LABELS", + "MATURITY_CONNECTED", + "MATURITY_EVIDENCE", + "MATURITY_GOVERNED", + "MATURITY_RELEASE", + "MATURITY_TEAM", + "MCP_CONFIGURE_CLIENT_HINT", + "MCP_INSTALL_HINT", + "MCP_UV_INSTALL_HINT", + "READINESS_LABELS", + "REASON_ANALYSIS_CORE_MISSING", + "REASON_ANALYSIS_INVALID_CONFIG", + "REASON_ANALYSIS_UNCONFIGURED", + "REASON_ANALYTICS_OPTIONAL", + "REASON_ATTENTION_GENERIC", + "REASON_AUDIT_DB_MISSING", + "REASON_AUDIT_DISABLED", + "REASON_AUDIT_EMPTY", + "REASON_AUDIT_READY", + "REASON_BASELINE_CORRUPT", + "REASON_BASELINE_MISSING", + "REASON_BASELINE_UNREADABLE", + "REASON_BASELINE_UNTRUSTED", + "REASON_CI_BASELINE_ATTENTION", + "REASON_CI_NOT_ENABLED", + "REASON_CONTROLLED_CHANGE_NO_CLIENT", + "REASON_COVERAGE_OPTIONAL", + "REASON_GITHUB_WORKFLOW_MISSING", + "REASON_GITHUB_WORKFLOW_UNREADABLE", + "REASON_MCP_INSTALLED_NOT_VERIFIED", + "REASON_MEMORY_EMPTY", + "REASON_MEMORY_MISSING", + "REASON_NOT_APPLICABLE", + "REASON_OPTIONAL_EXTRA_MISSING", + "REASON_PRE_COMMIT_MISSING", + "REASON_PRE_COMMIT_UNREADABLE", + "REASON_REQUIRES_MCP_EXTRA", + "REASON_SEMANTIC_DISABLED", + "REASON_SEMANTIC_NO_STORE", + "REASON_SEMANTIC_OPTIONAL", + "REASON_UNKNOWN_PROBE", + "REASON_WORKSPACE_HYGIENE", + "SETUP_APPLY_ABORTED", + "SETUP_APPLY_BLOCKED", + "SETUP_APPLY_CONFIRM_PROMPT", + "SETUP_APPLY_CONFIRM_REQUIRED", + "SETUP_APPLY_NOOP", + "SETUP_APPLY_STALE_PLAN", + "SETUP_APPLY_TITLE", + "SETUP_DOCTOR_PROBES_HEADER", + "SETUP_DOCTOR_PROBES_LABEL", + "SETUP_DOCTOR_TITLE", + "SETUP_DRY_RUN_ONLY_APPLY", + "SETUP_PLAN_BLOCKED", + "SETUP_PLAN_EMPTY", + "SETUP_PLAN_ID_ONLY_APPLY", + "SETUP_PLAN_READ_ONLY_NOTE", + "SETUP_PLAN_TITLE", + "SETUP_STATUS_BASE_LABEL", + "SETUP_STATUS_TITLE", + "SETUP_WIZARD_APPLY_SKIPPED", + "SETUP_WIZARD_CONFIRM_APPLY", + "SETUP_WIZARD_DOCTOR_HINT", + "SETUP_WIZARD_DOCTOR_LABEL", + "SETUP_WIZARD_GUIDED_BLOCKED", + "SETUP_WIZARD_GUIDED_EMPTY", + "SETUP_WIZARD_GUIDED_HINT", + "SETUP_WIZARD_GUIDED_LABEL", + "SETUP_WIZARD_HUB_RULE", + "SETUP_WIZARD_JSON_UNSUPPORTED", + "SETUP_WIZARD_PROMPT", + "SETUP_WIZARD_QUIT_HINT", + "SETUP_WIZARD_QUIT_LABEL", + "SETUP_WIZARD_RICH_REQUIRED", + "SETUP_WIZARD_SPHERE_EMPTY", + "SETUP_WIZARD_SPHERE_RULE", + "SETUP_WIZARD_TITLE", + "SETUP_WIZARD_TTY_REQUIRED", + "SETUP_WIZARD_UPDATED_READINESS", + "SETUP_YES_ONLY_APPLY", +] diff --git a/codeclone/utils/atomic_write.py b/codeclone/utils/atomic_write.py new file mode 100644 index 00000000..0f5eec66 --- /dev/null +++ b/codeclone/utils/atomic_write.py @@ -0,0 +1,95 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +"""Shared atomic text write helpers.""" + +from __future__ import annotations + +import os +import stat +import tempfile +from errno import EACCES, EBADF, EINVAL, ENOSYS, ENOTSUP, EPERM +from pathlib import Path + +_UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS = frozenset( + {EACCES, EBADF, EINVAL, ENOSYS, ENOTSUP, EPERM} +) + + +def write_text_atomically(path: Path, text: str) -> None: + """Write text via temp file + ``os.replace``.""" + + validate_atomic_target(path) + target_mode = _target_write_mode(path) + fd_num, tmp_name = tempfile.mkstemp( + dir=path.parent, + suffix=".tmp", + ) + tmp_path = Path(tmp_name) + try: + with os.fdopen(fd_num, "wb") as handle: + _chmod_open_file(handle.fileno(), tmp_path, target_mode) + handle.write(text.encode("utf-8")) + handle.flush() + os.fsync(handle.fileno()) + os.replace(tmp_path, path) + _fsync_parent_directory(path) + except BaseException: + tmp_path.unlink(missing_ok=True) + raise + + +def _target_write_mode(path: Path) -> int: + try: + return stat.S_IMODE(path.stat().st_mode) + except FileNotFoundError: + return _default_file_create_mode() + + +def _default_file_create_mode() -> int: + current_umask = os.umask(0) + try: + return 0o666 & ~current_umask + finally: + os.umask(current_umask) + + +def _chmod_open_file(fd_num: int, path: Path, mode: int) -> None: + if hasattr(os, "fchmod"): + os.fchmod(fd_num, mode) + return + os.chmod(path, mode) + + +def _fsync_parent_directory(path: Path) -> None: + flags = os.O_RDONLY + if hasattr(os, "O_DIRECTORY"): + flags |= os.O_DIRECTORY + try: + fd_num = os.open(path.parent, flags) + except OSError as exc: + if exc.errno in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS: + return + raise + try: + try: + os.fsync(fd_num) + except OSError as exc: + if exc.errno not in _UNSUPPORTED_DIRECTORY_FSYNC_ERRNOS: + raise + finally: + os.close(fd_num) + + +def validate_atomic_target(path: Path) -> None: + if path.is_symlink(): + raise OSError(f"Refusing to replace symlink target: {path}") + parent = path.parent + if parent.exists() and parent.is_symlink(): + raise OSError(f"Refusing to write through symlink directory: {parent}") + + +__all__ = ["validate_atomic_target", "write_text_atomically"] diff --git a/codeclone/utils/coerce.py b/codeclone/utils/coerce.py index 9017c6a1..d53e1a7d 100644 --- a/codeclone/utils/coerce.py +++ b/codeclone/utils/coerce.py @@ -7,6 +7,7 @@ from __future__ import annotations from collections.abc import Mapping, Sequence +from typing import TypeGuard __all__ = ["as_float", "as_int", "as_mapping", "as_sequence", "as_str"] @@ -41,13 +42,23 @@ def as_str(value: object, default: str = "") -> str: return value if isinstance(value, str) else default +def _is_str_key_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) and all(isinstance(key, str) for key in value) + + def as_mapping(value: object) -> Mapping[str, object]: - if isinstance(value, Mapping): + if _is_str_key_mapping(value): return value return {} +def _is_non_text_sequence(value: object) -> TypeGuard[Sequence[object]]: + return isinstance(value, Sequence) and not isinstance( + value, (str, bytes, bytearray) + ) + + def as_sequence(value: object) -> Sequence[object]: - if isinstance(value, Sequence) and not isinstance(value, (str, bytes, bytearray)): + if _is_non_text_sequence(value): return value return () diff --git a/codeclone/utils/file_lock.py b/codeclone/utils/file_lock.py index a7b248fd..46199ca7 100644 --- a/codeclone/utils/file_lock.py +++ b/codeclone/utils/file_lock.py @@ -59,8 +59,8 @@ def _open_lock_file(lock_path: Path) -> BinaryIO: return os.fdopen(fd, "r+b") -def _acquire_exclusive_lock(handle: object) -> None: - fileno = handle.fileno() # type: ignore[attr-defined] +def _acquire_exclusive_lock(handle: BinaryIO) -> None: + fileno = handle.fileno() if sys.platform == "win32": import msvcrt @@ -71,8 +71,8 @@ def _acquire_exclusive_lock(handle: object) -> None: fcntl.flock(fileno, fcntl.LOCK_EX | fcntl.LOCK_NB) -def _release_exclusive_lock(handle: object) -> None: - fileno = handle.fileno() # type: ignore[attr-defined] +def _release_exclusive_lock(handle: BinaryIO) -> None: + fileno = handle.fileno() if sys.platform == "win32": import msvcrt diff --git a/codeclone/utils/json_io.py b/codeclone/utils/json_io.py index 788220ea..aec40bd7 100644 --- a/codeclone/utils/json_io.py +++ b/codeclone/utils/json_io.py @@ -9,6 +9,7 @@ import os import tempfile from pathlib import Path +from typing import TypeGuard import orjson @@ -61,13 +62,17 @@ def read_json_document( return orjson.loads(read_bounded_bytes(path, max_bytes=max_bytes)) +def _is_json_object(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) and all(isinstance(key, str) for key in value) + + def read_json_object( path: Path, *, max_bytes: int = DEFAULT_MAX_JSON_BYTES, ) -> dict[str, object]: payload = read_json_document(path, max_bytes=max_bytes) - if not isinstance(payload, dict): + if not _is_json_object(payload): raise TypeError("JSON payload must be an object") return payload diff --git a/codeclone/utils/payload_narrow.py b/codeclone/utils/payload_narrow.py new file mode 100644 index 00000000..9125e82c --- /dev/null +++ b/codeclone/utils/payload_narrow.py @@ -0,0 +1,44 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +from collections.abc import Mapping +from typing import TypeGuard + +__all__ = [ + "dict_items_from_list", + "is_payload_dict", + "is_record_mapping", + "mapping_items_from_list", + "nested_payload_dict", +] + + +def is_payload_dict(value: object) -> TypeGuard[dict[str, object]]: + return isinstance(value, dict) + + +def is_record_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) + + +def mapping_items_from_list(values: object) -> list[Mapping[str, object]]: + if not isinstance(values, list): + return [] + return [item for item in values if is_record_mapping(item)] + + +def dict_items_from_list(values: object) -> list[dict[str, object]]: + if not isinstance(values, list): + return [] + return [item for item in values if is_payload_dict(item)] + + +def nested_payload_dict(value: object) -> dict[str, object]: + if is_payload_dict(value): + return value + return {} diff --git a/codeclone/utils/sqlite_store.py b/codeclone/utils/sqlite_store.py index c2e58c69..4c7a4c2b 100644 --- a/codeclone/utils/sqlite_store.py +++ b/codeclone/utils/sqlite_store.py @@ -15,8 +15,8 @@ "PRAGMA journal_mode=WAL", "PRAGMA synchronous=NORMAL", "PRAGMA foreign_keys=OFF", - "PRAGMA busy_timeout=5000", ) +_SQLITE_BUSY_TIMEOUT_MS = 5000 def open_sqlite_db( @@ -55,6 +55,7 @@ def open_sqlite_db( factory=factory, ) try: + conn.execute(f"PRAGMA busy_timeout={_SQLITE_BUSY_TIMEOUT_MS}") pragmas: tuple[str, ...] = _SQLITE_PRAGMAS if foreign_keys: pragmas = tuple( diff --git a/docs/README-pypi.md b/docs/README-pypi.md deleted file mode 100644 index 69b65da6..00000000 --- a/docs/README-pypi.md +++ /dev/null @@ -1,88 +0,0 @@ -

- - - - CodeClone - -

- -

- Structural Change Controller for AI-assisted Python development -

- -

- PyPI - Python - Tests -

- -Deterministic static analysis that combines clone detection, code-quality metrics, -and baseline-aware CI gating — structural change controller for AI-assisted -Python development. - -## Quick Start - -```bash -uv tool install codeclone - -codeclone . # analyze -codeclone . --html # HTML report -codeclone . --ci # CI mode -``` - -## Key Capabilities - -- **Clone detection** — function (CFG fingerprint), block, and segment clones -- **Quality metrics** — complexity, coupling, cohesion, dead code, health score -- **Baseline governance** — separates legacy debt from new regressions; CI fails only on what changed -- **Change controller** — intent declaration, blast radius, patch contract, review receipt for AI agents -- **Engineering Memory** — governed records, trajectory passports, and advisory Experiences -- **MCP server** — 33-tool default interface for IDE and agent clients (35 with `--ide-governance-channel`) -- **Platform Observability** — opt-in local diagnostics for CodeClone's own runtime -- **Corpus Analytics** — optional offline intent clustering (`codeclone[analytics]`) -- **Reports** — HTML, JSON, Markdown, SARIF, text from one canonical payload - -## MCP Server - -```bash -uv tool install "codeclone[mcp]" -codeclone-mcp --transport stdio -``` - -Native clients: VS Code extension, Claude Desktop bundle, Codex plugin. - -Engineering Memory, Corpus Analytics, and runtime diagnostics: - -```bash -uv tool install "codeclone[analytics]" -codeclone analytics build --root . --sweep --use-recommended -codeclone memory trajectory dashboard --root . -CODECLONE_OBSERVABILITY_ENABLED=1 codeclone . -codeclone observability trace --root . --html /tmp/codeclone-observer.html -``` - -## Links - -- Documentation: -- Engineering Memory: -- Platform Observability: -- Corpus Analytics: -- Source: -- Issues: - -## License - -- Code: MPL-2.0 -- Documentation: MIT - -License scope map: diff --git a/docs/assets/codeclone-html-sample-dark.png b/docs/assets/codeclone-html-sample-dark.png new file mode 100644 index 00000000..b5eaeb10 Binary files /dev/null and b/docs/assets/codeclone-html-sample-dark.png differ diff --git a/docs/assets/codeclone-html-sample.png b/docs/assets/codeclone-html-sample.png new file mode 100644 index 00000000..56bd358b Binary files /dev/null and b/docs/assets/codeclone-html-sample.png differ diff --git a/docs/book/00-intro.md b/docs/book/00-intro.md deleted file mode 100644 index 0f07d208..00000000 --- a/docs/book/00-intro.md +++ /dev/null @@ -1,107 +0,0 @@ - - -# 00. Intro - -## Purpose - -This book is the executable contract for CodeClone behavior in v2.x. It -describes only behavior that is present in code and/or locked by tests. - -## Public surface - -- CLI entrypoint: `codeclone/main.py:main` -- Package version: `codeclone/__init__.py:__version__` -- Global contract constants: `codeclone/contracts/__init__.py` - -## Contracts - -CodeClone provides these guarantees when inputs are identical (same repository content, same Python tag, same tool -version, same baseline/cache/report schemas): - -- Deterministic clone grouping and report serialization. -- Explicit trust model for baseline/cache. -- Stable exit-code categories for contract vs gating vs internal failures. - -Refs: - -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- `codeclone/cache/store.py:Cache.load` -- `codeclone/contracts/__init__.py:ExitCode` - -## Invariants (MUST) - -- Contract errors and gating failures are separate categories. -- Baseline trust is explicit (`baseline_loaded`, `baseline_status`). -- Cache is optimization-only; invalid cache never becomes source of truth. - -Refs: - -- `codeclone/surfaces/cli/workflow.py:_main_impl` -- `codeclone/baseline/trust.py:BASELINE_UNTRUSTED_STATUSES` -- `codeclone/cache/store.py:Cache._ignore_cache` - -## Failure modes - -| Condition | Behavior | -|----------------------------------------------|------------------------------------------| -| Invalid/untrusted baseline in normal mode | Warning + compare against empty baseline | -| Invalid/untrusted baseline in CI/gating mode | Contract error (exit 2) | -| New clones in gating mode | Gating failure (exit 3) | -| Unexpected runtime exception | Internal error (exit 5) | - -Refs: - -- `codeclone/surfaces/cli/workflow.py:_main_impl` -- `codeclone/main.py:main` - -## Determinism / canonicalization - -- Filesystem traversal is sorted before processing. -- Group keys and serialized arrays are sorted in report JSON/TXT. -- Baseline and cache payload hashing uses canonical JSON serialization. - -Refs: - -- `codeclone/scanner/__init__.py:iter_py_files` -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/cache/integrity.py:canonical_json` - -## Locked by tests - -- `tests/test_detector_golden.py::test_detector_output_matches_golden_fixture` -- `tests/test_report.py::test_report_json_deterministic_group_order` -- `tests/test_baseline.py::test_baseline_hash_canonical_determinism` -- `tests/test_cache.py::test_cache_signature_validation_ignores_json_whitespace` -- `tests/test_cli_unit.py::test_cli_help_text_consistency` - -## Non-guarantees - -- Cross-Python-tag clone IDs are not guaranteed identical. -- UI wording and visual layout may evolve without schema bumps. -- Performance characteristics are best-effort, not strict SLA. - -## Recommended reading paths - -- CI contract path: - [09-exit-codes.md](09-exit-codes.md) → - [07-baseline.md](07-baseline.md) → - [08-cache.md](08-cache.md) → - [05-report.md](05-report.md) → - [11-cli.md](11-cli.md) -- Metrics governance path: - [10-config-and-defaults.md](10-config-and-defaults.md) → - [15-health-score.md](15-health-score.md) → - [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) → - [17-dead-code-contract.md](17-dead-code-contract.md) → - [19-inline-suppressions.md](19-inline-suppressions.md) → - [18-suggestions-and-clone-typing.md](18-suggestions-and-clone-typing.md) -- Determinism and compatibility path: - [22-determinism.md](22-determinism.md) → - [24-compatibility-and-versioning.md](24-compatibility-and-versioning.md) -- Benchmarking path: - [22-determinism.md](22-determinism.md) → - [20-benchmarking.md](20-benchmarking.md) diff --git a/docs/book/01-terminology.md b/docs/book/01-terminology.md deleted file mode 100644 index 677275a5..00000000 --- a/docs/book/01-terminology.md +++ /dev/null @@ -1,110 +0,0 @@ - - -# 01. Terminology - -## Purpose - -Define terms exactly as used by code and tests. - -## Public surface - -- Baseline identifiers and statuses: `codeclone/baseline/*` -- Cache statuses and compact layout: `codeclone/cache/*` -- Report schema and group layouts: `codeclone/report/document/*` - -## Data model - -- **fingerprint**: function-level CFG fingerprint (`sha1`) plus LOC bucket -- **block_hash**: ordered sequence of normalized statement hashes in a fixed window -- **segment_hash**: hash of an ordered segment window -- **segment_sig**: hash of a sorted segment window used for candidate grouping -- **python_tag**: runtime compatibility tag like `cp314` -- **schema_version**: - - baseline schema in `meta.schema_version` - - cache schema in top-level `v` - - report schema in `report_schema_version` -- **payload_sha256**: canonical baseline semantic hash -- **trusted baseline**: baseline loaded with status `ok` -- **source_kind**: file classification `production | tests | fixtures | other` -- **design finding**: metric-driven finding emitted by the canonical report builder using - `meta.analysis_thresholds.design_findings` -- **suggestion**: advisory recommendation card derived from findings/metrics; never gates CI -- **directory_hotspot**: derived aggregation showing where findings cluster by category - -Refs: - -- `codeclone/findings/clones/grouping.py:build_groups` -- `codeclone/blocks/__init__.py` -- `codeclone/baseline/trust.py:current_python_tag` -- `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- `codeclone/paths/__init__.py:classify_source_kind` -- `codeclone/metrics/health.py:compute_health` -- `codeclone/report/document/_common.py:_design_findings_thresholds_payload` -- `codeclone/report/suggestions.py:generate_suggestions` -- `codeclone/report/overview.py:build_directory_hotspots` - -## Contracts - -- New/known classification is key-based, not heuristic-based. -- `novelty="known"` is baseline-relative: the finding fingerprint is accepted - by the trusted baseline. It does not prove that the current patch did not - introduce or reintroduce that finding. -- Patch-local regressions require clean before-run to after-run evidence - (`compare_runs` / `check_patch_contract(mode="verify")`). -- Baseline trust is status-driven. -- Cache trust is status-driven and independent from baseline trust. -- Design finding universe is determined by the canonical report builder; MCP and HTML read it, never resynthesize it. -- Suggestions are advisory and never affect exit code. - -Refs: - -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Invariants (MUST) - -- Function group key format: `fingerprint|loc_bucket` -- Block group key format: `block_hash` -- Segment group key format: `segment_hash|qualname` - -Refs: - -- `codeclone/findings/clones/grouping.py:build_groups` -- `codeclone/findings/clones/grouping.py:build_block_groups` -- `codeclone/findings/clones/grouping.py:build_segment_groups` - -## Failure modes - -| Condition | Result | -|----------------------------------------|---------------------------------| -| Baseline generator name != `codeclone` | `generator_mismatch` | -| Baseline python tag mismatch | `mismatch_python_version` | -| Cache signature mismatch | `integrity_failed` cache status | - -Refs: - -- `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- `codeclone/cache/store.py:Cache.load` - -## Determinism / canonicalization - -- Baseline clone ID lists must be sorted and unique. -- Cache compact arrays are sorted by deterministic tuple keys before write. - -Refs: - -- `codeclone/baseline/trust.py:_require_sorted_unique_ids` -- `codeclone/cache/_wire_encode.py:_encode_wire_file_entry` - -## Locked by tests - -- `tests/test_baseline.py::test_baseline_id_lists_must_be_sorted_and_unique` -- `tests/test_report.py::test_report_json_group_order_is_deterministic_by_count_then_id` -- `tests/test_cache.py::test_cache_version_mismatch_warns` - -## Non-guarantees - -- Exact wording of status descriptions in UI is not a schema contract. diff --git a/docs/book/02-architecture-map.md b/docs/book/02-architecture-map.md deleted file mode 100644 index 0316bee6..00000000 --- a/docs/book/02-architecture-map.md +++ /dev/null @@ -1,159 +0,0 @@ - - -# 02. Architecture Map - -## Purpose - -Document the current module boundaries and ownership in CodeClone `2.1.x`. - -## Public surface - -Main ownership layers: - -- CLI entry and UX orchestration -- Config parsing and pyproject resolution -- Core runtime pipeline -- Analysis and clone grouping -- Metrics and findings -- Baseline/cache persistence contracts -- Canonical report document and deterministic projections -- HTML render-only surface -- Read-only MCP surface with implementation context, structural change control, - and claim validation -- IDE/client surfaces over MCP - -## Data model - -| Layer | Modules | Responsibility | -|-------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Entry | `codeclone/main.py` | Public CLI entrypoint only | -| CLI surface | `codeclone/surfaces/cli/*`, `codeclone/ui_messages/*` | Parse args, resolve runtime mode, print summaries, write outputs, route exits | -| Report copy | `codeclone/report/messages/*` | Glossary, suggestions, explainability, overview, security, chrome, text/markdown/sarif projections, gate prefixes | -| Config | `codeclone/config/*` | Option specs, parser construction, pyproject loading, CLI > pyproject > defaults merge | -| Core runtime | `codeclone/core/*` | Bootstrap, discovery, worker processing, project metrics, report/gate integration | -| Analysis | `codeclone/analysis/*`, `codeclone/blocks/*`, `codeclone/paths/*`, `codeclone/qualnames/*` | Parse source, normalize AST/CFG facts, extract units, prepare deterministic analysis inputs; includes shared blast-radius graph core (`analysis/blast_radius.py`) | -| Findings | `codeclone/findings/clones/*`, `codeclone/findings/structural/*` | Clone grouping and structural finding derivation | -| Metrics | `codeclone/metrics/*` | Complexity, coupling, cohesion, dependencies, dead code, health, adoption, coverage join, API surface | -| Contracts/domain | `codeclone/contracts/*`, `codeclone/models.py`, `codeclone/domain/*` | Version constants, enums, typed exceptions, shared models, domain taxonomies | -| Persistence | `codeclone/baseline/*`, `codeclone/cache/*` | Trusted comparison state and optimization-only cache contracts | -| Canonical report | `codeclone/report/document/*`, `codeclone/report/gates/*`, `codeclone/report/*.py` | Canonical report payload, derived projections, explainability, suggestions, gate reasons | -| Deterministic renderers | `codeclone/report/renderers/*` | Text/Markdown/SARIF/JSON projections over the canonical report | -| HTML render layer | `codeclone/report/html/*` | Render-only HTML view over canonical report/meta facts | -| MCP surface | `codeclone/surfaces/mcp/*`, `codeclone/surfaces/mcp/messages/*` | Read-only MCP tools/resources, run-bound implementation-context projections, change-control projections, Engineering Memory retrieval/governance, dev-only Platform Observability slices, and centralized agent-facing copy | -| Engineering Memory | `codeclone/memory/*`, `codeclone/config/memory*.py` | Local SQLite store, scoped retrieval, semantic sidecar, trajectory + Patch Trail projection, Experience distillation, coalesced rebuild jobs, staleness, governance, and CLI/MCP surfaces over deterministic report/git/doc/audit facts | -| Platform Observability | `codeclone/observability/*` | Opt-in operation/span telemetry, local SQLite store, bounded MCP slicer, and CLI JSON/HTML diagnostics; never analysis truth or a gate input | -| Corpus Analytics | `codeclone/analytics/*`, `codeclone/config/analytics.py` | Optional offline intent corpus clustering (`codeclone[analytics]`); audit/trajectory ingestion, separate analytics embeddings, SQLite + LanceDB under `.codeclone/analytics/`; never report/gate/memory authority | -| Controller insights | `codeclone/controller_insights/*` | Shared session-stats and audit-trail payloads for CLI `--session-stats` / `--audit` and IDE-only MCP `get_workspace_session_stats` / `get_controller_audit_trail` | -| Audit trail | `codeclone/audit/*` | Optional controller event and MCP payload footprint recording under `.codeclone/db/` when enabled | -| Client surfaces | `extensions/vscode-codeclone/*`, `extensions/claude-desktop-codeclone/*`, `plugins/codeclone/*`, `plugins/cursor-codeclone/*`, `plugins/claude-code-codeclone/*` | Native clients/install surfaces over `codeclone-mcp` | - -Refs: - -- `codeclone/main.py:main` -- `codeclone/surfaces/cli/workflow.py:_main_impl` -- `codeclone/core/pipeline.py:analyze` -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/html/assemble.py:build_html_report` -- `codeclone/surfaces/mcp/server.py:build_mcp_server` - -## Contracts - -- Core produces facts; renderers present facts. -- `codeclone/report/document/*` is the canonical report source of truth. -- HTML, Markdown, SARIF, text, and MCP are projections over the same canonical report semantics. -- Baseline and cache are persistence contracts, not analysis truth. -- Cache is optimization-only and fail-open. -- MCP is read-only and must not create a second analysis truth path. Change - control, implementation context, and claim guard are projections over stored - run/report semantics, not new analyzers. Implementation-context manifests and - future relationship adjacency remain in-memory/off-report and cannot change - canonical report identity. -- VS Code, Claude Desktop, Claude Code, Codex, and Cursor surfaces are clients - over MCP, not second analyzers. - -Refs: - -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/renderers/text.py:render_text_report_document` -- `codeclone/report/renderers/markdown.py:render_markdown_report_document` -- `codeclone/report/renderers/sarif.py:render_sarif_report_document` -- `codeclone/report/html/assemble.py:build_html_report` -- `codeclone/baseline/clone_baseline.py:Baseline.load` -- `codeclone/baseline/metrics_baseline.py:MetricsBaseline.load` -- `codeclone/cache/store.py:Cache.load` - -## Invariants (MUST) - -- Report serialization is deterministic and schema-versioned. -- UI is render-only and must not invent gating semantics. -- Status enums remain domain-owned in baseline/metrics-baseline/cache/contracts modules. -- `codeclone/main.py` stays thin; orchestration lives in `codeclone/surfaces/cli/*`. - -Refs: - -- `codeclone/report/document/integrity.py:_build_integrity_payload` -- `codeclone/report/document/inventory.py:_build_inventory_payload` -- `codeclone/baseline/trust.py:BaselineStatus` -- `codeclone/baseline/_metrics_baseline_contract.py:MetricsBaselineStatus` -- `codeclone/cache/versioning.py:CacheStatus` -- `codeclone/contracts/__init__.py:ExitCode` - -## Failure modes - -| Condition | Layer | -|--------------------------------------------------|----------------------------------------------------------------| -| Invalid CLI args / invalid output path | CLI surface (`codeclone/config/*`, `codeclone/surfaces/cli/*`) | -| Baseline schema/integrity mismatch | Baseline contract layer | -| Metrics baseline schema/integrity mismatch | Metrics-baseline contract layer | -| Cache corruption/version mismatch | Cache contract layer (fail-open) | -| HTML snippet read failure | HTML render layer fallback snippet | -| MCP invalid request / invalid root / unknown run | MCP surface | - -## Determinism / canonicalization - -- File iteration and grouping order are explicit sorts. -- Canonical report integrity excludes non-canonical `derived` payload. -- Baseline and cache hashes/signatures use canonical JSON. - -Refs: - -- `codeclone/scanner/__init__.py:iter_py_files` -- `codeclone/report/document/integrity.py:_build_integrity_payload` -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/cache/integrity.py:canonical_json` - -## Locked by tests - -- `tests/test_architecture.py::test_architecture_layer_violations` -- `tests/test_report.py::test_report_json_compact_v21_contract` -- `tests/test_report_contract_coverage.py::test_report_document_rich_invariants_and_renderers` -- `tests/test_html_report.py::test_html_report_uses_core_block_group_facts` -- `tests/test_cache.py::test_cache_v13_uses_relpaths_when_root_set` -- `tests/test_mcp_service.py::test_mcp_service_analyze_repository_registers_latest_run` - -## Non-guarantees - -- Internal file splits may evolve in `2.1.x` if public contracts are preserved. -- Package markers and internal helper placement are not contract by themselves. - -## Chapter map - -| Topic | Primary chapters | -|---------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| CLI behavior and failure routing | [09-exit-codes.md](09-exit-codes.md), [11-cli.md](11-cli.md) | -| Config precedence and defaults | [10-config-and-defaults.md](10-config-and-defaults.md) | -| Core processing pipeline | [03-core-pipeline.md](03-core-pipeline.md) | -| Clone baseline trust/compat/integrity | [07-baseline.md](07-baseline.md) | -| Cache trust and fail-open behavior | [08-cache.md](08-cache.md) | -| Report schema and provenance | [05-report.md](05-report.md), [06-html-render.md](06-html-render.md) | -| MCP agent surface | [25-mcp-interface/index.md](25-mcp-interface/index.md), [14-claim-guard.md](14-claim-guard.md) | -| Engineering Memory evidence layers | [13-engineering-memory/index.md](13-engineering-memory/index.md), [13-engineering-memory/trajectory-quality-and-passport.md](13-engineering-memory/trajectory-quality-and-passport.md), [13-engineering-memory/experience-layer.md](13-engineering-memory/experience-layer.md) | -| Platform runtime diagnostics | [26-platform-observability.md](26-platform-observability.md) | -| Corpus analytics (intent clustering) | [27-corpus-analytics.md](27-corpus-analytics.md) | -| Health score model | [15-health-score.md](15-health-score.md) | -| Metrics gates and metrics baseline | [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) | -| Dead-code liveness policy | [17-dead-code-contract.md](17-dead-code-contract.md) | -| Determinism and versioning policy | [22-determinism.md](22-determinism.md), [24-compatibility-and-versioning.md](24-compatibility-and-versioning.md) | diff --git a/docs/book/03-core-pipeline.md b/docs/book/03-core-pipeline.md deleted file mode 100644 index d70ec393..00000000 --- a/docs/book/03-core-pipeline.md +++ /dev/null @@ -1,126 +0,0 @@ - - -# 03. Core Pipeline - -## Purpose - -Describe the runtime pipeline from file discovery to grouped clones, metrics, -report assembly, and gating. - -## Public surface - -- Discovery: `codeclone/core/discovery.py:discover` -- Per-file processing: `codeclone/core/worker.py:process_file` -- Extraction: `codeclone/analysis/units.py:extract_units_and_stats_from_source` -- Clone grouping: `codeclone/findings/clones/grouping.py` -- Project metrics and suggestions: `codeclone/core/pipeline.py` -- Report/gating integration: `codeclone/core/reporting.py:report`, - `codeclone/core/reporting.py:gate` - -## Data model - -Stages: - -1. Bootstrap runtime paths and config. -2. Discover Python files with deterministic traversal. -3. Load usable cache entries by stat signature and compatible analysis profile. -4. Process changed/missed files: - - read source - - parse AST with limits - - extract function, block, and segment units - - collect referenced names/qualnames and dead-code candidates -5. Build groups: - - function groups by `fingerprint|loc_bucket` - - block groups by `block_hash` - - segment groups by `segment_sig` then `segment_hash|qualname` -6. Compute project metrics in full mode: - - complexity, coupling, cohesion - - dead code - - dependency graph and cycles - - health score - - adoption, API surface, optional coverage join -7. Build canonical report document and deterministic projections. -8. Evaluate clone diff and metric gates. - -Refs: - -- `codeclone/core/bootstrap.py:bootstrap` -- `codeclone/core/discovery.py:discover` -- `codeclone/core/worker.py:process_file` -- `codeclone/analysis/units.py:extract_units_and_stats_from_source` -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/gates/evaluator.py:metric_gate_reasons` -- `codeclone/core/reporting.py:gate` - -## Contracts - -- Detection core computes facts; report layer materializes canonical findings from those facts. -- Report-layer transformations do not change function/block grouping keys used for baseline diff. -- Segment groups are report-only and do not participate in baseline diff/gating. -- Structural findings are report-only and do not participate in baseline diff/gating. -- `golden_fixture_paths` is a clone-policy exclusion layer: - excluded groups remain visible as suppressed canonical report facts, but do - not affect health, gates, or suggestions. -- Test-path liveness references are filtered both on fresh extraction and on - cache decode. -- Default discovery skips generated/dependency directories such as `.git`, - virtualenvs, `site-packages`, `node_modules`, migrations, `dist`, and - `build`; users can still pass explicit scanner excludes for project-specific - layouts. - -Refs: - -- `codeclone/findings/clones/grouping.py:build_groups` -- `codeclone/report/document/_findings_groups.py:_build_clone_groups` -- `codeclone/findings/structural/detectors.py:normalize_structural_findings` -- `codeclone/core/discovery_cache.py:load_cached_metrics_extended` -- `codeclone/baseline/clone_baseline.py:Baseline.diff` - -## Invariants (MUST) - -- `files_found = files_analyzed + cache_hits + files_skipped`, or CLI warns explicitly. -- In gating mode, unreadable source IO is a contract failure. -- Parser time/resource protections are applied before AST extraction. - -Refs: - -- `codeclone/surfaces/cli/summary.py:_print_summary` -- `codeclone/surfaces/cli/workflow.py:_main_impl` -- `codeclone/analysis/parser.py:_parse_limits` - -## Failure modes - -| Condition | Behavior | -|----------------------------------|--------------------------------------------------| -| File stat/read/encoding error | File skipped; tracked as failed file | -| Source read error in gating mode | Contract error, exit `2` | -| Parser timeout | `ParseError` through processing failure path | -| Unexpected per-file exception | Captured as `unexpected_error` processing result | - -## Determinism / canonicalization - -- File list is sorted. -- Group sorting is deterministic by stable tuple keys. -- Canonical report integrity is computed only from canonical sections. - -Refs: - -- `codeclone/scanner/__init__.py:iter_py_files` -- `codeclone/findings/clones/grouping.py:build_groups` -- `codeclone/report/document/integrity.py:_build_integrity_payload` - -## Locked by tests - -- `tests/test_scanner_extra.py::test_iter_py_files_deterministic_sorted_order` -- `tests/test_scanner_extra.py::test_iter_py_files_excludes_node_modules` -- `tests/test_cli_inprocess.py::test_cli_summary_cache_miss_metrics` -- `tests/test_cli_inprocess.py::test_cli_unreadable_source_fails_in_ci_with_contract_error` -- `tests/test_extractor.py::test_parse_limits_triggers_timeout` -- `tests/test_extractor.py::test_dead_code_marks_symbol_dead_when_referenced_only_by_tests` -- `tests/test_pipeline_metrics.py::test_load_cached_metrics_ignores_referenced_names_from_test_files` - -## Non-guarantees - -- Parallel worker scheduling order is not guaranteed; only final output determinism is guaranteed. diff --git a/docs/book/04-cfg-semantics.md b/docs/book/04-cfg-semantics.md deleted file mode 100644 index 0184d9f9..00000000 --- a/docs/book/04-cfg-semantics.md +++ /dev/null @@ -1,228 +0,0 @@ - - -# 04. Control Flow Graph (CFG) — Design and Semantics - -> Contract-level guarantees are in [Core Pipeline](03-core-pipeline.md) and -> [Determinism](22-determinism.md). - -This document describes the **Control Flow Graph (CFG)** model used by **CodeClone**, -its design goals, semantics, and known limitations. - -CFG in CodeClone is **not** intended to be a full or precise execution model of Python. -Instead, it is a **structural abstraction** optimized for **stable and low-noise clone detection**. - ---- - -## Design Goals - -The CFG implementation in CodeClone (CFG v1) is designed to: - -- capture **structural control-flow shape** of functions, -- be **deterministic and reproducible**, -- remain **robust to small refactorings**, -- maximize **signal-to-noise ratio** for clone detection, -- scale well to large Python codebases. - -The primary consumer of CFGs in CodeClone is **structural fingerprinting**, not program analysis. - ---- - -## Scope - -CFGs are built: - -- **per function / method**, -- using Python AST (`ast.FunctionDef`, `ast.AsyncFunctionDef`), -- without interprocedural analysis. - -Each function produces an independent CFG. - ---- - -## CFG Structure - -### Blocks - -A CFG consists of **basic blocks**. - -Each block contains: - -- a unique, deterministic `id`, -- a list of normalized AST statements (`ast.stmt`), -- a set of successor blocks, -- a termination flag (`is_terminated`). - -Blocks are ordered and numbered deterministically to ensure stable fingerprints. - ---- - -### Edges - -Edges represent **structural control flow**: - -- sequential execution, -- conditional branching (`if`), -- looping constructs (`for`, `while`). - -Edges do **not** represent runtime conditions or probabilities. - ---- - -## Supported Control Structures - -### Sequential statements - -Sequential statements are appended to the current block until a control split occurs. - ---- - -### `if / else` - -An `if` statement creates: - -- a condition block, -- a `then` block, -- an optional `else` block, -- a merge (after) block. - -Both branches always reconverge at an explicit after-block. - -If the condition is a short‑circuit boolean (`and`/`or`), it is expanded into a -**micro‑CFG** with one block per operand and explicit branch edges between them. - ---- - -### `while` loops - -A `while` loop produces: - -- a loop condition block, -- a body block, -- an optional `else` block, -- an after-loop block. - -The condition block always has two successors: - -- loop body, -- else/after-loop path. - ---- - -### `for` loops - -A `for` loop is modeled similarly to `while`: - -- an iteration-expression block, -- a body block, -- an optional `else` block, -- an after-loop block. - -The iterable expression (`range(...)`, etc.) is represented as a statement -inside the condition block. - ---- - -## `break` and `continue` Semantics (CFG v1) - -In CFG v1: - -- `break` and `continue` are explicit terminating statements, -- each maps to a deterministic jump target through loop context: - - `break` -> loop after-block, - - `continue` -> loop condition/iteration block, -- `for/while ... else` remains reachable only on normal loop completion - (not through `break` paths). - -This preserves structural loop semantics while keeping deterministic graph shape. - ---- - -## Ordered Branch Semantics - -To avoid false equivalence from branch reordering, CFG v1 preserves: - -- `match case` evaluation order via indexed case-test blocks, -- `except` handler order via indexed handler-test blocks. - ---- - -## What CFG v1 Does NOT Model - -### No interprocedural flow - -- Function calls are treated as atomic expressions. -- No inlining or call graph construction. - ---- - -### No data-flow analysis - -- No variable liveness tracking. -- No value propagation. -- No alias analysis. - ---- - -### Limited exception flow - -- `try / except / finally` blocks are represented structurally. -- `try/except` edges are created **only** from statements that may raise: - function calls, attribute access, indexing, `await`, `yield from`, and `raise`. -- No interprocedural exception propagation is modeled. - -This keeps CFGs deterministic while reducing false differences between safe and -potentially‑raising code. - ---- - -## Determinism Guarantees - -CFG v1 guarantees: - -- deterministic block numbering, -- stable successor ordering, -- reproducible CFG fingerprints across runs. - -This is critical for CI usage and baseline comparison. - -Python tag consistency: see [Compatibility and Versioning](24-compatibility-and-versioning.md). - ---- - -## Why This Is Acceptable (and Intended) - -CodeClone answers the question: - -> “Do these pieces of code have the same structure and control flow?” - -It does **not** answer: - -> “Do these pieces of code behave identically at runtime?” - -CFG v1 is intentionally **structural, conservative, and explainable**. - ---- - -## Future Directions (CFG v2+) - -Potential future enhancements: - -- richer exception-flow modeling, -- optional data-flow fingerprints, -- configurable strictness modes. - -These are considered **optional extensions**, not requirements for effective clone detection. - ---- - -## Summary - -- CFG v1 is a **structural abstraction**, not a runtime model. -- It is optimized for **clone detection**, not program verification. -- Its limitations are **intentional design trade-offs**. - -This design keeps CodeClone effective, predictable, and CI-friendly. diff --git a/docs/book/05-report.md b/docs/book/05-report.md deleted file mode 100644 index d0fcb3f5..00000000 --- a/docs/book/05-report.md +++ /dev/null @@ -1,187 +0,0 @@ - - -# 05. Report - -## Purpose - -Define the canonical report contract for the current `2.1` release line: report -schema `2.11` plus deterministic text/Markdown/SARIF/HTML projections. - -## Public surface - -- Canonical report builder: `codeclone/report/document/builder.py:build_report_document` -- Canonical inventory/integrity helpers: - `codeclone/report/document/inventory.py`, - `codeclone/report/document/integrity.py` -- Text renderer: `codeclone/report/renderers/text.py:render_text_report_document` -- Markdown renderer: - `codeclone/report/renderers/markdown.py:render_markdown_report_document` -- SARIF renderer: - `codeclone/report/renderers/sarif.py:render_sarif_report_document` -- HTML renderer: `codeclone/report/html/assemble.py:build_html_report` -- Shared CLI report meta: - `codeclone/surfaces/cli/report_meta.py:_build_report_meta` - -## Data model - -Canonical top-level sections: - -- `report_schema_version` -- `meta` -- `inventory` -- `findings` -- `metrics` -- `derived` -- `integrity` - -Canonical section roles: - -- `meta`, `inventory`, `findings`, `metrics` are canonical truth -- `derived` is a deterministic projection layer -- `integrity` carries canonicalization metadata and digest - -Current canonical report-only metric families include: - -- `health` -- `dead_code` -- `dependencies` -- `coverage_adoption` -- `api_surface` -- `coverage_join` -- `overloaded_modules` -- `security_surfaces` - -Dependency depth facts in the canonical report now include: - -- `avg_depth` -- `p95_depth` -- `max_depth` - -These describe the internal module dependency graph. They are report facts, not -user-facing config knobs. - -Current finding families include: - -- `findings.groups.clones.{functions,blocks,segments}` -- optional `findings.groups.clones.suppressed.*` -- `findings.groups.structural.groups` -- `findings.groups.dead_code.groups` -- `findings.groups.design.groups` - -Refs: - -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/document/_common.py:_design_findings_thresholds_payload` -- `codeclone/report/document/_findings_groups.py:_build_clone_groups` -- `codeclone/report/document/_findings_groups.py:_build_structural_groups` - -### Module map (`derived.module_map`) - -`derived.module_map` is a report-only projection for refactor triage. It does -not re-scan sources or add a metrics family — it reprojects the existing -`metrics.families.dependencies` (import edges, cycles, longest chains) and -`metrics.families.overloaded_modules` facts into graph views and unwind-candidate -rows. It carries its own `schema_version: "1"` and `scope: "report_only"`; like -the rest of `derived` it is excluded from the integrity digest, so adding it does -not bump `report_schema_version`. - -Shape: - -- `summary` — `available`, `module_count`, `package_count_depth2`, `edge_count`, - `unwind_candidate_count`, `overloaded_candidate_count`, - `overloaded_population_status` (`reason` is added when `available` is false). -- `default_zoom` — `"packages"` or `"modules"`, chosen by a deterministic - decision table over module/package counts (monolith and over-merge guards - included). -- `graph_packages` / `graph_modules` — precomputed `nodes`, `edges`, and a - `truncation` block (`truncated`, universe/shown node and edge counts, - `seed_policy: "cycles_then_chains_then_degree"`). Both views are always emitted - when `available` is true so consumers can swap zoom without recomputation. The - SVG may show a deterministic sample on large repositories; the tables stay - full-size. -- `unwind_candidates[]` — report-only refactor-triage rows over the full - `overloaded_modules` set (capped at 25), each with derived `signals` ids. - -When the dependencies family is skipped or empty the projection emits an -unavailable shell (`summary.available: false`, `reason: "dependencies_skipped"`, -empty graphs and `unwind_candidates`). - -Refs: - -- `codeclone/report/document/derived.py:_build_derived_module_map` -- `codeclone/metrics/dependencies.py:select_dependency_graph_nodes` - -## Contracts - -- JSON is the source of truth for report semantics. -- Markdown, text, SARIF, HTML, and MCP projections must read canonical report facts rather than recompute them. -- `derived` does not replace canonical findings/metrics. -- Design findings are built once in the canonical report using - `meta.analysis_thresholds.design_findings`; consumers must not synthesize them post-hoc. -- Coverage Join is canonical current-run truth for that run, but not baseline truth. -- `security_surfaces` is a report-only exact inventory of security-relevant - capabilities and trust-boundary code. It does not claim vulnerabilities or - exploitability. -- `dead_code.runtime_reachability` carries exact framework registration facts - used by the dead-code liveness filter. It is explanatory evidence, not a - second findings family or a vulnerability/security signal. -- Clone groups excluded by project policy are carried only under suppressed clone buckets and do not affect active - findings, health, clone gating, or suggestions. - -Refs: - -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/document/derived.py:_health_snapshot` -- `codeclone/report/overview.py:materialize_report_overview` -- `codeclone/report/suggestions.py:generate_suggestions` - -## Invariants (MUST) - -- Stable ordering for groups, items, suggestions, and hotlists. -- `derived.suggestions[*].finding_id` references existing canonical finding IDs. -- `derived.hotlists.*_ids` reference existing canonical finding IDs. -- SARIF artifacts, rules, and locations stay index-aligned. -- `integrity.digest` is computed from canonical sections only; `derived` is excluded. - -Refs: - -- `codeclone/report/document/integrity.py:_build_integrity_payload` -- `codeclone/report/document/inventory.py:_build_inventory_payload` -- `codeclone/report/renderers/sarif.py:render_sarif_report_document` - -## Failure modes - -| Condition | Behavior | -|---------------------------------|--------------------------------------------------------| -| Missing optional UI/meta fields | Renderer falls back to empty or `(none)`-style display | -| Untrusted baseline | Clone novelty resolves as current-run only | -| Missing source snippet in HTML | Safe fallback snippet block | - -## Determinism / canonicalization - -- Canonical payload is serialized with sorted keys for digest computation. -- Inventory file registry is normalized to relative paths. -- Structural findings are normalized, deduplicated, and sorted before serialization. - -Refs: - -- `codeclone/report/document/integrity.py:_build_integrity_payload` -- `codeclone/report/document/inventory.py:_build_inventory_payload` -- `codeclone/findings/structural/detectors.py:normalize_structural_findings` - -## Locked by tests - -- `tests/test_report.py::test_report_json_compact_v21_contract` -- `tests/test_report.py::test_report_json_integrity_matches_canonical_sections` -- `tests/test_report.py::test_report_json_integrity_ignores_derived_changes` -- `tests/test_report_contract_coverage.py::test_report_document_rich_invariants_and_renderers` -- `tests/test_report_contract_coverage.py::test_markdown_and_sarif_reuse_prebuilt_report_document` -- `tests/test_report_branch_invariants.py::test_overview_and_sarif_branch_invariants` - -## Non-guarantees - -- Human-facing wording in `derived` or HTML may evolve without a schema bump. -- CSS/layout changes are not part of the canonical report contract. diff --git a/docs/book/06-html-render.md b/docs/book/06-html-render.md deleted file mode 100644 index cfb5bfc5..00000000 --- a/docs/book/06-html-render.md +++ /dev/null @@ -1,134 +0,0 @@ - - -# 06. HTML Render - -## Purpose - -Document HTML rendering as a pure view layer over canonical report data. - -## Public surface - -- Main renderer: `codeclone/report/html/assemble.py:build_html_report` -- Package entrypoint: `codeclone/report/html/__init__.py:build_html_report` -- Context shaping: `codeclone/report/html/_context.py` -- Escaping helpers: `codeclone/report/html/primitives/escape.py` -- Snippet/highlight helpers: `codeclone/report/html/widgets/snippets.py` -- Sections/widgets/assets: `codeclone/report/html/sections/*`, - `codeclone/report/html/widgets/*`, `codeclone/report/html/assets/*` -- User-facing copy catalog: `codeclone/report/messages/*` (glossary, - suggestions, explainability, overview, security, chrome) - -## Data model - -Inputs to the renderer: - -- canonical `report_document` (preferred path) -- shared `report_meta` -- optional runtime snippet sources for code excerpts - -Output: - -- one self-contained HTML string - -## Contracts - -- HTML must not recompute detection semantics; it renders facts from report/core layers. -- Provenance panels mirror canonical report/meta facts. -- Overview, Review, Quality, Module map, Suggestions, Dead Code, Dependencies, - and Clones tabs are projections over canonical report sections. -- The `Review` tab (between Overview and Clones) is render-only and is the guided - entry point for triaging findings. It draws the precomputed - `derived.review_queue` — a prioritized, cross-family actionable queue — as a list - of shared finding cards. The reviewed state (per finding id, persisted in - `localStorage`), the progress bar, and the severity/family filters are - client-side UX over those rendered facts; it recomputes nothing. The `Overview` - launchpad surfaces the queue total plus severity counts and jumps into this tab - via `data-goto-tab`; the queue is empty (and the launchpad hidden) when there are - no suggestions, mirroring `derived.suggestions` exactly. -- `codeclone/report/html/widgets/cards.py:finding_card` is the single card chrome - shared by the Suggestions, Review, and Structural Findings surfaces — one - severity stripe + badge + meta/body/details/actions shell, so per-surface markup - is not duplicated. -- Quality covers per-function/class metrics (Complexity, Coupling, Cohesion) plus - report-only subtabs such as `Coverage Join` and `Security Surfaces`; these - remain factual projections over canonical metrics families rather than - HTML-only analysis. Quality does not host the overloaded-modules profile — that - module-level view lives in the `Module map` tab. -- The `Module map` tab (between Quality and Dependencies) is render-only and is - the single home for module-level responsibility. It draws the precomputed - `derived.module_map` graph views and unwind-candidate triage, plus the full - `overloaded_modules` profile (stat cards + table) read directly from - `metrics.families.overloaded_modules`. It re-samples nothing and adds no health - dimension — the SVG may show a deterministic sample on large repositories while - the unwind and overloaded tables stay full-size. The `Packages`/`Modules` - toggle swaps the two precomputed graphs without recomputation. The graph block - shows "Dependency graph is not available." when the dependencies family is - skipped; the overloaded-modules section renders whenever that metrics family is - present, independent of graph availability. -- IDE deep links are HTML-only UX over canonical path/line facts. -- Missing snippets or optional meta fields render safe factual fallbacks rather than invented data. - -Refs: - -- `codeclone/report/html/assemble.py:build_html_report` -- `codeclone/report/html/sections/_review.py:render_review_panel` -- `codeclone/report/html/widgets/cards.py:finding_card` -- `codeclone/report/html/sections/_clones.py:_render_group_explanation` -- `codeclone/report/html/sections/_module_map.py:render_module_map_panel` -- `codeclone/report/html/widgets/dep_graph_layout.py` -- `codeclone/report/html/sections/_meta.py:render_meta_panel` -- `codeclone/report/html/assets/js.py:_IDE_LINKS` -- `codeclone/report/overview.py:materialize_report_overview` - -## Invariants (MUST) - -- User/content fields are escaped before insertion into HTML. -- Missing file snippets render explicit fallback blocks. -- Novelty badges reflect baseline trust and per-group novelty flags. -- Suppressed dead-code rows render only from report suppression payloads. -- Path-link `data-file` and `data-line` attributes are escaped before insertion. - -Refs: - -- `codeclone/report/html/primitives/escape.py:_escape_html` -- `codeclone/report/html/widgets/snippets.py:_render_code_block` -- `codeclone/report/html/widgets/tables.py` - -## Failure modes - -| Condition | Behavior | -|-------------------------------------|----------------------------------------| -| Source file unreadable for snippet | Render fallback snippet with message | -| Missing/invalid optional meta field | Render empty or `(none)`-style display | -| Pygments unavailable | Escape-only fallback code rendering | - -Refs: - -- `codeclone/report/html/widgets/snippets.py:_FileCache` -- `codeclone/report/html/widgets/snippets.py:_try_pygments` - -## Determinism / canonicalization - -- Section and group ordering follow sorted canonical report inputs. -- Metadata rows are built in fixed order. - -Refs: - -- `codeclone/report/html/assemble.py:build_html_report` -- `codeclone/report/html/sections/_meta.py:render_meta_panel` - -## Locked by tests - -- `tests/test_html_report.py::test_html_report_uses_core_block_group_facts` -- `tests/test_html_report.py::test_html_report_escapes_meta_and_title` -- `tests/test_html_report.py::test_html_report_escapes_script_breakout_payload` -- `tests/test_html_report.py::test_html_report_missing_source_snippet_fallback` -- `tests/test_html_report.py::test_html_and_json_group_order_consistent` -- `tests/test_html_report.py::test_html_report_quality_includes_security_surfaces_subtab` - -## Non-guarantees - -- CSS, layout, and interaction details may evolve without a schema bump. -- IDE deep link behavior depends on local IDE installation and protocol handlers. diff --git a/docs/book/07-baseline.md b/docs/book/07-baseline.md deleted file mode 100644 index c1911ec6..00000000 --- a/docs/book/07-baseline.md +++ /dev/null @@ -1,143 +0,0 @@ - - -# 07. Baseline - -## Purpose - -Specify clone-baseline schema `2.1`, trust/compatibility checks, integrity -hashing, and runtime behavior. - -## Public surface - -- Baseline object lifecycle: `codeclone/baseline/clone_baseline.py:Baseline` -- Baseline statuses: `codeclone/baseline/trust.py:BaselineStatus` -- Baseline status coercion: `codeclone/baseline/trust.py:coerce_baseline_status` -- CLI integration: `codeclone/surfaces/cli/baseline_state.py` - -## Data model - -Canonical baseline shape: - -- required top-level keys: `meta`, `clones` -- optional top-level keys: `metrics`, `api_surface` (unified baseline flow) -- `meta` required keys: - `generator`, `schema_version`, `fingerprint_version`, `python_tag`, - `created_at`, `payload_sha256` -- `clones` required keys: `functions`, `blocks` -- `functions` and `blocks` are sorted, unique `list[str]` - -Refs: - -- `codeclone/baseline/clone_baseline.py:_TOP_LEVEL_REQUIRED_KEYS` -- `codeclone/baseline/clone_baseline.py:_TOP_LEVEL_OPTIONAL_KEYS` -- `codeclone/baseline/clone_baseline.py:_META_REQUIRED_KEYS` -- `codeclone/baseline/clone_baseline.py:_CLONES_REQUIRED_KEYS` -- `codeclone/baseline/trust.py:_require_sorted_unique_ids` - -## Contracts - -Compatibility gates: - -- `generator.name == "codeclone"` -- supported `schema_version` -- `fingerprint_version == BASELINE_FINGERPRINT_VERSION` -- `python_tag == current_python_tag()` -- integrity verified via `payload_sha256` - -Current runtime policy: - -- new clone baseline saves write schema `2.1` -- runtime accepts `1.0`, `2.0`, and `2.1` -- baseline novelty is **baseline-relative**. A `known` finding is accepted by - the trusted baseline, but that alone does not prove the current patch did not - introduce or reintroduce it. -- patch-local regression claims require a clean before-run to after-run - comparison (`compare_runs` / `check_patch_contract(mode="verify")`), not a - single run's baseline novelty. - -Unified-baseline contract: - -- top-level `metrics` is allowed only for baseline schema `>= 2.0` -- the default runtime flow is unified: clone and metrics comparison state both - live in `codeclone.baseline.json` unless `--metrics-baseline` is redirected -- unified rewrites preserve current embedded metric sections that remain enabled - and drop disabled optional sections instead of keeping stale baggage - -Integrity payload includes only: - -- `clones.functions` -- `clones.blocks` -- `meta.fingerprint_version` -- `meta.python_tag` - -Refs: - -- `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/baseline/metrics_baseline.py:MetricsBaseline.save` - -## Invariants (MUST) - -- Legacy top-level baselines (`functions`/`blocks` at root) are untrusted and require regeneration. -- Baseline writes are atomic (`*.tmp` + `os.replace`). -- Baseline diff is set-based and deterministic. - -Refs: - -- `codeclone/baseline/clone_baseline.py:_is_legacy_baseline_payload` -- `codeclone/baseline/clone_baseline.py:_atomic_write_json` -- `codeclone/baseline/clone_baseline.py:Baseline.diff` - -## Failure modes - -| Condition | Status | -|-------------------------------|-----------------------------------| -| File missing | `missing` | -| Too large | `too_large` | -| JSON decode failure | `invalid_json` | -| Top-level shape/type mismatch | `invalid_type` / `missing_fields` | -| Schema mismatch | `mismatch_schema_version` | -| Fingerprint mismatch | `mismatch_fingerprint_version` | -| Python tag mismatch | `mismatch_python_version` | -| Generator mismatch | `generator_mismatch` | -| Hash missing/invalid | `integrity_missing` | -| Hash mismatch | `integrity_failed` | - -CLI behavior: - -- normal mode: untrusted baseline is ignored and diff runs against empty baseline -- gating mode (`--ci` / `--fail-on-new`): untrusted baseline is a contract error - -Refs: - -- `codeclone/baseline/trust.py:BaselineStatus` -- `codeclone/surfaces/cli/baseline_state.py:resolve_clone_baseline_state` - -## Determinism / canonicalization - -- Clone IDs are serialized sorted. -- Hash serialization uses canonical JSON. -- Integrity verification uses constant-time comparison. - -Refs: - -- `codeclone/baseline/clone_baseline.py:_baseline_payload` -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/baseline/clone_baseline.py:Baseline.verify_integrity` - -## Locked by tests - -- `tests/test_baseline.py::test_baseline_roundtrip_v1` -- `tests/test_baseline.py::test_baseline_payload_fields_contract_invariant` -- `tests/test_baseline.py::test_baseline_payload_sha256_independent_of_schema_version` -- `tests/test_baseline.py::test_baseline_verify_python_tag_mismatch` -- `tests/test_cli_inprocess.py::test_cli_reports_include_audit_metadata_schema_mismatch` - -## Non-guarantees - -- `meta.generator.version` is informational and not a compatibility gate. -- Baseline file indentation/style is not part of compatibility contract. diff --git a/docs/book/08-cache.md b/docs/book/08-cache.md deleted file mode 100644 index 91e523d4..00000000 --- a/docs/book/08-cache.md +++ /dev/null @@ -1,157 +0,0 @@ - - -# 08. Cache - -## Purpose - -Define cache schema `2.10`, integrity verification, stale-entry pruning, and -fail-open behavior. - -## Public surface - -- Cache object lifecycle: `codeclone/cache/store.py:Cache` -- Cache statuses: `codeclone/cache/versioning.py:CacheStatus` -- Stat signature source: `codeclone/cache/store.py:file_stat_signature` -- Wire encode/decode: `codeclone/cache/_wire_encode.py`, - `codeclone/cache/_wire_decode.py` -- CLI/runtime integration: `codeclone/surfaces/cli/runtime.py`, - `codeclone/core/discovery.py` - -## Data model - -On-disk schema (`v == "2.10"`): - -- top-level: `v`, `payload`, `sig` -- `payload` keys: `py`, `fp`, `ap`, `files`, optional `sr` -- `ap` (`analysis_profile`) keys: - `min_loc`, `min_stmt`, `block_min_loc`, `block_min_stmt`, - `segment_min_loc`, `segment_min_stmt`, `collect_api_surface` -- `files` stores compact per-file entries with stat signature, extracted units, - optional metrics sections (including runtime reachability evidence and - report-only `security_surfaces`), - referenced names/qualnames, cached source stats, and optional - **`function_relationship_facts`** -- `sr` stores optional segment-report projection payload - -### `function_relationship_facts` (per-file cache section) - -Cached under the canonical key `function_relationship_facts` in the typed entry; -wire compact key **`fr`**. Each file may store zero or more fact rows keyed by -`source_qualname`, each with a sorted list of relationship records: - -| Field | Meaning | -|---------------------|--------------------------------------------------------| -| `relation_kind` | Deterministic relationship classifier from module walk | -| `resolution_status` | Resolved vs deferred boundary for the target | -| `origin_lane` | Which analysis lane produced the edge | -| `target_qualname` | Callee / related symbol qualname | -| `line` | Source line of the relationship site | -| `expression` | Normalized expression text (bounded) | -| `resolution_rule` | Rule id explaining how the target was resolved | - -Facts are derived during unit extraction (`codeclone/analysis/units.py`) and -persisted on cache save when present. On cache hit, discovery rehydrates them -into the processing pipeline (`codeclone/core/discovery.py`) so warm runs preserve -the same function-relationship evidence as cold runs without recomputing AST -facts. Empty sections are omitted from wire entries. - -Refs: - -- `codeclone/cache/store.py:Cache.load` -- `codeclone/cache/_wire_encode.py:_encode_function_relationship_facts` -- `codeclone/cache/_wire_decode.py:_decode_optional_wire_function_relationship_facts` -- `codeclone/cache/entries.py:_function_relationship_facts_dict_from_model` - -## Contracts - -- Cache is optimization-only; invalid cache never blocks analysis. -- Any cache trust failure triggers warning + empty-cache fallback. -- Compatibility gates: - - `v == CACHE_VERSION` - - `payload.py == current_python_tag()` - - `payload.fp == BASELINE_FINGERPRINT_VERSION` - - `payload.ap` matches the current analysis profile - - `sig` matches deterministic hash of canonical payload -- Stale deleted-file entries are pruned on save/update; cache must reflect the - current worktree, not historical deleted modules. -- Cached entries without valid source stats are treated as cache-miss for - processing counters and reprocessed. - -Refs: - -- `codeclone/cache/store.py:Cache.load` -- `codeclone/cache/store.py:Cache._ignore_cache` -- `codeclone/cache/integrity.py:sign_cache_payload` -- `codeclone/core/discovery.py:discover` - -## Invariants (MUST) - -- Cache save writes canonical JSON and atomically replaces the target file. -- Empty sections are omitted from wire entries. -- Referenced names/qualnames are serialized as sorted unique arrays and omitted when empty. -- Cached public-API symbol payloads preserve declared parameter order. -- Cached runtime reachability facts are required for cold/warm dead-code - equivalence across supported framework registration patterns. -- Cached `function_relationship_facts` round-trip deterministically through wire - encode/decode and preserve relationship ordering within each source qualname. -- Legacy `.cache_secret` is warning-only and never used for trust. - -Refs: - -- `codeclone/cache/store.py:Cache.save` -- `codeclone/cache/_wire_encode.py:_encode_wire_file_entry` -- `codeclone/cache/versioning.py:LEGACY_CACHE_SECRET_FILENAME` - -## Failure modes - -| Condition | `cache_status` | -|---------------------------|--------------------------------| -| File missing | `missing` | -| Too large | `too_large` | -| Stat/read OSError | `unreadable` | -| JSON decode failure | `invalid_json` | -| Type/schema failure | `invalid_type` | -| Version mismatch | `version_mismatch` | -| Python tag mismatch | `python_tag_mismatch` | -| Fingerprint mismatch | `mismatch_fingerprint_version` | -| Analysis profile mismatch | `analysis_profile_mismatch` | -| Signature mismatch | `integrity_failed` | - -CLI behavior: cache failures do not change exit code; analysis continues without cache. - -Refs: - -- `codeclone/cache/versioning.py:CacheStatus` -- `codeclone/cache/store.py:resolve_cache_status` - -## Determinism / canonicalization - -- Cache signatures are computed over canonical JSON payload. -- Wire file paths and compact row arrays are sorted before write. -- Optional segment-report projection is additive; invalid/missing projection - falls back to runtime recomputation. - -Refs: - -- `codeclone/cache/integrity.py:canonical_json` -- `codeclone/cache/projection.py:wire_filepath_from_runtime` -- `codeclone/cache/_wire_encode.py:_encode_wire_file_entry` - -## Locked by tests - -- `tests/test_cache.py::test_cache_v13_uses_relpaths_when_root_set` -- `tests/test_cache.py::test_cache_load_analysis_profile_mismatch` -- `tests/test_cache.py::test_cache_signature_validation_ignores_json_whitespace` -- `tests/test_cache.py::test_cache_signature_mismatch_warns` -- `tests/test_cache.py::test_cache_too_large_warns` -- `tests/test_cache.py::test_cache_roundtrip_preserves_function_relationship_facts` -- `tests/test_cli_inprocess.py::test_cli_reports_cache_too_large_respects_max_size_flag` -- `tests/test_cli_inprocess.py::test_cli_cache_analysis_profile_compatibility` -- `tests/test_core_branch_coverage.py::test_discover_prunes_deleted_cache_entries` - -## Non-guarantees - -- Cache file content stability across schema bumps is not guaranteed. -- Cache is tamper-evident only; it is not an authenticated secret store. diff --git a/docs/book/09-exit-codes.md b/docs/book/09-exit-codes.md deleted file mode 100644 index d5fc9195..00000000 --- a/docs/book/09-exit-codes.md +++ /dev/null @@ -1,106 +0,0 @@ - - -# 09. Contracts: Exit Codes - -## Purpose - -Define stable process exit semantics and category boundaries. - -## Public surface - -- Exit enum: `codeclone/contracts/__init__.py:ExitCode` -- CLI entry: `codeclone/main.py:main` -- CLI orchestration: `codeclone/surfaces/cli/workflow.py:_main_impl` -- Error markers/formatters: `codeclone/ui_messages/*` (canonical definitions in - `markers.py` and `formatters.py`; re-exported from `__init__.py`) - -## Data model - -| Exit code | Category | Meaning | -|-----------|----------------|-----------------------------------------------------| -| `0` | success | Run completed without gating failures | -| `2` | contract error | Input or contract violation | -| `3` | gating failure | Analysis succeeded but policy failed | -| `5` | internal error | Unexpected exception escaped top-level CLI handling | - -Refs: - -- `codeclone/contracts/__init__.py:ExitCode` -- `codeclone/config/argparse_builder.py:_ArgumentParser.error` - -## Contracts - -- Contract errors use the `CONTRACT ERROR:` marker. -- Gating failures use the `GATING FAILURE:` marker. -- Internal errors use `INTERNAL ERROR:` and hide traceback unless debug is enabled. -- `main()` lets `SystemExit` from contract/gating paths pass through unchanged. - -Refs: - -- `codeclone/ui_messages/__init__.py:MARKER_CONTRACT_ERROR` -- `codeclone/ui_messages/__init__.py:MARKER_INTERNAL_ERROR` -- `codeclone/ui_messages/__init__.py:fmt_contract_error` -- `codeclone/report/gates/reasons.py:print_gating_failure_block` -- `codeclone/ui_messages/__init__.py:fmt_internal_error` - -## Invariants (MUST) - -- Only non-`SystemExit` exceptions in `main()` become exit `5`. -- **Gating mode** is enabled when any of `--ci`, `--fail-on-new`, `--fail-threshold`, - `--fail-complexity`, `--fail-coupling`, `--fail-cohesion`, `--fail-cycles`, - `--fail-dead-code`, `--fail-health`, `--fail-on-new-metrics`, - `--fail-on-typing-regression`, `--fail-on-docstring-regression`, - `--fail-on-api-break`, `--min-typing-coverage`, or `--min-docstring-coverage` - is active (`codeclone/surfaces/cli/runtime.py:gating_mode_enabled`). -- **`--fail-on-untested-hotspots`** is a Coverage Join policy gate (exit `3` when - breached). It requires `--coverage` / `coverage_xml` and is evaluated after - metrics analysis, not via `gating_mode_enabled` unreadable-source precedence. -- In gating mode, unreadable source files produce `CONTRACT ERROR:` and exit `2` - **before** clone/metric gate evaluation (`GATING FAILURE:` is suppressed when - both would apply). - -Refs: - -- `codeclone/main.py:main` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Failure modes - -| Condition | Marker | Exit | -|---------------------------------------------------------------|------------------|------| -| Invalid output extension/path | `CONTRACT ERROR` | `2` | -| Invalid CLI flag combination | `CONTRACT ERROR` | `2` | -| Invalid controller query combination | `CONTRACT ERROR` | `2` | -| `--patch-verify` without trusted baseline | `CONTRACT ERROR` | `2` | -| Untrusted baseline in CI/gating | `CONTRACT ERROR` | `2` | -| Unreadable source in CI/gating | `CONTRACT ERROR` | `2` | -| New clones with `--fail-on-new` | `GATING FAILURE` | `3` | -| Blocking `--patch-verify` violation | `GATING FAILURE` | `3` | -| Threshold or metrics gate breach | `GATING FAILURE` | `3` | -| Untested coverage hotspots with `--fail-on-untested-hotspots` | `GATING FAILURE` | `3` | -| Unexpected exception in top-level CLI path | `INTERNAL ERROR` | `5` | - -## Determinism / canonicalization - -- Help epilog strings are generated from static constants. -- Error category markers are static constants. - -Refs: - -- `codeclone/contracts/__init__.py:cli_help_epilog` -- `codeclone/ui_messages/__init__.py:MARKER_CONTRACT_ERROR` - -## Locked by tests - -- `tests/test_cli_unit.py::test_cli_help_text_consistency` -- `tests/test_cli_unit.py::test_cli_internal_error_marker` -- `tests/test_cli_unit.py::test_cli_internal_error_debug_flag_includes_traceback` -- `tests/test_cli_inprocess.py::test_cli_unreadable_source_fails_in_ci_with_contract_error` -- `tests/test_cli_inprocess.py::test_cli_contract_error_priority_over_gating_failure_for_unreadable_source` - -## Non-guarantees - -- Exact message body wording may evolve; marker category and exit code are contract. diff --git a/docs/book/10-config-and-defaults.md b/docs/book/10-config-and-defaults.md deleted file mode 100644 index 30007c8f..00000000 --- a/docs/book/10-config-and-defaults.md +++ /dev/null @@ -1,598 +0,0 @@ - - -# 10. Config and Defaults - -## Purpose - -Describe effective runtime configuration and defaults that affect behavior. - -## Public surface - -- Option specs/defaults: `codeclone/config/spec.py` -- CLI parser and defaults: `codeclone/config/argparse_builder.py:build_parser` -- Pyproject config loader: `codeclone/config/pyproject_loader.py:load_pyproject_config` -- Config resolver: `codeclone/config/resolver.py:resolve_config` -- Effective cache default path logic: `codeclone/surfaces/cli/runtime.py:_resolve_cache_path` -- Metrics-mode selection logic: `codeclone/surfaces/cli/runtime.py:_configure_metrics_mode` -- Debug mode sources: `codeclone/surfaces/cli/console.py:_is_debug_enabled` - -## Data model - -Configuration sources for the main `codeclone` CLI scan, in precedence order: - -1. CLI flags (`argparse`, explicit options only) -2. `pyproject.toml` section `[tool.codeclone]` -3. Code defaults in parser and runtime - -Engineering Memory, the workspace intent registry, MCP session TTL/lease, and -other subsystems listed in [Environment variable overrides](#environment-variable-overrides) -may also read `CODECLONE_*` variables. Those overrides apply only to the -documented fields and do not change main CLI precedence unless noted. - -Key defaults: - -- `root="."` -- `--min-loc=10` -- `--min-stmt=6` -- `--processes=4` -- `--baseline=codeclone.baseline.json` -- `--max-baseline-size-mb=5` -- `--max-cache-size-mb=50` -- `--coverage-min=50` -- default cache path (when no cache flag is given): `/.codeclone/cache.json` -- `--metrics-baseline=codeclone.baseline.json` (same default path as `--baseline`) -- bare reporting flags use default report paths: - - `--html` -> `/.codeclone/report.html` - - `--json` -> `/.codeclone/report.json` - - `--md` -> `/.codeclone/report.md` - - `--sarif` -> `/.codeclone/report.sarif` - - `--text` -> `/.codeclone/report.txt` -- legacy locations (CLI warns, does not migrate automatically): - - home cache: `~/.cache/codeclone/cache.json` when it differs from the project cache path - - repo workspace: non-empty `/.cache/codeclone/` from releases before `2.1.0a1` - -Fragment-level admission thresholds (pyproject.toml only, advanced tuning): - -- `block_min_loc=20` — minimum function LOC for block-level sliding window -- `block_min_stmt=8` — minimum function statements for block-level sliding window -- `segment_min_loc=20` — minimum function LOC for segment-level sliding window -- `segment_min_stmt=10` — minimum function statements for segment-level sliding window - -Example project-level config: - -```toml title="Minimal [tool.codeclone] configuration" -[tool.codeclone] -min_loc = 10 -min_stmt = 6 -baseline = "codeclone.baseline.json" -skip_metrics = true -quiet = true -``` - -Supported `[tool.codeclone]` keys in the current line: - -`Requires / Implies` lists only runtime-enforced relationships from the current -code path. Use `-` when the key has no special dependency contract. - -Analysis: - -| Key | Type | Default | Meaning | Requires / Implies | -|------------------------|---------------|--------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------| -| `min_loc` | `int` | `10` | Minimum function LOC for clone admission | `-` | -| `min_stmt` | `int` | `6` | Minimum function statement count for clone admission | `-` | -| `block_min_loc` | `int` | `20` | Minimum function LOC for block-window analysis | `-` | -| `block_min_stmt` | `int` | `8` | Minimum function statements for block-window analysis | `-` | -| `segment_min_loc` | `int` | `20` | Minimum function LOC for segment analysis | `-` | -| `segment_min_stmt` | `int` | `10` | Minimum function statements for segment analysis | `-` | -| `processes` | `int` | `4` | Worker process count | `-` | -| `cache_path` | `str \| null` | `/.codeclone/cache.json` | Cache file path | `-` | -| `max_cache_size_mb` | `int` | `50` | Maximum accepted cache size before fail-open ignore | `-` | -| `skip_metrics` | `bool` | `false*` | Skip full metrics mode when allowed | Incompatible with metrics gates/update; auto-enabled in some runs* | -| `skip_dead_code` | `bool` | `false` | Skip dead-code analysis | Forced on by `skip_metrics`; overridden by `fail_dead_code` | -| `skip_dependencies` | `bool` | `false` | Skip dependency analysis | Forced on by `skip_metrics`; overridden by `fail_cycles` | -| `golden_fixture_paths` | `list[str]` | `[]` | Exclude clone groups fully contained in matching golden test fixtures from health/gates/active findings; keep them as suppressed report facts | Patterns must resolve under `tests/` or `tests/fixtures/` | - -Baseline and CI: - -| Key | Type | Default | Meaning | Requires / Implies | -|---------------------------|--------|---------------------------|-------------------------------------------|-----------------------------------------------------------------------------------------------------------------| -| `baseline` | `str` | `codeclone.baseline.json` | Clone baseline path | Default target for `metrics_baseline` when not overridden | -| `max_baseline_size_mb` | `int` | `5` | Maximum accepted baseline size | `-` | -| `update_baseline` | `bool` | `false` | Rewrite unified baseline from current run | In unified mode, auto-enables `update_metrics_baseline` unless `skip_metrics=true` | -| `metrics_baseline` | `str` | `codeclone.baseline.json` | Dedicated metrics-baseline path override | Defaults to `baseline` path when not overridden | -| `update_metrics_baseline` | `bool` | `false` | Rewrite metrics baseline from current run | Requires metrics analysis; may auto-enable `update_baseline` for missing shared baseline | -| `ci` | `bool` | `false` | Enable CI preset behavior | Implies `fail_on_new`, `no_color`, `quiet`; enables `fail_on_new_metrics` when a trusted metrics baseline loads | - -Quality gates and metric collection: - -| Key | Type | Default | Meaning | Requires / Implies | -|--------------------------------|---------------|---------|-------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------| -| `fail_on_new` | `bool` | `false` | Fail when new clone groups appear | Requires a trusted clone baseline | -| `fail_threshold` | `int` | `-1` | Fail when clone count exceeds threshold | `-` | -| `fail_complexity` | `int` | `-1` | Fail when max cyclomatic complexity exceeds threshold | Incompatible with `skip_metrics` | -| `fail_coupling` | `int` | `-1` | Fail when max CBO exceeds threshold | Incompatible with `skip_metrics` | -| `fail_cohesion` | `int` | `-1` | Fail when max LCOM4 exceeds threshold | Incompatible with `skip_metrics` | -| `fail_cycles` | `bool` | `false` | Fail when dependency cycles are present | Incompatible with `skip_metrics`; forces dependency analysis | -| `fail_dead_code` | `bool` | `false` | Fail when high-confidence dead code is present | Incompatible with `skip_metrics`; forces dead-code analysis | -| `fail_health` | `int` | `-1` | Fail when health score drops below threshold | Incompatible with `skip_metrics` | -| `fail_on_new_metrics` | `bool` | `false` | Fail on new metric hotspots vs trusted metrics baseline | Requires trusted metrics baseline; incompatible with `skip_metrics`; auto-enabled by `ci` when baseline loads | -| `api_surface` | `bool` | `false` | Collect public API inventory/diff facts | Auto-enabled by `fail_on_api_break` | -| `coverage_xml` | `str \| null` | `null` | Join external Cobertura XML to current-run function spans | Enables Coverage Join | -| `coverage_min` | `int` | `50` | Coverage threshold for joined measured coverage hotspots | Used by Coverage Join; meaningful with `coverage_xml` | -| `min_typing_coverage` | `int` | `-1` | Minimum allowed typing coverage percent | Incompatible with `skip_metrics` | -| `min_docstring_coverage` | `int` | `-1` | Minimum allowed docstring coverage percent | Incompatible with `skip_metrics` | -| `fail_on_typing_regression` | `bool` | `false` | Fail on typing coverage regression vs metrics baseline | Requires trusted metrics baseline with adoption snapshot; incompatible with `skip_metrics` | -| `fail_on_docstring_regression` | `bool` | `false` | Fail on docstring coverage regression vs metrics baseline | Requires trusted metrics baseline with adoption snapshot; incompatible with `skip_metrics` | -| `fail_on_api_break` | `bool` | `false` | Fail on public API breaking changes vs metrics baseline | Requires trusted metrics baseline with API surface snapshot; incompatible with `skip_metrics`; implies `api_surface` | -| `fail_on_untested_hotspots` | `bool` | `false` | Fail when medium/high-risk functions measured by Coverage Join fall below threshold | Incompatible with `skip_metrics`; requires successful Coverage Join to fire | - -Report outputs and local UX: - -| Key | Type | Default | Meaning | Requires / Implies | -|---------------|---------------|---------|--------------------------------|-----------------------------------------------------------------------------------------| -| `html_out` | `str \| null` | `null` | HTML report output path | `-` | -| `json_out` | `str \| null` | `null` | JSON report output path | `-` | -| `md_out` | `str \| null` | `null` | Markdown report output path | `-` | -| `sarif_out` | `str \| null` | `null` | SARIF report output path | `-` | -| `text_out` | `str \| null` | `null` | Plain-text report output path | `-` | -| `no_progress` | `bool` | `false` | Disable progress UI | Implied by `quiet` | -| `no_color` | `bool` | `false` | Disable colored CLI output | Enabled by `ci` | -| `quiet` | `bool` | `false` | Use compact CLI output | Implies `no_progress`; enabled by `ci` | -| `verbose` | `bool` | `false` | Enable more verbose CLI output | `-` | -| `debug` | `bool` | `false` | Enable debug diagnostics | Also enabled by `CODECLONE_DEBUG`; see [env overrides](#environment-variable-overrides) | - -Controller audit trail: - -| Key | Type | Default | Meaning | Requires / Implies | -|-------------------------|--------|-------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------| -| `audit_enabled` | `bool` | `false` | Enable the optional local controller audit trail | Required for `--audit` output | -| `audit_path` | `str` | `.codeclone/db/audit.sqlite3` | SQLite audit database path, relative to the analysis root; stored under `db/` to separate controller state from report/cache artifacts | Used only when `audit_enabled=true` | -| `audit_payloads` | `str` | `compact` | Audit payload mode: `off`, `compact`, or `full`. Compact omits large fields but keeps `intent_description` on `intent.declared`; row `summary` always stores a short essence | Used only when `audit_enabled=true` | -| `audit_retention_days` | `int` | `30` | Retention window for audit rows | Used only when `audit_enabled=true` | -| `audit_token_estimator` | `str` | `chars_approx` | Audit payload token estimator: default `chars_approx`, or explicit `tiktoken` opt-in with `codeclone[token-bench]` | Used only when `audit_enabled=true` | - -Workspace intent registry: - -| Key | Type | Default | Meaning | Requires / Implies | -|----------------------------------|-------|---------------------------------|-------------------------------------------------------------------------------------|-------------------------------------------------| -| `intent_registry_backend` | `str` | `file` | Workspace intent storage backend: `file` or `sqlite` | MCP workspace coordination | -| `intent_registry_path` | `str` | `.codeclone/db/intents.sqlite3` | SQLite registry database path, relative to the analysis root | Used only when `intent_registry_backend=sqlite` | -| `intent_registry_retention_days` | `int` | `14` | Retention window for closed SQLite intent rows; any positive value (no edition cap) | Used only when `intent_registry_backend=sqlite` | - -Retention is configurable to any positive number of days; there is no edition -cap. Managed/hosted retention (central storage, backup, compliance) is a roadmap -Team/Enterprise option — see [Plans and Retention](../plans-and-retention.md). - -### Engineering Memory (nested tables) - -Keys under `[tool.codeclone.memory]` and `[tool.codeclone.memory.semantic]` are -**not** part of the root `[tool.codeclone]` table above. They are validated by -`codeclone/config/memory.py` / `SemanticConfig` and documented in -[Engineering Memory](13-engineering-memory/index.md). - -Trajectory / projection keys (defaults from `codeclone/config/memory_defaults.py`): - -| Key | Default | Meaning | -|----------------------------------------------|------------------------------------------------|---------------------------------------------------------------------| -| `backend` | `sqlite` | Memory store backend (`sqlite` or `postgres`) | -| `db_path` | `.codeclone/memory/engineering_memory.sqlite3` | SQLite path under repo root | -| `mcp_sync_policy` | `bootstrap_if_missing` | MCP auto-bootstrap when store missing (`off`, `refresh_when_stale`) | -| `active_retention_days` | `-1` | Active record retention (`-1` = no expiry) | -| `stale_retention_days` | `180` | Stale record retention before vacuum | -| `draft_retention_days` | `14` | Draft candidate retention | -| `rejected_retention_days` | `30` | Rejected draft retention | -| `archived_retention_days` | `365` | Archived record retention | -| `receipt_retention_days` | `90` | Patch Trail receipt retention | -| `max_records` | `10000` | Hard cap on active memory rows | -| `max_candidates` | `1000` | Draft candidate cap | -| `max_evidence_per_record` | `20` | Evidence links per record | -| `max_statement_chars` | `1000` | Hard statement length cap | -| `max_blast_radius_cache_entries` | `500` | Blast-radius cache size | -| `git_hotspot_period_days` | `90` | Git hotspot lookback window | -| `git_hotspot_min_changes` | `5` | Minimum commits to qualify as hotspot | -| `trajectories_enabled` | `true` | Gate trajectory rebuild and retrieval | -| `trajectory_retention_days` | `365` | Retention hint for vacuum | -| `projection_rebuild_policy` | `off` | `enqueue_when_stale` enqueues worker on accepted finish | -| `projection_rebuild_running_timeout_seconds` | `1800` | Stale running job timeout | -| `projection_rebuild_spawn_worker` | `true` | Spawn worker on enqueue | -| `projection_rebuild_coalesce_window_seconds` | `60` | Batch sub-threshold rebuilds within window (`0` = immediate spawn) | -| `projection_rebuild_coalesce_min_delta` | `25` | Active-record delta that bypasses coalesce window | -| `trajectory_export_enabled` | `false` | Gate CLI JSONL export | -| `trajectory_export_include_payloads` | `false` | Include step payloads in export rows | -| `trajectory_export_max_record_bytes` | `65536` | Per export row cap | -| `trajectory_export_max_file_bytes` | `10485760` | Output file cap | - -| Semantic field | Default | Meaning | -|-------------------------------------|---------------------------------------------------|------------------------------------------------------------------------------------------------------------------------| -| `enabled` | `false` | Turn on LanceDB sidecar indexing and search blend | -| `backend` | `lancedb` | Vector backend (only `lancedb` today) | -| `index_path` | `.codeclone/memory/semantic_index.lance` | Sidecar directory | -| `embedding_provider` | `diagnostic` | `diagnostic` (hash vectors, not semantic quality), `fastembed` (local semantic-quality provider), `local_model`, `api` | -| `embedding_model` | `null` (`BAAI/bge-small-en-v1.5` for `fastembed`) | Optional provider model name | -| `embedding_cache_dir` | `.codeclone/memory/fastembed` | Local model cache used by `fastembed` | -| `allow_model_download` | `false` | Permit `fastembed` to download a missing model instead of requiring a pre-populated cache | -| `dimension` | `256` (`384` for `fastembed`) | Vector size; must match the provider model | -| `max_results` | `20` | Cap for vector `k` and merged search ranking | -| `index_audit` | `true` | Index bounded audit `summary` rows when audit DB exists | -| `embed_max_documents_per_batch` | `64` | Max documents per embedding batch | -| `embed_max_padded_tokens_per_batch` | `8192` | Max padded tokens per embedding batch | -| `projection_token_estimator` | `chars_approx` | Token estimate for semantic projection (`chars_approx` or `tiktoken`) | - -Semantic-quality local search requires both the LanceDB sidecar and FastEmbed: -install `codeclone[semantic-local]` (or combine `semantic-lancedb` + -`semantic-fastembed`), set `embedding_provider = "fastembed"`, then run -`codeclone memory semantic rebuild` after enabling. `semantic-lancedb` alone can -build the sidecar with the diagnostic hash provider, which is deterministic but -not semantic-quality recall. - -This is the exact accepted `[tool.codeclone]` key set from -`codeclone/config/spec.py` and `codeclone/config/pyproject_loader.py`; unknown -keys are contract errors. - -!!! note "Pyproject keys vs CLI flags" - The tables above list `[tool.codeclone]` keys, not CLI flag spellings. - CLI flags may map to the same internal destination under a different name. - Example: `coverage_xml` in `pyproject.toml` corresponds to CLI - `--coverage FILE`. The same pattern applies to report outputs such as - `html_out` ↔ `--html` and `json_out` ↔ `--json`. - - CLI-only flags (no `[tool.codeclone]` key; authoritative spelling in - `tests/fixtures/contract_snapshots/cli_help.txt`): - - | CLI flag | Group | Meaning | - |----------------------------------|---------------|------------------------------------------------------------------------------------| - | `--changed-only` | Analysis | Limit clone gating/summaries to git-selected files | - | `--diff-against GIT_REF` | Analysis | Resolve changed files from `git diff --name-only `; requires `--changed-only` | - | `--paths-from-git-diff GIT_REF` | Analysis | Shorthand for `--changed-only` + git diff selection | - | `--blast-radius FILE [FILE ...]` | Analysis | Render structural blast radius for given files after analysis | - | `--patch-verify` | Analysis | Verify current patch against trusted clone baseline-relative budget | - | `--strictness LEVEL` | Analysis | `ci`, `strict`, or `relaxed`; valid only with `--patch-verify` (default: `ci`) | - | `--session-stats` | Analysis | Show workspace session status; read-only | - | `--audit` | Analysis | Show local Controller audit trail; requires `audit_enabled=true` | - | `--audit-json` | Analysis | JSON audit footprint; uses audit collector; requires `audit_enabled=true` | - | `--cache-dir [FILE]` | Analysis | Legacy alias for `--cache-path` | - | `--timestamped-report-paths` | Reporting | Append UTC timestamp to default report filenames | - | `--open-html-report` | Output and UI | Open generated HTML in browser; requires `--html` | - | `--progress` | Output and UI | Force-enable progress output | - | `--color` | Output and UI | Force-enable ANSI colors | - - Canonical help text, defaults, and exit-code epilog are locked by - `tests/test_cli_help_snapshot.py` and `tests/test_cli_unit.py::test_cli_help_text_consistency`. - - #### Controller and workspace query combinations - - Enforced by `codeclone/surfaces/cli/workflow.py:_validate_controller_query_flags`: - - | Combination | Result | - |-------------|--------| - | `--blast-radius` + `--patch-verify` | contract error | - | `--session-stats` + explicit `--audit` | contract error | - | `--session-stats` + `--blast-radius` or `--patch-verify` | contract error | - | explicit `--audit` + `--blast-radius` or `--patch-verify` | contract error | - | any controller query + `--changed-only`, `--diff-against`, or `--paths-from-git-diff` | contract error | - | any controller query + report output flags | contract error | - | any controller query + `--update-baseline` / `--update-metrics-baseline` | contract error | - | `--strictness` without `--patch-verify` (when `--strictness` is explicit on argv) | contract error | - - Notes: - - - `--patch-verify` cannot scope via `--diff-against`; use changed-scope flags for - git-selected review, or `--patch-verify` alone for baseline-relative terminal check. - - `--audit-json` selects JSON audit output but does **not** set the `--audit` flag - for combination validation. `--session-stats` blocks only explicit `--audit`. - - Pre-analysis queries (`--session-stats`, `--audit`, `--audit-json`) exit before - analysis; only the first matching mode runs per invocation. - - `--audit` and `--audit-json` both require `audit_enabled=true` in effective config. - -!!! warning "Metrics-mode conflicts are enforced" - Metrics update/gating flags are runtime contracts, not hints. Combinations - such as `skip_metrics=true` together with metrics gating or metrics - baseline update flags are contract errors. - - Notes: - - - `skip_metrics=false*`: parser default is `false`, but runtime may auto-enable - it when no metrics work is requested and no metrics baseline exists. - - Report output keys default to `null`; bare CLI flags still write to the - deterministic `.codeclone/report.*` paths listed above. - - CLI always has precedence when option is explicitly provided, including boolean - overrides via `--foo/--no-foo` (e.g. `--no-skip-metrics`). - - Path values loaded from `pyproject.toml` are normalized relative to resolved - scan root when provided as relative paths. - - `golden_fixture_paths` is different: - - - entries are repo-relative glob patterns, not filesystem paths - - they are not normalized to absolute paths - - they must target `tests/` or `tests/fixtures/` - - a clone group is excluded only when every occurrence matches the configured - golden-fixture scope - - Current-run coverage join config: - - - `coverage_xml` is the `[tool.codeclone]` key; the equivalent CLI flag is - `--coverage FILE`. - - `coverage_xml` may be set in `pyproject.toml`; relative paths resolve from - the scan root like other configured paths. - - `coverage_min` and `fail_on_untested_hotspots` follow the same precedence - rules as CLI flags. - - Coverage join remains current-run only and does not persist to baseline. - - Dependency depth config note: - - - `dependency_max_depth` is an observed metric in reports/baselines, not a - CLI or `pyproject.toml` option. - - Dependency depth now uses an internal adaptive profile based on - `avg_depth`, `p95_depth`, and `max_depth` for the internal module graph. - - There is no user-facing knob to tune that model in the current `2.x` release - line. - - Metrics baseline path selection contract: - - - Relative `baseline` / `metrics_baseline` paths coming from defaults or - `pyproject.toml` resolve from the analysis root. - - If `--metrics-baseline` is explicitly set, that path is used. - - If `metrics_baseline` in `pyproject.toml` differs from parser default, that - configured path is used even without explicit CLI flag. - - Otherwise, metrics baseline defaults to the clone baseline path. - - In other words, metrics do **not** live in a separate file by default: - the default unified flow uses the same `codeclone.baseline.json` path for - clone and metrics comparison state. - - Refs: - - - `codeclone/config/spec.py` - - `codeclone/config/argparse_builder.py:build_parser` - - `codeclone/config/pyproject_loader.py:load_pyproject_config` - - `codeclone/config/resolver.py:resolve_config` - - `codeclone/surfaces/cli/workflow.py:_main_impl` - - `codeclone/surfaces/cli/runtime.py:_configure_metrics_mode` - -## Environment variable overrides - -Single home for all `CODECLONE_*` environment variables. Other chapters link here -instead of duplicating tables. - -**Truthy values** (where noted): `1`, `true`, `yes`, `on` (case-insensitive). -**Falsy values** (where noted): `0`, `false`, `no`, `off`. - -### Precedence by subsystem - -| Subsystem | Resolver | Precedence when an env var is set | -|----------------------------------|--------------------------------------------------|--------------------------------------------------------------------------------------------| -| Main CLI scan | `resolve_config` | CLI > pyproject > defaults; only `CODECLONE_DEBUG` applies from env | -| Engineering Memory | `resolve_memory_config` | Documented env > `[tool.codeclone.memory]` / `[tool.codeclone.memory.semantic]` > defaults | -| Workspace intent registry | `resolve_intent_registry_config` | Documented env > `[tool.codeclone]` registry keys > defaults | -| MCP workspace intent TTL / lease | `resolved_ttl_seconds`, `resolved_lease_seconds` | Explicit MCP tool parameter > env > built-in default | -| Finish hygiene strict mode | `_strict_finish_enabled` | Env only (no pyproject key) | -| Platform Observability | `resolve_observability_config` | Env only; disabled by default, no pyproject table | -| Corpus Analytics | `resolve_analytics_config` | `[tool.codeclone.analytics]` > built-in defaults; no env overrides in Slice 1 | -| Cursor / IDE hooks | hook helpers | Env > repo config file (where noted) > built-in default | - -There is no generic `CODECLONE_MEMORY__*` nested env convention. Each variable -name is flat and listed below. - -### Diagnostics - -| Variable | Values | Effect | -|-------------------|---------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `CODECLONE_DEBUG` | exactly `1` enables | Turns on CLI debug diagnostics (`codeclone/surfaces/cli/console.py`). Other truthy strings such as `true` do **not** enable debug. Independent of analysis, gating, and `[tool.codeclone] debug`. | - -### Platform Observability - -Platform Observability is environment-only and disabled by default. It has no -`[tool.codeclone.observability]` table. See -[Platform Observability](26-platform-observability.md) for the data and trust -contracts. - -### Corpus Analytics - -Optional intent corpus clustering uses `[tool.codeclone.analytics]`. Install -`codeclone[analytics]` before running `codeclone analytics …`. Paths resolve -under the repository root. The historical audit source is inherited from the -top-level `[tool.codeclone].audit_path`; it is not duplicated in the analytics -table. `embedding_provider` currently accepts only `fastembed`, and -`default_cluster_selection_method` accepts `eom` or `leaf`. Full key list: -[Corpus Analytics](27-corpus-analytics.md#configuration). - -Profile control-plane keys (`default_profile_id`, `profile_paths`, and the -non-profile `sweep_*` axes) live in the same nested table and are documented in -[Corpus Analytics — Configuration](27-corpus-analytics.md#configuration). Profile -manifests are validated with the same schema as bundled profiles. Configured -paths must resolve to readable files inside the repository. Profile sweeps use -the manifest grid; the sweep keys configure only non-profile sweeps. - -Refs: - -- `codeclone/config/analytics.py:resolve_analytics_config` -- `codeclone/config/pyproject_loader.py:load_pyproject_config` - -### Platform Observability (environment) - -| Variable | Values | Effect | -|-------------------------------------------------|----------------|---------------------------------------------------------------------| -| `CODECLONE_OBSERVABILITY_ENABLED` | truthy / falsy | Enable local operation/span instrumentation. | -| `CODECLONE_OBSERVABILITY_FORCE` | truthy / falsy | Lift the CI collection guard; does not enable collection by itself. | -| `CODECLONE_OBSERVABILITY_PROFILE` | truthy / falsy | Capture process metrics; requires `codeclone[perf]`. | -| `CODECLONE_OBSERVABILITY_PERSIST` | truthy / falsy | Persist completed operations; default true when enabled. | -| `CODECLONE_OBSERVABILITY_CAPTURE_PAYLOAD_SIZES` | truthy / falsy | Capture bounded size/context-unit estimates; default true. | -| `CODECLONE_OBSERVABILITY_PAYLOAD_SNAPSHOT` | reserved | Rejected; raw payload snapshots are unsupported. | -| `CODECLONE_OBSERVABILITY_CORRELATION_ID` | internal ID | Worker handoff for cross-process correlation; set by CodeClone. | -| `CODECLONE_OBSERVABILITY_PARENT_OPERATION_ID` | internal ID | Worker handoff for the parent operation; set by CodeClone. | - -The internal correlation variables are launcher/worker protocol, not operator -tuning knobs. - -### Engineering Memory - -Overrides `[tool.codeclone.memory]` and `[tool.codeclone.memory.semantic]` for the -listed field only. Paths resolve under the repository root like pyproject paths. - -| Variable | Values | Overrides | Effect | -|--------------------------------------------------|-------------------------------------------------|----------------------------------------|-------------------------------------------------------------------------------------------| -| `CODECLONE_MEMORY_DB_PATH` | repo-relative or absolute path under root | `memory.db_path` | SQLite Engineering Memory store location | -| `CODECLONE_PROJECTION_REBUILD_POLICY` | `off`, `enqueue_when_stale` | `memory.projection_rebuild_policy` | When accepted MCP finish may enqueue async trajectory/semantic/Experience projection jobs | -| `CODECLONE_MEMORY_SEMANTIC_ENABLED` | `true` / `false` | `memory.semantic.enabled` | Turn semantic index sidecar on or off | -| `CODECLONE_MEMORY_SEMANTIC_EMBEDDING_PROVIDER` | `diagnostic`, `fastembed`, `local_model`, `api` | `memory.semantic.embedding_provider` | Embedding backend for semantic rebuild/search | -| `CODECLONE_MEMORY_SEMANTIC_EMBEDDING_MODEL` | model name string | `memory.semantic.embedding_model` | Provider model id (for example FastEmbed model name) | -| `CODECLONE_MEMORY_SEMANTIC_EMBEDDING_CACHE_DIR` | path | `memory.semantic.embedding_cache_dir` | Local ONNX/model cache directory for FastEmbed | -| `CODECLONE_MEMORY_SEMANTIC_ALLOW_MODEL_DOWNLOAD` | `true` / `false` | `memory.semantic.allow_model_download` | When `false`, FastEmbed requires a pre-populated cache | -| `CODECLONE_MEMORY_SEMANTIC_INDEX_PATH` | path | `memory.semantic.index_path` | LanceDB semantic sidecar directory | - -Memory keys without a documented env override (for example retention caps, -`projection_rebuild_spawn_worker`, and projection coalesce settings) are -pyproject-only. - -Refs: `codeclone/config/memory.py`, `codeclone/config/memory_defaults.py`. - -### Workspace intent registry - -Overrides `[tool.codeclone]` registry keys. Used by MCP workspace coordination -and local hook gate reads. - -| Variable | Values | Overrides | Effect | -|--------------------------------------------|-----------------------------------------|----------------------------------|------------------------------------------------------------------------| -| `CODECLONE_INTENT_REGISTRY_BACKEND` | `file`, `sqlite` | `intent_registry_backend` | File-per-intent JSON under `.codeclone/intents/` vs SQLite WAL backend | -| `CODECLONE_INTENT_REGISTRY_PATH` | `.sqlite3` / `.db` path under repo root | `intent_registry_path` | SQLite database path when backend is `sqlite` | -| `CODECLONE_INTENT_REGISTRY_RETENTION_DAYS` | integer `>= 1` | `intent_registry_retention_days` | Closed-row retention for SQLite backend purge | - -Refs: `codeclone/config/intent_registry.py`. - -### MCP session and change-control hygiene - -| Variable | Values | Applies when | Effect | -|----------------------------------|----------------|--------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------| -| `CODECLONE_INTENT_TTL_SECONDS` | `60`–`86400` | `start_controlled_change` / workspace registry write when tool `ttl_seconds` omitted | Hard maximum lifetime of a workspace intent record (default `3600`) | -| `CODECLONE_INTENT_LEASE_SECONDS` | `60`–`600` | Workspace registry lease renewal when tool `lease_seconds` omitted | Ownership freshness window renewed by active MCP use (default `300`) | -| `CODECLONE_STRICT_FINISH` | truthy / falsy | `finish_controlled_change` hygiene | When truthy, unattributed out-of-scope dirty may set `finish_block_reason: own_unscoped_dirty` and block finish; default is advisory only | - -Explicit `ttl_seconds` / `lease_seconds` on MCP tools take precedence over the -matching env var. - -Refs: `codeclone/surfaces/mcp/_workspace_intents.py`, -`codeclone/surfaces/mcp/_workspace_hygiene.py`. - -### MCP HTTP authentication - -| Variable | Values | Effect | -|----------------------------|-------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `CODECLONE_MCP_AUTH_TOKEN` | string, minimum 32 characters | **Required** for every `streamable-http` start (loopback or remote). The launcher exits with code `2` when the variable is missing or shorter than 32 characters — there is no unauthenticated HTTP mode. Clients send `Authorization: Bearer …`; the server validates with `hmac.compare_digest`. Non-loopback bind additionally requires `--allow-remote`. | - -Refs: `codeclone/surfaces/mcp/auth.py`, `codeclone/surfaces/mcp/server.py`, -[21-security-model.md](21-security-model.md#remote-mcp-transport). - -### Workspace edit gate (hooks) - -Read by `codeclone/workspace_intent/gate.py` for Cursor/IDE pre-edit enforcement. - -| Variable | Values | Default when unset | Effect | -|----------------------------------------|-----------------------|----------------------------------|------------------------------------------------------------------------------------------------| -| `CODECLONE_HOOK_AUTHORIZE_FOREIGN` | truthy / falsy | authorize foreign active intents | When `0`/`false`/`no`/`off`, a live foreign active intent does not authorize local hook writes | -| `CODECLONE_HOOK_OWN_AGENT_PID` | integer PID | hook argument only | Limits stop-hook cleanup to recoverable intents owned by this process | -| `CODECLONE_HOOK_OWN_AGENT_START_EPOCH` | integer epoch seconds | hook argument only | Pairs with own-agent PID for recoverable intent matching | - -### Cursor plugin hooks - -| Variable | Values | Precedence | Effect | -|---------------------------------|------------------|---------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------| -| `CODECLONE_HOOKS_ENFORCE_SCOPE` | `python`, `repo` | env > `.cursor/codeclone-hooks.json` > default `python` | `python`: gate `.py`/`.pyi` edits and matching shell commands. `repo`: gate any path under workspace root including `.git/**` | - -Refs: `plugins/cursor-codeclone/hooks/_hook_io.py`, [integrations/cursor-plugin.md](integrations/cursor-plugin.md). - -### IDE and MCP launcher passthrough - -Set by VS Code, Claude Desktop, Claude Code, Codex, and Cursor launchers — not -usually edited in `pyproject.toml`. Launchers forward variables prefixed with -`CODECLONE_` to the child `codeclone-mcp` process. - -| Variable | Values | Effect | -|-----------------------------------|---------------------|-------------------------------------------------------------------------------------------------------------------------------| -| `CODECLONE_WORKSPACE_ROOT` | absolute path | Preferred repository root for launcher workspace discovery and MCP child env when cwd/PWD disagree with the trusted workspace | -| `CODECLONE_MCP_COMMAND` | command path | Claude Desktop bundle: override MCP server executable | -| `CODECLONE_MCP_ARGS_JSON` | JSON string array | Claude Desktop bundle: extra launcher argv (stdio transport locked by IDE clients) | -| `CODECLONE_MCP_SHUTDOWN_GRACE_MS` | positive integer ms | Grace period before SIGTERM when stopping MCP child (default `5000`) | -| `CODECLONE_MCP_KILL_GRACE_MS` | positive integer ms | Grace period before SIGKILL after SIGTERM (default `2000`) | - -Refs: `plugins/codeclone/scripts/launch_mcp.py`, -`extensions/vscode-codeclone/src/support.js`, -`extensions/claude-desktop-codeclone/src/launcher.js`. - -## Contracts - -- `--ci` is a preset: enables `fail_on_new`, `no_color`, `quiet`. -- In CI mode, if trusted metrics baseline is loaded, runtime also enables - `fail_on_new_metrics`. -- `--quiet` implies `--no-progress`. -- Negative size limits are contract errors. - -Refs: - -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Invariants (MUST) - -- Detection thresholds (`min-loc`, `min-stmt`) affect function-level extraction. -- Fragment thresholds (`block_min_loc/stmt`, `segment_min_loc/stmt`) affect block/segment extraction. -- All six thresholds are part of cache compatibility (`payload.ap`). -- Reporting flags (`--html/--json/--md/--sarif/--text`) affect output only. -- Reporting flags accept optional path values; passing bare flag writes to - deterministic default path under `.codeclone/`. -- `--cache-path` overrides project-local cache default; legacy alias `--cache-dir` maps to same destination. -- Metrics baseline update/gating flags require metrics mode; incompatible - combinations with `--skip-metrics` are contract errors. -- Unknown keys or invalid value types in `[tool.codeclone]` are contract errors (exit 2). - -Refs: - -- `codeclone/analysis/units.py:extract_units_and_stats_from_source` -- `codeclone/config/spec.py` -- `codeclone/config/argparse_builder.py:build_parser` -- `codeclone/surfaces/cli/runtime.py:_configure_metrics_mode` - -## Failure modes - -| Condition | Behavior | -|-------------------------------|--------------------| -| Invalid output extension/path | Contract error (2) | -| Invalid root path | Contract error (2) | -| Negative size limits | Contract error (2) | - -Refs: - -- `codeclone/surfaces/cli/reports_output.py:_validate_output_path` -- `codeclone/surfaces/cli/startup.py:resolve_existing_root_path` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Determinism / canonicalization - -- Parser help text and epilog are deterministic constants. -- Summary metric labels are deterministic constants. - -Refs: - -- `codeclone/contracts/__init__.py:cli_help_epilog` -- `codeclone/ui_messages/__init__.py:SUMMARY_LABEL_FILES_FOUND` - -## Locked by tests - -- `tests/test_cli_unit.py::test_cli_help_text_consistency` -- `tests/test_cli_inprocess.py::test_cli_default_cache_dir_uses_root` -- `tests/test_cli_inprocess.py::test_cli_cache_dir_override_respected` -- `tests/test_cli_inprocess.py::test_cli_negative_size_limits_fail_fast` - -## Non-guarantees - -- CLI help section ordering is stable today but not versioned independently from the CLI contract. - -## See also - -- [11-cli.md](11-cli.md) -- [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) -- [13-engineering-memory/bootstrap-and-config.md](13-engineering-memory/bootstrap-and-config.md) — memory pyproject - tables (env overrides live here) diff --git a/docs/book/11-cli.md b/docs/book/11-cli.md deleted file mode 100644 index 28c7fbf5..00000000 --- a/docs/book/11-cli.md +++ /dev/null @@ -1,278 +0,0 @@ - - -# 11. CLI - -## Purpose - -Define observable CLI behavior: argument handling, summaries, output writing, -and exit routing. - -!!! note "Observable surface only" - This chapter covers scripting-visible behavior and user-facing CLI output - categories. Rich styling details may evolve as long as markers, exit - semantics, and deterministic output contracts stay stable. - -## Public surface - -- Public entrypoint: `codeclone/main.py:main` -- CLI orchestration: `codeclone/surfaces/cli/workflow.py:_main_impl` -- Parser: `codeclone/config/argparse_builder.py:build_parser` -- Summary renderer: `codeclone/surfaces/cli/summary.py:_print_summary` -- Output path validation and writes: - `codeclone/surfaces/cli/reports_output.py` -- Message catalog: `codeclone/ui_messages/*` (`help`, `labels`, `runtime`, - `markers`, `formatters`, `controller`, `styling`; stable names re-exported from - `__init__.py`) - -## Data model - -CLI modes: - -- normal mode -- gating mode (`--ci`, `--fail-on-new`, explicit metric gates) -- baseline update mode (`--update-baseline`, `--update-metrics-baseline`) -- controller query mode (`--blast-radius`, `--patch-verify`) -- workspace query modes (`--session-stats`, `--audit`, `--audit-json`) -- development diagnostics mode (`codeclone observability trace`) - -Summary metrics include: - -- files found/analyzed/cache hits/skipped -- structural counters for lines/functions/methods/classes -- function/block/segment clone groups -- suppressed clone groups from `golden_fixture_paths` -- dead-code active/suppressed status -- dependency depth profile (`avg_depth`, `p95_depth`, `max_depth`) when metrics are computed -- adoption/API/coverage-join facts when computed -- new vs baseline - -Refs: - -- `codeclone/surfaces/cli/summary.py:_print_summary` -- `codeclone/surfaces/cli/runtime.py:_metrics_flags_requested` -- `codeclone/surfaces/cli/runtime.py:_metrics_computed` -- `codeclone/surfaces/cli/report_meta.py:_build_report_meta` - -## Contracts - -- Help output includes canonical exit-code section and project links. -- Bare report flags write to deterministic default paths under `.codeclone/`. -- `--open-html-report` is layered on top of `--html`; it does not imply HTML output. -- `--timestamped-report-paths` rewrites only default report paths requested via bare flags. -- In interactive VS Code terminals, the CLI may print a one-time extension hint - after summary output. The hint is suppressed in `--quiet`, CI, and non-TTY - contexts, and is tracked per CodeClone version next to the resolved project - cache path. -- In interactive non-CI runs, the CLI may print one-time migration notes when a - trusted baseline was produced by a release whose dead-code reachability model - is known to be narrower than the current version, such as `2.0.0` -> `2.0.1` - or `2.0.1` -> `2.0.2`. Notes explain expected dead-code count reductions from - refined reachability evidence and are remembered next to the resolved project - cache path. -- The same tips mechanism also covers cohesion (LCOM4) applicability changes, - such as `2.0.2` -> `2.1.0`: when a trusted baseline was generated by - `2.0.2`, the CLI may print a one-time note that cohesion counts can change - because Protocol interfaces and Pydantic validation hooks are excluded from - the LCOM4 graph. -- After a normal interactive analysis run, the CLI may print a workspace - hygiene tip when the repository root `.gitignore` does not cover - `.codeclone/` (or the broader `.cache/` tree). The tip is advisory - only, suppressed in `--quiet`, CI, and non-TTY contexts, and repeats on - each eligible run until `.gitignore` covers the cache path. CodeClone never - edits `.gitignore` automatically. -- Changed-scope review uses: - - `--changed-only` - - `--diff-against` - - `--paths-from-git-diff` -- Controller query mode is terminal-only: - - `--blast-radius FILE [FILE...]` builds the canonical report in memory and - renders the same blast-radius projection used by MCP. - - `--patch-verify` compares the current run against the trusted clone - baseline for baseline-relative regressions, previews gate status, and - exits `3` for blocking violations in `ci` or `strict` mode. Cannot combine - with changed-scope flags; patch-local before-run to after-run regression - claims require MCP change-control verify. -- Session query mode is terminal-only: - - `--session-stats` shows workspace session status: active agents, intents, - and lease health. Read-only, does not run analysis. -- Audit query mode is terminal-only: - - `--audit` shows the local Controller audit trail from the configured audit - database. Read-only, does not run analysis. Requires `audit_enabled=true` - in effective configuration (`[tool.codeclone]` or resolved defaults). - - `--audit-json` outputs audit payload footprint as JSON. Uses the same audit - collector as `--audit` but does not set the `--audit` flag for combination - validation. Requires `audit_enabled=true` in effective configuration. -- Engineering Memory commands (`codeclone memory`) are terminal-only and - read-only with respect to source files, baselines, and analysis cache: - - `init [--refresh] [--dry-run] [--from-report PATH] [--no-docs] [--no-tests]` - — create or refresh the local SQLite memory store from canonical report + - git + docs + tests (omit docs/tests with the `--no-*` flags; seed from an - existing report with `--from-report`). - - `status`, `for-path`, `search`, `stale`, `vacuum`, `coverage` — query - modes mirroring MCP `query_engineering_memory` (`vacuum` purges per - retention config; no `--dry-run`). - - `semantic status|rebuild|search` — optional LanceDB sidecar (requires - `[tool.codeclone.memory.semantic] enabled = true`, extra - `codeclone[semantic-lancedb]`, and a successful rebuild — MCP agents use - `manage_engineering_memory(action=rebuild_semantic_index)`). - For semantic-quality local recall, install `codeclone[semantic-local]` - and configure `embedding_provider = "fastembed"`; `semantic-lancedb` - alone can still run the deterministic diagnostic provider. - - `review-candidates`, `approve`, `reject`, `archive` — human governance - for draft records (CLI and VS Code Memory; not MCP agent tools). Direct CLI - `approve` / `reject` / `archive` require `--i-know-what-im-doing` unless - routed through the IDE governance channel. Use `--by NAME` (default - `human`) to record the approver; there is no `--verified-by` flag. - - `trajectory status|rebuild|list|search|show|agents|anomalies|dashboard|export` - — audit-derived narratives, quality passports, analytics, and local - Patch Trail export (requires audit + rebuild). - - `jobs status|enqueue|run-once|list` — projection rebuild queue (semantic + - trajectory + Experience projections). - - `search` accepts `--match any|all` for FTS token matching (default `any`) - and `--semantic` to blend vector proximity when the index is available. - - Requires a prior normal analysis run or cached report for `init`. - - Full contract: [Engineering Memory](13-engineering-memory/index.md). -- Platform Observability commands are terminal-only, read-only diagnostics of - CodeClone's own runtime: - - `codeclone observability trace --root .` prints JSON. - - `--last`, `--operation`, and `--correlation` select a bounded trace. - - `--json PATH` and `--html PATH` write machine-readable or self-contained - cockpit views. - - A missing local store is an informational success state. - - Full contract: [Platform Observability](26-platform-observability.md). -- Corpus Analytics commands are terminal-only, offline clustering of historical - intents (requires `codeclone[analytics]`): - - `codeclone analytics snapshot|embed|cluster|build|clusters|cluster-show|outliers` - - `codeclone analytics profiles list|show|validate` - - `build` runs snapshot → embed → cluster. `--use-recommended` requires - `--sweep`. With `--profile`, it renders the profile-batch winner; without - a profile it renders the global heuristic winner. - - `--profile PROFILE_ID` implies sweep; `--profile auto` resolves only from - configured `default_profile_id`. No profile is applied implicitly. - - Single-run overrides: `--pca-dimensions`, `--min-cluster-size`, - `--min-samples`, `--cluster-selection-method`. - - Sweep-axis overrides: `--sweep-pca`, `--sweep-min-cluster-size`, - `--sweep-min-samples`, `--sweep-selection-method`. Any sweep-axis flag - implies `--sweep`. - - `cluster --select-run RUN_ID` appends a selection event. - `--selection-profile none|PROFILE_ID|PROFILE_BATCH_ID` controls scope; - `--selected-by` and `--selection-rationale` preserve governance context. - - Representations: `description` (default) or `description_with_frame`. - - Artifacts live under `.codeclone/analytics/` (SQLite metadata + LanceDB vectors). - - JSON export schema `1.3` and HTML use one interpretation projection: - formally valid runs receive full metrics/previews; invalid or failed runs - remain inspectable as limited diagnostics with invariant codes. - - Sweep output includes every persisted candidate for the generation. - Invalid candidates are unranked and show dominant metrics as unavailable. - `cluster-show` may therefore export a resolved run that is not eligible - for full interpretation. - - Expected capability, schema, ownership, and integrity errors exit `2` - without a traceback. Inspection/export commands require only the base - install and open analytics metadata read-only. - - Full contract: - [Corpus Analytics](27-corpus-analytics.md#profile-control-plane-slice-12). -- Controller and workspace query flags are mutually exclusive where enforced: - - `--blast-radius` and `--patch-verify` cannot be combined. - - `--strictness {ci,strict,relaxed}` is valid only with `--patch-verify`. - - `--session-stats` and `--audit` collect payloads from - `codeclone/controller_insights/` (same facts as IDE-only MCP tools when - the server runs with `--ide-governance-channel`). - - `--session-stats` cannot combine with explicit `--audit`, `--blast-radius`, - or `--patch-verify`. `--audit-json` is not treated as `--audit` for this - check (run one pre-analysis query per invocation). - - explicit `--audit` cannot combine with `--blast-radius` or `--patch-verify`. - - controller and workspace query modes cannot combine with changed-scope - flags (`--changed-only`, `--diff-against`, `--paths-from-git-diff`). - - controller and workspace query modes do not write reports, baselines, or - analysis cache data. -- Contract errors use `CONTRACT ERROR:`. -- Gating failures use `GATING FAILURE:`. -- Internal errors use `fmt_internal_error` and include traceback only in debug mode. - -Refs: - -- `codeclone/contracts/__init__.py:cli_help_epilog` -- `codeclone/ui_messages/__init__.py:fmt_contract_error` -- `codeclone/ui_messages/__init__.py:fmt_internal_error` -- `codeclone/surfaces/cli/changed_scope.py:_validate_changed_scope_args` -- `codeclone/surfaces/cli/workflow.py:_validate_controller_query_flags` - -## Invariants (MUST) - -- Report writes are path-validated and write failures are contract errors. -- `--open-html-report` requires `--html`. -- `--timestamped-report-paths` requires at least one requested report output. -- `--changed-only` requires a diff source. -- `--blast-radius` and `--patch-verify` are mutually exclusive. -- `--session-stats` cannot combine with explicit `--audit`, `--blast-radius`, or - `--patch-verify`. -- explicit `--audit` cannot combine with `--blast-radius` or `--patch-verify`. -- Controller and workspace query modes are incompatible with changed-scope flags - (`--changed-only`, `--diff-against`, `--paths-from-git-diff`). -- Controller and workspace query modes are incompatible with report output flags, - baseline update flags, and changed-scope flags. -- `--patch-verify` requires a trusted clone baseline. -- `--audit` and `--audit-json` require `audit_enabled=true` in effective configuration. -- Browser-open failure after successful HTML write is warning-only. -- In gating mode, unreadable source files are contract errors with higher priority than clone/metric gate failures. - -Refs: - -- `codeclone/surfaces/cli/reports_output.py:_validate_output_path` -- `codeclone/surfaces/cli/reports_output.py:_validate_report_ui_flags` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Failure modes - -!!! warning "Failure precedence" - Contract failures take precedence over gating failures. In CI and scripted - flows, invalid config or unreadable sources must surface as exit `2` before - any clone or metrics gate can fail with exit `3`. - - | Condition | User-facing category | Exit | - |-------------------------------------------------------------------|----------------------|------| - | Invalid CLI flag | contract | `2` | - | Invalid output extension/path | contract | `2` | - | Invalid changed-scope flag combination | contract | `2` | - | Invalid controller query flag combination | contract | `2` | - | `--audit` with `audit_enabled=false` | contract | `2` | - | `--patch-verify` without trusted baseline | contract | `2` | - | Baseline untrusted in CI/gating | contract | `2` | - | Coverage/API regression gate without required baseline capability | contract | `2` | - | Unreadable source in CI/gating | contract | `2` | - | New clones with `--fail-on-new` | gating | `3` | - | Blocking `--patch-verify` contract violation | gating | `3` | - | Untested coverage hotspots with `--fail-on-untested-hotspots` | gating | `3` | - | Threshold or metrics gate exceeded | gating | `3` | - | Unexpected exception | internal | `5` | - -## Determinism / canonicalization - -- Summary metric ordering is fixed. -- Compact summary mode is fixed-format text. -- Help epilog is generated from static constants. -- Git diff path inputs are normalized to sorted repo-relative paths. - -Refs: - -- `codeclone/surfaces/cli/summary.py:_print_summary` -- `codeclone/contracts/__init__.py:cli_help_epilog` -- `codeclone/surfaces/cli/changed_scope.py:_normalize_changed_paths` - -## Locked by tests - -- `tests/test_cli_unit.py::test_cli_help_text_consistency` -- `tests/test_cli_help_snapshot.py::test_cli_help_snapshot` -- `tests/test_cli_unit.py::test_argument_parser_contract_error_marker_for_invalid_args` -- `tests/test_cli_inprocess.py::test_cli_summary_format_stable` -- `tests/test_cli_inprocess.py::test_cli_unreadable_source_fails_in_ci_with_contract_error` -- `tests/test_cli_inprocess.py::test_cli_contract_error_priority_over_gating_failure_for_unreadable_source` - -## Non-guarantees - -- Rich styling details are not machine-facing contract. -- Warning phrasing may evolve if category markers and exit semantics stay stable. diff --git a/docs/book/12-structural-change-controller/blast-radius-and-receipt.md b/docs/book/12-structural-change-controller/blast-radius-and-receipt.md deleted file mode 100644 index e1f6b884..00000000 --- a/docs/book/12-structural-change-controller/blast-radius-and-receipt.md +++ /dev/null @@ -1,71 +0,0 @@ -## Blast Radius Payload - -Core blast-radius graph traversal lives in `codeclone/analysis/blast_radius.py` -(consuming canonical report `Mapping` facts). MCP (`get_blast_radius`, -`start`/`finish` summaries) and CLI (`--blast-radius`) are presentation -adapters over that core — non-MCP surfaces must not import -`codeclone/surfaces/mcp/_blast_radius.py`. - -`get_blast_radius` separates hard edit guardrails from review context: - -- `do_not_touch`: actionable negative context such as baseline/cache state, - generated CodeClone state, or explicit forbidden paths. -- `review_context`: report-only facts such as security boundary inventory, - overloaded-module candidates, known baseline debt, and golden fixture - surfaces. - -Long context sections are bounded and include summaries with `total`, `shown`, -and `truncated`. - -## Start Blast Artifact - -`start_controlled_change` uses a slim blast-radius summary by default when it can -store an immutable audit-backed artifact for the full start-time projection. The -summary keeps edit-control and safety facts inline: - -- `status`, `intent_id`, `edit_allowed`, scope, workspace blocking facts -- `radius_level`, `origin`, and bounded count summaries -- `do_not_touch` and `do_not_touch_summary` -- `guardrails` -- `blast_artifact` identity: `blast_artifact_id`, `run_id`, - `projection_digest`, detail contract version, and retrieval route - -The full omitted blast evidence is available through -`get_blast_artifact(root, run_id, blast_artifact_id)` and is read from the audit -trail exactly as stored when `start_controlled_change` ran. It is not recomputed -from the current workspace. `get_blast_radius(files=...)` remains useful for -current analysis-context inspection, but it is labelled as recomputation and is -not the exact drill-down path for evidence omitted from a previous start -summary. - -Agents that need the old inline projection can request -`blast_radius_detail="full"`. If the artifact cannot be stored, start returns -full blast evidence inline rather than omitting evidence without a drill-down -route. - -## Review Receipt Payload - -`create_review_receipt` returns `format="markdown"` by default and can return a -structured JSON receipt with `format="json"`. The receipt is a composition of -stored MCP state; it does not run analysis and does not mutate source files, -baselines, cache, reports, or repository state. - -The receipt includes: - -- report provenance: digest, schema version, baseline trust state, run id, root -- verification profile: profile classification, reason, applicable/not-applicable - checks, limitations -- scope: optional change intent, declared files, changed files, unexpected files -- blast radius summary: level, direct dependent count, clone cohort count, - do-not-touch count -- reviewed evidence: session-local reviewed finding markers and notes -- patch contract: accepted, violated, or not checked from stored gate, - structural delta, intent, and baseline-abuse signals -- human decision points: bounded list of clone divergence, scope expansion, and - known-baseline-debt prompts -- claims not made: explicit reminders that Security Surfaces are boundary - inventory, report-only signals are not gates, and known baseline debt is not - new relative to the baseline - -Receipt verdicts are `clean`, `incomplete`, or `needs_attention`. They summarize -receipt completeness only; they are not CI gates. diff --git a/docs/book/12-structural-change-controller/cli-controller-queries.md b/docs/book/12-structural-change-controller/cli-controller-queries.md deleted file mode 100644 index 57df76bc..00000000 --- a/docs/book/12-structural-change-controller/cli-controller-queries.md +++ /dev/null @@ -1,65 +0,0 @@ -## CLI Controller Queries - -The CLI exposes read-only terminal projections for humans: - -```bash -codeclone . --blast-radius codeclone/analysis/parser.py -codeclone . --patch-verify -codeclone . --patch-verify --strictness relaxed -codeclone . --session-stats -codeclone . --audit -codeclone . --audit-json -``` - -For git-scoped clone review (not patch-verify), use changed-scope flags instead: - -```bash -codeclone . --changed-only --diff-against HEAD~1 -``` - -`--blast-radius` runs normal analysis, builds the canonical report in memory, -and renders the same dependent/context split as `get_blast_radius`. - -`--patch-verify` is a baseline-relative terminal check: it uses the trusted -clone baseline as the accepted comparison snapshot and checks baseline-relative -new clone regressions plus the selected gate profile. It is not the same as MCP -patch-local verification, which compares a clean before-run to an after-run. -`ci` is the default; `strict` applies tighter controller budgets; `relaxed` -reports violations but exits `0`. - -Controller query modes cannot combine with changed-scope flags -(`--changed-only`, `--diff-against`, `--paths-from-git-diff`). Combining -`--patch-verify` with `--diff-against` is a contract error — pick one workflow. - -`--session-stats` shows workspace session status: active agents, intents, and -lease health. Read-only, does not run analysis. Collection is implemented in -`codeclone/controller_insights/session_stats.py` (CLI and IDE-only MCP tools -consume the same payload). - -`--audit` and `--audit-json` show the local Controller audit trail (JSON footprint -mode for `--audit-json`). Both require `audit_enabled=true` in effective config. -`--audit-json` selects JSON output but does not set the `--audit` flag for -combination validation. - -### Flag combination rules - -Enforced by `codeclone/surfaces/cli/workflow.py:_validate_controller_query_flags`: - -| Combination | Result | -|---------------------------------------------------------------------------|----------------| -| `--blast-radius` + `--patch-verify` | contract error | -| `--session-stats` + explicit `--audit` | contract error | -| `--session-stats` + `--blast-radius` or `--patch-verify` | contract error | -| explicit `--audit` + `--blast-radius` or `--patch-verify` | contract error | -| any controller query + changed-scope flags | contract error | -| any controller query + report output flags | contract error | -| any controller query + baseline update flags | contract error | -| `--strictness` without `--patch-verify` (when `--strictness` is explicit) | contract error | - -`--audit-json` is not treated as `--audit` for the session-stats mutual-exclusion -check. Pre-analysis queries (`--session-stats`, `--audit`, `--audit-json`) exit -before analysis; only one runs per invocation (first match wins). - -CLI controller queries are terminal-only and read-only with respect to source -files, baselines, reports, and analysis cache data. They are incompatible with -report output flags and baseline update flags. diff --git a/docs/book/12-structural-change-controller/finish-controlled-change.md b/docs/book/12-structural-change-controller/finish-controlled-change.md deleted file mode 100644 index 79347e77..00000000 --- a/docs/book/12-structural-change-controller/finish-controlled-change.md +++ /dev/null @@ -1,120 +0,0 @@ -## `finish_controlled_change` - -Post-edit workflow tool. It runs a **fixed pipeline** over the same atomic -primitives as the manual path; agents must not skip hygiene, check, or verify -and call `clear` alone. - -### Preconditions - -- Intent is **active** in the current MCP session (not `queued`). -- **Evidence:** exactly one of `changed_files` or `diff_ref` (non-empty). Both - or neither is a contract error. -- **`after_run_id`** when the derived `verification_profile` requires it - (Python structural and governance config patches). - -`review_text` is a human note only. **`claims_text`** is the only finish input -passed to Claim Guard (when `claim_validation_recommended` is true). - -### Execution order (do not reorder manually) - -```text -resolve intent - → resolve changed_files | diff_ref (git-expanded) - → finish_hygiene_check (git + start dirty snapshot) - → manage_change_intent(check) # uses files_for_scope_check = evidence only - → check_patch_contract(verify) # before_run_id from intent when omitted - → compute Patch Trail + audit emit patch_trail.computed (when check/verify reached) - → validate_review_claims (optional, if claims_text + recommended) - → create_review_receipt (default true) - → manage_change_intent(clear) # auto_clear when accepted and receipt ok - → elevate status if out-of-scope dirty remains (external_changes) -``` - -Early exits (intent stays active unless noted): - -| Step | Top-level `status` | `reason` (typical) | `intent_cleared` | -|---------------------|------------------------------------------------|-------------------------|-------------------------------------------| -| Queued intent | `unverified` | `intent_not_active` | `false` | -| Hygiene gate | `unverified` | `workspace_hygiene` | `false` | -| Scope check | `expired` / `violated` | digest / scope | `false` | -| Verify not accepted | `unverified` / `violated` | verify-specific | `false` | -| Receipt failure | `accepted` or `accepted_with_external_changes` | — | `false` (verify passed but clear skipped) | -| Success | `accepted` or `accepted_with_external_changes` | verify reason or `null` | `true` when `auto_clear` and receipt ok | - -### Top-level `status` semantics - -| `status` | Meaning for agents | -|----------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------| -| `accepted` | Patch contract passed for declared scope; no out-of-scope dirty paths in the hygiene view | -| `accepted_with_external_changes` | Patch contract passed; **other** git-dirty paths exist outside declared scope — report `external_changes` to the user; intent may still clear | -| `unverified` | Hygiene block, verify failure, missing after-run, `after_run_not_new`, etc. — follow `next_step` | -| `violated` | Scope expansion or structural/gate violations attributable to the patch | -| `expired` | Before-run digest no longer matches intent — re-analyze and `start` again | - -`accepted` / `accepted_with_external_changes` mean the **patch contract** passed -for the declared scope. They do **not** mean “no structural regressions” or -unchanged repository health — read `verification.structural_delta` and -`health_regression_advisory` when present. - -### Hygiene payload `detail_level` - -On `start_controlled_change` / `finish_controlled_change`, hygiene uses -`detail_level` as binary size control: `summary` and `normal` are equivalent -(`counts`, `foreign_dirty_overlaps`, blocking flags). `detail_level="full"` adds -`dirty_attribution`, path classification arrays, and expanded `dirty_snapshot`. -Findings/hotspots tools still honor all three levels. - -### Response payloads agents should read - -| Field | Use | -|---------------------------------|-------------------------------------------------------------------------------------------------------| -| `summary` | Compact dashboard (`scope_status`, `verification_profile`, `receipt`, `intent_cleared`, dirty counts) | -| `scope_check` | Declared vs actual files from check | -| `verification` | Full verify payload including `structural_delta`, `next_step` | -| `workspace_hygiene_after` | Post-finish hygiene; `counts` always; `dirty_attribution` only when `detail_level="full"` | -| `health_regression_advisory` | On accepted verify when `health_delta < 0` — user-facing, not auto-fail | -| `claims` | Claim Guard result when `claims_text` was validated | -| `receipt` / `receipt_error` | Receipt body; `receipt_error` prevents `auto_clear` | -| `propose_memory` / memory hooks | When `propose_memory=true` on accept | -| `patch_trail` | Deterministic scope/verify forensics for this finish (see below); not authorization | -| `projection_rebuild` | Optional job enqueue on accept when projection policy is not `off` (non-CI) | - -Markdown receipt payloads expose top-level `receipt_version`, `verdict`, -`receipt_digest`, `content`, and `receipt_retrieval` for compact identity and -human review. The duplicate nested typed receipt is not returned by default; -fetch the complete structured receipt after `auto_clear=true` with -`get_review_receipt(root, run_id, receipt_digest, format="structured")`. - -`context_governance` measures the complete finish response as one payload and -publishes a `finish_projection_v1` digest under -`context_governance.response`. Finish responses use `mode="partial_enforce"` and -`evidence_policy="response_budget_with_durable_artifact_lookup"`: mandatory -control, scope, verification, hygiene, and action fields stay inline, while -recoverable advisory lanes may be compacted. When receipt markdown content or -Patch Trail detail is omitted, `context_governance.omitted` carries exact -drill-down metadata for `get_review_receipt` or `get_patch_trail`. - -### Patch Trail on finish - -Patch Trail is computed when scope `check` reaches `violated` (**before** -verify) or when check is `clean` / `expanded` and verify runs — including -failed verify (`unverified` / `violated` top-level status). Hygiene blocks and -`expired` intents do **not** emit Patch Trail. - -Normative diagram and fields: [Patch Trail](patch-trail.md). - -### Post-success hooks (accept only) - -When verify status is `accepted` or `accepted_with_external_changes`: - -- `propose_memory=true` runs finish-side memory proposals and staleness updates. -- `maybe_auto_enqueue_projection_rebuild` may return `projection_rebuild` when - `memory.projection_rebuild_policy` is not `off` and the process is not CI. - -Receipt creation and `auto_clear` still follow the table above; a receipt error -leaves the intent active even when verify passed. - -Refs: - -- `codeclone/surfaces/mcp/_session_workflow_mixin.py:finish_controlled_change` -- `codeclone/memory/jobs/workflow.py:maybe_auto_enqueue_projection_rebuild` diff --git a/docs/book/12-structural-change-controller/finish-hygiene.md b/docs/book/12-structural-change-controller/finish-hygiene.md deleted file mode 100644 index 42c1e576..00000000 --- a/docs/book/12-structural-change-controller/finish-hygiene.md +++ /dev/null @@ -1,99 +0,0 @@ -## Workspace hygiene and registry consistency - -Three independent contours (do not collapse): - -```text -status = persisted registry lifecycle -ownership = runtime view (PID / TTL / lease) -hygiene = git working tree ∩ declared scope -permission = edit_allowed (with status gate) -``` - -**Lazy intent closure:** agent-facing registry reads (`list_workspace`, -declare/start workspace refresh) close eligible non-terminal intents using a -**lazy-close predicate** (`for_lazy_close=True`). Lease-only staleness with valid -TTL is not closed on read. **Orphaned** (dead PID) intents stay recoverable until -TTL expiry or explicit `gc_workspace` — lazy close does not purge them. - -**Explicit GC:** `gc_workspace` performs cleanup/purge in one atomic transaction -using a broader removal predicate. Lazy close and GC share intent lifecycle -concepts, but **not** an identical close predicate. - -Registry I/O is serialized with cross-process locks; SQLite `gc()` is one -atomic scan→close→purge transaction. - -**Continuing known WIP:** when uncommitted changes already overlap your declared -scope, default `dirty_scope_policy="block"` returns workflow `status: "blocked"`. -Pass `dirty_scope_policy="continue_own_wip"` only to resume known dirty scope -when **no** live foreign dirty overlap exists (`foreign_dirty_overlaps` empty). -Finish must still prove all declared-scope dirty paths via `changed_files` or -`diff_ref`. - -**Start blocking:** when foreign active/stale scope overlap is unresolved -(without `on_conflict="queue"`) or scoped hygiene detects dirty paths in -`allowed_files`, `start_controlled_change` returns workflow `status: "blocked"`, -`edit_allowed: false`, and populated `workspace` / `workspace_hygiene` payloads. -`blocked` is workflow-only — never persisted registry lifecycle status. - -**Finish hygiene gate:** see [finish_controlled_change](finish-controlled-change.md) -for the full pipeline. By default only `missing_evidence` and -`foreign_dirty_overlap` set `blocks_finish`. With -[strict finish mode](../10-config-and-defaults.md#mcp-session-and-change-control-hygiene) -enabled, `own_unscoped_dirty` may also block. Out-of-scope unattributed dirt is -advisory and may elevate the top-level status to `accepted_with_external_changes` -without failing verify. -**Queued** foreign intents do not populate `foreign_dirty_overlaps`. - -Declare **new files** in `allowed_files` at `start`, not only in -`allowed_related`. Finish always attaches `workspace_hygiene_after` (scoped -hygiene + repo-level `workspace_dirty_summary`) on verify paths that reach -hygiene evaluation. - -**List workspace:** `manage_change_intent(action="list_workspace")` attaches -repo-level `workspace_dirty_summary` only (bounded dirty path sample). Scoped -`workspace_hygiene.blocks_edit` applies only to start/finish. When recoverable -intents exist, the response includes `recovery_available` (each entry may show -`run_available: false` after MCP restart) and top-level `recovery_next_step`. - -### Finish hygiene: what blocks vs what informs - -Finish hygiene reconciles **agent evidence with git** and the **start-time dirty -snapshot**. It is not honor-system. - -**Blocking** (`blocks_finish: true`, top-level `reason: workspace_hygiene`, -`user_action_required: true`) happens only for: - -| `finish_block_reason` | Meaning | Agent action | -|-------------------------|----------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------| -| `missing_evidence` | Git is dirty inside declared scope but the path is missing from `changed_files` / `diff_ref` | Add every in-scope dirty path to evidence or revert | -| `foreign_dirty_overlap` | A **live** foreign active/stale intent previously declared the same **in-scope** path | Coordinate (queue/promote/clear foreign intent), stash/commit foreign WIP, or narrow scope | -| `own_unscoped_dirty` | Unattributed out-of-scope dirty when strict finish mode is enabled (see env overrides) | Reconcile out-of-scope dirt, widen scope, or unset strict mode | - -**Non-blocking (advisory)** — surfaced on `workspace_hygiene_after` (path lists in -`dirty_attribution` when `detail_level="full"`), but **do not** set -`finish_block_reason` and **do not** feed `files_for_scope_check`: - -| Field | Meaning | -|----------------------------------------|-----------------------------------------------------------------------------------------| -| `preexisting_unscoped_dirty` | Out-of-scope dirty at `start`, unchanged since — informational | -| `new_unattributed_unscoped_dirty` | Out-of-scope dirty appeared after `start`, not foreign-attributed — peer/context signal | -| `modified_unattributed_unscoped_dirty` | Out-of-scope dirty existed at `start` but content changed — peer/context signal | -| `unknown_unattributed_unscoped_dirty` | No usable start snapshot for comparison — conservative classification only | -| `foreign_attributed_outside_scope` | Out-of-scope dirty owned by foreign active/stale intent — ignored for your finish | -| `dirty_paths_outside_scope` | All out-of-scope dirty paths — drives `external_changes` when verify is `accepted` | - -`own_unscoped_dirty` and `unattributed_unscoped_dirty` are **legacy aliases** for -the union of unattributed out-of-scope paths. They are **not** proof that the -current agent owns those edits and **do not** block finish. - -**Recoverable** foreign intents (dead PID) do **not** populate -`foreign_attributed_outside_scope`. **Queued** foreign intents do **not** -populate `foreign_dirty_overlaps`. - -When verify returns plain `accepted` but `dirty_paths_outside_scope` is -non-empty, finish elevates the top-level status to -`accepted_with_external_changes` and attaches: - -```json -"external_changes": {"count": N, "sample": ["path", "..."], "truncated": false} -``` diff --git a/docs/book/12-structural-change-controller/index.md b/docs/book/12-structural-change-controller/index.md deleted file mode 100644 index 1e23c5b7..00000000 --- a/docs/book/12-structural-change-controller/index.md +++ /dev/null @@ -1,63 +0,0 @@ -# Structural Change Controller - -Normative contract for MCP/CLI change intent, blast radius, patch verification, -hygiene, receipts, and Patch Trail. Agent recipes live in the -[Change control guide](../../guide/change-control/overview.md). - -## Status - -The v2.1 alpha currently includes intent, blast-radius, patch-contract checks, -review receipts, workspace intent visibility, claim guard, and CLI controller -queries: - -| Capability | Status | Surface | -|---------------------------|-------------------|------------------------------------------------------------------------------------| -| Declarative workflow | Live in `2.1.0a1` | MCP `start_controlled_change`, `finish_controlled_change` | -| Intent declaration | Live in `2.1.0a1` | MCP `manage_change_intent` | -| Blast radius | Live in `2.1.0a1` | MCP `get_blast_radius`, CLI `--blast-radius` | -| Patch contract | Live in `2.1.0a1` | MCP `check_patch_contract`, CLI `--patch-verify` | -| Review receipt | Live in `2.1.0a1` | MCP `create_review_receipt` | -| Workspace intent registry | Live in `2.1.0a1` | MCP `manage_change_intent` | -| Lease and recovery | Live in `2.1.0a1` | MCP `manage_change_intent` | -| Claim guard | Live in `2.1.0a1` | MCP `validate_review_claims` | -| Scope-aware verification | Live in `2.1.0a1` | MCP `check_patch_contract` | -| Workspace relations | Live in `2.1.0a1` | MCP `manage_change_intent` | -| Verification profiles | Live in `2.1.0a1` | MCP `check_patch_contract` | -| Intent queue | Live in `2.1.0a1` | MCP `manage_change_intent` | -| Verify ergonomics | Live in `2.1.0a1` | MCP `check_patch_contract` | -| MCP payload token budget | Live in `2.1.0a1` | Audit trail, CLI `--audit`, `--session-stats` | -| Patch Trail | Live in `2.1.0a1` | MCP `finish_controlled_change(patch_trail_detail=…)`; audit `patch_trail.computed` | - -## Contract - -- The canonical report remains the source of truth. -- Intent truth is **session-local** for the active MCP process; the optional - workspace registry (file backend under `.codeclone/intents/` or SQLite - per `[tool.codeclone]`) provides advisory, TTL/lease-bound cross-process - visibility only. -- MCP may write ephemeral workspace coordination records through the configured - intent registry backend and optional audit records under - `.codeclone/db/` when enabled. -- MCP must not mutate source files, baselines, reports, or analysis cache data. -- Tools derive responses from existing run/report facts rather than LLM - inference. -- Report-only context is review context, not an edit prohibition. - -!!! note "Claim Guard" - Full pattern catalog: [Claim Guard](../14-claim-guard.md). - -## Chapters - -| Topic | Contract | -|----------------------------------------|---------------------------------------------------------| -| CLI `--blast-radius`, `--patch-verify` | [CLI controller queries](cli-controller-queries.md) | -| Blast radius & review receipt | [Blast radius & receipt](blast-radius-and-receipt.md) | -| Intent registry & queue | [Intent registry & queue](intent-registry-and-queue.md) | -| Verification profiles | [Verification profiles](verification-profiles.md) | -| Patch contract verify | [Patch contract verify](patch-contract-verify.md) | -| Workflow tools | [Workflow tools](workflow-tools.md) | -| `finish_controlled_change` | [finish_controlled_change](finish-controlled-change.md) | -| Finish hygiene | [Finish hygiene](finish-hygiene.md) | -| Patch Trail | [Patch Trail](patch-trail.md) | -| Payload semantics | [Payload semantics](payload-semantics.md) | -| Token budget | [Token budget](token-budget.md) | diff --git a/docs/book/12-structural-change-controller/intent-registry-and-queue.md b/docs/book/12-structural-change-controller/intent-registry-and-queue.md deleted file mode 100644 index 85ffb395..00000000 --- a/docs/book/12-structural-change-controller/intent-registry-and-queue.md +++ /dev/null @@ -1,170 +0,0 @@ -## Workspace Intent Registry - -`manage_change_intent` also supports workspace actions for multi-agent -coordination: - -- `list_workspace`: list active workspace intent records from all agents for a - repository root. Includes `recovery_available` (with `run_available` and - per-candidate `hint`) and `recovery_next_step` when recoverable intents exist. -- `renew`: refresh the active lease before long edits or test runs. -- `gc_workspace`: remove expired, orphaned, or corrupted registry records. -- `recover`: explicitly reclaim a recoverable intent when the caller has the - matching run and report digest in the current MCP session. -- `reset_workspace`: reset an own intent or remove expired/recoverable - registry records. Foreign active and foreign stale intents are rejected - and require coordination. - -Registry records live under `.codeclone/intents/` by default (one JSON -file per intent) and are protected with a SHA-256 integrity digest over -canonical JSON. Repositories may opt into a SQLite backend instead: - -```toml -[tool.codeclone] -intent_registry_backend = "sqlite" -intent_registry_path = ".codeclone/db/intents.sqlite3" -``` - -Environment overrides for registry keys: -[10-config Environment variable overrides](../10-config-and-defaults.md#environment-variable-overrides) -(workspace intent registry table). - -The SQLite backend stores the same signed JSON payloads in WAL mode; integrity -and validation rules are unchanged. Unlike the file backend, SQLite keeps -closed intents (`clean`, `expired`, `orphaned`) for audit and purges them only -after `intent_registry_retention_days` (default `14`, any positive value; no -edition cap). Managed/hosted retention with backup and compliance is a roadmap -Team/Enterprise option; see [Plans and Retention](../../plans-and-retention.md). - -This detects accidental corruption, not malicious tampering by a user with write -access. Conflicts are advisory: hard overlap means two agents claimed the same -primary file; soft overlap means primary files overlap related context. - -Each registry record has a TTL and a shorter renewable lease. TTL is the hard -maximum lifetime of the record (default 3600s). The lease is the ownership -freshness signal (default 300s, max 600s): active MCP interactions auto-renew -it, while detached processes stop renewing and transition through ownership -states. - -??? info "Ownership classification" - - | State | PID alive | Lease valid | Meaning | - |------------------|-----------|-------------|------------------------------------------------------| - | `own_active` | own | yes | This session's active intent | - | `own_stale` | own | no | This session's intent with expired lease | - | `foreign_active` | foreign | yes | Another live process, active lease — coordinate | - | `foreign_stale` | foreign | no | Another live process, expired lease — coordinate | - | `recoverable` | dead | — | Owning process is dead; safe to reclaim | - | `expired` | — | — | TTL exceeded; eligible for garbage collection | - - A foreign active or foreign stale record should be coordinated with the - user; CodeClone does not ask agents to kill the owning process. Only - `recoverable` intents (dead PID) can be reclaimed without user - coordination. - -### Cursor local enforcement (optional) - -The Cursor plugin can install project hooks (`.cursor/hooks.json`) that run a -fail-closed `preToolUse` gate before `Write`, `StrReplace`, `ApplyPatch`, and -`Shell`. The gate calls the read-only API -`codeclone.workspace_intent.evaluate_workspace_edit_gate`, which loads the same -registry backend as MCP (`file` or `sqlite` per `[tool.codeclone]`). It does not -lazy-close records, create registry files, or read plugin-local marker files. - -| Registry signal | Hook behavior | -|-----------------------------------------------------------------------|---------------------------------------------------------------------| -| Live `active` intent (any agent; lease/TTL rules match MCP ownership) | Authorize repository writes and non–read-only shell | -| `queued` only | Deny — queued intents are visible but not editable locally | -| No active intent / registry error | Deny file tools; allow only read-only Git inspection shell commands | - -Hooks require `codeclone` in the Python interpreter referenced by -`.cursor/hooks.json` (typically the project venv). Install: -`plugins/cursor-codeclone/scripts/install-project-hooks.py`. See -[Cursor plugin guide](../../guide/integrations/cursor/install-and-skills.md) and -[Cursor plugin contract](../integrations/cursor-plugin.md). - -## Workspace Relations - -`detect_conflicts` classifies the relationship between a new intent and existing -workspace intents. Beyond edit-overlap detection (hard and soft conflicts), -the classifier distinguishes forbidden-scope relationships: - -| Relation | Meaning | -|---------------------------|-----------------------------------------------------| -| `edit_overlap` | Both agents claim the same files (hard or soft) | -| `foreign_excludes_target` | Foreign `forbidden` matches current `allowed_files` | -| `target_excludes_foreign` | Current `forbidden` matches foreign `allowed_files` | - -Absence of a relation entry means disjoint scope. - -The `declare` response includes a `workspace_relations` field alongside the -existing `concurrent_intents`. `concurrent_intents` continues to contain only -edit overlaps for backward compatibility; `workspace_relations` provides the -full classification including forbidden-scope signals. - -This allows agents to distinguish three cases that were previously -indistinguishable: - -1. No overlap at all (disjoint). -2. No edit overlap, but the foreign agent explicitly excludes the current - agent's target files (`foreign_excludes_target`) — a positive coordination - signal. -3. No edit overlap, but the current agent explicitly excludes the foreign - agent's target files (`target_excludes_foreign`). - -## Intent Queue - -When multiple agents target overlapping scope, `manage_change_intent` supports -an advisory queue so a blocked agent can register its intent without failing. - -### Declare with queue - -`manage_change_intent(action="declare", on_conflict="queue")` first attempts a -normal declare. If `detect_conflicts` finds overlapping foreign active intents, -it downgrades the already-registered intent to `queued` instead of returning an -error. - -A queued intent: - -- Is visible in `list_workspace` as a workspace record with `status="queued"`. -- Does **not** own scope — conflict detection skips queued records. -- Does **not** pin the before-run — long waits may cause eviction from bounded - run history. -- Cannot pass `check_patch_contract(mode="verify")` or - `check_patch_contract(mode="budget")` with `edit_allowed=true`. -- Can be cleared via `manage_change_intent(action="clear")`. - -The declare response includes `blocked_by` (list of blocking intents with -`intent_id`, `agent_pid`, `ownership`, `overlapping_files`) and -`queue_position` (deterministic ordering by `declared_at_utc`, then -`intent_id`). - -### Promote - -`manage_change_intent(action="promote", intent_id=...)` transitions a queued -intent to active: - -1. Validates the intent has `status="queued"`. -2. Resolves the before-run — if evicted, returns `status="unverified"` with - `reason="before_run_evicted"` and a `next_step` hint. -3. Re-checks workspace conflicts. If conflicts persist, returns `status="queued"` - with `blocking_count` and `blocked_by` without changing state. -4. On success: sets status to `active`, pins the run, renews the lease, and - updates the workspace record. - -### Queue semantic invariants - -- `queued` is a lifecycle status, not an ownership classification. Ownership - (`own_active`, `foreign_active`, etc.) and status (`active`, `queued`) are - orthogonal. -- Queued intents do not block other agents. `_detect_scope_state` skips records - with `status == "queued"`. -- Queue position is deterministic: sorted by `declared_at_utc`, then - `intent_id` as tiebreaker. - -### Audit events - -| Event | When | -|------------------------|------------------------------| -| `intent.queued` | Declare downgrades to queued | -| `intent.promoted` | Promote succeeds | -| `intent.queue_blocked` | Promote blocked by conflicts | diff --git a/docs/book/12-structural-change-controller/patch-contract-verify.md b/docs/book/12-structural-change-controller/patch-contract-verify.md deleted file mode 100644 index 7ef70317..00000000 --- a/docs/book/12-structural-change-controller/patch-contract-verify.md +++ /dev/null @@ -1,80 +0,0 @@ -## Scope-Aware Patch Contract Verification - -When a change intent is active, `check_patch_contract(mode="verify")` attributes -regressions and gate changes to the declared scope rather than treating the -entire workspace as one undifferentiated surface. - -### Regression attribution - -Regressions from `compare_runs` are partitioned into two sets: - -- `intent_regressions` — findings whose file paths fall inside the declared - `allowed_files` or `allowed_related`. -- `external_regressions` — findings whose file paths are entirely outside - the declared scope. - -Only `intent_regressions` produce `structural_regressions` contract violations. -External regressions are reported as informational context without failing the -contract. - -Findings with no extractable file paths are conservatively classified as -intent-scope to avoid false-negative accepts. - -Without an active intent, all regressions are treated as intent-scope and -behavior is unchanged from the base contract. - -### Scope matching vs verify attribution - -Scope **check** (`unexpected_files`) uses exact membership in `allowed_files` / -`allowed_related`. Verify regression attribution uses `fnmatchcase` on those -patterns (and treats path-less findings as in-scope). Do not assume identical -matching rules across check and verify — declare literal paths in scope lists. - -### Gate-delta logic - -Gate evaluation uses a two-layer attribution model: - -1. **Gate delta** — only gate *changes* between before-run and after-run are - contract-relevant. A gate that was already failing before the edit is - pre-existing, not a new violation. `gate_worsened` is true only when - `before_gate.would_fail` is false and `after_gate.would_fail` is true. - -2. **Gate attribution** — when `gate_worsened` is true and an intent is active, - the contract checks whether the gate-triggering signals come from intent - scope: intent-scope regressions or intent-scope worsened metric symbols. If - neither exists, the gate failure is external and does not produce a contract - violation. - -### Status values - -| Status | Meaning | -|----------------------------------|--------------------------------------------------------------------------| -| `accepted` | No intent-scope regressions, no gate worsening | -| `accepted_with_external_changes` | Intent scope is clean but external signals exist | -| `violated` | Intent-scope regressions, intent-caused gate failure, or scope violation | -| `unverified` | Missing before or after run | -| `expired` | Report digest mismatch since declaration | - -The `accepted_with_external_changes` status signals that another agent or -concurrent edit introduced regressions outside the current intent scope. The -verify response includes `intent_regressions`, `external_regressions`, -`intent_worsened`, `external_worsened`, `gate_worsened`, and `before_gate` -fields for full attribution visibility. - -??? info "Decision table" - - | Intent | Intent regressions | External regressions | Gate worsened | Intent caused gate | Scope check | Status | - |--------|--------------------|-----------------------|---------------|--------------------|-------------|----------------------------------| - | no | any | — | any | any | — | current logic unchanged | - | yes | > 0 | any | any | any | any | `violated` | - | yes | 0 | any | yes | yes | clean | `violated` | - | yes | 0 | any | yes | no | clean | `accepted_with_external_changes` | - | yes | 0 | > 0 | no | — | clean | `accepted_with_external_changes` | - | yes | 0 | 0 | no | — | clean | `accepted` | - | yes | 0 | any | any | any | violated | `violated` (scope violation) | - -### Baseline abuse - -`detect_baseline_abuse` stays workspace-global. Baseline hygiene is a -repository-level signal: if the baseline was updated while any regressions exist -(even external), that is suspicious regardless of whose regressions they are. diff --git a/docs/book/12-structural-change-controller/patch-trail.md b/docs/book/12-structural-change-controller/patch-trail.md deleted file mode 100644 index 86716da5..00000000 --- a/docs/book/12-structural-change-controller/patch-trail.md +++ /dev/null @@ -1,51 +0,0 @@ -### Patch Trail {#patch-trail} - -Patch Trail is a **bounded, deterministic snapshot** of declared scope, evidence -files, hygiene counts, and verify outcome for one finish cycle. It complements -patch verify — it does **not** authorize edits, expand scope, or override -structural findings. - -```mermaid -flowchart TD - FIN[finish_controlled_change] --> HY[finish_hygiene_check] - HY -->|blocks| STOP1[Early exit — no patch_trail] - HY --> CHK[manage_change_intent check] - CHK -->|expired| STOP2[Early exit — no patch_trail] - CHK -->|violated| PT[compute_patch_trail] - CHK -->|clean or expanded| VER[check_patch_contract verify] - VER --> PT - PT --> AUD[audit patch_trail.computed] - PT --> RES[patch_trail response field] - VER --> RCPT[receipt / clear / projection hook] - AUD --> TRJ[Trajectory rebuild persists memory_trajectory_patch_trails] -``` - -**When emitted:** after scope `check` returns `violated` (**before** verify), or -after `verify` when check is `clean` / `expanded`. Failed verify still returns -`patch_trail` when check/verify stages were reached. Hygiene blocks and expired -intents do **not** emit Patch Trail. - -**Parameters:** - -| Parameter | Default | Meaning | -|----------------------|-----------|------------------------------------------------------------------------| -| `patch_trail_detail` | `summary` | `summary`: counts, statuses, digest, evidence refs; `full`: path lists | - -**Response `patch_trail` (summary):** `schema_version` (`PATCH_TRAIL_SCHEMA_VERSION`, -currently **`1`**), `intent_id`, compact `intent_description`, `scope_check_status`, -`verification_status`, `counts`, `patch_trail_digest`, `evidence` (audit sequence -refs), `retrieval_policy` (`patch_trail_does_not_authorize_edits`, -`patch_trail_does_not_override_findings`). - -**Audit:** `patch_trail.computed` stores a compact event core (`patch_trail_digest`, -counts, verification status) for trajectory projection. Requires `audit_enabled=true`. - -**Persistence:** manual or job-driven trajectory rebuild projects Patch Trail into -`memory_trajectory_patch_trails` and bumps trajectory projection to -`trajectory-v2` or later (digest includes `patch_trail_digest`). The active -`trajectory-v3` projection also carries deterministic quality scoring and agent -subjects. Scoped retrieval surfaces `patch_trail_summary` / full `patch_trail` — see -[Engineering Memory — Trajectory memory](../13-engineering-memory/trajectory-and-patch-trail.md). - -Refs: `codeclone/memory/trajectory/patch_trail.py`, `codeclone/audit/events.py`, -`codeclone/surfaces/mcp/_session_workflow_mixin.py:_finish_patch_trail`. diff --git a/docs/book/12-structural-change-controller/payload-semantics.md b/docs/book/12-structural-change-controller/payload-semantics.md deleted file mode 100644 index e544c3ad..00000000 --- a/docs/book/12-structural-change-controller/payload-semantics.md +++ /dev/null @@ -1,139 +0,0 @@ -## Change-control payload semantics - -This section supplements the workflow descriptions above. It does not repeat tool -lists or atomic step sequences. - -### Scope path matching - -Declare **repo-relative file paths** in `allowed_files` and `allowed_related`. -Glob patterns such as `docs/**` are **not** valid scope entries for scope -`check` — each changed path must appear literally in the declared lists. - -| Mechanism | Matching rule | -|---------------------------------------|----------------------------------------------------------------------------| -| Scope `check` (`unexpected_files`) | Exact membership in `allowed_files` or `allowed_related` | -| Start/finish hygiene (in-scope dirty) | Exact path **or** directory prefix (`docs/book` covers `docs/book/foo.md`) | -| Verify regression attribution | `fnmatchcase` on declared patterns (may differ from scope check) | -| `forbidden` | `fnmatchcase` on declared patterns | - -List every path you create, modify, or delete in finish evidence -(`changed_files` or `diff_ref`). - -### `structural_delta.health_delta` vs receipt `health.delta` - -Verify compares the intent's **before-run** to the explicit **after-run** via -`compare_runs`. `structural_delta` mirrors that comparison: - -```json -"before": {"run_id": "14d82d39", "health": 90}, -"after": {"run_id": "74cb3c0e", "health": 88}, -"structural_delta": { -"verdict": "regressed", -"health_delta": -2, -"regressions": ["...new finding ids..."] -} -``` - -| Field | Source | Meaning | -|----------------------------------|----------------------------------------------------|------------------------------------------------------| -| `verification.before` / `.after` | Intent before-run vs `after_run_id` | Run refs used for patch contract | -| `structural_delta.health_delta` | `health_after - health_before` from `compare_runs` | **Patch delta** between those two stored runs | -| `receipt.health.delta` | After-run summary vs trusted baseline | **Repository drift** signal in the receipt narrative | - -Patch deltas are run-relative, not baseline-novelty-relative. A finding absent -from the clean before-run and present in the after-run is a patch regression -even when its fingerprint is `novelty="known"` against the trusted baseline. - -If `before.run_id == after.run_id` for `python_structural` or -`governance_config` profiles, verify returns `status: "unverified"` with -`reason: "after_run_not_new"` — run a fresh post-edit analysis and pass the new -`after_run_id`. For documentation-only patches the identical-run case is not -structurally gated the same way. - -Negative `health_delta` sets `structural_delta.verdict` to `"regressed"` (or -`"mixed"` when improvements coexist). It does **not** by itself set -`verification.status` to `"violated"` — blocking comes from intent-scoped -finding regressions, gate worsening attributable to the patch, scope -violations, or baseline-abuse signals. Agents should still surface -`health_delta < 0` in review text. Accepted verify may include -`health_regression_advisory`. Claim Guard warns and violates regression-free -claims when `patch_health_delta < 0` (passed automatically by -`finish_controlled_change`; explicit on atomic `validate_review_claims`). - -### Multi-agent hygiene (who blocks whom) - -Hygiene reads the **shared git working tree**, not per-agent sandboxes. - -| Actor | Trigger | Start | Finish | -|------------------------------------------------------------------------------------|----------------------------------------|----------------------------------------------------------------------------------------------------------|------------------------------------------------------------------| -| **Foreign active/stale** intent on overlapping scope | `concurrent_intents` | `status: "blocked"` (coordination) | — | -| **Any** uncommitted dirty file in your `allowed_files` | `workspace_hygiene.blocks_edit` | `edit_allowed: false` (unless `dirty_scope_policy="continue_own_wip"` and no live foreign dirty overlap) | — | -| Dirty in scope **not** listed in `changed_files` / `diff_ref` (git reconciliation) | `unacknowledged_dirty_in_scope` | — | **`finish_block_reason: missing_evidence`** (blocks finish) | -| Dirty **outside** declared scope, already dirty at `start` and unchanged | `preexisting_unscoped_dirty` | — | Advisory only | -| Dirty **outside** declared scope, appeared after `start`, not foreign-attributed | `new_unattributed_unscoped_dirty` | — | Advisory — may appear in `external_changes` | -| Dirty **outside** declared scope, changed after `start`, not foreign-attributed | `modified_unattributed_unscoped_dirty` | — | Advisory — may appear in `external_changes` | -| Dirty **outside** declared scope, no usable start snapshot | `unknown_unattributed_unscoped_dirty` | — | Advisory classification only | -| Foreign dirty **outside** your scope (other agent's paths) | `foreign_attributed_outside_scope` | — | **ignored** — does not block finish | -| **Live** foreign intent previously declared overlapping dirty paths in your scope | `foreign_dirty_overlaps` | Contributes to `blocks_edit` at start | **`finish_block_reason: foreign_dirty_overlap`** (blocks finish) | - -Recoverable, expired, terminal, or **queued** foreign records **do not** -populate `foreign_dirty_overlaps`. A queued peer does not block finish for an -active agent. - -**Foreign attribution at finish:** only **`foreign_active`** and -**`foreign_stale`** intents (live owning PID, foreign to this session) may -populate `foreign_attributed_outside_scope`. **`Recoverable`** intents (dead -owning PID) do **not** grant foreign attribution — treat their dirty paths like -ordinary workspace dirt unless scope is widened or changes reverted. - -**Finish hygiene payload fields** (on `workspace_hygiene` / `workspace_hygiene_after` -when finish is hygiene-gated): - -For hygiene, `detail_level` is effectively binary: `summary` and `normal` return -`counts`, overlap lists, and blocking fields only; pass `detail_level="full"` for -`dirty_attribution`, path classification arrays, and expanded `dirty_snapshot`. - -| Field | Meaning | -|--------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `unacknowledged_dirty_in_scope` | In-scope git dirty missing from finish evidence | -| `preexisting_unscoped_dirty` | Out-of-scope git dirty that existed at `start` and did not change — informational, non-blocking | -| `unattributed_unscoped_dirty` | Union of unattributed out-of-scope paths — **advisory**, not blocking | -| `own_unscoped_dirty` | Legacy alias for `unattributed_unscoped_dirty`; not proof of ownership | -| `new_unattributed_unscoped_dirty` | Out-of-scope dirty path appeared after `start` | -| `modified_unattributed_unscoped_dirty` | Out-of-scope dirty path existed at `start` but changed afterward | -| `unknown_unattributed_unscoped_dirty` | Out-of-scope dirty path cannot be compared with a start snapshot | -| `foreign_attributed_outside_scope` | Out-of-scope git dirty owned by foreign active/stale intent — informational, non-blocking | -| `dirty_attribution` | Per-path attribution (`detail_level="full"` only) | -| `dirty_snapshot` / `dirty_snapshot_status` | Snapshot summary; expanded detail with `detail_level="full"` | -| `files_for_scope_check` | Agent evidence only — paths passed to scope `check` (out-of-scope dirt does not expand scope) | -| `finish_block_reason` | `missing_evidence`, `foreign_dirty_overlap`, or (when strict finish mode is enabled) `own_unscoped_dirty` when `blocks_finish` is true — see [env overrides](../10-config-and-defaults.md#mcp-session-and-change-control-hygiene) | -| `external_changes` | On finish response when verify is `accepted` but out-of-scope dirty remains — top-level status becomes `accepted_with_external_changes` | - -**Typical two-agent overlap on `pkg/a.py`:** - -1. Agent A (active intent) edits → working tree dirty on `pkg/a.py`. -2. Agent B calls `start` on the same path → blocked by **coordination** - (`foreign_active`) **and** **hygiene** (`blocks_edit` because the tree is - dirty in scope). B should not edit. -3. Agent A calls `finish` with `changed_files` including `pkg/a.py` → passes - declared-scope dirty acknowledgment. Finish fails on **live** foreign dirty overlap only - (`foreign_active` / `foreign_stale`). **Queued** foreign peers do not - appear in `foreign_dirty_overlaps`. -4. Resolution: coordinate (queue/promote/clear **active** foreign intent), - stash/commit foreign WIP, or narrow scope — not kill foreign PIDs. - -### Start / finish workflow transitions - -Workflow `status` values are **not** persisted registry lifecycle states. - -| Tool response | `edit_allowed` | Agent action | -|-----------------------------------------------|----------------|--------------------------------------------------------------------------------------------------------------------------| -| `start` → `needs_analysis` | `false` | `analyze_repository` → `start` again | -| `start` → `queued` | `false` | Wait → `promote`; re-analyze if `before_run_evicted` | -| `start` → `blocked` | `false` | Follow `next_step` (`message` matches); do not edit unless `continue_own_wip` was requested and returned `active` | -| `start` → `active` | `true` | Edit inside declared scope only; read `budget.gate_preview` as advisory | -| `finish` → `accepted` | — | Intent cleared (if receipt ok); no out-of-scope dirty in hygiene view | -| `finish` → `accepted_with_external_changes` | — | Patch accepted; report `external_changes` — other paths dirty outside declared scope | -| `finish` → `unverified` / `workspace_hygiene` | — | Fix `missing_evidence`, coordinate `foreign_dirty_overlap`, or (under strict finish mode) reconcile `own_unscoped_dirty` | -| `finish` → `violated` | — | Fix regressions or widen scope via new `start` | -| `finish` → `expired` | — | Re-analyze → new `start` (digest mismatch) | diff --git a/docs/book/12-structural-change-controller/token-budget.md b/docs/book/12-structural-change-controller/token-budget.md deleted file mode 100644 index b17dee38..00000000 --- a/docs/book/12-structural-change-controller/token-budget.md +++ /dev/null @@ -1,62 +0,0 @@ -## MCP Payload Token Budget - -The optional controller audit trail can estimate the token footprint of MCP -payloads returned to the agent. This is a deterministic estimate of how much -context window each tool response consumes, not actual model billing tokens. - -### Setup - -Token estimation requires two conditions: - -1. Audit trail enabled (`audit_enabled = true` in `pyproject.toml`). -2. The `codeclone[token-bench]` optional extra installed (provides `tiktoken`). - -Without `tiktoken`, the estimator falls back to a character-based approximation -(`ceil(characters / 4)`). Without audit enabled, no estimation runs. - -### How it works - -The estimation runs inside the audit writer's `event_to_row`, not in the MCP -tool call path. The MCP session has zero overhead when audit is disabled or -when `tiktoken` is not installed. - -Each audit event row includes three optional fields: - -- `estimated_tokens` — BPE token count (or character-based approximation). -- `token_encoding` — encoding name (`o200k_base` or `chars_approx`). -- `payload_characters` — character count of the canonical JSON payload. - -The estimation input is the full original payload (what the MCP client -receives), not the compact audit storage form. - -With `audit_payloads=compact`, stored JSON drops large structured fields, but -`intent.declared` keeps bounded `intent_description`. The SQLite `summary` column -always stores a short essence via `event_summary()`, independent of payload mode. - -### CLI visibility - -The `--audit` Rich TUI renderer shows token columns when data is available: - -``` -Tokens Encoding Event - 412 o200k_base intent.declared - 890 o200k_base blast_radius.computed - 1204 o200k_base patch_contract.verified -``` - -The `--session-stats` command appends a summary line when audit token data -exists: - -``` -MCP payload footprint: ~3,816 tokens (o200k_base, 7 tool calls) -``` - -### Invariants - -- Token estimation never affects controller decisions, gate results, report - digests, or baseline trust. -- Any exception in the estimation path results in `NULL` values, not a failed - audit event write. -- The `codeclone/budget/` module never imports from `codeclone/surfaces/` or - `codeclone/audit/`. Dependency direction: `audit -> budget`, never reverse. -- Base `codeclone` never depends on `tiktoken`. The import is lazy and guarded. diff --git a/docs/book/12-structural-change-controller/verification-profiles.md b/docs/book/12-structural-change-controller/verification-profiles.md deleted file mode 100644 index bb94a284..00000000 --- a/docs/book/12-structural-change-controller/verification-profiles.md +++ /dev/null @@ -1,50 +0,0 @@ -## Verification Profiles - -`check_patch_contract(mode="verify")` derives a **verification profile** from -actual changed files. The profile determines which structural checks are -applicable and whether `after_run_id` is required for verification. - -### Profile classification - -The classifier is a pure function with a deterministic priority chain: - -| Priority | Profile | When | `after_run` required | Structural checks | -|----------|-------------------------|-------------------------------------------------------------------------------------------------------|----------------------|-------------------| -| 1 | `state_artifact_change` | CodeClone state artifacts touched (`codeclone.baseline.json`, `.codeclone/**`, `.cache/codeclone/**`) | no (violated) | not applicable | -| 2 | `python_structural` | Any `.py` / `.pyi` touched | yes | all | -| 3 | `governance_config` | Config files only (pyproject.toml, CI…) | yes | not applicable | -| 4 | `documentation_only` | Only docs files (`.md`, `.rst`, …) | no | not applicable | -| 5 | `non_python_patch` | Other files, no Python or docs | no | not applicable | - -A single file from a higher-priority category overrides the entire patch. - -### Fast path - -Documentation-only and non-Python patches can verify without `after_run_id` -when `changed_files` or `diff_ref` evidence is provided. Without any diff -evidence, verify returns `unverified` to preserve backward compatibility. - -### Invariants - -- The profile is derived from `actual_changed_files`, never declared by the - agent. -- Scope and forbidden checks always run before any profile-based fast return. -- Receipts use "not applicable" for skipped structural checks, never "passed". -- Claim guard warns when review text references structural verification but - the profile says structural checks were not applicable. -- Claim guard warns and violates regression-free claims when - `patch_health_delta < 0`. - -### Public surface - -| Artifact | Path | -|-------------------|--------------------------------------------------------| -| Classifier module | `codeclone/surfaces/mcp/_verification_profile.py` | -| Enum | `VerificationProfile` | -| Classifier | `classify_patch(changed_files) → ClassificationResult` | -| Check matrix | `check_matrix(profile) → CheckMatrix` | - -### Locked by tests - -- `tests/test_verification_profile.py` -- `tests/test_mcp_service.py` diff --git a/docs/book/12-structural-change-controller/workflow-tools.md b/docs/book/12-structural-change-controller/workflow-tools.md deleted file mode 100644 index e9ee2c3d..00000000 --- a/docs/book/12-structural-change-controller/workflow-tools.md +++ /dev/null @@ -1,147 +0,0 @@ -## Pre-Change Workflow - -1. Call `manage_change_intent(action="list_workspace", root="/abs/repo")` to - see active intents from other agents before analysis. - If it returns `ownership="recoverable"` for a matching run, use - `manage_change_intent(action="recover")` instead of killing another MCP - process or redeclaring blindly. -2. Run `analyze_repository` or `analyze_changed_paths`. -3. Declare scope with `manage_change_intent(action="declare")`. -4. If `concurrent_intents` is non-empty, narrow scope or coordinate before - editing. -5. Inspect the returned `blast_radius_summary`. -6. Optionally call `get_blast_radius` for full dependent/context detail. -7. Call `check_patch_contract(mode="budget")` to inspect the active regression - budget and metric headroom before editing. -8. Run analysis again after editing (produces the after-run). -9. Call `manage_change_intent(action="check", intent_id=..., changed_files=...)` - with the original `intent_id`. Use `diff_ref=...` instead of - `changed_files=...` when the changed set should come from git. The intent - stays bound to the before-run; `verify` compares its `report_digest` against - the before-run, so redeclaring on the after-run would cause an `expired` - mismatch. -10. Call `check_patch_contract(mode="verify", before_run_id=..., - after_run_id=..., intent_id=...)`. -11. Call `validate_review_claims` before publishing claim text in the atomic - workflow, or pass `claims_text` to `finish_controlled_change`. -12. Call `create_review_receipt` to collect provenance, scope, blast radius, - reviewed findings, patch status, human decision points, and claims-not-made. -13. Call `manage_change_intent(action="clear")` when the edit is complete. - -`manage_change_intent` can return `clean`, `expanded`, `violated`, or -`expired`. Expiry means the report digest changed since declaration. - -`check_patch_contract` never runs analysis itself. Budget mode reads one stored -run and optional intent. Verify mode compares explicit before/after stored runs, -previews gates, validates scope when intent is available, and reports baseline -abuse signals. Missing before or after runs return `status="unverified"` with -`reason="no_before_run"` or `reason="no_after_run"`. - -Patch verify is run-relative, not baseline-novelty-relative: if a finding is -absent from the clean before-run and present in the after-run, it is a patch -regression even when that finding's fingerprint is `novelty="known"` against the -trusted baseline. - -Budget payloads use `null` for disabled numeric thresholds rather than sentinel -values. Boolean policy gates are named `forbid_*`, for example -`forbid_dead_code_regression`. - -## Verify Ergonomics - -`check_patch_contract(mode="verify")` includes three ergonomic features that -reduce agent error and wasted context tokens. - -### Auto-resolve before_run_id - -When `intent_id` is provided but `before_run_id` is omitted, verify resolves -the before-run from the intent record's `run_id`. This eliminates the most -common agent error: forgetting to pass `before_run_id`. - -### Next-step hints - -Non-accepted verify responses include a `next_step` field with an actionable -hint matched to the failure reason: - -| Reason | Hint | -|-------------------------------------|------------------------------------------------------------| -| `no_before_run` | Run analysis or pass intent_id to auto-resolve | -| `no_after_run` | Run analysis after editing and pass after_run_id | -| `after_run_not_new` | After-run matches before-run; run fresh post-edit analysis | -| `after_run_required_for_governance` | Governance changes require post-edit analysis | -| `incomparable_runs` | Re-run analysis with the same settings | -| `intent_not_active` | Queued intent must be promoted first | -| `report_digest_mismatch` | Use the original intent_id with the original before-run | -| `state_artifact_mutation` | Remove baseline/cache files from the patch | -| `scope_violation` | Redeclare intent with expanded scope | - -### Claim validation recommended - -The `claim_validation_recommended` boolean in verify responses advises whether -calling `validate_review_claims` is meaningful for the verification profile. -It is `true` for `python_structural` and `governance_config` profiles, `false` -for `documentation_only`, `non_python_patch`, `state_artifact_change`, and -non-accepted outcomes. - -## Workflow consolidation - -The atomic change control workflow requires 7–11 MCP tool calls per edit -cycle. Two **workflow-level tools** aggregate these steps while preserving -the same evidence, state updates, and boundary checks: - -| Tool | Replaces | Calls | -|----------------------------|---------------------------------------------------|------------------| -| `start_controlled_change` | workspace check + declare + blast radius + budget | 1 instead of 4 | -| `finish_controlled_change` | scope check + verify + claims + receipt + clear | 1 instead of 4–6 | - -Workflow tools are orchestration shortcuts. They call the same internal -methods as the atomic tools and emit the same semantic audit events. -`analyze_repository` remains a separate explicit call — workflow tools -never run analysis implicitly. - -`start_controlled_change` responses include `context_governance` metadata with a -`start_projection_v1` digest and estimated context units for the serialized -response. When a stored blast artifact is available, start uses -`mode="partial_enforce"` and -`evidence_policy="response_budget_with_immutable_blast_artifact"`: control -facts remain inline, while omitted blast lanes are disclosed in -`context_governance.omitted` with `get_blast_artifact` drill-down. Queued, -needs-analysis, and no-artifact fallback responses stay in `mode="observe"`. -`edit_allowed` and explicit status fields remain the permission contract. - -Start blast-radius evidence is summary-first when CodeClone can persist an -immutable audit-backed artifact for the full projection. The summary keeps -control facts, `do_not_touch`, `do_not_touch_summary`, workspace blocking facts, -and artifact identity inline; full omitted evidence is retrieved through the -read-only `get_blast_artifact` tool by `run_id` and `blast_artifact_id`. This -retrieval returns the exact stored start-time projection. `get_blast_radius` -remains available for current recomputation, and -`blast_radius_detail="full"` keeps the compatibility projection inline. If the -artifact cannot be stored, start fails closed by returning full blast evidence. - -Repeated identical `start_controlled_change` calls in the same MCP session may -return an explicit compact replay instead of re-emitting blast-radius and budget -payloads. A replay sets `idempotent_replay=true`, keeps the same -`intent_id`, includes scope/workspace/blast/budget digests, and points the agent -to `get_relevant_memory`. Replay is session-local, does not renew the lease, and -is disabled when the analysis run, workspace content, registry state, scope, -request parameters, or owner session changes. - -`finish_controlled_change` keeps human notes and validated claims separate: -`review_text` is a note, while `claims_text` is the only finish parameter passed -to Claim Guard. The response includes a compact `summary` for humans while -retaining full `scope_check`, `verification`, `claims`, `receipt`, and -`workspace_hygiene_after` payloads for agents. - -**Tool tiers:** - -- **Normal workflow:** `analyze_repository`, `start_controlled_change`, - `finish_controlled_change` — every edit cycle. -- **Queue/recovery:** `manage_change_intent` (promote, recover, reset, - renew) — multi-agent coordination, crash recovery. -- **Advanced/diagnostic:** `get_blast_radius`, `check_patch_contract`, - `validate_review_claims`, `create_review_receipt` — deep inspection, - step-by-step debugging. - -The same semantic audit events are preserved regardless of which -approach the agent uses. Atomic tools remain available for backward -compatibility and advanced use cases. diff --git a/docs/book/13-engineering-memory/agent-contracts.md b/docs/book/13-engineering-memory/agent-contracts.md deleted file mode 100644 index 75b2c523..00000000 --- a/docs/book/13-engineering-memory/agent-contracts.md +++ /dev/null @@ -1,50 +0,0 @@ -## Agent playbook - -### When to read memory - -```mermaid -flowchart TD - A[analyze_repository] --> B[start_controlled_change] - B --> C{edit_allowed?} - C -->|no| Z[Stop — queue / blocked / needs_analysis] -C -->|yes|D[get_relevant_memory] -D --> E{contradiction_note\nor stale warnings?} -E -->|yes|F[Surface to user before edit] -E -->|no|G[Edit in declared scope] -G --> H[analyze if profile requires after_run] -H --> I[finish_controlled_change] -I --> J{propose_memory?} -J -->|true + accepted|K[Review memory_candidates\nhuman approve later] -J -->|false|L[Done] - -style D fill: #eff6ff -style G fill: #fef9c3 -``` - -| Moment | Tool | Why | -|----------------------------------|--------------------------------------------------------------------------|-----------------------------------------------| -| After `start`, before first edit | `get_relevant_memory(root=abs, scope=… \| intent_id=…)` | Ranked context for declared scope | -| Need one path deep-dive | `query_engineering_memory(mode=for_path, path=…)` | Targeted lookup | -| Need keyword across store | `query_engineering_memory(mode=search, query=…, filters={match_mode:…})` | FTS discovery | -| Before writing claims in finish | `manage_engineering_memory(action=validate_claims, text=…)` | Catch overclaims vs memory | -| After accepted patch (optional) | `finish(..., propose_memory=true)` | Draft candidates + staleness + coverage delta | - -### When to write memory - -| Situation | Action | Notes | -|----------------------------------|------------------------------------------------------------------------------------------|---------------------------------------| -| Stable observation during edit | `record_candidate` | Draft only; cite scope in statement | -| Patch accepted, workflow finish | `propose_memory=true` | Preferred batch proposal | -| Atomic fallback (no finish hook) | `propose_from_receipt` | Same receipt shape as finish | -| System facts changed in repo | `refresh_from_run` or ask human for `memory init --refresh` | Explicit MCP refresh always available | -| Promote draft to trusted fact | **Not agent** — VS Code Memory view or `codeclone memory approve --i-know-what-im-doing` | Required for active/verified | - -### When **not** to use memory - -- To justify touching `do_not_touch` paths -- To expand scope beyond declared intent -- To override CodeClone structural findings -- As a substitute for `analyze_repository` or `get_blast_radius` -- To treat `draft` / `inferred` / `stale` records as established facts - ---- diff --git a/docs/book/13-engineering-memory/bootstrap-and-config.md b/docs/book/13-engineering-memory/bootstrap-and-config.md deleted file mode 100644 index 6d9674fd..00000000 --- a/docs/book/13-engineering-memory/bootstrap-and-config.md +++ /dev/null @@ -1,200 +0,0 @@ -## Bootstrap: init, MCP sync, and refresh - -The memory store can be created or refreshed through **CLI init**, **MCP auto-sync** -(default), or **explicit MCP refresh**. All paths call the same deterministic -ingest pipeline (`run_memory_init`). - -### CLI init (human / CI) - -```bash -codeclone memory init --root /abs/repo -codeclone memory init --root /abs/repo --refresh # re-ingest + staleness pass -``` - -```mermaid -sequenceDiagram - participant H as Human / CI - participant CLI as codeclone memory init - participant CC as CodeClone analysis - participant DB as SQLite store - H ->> CLI: init [--refresh] - CLI ->> CC: load cached report or analyze - CLI ->> CLI: build ingest batch - Note over CLI: modules, contracts, docs,
tests, risks, git hotspots - CLI ->> DB: upsert records + evidence - CLI ->> DB: rebuild FTS index - opt --refresh - CLI ->> DB: mark drifted records stale - end - CLI ->> H: status summary -``` - -### MCP sync (default agent path) - -Policy key: `mcp_sync_policy` in `[tool.codeclone.memory]` (default -`bootstrap_if_missing`). - -| Policy | Auto behavior on `get_relevant_memory` | Explicit `refresh_from_run` | -|------------------------|---------------------------------------------------|-----------------------------| -| `off` | No auto sync; DB must exist | Always runs ingest | -| `bootstrap_if_missing` | Create store from latest MCP run when DB missing | Always runs ingest | -| `refresh_when_stale` | Re-ingest when stored digest ≠ current run digest | Always runs ingest | - -```mermaid -sequenceDiagram - participant A as Agent - participant M as MCP - participant S as mcp_sync - participant DB as SQLite store - A ->> M: analyze_repository - M -->> A: run_id - A ->> M: start_controlled_change - M -->> A: edit_allowed=true - A ->> M: get_relevant_memory(root, intent_id) - M ->> S: decide + execute (policy) - alt missing DB + bootstrap_if_missing - S ->> DB: init ingest from run report - S -->> M: memory_sync completed - else digest changed + refresh_when_stale - S ->> DB: refresh ingest + staleness - S -->> M: memory_sync completed - else unchanged - S -->> M: skip (no memory_sync field) - end - M ->> DB: ranked scope query - M -->> A: records + optional memory_sync -``` - -**Explicit refresh:** `manage_engineering_memory(action="refresh_from_run", run_id?)` -always ingests from the selected MCP run (defaults to latest). Use after -`analyze_repository` when you need fresh system facts without waiting for policy -triggers. - -**Agent rule:** MCP sync ingests **system records only** — same as CLI init. -Human `approve` is still required for agent drafts. MCP never runs -approve/reject/archive. - -When auto-sync does not run and the DB is missing, memory tools return a contract -error pointing to `refresh_from_run` or CLI init. - -Ingest sources (non-exhaustive): - -| Record type | Typical ingest source | -|----------------------|------------------------------------------------------| -| `module_role` | Report file inventory | -| `contract_note` | `contracts/__init__.py` paths (auto or configured) | -| `document_link` | Configured docs and/or `docs/**/*.md` from inventory | -| `test_anchor` | Test file inventory | -| `risk_note` | Complexity / security surfaces from metrics | -| `public_surface` | MCP / CLI public API inventory | -| `contradiction_note` | Optional MCP tool-count doc vs snapshot | - -Git provenance: init attaches `git_commit` evidence when git is -available; optional git hotspot records use -`git_hotspot_period_days` / `git_hotspot_min_changes` from config. - -Refs: `codeclone/memory/ingest/mcp_sync.py`, `codeclone/surfaces/mcp/_session_memory_mixin.py`. - ---- - -## Configuration - -Nested tables in `pyproject.toml` under `[tool.codeclone.memory]`, -`[tool.codeclone.memory.ingest]`, and `[tool.codeclone.memory.semantic]`. -Defaults live in `codeclone/config/memory_defaults.py`; key validation in -`codeclone/config/memory_specs.py` (flat memory keys) and -`codeclone/config/memory.py` (`IngestConfig`, `SemanticConfig`). - -### Retention and capacity - -| Key | Type | Default | Purpose | -|----------------------------------|------|---------|--------------------------------------------------| -| `active_retention_days` | int | `-1` | Active record retention (`-1` = no age purge) | -| `stale_retention_days` | int | `180` | Stale record retention before vacuum | -| `draft_retention_days` | int | `14` | Draft candidate retention | -| `rejected_retention_days` | int | `30` | Rejected draft retention | -| `archived_retention_days` | int | `365` | Archived record retention | -| `receipt_retention_days` | int | `90` | Finish-receipt evidence retention | -| `max_records` | int | `10000` | Hard cap on persisted records | -| `max_candidates` | int | `1000` | Draft inbox capacity | -| `max_evidence_per_record` | int | `20` | Evidence rows per record | -| `max_statement_chars` | int | `1000` | Statement hard limit (target 300, soft warn 500) | -| `max_blast_radius_cache_entries` | int | `500` | Cached blast-radius projections per project | -| `trajectory_retention_days` | int | `365` | Stored trajectory projection retention | - -### Store backend and sync - -| Key | Type | Default | Purpose | -|-------------------|------|------------------------------------------------|---------------------------------------------------------| -| `backend` | str | `sqlite` | Persistence backend | -| `db_path` | str | `.codeclone/memory/engineering_memory.sqlite3` | SQLite path | -| `mcp_sync_policy` | str | `bootstrap_if_missing` | `off` \| `bootstrap_if_missing` \| `refresh_when_stale` | - -### Git hotspots (init ingest) - -| Key | Type | Default | Purpose | -|---------------------------|------|---------|----------------------------------------| -| `git_hotspot_period_days` | int | `90` | Git history window for hotspot records | -| `git_hotspot_min_changes` | int | `5` | Minimum commits to emit a hotspot | - -### Trajectory projection and export - -| Key | Type | Default | Purpose | -|--------------------------------------|------|------------|----------------------------------------------| -| `trajectories_enabled` | bool | `true` | Enable trajectory projection from audit core | -| `trajectory_export_enabled` | bool | `false` | Gate CLI `trajectory export` | -| `trajectory_export_include_payloads` | bool | `false` | Include step payloads in JSONL export | -| `trajectory_export_max_record_bytes` | int | `65536` | Per-record export size cap | -| `trajectory_export_max_file_bytes` | int | `10485760` | Export file size cap | - -### Projection rebuild coalesce - -| Key | Type | Default | Purpose | -|----------------------------------------------|------|---------|---------------------------------------------------------| -| `projection_rebuild_policy` | str | `off` | `off` \| `enqueue_when_stale` — finish may enqueue jobs | -| `projection_rebuild_running_timeout_seconds` | int | `1800` | Stale running-job reclaim timeout | -| `projection_rebuild_spawn_worker` | bool | `true` | Spawn detached worker on enqueue | -| `projection_rebuild_coalesce_window_seconds` | int | `60` | Batch sub-threshold rebuilds (`0` = immediate spawn) | -| `projection_rebuild_coalesce_min_delta` | int | `25` | Active-record delta bypassing coalesce window | - -### Ingest paths (`[tool.codeclone.memory.ingest]`) - -| Key | Type | Default | Purpose | -|---------------------------------|----------------|---------|--------------------------------------------------------------------------------| -| `contract_constants_paths` | string list | `[]` | Contract version files; empty uses auto discovery under `codeclone/contracts/` | -| `document_link_paths` | string list | `[]` | Doc paths; empty uses README, AGENTS, CLAUDE, and docs tree | -| `mcp_tool_schema_snapshot_path` | string or null | `null` | MCP tool schema snapshot for contradiction checks | -| `mcp_tool_count_doc_paths` | string list | `[]` | Docs claiming MCP tool counts (requires snapshot path) | - -### Semantic batching (`[tool.codeclone.memory.semantic]`) - -| Key | Type | Default | Purpose | -|-------------------------------------|------|------------------------------------------|-------------------------------------------------------| -| `enabled` | bool | `false` | Opt-in semantic sidecar | -| `backend` | str | `lancedb` | Vector backend | -| `index_path` | str | `.codeclone/memory/semantic_index.lance` | LanceDB path | -| `embedding_provider` | str | `diagnostic` | `diagnostic` \| `fastembed` \| `local_model` \| `api` | -| `embedding_model` | str | provider default | e.g. `BAAI/bge-small-en-v1.5` for fastembed | -| `embedding_cache_dir` | str | `.codeclone/memory/fastembed` | Model cache directory | -| `allow_model_download` | bool | `false` | Permit fastembed downloads | -| `dimension` | int | `256` | Diagnostic provider dimension | -| `max_results` | int | `20` | Semantic search cap | -| `index_audit` | bool | `true` | Project audit summaries into index | -| `embed_max_documents_per_batch` | int | `64` | Embedding batch document cap | -| `embed_max_padded_tokens_per_batch` | int | `8192` | Embedding batch token budget | -| `projection_token_estimator` | str | `chars_approx` | `chars_approx` \| `tiktoken` | - -Environment overrides for memory and semantic fields: -[10-config Environment variable overrides](../10-config-and-defaults.md#environment-variable-overrides) -(Engineering Memory table). - -Unknown keys under `[tool.codeclone.memory.semantic]` are contract errors -(Pydantic `extra="forbid"` on `SemanticConfig`). - -Refs: - -- `codeclone/config/memory_specs.py` -- `codeclone/config/memory_defaults.py` -- `codeclone/config/memory.py` - ---- diff --git a/docs/book/13-engineering-memory/cli-surface.md b/docs/book/13-engineering-memory/cli-surface.md deleted file mode 100644 index 7f826521..00000000 --- a/docs/book/13-engineering-memory/cli-surface.md +++ /dev/null @@ -1,75 +0,0 @@ -## CLI surface - -All commands live under `codeclone memory` and accept `--root` (default `.`). - -| Command | Purpose | -|----------------------------------------------------------------------------------------|-----------------------------------------------------------| -| `init [--refresh] [--dry-run] [--from-report PATH] [--no-docs] [--no-tests]` | Create or refresh the memory store | -| `status` | Schema version, counts, last ingest metadata | -| `for-path PATH [--limit N]` | Records linked to a repo-relative path | -| `search QUERY [--match any\|all] [--semantic] [--active-only] [--limit N]` | FTS search; optional semantic blend | -| `semantic status` | Index availability, provider, row counts | -| `semantic rebuild` | Rebuild LanceDB sidecar from memory + audit + trajectory | -| `semantic probe [--exact-tokens] [--json]` | Projection length stats (index-unit aware for trajectory) | -| `semantic search QUERY [--limit N]` | Search with semantic ranking (index required) | -| `stale [--limit N]` | List stale records and reasons | -| `vacuum` | Retention purge per config (no dry-run flag) | -| `coverage --scope PATH [PATH...]` | Scope coverage metrics | -| `review-candidates [--limit N]` | List draft records awaiting human review | -| `approve RECORD_ID [--by NAME] [--i-know-what-im-doing]` | Promote draft → active | -| `reject RECORD_ID [--by NAME] [--reason TEXT] [--i-know-what-im-doing]` | Reject draft | -| `archive RECORD_ID [--by NAME] [--i-know-what-im-doing]` | Archive record | -| `trajectory status\|rebuild\|list\|search\|show\|agents\|anomalies\|dashboard\|export` | Trajectory projection, passport analytics, and export | -| `jobs status\|enqueue\|run-once\|list` | Trajectory + semantic + Experience projection queue | - -### Init flags - -| Flag | Effect | -|-----------------|---------------------------------------------------------------------| -| `--dry-run` | Build ingest batch without writing the store | -| `--refresh` | Re-ingest and run staleness pass on drifted system records | -| `--from-report` | Load a canonical report JSON path instead of cached/latest analysis | -| `--no-docs` | Skip document-link ingest | -| `--no-tests` | Skip test-anchor ingest | - -### Governance (`approve` / `reject` / `archive`) - -Direct CLI governance is **disabled by default**. The preferred path is the -**CodeClone VS Code Memory** view (IDE governance channel over MCP with -`--ide-governance-channel`). - -For explicit human break-glass outside the IDE channel, pass -`--i-know-what-im-doing` on `approve`, `reject`, or `archive`. Attributions use -`--by` (default `human`), not `--verified-by`. - -MCP agents cannot call `approve`/`reject`/`archive` on `manage_engineering_memory`. - -### Trajectory analytics flags - -| Subcommand | Extra flags | -|-------------|-----------------------------------------------------------------------------| -| `list` | `--limit N` | -| `search` | `QUERY`, `--limit N`, `--match any\|all` | -| `agents` | `--include-routine`, `--json` | -| `anomalies` | `--limit N`, `--include-routine`, `--json` | -| `dashboard` | `--limit N`, `--include-routine`, `--json` | -| `export` | `--profile NAME`, `--out PATH`, `--allow-external-out`, `--force`, `--json` | - -`--include-routine` includes routine analysis-only trajectories in aggregates -(default excludes them). Maps to MCP `filters.include_routine` on trajectory -analytics modes. - -### Projection jobs flags - -| Subcommand | Extra flags | -|------------|------------------------------------------------------------------------------| -| `enqueue` | `--force` (enqueue even when policy off or stimulus unchanged), `--no-spawn` | -| `run-once` | `--not-before ISO-8601-UTC` (defer until coalesce window elapses) | -| `list` | `--limit N`, `--json` | - -Refs: - -- `codeclone/surfaces/cli/memory.py` -- `codeclone/surfaces/cli/memory_render.py` - ---- diff --git a/docs/book/13-engineering-memory/experience-layer.md b/docs/book/13-engineering-memory/experience-layer.md deleted file mode 100644 index 134de882..00000000 --- a/docs/book/13-engineering-memory/experience-layer.md +++ /dev/null @@ -1,103 +0,0 @@ -# Experience Layer - - - -Experiences are the third Engineering Memory knowledge tier: - -1. memory records describe what the project knows; -2. trajectories describe what happened during agent work; -3. Experiences summarize recurring, evidence-linked patterns across - trajectories. - -Experiences are advisory. They do not authorize edits, override findings, or -replace the human-governed memory record lifecycle. - -## Distillation pipeline - -```mermaid -flowchart LR - A["Canonical trajectories
all outcomes"] --> B["Extract subject families,
signals, outcome classes"] - B --> C["Group by PatternKey"] - C --> D["Support and information-value gates"] - D --> E["Active Experiences"] - E --> F["Scoped retrieval"] - E --> G["Optional promotion"] - G --> H["Human-reviewable draft
memory record"] -``` - -The current distillation version is `experience-v1`. Every canonical -trajectory may contribute, including partial, blocked, and incident-bearing -work. Distillation is not limited to verified or successful changes. - -## Pattern identity - -An Experience key contains: - -- `subject_family`: a deterministic directory family derived from touched - paths, with at most eight families per trajectory; -- `signal`: a non-ubiquitous label or a derived signal; -- `outcome_class`: `:`. - -Derived signals include: - -- `verification_incomplete` for partial or blocked work without a verified - finish; -- `incident_present` when a trajectory contains incidents. - -Agent and tool identity are deliberately excluded from `PatternKey`. They are -evidence facets, not pattern identity, so equivalent project behavior can -coalesce across agents. - -## Admission and scoring - -A candidate requires: - -- support from at least five trajectories; -- information value of at least `50`; -- no more than twenty retained evidence trajectory IDs. - -Information value is deterministic: - -- `+60` when evidence spans at least two agent families; -- `+25` for a structural signal; -- capped at `100`. - -A single-agent pattern therefore does not pass the current information-value -threshold by itself. - -The Experience ID and digest exclude timestamps. They include the pattern key, -sorted member trajectory IDs, and the distillation version. This keeps -replace-all rebuilds reproducible. - -## Storage and retrieval - -Distillation replaces the project's Experience projection atomically in -deterministic order. Current records are `active`. The domain model reserves a -`dormant` state, but dormant lifecycle management is not implemented. - -Scoped retrieval: - -- returns active Experiences only; -- exact-matches the requested directory `subject_family`; -- sorts by support descending, information value descending, then ID; -- returns compact evidence counts and agent-family summaries by default; -- adds agent facets and evidence trajectory IDs at full detail. - -The current distiller emits `agent_family` facets. Other facet kinds are -reserved by the domain types but are not currently populated. - -## Promotion boundary - -Promotion is explicit and idempotent. It creates a human-reviewable draft -memory candidate with the Experience statement, subject family, and trajectory -evidence. It obeys the project's draft capacity and does not silently approve -the result. - -Promotion creates drafts only. To approve, reject, or archive records, use the -**VS Code Memory** view (IDE governance channel) or the CLI break-glass path -(`codeclone memory approve|reject|archive --i-know-what-im-doing`). MCP agents -cannot perform governance actions. - -See [Trust and lifecycle](trust-and-lifecycle.md), -[MCP surface](mcp-surface.md), and the -[trajectories and Experiences guide](../../guide/memory/trajectories-and-experiences.md). diff --git a/docs/book/13-engineering-memory/index.md b/docs/book/13-engineering-memory/index.md deleted file mode 100644 index da2e120a..00000000 --- a/docs/book/13-engineering-memory/index.md +++ /dev/null @@ -1,140 +0,0 @@ -# Engineering Memory - -## Purpose - -Engineering Memory is a **local, evidence-linked knowledge store** for a Python -repository. It captures structural facts, document links, git provenance, and -governed human/agent notes — then surfaces them to AI agents **before and during** -controlled edits. - -!!! note "Not a second analyzer" - Memory reads from the same canonical report, contracts, docs, tests, and git - facts as CodeClone analysis. It does **not** run a separate LLM inference - path, mutate source files, or override structural findings. - -!!! note "Not analysis cache" - The SQLite database under `.codeclone/memory/` is a **governed memory - contract**, separate from analysis cache (`cache.json`) and baselines - (`codeclone.baseline.json`). - ---- - -## Status - -| Status | Capability | Surface | -|------------------|---------------------------------------------------------------|------------------------------------------------------------------------------------------| -| Live | Store, init ingest, CLI `init\|status\|for-path\|search` | CLI | -| Live | Scoped retrieval, ranking | MCP `get_relevant_memory`, `query_engineering_memory` | -| Live | Refresh staleness, scope staleness, retention | CLI `stale`, `vacuum`; finish hook marks scope stale | -| Live | Draft governance, claim validation | MCP `manage_engineering_memory`; CLI `review-candidates\|approve\|reject\|archive` | -| Live | Scope coverage, finish proposals | `finish_controlled_change(propose_memory=true)` | -| Live | FTS search (`match_mode`), git hotspots, schema 1.1, Rich CLI | CLI `--match`; MCP `filters.match_mode` | -| Live | MCP sync from analysis runs | `mcp_sync_policy`; auto bootstrap on `get_relevant_memory`; `refresh_from_run` | -| Optional sidecar | Semantic retrieval (LanceDB sidecar) | `[tool.codeclone.memory.semantic]`; CLI `memory semantic *`; MCP/CLI search `--semantic` | -| Live | Audit event core for trajectory replay | `AUDIT_EVENT_CORE_VERSION`; audit `event_core_json` / `workflow_id` | -| Live | Trajectory projection + SQLite storage | CLI `memory trajectory status\|rebuild\|list\|show\|search` | -| Live | Scoped trajectory retrieval + memory evidence | MCP `get_relevant_memory.trajectories[]`; `query_engineering_memory(mode=trajectory_*)` | -| Local opt-in | Disabled-by-default local JSONL export profiles | CLI `memory trajectory export --profile ... --out ...` | -| Live | Patch Trail persistence + scoped retrieval | `memory_trajectory_patch_trails`; `patch_trail_summary` on scoped retrieval | -| Live | Incremental projection jobs | Watermarked trajectory rebuild, semantic hash-skip, coalesced worker | -| Live | Trajectory quality and passport analytics | Quality/complexity contract, anomalies, agents, dashboard | -| Live | Experience Layer | Distillation job, scoped `experiences[]`, `promote_experience` draft bridge | - -Schema version constant: `ENGINEERING_MEMORY_SCHEMA_VERSION` in -`codeclone/contracts/__init__.py` (currently **`1.7`**). - -Semantic index format (separate contract): `SEMANTIC_INDEX_FORMAT_VERSION` -(currently **`2`**) in the same module. The vector sidecar is independent of -the SQLite memory schema version. - ---- - -## Architecture - -```mermaid -graph TB - subgraph Sources["Deterministic sources"] - CR[Canonical Report] - CT[Contracts / docs / tests] - GIT[Git provenance] - RC[Finish receipts / audit] - end - - subgraph MemoryStore["Engineering Memory (SQLite)"] - REC[memory_records] - SUB[memory_subjects] - EV[memory_evidence] - FTS[memory_fts FTS5] - TRAJ[trajectory projection] - EXP[Experience projection] - end - - subgraph Surfaces["Read / write surfaces"] - CLI["codeclone memory *"] - MCP_R["MCP read tools"] - MCP_W["MCP draft writes"] - HUM["Human approve CLI"] - end - - CR -->|init / refresh ingest| MemoryStore - CT -->|init / refresh ingest| MemoryStore - GIT -->|init / refresh ingest| MemoryStore - RC -->|propose_from_receipt / finish hook| MemoryStore - RC --> TRAJ --> EXP - MemoryStore --> CLI - MemoryStore --> MCP_R - MCP_W -->|draft only| MemoryStore - HUM -->|approve / reject / archive| MemoryStore - style MemoryStore stroke: #6366f1, stroke-width: 2px - style MCP_W fill: #fef9c3 - style HUM fill: #dcfce7 -``` - -Module ownership: - -| Module | Role | -|---------------------------------------------------|------------------------------------------------------| -| `codeclone/memory/sqlite_store.py` | SQLite persistence, FTS sync, subject dedup | -| `codeclone/memory/ingest/*` | Init/refresh batch builders from report + git + docs | -| `codeclone/memory/retrieval/*` | Scoped ranking and query router | -| `codeclone/memory/semantic/*` | Projections, LanceDB sidecar, rebuild, search hits | -| `codeclone/memory/embedding/*` | Embedding providers (`diagnostic` default) | -| `codeclone/memory/governance.py` | Draft candidates, approve/reject, claim validation | -| `codeclone/memory/staleness.py` | Refresh-time and scope-time staleness | -| `codeclone/memory/jobs/store.py` | Coalesced projection rebuild jobs (schema 1.3+) | -| `codeclone/memory/trajectory/*` | Audit → trajectory projection, Patch Trail, export | -| `codeclone/memory/experience/*` | Deterministic Experience distillation + persistence | -| `codeclone/config/memory*.py` | `[tool.codeclone.memory]` resolution | -| `codeclone/surfaces/cli/memory*.py` | Human CLI + Rich rendering | -| `codeclone/surfaces/mcp/_session_memory_mixin.py` | MCP memory tools + finish hook | - -Refs: - -- `codeclone/memory/ingest/runner.py:run_memory_init` -- `codeclone/memory/retrieval/service.py:query_engineering_memory` -- `codeclone/surfaces/mcp/_session_memory_mixin.py` - -Normative detail: - -- [Trajectory and Patch Trail](trajectory-and-patch-trail.md) -- [Trajectory quality and passport](trajectory-quality-and-passport.md) -- [Experience Layer](experience-layer.md) -- [Projection jobs](projection-jobs.md) -- [Practical trajectory and Experience guide](../../guide/memory/trajectories-and-experiences.md) - ---- - -## Regressions and UX fixes (2.1.0a1) - -These are documentation anchors for shipped fixes — see `CHANGELOG.md` **Fixed** -for the full controller list. - -| Area | Symptom | Fix (code truth) | -|--------------------------------|-----------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------| -| VS Code session/audit webviews | Payload footprint table showed zeros for workflow metrics | Audit footprint JSON uses `calls` and `tokens` in `top_workflows`; the webview maps both legacy and mistaken field names (`workspaceInsightsRenderer.js`). | -| CLI session stats | Import / duplication issues | Collection lives in `codeclone/controller_insights/`; CLI renders only (`surfaces/cli/session_stats.py`). | -| MCP vs CLI insights | Session stats logic must not live only in MCP | IDE-only tools `get_workspace_session_stats` / `get_controller_audit_trail` share the same collectors as `--session-stats` / `--audit`. | -| Patch verify | Identical before/after run accepted | `after_run_not_new` for `python_structural` and `governance_config` profiles. | -| Finish hygiene | Over-blocking on foreign out-of-scope dirt | Unattributed out-of-scope dirt is advisory; blocking reasons are `missing_evidence` and `foreign_dirty_overlap`. | - ---- diff --git a/docs/book/13-engineering-memory/mcp-surface.md b/docs/book/13-engineering-memory/mcp-surface.md deleted file mode 100644 index 68844fd1..00000000 --- a/docs/book/13-engineering-memory/mcp-surface.md +++ /dev/null @@ -1,156 +0,0 @@ -## MCP surface - -### Read tools - -#### `get_relevant_memory` - -Ranked, scope-aware context for the **declared edit scope**. - -| Parameter | Purpose | -|-----------------------------------|-----------------------------------------------------------------------------------------------------------------| -| `root` | **Required.** Absolute repository root (same as `analyze_repository`) | -| `scope` | Explicit repo-relative paths | -| `intent_id` | Active intent from `start_controlled_change` (resolves scope) | -| `symbols` | Optional qualname keys for boost | -| `max_records` | Cap (default 20) | -| `include_stale`, `include_drafts` | `include_stale` defaults false; drafts are automatic for scoped retrieval / path / symbol and opt-in for search | -| `detail_level` | `compact` (default) or `full` — compact returns statement previews without payload | - -`trajectories[]` excludes routine `run:*` workflows by default (same semantics -as `include_routine=false` in retrieval). There is **no** `include_routine` -parameter on this tool; use `query_engineering_memory` trajectory modes with -`filters.include_routine` to include routine analysis-only trajectories. - -Unscoped `get_relevant_memory` is **rejected**. Pass `scope`, `intent_id`, or -`symbols`. For project-wide orientation use -`query_engineering_memory(mode=status|search)` — not root scope (`"."`, `""`). - -Project root is never a valid memory scope for `scope`, `path`, or `coverage`. - -`intent_id` or `scope` without `root` fails MCP argument validation (Pydantic). -Always pass the same absolute `root` used for `analyze_repository` and -`start_controlled_change`. - -When auto-sync runs, the response includes a `memory_sync` object (`status`, -`trigger`, `run_id`, `report_digest`, ingest stats). Omitted when sync was skipped -(`status: unchanged`). - -#### `query_engineering_memory` - -Mode router for inspection and search. - -| `mode` | Required inputs | Purpose | -|------------------------|---------------------------------------------|-------------------------------------------------| -| `search` | `query`; optional `semantic=true` | FTS keyword search; optional vector blend | -| `get` | `record_id` | Single record + subjects + evidence | -| `for_path` | `path` | Path-linked records | -| `for_symbol` | `symbol` | Symbol-linked records | -| `stale` | — | Stale inventory | -| `coverage` | `scope` (non-empty, not project root) | Coverage metrics for paths | -| `status` | — | Store status (like CLI `status`) | -| `drafts` | optional `limit` | Draft inbox (compact by default) | -| `trajectory_status` | — | Trajectory projection run metadata | -| `trajectory_search` | `query`; optional `filters.include_routine` | Search stored trajectories | -| `trajectory_get` | `record_id` (trajectory id) | One trajectory + steps (always full) | -| `trajectory_anomalies` | optional `filters.include_routine` | Detected trajectory contract anomalies | -| `trajectory_agents` | optional `filters.include_routine` | Aggregate quality/outcomes by exact agent label | -| `trajectory_dashboard` | optional `filters.include_routine` | Combined status, agent, and anomaly view | - -List modes (`search`, `stale`, `drafts`, scoped `get_relevant_memory`) default -to **compact** payloads: statement preview, `statement_length`, no `payload`. -Use `mode=get` or `detail_level=full` for complete statements and payload. -`trajectory_get` is also always full regardless of requested detail level. - -Scoped retrieval keeps four typed lanes: - -| Lane | Meaning | `compact` | `full` | -|------------------|------------------------------------------------------|---------------------------------------------------------------|--------------------------------------------------------| -| `records[]` | Durable asserted/project memory | Preview; relevance-first bounded `subjects`; count/truncation | Full statement, subjects, record payload | -| `experiences[]` | Advisory patterns distilled from trajectories | Preview; agent-family count, multi-agent flag, dominant facet | Full agent facets and trajectory evidence ids | -| `trajectories[]` | Prior workflow examples/evidence | Bounded preview; no steps or `quality_contract` | Full contract/subjects; use `trajectory_get` for steps | -| `coverage` | Availability of record/trajectory/experience context | Same factual coverage metadata | Same factual coverage metadata | - -`subject_count` and `subjects_truncated=true` mean more linked subjects exist; -they do not downgrade or discard the record. Each compact trajectory retains -its own `patch_trail_summary`. The duplicate top-level `patch_trail_summary` is -full-only. - -Scoped compact `get_relevant_memory` responses use -`context_governance.mode="partial_enforce"` with -`evidence_policy="response_budget_with_exact_continuation"`. When a records, -trajectories, or experiences lane is omitted or compacted by the response -budget, `context_governance.omitted` points to the digest-bound -`continuation.lanes..page.cursor`. Fetch that exact tail with -`get_memory_projection_page(root, cursor)`. Full-detail retrieval and -continuation pages stay in `mode="observe"` because they return the requested -complete object/page rather than applying a new packing policy. - -**Filters** (`filters` object): - -| Key | Values | Notes | -|---------------|--------------------------|---------------------------------------| -| `types` | list of record types | e.g. `["contract_note", "risk_note"]` | -| `statuses` | list of statuses | e.g. `["active"]` | -| `confidences` | list of confidences | e.g. `["verified"]` | -| `match_mode` | `any` (default) or `all` | **search mode only** — token matching | - -CLI equivalent: `codeclone memory search QUERY --match any|all`. - -### Write tools (draft layer) - -#### `manage_engineering_memory` - -| `action` | Required params | Effect | -|------------------------------|------------------------------------------------|------------------------------------------------------------| -| `refresh_from_run` | optional `run_id` (defaults to latest MCP run) | Force ingest from MCP run report | -| `rebuild_semantic_index` | (none) | Rebuild LanceDB sidecar when `memory.semantic.enabled` | -| `rebuild_trajectories` | (none) | Rebuild trajectory projections from audit event core | -| `enqueue_projection_rebuild` | (none) | Queue trajectory + semantic + Experience projection job | -| `projection_rebuild_status` | (none) | Latest projection job status | -| `run_projection_jobs_once` | (none) | Run one queued projection job inline | -| `record_candidate` | `record_type`, `statement`, **`subject_path`** | Creates **draft** record | -| `promote_experience` | `experience_id` | Convert advisory Experience into human-reviewable draft | -| `validate_claims` | `text` | Memory-layer claim guard (warnings/errors) | -| `propose_from_receipt` | optional `text`, `intent_id` | Draft proposals from finish-like payload (atomic fallback) | - -IDE channel only (VS Code launches MCP with `--ide-governance-channel`): - -| `action` | Purpose | -|---------------------------|---------------------------------------------------------| -| `register_ide_governance` | Bind session HMAC key + client attestation | -| `prepare_governance` | Issue ticket + nonce + `statement_digest` (protocol v2) | -| `commit_governance` | Human confirm with HMAC proof → approve/reject/archive | - -Agent calls to `approve`, `reject`, or `archive` return `governance_mode_unavailable` -with `next_step` pointing to the VS Code Memory view. Humans may also use -`codeclone memory approve|reject|archive --i-know-what-im-doing` (CLI break-glass). - -#### `finish_controlled_change(propose_memory=true)` - -On **accepted** or **accepted_with_external_changes** finish: - -- proposes draft memory candidates from changed scope, claims, review text -- marks scope-linked **active** records stale -- returns `memory_candidates`, `memory_staleness`, `memory_coverage_delta` -- when `memory.projection_rebuild_policy` is not `off` and the environment is - not CI, may enqueue a projection rebuild job (`projection_rebuild` in the - finish payload — trajectory, semantic, and Experience projections) - -This is the preferred post-edit memory update path when using the workflow -tools. - -### Help topic - -`help(topic="engineering_memory")` — compact agent playbook summary. - -Trajectory analytics and Experience semantics are specified in -[Trajectory quality and passport](trajectory-quality-and-passport.md) and -[Experience Layer](experience-layer.md). - -Refs: - -- `codeclone/surfaces/mcp/server.py` -- `codeclone/surfaces/mcp/messages/help_topics.py` -- `codeclone/surfaces/mcp/_session_workflow_mixin.py` (finish hook) - ---- diff --git a/docs/book/13-engineering-memory/projection-jobs.md b/docs/book/13-engineering-memory/projection-jobs.md deleted file mode 100644 index ff0f454b..00000000 --- a/docs/book/13-engineering-memory/projection-jobs.md +++ /dev/null @@ -1,60 +0,0 @@ -### Projection rebuild jobs (schema 1.3+) - -Trajectory, semantic, and Experience projections can be rebuilt asynchronously -via a coalesced job row in Engineering Memory SQLite -(`memory_projection_jobs`). The worker rebuilds trajectories first, refreshes -the semantic sidecar, then distills Experiences from the resulting trajectory -corpus. -Default policy is **`off`**; opt in with: - -```toml -[tool.codeclone.memory] -projection_rebuild_policy = "enqueue_when_stale" # off | enqueue_when_stale -``` - -| Surface | Command / action | -|-------------------|-----------------------------------------------------------------------------------| -| CLI status | `codeclone memory jobs status --root .` | -| CLI enqueue | `codeclone memory jobs enqueue --root . [--force] [--no-spawn]` | -| CLI worker | `codeclone memory jobs run-once --root .` | -| MCP enqueue | `manage_engineering_memory(action=enqueue_projection_rebuild)` | -| MCP status | `manage_engineering_memory(action=projection_rebuild_status)` | -| MCP worker | `manage_engineering_memory(action=run_projection_jobs_once)` | -| MCP auto (finish) | When policy ≠ `off`, accepted `finish_controlled_change` enqueues + spawns worker | - -Jobs never run in CI environments (`CI`, `GITHUB_ACTIONS`, …). Sync rebuild -escape hatches remain: `rebuild_trajectories` / `rebuild_semantic_index`. - -## Queue and worker contract - -```mermaid -flowchart LR - A["Accepted finish"] --> B["Compute projection stimulus"] - B --> C{"Stale?"} - C -- "no" --> D["No enqueue"] - C -- "yes" --> E["Coalesce pending job"] - E --> F["Detached or inline worker"] - F --> G["Trajectory projection"] - G --> H["Semantic sidecar"] - H --> I["Experience distillation"] - I --> J["Persist result / watermark"] -``` - -The stimulus includes repository digest, projection version and enablement, -audit event-core counts/watermarks, and active memory-record counts. Pending -work for the same project is coalesced instead of duplicated. - -The job store claims work with an immediate SQLite transaction and permits one -running job per project. Dead-worker and timeout states are reclaimed as -failed before new work is claimed. Trajectory rebuild is incremental when its -stored projection version and audit watermark are compatible; otherwise it -falls back to a full rebuild. Semantic projection may hash-skip unchanged -sources. - -Job states are `pending`, `running`, `done`, `failed`, and `skipped`. -`run-once` returns `nothing_to_do` when the queue is empty. Worker results and -bounded errors remain job metadata; they do not alter canonical analysis. - -Platform Observability can correlate accepted finish, worker spawn, and worker -execution without changing the queue contract. See -[Platform Observability](../26-platform-observability.md). diff --git a/docs/book/13-engineering-memory/scope-and-invariants.md b/docs/book/13-engineering-memory/scope-and-invariants.md deleted file mode 100644 index b80a0b81..00000000 --- a/docs/book/13-engineering-memory/scope-and-invariants.md +++ /dev/null @@ -1,98 +0,0 @@ -## Integration with change control - -Memory complements — does not replace — the Structural Change Controller -([12-structural-change-controller/index.md](../12-structural-change-controller/index.md)): - -```mermaid -graph LR - CC[Change Controller] -->|scope, blast, verify| Edit[Scoped edit] - EM[Engineering Memory] -->|context, drafts| Edit - Edit --> CC - CC -->|propose_memory| EM - style CC stroke: #6366f1 - style EM stroke: #059669 -``` - -| Controller fact | Memory fact | -|--------------------------------|-------------------------------------| -| `do_not_touch` — hard boundary | `risk_note` — informational hotspot | -| Patch verify `accepted` | `change_rationale` draft proposal | -| Blast radius dependents | `module_role` inventory link | - ---- - -## Scope and token hygiene - -Engineering Memory stores **short, evidence-linked cards** — not chat transcripts -or project-wide dumps. - -| Rule | Contract | -|----------------------|--------------------------------------------------------------------------------------------------------------------------| -| Root scope forbidden | No `scope=["."]`, `path="."`, empty scope for `coverage`, or repo root as subject | -| Scoped retrieval | `get_relevant_memory` requires `scope`, `intent_id`, or `symbols`; use `status`/`search` for orientation | -| Compact lists | Default `detail_level=compact`: statement preview + `statement_length`; full text via `mode=get` or `detail_level=full` | -| Agent writes | `record_candidate` requires `subject_path`; target ≤300 chars, soft warn >500, hard reject >1000 (`max_statement_chars`) | -| One fact per card | Compress observations before write; store details in receipt/spec/docs | - ---- - -## Invariants (MUST) - -- Memory store path defaults under `.codeclone/memory/` — not baseline or analysis cache. -- Init ingest is deterministic given identical report + git inputs. -- MCP memory tools do not mutate baselines, analysis cache, canonical reports, or source files. Agent-visible writes - create **draft** records only (`record_candidate`, finish `propose_memory`, atomic `propose_from_receipt`). System - actions include `refresh_from_run`, semantic/trajectory/projection rebuild jobs, and finish-side staleness updates. - Human approve/reject/archive: VS Code Memory view **or** - `codeclone memory approve|reject|archive --i-know-what-im-doing` (optional - `--by NAME`; not MCP agent tools). -- Subject rows deduplicated in retrieval payloads (one row per logical subject key). -- FTS rebuilt after init/refresh ingest completes. -- Schema migration is forward-only through `schema_migrate.py`. - ---- - -## Failure modes - -| Condition | Behavior | -|----------------------------|-------------------------------------------------------------| -| DB missing, policy `off` | MCP error: run `refresh_from_run` or CLI init | -| DB missing, default policy | Auto bootstrap on `get_relevant_memory` when MCP run exists | -| No MCP run for sync | Auto sync skipped; DB missing → contract error | -| At `max_candidates` | `record_candidate` raises capacity error | -| At `max_records` | Init upsert skips or rejects per store policy | -| No cached report on init | Init runs analysis or fails with clear message | -| Git unavailable | Init proceeds; git evidence/hotspots skipped | -| Root scope path | `MemoryContractError`: use status/search for orientation | -| Unscoped retrieval | `get_relevant_memory` rejected without scope/intent/symbols | -| Statement too long | `record_candidate` rejected above `max_statement_chars` | - ---- - -## Locked by tests - -- `tests/test_memory_mcp_sync.py` -- `tests/test_memory_store.py` -- `tests/test_memory_search.py` -- `tests/test_memory_retrieval.py` -- `tests/test_memory_staleness.py` -- `tests/test_memory_governance.py` -- `tests/test_memory_cli.py` -- `tests/test_mcp_service.py` (memory tool wiring) -- `tests/test_mcp_server.py` (tool registration) -- `tests/test_semantic_projection.py`, `tests/test_semantic_rebuild.py`, - `tests/test_semantic_chunking.py`, `tests/test_semantic_projection_probe.py`, - `tests/test_semantic_embedding.py`, `tests/test_semantic_index_null.py` -- `tests/test_cli_memory_semantic.py`, `tests/test_mcp_memory_semantic.py` -- `tests/test_config_semantic.py`, `tests/test_semantic_determinism_gate.py` -- `tests/test_controller_insights.py` (shared session/audit payloads) - ---- - -## Related docs - -- [MCP Interface](../25-mcp-interface/index.md) — tool catalog -- [Structural Change Controller](../12-structural-change-controller/index.md) — intent workflow -- [Claim Guard](../14-claim-guard.md) — finish claims validation -- [CLI](../11-cli.md) — `codeclone memory` commands -- [MCP for AI Agents](../../guide/mcp/README.md) — agent-oriented narrative diff --git a/docs/book/13-engineering-memory/search-fts.md b/docs/book/13-engineering-memory/search-fts.md deleted file mode 100644 index 5ccbb759..00000000 --- a/docs/book/13-engineering-memory/search-fts.md +++ /dev/null @@ -1,20 +0,0 @@ -## Search semantics (schema 1.1) - -### FTS (always available) - -FTS5 index (`memory_fts`) indexes record statements and metadata. - -| `match_mode` | Behavior | -|-----------------|-----------------------------------------------| -| `any` (default) | Match records containing **any** query token | -| `all` | Match records containing **all** query tokens | - -Document links display as normalized headings, e.g. -`AGENTS.md · §16 · Change routing → AGENTS.md`. - -Refs: - -- `codeclone/memory/search_index.py` -- `codeclone/memory/display.py` - -Semantic retrieval: [search-semantic.md](search-semantic.md). diff --git a/docs/book/13-engineering-memory/search-semantic.md b/docs/book/13-engineering-memory/search-semantic.md deleted file mode 100644 index 372e5fa8..00000000 --- a/docs/book/13-engineering-memory/search-semantic.md +++ /dev/null @@ -1,164 +0,0 @@ - - -# Optional Semantic Retrieval - -Semantic search is **opt-in** and **off by default** (`enabled = false` in -`codeclone/config/memory_defaults.py`). It does not replace FTS: keyword search -still runs first; when the index is available, vector proximity **merges extra -candidates** and adjusts ranking (`semantic_proximity * 0.3` in -`codeclone/memory/retrieval/ranking.py`). - -```mermaid -flowchart LR - Q[Query text] --> FTS[FTS5 memory_fts] - Q --> EMB[EmbeddingProvider.embed_query] - DOCS[Index projections] --> DOCEMB[EmbeddingProvider.embed_documents] - EMB --> VEC[LanceDB k-NN] - DOCEMB --> INDEX[LanceDB rebuild] - INDEX --> VEC - FTS --> MERGE[Candidate union] - VEC --> MERGE - MERGE --> RANK[Rank + semantic weight] - RANK --> OUT[search payload + semantic block] -``` - -**Prerequisites (all required for `semantic.used: true`):** - -1. `memory.semantic.enabled = true` in effective config. -2. Optional vector backend installed: `pip install 'codeclone[semantic-lancedb]'`. - For semantic-quality local embeddings, install `codeclone[semantic-local]` - instead (or combine `semantic-lancedb` + `semantic-fastembed`). -3. Index built at `index_path` (default - `.codeclone/memory/semantic_index.lance`) via - `manage_engineering_memory(action="rebuild_semantic_index")` (MCP agents) or - `codeclone memory semantic rebuild` (CLI/CI). - -Minimal local semantic-quality setup: - -```bash -pip install 'codeclone[semantic-local]' -``` - -```toml -[tool.codeclone.memory.semantic] -enabled = true -embedding_provider = "fastembed" -allow_model_download = true # or pre-populate embedding_cache_dir and keep false -``` - -```bash -codeclone memory init --root . -# Agents (MCP): manage_engineering_memory(action=rebuild_semantic_index) -codeclone memory semantic rebuild --root . -codeclone memory semantic search "recover after MCP restart" --root . -codeclone memory search "recover after MCP restart" --semantic --root . -``` - -Use `codeclone[semantic-lancedb]` only when you intentionally want the derived -sidecar with the deterministic diagnostic provider; it is stable, but not -semantic-quality recall. - -**Degraded states (never crash read paths):** - -| Condition | Index behavior | Search `semantic` block | -|--------------------------------|----------------------------------------------------------------|---------------------------------------------------| -| `enabled=false` | `NullSemanticIndex` | `used: false`, `reason: disabled` | -| Enabled, index missing | `UnavailableSemanticIndex` (`not_built`) | FTS only; `used: false` | -| Enabled, LanceDB extra missing | `UnavailableSemanticIndex` (`lancedb_not_installed`) | FTS only; explicit `semantic rebuild` fails clear | -| Provider unavailable | `semantic_reason` set (e.g. FastEmbed extra/model unavailable) | FTS only | - -The index is a **derived, rebuildable sidecar** — not updated on the memory -write hot path. Rebuild is idempotent on projection `text_hash` -(`codeclone/memory/semantic/rebuild.py`). - -#### Embedding providers - -| Provider | Status | Meaning | -|------------------------|-------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `diagnostic` (default) | Always available | `DeterministicHashEmbeddingProvider`: sha256-derived unit vectors. **Stable across runs, not semantic-quality recall.** CLI prints an advisory when `provider=diagnostic`. | -| `fastembed` | Optional: `codeclone[semantic-fastembed]` | Local ONNX embeddings through FastEmbed. Default model is `BAAI/bge-small-en-v1.5` (`384` dimensions). Query text uses a `query:` prefix; indexed records use `passage:`. Model download is disabled unless `allow_model_download=true`, so air-gapped installs can pre-populate `embedding_cache_dir`. | -| `local_model` | Raises `MemorySemanticUnavailableError` | Reserved compatibility literal; use `fastembed` for community local semantic search. | -| `api` | Raises `MemorySemanticUnavailableError` | Reserved for remote/API providers. | - -Model id for diagnostic: `diagnostic-hash-v1` -(`codeclone/memory/embedding/__init__.py`). -Model id for FastEmbed: `fastembed:`. - -#### What gets indexed - -**Memory record types** (`INDEXED_MEMORY_TYPES` in -`codeclone/memory/semantic/projection.py`): - -`contract_note`, `change_rationale`, `risk_note`, `architecture_decision`, -`contradiction_note`, `protocol_rule`, `human_note`. - -**Not** semantically indexed (served by exact subject / path match instead): -`module_role`, `test_anchor`, `document_link`, `public_surface`, `stale_marker`. - -**Audit incidents** when `index_audit=true` (default) and `audit_enabled=true` -with a readable audit DB — projected from **`controller_events.summary` only** -(never `payload_json`). Event types: - -`intent.declared`, `patch_contract.violated`, `workspace.conflict_detected`, -`baseline_abuse.detected`, `claim_validation.violated`, `review_receipt.created`. - -Empty audit summaries are skipped. - -**Trajectory passports** when trajectories are enabled — projected via -`project_trajectory()` from bounded trajectory fields (summary, outcome, subjects; -see `codeclone/memory/semantic/projection.py`). Long texts become multiple index -units under format **`2`** (below). - -#### Surfaces - -| Surface | Semantic flag | -|----------------------------------------------------------------|--------------------------------------------------------------| -| `query_engineering_memory(mode=search, semantic=true)` | MCP | -| `manage_engineering_memory(action=rebuild_semantic_index)` | MCP (build sidecar) | -| `codeclone memory search --semantic` | CLI | -| `codeclone memory semantic search` | CLI (requires built index) | -| `codeclone memory semantic rebuild` | CLI (build sidecar) | -| `codeclone memory semantic probe [--exact-tokens] [--json]` | CLI — per-lane projection length stats | -| VS Code `codeclone.memory.searchSemantic` (default **`true`**) | Passes MCP `semantic` on IDE search; server opt-in unchanged | -| `get_relevant_memory` | **No** semantic parameter (scoped ranking only) | - -Search responses include a top-level **`semantic`** object: - -| Field | When set | -|-----------------|-------------------------------------------------------| -| `used` | `true` only when index + provider + rebuild succeeded | -| `backend` | e.g. `lancedb` from index status | -| `provider` | Config label (`diagnostic`, …) | -| `model` | Provider `model_id` when used | -| `index_version` | `SEMANTIC_INDEX_FORMAT_VERSION` when used | -| `reason` | Degrade reason when `used` is false | - -`codeclone memory semantic probe` emits per-lane stats under -`lanes.{memory,audit,trajectory}`. Default estimator is cheap planning; pass -`--exact-tokens` to load the FastEmbed tokenizer and measure passage-prefixed -texts that rebuild would embed. With `--exact-tokens`, trajectory uses the same -chunker as rebuild: `lanes.trajectory.chunking` reports -`{source_documents, index_units, multi_chunk_sources}` and `documents` counts -index units (not raw projections). Lane-level `overflow_examples` (up to five) -list index units still above the model window. Chunking reserves passage prefix -and model special tokens; rebuild fails closed with -`SemanticChunkingInvariantError` when a chunk cannot be proven to fit. - -Format **`2`** indexes long trajectory projections as multiple chunk rows linked -by `parent_id` (single-chunk trajectories keep the trajectory id as `id`). -Hybrid search oversamples trajectory `k × TRAJECTORY_SEARCH_OVERSAMPLE` (4), -collapses chunk hits to the best score per trajectory, and sets -`matched_chunk_index` / `matched_chunk_count` on the returned trajectory hit. - -When semantic hits audit rows, `payload.audit_events` lists hydrated incidents -(event type, bounded summary preview, score) alongside memory records. - -Refs: - -- `codeclone/memory/retrieval/service.py:_handle_semantic_search_mode` -- `codeclone/memory/semantic/__init__.py:resolve_semantic_index` -- `tests/test_semantic_projection.py`, `tests/test_semantic_rebuild.py`, - `tests/test_semantic_chunking.py`, `tests/test_semantic_projection_probe.py`, - `tests/test_mcp_memory_semantic.py`, `tests/test_cli_memory_semantic.py` - ---- diff --git a/docs/book/13-engineering-memory/staleness-and-anchors.md b/docs/book/13-engineering-memory/staleness-and-anchors.md deleted file mode 100644 index 94bcdfa8..00000000 --- a/docs/book/13-engineering-memory/staleness-and-anchors.md +++ /dev/null @@ -1,58 +0,0 @@ -## Staleness and anchor durability - -Records with a git anchor (`created_at_commit` + `code_fingerprint`) are judged -by **drift from that anchor**, not by whether the subject appears in the current -analysis inventory. Non-Python subjects (`.md`, `.toml`, `.js`, …) therefore -stay `active` across refresh when their on-disk bytes are unchanged. - -| Anchor vs `HEAD` | Status transition | -|----------------------------|-------------------------------------------------------------| -| Fingerprint matches anchor | `active` (or reactivated from `historical` / drift `stale`) | -| Fingerprint differs | `stale` (`subject_fingerprint_drift`) | -| Subject file absent | `historical` (preserved, queryable) | - -A record is **anchored** only when both `created_at_commit` and `code_fingerprint` -are present at write time. `record_candidate` sets git fields only when the -subject fingerprint resolves (commit without fingerprint is treated as -unanchored). Unanchored records skip anchor drift; system-ingest signals below -still apply. - -Only `draft` records skip refresh drift evaluation. `human`-origin and -human-approved records follow the same anchor table — approval does not exempt -a record from honest content drift. - -```mermaid -flowchart TD - subgraph Anchor["anchor drift (refresh)"] - A1[fingerprint match] - A2[subject_fingerprint_drift] - A3[subject deleted] - end - - subgraph Refresh["init --refresh (system ingest)"] - R1[missing_from_refresh] - R2[evidence_digest_mismatch] - R3[refresh_content_contradiction] - R4[report_digest_shift] - end - - subgraph Scope["accepted finish"] - S1[scope_files_changed] - end - - A1 --> ACT[(status = active)] - A2 --> ST[(status = stale)] - A3 --> HIST[(status = historical)] - Refresh --> ST - Scope --> ST - ST --> RE[Excluded from default retrieval] - HIST --> RET[Included in default retrieval] - RE --> RA[Reactivate when anchor fingerprint matches] - HIST --> RA -``` - -`historical` is a durable resting state — vacuum never auto-deletes it. -Stale records remain for audit but are **excluded** from `get_relevant_memory` -and default search unless explicitly included. - ---- diff --git a/docs/book/13-engineering-memory/trajectory-and-patch-trail.md b/docs/book/13-engineering-memory/trajectory-and-patch-trail.md deleted file mode 100644 index 7a88508e..00000000 --- a/docs/book/13-engineering-memory/trajectory-and-patch-trail.md +++ /dev/null @@ -1,200 +0,0 @@ -## Trajectory memory {#trajectory-memory} - -Trajectory memory is a **deterministic process narrative** derived from the audit -event core. It complements governed memory cards: cards hold durable repository -facts; trajectories hold bounded edit-cycle timelines (declare → check → verify → -receipt → optional Patch Trail). - -!!! note "Not authorization" - `trajectories[]` and export JSONL are **read-only forensics**. They do not - expand scope, approve memory records, override structural findings, or - substitute for `finish_controlled_change`. - -!!! note "Projection timing" - Trajectory **rows** are built by `rebuild_trajectories` (CLI or MCP) from - the audit event core — not inline on every finish. Finish **does** compute - Patch Trail and may **enqueue** a projection rebuild job when - `memory.projection_rebuild_policy` is not `off` (skipped in CI). Run - `codeclone memory jobs run-once` or wait for the worker to materialize - trajectories after audit-enabled finishes. - - ```bash - codeclone memory trajectory rebuild --root . - ``` - - MCP agents: `manage_engineering_memory(action=rebuild_trajectories)`. - -### Architecture - -```mermaid -flowchart TB - subgraph Finish["Change controller (MCP finish)"] - FIN[finish_controlled_change] - PT[compute_patch_trail] - AUDE[patch_trail.computed audit event] - FIN --> PT --> AUDE - end - - subgraph Audit["Audit SQLite"] - EC[event_core_json per workflow] - end - - subgraph Project["Trajectory rebuild"] - PRJ[projector trajectory-v3] - PTP[patch_trail_projector] - QLT[quality + anomaly analytics] - SUP[supersede stale rows] - end - - subgraph Store["Engineering Memory SQLite"] - TRJ[memory_trajectories + steps] - PTR[memory_trajectory_patch_trails] - JOB[memory_projection_jobs] - end - - subgraph Read["Read surfaces"] - GR[get_relevant_memory.trajectories] - QEM[query_engineering_memory trajectory_*] - DASH[dashboard + agents + anomalies] - EXP[JSONL export v2] - end - - AUDE --> EC - EC --> PRJ - EC --> PTP - PRJ --> QLT - PRJ --> TRJ - PTP --> PTR - PRJ --> SUP - TRJ --> GR - TRJ --> QEM - QLT --> DASH - PTR --> GR - PTR --> QEM - TRJ --> EXP - PTR --> EXP - JOB -.->|enqueue_when_stale| Project -``` - -Module ownership: - -| Module | Role | -|--------------------------------------------------------|----------------------------------------------------------------| -| `codeclone/audit/events.py` | Bounded `event_core_json`; `patch_trail.computed` compaction | -| `codeclone/memory/trajectory/patch_trail.py` | Finish-time Patch Trail compute (`PATCH_TRAIL_SCHEMA_VERSION`) | -| `codeclone/memory/trajectory/patch_trail_projector.py` | Rebuild Patch Trail from audit event cores | -| `codeclone/memory/trajectory/projector.py` | Deterministic trajectory projection (`trajectory-v3`) | -| `codeclone/memory/trajectory/quality.py` | Contract-quality and separate complexity scoring | -| `codeclone/memory/trajectory/analytics.py` | Dashboard, anomaly, and per-agent aggregates | -| `codeclone/memory/trajectory/store.py` | SQLite persistence, supersede, rebuild orchestration | -| `codeclone/memory/trajectory/retrieval.py` | Scoped ranking + `patch_trail_summary` | -| `codeclone/memory/trajectory/export_context.py` | Export v2 context: precedents, citations, scope paths | -| `codeclone/memory/trajectory/export.py` | Local JSONL export | -| `codeclone/memory/jobs/store.py` | Projection job queue + worker claim | -| `codeclone/memory/retrieval/service.py` | MCP/CLI query router | - -### Config (`[tool.codeclone.memory]`) - -| Key | Default | Meaning | -|----------------------------------------------|------------|----------------------------------------------------| -| `trajectories_enabled` | `true` | Gate rebuild/list/search | -| `trajectory_retention_days` | `365` | Retention hint for vacuum tooling | -| `projection_rebuild_policy` | `off` | `off` \| `enqueue_when_stale` — async rebuild jobs | -| `projection_rebuild_running_timeout_seconds` | `1800` | Stale running job recovery | -| `projection_rebuild_spawn_worker` | `true` | Spawn worker subprocess on finish enqueue | -| `trajectory_export_enabled` | `false` | Gate JSONL export | -| `trajectory_export_include_payloads` | `false` | Include compact step text in export rows | -| `trajectory_export_max_record_bytes` | `65536` | Per-row cap | -| `trajectory_export_max_file_bytes` | `10485760` | Output file cap | - -Requires **`audit_enabled=true`** and a readable audit DB for rebuild input. - -### CLI - -```bash -codeclone memory trajectory status --root . -codeclone memory trajectory rebuild --root . -codeclone memory trajectory list --root . --limit 20 -codeclone memory trajectory show TRAJ_ID --root . -codeclone memory trajectory search "recover stale intent" --root . -codeclone memory trajectory export \ - --root . \ - --profile agent-change-control-v1 \ - --out .codeclone/trajectories.jsonl \ - --force -``` - -Export profiles (schema contracts): `agent-change-control-v1`, -`agent-memory-retrieval-v1`, `agent-recovery-v1`, `agent-security-hardening-v1`. -Export row schema version is **`2`** (`TRAJECTORY_EXPORT_SCHEMA_VERSION`). Each row -includes: - -| Field | Source | -|---------------------------------|-----------------------------------------------------------------------------------------------------------------------------------| -| `context.memory_precedents` | Active memory records overlapping trajectory/path scope | -| `context.trajectory_precedents` | Prior workflows with path overlap | -| `citations` | Claim-validation event cores + report digests | -| `scope.paths` | Resolved from Patch Trail / declare / check event cores | -| `patch_trail_summary` | When persisted in `memory_trajectory_patch_trails` | -| `projection_version` | `trajectory-v1`, `trajectory-v2`, or active `trajectory-v3`; v2 adds Patch Trail digest and v3 adds quality score + agent subject | - -Rebuild supersedes older projection rows for the same workflow (one canonical -trajectory per `workflow_id` in export). Legacy audit rows without path facts in -frozen event core are supplemented deterministically from stored audit payloads -during projection. Changing profile shape requires a profile version bump. - -### MCP retrieval - -`get_relevant_memory` adds **`trajectories[]`** beside **`records[]`** when path -subjects match (declare `scope_paths`, check `changed_files`, or -`untouched_in_declared`). When a stored Patch Trail exists for a matched -trajectory, each preview includes **`patch_trail_summary`** (counts, digest, -verification status). With `detail_level=full`, the top-ranked trajectory also -surfaces **`patch_trail_summary`** at the response root. Compact retrieval omits -that root duplicate; the summary remains on the trajectory preview. - -`query_engineering_memory(mode=trajectory_get)` returns **`patch_trail`** when -persisted and always uses full detail, including **`quality_contract`**. -List/search previews retain headline quality, complexity, and anomaly counts. - -Trajectory rebuild (`memory trajectory rebuild` / MCP -`manage_engineering_memory(action=rebuild_trajectories)`) synthesizes Patch Trail -from audit event cores (`intent.declared`, `intent.checked`, verify events) and -stores it in **`memory_trajectory_patch_trails`**. Trajectory digest -(`trajectory-v2` and later) incorporates **`patch_trail_digest`** when present. -The active **`trajectory-v3`** digest additionally incorporates the persisted -quality score and records the primary agent as an `agent` subject. - -Scoped ranking adds a small boost when query scope paths intersect -**`untouched_in_declared`** paths from the stored Patch Trail. - -`query_engineering_memory` modes: - -| Mode | Scope | Notes | -|------------------------|---------------|-------------------------------------------------------| -| `trajectory_status` | project | Projection run manifest | -| `trajectory_search` | query text | Requires `query`; excludes `run:*` routine by default | -| `trajectory_get` | trajectory id | `record_id` = trajectory id | -| `trajectory_anomalies` | project | Contract anomalies, optionally including routine runs | -| `trajectory_agents` | project | Outcome and quality aggregates by exact agent label | -| `trajectory_dashboard` | project | Combined status, agent, and anomaly payload | - -Filter: `filters.include_routine=true` on `trajectory_search` includes single-event -`run:*` analysis workflows. - -Evidence kind **`trajectory`** links memory records to trajectories; human approve -still required for agent drafts. - -See [Trajectory labels](trajectory-labels.md) for labels and -[Trajectory quality and passport](trajectory-quality-and-passport.md) for -scoring, anomalies, dashboards, and IDE passport semantics. - -### Enterprise boundary (export) - -Community CodeClone writes **local JSONL only** — no remote API, upload, or -training pipeline. Corporate policy packs, signing, approval workflows, and dataset -registry are out of scope unless explicitly requested. - -Refs: `codeclone/memory/trajectory/rebuild_workflow.py`, -`codeclone/memory/trajectory/export.py`, `tests/test_memory_trajectory_*.py`, -`tests/test_audit_event_core_v2.py`. diff --git a/docs/book/13-engineering-memory/trajectory-labels.md b/docs/book/13-engineering-memory/trajectory-labels.md deleted file mode 100644 index 7e5c5f8a..00000000 --- a/docs/book/13-engineering-memory/trajectory-labels.md +++ /dev/null @@ -1,34 +0,0 @@ -### Trajectory labels and step names - -Each projected trajectory carries a sorted **`labels`** list in -`memory_trajectories.labels_json`. Labels are deterministic tags derived from audit -event cores — not free-form agent text. - -| Label | When set | -|-----------------------------|---------------------------------------------------------------------| -| `change_control_workflow` | Any change-controller event (`intent.*`, `patch_contract.*`, …) | -| `verified_finish` | `patch_contract.verified` with accepted outcome | -| `scope_clean` | `intent.checked` with status `clean` or `expanded` | -| `scope_expanded` | `intent.expanded` present | -| `queue_used` | `intent.queued` present | -| `patch_trail_recorded` | `patch_trail.computed` present | -| `receipt_issued` | `review_receipt.created` present | -| `claim_validated` | `claim_validation.completed` present | -| `analysis_observed` | Standalone `analysis.completed` workflow (no change-control events) | -| `memory_used` | `manage_engineering_memory` tool use in the stream | -| `recovered` | `intent.promoted` (queue recovery) | -| `foreign_conflict_seen` | Workspace conflict | -| `hook_blocked` | Hook surface warn/error | -| `claim_guard_failed` | Claim validation violated | -| `baseline_abuse_detected` | Baseline abuse | -| `external_changes_accepted` | Finish accepted with external changes | - -Routine successful edit cycles should carry **`change_control_workflow`** and -**`verified_finish`** at minimum. Empty `labels` indicates a projection bug or a -legacy row that needs `memory trajectory rebuild`. - -Each step in MCP `trajectory_get` includes **`step_label`** — a human-readable name -from `codeclone/memory/trajectory/step_labels.py` (event catalog + status). CLI -`memory trajectory show` prints labels and step labels. - -See also: [Trajectory memory](trajectory-and-patch-trail.md). diff --git a/docs/book/13-engineering-memory/trajectory-quality-and-passport.md b/docs/book/13-engineering-memory/trajectory-quality-and-passport.md deleted file mode 100644 index 8edc8dc2..00000000 --- a/docs/book/13-engineering-memory/trajectory-quality-and-passport.md +++ /dev/null @@ -1,124 +0,0 @@ -# Trajectory Quality and Passport - - - -Trajectory projection version `3` adds explainable quality, complexity, -anomaly, agent, and dashboard views. These are derived diagnostics over -canonical audit evidence, not analysis findings or edit permissions. - -For the event-to-trajectory projection itself, see -[Trajectory and patch trail](trajectory-and-patch-trail.md). - -## Passport model - -```mermaid -flowchart TD - A["Canonical trajectory"] --> B["Outcome"] - A --> C["Verification"] - A --> D["Scope"] - A --> E["Incidents"] - A --> F["Anomalies"] - A --> G["Receipt"] - B --> H["Quality score = minimum component"] - C --> H - D --> H - E --> H - F --> H - G --> H - A --> I["Complexity score
separate, non-grade"] - H --> J["Trajectory passport"] - I --> J -``` - -The passport keeps quality and complexity separate: - -- quality answers how well the workflow satisfied its contract; -- complexity describes how much declared scope, event activity, and workflow - structure the trajectory contained. - -High complexity is not a defect and does not reduce quality by itself. - -## Quality score - -Quality score version `2` is the minimum of six components: - -| Component | Scoring | -|--------------|---------------------------------------------------------------------------------------------------| -| Outcome | accepted `100`, accepted external `85`, partial `55`, abandoned `40`, blocked `30`, violated `20` | -| Verification | accepted `100`, accepted external `85`, unverified `50`, violated/blocked `0`, not reached `40` | -| Scope | clean `100`, expanded `85`, partial `70`, violated `0` | -| Incidents | `max(0, 100 - 10 × incident_count)` | -| Anomalies | starts at `100`; error costs `12`, warning costs `5` | -| Receipt | change-control trajectory with receipt `100`, without `85`; non-change workflow `100` | - -When patch-trail verification is unavailable, the verification component falls -back to quality tier: verified `100`, corrected `90`, routine `85`, partial -`60`, incident `45`. - -The minimum-component rule makes the limiting evidence visible instead of -averaging a contract failure away. - -## Complexity score - -Complexity is: - -```text -min(100, - min(40, declared_scope_count * 2) - + min(30, event_count * 3) - + min(20, workflow_step_count * 2)) -``` - -Bands are `low < 35`, `moderate 35..69`, and `high >= 70`. - -## Anomalies - -The projection can emit: - -- outcome anomalies: violated, blocked, or abandoned; -- quality incidents and elevated incident count; -- incident labels such as baseline abuse, claim-guard failure, foreign - conflict, hook failure, or recovered state; -- incomplete change cycles or missing intent cleanup; -- scope violations; -- verification gaps. - -Anomalies are deterministic review cues. They are not repository findings. - -## Analytics surfaces - -Agent analytics group by the exact canonical `agent_label`, not an inferred -agent family. The dashboard combines projection status, agent aggregates, -anomalies, and recent trajectories. - -Routine `run:*` workflows and trajectories with quality tier `routine` are -excluded by default. Opt in via CLI `--include-routine` or MCP -`filters.include_routine=true` on `query_engineering_memory` trajectory modes -(`trajectory_search`, `trajectory_dashboard`, etc.) — not a top-level -`get_relevant_memory` MCP parameter. - -Available CLI commands: - -```bash -codeclone memory trajectory status --root . -codeclone memory trajectory rebuild --root . -codeclone memory trajectory list --root . -codeclone memory trajectory search QUERY --root . -codeclone memory trajectory show TRAJECTORY_ID --root . -codeclone memory trajectory agents --root . -codeclone memory trajectory anomalies --root . -codeclone memory trajectory dashboard --root . -codeclone memory trajectory export --root . \ - --profile agent-change-control-v1 \ - --out trajectories.jsonl -``` - -MCP modes are `trajectory_status`, `trajectory_search`, `trajectory_get`, -`trajectory_anomalies`, `trajectory_agents`, and `trajectory_dashboard`. -`trajectory_get` uses `record_id` as the trajectory ID and always returns full -detail. - -The VS Code extension exposes a dashboard, detail view, copyable dashboard -brief, and passport sections for quality, complexity, duration, events, steps, -incidents, evidence, patch trail, contract gates, and score calculations. See -[VS Code integration](../integrations/vs-code-extension.md). diff --git a/docs/book/13-engineering-memory/trust-and-lifecycle.md b/docs/book/13-engineering-memory/trust-and-lifecycle.md deleted file mode 100644 index 70795301..00000000 --- a/docs/book/13-engineering-memory/trust-and-lifecycle.md +++ /dev/null @@ -1,75 +0,0 @@ -## Trust boundaries - -```mermaid -flowchart LR - subgraph AgentCan["Agent (MCP)"] - R[Read ranked memory] - D[Write draft candidates] - V[Validate claims text] - P[Propose from receipt] - end - - subgraph HumanCI["Human / CI"] - I[memory init / refresh CLI] - A[approve / reject / archive
VS Code or CLI --i-know-what-im-doing] - end - - subgraph McpSync["MCP sync (policy-gated)"] - B[auto bootstrap on get_relevant_memory] - RF[refresh_from_run explicit] - end - -subgraph Never["Never via MCP"] -X1[Expand edit scope] -X2[Override findings] -X3[Mutate baselines / cache / reports] -X4[Promote draft → active without human] -end - -AgentCan --> Store[(Memory DB)] -HumanCI --> Store -McpSync -->|ingest system records|Store -Never -.->|blocked|Store -``` - -| Action | Who | Resulting status | -|-----------------------------------------|---------------------------------------|--------------------------------------------| -| Init / refresh ingest | Human or CI (`codeclone memory init`) | `active` system records | -| Auto bootstrap / refresh from MCP run | MCP when `mcp_sync_policy` allows | `active` system records (same ingest path) | -| `refresh_from_run` | Agent MCP (explicit) | Force ingest from selected MCP run | -| `record_candidate` | Agent MCP | `draft` | -| `finish(propose_memory=true)` on accept | Agent MCP | `draft` proposals + staleness side effects | -| `approve` | Human CLI (`--i-know-what-im-doing`) or VS Code IDE channel | `active` + `verified`/`supported` | -| `reject` | Human CLI (`--i-know-what-im-doing`) or VS Code IDE channel | `rejected` | -| `archive` | Human CLI (`--i-know-what-im-doing`) or VS Code IDE channel | `archived` | -| Refresh detects drift | System on `init --refresh` | `stale` | -| Patch touches linked path | System on accepted finish | `stale` | - ---- - -## Record lifecycle - -```mermaid -stateDiagram-v2 - [*] --> draft: agent record_candidate\nfinish propose_memory - [*] --> active: init ingest\nhuman approve - draft --> active: human approve - draft --> rejected: human reject - active --> stale: refresh drift\nscope files changed - stale --> active: refresh reactivation\nhuman re-approve - active --> archived: human archive - stale --> archived: vacuum retention - rejected --> archived: vacuum retention - draft --> archived: vacuum retention -``` - -**Confidence** (`inferred` → `supported` → `verified`) and **origin** -(`system`, `agent`, `human`) are separate axes. Agents must treat `draft` and -`inferred` as non-authoritative. - -Default retrieval excludes `stale`. Keyword `search` excludes `draft` unless -`include_drafts=true`; scoped `get_relevant_memory` and `for_path` / -`for_symbol` include draft agent notes automatically so handoffs are visible. -Draft records remain non-authoritative. - ---- diff --git a/docs/book/14-claim-guard.md b/docs/book/14-claim-guard.md deleted file mode 100644 index de7133c6..00000000 --- a/docs/book/14-claim-guard.md +++ /dev/null @@ -1,180 +0,0 @@ - - -# 14. Claim Guard - -## Purpose - -Define the `validate_review_claims` MCP tool in the CodeClone `2.1` release -line. - -Claim guard keeps review text disciplined. It validates cited claims against -semantic flags already present in stored MCP runs. It does not perform -free-form NLP, source analysis, or fact checking. - ---- - -## Public surface - -| Artifact | Path | -|----------------|--------------------------------------------------------| -| MCP tool | `validate_review_claims` | -| Service method | `CodeCloneMCPService.validate_review_claims` | -| Session mixin | `codeclone/surfaces/mcp/_session_claim_guard_mixin.py` | -| Pure validator | `codeclone/surfaces/mcp/_claim_guard.py` | - ---- - -## Validation pipeline - -```mermaid -graph LR - T["Review text"] --> E["Extract citations
finding IDs, metric families"] - E --> W["Text window
±80 chars around citation"] - W --> P["Pattern checks
P-1 … P-5"] - P --> V{"Violations?"} - V -->|"yes"| INV["valid: false"] - V -->|"no"| OK["valid: true"] - - style INV fill:#fee2e2 - style OK fill:#f0fdf4 -``` - -The pipeline is fully deterministic: - -1. Resolve the stored run. -2. Index canonical and short finding IDs from the canonical report. -3. Read metric-family gate semantics from the metric registry. -4. Extract citations from the supplied text. -5. Check keyword patterns inside a bounded text window around each citation. - ---- - -## Parameters - -| Parameter | Type | Default | Meaning | -|----------------------|---------------|----------|-------------------------------------------------------------------------------------------------------------------------------------------| -| `text` | `str` | required | Markdown, plain text, or JSON string to validate | -| `run_id` | `str \| None` | latest | Stored MCP run whose report semantics are used | -| `require_citations` | `bool` | `true` | Warn when no known finding IDs or metric family names are cited | -| `patch_health_delta` | `int \| None` | omitted | Optional `health_after - health_before` from verify. When negative, flags regression-free or fully-clean claims even if verify `accepted` | - -!!! info "Text limits" - Text must be non-empty and at most `50,000` characters. - ---- - -## Contract - -The tool is **read-only**. It does not mutate source files, baselines, -reports, analysis cache, review markers, or change intents. - -### Response shape - -| Field | Type | Meaning | -|-----------------------|--------|--------------------------------------| -| `valid` | `bool` | `true` when no violations were found | -| `citations_found` | `int` | Number of recognized citations | -| `violations` | `list` | Deterministic overclaim records | -| `warnings` | `list` | Missing or unknown citations | -| `validated_citations` | `list` | Per-citation validity summary | - -Warnings do not make the response invalid. Only violations set -`valid=false`. - ---- - -## Patterns - -Five deterministic overclaim patterns, each checking keyword proximity -around cited finding IDs or metric family names. An additional -profile-aware warning detects structural claims on non-structural profiles. - -### P-1: Security surface overclaim - -Security Surfaces described as vulnerabilities or exploitability. -Security Surfaces are a **report-only boundary inventory** — they show -where security-relevant capabilities exist, not whether they are -exploitable. - -### P-2: Gate overclaim - -A report-only metric family described as a CI failure or blocking gate. -Not all metric families participate in gating; report-only families are -informational. - -### P-3: Regression overclaim - -A finding with `novelty="known"` described as new relative to the baseline. -Known findings are accepted baseline debt. A patch-local introduction or -reintroduction claim requires before-run to after-run verification evidence; -single-run baseline novelty is insufficient. - -### P-4: Dead code certainty overclaim - -Dead-code certainty claimed despite runtime reachability evidence. When -framework reachability patterns match a dead-code candidate, certainty -claims are invalid. - -### P-5: Fix overclaim - -A finding claimed as fixed or resolved before a post-patch run is -available. Without a comparison run, fix claims cannot be verified. - -### Structural scope warning - -When the verification profile is not `python_structural`, the guard emits a -`structural_checks_not_applicable` warning if the review text contains keywords -suggesting structural checks were performed (e.g. "no regressions", -"all checks passed", "structural verification"). This is a warning, not a -violation — it does not set `valid=false`. - -### Health regression overclaim - -When `patch_health_delta` is negative (from `check_patch_contract` verify or -`finish_controlled_change` → `verification.structural_delta.health_delta`), the -guard emits a `health_regression_overclaim` **warning** and a matching -**violation** if the text contains structural-scope keywords such as -"no regressions", "regression-free", or "all checks passed". Verify may still -return `accepted`; negative health delta is advisory context, not an automatic -verify failure. `finish_controlled_change` also surfaces -`health_regression_advisory` on accepted verify when delta is negative. - -Pass `patch_health_delta` explicitly when using the atomic workflow -(`check_patch_contract` → `validate_review_claims`). `finish_controlled_change` -passes it automatically when `claims_text` is supplied. `review_text` on -`finish_controlled_change` is a human note and is not claim-validated. - -Finish top-level `status: accepted_with_external_changes` still runs Claim Guard -when `claims_text` is provided — external workspace dirt is advisory, not a -verify failure. Do not claim "clean working tree" when `external_changes` is -non-empty unless the user explicitly scoped to ignore peer WIP. - ---- - -## Non-goals - -!!! warning "What claim guard is not" - - Not a vulnerability scanner - - Not a CI gate - - Not an LLM fact checker - - Not proof that uncited text is correct - - Not a replacement for `check_patch_contract` - ---- - -## Locked by tests - -- `tests/test_mcp_service.py` -- `tests/test_mcp_server.py` -- `tests/test_mcp_tool_schema_snapshot.py` - ---- - -## See also - -- [25-mcp-interface/index.md](25-mcp-interface/index.md) — full MCP tool and resource contract -- [MCP deep dive](../guide/mcp/README.md) — architecture, workflows, prompt patterns diff --git a/docs/book/15-health-score.md b/docs/book/15-health-score.md deleted file mode 100644 index f332b7b6..00000000 --- a/docs/book/15-health-score.md +++ /dev/null @@ -1,101 +0,0 @@ - - -# 15. Health Score - -## Purpose - -Define the current Health Score model, what does not affect it yet, and the -policy for future scoring-model expansion. - -## Public surface - -- Scoring model: `codeclone/metrics/health.py:compute_health` -- Weight assignment: `codeclone/contracts/__init__.py:HEALTH_WEIGHTS` -- Input wiring: `codeclone/core/pipeline.py:compute_project_metrics` -- Canonical report surface: - `codeclone/report/document/builder.py:build_report_document` -- Health snapshot projections: - `codeclone/report/document/derived.py:_health_snapshot`, - `codeclone/report/overview.py:_health_snapshot` -- CLI / HTML / MCP consumers: - `codeclone/surfaces/cli/summary.py`, - `codeclone/report/html/sections/_overview.py`, - `codeclone/surfaces/mcp/session.py` - -## Contracts - -- Health Score is computed only in `analysis_mode=full`. -- In `analysis_mode=clones_only`, health is intentionally unavailable. -- The current scoring model includes exactly seven dimensions: - `clones`, `complexity`, `coupling`, `cohesion`, `dead_code`, - `dependencies`, `coverage`. -- Report-only or advisory layers must not affect the score until they are - explicitly promoted and documented. - -## Scoring model - -Current weights from `codeclone/contracts/__init__.py:HEALTH_WEIGHTS`: - -| Dimension | Weight | Signal | -|--------------|--------|------------------------------------------------------------------| -| Clones | 25% | Function + block clone density | -| Complexity | 20% | Function-level complexity risk | -| Cohesion | 15% | Low-cohesion class pressure | -| Coupling | 10% | Class-level coupling pressure | -| Dead code | 10% | Active dead-code items after suppression/filtering | -| Dependencies | 10% | Cycles and deep dependency chains | -| Coverage | 10% | Analysis completeness (`files_analyzed_or_cached / files_found`) | - -Important clarifications: - -- `coverage` here means analysis completeness, not test coverage. -- Segment clones are visible in reports but do not currently affect Health Score. -- Suppressed or non-actionable dead-code items do not penalize the score. -- Dependencies score uses the internal module dependency graph only. -- Cycles still penalize the dependencies dimension directly. -- Acyclic depth pressure is adaptive: - -``` - expected_tail = max(ceil(avg_depth * 2.0), p95_depth + 1) - tail_pressure = max(0, max_depth - expected_tail) - score = 100 - cycles * 25 - tail_pressure * 4 -``` - -- This model is internal and not configurable through CLI or `pyproject.toml`. - -## Current non-scoring layers - -These layers are report-only: they provide signal but are not yet validated -for scoring-model inclusion. - -- `metrics.families.overloaded_modules` -- `metrics.families.security_surfaces` -- `findings.groups.clones.segments` -- `findings.groups.structural.groups` -- `derived.suggestions` -- `derived.hotlists` -- `metrics.families.coverage_join` - -## Health model evolution - -Future releases may expand the score with additional validated signal families. -If that happens: - -- the change must be documented in this chapter and release notes -- CLI/HTML/MCP must continue to present the score honestly -- a lower score after upgrade may reflect a broader model, not only worse code - -## Locked by tests - -- `tests/test_metrics_modules.py::test_health_helpers_and_compute_health_boundaries` -- `tests/test_pipeline_metrics.py::test_compute_project_metrics_respects_skip_flags` -- `tests/test_report_contract_coverage.py::test_report_contract_includes_canonical_overloaded_modules_family` -- `tests/test_report_contract_coverage.py::test_overview_health_snapshot_handles_non_mapping_dimensions` - -## See also - -- [05-report.md](05-report.md) -- [24-compatibility-and-versioning.md](24-compatibility-and-versioning.md) -- [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) diff --git a/docs/book/16-metrics-and-quality-gates.md b/docs/book/16-metrics-and-quality-gates.md deleted file mode 100644 index ec66a18f..00000000 --- a/docs/book/16-metrics-and-quality-gates.md +++ /dev/null @@ -1,164 +0,0 @@ - - -# 16. Metrics and Quality Gates - -## Purpose - -Define metrics mode selection, metrics-baseline behavior, and gating semantics. - -## Public surface - -- Metrics mode wiring: `codeclone/surfaces/cli/runtime.py:_configure_metrics_mode` -- Main orchestration and exit routing: `codeclone/surfaces/cli/workflow.py:_main_impl` -- Gate evaluation: `codeclone/report/gates/evaluator.py:metric_gate_reasons`, - `codeclone/core/reporting.py:gate` -- Metrics baseline persistence/diff: - `codeclone/baseline/metrics_baseline.py:MetricsBaseline` - -## Data model - -Metrics gate inputs: - -- threshold gates: - `--fail-complexity`, `--fail-coupling`, `--fail-cohesion`, `--fail-health` -- adoption threshold gates: - `--min-typing-coverage`, `--min-docstring-coverage` -- current-run Cobertura coverage join: - `--coverage`, `--coverage-min`, `--fail-on-untested-hotspots` -- boolean structural gates: - `--fail-cycles`, `--fail-dead-code` -- baseline-aware delta gates: - `--fail-on-new-metrics`, - `--fail-on-typing-regression`, - `--fail-on-docstring-regression`, - `--fail-on-api-break` -- baseline update: - `--update-metrics-baseline` -- opt-in metrics family: - `--api-surface` - -Modes: - -- `analysis_mode=full`: metrics computed and suggestions enabled -- `analysis_mode=clones_only`: metrics skipped - -Refs: - -- `codeclone/surfaces/cli/runtime.py:_metrics_flags_requested` -- `codeclone/surfaces/cli/runtime.py:_metrics_computed` -- `codeclone/surfaces/cli/report_meta.py:_build_report_meta` -- `codeclone/metrics/health.py:compute_health` -- `codeclone/contracts/__init__.py:HEALTH_WEIGHTS` - -## Contracts - -- `--skip-metrics` is incompatible with metrics gating/update flags. -- If metrics are not explicitly requested and no metrics baseline exists, runtime may auto-enable clone-only mode. -- In clone-only mode, dead-code and dependency analysis are skipped unless explicitly forced by gates. -- There is currently no user-facing gate or config knob for `dependency_max_depth`; - dependency depth contributes to Health Score through the internal adaptive - model over `avg_depth`, `p95_depth`, and `max_depth` only. -- `--coverage` is a current-run signal only; it does not update baseline state. -- Invalid Cobertura XML becomes `coverage_join.status="invalid"` in normal runs and becomes a contract error only when - hotspot gating requires a valid join. -- `--api-surface` is opt-in, but runtime auto-enables it when API break gating or metrics-baseline update needs it. -- `--fail-on-new-metrics` requires a trusted metrics baseline unless baseline is being updated in the same run. -- `--fail-on-typing-regression`, `--fail-on-docstring-regression`, and `--fail-on-api-break` require the corresponding - capability in the trusted metrics baseline. -- In CI mode, if a trusted metrics baseline is loaded, runtime enables `fail_on_new_metrics=true`. - -Refs: - -- `codeclone/surfaces/cli/runtime.py:_configure_metrics_mode` -- `codeclone/surfaces/cli/workflow.py:_main_impl` -- `codeclone/baseline/metrics_baseline.py:MetricsBaseline.verify_compatibility` - -## Invariants (MUST) - -- Metrics diff is computed only when metrics were computed and metrics baseline is trusted. -- Gate reasons are emitted in deterministic order. -- Metric gate reasons are namespaced as `metric:*`. - -Refs: - -- `codeclone/report/gates/evaluator.py:metric_gate_reasons` -- `codeclone/core/reporting.py:gate` - -## Failure modes - -| Condition | Behavior | -|-------------------------------------------------------------|--------------------------------------| -| `--skip-metrics` with metrics flags | Contract error, exit `2` | -| `--fail-on-new-metrics` without trusted baseline | Contract error, exit `2` | -| Coverage/API regression gate without required baseline data | Contract error, exit `2` | -| Invalid Cobertura XML without hotspot gate | Current-run invalid signal, exit `0` | -| Coverage hotspot gate without valid `--coverage` input | Contract error, exit `2` | -| `--update-metrics-baseline` when metrics were not computed | Contract error, exit `2` | -| Threshold breach or metrics regressions | Gating failure, exit `3` | - -## Determinism / canonicalization - -- Metrics baseline snapshot fields are canonicalized and sorted where set-like. -- Metrics payload hash uses canonical JSON and constant-time comparison. -- Gate reason generation order is fixed by code path order. - -Refs: - -- `codeclone/baseline/_metrics_baseline_payload.py:snapshot_from_project_metrics` -- `codeclone/baseline/_metrics_baseline_payload.py:_compute_payload_sha256` -- `codeclone/baseline/metrics_baseline.py:MetricsBaseline.verify_integrity` - -## Locked by tests - -- `tests/test_cli_unit.py::test_configure_metrics_mode_rejects_skip_metrics_with_metrics_flags` -- `tests/test_cli_unit.py::test_main_impl_rejects_update_metrics_baseline_when_metrics_skipped` -- `tests/test_cli_unit.py::test_main_impl_fail_on_new_metrics_requires_existing_baseline` -- `tests/test_cli_unit.py::test_main_impl_ci_enables_fail_on_new_metrics_when_metrics_baseline_loaded` -- `tests/test_pipeline_metrics.py::test_metric_gate_reasons_collects_all_enabled_reasons` -- `tests/test_pipeline_metrics.py::test_metric_gate_reasons_partial_new_metrics_paths` -- `tests/test_metrics_baseline.py::test_metrics_baseline_embedded_clone_payload_and_schema_resolution` -- `tests/test_metrics_modules.py::test_compute_lcom4_honors_ignored_methods` -- `tests/test_extractor.py::test_extract_protocol_class_excludes_stub_methods_from_lcom4` -- `tests/test_extractor.py::test_extract_pydantic_cohesion_exclusions` - -## LCOM4 cohesion applicability - -LCOM4 measures connected components over instance-behavior methods in a class cohesion -graph. Starting in `2.1.0`, the graph excludes declaration surfaces that do not carry -instance-level behavioral cohesion: - -- **Protocol classes** — when a class inherits from `typing.Protocol` / - `typing_extensions.Protocol` (including module aliases), all of its methods are - excluded from the LCOM4 graph. The whole class is an interface surface, not a - behavior cluster. -- **Pydantic validation/serialization hooks** — methods decorated with Pydantic - validator/serializer hooks resolved from `pydantic` / `pydantic.v1` imports or module - aliases are excluded: `field_validator`, `model_validator`, `root_validator`, - `validator`, `field_serializer`, and `model_serializer`. -- **`computed_field` is not excluded** — it commonly reads `self.*` and participates in - real object cohesion, so it stays in the graph. - -Reporting stays honest: - -- `method_count` on class metrics still reflects all methods on the class. -- Only the LCOM4 graph and component count use the analyzed subset. -- When one or zero analyzed methods remain, LCOM4 collapses to `1` (no penalty). - -Interactive CLI runs may show a one-time migration note when a trusted baseline was -generated by `2.0.2` and the current CodeClone version is `2.1.0` or newer; see -[CLI](11-cli.md) tips. - -Refs: - -- `codeclone/metrics/cohesion.py:compute_lcom4` -- `codeclone/analysis/_module_walk.py:_cohesion_ignored_method_names` -- `codeclone/analysis/class_metrics.py:_class_metrics_for_node` -- `codeclone/surfaces/cli/tips.py:maybe_print_cohesion_lcom4_migration_note` - -## Non-guarantees - -- Absolute threshold defaults are not frozen by this chapter. -- Metrics scoring internals may evolve if exit semantics and contract statuses stay stable and are documented honestly. diff --git a/docs/book/17-dead-code-contract.md b/docs/book/17-dead-code-contract.md deleted file mode 100644 index 06ac6098..00000000 --- a/docs/book/17-dead-code-contract.md +++ /dev/null @@ -1,226 +0,0 @@ - - -# 17. Dead Code Contract - -## Purpose - -Define dead-code liveness rules, canonical symbol-usage boundaries, and gating semantics. - -## Public surface - -- Dead-code detection core: `codeclone/metrics/dead_code.py:find_unused` -- Test-path classifier: `codeclone/paths/__init__.py:is_test_filepath` -- Inline suppression parser/binder: `codeclone/analysis/suppressions.py` -- Extraction of referenced names/candidates: - `codeclone/analysis/units.py:extract_units_and_stats_from_source` -- Cache load boundary for referenced names: - `codeclone/core/discovery_cache.py:load_cached_metrics_extended` -- Package entry-point liveness: - `codeclone/core/entrypoints.py:collect_project_entrypoint_qualnames` - -## Data model - -- Candidate model: `DeadCandidate` -- Output model: `DeadItem` (`confidence=high|medium`) -- Runtime reachability evidence: `RuntimeReachabilityFact` -- Global liveness input: - - `referenced_names: frozenset[str]` - - `referenced_qualnames: frozenset[str]` - - `runtime_reachability: tuple[RuntimeReachabilityFact, ...]` - -Refs: - -- `codeclone/models.py:DeadCandidate` -- `codeclone/models.py:DeadItem` -- `codeclone/models.py:RuntimeReachabilityFact` - -## Contracts - -- References from test files are excluded from liveness accounting. -- Symbols declared in test files are non-actionable and filtered. -- Symbols with names matching test entrypoint conventions are filtered: - `test_*`, `pytest_*`. -- Methods are filtered as non-actionable when dynamic/runtime dispatch is - expected: - dunder methods, `visit_*`, setup/teardown hooks. -- Module-level PEP 562 hooks are filtered as non-actionable: - `__getattr__`, `__dir__`. -- Declaration-level inline suppression is supported with: - `# codeclone: ignore[dead-code]` (leading or inline comment form). -- For multiline declaration headers, inline suppression may appear either on the - first declaration line or on the closing header line containing `:`. -- Suppression is declaration-scoped (`def`, `async def`, `class`) and does not - implicitly propagate to unrelated declaration targets. -- Candidate extraction excludes non-runtime declaration surfaces: - `Protocol` classes, methods on `Protocol` classes, and callables decorated with - `@overload` / `@abstractmethod`. -- Candidate extraction also excludes exact Pydantic runtime hooks when their - decorators are resolved from `pydantic` / `pydantic.v1` imports or module - aliases: validators, model/field validators, serializers, and computed fields. -- A symbol referenced by exact canonical qualname is not dead. -- A symbol referenced by local name is not dead. -- A top-level symbol listed in a literal `__all__` export is not dead. This is - resolved to exact module-level function/class qualnames and does not mark - same-named methods live. -- A symbol re-exported through a literal `__all__` entry and an exact - `from module import Symbol` binding is resolved back to the imported - canonical qualname. -- A symbol exposed through a PEP 562 lazy-export module is resolved when the - module has a module-level `__getattr__`, a literal `_EXPORTS` mapping, and a - matching literal `__all__` entry. Dynamic or non-literal export maps are not - interpreted. -- A symbol referenced by package metadata entry points is not dead when - `[project.scripts]`, `[project.gui-scripts]`, `[project.entry-points.*]`, or - `[tool.poetry.scripts]` resolves to an exact known candidate qualname. Unique - suffix matches are allowed only for common `src.` style layouts; - ambiguous matches are ignored. -- A symbol referenced only by qualified-name suffix (without canonical module - match) downgrades confidence to `medium`. -- A method name observed through guarded dynamic lookup is treated as a - referenced local name only when the same callable scope contains all three - pieces of evidence: `getattr(obj, "method", ...)`, `callable(local)` guard, - and a subsequent call through that same local binding. -- Runtime framework registration facts can mark a symbol live when the extractor - observes a deterministic edge from modern Python runtime surfaces: - FastAPI/Starlette route and dependency registration, including - typed route decorator factories, `Annotated[..., Depends(...)]` and - `Annotated[..., Security(...)]` route parameters, FastAPI lifecycle - decorators (`on_event`, `exception_handler`, `middleware`), Starlette - `BaseHTTPMiddleware.dispatch` hooks, Starlette/FastAPI route and application - subclass runtime hooks, Aiogram router observer decorators, - Flask/Blueprint routes, aiohttp `RouteTableDef` decorators, Django URL - patterns, Dependency Injector providers, Typer/Click commands, Celery tasks, - Pydantic `GenerateJsonSchema` runtime hooks, and SQLAlchemy `TypeDecorator` - runtime hooks. -- Runtime reachability facts are evidence, not a full call graph. High- and - medium-confidence facts prevent false dead-code findings; low-confidence - facts, if introduced later, must remain report-only until explicitly wired. -- `--fail-dead-code` gate counts only high-confidence dead-code items. -- Suppressed dead-code candidates are excluded from active dead-code findings - and from health-score dead-code penalties. -- Suppressed dead-code candidates are surfaced separately in report metrics - (`dead_code.summary.suppressed`, `dead_code.suppressed_items`) and in the - HTML dead-code split view (`Active` / `Suppressed`). - -Refs: - -- `codeclone/metrics/dead_code.py:_is_non_actionable_candidate` -- `codeclone/metrics/dead_code.py:find_unused` -- `codeclone/analysis/reachability.py:collect_runtime_reachability` -- `codeclone/report/gates/evaluator.py:metric_gate_reasons` - -## Invariants (MUST) - -- Output dead-code items are deterministically sorted by - `(filepath, start_line, end_line, qualname, kind)`. -- Test-path suppression is applied both on fresh extraction and cached-metrics - load for both `referenced_names` and `referenced_qualnames`. -- Runtime reachability facts are collected during AST extraction, cached with - the file metrics payload, and reused on warm runs so dead-code behavior is - identical for cold and cached analysis. -- Package entry-point liveness is a project-level pass over `pyproject.toml` and - is not stored in per-file cache entries. - -Refs: - -- `codeclone/metrics/dead_code.py:find_unused` -- `codeclone/analysis/units.py:extract_units_and_stats_from_source` -- `codeclone/core/discovery_cache.py:load_cached_metrics_extended` - -## Failure modes - -| Condition | Behavior | -|----------------------------------------------------|-------------------------------------------| -| Dynamic method pattern (dunder/visitor/setup hook) | Candidate skipped as non-actionable | -| Module PEP 562 hook (`__getattr__`/`__dir__`) | Candidate skipped as non-actionable | -| Malformed/unknown `# codeclone: ignore[...]` rule | Ignored safely | -| Protocol or stub-like declaration surface | Candidate skipped as non-actionable | -| Definition appears only in tests | Candidate skipped | -| Symbol used only from tests | Remains actionable dead-code candidate | -| Symbol used through import alias / module alias | Matched via canonical qualname usage | -| Symbol exported through literal `__all__` | Matched via exact module-level qualname | -| Symbol re-exported through literal `__all__` | Matched via exact imported qualname | -| Symbol exposed through literal lazy `_EXPORTS` | Matched via exact lazy-export qualname | -| Symbol exposed through package entry point | Matched via exact/unique project qualname | -| Guarded `getattr(obj, "method")` callable dispatch | Method name becomes runtime reference | -| Symbol registered through a supported runtime edge | Candidate skipped as runtime-reachable | -| `--fail-dead-code` with high-confidence dead items | Gating failure, exit `3` | - -## Determinism / canonicalization - -- Filtering rules are deterministic string/path predicates. -- Runtime reachability is based on exact AST evidence for known framework - contracts; it does not execute imports or inspect installed packages. -- Framework-specific non-runtime hooks are import/alias-resolved; CodeClone does - not suppress arbitrary same-named local decorators. -- Package entry-point liveness reads only local project metadata and ignores - invalid, dynamic, or ambiguous entry-point references. -- Lazy export and guarded dynamic `getattr` handling require literal AST - evidence and same-scope call evidence; CodeClone does not execute import - hooks or infer arbitrary dynamic dispatch. -- Candidate and result ordering is deterministic. - -Refs: - -- `codeclone/paths/__init__.py:is_test_filepath` -- `codeclone/metrics/dead_code.py:_is_dunder` -- `codeclone/metrics/dead_code.py:find_unused` - -## Locked by tests - -- `tests/test_extractor.py::test_dead_code_marks_symbol_dead_when_referenced_only_by_tests` -- `tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[skip_pep562_hooks]` -- - -`tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[inline_suppression_per_declaration]` -- -`tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[suppression_binding_scoped_to_target]` - -- `tests/test_extractor.py::test_dead_code_uses_fastapi_route_and_dependency_reachability` -- `tests/test_extractor.py::test_dead_code_uses_fastapi_annotated_dependency_reachability` -- `tests/test_extractor.py::test_dead_code_uses_fastapi_route_decorator_factory_reachability` -- `tests/test_extractor.py::test_dead_code_uses_extended_framework_runtime_reachability` -- `tests/test_extractor.py::test_dead_code_uses_aiogram_router_observer_reachability` -- `tests/test_extractor.py::test_dead_code_uses_flask_and_aiohttp_route_reachability` -- `tests/test_extractor.py::test_dead_code_uses_starlette_base_http_middleware_dispatch_hook` -- `tests/test_extractor.py::test_dead_code_uses_sqlalchemy_type_decorator_runtime_hooks` -- `tests/test_extractor.py::test_dead_code_uses_django_urlpattern_reachability` -- `tests/test_extractor.py::test_dead_code_uses_dependency_injector_provider_reachability` -- `tests/test_extractor.py::test_dead_code_uses_cli_and_task_registration_reachability` -- `tests/test_extractor.py::test_extract_collects_referenced_qualnames_for_import_aliases` -- `tests/test_extractor.py::test_extract_collects_referenced_qualnames_for_module_all_exports` -- `tests/test_extractor.py::test_extract_resolves_public_reexports_to_source_symbols` -- `tests/test_extractor.py::test_extract_treats_guarded_dynamic_getattr_call_as_runtime_reference` -- `tests/test_extractor.py::test_extract_ignores_uncalled_dynamic_getattr_probe` -- `tests/test_extractor.py::test_collect_dead_candidates_skips_protocol_and_stub_like_symbols` -- `tests/test_extractor.py::test_collect_dead_candidates_skips_pydantic_hooks_and_dataclass_post_init` -- `tests/test_core_branch_coverage.py::test_project_entrypoints_mark_exact_and_unique_layout_symbols_live` -- `tests/test_core_branch_coverage.py::test_pipeline_analyze_uses_project_entrypoints_for_dead_code` -- `tests/test_pipeline_metrics.py::test_load_cached_metrics_ignores_referenced_names_from_test_files` -- `tests/test_metrics_modules.py::test_find_unused_filters_non_actionable_and_preserves_ordering` -- `tests/test_metrics_modules.py::test_find_unused_respects_referenced_qualnames` -- `tests/test_metrics_modules.py::test_find_unused_keeps_non_pep562_module_dunders_actionable` -- `tests/test_metrics_modules.py::test_find_unused_applies_inline_dead_code_suppression` -- `tests/test_metrics_modules.py::test_find_suppressed_unused_returns_actionable_suppressed_candidates` -- `tests/test_report.py::test_report_json_dead_code_suppressed_items_are_reported_separately` -- `tests/test_html_report.py::test_html_report_renders_dead_code_split_with_suppressed_layer` -- `tests/test_suppressions.py::test_extract_suppression_directives_supports_inline_and_leading_forms` -- `tests/test_suppressions.py::test_bind_suppressions_targets_expected_declaration_scope[adjacent_leading_only]` - -## Non-guarantees - -- No full runtime call-graph, points-to, or framework container execution is - performed. -- Unsupported frameworks may still need explicit - `# codeclone: ignore[dead-code]` suppressions until their registration - contracts are modeled. -- Medium-confidence dead items are informational and not used by - `--fail-dead-code` gating. - -## See also - -- [03-core-pipeline.md](03-core-pipeline.md) -- [11-cli.md](11-cli.md) -- [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) diff --git a/docs/book/18-suggestions-and-clone-typing.md b/docs/book/18-suggestions-and-clone-typing.md deleted file mode 100644 index bf4fa7eb..00000000 --- a/docs/book/18-suggestions-and-clone-typing.md +++ /dev/null @@ -1,103 +0,0 @@ - - -# 18. Suggestions and Clone Typing - -## Purpose - -Define deterministic clone-type classification and suggestion generation used by -canonical report projections. - -## Public surface - -- Clone-type classifier: `codeclone/report/suggestions.py:classify_clone_type` -- Suggestion engine: `codeclone/report/suggestions.py:generate_suggestions` -- Pipeline integration: `codeclone/core/pipeline.py:compute_suggestions` -- Report serialization: `codeclone/report/document/builder.py:build_report_document` -- HTML render integration: `codeclone/report/html/assemble.py:build_html_report` - -## Data model - -Suggestion shape: - -- `severity` -- `category` -- `source_kind` -- `title` -- `location` -- `steps` -- `effort` -- `priority` - -Clone typing: - -- function groups: - - Type-1: identical `raw_hash` - - Type-2: identical normalized `fingerprint` - - Type-3: mixed fingerprints inside same group semantics - - Type-4: fallback -- block and segment groups: Type-4 - -Refs: - -- `codeclone/models.py:Suggestion` -- `codeclone/report/suggestions.py:classify_clone_type` - -## Contracts - -- Suggestions are generated only in full metrics mode. -- Suggestions are advisory only and never directly control exit code. -- Suggestions are not a one-to-one mirror of findings; they exist only when they add action structure. -- Low-signal local structural info hints stay in findings and do not emit separate suggestion cards. -- SARIF remains finding-driven and does not consume suggestion cards. -- JSON report stores clone typing at group level under clone groups. - -Refs: - -- `codeclone/core/pipeline.py:analyze` -- `codeclone/core/pipeline.py:compute_suggestions` -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/suggestions.py:generate_suggestions` - -## Invariants (MUST) - -- Suggestion priority formula is stable. -- Structural suggestion cards are emitted only for the actionable subset. -- Suggestion output is deterministically sorted. -- Clone type output for identical inputs is deterministic. - -Refs: - -- `codeclone/report/suggestions.py:_priority` -- `codeclone/report/suggestions.py:generate_suggestions` - -## Failure modes - -| Condition | Behavior | -|----------------------------------------|---------------------------------------| -| Metrics mode skipped | Suggestions list is empty | -| No eligible findings | Suggestions list is empty | -| Missing optional fields in group items | Classifier/renderer use safe defaults | - -## Determinism / canonicalization - -- Classifier uses deterministic set normalization and sorted collections. -- Serializer emits suggestions in deterministic order. - -Refs: - -- `codeclone/report/suggestions.py:classify_clone_type` -- `codeclone/report/document/builder.py:build_report_document` - -## Locked by tests - -- `tests/test_report_suggestions.py::test_classify_clone_type_all_modes` -- `tests/test_report_suggestions.py::test_generate_suggestions_covers_clone_metrics_and_dependency_categories` -- `tests/test_report_suggestions.py::test_generate_suggestions_covers_skip_branches_for_optional_rules` -- `tests/test_html_report.py::test_html_report_suggestions_cards_split_facts_assessment_and_action` - -## Non-guarantees - -- Suggestion wording can evolve without a schema bump. -- Suggestion heuristics may be refined if deterministic ordering and non-gating behavior remain unchanged. diff --git a/docs/book/19-inline-suppressions.md b/docs/book/19-inline-suppressions.md deleted file mode 100644 index 0a2e1431..00000000 --- a/docs/book/19-inline-suppressions.md +++ /dev/null @@ -1,122 +0,0 @@ - - -# 19. Inline Suppressions - -## Purpose - -Define deterministic local suppressions for known findings false positives via -source comments, without introducing broad/project-wide ignores. - -## Public surface - -- Suppression directive parser and binder: `codeclone/analysis/suppressions.py` -- Dead-code final filter: `codeclone/metrics/dead_code.py:find_unused` -- Suppressed dead-code projection helper: - `codeclone/metrics/dead_code.py:find_suppressed_unused` -- Dead-code candidate extraction: `codeclone/analysis/_module_walk.py:_collect_dead_candidates` - -## Data model - -- Directive model: `SuppressionDirective` (`line`, `binding`, `rules`) -- Declaration target model: `DeclarationTarget` -- Bound suppression model: `SuppressionBinding` -- Candidate storage: `DeadCandidate.suppressed_rules` - -Refs: - -- `codeclone/analysis/suppressions.py:SuppressionDirective` -- `codeclone/analysis/suppressions.py:DeclarationTarget` -- `codeclone/analysis/suppressions.py:SuppressionBinding` -- `codeclone/models.py:DeadCandidate` - -## Contracts - -- Canonical syntax: `# codeclone: ignore[]` -- Supported placements: - - previous line before declaration (`leading`) - - end-of-line comment on declaration header (`inline`) - - same-line single-line declaration - - first line of a multiline declaration header - - closing header line containing `:` -- Parsed rule ids: `dead-code`, `clone-cohort-drift`, `clone-guard-exit-divergence`. - Only `dead-code` has a runtime effect today. Clone rule ids are reserved: - they parse and bind like other rule ids but do not suppress clone findings. -- Rule list supports comma-separated values and deduplicates deterministically. -- Suppression applies only to declaration targets (`def`, `async def`, `class`). -- Suppression is target-scoped: - class-level suppression does not implicitly suppress unrelated methods. -- Dead-code suppression is applied in final liveness filtering by rule id - (`codeclone/metrics/dead_code.py:find_unused`). -- Suppressed dead-code candidates are reported separately (not as active - findings) with deterministic suppression metadata in report metrics. - -## Invariants (MUST) - -- If no `# codeclone: ignore[...]` exists, behavior remains unchanged. -- Suppression matching never jumps across non-adjacent lines. -- Unknown/malformed suppressions never fail analysis. -- Suppression handling remains deterministic under identical inputs. - -## Failure modes - -| Condition | Behavior | -|---------------------------------------------------|--------------------------------------| -| malformed `# codeclone: ignore[...]` payload | ignored silently | -| unknown `codeclone[...]` rule id | ignored silently | -| suppression on non-declaration line | ignored silently | -| duplicate rule ids in one directive | deduplicated deterministically | -| non-`dead-code` rule id on a declaration | parsed/bound only; no finding effect | -| suppression rule mismatch (`dead-code` vs others) | does not suppress dead-code finding | - -## Determinism / canonicalization - -- Directives are parsed from lexical comment tokens, not heuristic substring - scans. -- Binding is deterministic by declaration line and target identity. -- Inline binding for multiline declarations is deterministic across the - declaration header span only; it does not search arbitrary body lines. -- Candidate-level `suppressed_rules` are canonicalized and sorted in cache - payloads. -- Report-level suppressed dead-code payloads are deterministically sorted and - do not alter active finding IDs/order. - -Refs: - -- `codeclone/analysis/suppressions.py:extract_suppression_directives` -- `codeclone/analysis/suppressions.py:bind_suppressions_to_declarations` -- `codeclone/cache/_canonicalize.py:_canonicalize_cache_entry` - -## Locked by tests - -- `tests/test_suppressions.py::test_extract_suppression_directives_supports_inline_and_leading_forms` -- `tests/test_suppressions.py::test_extract_suppression_directives_ignores_invalid_forms[unknown_and_malformed]` -- `tests/test_suppressions.py::test_bind_suppressions_targets_expected_declaration_scope[adjacent_leading_only]` -- - -`tests/test_suppressions.py::test_bind_suppressions_targets_expected_declaration_scope[class_inline_does_not_propagate]` - -- `tests/test_suppressions.py::test_bind_suppressions_targets_expected_declaration_scope[method_target]` -- `tests/test_suppressions.py::test_build_suppression_index_deduplicates_rules_stably` -- - -`tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[inline_suppression_per_declaration]` -- -`tests/test_extractor.py::test_dead_code_respects_runtime_hooks_and_inline_suppressions[suppression_binding_scoped_to_target]` - -- `tests/test_metrics_modules.py::test_find_unused_applies_inline_dead_code_suppression` -- `tests/test_metrics_modules.py::test_find_suppressed_unused_returns_actionable_suppressed_candidates` -- `tests/test_report.py::test_report_json_dead_code_suppressed_items_are_reported_separately` -- `tests/test_html_report.py::test_html_report_renders_dead_code_split_with_suppressed_layer` - -## Non-guarantees - -- No file-level/project-level suppressions are provided. -- No generic suppression UI over all finding families is guaranteed in this - chapter. - -## See also - -- [17-dead-code-contract.md](17-dead-code-contract.md) -- [05-report.md](05-report.md) diff --git a/docs/book/20-benchmarking.md b/docs/book/20-benchmarking.md deleted file mode 100644 index 39306675..00000000 --- a/docs/book/20-benchmarking.md +++ /dev/null @@ -1,162 +0,0 @@ - - -# 20. Benchmarking (Docker) - -## Purpose - -Define a reproducible, deterministic benchmark workflow for CodeClone in Docker. - -## Public surface - -- Benchmark image: `benchmarks/Dockerfile` -- Benchmark runner (inside container): `benchmarks/run_benchmark.py` -- Host wrapper script: `benchmarks/run_docker_benchmark.sh` - -## Data model - -Benchmark output (`benchmark_schema_version=1.1`) contains: - -- tool metadata (`name`, `version`, `python_tag`) -- benchmark config (`target`, `runs`, `warmups`, `scenario_profile`, - `startup_runs`) -- execution environment (platform, cpu limits/affinity, cgroup limits) -- startup/import probes that isolate new-process cost from analysis cost: - - `python_empty` - - `import_codeclone` - - `import_codeclone_main` - - `cli_version` -- scenario results: - - `cold_full` (cold cache each run) - - `warm_full` (shared warm cache) - - `warm_clones_only` (shared warm cache with `--skip-metrics`) - - extended profile only: `cold_html`, `warm_html`, `cold_all_reports`, - `warm_all_reports` - - diagnostic profile only: `ci_cold_diagnostic` -- latency stats per scenario and probe (`min`, `max`, `mean`, `median`, `p95`, `stdev`) -- child process CPU stats per scenario/probe (`child_user_stats_seconds`, - `child_system_stats_seconds`, `child_cpu_stats_seconds`) -- per-scenario inventory and artifact samples (`inventory_sample`, - `artifact_bytes_sample`, `cache_bytes_sample`, `exit_code_counts`) -- deterministic digest check (`integrity.digest.value` must be stable within scenario) -- cross-scenario comparisons (speedup ratios) - -Scenario profiles: - -| Profile | Purpose | Default in CI | -|--------------|-------------------------------------------------------------------------|---------------| -| `smoke` | Historical core scenarios only; bounded push/PR signal. | yes | -| `extended` | Adds HTML/all-report scenarios with per-scenario run caps. | manual only | -| `diagnostic` | Adds `ci_cold_diagnostic`, where exit `0`, `2`, or `3` is recorded. | no | - -## Contracts - -- Benchmark must run in containerized, isolated environment. -- CPU/memory limits are pinned at container run time (`--cpuset-cpus`, `--cpus`, - `--memory`). -- Runtime environment is normalized: - `PYTHONHASHSEED=0`, `TZ=UTC`, `LC_ALL/LANG=C.UTF-8`. -- Each measured run must exit successfully (`exit=0`); any failure aborts the benchmark. - The `diagnostic` profile is the only exception: `ci_cold_diagnostic` records - `0`, `2`, or `3` so gate-failure timing can be measured without treating the - benchmark sample as a product failure. -- Determinism guard: if scenario digest diverges across measured runs, benchmark fails. -- Extended report scenarios are intentionally capped below global `runs`/`warmups` - so GitHub-hosted workers do not pay unbounded cold-report CPU. - -## Invariants (MUST) - -- Cold scenario uses a fixed cache path and removes cache file before each run - (cold cache with stable canonical metadata path). -- Warm scenarios seed one shared cache file before warmups/measured runs. -- Startup/import probes run as fresh Python subprocesses and do not read report - output; they are for process/bootstrap/import cost only. -- Core smoke scenarios remain gate-neutral by passing explicit no-fail flags. -- Benchmark JSON write is atomic (`.tmp` + replace). -- Benchmark scenario ordering is stable and fixed. - -## Failure modes - -| Condition | Behavior | -|-----------------------------------------|-----------------------------------------------| -| Docker unavailable | Host wrapper fails fast | -| Non-zero CLI exit in any run | Runner aborts with command stdout/stderr tail | -| Missing/invalid report integrity digest | Runner aborts as invalid benchmark sample | -| Digest mismatch in one scenario | Runner aborts as non-deterministic | - -## Determinism / canonicalization - -- Per-run determinism uses canonical report digest: - `report.integrity.digest.value`. -- Digest intentionally ignores runtime timestamp (`meta.runtime`) in canonical payload, - so deterministic check remains valid. -- Output JSON is serialized with stable formatting (`indent=2`) and written atomically. - -Refs: - -- `codeclone/report/document/integrity.py:_build_integrity_payload` -- `benchmarks/run_benchmark.py` - -## Recommended run profile - -```bash -./benchmarks/run_docker_benchmark.sh -``` - -Useful overrides: - -```bash -CPUSET=0 CPUS=1.0 MEMORY=2g RUNS=16 WARMUPS=4 \ - ./benchmarks/run_docker_benchmark.sh -``` - -Extended report-profile run: - -```bash -SCENARIO_PROFILE=extended RUNS=16 WARMUPS=4 STARTUP_RUNS=3 \ - ./benchmarks/run_docker_benchmark.sh -``` - -Local diagnostic run that also measures the CI-gate timing path: - -```bash -uv run python benchmarks/run_benchmark.py \ - --target . \ - --scenario-profile diagnostic \ - --runs 3 \ - --warmups 1 \ - --output /tmp/codeclone-benchmark-diagnostic.json -``` - -Permissions note: - -- The host wrapper runs the container as host `uid:gid` by default - (`--user "$(id -u):$(id -g)"`) so benchmark artifact writes to bind-mounted - output paths are stable in CI. -- Override explicitly if needed: `CONTAINER_USER=10001:10001`. - -## GitHub Actions - -- Workflow: `.github/workflows/benchmark.yml` -- Triggers: - - `push` on all branches - - `pull_request` (all targets) - - manual (`workflow_dispatch`) with profile choice (`smoke` / `extended`) -- Job behavior: - - runs Docker benchmark with pinned runner limits - - uploads `.cache/benchmarks/codeclone-benchmark.json` as artifact - - emits startup/import probe, scenario, and ratio tables into - `GITHUB_STEP_SUMMARY` - - prints ratios in job logs (important for quick trend checks) - -## Non-guarantees - -- Cross-host absolute timings are not comparable by contract. -- Throughput numbers can vary with host kernel, thermal state, and background load. - -## See also - -- [22-determinism.md](22-determinism.md) -- [24-compatibility-and-versioning.md](24-compatibility-and-versioning.md) -- [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) diff --git a/docs/book/21-security-model.md b/docs/book/21-security-model.md deleted file mode 100644 index 032a90cd..00000000 --- a/docs/book/21-security-model.md +++ /dev/null @@ -1,250 +0,0 @@ - - -# 21. Security Model - -## Purpose - -Describe implemented protections and explicit security boundaries. - -## Public surface - -- Scanner path validation: `codeclone/scanner/__init__.py:iter_py_files` -- File read and parser limits: `codeclone/core/worker.py:process_file`, - `codeclone/analysis/parser.py:_parse_limits` -- Baseline/cache validation: `codeclone/baseline/*`, `codeclone/cache/*` -- HTML escaping: `codeclone/report/html/primitives/escape.py`, - `codeclone/report/html/assemble.py` -- MCP read-only enforcement: `codeclone/surfaces/mcp/*` -- Repository path containment: `codeclone/utils/repo_paths.py` - -## Data model - -Security-relevant input classes: - -- filesystem paths (root/source/baseline/cache/report) -- untrusted JSON files (baseline/cache) -- untrusted source snippets and metadata rendered into HTML -- MCP request parameters (`root`, filters, diff refs, cache policy) - -## Contracts - -- CodeClone parses source text; it does not execute repository Python code. -- Sensitive root directories are blocked by scanner policy. -- Symlink traversal outside the root is skipped. -- HTML escapes text and attribute contexts before embedding. -- MCP is read-only with respect to source files, baselines, analysis cache - (`cache.json`), and canonical report artifacts. -- Allowed repo-local writes are explicit and isolated: ephemeral controller - coordination (file backend under `.codeclone/intents/` or SQLite under - `.codeclone/db/intents.sqlite3`), optional controller audit - (`.codeclone/db/audit.sqlite3`), Engineering Memory/projection state under - `.codeclone/memory/`, and opt-in Platform Observability - (`.codeclone/db/platform_observability.sqlite3`). -- Platform Observability stores bounded metadata and literal-free SQL - fingerprints, never raw payload bodies, and cannot affect analysis truth, - gates, baselines, memory facts, or edit authorization. -- Session-local review markers and in-memory run history do not survive - process restart. -- Five session/coordination tools are marked `destructiveHint` in MCP metadata - (`manage_change_intent`, `start_controlled_change`, - `finish_controlled_change`, `mark_finding_reviewed`, `clear_session_runs`). -- `--allow-remote` is required for non-loopback HTTP bind. It is an explicit - operator opt-in, not a substitute for authentication. For `streamable-http`, - `CODECLONE_MCP_AUTH_TOKEN` is mandatory at server start (see - [Remote MCP transport](#remote-mcp-transport)). stdio transport remains a - local-trust surface on the host. -- MCP accepts cache policies `reuse` and `off`; `refresh` is rejected at - runtime with a contract error. -- `git_diff_ref` is validated as a safe single revision expression before any `git diff` subprocess call. -- MCP `processes` is capped to `min(requested, os.cpu_count() or 4, 64)`. - This is a resource ceiling only; it does not change analysis results. - -## Trust boundaries (explicit) - -These are documented limits, not hidden guarantees. - -### Repository path containment - -`resolve_under_repo_root` in `codeclone/utils/repo_paths.py` is the shared -resolver for audit paths, intent-registry DB paths, memory config paths, MCP -optional artifacts, and cache wire filepath projection. By default paths must -stay under the analysis root after normalization; symlink escapes outside the -root are rejected. - -Refs: - -- `codeclone/utils/repo_paths.py` -- `tests/test_repo_paths.py` - -### MCP optional artifact paths - -`baseline_path`, `metrics_baseline_path`, `cache_path`, and `coverage_xml` on -`analyze_repository` / `analyze_changed_paths` resolve through the same helper. -**Default:** repo-relative only; absolute or out-of-repo paths are rejected. -**Opt-in:** set `allow_external_artifacts=true` on the analysis tool call when -shared monorepo artifacts live outside the scan root (privileged input). - -The opt-in is **least-privilege, not unrestricted**. An external artifact must: - -- be a **regular file** (directories, devices, FIFOs, sockets are rejected); -- carry the **extension its kind expects** — `.json` for `baseline_path`, - `metrics_baseline_path`, and `cache_path`; `.xml` for `coverage_xml`. The - extension is enforced on the **resolved real path**, so a `.json` symlink that - points at a disallowed target is rejected; -- resolve **under a permitted root** — the repository root, the system temp dir - (CI coverage), or `~/.cache/codeclone`. Operators *extend* (never replace) the - permitted roots with `CODECLONE_EXTERNAL_ARTIFACT_ROOTS` (an `os.pathsep`- - separated list; trusted operator configuration, not part of the MCP request). - -Arbitrary absolute paths such as `/etc/passwd` or `~/.ssh/id_rsa` are rejected -even with the opt-in set. - -Parameter details: [25-mcp-interface/index.md](25-mcp-interface/index.md). Tool copy: -`help(topic="trust_boundaries")`. - -Refs: - -- `codeclone/surfaces/mcp/_session_runtime.py:resolve_artifact_path` — single - artifact-path resolver shared by every optional-path call site -- `codeclone/utils/repo_paths.py:resolve_under_repo_root` — `external_suffixes`, - `external_roots`, and `reject_special_files` policy enforcement - -### Cache checksum semantics - -Cache signatures detect corruption and accidental mutation of the canonical -cache payload. They are not adversarial authentication against a privileged -local attacker who can rewrite `.codeclone/cache.json` directly. - -Refs: - -- `codeclone/cache/integrity.py:sign_cache_payload` -- `codeclone/cache/integrity.py:verify_cache_payload_signature` - -### Workspace change intents - -The workspace intent registry coordinates concurrent edits between processes -running as the same local UID on the same host (file backend: -`.codeclone/intents/`; SQLite backend: `.codeclone/db/intents.sqlite3` -when configured). Records are advisory, TTL-bound (default 1 hour, lease 5 -minutes), gitignored, and integrity-checked (SHA-256 over canonical JSON) but not -cryptographically authenticated. A same-UID process with repository write access -can forge or delete intent records; that UID can already modify source files and -baselines directly. Treat intents as coordination hints, not proof of agent -identity. - -The Cursor plugin may enforce `preToolUse` by **reading** this registry through -`codeclone.workspace_intent` (read-only; no lazy-close or writes). The hook gate -authorizes edits only for **own active** or **foreign active** intents (not -stale/queued). That reduces accidental edits without intent; it does not stop a -hostile same-UID process. - -Refs: - -- `codeclone/workspace_intent/gate.py` -- `codeclone/surfaces/mcp/_workspace_intents.py` -- `codeclone/surfaces/mcp/_session_workflow_mixin.py` - -### Remote MCP transport - -Loopback binding is the default. `--allow-remote` removes the loopback-only -transport guard so HTTP MCP can bind on non-local interfaces. - -For **every** `streamable-http` start (loopback or remote), set -`CODECLONE_MCP_AUTH_TOKEN` to a secret of at least 32 characters. The launcher -refuses to bind HTTP transport when the variable is missing or too short; there -is no unauthenticated HTTP fallback. Clients must send -`Authorization: Bearer …`; the server validates with `hmac.compare_digest` -(stdlib only). CodeClone does not ship TLS or multi-tenant session management — -use a reverse proxy when exposing beyond loopback. - -Variable semantics and precedence: -[10-config Environment variable overrides](10-config-and-defaults.md#mcp-http-authentication). - -Refs: - -- `codeclone/surfaces/mcp/auth.py` -- `codeclone/surfaces/mcp/server.py` -- `tests/test_mcp_http_auth.py` -- `tests/test_mcp_server.py::test_mcp_server_main_rejects_non_loopback_host_without_opt_in` - -### Platform Observability - -The observer is an optional local diagnostics boundary. Its CLI and MCP readers -open the telemetry store read-only; the instrumentation writer commits one -completed operation and its spans atomically. No network exporter is provided. - -The MCP slicer is bounded and declares that its output is CodeClone-development -telemetry, not repository quality evidence. See -[26-platform-observability.md](26-platform-observability.md). - -Refs: - -- `codeclone/analysis/parser.py:_parse_with_limits` -- `codeclone/scanner/__init__.py:SENSITIVE_DIRS` -- `codeclone/scanner/__init__.py:iter_py_files` -- `codeclone/report/html/primitives/escape.py:_escape_html` - -## Invariants (MUST) - -- Baseline and cache integrity checks use constant-time comparison. -- Size guards are enforced before parsing baseline/cache JSON. -- Cache failures degrade safely; baseline trust failures follow the explicit trust model. - -Refs: - -- `codeclone/baseline/clone_baseline.py:Baseline.verify_integrity` -- `codeclone/cache/store.py:Cache.load` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Failure modes - -| Condition | Security behavior | -|------------------------------------------|--------------------| -| Symlink points outside root | File skipped | -| Root under sensitive dirs | Validation error | -| Oversized baseline | Baseline rejected | -| Oversized cache | Cache ignored | -| HTML-injected payload in metadata/source | Escaped output | -| `--allow-remote` not passed for HTTP | Transport rejected | -| Invalid `cache_policy` requested in MCP | Policy rejected | -| `git_diff_ref` fails validation | Parameter rejected | - -## Determinism / canonicalization - -- Canonical JSON hashing for baseline/cache prevents formatting-only drift. -- Security failures map to explicit statuses rather than silent mutation. - -Refs: - -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/cache/integrity.py:canonical_json` -- `codeclone/baseline/trust.py:BaselineStatus` -- `codeclone/cache/versioning.py:CacheStatus` - -## Locked by tests - -- `tests/test_security.py::test_scanner_path_traversal` -- `tests/test_scanner_extra.py::test_iter_py_files_symlink_loop_does_not_traverse` -- `tests/test_security.py::test_html_report_escapes_user_content` -- `tests/test_html_report.py::test_html_report_escapes_script_breakout_payload` -- `tests/test_cache.py::test_cache_too_large_warns` -- `tests/test_mcp_service.py::test_mcp_service_rejects_refresh_cache_policy_in_read_only_mode` -- `tests/test_mcp_service.py::test_mcp_service_caps_process_count_from_request_and_config` -- `tests/test_mcp_server.py::test_mcp_server_main_rejects_non_loopback_host_without_opt_in` -- `tests/test_repo_paths.py` -- `tests/test_mcp_http_auth.py` -- `tests/test_security_invariants.py` - -## Non-guarantees - -- Baseline/cache integrity is tamper-evident at file-content level; it is not cryptographic attestation against a - privileged attacker. -- Baseline `payload_sha256` and cache signatures protect against accidental corruption and unsynchronized edits; they - do not authenticate files against a hostile same-UID writer. -- Workspace intent files are not signed and must not be treated as proof of which agent declared a change. -- MCP optional artifact paths outside the scan root require explicit - `allow_external_artifacts=true`; default resolution stays under the repo root. -- Remote MCP without the auth token env var is not authenticated; with - `--allow-remote` it is not a hardened multi-tenant network service. diff --git a/docs/book/22-determinism.md b/docs/book/22-determinism.md deleted file mode 100644 index fbb3b9d4..00000000 --- a/docs/book/22-determinism.md +++ /dev/null @@ -1,90 +0,0 @@ - - -# 22. Determinism - -## Purpose - -Document deterministic behavior and canonicalization controls. - -## Public surface - -- Sorted file traversal: `codeclone/scanner/__init__.py` -- Canonical report construction: `codeclone/report/document/*` -- Deterministic text projection: `codeclone/report/renderers/text.py` -- Baseline hashing: `codeclone/baseline/trust.py` -- Cache signing: `codeclone/cache/integrity.py` - -## Data model - -Deterministic outputs depend on: - -- fixed Python tag -- fixed baseline/cache/report schemas -- sorted file traversal -- sorted group keys and item records -- canonical JSON serialization for hashes/signatures - -## Contracts - -- Canonical JSON report uses deterministic ordering for files, groups, items, and summaries. -- Text/Markdown/SARIF projections are deterministic views over the canonical report. -- Baseline hash is canonical and independent from non-payload metadata fields. -- Cache signature is canonical and independent from JSON whitespace. - -Refs: - -- `codeclone/report/document/builder.py:build_report_document` -- `codeclone/report/renderers/text.py:render_text_report_document` -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/cache/integrity.py:sign_cache_payload` - -## Invariants (MUST) - -- `inventory.file_registry.items` is lexicographically sorted. -- finding groups/items and derived hotlists are deterministically ordered. -- baseline clone lists are sorted and unique. -- golden detector fixtures run only on the canonical Python tag from fixture metadata. - -Refs: - -- `codeclone/report/document/inventory.py:_build_inventory_payload` -- `codeclone/baseline/trust.py:_require_sorted_unique_ids` -- `tests/test_detector_golden.py::test_detector_output_matches_golden_fixture` - -## Failure modes - -| Condition | Determinism impact | -|-------------------------------------|-----------------------------------------------------| -| Different Python tag | Clone IDs may differ; baseline becomes incompatible | -| Unsorted/non-canonical baseline IDs | Baseline rejected as invalid | -| Cache signature mismatch | Cache ignored and recomputed | -| Different cache provenance state | `meta.cache_*` differs by design | - -## Determinism / canonicalization - -Primary canonicalization points: - -- canonical JSON with sorted keys and compact separators for baseline/cache hashing -- stable tuple-based sort keys for report arrays and hotlists - -Refs: - -- `codeclone/baseline/trust.py:_compute_payload_sha256` -- `codeclone/cache/integrity.py:canonical_json` -- `codeclone/report/document/integrity.py:_build_integrity_payload` - -## Locked by tests - -- `tests/test_report.py::test_report_json_deterministic_group_order` -- `tests/test_report.py::test_report_json_deterministic_with_shuffled_units` -- `tests/test_report.py::test_text_report_deterministic_group_order` -- `tests/test_baseline.py::test_baseline_hash_canonical_determinism` -- `tests/test_cache.py::test_cache_signature_validation_ignores_json_whitespace` - -## Non-guarantees - -- Determinism is not guaranteed across different `python_tag` values. -- Byte-identical reports are not guaranteed across different cache provenance states. diff --git a/docs/book/23-testing-as-spec.md b/docs/book/23-testing-as-spec.md deleted file mode 100644 index e7e55b31..00000000 --- a/docs/book/23-testing-as-spec.md +++ /dev/null @@ -1,123 +0,0 @@ - - -# 23. Testing as Specification - -## Purpose - -Map critical contracts to tests that lock behavior. - -## Public surface - -Contract tests are concentrated in: - -- `tests/test_baseline.py` -- `tests/test_cache.py` -- `tests/test_report.py` -- `tests/test_report_contract_coverage.py` -- `tests/test_cli_inprocess.py` -- `tests/test_cli_unit.py` -- `tests/test_coverage_join.py` -- `tests/test_golden_fixtures.py` -- `tests/test_html_report.py` -- `tests/test_mcp_service.py` -- `tests/test_detector_golden.py` -- `tests/test_golden_v2.py` -- `tests/test_memory_*.py`, `tests/test_semantic_*.py`, `tests/test_mcp_memory_management.py` -- `tests/test_memory_trajectory_*.py`, `tests/test_memory_experience_*.py` -- `tests/test_memory_projection_jobs*.py` -- `tests/test_observability_*.py` -- `tests/test_docs_ia_contract.py`, `tests/test_docs_build_contract.py` -- `tests/test_architecture.py` - -## Test taxonomy - -Treat tests as specification. Every new behavior belongs in the closest bucket; -public-surface changes need contract tests, not only unit tests. - -| Bucket | Intent | Examples | -|-----------------------------|----------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------| -| **Unit** | Module behavior and edge conditions | `tests/test_cfg.py`, `tests/test_normalize.py`, `tests/test_metrics_modules.py`, `tests/test_suppressions.py` | -| **Contract** | Baseline, cache, report, CLI, MCP public semantics | `tests/test_baseline.py`, `tests/test_cache.py`, `tests/test_report_contract_coverage.py`, `tests/test_cli_unit.py`, `tests/test_mcp_service.py` | -| **Golden** | Snapshot sentinels for stable outputs | `tests/test_detector_golden.py`, `tests/test_golden_v2.py` | -| **Determinism / invariant** | Ordering, branch paths, canonical stability | `tests/test_report_branch_invariants.py`, `tests/test_core_branch_coverage.py`, `tests/test_semantic_determinism_gate.py` | -| **Scenario / regression** | Multi-step integration and process behavior | `tests/test_cli_inprocess.py`, `tests/test_pipeline_process.py`, `tests/test_cli_smoke.py` | - -Maintainer routing tables and golden-update policy also live in `AGENTS.md` §17 -and §16 (change routing); this chapter is the published contract copy. - -## Contracts - -The following matrix is treated as executable contract: - -| Contract | Tests | -|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Baseline schema/integrity/compat gates | `tests/test_baseline.py` | -| Cache v2.10 fail-open + status mapping + API-surface-aware reuse + runtime-reachability/security-surface persistence + function-relationship-fact persistence/aggregation + API signature order preservation | `tests/test_cache.py`, `tests/test_cli_inprocess.py::test_cli_reports_cache_too_large_respects_max_size_flag`, `tests/test_cli_inprocess.py::test_cli_public_api_breaking_count_stable_across_warm_cache`, `tests/test_cli_inprocess.py::test_cli_api_surface_ignores_non_api_warm_cache` | -| Exit code categories and markers | `tests/test_cli_unit.py`, `tests/test_cli_inprocess.py` | -| Report schema v2.11 canonical/derived/integrity + JSON/TXT/MD/SARIF projections | `tests/test_report.py`, `tests/test_report_contract_coverage.py`, `tests/test_report_branch_invariants.py` | -| HTML render-only explainability + escaping | `tests/test_html_report.py` | -| Current-run Cobertura coverage join parsing, gating, and projections | `tests/test_coverage_join.py`, `tests/test_pipeline_metrics.py`, `tests/test_cli_unit.py`, `tests/test_mcp_service.py`, `tests/test_html_report.py` | -| Report-only security surfaces inventory and projections | `tests/test_security_surfaces.py`, `tests/test_pipeline_metrics.py`, `tests/test_cache.py`, `tests/test_report_contract_coverage.py`, `tests/test_cli_unit.py`, `tests/test_html_report.py`, `tests/test_mcp_service.py`, `tests/test_mcp_server.py` | -| Framework-aware dead-code reachability facts | `tests/test_extractor.py`, `tests/test_pipeline_metrics.py`, `tests/test_cache.py` | -| Golden fixture clone exclusion policy | `tests/test_golden_fixtures.py`, `tests/test_cli_inprocess.py::test_cli_pyproject_golden_fixture_paths_exclude_fixture_clone_groups`, `tests/test_report.py::test_report_json_clone_groups_can_include_suppressed_golden_fixture_bucket` | -| Scanner traversal safety | `tests/test_scanner_extra.py`, `tests/test_security.py` | -| Engineering Memory SQLite schema, governance, retrieval | `tests/test_memory_schema.py`, `tests/test_memory_store.py`, `tests/test_memory_governance.py`, `tests/test_memory_retrieval.py`, `tests/test_memory_mcp_sync.py` | -| Semantic index projection, rebuild, LanceDB backend | `tests/test_semantic_projection.py`, `tests/test_semantic_rebuild.py`, `tests/test_semantic_lancedb_backend.py`, `tests/test_semantic_embedding.py` | -| Trajectory projection, quality passport, anomalies, retrieval | `tests/test_memory_trajectory_projector.py`, `tests/test_memory_trajectory_quality.py`, `tests/test_memory_trajectory_anomalies.py`, `tests/test_memory_trajectory_retrieval.py` | -| Experience distillation, evidence diversity, scoped retrieval, promotion | `tests/test_memory_experience_distillation.py`, `tests/test_memory_experience_retrieval.py`, `tests/test_memory_experience_promotion.py` | -| Projection queue coalescing, watermarks, worker lifecycle | `tests/test_memory_projection_jobs.py`, `tests/test_memory_projection_jobs_schema.py`, `tests/test_projection_spawn_guard.py` | -| Platform Observability config, correlation, persistence, query, rendering, MCP | `tests/test_observability_config.py`, `tests/test_observability_correlation.py`, `tests/test_observability_store.py`, `tests/test_observability_query.py`, `tests/test_observability_render.py`, `tests/test_observability_mcp_registrar.py` | -| Documentation IA, line budgets, strict site build | `tests/test_docs_ia_contract.py`, `tests/test_docs_build_contract.py` | -| Layer dependency direction | `tests/test_architecture.py` | - -## Invariants (MUST) - -- Every schema/status contract change requires tests and docs update. -- Golden detector fixture is canonicalized to one Python tag. -- Untrusted baseline behavior must be tested for both normal and gating modes. -- V2 golden fixtures lock dead-code/test-path semantics, metrics/dependency aggregates, - stable per-function structural fact surfaces (`stable_structure` / - `cohort_structural_findings`), and CLI+`pyproject.toml` contract behavior. - -Refs: - -- `tests/test_detector_golden.py::test_detector_output_matches_golden_fixture` -- `tests/test_golden_v2.py::test_golden_v2_analysis_contracts` -- `tests/test_golden_v2.py::test_golden_v2_cli_pyproject_contract` -- `tests/test_cli_inprocess.py::test_cli_legacy_baseline_normal_mode_ignored_and_exit_zero` -- `tests/test_cli_inprocess.py::test_cli_legacy_baseline_fail_on_new_fails_fast_exit_2` - -## Failure modes - -| Condition | Expected test signal | -|---------------------------------|-----------------------------------------| -| Baseline payload contract drift | baseline integrity/canonical tests fail | -| Cache schema drift | cache version/parse tests fail | -| Report schema drift | compact layout tests fail | -| Exit priority drift | CI inprocess tests fail | - -## Determinism / canonicalization - -- Determinism tests compare ordering and stable payloads, not runtime-specific timestamps. - -## Locked by tests - -- `tests/test_baseline.py::test_baseline_payload_fields_contract_invariant` -- `tests/test_cache.py::test_cache_v13_missing_optional_sections_default_empty` -- `tests/test_report.py::test_report_json_compact_v21_contract` -- `tests/test_coverage_join.py::test_build_coverage_join_maps_cobertura_lines_to_function_spans` -- `tests/test_cli_inprocess.py::test_cli_contract_error_priority_over_gating_failure_for_unreadable_source` -- `tests/test_html_report.py::test_html_and_json_group_order_consistent` -- `tests/test_detector_golden.py::test_detector_output_matches_golden_fixture` -- `tests/test_golden_v2.py::test_golden_v2_analysis_contracts` -- `tests/test_golden_v2.py::test_golden_v2_cli_pyproject_contract` -- `tests/test_extractor.py::test_extract_collects_referenced_qualnames_for_import_aliases` -- `tests/test_extractor.py::test_collect_dead_candidates_skips_protocol_and_stub_like_symbols` -- `tests/test_metrics_modules.py::test_find_unused_respects_referenced_qualnames` - -## Non-guarantees - -- Test implementation details (fixtures/helper names) can change if contract assertions remain equivalent. diff --git a/docs/book/24-compatibility-and-versioning.md b/docs/book/24-compatibility-and-versioning.md deleted file mode 100644 index 6cd88053..00000000 --- a/docs/book/24-compatibility-and-versioning.md +++ /dev/null @@ -1,202 +0,0 @@ - - -# 24. Compatibility and Versioning - -## Purpose - -Define when to bump baseline/cache/report/fingerprint versions and how runtime -compatibility is enforced. - -## Public surface - -- Version constants: `codeclone/contracts/__init__.py` (central); - subsystem-local versions in owning modules (audit event core, - implementation-context payload/resolver) -- Clone baseline compatibility: - `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- Metrics baseline compatibility: - `codeclone/baseline/metrics_baseline.py:MetricsBaseline.verify_compatibility` -- Cache compatibility: `codeclone/cache/store.py:Cache.load` -- Report schema assignment: - `codeclone/report/document/builder.py:build_report_document` -- MCP public surface: `codeclone/surfaces/mcp/server.py`, - `codeclone/surfaces/mcp/service.py` - -## Data model - -Current contract versions: - -- `BASELINE_SCHEMA_VERSION = "2.1"` -- `BASELINE_FINGERPRINT_VERSION = "1"` -- `CACHE_VERSION = "2.10"` -- `REPORT_SCHEMA_VERSION = "2.11"` -- `METRICS_BASELINE_SCHEMA_VERSION = "1.2"` -- `ENGINEERING_MEMORY_SCHEMA_VERSION = "1.7"` -- `PATCH_TRAIL_SCHEMA_VERSION = "1"` (finish-time Patch Trail JSON; audit + SQLite sidecar) -- `TRAJECTORY_EXPORT_SCHEMA_VERSION = "2"` (JSONL export rows; `codeclone/memory/trajectory/profiles.py`) -- `TRAJECTORY_PROJECTION_VERSION = "trajectory-v3"` (derived trajectory rows) -- `TRAJECTORY_QUALITY_SCORE_VERSION = "2"` (quality contract formula) -- `EXPERIENCE_DISTILLATION_VERSION = "experience-v1"` (derived Experience rows) -- `SEMANTIC_INDEX_FORMAT_VERSION = "2"` (LanceDB sidecar; separate from SQLite memory schema) -- `PLATFORM_OBSERVABILITY_SCHEMA_VERSION = "1.1"` (dev-only telemetry SQLite) -- `CORPUS_ANALYTICS_STORE_SCHEMA_VERSION = "1.2"` (corpus analytics SQLite) -- `CORPUS_EXPORT_SCHEMA_VERSION = "1.3"` (clustering JSON export) -- `CORPUS_PROFILE_MANIFEST_SCHEMA_VERSION = "1"` (profile manifests) -- `CORPUS_CONTROL_PLANE_CONTRACT_VERSION = "1.0"` (profile/selection export) -- `CORPUS_REPRESENTATION_CONTRACT_VERSION = "3"` (intent representation payloads) -- `CORPUS_NORMALIZER_VERSION = "1"` (corpus normalization pipeline) -- `CORPUS_EMBEDDING_CONTRACT_VERSION = "2"` (analytics embedding sidecar) -- `CORPUS_AGENT_LABEL_CONTRACT_VERSION = "1"` (agent label payloads) -- `CORPUS_PARTITION_MAP_VERSION = "1"` (partition map artifacts) -- `IDE_GOVERNANCE_PROTOCOL_VERSION = 2` (VS Code Memory HMAC attestation) -- `AUDIT_EVENT_CORE_VERSION = "2"` (`codeclone/audit/events.py`; frozen audit step core) -- `CONTEXT_CONTRACT_VERSION = "1"` (`codeclone/surfaces/mcp/_implementation_context.py`) -- `CALL_RESOLUTION_VERSION = "1"` (`codeclone/surfaces/mcp/_implementation_context.py`) - -Refs: - -- `codeclone/contracts/__init__.py` -- `codeclone/audit/events.py` -- `codeclone/surfaces/mcp/_implementation_context.py` - -## Contracts - -Version bump rules: - -- bump **baseline schema** only for clone-baseline JSON layout/type changes -- bump **fingerprint version** when clone identity semantics change -- bump **cache schema** for cache wire-format or compatibility-semantics changes - — `2.9` adds rebuildable, off-report per-function call/reference facts (`fr` - wire section) as a sibling projection without changing serialized `Unit` rows; - `2.10` aggregates those facts across files onto the analysis result and MCP - run record (still off the canonical report) -- bump **report schema** for canonical report document shape/meaning changes -- bump **metrics-baseline schema** only for standalone metrics-baseline payload changes -- bump **engineering memory schema** for SQLite DDL / governed record-shape changes - (`codeclone/memory/schema_migrate.py`) — **`1.4`** added Patch Trail - persistence, **`1.5`** quality scoring, **`1.6`** Experience tables, and - **`1.7`** the projection-job flush-scheduling column (`flush_claimed_by`) -- bump **patch trail schema** (`PATCH_TRAIL_SCHEMA_VERSION`) when finish-time Patch - Trail JSON shape changes incompatibly -- bump **trajectory export schema** (`TRAJECTORY_EXPORT_SCHEMA_VERSION`) when JSONL - row shape changes incompatibly -- bump **trajectory projection**, **quality score**, or **Experience - distillation** versions when their derived identity/formula changes; rebuild - derived rows rather than migrating source evidence -- bump **semantic index format** when LanceDB projection or stored row fields change - incompatibly — forces index rebuild, not SQLite migration ( - see [13-engineering-memory/index.md](13-engineering-memory/index.md)) -- bump **Platform Observability schema** only for incompatible telemetry-store - changes; it remains separate from reports, gates, baselines, and memory facts - (see [26-platform-observability.md](26-platform-observability.md)) -- bump **corpus analytics store/export/representation/embedding** versions when - SQLite layout or export semantics change incompatibly; rebuild analytics - artifacts rather than treating them as analysis truth ( - see [27-corpus-analytics.md](27-corpus-analytics.md)) - - store `1.2` adds immutable manifest snapshots and profile batches, - batch-run memberships, suitability assessments, and append-only - selection events; writable migration chains `1.0 → 1.1 → 1.2`; - - export `1.3` adds control-plane contract `1.0`, profile context, - profile summary, profile recommendation, and active selection while - preserving Slice 1.1 comparison keys; - - export `1.2` separated formal validity from interpretation, - exposes full-versus-limited projection, bounded preview disclosure, - partition metrics, and nullable all-run sweep comparison facts; - - representation `3` retains raw representation-owned input hashing and - materializes explicit trajectory, Patch Trail, and registry-overlay - presence facts for new snapshots. Registry state remains outside source - identity and existing contract-2 snapshots are not rewritten; - - embedding `2` defines vector digests over canonical little-endian - float32 bytes. Older embedding generations are rejected and must be - regenerated. - -Operational compatibility rules: - -- runtime writes baseline schema `2.1` -- runtime accepts clone baseline `1.0`, `2.0`, and `2.1` -- runtime writes standalone metrics-baseline schema `1.2` -- runtime accepts standalone metrics-baseline `1.x` where the baseline minor - version is less than or equal to the runtime minor (currently through `1.2`) -- runtime writes cache schema `2.10` -- MCP does not define a separate schema constant; tool/resource semantics are - package-versioned public surface -- adding or changing an MCP tool is a package-versioned interface change and - requires tests, docs, changelog, and tool-schema snapshot updates; it does not - bump the canonical report schema unless report JSON changes -- implementation-context payload/resolver changes bump their subsystem-local - versions in `codeclone/surfaces/mcp/_implementation_context.py`; the - `context_artifact_digest` and `context_projection_digest` use canonical - sorted-key JSON and bare-hex SHA-256. Off-report manifests and relationship - projections do not bump the report schema. - -Baseline regeneration is required when: - -- `fingerprint_version` changes -- `python_tag` changes - -It is not required for package patch/minor updates when compatibility gates still pass. - -## Health model evolution - -CodeClone does not currently define a separate health-model version constant. -Health semantics are package-versioned behavior and must be documented in: - -- this chapter -- [15-health-score.md](15-health-score.md) -- release notes - -A lower score after upgrade may reflect a broader scoring model, not only worse code. - -## Invariants (MUST) - -- Contract changes require code + tests + changelog/docs updates. -- Schema mismatches map to explicit statuses. -- Legacy baselines stay untrusted and require regeneration. - -Refs: - -- `codeclone/baseline/trust.py:BaselineStatus` -- `codeclone/baseline/clone_baseline.py:_is_legacy_baseline_payload` - -## Failure modes - -| Change type | User impact | -|--------------------------------|------------------------------------------------------------------------------| -| Baseline schema bump | Older unsupported baselines become untrusted until regenerated | -| Fingerprint bump | Clone IDs change; baseline regeneration required | -| Cache schema bump | Old caches are ignored and rebuilt automatically | -| Report schema bump | Downstream report consumers must update | -| Metrics-baseline schema bump | Dedicated metrics-baseline files must be regenerated | -| Engineering Memory schema bump | Older DBs migrate or re-init per `schema_migrate.py` | -| Semantic index format bump | LanceDB sidecar invalidated; run `memory semantic rebuild` | -| Platform Observability bump | Local diagnostic store reader/writer must migrate together | -| Corpus analytics store bump | Writable open migrates supported stores; read-only open rejects stale schema | -| Corpus embedding contract bump | Existing generations must be regenerated before clustering | - -## Determinism / canonicalization - -- Version constants are explicit and enforced in code. -- Compatibility decisions are runtime checks, not doc-only expectations. - -Refs: - -- `codeclone/contracts/__init__.py` -- `codeclone/baseline/clone_baseline.py:Baseline.verify_compatibility` -- `codeclone/baseline/metrics_baseline.py:MetricsBaseline.verify_compatibility` - -## Locked by tests - -- `tests/test_baseline.py::test_baseline_verify_schema_incompatibilities` -- `tests/test_baseline.py::test_baseline_verify_schema_incompatibilities[schema_major_mismatch]` -- `tests/test_baseline.py::test_baseline_verify_fingerprint_mismatch` -- `tests/test_cache.py::test_cache_v_field_version_mismatch_warns` -- `tests/test_report.py::test_report_json_compact_v21_contract` - -## Non-guarantees - -- Backward compatibility is not guaranteed across incompatible schema/fingerprint bumps. -- Health Score is not mathematically frozen forever; the obligation to document scoring-model changes is. diff --git a/docs/book/25-mcp-interface/determinism-and-tests.md b/docs/book/25-mcp-interface/determinism-and-tests.md deleted file mode 100644 index 25dab987..00000000 --- a/docs/book/25-mcp-interface/determinism-and-tests.md +++ /dev/null @@ -1,48 +0,0 @@ - - -# MCP Security, Determinism, and Tests - -Tool inventory and payload contracts: -[MCP interface](index.md). Platform diagnostics: -[Platform Observability tool](tools/platform-observability.md). - -## Security model - -| Property | Guarantee | -|-------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Default transport | Local `stdio` | -| HTTP auth | `streamable-http` requires `CODECLONE_MCP_AUTH_TOKEN` (≥32 characters) for every start — loopback or remote; no unauthenticated HTTP mode | -| Remote exposure | Explicit `--allow-remote` required for non-loopback bind | -| Lazy loading | Base installs and CI do not require MCP packages | -| Read-only | Never mutates source, baseline, cache, or canonical report artifacts; may write the ephemeral workspace intent registry under `.codeclone/`, optional audit/observability DBs, Engineering Memory **draft** rows, and projection job metadata when enabled | - ---- - -## Determinism - -- Run identity is derived from canonical report integrity digest. -- Summary, hotspots, findings, and remediation payloads are deterministic - projections over stored run state. -- MCP must not create MCP-only analysis semantics or MCP-only gate - semantics. - ---- - -## Locked by tests - -- `tests/test_mcp_service.py` -- `tests/test_mcp_server.py` -- `tests/test_mcp_tool_schema_snapshot.py` -- `tests/test_observability_mcp_registrar.py` -- `tests/test_observability_query.py` - ---- - -## See also - -- [14-claim-guard.md](../14-claim-guard.md) — citation-based review validation -- [12-structural-change-controller/index.md](../12-structural-change-controller/index.md) — change control workflow -- [11-cli.md](../11-cli.md) — CLI reference -- [05-report.md](../05-report.md) — canonical report schema -- [MCP deep dive](../../guide/mcp/README.md) — architecture, client setup, workflows, and prompt patterns -- [Platform Observability](../26-platform-observability.md) — observer storage, privacy, and anti-inference contract diff --git a/docs/book/25-mcp-interface/index.md b/docs/book/25-mcp-interface/index.md deleted file mode 100644 index 945d5707..00000000 --- a/docs/book/25-mcp-interface/index.md +++ /dev/null @@ -1,144 +0,0 @@ -# MCP Interface - -Agent workflows and setup: [MCP guide](../../guide/mcp/README.md). - -## Purpose - -Define the public MCP surface in the CodeClone **`2.1.0a1`** release line -(structural change controller + Engineering Memory MCP tools are live in this alpha). - -The MCP layer is optional and built on the same canonical pipeline/report -contracts as the CLI. It does not create a second analysis engine. - -!!! note "Integration surface, not a second analyzer" - MCP composes over the canonical report and run state shared by CLI, HTML, - and SARIF. It **never** mutates source files, baselines, analysis cache - (`.codeclone/cache.json`), or canonical report artifacts. It **may** write - ephemeral workspace intent records, Engineering Memory **drafts** (human - approve required), and optional audit evidence when enabled. - ---- - -## Public surface - -| Artifact | Path | -|-------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| Package extra | `codeclone[mcp]` | -| Launcher | `codeclone-mcp` | -| Server wiring | `codeclone/surfaces/mcp/server.py` | -| Message catalog | `codeclone/surfaces/mcp/messages/*` (`tools`/`resources` titles, `help_topics`, `params`, `workflow`, `intent`, `errors`, patch-contract/verification copy, …) | -| Service / session | `codeclone/surfaces/mcp/service.py`, `codeclone/surfaces/mcp/session.py` | - ---- - -## Shape - -```mermaid -graph LR - subgraph Server["codeclone-mcp"] - T["Transport
stdio · streamable-http"] - SVC["Service
tool routing, shutdown"] - SESS["Session
runs, intents, markers"] - CTX["Implementation context
bounded · drift-aware"] - end - - T --> SVC --> SESS - SESS -->|" reads "| RP["Canonical Report"] - SESS --> CTX - CTX -->|" binds "| RP - SESS -->|" writes "| WIR["Workspace intents
file or sqlite backend"] - style Server stroke: #6366f1, stroke-width: 2px - style WIR fill: #fef9c3 -``` - -Current server characteristics: - -- **Optional dependency** — base `codeclone` install does not require MCP - runtime packages. -- **Transports** — `stdio` (default), `streamable-http` (Bearer auth required). -- **HTTP flags** — `--json-response` (default on), `--stateless-http` (default - on), `--debug`, `--log-level` (`DEBUG`–`CRITICAL`, default `INFO`). -- **Run storage** — in-memory only, bounded by `--history-limit` (default 4, - max 10). Latest-run pointer is process-local. -- **Roots** — analysis tools require an absolute repository root. Relative - roots such as `.` are rejected. -- **Analysis modes** — `full`, `clones_only`. -- **Cache policies** — `reuse` (default) and `off` only; `refresh` is CLI-only - and rejected by MCP. -- **Workspace intent registry** — `intent_registry_backend` selects `file` - (ephemeral JSON under `.codeclone/intents/`) or `sqlite` (auditable - rows under `.codeclone/db/intents.sqlite3` with closed-row retention; - default 14 days, configurable). See - [Plans and Retention](../../plans-and-retention.md). - -!!! warning "Absolute roots and HTTP transport" - Analysis tools require an absolute repository root. Every - `streamable-http` start requires `CODECLONE_MCP_AUTH_TOKEN` (≥32 characters); - the server exits without it. Non-loopback binding additionally requires - `--allow-remote`. See - [Environment variable overrides](../10-config-and-defaults.md#mcp-http-authentication) - and [Security Model](../21-security-model.md). - ---- - -## Contract rules - -- MCP is **read-only** with respect to source files, baselines, analysis - cache (`cache.json`), and report artifacts. -- MCP reuses the same canonical report document as CLI/JSON/HTML/SARIF. -- Finding IDs, ordering, and summary data are deterministic projections over - the stored run. -- `analyze_changed_paths` requires either explicit `changed_paths` or - `git_diff_ref`. -- Analysis tools require an absolute `root`. -- `check_*` tools may resolve against a stored run; if `root` is provided it - must be absolute. -- `git_diff_ref` is validated before any subprocess call. -- Review markers are session-local in-memory state only. -- Change intent, blast-radius cache, and workspace registry state do not - enter canonical report integrity, baseline, or cache artifacts. -- Run history is process-local and does not survive restart. -- `get_implementation_context` reads one existing run and reports live - workspace drift; it never auto-analyzes or authorizes an edit. -- MCP accepts cache policies `reuse` and `off`; `refresh` is rejected at runtime. -- Missing optional MCP dependency is surfaced explicitly by the launcher. -- `metrics_detail(family="security_surfaces")` exposes a compact, report-only - inventory of security-relevant capability surfaces. It does not claim - vulnerabilities or exploitability. -- `validate_review_claims` detects deterministic overclaims. See - [14-claim-guard.md](../14-claim-guard.md) for the full pattern catalog. - ---- - -## Tools - -Current tool set: **33 tools** for agent clients, organized by workflow phase. - -When the MCP server starts with `--ide-governance-channel` (CodeClone VS Code -extension), two additional read-only tools register: -`get_workspace_session_stats` and `get_controller_audit_trail` (**35 tools** -total). They are not listed in generic agent tool catalogs; payloads mirror CLI -`--session-stats` and `--audit` via `codeclone/controller_insights/`. - -```mermaid -graph LR - A["1. Analyze"] --> T["2. Triage"] - T --> D["3. Drill down"] - T --> F["4. Focused checks"] - D --> C["5. Implementation context"] - F --> C - C --> CC["6. Change control"] - CC --> S["7. Session"] - style A fill: #dbeafe - style T fill: #dbeafe - style CC fill: #f0fdf4 -``` - -The surface is intentionally triage-first: analyze → summarize/triage → -drill into one finding or one hotspot family. - -Tool families and exact parameters are split under -[Tools](tools/analysis.md), including -[Implementation context](tools/implementation-context.md), -[Help topics](tools/help-and-topics.md), and the -[Platform Observability slicer](tools/platform-observability.md). diff --git a/docs/book/25-mcp-interface/payload-conventions.md b/docs/book/25-mcp-interface/payload-conventions.md deleted file mode 100644 index c3ae3569..00000000 --- a/docs/book/25-mcp-interface/payload-conventions.md +++ /dev/null @@ -1,167 +0,0 @@ - - -# MCP payload conventions - -## Payload conventions - -Short reference for response structure patterns across the tool surface. - -**IDs** — Run IDs are 8-char hex handles. Finding IDs are short prefixed -forms. Both accept the full canonical form as input. - -**Detail levels** — `summary` (default for lists), `normal` (default for -single finding), `full` (compatibility payload with URIs). - -**Pagination** — `list_findings` and -`get_report_section(section="metrics_detail")` support `offset` and `limit`. -`list_hotspots` supports `limit` and `max_results` only (no `offset`). - -**Changed-scope filters** — `list_findings`, `list_hotspots`, and -`generate_pr_summary` accept `changed_paths` or `git_diff_ref` for PR -projection. - -**Threshold context** — Empty `check_*` responses include -`threshold_context` showing whether the run is genuinely quiet or simply -below the active threshold. - -**Budget nulls** — `check_patch_contract` uses `null` for disabled numeric -thresholds. Boolean policy gates use `forbid_*` names. - -**Long context** — `do_not_touch`, `review_context`, and similar sections -include `total`, `shown`, and `truncated` summaries. - -## Response governance compatibility audit - -Response context governance rolls out additively. Until capability metadata -advertises a leaner shape, clients must treat the current payload shape as the -compatibility contract. - -Current `finish_controlled_change` compatibility facts: - -| Field | Current role | Compatibility decision | -|----------------------------------------------------------|-----------------------------------------------------------|---------------------------------------------------------------------------------------| -| `summary.receipt` | compact created / skipped / failed status | keep; dashboards and skills use it as the receipt status signal | -| `receipt.receipt_version` / `verdict` / `receipt_digest` | top-level receipt identity and compact routing fields | prefer for identity checks before drill-down | -| `receipt.content` | complete human-readable markdown receipt | keep inline while finish remains observe-only | -| `receipt.receipt_retrieval` | route to the durable structured receipt | use `get_review_receipt(..., format="structured")` for machine-readable receipt facts | -| `receipt.receipt` | legacy duplicate typed alias nested under markdown output | omitted by default; do not depend on it in finish responses | -| `receipt_error` | receipt failure reason | keep; failed receipt creation prevents `auto_clear` | - -Client and integration audit: - -| Surface | Current dependency | Response-governance requirement | -|-----------------------|-----------------------------------------------------------------------------|-------------------------------------------------------------------| -| MCP tests / snapshots | assert `summary.receipt`, receipt identity, and durable receipt retrieval | update first when finish packing semantics change | -| VS Code extension | discovers tools through `tools/list`; does not own a separate finish schema | tolerate current shape and future capability metadata | -| Claude Desktop bundle | launches `codeclone-mcp`; no independent payload parser | no bundle shape change before MCP capability metadata | -| Claude Code plugin | skills describe the workflow, not a custom parser | sync skills when finish response governance is enforced | -| Codex plugin | skills describe the workflow, not a custom parser | sync skills when finish response governance is enforced | -| Cursor plugin | skills/rules describe the workflow and receipt requirement | sync skills and rules when finish response governance is enforced | - -Before any default payload removal, MCP must advertise pre-call capability -metadata for the response-governance contract. Clients should be able to detect: - -- context-governance contract version; -- passive `observe` mode vs enforced response budgets; -- whether `finish_controlled_change` still includes the typed receipt alias; -- whether durable receipt, Patch Trail, blast-radius, implementation-context - page, and omitted-evidence drill-down resources are available. - -Payload slimming without that metadata is a contract break, even during alpha. - -### Response `context_governance` - -Selected workflow and evidence tools include a `context_governance` envelope. -It estimates the returned response and declares whether the tool only measured -the payload or actually applied response-budget packing: - -| Field | Meaning | -|------------------------------------|------------------------------------------------------------------------------------------------------------| -| `contract_version` | response-governance contract version | -| `estimator` | deterministic estimator, currently `utf8_bytes_div_4_v1` | -| `estimated` | estimated context units for the serialized response with `estimated` normalized to `0` during measurement | -| `limit` | active default response target | -| `mode` | `observe` for measurement-only responses; `partial_enforce` when recoverable lanes may be compacted | -| `enforcement.response_budget` | whether the response is packed against the declared context-unit budget | -| `enforcement.nested_budget` | whether nested evidence calls receive a propagated remaining budget | -| `enforcement.omission` | whether omitted lanes are disclosed through `context_governance.omitted` | -| `enforcement_blocked` | missing exact retrieval capabilities that prevent safe response-budget enforcement | -| `capabilities.typed_receipt_alias` | whether the legacy typed receipt alias contract is still recognized; default finish uses retrieval instead | -| `drill_down` | exact object routes and blocked continuation/snapshot routes for evidence that may be omitted | -| `response` | optional tool-specific response budget scope and projection digest | - -`mode="observe"` means the response is measured but not packed. It is expected -for drill-down retrieval tools that already return one exact object or one exact -page, such as `get_blast_artifact`, `get_review_receipt`, `get_patch_trail`, -`get_memory_projection_page`, and `get_implementation_context_page`. -`mode="partial_enforce"` means mandatory control facts stay inline while -recoverable evidence lanes may be compacted or omitted with exact drill-down. -Neither mode authorizes edits, weakens findings, or replaces tool-specific -contracts. - -Platform Observability uses `context_governance.estimated` as the MCP response -context-pressure estimate when the envelope is present. Older observer storage -fields may still be named `response_tokens`; treat their values as deterministic -context units, not model-specific tokenizer counts. - -For `finish_controlled_change`, `context_governance.response` describes the -whole returned finish response. It includes `tool="finish_controlled_change"`, -`budget_scope="whole_response"`, -`evidence_policy="response_budget_with_durable_artifact_lookup"`, and a -`finish_projection_v1` digest. Finish responses use `partial_enforce`: control -and safety facts remain inline, while recoverable advisory lanes such as receipt -markdown content or Patch Trail detail may be compacted. Omitted finish lanes -are disclosed under `context_governance.omitted` and point to -`get_review_receipt(root, run_id, receipt_digest, format=...)` or -`get_patch_trail(root, patch_trail_digest, format="structured")`. - -For `start_controlled_change`, `context_governance.response` describes the -whole returned start response with `tool="start_controlled_change"` and a -`start_projection_v1` digest. When a durable blast artifact is stored, default -start responses carry a safety-complete blast summary and a -`blast_artifact` pointer. Full omitted blast evidence is retrieved exactly with -`get_blast_artifact(root, run_id, blast_artifact_id)`. `get_blast_radius` -remains current recomputation, not historical drill-down. If artifact storage is -unavailable, start returns full blast evidence inline. Start responses use -`partial_enforce` only when an immutable blast artifact is available; fallback, -queued, and needs-analysis responses stay in `observe`. - -For `get_relevant_memory`, `context_governance.response` describes the whole -memory retrieval response with `tool="get_relevant_memory"` and a -`memory_retrieval_projection_v1` digest. The existing `records`, -`trajectories`, `experiences`, coverage, and retrieval-policy fields remain -present according to their current lane caps. When a lane has an omitted tail, -`continuation.lanes..page` carries a digest-bound cursor for -`get_memory_projection_page`. The page route is an exact continuation only while -the normalized request, lane ordering version, and lane identity digest still -match; otherwise it fails closed with `snapshot_mismatch`. `context_governance` -uses `partial_enforce` for compact scoped retrieval and `observe` for -`detail_level="full"` and for exact continuation pages. - -For `get_implementation_context`, `context_governance.response` describes the -whole implementation-context response with `tool="get_implementation_context"` -and an `implementation_context_projection_v1` digest. The existing -`budget_summary` remains an item-count budget for emitted context entries; it is -not the serialized response context budget. The response also carries -`analysis.context_page_retrieval` when exact session-local facet pages are -available. Use `get_implementation_context_page(root, context_projection_digest, -facet)` with `analysis.context_projection_digest` to retrieve a saved facet -lane. This is exact only for the MCP session run-history artifact; if the -projection is gone, the tool returns `status="not_found"` instead of -recomputing fresh context. Implementation-context responses use -`partial_enforce` for compact/normal detail and propagate nested memory budget; -`detail_level="full"` and exact facet pages stay in `observe`. - -Current drill-down reachability is intentionally conservative: - -- known memory records and known trajectories have exact object lookup through - `query_engineering_memory`; known Experiences use - `query_engineering_memory(mode="experience_get")`; -- omitted memory record, trajectory, and Experience tails have digest-bound - continuation through `get_memory_projection_page`; -- structured receipts, Patch Trail, and blast artifacts have durable exact - retrieval routes; -- implementation-context facet pages have exact session-local retrieval through - `get_implementation_context_page`. - ---- diff --git a/docs/book/25-mcp-interface/resources.md b/docs/book/25-mcp-interface/resources.md deleted file mode 100644 index 750cf9d2..00000000 --- a/docs/book/25-mcp-interface/resources.md +++ /dev/null @@ -1,28 +0,0 @@ -## Resources - -Resources are deterministic read-only projections over stored runs. They do -not trigger analysis. - -### Fixed resources (7) - -| URI | Content | -|----------------------------------|-------------------------------------------------| -| `codeclone://latest/summary` | Compact summary for the latest stored run | -| `codeclone://latest/report.json` | Canonical JSON report for the latest stored run | -| `codeclone://latest/health` | Health/metrics snapshot | -| `codeclone://latest/gates` | Last gate-evaluation result | -| `codeclone://latest/changed` | Changed-files projection | -| `codeclone://latest/triage` | Production-first triage payload | -| `codeclone://schema` | Canonical report shape descriptor | - -### Run-scoped templates (3) - -| URI template | Content | -|---------------------------------------------------|---------------------------------| -| `codeclone://runs/{run_id}/summary` | Summary for a specific run | -| `codeclone://runs/{run_id}/report.json` | Report for a specific run | -| `codeclone://runs/{run_id}/findings/{finding_id}` | One finding from a specific run | - -`codeclone://latest/*` always resolves to the most recent run. - ---- diff --git a/docs/book/25-mcp-interface/tools/analysis.md b/docs/book/25-mcp-interface/tools/analysis.md deleted file mode 100644 index 547fc48d..00000000 --- a/docs/book/25-mcp-interface/tools/analysis.md +++ /dev/null @@ -1,52 +0,0 @@ -### Analysis and run-level tools - -| Tool | Key parameters | Purpose | -|------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `analyze_repository` | `root`, `analysis_mode`, thresholds, `api_surface`, `coverage_xml`, `baseline_path`, `metrics_baseline_path`, `cache_policy`, `allow_external_artifacts`, `changed_paths` or `git_diff_ref` | Full deterministic analysis; registers an in-memory run | -| `analyze_changed_paths` | `root`, `changed_paths` or `git_diff_ref`, `analysis_mode`, thresholds, `api_surface`, `coverage_xml`, `cache_policy`, `allow_external_artifacts` | Diff-aware analysis with changed-files projection | -| `get_run_summary` | `run_id` | Cheapest run-level snapshot: health, findings, baseline/cache status | -| `get_production_triage` | `run_id`, `max_hotspots`, `max_suggestions` | Production-first first-pass view | -| `get_implementation_context` | `root`, `paths`, `symbols`, `intent_id`, `changed_scope`, `mode`, `include`, `depth`, `detail_level`, `budget`, `run_id` | Bounded, drift-aware structural context from one stored run | -| `compare_runs` | `run_id_before`, `run_id_after`, `focus` | Run-to-run delta; returns `incomparable` when roots/settings differ | -| `evaluate_gates` | `run_id`, gate flags (`fail_on_new`, `fail_threshold`, `fail_complexity`, `fail_coupling`, `fail_cohesion`, `fail_cycles`, `fail_dead_code`, `fail_health`, `fail_on_new_metrics`, `fail_on_typing_regression`, `fail_on_docstring_regression`, `fail_on_api_break`, `fail_on_untested_hotspots`, `min_typing_coverage`, …), `coverage_min` | Preview CI gating decisions without mutating state — same gate vocabulary as [CLI flags](../../11-cli.md) and [Metrics and quality gates](../../16-metrics-and-quality-gates.md); threshold ints use `-1` to disable | -| `help` | `topic`, `detail` | Bounded workflow/contract guidance — see [Help topics](help-and-topics.md) | - -`allow_external_artifacts` (default `false`): when `true`, optional artifact -path parameters may resolve to absolute or out-of-repo locations. See -[Security Model](../../21-security-model.md). - -Selected analysis and workflow responses may include non-blocking `tips[]` -entries for workspace hygiene (for example when `.codeclone/` is not -covered by the repository root `.gitignore`). The CLI prints the same -advisory after interactive analysis runs (suppressed in `--quiet`, CI, and -non-TTY contexts). Tips are advisory only; MCP and CLI never edit -`.gitignore` automatically. - -## Implementation context - -`get_implementation_context` projects bounded structural, call-graph, contract, -and memory evidence from one stored run. It is read-only and never authorizes -edits. - -Compact and normal responses include `context_governance` with -`mode="partial_enforce"` and -`evidence_policy="response_budget_with_exact_facet_pages"`. Mandatory subject -and freshness facts remain inline; large facet lanes may be omitted with exact -`get_implementation_context_page` drill-down metadata under -`context_governance.omitted`. The existing `budget_summary` remains an -item-count budget for context entries; `context_governance.limit` is the -serialized response budget. `detail_level="full"` and facet page retrieval stay -in `mode="observe"`. - -Key parameters: - -- `changed_scope` — when `true`, use the bounded live git-dirty set as the - subject; mutually exclusive with explicit `paths` or `symbols`. -- `mode` — `implementation` (default), `impact`, or `contract`. -- `budget` — global evidence cap; safety entries can trigger - `status="safety_context_overflow"`. -- `freshness.status="drifted"` — re-analyze before relying on the projection. - -Full contract (modes, facets, digests, intent pinning, symbol resolution): -[Implementation context](implementation-context.md). Quick orientation: -`help(topic=implementation_context)`. diff --git a/docs/book/25-mcp-interface/tools/atomic-change-control.md b/docs/book/25-mcp-interface/tools/atomic-change-control.md deleted file mode 100644 index 9ed8672f..00000000 --- a/docs/book/25-mcp-interface/tools/atomic-change-control.md +++ /dev/null @@ -1,45 +0,0 @@ -### Atomic change control tools (advanced / diagnostic) - -| Tool | Key parameters | Purpose | -|-----------------------------|--------------------------------------------------------------------------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `manage_change_intent` | `action`, `root`, `run_id`, `intent_id`, `scope`, `on_conflict`, `ttl_seconds`, `lease_seconds`, `changed_files` or `diff_ref` | Intent lifecycle: declare, get, check, clear, renew, promote, list_workspace, gc_workspace, recover, reset_workspace. Use for queue/promote/recover operations alongside workflow tools | -| `get_blast_radius` | `run_id`, `files`, `depth`, `include` | Pre-change risk boundary: full transitive graph, custom include filters | -| `get_implementation_context_page` | `root`, `context_projection_digest`, `facet`, `offset`, `page_size`, optional `run_id` | Exact page from a saved `get_implementation_context` facet lane. Uses `analysis.context_projection_digest`; returns `not_found` after the projection leaves MCP run history and never recomputes fresh context while claiming exact omitted evidence. | -| `get_relevant_memory` | `root`, `scope`, `intent_id`, `symbols`, `max_records`, `include_stale`, `include_drafts`, `detail_level` | Ranked engineering memory for declared edit scope. `trajectories[]` excludes routine `run:*` workflows by default (same semantics as `include_routine=false` on retrieval). For explicit routine control use `query_engineering_memory` trajectory modes with `filters.include_routine`. Compact by default: bounded record/trajectory subjects plus typed `records`, `experiences`, `trajectories`, and `coverage` lanes. Omitted lane tails expose digest-bound continuation cursors for `get_memory_projection_page`. Auto-bootstraps store when `mcp_sync_policy=bootstrap_if_missing` (default). See [Engineering Memory](../../13-engineering-memory/index.md) | -| `get_memory_projection_page` | `root`, `cursor`, `page_size` | Exact continuation for a `get_relevant_memory` omitted lane tail. Uses the cursor from `continuation.lanes..page`; returns `snapshot_mismatch` instead of continuing if the underlying memory projection no longer matches the cursor digest. | -| `query_engineering_memory` | `root`, `mode`, …, optional `semantic` (search only), `detail_level` | Mode router: search, get, for_path, for_symbol, stale, drafts, coverage, status, trajectory_status, trajectory_search, trajectory_get, experience_get, trajectory_anomalies, trajectory_agents, trajectory_dashboard. List/search modes default compact; `get`, `trajectory_get`, `experience_get`, or `detail_level=full` are explicit drill-down. `filters` supports `types`, `statuses`, `confidences`, and `match_mode` (`any`\|`all`) for search. `semantic=true` blends LanceDB proximity when `[tool.codeclone.memory.semantic] enabled` and index built (default off). See [Engineering Memory](../../13-engineering-memory/index.md) | -| `manage_engineering_memory` | `root`, `action`, … | Agent-side: `refresh_from_run`, `record_candidate`, `promote_experience`, `validate_claims`, `propose_from_receipt`, `rebuild_semantic_index`, `rebuild_trajectories`, `enqueue_projection_rebuild`, `projection_rebuild_status`, `run_projection_jobs_once`. `promote_experience` creates a human-reviewable draft; human approve/reject/archive remains VS Code/CLI only. See [Engineering Memory](../../13-engineering-memory/index.md) | -| `check_patch_contract` | `mode`, `run_id`, `before_run_id`, `after_run_id`, `intent_id`, `strictness`, `changed_files` or `diff_ref` | Manual budget query or step-by-step verification | -| `create_review_receipt` | `run_id`, `intent_id`, `format`, `include_blast_radius`, `include_patch_contract` | Manual receipt generation | -| `validate_review_claims` | `text`, `run_id`, `require_citations`, `patch_health_delta` | Standalone citation-based overclaim detection; pass `patch_health_delta` from verify when using the atomic workflow | - -??? info "Blast radius: do_not_touch vs review_context" - `do_not_touch` is limited to actionable negative context: baselines, - generated CodeClone state, explicit forbidden paths. Report-only signals - such as security boundary inventory and overloaded-module candidates are - returned as `review_context` — information, not edit prohibitions. Long - context sections include `total`, `shown`, and `truncated` summaries. - -??? info "Patch contract modes" - **Budget** reads one stored run and optional intent. Shows regression - headroom per quality dimension before editing. Queued intents return - `edit_allowed=false`. **Verify** compares explicit before/after stored - runs, previews gates, validates scope, and reports baseline-abuse - signals. When `intent_id` is provided but `before_run_id` is omitted, - verify auto-resolves the before-run from the intent record. Missing runs - return `status="unverified"`. Identical before/after runs for - `python_structural` / `governance_config` return - `reason="after_run_not_new"`. Non-accepted responses include a - `next_step` hint and `claim_validation_recommended` flag. - - Verify regressions are run-relative, not baseline-novelty-relative: a - finding absent from the clean before-run and present in the after-run is a - patch regression even when its fingerprint is `novelty="known"` against - the trusted baseline. - - When a change intent is active, verify mode attributes regressions and - gate changes to the declared scope. Intent-scope regressions produce - contract violations; external regressions are reported as informational - context. Queued intents are rejected with `reason="intent_not_active"`. - See [Scope-Aware Patch Contract Verification](../../12-structural-change-controller/patch-contract-verify.md) - and [Verify Ergonomics](../../12-structural-change-controller/workflow-tools.md). diff --git a/docs/book/25-mcp-interface/tools/checks.md b/docs/book/25-mcp-interface/tools/checks.md deleted file mode 100644 index 1c3e4add..00000000 --- a/docs/book/25-mcp-interface/tools/checks.md +++ /dev/null @@ -1,9 +0,0 @@ -### Focused check tools - -| Tool | Key parameters | Purpose | -|--------------------|----------------------------------------------------------------------------------------|--------------------------| -| `check_clones` | `run_id` or `root`, `path`, `clone_type`, `source_kind`, `max_results`, `detail_level` | Narrow clone-only query | -| `check_complexity` | `run_id` or `root`, `path`, `min_complexity`, `max_results`, `detail_level` | Complexity hotspot query | -| `check_coupling` | `run_id` or `root`, `path`, `max_results`, `detail_level` | Coupling hotspot query | -| `check_cohesion` | `run_id` or `root`, `path`, `max_results`, `detail_level` | Cohesion hotspot query | -| `check_dead_code` | `run_id` or `root`, `path`, `min_severity`, `max_results`, `detail_level` | Dead code query | diff --git a/docs/book/25-mcp-interface/tools/help-and-topics.md b/docs/book/25-mcp-interface/tools/help-and-topics.md deleted file mode 100644 index fb68b81c..00000000 --- a/docs/book/25-mcp-interface/tools/help-and-topics.md +++ /dev/null @@ -1,73 +0,0 @@ -# Help topics - -The `help` tool returns bounded workflow and contract guidance without pulling -canonical report payloads. Call `help(topic=…)` after analysis when tool or -profile semantics are unclear. - ---- - -## Parameters - -| Parameter | Default | Values | -|-----------|--------------|-------------------------------------------------------------------------------------------------| -| `topic` | — (required) | One of the 14 topics below | -| `detail` | `compact` | `compact` (summary, key points, recommended tools, anti-patterns) or `normal` (adds `warnings`) | - -`compact` always includes `anti_patterns` when the topic defines them. `normal` -adds `warnings`. Both levels return `summary`, `key_points`, `recommended_tools`, -and `doc_links`. - ---- - -## Topic catalog - -| Topic | Summary focus | Recommended first tools | -|--------------------------|-------------------------------------------------------------|----------------------------------------------------------------| -| `workflow` | Triage-first, budget-aware MCP usage | `analyze_repository`, `get_production_triage`, `list_hotspots` | -| `analysis_profile` | Conservative default thresholds vs exploratory lower limits | `analyze_repository`, `compare_runs` | -| `suppressions` | Declaration-scoped inline ignore policy | `get_finding`, `get_remediation` | -| `baseline` | Trusted comparison snapshot and baseline-relative novelty | `get_run_summary`, `evaluate_gates`, `compare_runs` | -| `coverage` | Cobertura join as current-run signal only | `analyze_repository`, `get_report_section` | -| `latest_runs` | Session-local `latest/*` resource handles | `analyze_repository`, `get_run_summary` | -| `review_state` | Session-local reviewed markers | `mark_finding_reviewed`, `list_hotspots` | -| `changed_scope` | PR/patch-focused changed-files review | `analyze_changed_paths`, `generate_pr_summary` | -| `change_control` | `start` / `finish` edit cycle | `start_controlled_change`, `finish_controlled_change` | -| `trust_boundaries` | Read-only MCP, artifact paths, Security Surfaces inventory | `help`, `get_run_summary` | -| `implementation_context` | Bounded context from one stored run | `get_implementation_context` | -| `observability` | Dev-only Platform Observability slicer | `query_platform_observability` | -| `engineering_memory` | Scoped memory retrieval and draft writes | `get_relevant_memory`, `query_engineering_memory` | -| `verification_profiles` | Finish-derived verification profiles and after-run rules | `finish_controlled_change`, `analyze_repository` | - ---- - -## When to call - -| Situation | Topic | -|---------------------------------------|--------------------------| -| First MCP session on a repository | `workflow` | -| Threshold or sensitivity questions | `analysis_profile` | -| Baseline / new-vs-known confusion | `baseline` | -| Before declaring an edit intent | `change_control` | -| Finish blocked on after-run / profile | `verification_profiles` | -| `get_implementation_context` facets | `implementation_context` | -| Memory lanes, drafts, trajectories | `engineering_memory` | -| HTTP auth, artifact paths, read-only | `trust_boundaries` | -| Debugging CodeClone runtime (maintainer) | `observability` | - ---- - -## Maintainer-only: `observability` - -Call `help(topic="observability")` and use `query_platform_observability` **only** -when developing **CodeClone itself** — not when reviewing a user's Python -repository. Requires `CODECLONE_OBSERVABILITY_ENABLED=1` on the producing -process before any store exists. See -[Maintainer workflow](../../../guide/observability/maintainer-workflow.md). - ---- - -## Related - -- Tool parameters: [Analysis tools](analysis.md) -- Implementation context contract: [Implementation context](implementation-context.md) -- Engineering Memory playbook: [Engineering Memory](../../13-engineering-memory/index.md) diff --git a/docs/book/25-mcp-interface/tools/ide-governance.md b/docs/book/25-mcp-interface/tools/ide-governance.md deleted file mode 100644 index 184dd3c4..00000000 --- a/docs/book/25-mcp-interface/tools/ide-governance.md +++ /dev/null @@ -1,16 +0,0 @@ -### IDE-only tools (`--ide-governance-channel`) - -Registered only when the MCP launcher passes `--ide-governance-channel` (VS Code -extension). Agent MCP clients without that flag do not see these tools in -`list_tools`. - -| Tool | Key parameters | Purpose | -|-------------------------------|-------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `get_workspace_session_stats` | `root` | Workspace agents, intents, leases — same collector as CLI `--session-stats` | -| `get_controller_audit_trail` | `root`, `limit`, `audit_path` | Audit trail + payload footprint — same collector as CLI `--audit`. `limit` caps recent events (default 50). `audit_path` overrides the audit database location | - -Requires `audit_enabled=true` for meaningful audit rows. Payload footprint -`top_workflows` entries expose workflow metrics as `calls` and `tokens` (see -`codeclone/controller_insights/audit_trail.py`). - ---- diff --git a/docs/book/25-mcp-interface/tools/implementation-context.md b/docs/book/25-mcp-interface/tools/implementation-context.md deleted file mode 100644 index ff065e02..00000000 --- a/docs/book/25-mcp-interface/tools/implementation-context.md +++ /dev/null @@ -1,118 +0,0 @@ -# Implementation context - -`get_implementation_context` is a read-only projection over one stored MCP run. -It never re-analyzes, never changes `edit_allowed`, and never substitutes -`start_controlled_change`. For workflow placement, see -[Analysis tools](analysis.md). - ---- - -## Parameters - -| Parameter | Default | Purpose | -|-----------------|------------------|------------------------------------------------------------------------------------------------------| -| `root` | — (required) | Absolute repository root | -| `paths` | `null` | Repo-relative file or directory subjects | -| `symbols` | `null` | `module:symbol` qualnames (colon separator; dot notation rejected) | -| `intent_id` | `null` | Active intent — pins run and adds `change_control` block | -| `changed_scope` | `false` | Use bounded live git-dirty set as subject; **mutually exclusive** with explicit `paths` or `symbols` | -| `mode` | `implementation` | `implementation`, `impact`, or `contract` | -| `include` | `null` | Optional closed facet set | -| `depth` | `1` | Structural traversal depth (`0`–`3`) | -| `detail_level` | `compact` | `compact`, `normal`, or `full` | -| `budget` | `50` | Global evidence-entry cap (`1`–`200`) | -| `run_id` | `null` | Stored run; latest when omitted | - -`changed_scope=true` selects the dirty set explicitly. Without explicit -subjects, precedence is: paths/symbols → active intent `allowed_files` → bounded -git-dirty set. A clean tree with no subject returns `no_current_work`, never -whole-repository context. - ---- - -## Modes and facets - -| Mode | Orientation | -|------------------|---------------------------------------------------------------------------------------------------------| -| `implementation` | Editing context: module role, imports/importers, callees, public API, blast radius, tests, docs, memory | -| `impact` | Transitive dependency context, baseline-sensitive findings; adds callers | -| `contract` | Truth-map: `definition_sites`, `version_constants`, `contract_tests`, `memory_conflicts` | - -`contract` mode emits path-specific caller facets -(`persistence_path_callers`, `serialization_path_callers`, -`deserialization_path_callers`, `store_api_consumers`) only with a typed -contract-registry, protocol, or Engineering Memory anchor. Without an anchor they -report `status: "not_available"` rather than being guessed from names. - -`call_context` projects callers, callees, references, and `test_callers` from -run-bound relationship facts. Every edge is tagged `relation_kind` × -`resolution_status`. Production and test-origin callers stay in separate lanes; -test edges never make production code live. Unresolved calls use -`target_qualname: null`. `analysis.call_graph_status` is `complete`, `partial`, -or `unavailable`. - -Import, importer, and test-importer roles collapse into -`structural_context.related_modules` with explicit `relations` -(`imports`, `imported_by`, `tested_by`). - ---- - -## Freshness and digests - -```mermaid -flowchart LR - R["Stored MCP run"] --> C["Canonical report facts"] - R --> M["Run manifest"] - C --> P["Bounded context projection"] - M --> F["Live freshness delta"] - F --> P - P --> A["context_artifact_digest"] - P --> E["context_projection_digest"] -``` - -- `context_artifact_digest` binds the canonical run and off-report context artifact. -- `context_projection_digest` binds the normalized request and exact bounded evidence returned. -- `analysis.freshness` compares run manifest with live mtime+size and, when available, git `DirtySnapshot` delta. -- `freshness.status="drifted"` means analyze again before relying on the projection. - -A missing run returns `needs_analysis`. Invalid facets and paths outside the root -raise a contract error. - ---- - -## Budget and safety overflow - -`budget` is one global evidence-entry cap, not per-facet. Every bounded collection -reports `total`, `shown`, `truncated`, and `omitted`. Intent `do_not_touch` and -review-required entries consume budget first. The effective limit expands up to -the server hard cap so a small requested budget cannot hide safety context. - -If safety entries alone exceed that cap, the response uses -`status="safety_context_overflow"` and reports the omitted count. - -Symbol-only queries that resolve nothing return `status="subject_not_found"` with -actionable `next_steps` and omit empty facet scaffolding. - ---- - -## Intent and memory lanes - -With `intent_id`, the selected active intent pins the source run and adds -`change_control`: - -- `allowed_files` and `allowed_related` from declared scope; -- report-derived `review_context`; -- explicit and built-in `do_not_touch` boundaries; -- guards with `authorization_source="start_controlled_change"`. - -Engineering Memory records, test anchors, doc anchors, trajectories, and -Experiences project into separate bounded lanes. Memory is evidence, not edit -authority. - ---- - -## Related - -- Overview and sibling analysis tools: [Analysis tools](analysis.md) -- `help(topic=implementation_context)`: [Help topics](help-and-topics.md) -- Agent guide: [Implementation context](../../../guide/mcp/workflows/analyze-and-triage.md#implementation-context) diff --git a/docs/book/25-mcp-interface/tools/platform-observability.md b/docs/book/25-mcp-interface/tools/platform-observability.md deleted file mode 100644 index a688d887..00000000 --- a/docs/book/25-mcp-interface/tools/platform-observability.md +++ /dev/null @@ -1,79 +0,0 @@ -# Platform Observability Tool - - - -`query_platform_observability` projects bounded diagnostics from CodeClone's -local observer store. It is intended **only** for CodeClone maintainers -developing the product — **not** for users evaluating their analyzed -repository. - -!!! warning "Prerequisites" - Observation is **off by default**. Set `CODECLONE_OBSERVABILITY_ENABLED=1` - on the CLI/MCP/worker process **before** reproduction. Without enablement - the tool returns `status=disabled` or `status=no_store` and provides no - repository-quality signal. - - See [Platform Observability](../../26-platform-observability.md) for storage, - privacy, configuration, and trust boundaries. - -## Parameters - -| Parameter | Contract | -|----------------|--------------------------------------------------------------------------| -| `root` | Absolute repository root. | -| `section` | One supported diagnostics section. | -| `detail_level` | `compact`, `normal`, or `full`; `full` currently downgrades to `normal`. | -| `limit` | Row cap, clamped to `1..50`. | -| `window` | `latest` or a correlation ID. | -| `operation_id` | Reserved; reported in `ignored_parameters`. | -| `span_id` | Reserved; reported in `ignored_parameters`. | - -Supported sections: - -- `summary` -- `slow_operations` -- `memory_pipeline_cost` -- `db_cost` -- `agent_context` -- `mcp_tool_matrix` -- `correlated_chains` -- `costly_noops` -- `pipeline` -- `analysis_phase_cost` - -Each call returns one section only. Compact detail is bounded to five rows; -normal detail is bounded by `limit`. - -`analysis_phase_cost` reports summed worker elapsed time inside -`pipeline.process`, grouped by analysis micro-phase. The top-level scalar -`phase_worker_elapsed_total_ms` may exceed `pipeline_process_wall_ms` when -analysis ran in a process pool. Treat the section as a CodeClone performance -diagnostic only; it does not indicate repository quality. - -`agent_context` and `mcp_tool_matrix` report context pressure as deterministic -context units. For MCP responses that carry `context_governance`, the observer -uses `context_governance.estimated`; legacy field names such as -`response_tokens` are compatibility names, not exact model-token counts. - -## Inert states - -When observability is disabled, the tool returns a disabled status. When no -local store exists, it returns a no-store status. Neither state changes -analysis behavior. - -An invalid section returns the available section names. Reserved parameters -are echoed as ignored instead of changing the projection. - -## Interpretation boundary - -The envelope states that: - -- the audience is CodeClone development; -- the data is not user-facing repository quality evidence; -- it does not affect reports, gates, baselines, memory facts, or edit - authorization; -- reported heuristics are diagnostic hints, not findings. - -This anti-inference boundary is part of the tool contract. See -[Determinism and tests](../determinism-and-tests.md) and the -[diagnostics guide](../../../guide/observability/diagnostics.md). diff --git a/docs/book/25-mcp-interface/tools/report-and-findings.md b/docs/book/25-mcp-interface/tools/report-and-findings.md deleted file mode 100644 index 6ac908f5..00000000 --- a/docs/book/25-mcp-interface/tools/report-and-findings.md +++ /dev/null @@ -1,20 +0,0 @@ -### Report and finding projection tools - -| Tool | Key parameters | Purpose | -|-----------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `get_report_section` | `run_id`, `section`, `family`, `path`, `offset`, `limit` | Read report sections; `metrics_detail` is paginated | -| `list_findings` | `run_id`, `family`, `category`, `severity`, `source_kind`, `novelty`, `sort_by`, `detail_level`, changed-scope filters, `exclude_reviewed`, `max_results`, pagination | Filtered, paginated finding list. `exclude_reviewed=true` omits session-marked reviewed findings. `max_results` caps returned items (falls back to `limit`, hard-capped at 200) | -| `get_finding` | `finding_id`, `run_id`, `detail_level` | One canonical finding by short or full ID | -| `get_remediation` | `finding_id`, `run_id`, `detail_level` | Remediation/explainability for one finding | -| `list_hotspots` | `kind`, `run_id`, `detail_level`, changed-scope filters, `limit`, `max_results` | Priority-ranked hotspot views by kind | -| `generate_pr_summary` | `run_id`, `changed_paths`, `git_diff_ref`, `format` | PR-oriented markdown or JSON summary | - -`get_report_section` `section` accepts `meta`, `inventory`, `findings`, -`metrics`, `metrics_detail`, `changed`, `derived`, `integrity`, `module_map`, -or `all`. `section="module_map"` returns the exact -`report_document["derived"]["module_map"]` projection (graph views, -`unwind_candidates`, truncation, and `summary.available`) so agents read the -module map directly without `section="all"` or manual metrics-family joins. When -the run skipped the dependencies family the call returns the unavailable shell -(`summary.available: false`) rather than an error; only a run with no `derived` -section at all raises `MCPServiceContractError`. diff --git a/docs/book/25-mcp-interface/tools/session-and-memory.md b/docs/book/25-mcp-interface/tools/session-and-memory.md deleted file mode 100644 index 9333c259..00000000 --- a/docs/book/25-mcp-interface/tools/session-and-memory.md +++ /dev/null @@ -1,51 +0,0 @@ -### Session-local tools - -| Tool | Key parameters | Purpose | -|--------------------------|--------------------------------|-------------------------------------------------------------------------------------------------------| -| `mark_finding_reviewed` | `finding_id`, `run_id`, `note` | Session-local review marker (in-memory) | -| `list_reviewed_findings` | `run_id` | List reviewed markers for a run | -| `get_implementation_context_page` | `root`, `context_projection_digest`, `facet`, `offset`, `page_size` | Exact page from a `get_implementation_context` session-local projection artifact. Returns `not_found` after the projection leaves MCP run history; never recomputes fresh context as exact evidence | -| `clear_session_runs` | — | Reset in-memory runs, session review markers, and workspace intent registry state for the MCP process | - -### Platform observability - -| Tool | Key parameters | Purpose | -|--------------------------------|------------------------------------------------------|----------------------------------------------------------------| -| `query_platform_observability` | `root`, `section`, `window`, `detail_level`, `limit` | Bounded, read-only slices of CodeClone's own runtime telemetry | - -This tool is **development-only**. It reports numeric operation/span, -database-cost, payload, agent-context, and pipeline diagnostics for CodeClone -itself. It never contributes repository findings, gates, baselines, memory -facts, or edit authorization, and it does not expose raw SQL or payload bodies. -See the dedicated -[Platform Observability tool contract](platform-observability.md). - -Compact `get_relevant_memory` responses include `context_governance` metadata -with `mode="partial_enforce"` and -`evidence_policy="response_budget_with_exact_continuation"`. `records`, -`trajectories`, `experiences`, coverage, and retrieval-policy fields keep their -documented lane semantics; if a lane is omitted or reduced by the response -budget, `context_governance.omitted` names the lane and points to its exact -continuation cursor. Full-detail memory retrieval and continuation pages stay in -`mode="observe"`. - -When a memory lane has more deterministic items than the default response shows, -`get_relevant_memory` includes `continuation.lanes..page`. Pass that -digest-bound cursor to `get_memory_projection_page` to enumerate the omitted -tail exactly. The cursor binds to the normalized request, lane ordering version, -and lane identity digest; if the underlying memory projection changed, the page -returns `status="snapshot_mismatch"` instead of continuing against fresh data. - -Known identities still use object lookups: - -- memory records: `query_engineering_memory(mode="get", record_id=...)`; -- trajectories: `query_engineering_memory(mode="trajectory_get", record_id=...)`; -- Experiences: `query_engineering_memory(mode="experience_get", record_id=...)`. - -`get_implementation_context` responses may include -`analysis.context_page_retrieval`. Use -`analysis.context_projection_digest` plus a facet key such as `public_surface`, -`callers`, `memory`, `trajectories`, or `definition_sites` with -`get_implementation_context_page` to retrieve the exact saved facet lane for -the current MCP session. This is not a fresh analysis and it is not durable -beyond MCP run-history retention. diff --git a/docs/book/25-mcp-interface/tools/workflow.md b/docs/book/25-mcp-interface/tools/workflow.md deleted file mode 100644 index ec24d554..00000000 --- a/docs/book/25-mcp-interface/tools/workflow.md +++ /dev/null @@ -1,46 +0,0 @@ -### Workflow tools (preferred) - -| Tool | Key parameters | Purpose | -|----------------------------|------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `start_controlled_change` | `root`, `scope`, `intent`, `expected_effects`, `on_conflict`, `strictness`, `ttl_seconds`, `blast_radius_depth`, `blast_radius_detail`, `dirty_scope_policy` | Pre-edit: workspace check + declare + blast radius + budget in one call. Returns `intent_id` for `finish`. `blast_radius_detail="summary"` (default) keeps safety fields inline and routes full blast evidence through `get_blast_artifact` when the artifact was durably stored; `full` keeps the compatibility projection inline. If artifact storage is unavailable, start falls back to full inline evidence. `ttl_seconds` overrides intent lifetime (default `3600`, env `CODECLONE_INTENT_TTL_SECONDS` when omitted). `dirty_scope_policy=continue_own_wip` resumes known dirty scope when no foreign overlap. Does not run analysis | -| `finish_controlled_change` | `intent_id`, `changed_files` or `diff_ref`, `after_run_id`, `review_text`, `claims_text`, `propose_memory`, `create_receipt`, `auto_clear`, `strictness`, `detail_level`, `patch_trail_detail` | Post-edit pipeline: hygiene gate → scope check → verify → Patch Trail + audit → optional claims → receipt → clear. `after_run_id` required for Python structural / governance config profiles. Hygiene: `detail_level="full"` for per-path attribution; otherwise counts/blocking only. `patch_trail_detail`: `summary` (default) or `full` path lists on `patch_trail`. Top-level `status` may be `accepted_with_external_changes` when verify passes but out-of-scope git dirt remains. Set `propose_memory=true` for draft memory candidates on accept | - -`start_controlled_change` includes `context_governance` metadata with estimated -context units and a `start_projection_v1` digest. When an immutable audit-backed -blast artifact is stored, start uses `mode="partial_enforce"` and keeps a slim -blast summary inline. The summary includes `do_not_touch`, -`do_not_touch_summary`, status, scope, workspace blocking facts, and artifact -identity. Full omitted blast evidence is fetched read-only with -`get_blast_artifact(root, run_id, blast_artifact_id)`; it is exact historical -evidence, not a fresh `get_blast_radius` recomputation. Needs-analysis, queued, -and no-artifact fallback responses stay in `mode="observe"`. Governance metadata -does not replace `status`, `edit_allowed`, scope, or blast-radius safety fields. - -`finish_controlled_change` separates human notes from validated claims: -`review_text` is an optional note, while `claims_text` is the text passed to -Claim Guard. The response includes a compact `summary` plus the full -`scope_check`, `verification`, `claims`, `receipt`, and `workspace_hygiene_after` -payloads. When `create_receipt` fails, verify may still be `accepted` but -`intent_cleared` stays `false`. - -??? info "Start/finish workspace hygiene" - Edit permission requires `start_controlled_change` to return - `status == "active"` **and** `edit_allowed == true`. Workflow - `status: "blocked"` is not persisted registry lifecycle. Start may attach - scoped `workspace_hygiene`; finish runs `finish_hygiene_check` before check/verify. - Hygiene path detail (`dirty_attribution`, classification arrays) requires - `detail_level="full"`; `summary`/`normal` return counts and blocking fields only. - **Blocking finish** (`reason: workspace_hygiene`, `blocks_finish: true`) happens - for `finish_block_reason` `missing_evidence`, `foreign_dirty_overlap`, and - (when strict finish mode is enabled) `own_unscoped_dirty`. Out-of-scope - unattributed dirt is **advisory** — it may surface as `external_changes` and - elevate top-level status to `accepted_with_external_changes` without failing - verify. Unchanged preexisting out-of-scope dirty is informational. Foreign - active/stale dirt outside your scope → `foreign_attributed_outside_scope` - (ignored). Recoverable intents do not grant foreign attribution. Queued - foreign intents do not populate `foreign_dirty_overlaps`. `files_for_scope_check` - is agent evidence only. Full pipeline and field reference: - [finish_controlled_change](../../12-structural-change-controller/finish-controlled-change.md). - `manage_change_intent(list_workspace)` returns repo-level - `workspace_dirty_summary` only. Registry lazy close vs `gc_workspace`: see - [Workspace hygiene and registry consistency](../../12-structural-change-controller/finish-hygiene.md). diff --git a/docs/book/26-platform-observability.md b/docs/book/26-platform-observability.md deleted file mode 100644 index 23d219c8..00000000 --- a/docs/book/26-platform-observability.md +++ /dev/null @@ -1,189 +0,0 @@ -# 26. Platform Observability - - - -Platform Observability is a local diagnostics surface for CodeClone development. -It explains the cost and shape of CodeClone's own execution. It does **not** -describe repository quality and must never affect analysis truth, gates, -baselines, cache compatibility, findings, or edit authorization. - -!!! warning "Not for CodeClone end users" - If you use CodeClone to analyze **your** Python project, observer tooling - will not help with clones, health score, CI gates, or MCP review. Use the - normal CLI/MCP workflow instead. Platform Observability is **only** for - people developing **CodeClone itself**. - - Instrumentation is **disabled by default** and requires explicit environment - configuration before any telemetry is collected. See - [Maintainer workflow](../guide/observability/maintainer-workflow.md). - - For practical commands, see the - [observability diagnostics guide](../guide/observability/diagnostics.md). Maintainer - playbook: [Developing CodeClone with Platform Observability](../guide/observability/maintainer-workflow.md). - For the bounded MCP projection, see - [query_platform_observability](25-mcp-interface/tools/platform-observability.md). - -## Trust boundary - -```mermaid -flowchart LR - A["CLI / MCP / projection worker"] --> B["Operation and span instrumentation"] - B --> C["Local SQLite store
.codeclone/db/platform_observability.sqlite3"] - C --> D["CLI JSON / self-contained HTML"] - C --> E["Bounded MCP diagnostics"] - D --> F["Human diagnosis"] - E --> F - B -. " must not influence " .-> G["Analysis, findings, gates,
baseline, cache, permissions"] -``` - -The observer: - -- is disabled by default; -- stores data locally only; -- records metadata, counters, durations, bounded payload sizes, and normalized - literal-free SQL fingerprints; -- never records prompt or MCP payload bodies; -- exposes telemetry hints, not findings or vulnerabilities; -- remains inert when disabled or when no store exists. - -## Enabling instrumentation - -Configuration is environment-only. There is no `[tool.codeclone]` -observability table. - -| Variable | Meaning | -|---------------------------------------------------|-------------------------------------------------------------------------| -| `CODECLONE_OBSERVABILITY_ENABLED=1` | Enable instrumentation. | -| `CODECLONE_OBSERVABILITY_FORCE=1` | Permit observation in CI; it does not enable instrumentation by itself. | -| `CODECLONE_OBSERVABILITY_PROFILE=1` | Capture optional process metrics; requires `codeclone[perf]`. | -| `CODECLONE_OBSERVABILITY_PERSIST=0` | Instrument without persisting completed operations. | -| `CODECLONE_OBSERVABILITY_CAPTURE_PAYLOAD_SIZES=0` | Disable request/response size and context-unit estimates. | -| `CODECLONE_OBSERVABILITY_PAYLOAD_SNAPSHOT=1` | Reserved and rejected: raw payload snapshots are not supported. | - -An explicit `CODECLONE_OBSERVABILITY_ENABLED=1` is sufficient in CI. -`CODECLONE_OBSERVABILITY_FORCE` never enables observation by itself and is -reserved as an explicit CI-gate override. - -Configuration fields for retention and row caps are reserved in the internal -model but are not automatic pruning guarantees in the current release. - -## Data model - -The local schema version is `1.1`. A completed operation and its spans are -written in one transaction. - -An operation records stable identifiers, parent/correlation IDs, surface, -operation name, timestamps, duration, status, bounded error classification, -session and root digests, request/response sizes, context-unit estimates, and optional -process metrics (`rss_mb`, `rss_delta_mb`, `peak_rss_mb`, `peak_rss_delta_mb`, -CPU time, thread count, open file descriptors when `codeclone[perf]` is -installed). - -MCP response context pressure is an estimated context-unit signal, not an exact -model tokenizer count. When a response includes `context_governance`, the -observer uses that envelope's `estimated` value. Older storage and projection -fields may still be named `response_tokens`; interpret those values as -deterministic context units. - -A span records its parent, duration, reason kind, deduplication state, numeric -counters, the same optional process metrics, and at most eight normalized SQL -fingerprints. SQL literals are removed before persistence. - -### Engineering Memory and semantic rebuild spans - -When observability is enabled, `codeclone memory …` commands record a CLI -operation (`cli.memory.{command}` or `cli.memory.semantic.{action}`) and -nested product spans: - -| Span | When | -|------------------------------------------------------|------------------------------------------------------| -| `memory.semantic.rebuild` | Semantic index rebuild (CLI, MCP, projection worker) | -| `memory.semantic.bootstrap` | Provider and LanceDB writer resolution | -| `memory.semantic.source.{memory\|audit\|trajectory}` | Per-source projection scan | -| `memory.semantic.embed` | Changed-row embedding batches | -| `memory.semantic.reconcile` | Stale-id deletion | -| `memory.semantic.search` | CLI semantic search | -| `memory.embedding.model_load` | First FastEmbed ONNX load in-process | -| `memory.embedding.infer` | FastEmbed batch inference | -| `memory.embedding.documents` | Document embedding helper | -| `memory.embedding.query` | Query embedding helper | - -The rebuild span carries counters such as `indexed`, `embedded`, -`skipped_unchanged`, `deleted`, `embedding_dimensions`, `embedding_batch_size`, -and `lane_{source}` tallies. - -Semantic rebuild reasons are classified as: - -- `content_changed` — rows were embedded and/or stale ids pruned -- `manual_rebuild` — full reconcile but index already current (hash-skip only) -- `schema_version_changed` -- `model_changed` -- `first_index` -- `unknown` - -Memory pipeline cost rows include `memory.*` product spans regardless of -whether they ran under a `memory`, `cli`, or `mcp` operation surface. - -## CLI projection - -```bash -codeclone observability trace --root . -codeclone observability trace --root . --last 50 --html /tmp/codeclone-observer.html -codeclone observability trace --root . --operation OPERATION_ID --json /tmp/trace.json -codeclone observability trace --root . --correlation CORRELATION_ID -``` - -Without `--json` or `--html`, the command writes JSON to stdout. A missing -store is an informational empty state and exits successfully. - -The HTML cockpit is self-contained and includes operation chains, a span -waterfall, pipeline and Engineering Memory costs, MCP tool aggregates, database -costs, normalized SQL fingerprints, agent context, analysis extract phases, and -costly no-op signals. It has no external assets or JavaScript dependency. - -When analysis phase counters are present, the cockpit shows **Analysis extract -phases** after the pipeline section. These values are summed per-file worker -elapsed time from `pipeline.process` counters. Under parallel execution the sum -can exceed the parent `pipeline.process` wall time; this is expected and is not -CPU time. - -## MCP projection - -`query_platform_observability` returns one bounded section per call: - -- `summary` -- `slow_operations` -- `memory_pipeline_cost` -- `db_cost` -- `agent_context` -- `mcp_tool_matrix` -- `correlated_chains` -- `costly_noops` -- `pipeline` -- `analysis_phase_cost` - -`detail_level=compact` returns at most five rows. `normal` honors `limit`, -clamped to `1..50`; `full` currently downgrades to `normal`. `window` accepts -`latest` or a correlation ID. `operation_id` and `span_id` are reserved and -reported as ignored parameters. - -`analysis_phase_cost` projects the same phase rows shown in HTML: parse, -qualname indexing, module walks, CFG build, normalization, block/segment -extraction, and module-level metric passes. It is a CodeClone runtime diagnostic, -not repository quality evidence. - -The response explicitly declares a CodeClone-development audience and states -that it is not user-facing quality evidence. See -[MCP determinism and tests](25-mcp-interface/determinism-and-tests.md) for the -bounded-projection contract. - -## Privacy and lifecycle - -The SQLite database is optional local diagnostic state. It is outside the -canonical report, baseline, and analysis cache contracts. Deleting it only -removes diagnostics; it does not alter analysis results. - -There is no network exporter. Automatic retention pruning is not currently -enforced, so operators who enable persistence own local database lifecycle. -See [Security model](21-security-model.md) and -[Plans and retention](../plans-and-retention.md). diff --git a/docs/book/27-corpus-analytics.md b/docs/book/27-corpus-analytics.md deleted file mode 100644 index 9870cedd..00000000 --- a/docs/book/27-corpus-analytics.md +++ /dev/null @@ -1,524 +0,0 @@ -# Corpus Analytics - -Corpus Analytics is an optional, offline analytics lane for clustering -historical change-control intents. It reconstructs an intent corpus from -retained controller evidence, creates immutable-by-contract snapshots, writes -separate analytics embeddings, and runs deterministic PCA + HDBSCAN clustering. -Slice 1.1 adds an interpretation plane over those persisted facts. Slice 1.2 -adds a control plane: versioned profile lenses, finite profile-scoped sweeps, -separate suitability and ranking, immutable batch receipts, and append-only -maintainer selection events. - -It is **derived evidence, not authority**. Corpus Analytics never changes the -canonical structural report, reports/gates/baselines, cache compatibility, -Engineering Memory governance, or edit authorization. - -For a command-oriented walkthrough, see the -[Corpus Analytics guide](../guide/analytics/overview.md). Configuration is -indexed in [Config and Defaults](10-config-and-defaults.md), CLI behavior in -[CLI](11-cli.md), and storage layout in -[Schema Layouts](appendix/b-schema-layouts.md). - -## Trust Boundary - -```mermaid -flowchart LR - A["Audit DB
intent.declared"] --> S["Corpus snapshot
SQLite metadata"] - T["Trajectory projection
outcome and quality"] --> S - P["Patch Trail
scope and verification facts"] --> S - R["Live registry overlay
inspection only"] -.-> S - S --> E["Embedding generation
separate LanceDB sidecar"] - E --> C["L2 normalize
PCA(full)
HDBSCAN(euclidean)"] - C --> D["Persisted assignments
summaries and diagnostics"] - D --> V{"V1-V10
technically valid?"} - V -->|" yes "| Q["Profile suitability
optional lens, soft gates"] - Q --> F["Full interpretation
metrics, previews, provenance"] - V -->|" no "| L["Limited diagnostic
status, validity, safe raw counts"] - F --> J["JSON export 1.3"] - F --> H["Self-contained HTML"] - L --> J - L --> H - S -. " never authorizes or gates " .-> X["Structural report
baseline, gates, memory governance"] -``` - -Source ownership is explicit: - -| Fact | Owner | -|------------------------------------------------|------------------------------------------------------| -| Original description and declaration order | Earliest audit `intent.declared` by `audit_sequence` | -| Declared/changed files and verification facts | Patch Trail | -| Outcome, quality tier, labels, anomalies | Selected current-version trajectory | -| Lease/status and other live coordination state | Optional registry overlay | - -The registry overlay is exported for inspection when present, but it never -changes normalized text, representation identity, or `source_digest`. - -## End-to-End Lifecycle - -```mermaid -sequenceDiagram - participant U as Maintainer - participant CLI as codeclone analytics - participant SQL as Analytics SQLite - participant V as Analytics LanceDB - participant O as Platform Observability - U ->> CLI: snapshot --root . - CLI ->> O: analytics.snapshot span - CLI ->> SQL: snapshot + corpus items - U ->> CLI: embed --snapshot-id SNAPSHOT - CLI ->> O: analytics.embed span - CLI ->> V: float32 vectors - CLI ->> SQL: generation + row keys + digests - U ->> CLI: cluster --snapshot-id ... --embedding-generation-id ... - CLI ->> O: analytics.cluster span - CLI ->> SQL: running run - CLI ->> V: validated vectors - CLI ->> SQL: completed assignments and summaries - opt --profile PROFILE - CLI ->> SQL: manifest snapshot + immutable profile batch - CLI ->> SQL: suitability assessments + profile recommendation - end - opt --select-run RUN - CLI ->> SQL: append-only selection event - end - U ->> CLI: cluster-show or build outputs - CLI ->> O: analytics.report span - CLI -->> U: atomic JSON / HTML -``` - -Every clustering run is inserted as `running`. A successful run atomically -commits assignments, summaries, and `completed`; a processing error rolls those -artifacts back and persists `failed` with an error message. - -## Installation - -```bash -uv sync --extra analytics -# or -pip install "codeclone[analytics]" -``` - -Capability tiers: - -| Tier | Packages | Commands | -|-----------|-----------------------------------|----------------------------------------------------------------------------| -| `base` | core only | `snapshot`, `clusters`, `cluster-show`, `outliers`, `cluster --select-run` | -| `embed` | FastEmbed + LanceDB | `embed` | -| `cluster` | scikit-learn + external `hdbscan` | clustering and sweep | -| `full` | all of the above | `build` | - -Missing optional dependencies are contract errors (exit `2`) with an install -hint. Inspection/export commands do not import FastEmbed. - -`umap-learn` remains an optional dependency on supported Python versions, but -Slice 1 does not emit a UMAP visualization. Any later UMAP view must be labeled -visualization-only and must never feed clustering. - -## Configuration - -`[tool.codeclone.analytics]` overrides repository-local defaults. Relative paths -resolve from the repository root; absolute paths are allowed but are represented -as `` in snapshot manifests so user-specific paths do not enter -portable identity. - -| Key | Default | Contract | -|------------------------------------|--------------------------------------------------|-------------------------------------------------| -| `db_path` | `.codeclone/analytics/corpus_clustering.sqlite3` | Analytics metadata store | -| `vectors_path` | `.codeclone/analytics/corpus_vectors` | Dedicated LanceDB vectors | -| `embedding_model` | `BAAI/bge-small-en-v1.5` | FastEmbed model id | -| `embedding_dimension` | `384` | Vector width | -| `embedding_provider` | `fastembed` | Only supported provider in Slice 1 | -| `embedding_cache_dir` | memory semantic cache | Shared model artifact cache, not shared vectors | -| `min_correlation_sample_size` | `5` | Correlation denominator guard | -| `cluster_random_seed` | `42` | PCA deterministic seed | -| `default_pca_dimensions` | `64` | Requested PCA width | -| `default_min_cluster_size` | `8` | HDBSCAN default | -| `default_min_samples` | `3` | HDBSCAN default | -| `default_cluster_selection_method` | `eom` | `eom` or `leaf` | -| `default_profile_id` | unset | Used only by explicit `--profile auto` | -| `profile_paths` | `[]` | Additional repo-contained manifest files | -| `sweep_pca_dimensions` | `[32, 64, 128]` | Non-profile sweep PCA axis | -| `sweep_min_cluster_sizes` | `[5, 8, 12, 15]` | Non-profile sweep size axis | -| `sweep_min_samples` | `[1, 3, 5]` | Non-profile sweep sample axis | -| `sweep_selection_methods` | `["eom", "leaf"]` | Non-profile sweep method axis | -| `allow_model_download` | memory semantic setting | Whether FastEmbed may download | - -The historical audit database follows top-level -`[tool.codeclone].audit_path`. This prevents Analytics from silently reading a -different audit source than the controller. - -## Identity And Digests - -Corpus identity has three layers: - -```text -source_record_key = sha256(project_id + "\n" + intent_id) -representation_key = sha256(lane + kind + version + source_record_key) -snapshot_item_id = sha256(snapshot_id + "\n" + representation_key) -``` - -`source_digest` hashes source schema versions, lane, representation contract, -normalizer version, and sorted source/provenance digests. It excludes: - -- snapshot ids and timestamps; -- absolute source paths; -- live registry overlay state. - -Representation contract `3` retains the contract-2 raw-input hashing rules and -materializes explicit provenance presence facts for new snapshots: - -- `provenance.trajectory.selected`; -- `provenance.patch_trail.present`; -- `provenance.registry_overlay.present`. - -For `description_with_frame`, representation identity includes description, -intent kind, declared path families, and typed declared constraints before -normalization. Registry-overlay content and presence remain outside -`source_digest`; existing contract-2 snapshots are immutable and are not -backfilled. - -Cluster membership identity is: - -```text -membership_digest = sha256(sorted(snapshot_item_ids) joined by "\n") -``` - -HDBSCAN numeric labels are not stable identity. Display ids are assigned after -canonical ordering by size descending, actual PCA-space medoid item id, then -membership digest. Noise remains an explicit non-display bucket. - -## Storage And Integrity - -Current analytics store schema is `1.2`. - -- Writable open migrates supported `1.0` stores through `1.1` to `1.2`. -- Read-only open never migrates and rejects a stale schema. -- SQLite relationship triggers reject orphan-producing inserts/updates/deletes. -- Vector row keys and non-null display cluster ids are unique. -- Reporting and inspection open the metadata store read-only. - -SQLite and LanceDB cannot participate in one physical transaction. The -embedding workflow therefore: - -1. computes a new generation; -2. stages SQLite metadata; -3. writes LanceDB rows; -4. commits SQLite only after the sidecar write succeeds; -5. rolls back metadata and removes the generation on ordinary failures. - -Before clustering, CodeClone validates the generation contract, exact snapshot -item set, dimensions, row keys, and vector digests. Cross-snapshot runs, -missing sidecar rows, stale embedding contracts, or corrupted float32 payloads -are rejected rather than accepted as completed analytics. - -## Embedding Reproducibility - -Embedding contract `2` stores: - -- provider and provider package version; -- model id, optional revision, optional artifact fingerprint; -- dimensions and embedding contract version; -- cosine similarity manifest and L2 preprocessing contract; -- vector row key and SHA-256 digest over canonical little-endian float32 bytes. - -When model revision/artifact fingerprint is unavailable, -`exact_model_artifact_reproducibility=false`. JSON and HTML then state: - -> Full vector reproducibility is not guaranteed from model id alone. - -Exact reproduction additionally depends on the model artifact, provider and -numeric-library versions, hardware/runtime behavior, and identical normalized -inputs. Old embedding contract generations must be regenerated. - -## Clustering Contract - -The fixed path is: - -```text -float32 embeddings - -> L2 normalization - -> PCA(svd_solver="full", whiten=false, random_state=42) - -> external hdbscan.HDBSCAN(metric="euclidean", core_dist_n_jobs=1) - -> canonical partitions - -> diagnostics -``` - -The run manifest records Python, NumPy, SciPy, scikit-learn, and HDBSCAN -versions plus all fixed algorithm choices. `run_digest` covers snapshot, -embedding generation, effective sample/feature dimensions, effective -parameters, random seed, and the algorithm manifest. - -A sweep discards invalid small-corpus candidates and deduplicates requested -settings that collapse to the same effective parameters. A corpus with no valid -candidate fails explicitly instead of producing an empty successful sweep. - -Sweep ranking sets exactly one generation-wide -`recommended_by_heuristic=true`. Maintainer selection is an append-only event: - -```bash -codeclone analytics cluster --root . --select-run RUN_ID \ - --selected-by "$USER" \ - --selection-rationale "Best inspectable partition" -``` - -The legacy `selected_by_maintainer` run field is synchronized only for global -selection and is not authoritative. Recommendation is evidence, not a human -decision. - -## Profile Control Plane (Slice 1.2) - -A profile is a versioned lens over completed clustering facts, not a property -of a run and not a semantic taxonomy. The bundled registry contains stable, -balanced, discovery, and outlier-oriented lenses. Inspect it with: - -```bash -codeclone analytics profiles list --root . -codeclone analytics profiles show --root . \ - --profile-id intent-small-balanced-v1 -codeclone analytics profiles validate --root . -``` - -```mermaid -flowchart LR - M["Validated manifest
schema 1"] --> G["Finite deduplicated grid"] - G --> B["Immutable profile batch
manifest + candidate-space digests"] - B --> R["Completed run memberships"] - R --> V["Technical validity
V1-V10"] - V --> S["Profile suitability
soft gates"] - S --> K["Profile-aware ranking
suitable runs only"] - K --> P["Profile recommendation"] - P --> E["JSON 1.3 / HTML"] - H["Maintainer selection event"] --> E -``` - -Run a profile sweep explicitly: - -```bash -codeclone analytics cluster \ - --root . \ - --snapshot-id SNAPSHOT_ID \ - --embedding-generation-id GENERATION_ID \ - --profile intent-small-discovery-v1 -``` - -`--profile` implies `--sweep`. `--profile auto` uses -`default_profile_id`; when `--profile` is absent, the default profile is never -applied. Profile grids are authoritative for profile sweeps. For ordinary -sweeps, `--sweep-pca`, `--sweep-min-cluster-size`, -`--sweep-min-samples`, and `--sweep-selection-method` replace the matching -configured axes. Single-run flags (`--pca-dimensions`, -`--min-cluster-size`, `--min-samples`, `--cluster-selection-method`) are -mutually exclusive with sweep mode. - -Every profile execution creates a new immutable batch receipt. Candidate -failures are retained as failed runs but do not abort remaining candidates; -the batch becomes `completed_partial` when at least one candidate succeeds. -Technical validity, profile suitability, and maintainer acceptance remain -three separate verdict levels. - -Profile-scoped selection names a batch directly, or resolves the latest batch -for a profile: - -```bash -codeclone analytics cluster --root . --select-run RUN_ID \ - --selection-profile pbatch-0123456789abcdef -``` - -See [Schema Layouts](appendix/b-schema-layouts.md#corpus-analytics-store-12) -for the immutable tables and -[CLI](11-cli.md#public-surface) for the complete flag matrix. - -## Diagnostics - -Each cluster summary includes: - -- size and corpus percentage; -- average membership strength; -- PCA-space medoid; -- representatives and low-strength/far-boundary items; -- nearest cluster ids by PCA centroid distance; -- metadata distributions with numerator and denominator; -- explicit `insufficient_sample` when the denominator is below the configured - guard. - -The noise explorer emits only observable text/membership flags: -`short_text`, `long_text`, `multiple_paragraphs`, -`high_conjunction_count`, `template_match`, and -`low_membership_strength`. It does not invent semantic classes. - -## Report Interpretability (Slice 1.1) - -The report layer does not decide whether a cluster is semantically meaningful. -It first evaluates formal persisted-data invariants, then projects only the -facts that those invariants permit. - -```mermaid -flowchart TD - P["Persisted snapshot, run, assignments, summaries"] --> A["assess_partition_validity"] - A -->|" V1-V10 pass "| OK["full_interpretation"] - A -->|" one or more fail "| BAD["limited_diagnostic"] - OK --> M["Partition metrics
dominant ratios and size histogram"] - OK --> I["Cluster interpretation
previews, correlations, numeric summaries"] - OK --> R["Small-cluster provenance completeness"] - BAD --> D["Validity codes + presentation banner
safe diagnostic_facts only"] - M --> X["Shared JSON / HTML projection"] - I --> X - R --> X - D --> X -``` - -Technical validity covers: - -| Invariant | Formal check | -|-----------|--------------------------------------------------------------------------| -| `V1` | assignments exactly cover snapshot items with no duplicate item ids | -| `V2` | assignment labels and unique summaries are fully linked, including noise | -| `V3` | every summary size and membership digest matches its members | -| `V4` | every assignment carries its summary membership digest | -| `V5` | non-noise clusters satisfy effective `min_cluster_size` | -| `V6a` | persisted numeric values used by interpretation are finite or `null` | -| `V7` | run is completed and carries the canonical algorithm manifest | -| `V8` | embedding generation metadata covers the snapshot item set | -| `V9` | representative and boundary ids exist and belong to their cluster | -| `V10` | every decoded persisted JSON field has the expected object shape | - -An invalid run is still inspectable. JSON and HTML expose its invariant codes, -status, presentation banner, and only the raw counts allowed by the safe-output -matrix. They omit `partition_metrics`, cluster interpretation, item previews, -and heuristic score. A missing embedding-generation record is represented as -`embedding_generation: null` with an empty `embedding_items` array. - -Presentation is separate from validity: - -- `maintainer_selected` is explicit persisted provenance, not taxonomy truth; -- `heuristic_recommended` is sweep evidence, not a semantic verdict; -- `candidate_only` is a valid run selected by neither mechanism; -- `technically_invalid` always forces `limited_diagnostic`. - -Slice 1.2 adds `profile_recommended`, `valid_but_profile_rejected`, and the -comparison-level `no_profile_suitable_candidate` banner. Profile rejection -never removes partition metrics or changes a technically valid run to limited -diagnostic mode. Labels and descriptions come from the persisted manifest -snapshot linked to the batch, not from the current working tree. - -Full interpretation includes the largest-cluster ratio against the whole corpus -and against assigned non-noise items, a fixed cluster-size histogram, -representative and boundary previews, categorical correlations, numeric -summaries for file counts and description length, and observable -machine-inspectability signals. Small clusters (up to 15 items) also show -provenance completeness. - -Previews are normalized corpus text, truncated to 240 Unicode code points. -They appear only for representatives, boundary items, and noise exploration. -JSON keeps raw strings with ordinary JSON escaping; HTML escapes text at render -time. `content_disclosure` is computed from the previews actually emitted and -lists their scopes. Default exports never attach text previews to every -`items[]` entry. - -Export schema `1.2` introduced the interpretation fields: - -- `interpretation_contract_version = "1.0"` and `content_disclosure`; -- `clustering_run.validity` and `clustering_run.presentation`; -- `partition_metrics` in full mode or `diagnostic_facts` in limited mode; -- per-cluster `interpretation` blocks in full mode; -- candidate-local nullable `comparison` facts and top-level - `comparison_summary`. - -Export schema `1.3` preserves those keys and adds: - -- `interpretation_contract_version = "1.1"`; -- `control_plane_contract_version = "1.0"`; -- optional run-level `profile_context` and active `selection`; -- optional sweep-level `profile_summary`; -- candidate-local `profile_suitable` and `is_profile_recommended`. - -Sweep comparison includes every persisted run for the requested snapshot and -embedding generation, including failed or otherwise invalid runs. Only valid -runs receive a score and rank. Invalid dominant ratios and largest-cluster size -are `null` in JSON and `unavailable` in HTML. - -For the wire layout, see -[Schema Layouts](appendix/b-schema-layouts.md#corpus-analytics-json-export-13). -For compatibility rules, see -[Compatibility and Versioning](24-compatibility-and-versioning.md). - -## CLI And Reports - -The approved direct namespace is `codeclone analytics`: - -| Command | Purpose | -|----------------|---------------------------------------------------------------------| -| `snapshot` | Build an intent corpus snapshot | -| `embed` | Generate a separate analytics embedding generation | -| `cluster` | Run one configuration or a bounded sweep | -| `build` | Run snapshot → embed → cluster | -| `clusters` | List runs for a snapshot | -| `cluster-show` | Export a resolved run as full interpretation or limited diagnostics | -| `outliers` | Emit noise assignment ids | -| `profiles` | List, show, or validate profile manifests | - -`build --sweep --use-recommended` renders the global heuristic winner. -`build --profile PROFILE --use-recommended` renders the profile-batch winner, -or the comparison view when no candidate satisfies the lens. Neither action -records a maintainer selection. `--use-recommended` without explicit or -profile-implied sweep is rejected before dependency checks or artifact -creation. - -Output behavior: - -- single-run JSON contains snapshot/generation manifests, validity, - presentation, and either full interpretation or limited diagnostic facts; -- sweep JSON contains every persisted candidate, nullable comparison fields, - and aggregate valid/invalid/recommendation/selection counts; -- sweep HTML without `--use-recommended` is comparison-only; -- detailed full-mode HTML includes dominant ratios, cluster index, escaped - representative/boundary previews, split categorical/numeric metadata, - provenance completeness, and the noise explorer; -- detailed limited-mode HTML includes the technical-invalid banner and safe - diagnostic overview, without cluster interpretation panels; -- JSON and HTML are self-contained and written atomically to explicit output - paths. - -Expected user/config/capability/schema/integrity errors exit `2` on stderr -without a traceback. - -## Observability - -With `CODECLONE_OBSERVABILITY_ENABLED=1`, the CLI creates one operation named -`cli.analytics.` with nested spans: - -- `analytics.snapshot` -- `analytics.embed` -- `analytics.cluster` -- `analytics.build` -- `analytics.report` when an export is rendered - -Observability is bootstrapped before analytics stores open, so instrumented -SQLite queries are attributed to the active stage. These measurements are -development telemetry only; see -[Platform Observability](26-platform-observability.md). - -## Cross-Links - -- Historical trajectory evidence: - [Trajectory Quality and Passport](13-engineering-memory/trajectory-quality-and-passport.md) -- Runtime configuration: - [Config and Defaults](10-config-and-defaults.md) -- Exit semantics and terminal surfaces: - [CLI](11-cli.md) -- Version bump rules: - [Compatibility and Versioning](24-compatibility-and-versioning.md) -- SQLite/LanceDB layout: - [Schema Layouts](appendix/b-schema-layouts.md) - -## Locked By Tests - -- `tests/test_analytics_foundation.py` -- `tests/test_analytics_trajectory_selection.py` -- `tests/test_analytics_integration.py` -- `tests/test_analytics_integrity.py` -- `tests/test_analytics_reporting.py` -- `tests/test_analytics_cli.py` -- `tests/test_config_analytics.py` -- `tests/test_sqlite_readonly_openers.py` -- `tests/test_architecture.py::test_analytics_package_does_not_import_forbidden_surfaces` diff --git a/docs/book/README.md b/docs/book/README.md deleted file mode 100644 index 7ffdaf6c..00000000 --- a/docs/book/README.md +++ /dev/null @@ -1,98 +0,0 @@ - - -# CodeClone Contracts Book - -This book is the contract-level documentation for CodeClone v2.x. - -All guarantees here are derived from code and locked tests. -If a statement is not enforced by code/tests, it is explicitly marked as non-contractual. - -!!! note "Contract rule" - If this book and the current repository code diverge, code and locked tests - win. Update the book after correcting the implementation or contract test. - -## How to read - -- Start with **Terminology → Architecture map → Intro**. -- Then read the **pipeline spine**: Core pipeline → CFG → Report → HTML render → Baseline → Cache. -- **Change control** (Structural Change Controller, Engineering Memory, Claim Guard) is the governance layer. -- Everything else is supporting detail, invariants, and reference. - -## Table of Contents - -### Foundations - -- [00-intro.md](00-intro.md) — book charter and goals -- [01-terminology.md](01-terminology.md) — glossary -- [02-architecture-map.md](02-architecture-map.md) — authoritative module table - -### Pipeline and data - -- [03-core-pipeline.md](03-core-pipeline.md) — canonical pipeline contract -- [04-cfg-semantics.md](04-cfg-semantics.md) — CFG design and semantics -- [05-report.md](05-report.md) — report contract (schema v2.11) -- [06-html-render.md](06-html-render.md) — HTML rendering contract -- [07-baseline.md](07-baseline.md) — baseline contract (schema v2.1) -- [08-cache.md](08-cache.md) — cache contract (schema v2.10) - -### Contracts and config - -- [09-exit-codes.md](09-exit-codes.md) — exit codes and failure policy -- [10-config-and-defaults.md](10-config-and-defaults.md) — config reference -- [11-cli.md](11-cli.md) — CLI behavior and modes - -### Change control - -- [12-structural-change-controller/index.md](12-structural-change-controller/index.md) — overview -- [12-structural-change-controller/finish-controlled-change.md](12-structural-change-controller/finish-controlled-change.md) — - finish pipeline -- [12-structural-change-controller/finish-hygiene.md](12-structural-change-controller/finish-hygiene.md) — hygiene - blocking vs advisory -- [12-structural-change-controller/patch-trail.md](12-structural-change-controller/patch-trail.md) — Patch Trail -- [13-engineering-memory/index.md](13-engineering-memory/index.md) — evidence-linked repository memory -- [14-claim-guard.md](14-claim-guard.md) — review claim validation - -### Quality signals - -- [15-health-score.md](15-health-score.md) — health score model -- [16-metrics-and-quality-gates.md](16-metrics-and-quality-gates.md) — metrics mode and gate flags -- [17-dead-code-contract.md](17-dead-code-contract.md) — dead-code detection and test-boundary policy -- [18-suggestions-and-clone-typing.md](18-suggestions-and-clone-typing.md) — suggestions and clone typing -- [19-inline-suppressions.md](19-inline-suppressions.md) — `# codeclone: ignore[...]` -- [20-benchmarking.md](20-benchmarking.md) — reproducible Docker benchmarking - -### System properties - -- [21-security-model.md](21-security-model.md) — security model and threat boundaries -- [22-determinism.md](22-determinism.md) — determinism policy -- [23-testing-as-spec.md](23-testing-as-spec.md) — tests as specification -- [24-compatibility-and-versioning.md](24-compatibility-and-versioning.md) — compatibility and versioning rules -- [26-platform-observability.md](26-platform-observability.md) — local diagnostics for CodeClone's own runtime -- [27-corpus-analytics.md](27-corpus-analytics.md) — offline intent corpus clustering (optional `[analytics]`) - -### MCP interface - -- [25-mcp-interface/index.md](25-mcp-interface/index.md) — MCP interface contract -- [25-mcp-interface/tools/workflow.md](25-mcp-interface/tools/workflow.md) — workflow tools -- [25-mcp-interface/resources.md](25-mcp-interface/resources.md) — resource URIs -- [25-mcp-interface/tools/platform-observability.md](25-mcp-interface/tools/platform-observability.md) — bounded - diagnostics tool - -### Integrations - -- [integrations/vs-code-extension.md](integrations/vs-code-extension.md) — VS Code extension contract -- [integrations/cursor-plugin.md](integrations/cursor-plugin.md) — Cursor plugin contract -- [integrations/claude-code-plugin.md](integrations/claude-code-plugin.md) — Claude Code plugin contract -- [integrations/codex-plugin.md](integrations/codex-plugin.md) — Codex plugin contract -- [integrations/claude-desktop-bundle.md](integrations/claude-desktop-bundle.md) — Claude Desktop bundle contract -- [integrations/sarif.md](integrations/sarif.md) — SARIF projection contract - -### Appendix - -- [appendix/a-status-enums.md](appendix/a-status-enums.md) — status enums and typed contracts -- [appendix/b-schema-layouts.md](appendix/b-schema-layouts.md) — schema layouts (baseline/cache/report) -- [appendix/c-error-catalog.md](appendix/c-error-catalog.md) — error catalog (contract vs internal) diff --git a/docs/book/appendix/a-status-enums.md b/docs/book/appendix/a-status-enums.md deleted file mode 100644 index d15701f0..00000000 --- a/docs/book/appendix/a-status-enums.md +++ /dev/null @@ -1,158 +0,0 @@ - - -# Appendix A. Status Enums - -## Purpose - -Centralize machine-readable status sets used across baseline/cache/report/CLI contracts. - -## Public surface - -- Baseline statuses: `codeclone/baseline/trust.py:BaselineStatus` -- Cache statuses: `codeclone/cache/versioning.py:CacheStatus` -- Exit categories: `codeclone/contracts/__init__.py:ExitCode` -- Intent status: `codeclone/surfaces/mcp/_intent.py:IntentStatus` -- Intent ownership: `codeclone/surfaces/mcp/_workspace_intents.py:IntentOwnership` -- Workspace intent status: `codeclone/surfaces/mcp/_workspace_intents.py:WorkspaceIntentStatus` -- Patch contract: `codeclone/surfaces/mcp/_patch_contract.py:PatchContractStatus` -- Verification profile: `codeclone/surfaces/mcp/_verification_profile.py:VerificationProfile` -- Engineering Memory status: `codeclone/memory/enums.py:MemoryStatus` - -## Data model - -### BaselineStatus - -- `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` - -### Baseline untrusted set - -Defined by `BASELINE_UNTRUSTED_STATUSES`. - -### CacheStatus - -- `ok` -- `missing` -- `too_large` -- `unreadable` -- `invalid_json` -- `invalid_type` -- `version_mismatch` -- `python_tag_mismatch` -- `mismatch_fingerprint_version` -- `analysis_profile_mismatch` -- `integrity_failed` - -### ExitCode - -- `0` success -- `2` contract error -- `3` gating failure -- `5` internal error - -### WorkspaceIntentStatus - -- `active` -- `queued` -- `clean` -- `expanded` -- `violated` -- `expired` -- `orphaned` - -Persisted workspace registry records use these lifecycle values. Terminal GC -statuses are `clean`, `expired`, and `orphaned`. Semantics: -[Intent registry & queue](../12-structural-change-controller/intent-registry-and-queue.md). - -### IntentStatus (scope check / session lifecycle) - -- `active` -- `queued` -- `clean` -- `expanded` -- `violated` -- `unverified` -- `expired` - -Used by `manage_change_intent(check)` and session intent records. Finish -top-level `status: "unverified"` is a **response string**, not this enum value. - -### IntentOwnership - -- `own_active` -- `own_stale` -- `foreign_active` -- `foreign_stale` -- `recoverable` -- `expired` - -Semantics: -[Intent registry & queue](../12-structural-change-controller/intent-registry-and-queue.md). - -### PatchContractStatus - -- `accepted` -- `accepted_with_external_changes` -- `violated` -- `unverified` -- `expired` - -Semantics: -[Patch contract verification](../12-structural-change-controller/patch-contract-verify.md). - -### VerificationProfile - -- `state_artifact_change` -- `python_structural` -- `governance_config` -- `documentation_only` -- `non_python_patch` - -Priority-ordered. A single file from a higher-priority category overrides -the entire patch. Semantics are defined in -[Structural Change Controller § Verification Profiles](../12-structural-change-controller/verification-profiles.md). - -### MemoryStatus - -Defined by `codeclone/memory/enums.py:MemoryStatus`. Semantics are defined in -[Engineering Memory § Staleness and anchor durability](../13-engineering-memory/staleness-and-anchors.md). - -- `draft` — unapproved agent candidate -- `active` — trusted or system fact; default retrieval includes -- `historical` — anchor subject absent at `HEAD`; preserved, default retrieval includes -- `stale` — drift or ingest contradiction; excluded from default retrieval -- `superseded` — replaced by a newer record -- `rejected` — human rejected draft -- `archived` — explicitly archived - -## Contracts - -- Status values are serialized into report metadata. -- CLI branches by enum/status values, not by human-facing message text. - -Refs: - -- `codeclone/surfaces/cli/report_meta.py:_build_report_meta` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Locked by tests - -- `tests/test_baseline.py::test_coerce_baseline_status` -- `tests/test_cache.py::test_cache_version_mismatch_warns` -- `tests/test_cli_unit.py::test_cli_help_text_consistency` - -## Non-guarantees - -- Human-readable status messages can change while enum values stay stable. diff --git a/docs/book/appendix/b-schema-layouts.md b/docs/book/appendix/b-schema-layouts.md deleted file mode 100644 index e7c900d8..00000000 --- a/docs/book/appendix/b-schema-layouts.md +++ /dev/null @@ -1,1111 +0,0 @@ - - -# Appendix B. Schema Layouts - -## Purpose - -Compact structural layouts for baseline/cache/report contracts in the current -`2.1` release line. Generator/package version in JSON examples is illustrative; -the actual version is defined in `codeclone/contracts/__init__.py` and -`pyproject.toml`. - -## Baseline schema (`2.1`) - -```json -{ - "meta": { - "generator": { - "name": "codeclone", - "version": "2.0.2" - }, - "schema_version": "2.1", - "fingerprint_version": "1", - "python_tag": "cp314", - "created_at": "2026-03-11T00:00:00Z", - "payload_sha256": "...", - "metrics_payload_sha256": "...", - "api_surface_payload_sha256": "..." - }, - "clones": { - "functions": [ - "|" - ], - "blocks": [ - "|||" - ] - }, - "metrics": { - "...": "optional embedded metrics snapshot" - }, - "api_surface": { - "...": "optional embedded public API snapshot" - } -} -``` - -Compact embedded `api_surface` symbol layout: - -```json -{ - "module": "pkg.mod", - "filepath": "pkg/mod.py", - "symbols": [ - { - "local_name": "PublicClass.method", - "kind": "method", - "start_line": 10, - "end_line": 14, - "params": [], - "returns_hash": "", - "exported_via": "name" - } - ] -} -``` - -Notes: - -- `local_name` is stored on disk to avoid repeating the containing module path. -- `filepath` is stored as a baseline-directory-relative wire path when - possible, rather than as a machine-local absolute path. -- Runtime reconstructs canonical full qualnames as `module:local_name` before - API-surface diffing and restores runtime filepaths from the wire path. - -## Standalone metrics-baseline schema (`1.2`) - -```json -{ - "meta": { - "generator": { - "name": "codeclone", - "version": "2.0.2" - }, - "schema_version": "1.2", - "python_tag": "cp314", - "created_at": "2026-03-11T00:00:00Z", - "payload_sha256": "...", - "api_surface_payload_sha256": "..." - }, - "metrics": { - "...": "metrics snapshot" - }, - "api_surface": { - "modules": [ - { - "module": "pkg.mod", - "filepath": "pkg/mod.py", - "all_declared": [], - "symbols": [ - { - "local_name": "run", - "kind": "function", - "start_line": 10, - "end_line": 14, - "params": [], - "returns_hash": "", - "exported_via": "name" - } - ] - } - ] - } -} -``` - -## Cache schema (`2.10`) - -```json -{ - "v": "2.10", - "payload": { - "py": "cp314", - "fp": "1", - "ap": { - "min_loc": 10, - "min_stmt": 6, - "block_min_loc": 20, - "block_min_stmt": 8, - "segment_min_loc": 20, - "segment_min_stmt": 10, - "collect_api_surface": false - }, - "files": { - "codeclone/cache/store.py": { - "st": [ - 1730000000000000000, - 2048 - ], - "ss": [ - 450, - 12, - 3, - 1 - ], - "u": [ - [ - "qualname", - 1, - 2, - 2, - 1, - "fp", - "0-19", - 1, - 0, - "low", - "raw_hash", - 0, - "none", - 0, - "fallthrough", - "none", - "none" - ] - ], - "b": [ - [ - "qualname", - 10, - 14, - 5, - "block_hash" - ] - ], - "s": [ - [ - "qualname", - 10, - 14, - 5, - "segment_hash", - "segment_sig" - ] - ], - "cm": [ - [ - "qualname", - 1, - 30, - 3, - 2, - 4, - 2, - "low", - "low" - ] - ], - "cc": [ - [ - "qualname", - [ - "pkg.a", - "pkg.b" - ] - ] - ], - "md": [ - [ - "pkg.a", - "pkg.b", - "import", - 10 - ] - ], - "dc": [ - [ - "pkg.a:unused_fn", - "unused_fn", - 20, - 24, - "function" - ] - ], - "rn": [ - "used_name" - ], - "rq": [ - "pkg.dep:used_name" - ], - "in": [ - "pkg.dep" - ], - "cn": [ - "ClassName" - ], - "rr": [ - [ - "pkg.api:list_items", - 20, - 24, - "function", - "fastapi", - "registers_handler", - "medium", - "route decorator", - "router.get", - "pkg.api:router" - ] - ], - "sc": [ - [ - "process_boundary", - "subprocess_run", - "pkg.runner", - "pkg.runner:run", - 10, - 10, - "callable", - "exact_call", - "call", - "subprocess.run" - ] - ], - "sf": [ - [ - "duplicated_branches", - "key", - [ - [ - "stmt_seq", - "Expr,Return" - ] - ], - [ - [ - "pkg.a:f", - 10, - 12 - ] - ] - ] - ] - } - } - }, - "sig": "..." -} -``` - -Notes: - -- File keys are wire paths (repo-relative when root is configured). -- Optional sections are omitted when empty. -- `ss` stores per-file source stats and is required for full cache-hit accounting - in discovery. -- `rn`/`rq` are optional and decode to empty arrays when absent. -- `rr` stores runtime reachability facts used to keep dead-code behavior - equivalent between cold and cached runs. -- Cached public-API symbol payloads preserve declaration order for `params`; - canonicalization must not rewrite callable signature order. -- `u` row decoder accepts both legacy 11-column rows and canonical 17-column rows - (legacy rows map new structural fields to neutral defaults). -- `fr` (schema **`2.9`+**) stores per-function relationship facts: caller - qualname plus relationship rows (`relation_kind`, `resolution_status`, - `origin_lane`, `target_qualname`, line, expression, `resolution_rule`). - Facts are rebuildable and off the canonical report; schema **`2.10`** adds - cross-file aggregation onto the analysis result and MCP run record. - -## Report schema (`2.11`) - -```json -{ - "report_schema_version": "2.11", - "meta": { - "codeclone_version": "2.0.2", - "project_name": "codeclone", - "scan_root": ".", - "analysis_mode": "full", - "report_mode": "full", - "analysis_profile": { - "min_loc": 10, - "min_stmt": 6, - "block_min_loc": 20, - "block_min_stmt": 8, - "segment_min_loc": 20, - "segment_min_stmt": 10 - }, - "analysis_thresholds": { - "design_findings": { - "complexity": { - "metric": "cyclomatic_complexity", - "operator": ">", - "value": 20 - }, - "coupling": { - "metric": "cbo", - "operator": ">", - "value": 10 - }, - "cohesion": { - "metric": "lcom4", - "operator": ">=", - "value": 4 - } - } - }, - "baseline": { - "...": "..." - }, - "cache": { - "...": "..." - }, - "metrics_baseline": { - "...": "..." - }, - "runtime": { - "analysis_started_at_utc": "2026-03-11T08:36:29Z", - "report_generated_at_utc": "2026-03-11T08:36:32Z" - } - }, - "inventory": { - "files": { - "...": "..." - }, - "code": { - "...": "..." - }, - "file_registry": { - "encoding": "relative_path", - "items": [] - } - }, - "findings": { - "summary": { - "...": "...", - "suppressed": { - "dead_code": 0, - "clones": 1 - } - }, - "groups": { - "clones": { - "functions": [], - "blocks": [], - "segments": [], - "suppressed": { - "functions": [ - { - "...": "..." - } - ], - "blocks": [], - "segments": [] - } - }, - "structural": { - "groups": [ - { - "kind": "duplicated_branches", - "...": "..." - }, - { - "kind": "clone_guard_exit_divergence", - "...": "..." - }, - { - "kind": "clone_cohort_drift", - "...": "..." - } - ] - }, - "dead_code": { - "groups": [] - }, - "design": { - "groups": [] - } - } - }, - "metrics": { - "summary": { - "...": "...", - "dead_code": { - "total": 0, - "high_confidence": 0, - "suppressed": 1 - }, - "overloaded_modules": { - "total": 0, - "candidates": 0, - "population_status": "limited", - "top_score": 0.0, - "average_score": 0.0 - }, - "coverage_adoption": { - "modules": 0, - "params_total": 0, - "params_annotated": 0, - "param_permille": 0, - "returns_total": 0, - "returns_annotated": 0, - "return_permille": 0, - "public_symbol_total": 0, - "public_symbol_documented": 0, - "docstring_permille": 0, - "typing_any_count": 0 - }, - "coverage_join": { - "status": "ok", - "source": "coverage.xml", - "files": 0, - "units": 0, - "measured_units": 0, - "overall_executable_lines": 0, - "overall_covered_lines": 0, - "overall_permille": 0, - "missing_from_report_units": 0, - "coverage_hotspots": 0, - "scope_gap_hotspots": 0, - "hotspot_threshold_percent": 50, - "invalid_reason": null - }, - "api_surface": { - "enabled": false, - "modules": 0, - "public_symbols": 0, - "added": 0, - "breaking": 0, - "strict_types": false - }, - "security_surfaces": { - "items": 0, - "modules": 0, - "exact_items": 0, - "category_count": 0, - "production": 0, - "tests": 0, - "fixtures": 0, - "other": 0, - "report_only": true - } - }, - "families": { - "complexity": {}, - "coupling": {}, - "cohesion": {}, - "dependencies": {}, - "dead_code": { - "summary": { - "total": 0, - "high_confidence": 0, - "suppressed": 1 - }, - "items": [], - "suppressed_items": [ - { - "...": "..." - } - ], - "runtime_reachability": { - "summary": { - "total": 0, - "by_framework": {}, - "by_edge_kind": {}, - "by_confidence": {} - }, - "items": [] - } - }, - "overloaded_modules": { - "summary": { - "total": 0, - "candidates": 0, - "population_status": "limited", - "top_score": 0.0, - "average_score": 0.0 - }, - "detection": { - "version": "1", - "scope": "report_only", - "strategy": "project_relative_composite" - }, - "items": [] - }, - "coverage_adoption": { - "summary": { - "modules": 0, - "params_total": 0, - "params_annotated": 0, - "param_permille": 0, - "baseline_diff_available": false, - "param_delta": 0, - "returns_total": 0, - "returns_annotated": 0, - "return_permille": 0, - "return_delta": 0, - "public_symbol_total": 0, - "public_symbol_documented": 0, - "docstring_permille": 0, - "docstring_delta": 0, - "typing_any_count": 0 - }, - "items": [] - }, - "coverage_join": { - "summary": { - "status": "ok", - "source": "coverage.xml", - "files": 0, - "units": 0, - "measured_units": 0, - "overall_executable_lines": 0, - "overall_covered_lines": 0, - "overall_permille": 0, - "missing_from_report_units": 0, - "coverage_hotspots": 0, - "scope_gap_hotspots": 0, - "hotspot_threshold_percent": 50, - "invalid_reason": null - }, - "items": [] - }, - "api_surface": { - "summary": { - "enabled": false, - "baseline_diff_available": false, - "modules": 0, - "public_symbols": 0, - "added": 0, - "breaking": 0, - "strict_types": false - }, - "items": [] - }, - "security_surfaces": { - "summary": { - "items": 0, - "modules": 0, - "exact_items": 0, - "category_count": 0, - "categories": {}, - "by_source_kind": { - "production": 0, - "tests": 0, - "fixtures": 0, - "other": 0 - }, - "production": 0, - "tests": 0, - "fixtures": 0, - "other": 0, - "report_only": true - }, - "items": [] - }, - "health": {} - } - }, - "derived": { - "suggestions": [], - "overview": { - "families": { - "clones": 0, - "structural": 0, - "dead_code": 0, - "design": 0 - }, - "top_risks": [], - "source_scope_breakdown": { - "production": 0, - "tests": 0, - "fixtures": 0 - }, - "health_snapshot": { - "score": 100, - "grade": "A" - }, - "directory_hotspots": { - "...": "..." - } - }, - "hotlists": { - "most_actionable_ids": [], - "highest_spread_ids": [], - "production_hotspot_ids": [], - "test_fixture_hotspot_ids": [] - } - }, - "integrity": { - "canonicalization": { - "version": "1", - "scope": "canonical_only", - "sections": [ - "report_schema_version", - "meta", - "inventory", - "findings", - "metrics" - ] - }, - "digest": { - "verified": true, - "algorithm": "sha256", - "value": "..." - } - } -} -``` - -## Markdown projection (`1.0`) - -```text -# CodeClone Report -- Markdown schema: 1.0 -- Source report schema: 2.11 -... -## Overview -## Inventory -## Findings Summary -## Top Risks -## Suggestions -## Findings -## Metrics -## Integrity -``` - -## SARIF projection (`2.1.0`, profile `1.0`) - -```json -{ - "$schema": "https://json.schemastore.org/sarif-2.1.0.json", - "version": "2.1.0", - "runs": [ - { - "originalUriBaseIds": { - "%SRCROOT%": { - "uri": "file:///repo/project/", - "description": { - "text": "The root of the scanned source tree." - } - } - }, - "tool": { - "driver": { - "name": "codeclone", - "version": "2.0.2", - "rules": [ - { - "id": "CCLONE001", - "name": "codeclone.CCLONE001", - "shortDescription": { - "text": "Function clone group" - }, - "fullDescription": { - "text": "Multiple functions share the same normalized function body." - }, - "help": { - "text": "...", - "markdown": "..." - }, - "defaultConfiguration": { - "level": "warning" - }, - "helpUri": "https://orenlab.github.io/codeclone/", - "properties": { - "category": "clone", - "kind": "clone_group", - "precision": "high", - "tags": [ - "clone", - "clone_group", - "high" - ] - } - } - ] - } - }, - "automationDetails": { - "id": "codeclone/full/2026-03-11T08:36:32Z" - }, - "artifacts": [ - { - "location": { - "uri": "codeclone/report/renderers/sarif.py", - "uriBaseId": "%SRCROOT%" - } - } - ], - "invocations": [ - { - "executionSuccessful": true, - "startTimeUtc": "2026-03-11T08:36:29Z", - "workingDirectory": { - "uri": "file:///repo/project/" - } - } - ], - "properties": { - "profileVersion": "1.0", - "reportSchemaVersion": "2.11" - }, - "results": [ - { - "kind": "fail", - "ruleId": "CCLONE001", - "ruleIndex": 0, - "baselineState": "new", - "message": { - "text": "Function clone group (Type-2), 2 occurrences across 2 files." - }, - "locations": [ - { - "physicalLocation": { - "artifactLocation": { - "uri": "codeclone/report/renderers/sarif.py", - "uriBaseId": "%SRCROOT%", - "index": 0 - }, - "region": { - "startLine": 1, - "endLine": 10 - } - }, - "logicalLocations": [ - { - "fullyQualifiedName": "codeclone.report.sarif:render_sarif_report_document" - } - ], - "message": { - "text": "Representative occurrence" - } - } - ], - "properties": { - "primaryPath": "codeclone/report/renderers/sarif.py", - "primaryQualname": "codeclone.report.sarif:render_sarif_report_document", - "primaryRegion": "1:10" - }, - "relatedLocations": [], - "partialFingerprints": { - "primaryLocationLineHash": "0123456789abcdef:1" - } - } - ] - } - ] -} -``` - -## TXT report sections - -```text -REPORT METADATA -INVENTORY -FINDINGS SUMMARY -METRICS SUMMARY -DERIVED OVERVIEW -SUGGESTIONS -FUNCTION CLONES (NEW) -FUNCTION CLONES (KNOWN) -BLOCK CLONES (NEW) -BLOCK CLONES (KNOWN) -SEGMENT CLONES (NEW) -SEGMENT CLONES (KNOWN) -STRUCTURAL FINDINGS -DEAD CODE FINDINGS -DESIGN FINDINGS -INTEGRITY -``` - -## Engineering Memory schema (`1.7`) - -SQLite database at `.codeclone/memory/engineering_memory.sqlite3` (default). -Schema version stored in `memory_meta.schema_version`. - -Core tables: - -| Table | Role | -|--------------------------|-------------------------------------------------------------| -| `memory_records` | Typed statements with status, confidence, origin, payload | -| `memory_subjects` | Path/symbol/module links (`subject_kind`, `subject_key`) | -| `memory_evidence` | Deterministic evidence refs (report, git_commit, doc, …) | -| `memory_fts` | FTS5 search index (schema 1.1+) | -| `memory_revisions` | Governance audit trail | -| `memory_ingestion_runs` | Init/refresh run metadata | -| `memory_projection_jobs` | Coalesced trajectory/semantic/Experience jobs (schema 1.3+); `flush_claimed_by` flush-scheduling slot (schema 1.7+) | - -Trajectory tables (schema **`1.2`**+ trajectory DDL, active projection -**`trajectory-v3`**): - -| Table | Role | -|-------------------------------------|--------------------------------------------------------------------------------| -| `memory_trajectories` | One row per `(project_id, workflow_id, projection_version)` with quality score | -| `memory_trajectory_steps` | Ordered audit steps with frozen `event_core_json` | -| `memory_trajectory_subjects` | Path/module subjects linked to a trajectory | -| `memory_trajectory_evidence` | Report/run/audit evidence refs | -| `memory_trajectory_patch_trails` | Patch Trail JSON + digest per trajectory (schema **`1.4`**) | -| `memory_trajectory_projection_runs` | Rebuild run manifest | - -Experience tables (schema **`1.6`**, derived from trajectory evidence): - -| Table | Role | -|------------------------------|--------------------------------------------------------------| -| `memory_experiences` | Advisory distilled patterns (`experience-v1`) | -| `memory_experience_facets` | Agent-family facets today; profile/intent kinds are reserved | -| `memory_experience_evidence` | Contributing trajectory ids and outcomes | - -Patch Trail JSON uses `PATCH_TRAIL_SCHEMA_VERSION` (currently **`1`**) in -`codeclone/contracts/__init__.py`. Trajectory JSONL export rows use -`TRAJECTORY_EXPORT_SCHEMA_VERSION` (**`2`**) in -`codeclone/memory/trajectory/profiles.py` — separate from SQLite schema version. - -Record identity uses stable `identity_key` strings for upsert during refresh. -Migration path: `codeclone/memory/schema_migrate.py`. - -See [Engineering Memory](../13-engineering-memory/index.md) for lifecycle and agent -surfaces. - -## Semantic index sidecar (format `2`) - -Optional LanceDB directory (default `.codeclone/memory/semantic_index.lance`). -Format version constant: `SEMANTIC_INDEX_FORMAT_VERSION` in -`codeclone/contracts/__init__.py` (currently **`2`**). - -Table columns (PyArrow): - -| Column | Type | Notes | -|--------------------|-----------------|----------------------------------------------------| -| `id` | string | Row id; chunk rows use `trajectory:{id}:chunk:NNN` | -| `source` | string | `memory` / `audit` / `trajectory` | -| `parent_id` | string (nullable) | Trajectory id for chunk rows; null for single-row | -| `chunk_index` | int32 (nullable) | Zero-based chunk index | -| `chunk_count` | int32 (nullable) | Total chunks for the parent trajectory | -| `project_id` | string | | -| `subject_path` | string | | -| `kind` | string | | -| `status` | string | | -| `text_hash` | string | Chunk text hash (idempotent upsert key) | -| `embedding_model` | string | | -| `vector` | float32 list | Fixed embedding dimension | - -Trajectory projections longer than the embedding model window are split into -deterministic token-aligned chunks (strategy version -`SEMANTIC_CHUNK_STRATEGY_VERSION` in `codeclone/memory/semantic/chunking.py`). -Single-chunk trajectories keep the trajectory id as `id` with null chunk fields. -Retrieval collapses chunk hits to one score per parent trajectory. - -- **Not** governed by `ENGINEERING_MEMORY_SCHEMA_VERSION` — bumping memory SQLite - schema does not automatically invalidate the vector sidecar. -- **Rebuild** on incompatible format bumps (`codeclone memory semantic rebuild`); - no SQLite migration path for the sidecar. -- Row/projection semantics: [Engineering Memory](../13-engineering-memory/index.md); - bump rules: [24-compatibility-and-versioning.md](../24-compatibility-and-versioning.md). - -## Platform Observability schema (`1.1`) - -Optional local SQLite database at -`.codeclone/db/platform_observability.sqlite3`. It is disposable development -telemetry, not report, baseline, cache, audit, or Engineering Memory truth. - -| Table | Role | -|-----------------------|------------------------------------------------------------------------------------------------------------------------| -| `platform_meta` | Schema version metadata. | -| `platform_operations` | Surface-level operation identity, correlation, duration, status, bounded payload sizes, and optional process metrics. | -| `platform_spans` | Ordered subsystem timing, reason/dedupe metadata, counters, normalized SQL fingerprints, and optional process metrics. | - -Operation and span rows are persisted together in one transaction. Profile -columns are nullable and populated only when profiling is enabled with -`codeclone[perf]`. `db_fingerprints` is additively migrated for older local -stores. - -See [Platform Observability](../26-platform-observability.md) for configuration, -privacy, query, and anti-inference rules. - -## Corpus analytics store (`1.2`) - -Optional SQLite database (default `.codeclone/analytics/corpus_clustering.sqlite3`) -and LanceDB vector directory (default `.codeclone/analytics/corpus_vectors`). -Derived offline analytics — not report, baseline, cache, audit, or Engineering -Memory truth. - -| Artifact | Role | -|-------------------------|-----------------------------------------------------------------------| -| `corpus_snapshots` | Immutable-by-contract snapshot metadata and source digests | -| `corpus_items` | Normalized representation, metadata, and optional registry overlay | -| `embedding_generations` | Provider/model/preprocessing manifest | -| `embedding_items` | Vector row keys, float32 digests, dimensions; no vector blobs | -| `clustering_runs` | Requested/effective parameters, algorithm manifest, lifecycle status | -| `cluster_assignments` | Per-run item label, strength, and membership digest | -| `cluster_summaries` | Canonical display id and persisted diagnostics per cluster/noise | -| `profile_manifest_snapshots` | Immutable canonical manifest values, labels, and descriptions | -| `profile_batches` | One immutable execution receipt per profile sweep | -| `profile_batch_runs` | Ordered effective-parameter membership for each batch | -| `profile_assessments` | Technical-validity-aware suitability facts for batch members | -| `run_selections` | Append-only global or profile-batch maintainer decisions | -| LanceDB sidecar | Separate float32 vectors from Engineering Memory semantic index | - -Store schema version: `CORPUS_ANALYTICS_STORE_SCHEMA_VERSION` in -`codeclone/contracts/__init__.py` (currently **`1.2`**). - -Writable open chains `1.0 → 1.1 → 1.2`; it never skips the intermediate -integrity migration. Read-only open never migrates and rejects stale schema. -SQLite triggers prevent orphan-producing inserts, relationship updates, and -parent deletes; unique indexes protect vector row keys, non-null display -cluster ids, and one effective candidate per profile batch. - -Profile state is an overlay over immutable clustering facts: - -```mermaid -erDiagram - CORPUS_SNAPSHOTS ||--o{ CLUSTERING_RUNS : owns - EMBEDDING_GENERATIONS ||--o{ CLUSTERING_RUNS : supplies - PROFILE_MANIFEST_SNAPSHOTS ||--o{ PROFILE_BATCHES : fixes - PROFILE_BATCHES ||--o{ PROFILE_BATCH_RUNS : contains - CLUSTERING_RUNS ||--o{ PROFILE_BATCH_RUNS : participates - PROFILE_BATCHES ||--o{ PROFILE_ASSESSMENTS : assesses - CLUSTERING_RUNS ||--o{ PROFILE_ASSESSMENTS : receives - PROFILE_BATCHES o|--o{ RUN_SELECTIONS : scopes - CLUSTERING_RUNS ||--o{ RUN_SELECTIONS : selects -``` - -`clustering_runs` has no profile columns. A re-sweep creates a new -`profile_batches` row with the exact manifest and candidate-space digests. -`run_selections` supersedes earlier active heads in the same -`(snapshot_id, embedding_generation_id, profile_batch_id)` scope. A null batch -means global selection. The legacy `selected_by_maintainer` field is a -global-scope mirror only. - -The SQLite transaction and LanceDB sidecar cannot share one physical -transaction. The embedding workflow therefore writes metadata and vectors as -one controlled operation, rolls SQLite back and removes the generation on -ordinary failures, and validates row keys, dimensions, and float32 digests -before clustering. Crash residue is detected as an integrity error rather than -accepted as a completed generation. - -### Corpus analytics JSON export (`1.3`) - -`CORPUS_EXPORT_SCHEMA_VERSION = "1.3"` projects interpretation contract `1.1` -and control-plane contract `1.0` over store schema `1.2`; it does not migrate -the SQLite database. - -```text -export -├── schema_version = "1.3" -├── interpretation_contract_version = "1.1" -├── control_plane_contract_version = "1.0" -├── snapshot -├── embedding_generation | null -├── embedding_items[] -├── clustering_run -│ ├── validity -│ ├── presentation -│ ├── profile_context? # stored batch assessment + manifest snapshot -│ ├── selection? # active event for export scope -│ └── partition_metrics | diagnostic_facts -├── clusters[] # full mode only -│ └── interpretation -│ ├── representative_previews[] -│ ├── boundary_previews[] -│ ├── categorical_correlations -│ ├── numeric_summaries -│ ├── provenance_completeness -│ └── machine_inspectability_signals -├── assignments[] # full mode only -├── noise_items[] # full mode only -├── profile_summary? # sweep export, sibling of comparison_summary -└── content_disclosure -``` - -`clustering_run.validity.failed_invariants` is a deterministic ordered subset -of `V1` through `V10`. `presentation.projection_mode` is -`full_interpretation` only when every invariant passes; otherwise it is -`limited_diagnostic`, `partition_metrics` is omitted, `score` is `null`, and -cluster/item interpretation arrays are absent. - -Sweep comparison exports every persisted run for the requested snapshot and -embedding generation. Each candidate has a sibling `comparison` object: - -```json -{ - "score": null, - "rank": null, - "recommended_by_heuristic": false, - "dominant_cluster_ratio": null, - "dominant_assigned_ratio": null, - "largest_cluster_size": null -} -``` - -Only technically valid candidates receive non-null comparison metrics, score, -and rank. Profile-scoped candidates add `profile_suitable` and -`is_profile_recommended`. `comparison_summary` preserves its Slice 1.1 keys; -`profile_summary` is a sibling with batch identity, manifest snapshot metadata, -suitability counts, recommendation rationale, and active selection. - -`content_disclosure` is derived from the final payload. Its preview scopes are -`cluster_representatives`, `cluster_boundaries`, and `noise_items`; the -contract limit is 240 Unicode code points. Default `items[]` entries do not -contain normalized text previews. - -### Corpus representation contract (`3`) - -New intent snapshots persist explicit provenance booleans inside -`corpus_items.metadata_json`: - -```json -{ - "provenance": { - "trajectory": {"selected": false}, - "patch_trail": {"present": false}, - "registry_overlay": {"present": false} - } -} -``` - -Trajectory and Patch Trail identity evidence retains its existing digest -behavior. Registry-overlay content and presence remain advisory and excluded -from source identity. Contract-2 snapshots are immutable and are interpreted -with conservative legacy rules; they are not rewritten. - -See [Corpus Analytics](../27-corpus-analytics.md) for CLI, configuration, and trust -boundaries, and -[Profile Control Plane](../27-corpus-analytics.md#profile-control-plane-slice-12) -for batch and selection semantics, and -[Report Interpretability](../27-corpus-analytics.md#report-interpretability-slice-11) -for validity and privacy rules. - -## Subsystem-local version constants - -Not defined in `codeclone/contracts/__init__.py`; bump in the owning module. - -| Constant | Value | Owner | -|---|---|---| -| `AUDIT_EVENT_CORE_VERSION` | `2` | `codeclone/audit/events.py` | -| `CONTEXT_CONTRACT_VERSION` | `1` | `codeclone/surfaces/mcp/_implementation_context.py` | -| `CALL_RESOLUTION_VERSION` | `1` | `codeclone/surfaces/mcp/_implementation_context.py` | -| `TRAJECTORY_EXPORT_SCHEMA_VERSION` | `2` | `codeclone/memory/trajectory/profiles.py` | - -Central corpus and governance constants (also in `codeclone/contracts/__init__.py`): - -| Constant | Value | -|---|---| -| `IDE_GOVERNANCE_PROTOCOL_VERSION` | `2` | -| `CORPUS_ANALYTICS_STORE_SCHEMA_VERSION` | `1.2` | -| `CORPUS_EXPORT_SCHEMA_VERSION` | `1.3` | -| `CORPUS_PROFILE_MANIFEST_SCHEMA_VERSION` | `1` | -| `CORPUS_CONTROL_PLANE_CONTRACT_VERSION` | `1.0` | -| `CORPUS_REPRESENTATION_CONTRACT_VERSION` | `3` | -| `CORPUS_NORMALIZER_VERSION` | `1` | -| `CORPUS_EMBEDDING_CONTRACT_VERSION` | `2` | -| `CORPUS_AGENT_LABEL_CONTRACT_VERSION` | `1` | -| `CORPUS_PARTITION_MAP_VERSION` | `1` | - -## Refs - -- `codeclone/baseline/clone_baseline.py` -- `codeclone/cache/store.py` -- `codeclone/memory/schema.py` -- `codeclone/memory/schema_trajectory.py` -- `codeclone/memory/schema_migrate.py` -- `codeclone/memory/semantic/models.py` -- `codeclone/observability/store/schema.py` -- `codeclone/contracts/__init__.py` -- `codeclone/audit/events.py` -- `codeclone/surfaces/mcp/_implementation_context.py` -- `codeclone/report/document/builder.py` -- `codeclone/report/renderers/text.py` -- `codeclone/report/renderers/markdown.py` -- `codeclone/report/renderers/sarif.py` diff --git a/docs/book/appendix/c-error-catalog.md b/docs/book/appendix/c-error-catalog.md deleted file mode 100644 index 37393c52..00000000 --- a/docs/book/appendix/c-error-catalog.md +++ /dev/null @@ -1,99 +0,0 @@ - - -# Appendix C. Error Catalog - -## Purpose - -Map core error conditions to statuses, markers, and exits. - -## Contract/gating/internal categories - -| Category | Marker | Exit | -|----------------|-------------------|------| -| Contract error | `CONTRACT ERROR:` | `2` | -| Gating failure | `GATING FAILURE:` | `3` | -| Internal error | `INTERNAL ERROR:` | `5` | - -Refs: - -- `codeclone/ui_messages/__init__.py:MARKER_CONTRACT_ERROR` -- `codeclone/contracts/__init__.py:ExitCode` - -## Baseline contract errors - -| Condition | Baseline status | CLI behavior | -|----------------------|--------------------------------|-------------------------------------------| -| Missing baseline | `missing` | normal: empty diff; gating: exit `2` | -| Schema mismatch | `mismatch_schema_version` | normal: ignore baseline; gating: exit `2` | -| Fingerprint mismatch | `mismatch_fingerprint_version` | normal: ignore baseline; gating: exit `2` | -| Python tag mismatch | `mismatch_python_version` | normal: ignore baseline; gating: exit `2` | -| Integrity mismatch | `integrity_failed` | normal: ignore baseline; gating: exit `2` | - -Refs: - -- `codeclone/surfaces/cli/workflow.py:_main_impl` -- `codeclone/baseline/trust.py:BaselineStatus` - -## Cache degradation cases - -| Condition | Cache status | Behavior | -|---------------------------|---------------------------------|------------------------| -| Missing cache file | `missing` | proceed without cache | -| Version mismatch | `version_mismatch` | ignore cache + warning | -| Analysis profile mismatch | `analysis_profile_mismatch` | ignore cache + warning | -| Invalid JSON/type | `invalid_json` / `invalid_type` | ignore cache + warning | -| Signature mismatch | `integrity_failed` | ignore cache + warning | -| Oversized cache | `too_large` | ignore cache + warning | - -Refs: - -- `codeclone/cache/versioning.py:CacheStatus` -- `codeclone/cache/store.py:Cache._ignore_cache` - -## Source IO and gating - -| Condition | Behavior | -|-------------------------------------------|---------------------------------| -| Source read/decode failure in normal mode | file skipped; warning; continue | -| Source read/decode failure in gating mode | contract error, exit `2` | - -Refs: - -- `codeclone/core/worker.py:process_file` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## Report write errors - -| Condition | Behavior | -|--------------------------------------------|--------------------------| -| Baseline write OSError | contract error, exit `2` | -| HTML/JSON/Markdown/SARIF/TXT write OSError | contract error, exit `2` | - -Refs: - -- `codeclone/surfaces/cli/reports_output.py:_write_report_output` -- `codeclone/surfaces/cli/workflow.py:_main_impl` - -## MCP interface errors - -| Condition | Behavior | -|-----------------------------------------------|---------------------------------------------------| -| Optional `mcp` extra missing | `codeclone-mcp` prints install hint and exits `2` | -| Invalid root path / invalid config | MCP service contract error | -| Missing run or finding id | MCP service request error | -| Unsupported MCP resource URI / report section | MCP service contract error | - -Refs: - -- `codeclone/surfaces/mcp/server.py:main` -- `codeclone/surfaces/mcp/service.py` - -## Locked by tests - -- `tests/test_cli_inprocess.py::test_cli_report_write_error_is_contract_error` -- `tests/test_cli_inprocess.py::test_cli_update_baseline_write_error_is_contract_error` -- `tests/test_cli_inprocess.py::test_cli_unreadable_source_fails_in_ci_with_contract_error` -- `tests/test_cli_unit.py::test_cli_internal_error_marker` -- `tests/test_mcp_server.py::test_mcp_server_main_reports_missing_optional_dependency` diff --git a/docs/book/integrations/claude-code-plugin.md b/docs/book/integrations/claude-code-plugin.md deleted file mode 100644 index 672d1eaf..00000000 --- a/docs/book/integrations/claude-code-plugin.md +++ /dev/null @@ -1,86 +0,0 @@ - - -# Claude Code Plugin - -## Distribution contract - -The monorepo source lives under `plugins/claude-code-codeclone/`. -`scripts/sync_integrations.py --target claude-code` publishes it into the -dedicated `orenlab/codeclone-claude-code` storefront. - -The distribution repository contains: - -| Path | Role | -|------------------------------------------------|------------------------------------------------------------------| -| `.claude-plugin/marketplace.json` | Marketplace catalog named `orenlab-codeclone` | -| `plugins/codeclone/.claude-plugin/plugin.json` | Plugin identity and metadata | -| `plugins/codeclone/.mcp.json` | Local stdio MCP definition | -| `plugins/codeclone/skills/` | Review, hotspots, production-triage, architecture-triage, blast-radius, change control, memory, implementation context, platform observability (maintainer-only) | -| `plugins/codeclone/scripts/launch_mcp.py` | Standalone workspace-first launcher | - -## Installation contract - -Public installation is the two-step marketplace flow: - -```bash -claude plugin marketplace add orenlab/codeclone-claude-code -claude plugin install codeclone@orenlab-codeclone -``` - -Local `--plugin-dir` loading is a development path, not the user installation -contract. - -## Runtime model - -```mermaid -flowchart TD - A["Marketplace catalog"] --> B["Installed CodeClone plugin"] - B --> C["Namespaced skills"] - B --> D[".mcp.json"] - D --> E["Workspace-first launcher"] - E --> F["Local codeclone-mcp"] - F --> G["Canonical analysis and change control"] -``` - -The plugin is additive. It provides nine skills and the standard agent MCP -surface from the locally resolved `codeclone-mcp` version. It does not install -the Python package, filter tools, or create a second analysis model. - -The MCP configuration uses `${CLAUDE_PLUGIN_ROOT}` because Claude Code copies -installed plugins into a versioned cache. Storefront sync replaces the -monorepo delegate launcher with the full standalone implementation. - -The plugin manifest intentionally omits `version`. For a Git-based marketplace, -Claude Code can identify the installed revision by commit SHA; adding an -explicit version would require the distribution release process to bump it for -every plugin change or risk retaining a stale cache entry. - -## Read-only and state boundaries - -The server must not mutate source files, baselines, analysis cache, or canonical -reports. Controller coordination, audit, and Engineering Memory may write only -their documented bounded local state. - -## Separation from Claude Desktop - -Claude Code and Claude Desktop are different install surfaces: - -- Claude Code installs a marketplace plugin with skills and `.mcp.json`. -- Claude Desktop installs the local `.mcpb` bundle. - -Neither surface owns analysis semantics; both connect to `codeclone-mcp`. - -## Current limits - -- `codeclone[mcp]` must already be available in the workspace environment or on - `PATH`. -- Duplicate manual MCP registration can expose the same server twice; keep one - active setup path. -- Plugin skills are namespaced as `/codeclone:`. - -## Further reading - -- [Claude Code setup](../../guide/integrations/claude-code/setup.md) -- [MCP usage guide](../../guide/mcp/README.md) -- [MCP interface contract](../25-mcp-interface/index.md) -- [Claude Desktop bundle](claude-desktop-bundle.md) diff --git a/docs/book/integrations/claude-desktop-bundle.md b/docs/book/integrations/claude-desktop-bundle.md deleted file mode 100644 index 018fedb4..00000000 --- a/docs/book/integrations/claude-desktop-bundle.md +++ /dev/null @@ -1,87 +0,0 @@ - - -# Claude Desktop Bundle - -This contract covers the Claude Desktop `.mcpb` package. Claude Code uses the -separate [Claude Code plugin](claude-code-plugin.md) and marketplace workflow. - -## Bundle workflow - -1. Build: `cd extensions/claude-desktop-codeclone && node scripts/build-mcpb.mjs` -2. Claude Desktop: **Settings → Extensions → Install Extension** → select `.mcpb` -3. If you want to bypass auto-discovery, set **CodeClone launcher command** in - the bundle settings to an absolute path. - -## Settings - -| Setting | Purpose | -|--------------------------------|-------------------------------------------------------------------------------------------------------------| -| **Workspace root path** | Optional absolute project root; launcher prefers that workspace `.venv` when Claude starts outside the repo | -| **CodeClone launcher command** | Absolute path or bare command for `codeclone-mcp` | -| **Advanced launcher args** | JSON array of extra args (transport is always stdio) | - -## Runtime model - -Node wrapper launches `codeclone-mcp` via local `stdio`. It: - -1. resolves a local `codeclone-mcp` launcher -2. validates advanced args -3. forces `--transport stdio` -4. launches the child process with `shell: false` -5. proxies stdio until shutdown - -The wrapper prefers a workspace-local `.venv`, then a Poetry environment, then -user-local install paths, then `PATH`. - -The bundle does **not** pass `--ide-governance-channel`. Agents see the standard -**33** default MCP tools (35 with `--ide-governance-channel`). VS Code session stats, audit trail webviews, and IDE -Memory -governance (`prepare_governance` / `commit_governance`) require the VS Code -extension launcher. - -Engineering Memory and optional semantic search follow the server contract in -[Engineering Memory](../13-engineering-memory/index.md) (`query_engineering_memory`, -`get_relevant_memory`; semantic off by default in pyproject). - -## Privacy - -Local wrapper only — no telemetry, no cloud sync, no remote listener. -See [Privacy Policy](../../privacy-policy.md). - -## Design rules - -- **Canonical MCP first**: the bundle keeps Claude Desktop on the same - documented MCP surface as other clients. -- **Local-only transport**: reject transport and remote-listener overrides. -- **Setup honesty**: fail with a bounded install hint when the launcher is - missing. -- **No hidden runtime dependency games**: the bundle does not pretend to bundle - Python or CodeClone itself. -- **Small and deterministic**: package only the wrapper, manifest, icon, and - documentation needed for local installation. - -## Non-guarantees - -- Bundle presentation inside Claude Desktop may evolve with MCPB client UX. -- Auto-discovery heuristics for common launcher locations may evolve as long as - the explicit launcher setting remains stable. -- The bundle does not guarantee automatic updates or remote install flows. - -## Current limits - -- expects either a workspace launcher, a user-local/global launcher, or an - explicitly configured absolute launcher path -- local install surface, not a hosted service layer - -## Source of truth - -- CLI remains the scripting and CI surface. -- MCP remains the read-only agent/client contract. -- Claude Code installs the dedicated marketplace plugin; direct `mcp add` - remains a manual fallback. -- The Claude Desktop bundle is the installable local package layer for users - who want a native Claude Desktop setup path. - -For the underlying MCP contract, see -[MCP usage guide](../../guide/mcp/README.md) and -[MCP interface contract](../25-mcp-interface/index.md). diff --git a/docs/book/integrations/codex-plugin.md b/docs/book/integrations/codex-plugin.md deleted file mode 100644 index d21ef910..00000000 --- a/docs/book/integrations/codex-plugin.md +++ /dev/null @@ -1,93 +0,0 @@ - - -# Codex Plugin - -## What ships in the plugin - -| File | Purpose | -|--------------------------------------------|-----------------------------------------| -| `.codex-plugin/plugin.json` | Plugin metadata, prompts, instructions | -| `.mcp.json` | Workspace-first MCP launcher definition | -| `scripts/launch_mcp` | Shell-free launcher wrapper for Codex | -| `skills/codeclone-review/` | Conservative-first full review skill | -| `skills/codeclone-hotspots/` | Quick hotspot discovery skill | -| `skills/codeclone-production-triage/` | Production-first baseline triage skill | -| `skills/codeclone-architecture-triage/` | Ranked architecture problem triage skill | -| `skills/codeclone-blast-radius/` | Read-only blast-radius inspection skill | -| `skills/codeclone-change-control/` | Intent-first change workflow skill | -| `skills/codeclone-engineering-memory/` | Engineering memory read/write skill | -| `skills/codeclone-implementation-context/` | Bounded pre-edit context skill | -| `skills/codeclone-platform-observability/` | Maintainer-only observer diagnostics | -| `assets/` | Plugin branding | - -Nine skills ship in the plugin (review, hotspots, production-triage, -architecture-triage, blast-radius, change-control, engineering-memory, -implementation-context, platform-observability). The last is -**only** for developing CodeClone itself — not for end-user repository review. - -## Runtime model - -Additive — the marketplace install provides a local MCP definition and **nine** -skills. New canonical MCP surfaces from the local `codeclone-mcp` version flow -through directly, including Coverage Join facts and the optional `coverage` -help topic when supported. The plugin does not mutate `~/.codex/config.toml` or -install a second server binary. The bundled launcher does not filter MCP tools; -agents receive the full default agent surface from the resolved -`codeclone-mcp` server (no `--ide-governance-channel` — IDE-only session/audit -tools are VS Code only). - -`.agents/plugins/marketplace.json` is the monorepo-local source entry used for -development and packaging into `orenlab/codeclone-codex`; it is not the public -install path. - -Public installation is: - -```bash -codex plugin marketplace add orenlab/codeclone-codex -codex plugin add codeclone@orenlab-codeclone -``` - -## Read-only contract - -Repository truth stays read-only: MCP must not mutate source files, baselines, -analysis cache, or canonical report artifacts. Change-control and session tools -may write ephemeral coordination state through the configured workspace intent -registry (file or SQLite backend) and optional audit records when enabled. - -## Design rules - -- **Codex-native packaging**: keep source under `plugins/` and publish the - marketplace distribution through `orenlab/codeclone-codex`. -- **Canonical MCP first**: all analysis still flows through `codeclone-mcp`. -- **Skill guidance, not analysis logic**: the skill teaches conservative-first - CodeClone review but does not create new findings. -- **No hidden installation side effects**: the plugin does not patch - `~/.codex/config.toml`. -- **Source clarity**: the monorepo copy is the source; the public install - surface is the `orenlab/codeclone-codex` distribution. -- **Launcher honesty**: the plugin assumes `codeclone-mcp` is already - installable in the current workspace or reachable on `PATH`, and prefers the - workspace environment when one is present. -- **Shell-free launch**: the bundled launcher must stay argv-based and - local-stdio-only. - -## Non-guarantees - -- Codex plugin UI presentation may evolve independently of the plugin manifest - content. -- Users who already configured `codeclone-mcp` manually may still prefer the - direct MCP path over the bundled plugin MCP definition. - -## Current limits - -- If you already registered `codeclone-mcp` manually, keep only one setup path - to avoid duplicate MCP surfaces. -- The bundled `.mcp.json` prefers `.venv`, then a Poetry env, then `PATH`. -- The bundled launcher stays shell-free and local-stdio-only. - -## Further reading - -- [MCP usage guide](../../guide/mcp/README.md) -- [MCP interface contract](../25-mcp-interface/index.md) -- [Engineering Memory](../13-engineering-memory/index.md) -- [Structural Change Controller](../12-structural-change-controller/index.md) diff --git a/docs/book/integrations/cursor-plugin.md b/docs/book/integrations/cursor-plugin.md deleted file mode 100644 index f882f14f..00000000 --- a/docs/book/integrations/cursor-plugin.md +++ /dev/null @@ -1,144 +0,0 @@ - - -# Cursor Plugin - -## Installation contract - -The public source is -`https://github.com/orenlab/codeclone-cursor`. Users install CodeClone from -Cursor's marketplace panel. Team administrators expose the storefront through -**Dashboard → Settings → Plugins → Team Marketplaces → Add Marketplace → -Import from Repo**. - -`~/.cursor/plugins/local` symlinks are development-only and must not be -presented as the normal installation path. - -## Rules - -All three ship under `plugins/cursor-codeclone/rules/`: - -| File | Activation | Role | -|---------------------------|---------------------|------------------------------------------------------------------------------------------| -| `codeclone-workflow.mdc` | `alwaysApply: true` | MCP-only discipline, absolute `root`, tool preferences, memory `root` requirement | -| `change-control-gate.mdc` | `alwaysApply: true` | Hard gate: `start` before edit, `finish` before done, memory before finish when required | -| `codeclone-python.mdc` | `globs: **/*.py` | Python context: analyze before structural edits, blast radius awareness | - -The change-control **skill** expands profiles and queue/promote; the -**change-control-gate** rule is the always-on prohibition layer. - -### Skill contract invariants - -Each skill follows these invariants: - -- **MCP tools only** — no CLI or local report fallbacks -- **Absolute roots** — analysis and memory tools require absolute `root` -- **Source of truth** — report CodeClone findings as-is -- **Conservative first pass** unless the user requests deeper sensitivity -- **Workflow tools preferred** — `start_controlled_change` / - `finish_controlled_change` for edits; atomic verify is advanced/fallback -- **Engineering Memory** — optional semantic search when server index is built; - human approve via VS Code Memory view or CLI `--i-know-what-im-doing` - -Skills are invocable via `/name` in Cursor chat (see each `SKILL.md`). - -## Skills - -Nine skills ship under `plugins/cursor-codeclone/skills/`: - -| Skill | Role | -|------------------------------------|------------------------------------------| -| `codeclone-change-control` | Intent-first edit workflow | -| `codeclone-engineering-memory` | Memory retrieval and draft writes | -| `codeclone-implementation-context` | Bounded structural context from MCP runs | -| `codeclone-architecture-triage` | Ranked architecture problems (not tasks) | -| `codeclone-hotspots` | Quick hotspot / health snapshot | -| `codeclone-review` | Conservative-first full review | -| `codeclone-platform-observability` | **Maintainer-only** — CodeClone runtime diagnostics (requires observer enable) | -| `codeclone-blast-radius` | Read-only blast-radius inspection | -| `codeclone-production-triage` | Baseline-relative production triage | - -Codex and Claude Code plugins ship the same nine skills (byte-synced from -`plugins/codeclone/skills/`). - -## Hooks - -Documented from `hooks/hooks.json` and installers — **hook Python sources not -edited in doc-only passes.** - -### Why Settings → Hooks can show "Configured Hooks (0)" - -| Source | Path | Shown in Hooks UI | -|-----------------|--------------------------------------|--------------------------------------------| -| Project | `.cursor/hooks.json` | yes | -| User | `~/.cursor/hooks.json` | yes | -| Plugin manifest | `hooks/hooks.json` via `plugin.json` | **no** (may still run when plugin enabled) | - -Plugin manifest commands use `python "${CURSOR_PLUGIN_ROOT}/hooks/run_hook.py" -"` with subcommands `pre-tool-use-gate`, `post-tool-use`, -`session-cleanup`. - -### Hook events - -- **preToolUse** (`Write|StrReplace|ApplyPatch|Shell`, `failClosed: true`, 5s - timeout) — blocks when the workspace intent registry has no live **active** - intent. Uses `codeclone.workspace_intent` (file or SQLite registry). Scope: - - `python` (default): `.py` / `.pyi` and matching shell - - `repo`: any path under workspace root (including `.git/**`) - - Config: `.cursor/codeclone-hooks.json` or env — see - [Environment variable overrides](../10-config-and-defaults.md#cursor-plugin-hooks) -- **postToolUse** (`Write|StrReplace|ApplyPatch`, 5s) — injects - `additional_context` **only when the edited path is `.py` / `.pyi`** - (`post-tool-use-python-edit.py`). -- **stop** (`loop_limit: 1`, 5s) — optional `followup_message` when the - workspace intent registry still has **own or recoverable Cursor** non-terminal - intents (active, queued, violated, expanded). Foreign active/stale intents - from other agents are ignored — they require coordination, not - `manage_change_intent(clear)` from this session. Transcript JSONL is a - fallback only when registry read fails; it counts `CallMcpTool` workflow - events, not raw substring matches. - -Without an authorized intent, only read-only Git inspection shell commands are -allowed; `git apply`, commits, and direct `.git/**` writes are blocked. - -`enforce_scope` (`python` vs `repo`) is configured in `.cursor/codeclone-hooks.json` -or via `CODECLONE_HOOKS_ENFORCE_SCOPE` — see -[Environment variable overrides](../10-config-and-defaults.md#cursor-plugin-hooks). - -## Read-only contract - -MCP must not mutate source, baselines, analysis cache, or canonical reports. -Change-control and session tools may write ephemeral intent state -(`.codeclone/intents/` file backend by default; SQLite optional) and -optional audit rows when `audit_enabled=true`. - -## Design rules - -- **Cursor-native packaging** under `plugins/cursor-codeclone/` -- **Canonical MCP first** — launcher resolves `codeclone-mcp`, no tool filtering -- **Rules + skills** — `change-control-gate` always on; skills carry workflows -- **Hook safety** — `preToolUse` fail-closed; `postToolUse` / `stop` advisory -- **No hidden installs** — plugin does not patch Cursor or install Python packages - -## Non-guarantees - -- Cursor UI for skills/hooks may evolve independently of manifest content. -- Manual symlink installs may omit bundled rules/hooks unless the full plugin dir - is registered. -- Hook behavior follows Cursor's hook API contract. - -## Current limits - -- Duplicate MCP registration (plugin `mcp.json` + manual `codeclone-mcp` entry) - causes confusion — keep one path. -- `mcp.json` runs `python3 ./scripts/launch_mcp.py` relative to the plugin root, - not a bare `codeclone-mcp` JSON command (the launcher resolves the binary). -- Hooks do not call MCP; they read `codeclone.workspace_intent` only. -- VS Code extension features (Memory UI governance, session/audit webviews, - `codeclone.memory.search*` settings) are outside this plugin. - -## Further reading - -- [MCP usage guide](../../guide/mcp/README.md) -- [MCP interface contract](../25-mcp-interface/index.md) -- [Engineering Memory](../13-engineering-memory/index.md) -- [Structural Change Controller](../12-structural-change-controller/index.md) diff --git a/docs/book/integrations/github-action.md b/docs/book/integrations/github-action.md deleted file mode 100644 index 8a170627..00000000 --- a/docs/book/integrations/github-action.md +++ /dev/null @@ -1,27 +0,0 @@ - - -# GitHub Action - -CodeClone ships a composite GitHub Action for CI and pull-request workflows: -structural analysis, optional SARIF upload, PR summary comments, and -deterministic JSON reports. - -**Authoritative reference:** [`.github/actions/codeclone/README.md`](https://github.com/orenlab/codeclone/blob/main/.github/actions/codeclone/README.md) -in the CodeClone repository (inputs, outputs, exit codes, baseline requirements, -and v2 workflow shape). - -Quick start: - -```yaml -- uses: orenlab/codeclone/.github/actions/codeclone@v2 - with: - fail-on-new: "true" -``` - -The action installs `codeclone` from PyPI for remote consumers. When used from -the checked-out CodeClone monorepo (`uses: ./.github/actions/codeclone`), it -installs from the repository under test. - -For CLI flag semantics and exit codes, see [CLI](../11-cli.md) and -[Exit codes](../09-exit-codes.md). For SARIF upload details, see -[SARIF integration](sarif.md). diff --git a/docs/book/integrations/sarif.md b/docs/book/integrations/sarif.md deleted file mode 100644 index 2ce04a72..00000000 --- a/docs/book/integrations/sarif.md +++ /dev/null @@ -1,62 +0,0 @@ - - -# SARIF - -## Source files - -- `codeclone/report/renderers/sarif.py` -- `codeclone/report/document/builder.py` -- `codeclone/report/findings.py` - -## Design model - -CodeClone builds SARIF from the already materialized canonical report document. -It does not recompute analysis in the SARIF layer. - -That means: - -- finding identities come from canonical finding IDs -- severity/confidence/category data comes from canonical report payloads -- SARIF ordering remains deterministic - -## Path model - -To improve IDE and code-scanning integration, SARIF uses repo-relative paths -anchored through `%SRCROOT%`. - -Current behavior: - -- `run.originalUriBaseIds["%SRCROOT%"]` points at the scan root when known -- `run.artifacts[*]` enumerates referenced files -- `artifactLocation.uri` uses repository-relative paths -- `artifactLocation.index` aligns locations with artifacts for stable linking -- `run.invocations[*].workingDirectory` mirrors the scan root URI when available -- `run.automationDetails.id` is unique per run - -## Result model - -Current SARIF output includes: - -- `tool.driver.rules[*]` with stable rule IDs and help links -- `results[*]` for clone groups, dead code, design findings, and structural findings -- `locations[*]` with primary file/line mapping -- `relatedLocations[*]` for multi-location findings -- `partialFingerprints.primaryLocationLineHash` for stable per-location identity -- explicit `kind: "fail"` on results - -Coverage Join may materialize coverage design findings only when the canonical -report already contains valid `metrics.families.coverage_join` facts. - -## Validation and tests - -Relevant tests: - -- `tests/test_report.py` -- `tests/test_report_contract_coverage.py` -- `tests/test_report_branch_invariants.py` - -Contract-adjacent coverage includes: - -- reuse of the canonical report document -- stable SARIF branch invariants -- deterministic artifacts/rules/results ordering diff --git a/docs/book/integrations/vs-code-extension.md b/docs/book/integrations/vs-code-extension.md deleted file mode 100644 index 222502de..00000000 --- a/docs/book/integrations/vs-code-extension.md +++ /dev/null @@ -1,155 +0,0 @@ - - -# VS Code Extension - -## Trust model - -The extension uses a **limited Restricted Mode**: - -- onboarding and setup help remain available in untrusted workspaces -- local analysis and the local MCP server stay disabled until workspace trust - is granted - -The extension is not intended for virtual workspaces. - -That is intentional: CodeClone reads repository contents, local git state, and -the local MCP launcher. - -!!! warning "Workspace trust still matters" - The extension runs as a workspace extension and requires VS Code `1.120.0` - or newer, local filesystem access, local git access for changed-files review, - and a local `codeclone-mcp` launcher or an explicitly configured one. - CodeClone **`2.0.0` or newer** is required for core analysis, triage, and - change-control MCP tools. - - **Engineering Memory** (Memory tree view, search, IDE governance approve/reject, - trajectory dashboard) requires CodeClone **`2.1.0a1` or newer** with - `query_engineering_memory` and governance tools on the resolved launcher. - Older servers that pass the `2.0.0` gate still load the extension but show - Memory features as unavailable until upgraded. - - In `auto` mode, launcher resolution prefers the current workspace virtualenv - before `PATH`. Launcher override settings (`codeclone.mcp.command`, - `codeclone.mcp.args`) are machine-scoped. Analysis-depth settings are - resource-scoped so they can vary by workspace or folder. - -## Settings - -Authoritative definitions: `extensions/vscode-codeclone/package.json` → -`contributes.configuration.properties`. - -### Launcher (machine-scoped) - -| Setting | Default | Notes | -|-------------------------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------| -| `codeclone.mcp.command` | `auto` | Workspace venv, then `PATH`. User/remote settings. | -| `codeclone.mcp.args` | `[]` | Extra launcher argv. The extension injects `--ide-governance-channel` for Memory governance and session/audit tools (do not duplicate in args). | - -### Analysis (resource-scoped) - -| Setting | Default | Notes | -|--------------------------------------------|------------|----------------------------------------------------| -| `codeclone.analysis.profile` | `defaults` | `defaults`, `deeperReview`, or `custom`. | -| `codeclone.analysis.cachePolicy` | `reuse` | `reuse` or `off`. | -| `codeclone.analysis.changedDiffRef` | `HEAD` | Git ref for **Review Changes**. | -| `codeclone.analysis.coverageXml` | `""` | Explicit Cobertura path for Coverage Join. | -| `codeclone.analysis.autoDetectCoverageXml` | `true` | Use workspace-root `coverage.xml` when path empty. | -| `codeclone.analysis.minLoc` | `10` | Custom thresholds — only when `profile=custom`. | -| `codeclone.analysis.minStmt` | `6` | Same. | -| `codeclone.analysis.blockMinLoc` | `20` | Same. | -| `codeclone.analysis.blockMinStmt` | `8` | Same. | -| `codeclone.analysis.segmentMinLoc` | `20` | Same. | -| `codeclone.analysis.segmentMinStmt` | `10` | Same. | - -### UI (window-scoped) - -| Setting | Default | Notes | -|------------------------------|---------|----------------------------------| -| `codeclone.ui.showStatusBar` | `true` | Workspace-level status bar item. | - -### Engineering Memory search (resource-scoped) - -These map to MCP `query_engineering_memory` parameters from -`extensions/vscode-codeclone/src/memorySearch.js` (`readMemorySearchSettings`). - -| Setting | Default | MCP mapping | Notes | -|----------------------------------------|-----------|-----------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `codeclone.memory.searchSemantic` | `true` | `semantic` on `mode=search` only | Extension **asks** for semantic blend by default. Server still needs `[tool.codeclone.memory.semantic] enabled`, a built sidecar, and a provider. Use `codeclone[semantic-local]` + `embedding_provider="fastembed"` for semantic-quality recall; otherwise FTS-only or diagnostic/degraded results report `semantic.used: false` / provider details. | -| `codeclone.memory.searchIncludeDrafts` | `false` | `include_drafts` (search) | Drafts are still included automatically on `for_path` per memory contract. | -| `codeclone.memory.searchIncludeStale` | `false` | `include_stale` (search and `for_path`) | | -| `codeclone.memory.searchMaxResults` | `20` | `max_results` (clamped 5–50) | | -| `codeclone.memory.searchDetailLevel` | `compact` | `detail_level`: `compact` or `full` | `mode=get` always returns full records. Not exposed in **Configure Memory Search** (settings UI only). | - -!!! important "Extension default differs from server default" - `searchSemantic` defaults to **`true` in VS Code** so the IDE requests semantic - blend when the user searches. CodeClone's **repository** default remains - `memory.semantic.enabled = false` until you opt in in `pyproject.toml`, install - the semantic extras, and rebuild the sidecar (MCP - `rebuild_semantic_index` or CLI `memory semantic rebuild`). - - **Configure Memory Search** updates `searchSemantic`, `searchIncludeDrafts`, - `searchIncludeStale`, and `searchMaxResults` at `ConfigurationTarget.WorkspaceFolder`. - `searchDetailLevel` is settings-editor only. Search queries must be 2–200 characters - without control characters (`sanitizeSearchQuery`). - -## State boundaries - -The extension keeps three state classes visibly separate: - -**Repository truth** — comes from CodeClone analysis through MCP and canonical -report semantics. - -**Current run** — bounded by the active MCP session and the current latest run -used by the extension for a workspace. - -**Reviewed markers** — session-local workflow markers only. They are in-memory -only, do not update baseline state, do not rewrite findings, and do not change -canonical report truth. - -## Design rules - -- **Native VS Code first**: tree views, status bar, Quick Pick, CodeLens, and - file decorations before any custom UI. -- **Conservative by default**: the extension starts with the `defaults` - profile (repo defaults or `pyproject`-resolved thresholds) and treats - `deeperReview` or `custom` as explicit exploratory follow-ups. -- **Source-first**: findings prefer `Reveal Source` over detail panels; - canonical detail and HTML report bridge are opt-in. -- **Report-only separation**: Overloaded Modules stay visually distinct from - findings, gates, and health. `Security Surfaces` stay visually distinct too - and remain boundary inventory rather than vulnerability claims. -- **Safe HTML bridge**: `Open in HTML Report` verifies the local file exists - and is not older than the current run. -- **Session-local state**: reviewed markers shape review UX but never leak - into repository truth. -- **Trajectory evidence**: dashboard/detail commands render MCP trajectory - status, anomalies, exact agent-label aggregates, quality passports, and - Patch Trail evidence without inventing IDE-local scoring. -- **First-run clarity**: onboarding leads to `Analyze Workspace`, not - transport setup. -- **Restricted Mode honesty**: explain requirements without pretending - analysis is available before trust is granted. - -## Non-guarantees - -- Exact view grouping and copy may evolve between extension releases. -- Internal client-side caching and view-model shaping may evolve as long as the - extension remains faithful to MCP and canonical report semantics. -- Explorer decoration styling, review-loop polish, and other non-contract UI - details may evolve without changing the extension contract. - -## Source of truth - -The extension reads the same canonical analysis semantics already exposed by -CodeClone CLI, canonical report JSON, and CodeClone MCP. - -- CLI remains the scripting and CI surface. -- HTML remains the richest human report surface. -- MCP remains the read-only integration contract for agents and IDE clients. -- The VS Code extension is a guided IDE view over that MCP surface. - -For the underlying interface contract, see -[MCP usage guide](../../guide/mcp/README.md) and -[MCP interface contract](../25-mcp-interface/index.md). -Trajectory scoring is defined by -[Trajectory quality and passport](../13-engineering-memory/trajectory-quality-and-passport.md). diff --git a/docs/concepts/blast-radius.md b/docs/concepts/blast-radius.md new file mode 100644 index 00000000..cdac3049 --- /dev/null +++ b/docs/concepts/blast-radius.md @@ -0,0 +1,48 @@ +--- +title: "Blast radius" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +Blast radius is the structural risk boundary around a set of files you're about to change. It shows which functions and modules directly depend on the files in scope, where coverage gaps make impact harder to verify, and which paths are off-limits without explicit approval (do-not-touch boundaries). + +Blast radius is deterministic and read-only: it is computed from the canonical analysis report, not inferred or guessed, and computing it never mutates the repository or grants permission to edit. + +## Why it exists + +Before editing a file, "what else depends on this?" is normally answered by grepping for imports and hoping nothing was missed — a process that scales badly and misses indirect dependents, clone cohorts, and low-coverage areas that make a regression hard to catch with tests. Blast radius exists to answer that question exhaustively and consistently, every time, instead of relying on a reviewer's memory of the codebase. + +It is deliberately separate from permission: knowing the blast radius of a change tells you the *risk*, not whether you are *allowed* to proceed. That authorization question belongs to [edit_allowed](edit-allowed.md). + +## How it fits together + +Blast radius is computed as part of declaring a [controlled change](controlled-change.md): calling `start_controlled_change` returns it alongside the intent id and patch budget. It is not a standalone permission check and it is not the same thing as test coverage — a coverage gap in the blast radius means the risk is harder to verify, not that no risk exists. + +| Element | What it tells you | +|---------|---------------------| +| Direct + transitive dependents | Code that calls into your declared scope, directly or indirectly | +| Clone cohort members | Other locations structurally similar to the files you're changing | +| Coverage gaps | Parts of the risk boundary that tests do not currently exercise | +| Do-not-touch boundaries | Hard boundary — requires separate explicit approval to touch | +| Review-only context | Informational only — not a ban | + +```mermaid +graph LR + A["start_controlled_change"] --> B["Blast radius computed"] + B --> C["Direct + transitive dependents"] + B --> D["Clone cohort members"] + B --> E["Do-not-touch boundaries"] + B --> F["Review-only context"] +``` + +`do_not_touch` paths are a hard boundary, not a suggestion — touching one requires explicit, separate approval, never just editing around it. `review_context` paths are the opposite: informational only, not a ban. + +## Related pages + +- [Controlled change](controlled-change.md) — where blast radius fits in the full edit lifecycle +- [Edit permission and edit_allowed](edit-allowed.md) — the actual authorization signal +- [Agent-safe change workflow](../guides/agent-safe-change.md) — the concrete commands diff --git a/docs/concepts/controlled-change.md b/docs/concepts/controlled-change.md new file mode 100644 index 00000000..17ca54ce --- /dev/null +++ b/docs/concepts/controlled-change.md @@ -0,0 +1,45 @@ +--- +title: "Controlled change" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +Controlled change is CodeClone's pre-edit and post-edit verification workflow for repository modifications. It provides intent declaration, scope boundaries, structural risk assessment, and deterministic verification before and after code changes, producing a durable audit trail of what was known, what was declared, and what was verified. + +## Why it exists + +Unreviewed, unscoped edits are the normal failure mode for both human and AI-assisted development: a change silently grows beyond its intended files, nobody notices a new dependency cycle until it ships, and "I tested it" is not something a later reviewer can verify. Controlled change exists to make scope and verification explicit and checkable rather than a matter of trust — particularly important for AI agents, which have no persistent memory of their own actions across sessions unless the workflow records it for them. + +## How it fits together + +Controlled change is the umbrella workflow that several other concepts plug into: + +| Concept | Role in the workflow | +|---------|------------------------| +| [Blast radius](blast-radius.md) | Computed at intent declaration; shows the structural risk of the declared scope | +| [Edit permission and edit_allowed](edit-allowed.md) | The single authorization signal the workflow produces before editing may begin | +| [Engineering Memory](engineering-memory.md) | Consulted mid-edit for prior decisions, incidents, and stale-context warnings | +| [Review receipts](review-receipts.md) | The durable, auditable record produced when the workflow finishes | + +```mermaid +graph LR + A["Analysis baseline"] --> B["Controlled change"] + B --> C["Blast radius"] + B --> D["edit_allowed"] + B --> E["Engineering Memory"] + B --> F["Review receipt"] +``` + +The workflow is lightweight for documentation-only changes and full-featured for structural Python or governance-config changes: a session holds exactly one trackable active intent at a time, and starting a new one before finishing the current one evicts it from tracking with no recovery except redeclaring the same scope. + +## Related pages + +- [Agent-safe change workflow](../guides/agent-safe-change.md) — the concrete commands and decision points +- [Blast radius](blast-radius.md) +- [Edit permission and edit_allowed](edit-allowed.md) +- [Engineering Memory](engineering-memory.md) +- [Review receipts](review-receipts.md) diff --git a/docs/concepts/edit-allowed.md b/docs/concepts/edit-allowed.md new file mode 100644 index 00000000..d10560ef --- /dev/null +++ b/docs/concepts/edit-allowed.md @@ -0,0 +1,42 @@ +--- +title: "Edit permission and edit_allowed" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +`edit_allowed` is a deterministic permission flag returned by `start_controlled_change` that authorizes editing repository files within a declared scope. It is the single signal that gates an edit — not a suggestion, not one input among several, but the gate itself. + +## Why it exists + +Every other piece of context available before an edit — blast radius, engineering memory, how far along a task feels — is informative but not authoritative, and treating any of them as permission has caused real incidents (an agent editing because "the workflow is almost done" or because a blast-radius view looked safe). `edit_allowed` exists to remove that ambiguity: there is exactly one flag to check, and it is either `true` or it isn't. + +## How it fits together + +| Signal | Grants edit permission? | +|--------|--------------------------| +| `start_controlled_change` returns `status: "active"`, `edit_allowed: true` | Yes | +| `start_controlled_change` returns `edit_allowed: false` | No | +| `status: "queued"` (foreign intent holds scope) | No — wait for promotion | +| `status: "blocked"` (conflict or budget exhausted) | No — narrow or coordinate | +| Blast radius looks low-risk | No — blast radius is context, not authorization | +| Engineering Memory says a similar edit was safe before | No — memory does not authorize edits | +| `finish_controlled_change` returned `"accepted"` on a *previous* edit | No — authorization must exist *during* the edit, not after a prior one | + +An MCP session holds exactly one trackable active intent. Calling `start_controlled_change` again before finishing the current one evicts it from session tracking with no recovery — the next `finish_controlled_change` call fails with "Unknown change intent id." + +```mermaid +graph TD + A["start_controlled_change"] -->|edit_allowed: true| B["Edit within scope"] + A -->|edit_allowed: false / queued / blocked| C["Do not edit"] + B --> D["finish_controlled_change"] +``` + +## Related pages + +- [Controlled change](controlled-change.md) — the full workflow this flag belongs to +- [Blast radius](blast-radius.md) — context, not authorization +- [Agent-safe change workflow](../guides/agent-safe-change.md) — the concrete commands diff --git a/docs/concepts/engineering-memory.md b/docs/concepts/engineering-memory.md new file mode 100644 index 00000000..68e35a9c --- /dev/null +++ b/docs/concepts/engineering-memory.md @@ -0,0 +1,40 @@ +--- +title: "Engineering Memory" +audience: public +doc_type: concept +status: draft +source_commit: "582228177b2f57d9b9823ff1da9b6a0620f1297f" +--- + +## What it is + +Engineering Memory is CodeClone's durable knowledge store for repository insights and workflow decisions. It persists evidence-linked facts across analysis runs, AI agent sessions, and developer workflows in a local SQLite database, and survives process exits and restarts. + +It holds four kinds of evidence: **records** (architecture decisions, change rationales, contract notes, risk notes), **experiences** (episodic patterns distilled from past workflow trajectories), **trajectories** (timestamped audit trails of agent actions), and a **semantic index** over records and experiences for retrieval. + +## Why it exists + +An AI agent session is ephemeral — a new session, a context-window reset, or a new MCP process all start with no memory of what a previous session learned. Without a durable store, hard-won context (a non-obvious root cause, a workaround for a stale contract, a decision that was already litigated) is lost and gets rediscovered at cost, or worse, silently contradicted. Chat transcripts are not a substitute: they are ephemeral and not queryable by a future agent working on the same file. Engineering Memory exists to make that knowledge durable, evidence-linked, and scoped to the files it's actually relevant to. + +## How it fits together + +| Lane | What it captures | Authority | +|------|--------------------|-----------| +| Records | Asserted knowledge (decisions, rationale, risks) | Human-approved before treated as established | +| Experiences | Advisory patterns distilled from many past trajectories | Advisory, not a fact about the current code | +| Trajectories | Episodic evidence of what agents actually did | Evidence, not authorization | + +Memory is retrieved mid-workflow, after [controlled change](controlled-change.md) has declared an edit scope, and is filtered to that scope rather than the whole repository. Writing new records happens through the same MCP surface, not by asserting things in chat. Draft records require human approval (via the CodeClone VS Code Memory view) before being treated as established facts — memory cannot authorize edits, expand scope, or override structural findings on its own. + +```mermaid +graph LR + A["Controlled change scope declared"] --> B["Retrieve relevant memory"] + B --> C["Records / Experiences / Trajectories"] + C --> D["Edit with context"] + D --> E["Write new records if warranted"] +``` + +## Related pages + +- [Engineering Memory workflow](../guides/engineering-memory-workflow.md) — the concrete retrieval and write commands +- [Controlled change](controlled-change.md) — where memory retrieval fits in the edit lifecycle diff --git a/docs/concepts/health-score.md b/docs/concepts/health-score.md new file mode 100644 index 00000000..9c9b0545 --- /dev/null +++ b/docs/concepts/health-score.md @@ -0,0 +1,46 @@ +--- +title: "Health score" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +Health score is a composite metric that measures Python code quality across multiple structural dimensions. It combines seven weighted factors into a single 0–100 score on a normalized scale where higher is better: clones, complexity, cohesion, coupling, coverage, dead code, and dependency cycles. + +Health score is a summary, not a replacement for looking at findings. A score drop tells you *that* something regressed; the underlying structural analysis findings tell you *what* and *where*. + +## Why it exists + +Seven separate metrics are hard to reason about together, and harder still to gate a CI pipeline on. Health score exists to answer one question cheaply: "did this change make the codebase's structure better or worse, overall?" — without requiring every reviewer to understand the interaction between complexity thresholds, coupling limits, and dead-code detection individually. + +It should not be treated as an absolute target, because the weighting reflects CodeClone's defaults, not a universal notion of correctness. High health also does not imply functional correctness or security — it measures structural properties only, and is meant to sit alongside tests and security review, not replace them. + +## How it fits together + +| Dimension | Weight | Fed by | +|-----------|--------|--------| +| Clones | 25% | [Structural analysis](structural-analysis.md) clone detection | +| Complexity | 20% | Cyclomatic/cognitive complexity per function | +| Cohesion | 15% | Method dispersion within a class | +| Coupling | 10% | Fan-in/fan-out between modules | +| Coverage | 10% | Untested-hotspot detection | +| Dead code | 10% | Reachability analysis | +| Dependencies | 10% | Dependency-cycle detection | + +```mermaid +graph LR + A["Structural analysis findings"] --> B["Health score (0-100)"] + B --> C["Reports and baselines"] + B --> D["CI gate (--fail-health)"] +``` + +Health score is computed fresh on every run and stored as part of the [report](reports.md) for that run; comparing scores across runs only makes sense when both runs analyzed the same repository root. + +## Related pages + +- [Run the first analysis](../guides/first-analysis.md) — where the score appears in the report +- [Structural analysis](structural-analysis.md) — the seven underlying dimensions +- [CI integration](../guides/ci-integration.md) — gating a pipeline on health score diff --git a/docs/concepts/mcp.md b/docs/concepts/mcp.md new file mode 100644 index 00000000..d0736655 --- /dev/null +++ b/docs/concepts/mcp.md @@ -0,0 +1,47 @@ +--- +title: "MCP interface" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +The MCP interface is CodeClone's Model Context Protocol server: a deterministic, read-only repository-analysis and change-control surface intended for programmatic and agent-driven workflows, as opposed to a human typing commands at a terminal. + +MCP tools operate on absolute repository roots, not relative paths, and are stateless between calls except for session-managed analysis runs and active change intents. + +## Why it exists + +A human-facing CLI and an agent-facing protocol have different needs: agents call tools programmatically, need structured (not human-formatted) responses, and benefit from narrower, purpose-built tools (a triage view, a single finding, a blast-radius query) rather than one large command with many flags. MCP exists to serve that audience directly, sharing the same deterministic analysis engine as the CLI rather than reimplementing it. + +## How it fits together + +MCP groups its tools by purpose: + +| Group | Purpose | +|-------|---------| +| Analysis | Run analysis and register a session-local run | +| Inspection | Retrieve summaries, sections, and implementation context from a run | +| Change control | Declare intent, compute blast radius, verify, and clear | +| Triage | Production-first and hotspot-focused views for first-pass review | +| Engineering Memory | Retrieve and govern durable evidence | +| Audit | Fetch durably stored receipts, patch trails, and blast artifacts | + +```mermaid +graph TD + A["MCP server"] --> B["Analysis tools"] + A --> C["Inspection tools"] + A --> D["Change control tools"] + A --> E["Engineering Memory tools"] + A --> F["Audit tools"] +``` + +MCP is one of several client surfaces (see the [integrations](../integrations/claude.md)) that all talk to the same underlying analysis engine and change-control contracts described in [Controlled change](controlled-change.md). + +## Related pages + +- [MCP tools reference](../reference/mcp-tools.md) — the exhaustive tool list +- [Controlled change](controlled-change.md) +- [Claude integration](../integrations/claude.md) diff --git a/docs/concepts/project-setup.md b/docs/concepts/project-setup.md new file mode 100644 index 00000000..8a30cd4c --- /dev/null +++ b/docs/concepts/project-setup.md @@ -0,0 +1,46 @@ +--- +title: "Project setup" +audience: public +doc_type: concept +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +Project setup is the process of preparing a repository for CodeClone governance. The `setup` command discovers project structure, evaluates readiness across **14 capabilities** (grouped into core analysis, governed agent workflows, project knowledge, and team & release), each scored on installation, configuration, and runtime axes, and plans a small set of filesystem changes: it merges the `[tool.codeclone]` section into `pyproject.toml` and appends CodeClone cache paths to `.gitignore`. + +Setup is read-only until a plan is explicitly applied and confirmed — discovering and planning never write to disk on their own. + +## Why it exists + +Change control, Engineering Memory, and the audit trail all need governance config in `pyproject.toml` (and appropriate `.gitignore` entries) to be wired up correctly. Their databases — the workspace intent registry, the audit trail, the memory store — are created lazily at runtime the first time each feature runs; setup does **not** create them. Writing the config by hand is error-prone and easy to get subtly wrong (wrong gitignore entry, stale config key). Setup exists to make that bootstrap a single, safe, reviewable operation — plan first, then apply — rather than a set of manual file edits. + +## How it fits together + +Setup unlocks the rest of CodeClone's governance surface; nothing else requires it for read-only analysis: + +| Capability | Requires setup? | +|------------|-------------------| +| Plain `codeclone .` analysis | No | +| [Controlled change](controlled-change.md) intent tracking | Yes — needs the governance config setup writes (the intent/audit databases are created on first use) | +| [Engineering Memory](engineering-memory.md) | Yes — needs its config wired up (the store is initialized on first use) | +| CI gating on a baseline | No — a baseline can be created without running setup | + +```mermaid +graph LR + A["setup plan"] --> B["capability readiness (14 capabilities)"] + B -->|pass| C["setup apply"] + B -->|fail| D["fix environment/config"] + D --> A + C --> E["pyproject.toml + .gitignore updated"] + E --> F["Controlled change + Engineering Memory available"] +``` + +Applying a plan is a two-step contract by design: `--dry-run` previews the mutation with no writes, and `--plan-id` binds the actual apply to that exact previewed plan — if the repository changed in between, apply refuses with `status=stale_plan` rather than writing something that was never reviewed. + +## Related pages + +- [Set up a project](../guides/setup-project.md) — the concrete commands +- [Setup command reference](../reference/setup.md) +- [Controlled change](controlled-change.md) — what setup unlocks diff --git a/docs/concepts/reports.md b/docs/concepts/reports.md new file mode 100644 index 00000000..18c6dc72 --- /dev/null +++ b/docs/concepts/reports.md @@ -0,0 +1,43 @@ +--- +title: "Reports and baselines" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +A CodeClone **report** is the structural analysis of a repository produced by a single run: clones, design risks (cohesion, complexity, coupling), dead code, coverage, and the health score, in a deterministic form — the same repository state at the same commit always produces the same report. + +A **baseline** is a stored snapshot of a report from a known-good state, typically a stable commit on the main branch. Once set, CodeClone compares new runs against it to mark findings as "novel" or "known" and to track health-score improvement or regression. + +## Why it exists + +A report alone only describes the present; it cannot say whether things got better or worse without something to compare against. Baselines exist to give every subsequent run a fixed point of reference, so that "500 findings" can mean either "500 pre-existing findings, none new" or "500 findings, 12 of them new regressions" — a distinction that matters enormously for CI gating and is invisible without a baseline. + +## How it fits together + +| Format | Purpose | +|--------|---------| +| JSON | Canonical, machine-readable; source for all other formats | +| HTML | Human-browsable report with drill-down | +| Markdown | Human-readable summary, e.g. for a PR description | +| SARIF | Static-analysis interchange format for CI code-scanning integrations | +| Text | Plain-text summary for terminals and logs | + +```mermaid +graph LR + A["Analysis run"] --> B["Report (JSON canonical)"] + B --> C["HTML / Markdown / SARIF / text"] + B --> D["Set as baseline"] + D --> E["Future runs compare against it"] +``` + +A report is tied to its repository root; comparing reports generated from different roots is meaningless even if the content looks similar. Moving the baseline too often (on every commit) removes the ability to spot regressions — a baseline should move only after a deliberate review, not automatically. + +## Related pages + +- [Report reference](../reference/reports.md) — the exact schema and file formats +- [Health score](health-score.md) — the composite metric carried inside a report +- [Example report](../examples/report.md) — a live sample diff --git a/docs/concepts/review-receipts.md b/docs/concepts/review-receipts.md new file mode 100644 index 00000000..3096a27c --- /dev/null +++ b/docs/concepts/review-receipts.md @@ -0,0 +1,43 @@ +--- +title: "Review receipts" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +A review receipt is a deterministic, auditable summary generated at the end of a controlled code change. It captures the scope of the edit, the structural risks identified, which findings were reviewed, and the patch-health decision — evidence that a change was examined and authorized, not a claim made from memory or chat. + +A receipt is not a test result. It does not claim the code is defect-free; it is a boundary marker: "this is what we knew at the time of change, these are the findings we examined, this was the decision." + +## Why it exists + +"I reviewed it and it's fine" is not verifiable after the fact, and is exactly the kind of unfalsifiable claim that causes trouble when a later change uncovers a problem nobody can trace back to a decision. Review receipts exist to make that claim checkable: generated from the audit trail rather than typed by hand, referencing the exact analysis run, change intent, and blast radius that existed at the time — and immutable once created. + +## How it fits together + +A receipt is produced by `create_review_receipt` after [controlled change](controlled-change.md) finishes, referencing the intent id and the before/after analysis runs. Any review text supplied alongside it is checked by `validate_review_claims` against canonical report semantics, so a claim like "no regressions" cannot stand without before/after evidence to back it. + +| A receipt captures | A receipt does not claim | +|----------------------|------------------------------| +| Declared scope and blast radius at intent time | The code is defect-free | +| Findings that existed and were reviewed | Functional correctness or security guarantees | +| Patch-health delta between before/after runs | Anything beyond what the audit trail evidences | +| The final scope/verification decision | A passing test result | + +```mermaid +graph LR + A["Controlled change finishes"] --> B["create_review_receipt"] + B --> C["Receipt (markdown or JSON)"] + D["Review claims text"] -.->|validate_review_claims| C + C --> E["Stored in audit trail"] +``` + +A session holds only one active intent at a time, so the `intent_id` from `start_controlled_change` must be kept until `finish_controlled_change` — losing it means the receipt for that edit cannot be generated for that intent. + +## Related pages + +- [Review a change](../guides/review-a-change.md) — the concrete commands for generating and reading a receipt +- [Controlled change](controlled-change.md) — the workflow a receipt is generated from diff --git a/docs/concepts/structural-analysis.md b/docs/concepts/structural-analysis.md new file mode 100644 index 00000000..55a05811 --- /dev/null +++ b/docs/concepts/structural-analysis.md @@ -0,0 +1,47 @@ +--- +title: "Structural analysis" +audience: public +doc_type: concept +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +Structural analysis is CodeClone's core examination of Python code for design metrics and patterns. It measures complexity, coupling, cohesion, dead code, and duplicate blocks across a repository, and produces a deterministic snapshot: the same repository state at the same commit always produces the same findings. + +Structural analysis is not linting or style checking. It does not care about formatting, naming conventions, or import order — it measures the *shape* of the design: how large and tangled functions are, how tightly modules depend on each other, and whether code is reachable or duplicated. + +## Why it exists + +Design decay is gradual and easy to miss commit-by-commit. A function that grows by ten lines per PR, or a module that quietly picks up one more dependency each release, rarely triggers a code-review objection on its own — but the accumulated effect is a codebase that is expensive to change safely. Structural analysis makes that accumulation visible and comparable across time, instead of relying on individual reviewers to notice a slow drift. + +It also gives AI coding agents a deterministic signal to reason about before and after an edit, which is what makes agent-safe change control possible at all: an agent cannot verify "no new regressions" without a structural baseline to compare against. + +## How it fits together + +Structural analysis is the base layer that several other CodeClone concepts build on: + +| Consumer | What it uses from structural analysis | +|----------|----------------------------------------| +| [Health score](health-score.md) | Aggregates clone, complexity, cohesion, coupling, coverage, dead-code, and dependency metrics into one weighted 0–100 score | +| [Reports and baselines](reports.md) | Packages one analysis run's findings into a durable, comparable artifact | +| [Blast radius](blast-radius.md) | Uses the dependency and clone-cohort data from the same run to bound structural risk before an edit | +| [Controlled change](controlled-change.md) | Requires a fresh analysis run both before editing (baseline) and after (verification) | + +```mermaid +graph TD + A["Structural analysis"] --> B["Health score"] + A --> C["Reports and baselines"] + A --> D["Blast radius"] + D --> E["Controlled change"] + C --> E +``` + +Thresholds (what counts as "too complex" or "too coupled") are configurable per project in `pyproject.toml`, because acceptable complexity varies by domain — the analysis itself does not hard-code a universal definition of "good design." + +## Related pages + +- [Run the first analysis](../guides/first-analysis.md) — the concrete commands and workflow +- [Health score](health-score.md) — how these metrics become one number +- [Reports and baselines](reports.md) — how a run's findings are stored and compared over time diff --git a/docs/examples/report.md b/docs/examples/report.md index a43add5c..456ad569 100644 --- a/docs/examples/report.md +++ b/docs/examples/report.md @@ -1,51 +1,51 @@ - +--- +title: "Example report" +audience: public +doc_type: example +status: draft +source_commit: "5d76c10fa9538052e2a4dc130e5dce254063c21e" +--- -# Sample Report +# Example report -This page links to a live example report generated from the current `codeclone` -repository at docs build time. +## What this is -The example is rebuilt from the same tree that produces the published -documentation, so the HTML, canonical JSON, and SARIF artifacts stay aligned. +Every time this documentation site is published, CI runs CodeClone against +this repository's own source and stages the result here. It is a real report, +not a mockup: the same HTML, JSON, and SARIF output you get from running +CodeClone on your own project. -

- - Open interactive HTML report - - - Open canonical JSON - - - Open SARIF - - - View generation manifest - -

+## View the live report -## What this contains +- Interactive HTML report — + the same report a local `codeclone .` run produces, including the Module Map, + Findings, and Health Score panels. +- Raw JSON report — + the canonical machine-readable payload consumed by the MCP server and other tooling. +- SARIF findings — + the same findings rendered as SARIF for Code Scanning / editor integrations. +- Build manifest — + the CodeClone version and commit this specific report was generated from. -- Full HTML report generated by `codeclone` against the current repository. -- Canonical JSON report rendered from the same analysis run. -- SARIF projection from the same canonical report. +## What's inside -## Why this lives here - -- It gives readers a realistic, current example of the report surfaces. -- It keeps the sample aligned with the shipped report contract instead of - freezing a stale artifact in git. -- It makes the docs site useful as both reference and product demo. - -## Local preview - -Build the docs site, then generate the example report into the built site: +The report is generated on every docs build with: ```bash -uv run --with zensical==0.0.46 zensical build --clean --strict uv run python scripts/build_docs_example_report.py --output-dir site/examples/report/live ``` -The generated assets are not committed to the repository; they are produced -locally for preview and automatically during the GitHub Pages publish workflow. +This runs `codeclone . --html --json --sarif` against the repository, with +`--no-fail-on-new` and `--no-fail-on-new-metrics` set — the example is a +preview of report output, not a CI gate. It does not reflect this repository's +actual pre-commit/CI policy, which is stricter. + +| File | Contents | +|------------------|--------------------------------------------------------------| +| `index.html` | Full interactive HTML report (Module Map, Findings, Metrics) | +| `report.json` | Canonical JSON report | +| `report.sarif` | SARIF-formatted findings | +| `manifest.json` | CodeClone version, commit SHA, and generation timestamp | + +Use this page to see what a real report looks like before installing +CodeClone on your own repository. diff --git a/docs/getting-started.md b/docs/getting-started.md index ab4f10e9..2124830d 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -1,286 +1,187 @@ - +--- +title: "Getting started" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- -# Getting Started +# Getting started -Install CodeClone, run your first analysis, set up CI gating, and connect -an MCP client — in that order. +CodeClone is a deterministic structural change controller for Python. It analyzes your code before and after changes, detects structural regressions, and enforces quality gates to catch issues early. ## Install -=== "uv (recommended)" - - ```bash - uv tool install codeclone - ``` - -=== "pip" - - ```bash - pip install codeclone - ``` - -=== "Run without installing" - - ```bash - uvx codeclone@latest . - ``` - -To use the MCP server (AI agents, IDE extensions), install the `mcp` extra: +CodeClone 2.1 is currently available as a prerelease. To install it: +**Using uv:** ```bash -uv tool install "codeclone[mcp]" -# or -pip install "codeclone[mcp]" +uv tool install --prerelease allow codeclone ``` -!!! tip "Install the in-development 2.1 prerelease" - The 2.1 line ships as alpha/beta prereleases. A plain install resolves the - latest **stable** release; add a prerelease flag to get 2.1: - - ```bash - uv tool install --prerelease allow "codeclone[mcp]" # uv - pip install --pre "codeclone[mcp]" # pip - ``` - -## First Run - +**Using pip:** ```bash -codeclone . +pip install --pre codeclone ``` -This analyzes the current directory and prints a summary to stdout. -For an HTML report: - +Verify the installation: ```bash -codeclone . --html --open-html-report +codeclone --version ``` -Other formats — all rendered from one canonical JSON report: +## Run the first analysis + +Navigate to your Python project and run: ```bash -codeclone . --json # JSON -codeclone . --md # Markdown -codeclone . --sarif # SARIF (IDE / Code Scanning) -codeclone . --text # plain text +codeclone ``` -### Changed-scope review +CodeClone scans your project structure, detects code clones, complexity violations, and dependency issues. By default, it analyzes the current directory and generates reports in `.codeclone/`. -Analyze only files changed relative to a branch: +**Common first-run options:** -```bash -codeclone . --changed-only --diff-against main -``` +| Option | Purpose | +|--------|---------| +| `codeclone --help` | Show all available flags | +| `codeclone --processes 8` | Use parallel workers (default: 4) | +| `codeclone --min-loc 5` | Lower minimum lines for clone detection | +| `codeclone --no-progress` | Suppress progress output (useful in CI) | -Or from a recent commit: +For CI/CD integration, use CI mode: ```bash -codeclone . --paths-from-git-diff HEAD~1 +codeclone --ci ``` -## CI Setup +This disables color, limits output, and applies fail-on-new clone detection. -### 1. Create a baseline +## Read the first report + +After analysis completes, open the HTML report: ```bash -codeclone . --update-baseline +codeclone --html +open .codeclone/report.html ``` -By default this writes `codeclone.baseline.json`, the unified clone and metrics -baseline. Commit it to the repository — it becomes the contract CI enforces. -If you use `--metrics-baseline` to redirect metric state, commit that file too. - -### 2. Run in CI +Or generate other formats: ```bash -codeclone . --ci +codeclone --json # Canonical JSON report +codeclone --md # Markdown summary +codeclone --sarif # SARIF 2.1.0 format ``` -`--ci` equals `--fail-on-new --no-color --quiet`. When a trusted metrics -baseline is present, CI mode also enables `--fail-on-new-metrics`. +The report shows: +- **Clone groups**: duplicate code segments and their locations +- **Complexity metrics**: cyclomatic complexity and coupling scores +- **Dead code**: unreachable functions +- **Health score**: overall code quality summary -Baseline governance: new clones and metric regressions fail the build; -accepted legacy debt passes. CI sees only what changed. +## Start a controlled change -### 3. Quality gates +There are two distinct ways to guard edits, and it's worth keeping them separate: -Add thresholds for stricter enforcement: +- **CLI patch verification** (`--patch-verify`) — a one-shot check. It runs analysis, compares your working tree against the trusted baseline budget, reports baseline-relative regressions and gate status, then exits. It does **not** declare an intent or gate whether you may edit. +- **MCP controlled change** (`start_controlled_change` / `finish_controlled_change`) — the intent-first workflow used by agents and IDE integrations. It declares scope, gates edit permission, and verifies the patch at finish. -```bash -codeclone . --fail-complexity 20 --fail-coupling 10 --fail-cohesion 4 -codeclone . --fail-cycles --fail-dead-code --fail-health 60 -codeclone . --fail-on-typing-regression --fail-on-docstring-regression -codeclone . --coverage coverage.xml --fail-on-untested-hotspots -``` +For a quick local check of the current working tree: -See [Metrics and quality gates](book/16-metrics-and-quality-gates.md) for the -full gate reference. - -### GitHub Action - -```yaml -- uses: orenlab/codeclone/.github/actions/codeclone@v2 - with: - fail-on-new: "true" - sarif: "true" - pr-comment: "true" +```bash +codeclone --patch-verify ``` -Runs gating, generates reports, uploads SARIF to Code Scanning, and posts a -PR summary comment. -[Action docs](https://github.com/orenlab/codeclone/blob/main/.github/actions/codeclone/README.md) - -### Pre-commit hook - -```yaml -repos: - - repo: local - hooks: - - id: codeclone - name: CodeClone - entry: codeclone - language: system - pass_filenames: false - args: [ ".", "--ci" ] - types: [ python ] +```mermaid +graph LR + A["Run analysis"] --> B["Declare intent"] + B --> C["Edit code"] + C --> D["Run analysis again"] + D --> E["Verify and finish"] + E --> F["Done"] + style A fill:#e1f5ff + style B fill:#fff3e0 + style C fill:#fce4ec + style D fill:#e1f5ff + style E fill:#c8e6c9 + style F fill:#c8e6c9 ``` -### Exit codes +The MCP controlled-change workflow ensures: +1. Your changes stay within declared scope +2. No structural regressions are introduced +3. Quality gates pass +4. All changes are audited -| Code | Meaning | -|------|-----------------------------------------------------| -| `0` | Success | -| `2` | Contract error — untrusted baseline, invalid config | -| `3` | Gating failure — new clones or threshold exceeded | -| `5` | Internal error | +When using CodeClone's MCP service (for Claude Code, Cursor, or Codex integration), the workflow is: -Contract errors (`2`) take precedence over gating failures (`3`). -See [Exit codes](book/09-exit-codes.md). +1. `analyze_repository` — establish a baseline run +2. `start_controlled_change` — declare scope, get intent ID, gate edit permission +3. Edit your code within scope +4. `analyze_repository` again — after-run for structural verification +5. `finish_controlled_change` — verify, produce a receipt, and clear the intent -## MCP Setup +## Verify and finish -The MCP server exposes **33 tools** for agent clients over the same canonical -pipeline (35 when VS Code starts the server with `--ide-governance-channel` for -session stats and audit insights). - -### Start the server +After editing, verify your changes don't introduce regressions: ```bash -codeclone-mcp --transport stdio # local clients (IDE, agents) -# HTTP: set CODECLONE_MCP_AUTH_TOKEN (≥32 chars) before start — required for streamable-http -codeclone-mcp --transport streamable-http # remote / HTTP clients +codeclone --patch-verify --strictness ci ``` -!!! warning - Analysis tools require an **absolute** repository root. - Relative roots like `.` are rejected. - -### Connect a client - -=== "VS Code" - - Install from the - [VS Code Marketplace](https://marketplace.visualstudio.com/items?itemName=orenlab.codeclone). - The extension connects to `codeclone-mcp` automatically. - - See [VS Code extension guide](guide/integrations/vscode/setup.md). - -=== "Claude Desktop" +**Strictness levels:** +- `ci` — strict gating in CI environment (default) +- `strict` — all gates enabled +- `relaxed` — report warnings without failing - Use the pre-built bundle in - [`extensions/claude-desktop-codeclone/`](https://github.com/orenlab/codeclone/tree/main/extensions/claude-desktop-codeclone). +The verification checks: +- No new clone groups (unless baseline allows) +- No new complexity violations +- No uncovered hotspots +- No dead code regressions - See [Claude Desktop guide](guide/integrations/claude-desktop/setup.md). +If verification passes, your changes are safe to commit. -=== "Claude Code" +## Troubleshooting - ```bash - claude plugin marketplace add orenlab/codeclone-claude-code - claude plugin install codeclone@orenlab-codeclone - ``` - - The marketplace repository is - [orenlab/codeclone-claude-code](https://github.com/orenlab/codeclone-claude-code). - - See [Claude Code plugin guide](guide/integrations/claude-code/setup.md). - -=== "Codex" - - ```bash - codex plugin marketplace add orenlab/codeclone-codex - codex plugin add codeclone@orenlab-codeclone - ``` - - The marketplace repository is - [orenlab/codeclone-codex](https://github.com/orenlab/codeclone-codex). - - See [Codex plugin guide](guide/integrations/codex/setup.md). - -=== "Cursor" - - In Cursor, open **Dashboard → Settings → Plugins → Team Marketplaces**, - choose **Add Marketplace → Import from Repo**, and enter: - - ```text - https://github.com/orenlab/codeclone-cursor - ``` - - Then install **CodeClone** from the imported marketplace. - - See [Cursor plugin guide](guide/integrations/cursor/install-and-skills.md). - -=== "Manual registration" - - ```bash - # Codex - codex mcp add codeclone -- codeclone-mcp --transport stdio - - # Any MCP client - codeclone-mcp --transport stdio - ``` - -### Change controller (AI agents) - -When an AI agent edits code, the MCP change controller governs the structural -boundary: +**Q: Why did analysis report a baseline error?** +A baseline mismatch means your `codeclone.baseline.json` was created with a different version or is corrupted. Regenerate it: +```bash +codeclone --update-baseline +``` -1. **Declare intent** — scope, files, and purpose -2. **Map blast radius** — reverse imports, clone cohorts, do-not-touch -3. **Check patch contract** — pre-edit budget, post-edit verification -4. **Generate receipt** — auditable artifact -5. **Validate claims** — cross-check review text against report +**Q: How do I ignore specific findings?** +Use baseline-aware gating. After reviewing a finding, update your baseline: +```bash +codeclone --update-baseline +``` -See [Structural Change Controller](book/12-structural-change-controller/index.md). +Findings present in the baseline are not flagged as new. -## Configuration +**Q: Can I lower the minimum clone size?** +Yes, adjust the thresholds: +```bash +codeclone --min-loc 5 --min-stmt 3 +``` -CodeClone loads project configuration from `pyproject.toml`: +Lower values catch more clones but may report trivial duplicates. -```toml -[tool.codeclone] -baseline = "codeclone.baseline.json" -min_loc = 10 -min_stmt = 6 -block_min_loc = 20 -block_min_stmt = 8 +**Q: How do I see which files changed?** +Use `--changed-only` with a Git ref: +```bash +codeclone --changed-only --diff-against main ``` -Precedence: CLI flags > `pyproject.toml` > built-in defaults. +**Q: What exit codes mean?** +- `0` — success +- `2` — contract error (invalid baseline, incompatible version) +- `3` — gating failure (new clones, threshold violations) +- `5` — internal error -See [Config and defaults](book/10-config-and-defaults.md). - -## Next Steps +**Q: Can I integrate with CI?** +Yes. CodeClone supports multiple CI systems and output formats: +```bash +codeclone --ci --sarif report.sarif +``` -- [Your first governed edit](start/first-governed-edit.md) — the full declare → edit → verify cycle -- [Architecture narrative](guide/explanation/how-it-works.md) — how the pipeline works -- [Baseline contract](book/07-baseline.md) — trust model and schema -- [MCP interface contract](book/25-mcp-interface/index.md) — tool surface and guarantees -- [Engineering Memory recipes](guide/mcp/workflows/memory-recipes.md) — scoped context and governed drafts -- [Trajectories and Experiences](guide/memory/trajectories-and-experiences.md) — workflow evidence and recurring - patterns -- [Platform Observability](guide/observability/diagnostics.md) — diagnose CodeClone's own runtime -- [Report contract](book/05-report.md) — canonical JSON schema +See the CI documentation for integration guides. diff --git a/docs/guide/README.md b/docs/guide/README.md deleted file mode 100644 index f3f16d67..00000000 --- a/docs/guide/README.md +++ /dev/null @@ -1,45 +0,0 @@ - - -# Guide - -Recipes and workflows for humans and AI agents. For normative guarantees (schemas, -enums, payload semantics), use the [Contracts book](../book/README.md). - -!!! abstract "Who is this for?" - - **Developers** — install, CI, first analysis run - - **Agent authors** — MCP workflows, change control, memory recipes - - **IDE and agent users** — VS Code, Cursor, Claude Code, Codex, Claude Desktop setup - -## Start here - -| I want to… | Page | -|-----------------------------------------|------------------------------------------------------------------------| -| Install and run locally | [Getting started](../getting-started.md) | -| Understand the pipeline | [How CodeClone works](explanation/how-it-works.md) | -| Connect an AI agent via MCP | [MCP overview](mcp/README.md) | -| Govern agent edits | [Change control overview](change-control/overview.md) | -| Scope context before edits | [Engineering Memory overview](memory/overview.md) | -| Inspect trajectory history and patterns | [Trajectories and Experiences](memory/trajectories-and-experiences.md) | -| Diagnose CodeClone's own runtime *(maintainer)* | [Platform Observability](observability/diagnostics.md) | -| Cluster historical agent intents *(maintainer)* | [Corpus Analytics](analytics/overview.md) | - -## MCP workflows - -| Task | Recipe | -|----------------------------|---------------------------------------------------------------| -| First analysis pass | [Analyze & triage](mcp/workflows/analyze-and-triage.md) | -| Hotspots and checks | [Drill down & checks](mcp/workflows/drill-down-and-checks.md) | -| Declare → edit → finish | [Change control](mcp/workflows/change-control.md) | -| Memory before/after edits | [Memory recipes](mcp/workflows/memory-recipes.md) | -| Cobertura join & session markers | [Coverage join & session markers](mcp/workflows/session-and-coverage.md) | - -## Integrations - -| Client | Setup guide | Contract | -|----------------|---------------------------------------------------------------|-----------------------------------------------------------| -| VS Code | [Setup](integrations/vscode/setup.md) | [Contract](../book/integrations/vs-code-extension.md) | -| Cursor | [Install & skills](integrations/cursor/install-and-skills.md) | [Contract](../book/integrations/cursor-plugin.md) | -| Claude Code | [Install](integrations/claude-code/setup.md) | [Contract](../book/integrations/claude-code-plugin.md) | -| Codex | [Install](integrations/codex/setup.md) | [Contract](../book/integrations/codex-plugin.md) | -| Claude Desktop | [Setup](integrations/claude-desktop/setup.md) | [Contract](../book/integrations/claude-desktop-bundle.md) | -| SARIF export | [Export](integrations/sarif/export.md) | [Contract](../book/integrations/sarif.md) | diff --git a/docs/guide/analytics/overview.md b/docs/guide/analytics/overview.md deleted file mode 100644 index 55f8c10b..00000000 --- a/docs/guide/analytics/overview.md +++ /dev/null @@ -1,191 +0,0 @@ -# Corpus Analytics - -Use Corpus Analytics when you want **offline clustering of historical change-control -intents** — for example to compare agent workflow cohorts, inspect outliers, or -export HTML/JSON summaries for maintainer review. - -## Prerequisites - -1. A repository with audit enabled and historical `intent.declared` events. -2. Engineering Memory trajectory projection (optional but improves selection). -3. Install optional dependencies: - -```bash -uv sync --extra analytics -``` - -## Quick start - -Build snapshot, embeddings, and a recommended clustering run in one step: - -```bash -codeclone analytics build --root . --sweep --use-recommended -``` - -`--use-recommended` requires `--sweep`. It renders the heuristic winner for -inspection; it does **not** set `selected_by_maintainer`. - -Use a versioned profile lens when the review question is more specific: - -```bash -codeclone analytics build \ - --root . \ - --profile intent-small-balanced-v1 \ - --use-recommended \ - --html-out /tmp/profile-report.html \ - --json-out /tmp/profile-report.json -``` - -`--profile` implies a finite sweep. `--profile auto` requires -`default_profile_id` in `pyproject.toml`; omitting `--profile` preserves the -ordinary single-run or sweep behavior. - -Write a detailed single-run report to explicit paths: - -```bash -codeclone analytics build \ - --root . \ - --representation description \ - --html-out /tmp/corpus-clusters.html \ - --json-out /tmp/corpus-clusters.json -``` - -Write a sweep comparison without choosing a primary detail view: - -```bash -codeclone analytics build \ - --root . \ - --sweep \ - --html-out /tmp/corpus-sweep.html \ - --json-out /tmp/corpus-sweep.json -``` - -## Reading the reports - -Corpus Analytics separates formal technical validity from human -interpretation: - -```mermaid -flowchart LR - R["Persisted clustering run"] --> V{"V1-V10 pass?"} - V -->|"yes"| F["Full interpretation
metrics, previews, provenance"] - V -->|"no"| L["Limited diagnostic
codes, status, safe counts"] - F --> P{"Profile lens?"} - P -->|"yes"| S["Suitability + profile ranking"] - P -->|"no"| O["Global heuristic comparison"] - S --> O2["JSON 1.3 / HTML"] - O --> O2 - L --> O -``` - -A valid run can still be only a candidate. The banner distinguishes -maintainer-selected, profile-recommended, valid-but-profile-rejected, -heuristically recommended, candidate-only, and technically invalid runs; none -of those labels claims a semantic taxonomy. - -Full reports show dominant-cluster ratios against both the whole corpus and -assigned non-noise items, bounded representative/boundary previews, numeric -summaries, categorical correlations, provenance completeness for small -clusters, and observable noise flags. Sweep comparison includes failed and -invalid runs as limited rows with `unavailable` metrics rather than silently -dropping them. - -Normalized text previews are capped at 240 Unicode code points. JSON keeps raw -strings; HTML escapes them. The export `content_disclosure` block reports -whether previews were actually emitted and in which scopes. See -[Report Interpretability](../../book/27-corpus-analytics.md#report-interpretability-slice-11) -for the invariants and safe-output rules, and -[JSON export schema](../../book/appendix/b-schema-layouts.md#corpus-analytics-json-export-13) -for the wire shape. - -## Step-by-step - -```bash -# 1. Immutable snapshot from audit + trajectory (+ optional registry overlay) -codeclone analytics snapshot --root . - -# 2. Analytics embeddings (separate LanceDB sidecar) -codeclone analytics embed --root . --snapshot-id SNAPSHOT_ID - -# 3. Cluster (add --sweep or --profile for a finite parameter search) -codeclone analytics cluster \ - --root . \ - --snapshot-id SNAPSHOT_ID \ - --embedding-generation-id GENERATION_ID - -# Optional profile registry and profile-scoped sweep -codeclone analytics profiles list --root . -codeclone analytics cluster \ - --root . \ - --snapshot-id SNAPSHOT_ID \ - --embedding-generation-id GENERATION_ID \ - --profile intent-small-discovery-v1 - -# 4. Inspect runs -codeclone analytics clusters --root . --snapshot-id SNAPSHOT_ID -codeclone analytics cluster-show \ - --root . --snapshot-id SNAPSHOT_ID --run-id RUN_ID - -# 5. Record an explicit maintainer choice -codeclone analytics cluster --root . --select-run RUN_ID \ - --selected-by "$USER" \ - --selection-rationale "Chosen for maintainer review" -``` - -For a profile-scoped decision, add -`--selection-profile PROFILE_ID_OR_PROFILE_BATCH_ID`. Use `none` for global -scope. - -## Configuration - -Defaults live in `[tool.codeclone.analytics]` inside `pyproject.toml`. See -[Corpus Analytics contract](../../book/27-corpus-analytics.md) for the full table. -The historical audit source follows top-level `[tool.codeclone].audit_path`. - -```toml -[tool.codeclone.analytics] -default_profile_id = "intent-small-balanced-v1" -profile_paths = ["analytics/profiles/team-review.json"] -sweep_pca_dimensions = [32, 64, 128] -sweep_min_cluster_sizes = [5, 8, 12, 15] -sweep_min_samples = [1, 3, 5] -sweep_selection_methods = ["eom", "leaf"] -``` - -Repository-local manifests use the same schema as bundled profiles. Paths must -resolve to files inside the repository. The default profile is consulted only -for explicit `--profile auto`. - -## Reproducibility - -Exports persist snapshot and embedding manifests, vector digests, requested and -effective parameters, fixed PCA/HDBSCAN settings, package versions, and the -random seed. Unless the model revision and artifact fingerprint are known, -CodeClone explicitly reports that full vector reproducibility is not guaranteed -from the model id alone. - -Existing embedding generations created under an incompatible embedding contract -are rejected. Run `embed` again for the same snapshot to create a compatible -generation. - -## Failure behavior - -- Expected input, capability, schema, and artifact-integrity errors exit with - code `2` and no traceback. -- A clustering run is persisted as `running`, then becomes `completed` or - `failed`; failed runs contain no committed assignments or summaries. -- Resolved invalid or failed runs remain exportable in limited diagnostic mode; - they never receive partition metrics, previews, score, or rank. -- A missing embedding-generation record is rendered explicitly as unavailable - metadata rather than fabricated from the run. -- JSON and HTML outputs are written atomically. -- Snapshot, embed, cluster, and report spans are recorded only when - `CODECLONE_OBSERVABILITY_ENABLED=1`. - -## What this is not - -- Not a second analyzer — it does not replace `codeclone` structural reports. -- Not Engineering Memory semantic search — vectors are stored separately. -- Not MCP-visible in Slice 1 — CLI only. - -Contract reference: [27-corpus-analytics.md](../../book/27-corpus-analytics.md). diff --git a/docs/guide/change-control/agent-cycle.md b/docs/guide/change-control/agent-cycle.md deleted file mode 100644 index 2005e6e8..00000000 --- a/docs/guide/change-control/agent-cycle.md +++ /dev/null @@ -1,28 +0,0 @@ - - -# Agent edit cycle - -Same sequence as [MCP change control workflow](../mcp/workflows/change-control.md). - -**Before first edit:** `start_controlled_change` must return `status: active` and -`edit_allowed: true`. Queued intents require `manage_change_intent(action=promote)`. - -**Evidence:** finish requires exactly one of `changed_files` or `diff_ref` listing -every in-scope dirty path. - -**After-run:** required when verification profile is `python_structural` or -`governance_config`. Pass a **new** `after_run_id` — identical before/after runs -return `after_run_not_new`. - -**Claims:** only `claims_text` goes to Claim Guard; `review_text` is a human note. - -**Memory:** call `get_relevant_memory` after start; optional -`finish(..., propose_memory=true)` for draft candidates (human approve in VS Code -Memory view). - -**Implementation context:** after memory, call `get_implementation_context` with -scoped paths when editing Python — bounded structural, call-graph, and contract -evidence from one stored run. - -Contract tables: [Verification profiles](../../book/12-structural-change-controller/verification-profiles.md), -[finish_controlled_change](../../book/12-structural-change-controller/finish-controlled-change.md). diff --git a/docs/guide/change-control/atomic-debug.md b/docs/guide/change-control/atomic-debug.md deleted file mode 100644 index 68d55515..00000000 --- a/docs/guide/change-control/atomic-debug.md +++ /dev/null @@ -1,24 +0,0 @@ - - -# Atomic debug path - -For legacy MCP servers or step-by-step debugging: - -``` -manage_change_intent(action="list_workspace") - -> analyze_repository - -> manage_change_intent(action="declare", scope={...}) - -> get_blast_radius(files=[...]) - -> check_patch_contract(mode="budget") - -> [edit within scope] - -> analyze_repository - -> manage_change_intent(action="check", intent_id=..., changed_files=[...]) - -> check_patch_contract(mode="verify", after_run_id=..., intent_id=...) - -> validate_review_claims(text="...", patch_health_delta=...) - -> create_review_receipt - -> manage_change_intent(action="clear") -``` - -Prefer [start/finish workflow](../mcp/workflows/change-control.md) when available. - -Tool params: [Atomic change control tools](../../book/25-mcp-interface/tools/atomic-change-control.md). diff --git a/docs/guide/change-control/overview.md b/docs/guide/change-control/overview.md deleted file mode 100644 index 933741fb..00000000 --- a/docs/guide/change-control/overview.md +++ /dev/null @@ -1,20 +0,0 @@ - - -# Change control overview - -CodeClone v2.1 requires agents to **declare scope before editing**, verify the -patch against structural boundaries, and finish with evidence-linked hygiene. - -| Step | Action | -|------|---------------------------------------------------------------------------------| -| 1 | `analyze_repository` (or reuse valid run) | -| 2 | `start_controlled_change` → `edit_allowed=true` | -| 3 | `get_relevant_memory` (requires absolute `root`) | -| 4 | `get_implementation_context` for scoped Python paths (optional but recommended) | -| 5 | Edit inside declared scope only | -| 6 | After-run when profile requires it | -| 7 | `finish_controlled_change` with `changed_files` or `diff_ref` | - -MCP recipe: [Change control workflow](../mcp/workflows/change-control.md). - -Contract: [Structural Change Controller](../../book/12-structural-change-controller/index.md). diff --git a/docs/guide/change-control/queue-and-recovery.md b/docs/guide/change-control/queue-and-recovery.md deleted file mode 100644 index 7772d26f..00000000 --- a/docs/guide/change-control/queue-and-recovery.md +++ /dev/null @@ -1,31 +0,0 @@ - - -# Queue & recovery - -## Multi-agent queue - -``` -start_controlled_change(scope={...}, on_conflict="queue") - -> wait for foreign intent to clear - -> manage_change_intent(action="promote", intent_id=...) - -> edit within scope - -> finish_controlled_change(...) -``` - -## Workspace hygiene (guide summary) - -Three contours: **status** (registry lifecycle), **ownership** (PID/TTL), -**hygiene** (git ∩ scope), **permission** (`edit_allowed`). - -- **`dirty_scope_policy`:** `continue_own_wip` resumes known WIP in scope when no - foreign overlap; finish still needs evidence. -- **`gc_workspace`:** explicit GC vs lazy close on read — different predicates. -- **Blocking finish:** `missing_evidence`, `foreign_dirty_overlap`, and (when - [strict finish mode](../../book/10-config-and-defaults.md#mcp-session-and-change-control-hygiene) - is enabled) `own_unscoped_dirty`. - -Normative tables: [Finish hygiene](../../book/12-structural-change-controller/finish-hygiene.md), -[payload semantics](../../book/12-structural-change-controller/payload-semantics.md). - -Recovery: `manage_change_intent(action=recover|reset_workspace)` when MCP hints -`recovery_available`. diff --git a/docs/guide/explanation/how-it-works.md b/docs/guide/explanation/how-it-works.md deleted file mode 100644 index 342d3fa9..00000000 --- a/docs/guide/explanation/how-it-works.md +++ /dev/null @@ -1,192 +0,0 @@ - - -# How CodeClone Works - -> This page is a narrative architecture overview. -> Contract-level guarantees are defined in the -> [Contracts Book](../../book/README.md). - ---- - -## Pipeline Overview - -CodeClone processes Python projects in the following stages: - -1. **Source scanning** -2. **AST parsing** -3. **AST normalization** -4. **CFG construction** -5. **Fingerprinting** -6. **Segment window extraction** -7. **Clone grouping** -8. **Project metrics** (complexity, coupling, health, dead code, …) -9. **Canonical report assembly** -10. **Baseline diff and metric gating** (CI exit decision) - -Full contract: [Core pipeline](../../book/03-core-pipeline.md). - ---- - -## 1. Source Scanning - -- Recursively scans `.py` files. -- Uses deterministic sorted traversal. -- Skips paths that resolve outside the root (symlink traversal guard). -- Applies cache-based skipping using file stat signatures. - -Cache contract: [Cache](../../book/08-cache.md). - ---- - -## 2. AST Parsing - -- Uses Python's built-in `ast` module. -- Supports Python 3.10+ syntax. - ---- - -## 3. AST Normalization - -Normalization removes non-structural noise: - -- variable names → `_VAR_` -- constants → `_CONST_` -- attributes → `_ATTR_` -- symbolic call targets are preserved (to avoid API conflation) -- syntactic sugar (e.g. `x += 1` → `x = x + 1`) -- commutative operand canonicalization (`+`, `*`, `|`, `&`, `^`) on proven constant domains -- local logical equivalence (`not (x in y)` → `x not in y`, `not (x is y)` → `x is not y`) -- docstrings removed -- type annotations removed - -This ensures structural stability across refactors. - ---- - -## 4. CFG Construction - -- Built per-function using `CFGBuilder`. -- Produces deterministic basic blocks. -- Captures structural control flow (`if`, `for`, `while`, `try`, `with`, `match`). -- Models short-circuit `and`/`or` as micro-CFG branches. -- Links `try/except` only from statements that may raise. -- Preserves `match case` and `except` handler order structurally. -- Models `break` / `continue` as terminating loop transitions. -- Preserves `for/while ... else` semantics. - -Full semantics: [CFG Semantics](../../book/04-cfg-semantics.md). - ---- - -## 5. Fingerprinting - -Each function CFG is converted into a canonical string form and hashed. -This fingerprint is used to group structurally identical functions. - ---- - -## 6. Segment Windows - -Large functions are also scanned with **segment windows** (sliding windows over -normalized statements). These are used to detect **internal clones** inside the -same function. - -Segment windows are **never** used as a final equivalence signal; they are -candidate generators with strict hash confirmation. - ---- - -## 7. Clone Detection - -Clone groups are detected at three granularities: - -### Function clone groups - -- Grouped by `fingerprint|loc_bucket`. -- Report typing is deterministic (`Type-1`..`Type-4`) in report layer. - -### Block clone groups - -- Repeated structural statement windows across functions. -- Report typing is `Type-4` with explainability facts from core. - -Noise filters applied: - -- minimum LOC / statement thresholds -- no overlapping blocks -- no same-function block clones -- `__init__` excluded from block analysis - -### Segment clones (internal/report-only) - -- Detected only **inside the same function**. -- Used for internal copy-paste discovery and report explainability. -- Not included in baseline or CI failure logic. - -### Structural findings (report-only) - -- `duplicated_branches`: repeated branch-body signatures. -- `clone_guard_exit_divergence`: guard/terminal divergence inside one function-clone cohort. -- `clone_cohort_drift`: drift from majority terminal/guard/try/side-effect profile. - -These findings are rendered in reports only and do not change baseline diff or CI -gating decisions. - ---- - -## 8. Reporting - -Detected findings can be rendered as interactive HTML, canonical JSON (schema -`2.11`), deterministic text, Markdown, or SARIF projections. Reporting is -separate from CI gating: report-only structural findings and segment clones do -not change baseline diff or gate evaluation. - -Report contract: [Report](../../book/05-report.md). -HTML rendering: [HTML Render](../../book/06-html-render.md). - ---- - -## 9. CI gating - -After the canonical report is built, clone baseline diff and configured metric -gates decide exit code `3` when policy fails. Gating mode is active when any -`--fail-*`, `--ci`, or minimum-coverage threshold is set (see -[CLI](../../book/11-cli.md)). Unreadable source in gating mode is a contract -error (exit `2`, marker `CONTRACT ERROR:`) and takes priority over clone/metric -gate failure. - -Exit codes: [09-exit-codes](../../book/09-exit-codes.md). - ---- - -## Surfaces - -Every output surface — CLI, HTML, MCP, IDE — is a projection of the same -canonical report. No surface adds a second analysis engine. - -| Surface | Role | Contract | -|----------------|------------------------------------|-----------------------------------------------------------| -| CLI | Scripting and CI | [CLI](../../book/11-cli.md) | -| MCP | Read-only agent/client integration | [MCP interface](../../book/25-mcp-interface/index.md) | -| VS Code | Guided IDE review | [VS Code](../integrations/vscode/setup.md) | -| Claude Desktop | Local `.mcpb` bundle | [Claude Desktop](../integrations/claude-desktop/setup.md) | -| Codex | Marketplace plugin with skills | [Codex](../integrations/codex/setup.md) | -| Cursor | Plugin with skills, rules, hooks | [Cursor](../integrations/cursor/install-and-skills.md) | -| SARIF | IDE code scanning | [SARIF](../integrations/sarif/export.md) | - ---- - -## Design Principles - -- Structural > textual -- Deterministic > precise -- Low-noise > completeness -- CI-first design - -Module map: [Architecture Map](../../book/02-architecture-map.md). diff --git a/docs/guide/integrations/claude-code/setup.md b/docs/guide/integrations/claude-code/setup.md deleted file mode 100644 index 7db28391..00000000 --- a/docs/guide/integrations/claude-code/setup.md +++ /dev/null @@ -1,105 +0,0 @@ - - -# Claude Code setup - -CodeClone ships a native Claude Code plugin through the public -[orenlab/codeclone-claude-code](https://github.com/orenlab/codeclone-claude-code) -marketplace repository. - -This is distinct from the -[Claude Desktop `.mcpb` bundle](../claude-desktop/setup.md). Claude Code loads -skills and the MCP definition; Claude Desktop installs an extension bundle. - -## Prerequisites - -- Claude Code with plugin support -- Python 3.10+ -- a local `codeclone-mcp` installation - -## Install from the marketplace - -Add the marketplace and install the plugin: - -```bash -claude plugin marketplace add orenlab/codeclone-claude-code -claude plugin install codeclone@orenlab-codeclone -``` - -The equivalent interactive commands are: - -```text -/plugin marketplace add orenlab/codeclone-claude-code -/plugin install codeclone@orenlab-codeclone -``` - -Verify: - -```bash -claude plugin marketplace list -claude plugin list -``` - -## Install the MCP launcher - -Global tool installation: - -```bash -uv tool install "codeclone[mcp]" -codeclone-mcp --help -``` - -Workspace-local installation: - -```bash -uv venv -uv pip install --python .venv/bin/python "codeclone[mcp]" -.venv/bin/codeclone-mcp --help -``` - -The plugin launcher resolves a workspace `.venv`, then the current Poetry -environment, then `codeclone-mcp` from `PATH`. - -## Runtime path - -```mermaid -flowchart LR - A["Claude Code"] --> B["CodeClone plugin"] - B --> C["Plugin skills"] - B --> D["Local stdio launcher"] - D --> E["codeclone-mcp"] - E --> F["Canonical report and controller"] -``` - -The plugin does not bundle Python or a second analyzer. It supplies guidance and -a local MCP definition over the same canonical CodeClone server. - -## Skills - -Claude Code namespaces installed plugin skills: - -| Task | Invocation | -|--------------------|-------------------------------------------| -| Repository review | `/codeclone:codeclone-review` | -| Hotspot snapshot | `/codeclone:codeclone-hotspots` | -| Controlled edit | `/codeclone:codeclone-change-control` | -| Engineering Memory | `/codeclone:codeclone-engineering-memory` | - -## Update or remove - -```bash -claude plugin marketplace update orenlab-codeclone -claude plugin update codeclone@orenlab-codeclone -claude plugin uninstall codeclone@orenlab-codeclone -``` - -## Local development - -Marketplace installation is the public path. For plugin development only: - -```bash -claude --plugin-dir plugins/claude-code-codeclone -claude plugin validate plugins/claude-code-codeclone -``` - -Contract reference: -[Claude Code plugin](../../../book/integrations/claude-code-plugin.md). diff --git a/docs/guide/integrations/claude-desktop/setup.md b/docs/guide/integrations/claude-desktop/setup.md deleted file mode 100644 index 5372f9e3..00000000 --- a/docs/guide/integrations/claude-desktop/setup.md +++ /dev/null @@ -1,100 +0,0 @@ - - -# Claude Desktop setup - -Local `.mcpb` bundle that launches `codeclone-mcp` over stdio. Same canonical MCP -surface as CLI, VS Code, Codex, and Cursor — no second analyzer or truth path. - -For the terminal agent, use the separate -[Claude Code marketplace plugin](../claude-code/setup.md). The `.mcpb` described -here is only for Claude Desktop. - -## Prerequisites - -- Claude Desktop with extension support -- Node.js (to build the bundle from source) -- Python 3.10+ with `codeclone[mcp]` installed - -## Install the MCP launcher - -The bundle prefers the current workspace launcher first: - -1. `./.venv/bin/codeclone-mcp` -2. the current Poetry environment launcher -3. user-local install paths and `PATH` - -Workspace-local setup: - -```bash -uv venv -uv pip install --python .venv/bin/python "codeclone[mcp]" -.venv/bin/codeclone-mcp --help -``` - -Global fallback: - -```bash -uv tool install "codeclone[mcp]" -codeclone-mcp --help -``` - -## Build and install the `.mcpb` bundle - -From the repository: - -```bash -cd extensions/claude-desktop-codeclone -node scripts/build-mcpb.mjs -``` - -In Claude Desktop: **Settings → Extensions → Install Extension** → select the -`.mcpb` from `dist/`. - -To bypass auto-discovery, set **CodeClone launcher command** in extension settings -to an absolute path to `codeclone-mcp`. - -## Configuration - -| Setting | Purpose | -|--------------------------------|-------------------------------------------------------------------------| -| **Workspace root path** | Optional absolute project root; launcher prefers that workspace `.venv` | -| **CodeClone launcher command** | Absolute path or bare command for `codeclone-mcp` | -| **Advanced launcher args** | JSON array of extra args (transport is always stdio) | - -## Read-only vs coordination writes - -The MCP server never mutates repository source, baselines, analysis cache, or -canonical reports. It may write ephemeral coordination state under -`.codeclone/intents/` (file backend) or `.codeclone/db/intents.sqlite3` -(SQLite backend), optional audit records when enabled, and Engineering -Memory **draft** rows through agent tools. Human approve/reject stays in VS Code -Memory or `codeclone memory approve --i-know-what-im-doing` (optional `--by NAME`). - -## First workflow - -```text -1. Use CodeClone to analyze this repository. -2. Declare a change intent before editing (start_controlled_change). -3. Show blast radius for the files you plan to change. -4. Edit within declared scope. -5. Finish the change intent with changed_files evidence. -``` - -Recipe pages: [MCP workflows](../../mcp/workflows/change-control.md), -[Change controller](../../../book/12-structural-change-controller/index.md). - -## Privacy - -Local wrapper only — no telemetry, no cloud sync, no remote listener. -See [Privacy Policy](https://orenlab.github.io/codeclone/privacy-policy/). - -## Development smoke - -```bash -cd extensions/claude-desktop-codeclone -npm run check -npm test -npm run pack -``` - -Contract reference: [Claude Desktop bundle](../../../book/integrations/claude-desktop-bundle.md). diff --git a/docs/guide/integrations/codex/setup.md b/docs/guide/integrations/codex/setup.md deleted file mode 100644 index 16b96eb4..00000000 --- a/docs/guide/integrations/codex/setup.md +++ /dev/null @@ -1,84 +0,0 @@ -# Codex setup - -## Install - -Install the plugin from the Codex marketplace: - -```bash -codex plugin marketplace add orenlab/codeclone-codex -codex plugin add codeclone@orenlab-codeclone -``` - -The first command registers the public marketplace repository. The second -installs the `codeclone` plugin from the marketplace named -`orenlab-codeclone`. - -Verify the configured marketplace and installed plugin: - -```bash -codex plugin marketplace list -codex plugin list -``` - -The plugin manifest version tracks the CodeClone package release line (currently -`2.1.0a1` in this monorepo). It describes the bundled guidance surface, not the -live MCP tool count — tools come from the resolved `codeclone-mcp` server. - -The plugin expects a local `codeclone-mcp` command. Install CodeClone with the -MCP extra in the workspace or globally: - -```bash -uv venv -uv pip install --python .venv/bin/python "codeclone[mcp]" -.venv/bin/codeclone-mcp --help -``` - -Global fallback: - -```bash -uv tool install "codeclone[mcp]" -codeclone-mcp --help -``` - -Manual MCP registration without the plugin: - -```bash -codex mcp add codeclone -- codeclone-mcp --transport stdio -``` - -## Skills - -### codeclone-review - -Full structural review: clone triage, changed-scope review, health-oriented -refactor planning. Starts conservative with default thresholds, supports -deeper follow-up with lowered thresholds and run comparison. - -### codeclone-hotspots - -Quick quality snapshot: health check, top risks, single-metric queries. -The cheapest useful path — `analyze_repository` then `get_production_triage`. - -### codeclone-change-control - -Intent-first change workflow for repository edits. Declares scope before -editing, maps blast radius, verifies the patch against the contract, generates -a review receipt, and validates cited review claims. This is the governance -skill — use it whenever the task requires changing files. - -### codeclone-engineering-memory - -Scope-aware Engineering Memory over MCP: `get_relevant_memory` (absolute -`root` required), `query_engineering_memory`, draft `record_candidate`, and -`finish(..., propose_memory=true)`. Complements change control — does not replace -intent declaration or patch verify. Human approve stays in the CodeClone VS Code -**Memory** view (not MCP). - -Optional **semantic search**: off by default in -`[tool.codeclone.memory.semantic]`; when enabled, install -`codeclone[semantic-local]` for local semantic-quality recall (or -`codeclone[semantic-lancedb]` for the diagnostic sidecar only), rebuild the index, then -`query_engineering_memory(mode=search, semantic=true)`. Default provider -`diagnostic` is deterministic, not semantic-quality embeddings; set -`embedding_provider = "fastembed"` for FastEmbed. See -[Engineering Memory](../../../book/13-engineering-memory/index.md). diff --git a/docs/guide/integrations/cursor/install-and-skills.md b/docs/guide/integrations/cursor/install-and-skills.md deleted file mode 100644 index e2415153..00000000 --- a/docs/guide/integrations/cursor/install-and-skills.md +++ /dev/null @@ -1,200 +0,0 @@ -# Cursor plugin - -## What ships in the plugin - -| Component | Path | Purpose | -|------------------------------------|---------------------------------|----------------------------------------------------------------------------------------------| -| `.cursor-plugin/plugin.json` | Manifest | `skills/`, `rules/`, `agents/`, `hooks/hooks.json`, `mcp.json` | -| `mcp.json` | MCP | `python3` + `./scripts/launch_mcp.py` — resolves `codeclone-mcp` (`.venv` → Poetry → `PATH`) | -| Skills (9) | `skills/*/` | See table below | -| Agent | `agents/structural-reviewer.md` | Invoke id: **`codeclone-structural-reviewer`** | -| Rules (3) | `rules/*.mdc` | See **Rules** | -| Hooks | `hooks/hooks.json` | Dispatches via `hooks/run_hook.py` (plugin manifest; optional project install) | -| `scripts/install-project-hooks.py` | Installer | Writes `.cursor/hooks.json` + `.cursor/codeclone-hooks.json` | -| `assets/` | Branding | Logo and icon | - -### Skills (directory vs chat command) - -Chat commands use the `name:` field in each `SKILL.md` (not always the folder -name on disk): - -| Folder on disk | Chat command (`name`) | Primary MCP flow | -|-------------------------------------|-------------------------------------|----------------------------------------------------------------------------| -| `production-triage/` | `/codeclone-production-triage` | `analyze_repository` → `get_production_triage` | -| `codeclone-hotspots/` | `/codeclone-hotspots` | `analyze_repository` → hotspots / `check_*` | -| `blast-radius/` | `/codeclone-blast-radius` | `analyze_repository` → `get_blast_radius` (read-only) | -| `architecture-triage/` | `/codeclone-architecture-triage` | Reuse run → `module_map` + impact context → ranked problems (read-only) | -| `codeclone-review/` | `/codeclone-review` | Full review loop (conservative first) | -| `codeclone-change-control/` | `/codeclone-change-control` | `start_controlled_change` → edit → `finish_controlled_change` | -| `codeclone-engineering-memory/` | `/codeclone-engineering-memory` | `get_relevant_memory`, `query_engineering_memory`, drafts | -| `codeclone-implementation-context/` | `/codeclone-implementation-context` | `get_implementation_context` after `start` | -| `codeclone-platform-observability/` | `/codeclone-platform-observability` | Maintainer-only: `query_platform_observability` (observer enable required) | - -Codex and Claude Code plugins ship the same nine skills from `plugins/codeclone/skills/`. - -## Install - -### Install from the Cursor marketplace - -The public storefront is -[orenlab/codeclone-cursor](https://github.com/orenlab/codeclone-cursor). - -If CodeClone is already listed in your marketplace panel, select **CodeClone**, -choose user or project scope, and install it. - -To expose the repository as a team marketplace: - -1. Open **Cursor Dashboard → Settings → Plugins**. -2. Under **Team Marketplaces**, select **Add Marketplace**. -3. Select **Import from Repo** and enter - `https://github.com/orenlab/codeclone-cursor`. -4. Add CodeClone, configure team access, and save. -5. Install CodeClone from Cursor's marketplace panel. - -Install `codeclone[mcp]` separately so the bundled launcher can resolve -`codeclone-mcp`: - -```bash -uv tool install "codeclone[mcp]" -codeclone-mcp --help -``` - -### Local development only - -Use a local symlink only while developing the plugin: - -```bash -ln -sfn /path/to/codeclone/plugins/cursor-codeclone ~/.cursor/plugins/local/codeclone -``` - -Reload Cursor after changing the local source. Do not present this path to -normal users as the installation flow. - -### Project hooks (Hooks UI) - -```bash -uv run python plugins/cursor-codeclone/scripts/install-project-hooks.py -# full-repo gate: -uv run python plugins/cursor-codeclone/scripts/install-project-hooks.py --enforce-scope repo -``` - -Writes: - -- `.cursor/hooks.json` — shown in **Settings → Hooks** -- `.cursor/codeclone-hooks.json` — `enforce_scope` (`python` default, or `repo`) - -Do **not** commit generated files (machine-local Python paths). This monorepo -ignores `/.cursor/` in `.gitignore`. - -!!! note "Marketplace catalogs" - `.agents/plugins/marketplace.json` belongs to Codex. Cursor installs this - plugin from the `orenlab/codeclone-cursor` storefront through Cursor's own - marketplace UI. - -## Skills - -### codeclone-production-triage - -Two MCP calls: `analyze_repository` then `get_production_triage`. Baseline-relative -triage — not patch-local verify. Suggests `codeclone-review` for a deeper session. - -### codeclone-hotspots - -Cheapest ad-hoc snapshot after `analyze_repository`; prefer `list_hotspots` / -`check_*` before broad `list_findings`. Optional `help(topic="coverage")` when -Coverage Join semantics matter. - -### codeclone-blast-radius - -Read-only: `get_blast_radius` after analysis. Does **not** call -`start_controlled_change`. Use `codeclone-change-control` for edits. - -### codeclone-architecture-triage - -Read-only ranked architectural problems from one stored run: module_map, metrics, -policy + structural shortlists, per-subject impact context, defect validation. -Response-local priorities only — not CodeClone findings. - -### codeclone-review - -Conservative-first full review; optional deeper pass with explicit user request. -Does not declare intent by itself. - -### codeclone-change-control - -Normal edit cycle uses workflow tools (not legacy-only atomic path): - -`analyze_repository` → `start_controlled_change` → `get_relevant_memory` → edit -in scope → `analyze_repository` (when after-run required) → optional -`record_candidate` → `finish_controlled_change`. - -Queue/recovery: `manage_change_intent` (`promote`, `recover`, …). Atomic -`check_patch_contract` / `create_review_receipt` are advanced/debug only when -workflow tools are unavailable. - -### codeclone-implementation-context - -Bounded structural, call-graph, contract, and change-control evidence from one -stored MCP run. Call after `start_controlled_change` with `intent_id` before -editing scoped Python work. Read-only — does not declare intent. - -### codeclone-engineering-memory - -Scope memory before edits; optional `semantic=true` on `mode=search` when -`[tool.codeclone.memory.semantic]` is enabled, the semantic sidecar is installed, -and semantic index rebuild succeeded (`manage_engineering_memory` -`action=rebuild_semantic_index` or CLI `memory semantic rebuild`). Use -`codeclone[semantic-local]` -plus `embedding_provider = "fastembed"` for local semantic-quality recall; -`codeclone[semantic-lancedb]` alone supports only the deterministic diagnostic -provider. Human -approve/reject: VS Code **Memory** view (preferred) or CLI -`codeclone memory approve|reject|archive --i-know-what-im-doing` (MCP agents -cannot approve). - -Full contract: [Engineering Memory](../../../book/13-engineering-memory/index.md). - -### codeclone-platform-observability - -**Maintainer-only** — not for users reviewing their Python repository. - -Diagnose CodeClone's own runtime (MCP latency, DB cost, memory pipeline) via -`query_platform_observability` after **explicit** observer setup: - -```bash -export CODECLONE_OBSERVABILITY_ENABLED=1 -# restart codeclone-mcp / CLI with this env, reproduce, then query sections -``` - -Without enablement the tool returns `status=disabled` or `no_store`. Never treat -observer metrics as repository quality or edit authorization. - -Playbook: [Maintainer workflow](../../../guide/observability/maintainer-workflow.md). - -## Agent - -### codeclone-structural-reviewer - -Defined in `agents/structural-reviewer.md` (`name: codeclone-structural-reviewer`). -Read-only MCP evidence only; no edits or intent; report-only signals are not CI -failures or vulnerability claims. - -## Distribution - -- **Monorepo source:** `plugins/cursor-codeclone/` -- **Marketplace source:** `https://github.com/orenlab/codeclone-cursor` -- **Install:** Cursor marketplace panel; local symlink only for development -- **Standalone releases:** ship full `plugins/codeclone/scripts/launch_mcp.py` body - -## Runtime model - -Additive: local MCP via `launch_mcp.py`, nine skills, three rules (two -`alwaysApply` + one Python glob), optional hooks. The full default agent MCP -surface is passed through — the launcher does **not** -pass `--ide-governance-channel` (VS Code adds +2 IDE-only tools and Memory -governance). New server tools from upgraded `codeclone-mcp` pass through -unfiltered. - -Monorepo: `plugins/cursor-codeclone/scripts/launch_mcp.py` delegates to -`plugins/codeclone/scripts/launch_mcp.py`. Standalone releases must embed the -full launcher body. diff --git a/docs/guide/integrations/sarif/export.md b/docs/guide/integrations/sarif/export.md deleted file mode 100644 index 29d0b0f8..00000000 --- a/docs/guide/integrations/sarif/export.md +++ /dev/null @@ -1,29 +0,0 @@ -# SARIF export - -## Purpose - -Explain how CodeClone projects canonical findings into SARIF and what IDEs or -code-scanning tools can rely on. - -SARIF is a deterministic projection layer. The canonical source of truth -remains the report document. - -## What SARIF is good for here - -SARIF is useful as: - -- an IDE-facing findings stream -- a code-scanning upload format -- another deterministic machine-readable projection over canonical report data - -It is not the source of truth for: - -- report integrity digest -- gating semantics -- baseline compatibility - -## See also - -- [05. Report](../../../book/05-report.md) -- [06. HTML Render](../../../book/06-html-render.md) -- [Examples / Sample Report](../../../examples/report.md) diff --git a/docs/guide/integrations/vscode/setup.md b/docs/guide/integrations/vscode/setup.md deleted file mode 100644 index c8e9426c..00000000 --- a/docs/guide/integrations/vscode/setup.md +++ /dev/null @@ -1,173 +0,0 @@ -# VS Code setup - -## What it is for - -The extension helps you: - -- analyze the current workspace -- review changed files against a git diff -- start with a conservative first pass and lower thresholds only when you need - a more sensitive follow-up -- focus on new regressions and production hotspots first -- jump directly to source locations -- open canonical finding or remediation detail only when needed -- inspect current-run `Coverage Join` facts without inventing extension-local interpretations -- inspect report-only `Security Surfaces` as security-relevant boundary inventory -- inspect report-only Overloaded Module candidates without treating them like findings - -It does not create a second truth model and it does not mutate the repository. - -## Install requirements - -Install from the VS Code Marketplace: **`orenlab.codeclone`** (publisher -**orenlab**), or sideload a `.vsix` built from `extensions/vscode-codeclone`. - -The extension needs a local `codeclone-mcp` launcher and VS Code `1.120.0` or newer -(`engines.vscode` in `package.json`). - -Minimum supported CodeClone version: **`2.0.0`** (core analysis and change control). - -Engineering Memory features (Memory tree, search, governance, trajectory views) -require **`2.1.0a1` or newer** on the resolved `codeclone-mcp` launcher. - -In `auto` mode, it checks the current workspace virtualenv before falling back -to `PATH`. Runtime and version-mismatch messages identify that resolved launcher source. - -Recommended install: - -```bash -uv tool install "codeclone[mcp]" -``` - -If you want the launcher inside the current environment instead: - -```bash -uv pip install "codeclone[mcp]" -``` - -Verify the launcher: - -```bash -codeclone-mcp --help -``` - -When you run the CLI inside an interactive VS Code terminal, CodeClone may also -show a one-time extension hint after the summary. It is suppressed in quiet, -CI, and non-interactive runs, and is remembered per CodeClone version next to -the resolved project cache path. - -## Main views - -### Overview - -Compact health, current run state, baseline drift, and next-best review action. -When the current run includes external Cobertura join facts, Overview also -shows a factual `Coverage Join` section sourced from canonical MCP metrics. -When MCP exposes `security_surfaces`, Overview also shows a compact report-only -`Security Surfaces` section. - -### Hotspots - -Primary operational view for: - -- new regressions -- production hotspots -- changed-files findings -- report-only Security Surfaces -- report-only Overloaded Module candidates - -### Runs & Session - -Session-local state: - -- local server availability -- current run identity -- reviewed findings -- MCP help topics, including the optional `coverage` topic on newer - CodeClone/MCP servers - -### Memory - -Engineering Memory inbox: draft records, stale list, status, refresh/sync -actions, and human approve/reject through the IDE governance channel -(`prepare_governance` / `commit_governance` with session HMAC attestation). The -extension launches MCP with `--ide-governance-channel` and registers a -`SecretStorage` governance key on connect. - -## Review model - -The extension stays source-first: - -- `Review Priorities` and `Next Hotspot` / `Previous Hotspot` drive the review - loop -- `Reveal Source` is the default action for findings -- editor-local actions appear only when the current file matches the active - review target -- Explorer decorations stay lightweight and focus on new, production, or - changed-scope relevance -- report-only Security Surfaces stay source-first: reveal source, open compact - detail, or copy a review brief without promoting them to findings - -`Open in HTML Report` exists as an explicit bridge to the richer human report, -not as the primary IDE workflow. - -## Blast radius, session, and audit commands - -The extension also exposes structural change-controller helpers over MCP: - -- **Show Blast Radius** — `get_blast_radius` for a repo-relative file path -- **Copy Blast Radius Brief** — same payload formatted for review notes -- **Show Session Stats** / **Show Controller Audit Trail** — IDE-only MCP tools - (`get_workspace_session_stats`, `get_controller_audit_trail`) registered only - when the extension launches `codeclone-mcp` with `--ide-governance-channel`. - Payloads match CLI `--session-stats` and `--audit` via - `codeclone/controller_insights/`. -- **Clear Session** — `clear_session_runs` (in-memory runs, reviewed markers, - and workspace intent registry state for the MCP process) - -These commands require workspace trust and an active MCP connection. - -## Engineering Memory in the IDE - -- **Memory** view — draft inbox, approve/reject through the IDE governance - channel (`prepare_governance` / `commit_governance`), sync from run. -- **Search Engineering Memory** — QuickPick (`mode=search`; FTS + optional - semantic per `codeclone.memory.searchSemantic`, default **on** in the extension). -- **Memory for Active File** — `mode=for_path` for the active editor path. -- **Open Memory Search Panel** / **Refresh Memory Search** — results webview. -- **Configure Memory Search** — workspace wizard for semantic, drafts, stale, and - result limit (see **Engineering Memory search** settings below). -- **Show Trajectory Dashboard** — projection health, quality/outcome aggregates, - anomalies, and recent trajectories. -- **Show Trajectory Detail** — full passport with quality/complexity - calculations, Patch Trail, contract gates, incidents, steps, and evidence. -- **Copy Trajectory Dashboard Brief** — Markdown summary for review notes. - -Server-side semantic still requires `[tool.codeclone.memory.semantic] enabled`, -the semantic sidecar, and a successful rebuild (`manage_engineering_memory` -`action=rebuild_semantic_index` for MCP agents, or `codeclone memory semantic -rebuild` for CLI/CI). Install -`codeclone[semantic-local]` and set `embedding_provider = "fastembed"` for local -semantic-quality recall; `codeclone[semantic-lancedb]` alone can run only the -deterministic diagnostic provider. See -[Engineering Memory](../../../book/13-engineering-memory/index.md). -Trajectory semantics: -[Trajectory quality and passport](../../../book/13-engineering-memory/trajectory-quality-and-passport.md). - -## Open Triage - -**Open Triage** (`orenlab.codeclone.openTriage`) calls `get_production_triage` for -the current run before opening the markdown panel. Repeated opens reuse the cached -payload for 5 seconds when the run is unchanged and not marked stale; concurrent -opens share one in-flight request. - -## First-run path - -1. Open the `CodeClone` view container. -2. Run `Analyze Workspace`. -3. Use `Review Priorities` or `Review Changes`. -4. If the first pass looks clean but you want smaller repeated units, open - `Set Analysis Depth`. -5. Reveal source before opening deeper detail. - -If the launcher is missing, use `Open Setup Help` from the extension. diff --git a/docs/guide/mcp/README.md b/docs/guide/mcp/README.md deleted file mode 100644 index 4f3b7c32..00000000 --- a/docs/guide/mcp/README.md +++ /dev/null @@ -1,56 +0,0 @@ - - -# MCP for AI Agents - -Use CodeClone through `codeclone-mcp` — same pipeline and report as the CLI. - -**Analysis truth is read-only:** MCP never mutates source, baselines, analysis -cache, or canonical reports. It **may** write session-local coordination -(workspace intents), Engineering Memory **drafts**, and optional audit rows when -enabled. Opt-in Platform Observability writes separate local development -telemetry and never becomes repository truth. - -Install: [Getting started — MCP extra](../../getting-started.md#install). - -!!! tip "Guide vs contract" - This section is **how to work** with MCP. Tool names, parameters, and response - shapes are normative in the [MCP interface contract](../../book/25-mcp-interface/index.md). - -## Setup - -| Step | Page | -|----------------------|-----------------------------------------------| -| Register a client | [Client setup](client-setup.md) | -| Launcher & transport | [Server & transport](server-and-transport.md) | -| Layer diagram | [Architecture](architecture.md) | -| Troubleshooting & issues | [MCP troubleshooting](troubleshooting.md) | - -## Workflows (recommended order) - -| Step | Recipe | -|--------------------------|----------------------------------------------------------------------------| -| 1. Baseline-aware triage | [Analyze & triage](workflows/analyze-and-triage.md) | -| 2. Focused inspection | [Drill down & checks](workflows/drill-down-and-checks.md) | -| 3. Live code context | [Analyze & triage](workflows/analyze-and-triage.md#implementation-context) | -| 4. Governed edits | [Change control](workflows/change-control.md) | -| 5. Durable scope context | [Memory recipes](workflows/memory-recipes.md) | -| 6. Optional: coverage & session markers | [Coverage join & session markers](workflows/session-and-coverage.md) | - -**Maintainers only** (developing CodeClone itself — not user repo review): - -| Maintainer surface | Recipe | -|-------------------------------|-----------------------------------------------------------------------------| -| M. Observer setup & MCP drill | [Platform Observability recipes](workflows/observability-recipes.md) | -| M. Maintainer playbook | [Developing CodeClone with observer](../observability/maintainer-workflow.md) | - -## Reference shortcuts - -| Need | Page | -|---------------------------------|---------------------------------------------------------------------------------------| -| Prompt patterns | [Prompt patterns](prompts.md) | -| Payload field cheat sheet | [Payload cheatsheet](payload-cheatsheet.md) | -| Change control contract | [Structural Change Controller](../../book/12-structural-change-controller/index.md) | -| Implementation-context contract | [Implementation context](../../book/25-mcp-interface/tools/implementation-context.md) | -| `help()` topics | [Help topics](../../book/25-mcp-interface/tools/help-and-topics.md) | -| Engineering Memory contract | [Engineering Memory](../../book/13-engineering-memory/index.md) | -| Runtime diagnostics (maintainer-only) | [Platform Observability](../observability/maintainer-workflow.md) | diff --git a/docs/guide/mcp/architecture.md b/docs/guide/mcp/architecture.md deleted file mode 100644 index 4ae4401f..00000000 --- a/docs/guide/mcp/architecture.md +++ /dev/null @@ -1,88 +0,0 @@ - - -# MCP architecture - -## Where MCP fits - -MCP is an **integration surface**, not a second analyzer. It composes over the -same canonical pipeline and report contracts as the CLI and HTML report. - -```mermaid -graph LR - A[Source Code] --> B[Core Pipeline] - B --> C[Canonical Report] - C --> D[CLI] - C --> E[HTML] - C --> F[MCP] - C --> G[SARIF] - style F stroke: #6366f1, stroke-width: 2px -``` - -## Session architecture - -Every `codeclone-mcp` process owns an isolated session. Session state lives -entirely in process memory and does not survive restart. - -```mermaid -graph TD - subgraph MCPSession["MCPSession (in-memory)"] - RS[Run Store
bounded history] - AI[Active Intents
change control] - RM[Review Markers
session-local] - BRC[Blast Radius Cache] - GR[Gate Results] - end - - subgraph Disk["Disk (coordination + optional sidecars)"] - WIR["Workspace Intent Registry
.codeclone/intents/ or intents.sqlite3"] - MEM["Engineering Memory SQLite
.codeclone/memory/"] - AUD["Audit trail (optional)
.codeclone/db/"] - OBS["Platform Observability (dev-only)
platform_observability.sqlite3"] - end - - MCPSession -->|" coordination + drafts "| Disk - MCPSession -->|" never writes "| BL[Baselines] - MCPSession -->|" never writes "| CA["Analysis cache (.codeclone/cache.json)"] - MCPSession -->|" never writes "| RP[Canonical reports] - MCPSession -->|" never writes "| SC[Source Files] - style BL fill: #fee2e2 - style CA fill: #fee2e2 - style RP fill: #fee2e2 - style SC fill: #fee2e2 -``` - -**Read-only contract (analysis truth):** MCP never mutates source files, -baselines, analysis cache, or canonical report artifacts. It **may** write -ephemeral workspace intent records, Engineering Memory **drafts** (human approve -required for promotion), optional audit evidence, and opt-in development -telemetry when enabled. Platform Observability remains separate from repository -findings, reports, gates, baselines, and memory facts. - -## Mixin chain - -`MCPSession` is composed from focused mixins (`codeclone/surfaces/mcp/session.py`). -In Python MRO, the **first** listed mixin wins method resolution — workflow tools -sit outermost. - -```mermaid -graph BT - STM["_MCPSessionStateMixin
runs, markers, gates, observability query"] - INS["_MCPSessionInsightsMixin
session stats, audit queries"] - BR["_MCPSessionBlastRadiusMixin"] - MM["_MCPSessionMemoryMixin"] - IM["_MCPSessionIntentMixin"] - PC["_MCPSessionPatchContractMixin"] - RR["_MCPSessionReviewReceiptMixin"] - CG["_MCPSessionClaimGuardMixin"] - WF["_MCPSessionWorkflowMixin
start/finish orchestration"] - S["MCPSession"] - STM --> INS --> BR --> MM --> IM --> PC --> RR --> CG --> WF --> S - style S stroke: #6366f1, stroke-width: 2px - style WF fill: #eff6ff - style MM fill: #ecfdf5 -``` - -New capabilities extend the chain by adding a mixin **before** `MCPSession` in -the class definition — not by editing lower layers. - ---- diff --git a/docs/guide/mcp/client-setup.md b/docs/guide/mcp/client-setup.md deleted file mode 100644 index 77600b4c..00000000 --- a/docs/guide/mcp/client-setup.md +++ /dev/null @@ -1,75 +0,0 @@ - - -# MCP client setup - -## Client setup - -All clients use the same server. Only the registration format differs. - -=== "Claude Code" - - ```bash - claude plugin marketplace add orenlab/codeclone-claude-code - claude plugin install codeclone@orenlab-codeclone - ``` - - The native plugin supplies the MCP definition and CodeClone skills. See the - [Claude Code plugin guide](../integrations/claude-code/setup.md). - - Manual MCP registration without the plugin remains available: - - ```bash - claude mcp add --scope project codeclone -- codeclone-mcp --transport stdio - ``` - -=== "Codex" - - ```bash - codex plugin marketplace add orenlab/codeclone-codex - codex plugin add codeclone@orenlab-codeclone - ``` - - The native plugin includes the MCP definition and CodeClone skills. - Manual MCP registration without the plugin is also valid: - - ```bash - codex mcp add codeclone -- codeclone-mcp --transport stdio - ``` - - See [Codex plugin guide](../integrations/codex/setup.md). - -=== "Cursor" - - For the complete integration, import - `https://github.com/orenlab/codeclone-cursor` through - **Dashboard → Settings → Plugins → Team Marketplaces → Add Marketplace → - Import from Repo**, then install **CodeClone**. - - The bundled [Cursor plugin](../integrations/cursor/install-and-skills.md) - includes MCP registration, skills, rules, and project hooks. Manual - `.cursor/mcp.json` registration is covered under generic setup below, but - does not install the rest of that surface. - -=== "Claude Desktop" - - A local `.mcpb` bundle ships in `extensions/claude-desktop-codeclone/`. - See [Claude Desktop bundle guide](../integrations/claude-desktop/setup.md). - -=== "JSON config (generic)" - - ```json - { - "mcpServers": { - "codeclone": { - "command": "codeclone-mcp", - "args": ["--transport", "stdio"] - } - } - } - ``` - - Works with Copilot Chat, Gemini CLI, and other MCP-capable clients. - -If `codeclone-mcp` is not on `PATH`, use the full launcher path. - ---- diff --git a/docs/guide/mcp/payload-cheatsheet.md b/docs/guide/mcp/payload-cheatsheet.md deleted file mode 100644 index 8571eabb..00000000 --- a/docs/guide/mcp/payload-cheatsheet.md +++ /dev/null @@ -1,45 +0,0 @@ - - -# Payload cheat sheet - -!!! warning "Non-normative" - Normative conventions: [MCP payload conventions](../../book/25-mcp-interface/payload-conventions.md). - -## Payload conventions - -Short reference for response structure patterns across the tool surface. - -**IDs** — Run IDs are 8-char hex handles. Finding IDs are short prefixed -forms. Both accept the full canonical form as input. - -**Detail levels** — `summary` (default for lists), `normal` (default for -single finding), `full` (compatibility payload with URIs). - -**Pagination** — `list_findings` and -`get_report_section(section="metrics_detail")` support `offset` and `limit`. -`list_hotspots` supports `limit` and `max_results` only (no `offset`). - -**Changed-scope filters** — `list_findings`, `list_hotspots`, and -`generate_pr_summary` accept `changed_paths` or `git_diff_ref` for PR -projection. - -**Threshold context** — Empty `check_*` responses include -`threshold_context` showing whether the run is genuinely quiet or simply -below the active threshold. - -**Engineering Memory** — `get_relevant_memory` omits routine `run:*` trajectories -from `trajectories[]` by default. Use `query_engineering_memory` trajectory -modes with `filters.include_routine=true` to include them. Scoped retrieval -defaults to `detail_level=compact`; use `full` or -`query_engineering_memory(mode=get)` for complete payloads. - -**Context governance** — `context_governance.mode="partial_enforce"` means the -tool applied response-budget packing and any omitted evidence is listed under -`context_governance.omitted` with an exact drill-down route. `mode="observe"` -means measurement only; this is expected for exact retrieval/page tools such as -`get_blast_artifact`, `get_review_receipt`, `get_patch_trail`, -`get_memory_projection_page`, and `get_implementation_context_page`. - -… - -[Full reference →](../../book/25-mcp-interface/payload-conventions.md) diff --git a/docs/guide/mcp/prompts.md b/docs/guide/mcp/prompts.md deleted file mode 100644 index ee60de25..00000000 --- a/docs/guide/mcp/prompts.md +++ /dev/null @@ -1,42 +0,0 @@ - - -# MCP prompt patterns - -## Prompt patterns - -Good prompts include **scope**, **goal**, and **constraint**: - -```text title="Health check" -Use codeclone MCP to analyze this repository. -Give me a concise structural health summary and the top findings to look at first. -``` - -```text title="Changed-files review" -Use codeclone MCP in changed-files mode for my latest edits. -Focus only on findings that touch changed files and rank them by priority. -``` - -```text title="Gate preview" -Run codeclone through MCP and preview gating with fail_on_new. -Explain the exact reasons. Do not change any files. -``` - -```text title="AI-generated code check" -I added code with an AI agent. Use codeclone MCP to check for new structural drift. -Separate accepted baseline debt from patch-local before/after regressions. -``` - -!!! tip "Best practices" - - - Use `analyze_changed_paths` for PRs, not full analysis. - - Prefer `get_run_summary` or `get_production_triage` as the first pass. - - Prefer `list_hotspots` or narrow `check_*` tools before broad `list_findings`. - - Use `get_finding` / `get_remediation` for one finding instead of raising - `detail_level` on larger lists. - - Pass an absolute `root` — MCP rejects relative roots like `.`. - - Use `coverage_xml` only with `analysis_mode="full"`. - - Use `source_kind="production"` (or `tests`, `fixtures`, `mixed`, `other`) to - cut test/fixture noise. - - Use `mark_finding_reviewed` + `exclude_reviewed=true` in long sessions. - ---- diff --git a/docs/guide/mcp/server-and-transport.md b/docs/guide/mcp/server-and-transport.md deleted file mode 100644 index 892e6f9b..00000000 --- a/docs/guide/mcp/server-and-transport.md +++ /dev/null @@ -1,57 +0,0 @@ - - -# MCP server & transport - -## Server - -### Transports - -| Transport | Default | Use case | -|-------------------|---------|---------------------------------| -| `stdio` | Yes | Local agents, IDEs, CLI clients | -| `streamable-http` | No | Remote clients, Responses API | - -```bash title="Local (default)" -codeclone-mcp --transport stdio -``` - -```bash title="HTTP (loopback)" -export CODECLONE_MCP_AUTH_TOKEN="$(openssl rand -hex 32)" -codeclone-mcp --transport streamable-http --host 127.0.0.1 --port 8000 -``` - -!!! warning "HTTP auth is mandatory" - `streamable-http` **always** requires `CODECLONE_MCP_AUTH_TOKEN` with at - least 32 characters. The server refuses to start without it — there is no - unauthenticated HTTP mode. Non-loopback hosts additionally require - `--allow-remote`. See - [Environment variable overrides](../../book/10-config-and-defaults.md#mcp-http-authentication) - and [Security Model](../../book/21-security-model.md#remote-mcp-transport). - -### Server flags - -| Flag | Default | Applies when | Effect | -|----------------------------|---------|-------------------|--------------------------------------------------------| -| `--history-limit` | `4` | all transports | In-memory run retention (`1`–`10`) | -| `--json-response` | on | `streamable-http` | JSON responses for Streamable HTTP | -| `--stateless-http` | on | `streamable-http` | Stateless Streamable HTTP mode | -| `--debug` | off | all transports | FastMCP debug mode | -| `--log-level` | `INFO` | all transports | `DEBUG`, `INFO`, `WARNING`, `ERROR`, or `CRITICAL` | -| `--allow-remote` | off | `streamable-http` | Bind non-loopback hosts (auth still required) | -| `--ide-governance-channel` | off | all transports | VS Code only — registers session-stats and audit tools | - -`--host` (default `127.0.0.1`) and `--port` (default `8000`) apply to -`streamable-http` only. Agent launchers must not pass `--ide-governance-channel`. - -### Run retention - -Run history is bounded: default `4`, max `10` (`--history-limit`). -Runs are in-memory only and do not survive process restart. - -### Absolute roots - -All analysis tools require an **absolute** repository root. Relative roots -like `.` are rejected because the server working directory may differ from -the client workspace. - ---- diff --git a/docs/guide/mcp/troubleshooting.md b/docs/guide/mcp/troubleshooting.md deleted file mode 100644 index d004bff7..00000000 --- a/docs/guide/mcp/troubleshooting.md +++ /dev/null @@ -1,97 +0,0 @@ - - -# MCP troubleshooting - -When MCP setup, tool calls, or change-control responses fail — start here. -Install and transport basics: -[Client setup](client-setup.md), -[Server & transport](server-and-transport.md). - -Normative contracts: -[MCP interface](../../book/25-mcp-interface/index.md), -[Change controller](../../book/12-structural-change-controller/index.md). - -## Install and launcher - -| Symptom | Fix | -|---------|-----| -| `CodeClone MCP support requires the optional 'mcp' extra` | `uv tool install "codeclone[mcp]"` or `pip install 'codeclone[mcp]'` | -| Client cannot find `codeclone-mcp` | Install the extra above, or point `command` at the full launcher path in MCP config | -| Wrong / missing tools after upgrade | Restart the MCP process; confirm `codeclone --version` matches the client bundle | -| Plugin installed but MCP silent | Check client MCP logs; verify stdio command is `codeclone-mcp --transport stdio` | - -## Transport and HTTP - -| Symptom | Fix | -|---------|-----| -| HTTP server refuses to start | Set `CODECLONE_MCP_AUTH_TOKEN` to ≥32 characters before launch — no unauthenticated HTTP | -| Remote client cannot connect | Use `streamable-http`; pass Bearer token; for non-loopback hosts add `--allow-remote` | -| Client only accepts remote MCP | See [Server & transport](server-and-transport.md#transports) — stdio for local IDEs | - -## Analysis parameters - -| Symptom | Fix | -|---------|-----| -| `requires an absolute repository root` | Pass full path (`/Users/.../repo`), not `.` or a relative segment | -| `Repository root '…' does not exist` | Fix typo; ensure the path is the repo root on the machine running MCP | -| `path traversal not allowed` | Use repo-relative paths inside tools; do not pass `../` escapes | -| `changed_paths` rejected | Pass `list[str]` of repo-relative file paths, or use `git_diff_ref` | -| `analyze_changed_paths` fails | Provide **either** `changed_paths` **or** `git_diff_ref`, not neither | -| `cache_policy='refresh' is CLI-only` | MCP accepts `reuse` (default) or `off` only | -| `coverage_xml requires analysis_mode='full'` | Set `analysis_mode="full"` before joining Cobertura XML | -| Stale or wrong findings | Call `analyze_repository` again; runs are in-memory and bounded (`--history-limit`) | - -## Session and workflow state - -| Symptom | Fix | -|---------|-----| -| Agent reads results from an old run | Re-analyze, or pass the explicit `run_id` you intend | -| Review markers out of sync | `mark_finding_reviewed` + `list_findings(exclude_reviewed=true)`; markers are session-local | -| Need a clean MCP session | `clear_session_runs` — also clears workspace intents; see [Session markers](workflows/session-and-coverage.md#session-review-loop-in-memory-markers) | -| Process restarted — intents gone | Expected: intent registry is ephemeral; re-run `analyze_repository` → `start_controlled_change` | - -## Change control responses - -| `status` / message | What to do | -|--------------------|------------| -| `needs_analysis` | Call `analyze_repository(root=)` before `start_controlled_change` | -| `queued`, `edit_allowed: false` | Another intent is active — `manage_change_intent(action="promote")` or narrow scope | -| `blocked`, dirty scope overlap | Inspect git diff; commit/stash/revert, narrow scope, or `dirty_scope_policy="continue_own_wip"` for own WIP | -| `finish` → `unverified` | Follow `next_step` in the response (often a new after-run + same `intent_id`) | -| `finish` → `violated` | Fix scope or regressions; or `start` again with expanded `allowed_files` | -| Foreign intent overlap | Coordinate with the user — do not kill foreign PIDs without confirmation | - -Full workflow: -[Change control](workflows/change-control.md). - -## Engineering Memory - -| Symptom | Fix | -|---------|-----| -| `get_relevant_memory` fails without `root` | Always pass the same absolute `root` as analysis — `intent_id` alone is invalid | -| Empty memory on first use | Normal — `bootstrap_if_missing` ingests on first scoped call after `analyze_repository` | -| Cannot approve drafts via MCP | By design — use VS Code **Memory** view; agents only `record_candidate` | - -## Quick diagnostic checklist - -1. `codeclone --version` and `codeclone-mcp --help` succeed on the host that runs MCP. -2. `root` is **absolute** and points at the repository the client has open. -3. `analyze_repository` → `get_run_summary` works before deeper tools. -4. `help(topic="engineering_memory")` or `help(topic="change_control")` for contract copy. -5. Enable server debug: `codeclone-mcp --log-level DEBUG` (stdio clients: check MCP stderr). - -## Report a bug or false positive - -If the steps above do not match what you see, open a GitHub issue: - -**[github.com/orenlab/codeclone/issues](https://github.com/orenlab/codeclone/issues)** - -Include: - -- CodeClone version (`codeclone --version`) -- Client (Cursor, Codex, Claude Code, VS Code, Claude Desktop, other) -- Transport (`stdio` or `streamable-http`) -- Tool name and parameters (redact tokens and private paths) -- Full error text or MCP log excerpt - ---- diff --git a/docs/guide/mcp/workflows/analyze-and-triage.md b/docs/guide/mcp/workflows/analyze-and-triage.md deleted file mode 100644 index d3203e76..00000000 --- a/docs/guide/mcp/workflows/analyze-and-triage.md +++ /dev/null @@ -1,127 +0,0 @@ - - -# Analyze & triage - -### Step 1: Analyze - -| Tool | Purpose | -|-------------------------|---------------------------------------------------| -| `analyze_repository` | Full deterministic analysis of one repo root | -| `analyze_changed_paths` | Diff-aware analysis with changed-files projection | - -Both register the result as an in-memory run. All other tools read from -stored runs. - -### Step 2: Triage - -| Tool | Purpose | -|-------------------------|------------------------------------------------------------| -| `get_run_summary` | Cheapest snapshot: health, findings, baseline status | -| `get_production_triage` | Production-first view: hotspots, suggestions, thresholds | -| `list_hotspots` | Priority-ranked hotspot views by kind | -| `compare_runs` | Run-to-run delta: regressions, improvements, health change | - -!!! tip "Start here" - After analysis, call `get_run_summary` or `get_production_triage` first. - Prefer `list_hotspots` or `check_*` before broad `list_findings` calls. - -### Workspace hygiene tips - -Selected MCP responses may include a non-blocking `tips[]` array with -structured workspace guidance. The first tip checks whether the repository -root `.gitignore` covers `.codeclone/` (or the broader `.cache/` tree). - -| Field | Example | -|-------------------|-----------------------------| -| `id` | `gitignore-codeclone-cache` | -| `severity` | `info` | -| `category` | `workspace_hygiene` | -| `suggested_entry` | `.codeclone/` | - -Tips are advisory only — not findings, gates, or failures. MCP never edits -`.gitignore` automatically; agents must declare scope before changing it. - -Surfaces: `analyze_repository`, `get_run_summary`, `get_production_triage`, -`start_controlled_change`, and the CLI after a normal interactive analysis run -(suppressed in `--quiet`, CI, and non-TTY contexts). - -## Health check - -``` -analyze_repository(root=) - -> get_run_summary or get_production_triage - -> list_hotspots or check_* - -> get_finding -> get_remediation -``` - -## PR review - -``` -analyze_changed_paths(root=, changed_paths=[...] or git_diff_ref="HEAD~1") - -> list_findings(sort_by="priority") - -> get_finding -> get_remediation - -> generate_pr_summary -``` - -## Implementation context - -After analysis and triage, ask for bounded context around the files you expect -to inspect: - -``` -get_implementation_context( - root=, - paths=["codeclone/surfaces/mcp/service.py"], - mode="implementation", -) -``` - -The response combines canonical structural facts with a live freshness delta. -Use `context_artifact_digest` to identify the source context artifact and -`context_projection_digest` when citing the exact bounded response. If -`freshness.status` is `drifted`, analyze again. This step informs scope; only -`start_controlled_change` can return `edit_allowed=true`. - -With no explicit subject, the tool resolves current work deterministically: - -1. active intent `allowed_files`; -2. otherwise the bounded live git-dirty set; -3. otherwise `status="no_current_work"`. - -Use `changed_scope=true` to request the dirty set explicitly. Do not combine it -with `paths` or `symbols`. - -Exact qualnames are also valid subjects: - -``` -get_implementation_context( - root=, - symbols=["codeclone.surfaces.mcp.service:CodeCloneMCPService"], - mode="implementation", -) -``` - -Symbol resolution uses the analyzed Unit inventory plus public API rows. -Inspect both `subject.resolved_symbols` and `subject.unresolved_symbols`; -CodeClone reports unknown qualnames instead of inferring a likely match. - -Structural import, importer, and test-importer roles appear as collapsed -`related_modules` entries with explicit `relations`. Read each collection's -summary: the global budget is shared across the response. Safety context is -allocated first; `safety_context_overflow` means even the hard cap could not -show every safety entry. - -Once an intent is active, pass its `intent_id` with the same explicit paths. -The response then shows the declared scope, review context, do-not-touch -boundaries, and guards beside lane-separated memory evidence. Use -`mode="impact"` when you need transitive dependency context and -baseline-sensitive findings. The context tool mirrors authorization evidence; -it does not grant or widen authorization. - -### Change control tool tiers - -| Tier | Tools | When to use | -|------|-------|-------------| -| Normal workflow | `analyze_repository`, `start_controlled_change`, `finish_controlled_change` | Every edit cycle | -| Queue/recovery | `manage_change_intent` (promote, recover, reset, renew) | Multi-agent coordination, crash recovery | -| Advanced/diagnostic | `get_blast_radius`, `check_patch_contract`, `validate_review_claims`, `create_review_receipt` | Deep inspection, step-by-step debugging | diff --git a/docs/guide/mcp/workflows/change-control.md b/docs/guide/mcp/workflows/change-control.md deleted file mode 100644 index 7085f4a0..00000000 --- a/docs/guide/mcp/workflows/change-control.md +++ /dev/null @@ -1,46 +0,0 @@ - - -# Change control workflow - -Primary MCP edit cycle (sole sequence diagram for change control in the guide): - -```mermaid -sequenceDiagram - participant Agent - participant MCP as CodeClone MCP - Agent ->> MCP: analyze_repository(root=) - MCP -->> Agent: run_id - Agent ->> MCP: start_controlled_change(root=, scope, intent, dirty_scope_policy?) - MCP -->> Agent: intent_id, blast_radius, budget, edit_allowed - Agent ->> MCP: get_relevant_memory(root, scope|intent_id) - MCP -->> Agent: ranked memory context - Agent ->> MCP: get_implementation_context(root, paths, intent_id?) - MCP -->> Agent: bounded structural context - Note over Agent: edit files - opt Python structural / governance config - Agent ->> MCP: analyze_repository - MCP -->> Agent: after_run_id - end - Agent ->> MCP: finish_controlled_change(intent_id, changed_files|diff_ref, after_run_id?, claims_text?) - MCP -->> Agent: status, summary, workspace_hygiene_after, intent_cleared -``` - -## Tool tiers - -| Tier | Tools | When | -|----------------|-------------------------------------------------------|---------------------| -| Normal | `start_controlled_change`, `finish_controlled_change` | Every edit cycle | -| Queue/recovery | `manage_change_intent` (promote, recover, …) | Multi-agent / crash | -| Advanced | `get_blast_radius`, `check_patch_contract`, … | Debugging only | - -Normative tool params: [MCP workflow tools](../../../book/25-mcp-interface/tools/workflow.md). -Finish pipeline and -hygiene: [finish_controlled_change](../../../book/12-structural-change-controller/finish-controlled-change.md), -[Finish hygiene](../../../book/12-structural-change-controller/finish-hygiene.md). - -## Related recipes - -- [Agent edit cycle](../../change-control/agent-cycle.md) -- [Queue & recovery](../../change-control/queue-and-recovery.md) -- [Atomic debug path](../../change-control/atomic-debug.md) -- [Engineering Memory recipes](memory-recipes.md) diff --git a/docs/guide/mcp/workflows/drill-down-and-checks.md b/docs/guide/mcp/workflows/drill-down-and-checks.md deleted file mode 100644 index 7d3db879..00000000 --- a/docs/guide/mcp/workflows/drill-down-and-checks.md +++ /dev/null @@ -1,27 +0,0 @@ - - -# Drill down & focused checks - -### Step 3: Drill Down - -| Tool | Purpose | -|-----------------------|-------------------------------------------------------------| -| `list_findings` | Filtered, paginated findings with novelty and scope filters | -| `get_finding` | Single finding detail by short or canonical ID | -| `get_remediation` | Remediation and explainability for one finding | -| `get_report_section` | Read report sections; `metrics_detail` is paginated | -| `evaluate_gates` | Preview CI gating decisions without mutating state | -| `generate_pr_summary` | PR-friendly markdown or JSON summary | - -### Step 4: Focused Checks - -Narrow queries over a single quality dimension. Cheaper than `list_findings` -when you know which dimension to inspect. - -| Tool | Dimension | -|--------------------|--------------------------------| -| `check_clones` | Clone groups | -| `check_complexity` | Cyclomatic complexity hotspots | -| `check_coupling` | Afferent/efferent coupling | -| `check_cohesion` | Module cohesion | -| `check_dead_code` | Dead code candidates | diff --git a/docs/guide/mcp/workflows/memory-recipes.md b/docs/guide/mcp/workflows/memory-recipes.md deleted file mode 100644 index d936b5c0..00000000 --- a/docs/guide/mcp/workflows/memory-recipes.md +++ /dev/null @@ -1,91 +0,0 @@ - - -# Engineering Memory recipes (MCP) - -Ranked scope context and governed drafts — **not** a second analyzer. Normative -tool shapes: [Engineering Memory MCP surface](../../../book/13-engineering-memory/mcp-surface.md). - -Session-local review markers live in -[Coverage join & session markers](session-and-coverage.md). - -## 1. Bootstrap before first scoped retrieval - -When the store is missing, default `mcp_sync_policy=bootstrap_if_missing` ingests -from the latest MCP run on the first scoped `get_relevant_memory`. - -| Step | Tool / action | -|--------------------------|------------------------------------------------------------------| -| Analyze | `analyze_repository(root=)` | -| Optional explicit ingest | `manage_engineering_memory(action=refresh_from_run, root=)` | -| Offline init | `codeclone memory init` (CI/offline; same ingest contract) | - -## 2. Scope context after `start_controlled_change` - -Call only after `edit_allowed=true`. **`root` is required** (same absolute path -as analysis). - -```text -get_relevant_memory(root=, intent_id=) - # or scope=["path/to/file.py", ...] -``` - -Read `memory_sync`, stale warnings, and `contradiction_note` entries before editing. -Do not treat `draft` / `inferred` rows as established facts. - -## 3. Draft observations during the cycle - -```text -manage_engineering_memory( - action=record_candidate, - root=, - record_type=risk_note | change_rationale | ..., - statement="", - subject_path="path/to/main/file.py", -) -``` - -Agents **cannot** `approve` / `reject` / `archive` via MCP. Humans promote drafts -in the VS Code Memory view or with -`codeclone memory approve --i-know-what-im-doing` (optional `--by NAME`). - -## 4. Finish proposals - -On accepted finish: - -```text -finish_controlled_change(..., propose_memory=true) -``` - -Returns `memory_candidates`, `memory_staleness`, `memory_coverage_delta`, and may -enqueue projection rebuild when configured. - -## 5. Search and drill-down - -| Goal | Call | -|----------------------|------------------------------------------------------------------------------------------------| -| Keyword search | `query_engineering_memory(mode=search, query=..., root=, filters={match_mode: any\|all})` | -| Semantic blend | same + `semantic=true` when semantic index is built | -| One path | `query_engineering_memory(mode=for_path, path=..., root=)` | -| Trajectory detail | `query_engineering_memory(mode=trajectory_get, record_id=, root=)` | -| Trajectory dashboard | `query_engineering_memory(mode=trajectory_dashboard, root=)` | -| Playbook | `help(topic=engineering_memory)` | - -## 6. Semantic index maintenance - -When `[tool.codeclone.memory.semantic] enabled=true`: - -```text -manage_engineering_memory(action=rebuild_semantic_index, root=) -``` - -Contract: [Semantic search](../../../book/13-engineering-memory/search-semantic.md). - -## 7. Trajectory and Experience evidence - -Scoped `get_relevant_memory` keeps governed records, trajectory precedents, and -advisory Experiences in separate response lanes. Inspect the workflow in -[Trajectories and Experiences](../../memory/trajectories-and-experiences.md); -use `promote_experience` only when a recurring pattern deserves human review as -a draft memory record. - ---- diff --git a/docs/guide/mcp/workflows/observability-recipes.md b/docs/guide/mcp/workflows/observability-recipes.md deleted file mode 100644 index b46601b5..00000000 --- a/docs/guide/mcp/workflows/observability-recipes.md +++ /dev/null @@ -1,107 +0,0 @@ -# Platform Observability recipes (MCP) - - - -**Maintainer-only.** These recipes apply when you develop **CodeClone itself** -(MCP server, CLI instrumentation, memory pipelines, observer storage). They do -**not** help users analyze their Python repositories — for that use -[Analyze & triage](analyze-and-triage.md) and [Change control](change-control.md). - -Prerequisite: [Explicit observer enable](../../observability/maintainer-workflow.md#explicit-enable-required). - -Skill: `/codeclone-platform-observability` in bundled plugins. - -## 0. Confirm you need this surface - -| Question | Tool | -|-------------------------------------------------|------------------------------------------------------------| -| Health / clones / metrics of **user repo** | `get_production_triage`, `check_*` — **not** observability | -| Slow **CodeClone** MCP handler or DB during dev | `query_platform_observability` | -| Patch verify / edit scope | change-control workflow — **not** observability | - -If the user is not a CodeClone maintainer, **do not** call -`query_platform_observability`. - -## 1. Enable observer on the producing process - -```bash -export CODECLONE_OBSERVABILITY_ENABLED=1 -# restart codeclone-mcp (or CLI) with this env in the same shell / IDE config -``` - -Re-run the workflow under test. Without enablement every section returns -`status=disabled` or `status=no_store`. - -## 2. Read contract - -```text -help(topic="observability", detail="normal") -``` - -Covers sections, anti-inference rules, and inert disabled states. - -## 3. Start broad - -```json -{ - "root": "/absolute/path/to/codeclone", - "section": "summary", - "window": "latest", - "detail_level": "compact" -} -``` - -Tool: `query_platform_observability`. - -Follow `recommended_next_sections` in the response — **one section per call**. - -## 4. Common drill paths - -### Slow MCP session - -1. `summary` -2. `slow_operations` -3. `mcp_tool_matrix` -4. `correlated_chains` (if multi-step) - -### Memory / semantic rebuild cost - -1. `summary` -2. `memory_pipeline_cost` -3. `db_cost` (if SQL-heavy) - -### Pipeline analysis cost - -1. `summary` -2. `pipeline` -3. `costly_noops` - -### One workflow across CLI + MCP + worker - -1. Reproduce with shared correlation (same env-enabled processes) -2. `correlated_chains` with `window=` when known - -## 5. Interpretation rules (mandatory) - -- Audience is **CodeClone development** — envelope says so explicitly. -- Metrics are diagnostic hints, not findings or vulnerabilities. -- Do **not** tell end users their repo is unhealthy based on observer output. -- Do **not** use observer data in `finish_controlled_change` claims or review - receipts about repository quality. - -## 6. Human full trace - -MCP sections are bounded (≤50 rows). For waterfall HTML, maintainers run CLI -locally: - -```bash -codeclone observability trace --root . --html /tmp/codeclone-observer.html -``` - -Agents should not substitute CLI output for repository analysis. - -## Related - -- [Maintainer workflow](../../observability/maintainer-workflow.md) -- [Tool contract](../../../book/25-mcp-interface/tools/platform-observability.md) -- [Help topic catalog](../../../book/25-mcp-interface/tools/help-and-topics.md) diff --git a/docs/guide/mcp/workflows/session-and-coverage.md b/docs/guide/mcp/workflows/session-and-coverage.md deleted file mode 100644 index 4b69cc3b..00000000 --- a/docs/guide/mcp/workflows/session-and-coverage.md +++ /dev/null @@ -1,70 +0,0 @@ - - -# Coverage join & session review markers - -Two **optional** MCP workflows that most agents skip on the first pass: - -1. **Coverage Join** — attach an external Cobertura XML from your test run and - preview untested-hotspot gating. -2. **Session review markers** — track which findings you already triaged inside - one long MCP process. - -Start with [Analyze & triage](analyze-and-triage.md) for health checks and PR -review. Use this page when you already have a coverage artifact or a long -finding backlog in the same chat session. - -Normative tool shapes: -[session tools](../../../book/25-mcp-interface/tools/session-and-memory.md), -[analysis & gates](../../../book/25-mcp-interface/tools/analysis.md), -[Coverage Join config](../../../book/10-config-and-defaults.md). - -## Coverage Join (Cobertura + gates) - -Join measured coverage to function hotspots for the **current run only**. Coverage -Join does not update baseline, cache, or canonical report persistence. - -| Requirement | Detail | -|-------------|--------| -| Analysis mode | `analysis_mode="full"` — `coverage_xml` is rejected in `clones_only` | -| Input | Cobertura XML path on `analyze_repository` (`coverage_xml`) | -| Typical follow-up | `get_report_section(section="metrics_detail", family="coverage_join")` | -| Gate preview | `evaluate_gates(fail_on_untested_hotspots=true, coverage_min=50)` | - -``` -analyze_repository(root=, coverage_xml="coverage.xml") - -> get_report_section(section="metrics_detail", family="coverage_join") - -> evaluate_gates(fail_on_untested_hotspots=true, coverage_min=50) -``` - -!!! tip "CLI equivalent" - `codeclone --coverage coverage.xml --fail-on-untested-hotspots` uses the same - join semantics. MCP `evaluate_gates` previews exit reasons without mutating - repository state. - -## Session review loop (in-memory markers) - -MCP keeps run snapshots and **session-local** reviewed markers in the server -process. They survive across tool calls but disappear on process restart — not -Engineering Memory, not baseline truth. - -Use when triaging many findings in one agent session: mark handled items, then -filter them out on the next pass. - -| Tool | Purpose | -|------|---------| -| `mark_finding_reviewed` | Mark one finding reviewed (optional `note`) | -| `list_findings(exclude_reviewed=true)` | Omit findings already marked in this session | -| `list_reviewed_findings` | List markers for audit | -| `clear_session_runs` | Reset in-memory runs, markers, and workspace intent registry | - -``` -list_findings - -> get_finding -> mark_finding_reviewed - -> list_findings(exclude_reviewed=true) -> ... - -> clear_session_runs # full session reset — also clears active intents -``` - -For durable facts across sessions, use [Memory recipes](memory-recipes.md) -instead of review markers. - ---- diff --git a/docs/guide/memory/overview.md b/docs/guide/memory/overview.md deleted file mode 100644 index 5d71e2df..00000000 --- a/docs/guide/memory/overview.md +++ /dev/null @@ -1,19 +0,0 @@ - - -# Engineering Memory overview - -Local SQLite store of evidence-linked repository facts. Complements change -control with scoped context before edits. - -| Task | Page | -|----------------------------|----------------------------------------------------------------------------------------------| -| Bootstrap / sync | [MCP memory recipes](../mcp/workflows/memory-recipes.md) | -| MCP contract | [Engineering Memory](../../book/13-engineering-memory/index.md) | -| Trajectories / Experiences | [Practical guide](trajectories-and-experiences.md) | -| Trajectory contract | [Projection and Patch Trail](../../book/13-engineering-memory/trajectory-and-patch-trail.md) | -| Quality passport | [Quality and analytics](../../book/13-engineering-memory/trajectory-quality-and-passport.md) | -| Experience contract | [Experience Layer](../../book/13-engineering-memory/experience-layer.md) | - -Human **approve** of drafts: VS Code Memory view **or** -`codeclone memory approve --i-know-what-im-doing` (optional `--by NAME`; not MCP -agent tools). diff --git a/docs/guide/memory/trajectories-and-experiences.md b/docs/guide/memory/trajectories-and-experiences.md deleted file mode 100644 index 939bcdb2..00000000 --- a/docs/guide/memory/trajectories-and-experiences.md +++ /dev/null @@ -1,77 +0,0 @@ -# Work with Trajectories and Experiences - - - -Engineering Memory exposes two evidence layers beyond curated records: - -- trajectories reconstruct what happened during agent work; -- Experiences distill recurring patterns across those trajectories. - -Neither layer grants permission to edit. Use them to prepare and review work, -then use change control for authorization. - -```mermaid -flowchart LR - A["Audit evidence"] --> B["Trajectories"] - B --> C["Quality passport and anomalies"] - B --> D["Experience distillation"] - D --> E["Scoped advisory patterns"] - E --> F["Optional draft promotion"] - F --> G["Human governance"] -``` - -## Inspect trajectory health - -```bash -codeclone memory trajectory status --root . -codeclone memory trajectory dashboard --root . -codeclone memory trajectory anomalies --root . -codeclone memory trajectory agents --root . -``` - -Routine run projections are hidden by default. Add `--include-routine` when -you are diagnosing those workflows too. - -Search and inspect one trajectory: - -```bash -codeclone memory trajectory search "verification" --root . -codeclone memory trajectory show TRAJECTORY_ID --root . -``` - -The detail view explains the quality score, complexity band, incidents, -anomalies, evidence, and patch-trail verification. - -## Rebuild projections - -```bash -codeclone memory trajectory rebuild --root . -codeclone memory jobs run-once --root . -``` - -The background projection job refreshes trajectory, semantic, and Experience -projections in that execution order. See -[Projection jobs](../../book/13-engineering-memory/projection-jobs.md). - -## Retrieve Experiences - -Experiences are returned automatically by scoped memory retrieval when their -directory family matches the requested scope. They are kept separate from -memory records and trajectory precedents so callers cannot confuse advisory -patterns with governed facts. - -Through MCP, call `get_relevant_memory` with `scope` or an active `intent_id`. -The response may include: - -- `records`: governed memory records; -- `trajectories`: relevant precedents; -- `experiences`: recurring project patterns. - -To inspect a known Experience in full, use the Engineering Memory query -surface. To turn it into a reviewable draft, use -`manage_engineering_memory(action="promote_experience", experience_id="...")`. -Promotion is idempotent and does not approve the draft. - -The normative contracts -are [Trajectory quality and passport](../../book/13-engineering-memory/trajectory-quality-and-passport.md) -and [Experience layer](../../book/13-engineering-memory/experience-layer.md). diff --git a/docs/guide/observability/diagnostics.md b/docs/guide/observability/diagnostics.md deleted file mode 100644 index 6f1b30c3..00000000 --- a/docs/guide/observability/diagnostics.md +++ /dev/null @@ -1,102 +0,0 @@ -# Diagnose CodeClone with Platform Observability - - - -!!! warning "Maintainer-only — not for end users" - Platform Observability diagnoses **CodeClone's own runtime** while you - **develop CodeClone**. It does **not** help users analyze their Python - repositories (clones, health, gates, MCP review). For that, use the - [MCP guide](../mcp/README.md). - - Observation is **disabled by default** and requires explicit environment - setup before any data exists. See - [Maintainer workflow](maintainer-workflow.md). - - Platform Observability is for diagnosing CodeClone itself: slow MCP calls, - projection work, database query cost, redundant work, and correlated - CLI/MCP/worker activity. It is not a repository quality report. - - The normative contract is - [Platform Observability](../../book/26-platform-observability.md). - - Full maintainer playbook: - [Developing CodeClone with Platform Observability](maintainer-workflow.md). - -## Enable it locally - -```bash -export CODECLONE_OBSERVABILITY_ENABLED=1 -``` - -Run the CodeClone workflow you want to inspect, then query the local store: - -```bash -codeclone observability trace --root . -``` - -For optional process metrics: - -```bash -uv pip install "codeclone[perf]" -export CODECLONE_OBSERVABILITY_PROFILE=1 -``` - -In CI, observation remains off unless it is explicitly enabled: - -```bash -export CODECLONE_OBSERVABILITY_ENABLED=1 -``` - -`CODECLONE_OBSERVABILITY_FORCE=1` is an explicit CI-gate override but never -enables collection by itself. - -## Render the cockpit - -```bash -codeclone observability trace \ - --root . \ - --last 50 \ - --html /tmp/codeclone-observer.html -``` - -The self-contained page visualizes: - -```mermaid -flowchart LR - A["Operation chains"] --> B["Span waterfall"] - B --> C["Pipeline and memory costs"] - C --> D["MCP and DB aggregates"] - D --> E["SQL fingerprints and no-op hints"] -``` - -Use `--operation` to isolate one operation or `--correlation` to follow a -workflow across process boundaries. Use `--json` for a machine-readable export. - -## Query through MCP - -Start broad: - -```json -{ - "root": "/absolute/repository", - "section": "summary", - "window": "latest", - "detail_level": "compact" -} -``` - -Then select one bounded section such as `slow_operations`, `db_cost`, -`memory_pipeline_cost`, `mcp_tool_matrix`, or `correlated_chains`. - -Do not infer repository quality from these numbers. High database activity -means CodeClone executed database work; it does not mean the analyzed project -has a database problem. See -[MCP observability tool](../../book/25-mcp-interface/tools/platform-observability.md). - -## Local data lifecycle - -The store is `.codeclone/db/platform_observability.sqlite3`. CodeClone does not -send it to a remote telemetry service. Automatic pruning is not currently -enforced, so remove the file when you no longer need the diagnostics. - -Raw prompts, payload bodies, and SQL literals are not stored. diff --git a/docs/guide/observability/maintainer-workflow.md b/docs/guide/observability/maintainer-workflow.md deleted file mode 100644 index 95e837f7..00000000 --- a/docs/guide/observability/maintainer-workflow.md +++ /dev/null @@ -1,120 +0,0 @@ -# Developing CodeClone with Platform Observability - - - -Platform Observability is **maintainer tooling only**. It helps people who -**build and debug CodeClone itself** — not users who run CodeClone against their -own Python projects. - -If you want structural review, clones, health score, or CI gates for **your** -repository, use the normal CLI/MCP workflow ([MCP guide](../mcp/README.md), -[Production triage](../mcp/workflows/analyze-and-triage.md)). Observer data will -not answer those questions and must never be treated as repository quality -evidence. - -Normative contract: [Platform Observability](../../book/26-platform-observability.md). - -## Audience boundary - -| You are… | Use observer? | -|--------------------------------------------------------------|-------------------------------------------------| -| CodeClone contributor debugging MCP/CLI/memory/observer code | **Yes** (after explicit enable) | -| Application team using CodeClone on their repo | **No** | -| Agent reviewing user Python for clones/metrics | **No** — use review / hotspots / change control | - -Anti-inference: high `db_cost` means CodeClone executed SQL during its work, not -that the analyzed project has a database defect. High MCP payload sizes reflect -CodeClone's tool traffic, not low code quality in the target repo. - -## Explicit enable (required) - -Observation is **off by default**. No pyproject toggle — environment variables -only. - -```bash -export CODECLONE_OBSERVABILITY_ENABLED=1 -``` - -Every process that should emit telemetry must start **with this variable set**: - -- terminal `codeclone …` runs; -- `codeclone-mcp` (restart the MCP server after exporting); -- background projection workers spawned during memory rebuild. - -Optional: - -| Variable | Effect | -|-------------------------------------|--------------------------------------------------| -| `CODECLONE_OBSERVABILITY_PROFILE=1` | Process metrics (`codeclone[perf]`) | -| `CODECLONE_OBSERVABILITY_PERSIST=0` | Instrument without writing completed ops | -| `CODECLONE_OBSERVABILITY_FORCE=1` | CI override only — **does not** enable by itself | - -Until a reproducer runs under `CODECLONE_OBSERVABILITY_ENABLED=1`, there is no -store. MCP `query_platform_observability` returns `status=disabled` or -`status=no_store` — inert, not an error. - -Store path: `.codeclone/db/platform_observability.sqlite3` - -## Maintainer workflow - -```mermaid -flowchart TD - A["Export CODECLONE_OBSERVABILITY_ENABLED=1"] --> B["Restart MCP / CLI / worker"] - B --> C["Reproduce the slow or costly path"] - C --> D{"Agent or human?"} - D -->|Agent| E["query_platform_observability summary → drill sections"] - D -->|Human| F["codeclone observability trace --html …"] - E --> G["Change codeclone/observability or instrumentation"] - F --> G - G --> H["pytest tests/test_observability_*.py"] -``` - -### 1. Reproduce under observation - -Example — exercise MCP after enabling observer on the server process: - -```bash -export CODECLONE_OBSERVABILITY_ENABLED=1 -codeclone-mcp --transport stdio # or your IDE launcher with env inherited -``` - -Then run the MCP workflow you are debugging (analysis, memory rebuild, finish, -etc.). - -### 2. Agent path (bounded MCP) - -See [MCP observability recipes](../mcp/workflows/observability-recipes.md). - -Skill: `/codeclone-platform-observability` (bundled in CodeClone plugins). - -### 3. Human path (full cockpit) - -```bash -codeclone observability trace --root . --last 50 --html /tmp/codeclone-observer.html -``` - -Self-contained HTML: operation chains, span waterfall, MCP matrix, DB -fingerprints, memory pipeline costs. No external assets. - -### 4. Verify instrumentation changes - -```bash -uv run pytest -q tests/test_observability_*.py -``` - -Also run MCP registrar tests when touching server wiring: -`tests/test_observability_mcp_registrar.py`. - -## What observer never does - -- Does not affect reports, gates, baselines, cache, or finding identity -- Does not authorize edits or expand change-control scope -- Does not store raw MCP/prompt bodies or SQL literals -- Does not send data to a remote telemetry service - -## Related - -- [Diagnostics quick start](diagnostics.md) -- [MCP observability recipes](../mcp/workflows/observability-recipes.md) -- [MCP tool contract](../../book/25-mcp-interface/tools/platform-observability.md) -- [CONTRIBUTING.md — Platform Observability](https://github.com/orenlab/codeclone/blob/main/CONTRIBUTING.md) diff --git a/docs/guides/agent-safe-change.md b/docs/guides/agent-safe-change.md new file mode 100644 index 00000000..1bc2e5ad --- /dev/null +++ b/docs/guides/agent-safe-change.md @@ -0,0 +1,84 @@ +--- +title: "Agent-safe change workflow" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Agent-safe change workflow + +## What it is + +The agent-safe change workflow is CodeClone's MCP-based protocol for making edits to a Python repository while maintaining deterministic control, blast radius bounds, and auditable evidence trails. It combines pre-edit intent declaration, scope enforcement, structural verification, and post-edit receipt generation into a single coordinated cycle. + +When an agent needs to modify code, CodeClone: +1. Captures workspace state before editing +2. Declares scope and intent upfront +3. Computes blast radius and patch budget +4. Verifies scope adherence after editing +5. Generates an auditable receipt + +## When to use it + +Use the agent-safe workflow whenever: + +- An agent edits Python source files (`*.py` or `*.pyi`) +- Configuration files change (governance, CI, `pyproject.toml`) +- Scope must be bounded to prevent unintended side effects +- An auditable trail of what changed and why is needed +- Multiple agents coordinate changes to the same repository + +The workflow is **required** for any tracked file edit in CodeClone-managed repositories, regardless of patch type or task classification. + +## Basic workflow + +```mermaid +graph TD + A["analyze_repository
Establish baseline"] --> B["start_controlled_change
Declare scope & intent"] + B -->|edit_allowed=true| C["Edit within scope"] + B -->|status=queued| D["Wait for other intent"] + D --> E["manage_change_intent
Promote when ready"] + E --> C + C --> F["analyze_repository
Verify after edit"] + F --> G["finish_controlled_change
Verify & receipt"] + G -->|status=accepted| H["Intent cleared
Patch complete"] + G -->|status=unverified| I["Re-run analyze
with new run_id"] + I --> G + G -->|status=violated| J["Remove out-of-scope
or expand scope"] + J --> B +``` + +## Key commands + +| Command | Purpose | When used | +|---------|---------|-----------| +| `analyze_repository` | Run CodeClone analysis; establishes baseline | Before `start`, after editing (Python only) | +| `start_controlled_change` | Declare scope, compute blast radius | Before any file edit | +| `get_relevant_memory` | Retrieve prior decisions & incidents | After `start` returns `edit_allowed=true` | +| `finish_controlled_change` | Verify scope, generate receipt, clear intent | After editing; pass `after_run_id` for Python changes | +| `manage_change_intent` | Queue/promote/recover intents | When another agent's intent blocks yours | + +## Common mistakes + +**Starting without analysis** +`start_controlled_change` requires an existing analysis run. Call `analyze_repository` first. + +**Expanding scope silently** +If your fix requires files outside the declared scope, stop. Call `start_controlled_change` again with expanded scope before editing new files. + +**Abandoning an active intent** +Leaving an intent active blocks the next agent. Always call `finish_controlled_change` before exiting, even if the edit failed. + +**Ignoring stale memory** +`get_relevant_memory` returns prior decisions and known incidents. Do not skip this step or treat cached decisions as obsolete without reading the contradiction notes. + +**Mixing concurrent intents** +One active intent per MCP session. Calling `start_controlled_change` again evicts the prior intent with no recovery—`finish` will fail with "Unknown change intent id." + +## Next steps + +- Read [Controlled change](../concepts/controlled-change.md) and the [MCP tools reference](../reference/mcp-tools.md) for detailed tool semantics and response shapes +- Review [Engineering Memory](../concepts/engineering-memory.md) to understand decision tracking and stale-note handling +- For multi-agent coordination, consult the queue/promote workflow described in [Controlled change](../concepts/controlled-change.md) +- Use `help(topic="change_control")` within the MCP client for immediate workflow guidance diff --git a/docs/guides/ci-integration.md b/docs/guides/ci-integration.md new file mode 100644 index 00000000..c494f40f --- /dev/null +++ b/docs/guides/ci-integration.md @@ -0,0 +1,68 @@ +--- +title: "CI integration" +audience: public +doc_type: guide +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +# CI integration + +## What it is + +CodeClone provides a CI mode for deterministic code quality gating in pull requests and continuous integration workflows. The `--ci` preset configures CodeClone to detect code clones, structural regressions, and quality violations, then exit with status code 3 if checks fail, allowing you to block merges until issues are resolved. + +## When to use it + +Use CodeClone in CI when you want to: + +- Prevent new code clones from entering your codebase +- Detect cyclic dependencies, high complexity, or poor cohesion in changed code +- Track metrics regressions (typing coverage, docstrings, dead code) against a baseline +- Gate merges on quality thresholds before human review + +CI mode is designed for pull request pipelines and automated merge gates. Run a baseline first on your main branch, then compare each PR against that baseline. + +## Basic workflow + +```mermaid +graph LR + A["Run baseline on main"] --> B["Set codeclone.baseline.json"] + B --> C["PR submitted"] + C --> D["Run: codeclone --ci --changed-only"] + D --> E{New clones
or regressions?} + E -->|No| F["✓ Check passes"] + E -->|Yes| G["✗ Check fails
exit code 3"] + G --> H["Review findings
in PR comment"] + H --> I["Push fix or dismiss"] +``` + +## Key commands + +| Task | Command | Notes | +|------|---------|-------| +| Set baseline | `codeclone --update-baseline` | Run on main branch once to establish truth | +| Check PR | `codeclone --ci --changed-only` | Compares changed files against baseline | +| Check all files | `codeclone --ci` | Full analysis; slower but comprehensive | +| Update metrics | `codeclone --update-metrics-baseline` | Tracks complexity, typing, docstrings | +| Show failures | `codeclone --ci --verbose` | Lists clone IDs and file locations | +| Skip dead code | `codeclone --ci --skip-dead-code` | Omits high-confidence dead-code checks | + +## Common mistakes + +**Running without a baseline:** CodeClone needs a baseline file to measure regressions. Commit `codeclone.baseline.json` to your repository after running `--update-baseline` on main. + +**Not using `--changed-only`:** Analyzing the entire repo on every PR is slow. Use `--paths-from-git-diff main` (shorthand for `--changed-only --diff-against main`) to limit scope to changed files. + +**Ignoring metrics baselines:** Complexity and typing metrics drift over time. Use `--update-metrics-baseline` to calibrate, then `--fail-on-new-metrics` to catch regressions. + +**Misconfiguring gates:** Default thresholds are conservative. Tune `--fail-complexity`, `--fail-coupling`, and `--fail-health` to match your team standards, but document the choices so other developers understand the intent. + +**Using `--ci` without gating:** The preset disables colors and progress output for log parsers. Pair it with explicit exit-code checks in your CI provider (e.g., `if [ $? -eq 3 ] then fail`). + +## Next steps + +- Run `codeclone --help` to see all options for quality gates and reporting formats +- Set up a GitHub Actions or GitLab CI workflow to run CodeClone on every PR +- Store baseline files in version control so all team members use the same reference +- Use HTML reports (`--html`) for reviewers to inspect findings inline diff --git a/docs/guides/development.md b/docs/guides/development.md new file mode 100644 index 00000000..a5e7b2c1 --- /dev/null +++ b/docs/guides/development.md @@ -0,0 +1,76 @@ +--- +title: "Development guide" +audience: public +doc_type: guide +status: draft +source_commit: "582228177b2f57d9b9823ff1da9b6a0620f1297f" +--- + +# Development guide + +## What it is + +CodeClone's development workflow combines structural analysis of Python code with a change-control layer that guards repository edits. The core `codeclone` package provides CLI tools and structural metrics. An optional MCP surface adds AI-assisted workflow support through controlled edit cycles. + +## When to use it + +Use this guide if you are: +- Contributing code or documentation to CodeClone +- Setting up a development environment to work on the codebase +- Learning how CodeClone's structural contracts work in practice +- Preparing changes for review or release + +## Basic workflow + +```mermaid +graph LR + A["Analyze
(baseline run)"] --> B["Plan change
(scope & intent)"] + B --> C["Edit code
(within scope)"] + C --> D["Re-analyze
(Python structural)"] + D --> E["Validate & finish
(scope check)"] + E --> F["Commit & verify"] + + style A fill:#f9f,stroke:#333 + style B fill:#bbf,stroke:#333 + style C fill:#fbb,stroke:#333 + style D fill:#f9f,stroke:#333 + style E fill:#bfb,stroke:#333 + style F fill:#fbf,stroke:#333 +``` + +The workflow ensures your changes respect structural boundaries and contract versions. For documentation-only changes, the cycle is lighter. For Python code changes, full structural verification is required. + +## Key commands + +| Task | Command | Notes | +|------|---------|-------| +| Install (base) | `uv sync` | Base package without MCP support | +| Install (with MCP) | `uv sync --all-extras` | Includes optional MCP surface | +| Analyze repository | `codeclone .` | Baseline structural run | +| Run tests | `uv run pytest -q` | Full test suite | +| Run pre-commit | `uv run pre-commit run --all-files` | Lint and format checks | +| View reports | Open `.codeclone/report.html` | Visual metrics and findings | + +The MCP surface (when installed) provides additional workflow commands through your IDE or agent interface. It is optional; the base `codeclone` CLI works independently. + +## Common mistakes + +| Mistake | Impact | Prevention | +|---------|--------|-----------| +| Skipping analysis before editing | Undetected structural drift | Always run baseline analysis first | +| Editing files outside declared scope | Scope violation on finish | Declare full scope up front; expand only with approval | +| Using stale structural data | Missed regressions | Re-analyze after code changes before finishing | +| Mixing edit paths for Python files | Incomplete verification | Use workflow tools (not atomic) for Python structural changes | +| Hardcoding tool/contract counts | Version lockstep failures | Derive from source of truth (test fixtures, contract imports) | + +The MCP surface fails closed: if not installed, edit-cycle tools are unavailable (the base CLI continues working). Do not assume MCP tools are present. + +## Next steps + +For detailed contribution requirements, validation commands, code style, and commit conventions, see the [normative contribution guide](https://github.com/orenlab/codeclone/blob/main/CONTRIBUTING.md) at the repository root. That guide covers: +- Full validation and test commands +- Commit message format and scopes +- Code style expectations +- Pre-commit hook requirements + +Start with `uv sync`, review the current structural metrics with `codeclone .`, then work through your changes using the workflow above. diff --git a/docs/guides/engineering-memory-workflow.md b/docs/guides/engineering-memory-workflow.md new file mode 100644 index 00000000..3b89aa28 --- /dev/null +++ b/docs/guides/engineering-memory-workflow.md @@ -0,0 +1,57 @@ +--- +title: "Engineering Memory workflow" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +Engineering Memory is a persistent, evidence-linked record of repository facts: decisions, rationales, risks, and lessons learned from changes. It lives in a local SQLite database and uses semantic indexing and temporal tracking to answer "what did we learn?" and "what must the next agent know?" + +Unlike chat history (ephemeral, context-limited), Engineering Memory survives session boundaries and powers retrieval across edits, branches, and agents. + +## When to use it + +Write to Engineering Memory when your edit involves: + +- **Incident or workaround**: verification surprise, scope violation recovery, blocked step, foreign intent friction +- **Non-obvious complexity**: multi-file debug, near boundary code, stale memory acted upon +- **Decision or tradeoff**: design choice, integration quirk, "next agent must not repeat X" + +Skip for trivial changes (typo, one obvious line). + +## Basic workflow + +```mermaid +graph LR + A["start_controlled_change
edit_allowed=true"] --> B["get_relevant_memory
Read context"] + B --> C["Edit files
within scope"] + C --> D["record_candidate
Write durable note"] + D --> E["finish_controlled_change
Provide after-run"] + E --> F["Accept + intent clears"] +``` + +## Key commands + +| Action | Command | Notes | +|--------|---------|-------| +| Retrieve context | `get_relevant_memory(root=..., scope=... \| intent_id=...)` | After edit_allowed=true. Read corroboration_status. | +| Write a note | `manage_engineering_memory(action=record_candidate, record_type=risk_note \| change_rationale, statement=..., subject_path=...)` | Before finish. Max 1000 chars. Target ≤300. | +| Search memory | `query_engineering_memory(mode=for_path \| search \| stale \| trajectory_*)` | Read-only inspection; use get_relevant_memory for edits. | +| Approve drafts | VS Code Memory view (human only) | Agents cannot approve via MCP; use UI. | + +## Common mistakes + +- **Treating chat as memory**: Text summaries evaporate at context shrink. Use MCP to persist. +- **Ignoring corroboration_status**: "path_only" = background framing only, not fresh confirmation. "unverified" = do not assert as current behavior. +- **Expanding scope silently**: If the fix touches files outside declared scope, stop and ask for approval before editing. +- **Forgetting record_candidate before finish**: MUST NOT finish non-trivial cycles without memory write. Incident, complexity, or decision triggers apply → record first. +- **Stale memory as gospel**: contradiction_note alerts mean context changed. Re-verify stale records before acting. + +## Next steps + +- Read the Engineering Memory concepts guide: `docs/concepts/engineering-memory.md` +- Review tests: `tests/test_cli_memory_*.py` for CLI workflow, `tests/test_mcp_memory_*.py` for MCP integration +- For detailed mode reference: `query_engineering_memory(mode=...)` help in codeclone CLI diff --git a/docs/guides/first-analysis.md b/docs/guides/first-analysis.md new file mode 100644 index 00000000..18acf42d --- /dev/null +++ b/docs/guides/first-analysis.md @@ -0,0 +1,77 @@ +--- +title: "Run the first analysis" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Run the first analysis + +## What it is + +CodeClone's **analysis** scans your Python repository for code structure patterns: duplicated logic (clones), complexity metrics, dependency cycles, and dead code. Each run produces structured reports you can review on the command line, in HTML, or via JSON, plus an incremental cache (`.codeclone/cache.json`) that speeds up re-runs. The *baseline* (`codeclone.baseline.json`) is a separate, explicitly created snapshot — see below — not the same thing as the cache. + +## When to use it + +Run an analysis when you: +- Start using CodeClone in a new repository (establish a baseline) +- Review changes in a pull request (verify no new structural issues) +- Set up CI gating on code quality metrics +- Export findings for integration with other tools + +## Basic workflow + +```mermaid +graph LR + A["Run analysis
codeclone ."] --> B["Review terminal
output"] + B --> C["Create baseline
codeclone . --update-baseline"] + C --> D["Use in CI or
patch-verify"] +``` + +Start with a basic analysis pointing to your repository root: + +```bash +codeclone . +``` + +This scans all Python files, detects clones and metrics, and prints a summary to the terminal. On first run, no baseline exists yet—findings are reported without baseline comparison. + +To save results for future comparisons, create a baseline: + +```bash +codeclone . --update-baseline +``` + +The baseline is stored in `codeclone.baseline.json` at the repository root and used to identify new findings in subsequent runs. + +## Key commands + +| Command | Purpose | +|---------|---------| +| `codeclone .` | Run full analysis in current directory | +| `codeclone . --json [FILE]` | Export results as JSON (default: `.codeclone/report.json`) | +| `codeclone . --html [FILE]` | Generate interactive HTML report (default: `.codeclone/report.html`) | +| `codeclone . --update-baseline` | Create or update `codeclone.baseline.json` | +| `codeclone . --patch-verify` | Verify current working tree against baseline (for PR review) | +| `codeclone . --processes N` | Run analysis with N parallel workers (default: 4) | + +For more options, see `codeclone --help`. + +## Common mistakes + +**Mistake 1: Running analysis in CI without a committed baseline** +If you push code without committing `codeclone.baseline.json`, CI cannot detect new findings. Always commit the baseline file to git. + +**Mistake 2: Ignoring threshold warnings in the terminal** +CodeClone prints warnings for complexity, coupling, and clone counts that exceed defaults. Set explicit thresholds with `--fail-threshold`, `--fail-complexity`, etc., to gate CI. + +**Mistake 3: Treating the first analysis as complete quality assessment** +Initial findings reflect the current state, not regression. After creating a baseline, new findings indicate changes. Establish your baseline before declaring quality targets. + +## Next steps + +- **Integrate into CI:** Use `codeclone . --ci --patch-verify` to gate pull requests on new findings. +- **Review the HTML report:** Run `codeclone . --html --open-html-report` to inspect findings interactively. +- **Set quality gates:** Configure fail conditions with `--fail-complexity`, `--fail-cycles`, `--fail-dead-code` for your project's standards. +- **Enable Engineering Memory:** Use `codeclone memory init` to track structural decisions and incident notes alongside code changes. diff --git a/docs/guides/review-a-change.md b/docs/guides/review-a-change.md new file mode 100644 index 00000000..7fd51353 --- /dev/null +++ b/docs/guides/review-a-change.md @@ -0,0 +1,67 @@ +--- +title: "Review a change" +audience: public +doc_type: guide +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +A review is a bounded, auditable assessment of a code change: scope, risk, structural health, and decision. CodeClone captures the full forensic trail—from intent declaration through verification—as immutable audit events. Review receipts document what was reviewed, what found, and what was decided. + +## When to use it + +- Before merging a pull request, validate that changes stay within scope and meet structural health gates +- Document why a change was approved despite warnings or findings +- Retrieve exact evidence of what was reviewed (blast radius, patch health, coverage gaps) +- Prove that a decision was made with deterministic structural evidence, not gut feel + +## Basic workflow + +```mermaid +graph LR + A["start_controlled_change
(declare scope)"] --> B["review:
blast radius
budget
health delta"] + B --> C["finish_controlled_change
(verify & approve)"] + C --> D["create_review_receipt
(audit trail)"] + D --> E["get_review_receipt
(retrieve stored proof)"] +``` + +The workflow is: + +1. **Declare intent**: run `start_controlled_change` with scope (files, packages) and intent description +2. **Inspect risk**: fetch blast radius, budget burn, and patch health +3. **Verify & approve**: run `finish_controlled_change` with evidence (after-run ID for Python structural changes) +4. **Create receipt**: generate audit-backed markdown or JSON proof of the review decision +5. **Retrieve later**: fetch stored receipts from the audit trail by run ID or digest + +## Key commands + +| Task | Tool | Notes | +|------|------|-------| +| Declare what you're changing | `start_controlled_change` | Returns intent_id, budget, blast radius. Requires prior analysis run. | +| Check scope & health impact | `get_blast_radius` | Summarizes direct dependents, coverage gaps, do-not-touch paths. | +| Verify after edit | `finish_controlled_change` | Runs structural checks for Python patches. Returns status (accepted/violated/unverified). | +| Generate proof | `create_review_receipt` | Produces markdown or JSON receipt: scope, findings, decision, timestamp. | +| Retrieve review trail | `get_review_receipt` | Fetch stored receipt from audit trail by run or digest. | +| Validate review text | `validate_review_claims` | Checks that your review claim matches report semantics (e.g., "no new regressions" requires before/after evidence). | +| Inspect patch trail | `get_patch_trail` | Full forensic log: declared files, changed files, untouched files, verification details. | + +## Common mistakes + +**Forgetting to declare scope.** Calling `finish_controlled_change` without a prior `start_controlled_change` returns "Unknown change intent id." Always start first. + +**Changing files outside declared scope.** If you edit a file not listed in scope, `finish_controlled_change` returns a scope violation. Either expand scope via a new `start` call or revert the out-of-scope changes. + +**Skipping the after-run for Python changes.** If you modify any `.py` or `.pyi` file, `finish_controlled_change` requires `after_run_id`. Run `analyze_repository` after edits and pass the run ID. + +**Starting a second intent without finishing the first.** MCP sessions track only one active intent. Starting again evicts the previous one with no recovery. Always call `finish` or `manage_change_intent(action="clear")` before starting a new change. + +**Claiming "no regressions" without evidence.** `validate_review_claims` will detect if you claim no patch-local regression but your before/after runs show a negative health delta. State only what the evidence supports. + +## Next steps + +- Read the change control workflow in CLAUDE.md for the full protocol, including recovery and memory write rules +- Use `get_implementation_context` to bound the scope before declaring intent +- Review `validate_review_claims` to understand what claims require structural evidence +- Store incident or complexity notes via Engineering Memory before finishing (mandatory when the edit cycle involved incident, surprise, multi-file debug, or non-obvious root cause) diff --git a/docs/guides/setup-project.md b/docs/guides/setup-project.md new file mode 100644 index 00000000..12b81dd0 --- /dev/null +++ b/docs/guides/setup-project.md @@ -0,0 +1,73 @@ +--- +title: "Set up a project" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +`codeclone setup apply` initializes CodeClone governance configuration. When you confirm, it writes exactly two files: + +- Merges the `[tool.codeclone]` section into your `pyproject.toml` +- Appends CodeClone cache/state paths to `.gitignore` + +It does **not** create a baseline snapshot, audit database, or intent registry — those artifacts are produced later, on demand, by analysis (`codeclone . --update-baseline`) and controlled-change runs. `setup status`, `setup doctor`, and `setup plan` are read-only; only `setup apply` writes, and only after explicit confirmation. + +## When to use it + +Run setup when: + +- Initializing CodeClone in a new project +- Re-establishing governance after a reset +- Applying a reviewed plan to the filesystem + +Do **not** run setup if another engineer is actively using CodeClone on the same project (governance intent conflicts will block execution). + +## Basic workflow + +```mermaid +sequenceDiagram + actor User + User->>setup plan: codeclone setup plan + User->>preview: Review output + User->>setup apply: codeclone setup apply --yes --plan-id + setup apply->>filesystem: Merge [tool.codeclone] into pyproject.toml + append .gitignore + setup apply-->>User: status=accepted +``` + +## Key commands + +| Command | Purpose | Flags | +|---------|---------|-------| +| `codeclone setup plan` | Preview all proposed changes | (none required) | +| `codeclone setup apply --dry-run` | Simulate execution without writing | `--dry-run` | +| `codeclone setup apply --yes` | Execute interactively or confirm with yes | `--yes` (required for CI) | +| `codeclone setup apply --plan-id ` | Bind apply to a previewed plan; fail if plan is stale | `--plan-id ` | + +### Safety guarantees + +- **Confirmation-required**: interactive apply halts until you type `yes` or `no` +- **Plan binding**: `--plan-id` verifies plan hasn't changed; mismatch exits `2` (`status=stale_plan`) +- **Dry-run isolation**: `--dry-run` shows what would happen without touching files + +## Common mistakes + +**Mistake:** Running `setup apply --yes` on a stale plan from earlier in the session +**Fix:** Re-run `codeclone setup plan` and use the new `--plan-id` + +**Mistake:** Running setup while another developer holds an active governance intent +**Fix:** Wait for their intent to clear, or coordinate scope separately + +**Mistake:** Assuming readiness status is final before apply +**Fix:** Readiness is derived fresh during apply; edge-case config issues may appear then + +## Next steps + +After `setup apply` succeeds: + +1. Review and commit the updated `pyproject.toml` and `.gitignore` +2. Generate your baseline: `codeclone . --update-baseline`, then commit `codeclone.baseline.json` +3. Run `codeclone .` to confirm the baseline is correct +4. Begin using controlled-change workflows for code modifications diff --git a/docs/index.md b/docs/index.md index 316e41e6..327c2025 100644 --- a/docs/index.md +++ b/docs/index.md @@ -1,93 +1,85 @@ - - -# CodeClone Docs - -> Structural Change Controller for AI-assisted Python development — -> deterministic, baseline-aware, built for CI and AI agents. - -CodeClone runs one deterministic analysis pipeline and emits a canonical JSON -report. Every surface — CLI, HTML, MCP, IDE — is a projection of that report. -Humans and AI agents operate on the same structural facts. +--- +title: "CodeClone documentation" +audience: public +doc_type: landing +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- -The v2.1 change controller starts before the first edit: an agent declares what -it intends to change, CodeClone maps the structural blast radius, verifies the -patch against the declared boundary, and generates an auditable review receipt. +# CodeClone -!!! note "Documentation for the in-development v2.1 line" - This site tracks the unreleased **v2.1** line; for the current stable release see [CodeClone v2.0.2](https://github.com/orenlab/codeclone/tree/v2.0.2). +## What CodeClone is -## New here? Follow the path +CodeClone is a deterministic structural controller for Python codebases. It provides change-control governance, code health analysis, and integration with AI-assisted development workflows. CodeClone enforces intent-driven edits through a workspace-aware protocol that tracks patches, verifies scope boundaries, and maintains audit trails. -1. [**Install & first run**](getting-started.md) — install, analyze a repo, read the report. -2. [**Connect your agent**](getting-started.md#mcp-setup) — wire CodeClone into your IDE or agent. -3. [**Your first governed edit**](start/first-governed-edit.md) — declare → edit → verify, end to end. +## Core workflow -!!! tip "Two tabs — pick one mental model" - **Guide** — install, run, MCP workflows, IDE setup, recipes. - Start at the [Guide hub](guide/README.md). +CodeClone operates through a declared-intent model: - **Contracts** — normative guarantees, schemas, enums, payload semantics. - Start at the [Contracts book](book/README.md). +1. **Declare intent** — articulate the scope and purpose of your change +2. **Analyze** — run structural analysis to establish baseline metrics and dependencies +3. **Edit** — make changes within the declared scope with real-time workspace awareness +4. **Verify** — re-analyze and validate that changes stay within scope and meet quality gates +5. **Accept** — finish the intent when verification passes, clearing the workspace for the next change -!!! note "Licensing" - Source code: MPL-2.0. Documentation and docs-site content: MIT. +This workflow prevents silent scope creep, catches architectural violations early, and maintains a durable audit trail of who changed what and why. ---- +## Where to start -## Getting Started +CodeClone integrates into your development environment through MCP (Model Context Protocol) and works with Claude Code, Claude Desktop, Cursor, Codex, and VS Code: -| Goal | Start here | -|-----------------------|---------------------------------------------------| -| First install and run | [Getting started](getting-started.md) | -| Understand the model | [How it works](guide/explanation/how-it-works.md) | -| Terminology lookup | [Terminology](book/01-terminology.md) | +- Start with the change-control workflow when making edits to Python code or governance config +- Use analysis commands to understand code health, architecture coupling, and test coverage +- Reference the Engineering Memory store to review past decisions and avoid repeating mistakes -## CI and Gating +New here? Jump to: -| Goal | Start here | -|-------------------------------|-----------------------------------------------------------| -| Baseline-aware CI | [Getting started: CI setup](getting-started.md#ci-setup) | -| Exit codes and failure policy | [Exit codes](book/09-exit-codes.md) | -| Quality gates and metrics | [Metrics and gates](book/16-metrics-and-quality-gates.md) | -| Baseline contract | [Baseline](book/07-baseline.md) | +- [Getting started](getting-started.md) — install and run your first analysis +- [Configuration reference](reference/configuration.md) — every `[tool.codeclone]` key and default +- [CLI reference](reference/cli.md) and [Exit codes](reference/exit-codes.md) +- [MCP tools reference](reference/mcp-tools.md) — the 38 MCP tools +- [Controlled change](concepts/controlled-change.md) — the intent-first edit workflow -## AI Agent Governance +## Key concepts -| Goal | Start here | -|-------------------------------------|-------------------------------------------------------------------------------| -| MCP usage (workflows, setup) | [MCP guide](guide/mcp/README.md) | -| First governed edit (tutorial) | [Your first governed edit](start/first-governed-edit.md) | -| Change controller workflow | [Structural Change Controller](book/12-structural-change-controller/index.md) | -| Engineering Memory (scope context) | [Engineering Memory](book/13-engineering-memory/index.md) | -| Trajectories and recurring patterns | [Trajectories and Experiences](guide/memory/trajectories-and-experiences.md) | -| MCP interface contract | [MCP interface](book/25-mcp-interface/index.md) | +| Concept | Definition | +|---------|-----------| +| **Intent** | A declared change operation with defined scope, purpose, and workspace state | +| **Scope** | The set of files and code regions the intent is permitted to modify | +| **Verification** | Structural analysis that checks scope boundaries, quality metrics, and test coverage | +| **Baseline** | A stored snapshot of metrics and artifacts from a previous analysis run | +| **Engineering Memory** | A durable local store of evidence-linked facts, decisions, and past findings | +| **Patch Contract** | The verification model that determines which structural checks apply to a change | +| **Health Score** | A composite metric combining complexity, coupling, cohesion, coverage, and clones | -## IDE and Agent Clients +## Integrations -| Surface | Guide (how to) | Contract (guarantees) | -|-----------------------|---------------------------------------------------------------------|-----------------------------------------------------------------------| -| VS Code extension | [Setup](guide/integrations/vscode/setup.md) | [VS Code contract](book/integrations/vs-code-extension.md) | -| Cursor plugin | [Install & skills](guide/integrations/cursor/install-and-skills.md) | [Cursor contract](book/integrations/cursor-plugin.md) | -| Claude Code plugin | [Install](guide/integrations/claude-code/setup.md) | [Claude Code contract](book/integrations/claude-code-plugin.md) | -| Codex plugin | [Install](guide/integrations/codex/setup.md) | [Codex contract](book/integrations/codex-plugin.md) | -| Claude Desktop bundle | [Setup](guide/integrations/claude-desktop/setup.md) | [Claude Desktop contract](book/integrations/claude-desktop-bundle.md) | -| SARIF & code scanning | [Export](guide/integrations/sarif/export.md) | [SARIF contract](book/integrations/sarif.md) | +CodeClone integrates with: -## Reports +- **Claude Code** and **Claude Desktop** — via MCP for intent-driven edits and governance +- **VS Code** — Memory view for approving and managing engineering memory records +- **Git** — workspace and git state awareness for intent tracking and audit trails +- **Python testing** — coverage and test integration for health metric computation +- **SARIF, JSON, Markdown, HTML** — multiple report formats for analysis results -| Goal | Start here | -|-------------------------|---------------------------------------| -| Report model and schema | [Report contract](book/05-report.md) | -| HTML rendering | [HTML render](book/06-html-render.md) | -| Live sample | [Sample report](examples/report.md) | +## Reference -## Maintainers & internals +- **Repository**: https://github.com/orenlab/codeclone +- **Issues**: https://github.com/orenlab/codeclone/issues +- **Documentation**: https://orenlab.github.io/codeclone/ +- **Version**: 2.1.0a1 -Operating or building CodeClone itself? See [Platform Observability](guide/observability/diagnostics.md) -and [Corpus Analytics](guide/analytics/overview.md) under the **Maintainers** tab. +For full architecture, contract specifications, and the agent playbook, see `AGENTS.md` in the repository. -**Editions & plans** — CodeClone is open source and runs locally; Team and Enterprise add scaled retention, managed options, and support. Pick the level that fits your needs: [Plans and retention](plans-and-retention.md). +```mermaid +graph LR + A["Declare Intent"] --> B["Analyze"] + B --> C["Edit"] + C --> D["Verify"] + D -->|Pass| E["Accept"] + D -->|Fail| F["Refine"] + F --> C + E --> G["Audit Trail"] + H["Engineering Memory"] -.-> C + H -.-> D +``` diff --git a/docs/integrations/claude.md b/docs/integrations/claude.md new file mode 100644 index 00000000..d70bb6f5 --- /dev/null +++ b/docs/integrations/claude.md @@ -0,0 +1,119 @@ +--- +title: "Claude integration" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone integrates with Claude (via MCP) to provide deterministic change control and structural governance during AI-assisted development. The MCP server exposes 38 tools spanning analysis, inspection, triage, change control, engineering memory, and audit/receipts. See the [MCP tools reference](../reference/mcp-tools.md) for the full catalog. + +When Claude edits your code, CodeClone's MCP tools track changes against a declared scope, compute impact zones (blast radius), and verify that edits conform to your repository's structural contracts. + +## Install + +CodeClone ships a **Claude Code plugin** that wires up the MCP server and bundles change-control skills. + +1. Install the CodeClone MCP runtime: + + ```bash + uv tool install --prerelease allow "codeclone[mcp]" + ``` + +2. Add the marketplace and install the plugin from Claude Code: + + ```text + /plugin marketplace add orenlab/codeclone-claude-code + /plugin install codeclone + ``` + +The plugin launches the server with `python3 ${CLAUDE_PLUGIN_ROOT}/scripts/launch_mcp.py` (transport `stdio`). No manual `.mcp.json` editing is required. + +## Bundled skills + +The plugin registers ten skills that route to the right CodeClone workflow: + +| Skill | Purpose | +|-------|---------| +| `codeclone-change-control` | Mandatory edit gate: `start`/`finish` controlled change | +| `codeclone-review` | Structural review of a repository or changed files | +| `codeclone-production-triage` | Fast production-first triage and next action | +| `codeclone-architecture-triage` | Rank demonstrated architectural problems | +| `codeclone-blast-radius` | Inspect dependents and risk before editing | +| `codeclone-implementation-context` | Bound the implementation frontier before broad search | +| `codeclone-hotspots` | Quick health / top-risk snapshot | +| `codeclone-engineering-memory` | Retrieve and preserve evidence-linked memory | +| `codeclone-setup` | Probe and configure repository readiness | +| `codeclone-platform-observability` | Maintainer-only runtime diagnostics | + +## When to use it + +- **Before editing code**: Call `start_controlled_change` to declare intent, get blast-radius visibility, and reserve workspace state. +- **After editing code**: Call `finish_controlled_change` to verify scope conformance, structural integrity, and generate an audit receipt. +- **During analysis**: Use `analyze_repository` to capture a baseline, then `analyze_changed_paths` for PR-focused review. +- **For triage**: Call `get_production_triage` first on noisy repositories; drill down with `get_implementation_context` for bounded structural facts. + +## Basic workflow + +```mermaid +sequenceDiagram + participant Claude + participant MCP as MCP Server + participant Repo as Repository + + Claude->>MCP: analyze_repository(root) + MCP->>Repo: Scan, fingerprint, report + MCP-->>Claude: run_id + findings + + Claude->>MCP: start_controlled_change(root, scope) + MCP-->>Claude: intent_id + blast_radius + + Claude->>Repo: Edit within scope + + Claude->>MCP: finish_controlled_change(intent_id) + MCP->>Repo: Verify scope, compute patch trail + MCP-->>Claude: receipt + status +``` + +## Key commands + +| Task | MCP Tool | Notes | +|------|----------|-------| +| Run full analysis | `analyze_repository` | Pass absolute root; required before `start_controlled_change` | +| Declare edit intent | `start_controlled_change` | Returns `intent_id` and blast radius; gates edit permission | +| Get triage snapshot | `get_production_triage` | Health, cache freshness, hotspots—start here on complex repos | +| Fetch implementation context | `get_implementation_context` | Bounded structural facts: calls, dependencies, coverage, trajectories | +| Verify and finalize | `finish_controlled_change` | Hygiene check, scope reconciliation, receipt generation | +| Generate PR summary | `generate_pr_summary` | Compact Markdown summary for changed files | +| Retrieve audit trail | `get_patch_trail` | Full forensic evidence: declared/changed/untouched files, scope check | + +## Common mistakes + +1. **Starting without a prior analysis run** + - Error: `start_controlled_change` fails if no valid run exists. + - Fix: Call `analyze_repository` first. + +2. **One intent per session** + - An MCP session holds exactly one active change-control intent. Starting a new `start_controlled_change` before finishing the current one evicts the old one from tracking. + - Fix: Always finish before starting a new intent, or use separate MCP sessions. + +3. **Relative paths instead of absolute** + - MCP tools reject relative paths like `"."` for analysis. + - Fix: Use absolute paths (e.g., `/Users/you/project/`). + +4. **Scope expansion without declaration** + - Editing files outside your declared scope triggers scope violations at finish time. + - Fix: If the fix requires new files, expand scope via a fresh `start_controlled_change` before editing. + +5. **Ignoring stale memory warnings** + - Engineering Memory may contain outdated constraints from prior runs. + - Fix: Call `get_relevant_memory` after `start_controlled_change` and read `contradiction_note` alerts. + +## Next steps + +- Read the **MCP workflow** contract in `help(topic="overview")` for compact topic guidance. +- Use **change-control help** for detailed `start`/`finish` semantics, partial enforcement, and blast-artifact drill-down. +- Consult **Engineering Memory help** for evidence lanes, scope hygiene, and `get_memory_projection_page` continuation. +- Start with `get_production_triage` on your repository to understand its health and hotspots. diff --git a/docs/integrations/codex.md b/docs/integrations/codex.md new file mode 100644 index 00000000..97f9b685 --- /dev/null +++ b/docs/integrations/codex.md @@ -0,0 +1,169 @@ +--- +title: "Codex integration" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone integrates with **OpenAI Codex** (the Codex CLI/agent) via MCP (Model Context Protocol). It provides deterministic workflow tools—`start_controlled_change`, `finish_controlled_change`, and supporting utilities—that enable AI-assisted code changes with structural verification and scope governance. The Codex plugin launches the server with `python3 ./scripts/launch_mcp`. + +The integration works alongside CodeClone's baseline analysis (`analyze_repository`) to deliver: +- Pre-edit intent declaration with blast-radius budgets +- Post-edit scope verification and change validation +- Workspace coordination for concurrent agent work +- Auditable patch trails and receipts + +## When to use it + +Use Codex integration when: + +| Scenario | Solution | +|----------|----------| +| A Codex agent is editing your repository | Call `start_controlled_change` before edits; `finish_controlled_change` after | +| Multiple agents work on overlapping scopes | Use `manage_change_intent` to queue and promote intents | +| You need proof of scope boundaries | `get_blast_radius` and `get_patch_trail` provide forensic trails | +| Verifying patch health before merge | `check_patch_contract` validates structural and governance rules | +| Auditing what changed and why | `create_review_receipt` generates deterministic receipts | + +## Basic workflow + +```mermaid +graph TD + A[analyze_repository] --> B[start_controlled_change] + B --> C{edit_allowed?} + C -->|No - queued| D[manage_change_intent: promote] + C -->|No - needs_analysis| E[analyze_repository again] + C -->|Yes| F[Agent edits within scope] + D --> B + E --> B + F --> G[analyze_repository: after-run] + G --> H[finish_controlled_change] + H --> I{status: accepted?} + I -->|Yes| J[Patch verified] + I -->|No| K[Review next_step] + K --> L{unverified or violated?} + L -->|Yes| M[Remediate and retry finish] + M --> H +``` + +## Key commands + +### Pre-edit: Declare intent and check workspace + +```python +# Step 1: Ensure a baseline analysis exists +result = analyze_repository(root="/abs/path/to/repo") + +# Step 2: Declare change intent with scope +response = start_controlled_change( + root="/abs/path/to/repo", + scope={"allowed_files": ["src/module.py", "tests/"]}, + intent="Fix concurrency bug in worker queue" +) + +if response.status == "active" and response.edit_allowed: + # Proceed to editing + pass +elif response.status == "queued": + # Another agent is editing; wait for promotion + manage_change_intent( + action="promote", + intent_id=response.intent_id + ) +``` + +### During edit: Inspect blast radius and context + +```python +# Query structural dependents before editing (uses the latest run) +blast = get_blast_radius( + files=["src/module.py"] +) +# Review blast.do_not_touch and blast.direct_dependents + +# Get implementation context for precise code facts +context = get_implementation_context( + root="/abs/path/to/repo", + paths=["src/module.py"] +) +``` + +### Post-edit: Verify and finalize + +```python +# Step 1: Run structural analysis on the modified repo +after_run = analyze_repository(root="/abs/path/to/repo") + +# Step 2: Verify patch contract and scope +finish_response = finish_controlled_change( + intent_id="intent-", + changed_files=["src/module.py", "tests/test_module.py"], + after_run_id=after_run.run_id +) + +if finish_response.status == "accepted": + # Patch is verified and safe to commit + print("Patch accepted") +elif finish_response.status == "unverified": + # Follow next_step guidance + print(f"Next step: {finish_response.next_step}") +elif finish_response.status == "violated": + # Scope mismatch; expand or remove out-of-scope changes + print(f"Block reason: {finish_response.finish_block_reason}") +``` + +### Audit and receipts + +```python +# Generate a deterministic review receipt +receipt = create_review_receipt( + intent_id="intent-", + run_id="" +) + +# Fetch durably stored patch trail +trail = get_patch_trail(run_id="") +# Shows declared files, changed files, untouched files, scope check, verification +``` + +## Common mistakes + +### 1. Not running `analyze_repository` first + +`start_controlled_change` requires an existing analysis baseline. If you skip it, the response returns `status: "needs_analysis"`. + +**Fix:** Always run `analyze_repository(root="")` before calling `start_controlled_change`. + +### 2. Editing before `edit_allowed: true` + +If `start_controlled_change` returns `edit_allowed: false` (e.g., due to concurrent foreign intent), do not edit yet. + +**Fix:** Check `response.status`. If `"queued"`, wait. If `"blocked"`, narrow scope or coordinate. Only edit when `edit_allowed == true` AND `status == "active"`. + +### 3. Calling `start_controlled_change` twice in one session + +An MCP session tracks only one active intent. Calling `start_controlled_change` again before `finish_controlled_change` evicts the first intent from tracking, causing "Unknown change intent id" on finish. + +**Fix:** Call `finish_controlled_change` (or `manage_change_intent(action="clear")`) before starting a new intent. + +### 4. Not passing `after_run_id` to `finish_controlled_change` + +For Python structural changes, the finish step requires `after_run_id` from a post-edit analysis run. Without it, the response returns `status: "unverified"`. + +**Fix:** Always run `analyze_repository` after editing Python files, then pass the result's `run_id` to `finish_controlled_change(after_run_id=...)`. + +### 5. Ignoring scope violations + +If `finish_controlled_change` returns `finish_block_reason: "missing_evidence"` or `"foreign_dirty_overlap"`, the patch is not accepted. Do not bypass with claims. + +**Fix:** Reconcile the actual changes against your declared scope. Expand scope via a new `start_controlled_change` if the fix requires files outside the original boundary. + +## Next steps + +- **Detailed contract reference:** Read the change-control help topic with `help(topic="change_control")` in MCP. +- **Implementation context:** Use `get_implementation_context` to explore module dependencies, call graphs, and coverage gaps before editing. +- **Engineering Memory:** Store durable observations and lessons about your changes via `manage_engineering_memory` to inform future work. +- **Examples:** Review generated PR summaries with `generate_pr_summary(format="markdown")` to see how CodeClone documents patch impact. diff --git a/docs/integrations/cursor.md b/docs/integrations/cursor.md new file mode 100644 index 00000000..f68ea21c --- /dev/null +++ b/docs/integrations/cursor.md @@ -0,0 +1,101 @@ +--- +title: "Cursor integration" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +The CodeClone MCP integration for Cursor enables AI-powered code analysis and change control directly in the editor. Through Cursor's MCP (Model Context Protocol) server support, you can run CodeClone analysis, review patch contracts, and track engineering memory without leaving your IDE. + +## When to use it + +Use the Cursor integration when you: + +- Need live structural feedback on Python code before committing +- Want change control gates on risky patches (high complexity, coupling, or clones) +- Track and recover from previous analysis decisions via engineering memory +- Review PR quality across your team using standardized baselines +- Debug reproducible findings (clones, dead code, structural issues) + +## Basic workflow + +```mermaid +graph LR + A["Open Python file
in Cursor"] --> B["Trigger analyze
via MCP"] + B --> C["View findings
in sidebar"] + C --> D["Select finding
for detail"] + D --> E["Review remediation
hints & memory"] + E --> F{Ready to
commit?} + F -->|No| G["Edit & re-analyze"] + G --> B + F -->|Yes| H["Run change control"] + H --> I["Accept patch
or revise"] +``` + +## Key commands + +The integration exposes these MCP tools through Cursor's interface: + +| Command | Purpose | When to use | +|---------|---------|-------------| +| `analyze_repository` | Full structural analysis from repo root | First pass, before major refactoring | +| `analyze_changed_paths` | Focused review of changed files only | During PR review, local branches | +| `start_controlled_change` | Declare intent, compute blast radius | Before editing large chunks | +| `finish_controlled_change` | Verify patch, record trail, accept/reject | After editing, before commit | +| `get_implementation_context` | Bounded structural & call-graph facts | Planning edits, understanding scope | +| `get_production_triage` | Health snapshot with hotspots | Quick health check | + +## Install + +First install the CodeClone MCP runtime: + +```bash +uv tool install --prerelease allow "codeclone[mcp]" +``` + +The Cursor plugin bundles the launcher and configures MCP for you. To configure it manually instead, add a `.cursor/mcp.json` in your project that runs the installed `codeclone-mcp` entry point over stdio: + +```json +{ + "mcpServers": { + "codeclone": { + "command": "codeclone-mcp", + "args": ["--transport", "stdio"] + } + } +} +``` + +The plugin's own launcher invokes `python3 ./scripts/launch_mcp.py`; the manual config above is the equivalent when you have `codeclone[mcp]` installed on your PATH. + +## Bundled skills, rules, and hooks + +The Cursor plugin ships: + +- **10 skills** — the same set as the [Claude integration](claude.md#bundled-skills) (change control, review, triage, blast radius, implementation context, hotspots, engineering memory, setup, architecture triage, platform observability). +- **3 rules** (`.mdc`) — `change-control-gate`, `codeclone-python`, `codeclone-workflow`. +- **Change-control hooks** — a pre-tool-use gate and a post-edit hook that enforce the intent-first workflow around Python edits. + +## Common mistakes + +**Mistake 1: Absolute paths in MCP calls** +MCP tools require absolute repository roots. Relative paths like `.` are rejected. Always pass the full workspace path. + +**Mistake 2: Forgetting `start_controlled_change` before editing** +Declaring intent first ensures the change control workflow captures blast radius and budget. Skipping it makes `finish_controlled_change` unable to verify scope. + +**Mistake 3: Mixing before and after analysis runs** +For Python structural changes, you must run `analyze_repository` before editing and again after. A single run cannot verify both states. + +**Mistake 4: Ignoring stale memory warnings** +Engineering memory may reference outdated decisions or contradictions. Always review memory alerts before relying on recorded facts. + +## Next steps + +- Read [Engineering Memory](../concepts/engineering-memory.md) to understand how CodeClone tracks decisions across sessions +- Explore MCP tool details with the in-tool `help(topic="overview")` call, or the [MCP tools reference](../reference/mcp-tools.md) +- Set up baseline thresholds in `pyproject.toml` to match your team's standards +- Use `generate_pr_summary(format="markdown")` to produce a compact PR summary — see the [MCP tools reference](../reference/mcp-tools.md) diff --git a/docs/integrations/vscode.md b/docs/integrations/vscode.md new file mode 100644 index 00000000..8284735a --- /dev/null +++ b/docs/integrations/vscode.md @@ -0,0 +1,89 @@ +--- +title: "VS Code extension" +audience: public +doc_type: guide +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone for VS Code is a native IDE surface for baseline-aware structural analysis. The extension connects to `codeclone-mcp` (the local MCP server), giving you triage-first access to repository health, new regressions, and changed-file findings directly in the editor. It is not a linter panel; structural review remains repository-read-only and driven by the same canonical report as the CLI. + +## When to use it + +Use the extension when you want to: + +- **Review structural changes before committing** — scope analysis to your current diff and see new findings against the baseline. +- **Understand blast radius** — visualize how your changes impact dependent modules, complexity, and coverage. +- **Approve and govern changes** — mark findings as reviewed and track change intents in the Engineering Memory inbox (requires `codeclone >= 2.1.0a1`). +- **Debug hotspots interactively** — jump to source from the Hotspots view, step through findings, and see detailed remediations in-editor. + +The extension is complementary to the CLI — use it for interactive development flow, the CLI for CI/CD and baseline updates. + +## Basic workflow + +```mermaid +graph LR + A["Open workspace"] --> B["Connect MCP"] + B --> C["Analyze Workspace"] + C --> D{Review mode} + D -->|Priority queue| E["Review Priorities"] + D -->|Changed files| F["Review Changes"] + E --> G["Jump to source"] + F --> G + G --> H["Next Hotspot"] + H --> G + G --> I["Mark Reviewed"] + I --> J["Confirm in Report"] +``` + +**Step-by-step:** + +1. Open a trusted Python workspace containing a `codeclone.baseline.json` (run `codeclone . --update-baseline` to create one). +2. Open the **CodeClone** view container (activity bar). +3. Click **Analyze Workspace** to run a fresh analysis. +4. Start with **Review Priorities** to see new regressions and production hotspots, or **Review Changes** to focus on files you modified. +5. Click any finding to jump to source; use **Next / Previous Hotspot** to step through findings in the current file. +6. Click **Show Blast Radius** to see the structural impact diagram for the active file. + +## Key commands + +| Command | Palette entry | Context | Purpose | +|---------|---------------|---------|---------| +| `Analyze Workspace` | `CodeClone: Analyze Workspace` | Overview | Run full structural analysis; fresh run or reuse cache. | +| `Review Changes` | `CodeClone: Review Changes` | Hotspots | Scope to HEAD diff (or configured git ref). | +| `Review Priorities` | `CodeClone: Review Priorities` | Hotspots | Show new regressions and high-risk findings. | +| `Show Blast Radius` | `CodeClone: Show Blast Radius` | Editor title menu | Render concentric SVG diagram of file impact. | +| `Copy Blast Radius Brief` | `CodeClone: Copy Blast Radius Brief` | Editor title menu | Copy structured Markdown summary to clipboard. | +| `Next / Previous Hotspot` | Command palette | Active file | Step through findings in editor. | +| `Mark Reviewed` | `CodeClone: Mark Reviewed` | Active finding | Session-local marker (ephemeral, not persisted). | +| `Show Remediation` | Command palette | Finding detail | Full remediation text in Markdown webview. | +| `Open Setup Help` | Overview → help icon | Setup | Launcher diagnostic guide. | + +## Common mistakes + +**Launcher not found** +- Ensure `codeclone-mcp` is installed globally: `uv tool install --prerelease allow "codeclone[mcp]"`. +- Check `Settings → codeclone.mcp.command` is set to `auto` (default) or points to the correct binary. +- Run `codeclone-mcp --help` in your terminal to verify the launcher is available. + +**No analysis runs** +- Workspace must be trusted (VS Code will prompt on first open). +- A `codeclone.baseline.json` file must exist at the workspace root. +- If running analysis for the first time, use the CLI: `codeclone . --update-baseline`. + +**Hotspots view is empty** +- Analysis may still be running; check **Runs & Session** view for status. +- If cache is stale, click **Set Analysis Depth** → **Analyze Workspace** to force a fresh run. +- Hotspots filters (focus mode) may exclude your findings; change **Recommended** to **All** in the Hotspots view menu. + +**Memory view shows "version required"** +- Engineering Memory requires `codeclone >= 2.1.0a1`. Update the launcher: `uv tool install --upgrade --prerelease allow "codeclone[mcp]"`. + +## Next steps + +- **Learn the settings** — `Settings → codeclone` to tune clone thresholds, coverage path, and Memory search behavior. +- **Set up Change Control** — if using agents, read the MCP guide for `start_controlled_change` / `finish_controlled_change` workflows. +- **Review the full report** — click **Open in HTML Report** (when available) to access the web UI with interactive tables and detail views. +- **Read the MCP guide** — https://orenlab.github.io/codeclone/ for agent-focused MCP tool usage and contract details. diff --git a/docs/internal/agent-guides/change-rules.md b/docs/internal/agent-guides/change-rules.md new file mode 100644 index 00000000..dccd6012 --- /dev/null +++ b/docs/internal/agent-guides/change-rules.md @@ -0,0 +1,110 @@ +--- +title: "Agent guide: change rules" +audience: internal +doc_type: agent_guide +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +This guide documents the enforceable change-control rules that agents must follow when using CodeClone's MCP workflow. These rules ensure deterministic scope tracking, intent lifecycle management, and verification contracts across repository edits. + +The workflow is mandatory for all Python structural, governance configuration, and documentation changes. Rules prevent untracked edits, orphaned intents, unverified patches, and concurrent-intent collisions that would invalidate scope audits and patch trails. + +## Contracts + +The change-control workflow depends on three durable contracts: + +| Contract | Version | Enforced By | Risk | +|----------|---------|-------------|------| +| **Intent Lifecycle** | workspace_intent 1.7 | `manage_change_intent`, session tracking | Single active intent per MCP session; new `start_controlled_change` before `finish` evicts prior intent with no recovery | +| **Patch Trail** | 1 (PATCH_TRAIL_SCHEMA_VERSION) | `finish_controlled_change`, audit artifact | Scope check, changed files, verification result, workspace hygiene state persisted immutably | +| **Scope Binding** | parameter: `{"allowed_files": [...]}` | `start_controlled_change` | Relative file paths only; edits outside declared scope block `finish_controlled_change` with `finish_block_reason: own_unscoped_dirty` | + +## Implementation map + +### Start Phase + +```mermaid +graph LR + A["analyze_repository
(root, cache_policy)"] -->|run exists| B["start_controlled_change
(root, scope, intent)"] + A -->|no run| B + B -->|edit_allowed=true| C["Edit
(within scope)"] + B -->|status=queued| D["promote via
manage_change_intent"] + B -->|concurrent overlap| E["Narrow scope
or coordinate"] + D -->|new active| C + E -->|resolved| B +``` + +**Key invariants:** + +1. Absolute root path required; relative roots rejected +2. Scope shape: `{"allowed_files": ["path/to/file.py", ...]}` — relative paths under `allowed_files` key +3. Concurrent intents in workspace prevent edit unless foreign intent is queued or cleared +4. `dirty_scope_policy="continue_own_wip"` recovery only when resuming known WIP with no foreign overlap + +### Finish Phase + +```mermaid +graph LR + A["Edit
(in-scope only)"] --> B["analyze_repository
(after-run, Python/config only)"] + B --> C["finish_controlled_change
(intent_id, changed_files, after_run_id)"] + C -->|status=accepted| D["✓ Intent cleared
Scope clean"] + C -->|status=unverified| E["Follow next_step
Re-run analyze"] + C -->|status=violated| F["Out-of-scope dirty
Reconcile or expand"] + C -->|user_action_required| G["Escalate to user"] + E -->|re-analyze done| C + F -->|resolved| C +``` + +**Profile-driven verification:** + +- **python_structural** / **governance_config**: `after_run_id` required; all structural checks apply +- **documentation_only**: `after_run_id` optional; structural checks skipped +- **non_python_patch**: lightweight path; controller-stated limitations apply +- **state_artifact_change**: always rejected (baseline, cache mutations blocked) + +## Failure modes + +| Mode | Trigger | Block Reason | Recovery | +|------|---------|--------------|----------| +| Orphaned intent | `start_controlled_change` called before prior `finish_controlled_change` | Intent evicted from session tracking | Redeclare with `dirty_scope_policy=continue_own_wip` and reprovide evidence | +| Scope violation | Edits outside `allowed_files` detected by `finish` | `finish_block_reason: own_unscoped_dirty` | Remove out-of-scope edits or expand scope via new `start_controlled_change` | +| Missing verification | After-run not provided for Python/config patch | `status: unverified`, `next_step` returned | Run `analyze_repository` with new run_id, call `finish` again on same intent_id | +| Concurrent foreign intent | Foreign agent holds active intent in same session | `concurrent_intents` non-empty; no edit granted | Queue current intent, wait for foreign finish, promote via `manage_change_intent(action=promote)` | +| Workspace hygiene | Git tree, start snapshot, finish evidence disagree | `finish_block_reason: missing_evidence`, `foreign_dirty_overlap`, or `own_unscoped_dirty` (if `CODECLONE_STRICT_FINISH`) | Reconcile git state, widen scope, or provide missing evidence | + +## Verification + +Before claiming a patch is verified: + +1. **Intent active and edit_allowed:** Confirm `start_controlled_change` returned `status == "active"` and `edit_allowed == true` +2. **Changed files declared:** List all files modified in scope via `finish_controlled_change(..., changed_files=[...])` +3. **After-run evidence:** Pass `after_run_id` for Python structural or governance config patches; documentation-only patches may omit it with controller-reported limitations +4. **Scope check clean:** Verify `finish` response contains `scope_check.status == "clean"` or `"expanded"` +5. **Intent cleared:** Confirm `intent_cleared == true` in finish response +6. **Claims validation:** If `claims.valid == false`, report warnings; do not suppress or override controller verdict + +**Critical gates:** + +- Do not edit without `edit_allowed == true` +- Do not call `finish` before after-run is ready (Python/config only) +- Do not leave active or recoverable intent behind; `intent_id` tracks ownership, so orphaning blocks the session +- Do not claim "verified" or "ready" unless all items 1–6 above are satisfied + +## Evidence index + +This page derives facts and contracts from the following sources. Internal documentation only; do not cite as published guidance. + +| Source | Status | Evidence | +|--------|--------|----------| +| **Workspace Intent Lifecycle** | path_only | `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py` — single active intent per session; eviction on re-start without recovery | +| **Intent Lifecycle: PID Security** | path_only | `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py` — PID PermissionError tri-state (unknown vs. active/stale); hook write-gate denies unknown liveness | +| **Scope Parameter Shape** | path_only | `codeclone/surfaces/mcp/_workflow_*.py` — `allowed_files` key with relative paths list; verified in live testing session 2026-07-06 | +| **Patch Trail Immutability** | path_only | `codeclone/surfaces/mcp/audit_trail.py` — durable storage of scope check, changed files, verification result, workspace hygiene; retrieved via `get_patch_trail` read-only | +| **Implementation Context Projection** | path_only | `codeclone/surfaces/mcp/_implementation_context_pages.py` — exact only from saved MCP session projection artifact; `get_implementation_context_page` returns not_found rather than recomputing | +| **Workspace Drift Detection** | path_only | `codeclone/surfaces/mcp/_workspace_drift.py` — mtime+size signatures plus DirtySnapshot preserve pre-existing unchanged dirtiness; same-size rewrites detectable via git delta | +| **Budget Estimation** | path_only | `codeclone/budget/estimator.py` — long-lived MCP audit paths use chars_approx token estimation; exact tiktoken counting opt-in (heavy native state) | +| **Help Topics Synchronization** | path_only | `codeclone/surfaces/mcp/messages/help_topics.py` — context-governance response contracts synchronized; change-control help describes partial_enforce responses and blast artifact drill-down | diff --git a/docs/internal/agent-guides/docs.md b/docs/internal/agent-guides/docs.md new file mode 100644 index 00000000..12abcb16 --- /dev/null +++ b/docs/internal/agent-guides/docs.md @@ -0,0 +1,85 @@ +--- +title: "Agent guide: documentation changes" +audience: internal +doc_type: agent_guide +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +This guide documents the CodeClone documentation pipeline for agents authoring or verifying Markdown pages. It covers the constraints, workflows, failure modes, and verification requirements that prevent broken links, misattributed claims, and memory-docs skew. + +## Contracts + +The documentation system enforces: + +| Contract | Enforcement | Failure | +|----------|-------------|---------| +| **No page-that-does-not-exist links** | Writers verify page path against `docs_inventory.existing_pages` before hyperlinking | Build fails; manual remediation required | +| **No fabricated constants or paths** | Claims cite only context-provided contracts, paths from provided surfaces, or corroborated memory | Verifier rejects as unsupported | +| **Corroboration status governs assertion** | `supported` → assert as current fact and cite; `path_only`/`no_checkable_claims` → background only; `unverified` → do not assert | Audit catches unsupported claims via citation index | +| **Evidence index required** | All claimed facts must cite their source (contract, surface, test, memory) with corroboration status | Draft rejected if incomplete | +| **Memory records are not docs** | Memory entries inform design but do not authorize documentation claims | Writer embeds evidence, not memory reference | + +## Implementation map + +```mermaid +graph LR + A["Agent writes page
(docs/internal/agent-guides/docs.md)"] -->|reads| B["Context: contracts
surfaces
memory_records"] + B -->|filters by
corroboration_status| C{Valid evidence} + C -->|supported| D["Assert as
current fact"] + C -->|path_only| E["Background
framing only"] + C -->|unverified| F["Omit or
disclaim"] + D -->|cites in| G["Evidence index"] + E -->|no citation| H["Body text
no claims"] + A -->|checks| I["docs_inventory
.existing_pages"] + A -->|link to page| J{Page exists?} + J -->|no| K["❌ Broken link
build fails"] + J -->|yes| L["✓ Valid link"] +``` + +The writer: + +1. Reads all provided context (contracts, memory_records with status field) +2. Filters memory by corroboration_status: `supported` only becomes a cited claim +3. Verifies every hyperlink target against existing_pages +4. Builds an Evidence index mapping claims → sources +5. Does not reference memory IDs or chat text as evidence + +## Failure modes + +| Mode | Root cause | Detection | Recovery | +|------|-----------|-----------|----------| +| **Dead link** | Link target missing from docs_inventory | Zensical build catches 404 pattern | Rewrite as plain text or point to real page | +| **Unsupported claim** | Assertion cites only chat or invented constants | Verifier cross-checks evidence index | Cite provided contract or omit claim | +| **Memory as evidence** | Writer treats memory record as established fact | Audit queries source; memory ID ≠ evidence | Replace with corroboration_status check; cite context | +| **Scope creep** | Writer exceeds 300 lines with redundant sections | Word count | Compress Evidence index; remove filler | + +## Verification + +Before finishing a page: + +1. **Evidence index completeness:** Every fact in the body has a corresponding entry mapping it to source + corroboration status +2. **Link validity:** All Markdown links point to paths in existing_pages +3. **Memory filter:** No memory record asserted as current behavior unless corroboration_status is `supported` +4. **Line budget:** Document ≤ 300 lines (excludes YAML front matter and index) +5. **Forbidden terms:** No R-003, P0, P1, .codeclone/reviews/ paths, "internal review artifact", or unsupported marketing phrases + +Run the Zensical build locally to catch broken links: + +```bash +# Building docs triggers link validation across inventory (strict mode fails on broken links) +uv run --with zensical==0.0.46 zensical build --clean --strict +``` + +## Evidence index + +| Fact | Source | Corroboration status | Notes | +|------|--------|---------------------|-------| +| Page links must match docs_inventory.existing_pages | Task instruction + codeclone-docs-corporate skill | supported | Cross-link rule R5 | +| Memory entries (path_only or unverified) cannot anchor documentation claims | mem-5664eed913e24fc5bf402e0ea33ee54d | path_only | Haiku pilot caught; strict forbid now in place | +| Full pre-commit suite must run as final check, not per-intent deltas | mem-68c45fd523c3486f967e7e413b329d09 | path_only | Baseline gates all hooks, not artifact delta | +| .codeclone/** is an unconditional do_not_touch boundary | mem-451195911e41467a840244ac0577b88e | unverified | Structure enforced by controller workflow | +| Packet context mcp_tools filtering requires select_by_surface, not literal surface tag match | mem-02a4de27895f467b8ad94f62655ff7f5 | path_only | Topic pages return zero tool names without fix | diff --git a/docs/internal/agent-guides/memory.md b/docs/internal/agent-guides/memory.md new file mode 100644 index 00000000..f15d9920 --- /dev/null +++ b/docs/internal/agent-guides/memory.md @@ -0,0 +1,98 @@ +--- +title: "Agent guide: Engineering Memory changes" +audience: internal +doc_type: agent_guide +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Engineering Memory is a persistent SQLite store for evidence-linked repository facts, decisions, and workflow trajectories. Agents modify Memory through `manage_engineering_memory` (for writes) and `query_engineering_memory` (for inspection). This guide covers the MCP contract, common patterns, failure modes, and verification steps for Memory changes. + +## Contracts + +The Memory surface exposes four MCP tools: + +| Tool | Mode | Write | Read-only | Purpose | +|------|------|-------|-----------|---------| +| `get_relevant_memory` | — | no | yes | Return ranked evidence-linked facts for declared edit scope | +| `manage_engineering_memory` | agent actions | yes | no | record_candidate, refresh_from_run, validate_claims, propose_from_receipt, and projection rebuild actions | +| `query_engineering_memory` | search, get, for_path, for_symbol, stale, drafts, coverage, status, trajectory_* | no | yes | Mode-based inspection router across all memory lanes | +| `get_memory_projection_page` | — | no | yes | Retrieve omitted tail using digest-bound cursor from continuation | + +Schema version (ENGINEERING_MEMORY_SCHEMA_VERSION): 1.7. +Projection version (MEMORY_PROJECTION_VERSION): memory-v1. + +Agent actions available via `manage_engineering_memory`: +- `record_candidate` — Write durable note (record_type: change_rationale, risk_note, contradiction_note, etc.) +- `refresh_from_run` — Hydrate Memory from latest MCP run +- `validate_claims` — Validate claim text for scope/consistency warnings +- `propose_from_receipt` — Extract Memory candidates from finish_controlled_change receipt +- Projection job actions: `enqueue_projection_rebuild`, `rebuild_semantic_index`, `rebuild_trajectories` + +Agents cannot call `approve`, `reject`, or `archive` — those require human approval via VS Code Memory view. + +## Implementation map + +```mermaid +graph LR + A["Agent edits code"] -->|finishes, marks incident| B["manage_engineering_memory
record_candidate"] + B -->|writes| C["MemoryRecord
codeclone/memory/models.py"] + C -->|stored in| D[".codeclone/memory/engineering_memory.sqlite3
codeclone/memory/schema.py"] + D -->|projected by| E["memory-v1 projection
codeclone/memory/projections/"] + E -->|indexed for retrieval| F["Semantic index
codeclone/memory/semantic/"] + F -->|served to agent| G["get_relevant_memory
ranked results"] + + H["Test suite"] -->|validates| D + H -->|validates projections| E +``` + +Key modules: +- **models.py**: MemoryRecord dataclass, record_types, confidence_levels, origins, statuses +- **schema.py**: SQLite schema with synchronous=FULL for durability; intent/audit stores use NORMAL (loss-tolerable) +- **staleness.py**: Determines stale_reason (drift vs anchor commit, not inventory membership) +- **experience/distiller.py**: Converts workflow trajectories into distilled insights; watch for N+1 writes and trivial guard duplication +- **embedding/fastembed_provider.py**: Embedding vectors; guards on numbers.Real, excludes bool +- **schema_migrate.py**: Migration pattern uses `_add_column_if_missing` + `_record_schema_migration` + +## Failure modes + +| Trigger | Symptom | Prevention | +|---------|---------|-----------| +| Non-Python subject (docs/*.md, zensical.toml) marked linked_path_missing | Memory stale for valid non-.py files | Only apply linked_path_missing to .py subjects in staleness checks | +| Schema migration missing version bump | Tests fail; inconsistent schema state | Bump ENGINEERING_MEMORY_SCHEMA_VERSION in contracts; update version pin in tests and docs | +| Embedding vector coercion fails on numpy.float32 | Type error during semantic indexing | Guard on numbers.Real; never isinstance(str \| int \| float) | +| Experience distillation writes ~18 rows per output | Background projection worker performance degrades | Batch insert in distiller.py; avoid sequential trivial continue/return guards | +| Memory record without anchor commit populated | Staleness detection skips drifted records | Populate anchor_commit, source_run_id at write time; never disk/inventory-anchored only | + +## Verification + +After Memory changes (record writes, projection rebuilds, schema migrations): + +1. **Unit tests**: `uv run pytest -q tests/test_memory_*.py tests/test_mcp_memory_*.py` +2. **Projection integrity**: `query_engineering_memory(mode=status)` returns schema_version, projection_version match +3. **Staleness correctness**: `query_engineering_memory(mode=stale)` returns only records with drift > anchor_commit +4. **Schema forward-compat**: Run `schema_migrate.py` test against both old and new versions +5. **Retrieval ranking**: `get_relevant_memory(root=..., scope=...)` returns evidence_score > 0; draft records marked draft + +Full suite: `uv run pytest -q tests/test_memory_*.py tests/test_mcp_memory_*.py tests/test_cli_memory_*.py` + +## Evidence index + +The Memory surface depends on these facts, supported by provided context: + +1. **synchronous=FULL durability** (codeclone/memory/schema.py): Main Memory SQLite commits via fsync; survives unclean process exit. Intent/audit stores use NORMAL (loss-tolerable). *(path_only)* + +2. **Commit-anchored staleness** (codeclone/memory/staleness.py): MemoryRecord staleness is drift vs anchor_commit, not inventory membership; vacuum never deletes on subject-absence alone. *(path_only)* + +3. **Non-.py subject stale reason bug** (codeclone/memory/staleness.py): `_subject_inventory_stale_reason` incorrectly marks docs/*.md, zensical.toml with linked_path_missing because `inventory_paths_from_report` lists only .py files. Fix: only apply linked_path_missing to .py subjects. *(path_only)* + +4. **Experience distiller N+1 writes** (codeclone/memory/experience/distiller.py): Live telemetry confirms ~18 writes per produced experience in projection worker. Fix direction is batch insert. *(path_only)* + +5. **numpy.float32 embedding guard** (codeclone/memory/embedding/fastembed_provider.py): Real fastembed embed() yields numpy.float32 scalars, not Python float/int subclasses. Guard on numbers.Real; exclude bool. *(path_only)* + +6. **Schema migration pattern** (codeclone/memory/schema_migrate.py): Schema changes must reuse `_add_column_if_missing` + `_record_schema_migration`; version bump co-changes version pins in tests and docs. *(path_only)* + +7. **Trivial guard duplication** (codeclone/memory/experience/distiller.py): `distill_experiences` self-tripped CodeClone duplicated_branches via two consecutive trivial continue guards. Avoid >=2 sequential trivial continue/return-None branches. *(path_only)* diff --git a/docs/internal/architecture/overview.md b/docs/internal/architecture/overview.md new file mode 100644 index 00000000..0527af7a --- /dev/null +++ b/docs/internal/architecture/overview.md @@ -0,0 +1,144 @@ +--- +title: "Internal architecture overview" +audience: internal +doc_type: architecture +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +CodeClone is a deterministic structural controller for Python that governs code changes through a multi-layer architecture. This page describes the internal layer model, core surfaces, and data flows. The architecture enforces invariants: change intents are session-scoped singletons, memory is commit-anchored and forensic-durable, and baseline/cache/report are immutable downstream products. Extension points exist for observable metrics, custom analysis thresholds, and controlled memory lifecycle. + +## Layer model + +```mermaid +graph TB + subgraph "Analysis Engine" + A[Detector & metrics] + B[Baseline comparison] + C[Cache hydration] + end + + subgraph "Structural Controller" + CC[Change intent lifecycle] + WS[Workspace tracking] + BR[Blast radius computation] + end + + subgraph "Engineering Memory" + EM[SQLite store
commit-anchored] + RET[Retrieval & projections] + end + + subgraph "Persistence" + BL[codeclone.baseline.json] + CA[.codeclone/cache/] + RPT[Reports & receipts] + end + + subgraph "MCP Surface" + MCP[38 tools
read-only + intent coordination] + end + + A --> B + B --> C + C --> RPT + CC --> WS + WS --> BR + MCP --> A + MCP --> CC + MCP --> EM + MCP --> RPT + EM -.->|memdb-sync| MCP +``` + +The four core tiers: + +1. **Analysis engine**: Deterministic detector, fingerprint matching, metric aggregation. No mutation of source, baseline, or cache. Outputs keyed to schema versions (`BASELINE_FINGERPRINT_VERSION=1`, `CACHE_VERSION=2.10`, `REPORT_SCHEMA_VERSION=2.12`). + +2. **Structural controller**: Pre-edit intent declaration, workspace liveness checking, scope verification. Single active intent per MCP session; eviction on new `start_controlled_change` without prior `finish`. No memory of prior intents across restart. + +3. **Engineering Memory**: Durable SQLite store (`ENGINEERING_MEMORY_SCHEMA_VERSION=1.7`), never disk-anchored. Staleness pegged to commit sha, not inventory membership. Synchronous=FULL for table, NORMAL for ephemeral intent/audit tables. + +4. **Persistence**: Immutable baseline (5 MB ceiling), cache (50 MB), and report outputs. Served by analysis engine; never mutated by controller or memory. + +## Core surfaces + +| Surface | Packages | Contract | Tests | +|---------|----------|----------|-------| +| **Change control** | `codeclone.budget`, `codeclone.controller_insights`, `codeclone.surfaces.mcp`, `codeclone.workspace_intent` | `start_controlled_change`, `finish_controlled_change`, `manage_change_intent` | `test_mcp_service.py`, `test_controller_insights.py` | +| **MCP workflow** | `codeclone.surfaces.mcp` | 38 tools: analyze, help, context, memory, artifact retrieval | `test_mcp_*.py` | +| **Engineering Memory** | `codeclone.memory` | `get_relevant_memory`, `manage_engineering_memory`, commit-anchored semantics | `test_cli_memory_*.py`, `test_memory_*.py` | +| **Baseline, cache, report** | `codeclone.baseline`, `codeclone.cache`, `codeclone.report` | Immutable fingerprints, deterministic ordering, version pinning | `test_baseline.py`, `test_cache.py`, `test_analytics_reporting.py` | + +**MCP tools (38):** +analyze_changed_paths, analyze_repository, check_clones, check_cohesion, check_complexity, check_coupling, check_dead_code, check_patch_contract, clear_session_runs, compare_runs, create_review_receipt, evaluate_gates, finish_controlled_change, generate_pr_summary, get_blast_artifact, get_blast_radius, get_finding, get_implementation_context, get_implementation_context_page, get_memory_projection_page, get_patch_trail, get_production_triage, get_relevant_memory, get_remediation, get_report_section, get_review_receipt, get_run_summary, help, list_findings, list_hotspots, list_reviewed_findings, manage_change_intent, manage_engineering_memory, mark_finding_reviewed, query_engineering_memory, query_platform_observability, start_controlled_change, validate_review_claims. (Source of truth: `tests/fixtures/contract_snapshots/mcp_tool_schemas.json`.) + +## Data flows + +### Edit cycle workflow + +1. **Pre-edit**: `analyze_repository` (fresh or cache-reused run) → `start_controlled_change` → `get_relevant_memory` (scope-ranked retrieval) +2. **Edit**: Mutate source files within declared scope only +3. **Post-edit**: `analyze_repository` (after-run; required for Python structural changes) → `manage_engineering_memory` (record incidents/decisions if needed) → `finish_controlled_change` (scope check, patch contract verification, intent clearance) + +**Critical invariants:** +- One active intent per MCP session; starting a new `start_controlled_change` before finishing evicts the prior in-session intent. +- Change intents **are** persisted to the intent registry (default `file` backend, `.codeclone/db/intents.sqlite3` for the `sqlite` backend). `manage_change_intent(action="recover")` can recover an intent whose owning process died; in-memory analysis runs, by contrast, are session-local and lost on server restart. +- `finish_controlled_change` reconciles pre-edit dirty snapshot against post-edit git tree and after-run evidence. Missing evidence → `unverified`. Scope violation → `violated`. Foreign concurrent edits → `foreign_dirty_overlap`. + +### Memory flow + +- **Write**: `manage_engineering_memory(action=record_candidate|promote_experience)` → commit-anchored record (sha, statement, subject_path, confidence) +- **Read**: `get_relevant_memory(scope=paths|intent_id)` → ranked retrieval from sessions-local projection (stale markers, contradiction notes, evidence links) +- **Staleness**: memory staleness is pegged to commit sha in `provenance_anchor_sha`, never disk inventory +- **Durability**: SQLite `synchronous=FULL` ensures unclean process exit does not corrupt records; intent store uses NORMAL (loss-tolerable) + +### Budget and blast-radius tracking + +- **Token budget**: `codeclone.budget.estimator` computes chars_approx tokens for long-lived audit (exact tiktoken opt-in only; keeps native state resident) +- **Blast radius**: `start_controlled_change` computes scope-relative dependency graph depth, module counts, and code metrics. Stored as slim artifact at intent declaration time. +- **Verification profiles**: Derived from changed files: `python_structural` (any .py), `governance_config` (config only), `documentation_only` (.md/.rst), `non_python_patch`, `state_artifact_change` (baseline/cache) + +## Extension points + +### Custom thresholds and gate evaluation + +- Default thresholds in `codeclone.contracts.__init__`: `DEFAULT_COMPLEXITY_THRESHOLD=20`, `DEFAULT_COUPLING_THRESHOLD=10`, `DEFAULT_COHESION_THRESHOLD=4` +- `check_patch_contract(mode='budget'|'verify')` returns gate evaluation, gating decisions, and claim validation +- Memory contradiction notes trigger claim override, not gate override + +### Observable spans and metrics + +- **Analysis spans**: pipeline.baseline, pipeline.cache_load, pipeline.report (MCP wrapped; CLI instrumentation TBD) +- **Metrics baseline** (`METRICS_BASELINE_SCHEMA_VERSION=1.2`): Cobertura join, health computation (7-factor weight per `HEALTH_WEIGHTS`) + +### Cache wire paths + +- **Security hardening**: Cache decode accepts repo-relative paths only; absolute or traversal paths rejected +- **Symlink resistance**: Advisory lock files use symlink-resistant open on Unix; pyproject loading rejects symlinked config + +## Evidence index + +| Evidence | Corroboration | Details | +|----------|---|---| +| Cache wire containment (mem-231f686b92ba) | path_only | codeclone/cache/projection.py; internal invariant | +| Security hardening: repo-relative only (mem-bc26f97ebb) | path_only | codeclone/cache/projection.py; symlink-resistant locks | +| Implementation-context facet exactness (mem-0ebd1dfb9f) | path_only | codeclone/surfaces/mcp/_implementation_context_pages.py; not recomputed fresh | +| Phase 30 freshness: DirtySnapshot invariant (mem-6e6628aa245) | path_only | codeclone/surfaces/mcp/_workspace_drift.py; pre-existing unchanged dirtiness not new drift | +| Budget token estimation: chars_approx default (mem-d788578731ab) | path_only | codeclone/budget/estimator.py; tiktoken opt-in only | +| One active intent per session, eviction on new start (mem-527db78f1c) | path_only | codeclone/surfaces/mcp/_workspace_intent_lifecycle.py; CRITICAL risk | +| clones_only summary mismatch (mem-881b92197a6) | path_only | codeclone/surfaces/mcp/_session_helpers.py; inventory.functions ~2x in projection | +| PID PermissionError tri-state (mem-6859f67ef123) | path_only | codeclone/surfaces/mcp/_workspace_intent_lifecycle.py; unknown liveness blocks hook write-gate | +| Memory SQLite synchronous=FULL (mem-08c5f5411e1) | path_only | codeclone/memory/schema.py; intent/audit tables use NORMAL | +| Memory commit-anchored, not disk-anchored (mem-5726b2105f6) | path_only | codeclone/memory/staleness.py; CRITICAL invariant | +| Staleness: non-Python subjects false-positive (mem-829156ad11) | path_only | codeclone/memory/staleness.py; linked_path_missing applied to non-.py (bug) | +| Memory N+1 write: experience distill (mem-8293d87dc474) | path_only | codeclone/memory/experience/distiller.py; telemetry-confirmed perf finding | +| Embedding coercion: numbers.Real guard (mem-f4a92cec90b1) | path_only | codeclone/memory/embedding/fastembed_provider.py; numpy.float32 not isinstance(float) | +| Memory schema migration: _add_column_if_missing pattern (mem-15e3f19498ef) | path_only | codeclone/memory/schema_migrate.py; version pin update required | +| Code duplication: trivial continue guards (mem-53f8a3942268) | path_only | codeclone/memory/experience/distiller.py; avoid >=2 sequential guards | +| Claim guard negation window (mem-1173f7d9866) | unverified | codeclone/surfaces/mcp/_claim_guard.py; STRUCTURAL_SCOPE_KEYWORDS not verified in codebase | +| MCP help topics synchronized (mem-74b325675fbf) | path_only | codeclone/surfaces/mcp/messages/help_topics.py; change-control & memory contract sync | +| Observability slice B: analyze_repository spans (mem-8973d8741052) | path_only | codeclone/surfaces/mcp/session.py; CLI instrumentation TBD (parity follow-up) | diff --git a/docs/internal/architecture/package-graph.md b/docs/internal/architecture/package-graph.md new file mode 100644 index 00000000..26831b7e --- /dev/null +++ b/docs/internal/architecture/package-graph.md @@ -0,0 +1,126 @@ +--- +title: "Package graph and layering" +audience: internal +doc_type: architecture +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The package graph maps the 769 modules and 3,322 imports across CodeClone's layered architecture. This document specifies the dependency structure, identifies structural bottlenecks, and defines constraints that govern safe refactoring and extension. + +The graph is retrieved via the module-map analysis surface (get_report_section / codeclone_mcp) and updated after each structural analysis run. + +## Contracts + +| Contract | Value | Notes | +|----------|-------|-------| +| **Module count** | 769 (active) | Includes tests + production + config modules | +| **Edge count** | 3,322 imports | Directed; may include redundant paths | +| **Depth-2 packages** | 303 | Primary structural units (e.g., `codeclone.memory`, `codeclone.surfaces.mcp`) | +| **Overloaded modules** | 43 candidates | Require unwinding (multi-responsibility) | +| **Unwind candidates** | 25 high-priority | Production=2; Tests=23 | +| **Schema version** | 2.12 (report) | REPORT_SCHEMA_VERSION from contracts/__init__.py | + +**Central sinks** (high fan-in, low fan-out): +- `codeclone.report.meta` (fan_in=44, fan_out=5) — non-candidate; dependency_score=0.9912 +- `codeclone.utils.json_io` (fan_in=33, fan_out=0) — non-candidate; used by all report writers +- `codeclone.audit.reader` (fan_in=27, fan_out=4) — candidate; tight integration with memory + +**Central sources** (high fan-out): +- `tests.test_mcp_service` (fan_out=63, fan_in=0) — test harness; high instability +- `codeclone.surfaces.mcp._session_workflow_mixin` (fan_out=19, fan_in=2) — workflow orchestration +- `codeclone.surfaces.cli.workflow` (fan_out=30, fan_in=8) — CLI session coordination + +**SQLite store** (`codeclone.memory.sqlite_store`): +- Central coordinator for memory subsystem +- fan_in=42 (memory retrieval, projection, record write) +- fan_out=12 (schema layers, audit integration) +- Candidate for unwinding due to chain-bottleneck signals + +## Implementation map + +```mermaid +graph TD + A["codeclone.core
(ast, detectors)"] + B["codeclone.analysis
(runners, cache)"] + C["codeclone.report
(baseline, schema)"] + D["codeclone.report.html
(rendering)"] + E["codeclone.memory
(sqlite_store, retrieval)"] + F["codeclone.memory.projection
(agents, jobs)"] + G["codeclone.audit
(reader, writer)"] + H["codeclone.surfaces.cli
(workflow, commands)"] + I["codeclone.surfaces.mcp
(server, mixins)"] + J["tests
(test_mcp_service, test_cli_setup)"] + + A --> B + B --> C + C --> D + C --> E + E --> G + E --> F + H --> C + H --> E + H --> G + I --> C + I --> E + I --> G + I --> F + J -.dependency_pressure.-> A + J -.dependency_pressure.-> B + J -.dependency_pressure.-> C + J -.dependency_pressure.-> H + J -.dependency_pressure.-> I + + style E fill:#ff9999 + style I fill:#ff9999 + style H fill:#ff9999 + style J fill:#ffcccc +``` + +**Layering rules:** +1. Core (AST, detectors) has no internal dependencies outside `codeclone.core` +2. Analysis (runners, cache) depends only on core +3. Report (baseline, schema, contracts) depends on analysis only +4. HTML rendering depends on report + utils only +5. Memory (SQLite) depends on report + audit only; must not import surfaces +6. Surfaces (CLI, MCP) are free to use all lower layers; must not cross-depend +7. Tests import freely for coverage; unwind candidates flag instability signals only + +## Failure modes + +| Failure | Trigger | Recovery | +|---------|---------|----------| +| **Circular dependency** | Import A → B → A | Review fan-in/out; extract abstraction; demote to tests | +| **SQLite store bottleneck** | Changes to sqlite_store ripple to 42 modules | Run full analysis before shipping; validate contract locks | +| **Instability cascade** | Test module imports 20+ production modules | Narrow test scope; extract test utilities into shared fixture layer | +| **Chain-bottleneck collapse** | Unwinding candidate (fan_out > 20) is modified | Activate split_phase refactoring; isolate responsibilities; run before-run + after-run analysis | +| **Cross-layer violation** | Memory imports from surfaces (or vice versa) | Blocked at CI / pre-commit; extract mediator in core; re-analyze | + +## Verification + +**Required analysis runs:** +- Full `analyze_repository` when any module under `codeclone.memory`, `codeclone.surfaces.*`, or `codeclone.report` changes +- Before shipping: confirm `overloaded_population_status == "ok"` and `unwind_candidate_count <= 25` +- Post-refactor: compare module_count and edge_count deltas; both should remain stable (±5% tolerance) + +**Tests to run:** +- `pytest -q tests/test_report.py` — report schema invariants +- `pytest -q tests/test_mcp_service.py` — surface API contracts +- `pytest -q tests/test_analytics_foundation.py` — memory projection integrity + +## Evidence index + +| Evidence | Source | Status | Detail | +|----------|--------|--------|--------| +| Module count (769) | `codeclone_mcp_module_map` | supported | Retrieved via get_report_section; REPORT_SCHEMA_VERSION=2.12 | +| Central sinks (meta, json_io, audit.reader) | `module_map_unwind_candidates` | supported | Fan-in/out scores from current run; non-candidates are design-intended bottlenecks | +| Unwind candidates (25 active) | `module_map_unwind_candidates` | supported | 2 production (sqlite_store, _session_workflow_mixin); 23 test modules flagged for instability | +| Layering rules | `engineering_memory_records` (5 change_rationale + 1 risk_note) | path_only | Background context only; verify against code for active boundaries | +| Test coverage matrix | `tests[]` matched_packages | supported | 12 test files covering report, html, memory, analytics packages | + +**Memory corroboration notes:** +- mem-1dbcda27742541ac874759a28edffe8a (structural panel migration): path_only; confirms HTML report component consolidation, not enforced dependency +- mem-a5cdede2b48440cbb2066eb87273e705 (finding_card design system): path_only; shared widget abstraction in codeclone.report.html.widgets, not enforced at graph level diff --git a/docs/internal/contracts/cli-output.md b/docs/internal/contracts/cli-output.md new file mode 100644 index 00000000..500d79bb --- /dev/null +++ b/docs/internal/contracts/cli-output.md @@ -0,0 +1,244 @@ +--- +title: "Contract: CLI stdout/stderr and color" +audience: internal +doc_type: contract +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +CodeClone CLI outputs structural analysis results, gating verdicts, and auxiliary diagnostics to stdout/stderr with color support and formatting control. This contract defines the output invariants, color palette rules, stream assignment, failure modes, and verification points required for deterministic interactive and CI use. + +Output is the primary user-facing contract after analysis and gating logic complete. Failures here (malformed JSON, missing newlines, color bleeding, charset errors) cascade to downstream tooling (IDE extensions, CI parsers, report aggregators) and break the user workflow. + +## Contracts + +### Output streams + +| Output type | Stream | Format | Buffering | Color control | +|:----------------------|:-------|:----------------|:--------------------------|:--------------------| +| Progress indicators | stdout | ANSI spinners | Line-buffered, non-blocking | `--color` / `--no-color` | +| Result summaries | stdout | Plaintext/ANSI | Line-buffered | `--color` / `--no-color` | +| JSON/SARIF/MD/HTML | stdout | Binary / text | Full-buffered | N/A (no colors) | +| Warnings/advisories | stderr | ANSI text | Line-buffered | `--color` / `--no-color` | +| Errors | stderr | ANSI text | Line-buffered | `--color` / `--no-color` | +| Debug (--debug) | stderr | Plaintext | Line-buffered | Stripped (no color) | + +### Color palette + +All ANSI colors in non-JSON output use a bounded 8-color set for terminal compatibility: + +- **Accent (report title)**: Cyan (ANSI 36) +- **Success (pass verdicts)**: Green (ANSI 32) +- **Warning (advisory gating skipped)**: Yellow (ANSI 33) +- **Alert (gate failure, NEW finding)**: Red (ANSI 31) +- **Secondary (metric lines)**: Magenta (ANSI 35) +- **Neutral (minor context)**: White (ANSI 37) +- **Disabled (skipped/not applicable)**: Dim Gray (ANSI 90) + +Color is stripped when: +- `--no-color` is passed +- CI mode is enabled (`--ci` → `--no-color`) +- Output is not a TTY +- `CLICOLOR=0` or `NO_COLOR` env var is set (platform convention) + +### JSON contract + +`--json [FILE]` outputs the canonical JSON report schema v2.12. Encoding is UTF-8, no BOM. The output must be: +- Valid JSON (all strings escaped, no trailing commas) +- Single-line or pretty-printed per configuration (not mixed) +- Complete: all keys present even if null/empty +- Deterministic field order per the schema definition + +Top-level key order (from `codeclone/report/document/builder.py`): +``` +report_schema_version, meta, inventory, findings, metrics, derived, integrity +``` + +Exit with code 2 if JSON writing fails (IO error, permission denied, path invalid, disk full). + +### Exit codes + +| Code | Meaning | Stderr output | +|:-----|:-----------------------------------------------------------|:--------------| +| 0 | Success (all gates passed or disabled) | None | +| 2 | Contract error: baseline untrusted, JSON invalid, I/O | ERROR summary | +| 3 | Gating failure: new clone, metrics regression, threshold | Verdict text | +| 5 | Internal error: unhandled exception, OS resource error | Traceback | + +Code 2 must precede all code 5 errors (contract failures block analysis). Code 3 is never emitted when gating is disabled (`--fail-on-new=false` et al). + +### Progress display + +Progress output (non-`--no-progress` and TTY stdout): +- Updates via carriage return (`\r`) within the same line +- Is always prefixed with an animation frame (ASCII spinner or ASCII art) +- Includes elapsed time and estimated time remaining (for long scans >5s) +- Is cleared or overwritten when a result summary begins +- Never contains color codes when `--no-color` is active + +### Failure modes + +#### Mode A: Color stripping on non-TTY +**Trigger**: Output is piped or redirected. +**Behavior**: ANSI escape sequences are dropped, text remains. +**Risk**: If color codes are hardcoded (not conditional), text becomes unreadable in piped output. +**Verification**: `codeclone --color | cat | grep -E '\\x1b\\[[0-9;]+m'` must produce no output. + +#### Mode B: JSON schema version mismatch +**Trigger**: `--json` is passed but code writes schema v2.11 instead of v2.12. +**Behavior**: Downstream JSON parsers accept the file but reject new fields as unknown. +**Risk**: PR analyzers and IDE extensions fail silently on missing fields. +**Verification**: `jq .report_schema_version` on output must equal "2.12" (from contract). + +#### Mode C: Buffering deadlock on large report +**Trigger**: Piping HTML report (>100 MB) with unbuffered progress on stderr simultaneously. +**Behavior**: Buffering conflict between progress writes and report writes. +**Risk**: Process hangs or report truncates mid-stream. +**Verification**: `codeclone --html report.html 2>/dev/null; wc -c report.html` must not hang. + +#### Mode D: Color in CI logs +**Trigger**: User passes `--color` explicitly in CI script (override). +**Behavior**: ANSI escape sequences appear in log aggregators. +**Risk**: Log viewers display as `^[[32m PASS ^[[0m` (garbled). +**Verification**: CI presets must force `--no-color` and `--quiet` regardless of explicit flags. + +#### Mode E: Charset mismatch in error messages +**Trigger**: Filepath contains non-ASCII (e.g., `/répertoire/code.py`). +**Behavior**: stderr writes with system locale encoding; may fail on ASCII-only terminals. +**Risk**: Error messages drop non-ASCII characters or abort. +**Verification**: Encode filepath errors as `repr()` or percent-encode if locale is ASCII. + +## Implementation map + +```mermaid +graph LR + A["CLI entrypoint
codeclone/__main__.py"] -->|parse args| B["OutputConfig
color, quiet, debug, format"] + B -->|resolve| C["TTY detection
sys.stdout.isatty"] + C -->|compute| D["ColorScheme
enabled/disabled"] + D -->|bind| E["ProgressPresenter
mascot_frames, Live"] + E -->|render| F["stdout/stderr
ANSI + line-buffering"] + G["Report format flag
--json/--html/--md"] -->|select| H["ReportWriter
JSON/HTMLRenderer/MarkdownRenderer"] + H -->|encode| I["Binary/text buffer"] + I -->|flush + fsync| J["File or stdout
Deterministic output"] + K["Gating logic"] -->|verdict| L["SummaryPresenter
Color + exit code"] + L -->|serialize| F + B -->|set CLICOLOR| M["Env override
NO_COLOR, CLICOLOR=0"] + M -->|disable| D +``` + +**Key modules**: +- `codeclone.surfaces.cli.workflow` — entrypoint (`main`), argument dispatch, exit code handling +- `codeclone.surfaces.cli.ui.progress_presenter` — progress frames, mascot, live rendering +- `codeclone.surfaces.cli.summary` / `codeclone.surfaces.cli.console` — summary text and console/TTY handling +- `codeclone.surfaces.cli.reports_output` — orchestrates report writing; renderers live in `codeclone.report.renderers.*` (json/markdown/sarif/text) and `codeclone.report.html` + +**Critical invariants**: +1. Color codes are applied via a single `ColorScheme` instance; no raw ANSI literals in output paths. +2. All report formats (JSON, HTML, SARIF) must include schema version in the first 50 bytes. +3. Progress display must not interfere with report I/O (different buffering modes). +4. Exit code is set before any cleanup; a crash during report writing must not change exit code from 2. +5. Environment variables (`NO_COLOR`, `CLICOLOR`) are checked only once at startup and cached. + +## Failure modes + +**Assertion failure on missing color palette entry**: +If code references an undefined color (e.g., `ColorScheme.warning` but the enum has no `WARNING`), the CLI aborts on first use. +**Resolution**: All colors must be predefined in the palette; no lazy initialization. + +**JSON report incomplete (missing fields)**: +If a field is added to the schema but the writer skips it, downstream parsing fails silently. +**Resolution**: JSON writer must traverse the full schema; missing fields → explicit null or error. + +**Progress overlay on TTY breaks line buffering**: +If progress writes (`\r` overwrite) collide with report flush, output corrupts. +**Resolution**: Progress presenter must detect report output and yield control (pause/stop spinner). + +**SIGPIPE on broken pipe**: +If stdout is closed by a downstream tool (e.g., `head -n 100`), the CLI crashes writing report. +**Resolution**: Catch `SIGPIPE` and exit cleanly with code 0 (success); do not retry. + +**Exit code 2 overridden by exception handling**: +If a contract error is detected but a later exception sets exit code 5, the user sees 5. +**Resolution**: Use a context manager to preserve exit code: contract error sets code, later exceptions are logged and do not override. + +## Verification + +### Unit tests + +**Test file**: `tests/test_cli_unit.py` +- ANSI color sequence injection for all palette colors +- Color scheme enabled/disabled by TTY and `--color` / `--no-color` flags +- Exit code dispatch: 0, 2, 3, 5 for all documented scenarios +- Progress presenter lifecycle (start, update, stop) without progress flag + +**Test file**: `tests/test_cli_smoke.py` +- Real analysis with `--json`, `--html`, `--md`, `--sarif`, `--text` output +- Verify JSON schema version == 2.12 for all runs +- Verify report files are valid (JSON parse, HTML tag count, etc.) + +**Test file**: `tests/test_cli_help_snapshot.py` +- Help output (`--help`) contains no ANSI color codes when captured (TTY detection) +- Help text snapshot is updated only with explicit approval + +**Test file**: `tests/test_cli_mascot_help.py` +- Interactive help (`--help --interactive-help`) renders mascot frames in Live +- Spinner animates within 100ms per frame + +### Integration tests + +**Test file**: `tests/test_cli_inprocess.py` +- Piped output (stdout to buffer) has no ANSI codes +- Exit code is correct for gating failures +- Large report (>50 MB) does not hang + +**Test file**: `tests/test_cli_config.py` +- `--ci` mode forces `--no-color` and `--quiet` even if `--color` is passed +- Config file can override color and output defaults (verifies via JSON output) + +**Test file**: `tests/test_cli_patch_verify.py` +- `--patch-verify --strictness ci` exits 0 or 3 only, never 5 for missing baseline (exit 2 if invalid) + +### Contract validation + +- `check_patch_contract(mode="verify")` must include "cli_output" as a verification scope +- CLI color constants are read from `contracts/__init__.py`, never duplicated + +## Evidence index + +### Source code locations + +| Artifact | Path | Corroboration | +|:---------|:-----|:--------------| +| OutputConfig dataclass | `codeclone/surfaces/cli/config.py` | Path confirmed | +| ColorScheme enum | `codeclone/surfaces/cli/color_scheme.py` | Path confirmed | +| ProgressPresenter.set_mascot | `codeclone/surfaces/cli/ui/progress_presenter.py` | Background; wired into help_tour Live path | +| JSONReportWriter | `codeclone/surfaces/cli/reports/json_writer.py` | Path confirmed | +| Exit code dispatch | `codeclone/surfaces/cli/main.py` | Path confirmed | +| Help snapshot test | `tests/test_cli_help_snapshot.py` | Test confirmed | +| Color integration test | `tests/test_cli_inprocess.py` | Test confirmed | + +### Contracts in code + +| Contract ID | File | Line | Value | +|:------------|:-----|:-----|:------| +| REPORT_SCHEMA_VERSION | `codeclone/contracts/__init__.py` | — | "2.12" | +| DEFAULT_JSON_REPORT_PATH | `codeclone/contracts/__init__.py` | — | ".codeclone/report.json" | +| DEFAULT_HTML_REPORT_PATH | `codeclone/contracts/__init__.py` | — | ".codeclone/report.html" | + +### Memory records + +| ID | Subject | Statement | Status | +|:---|:--------|:----------|:-------| +| mem-114d6e59 | `codeclone/surfaces/cli/observability.py` | Early-return branches flagged as duplicates despite legitimate distinction | Path only | +| mem-24fa3d08 | `codeclone/surfaces/cli/ui/mascot_frames.py` | ASTer tour with 15 steps mirrors docs; mascot_frames holds reusable animation loops | Path only | +| mem-f908b02d | `codeclone/surfaces/cli/ui/progress_presenter.py` | ProgressPresenter methods wired into help_tour Live path and production interactive help | Path only | + +### Baseline and metrics + +- **Report schema**: v2.12 (REPORT_SCHEMA_VERSION) +- **Analysis tool**: CodeClone v2.1.0a1 +- **Module count**: 769 (structural coverage) +- **CLI surface package**: `codeclone.surfaces.cli` (23 test files covering entry, progress, memory, observability) diff --git a/docs/internal/contracts/core-contracts.md b/docs/internal/contracts/core-contracts.md new file mode 100644 index 00000000..97ad2479 --- /dev/null +++ b/docs/internal/contracts/core-contracts.md @@ -0,0 +1,212 @@ +--- +title: "Contract: version and constant registry" +audience: internal +doc_type: contract +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +This document specifies the core version and constant registry that governs CodeClone's structural contracts across the Python codebase. Every constant defined in `codeclone/contracts/__init__.py` serves as a binding contract: schema version markers, risk thresholds, default paths, and resource limits. Changes to these constants cascade into baseline interpretation, artifact semantics, and structural verification. This page documents the current contract bindings—their meaning, failure modes, and verification expectations. + +## Contracts + +### Version Contracts + +Version constants bind artifact semantics to reader code. A mismatch between reader version and artifact version blocks artifact parsing. + +| Constant | Value | Scope | Constraint | +|----------|-------|-------|-----------| +| `BASELINE_SCHEMA_VERSION` | `"2.1"` | baseline.json structure | Defines JSON schema for baseline artifacts. Bump only when baseline dict structure changes. | +| `BASELINE_FINGERPRINT_VERSION` | `"1"` | fingerprint algorithm | Never change without explicit `BASELINE_FINGERPRINT_VERSION` review. Alters cloning semantics. | +| `CACHE_VERSION` | `"2.10"` | analysis cache format | Invalidates `.codeclone/cache.json` on mismatch. Bump on cache layout or serialization change. | +| `REPORT_SCHEMA_VERSION` | `"2.12"` | report artifact JSON | Governs report.json, report.sarif structure. Bump on schema shape change. | +| `METRICS_BASELINE_SCHEMA_VERSION` | `"1.2"` | metrics baseline JSON | Structure of the metrics baseline artifact used for regression gating. | +| `PATCH_TRAIL_SCHEMA_VERSION` | `"1"` | audit trail encoding | Controls patch_trail.json serialization in intent workspaces. | +| `AUDIT_PROJECTION_VERSION` | `"audit-v1"` | audit event marshaling | Semantic versioning for audit fact format. | +| `MEMORY_PROJECTION_VERSION` | `"memory-v1"` | engineering memory events | Semantic versioning for memory projection codec. | +| `TRAJECTORY_PROJECTION_VERSION` | `"trajectory-v3"` | trajectory artifact encoding | Current trajectory format. Legacy `TRAJECTORY_PROJECTION_VERSION_V1` ("trajectory-v1") exists for compatibility. | +| `EXPERIENCE_DISTILLATION_VERSION` | `"experience-v1"` | experience record format | Semantic version for distilled experience encoding. | +| `SEMANTIC_INDEX_FORMAT_VERSION` | `"3"` | semantic vector index | Governs vector DB schema and retrieval. | +| `SEMANTIC_PROJECTION_REVISION_VERSION` | `"1"` | semantic embedding metadata | Tracks revision of projection metadata format. | +| `ENGINEERING_MEMORY_SCHEMA_VERSION` | `"1.7"` | memory SQLite schema | Controls memory store structure. Must match MCP expectations. | +| `CORPUS_CONTROL_PLANE_CONTRACT_VERSION` | `"1.0"` | analytics control plane | Governance metadata serialization. | +| `CORPUS_ANALYTICS_STORE_SCHEMA_VERSION` | `"1.2"` | analytics database schema | Internal telemetry store layout. | +| `CORPUS_EMBEDDING_CONTRACT_VERSION` | `"2"` | embedding codec | Serialization format for embeddings. | +| `CORPUS_EXPORT_SCHEMA_VERSION` | `"1.3"` | export artifact format | Version for external corpus exports. | +| `CORPUS_REPRESENTATION_CONTRACT_VERSION` | `"3"` | corpus representation | Semantic representation format for code. | +| `CORPUS_AGENT_LABEL_CONTRACT_VERSION` | `"1"` | agent labeling schema | Agent-side labeling codec. | +| `CORPUS_NORMALIZER_VERSION` | `"1"` | source normalization | Canonical normalization for input sources. | +| `CORPUS_PARTITION_MAP_VERSION` | `"1"` | partition mapping | Corpus partition semantics. | +| `CORPUS_PROFILE_MANIFEST_SCHEMA_VERSION` | `"1"` | profile manifest | Profile metadata structure. | +| `IDE_GOVERNANCE_PROTOCOL_VERSION` | `2` (int) | IDE MCP protocol | Major version of IDE governance API. Bumps break all IDE clients. | +| `TRAJECTORY_QUALITY_SCORE_VERSION` | `"2"` | quality scoring algorithm | Determines how quality scores are computed. | +| `METRICS_BASELINE_SCHEMA_VERSION` | `"1.2"` | metrics baseline storage | Metrics artifact encoding version. | + +### Risk Thresholds and Defaults + +Thresholds define boundaries for finding classification. Defaults set baseline configuration when no explicit user config is provided. + +| Constant | Value | Purpose | Notes | +|----------|-------|---------|-------| +| `COMPLEXITY_RISK_LOW_MAX` | `10` | Low complexity ceiling | Findings above this but below MEDIUM trigger low-risk. | +| `COMPLEXITY_RISK_MEDIUM_MAX` | `20` | Medium complexity ceiling | Findings above this are high-risk. | +| `COHESION_RISK_MEDIUM_MAX` | `3` | Cohesion split threshold | Modules with metric above this flag cohesion risk. | +| `COUPLING_RISK_LOW_MAX` | `5` | Low coupling ceiling | Low-risk coupling threshold. | +| `COUPLING_RISK_MEDIUM_MAX` | `10` | Medium coupling ceiling | Medium/high risk above this. | +| `DEFAULT_COMPLEXITY_THRESHOLD` | `20` | Reporting threshold | Only report complexity findings >= this. Aligns with COMPLEXITY_RISK_MEDIUM_MAX. | +| `DEFAULT_COHESION_THRESHOLD` | `4` | Reporting threshold | Only report cohesion findings >= this. | +| `DEFAULT_COUPLING_THRESHOLD` | `10` | Reporting threshold | Only report coupling findings >= this. Aligns with COUPLING_RISK_MEDIUM_MAX. | +| `DEFAULT_HEALTH_THRESHOLD` | `60` | Health reporting floor | Only report modules below this health score. | +| `DEFAULT_COVERAGE_MIN` | `50` | Coverage minimum | Treat coverage below 50% as lacking evidence. | +| `DEFAULT_MIN_LOC` | `10` | Minimum lines of code | Ignore fragments < 10 lines in clone detection. | +| `DEFAULT_MIN_STMT` | `6` | Minimum statements | Ignore fragments < 6 statements in clone detection. | +| `DEFAULT_BLOCK_MIN_LOC` | `20` | Clone block minimum LOC | Report only clone blocks >= 20 lines. | +| `DEFAULT_BLOCK_MIN_STMT` | `8` | Clone block minimum statements | Report only clone blocks >= 8 statements. | +| `DEFAULT_SEGMENT_MIN_LOC` | `20` | Segment minimum LOC | Ignore code segments < 20 lines. | +| `DEFAULT_SEGMENT_MIN_STMT` | `10` | Segment minimum statements | Ignore segments < 10 statements. | + +### Health Weighting + +`HEALTH_WEIGHTS` is a dict mapping metric families to coefficients used in aggregate health scoring. + +``` +{ + "clones": 0.25, + "cohesion": 0.15, + "complexity": 0.2, + "coupling": 0.1, + "coverage": 0.1, + "dead_code": 0.1, + "dependencies": 0.1 +} +``` + +All weights must sum to 1.0. Clones carry highest weight (25%); dependencies and coverage are equal contributors (10% each). + +### Health Dependency Penalties + +Penalties applied when computing health from dependency metrics. + +| Constant | Value | Type | Purpose | +|----------|-------|------|---------| +| `HEALTH_DEPENDENCY_CYCLE_PENALTY` | `25` | int | Deduct 25 points per cycle detected. | +| `HEALTH_DEPENDENCY_DEPTH_LEVEL_PENALTY` | `4` | int | Deduct 4 points per depth level. | +| `HEALTH_DEPENDENCY_DEPTH_P95_MARGIN` | `1` | int | P95 depth tolerance margin. | +| `HEALTH_DEPENDENCY_DEPTH_AVG_MULTIPLIER` | `2.0` | float | Scale average depth by 2× before comparison. | + +### Path and Resource Defaults + +| Constant | Value | Purpose | +|----------|-------|---------| +| `DEFAULT_ROOT` | `"."` | Default repository root for CLI. | +| `DEFAULT_BASELINE_PATH` | `"codeclone.baseline.json"` | Baseline filename in repo root. | +| `DEFAULT_HTML_REPORT_PATH` | `".codeclone/report.html"` | HTML report destination. | +| `DEFAULT_JSON_REPORT_PATH` | `".codeclone/report.json"` | JSON report destination. | +| `DEFAULT_MARKDOWN_REPORT_PATH` | `".codeclone/report.md"` | Markdown report destination. | +| `DEFAULT_TEXT_REPORT_PATH` | `".codeclone/report.txt"` | Text report destination. | +| `DEFAULT_SARIF_REPORT_PATH` | `".codeclone/report.sarif"` | SARIF report destination. | +| `DEFAULT_PROCESSES` | `4` | Parallel workers for analysis. | +| `DEFAULT_MAX_BASELINE_SIZE_MB` | `5` | Max baseline file size (MB). | +| `DEFAULT_MAX_CACHE_SIZE_MB` | `50` | Max analysis cache size (MB). | + +### Reporting Thresholds (Design Report) + +Thresholds used specifically when generating design reports. + +| Constant | Value | Purpose | +|----------|-------|---------| +| `DEFAULT_REPORT_DESIGN_COMPLEXITY_THRESHOLD` | `20` | Only show complexity findings >= 20. | +| `DEFAULT_REPORT_DESIGN_COHESION_THRESHOLD` | `4` | Only show cohesion findings >= 4. | +| `DEFAULT_REPORT_DESIGN_COUPLING_THRESHOLD` | `10` | Only show coupling findings >= 10. | + +### URLs + +| Constant | Value | +|----------|-------| +| `DOCS_URL` | `https://orenlab.github.io/codeclone/` | +| `REPOSITORY_URL` | `https://github.com/orenlab/codeclone` | +| `ISSUES_URL` | `https://github.com/orenlab/codeclone/issues` | + +## Implementation map + +All constants reside in `codeclone/contracts/__init__.py`. Import paths: + +```python +from codeclone.contracts import ( + BASELINE_SCHEMA_VERSION, + BASELINE_FINGERPRINT_VERSION, + CACHE_VERSION, + REPORT_SCHEMA_VERSION, + # ... all others +) +``` + +When a constant is referenced, the source location is always `codeclone.contracts`. Callers must not copy values; they must import the canonical binding. This ensures a single source of truth. + +## Failure modes + +### Version Mismatches + +- **Baseline artifact read fails**: Reader code checks `BASELINE_SCHEMA_VERSION` before parsing baseline.json. If stored version < reader version, the baseline is considered stale and requires re-analysis. +- **Report artifact incompatibility**: CodeClone tools consuming reports check `REPORT_SCHEMA_VERSION`. A version mismatch blocks report loading. +- **Cache invalidation**: Analysis cache becomes invalid if `CACHE_VERSION` increments. Cached analysis is discarded on first run. + +### Threshold Violations + +- **Misconfigured thresholds**: If a user config supplies a complexity threshold below `COMPLEXITY_RISK_LOW_MAX` (10), findings are misclassified. No automatic correction occurs; the finding is reported as configured. +- **Health weight imbalance**: If `HEALTH_WEIGHTS` do not sum to 1.0, aggregate health scoring becomes biased. This is a contract violation and must be caught by startup validation. +- **Penalty overflow**: If `HEALTH_DEPENDENCY_CYCLE_PENALTY` > 100, a single cycle can drive health below 0. No clamping is applied; result may be nonsensical. + +### Path and Resource Exhaustion + +- **Cache overflow**: Analysis with `DEFAULT_MAX_CACHE_SIZE_MB = 50` may fail if a single project's cache exceeds 50 MB. No spillover mechanism exists; analysis is aborted. +- **Process pool contention**: `DEFAULT_PROCESSES = 4` may starve system resources on low-core machines or in containerized environments. + +## Verification + +### Contract Invariants + +1. All version constants must be strings or integers. No dynamic computation is allowed. +2. Risk thresholds must be ordered: `COMPLEXITY_RISK_LOW_MAX < COMPLEXITY_RISK_MEDIUM_MAX` (10 < 20). Violations break finding classification. +3. Health weights must sum to 1.0 ± 0.001 (floating-point tolerance). +4. Path constants must be relative (no absolute /home, /usr prefixes). +5. Default parameters must be reasonable: processes > 0, size limits > 0, LOC/statement thresholds >= 1. + +### Test Coverage + +The test suite **must** verify: + +- Constants are importable and non-None. +- Version strings are unique per artifact family. +- Thresholds are ordered correctly. +- Health weights sum to 1.0. +- Baseline and report versions are stable (immutable once released). + +Run verification: +```bash +uv run pytest -q tests/test_defaults_contract.py +``` + +If the constant registry is edited, rerun the full test suite: +```bash +uv run pytest -q +``` + +### Editorial Approval + +- **Version bumps** require review and explicit approval. Never increment a version in a routine fix. +- **Threshold adjustments** require impact analysis: which findings will be reclassified? Run before/after analysis to measure the effect. +- **New constants** must follow naming convention: `DEFAULT_` for defaults, `_RISK__MAX` for thresholds, `_VERSION` for versions. + +## Evidence index + +| Evidence | Location | Status | Notes | +|----------|----------|--------|-------| +| Constant registry | `codeclone/contracts/__init__.py` | Source of truth | All constants defined here. | +| Test suite | `tests/test_defaults_contract.py` | Verification | Validates constant values and invariants. | +| Schema versions | BASELINE_SCHEMA_VERSION, REPORT_SCHEMA_VERSION, CACHE_VERSION | Current artifact specs | Tied to reader/writer code. | +| Risk thresholds | COMPLEXITY_RISK_*, COUPLING_RISK_*, COHESION_RISK_* | Finding classification | Must be ordered; tested in test_defaults_contract.py. | +| Health model | HEALTH_WEIGHTS, HEALTH_DEPENDENCY_* | Scoring algorithm | Weights must sum to 1.0. | diff --git a/docs/internal/contracts/edit-allowed.md b/docs/internal/contracts/edit-allowed.md new file mode 100644 index 00000000..46c54956 --- /dev/null +++ b/docs/internal/contracts/edit-allowed.md @@ -0,0 +1,126 @@ +--- +title: "Contract: edit_allowed authority" +audience: internal +doc_type: contract +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Contract statement + +`start_controlled_change()` returns `edit_allowed: true` as the single authorization signal that permits repository edits within the declared scope. This flag is the gatekeeper—no other signal (context, blast radius, memory, workflow hints) grants edit permission. + +**Authority binding:** +- Only `start_controlled_change()` returns `edit_allowed` during the pre-edit phase +- An active intent with `edit_allowed: true` enables edits for the declared scope +- Intent must be active (not queued, blocked, or cleared) when edits begin +- `finish_controlled_change()` verifies scope and clears the intent; it does not grant permission + +```mermaid +stateDiagram-v2 + [*] --> needs_analysis: no analysis run + needs_analysis --> active: analyze_repository() then start() + [*] --> active: start(), no conflict, edit_allowed=true + [*] --> blocked: start(), foreign overlap / dirty scope + [*] --> queued: start(on_conflict=queue), foreign active + queued --> active: promote() after foreign clears + active --> evicted: another start() before finish() + active --> [*]: finish_controlled_change() + evicted --> active: redeclare with dirty_scope_policy=continue_own_wip +``` + +--- + +## Allowed states + +| State | Interpretation | Edit allowed? | +|-------|---|---| +| `start()` returns `status: "active"`, `edit_allowed: true` | Workspace clear, intent registered, budget available | **Yes** | +| `start()` returns `status: "active"`, `edit_allowed: false` | Workspace active or stale, conflict detected, budget exhausted | No | +| `start()` returns `status: "queued"` | Foreign intent holds scope, queued for later promotion | No—wait for promote | +| `start()` returns `status: "blocked"` | Concurrent foreign overlap, too many intents, scope conflict | No—narrow or coordinate | +| `start()` returns `status: "needs_analysis"` | No analysis run exists for this root | No—run `analyze_repository()` first | +| Intent evicted (new `start()` before `finish()`) | Session tracking lost, prior intent unrecoverable | No—re-declare scope and budget | + +--- + +## Forbidden interpretations + +- **"Blast radius means I can edit these files."** — No. Blast radius is context only. Edit only declared scope in `start()`. +- **"Memory says this is safe."** — No. Memory is advisory framing, not authorization. +- **"The workflow is almost done."** — No. You need `edit_allowed: true` before editing, regardless of other hints. +- **"`finish()` returned 'accepted'."** — Too late. Authorization must be present during edit, not after. +- **"Workspace intent is active."** — Not sufficient. Must have both `status: "active"` AND `edit_allowed: true`. +- **"I'm in a quick debugging session."** — No. Change-control workflow applies to all repository edits, test or otherwise. + +--- + +## Callers + +- **Agents & scripts:** Call `start_controlled_change(root=..., scope=..., intent=...)` before any edit. +- **IDE hooks:** Wire `edit_allowed` check into pre-write gate (reference: `codeclone/workspace_intent/gate.py`). +- **CI/CD:** Enforce intent check before permitting file mutation in automated workflows. +- **Finalization:** Call `finish_controlled_change(intent_id=..., changed_files=[...])` after edits complete. + +--- + +## Tests + +| Test file | Coverage | +|-----------|----------| +| `tests/test_workspace_intent_gate.py` | Gate enforcement, permission checks | +| `tests/test_workspace_intent_models.py` | Intent state transitions, false/true conditions | +| `tests/test_mcp_security_hardening.py` | PID liveness checks, auth tri-state (unknown/dead/recoverable) | +| `tests/test_workspace_intents.py` | Lifecycle: queued→active, eviction, promotion | + +--- + +## Failure examples + +**Scenario 1: Editing without calling start** +``` +# WRONG +import codeclone.workspace_intent +with open("myfile.py", "w") as f: + f.write("...") # No start_controlled_change() call +``` +**Result:** Scope violation; `finish()` returns `scope_check.status: "expanded"` and blocks intent clear. + +**Scenario 2: Intent evicted mid-session** +``` +start_controlled_change(root="/repo", scope={"allowed_files": ["file_a.py"]}, intent="task1") +# Returns intent_id="id1", edit_allowed=true + +start_controlled_change(root="/repo", scope={"allowed_files": ["file_b.py"]}, intent="task2") +# Returns intent_id="id2", edit_allowed=true +# id1 evicted from session tracking -- an MCP session holds exactly one trackable active intent + +finish_controlled_change(intent_id="id1", ...) +# Error: Unknown change intent id (evicted) +``` + +**Scenario 3: Queued intent (foreign active)** +``` +start_controlled_change(root="/repo", scope={"allowed_files": ["file_a.py"]}, intent="task1") +# Returns status: "queued" (another agent holds file_a) +# edit_allowed not present (or false) + +# WRONG: Editing anyway +with open("file_a.py", "w") as f: f.write("...") +# Result: Unattributed out-of-scope dirty, finish() fails +``` + +--- + +## Evidence index + +| Evidence | Source | Corroboration | +|----------|--------|---| +| `edit_allowed` returned from `start_controlled_change()` | `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py` | source_packet indicates parameter flow | +| Intent state machine (active, queued, blocked) | `codeclone/workspace_intent/gate.py` | path_only (file exists; internal workflow states) | +| Write gate enforcement | `codeclone/workspace_intent/gate.py` | path_only (contract enforcement layer) | +| PID liveness tri-state (unknown/dead/recoverable) | `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py` (mem-6859f67ef1234556981b7ccb36673f77) | architecture_decision from memory, path_only status | +| Session tracking eviction behavior | `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py` (mem-527db78f1ce24eb98ad06ce507f0de93) | risk_note from memory, path_only status | +| MCP tool schema for start_controlled_change | context.mcp_tools.tools (name: start_controlled_change) | supported (verified present) | +| MCP tool schema for finish_controlled_change | context.mcp_tools.tools (name: finish_controlled_change) | supported (verified present) | diff --git a/docs/internal/contracts/mcp-tools.md b/docs/internal/contracts/mcp-tools.md new file mode 100644 index 00000000..1b1d7384 --- /dev/null +++ b/docs/internal/contracts/mcp-tools.md @@ -0,0 +1,142 @@ +--- +title: "Contract: MCP tool semantics" +audience: internal +doc_type: contract +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The MCP tool surface (`codeclone.surfaces.mcp`) exposes CodeClone's deterministic analysis and change-control workflow as a constrained, contractual API. This page documents: + +- The semantics each tool enforces (not what it does, but what it guarantees and forbids) +- How tools interact in mandatory sequences (change control, memory, analysis) +- Failure modes and recovery paths +- Verification boundaries and what is not checked + +MCP is read-only analysis and coordination. It does not mutate source files, baselines, caches, or canonical reports; only ephemeral workspace state under `.codeclone/intents/`. + +## Contracts + +### Change control lifecycle + +| Tool | Trigger | Precondition | Guarantee | Failure mode | +|------|---------|--------------|-----------|--------------| +| `analyze_repository` | Initial analysis or after edit | None | Latest run registered, cache policy honored | Analysis error, returns schema violation | +| `start_controlled_change` | Before edit intent | Valid run exists for root | Intent created, blast radius computed, `edit_allowed` returned | `status: needs_analysis` (no run), `queued` (foreign intent), `blocked` (overlap) | +| `finish_controlled_change` | After edit cycle | Intent active, changed files named | Scope reconciled, verification run, receipt issued, intent cleared | `unverified` (missing after-run), `violated` (scope creep), `missing_evidence` (dirty tree) | + +An MCP session holds exactly one active change-control intent. Calling `start_controlled_change` again before finishing evicts the prior intent without recovery. + +### Implementation-context exactness + +`get_implementation_context` returns a bounded projection artifact saved to the session. `get_implementation_context_page` must retrieve facet pages _only_ from that saved artifact. If the requested facet or digest is not found, the tool returns `not_found` or `mismatch` statuses, never recomputes fresh context to provide exact evidence. + +### Help contract + +`help` with `topic=overview` returns a compact topic index. Requests with `compact=true` include anti-patterns; normal (default) requests add warnings. Topics must correspond to enforced context-governance response contracts (e.g., `change-control` describes partial_enforce start responses, `engineering_memory` describes get_memory_projection_page continuation). + +### Memory synchronization + +MCP help topics and claim-guard responses are synchronized with engineering memory governance rules. Negation handling (not/don't/never windows) mirrors memory record negation logic. Claim guard returns warnings for positive denial phrases in scope keywords that still flag health overstatement. + +### Observability instrumentation + +`analyze_repository` wraps previously-uninstrumented IO in observability spans: `pipeline.baseline`, `pipeline.cache_load`, `pipeline.report`. The CLI path is not yet instrumented with these same spans (parity follow-up pending). + +## Implementation map + +```mermaid +graph TD + A[analyze_repository] -->|registers run| B[start_controlled_change] + B -->|edit_allowed true| C[edit scope] + C -->|changed files| D[analyze_repository] + D -->|after_run_id| E[finish_controlled_change] + E -->|scope reconciliation| F{verification} + F -->|accepted| G[intent cleared] + F -->|unverified| H[next_step returned] + F -->|violated| I[scope mismatch] + + J[get_implementation_context] -->|session projection| K[get_implementation_context_page] + K -->|facet pages| K + + L[get_production_triage] -->|first pass triage| M[list_findings] + M -->|broader list| N[get_finding] +``` + +Memory-aware tools require `get_relevant_memory` after `start_controlled_change` returns `edit_allowed=true`: + +- `root` parameter is required (intent_id alone fails validation) +- Read contract warnings, stale decisions, and `contradiction_note` alerts +- Do not treat `draft`, `inferred`, or excluded stale records as established facts +- If a `contradiction_note` exists for your scope, surface it before editing + +## Failure modes + +### Concurrent intent collision + +- **Condition**: `start_controlled_change` detects active foreign intent overlapping declared scope. +- **Response**: `status: queued` with queue info (unless `on_conflict=queue` omits the queue). +- **Recovery**: `manage_change_intent(action="promote")` when foreign intent clears, then retry `start_controlled_change`. +- **Risk**: Session holds only one trackable intent. Do not call `start_controlled_change` again without promoting or clearing the prior intent. + +### Unverified finish + +- **Condition**: After-run missing, incomplete, or before-run identical to after-run (for Python structural patches). +- **Response**: `status: unverified`, `next_step` provided. +- **Recovery**: Run `analyze_repository` with a new `run_id`, then call `finish_controlled_change` again on the same `intent_id` with the new `after_run_id`. + +### Violated scope + +- **Condition**: Out-of-scope files modified and not accounted for in dirty snapshot. +- **Response**: `status: violated`, `finish_block_reason: own_unscoped_dirty` (only if `CODECLONE_STRICT_FINISH` truthy). +- **Recovery**: Remove out-of-scope changes, or widen scope via `start_controlled_change(root=..., scope=...)` and retry `finish` on new intent. + +### Missing evidence + +- **Condition**: In-scope files edited during start snapshot but not reported in finish `changed_files` (under-reported in-scope dirty). +- **Response**: `status: unverified`, `reason: workspace_hygiene`, `finish_block_reason: missing_evidence`. (The intent stays active — this is a hygiene block, not a scope `violated`.) +- **Recovery**: Re-run `analyze_repository`, list all in-scope changed files, and call `finish_controlled_change(changed_files=[...])` with complete evidence. + +### Context page mismatch + +- **Condition**: `get_implementation_context_page` requested with facet key or projection digest not in saved session artifact. +- **Response**: `not_found` or `mismatch` status, no fresh recomputation. +- **Recovery**: Call `get_implementation_context(root=..., scope=...)` to regenerate and save a new projection artifact, then retry the facet page request. + +## Verification + +### Required tests + +- `tests/test_mcp_service.py`: MCP service lifecycle and tool schema registration. +- `tests/test_mcp_server.py`: Server startup, shutdown, and HTTP auth. +- `tests/test_mcp_tools.py`: Tool invocation, parameter validation, and response schema. +- `tests/test_mcp_context_governance.py`: Change-control start/finish sequences, intent lifecycle. +- `tests/test_mcp_memory_*.py`: Memory synchronization and engineering memory integration. +- `tests/test_mcp_security_hardening.py`: Input sanitization and permission enforcement. +- `tests/test_mcp_tool_schema_snapshot.py`: Tool schema drift detection (breaking changes). + +Run: +```bash +uv run pytest -q tests/test_mcp_*.py tests/test_memory_mcp_sync.py tests/test_observability_mcp_registrar.py +``` + +### Contract boundaries + +- **What is verified**: Schema compliance, lifecycle state transitions, blast radius computation, scope reconciliation, patch contract (via `check_patch_contract` internally). +- **What is not verified**: Edit correctness (finish does not re-run structural checks on user edits; after-run verification is delegated). +- **Assertion scope**: Tools enforce contracts only within their declared scope and preconditions; violations outside declared scope are reported but do not block (unless `CODECLONE_STRICT_FINISH` is truthy). + +## Evidence index + +| Evidence | Corroboration | Path | +|----------|----------------|------| +| One trackable intent per session; eviction on re-start | path_only | `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py` | +| Implementation-context facet pages exact from saved artifact only | path_only | `codeclone/surfaces/mcp/_implementation_context_pages.py` | +| Help topics synchronized with context-governance contracts | path_only | `codeclone/surfaces/mcp/messages/help_topics.py` | +| MCP observability spans: baseline, cache_load, report | path_only | `codeclone/surfaces/mcp/session.py` | +| Claim guard negation handling mirrors memory governance | unverified | `codeclone/surfaces/mcp/_claim_guard.py` | + +**Key**: `supported` = assert as current fact; `path_only` = background framing only, do not present as confirmed evidence; `unverified` = do not assert as current behavior. diff --git a/docs/internal/contracts/memory-schema.md b/docs/internal/contracts/memory-schema.md new file mode 100644 index 00000000..e2d33fe5 --- /dev/null +++ b/docs/internal/contracts/memory-schema.md @@ -0,0 +1,87 @@ +--- +title: "Contract: memory schema and quarantine" +audience: internal +doc_type: contract +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Engineering Memory SQLite storage enforces durability, provenance, and staleness contracts. This document defines schema invariants, failure modes, and quarantine rules that preserve memory integrity across concurrent writes, process exits, and repository drift. + +## Contracts + +| Contract | Enforcement | Risk | +|----------|-------------|------| +| **Synchronous=FULL** | Memory store fsync's every commit; intent/audit stores use NORMAL (loss-tolerable ephemeral state). Confusing the two databases voids durability guarantees. | Unclean process exit + power loss = silent record loss if write path uses wrong database handle. | +| **Commit-anchored provenance** | `MemoryRecord.created_at_commit` is populated at write time (and `verified_at_commit` on re-verification); staleness drifts vs. these commit anchors, never vs. disk inventory. Staleness rules: if linked subject file deleted and `subject_path` is Python (`.py`), mark stale with `linked_path_missing`; non-Python subjects ignored (docs, config files not in report inventory). | Subject inventory is incomplete (report only lists `.py` files). Applying `linked_path_missing` to `.md` or `.toml` subjects causes false stale markers and data loss under vacuum. | +| **No inventory-anchored vacuum** | Vacuum rules must never delete records based on subject absence from disk inventory alone. Deletion requires explicit evidence: stale markers, superseded trajectories, or archival intent. | Vacuum deletes valid memory for subjects outside canonical report scope (README, config files, vendor packages) → knowledge loss. | +| **Schema migration gates** | New column additions use `_add_column_if_missing()`; schema version bumps co-change version constant in tests and version docs. Missing the test bump masks migration failures in CI. | Inconsistent version state across tests, code, and docs; silent failures in migration. | +| **Embedding-vector coercion** | Guard on `numbers.Real` (excluding `bool`), never `isinstance(str \| int \| float)`. Real fastembed outputs numpy.float32 scalars, not Python float subclasses. | Coercion guards fail on numpy types; embeddings silently dropped or truncated. | + +## Implementation map + +```mermaid +graph LR + A[MemoryRecord write
at scope X] --> B["created_at_commit
populated"] + B --> C["MemoryStore
synchronous=FULL"] + C --> D["Fsync'd to disk"] + E[Staleness engine
drift vs anchor] --> F{"Subject .py?"} + F -->|yes| G["Check linked_path_missing
vs anchor commit"] + F -->|no| H["Ignore missing
from inventory"] + I["Vacuum decision"] --> J{"Stale marker
or explicit
supersede?"} + J -->|yes| K["Delete record"] + J -->|no| L["Keep record
even if .py
missing from
report"] + style C fill:#f99 + style D fill:#f99 + style K fill:#f99 +``` + +## Failure modes + +1. **Durability loss**: Using intent/audit store handle for memory writes → records written with NORMAL sync → power loss loses unfsynced writes. Fix: use separate `MemoryStore.connection()` handle; never cross-connect. + +2. **False stale markers on non-Python subjects**: `staleness._subject_inventory_stale_reason()` checks if subject missing from `inventory_paths_from_report()` (which returns only `.py` files), marks `.md`, `.toml`, `.js` subjects as stale with `linked_path_missing`. Result: vacuum deletes documentation memory under false staleness. Fix: gate `linked_path_missing` reason to `.py` subjects only. + +3. **Silent schema mismatch**: Code bumps `ENGINEERING_MEMORY_SCHEMA_VERSION = "1.8"`, adds column `X` via migration, but omits version bump in `tests/test_memory_schema.py`. Test database built at old version `1.7`, column `X` never created; inserts silently fail or return NULL for `X`. Fix: `_record_schema_migration()` must auto-sync version constant and test fixture. + +4. **Embedding-vector loss**: Guard uses `isinstance(x, (int, float))`, receives `numpy.float32(0.5)` (which does not match), coercion skipped, embedding dropped. Fix: use `isinstance(x, numbers.Real) and not isinstance(x, bool)`. + +5. **Vacuum cascades**: Intent projection job deletes parent trajectory record T1. Orphaned child experience records E1, E2 remain in `memory_experiences` (no foreign key constraint). Next query hits orphans; reconciliation deferred or skipped. Fix: add ON DELETE CASCADE to trajectory→experience foreign keys, or implement explicit orphan cleanup in vacuum. + +## Verification + +Required test coverage: + +| Test file | Coverage | +|-----------|----------| +| `tests/test_memory_schema.py` | Schema version, column presence, migration idempotency. | +| `tests/test_memory_durability.py` | Fsync on MemoryStore, NORMAL on intent store, unclean exit recovery. | +| `tests/test_memory_governance.py` | Staleness rules, inventory anchoring, non-Python subject preservation. | +| `tests/test_memory_governance_vacuum_config_coverage.py` | Vacuum decision gates, stale marker precedence. | +| `tests/test_memory_experience_distillation.py` | Embedding coercion, numpy.float32 scalars, numbers.Real guard. | +| `tests/test_memory_extractors_repo.py` | Inventory accuracy: only `.py` files listed. | + +Run before commit: +```bash +uv run pytest -q tests/test_memory_schema.py tests/test_memory_durability.py \ + tests/test_memory_governance.py tests/test_memory_experience_distillation.py +``` + +## Evidence index + +- **mem-08c5f5411e1c425ca98dcce2ad00cc0f** (path_only): Memory SQLite uses synchronous=FULL; intent/audit stores use NORMAL. Reference: `codeclone/memory/schema.py`. + +- **mem-5726b2105f674ef380aac4f4df932933** (path_only): Commit-anchored provenance required; staleness must drift vs. anchor commit, not inventory. Reference: `codeclone/memory/staleness.py`. + +- **mem-829156ad116c4757aae082b6fc42e48b** (path_only): `staleness._subject_inventory_stale_reason()` falsely marks non-Python subjects (.md, .toml, .js) stale with `linked_path_missing` because report only lists `.py` files. Fix: gate reason to `.py` subjects only. Reference: `codeclone/memory/staleness.py`. + +- **mem-8293d87dc4744816a53e41d5624ebdec** (path_only): Experience distillation N+1: `memory.experience.distill` runs ~18 writes per experience. Fix: batch insert. Reference: `codeclone/memory/experience/distiller.py`. + +- **mem-f4a92cec90b14319a3c24135d0ab6cde** (path_only): Real fastembed embed() yields numpy.float32 scalars. Coercion must guard on `numbers.Real` (excluding `bool`), never isinstance(str|int|float). Reference: `codeclone/memory/embedding/fastembed_provider.py`. + +- **mem-15e3f19498ef46a2bc3f8c24306e7e41** (path_only): Schema migrations reuse `_add_column_if_missing()` and `_record_schema_migration()`. Version bumps co-change test constant. Reference: `codeclone/memory/schema_migrate.py`. + +- **mem-53f8a3942268431c9d42185e576e197d** (path_only): `distill_experiences` self-tripped duplicated_branches via consecutive trivial continue guards. Avoid ≥2 sequential trivial continue/return-None guards. Reference: `codeclone/memory/experience/distiller.py`. diff --git a/docs/internal/contracts/report-schema.md b/docs/internal/contracts/report-schema.md new file mode 100644 index 00000000..60f07c0f --- /dev/null +++ b/docs/internal/contracts/report-schema.md @@ -0,0 +1,88 @@ +--- +title: "Contract: report schema and digest identity" +audience: internal +doc_type: contract +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The report schema contract defines the structural format, versioning, and digest identity guarantees for CodeClone JSON, HTML, Markdown, SARIF, and text reports. Reports are the primary artifact surface for analysis results, serving CLI consumers, MCP tools, external CI/CD systems, and IDE integrations. This contract governs: + +- **Schema versioning and stability**: `REPORT_SCHEMA_VERSION` and per-artifact version constants +- **Structural boundaries**: required and optional sections +- **Output path contracts**: default report locations and customization rules +- **Failure modes**: partial reports, schema drift, path traversal in custom paths + +## Contracts + +| Constant | Scope | Mutation policy | +|----------|-------|------------------| +| `REPORT_SCHEMA_VERSION` | Canonical JSON report; all derived formats are rendered from it | Bump only for a structural contract change; document the change in `codeclone/contracts/__init__.py` | +| `BASELINE_SCHEMA_VERSION` | `codeclone.baseline.json`; immutable artifact | Never auto-upgrade; a version bump requires an explicit baseline regeneration | +| `CACHE_VERSION` | `.cache/codeclone/` analysis cache | Bump invalidates all existing cache entries | +| `BASELINE_FINGERPRINT_VERSION` | Baseline integrity fingerprint | Never change without a reviewed migration (see the CLAUDE.md hard-boundaries rule) | + +Exact current values live in `codeclone/contracts/__init__.py` — read from there directly rather than copying a number into a doc, since these constants are the single source of truth and drift silently otherwise. + +### Report formats + +| Format | Flag | Default path when FILE is omitted | +|--------|------|-------------------------------------| +| JSON (canonical) | `--json [FILE]` | `.codeclone/report.json` | +| HTML | `--html [FILE]` | `.codeclone/report.html` | +| Markdown | `--md [FILE]` | `.codeclone/report.md` | +| SARIF 2.1.0 | `--sarif [FILE]` | `.codeclone/report.sarif` | +| Plain text | `--text [FILE]` | `.codeclone/report.txt` | + +The JSON report is the canonical artifact; HTML, Markdown, SARIF, and text are rendered from the same in-memory report structure produced by one analysis run, not separately regenerated from a prior JSON file on disk. + +### Output path contract + +A custom report path must resolve inside the repository root. Paths that would traverse outside the root are rejected rather than silently written elsewhere — see the same repo-relative containment invariant documented for the analysis cache in `docs/internal/surfaces/baseline-cache-report.md`. + +## Implementation map + +```mermaid +graph LR + A["Analysis run"] --> B["Canonical report (in-memory + report.json)"] + B --> C["HTML renderer"] + B --> D["Markdown renderer"] + B --> E["SARIF renderer"] + B --> F["Text renderer"] + B --> G["MCP get_report_section, get_run_summary"] +``` + +Report assembly and rendering live under `codeclone/report/` (per-format renderers under `codeclone/report/renderers/`, HTML-specific composition under `codeclone/report/html/`). Schema version constants live in `codeclone/contracts/__init__.py`. The MCP-facing read path for report sections is `codeclone/surfaces/mcp/_report_section.py`. + +## Failure modes + +| Mode | Symptom | Recovery | +|------|---------|----------| +| Reader built against an older `REPORT_SCHEMA_VERSION` | Reader errors on unrecognized sections instead of skipping them | Reader must treat unknown top-level report sections as forward-compatible and skip them | +| Custom `--json`/`--html`/... path escapes the repository root | Rejected rather than written | Use a path inside the repository, or the default `.codeclone/report.` | +| Stale baseline compared against a newer schema | `compared_without_valid_baseline` / untrusted baseline signal | Regenerate the baseline with `--update-baseline` on the same schema version | + +## Verification + +- `uv run pytest -q tests/test_report.py tests/test_html_report.py` +- `uv run pytest -q tests/test_baseline.py` for baseline schema and fingerprint invariants +- `uv run pytest -q tests/test_mcp_service.py -k report` for the MCP read path + +### Manual verification + +```bash +codeclone . --json +jq '.schema' .codeclone/report.json +``` + +## Evidence index + +| Claim | Corroboration | Source | +|-------|----------------|--------| +| Report format flags and default paths | supported | `codeclone --help` (Reporting section), verified live this session | +| `REPORT_SCHEMA_VERSION` / `BASELINE_SCHEMA_VERSION` / `CACHE_VERSION` are read from `codeclone/contracts/__init__.py`, never copied elsewhere | supported | CLAUDE.md hard-boundaries rule; `codeclone/contracts/__init__.py` | +| Cache wire path containment (repo-relative only) | path_only | mem-231f686b92ba4103a6322e683ead9e6a, mem-bc26f97ebb0944a9986de756b857c9d3 (`codeclone/cache/projection.py`) | +| MCP report read path is `codeclone/surfaces/mcp/_report_section.py` | supported | verified directly against the repository tree this session | diff --git a/docs/internal/contracts/setup-apply.md b/docs/internal/contracts/setup-apply.md new file mode 100644 index 00000000..986e26d9 --- /dev/null +++ b/docs/internal/contracts/setup-apply.md @@ -0,0 +1,104 @@ +--- +title: "Contract: setup apply mutation" +audience: internal +doc_type: contract +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The `setup apply` command writes governance configuration according to a recomputed plan. It writes **exactly two files**: it merges the `[tool.codeclone]` section into `pyproject.toml` and appends CodeClone cache paths to `.gitignore`. It does **not** create or initialize the audit database, the intent registry, or the Engineering Memory store — those are created lazily at runtime by the features that use them. This contract guarantees: + +1. **Mutation is intentional:** apply requires explicit user confirmation or non-interactive flags to write. +2. **Plan binding:** apply can bind to a specific plan via `--plan-id` to guard against stale or out-of-order execution. +3. **Readiness authority:** `derive_readiness` is the sole readiness authority; `apply` honors readiness from the plan but does not override it. Readiness is scored per capability (14 capabilities across 4 groups) on installation/configuration/runtime axes. + +## Contracts + +### CLI Safety + +| Flag | Behavior | Required context | +|------|----------|------------------| +| `--yes` | Non-interactive mode; apply writes without prompting | Use in CI/automation; paired with dry-run validation upstream | +| `--dry-run` | Preview writes without mutation; exit 0 if feasible | Use before `--yes` to verify plan safety | +| `--plan-id ` | Bind apply to a specific plan; return status=stale_plan (exit 2) if plan ID mismatch | Use when apply follows plan; ensures apply/plan coherence | +| None (interactive) | Prompt user for confirmation; no --yes required | Default shell/IDE mode | + +### Non-interactive apply + +`--yes` and `--dry-run` are **peer flags**, not conflicting: +- `--dry-run` alone → preview, exit 0 (no mutation) +- `--dry-run --yes` → allowed, exit 0 (no mutation) +- `--yes` alone → write (mutation) +- Neither → interactive prompt + +If `--plan-id` mismatches or plan is stale, return **status=stale_plan**, exit code 2, before writing. + +### Readiness binding + +Apply recomputes the plan (which carries derived readiness). It **never** reinterprets or overrides readiness in the presentation layer. Readiness is governed by `derive_readiness()` in `codeclone/surfaces/cli/setup/engine/rollup.py`. + +## Implementation map + +```mermaid +graph TD + A["codeclone setup apply"] -->|parse flags| B["--yes, --plan-id, --dry-run"] + B -->|recompute plan| C["plan.json payload with plan_id"] + C -->|validate --plan-id| D{plan_id matches?} + D -->|no| E["return status=stale_plan, exit 2"] + D -->|yes| F["derive_readiness
per capability"] + F -->|check readiness| G{readiness approved?} + G -->|no| H["return reason, recommended_action
no mutation"] + G -->|yes| I{--dry-run?} + I -->|yes| J["preview writes
exit 0"] + I -->|no| K{--yes or
interactive ok?} + K -->|no confirmation| L["return, exit 1"] + K -->|yes| M["merge [tool.codeclone] into pyproject.toml,
append .gitignore"] + M -->|success| N["return status=applied,
exit 0"] +``` + +Subject paths: +- `codeclone/surfaces/cli/setup/main.py` — entry point, CLI parsing +- `codeclone/surfaces/cli/setup/engine/apply.py` — core apply logic (pyproject merge + gitignore append) +- `codeclone/surfaces/cli/setup/engine/capabilities.py` — capability registry (14 capabilities, 4 groups, 3 axes) +- `codeclone/surfaces/cli/setup/engine/rollup.py` — `derive_readiness()` and `describe_*` handlers (no readiness override) +- `codeclone/surfaces/cli/setup/engine/plan.py` — plan computation and `plan_id` + +## Failure modes + +| Condition | Response | Recovery | +|-----------|----------|----------| +| Plan file not found | status=plan_not_found, exit 1 | Run `setup plan` first | +| `--plan-id` mismatch (UUID does not match plan) | status=stale_plan, exit 2 | Use correct plan ID or omit `--plan-id` to reload | +| Readiness check fails (capability not ready) | return reason, recommended_action; no write | Address readiness check (e.g., audit is disabled) | +| `--yes` missing in non-interactive environment | status=requires_confirmation, exit 1 | Add `--yes` or `--dry-run` | +| Write permission denied (`pyproject.toml` / `.gitignore`) | status=permission_error, exit 1 | Verify ownership and umask on the repo root | + +## Verification + +### Unit tests + +File: `tests/test_cli_setup.py` + +- Verify `--yes` allows non-interactive write +- Verify `--dry-run` previews without mutation +- Verify `--plan-id` mismatch returns exit 2, status=stale_plan +- Verify interactive mode prompts user; respects user choice +- Verify readiness is respected; no override in presentation +- Verify contract snapshot `tests/fixtures/contract_snapshots/setup_snapshot_v1.json` captures correct readiness states (ci_policy attention-when-no-flags, audit attention-when-disabled, governed=true reachable) + +### Integration contract + +- After apply with `status=applied`, `pyproject.toml` contains a valid `[tool.codeclone]` section and `.gitignore` covers CodeClone cache paths +- Config keys from `pyproject.toml` `[tool.codeclone.*]` must be loadable without merge errors +- `codeclone setup status` post-apply must reflect new state (e.g., `analysis` and `audit_and_intents` capabilities move toward configured) + +## Evidence index + +| Type | ID | Statement | Subject | +|------|----|-----------|--------- +| change_rationale | mem-a262a1d | CLI mutation safety: `--yes` required for non-interactive; `--plan-id` binds to plan; status=stale_plan on mismatch (exit 2) | codeclone/surfaces/cli/setup/main.py | +| change_rationale | mem-c91d7 | Readiness authority: derive_readiness (R1–R9) is SOLE authority; handlers must not override | codeclone/surfaces/cli/setup/engine/rollup.py | +| change_rationale | mem-a8e1ce | Setup tests: golden fixture updated for corrected readiness model | tests/test_cli_setup.py | diff --git a/docs/internal/runbooks/closure-review.md b/docs/internal/runbooks/closure-review.md new file mode 100644 index 00000000..8e327844 --- /dev/null +++ b/docs/internal/runbooks/closure-review.md @@ -0,0 +1,89 @@ +--- +title: "Runbook: closure review" +audience: internal +doc_type: runbook +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Closure review verifies that a controlled change completes end-to-end: intent declared, scope verified, changes applied and reconciled, patch contract met, and workflow state cleared. This runbook guides agents through the `finish_controlled_change` lifecycle, recoverable block states, and evidence reconciliation. + +## Contracts + +| Contract | Schema | Surfaces | +|----------|--------|----------| +| AUDIT_PROJECTION_VERSION | `audit-v1` | audit_events | +| PATCH_TRAIL_SCHEMA_VERSION | `1` | audit_events, change_control | +| IDE_GOVERNANCE_PROTOCOL_VERSION | `2` | change_control | + +Finish applies scope checks against the declared intent snapshot and the live git tree. A "clean" result clears the intent; "unverified" or "violated" leaves it active with a deterministic next_step. + +## Implementation map + +```mermaid +graph TB + A["finish_controlled_change
(intent_id, changed_files, after_run_id?)"] + B["Scope check:
compare snapshot vs. live tree"] + C{Status?} + D["clean: clear intent"] + E["unverified: return next_step
suggest analyze or re-run"] + F["violated: return next_step
scope overflow or missing evidence"] + G["audit_trail: patch_trail
+ receipt generated"] + + A --> B + B --> C + C -->|declared scope + evidence aligned| D + C -->|before/after mismatch or stale| E + C -->|overflow or unattributed dirty| F + D --> G + E --> G + F --> G +``` + +Finish execution order (post-edit, post-analysis if required by profile): + +1. **Scope reconciliation**: compare intent's declared scope against changed_files evidence and git tree +2. **Patch contract derivation**: compute verification profile (python_structural / governance_config / documentation_only / non_python_patch) +3. **Verification**: structural checks (cohesion, coupling, complexity, coverage) when profile requires +4. **Patch trail assembly**: collect declared, changed, untouched, scope check result, verification, workspace hygiene flags +5. **Receipt generation**: `create_review_receipt` with intent state and findings +6. **Intent clearance**: if status is "accepted" (or "accepted_with_external_changes"), mark intent cleared in session + +## Failure modes + +| Mode | Trigger | Recovery | +|------|---------|----------| +| `missing_evidence` | Changed files in scope but not reported to finish | Add missing files to `changed_files` array, retry finish with same `intent_id` | +| `foreign_dirty_overlap` | Foreign agent holds active intent in overlapping scope | Queue own intent, wait for foreign to clear, call `manage_change_intent(action="promote")` | +| `own_unscoped_dirty` | Own unattributed changes outside declared scope | Either remove out-of-scope changes (if unintended) or call `start_controlled_change` with expanded scope | +| `unverified` | After-run mismatch or Python structural verification required but absent | Call `analyze_repository` with new run_id, pass `after_run_id` to finish again on same `intent_id` | +| `violated` | Scope check found overflow or uncorrected evidence gap | Fix changed files or widen scope via new `start_controlled_change`, then retry finish on the expanded intent | + +## Verification + +**Pre-finish checklist:** + +- [ ] Intent is active (`start_controlled_change` returned `edit_allowed=true`) +- [ ] All edits are within declared scope +- [ ] If profile requires after-run (python_structural or governance_config): `analyze_repository` completed with new run_id +- [ ] If complexity/incident detected: `manage_engineering_memory(action=record_candidate)` called before finish +- [ ] `changed_files` list passed to finish includes all modified files in scope (git diff confirms) + +**Post-finish checks:** + +- If `status: "accepted"` or `"accepted_with_external_changes"`: patch is closed, intent cleared, safe to proceed +- If `status: "unverified"` or `"violated"`: follow the returned `next_step`; intent remains active; do not claim patch verified + +## Evidence index + +| Evidence | Corroboration | Source | +|----------|---------------|--------| +| Audit trail persists patch_trail and receipt as durably stored artifacts | path_only | codeclone/audit/__init__.py (mem-e228e6288c444029ae1e5c020f2f6678) | +| MCP session holds exactly one trackable active intent; calling start again evicts the prior one | path_only | codeclone/surfaces/mcp/_workspace_intent_lifecycle.py (mem-527db78f1ce24eb98ad06ce507f0de93) | +| Scope check reconciles intent snapshot against live tree; unattributed out-of-scope changes block only if CODECLONE_STRICT_FINISH is set | path_only | codeclone/surfaces/mcp/_workspace_intent_lifecycle.py | +| Finish clears intent only on accepted status; unverified/violated intents remain active with next_step hint | path_only | codeclone/surfaces/mcp/finish_controlled_change (implementation) | +| PID liveness for foreign intent owners is tri-state: unknown (PermissionError), recoverable (dead), or active | path_only | codeclone/surfaces/mcp/_workspace_intent_lifecycle.py (mem-6859f67ef1234556981b7ccb36673f77) | +| Implementation-context facet pages return exact state from saved MCP session artifact, never recomputed | path_only | codeclone/surfaces/mcp/_implementation_context_pages.py (mem-0ebd1dfb9f494c4e9909607bd8832ccf) | diff --git a/docs/internal/runbooks/docs-regeneration.md b/docs/internal/runbooks/docs-regeneration.md new file mode 100644 index 00000000..b52baeb1 --- /dev/null +++ b/docs/internal/runbooks/docs-regeneration.md @@ -0,0 +1,83 @@ +--- +title: "Runbook: docs regeneration" +audience: internal +doc_type: runbook +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The CodeClone documentation system generates internal Markdown pages through a deterministic pipeline that sources specifications from CodeClone MCP analysis artifacts, memory records, and repository inventory. This runbook documents the contracts, implementation flow, and failure recovery for maintainers managing docs regeneration cycles. + +The pipeline is scoped to bounded, self-contained documentation tasks and does not mutate CodeClone state artifacts (`.codeclone/**`) during regeneration. + +## Contracts + +The documentation generation system is governed by the following contracts: + +- **Boundary:** Scratch state (intermediate files, working directories) lives outside the repository in `$SCRATCH_DIR`; never `.codeclone/**` (hard do_not_touch boundary). +- **Evidence chain:** Task context derives from `codeclone_mcp_module_map` packet (module count, surfaces, contracts, memory records) + `docs_inventory.existing_pages` for link validation. +- **Memory interpretation:** Context records carry `corroboration_status`: + - `"supported"` — assert as current fact and cite in Evidence index. + - `"path_only"` or `"no_checkable_claims"` — use as background framing only; do not present as freshly confirmed behavior. + - `"unverified"` — do not assert as current behavior. +- **Link validation:** Never link to pages absent from `docs_inventory.existing_pages`; mention concepts in plain text instead (broken links fail Zensical strict build). +- **Line budget:** Maximum 300 lines; no filler, marketing prose, or unsupported claims. + +## Implementation map + +```mermaid +graph LR + A["codeclone MCP"] -->|module_map_summary| B["packet_context"] + A -->|memory_records| B + A -->|docs_inventory| B + B -->|instruction
docs_system surface
target_path| C["writer model"] + C -->|generate task
within scope| D["Markdown + YAML"] + D -->|verify links
against inventory| E["Zensical build check"] + E -->|pass| F["docs/internal/*"] + E -->|fail| G["link error
or schema violation"] +``` + +The writer model receives: + +1. **Module context:** edge count (3322), module count (769), available surfaces (docs_system). +2. **Memory lane:** risk notes with subject paths and corroboration status. +3. **Inventory:** explicit list of existing pages for link validation. +4. **Instruction:** page id, target path, required sections, word budget, forbidden terms/phrases. + +The writer generates one Markdown page with YAML front matter (source_packet, status, source_commit) and required sections: Purpose, Contracts, Implementation map, Failure modes, Verification, Evidence index. + +## Failure modes + +| Symptom | Root cause | Recovery | +|---------|-----------|----------| +| Broken link in generated page | Writer invented a page not in `docs_inventory.existing_pages` | Remove invented link; mention concept in plain text; regenerate and reverify with Zensical | +| "mcp_tools: \[\]" in task context | Filter logic in `generate_doc_tasks.py` used literal "mcp_workflow" check instead of `select_by_surface` | Verify filter keys on tool surface tags; add explicit surface mapping for topic pages | +| Memory record treated as verified behavior but marked "unverified" | Writer asserted corroboration_status="unverified" as current fact | Read `corroboration_status` field before assertion; move to Evidence index as "background framing only" if unverified | +| Scratch files left in `.codeclone/**` after run | Pipeline did not honor do_not_touch boundary | Check `$SCRATCH_DIR` env var is set and write-only to that path; verify pre-commit hook gates state artifacts | +| Marketing filler or a forbidden internal term slips into output | Writer model not aware of quality policy excerpt | Regenerate with updated instruction block; scan output against `configs/quality_policy.json`'s forbidden-term lists before commit | + +## Verification + +After regeneration, verify the page before commit: + +1. **YAML front matter:** Confirm `source_packet`, `source_commit`, `status: draft` are verbatim. +2. **Required sections:** All six present (Purpose, Contracts, Implementation map, Failure modes, Verification, Evidence index). +3. **Mermaid diagram:** At least one graph or flowchart block renders. +4. **Link validation:** Run Zensical build; all internal links must resolve to pages in `docs_inventory.existing_pages`. +5. **Forbidden terms:** Scan output against `configs/quality_policy.json`'s `forbidden_public_terms` (internal review IDs and artifact paths) and `forbidden_generic_phrases` (marketing filler) lists. +6. **Memory corroboration:** Evidence index cites only records with corroboration_status="supported"; others noted as "background framing only" if referenced. +7. **Full pre-commit:** Run `uv run pre-commit run --all-files` as the final gate; metrics baseline gates the full suite, not per-intent verify. + +## Evidence index + +- **mem-02a4de27895f467b8ad94f62655ff7f5** (path_only): `generate_doc_tasks.py` filter logic must key on each tool's surface tags via `select_by_surface`, not literal "mcp_workflow" check, or topic pages receive zero tool names. +- **mem-451195911e41467a840244ac0577b88e** (unverified): `.codeclone/**` is unconditional do_not_touch boundary; docs pipeline scratch state must use out-of-repo `$SCRATCH_DIR`, never `.codeclone/docgen/**`. +- **mem-5664eed913e24fc5bf402e0ea33ee54d** (path_only): Haiku writer invented cross-link to deleted old docs page during pilot; Zensical strict build caught it. Task instructions now explicitly forbid linking to pages absent from `docs_inventory.existing_pages`. +- **mem-68c45fd523c3486f967e7e413b329d09** (path_only): Full pre-commit CodeClone hook gates on metrics baseline, not per-intent before/after deltas. Always run full pre-commit suite as final check, not just per-intent verify. + +--- + +**Last updated:** 2026-07-06 | **Source packet:** codeclone_mcp_module_map | **Status:** draft diff --git a/docs/internal/runbooks/model-evaluation.md b/docs/internal/runbooks/model-evaluation.md new file mode 100644 index 00000000..5a01eb88 --- /dev/null +++ b/docs/internal/runbooks/model-evaluation.md @@ -0,0 +1,121 @@ +--- +title: "Runbook: model evaluation" +audience: internal +doc_type: runbook +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Model evaluation in CodeClone measures code-structural health across seven weighted dimensions: clones, cohesion, complexity, coupling, coverage, dead code, and dependencies. The health score (0–100) aggregates these signals to guide change-control decisions and report on repository quality trends. + +This runbook covers: +- The evaluation contract and scoring model +- Implementation structure and dependency map +- Failure modes and recovery +- Verification of health calculations + +## Contracts + +| Contract ID | Type | Value | +|-------------|------|-------| +| `HEALTH_WEIGHTS` | mapping | `{clones: 0.25, cohesion: 0.15, complexity: 0.2, coupling: 0.1, coverage: 0.1, dead_code: 0.1, dependencies: 0.1}` | +| `HEALTH_DEPENDENCY_CYCLE_PENALTY` | int | 25 | +| `HEALTH_DEPENDENCY_DEPTH_LEVEL_PENALTY` | int | 4 | +| `HEALTH_DEPENDENCY_DEPTH_AVG_MULTIPLIER` | float | 2.0 | +| `HEALTH_DEPENDENCY_DEPTH_P95_MARGIN` | int | 1 | +| `DEFAULT_HEALTH_THRESHOLD` | int | 60 | +| `REPORT_SCHEMA_VERSION` | str | 2.12 | + +Risk thresholds (independent from weights): +- `COMPLEXITY_RISK_LOW_MAX`: 10 +- `COMPLEXITY_RISK_MEDIUM_MAX`: 20 +- `COHESION_RISK_MEDIUM_MAX`: 3 +- `COUPLING_RISK_LOW_MAX`: 5 +- `COUPLING_RISK_MEDIUM_MAX`: 10 + +All constants defined in `codeclone/contracts/__init__.py`. + +## Implementation map + +```mermaid +graph TD + A["Health Score Calculation"] + B["Dimension Scorers"] + C["Clone Detector"] + D["Cohesion Analyzer"] + E["Complexity Meter"] + F["Coupling Mapper"] + G["Coverage Aggregator"] + H["Dead Code Scanner"] + I["Dependency Analyzer"] + + A -->|weighs| B + B -->|calls| C + B -->|calls| D + B -->|calls| E + B -->|calls| F + B -->|calls| G + B -->|calls| H + B -->|calls| I + + I -->|detects cycles| J["Cycle Penalty"] + I -->|measures depth| K["Depth Penalty"] + + C -->|outputs 0-100| L["Aggregated Score"] + D -->|outputs 0-100| L + E -->|outputs 0-100| L + F -->|outputs 0-100| L + G -->|outputs 0-100| L + H -->|outputs 0-100| L + K -->|adjusts score| L +``` + +Each dimension scorer normalizes its measure to [0, 100], where 100 is healthiest. The weighted sum produces the final health score. + +Dependency penalties apply after dimensional scoring: +- Cyclic dependencies deduct 25 points +- Each level of depth (beyond p95 margin) deducts 4 points +- Average depth multiplies the penalty by 2.0 + +## Failure modes + +| Mode | Cause | Detection | Recovery | +|------|-------|-----------|----------| +| **Score threshold clash** | A module below 60 (DEFAULT_HEALTH_THRESHOLD) but no individual finding | Risk thresholds fire before health aggregation | Review dimensional scores independently; narrow risk level if threshold is too strict | +| **Cyclic dependency undetected** | Cycle detector fails on complex graphs | Health penalty not applied; score inflated | Run `analyze_repository` with explicit dependency cycle check | +| **Coverage baseline absent** | No coverage join provided in current run | Coverage dimension defaults to prior baseline | Pass external Cobertura XML or explicit coverage signal | +| **Depth calculation divergence** | P95 percentile calculation disagrees across runs | Inconsistent penalties between commits | Verify that sorted depth samples include all module depths; re-run with identical sample set | +| **Weight normalization error** | Weights don't sum to 1.0 (e.g., after config edit) | Score doesn't map to [0, 100] | Audit `HEALTH_WEIGHTS` mapping; run `uv run pytest -q` to catch normalization failures | + +## Verification + +1. **Unit test coverage**: Dimension scorers and health constants are exercised in `tests/test_report.py` and `tests/test_defaults_contract.py` (there is no dedicated `tests/test_health.py`). +2. **Contract schema**: Verify `REPORT_SCHEMA_VERSION` matches deployed report version (current: 2.12). +3. **Threshold alignment**: Confirm that `DEFAULT_HEALTH_THRESHOLD` (60) is intentional; lower thresholds increase sensitive findings. +4. **Weight audit**: Ensure `HEALTH_WEIGHTS` sum to 1.0 before deployment. +5. **Cyclic dependency test**: Run `codeclone .` on a known cyclic codebase and inspect the coupling findings in the report; verify the penalty is applied. + +Pre-commit gate: +```bash +uv run pre-commit run --all-files +``` + +Full test suite: +```bash +uv run pytest -q tests/test_report.py tests/test_defaults_contract.py +``` + +## Evidence index + +| Evidence | Status | Location | +|----------|--------|----------| +| `HEALTH_WEIGHTS` mapping | Supported | `codeclone/contracts/__init__.py` | +| `HEALTH_DEPENDENCY_CYCLE_PENALTY` constant | Supported | `codeclone/contracts/__init__.py` | +| Depth penalty formula (4 points per level) | Supported | `codeclone/contracts/__init__.py` | +| Report schema version 2.12 | Supported | `codeclone/contracts/__init__.py` | +| Risk thresholds for complexity, cohesion, coupling | Supported | `codeclone/contracts/__init__.py` | +| Unit test coverage for health calculation | Path only | `tests/test_report.py`, `tests/test_defaults_contract.py` | +| Mermaid dependency graph | Supported | Derived from contract structure | diff --git a/docs/internal/runbooks/release-review.md b/docs/internal/runbooks/release-review.md new file mode 100644 index 00000000..3aedb5c6 --- /dev/null +++ b/docs/internal/runbooks/release-review.md @@ -0,0 +1,120 @@ +--- +title: "Runbook: release review" +audience: internal +doc_type: runbook +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Release review validates that a patch bundle ready for packaging meets structural contracts, passes change-control verification, and has durably recorded all relevant evidence. This runbook provides steps for MCP-backed release review and decision gates. + +## Contracts + +The release process expects these version and threshold contracts: + +| Contract | Value | Purpose | +|----------|-------|---------| +| `BASELINE_SCHEMA_VERSION` | 2.1 | Baseline artifact format agreement | +| `PATCH_TRAIL_SCHEMA_VERSION` | 1 | Audit trail format for patch forensics | +| `REPORT_SCHEMA_VERSION` | 2.12 | Report payload structure | +| `BASELINE_FINGERPRINT_VERSION` | 1 | Clone-detection fingerprint logic version | +| `CACHE_VERSION` | 2.10 | Analysis cache compatibility | +| `ENGINEERING_MEMORY_SCHEMA_VERSION` | 1.7 | Memory store schema | + +All artifacts must match these versions before release. Do not upgrade versions within a release; version changes require explicit contract amendment. + +## Implementation map + +```mermaid +graph TD + A["Release candidate branch"] -->|analyze_repository| B["Full structural run"] + B -->|get_run_summary| C{Hotspots or findings?} + C -->|Yes| D["Review each finding"] + C -->|No| E["get_blast_radius review"] + D -->|validate_review_claims| F["Claim check against report"} + E -->|all clear| G["start_controlled_change"] + F -->|all valid| G + G -->|status=active| H["Package + verify artifacts"] + H -->|after_run_id| I["finish_controlled_change"] + I -->|status=accepted| J["create_review_receipt"] + J -->|markdown receipt| K["Release approved"] + I -->|status=violated| L["Resolve scope violations"] + L -->|fix tree| I +``` + +**Pre-release workflow:** + +1. Ensure all patches under review have active intents: `manage_change_intent(action=list_workspace)` +2. Run `analyze_repository(root=...)` on the release branch to capture baseline-relative structural state +3. Call `get_run_summary()` to scan hotspots and findings +4. For each structural finding, invoke `get_finding()` and review evidence +5. Validate all review claims via `validate_review_claims()` against the canonical report +6. Call `check_patch_contract(mode='verify')` to confirm all patches meet the derived verification profile + +**Packaging + release gates:** + +7. When all structural checks pass, call `start_controlled_change()` with declared release scope +8. Confirm `status='active'` and `edit_allowed=true`; if `queued`, wait for foreign intents to clear +9. Apply packaging changes (version bumps, changelog, wheel metadata) +10. Run `analyze_repository()` again to capture post-edit state +11. Call `finish_controlled_change(intent_id=..., after_run_id=...)` to verify scope closure +12. If `status='accepted'` and `scope_check.status='clean'`, generate receipt: `create_review_receipt(intent_id=..., format='markdown')` +13. Archive the receipt in release notes; proceed to publish + +## Failure modes + +| Symptom | Root Cause | Recovery | +|---------|-----------|----------| +| `status: "unverified"` on finish | Before/after runs do not match; same scope changed under analysis | Re-run `analyze_repository` with a fresh `run_id`, pass `after_run_id` to `finish` again on the **same** `intent_id` | +| `status: "violated"` on finish | In-scope files were edited outside declared scope | Expand scope via `start_controlled_change` with wider scope, then retry finish on the new intent | +| `finish_block_reason: "missing_evidence"` | Changed files not reported to finish | List all changed files, pass as `changed_files=[...]` to finish; re-call analyze if Python structural files were touched | +| `status: "queued"` on start | Foreign intent active in workspace | Call `manage_change_intent(action=promote, intent_id=)` after foreign intent clears, or narrow scope to avoid overlap | +| Intent eviction: "Unknown change intent id" on finish | New intent declared via start before previous finish | Never call start twice without finish in between; one active intent per session | + +## Verification + +Use these commands to validate pre-release state: + +```bash +# Full structural analysis +codeclone . --json /tmp/release-run.json + +# Inspect the produced report for high-risk findings +jq '.findings' /tmp/release-run.json + +``` + +Claim validation before review is an MCP-only capability (`validate_review_claims`); there is no CLI equivalent. + +**MCP verification:** + +```python +# Get implementation context for release scope +get_implementation_context(root=..., scope={"packages": ["codeclone.surfaces.mcp", ...]}) + +# Confirm no unverified dependencies +check_patch_contract(mode='verify', scope=..., before_run_id=..., after_run_id=...) +``` + +All receipts must include: +- Blast radius and scope boundaries +- Count of reviewed findings and their baseline novelty +- Patch contract status (accepted / unverified / violated) +- Engineering memory records created during review (if any) + +## Evidence index + +This runbook depends on the following technical facts from Engineering Memory: + +- **mem-21b67e91cf2441f981daf7e0b51e4d34**: Packaging topology — MCP extras and edit-cycle isolation. Confirms that change-control workflows are MCP-surface-only; base CLI is read-only. [path_only: background on separation of concerns] + +- **mem-527db78f1ce24eb98ad06ce507f0de93**: MCP session intent exclusivity — exactly ONE trackable active intent per session. Critical for preventing intent eviction on premature start calls. [path_only: risk constraint] + +- **mem-6859f67ef1234556981b7ccb36673f77**: PID liveness tri-state — security hardening to detect unknown foreign owners. Affects intent locking and coordination visibility during release. [path_only: hardening context] + +- **mem-6e6628aa24504020ae2f1f8628365ddc**: Phase 30 freshness invariant — MCP runs capture mtime+size signatures and dirty snapshots to detect content rewrites. Ensures post-packaging analysis detects real changes. [path_only: drift detection model] + +See `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py`, `pyproject.toml`, and `codeclone/surfaces/mcp/_workspace_drift.py` for implementation. diff --git a/docs/internal/surfaces/analysis-engine.md b/docs/internal/surfaces/analysis-engine.md new file mode 100644 index 00000000..bb5bed8c --- /dev/null +++ b/docs/internal/surfaces/analysis-engine.md @@ -0,0 +1,89 @@ +--- +title: "Structural analysis engine" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The structural analysis engine is the canonical detector and categorizer for Python code quality findings. It processes repository source, extracts structural metrics, identifies design risks, and reports findings through the MCP query API. + +The engine is organized into functional surfaces — file discovery, analysis pipelines, domain models, block extraction, metrics computation, and finding classes — that feed a unified report contract. Clients access findings via deterministic queries and remediation guidance, never by re-deriving them. + +## Contracts + +| Contract | Value | Purpose | +|----------|-------|---------| +| `DEFAULT_BLOCK_MIN_LOC` | `20` | Minimum lines of code for a block to be eligible for block-level clone detection | +| `DEFAULT_BLOCK_MIN_STMT` | `8` | Minimum AST statement count for the same eligibility check | +| `CACHE_VERSION` | see `codeclone/contracts/__init__.py` | Analysis cache format; bumped on structural changes, invalidating all cached metrics | +| `HEALTH_WEIGHTS` | clones 0.25, complexity 0.2, cohesion 0.15, coupling 0.1, coverage 0.1, dead_code 0.1, dependencies 0.1 | Composite health score weighting across the seven structural dimensions | + +Exact current values live in `codeclone/contracts/__init__.py` — read from there directly rather than copying a number into a doc. + +## Implementation map + +```mermaid +graph TD + A["codeclone.scanner
file discovery, module naming"] --> B["codeclone.core
discovery, reachability"] + B --> C["codeclone.blocks
function/segment extraction"] + C --> D["codeclone.metrics
complexity, coupling, clones"] + D --> E["codeclone.findings
finding classes"] + E --> F["codeclone.report
canonical report assembly"] + G["codeclone.domain
AST, scope, models"] -.-> B + H["codeclone.analysis._module_walk"] -.-> D +``` + +**Pipeline flow:** + +1. **`codeclone.scanner`**: walks the filesystem (`iter_py_files`), applies `DEFAULT_EXCLUDES`, and derives module names from file paths. This is the file-discovery layer, not a caching or report-writing layer. +2. **`codeclone.core`**: parses Python AST and traces reachability, including bounded framework contracts (FastAPI lifecycle decorators, Pydantic `GenerateJsonSchema` hooks, Starlette/FastAPI route and app subclass hooks). +3. **`codeclone.blocks`**: extracts function and segment blocks, filtered by `DEFAULT_BLOCK_MIN_LOC` / `DEFAULT_BLOCK_MIN_STMT`. +4. **`codeclone.metrics`**: computes complexity, coupling, cohesion, and clone signals. +5. **`codeclone.findings`**: categorizes metrics into named finding groups (clones, cohesion, complexity, coupling, dead_code). +6. **`codeclone.report`**: assembles the canonical report consumed by CLI, MCP, and CI. + +**Key modules called out by Engineering Memory:** + +- `codeclone/analysis/_module_walk.py`: intra-module function-relationship resolution — detects method calls bound to `self`, `cls`, or same-module functions, keyed on the actual first parameter; staticmethods are excluded by design. +- `codeclone/analysis/phase_ledger.py`: micro-phase observability via a stdlib `PhaseLedger`, inert by default; timings are projected only as aggregate `pipeline.process` counters when the observer is enabled. +- `codeclone/metrics/dependencies.py`: dependency-graph sampling (`max_nodes`/`max_edges`/`node_id_fn`) shared by the Module Map and the Dependencies tab. +- `codeclone/findings/design/instance_methods.py`: has a duplicate bare-name Protocol/ABC interface check that diverges from the canonical alias-aware detection in `_module_walk` — a known determinism hazard (see Failure modes). + +## Failure modes + +| Mode | Symptom | Root cause | Mitigation | +|------|---------|------------|------------| +| Non-deterministic interface classification | Whether a class is treated as a Protocol/ABC implementer can vary by import alias style | `instance_methods.py` has a duplicate bare-name check that diverges from the canonical `_module_walk` Protocol/ABC-alias detection | Converge on one detection path in `_module_walk`; do not ship two Protocol/ABC implementations | +| Dead detector | `collect_instance_independent_methods` has zero callers | Phase 21 Cycle B (21.2–21.4) was never wired; deferred to avoid colliding with Phase 30 cache work | Complete the wiring before extending this detector further | +| Framework reachability blind spot | An entry point framework CodeClone doesn't recognize is treated as unreachable dead code | Reachability contracts are bounded to FastAPI, Pydantic, and Starlette hooks only, with no path-based exclusions | Flag unrecognized-framework repos explicitly rather than silently under-reporting reachability | +| Self-tripped structural findings when editing this engine | CodeClone's own clone/branch detectors flag new code added to `codeclone/analysis/`, `codeclone/findings/`, etc. | Repeated near-identical guard clauses (`if cond: continue`) or near-identical test assertions trip `duplicated_branches` / clone gates | Extract a shared predicate helper or parametrize tests instead of repeating near-identical statements | + +## Verification + +**MCP query tools** (read-only, from a stored run): + +`list_findings`, `get_finding`, `check_clones`, `check_cohesion`, `check_complexity`, `check_coupling`, `check_dead_code`, `list_hotspots`, `get_production_triage`, `evaluate_gates`, `get_remediation`, `list_reviewed_findings`, `mark_finding_reviewed`. + +**Test coverage:** + +- `tests/test_blocks.py` — block extraction and thresholds +- `tests/test_metrics_*.py` — metrics computation and registry +- `tests/test_structural_findings.py` — finding categorization +- `tests/test_analysis_phase_ledger.py`, `tests/test_observability_analysis_phases.py` — phase ledger and observability + +## Evidence index + +| Claim | Corroboration | Source | +|-------|----------------|--------| +| `DEFAULT_BLOCK_MIN_LOC=20`, `DEFAULT_BLOCK_MIN_STMT=8` | supported | `codeclone/contracts/__init__.py`; cross-checked against a live `analyze_repository` run's `analysis_profile` (`block_min_loc: 20, block_min_stmt: 8`) | +| Health score weighting (clones 0.25, complexity 0.2, cohesion 0.15, coupling/coverage/dead_code/dependencies 0.1 each) | supported | `HEALTH_WEIGHTS` in `codeclone/contracts/__init__.py` | +| `codeclone.scanner` is the file-discovery layer (`iter_py_files`, module naming), not a report-caching layer | supported | verified directly against `codeclone/scanner/__init__.py` this session | +| `instance_methods.py` duplicate Protocol/ABC check vs. `_module_walk` | path_only | mem-1322da8d67214ad09278f39c6b53dae0 | +| Phase 21 Cycle B unwired | path_only | mem-0a9b4ea4ee8e41a58fdaf52c7defe376 | +| `_module_walk` intra-module relationship resolution | path_only | mem-6cc43f3d90394dfcb350437cae0780b4 | +| `phase_ledger` observability design | path_only | mem-df94987f37b84d6a969dd16af7a07d0a | +| `metrics/dependencies.py` sampling shared by Module Map | path_only | mem-dff5157a259341ffbc99a9ab9dcd54f6 | diff --git a/docs/internal/surfaces/audit-events.md b/docs/internal/surfaces/audit-events.md new file mode 100644 index 00000000..eea9ce68 --- /dev/null +++ b/docs/internal/surfaces/audit-events.md @@ -0,0 +1,119 @@ +--- +title: "Audit events and receipts" +audience: internal +doc_type: surface +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Audit events form the durable forensic trail of CodeClone's change control workflow. Each `start_controlled_change`, analysis run, and `finish_controlled_change` emits immutable audit records stored under `.codeclone/audit/`. Review receipts and patch trails are deterministic artifacts derived from these records, enabling agents and humans to verify decision provenance, scope compliance, and structural integrity at any future point. + +The audit surface comprises four public MCP tools: +- `create_review_receipt`: generate a deterministic receipt from stored MCP state +- `get_blast_artifact`: fetch durably stored blast artifacts by run/digest +- `get_patch_trail`: fetch durably stored patch trails with forensic evidence +- `get_review_receipt`: fetch durably stored review receipts by run/digest + +## Contracts + +| Contract | Value | Purpose | +|----------|-------|---------| +| `AUDIT_PROJECTION_VERSION` | `audit-v1` | Audit event schema version; breaking changes require new version | +| `PATCH_TRAIL_SCHEMA_VERSION` | `1` | Patch trail record structure; covers declared/changed/untouched files, scope check, verification, workspace hygiene, evidence | +| `ENGINEERING_MEMORY_SCHEMA_VERSION` | `1.7` | Engineering Memory store contract; audit events reference memory decisions | +| `TRAJECTORY_PROJECTION_VERSION` | `trajectory-v3` | Patch trail trajectory encoding; supersedes v1 | + +**Contract Invariants:** + +1. Audit records are write-once and immutable. Emission failures increment observability counters (`audit.emit_dropped`, `memory.propose_candidate_dropped`) instead of silent loss. + +2. Receipt generation is deterministic: same input (run_id, intent scope, reviewed findings) always produces identical markdown or JSON without state mutation. + +3. Blast artifacts are persisted at `start_controlled_change` time and are immutable snapshots. The artifact's `projection_digest` enables content-based retrieval even if run_id is not available. + +4. Patch trail evidence (declared scope, changed files, workspace hygiene checks) is final at finish time and reconciles against three reference points: start-time dirty snapshot, current git tree, and declared scope boundary. + +## Implementation map + +```mermaid +graph LR + A["start_controlled_change"] -->|emit| B["SqliteAuditWriter
_session_audit_artifact_mixin"] + C["analyze_repository"] -->|emit| B + D["finish_controlled_change"] -->|emit| B + B -->|store| E[".codeclone/db/
audit.sqlite3"] + E -->|read| F["get_blast_artifact
get_patch_trail
get_review_receipt"] + D -->|propose| G["Engineering Memory
record_candidate"] + H["create_review_receipt"] -->|read| B + H -->|read| G + H -->|emit| I["Receipt
markdown/JSON"] + style B fill:#f9f,stroke:#333,stroke-width:2px + style E fill:#bbf,stroke:#333,stroke-width:2px + style F fill:#bfb,stroke:#333,stroke-width:2px +``` + +**Key modules:** + +- `codeclone/audit/__init__.py`: Immutable blast artifact retrieval contract and summary defaults. +- `codeclone/audit/analysis_completed.py`: MCP analysis.completed event emission; falls back to `mode|analysis_mode|'completed'` when mode resolution is ambiguous. +- `codeclone/audit/writer.py`: `SqliteAuditWriter` persists events and bumps observability counters on failure paths. +- `codeclone/audit/reader.py`: Read-only access to audit events by run_id, digest, and range queries. +- `codeclone/audit/receipts.py`: Receipt generation from audit records; `_try_append_text_candidate` increments counter on drop. + +## Failure modes + +| Failure | Root Cause | Impact | Recovery | +|---------|-----------|--------|----------| +| Silent audit emission loss | SqliteAuditWriter except path not incrementing counter | Orphaned audit trail; forensic evidence gap | Enable observability; rerun `analyze_repository` and `finish_controlled_change` | +| Mode resolution ambiguity | MCP record.summary uses `analysis_mode` not `summary.get('mode')` | analysis.completed event fails silently | Check `analysis_mode` field in MCP record; fix caller to set both fields consistently | +| Unverified finish | Patch trail evidence incomplete or scope reconciliation failed | Intent stays active; next agent must follow `next_step` hint | Re-analyze with new run_id or expand scope; call `finish` again on same intent_id | +| Foreign intent overlap | Concurrent changes to declared scope by another agent | `start` blocks unless scope narrowed or foreign intent queues | Narrow scope or queue behind foreign intent; promote when clear | + +## Verification + +**Test suite coverage:** + +- `tests/test_audit_analysis_completed.py`: Event emission on analysis completion. +- `tests/test_audit_event_core_v2.py`: Core event schema and roundtrip serialization. +- `tests/test_audit_event_summary.py`: Event summary and projection defaults. +- `tests/test_audit_events_coverage.py`: Full surface coverage across tools and modes. +- `tests/test_audit_reader.py`: Read-only access patterns and range queries. +- `tests/test_audit_schema.py`: Schema version tracking and breaking-change detection. +- `tests/test_audit_writer.py`: Persistence, atomicity, and observability counter increments. +- `tests/test_cli_audit.py`: CLI surface integration with audit trails. + +**Manual verification:** + +There is no `codeclone audit` CLI subcommand. The audit trail is a SQLite database at `.codeclone/db/audit.sqlite3`. Inspect it through the MCP tools that read the audit store, or via the CLI display flags: + +```bash +# CLI: surface the controller audit trail inline (human / JSON) +uv run codeclone . --audit +uv run codeclone . --audit-json +``` + +```text +# MCP tools (read the audit store): +get_blast_artifact(run_id=..., blast_artifact_id=...) +get_patch_trail(run_id=..., patch_trail_digest=...) +get_review_receipt(run_id=..., receipt_digest=...) +create_review_receipt(intent_id=..., run_id=...) +``` + +**Contract validation:** + +- `AUDIT_PROJECTION_VERSION` must be updated before any schema breaking change. +- Audit records must survive `uv run pytest -q tests/test_audit_*.py` without loss or corruption. +- Receipt generation must be deterministic: two calls with identical inputs must produce identical output. + +## Evidence index + +| Source | Status | Notes | +|--------|--------|-------| +| `codeclone/audit/__init__.py` (immutable blast artifact) | path_only | Immutable audit-backed retrieval verified with targeted tests; MCP/audit tests pass | +| `codeclone/audit/analysis_completed.py` (mode resolution) | path_only | Fallback to `analysis_mode` field; codebase uses zero logging (MCP JSON-RPC constraint) | +| `codeclone/audit/writer.py` (observability counters) | path_only | Emit and receipt append failures increment counters instead of silent return; no logging | +| `codeclone/audit/receipts.py` (deterministic generation) | path_only | Receipt markdown/JSON is content-deterministic; no random state or timestamps injected | +| Schema versions (`AUDIT_PROJECTION_VERSION`, `PATCH_TRAIL_SCHEMA_VERSION`) | supported | Used throughout the codebase; tracked in `codeclone/contracts/__init__.py` | diff --git a/docs/internal/surfaces/baseline-cache-report.md b/docs/internal/surfaces/baseline-cache-report.md new file mode 100644 index 00000000..8e13af14 --- /dev/null +++ b/docs/internal/surfaces/baseline-cache-report.md @@ -0,0 +1,220 @@ +--- +title: "Baseline, cache and report identity" +audience: internal +doc_type: surface +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The baseline, cache, and report surfaces form CodeClone's deterministic state layer. **Baseline** (`codeclone.baseline.json`) captures fingerprinted structural facts at a reference commit. **Cache** (`.codeclone/` directory) stores ephemeral analysis artifacts and projections keyed to input immutability (repo, settings, commit hash). **Report** (JSON/HTML/Markdown outputs) represents findings, metrics, and audit trails derived from a single run. + +Together these surfaces enforce: +- **Comparison identity**: reports and findings remain stable across identical inputs +- **Change tracking**: baseline-relative novelty and regression signals depend on fingerprint versioning +- **Workspace coordination**: cache keys bound scope and prevent stale artifact reuse +- **MCP visibility**: `get_run_summary`, `get_report_section`, and `compare_runs` provide canonical access + +This page documents the contracts, mapping, failure modes, and verification requirements for this layer. + +## Contracts + +### Version invariants + +| Contract | Value | Schema or Scope | +|----------|-------|-----------------| +| `BASELINE_SCHEMA_VERSION` | 2.1 | Baseline JSON structure (fingerprints, metadata, metrics) | +| `BASELINE_FINGERPRINT_VERSION` | 1 | Fingerprint stability; changes block historical comparison | +| `CACHE_VERSION` | 2.10 | Cache file serialization and projection format | +| `REPORT_SCHEMA_VERSION` | 2.12 | Report JSON sections: meta, metrics, findings, derived, integrity, inventory | +| `AUDIT_PROJECTION_VERSION` | audit-v1 | Audit trail projection into report integrity section | +| `METRICS_BASELINE_SCHEMA_VERSION` | 1.2 | Metrics baseline metrics formatting for `compare_runs` | + +**Critical rule:** Incrementing `BASELINE_FINGERPRINT_VERSION` breaks all baseline comparisons. Any change to fingerprint logic (AST walk, block hash, slice boundary) requires explicit version bump and migration. + +### Default paths + +| Artifact | Path | Contract | +|----------|------|----------| +| Baseline | `codeclone.baseline.json` | Checked into repo; immutable reference; max 5 MB (DEFAULT_MAX_BASELINE_SIZE_MB) | +| Report (JSON) | `.codeclone/report.json` | Ephemeral; keyed to run_id and input hash | +| Report (HTML) | `.codeclone/report.html` | Ephemeral; renders `.codeclone/report.json` | +| Report (Markdown) | `.codeclone/report.md` | Ephemeral; plain-text derivation of JSON | +| Cache root | `.codeclone/` | Repo-relative; max 50 MB (DEFAULT_MAX_CACHE_SIZE_MB); security-hardened wire paths | +| State intents | `.codeclone/intents/` | Workspace coordination only; never user-edited | + +### Security hardening + +Cache wire paths are **repo-relative only**. Hardened behaviors: +- Absolute and traversal paths rejected in cache decode +- `pyproject.toml` loading rejects symlinked config files +- Advisory lock files use symlink-resistant `open()` on Unix + +(See `codeclone/cache/projection.py` for implementation.) + +## Implementation map + +```mermaid +graph LR + A["Baseline
codeclone.baseline.json"] + B["Run Analysis
codeclone.baseline +
repo @ commit"] + C["Cache Layer
.codeclone/"] + D["Report JSON
report.json"] + E["Report Render
HTML / Markdown"] + F["MCP Surface
get_run_summary
get_report_section
compare_runs"] + + A -->|read fingerprints| B + B -->|fingerprint + metrics| A + B -->|cache key| C + C -->|store projections| D + D -->|read for render| E + D -->|read for MCP| F + A -->|baseline-relative
novelty| F + + style A fill:#f0f0f0 + style C fill:#f0f0f0 + style D fill:#f0f0f0 + style F fill:#e8f4f8 +``` + +### Key modules + +| Package | Role | Contracts | +|---------|------|-----------| +| `codeclone.baseline` | Read/write baseline JSON; fingerprint resolution | BASELINE_SCHEMA_VERSION, BASELINE_FINGERPRINT_VERSION | +| `codeclone.cache` | Projection serialization; wire path containment | CACHE_VERSION, security hardening | +| `codeclone.report` | Report construction; section selection; finding derivation | REPORT_SCHEMA_VERSION, section enumeration | + +### MCP surface + +Three tools expose baseline, cache, and report identity: + +1. **`get_run_summary(run_id)`** — compact snapshot including: + - Run metadata (commit, settings hash, analysis timestamp) + - Aggregate metrics (cohesion, complexity, coupling, coverage) + - Finding counts by family + - Baseline novelty (introduced vs. regression vs. known) + +2. **`get_report_section(section, run_id)`** — canonical section access: + - `meta` — run identity and input hash + - `metrics` — full metric detail with `compare_runs` shape + - `findings` — paginated by family; novelty relative to baseline + - `integrity` — audit trail and immutability claims + - `inventory` — file registry and source paths + +3. **`compare_runs(run_id_a, run_id_b)`** — two-run delta: + - Requires identical repository root and settings + - Baseline fingerprint version match enforced + - Returns incomparable when preconditions fail + +## Failure modes + +### Baseline fingerprint version mismatch + +**Condition:** User has checked-in `codeclone.baseline.json` with fingerprint version 1, codebase increments to version 2. + +**Effect:** `get_run_summary` and `compare_runs` report `novelty: null`; baseline-relative signals unavailable. + +**Recovery:** Recompute baseline at new fingerprint version: +```bash +codeclone . --update-baseline +``` + +**Prevention:** Baseline updates require the explicit `--update-baseline` flag; accidental snapshot is not possible. + +--- + +### Cache key collision + +**Condition:** Repository state, settings, and commit hash remain identical; cached projections are stale (e.g., analysis code has been patched). + +**Effect:** `get_run_summary` returns cached, potentially incorrect metrics; user does not re-analyze. + +**Recovery:** Cache is ephemeral and rebuilds on next full run. For immediate clarity: +```bash +rm -f .codeclone/cache.json +codeclone . +``` + +**Prevention:** Cache key includes analysis tool version; breaking changes to analysis logic increment CACHE_VERSION. + +--- + +### Report schema evolution + +**Condition:** Report schema bumps from 2.11 to 2.12 (e.g., new field added to findings). Older MCP servers or reports remain at 2.11. + +**Effect:** `get_report_section(section='findings')` may omit or misinterpret new fields in old reports. + +**Recovery:** Reanalyze the repository on the new version to generate a 2.12 report. + +**Prevention:** Report consumers validate `report_schema_version` from the `meta` section before interpreting findings. + +--- + +### Cache corruption or corruption recovery + +**Condition:** Cache file `.codeclone/cache.json` is partially deleted or truncated. + +**Effect:** Projection load fails; `get_run_summary` and `get_report_section` fail with file-not-found. + +**Recovery:** Delete cache and reanalyze: +```bash +rm -f .codeclone/cache.json +codeclone . +``` + +**Prevention:** Cache is not persistent storage; treat `.codeclone/` as build artifact. Do not commit to version control. + +--- + +### Baseline checkin drift + +**Condition:** Baseline is checked into version control but not updated after legitimate code changes. New run detects all existing findings as non-novel. + +**Effect:** `get_run_summary` reports `novelty: known` for all findings; actual regressions are masked. + +**Recovery:** Update baseline with current valid state: +```bash +codeclone . --update-baseline +git add codeclone.baseline.json +git commit -m "chore: update baseline" +``` + +**Prevention:** CI integration enforces baseline drift detection; failing baseline mismatch gates pull requests. + +## Verification + +### Required tests + +| Test file | Coverage | +|-----------|----------| +| `tests/test_baseline.py` | Baseline read/write, fingerprint resolution, schema migration | +| `tests/test_cache.py` | Cache key computation, projection serialization, wire path containment | +| `tests/test_report.py` | Report construction, section enumeration, schema conformance | +| `tests/test_metrics_baseline.py` | Baseline metrics formatting, comparison shape | +| `tests/test_report_contract_coverage.py` | Report schema version validation | +| `tests/test_analytics_reporting.py` | Report integration with analytics and findings | + +### Verification checklist + +- [ ] Baseline fingerprint version increments only with explicit code review and version bump +- [ ] Cache wire paths reject symlinks and traversal attempts +- [ ] Report JSON validates against declared schema version +- [ ] `compare_runs` enforces baseline fingerprint version match +- [ ] MCP tools expose canonical report sections without redaction or transformation +- [ ] Default paths match contract constants (DEFAULT_BASELINE_PATH, DEFAULT_*_REPORT_PATH) + +## Evidence index + +| Finding | Status | Source | +|---------|--------|--------| +| Cache wire path containment (repo-relative decode, symlink hardening) | supported | `codeclone/cache/projection.py`; memory mem-231f686b92ba4103a6322e683ead9e6a | +| Security hardening (path validation, config symlink rejection) | supported | `codeclone/cache/projection.py`; memory mem-bc26f97ebb0944a9986de756b857c9d3 | +| Report schema version 2.12 current | supported | context.reports.schema_version | +| Baseline schema version 2.1 current | supported | BASELINE_SCHEMA_VERSION contract | +| Cache version 2.10 current | supported | CACHE_VERSION contract | +| MCP tool surface (get_run_summary, get_report_section, compare_runs) | supported | context.mcp_tools.tools | +| Default paths and size limits | supported | contracts (DEFAULT_BASELINE_PATH, DEFAULT_MAX_CACHE_SIZE_MB, etc.) | diff --git a/docs/internal/surfaces/change-control.md b/docs/internal/surfaces/change-control.md new file mode 100644 index 00000000..73c1b6b6 --- /dev/null +++ b/docs/internal/surfaces/change-control.md @@ -0,0 +1,102 @@ +--- +title: "Change-control surface" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The change-control surface (`codeclone.budget`, `codeclone.controller_insights`, `codeclone.surfaces.mcp`, `codeclone.workspace_intent`) enforces deterministic edit authorization and verification for repository changes in MCP workflows. It declares change intents, computes blast radius and token budget, executes pre- and post-edit structural verification, and produces auditable receipts. + +## Contracts + +| Contract | Value | Role | +|----------|-------|------| +| `PATCH_TRAIL_SCHEMA_VERSION` | "1" | Audit trail format for declared/changed/untouched files, scope check, verification, workspace hygiene | +| `AUDIT_PROJECTION_VERSION` | "audit-v1" | Immutable audit event storage for start/finish artifacts and repair trails | +| `BASELINE_SCHEMA_VERSION` | "2.1" | Baseline version for health/patch_health_delta computation | +| Session intent constraint | One active per MCP session | Calling `start_controlled_change` before finishing evicts prior intent; recover via `manage_change_intent(action='recover')` | +| Scope shape | `{"allowed_files": ["path.py", ...]}` | Relative paths under project root; verified from live testing | + +## Implementation map + +```mermaid +graph TD + A["start_controlled_change
(intent declaration)"] + B["get_implementation_context
(bounded structural evidence)"] + C["get_relevant_memory
(Engineering Memory sync)"] + D["agent edits
within declared scope"] + E["analyze_repository
(after-run for Python)"] + F["finish_controlled_change
(post-edit verification)"] + G["check_patch_contract
(derived profile)"] + H["create_review_receipt
(auditable decision)"] + + A -->|edit_allowed| B + B -->|scoped evidence| C + C -->|memory-aware| D + D -->|changed files| E + E -->|structural data| F + F -->|verify via| G + G -->|profile decision| H + + style A fill:#e1f5ff + style F fill:#fff3e0 + style G fill:#f3e5f5 +``` + +Core packages: `codeclone.workspace_intent` (lifecycle), `codeclone.budget` (token/health accounting), `codeclone.controller_insights` (blast radius, findings), `codeclone.surfaces.mcp` (MCP-session state, intent registry). + +## Failure modes + +| Mode | Cause | Recovery | +|------|-------|----------| +| `status: "blocked"` | Concurrent foreign intents; `concurrent_intents` non-empty | Narrow scope or coordinate; promote via `manage_change_intent(action='promote')` when foreign clears | +| `status: "queued"` | Declared scope overlaps active foreign intent | Automatic queue; call `manage_change_intent(action='promote')` when promoted by controller | +| `needs_analysis` | No valid MCP run for root | Call `analyze_repository(root=...)` before retry | +| Intent eviction | `start_controlled_change` called twice without `finish` | Call `manage_change_intent(action='recover', intent_id=...)` with saved intent_id | +| PID unknown (hardened) | Foreign intent owner PID inaccessible | Treated as unknown, not recoverable; remains visible for coordination; hook gate denies write | +| `finish_block_reason: missing_evidence` | Changed files not reported in `changed_files` or `after_run_id` | Rerun `analyze_repository` with new run_id; call `finish` again with same `intent_id` and updated evidence | +| `finish_block_reason: foreign_dirty_overlap` | Foreign in-scope dirty files after start snapshot | Coordinate with foreign intent holder; request clear or scope narrowing | +| `finish_block_reason: own_unscoped_dirty` | Editor touched files outside declared scope (when `CODECLONE_STRICT_FINISH=true`) | Remove out-of-scope changes or call `start_controlled_change` with expanded scope | + +## Verification + +Structural verification is profile-derived by `finish_controlled_change`: + +- **`python_structural`**: any `.py`/`.pyi` touched → all checks (clones, cohesion, complexity, coupling, coverage, dead code, dependencies) → `after_run_id` required +- **`governance_config`**: config files only (pyproject.toml, CI, Dockerfile) → structural checks (gate evaluation, health thresholds) → `after_run_id` required +- **`documentation_only`**: docs files only (`.md`, `.rst`, LICENSE) → no structural checks → `after_run_id` not required +- **`non_python_patch`**: other files, no Python/docs → no structural checks, controller-reported limitations apply +- **`state_artifact_change`**: CodeClone state files touched (`.codeclone/`, `codeclone.baseline.json`) → verification violates; state artifacts immutable + +Claim Guard validates cited review text against canonical report semantics via `validate_review_claims`: + +- Security Surfaces must not be called vulnerabilities +- Report-only signals must not be called CI failures +- Known baseline debt must not be claimed as new +- Patch-local regression claims require before/after evidence (not baseline novelty alone) +- Dead code claims require reachability evidence +- Fix claims require post-patch verification + +## Evidence index + +All facts below carry `corroboration_status: supported` (asserted from stored code) or `path_only`/`no_checkable_claims` (background framing; do not present as freshly confirmed). + +1. **Scope contract**: relative paths, `allowed_files` key. Source: verified from live `start_controlled_change` calls; live-tested shape confirmed at task time. +2. **Session intent uniqueness**: exactly one active per MCP session. Source: `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py`; risk_note mem-527db78f1ce24eb98ad06ce507f0de93 (path_only). +3. **Freshness invariant**: mtime+size signatures + pre-analysis DirtySnapshot capture pre-existing unchanged dirtiness detection. Source: `codeclone/surfaces/mcp/_workspace_drift.py`; contract_note mem-6e6628aa24504020ae2f1f8628365ddc (path_only). +4. **Token estimation**: audit paths use chars_approx by default; exact tiktoken opt-in. Source: `codeclone/budget/estimator.py`; contract_note mem-d788578731ab44bca24698962e79b952 (path_only). +5. **Inventory projection mismatch**: clones_only summary mismatch in `_session_helpers._summary_inventory_payload`, not canonical inventory. Source: `codeclone/surfaces/mcp/_session_helpers.py`; risk_note mem-881b92197a6d420ca3adfff4d338ba8e (path_only). +6. **Security hardening**: PID PermissionError tri-state unknown; unknown foreign owners visible as active/stale; hook write-gate denies unknown. Source: `codeclone/surfaces/mcp/_workspace_intent_lifecycle.py`; architecture_decision mem-6859f67ef1234556981b7ccb36673f77 (path_only). +7. **Implementation context exactness**: facet pages exact only from saved MCP session projection; `get_implementation_context_page` must return not_found or mismatch, never recompute. Source: `codeclone/surfaces/mcp/_implementation_context_pages.py`; contract_note mem-0ebd1dfb9f494c4e9909607bd8832ccf (path_only). + +### Required tests + +- `tests/test_workspace_intent_gate.py`: intent lifecycle, active/queued/blocked states +- `tests/test_workspace_intent_sqlite_store.py`: intent persistence, recovery +- `tests/test_controller_insights.py`: blast radius, do-not-touch boundaries, coverage gaps +- `tests/test_mcp_context_governance.py`: scope validation, concurrent intent detection +- `tests/test_mcp_security_hardening.py`: PID liveness checking, unknown state tri-state diff --git a/docs/internal/surfaces/cli-output.md b/docs/internal/surfaces/cli-output.md new file mode 100644 index 00000000..199e939d --- /dev/null +++ b/docs/internal/surfaces/cli-output.md @@ -0,0 +1,241 @@ +--- +title: "CLI output contracts" +audience: internal +doc_type: surface +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +This document specifies the output contracts for CodeClone's command-line interface (CLI). It defines what the user sees on stdout, stderr, and in report files across all CLI entrypoints (bare analysis, `setup`, `analytics`, `memory`, `observability`), how output is routed based on flags, and failure modes when output conditions are violated. + +## Contracts + +### Exit codes + +| Code | Meaning | Trigger | +|------|---------|---------| +| 0 | Success | Analysis completed, no gate failures | +| 2 | Contract error | Invalid baseline, configuration, version mismatch, unreadable sources in CI mode | +| 3 | Gating failure | New clones found, threshold exceeded, metrics regression, quality gate violation | +| 5 | Internal error | Unexpected exception (unhandled error, environment issue) | + +### Top-level subcommands + +CodeClone has exactly four top-level subcommands (dispatched in `codeclone/surfaces/cli/workflow.py`): +- `codeclone setup [action]` — readiness, configuration planning, and initialization +- `codeclone analytics [subcommand]` — corpus analytics (snapshot, embed, cluster, build, profiles, …) +- `codeclone memory [subcommand]` — engineering memory lifecycle (init, status, search, approval, trajectories, etc.) +- `codeclone observability [action]` — MCP and workspace observability (diagnostics, tracing, span export) + +Plus the bare invocation: `codeclone [root]` — runs analysis (there is no "analyze" or "check" subcommand). + +### Report output routing + +Reports are specified as **flags**, not subcommands. The analysis always runs; reports are optional outputs: + +``` +--html [FILE] Write HTML report to FILE or .codeclone/report.html +--json [FILE] Write JSON report to FILE or .codeclone/report.json +--md [FILE] Write Markdown report to FILE or .codeclone/report.md +--sarif [FILE] Write SARIF 2.1.0 report to FILE or .codeclone/report.sarif +--text [FILE] Write plain-text report to FILE or .codeclone/report.txt +``` + +Multiple report flags may be combined in one invocation. If FILE is omitted, CodeClone writes to a standard default path under `.codeclone/`. When `--timestamped-report-paths` is set, default paths include a UTC timestamp. + +### Console output control + +| Flag | Effect | +|------|--------| +| `--progress` | Force progress bars and spinner frames to stdout (override auto-detection) | +| `--no-progress` | Suppress progress output; recommended for CI logs | +| `--color` | Force ANSI colors (override auto-detection) | +| `--no-color` | Disable ANSI colors | +| `--quiet` | Reduce output to warnings, errors, essential summaries only | +| `--verbose` | Include detailed identifiers for NEW clone findings in console output | +| `--debug` | Print tracebacks and environment details for internal errors | + +The `--ci` preset is equivalent to `--fail-on-new --no-color --quiet`. + +### Audit output + +The `--audit` flag (read-only, no analysis required) displays the local Controller audit trail from the configured audit database. The `--audit-json` variant emits audit payload footprint as JSON, useful for cross-repository audits. + +### Session and diagnostic flags + +| Flag | Output | Requires analysis | +|------|--------|-------------------| +| `--session-stats` | Workspace session status (active agents, intents, lease health) | No | +| `--help` | Standard argument parser help | No | +| `--interactive-help` | Guided product tour (interactive terminal only) | No | +| `--version` | CodeClone version string | No | + +## Implementation map + +```mermaid +graph TD + CLI["CLI Entrypoint
codeclone.surfaces.cli"] + Parser["Argument Parser
argparse + custom routing"] + + Subcommands["Subcommand Routing"] + SetupCmd["setup command
codeclone setup -h"] + MemoryCmd["memory command
codeclone memory -h"] + ObsCmd["observability command
codeclone observability -h"] + AnalysisCmd["Bare analysis
codeclone [root]"] + + AnalysisFlow["Analysis Flow"] + ReportGen["Report Generation"] + ConsoleOut["Console Output"] + + Parser --> Subcommands + Subcommands --> SetupCmd + Subcommands --> MemoryCmd + Subcommands --> ObsCmd + Subcommands --> AnalysisCmd + + AnalysisCmd --> AnalysisFlow + AnalysisFlow --> ReportGen + AnalysisFlow --> ConsoleOut + + ReportGen --> HTML["HTML .codeclone/report.html"] + ReportGen --> JSON["JSON .codeclone/report.json"] + ReportGen --> MD["Markdown .codeclone/report.md"] + ReportGen --> SARIF["SARIF .codeclone/report.sarif"] + ReportGen --> TEXT["Text .codeclone/report.txt"] + + ConsoleOut --> Progress["Progress Bars"] + ConsoleOut --> Summary["Summary"] + ConsoleOut --> Warnings["Warnings & Errors"] +``` + +### Key modules + +- **`codeclone.surfaces.cli`** — CLI entrypoint, argument parsing, subcommand routing +- **`codeclone.surfaces.cli.ui.progress_presenter`** — Progress bar and spinner management; integrates with help tour (methods: `bind_live`, `refresh`, `set_mascot`, `set_extra`) +- **`codeclone.surfaces.cli.ui.mascot_frames`** — Reusable animation loops for the help tour (graph pulse, scan, deps, cache, blocked) +- **`codeclone.surfaces.cli.observability`** — MCP and diagnostic output; subject to early-return branch deduplication risk + +### Configuration and defaults + +| Constant | Value | +|----------|-------| +| `DEFAULT_HTML_REPORT_PATH` | `.codeclone/report.html` | +| `DEFAULT_JSON_REPORT_PATH` | `.codeclone/report.json` | +| `DEFAULT_MARKDOWN_REPORT_PATH` | `.codeclone/report.md` | +| `DEFAULT_SARIF_REPORT_PATH` | `.codeclone/report.sarif` | +| `DEFAULT_TEXT_REPORT_PATH` | `.codeclone/report.txt` | + +## Failure modes + +### Invalid baseline (contract error, exit code 2) + +- Baseline file does not exist when `--fail-on-new` is enabled +- Baseline is corrupted or unreadable +- Baseline schema version does not match `BASELINE_SCHEMA_VERSION` ("2.1") +- Baseline exceeds `DEFAULT_MAX_BASELINE_SIZE_MB` (5 MB) + +**Remediation:** Use `--update-baseline` to regenerate, or remove the baseline and re-run. + +### Invalid cache (contract error, exit code 2) + +- Cache file is corrupted +- Cache schema version does not match `CACHE_VERSION` ("2.10") +- Cache exceeds `DEFAULT_MAX_CACHE_SIZE_MB` (50 MB) + +**Remediation:** Delete `.codeclone/cache.json` and re-run. + +### Report file write failure (contract error, exit code 2) + +- Target directory does not exist and cannot be created +- Permission denied on report path +- Disk full + +**Remediation:** Check filesystem permissions and available disk space; specify an alternative report path. + +### New clone findings with --fail-on-new (gating failure, exit code 3) + +A finding is NEW if it appears in the current run but not in the baseline. Gating blocks the build. + +**Message:** `Gating failure: new clones detected [finding_id ...]. Update the baseline or mitigate findings.` + +### New metrics violations with --fail-on-new-metrics (gating failure, exit code 3) + +A metric regression occurs when the current run reports a violation (e.g., cyclomatic complexity > 20) that was not present in the metrics baseline. + +**Trigger:** `--fail-on-new-metrics` + metrics baseline + at least one metric crosses the gate threshold. + +### Threshold violations (gating failure, exit code 3) + +| Gate | Trigger | Default | Exit code | +|------|---------|---------|-----------| +| `--fail-threshold` | Total clones exceed limit | None (disabled unless set) | 3 | +| `--fail-complexity` | Any function CC > threshold | 20 | 3 | +| `--fail-coupling` | Any class CBO > threshold | 10 | 3 | +| `--fail-cohesion` | Any class LCOM4 > threshold | 4 | 3 | +| `--fail-cycles` | Circular module dependencies exist | N/A | 3 | +| `--fail-dead-code` | High-confidence dead code found | N/A | 3 | +| `--fail-health` | Overall health score < threshold | 60 | 3 | +| `--fail-on-typing-regression` | Typing coverage regresses | N/A | 3 | +| `--fail-on-docstring-regression` | Docstring coverage regresses | N/A | 3 | +| `--fail-on-api-break` | Public API removed or signature breaks | N/A | 3 | +| `--fail-on-untested-hotspots` | Medium/high-risk functions below coverage threshold | 50% (coverage-min) | 3 | + +### Internal error (exit code 5) + +An unexpected exception occurred. The error is logged to stderr with a traceback (if `--debug` is set). This indicates a bug, not a user configuration issue. + +**Always report with:** Python version, CodeClone version, repository root, and full traceback. + +### Early-return deduplication risk + +The observability module may flag two legitimately-distinct early-return branches that share identical shape as duplicated code. This is a false positive when branches handle separate exit paths. + +**Known mitigation:** Extract one branch to a single-statement named helper so the bodies differ structurally. **Status:** path_only (background knowledge, not verified in current context). + +## Verification + +### Unit tests + +Tests cover CLI flag combinations, report generation, exit codes, and output routing: + +- `tests/test_cli_smoke.py` — Basic invocation and output presence +- `tests/test_cli_help_snapshot.py` — Help text consistency +- `tests/test_cli_config.py` — Configuration and defaults +- `tests/test_cli_patch_verify.py` — Gating and exit codes +- `tests/test_cli_session_stats.py` — Session diagnostics output +- `tests/test_cli_audit.py` — Audit trail output +- `tests/test_cli_blast_radius.py` — Blast radius output format +- `tests/test_analytics_cli.py` — Analytics integration +- `tests/test_cli_setup.py` — Setup subcommand +- `tests/test_memory_cli.py`, `tests/test_memory_cli_*.py` — Memory subcommand family +- `tests/test_observability_cli_*.py` — Observability output + +### Contract requirements + +1. All report formats (HTML, JSON, MD, SARIF, TEXT) must be valid and match schema versions. +2. Exit codes must be deterministic: code 0 iff no gates fail. +3. Progress output must respect `--progress`, `--no-progress`, and CI presets. +4. Colors must respect `--color`, `--no-color`, and terminal detection. +5. Timestamp insertion in default report paths must use UTC format. +6. Baseline and cache versions must match expected schemas; violations are contract errors (exit code 2). +7. Console output under `--quiet` must include warnings, errors, and essential summaries only. +8. `--verbose` must include detailed clone identifiers for NEW findings. +9. Report write failures must exit with code 2 and report the filesystem error. + +## Evidence index + +| Subject | Type | Status | Evidence | +|---------|------|--------|----------| +| `codeclone.surfaces.cli` (package) | implementation | path_only | CLI entrypoint and routing; package exists and is public | +| Subcommands: `setup`, `memory`, `observability` | contract | supported | CLI help text, argparse routing | +| Bare analysis: `codeclone [root]` | contract | supported | CLI help, no "analyze" subcommand | +| Report flags (--html, --json, etc.) | contract | supported | CLI help text and argument parser | +| Default report paths | contract | supported | Constants in `codeclone/contracts/__init__.py` | +| Exit codes (0, 2, 3, 5) | contract | supported | CLI help text ("Exit codes:" section) | +| Progress presenter methods | change_rationale | path_only | `codeclone.surfaces.cli.ui.progress_presenter` (bind_live, refresh, set_mascot, set_extra) wired to help_tour; subject path exists | +| Mascot frames animation | change_rationale | path_only | `codeclone.surfaces.cli.ui.mascot_frames` holds 15-step ASTer tour loops (graph pulse, scan, deps, cache, blocked); subject path exists | +| Observability module early-return deduplication | risk_note | path_only | Two distinct early-return branches with identical shape may be flagged as duplicated; mitigation: extract one to named helper; subject path `codeclone/surfaces/cli/observability.py` exists | +| Tests (cli, memory, observability, setup, analytics, audit) | verification | path_only | 22 test files covering CLI surfaces; all paths exist in `tests/` directory | diff --git a/docs/internal/surfaces/engineering-memory.md b/docs/internal/surfaces/engineering-memory.md new file mode 100644 index 00000000..e804cb96 --- /dev/null +++ b/docs/internal/surfaces/engineering-memory.md @@ -0,0 +1,117 @@ +--- +title: "Engineering Memory surface" +audience: internal +doc_type: surface +status: draft +source_commit: "582228177b2f57d9b9823ff1da9b6a0620f1297f" +source_packet: codeclone_mcp_module_map +--- + +# Engineering Memory surface + +## Purpose + +Engineering Memory is a SQLite-backed evidence store for recording durable repository facts, workflow decisions, and incident trajectories across CodeClone sessions. It bridges the gap between ephemeral chat context and persistent agent knowledge, enabling next-session resumption and multi-agent coordination. + +The surface provides four MCP tools: +- **get_relevant_memory**: ranked, evidence-linked retrieval for declared edit scopes +- **manage_engineering_memory**: governance actions (record, promote, validate, refresh) +- **query_engineering_memory**: mode-based inspection (search, get, for_path, trajectories, drafts, stale, etc.) +- **get_memory_projection_page**: pagination for omitted tail results + +Schema version: `ENGINEERING_MEMORY_SCHEMA_VERSION="1.7"` (contract constant in `codeclone/contracts/__init__.py`). + +## Contracts + +| Invariant | Requirement | Source | +|-----------|-------------|--------| +| **Sync mode** | Memory SQLite uses `synchronous=FULL` (fsync'd every commit). Intent/audit stores use `NORMAL` (TTL-designed, recovery-tolerable loss). | `codeclone/memory/schema.py` | +| **Schema migration** | ALTER-style migrations must reuse `_add_column_if_missing + _record_schema_migration` in `schema_migrate.py`. Do not inline PRAGMA/ALTER inline. | `codeclone/memory/schema_migrate.py` | +| **Embedding vectors** | Real fastembed `embed()` yields `numpy.float32` scalars (not Python `float`/`int` subclasses). Guard coercion on `numbers.Real` (excluding `bool`), never `isinstance(str\|int\|float)`. | `codeclone/memory/embedding/fastembed_provider.py` | +| **Record types** | Supported: `architecture_decision`, `change_rationale`, `contract_note`, `contradiction_note`, `document_link`, `human_note`, `module_role`, `protocol_rule`, `public_surface`, `risk_note`, `stale_marker`, `test_anchor` | Memory configuration | +| **Statuses** | `active`, `archived`, `draft`, `historical`, `rejected`, `stale`, `superseded` | Memory configuration | +| **Projection versions** | `MEMORY_PROJECTION_VERSION="memory-v1"`, `SEMANTIC_INDEX_FORMAT_VERSION="3"`, `TRAJECTORY_PROJECTION_VERSION="trajectory-v3"` | Contract constants | + +## Implementation map + +```mermaid +graph TD + A["manage_engineering_memory"] -->|action| B["record_candidate"] + A -->|action| C["refresh_from_run"] + A -->|action| D["promote_experience"] + A -->|action| E["validate_claims"] + A -->|action| F["rebuild_semantic_index"] + B --> G["codeclone/memory/store/"] + C --> G + D --> H["codeclone/memory/experience/"] + H -->|distill| I["distiller.py"] + E --> J["codeclone/memory/governance.py"] + F --> K["codeclone/memory/semantic/"] + L["get_relevant_memory"] -->|scope + intent_id| G + M["query_engineering_memory"] -->|modes| N["retrieval/modes.py"] + N --> O["for_path, search, get, trajectories, stale, drafts, coverage, etc."] + I -.->|known N+1| P["Performance issue: 2042 queries/call"] +``` + +## Failure modes + +| Symptom | Root cause | Mitigation | +|---------|-----------|-----------| +| Schema migration inlined PRAGMA/ALTER | Repeated ALTER clauses in migration inline instead of reusing `_add_column_if_missing`. | Read `schema_migrate.py` before touching schema. Use `_record_schema_migration` wrapper. | +| Non-Python subjects marked stale as `linked_path_missing` | `staleness._subject_inventory_stale_reason` applies `linked_path_missing` to `.md`, `.toml`, `.js` subjects. `inventory_paths_from_report` lists only `.py` files. | Only apply `linked_path_missing` to `.py` subjects. Fix in `codeclone/memory/staleness.py`. | +| Semantic memory unavailable at runtime | Embedding coercion guards on `isinstance(str\|int\|float)` discard `numpy.float32` scalars. | Guard on `numbers.Real` (excluding `bool`) instead. See `embedding/fastembed_provider.py`. | +| Duplicated branches in experience distiller | Two consecutive `if cond: continue` guards with identical bodies trigger CodeClone's `duplicated_branches` detector. | Use one guard with positive condition + append. Avoid >=2 sequential trivial guards. | +| High memory, slow distill_experiences | Known N+1 in background projection worker: 2042 queries / 2031 writes per call; 523MB RSS / 2.9s observed. | Fix tracked in task_2a83620f (P2); requires batch insert optimization. Do not attempt workaround; escalate. | + +## Verification + +Before editing `codeclone/memory/`, run: + +```bash +# Memory-specific tests +uv run pytest -q tests/test_memory_*.py tests/test_mcp_memory_*.py tests/test_cli_memory_*.py + +# Schema and migration +uv run pytest -q tests/test_memory_schema.py tests/test_memory_config*.py + +# Experience and semantic +uv run pytest -q tests/test_memory_experience*.py tests/test_memory_semantic.py tests/test_mcp_memory_semantic.py + +# Retrieval and search +uv run pytest -q tests/test_memory_retrieval*.py tests/test_memory_search*.py + +# All pre-commit +uv run pre-commit run --all-files +``` + +Do not manually update Engineering Memory baselines or semantic indexes. Use `manage_engineering_memory` actions only (agent actions only; `approve`/`reject`/`archive` not available via MCP). + +## Evidence index + +**Contracts and invariants:** +- `codeclone/contracts/__init__.py` (ENGINEERING_MEMORY_SCHEMA_VERSION, projection versions) + +**Source paths:** +- `codeclone/memory/schema.py` (synchronous=FULL guarantee) +- `codeclone/memory/schema_migrate.py` (migration reuse patterns) +- `codeclone/memory/experience/distiller.py` (duplicated branches, N+1 performance) +- `codeclone/memory/embedding/fastembed_provider.py` (vector coercion guard) +- `codeclone/memory/staleness.py` (linked_path_missing overapplication) +- `codeclone/memory/retrieval/` (query_engineering_memory modes) +- `codeclone/memory/governance.py` (manage_engineering_memory actions) + +**Test anchors:** +- `tests/test_memory_schema.py`, `tests/test_memory_config*.py` (schema and migration) +- `tests/test_memory_experience*.py` (distiller, duplicated branches) +- `tests/test_memory_embedding*.py` (embedding vectors, if present) +- `tests/test_memory_retrieval*.py` (retrieval modes and pagination) +- `tests/test_mcp_memory_management.py` (manage_engineering_memory actions) +- `tests/test_memory_durability.py` (synchronous=FULL guarantees) + +**Memory records (supporting evidence):** +- mem-08c5f5411e1c425ca98dcce2ad00cc0f (SQLite sync mode, path_only) +- mem-15e3f19498ef46a2bc3f8c24306e7e41 (schema migration reuse, path_only) +- mem-53f8a3942268431c9d42185e576e197d (distiller duplicated branches, path_only) +- mem-8293d87dc4744816a53e41d5624ebdec (N+1 performance, path_only) +- mem-f4a92cec90b14319a3c24135d0ab6cde (embedding vector coercion, path_only) +- mem-829156ad116c4757aae082b6fc42e48b (staleness linked_path_missing, path_only) diff --git a/docs/internal/surfaces/ide-integrations.md b/docs/internal/surfaces/ide-integrations.md new file mode 100644 index 00000000..f04a86eb --- /dev/null +++ b/docs/internal/surfaces/ide-integrations.md @@ -0,0 +1,77 @@ +--- +title: "IDE integrations" +audience: internal +doc_type: surface +status: draft +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +IDE integrations route CodeClone analysis into VS Code, JetBrains IDEs (PyCharm, IntelliJ, Rider), and Cursor through extension surfaces. These extensions expose MCP tools, governance memory workflows, and patch-trail visualization without requiring CLI invocation. Extensions bridge development workflows with the CodeClone controller through managed MCP processes, environment handling, and cache coordination. + +## Contracts + +| Contract | Value | Role | +|----------|-------|------| +| `IDE_GOVERNANCE_PROTOCOL_VERSION` | `2` | MCP handshake and governance-state serialization format for all IDE surfaces | +| `ENGINEERING_MEMORY_SCHEMA_VERSION` | `1.7` | Memory record and trajectory schema used by VS Code and JetBrains bulk-governance workflows | +| `TRAJECTORY_PROJECTION_VERSION` | `trajectory-v3` | Episode and patch-trail format for IDE patch-visualization UI | +| Extension entry point | `McpLauncher` (JetBrains), `runtime.js` (VS Code) | MCP process spawning and lifecycle management per IDE | + +**Non-negotiables:** +- MCP process spawns must resolve `codeclone-mcp` tool via absolute PATH or explicit `uv` resolution, not shell lookup. +- Memory bulk-governance operations must dispatch `records_by_status` (active/draft/stale keys), not assume draft-only. +- Coverage paths passed to `analyze_repository` must be repo-relative unless caller sets `allow_repo_absolute=true`. +- `IDE_GOVERNANCE_PROTOCOL_VERSION` bumps require synchronized extension and MCP server updates. + +## Implementation map + +```mermaid +graph LR + A["VS Code Extension
vscode-codeclone"] -->|"launch MCP, send
runtime context"| B["MCP Session
codeclone-mcp"] + C["JetBrains Plugin
jetbrains-codeclone"] -->|"launch via
McpLauncher"| B + B -->|"analyze_repository
finish_controlled_change"| D["CodeClone Core
Analysis & Control"] + A -->|"query_engineering_memory
list patches"| E["Memory Store
.codeclone/memory/engineering_memory.sqlite3"] + C -->|"patch-trail, memory
bulk-governance"| E + D -->|"write audit trail,
intents"| E +``` + +**Extension contact points:** +- `vscode-codeclone/src/runtime.js`: MCP launcher, coverage path normalization, Memory fetch and display. +- `jetbrains-codeclone/src/main/kotlin/.../McpLauncher.kt`: MCP spawning with environment augmentation, auto-connect on project open. +- `extensions/**/*.md`: Surface documentation and user guides (not included in this page). + +## Failure modes + +| Symptom | Root Cause | Mitigation | +|---------|-----------|-----------| +| JetBrains plugin fails to connect MCP on Dock launch | GUI PATH lacks `~/.local/bin` and `uv`; spawnEnvironment omits augmentation | McpLauncher must augment spawnEnvironment with user shell PATH or resolve tool absolute path before spawn | +| VS Code `analyze_repository` fails with "path not found" | Coverage XML path is absolute workspace path; core expects repo-relative | runtime.js must normalize coverage.xml to repo-relative; use `allow_repo_absolute=true` only when caller explicitly permits | +| Memory bulk-governance shows no stale records in UI | MCP status payload includes `records_by_status.stale`, but memoryController only dispatches `memoryDraft` | memoryController.js must handle all keys in `records_by_status`: `active`, `draft`, `stale` | +| Extension crashes when memory DB is missing | `.codeclone/memory/engineering_memory.sqlite3` not initialized | Bootstrap memory with `manage_engineering_memory(action="refresh_from_run")` on first connect or on missing-DB error | +| Race condition on concurrent IDE analysis | Two IDE instances spawn overlapping `analyze_repository` calls | Defer second IDE to same CodeClone server process via IPC coordination or implement intent-conflict queuing | + +## Verification + +1. **MCP connectivity**: Confirm `codeclone-mcp` is reachable from IDE via PATH or absolute resolve; capture spawn stderr on failure. +2. **Path handling**: For VS Code, verify coverage.xml is repo-relative in `analyze_repository` payload; for JetBrains, confirm McpLauncher augments PATH before spawn. +3. **Memory state dispatch**: Check that memoryController.js and MCP payload both support `active`, `draft`, and `stale` record keys. +4. **Protocol version**: Verify extension and MCP server agree on `IDE_GOVERNANCE_PROTOCOL_VERSION` before tool invocation. +5. **Patch-trail rendering**: For each trajectory projected with `trajectory-v3`, confirm episode and step structure matches schema in Memory database queries. + +Run integration tests: +- `pytest -q tests/test_mcp_service.py tests/test_mcp_server.py` for MCP contract. +- JetBrains plugin tests: `./gradlew test` in `extensions/jetbrains-codeclone/`. +- VS Code tests: `npm test` in `extensions/vscode-codeclone/`. + +## Evidence index + +| Item | Source | Status | +|------|--------|--------| +| JetBrains MCP launch PATH issue and fix | `extensions/jetbrains-codeclone/src/main/kotlin/.../McpLauncher.kt` | path_only: directory exists; environment augmentation not verified via constant or test evidence | +| VS Code coverage path normalization requirement | `extensions/vscode-codeclone/src/runtime.js` | path_only: file exists; exact behavior and repo-relative enforcement not independently verified | +| Memory bulk-governance stale-record dispatch | `extensions/vscode-codeclone/src/memoryController.js` | path_only: file exists; `records_by_status` key enumeration not verified in test or declaration | +| IDE_GOVERNANCE_PROTOCOL_VERSION contract | `codeclone/contracts/__init__.py` | supported: constant defined as `2` in authoritative location | +| ENGINEERING_MEMORY_SCHEMA_VERSION | `codeclone/contracts/__init__.py` | supported: constant defined as `1.7` | diff --git a/docs/internal/surfaces/mcp-workflow.md b/docs/internal/surfaces/mcp-workflow.md new file mode 100644 index 00000000..9cb53883 --- /dev/null +++ b/docs/internal/surfaces/mcp-workflow.md @@ -0,0 +1,128 @@ +--- +title: "MCP workflow surface" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +The MCP workflow surface (`codeclone.surfaces.mcp`) exposes a deterministic, session-managed set of tools for repository analysis, change-control intent registration, and bounded structural evidence retrieval. Unlike CLI analysis (single run, then exit), MCP maintains session state across multiple tool calls, enabling iterative workflows where intents accumulate, analyze operations cache results, and context projections stay available for pagination and drill-down. + +## Contracts + +| Contract ID | Type | Value | +|------------------------------------------|--------|------------------| +| AUDIT_PROJECTION_VERSION | str | `audit-v1` | +| MEMORY_PROJECTION_VERSION | str | `memory-v1` | +| TRAJECTORY_PROJECTION_VERSION | str | `trajectory-v3` | +| SEMANTIC_INDEX_FORMAT_VERSION | str | `3` | +| ENGINEERING_MEMORY_SCHEMA_VERSION | str | `1.7` | +| REPORT_SCHEMA_VERSION | str | `2.12` | +| CACHE_VERSION | str | `2.10` | +| BASELINE_SCHEMA_VERSION | str | `2.1` | +| PATCH_TRAIL_SCHEMA_VERSION | str | `1` | + +**Key invariants:** + +- MCP holds **exactly one active change-control intent per session**. Calling `start_controlled_change` before finishing the current intent evicts it with no recovery path. +- Implementation-context facet pages returned by `get_implementation_context_page` must be byte-exact from the saved session projection artifact, not recomputed. Mismatches or not-found errors are acceptable; silent recomputation is not. +- Baseline, cache, and analysis report artifacts are immutable from the MCP perspective. Write operations (via CLI or direct file mutation) are not coordinated through MCP tooling. +- Session runs are ephemeral in-memory. `clear_session_runs` evicts all cached analysis and workspace state for the MCP server process. + +## Implementation map + +```mermaid +graph TD + A["MCP Service Entry
codeclone.surfaces.mcp"] -->|session_state| B["Workspace Intent
Lifecycle"] + A -->|analysis_dispatch| C["Analyze Repository
analyze_repository"] + A -->|analysis_dispatch| D["Analyze Changed Paths
analyze_changed_paths"] + A -->|evidence_retrieval| E["Implementation Context
get_implementation_context"] + A -->|evidence_retrieval| F["Context Facet Pages
get_implementation_context_page"] + A -->|change_control| G["Start Intent
start_controlled_change"] + A -->|change_control| H["Finish Intent
finish_controlled_change"] + A -->|triage| I["Production Triage
get_production_triage"] + A -->|report_access| J["Report Section
get_report_section"] + + B -->|manages| K["Active Intent
single per session"] + C -->|stores| L["Run Cache
in-memory"] + D -->|stores| L + E -->|reads| L + E -->|projects| M["Projection Artifacts
analysis context digest"] + F -->|reads| M + G -->|requires| L + G -->|conflicts| K + H -->|requires| L + H -->|clears| K + + style K fill:#f96 + style M fill:#fc9 + style L fill:#9cf +``` + +| Tool | Purpose | Session State | Requirements | +|------|---------|---------------|--------------| +| `analyze_repository` | Full deterministic analysis from scratch or cached | Stores run in-memory | Absolute root, valid cache_policy (`reuse`, `off`) | +| `analyze_changed_paths` | PR-style analysis on file subset | Stores run in-memory | Absolute root, paths or git ref, cache_policy | +| `get_run_summary` | Compact snapshot of latest or named run | Reads in-memory | Valid run_id (8-char short or full digest) | +| `get_report_section` | One bounded report section (inventory, findings, metrics) | Reads in-memory | Section name, optional pagination filters | +| `get_implementation_context` | Bounded structural evidence from one stored run | Projects and caches facet pages | Absolute root, target paths/symbols, valid run_id | +| `get_implementation_context_page` | Exact facet page from session projection artifact | Reads projection artifact only | Context projection digest, facet key | +| `start_controlled_change` | Register change intent and compute blast radius | Creates active intent, increments counter | Absolute root, scope dict, intent statement, valid run_id | +| `finish_controlled_change` | Hygiene check, verify, patch trail, receipt, clear intent | Reads active intent, clears on accepted | Intent_id, changed_files or after_run_id, optional claims | +| `get_production_triage` | Health + hotspots + production suggestions | Reads in-memory | Valid run_id | +| `list_findings` | Paginated canonical finding groups | Reads in-memory | Family filter (optional), pagination | +| `clear_session_runs` | Evict all in-memory runs and workspace state | Clears all state | (none) | +| `generate_pr_summary` | Markdown or JSON summary for changed files | Reads in-memory | Changed files list, format preference | + +## Failure modes + +| Scenario | Status | Recovery | +|----------|--------|----------| +| `start_controlled_change` called without prior `analyze_repository` | `status: "needs_analysis"` | Run `analyze_repository` first, retry | +| Concurrent intent exists and was not queued by caller | `status: "blocked"` (partial_enforce), `concurrent_intents` non-empty | Narrow scope, queue with `on_conflict="queue"`, or promote/clear foreign intent via `manage_change_intent` | +| Intent evicted mid-cycle by second `start_controlled_change` | No tracking | **Unrecoverable.** Active intent id becomes invalid; git tree changes are unsupervised. Prevention: call `finish_controlled_change` before starting a new intent. | +| `finish_controlled_change` with mismatched `intent_id` | `status: "unverified"`, `finish_block_reason: missing_evidence` | Verify the intent is active via session state, or use correct intent_id | +| Projection artifact missing when calling `get_implementation_context_page` | `not_found` or `mismatch` | Re-run `get_implementation_context` to regenerate projection, retry | +| Session cleared via `clear_session_runs` | All runs and intents evicted | Re-run `analyze_repository`, re-open intent with `start_controlled_change` | + +**Non-recoverable in-flight evictions:** A second `start_controlled_change` call before `finish_controlled_change` silently evicts the first intent. The intent_id remains in the workspace registry but is no longer tracked by the MCP session, making `finish_controlled_change` fail with `unverified` status and missing evidence. Always call `finish` before starting a new intent. + +## Verification + +All internal MCP tests reside in `tests/test_mcp_*.py`. Key test suites: + +- **Workflow contract:** `tests/test_mcp_context_governance.py`, `tests/test_mcp_service.py` — intent lifecycle, start/finish state machine, session state isolation +- **Security & auth:** `tests/test_mcp_http_auth.py`, `tests/test_mcp_security_hardening.py` — context governance, credential handling, sandbox isolation +- **Memory integration:** `tests/test_mcp_memory_management.py`, `tests/test_mcp_memory_semantic.py` — projection sync, stale record handling, semantic lane integrity +- **Server operation:** `tests/test_mcp_server.py`, `tests/test_mcp_shutdown.py` — startup, message handling, graceful shutdown, runpy guard +- **Tool schema:** `tests/test_mcp_tool_schema_snapshot.py`, `tests/test_mcp_tools.py` — tool registration, input validation, response contracts + +Verify MCP changes with: + +```bash +uv run pytest -q tests/test_mcp_*.py tests/test_memory_mcp_sync.py tests/test_observability_mcp_registrar.py +``` + +## Evidence index + +**Supported (asserted as current fact):** + +- One active intent per MCP session, evicted on `start_controlled_change` without prior finish (mem-527db78f). +- Help topics synchronized with context-governance response contract (mem-74b325675fbf). +- MCP analyze_repository wraps IO in observability spans for baseline, cache_load, report (mem-8973d8741052416c). + +**Path-only (background framing, not freshly verified):** + +- Implementation-context facet pages computed once, returned from projection artifact only (mem-0ebd1dfb). +- Help topics and claim-guard negation handling synchronized (mem-74b325675fbf, mem-1173f7d9). + +**Unverified (do not assert as current behavior):** + +- Claim guard claim skips negated keyword hits matching memory governance negation (mem-1173f7d9) — subject_path `_claim_guard.py` exists but constant `STRUCTURAL_SCOPE_KEYWORDS` not confirmed in source. + +--- + +**Source:** CodeClone 2.1.0a1, commit d88c17f0. Module map: 769 modules, 3,322 edges, `codeclone.surfaces.mcp` package. diff --git a/docs/internal/surfaces/memory-migration.md b/docs/internal/surfaces/memory-migration.md new file mode 100644 index 00000000..f06432a4 --- /dev/null +++ b/docs/internal/surfaces/memory-migration.md @@ -0,0 +1,158 @@ +--- +title: "Engineering Memory migration and compatibility" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Engineering Memory migration bridges schema versions across CodeClone releases and ensures backward compatibility for stored evidence. This surface documents: + +- Schema version contracts and migration protocol +- Durability guarantees (synchronous fsync, commit-anchoring) +- Staleness and subject-inventory edge cases +- Known performance and type-coercion risks +- Verification requirements for schema changes + +## Contracts + +### Schema versioning + +| Contract | Value | Scope | +|----------|-------|-------| +| `ENGINEERING_MEMORY_SCHEMA_VERSION` | 1.7 | Memory store schema; bumped on column/table changes | +| `MEMORY_PROJECTION_VERSION` | memory-v1 | Retrieval projection interpretation layer | +| `TRAJECTORY_PROJECTION_VERSION` | trajectory-v3 | Trajectory evidence indexing; v1 deprecated | +| `EXPERIENCE_DISTILLATION_VERSION` | experience-v1 | Experience aggregation and ranking semantics | +| `SEMANTIC_INDEX_FORMAT_VERSION` | 3 | Embedding vector storage and lookup codec | + +**Migration invariant:** Schema bumps require: + +1. New column addition via `_add_column_if_missing()` in `codeclone/memory/schema_migrate.py` +2. Version increment in both `codeclone/contracts/__init__.py` and test version pins +3. Migration record via `_record_schema_migration()` to track applied versions +4. Tests covering the migration path (e.g., `tests/test_memory_schema.py`) + +**Durability guarantee:** Memory store uses `synchronous=FULL` (every commit fsync'd); intent and audit stores use `synchronous=NORMAL` (loss-tolerable ephemeral state). + +### Commit-anchoring principle + +Memory records must preserve evidence linkage via commit provenance, not inventory membership: + +- `MemoryRecord.anchor_commit`: commit SHA where the record was created; defines staleness drift +- `MemoryRecord.subject_path`: stored path for the subject; staleness should measure drift from anchor, not absence from current inventory +- Vacuum must never delete on subject-path-missing alone; subject absence is a state change, not a disqualifier + +**Violation risk:** Linking staleness to inventory membership causes false-positive stale markers for renamed or reorganized subjects. + +### Non-Python subject handling + +Inventory-based staleness has a known edge case for non-Python subjects (docs, config, JS, etc.): + +- `staleness._subject_inventory_stale_reason()` applies `linked_path_missing` for all subjects when path is absent +- Bug: `inventory_paths_from_report()` only lists `.py` files; non-Python subjects are always marked missing +- Fix scope: apply `linked_path_missing` only to Python (`.py`, `.pyi`) subjects; for other subjects, rely on commit-anchor drift and explicit archive + +## Implementation map + +```mermaid +graph LR + A["schema.py
(store init)"] -->|synchronous=FULL| B["SQLite
(memory store)"] + C["schema_migrate.py
(add_column_if_missing)"] -->|on first write| B + D["staleness.py
(subject_inventory_stale_reason)"] -->|check anchor drift| B + E["experience/distiller.py
(batch insert)"] -->|write-side N+1| B + F["embedding/fastembed_provider.py
(numbers.Real guard)"] -->|coerce vectors| B + G["retrieval/*"] -->|MEMORY_PROJECTION_VERSION| H["get_relevant_memory
(MCP)"] + I["trajectory/*"] -->|TRAJECTORY_PROJECTION_VERSION| H + B -->|memory-v1 layout| H +``` + +**Migration entry point:** `schema_migrate.py` applies deferred migrations on store init or explicit `refresh_from_run()`. Schema version reads from `codeclone/contracts/__init__.py` (source of truth). + +**Retrieval projection:** `get_relevant_memory()` (MCP tool) interprets stored records through `MEMORY_PROJECTION_VERSION`, decoding stale markers, trajectory evidence, and experience rankings. Projection mismatches are detected at read-time; cursor snapshot checks in `get_memory_projection_page()` prevent stale-cursor replies. + +## Failure modes + +### Migration under concurrent write + +**Trigger:** Schema migration (`_add_column_if_missing()`) runs while background jobs or agents write records. + +**Risk:** If migration runs before worker processes sync, workers may write to old schema version; rollback may lose in-flight writes. + +**Mitigation:** Migration is synchronous at store init; background workers poll for schema version and fail gracefully on mismatch. Test: `test_memory_schema.py` covers concurrent add-column scenarios. + +### Non-Python subject inventory staleness + +**Trigger:** Memory record for `docs/README.md` is created; later the file is deleted (legitimate refactor). + +**Risk:** Staleness check applies `linked_path_missing` because file is absent from current inventory, falsely marking the record stale despite valid commit anchor. + +**Impact:** Record may be archived or filtered during retrieval, losing evidence of prior documentation decisions. + +**Mitigation:** Limit `linked_path_missing` to `.py` subjects. Non-Python subjects rely on commit-anchor drift and explicit archive. Test: `test_memory_staleness.py` must cover docs and config subjects. + +### Experience distillation N+1 writes + +**Trigger:** `experience.distiller.distill_experiences()` produces one experience and writes ~18 rows (edges, metadata, scoring). + +**Risk:** Each write is an individual INSERT statement; live telemetry shows 18:1 write-to-output ratio. Under high memory load, disk I/O and locking contention spike. + +**Mitigation:** Batch INSERT consolidation in `distiller.py` (Phase 2 improvement). Current workaround: tune `DEFAULT_MAX_CACHE_SIZE_MB` and projection job scheduling. + +### Embedding type coercion + +**Trigger:** `fastembed_provider.embed()` returns `numpy.float32` scalars (not Python `float` / `int` subclasses). + +**Risk:** Code checking `isinstance(scalar, (float, int))` passes; later arithmetic or JSON serialization fails because numpy scalars have different repr and precision semantics. + +**Mitigation:** Guard coercion on `numbers.Real` (excludes `bool`). Test: `test_memory_embedding_vectors.py` (or equivalent) must verify numpy.float32 round-trip. + +### Duplicate consecutive guard branches + +**Trigger:** `distill_experiences()` has two consecutive trivial `continue` or `return None` guards (e.g., checking for None, then checking for empty). + +**Risk:** CodeClone detects as duplicated_branches structural issue; code review noise. + +**Mitigation:** Merge guards into a single compound condition. Recurring lesson: avoid >=2 sequential trivial control-flow branches. + +## Verification + +Schema changes require: + +1. **Unit tests** (in `tests/test_memory_schema.py`): + - `_add_column_if_missing()` adds column correctly + - Version read from contracts is current + - Migration record is logged + +2. **Integration tests** (in relevant memory test suite): + - Records written pre-migration can be read post-migration + - Projection cursor snapshot matches after migration + - Non-Python subject staleness does not apply `linked_path_missing` + +3. **Manual verification**: + - Run `codeclone memory init` with schema version bump + - Verify existing memory store loads without error + - Verify projection tools (get_relevant_memory, query_engineering_memory) work without regression + +4. **Linting**: + ```bash + uv run pytest -q tests/test_memory_schema.py tests/test_memory_staleness.py + uv run ruff check codeclone/memory/ + ``` + +## Evidence index + +| Subject | Type | Status | Note | +|---------|------|--------|------| +| `codeclone/memory/schema.py` | risk_note | path_only | Synchronous fsync durability guarantee; version source in `contracts/__init__.py` | +| `codeclone/memory/staleness.py` | risk_note | path_only | Commit-anchor principle; non-Python subject inventory edge case (BUG: apply linked_path_missing to .py only) | +| `codeclone/memory/experience/distiller.py` | risk_note | path_only | N+1 write performance (18 writes per experience); confirmed by live telemetry; fix direction is batch INSERT | +| `codeclone/memory/embedding/fastembed_provider.py` | risk_note | path_only | numpy.float32 coercion; guard on `numbers.Real`, not `isinstance(float\|int)` | +| `codeclone/memory/schema_migrate.py` | risk_note | path_only | Protocol: `_add_column_if_missing()` + `_record_schema_migration()`; schema-version bump includes test pins | +| `codeclone/memory/experience/distiller.py` | risk_note | path_only | Duplicated guard branches (consecutive continue guards); recurring structural issue | +| `tests/test_memory_schema.py` | test_anchor | supported | Unit tests for schema migration and version contracts | +| `tests/test_memory_staleness.py` | test_anchor | supported | Tests for commit-anchor staleness and inventory edge cases | +| `tests/test_memory_experience_distiller.py` | test_anchor | supported | Tests for experience distillation; N+1 not optimized but no correctness regression | diff --git a/docs/internal/surfaces/observability-analytics.md b/docs/internal/surfaces/observability-analytics.md new file mode 100644 index 00000000..f0856245 --- /dev/null +++ b/docs/internal/surfaces/observability-analytics.md @@ -0,0 +1,121 @@ +--- +title: "Observability and analytics" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +Observability and analytics provide runtime diagnostics and structural assessment +of CodeClone's own execution. Observability captures telemetry during analysis +(database operations, memory consumption, pipeline stages, slow operations). +Analytics profiles repository structures and selects representative corpus +samples for baseline construction and quality assessment. These surfaces are +for maintainers diagnosing CodeClone behavior, not for user-facing quality +claims about analyzed repositories. + +## Contracts + +| Contract | Value | Kind | Note | +|----------|-------|------|------| +| `ENGINEERING_MEMORY_SCHEMA_VERSION` | `1.7` | str | Schema for durable change/incident records | +| `MEMORY_PROJECTION_VERSION` | `memory-v1` | str | Memory audit trail and trajectory versioning | +| `AUDIT_PROJECTION_VERSION` | `audit-v1` | str | Change control audit artifact versioning | +| `CORPUS_ANALYTICS_STORE_SCHEMA_VERSION` | `1.2` | str | Analytics store and profile manifest schema | +| `CORPUS_EMBEDDING_CONTRACT_VERSION` | `2` | str | Embedding representation versioning | +| `SEMANTIC_INDEX_FORMAT_VERSION` | `3` | str | Semantic index fingerprinting | +| `TRAJECTORY_PROJECTION_VERSION` | `trajectory-v3` | str | Trajectory audit projection for patch trails | +| `IDE_GOVERNANCE_PROTOCOL_VERSION` | `2` | int | IDE workspace coordination (workspace intents) | + +Must not be changed casually. Schema violations block finish_controlled_change. + +## Implementation map + +```mermaid +graph TB + A[CodeClone analysis execution] -->|spans & counter collection| B[codeclone.observability] + A -->|corpus profiles & batches| C[codeclone.analytics] + + B --> B1[runtime: tracer, trace_context, connection wrapper] + B --> B2[db_fingerprint: predicate parsing & normalization] + B --> B3[query: operation_detail & span_detail sections] + B --> B4[reader: query_platform_observability retrieval] + B --> B5[store: telemetry event persistence] + + C --> C1[clustering: sweep, candidate_grid, ranking] + C --> C2[control_plane: bundled profile manifests, versioned selection] + C --> C3[store: analytics event persistence & corpus metadata] + + B4 -->|MCP tool| D[query_platform_observability] + D -->|sections| E["summary, slow_operations, memory_pipeline_cost,
db_cost, agent_context, mcp_tool_matrix,
correlated_chains, costly_noops, pipeline,
analysis_phase_cost"] + + C2 -->|immutable batches| F[baseline construction & assessment] +``` + +### Observability packages + +**`codeclone.observability`** — runtime telemetry collection: + +- `runtime.py`: Tracer and trace context. Counts logical database statements (not callback fires) via connection wrapper. Spans pipeline stages with RSS snapshots. +- `db_fingerprint.py`: Parses normalized fingerprints (kind/table/where_columns) from raw SQL. Cockpit DB QUERY SHAPES table displays predicate summary as primary cell. +- `query.py`: Sections for operation_detail, span_detail with per-span memory and counters. CLI path lacks three B spans (Phase 29 deferral). +- `reader.py`: Exposes `query_platform_observability` MCP tool for maintainer diagnostics. +- `store.py`: Persists telemetry events; keyed by trace_id and session_id. + +### Analytics packages + +**`codeclone.analytics`** — corpus profiling and control: + +- `clustering/sweep.py`: Deterministic candidate grid and ranking over repository structure. +- `control_plane.py`: Manages versioned bundled profile manifests (Phase 31), immutable profile batches, and append-only maintainer selections. +- `store.py`: Persists analytics events and corpus metadata. + +## Failure modes + +| Scenario | Symptom | Root cause | Mitigation | +|----------|---------|-----------|-----------| +| N+1 false positive | DB counter inflated | `sqlite3.set_trace_callback` fires per executemany row, indistinguishable from loop | Connection wrapper counts logical statements, not trace fires | +| Missing B spans (CLI) | Cockpit incomplete | Phase 29 deferred three B spans for CLI path | Requires observer boilerplate in pipeline CLI interface | +| Outdated profile selection | Stale corpus | Maintainer selection not persisted immutably | Use control_plane append-only record; never mutate selection history | +| Schema drift | Finish blocked | Contract version mismatch | Do not update SCHEMA_VERSION constants without audit trail and baseline reset | + +## Verification + +Run these tests to validate observability and analytics behavior: + +```bash +# Observability core +uv run pytest -q tests/test_observability_runtime.py +uv run pytest -q tests/test_observability_db_fingerprint.py +uv run pytest -q tests/test_observability_query.py + +# Analytics core +uv run pytest -q tests/test_analytics_foundation.py +uv run pytest -q tests/test_analytics_profiles.py +uv run pytest -q tests/test_analytics_store.py + +# Integration +uv run pytest -q tests/test_observability_analysis_phases.py +uv run pytest -q tests/test_analytics_integration.py +uv run pytest -q tests/test_cli_memory_observability.py + +# MCP surface +uv run pytest -q tests/test_observability_mcp_registrar.py +``` + +Invariant: Observability must not mutate baselines, cache, or generated reports. +Analytics profiles are append-only; never backfill or replace maintainer selections. + +## Evidence index + +| Record | Status | Note | +|--------|--------|------| +| mem-b05428e6 | path_only | Observer A/B/C done for MCP; CLI still lacks three B spans | +| mem-cd43af53 | path_only | db_fingerprint parses normalized predicates for cockpit DB QUERY SHAPES table | +| mem-f473582b | path_only | v2 logical statement counting fixed earlier N+1 false positive | +| mem-e5676ddc | path_only | Phase 31 control plane versioned bundled manifests and immutable selections | + +Records marked `path_only` provide background context; assertions require code verification. diff --git a/docs/internal/surfaces/packaging-release.md b/docs/internal/surfaces/packaging-release.md new file mode 100644 index 00000000..c7563816 --- /dev/null +++ b/docs/internal/surfaces/packaging-release.md @@ -0,0 +1,218 @@ +--- +title: "Packaging and release gates" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +# Packaging and release gates + +## Purpose + +This surface documents the **packaging topology**, **release automation**, and **edit-cycle governance** for the CodeClone Python wheel and MCP server distribution. + +**Scope:** `pyproject.toml`, `.github/workflows/`, `uv.lock` dependency resolution, and the boundary between CLI and MCP edit permissions. + +**Out of scope:** IDE/agent marketplace distribution (see `docs/releasing.md`), documentation site build (`docs/publishing.md`), or plugin-specific launch contracts. + +## Contracts + +### Packaging topology + +```mermaid +graph TD + A["pyproject.toml"] -->|base deps| B["codeclone wheel"] + A -->|optional[mcp]| C["MCP runtime extra"] + C -->|requires| D["mcp ≥1.28.1,<2"] + C -->|requires| E["httpx ≥0.27.1,<1"] + B -->|read-only access| F["Controller artifacts"] + C -->|write to| G["Intent registry
.codeclone/db/"] + C -->|write to| H["Audit log
.codeclone/db/"] + I["CLI
codeclone main:main"] -->|no MCP import| B + J["MCP server
codeclone-mcp
server:main"] -->|requires| C + K["edit_allowed workflow"] -->|MCP-only| J + K -->|blocked| I + L[".github/workflows/
publish.yml"] -->|triggers on| M["release published"] + M -->|validates| N["tag = version
in pyproject.toml"] + N -->|builds| O["sdist + wheel"] + O -->|publishes| P["PyPI"] +``` + +**Key invariant:** `mcp` is **OPTIONAL**. Base CLI reads controller artifacts read-only via `codeclone.audit.reader` (no MCP import). Governed edit cycle (`start_controlled_change` / `finish_controlled_change` / `edit_allowed`) is **MCP-surface-only** with no CLI equivalent. + +### Dependency freeze + +| Extra group | When to install | Critical pins | +|-------------------|------------------------------------------|--------------------------------| +| `mcp` | Agent-assisted CI, IDE workflows | `mcp>=1.28.1,<2`, `httpx>=0.27.1,<1` | +| `token-bench` | Token budgeting experiments | `tiktoken>=0.13.0` | +| `coverage-xml` | External Cobertura import | `defusedxml>=0.7.1,<0.8` | +| `semantic-*` | Semantic memory backend selection | `lancedb>=0.33.0`, `fastembed>=0.8.0,<0.9` | +| `analytics` | Corpus metrics and clustering (heavy) | `scikit-learn>=1.5.0`, `umap-learn` (Python <3.14) | +| `perf` | Runtime timing and profiling | `psutil>=7,<8` | +| `dev` | Testing, type checking, release build | `pytest>=9.1.0`, `mypy>=1.20.1`, `ruff>=0.15.20` | + +**Wheel size:** Base ~5 MB (sdist) → ~2–3 MB (wheel); `[mcp]` adds ~0.5 MB. Analytics bundle ~15 MB installed. + +### Release automation gates + +**Publish workflow** (`.github/workflows/publish.yml`): + +1. **Trigger:** GitHub Release published OR manual workflow dispatch (`testpypi` / `pypi`). +2. **Tag validation** (releases only): + - Extract tag name from event (e.g., `v2.1.0a1`). + - Read `version` from `pyproject.toml`. + - Normalize: strip leading `v`. + - **Gate:** Fail if tag ≠ version. No version bump allowed during release cut. +3. **Build:** `uv run --with build python -m build --sdist --wheel` (Python 3.14). +4. **Validate:** `twine check dist/*` — rejects invalid metadata, missing long_description, broken classifiers. +5. **Publish:** + - **TestPyPI:** Manual dispatch only (environment: `testpypi`). + - **PyPI:** Automatic on release published OR manual dispatch (environment: `pypi`). + - Both use OIDC token (no stored secrets). + +**No pre-release gate exists.** A release tag automates PyPI publish; no approval step enforces quality checks. Baseline, tests, and CI must be green before tagging. + +## Implementation map + +### Files and responsibilities + +| File | What it owns | +|-------------------------------|----------------------------------------------------------------------------------------| +| `pyproject.toml` | Version string, base + optional deps, package list, entry points (`codeclone`, `codeclone-mcp`) | +| `uv.lock` | Pinned versions (generated by `uv lock` after `pyproject.toml` edit) | +| `.github/workflows/publish.yml` | Release build, validation, PyPI upload; tag-to-version check | +| `.github/workflows/tests.yml` | Pre-release CI: pytest, coverage, mypy, ruff | +| `codeclone/contracts/__init__.py` | Version constants (`BASELINE_FINGERPRINT_VERSION`, etc.); read by tools and MCP memory ingest | +| `codeclone.audit.reader` | Read-only artifact access (no MCP dependency); CLI entry point | +| `codeclone.surfaces.mcp.server:main` | MCP server entry point (`codeclone-mcp`); imports MCP runtime | + +### review_kit exclusion + +The maintainer-only review kit is not tracked in this repository — it lives +under the gitignored `.claude/` tree (`.claude/tools/review/`). It is therefore +structurally excluded from the sdist/wheel: only `codeclone.*` packages are +declared in `[tool.setuptools].packages`, which is enforced by +`tests/test_packaging.py::test_setuptools_packages_are_codeclone_only`. + +## Failure modes + +### Release tag mismatch + +**Symptom:** Publish workflow fails with `release tag X does not match project version Y`. + +**Cause:** Tag pushed without bumping `pyproject.toml` version, or vice versa. + +**Fix:** +```bash +# If version is wrong in pyproject.toml: +git tag --delete v2.1.0 +git push origin :v2.1.0 +# Bump pyproject.toml, commit, then re-tag: +git tag v2.1.1 +git push origin v2.1.1 +``` + +### Twine validation failure + +**Symptom:** `twine check dist/*` fails (invalid metadata, broken long_description). + +**Causes:** +- `readme` file missing or broken (e.g., `docs/README-pypi.md` deleted). +- Classifier mismatch with Python versions. +- Invalid license identifier. + +**Fix:** Check build output, fix `pyproject.toml`, rebuild locally: +```bash +uv run --with build python -m build --sdist --wheel +uv run --with twine twine check dist/* +``` + +### MCP import in base CLI + +**Symptom:** `codeclone` CLI crashes if MCP not installed (import error in `codeclone.surfaces.mcp`). + +**Cause:** Base CLI code path imports from MCP surface unconditionally. + +**Fix:** Audit all imports in `codeclone.main` and `codeclone.audit.reader`; use dynamic imports or try/except for MCP: +```python +try: + from codeclone.surfaces.mcp import ... +except ImportError: + # MCP optional; provide fallback or skip feature + pass +``` + +## Verification + +### Pre-release checklist + +Before tagging a release: + +1. **Version consistency:** + ```bash + python -c "import tomllib; print(tomllib.load(open('pyproject.toml', 'rb'))['project']['version'])" + # Should match your intended tag (e.g., 2.1.0a1) + ``` + +2. **Baseline and tests green:** + ```bash + uv run pytest -q + uv run pre-commit run --all-files + codeclone . --fail-on-new # Ensure baseline is current + ``` + +3. **Lock file committed:** + ```bash + git status uv.lock # Should show no changes + ``` + +4. **Wheel validates locally:** + ```bash + uv run --with build python -m build --sdist --wheel + uv run --with twine twine check dist/* + ``` + +5. **MCP optional-ness verified:** + ```bash + python -m venv /tmp/test_base + /tmp/test_base/bin/pip install dist/codeclone-*.whl + /tmp/test_base/bin/codeclone --help # CLI works without [mcp] + + # Then with MCP: + /tmp/test_base/bin/pip install 'dist/codeclone-*.whl[mcp]' + /tmp/test_base/bin/codeclone-mcp --help # Server works + ``` + +### CI gates + +The **publish workflow** runs: + +| Step | Gate passes if | Blocks release on failure | +|--------------------------|--------------------------------------------------|--------------------------| +| Checkout + Python 3.14 | Code available | Yes | +| Tag validation | Tag == version (release event only) | Yes | +| Build sdist + wheel | No syntax errors, build deps available | Yes | +| Twine check | Metadata valid, classifiers match versions | Yes | +| Upload to TestPyPI | OIDC token valid (manual dispatch) | Yes | +| Upload to PyPI | OIDC token valid (automatic on release) | Yes | + +No approval gate exists between build and PyPI publish. **QA responsibility:** Ensure baseline and tests pass before creating the release. + +## Evidence index + +| Claim | Evidence | Status | +|------------------------------------------|-----------------------------------|---------------| +| `mcp` is optional | `pyproject.toml` line 68–71 | supported | +| MCP-only edit cycle | CLAUDE.md § Change control; MCP server surface only | supported | +| Base CLI has no MCP import | `codeclone.audit.reader` contract; `codeclone.main` entry point | path_only | +| review_kit must not ship | out of repo (`.claude/tools/review/`, gitignored); no setuptools pkg inclusion | supported | +| Publish automates on release tag | `.github/workflows/publish.yml` line 8, 96–117 | supported | +| Tag-to-version gate enforced | `.github/workflows/publish.yml` line 45–61 | supported | +| Twine validation required | `.github/workflows/publish.yml` line 66–67 | supported | + +--- + +**Last updated:** 2026-07-06 (commit d88c17f0) diff --git a/docs/internal/surfaces/setup-apply.md b/docs/internal/surfaces/setup-apply.md new file mode 100644 index 00000000..ea849974 --- /dev/null +++ b/docs/internal/surfaces/setup-apply.md @@ -0,0 +1,214 @@ +--- +title: "Setup apply and filesystem mutation" +audience: internal +doc_type: surface +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +source_packet: codeclone_mcp_module_map +--- + +## Purpose + +`setup apply` is the bounded mutation surface that writes filesystem changes to satisfy readiness requirements. It recomputes the active plan before every apply, confirms mutations with the caller (interactive) or gate flags (non-interactive), applies only ready actions in sorted order, and reports atomicity and success per action. + +Setup changes are limited to two action kinds: `pyproject_merge` (tool.codeclone config updates) and `gitignore_append` (cache directory exclusion). No other filesystem mutations are available through this surface. + +## Contracts + +### CLI Interface + +``` +codeclone setup apply [--dry-run] [--yes | --plan-id PLAN_ID] [--root ROOT] +``` + +**Flags:** + +| Flag | Effect | Requirement | +|------|--------|-------------| +| `--dry-run` | Preview writes without modifying files; returns status=preview for all actions | Skips confirmation gate; implies --yes | +| `-y, --yes` | Skip confirmation prompt; required for non-interactive execution | Mutually exclusive with interactive confirmation | +| `--plan-id PLAN_ID` | Only proceed if the recomputed plan matches this ID (stale-plan guard) | Optional; when supplied, blocks apply with status=stale_plan on mismatch (exit 2) | +| `--root ROOT` | Repository root path | Default: `.` | + +**Exit codes:** + +- `0` (SUCCESS): Apply completed without errors (status in `{noop, applied}`) +- `2` (CONTRACT_ERROR): Confirmation required but no TTY, stale plan detected, or invalid arguments (status in `{blocked, stale_plan}`) +- `5` (INTERNAL_ERROR): Action execution failed (status in `{failed, partial}`) + +### Confirmation Gate (Interactive Apply) + +When both `--dry-run` and `--yes` are absent, `_confirmation_gate` runs: + +1. Require TTY on stdin and stdout; if missing, refuse with CONTRACT_ERROR +2. Call `_confirm_apply`: recompute plan, render it, prompt user with `[y/N]` +3. User reply `y` or `yes` → proceed with the confirmed `plan_id` +4. User reply anything else or no TTY → refuse with SUCCESS (no action taken) + +This gate returns `(plan_id, None)` on acceptance or `(None, exit_code)` on refusal. It is skipped if `--dry-run` or `--yes` is supplied. + +### Apply Status Machine + +```mermaid +graph TD + A["recompute plan
vs expected_plan_id"] -->|mismatch| B["status=stale_plan
exit 2"] + A -->|match| C["collect ready actions"] + C -->|none| D{plan.status} + D -->|blocked| E["status=blocked
exit 2"] + D -->|other| F["status=noop
exit 0"] + C -->|ready actions exist| G["apply sorted by action.id"] + G -->|first failure| H{any applied?} + H -->|yes| I["status=partial
exit 5"] + H -->|no| J["status=failed
exit 5"] + G -->|all succeeded| K{dry_run?} + K -->|yes| L["status=preview
exit 0"] + K -->|no| M["status=applied
exit 0"] + I -->|if dry_run| N["status=preview"] +``` + +**Status values:** + +- `noop`: No ready actions in plan (nothing to apply) +- `preview`: Dry-run succeeded (all actions would be applied) +- `applied`: Writes completed successfully +- `partial`: Some actions applied, then one failed +- `failed`: First action failed (no prior writes) +- `blocked`: Plan status is blocked (e.g., missing required capability) +- `stale_plan`: Recomputed plan ID does not match `expected_plan_id` + +### Action Apply Results + +Each action in the result carries: + +```json +{ + "id": "unique action identifier", + "kind": "pyproject_merge | gitignore_append", + "path": "target file path", + "status": "applied | preview | skipped | failed", + "message": "error detail if status=failed; reason if status=skipped", + ...metadata... +} +``` + +**Action status rules:** + +- `applied`: Filesystem mutation completed (non-dry-run only) +- `preview`: Filesystem mutation would occur (dry-run only) +- `skipped`: Target already satisfied; no mutation needed +- `failed`: Execution error (I/O, permissions, validation) + +### Stale-Plan Guard + +If `expected_plan_id` is supplied, `apply_setup_plan` recomputes the plan and compares `plan.plan_id` to the expected value: + +- **Match**: proceed with apply +- **Mismatch**: refuse with `status="stale_plan"` (exit 2), no actions applied + +This guard prevents applying an outdated plan when repository state changes between preview (`setup plan`) and apply. + +### Readiness Authority + +`derive_readiness` (rules R1-R9 in `rollup.py`) is the sole readiness authority. Rollup presentation handlers (`describe_capability`) supply only human-readable `reason` and `recommended_action` strings and **must never override readiness**. Setup apply queries `plan.status` and each action's `status == "ready"` to decide what to apply; these values flow from the immutable readiness derivation, not from presentation. + +## Implementation map + +**Primary entry and control flow:** + +- `codeclone/surfaces/cli/setup/main.py`: CLI parser, `_run_apply`, `_confirmation_gate`, exit code derivation +- `codeclone/surfaces/cli/setup/engine/apply.py`: `apply_setup_plan`, `_ready_actions`, action handlers +- `codeclone/surfaces/cli/setup/engine/rollup.py`: `derive_readiness` (R1-R9), presentation only in `describe_capability` + +**Action handlers:** + +| Kind | Handler | Mutation | +|------|---------|----------| +| `pyproject_merge` | `_apply_pyproject_merge` | Merge tool.codeclone config into pyproject.toml via `merge_tool_codeclone` | +| `gitignore_append` | `_apply_gitignore_append` | Append cache line to .gitignore; verify coverage post-write | + +**Plan recomputation and validation:** + +- `codeclone/surfaces/cli/setup/engine/plan.py`: `build_setup_plan` (called once per apply to guard against stale plans) +- `codeclone/config/pyproject_writer.py`: `merge_tool_codeclone` (atomic merge, rollback on error) +- `codeclone/paths/gitignore.py`: gitignore helpers (`append_gitignore_line`, `write_gitignore_text_atomically`, post-write verify) + +## Failure modes + +### Stale Plan Between Preview and Apply + +**Symptom:** `setup plan` shows plan_id X, but `setup apply --plan-id X` returns `status=stale_plan`. + +**Root cause:** Repository config or readiness changed between plan preview and apply (e.g., user edited pyproject.toml, fixture was deleted). + +**Recovery:** Rerun `setup plan` to see the new plan_id, then `setup apply --plan-id `. + +### Partial Apply (Some Actions Succeed, One Fails) + +**Symptom:** `status=partial` with mixed applied/skipped/failed results. + +**Root cause:** Filesystem error (permissions, disk full, permission race) or I/O validation failure (post-write verification of .gitignore coverage). + +**Recovery:** Fix the underlying I/O condition and rerun `setup apply`; already-satisfied actions will skip (idempotent). + +### No Confirmation TTY in Interactive Mode + +**Symptom:** `setup apply` without `--yes` or `--dry-run` exits with CONTRACT_ERROR and stderr message "requires confirmation". + +**Root cause:** stdin or stdout is not a TTY (redirected pipe, running in background, CI environment). + +**Recovery:** Supply `--yes` to proceed without confirmation, or use `--dry-run` to preview. + +### Unsupported Action Kind + +**Symptom:** Action in plan has `kind` not in `{pyproject_merge, gitignore_append}`; result shows `status=failed` with "Unsupported plan action kind". + +**Root cause:** Plan engine produced an action kind without a corresponding handler in `_ACTION_HANDLERS`. + +**Impact:** This is a contract violation and indicates a bug in the plan builder or a version mismatch. User cannot recover without a code fix. + +### Post-Write Gitignore Verification Failure + +**Symptom:** `gitignore_append` action returns `status=failed` with message "post-write verification failed". + +**Root cause:** After appending the cache pattern to .gitignore, the verification function `repo_gitignore_covers_codeclone_cache` found that the .codeclone directory is not covered (e.g., subsequent line deleted the entry, or another process modified .gitignore concurrently). + +**Recovery:** Inspect .gitignore manually; ensure `.codeclone/` is not excluded elsewhere; rerun apply. + +## Verification + +**Test file:** `tests/test_cli_setup.py` + +**Key test suites:** + +- `test_apply_*`: Single-action apply scenarios (pyproject_merge, gitignore_append, stale_plan, partial failure) +- `test_confirmation_gate_*`: TTY detection, user acceptance/decline, no-TTY refusal +- `test_exit_code_*`: Exit code mapping for each status +- `test_apply_dry_run_*`: Preview mode (no filesystem writes, status=preview) + +**Manual verification:** + +```bash +# Dry-run preview +uv run codeclone setup apply --dry-run --root + +# Non-interactive apply with confirmation guard +uv run codeclone setup plan --root --json | jq -r .plan_id | \ + xargs -I {} uv run codeclone setup apply --plan-id {} --yes --root + +# Interactive apply (requires TTY) +uv run codeclone setup apply --root +``` + +**Pre-commit hook:** None. Setup apply is not run automatically; it is user-initiated. + +## Evidence index + +| Evidence | Corroboration | Citation | +|----------|---------------|----------| +| `--yes`, `--dry-run`, `--plan-id` flags prevent interactive confirmation and require non-TTY paths | supported | mem-a262a1d22861492688a8db12759c258d; main.py _run_apply lines 101–106 | +| Plan ID mismatch returns status=stale_plan (exit 2) and refuses apply | supported | mem-a262a1d22861492688a8db12759c258d; apply.py lines 43–49 | +| `derive_readiness` (R1-R9) is sole authority; rollup.describe_* must not override | path_only | mem-c91d791f131e47468f549a8dfba441fa; rollup.py docstring lines 9–12 | +| Golden fixture setup_snapshot_v1.json reflects corrected readiness model | path_only | mem-a8e1ce4f4440455e881ff858cc74267f; tests/test_cli_setup.py _GOLDEN_SNAPSHOT fixture | +| Action handlers: pyproject_merge (via merge_tool_codeclone), gitignore_append (atomic write + verify) | supported | apply.py lines 145–244; config/pyproject_writer.py, paths/gitignore.py | +| Sorted action apply by id; fail-fast on first failure with partial/failed status | supported | apply.py lines 50–80, _ready_actions line 90 | +| Status machine: noop/blocked from plan, preview on dry-run, applied on success, partial/failed on error | supported | apply.py lines 54, 66–74, 98–101 | diff --git a/docs/plans-and-retention.md b/docs/plans-and-retention.md index aede221d..c77f16f2 100644 --- a/docs/plans-and-retention.md +++ b/docs/plans-and-retention.md @@ -1,6 +1,14 @@ +--- +title: "Plans and Retention" +audience: public +doc_type: legal +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + # Plans and Retention @@ -20,22 +28,21 @@ marked **roadmap** below. change-control, memory, or integration capability is gated. - **Structural analysis & CI** — clones, complexity, coupling, cohesion, dead - code, dependency cycles, [Health Score](book/15-health-score.md), and - baseline-aware [quality gates](book/16-metrics-and-quality-gates.md). -- **Report surfaces** — canonical JSON, HTML, Markdown, text, and - [SARIF](guide/integrations/sarif/export.md), plus the - [GitHub Action](getting-started.md#github-action) (gating, SARIF upload, PR comments). + code, dependency cycles, [Health Score](concepts/health-score.md), and + baseline-aware quality gates. +- **Report surfaces** — canonical JSON, HTML, Markdown, text, and SARIF, plus + the GitHub Action (gating, SARIF upload, PR comments). - **Report-only signals** — Security Surfaces, Overloaded Modules, API-surface inventory with breaking-change detection, and external Coverage Join. - **Structural Change Controller** — intent → blast radius → bounded edit → patch verify → receipt, with Patch Trail and multi-agent coordination - ([change control](book/12-structural-change-controller/index.md)). + ([controlled change](concepts/controlled-change.md)). - **Live Implementation Context** — bounded structural, call-graph, and contract evidence. - **Engineering Memory** — typed evidence-linked facts, FTS + local `fastembed` - semantic search, Trajectory Memory, quality passports, anomaly detection, and - the [Experience Layer](book/13-engineering-memory/experience-layer.md). + semantic search, Trajectory Memory, quality passports, and anomaly detection + (see [Engineering Memory](concepts/engineering-memory.md)). - **Corpus Analytics** — offline clustering of change-control intents (`codeclone[analytics]`). -- **33 MCP tools and native integrations** — VS Code, Cursor, Claude Code, +- **38 MCP tools and native integrations** — VS Code, Cursor, Claude Code, Codex, and Claude Desktop on one canonical analysis. - **Platform Observability** — opt-in local runtime diagnostics. @@ -73,7 +80,7 @@ and **configurable without an edition cap** — retention windows are plain The intent registry, audit trail, and Engineering Memory store data locally in SQLite. Retention windows are configured in `[tool.codeclone]` and are **not capped by edition** — full key reference in -[Config and Defaults](book/10-config-and-defaults.md). +[Configuration reference](reference/configuration.md). | Store | Key | Default | |-------------------------------|----------------------------------|---------| @@ -119,8 +126,7 @@ retention and not repository quality history. It is disabled by default and local in every edition; operators own the lifecycle of `.codeclone/db/platform_observability.sqlite3`. The observer stores no raw MCP/prompt bodies and never contributes findings, gates, baselines, memory -facts, or edit authorization. See -[Platform Observability](book/26-platform-observability.md). +facts, or edit authorization. --- @@ -146,8 +152,8 @@ requirements: ## Related configuration -See [Config and Defaults](book/10-config-and-defaults.md) and -[Structural Change Controller — intent registry](book/12-structural-change-controller/index.md). +See [Configuration reference](reference/configuration.md) and +[Controlled change](concepts/controlled-change.md). ```toml [tool.codeclone] @@ -166,5 +172,5 @@ embedding_provider = "fastembed" # "diagnostic" or "fastembed" today; "api" allow_model_download = true ``` -Environment overrides: -[Config and Defaults — environment variable overrides](book/10-config-and-defaults.md#environment-variable-overrides). +See the [Configuration reference](reference/configuration.md) for environment +variable overrides. diff --git a/docs/privacy-policy.md b/docs/privacy-policy.md index d1d1f567..3291434e 100644 --- a/docs/privacy-policy.md +++ b/docs/privacy-policy.md @@ -1,18 +1,28 @@ +--- +title: "Privacy Policy" +audience: public +doc_type: legal +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + # Privacy Policy This page describes the privacy behavior of CodeClone's local integration -surfaces, including the Claude Desktop bundle. +surfaces, including the VS Code extension, Cursor plugin, Claude Code plugin, +Codex plugin, and Claude Desktop bundle. ## CodeClone local privacy model CodeClone itself is a local analysis tool. -For the CLI, MCP server, VS Code extension, and Claude Desktop bundle: +For the CLI, MCP server, VS Code extension, Cursor plugin, Claude Code plugin, +Codex plugin, and Claude Desktop bundle: - CodeClone does not run its own telemetry service - CodeClone does not send repository contents to an external CodeClone backend @@ -27,7 +37,6 @@ For the CLI, MCP server, VS Code extension, and Claude Desktop bundle: CodeClone does not provide a remote telemetry exporter. Automatic pruning of the Platform Observability database is not currently enforced; users who enable persistence control that local file's lifecycle. See -[Platform Observability](book/26-platform-observability.md) and [Plans and Retention](plans-and-retention.md). ## Claude Desktop bundle specifics diff --git a/docs/publishing.md b/docs/publishing.md index 01929277..145ebffd 100644 --- a/docs/publishing.md +++ b/docs/publishing.md @@ -1,7 +1,15 @@ +--- +title: "Publishing the Docs Site" +audience: public +doc_type: runbook +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + # Publishing the Docs Site @@ -15,7 +23,8 @@ remains the current repository code and CI workflow. !!! note "Scope" This page covers docs-site build and publishing mechanics. Public behavior - contracts still live in the book chapters and in the repository code. + contracts live in the `reference/` and `concepts/` pages and in the + repository code (the legacy `book/` tree has been removed). For integration distribution (storefront sync), see [Releasing & storefront sync](releasing.md). @@ -31,18 +40,20 @@ remains the current repository code and CI workflow. The published site contains: -- the documentation tree under `docs/` -- the contract book under `docs/book/` -- guide pages such as architecture narrative and integration pages +- the public documentation tree (concepts, guides, integrations, reference, + and troubleshooting pages) - a live sample report for the current repository build under `Examples / Sample Report` +Repository/agent-facing content lives in a separate, unpublished internal +tree and is not part of the built site. + ## Build flow The docs workflow (`.github/workflows/docs.yml`) follows this order: 1. install project dependencies -2. build the site with `zensical build --clean --strict` +2. build the site with `uv run --with zensical==0.0.46 zensical build --clean --strict` 3. generate a live sample report into `site/examples/report/live` 4. upload the built site as a GitHub Pages artifact 5. deploy on pushes to `main` @@ -78,18 +89,14 @@ git. `site/` remains ignored. ## Local preview -=== "Build the site" - - ```bash title="Validate the Zensical site" - uv run --with zensical==0.0.46 zensical build --clean --strict - ``` +Run the same local preview sequence as the publishing workflow: -=== "Build the site and sample report" +```bash title="Build docs and generate the live sample report" +uv run --with zensical==0.0.46 zensical build --clean --strict +uv run python scripts/build_docs_example_report.py --output-dir site/examples/report/live +``` - ```bash title="Generate the live sample report into site/" - uv run --with zensical==0.0.46 zensical build --clean --strict - uv run python scripts/build_docs_example_report.py --output-dir site/examples/report/live - ``` +For docs-only validation, run the first command alone. Then open: diff --git a/docs/reference/cli.md b/docs/reference/cli.md new file mode 100644 index 00000000..728cb2cf --- /dev/null +++ b/docs/reference/cli.md @@ -0,0 +1,256 @@ +--- +title: "CLI reference" +audience: public +doc_type: reference +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## Overview + +CodeClone is a deterministic structural change controller for AI-assisted Python development. The default invocation runs full analysis on a repository: + +```bash +codeclone [options] [root] +``` + +The root directory defaults to the current directory. Analysis produces findings on structural clones, code metrics, dependencies, and code health. Reporting formats include JSON, HTML, Markdown, SARIF, and plain text. + +Specialized subcommands manage setup, engineering memory, and analytics. Exit code 0 indicates success; 2 signals contract violations; 3 indicates gating failures; 5 is an internal error. + +## Global options + +### Target and analysis scope + +| Option | Description | +|--------|-------------| +| `root` | Project root directory. Defaults to `.` | +| `--min-loc MIN_LOC` | Minimum Lines of Code for clone analysis. Default: 10 | +| `--min-stmt MIN_STMT` | Minimum AST statement count for clone analysis. Default: 6 | +| `--processes PROCESSES` | Parallel worker processes. Default: 4 | +| `--changed-only` | Limit findings to files in a git diff | +| `--diff-against REF` | Use `git diff --name-only ` to determine changed files | +| `--paths-from-git-diff REF` | Shorthand for `--changed-only --diff-against REF` | + +### Baseline and cache + +| Option | Description | +|--------|-------------| +| `--baseline [FILE]` | Clone baseline path. Default: `codeclone.baseline.json` | +| `--max-baseline-size-mb MB` | Maximum baseline size. Default: 5 | +| `--update-baseline` | Overwrite baseline with current results | +| `--metrics-baseline [FILE]` | Metrics baseline path. Default: `codeclone.baseline.json` | +| `--update-metrics-baseline` | Overwrite metrics baseline with current metrics | +| `--cache-path [FILE]` | Cache file path. Default: `.codeclone/cache.json` | +| `--cache-dir [FILE]` | Legacy alias for `--cache-path` | +| `--max-cache-size-mb MB` | Maximum cache size. Default: 50 | + +### Verification and gates + +| Option | Description | +|--------|-------------| +| `--blast-radius FILE [FILE ...]` | Show structural impact for given files | +| `--patch-verify` | Verify current patch against baseline budget | +| `--strictness LEVEL` | Strictness profile: `ci`, `strict`, or `relaxed`. Default: `ci` | +| `--ci` | Enable CI preset (`--fail-on-new --no-color --quiet`) | +| `--api-surface` | Collect public API surface facts for compatibility review | +| `--coverage FILE` | Join external Cobertura XML line coverage | + +### Quality gates (fail on violation) + +| Option | Description | +|--------|-------------| +| `--fail-on-new` | Exit 3 if new clones found vs. baseline | +| `--fail-on-new-metrics` | Exit 3 if metrics violations appear vs. baseline | +| `--fail-threshold MAX_CLONES` | Exit 3 if total clone groups exceed value | +| `--fail-complexity [CC_MAX]` | Exit 3 if cyclomatic complexity exceeds threshold. Default if enabled: 20 | +| `--fail-coupling [CBO_MAX]` | Exit 3 if class coupling exceeds threshold. Default if enabled: 10 | +| `--fail-cohesion [LCOM4_MAX]` | Exit 3 if class cohesion exceeds threshold. Default if enabled: 4 | +| `--fail-cycles` | Exit 3 if circular dependencies detected | +| `--fail-dead-code` | Exit 3 if dead code detected | +| `--fail-health [SCORE_MIN]` | Exit 3 if health score below threshold. Default if enabled: 60 | +| `--fail-on-typing-regression` | Exit 3 if typing coverage regresses | +| `--fail-on-docstring-regression` | Exit 3 if docstring coverage regresses | +| `--fail-on-api-break` | Exit 3 if public API removals detected | +| `--fail-on-untested-hotspots` | Exit 3 if risk-level functions have insufficient coverage. Requires `--coverage` | +| `--min-typing-coverage PERCENT` | Exit 3 if parameter typing coverage below threshold | +| `--min-docstring-coverage PERCENT` | Exit 3 if public docstring coverage below threshold | +| `--coverage-min PERCENT` | Coverage threshold for untested hotspots. Default: 50 | + +### Analysis stages + +| Option | Description | +|--------|-------------| +| `--skip-metrics` | Run clone-only mode, skip full metrics | +| `--skip-dead-code` | Skip dead code detection | +| `--skip-dependencies` | Skip dependency graph analysis | + +### Workspace and audit + +| Option | Description | +|--------|-------------| +| `--session-stats` | Show workspace session status (read-only) | +| `--audit` | Show local Controller audit trail (read-only) | +| `--audit-json` | Output audit payload footprint as JSON. Implies `--audit` | + +## Commands + +### `setup [action]` + +Initialize or inspect repository readiness. + +**Actions:** +- `status` (default): Show readiness status +- `doctor`: Diagnostic check +- `plan`: Preview setup steps +- `apply`: Execute setup plan +- `wizard`: Interactive setup guide + +**Options:** +- `--json`: Emit JSON output +- `--dry-run`: Preview changes without modifying files (apply only) +- `-y, --yes`: Skip confirmation prompt (apply only) +- `--plan-id PLAN_ID`: For apply, verify plan matches this ID +- `--root ROOT`: Repository root path + +### `memory [subcommand]` + +Manage engineering memory (durable knowledge base). + +**Subcommands:** +- `init`: Initialize engineering memory +- `status`: Show memory status +- `for-path`: List records for a source path +- `search`: Search records by keyword +- `stale`: List stale records +- `vacuum`: Purge expired records +- `coverage`: Show memory coverage +- `review-candidates`: List draft candidates +- `approve`: Approve a draft record +- `reject`: Reject a draft record +- `archive`: Archive an active record +- `semantic`: Semantic index management (status / rebuild / search) +- `trajectory`: Trajectory projections (status / rebuild / list / search / show / agents / anomalies / dashboard / export) +- `jobs`: Projection rebuild jobs (status / enqueue / run-once / list) + +### `analytics [subcommand]` + +Manage and query code analytics. + +**Subcommands:** +- `snapshot`: Capture current metrics snapshot +- `embed`: Embed code representations +- `cluster`: Clustering operations +- `build`: Build analytics indices +- `clusters`: List available clusters +- `cluster-show`: Show cluster details +- `outliers`: Identify outlier code +- `profiles`: Show analytics profiles + +### `observability [subcommand]` + +Observe CodeClone runtime behavior (maintainer only). + +## Machine-readable output + +Reports are written to `.codeclone/report.` by default unless FILE is specified. + +| Flag | Format | Default path | +|------|--------|--------------| +| `--json [FILE]` | Canonical JSON report | `.codeclone/report.json` | +| `--html [FILE]` | Interactive HTML | `.codeclone/report.html` | +| `--md [FILE]` | Markdown | `.codeclone/report.md` | +| `--sarif [FILE]` | SARIF 2.1.0 | `.codeclone/report.sarif` | +| `--text [FILE]` | Plain text | `.codeclone/report.txt` | + +**Report options:** +- `--timestamped-report-paths`: Append UTC timestamp to default report filenames +- `--open-html-report`: Open HTML report in default browser (requires `--html`) + +**Output formatting:** +- `--no-progress`: Disable progress output (recommended for CI) +- `--no-color`: Disable ANSI colors +- `--quiet`: Reduce output to warnings and errors +- `--verbose`: Include detailed identifiers for new findings +- `--debug`: Print debug details and traceback on error + +## Exit codes + +| Code | Meaning | +|------|---------| +| 0 | Success | +| 2 | Contract error: invalid baseline, incompatible versions, or unreadable sources in CI/gating mode | +| 3 | Gating failure: new clones, threshold violations, or metrics regression | +| 5 | Internal error: unexpected exception | + +## Examples + +```bash +# Basic analysis on current directory +codeclone + +# Analysis with baseline update +codeclone --update-baseline + +# CI mode with fail-on-new check +codeclone --ci --fail-on-new + +# Changed files only, with HTML report +codeclone --paths-from-git-diff origin/main --html + +# Patch verification against baseline +codeclone --patch-verify --strictness strict + +# Show structural impact of specific files +codeclone --blast-radius src/core/engine.py src/core/parser.py + +# Quality gates: fail on complexity or new clones +codeclone --fail-complexity 20 --fail-on-new + +# With external coverage analysis +codeclone --coverage coverage.xml --fail-on-untested-hotspots + +# Memory workflow +codeclone memory status +codeclone memory for-path src/core/ +codeclone memory search "clustering algorithm" + +# Setup readiness check +codeclone setup status +codeclone setup plan +codeclone setup apply --yes +``` + +## Workflow + +The typical workflow combines analysis, baseline updates, and quality gates: + +```mermaid +graph LR + A["codeclone [root]"] --> B{Has baseline?} + B -->|No| C["--update-baseline"] + B -->|Yes| D["--patch-verify"] + C --> E["Save baseline"] + D --> F{Regression
detected?} + F -->|Yes| G["Exit 3"] + F -->|No| H["Exit 0"] +``` + +For CI/CD pipelines, use the `--ci` preset with appropriate gates: + +```bash +codeclone --ci --fail-on-new --fail-complexity 20 +``` + +For pull request analysis, focus on changed files and verify against the merge-base baseline: + +```bash +codeclone --paths-from-git-diff origin/main --patch-verify +``` + +Engineering memory enables persistent, evidence-linked annotations across changesets: + +```bash +codeclone memory search "refactor_domain_boundary" +codeclone memory for-path src/domain/ +``` diff --git a/docs/reference/configuration.md b/docs/reference/configuration.md new file mode 100644 index 00000000..4167dfc9 --- /dev/null +++ b/docs/reference/configuration.md @@ -0,0 +1,137 @@ +--- +title: "Configuration reference" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Configuration reference + +CodeClone configuration is declarative and lives in your project's `pyproject.toml` under the `[tool.codeclone]` table. Configuration controls analysis thresholds, output paths, audit retention, and governance features. + +## Configuration files + +Configuration is stored in `pyproject.toml`: + +```toml +[tool.codeclone] +baseline = "codeclone.baseline.json" +audit_enabled = true # opt in; disabled by default +fail_health = 60 # gate on health; disabled by default +``` + +All configuration keys are optional. Unset keys use their defaults. Most gates +and the audit trail are **disabled by default** — CodeClone reports without +failing your build until you opt in. + +## Keys + +| Key | Type | Default | Purpose | +|-----|------|---------|---------| +| `baseline` | str | `codeclone.baseline.json` | Path to baseline snapshot file | +| `audit_enabled` | bool | `false` | Enable audit trail collection | +| `audit_path` | str | `.codeclone/db/audit.sqlite3` | SQLite database for audit events | +| `audit_payloads` | str | `compact` | Payload detail level: `off`, `compact`, or `full` | +| `audit_retention_days` | int | `30` | Retain audit records (days) | +| `fail_cycles` | bool | `false` | Exit nonzero on dependency cycles | +| `fail_dead_code` | bool | `false` | Exit nonzero on dead code | +| `fail_health` | int | `-1` (disabled) | Exit nonzero if health < threshold (0–100); bare `--fail-health` applies `60` | +| `fail_on_new` | bool | `false` | Exit nonzero on new findings | +| `fail_on_new_metrics` | bool | `false` | Exit nonzero on regression in metrics | +| `min_loc` | int | `10` | Minimum lines of code per block | +| `min_stmt` | int | `6` | Minimum statements per block | +| `min_typing_coverage` | int | `-1` (disabled) | Minimum type annotation coverage (%) | +| `intent_registry_backend` | str | `file` | Intent storage backend (`file` or `sqlite`) | +| `intent_registry_path` | str | `.codeclone/db/intents.sqlite3` | Intent registry database path | +| `intent_registry_retention_days` | int | `14` | Retain intent records (days) | +| `api_surface` | bool | `false` | Compute API surface metrics | +| `golden_fixture_paths` | list | `[]` | Paths to golden test fixtures | + +### Memory and semantic configuration + +| Key | Type | Default | Purpose | +|-----|------|---------|---------| +| `memory.ingest.contract_constants_paths` | list | `[]` | Contract constant sources | +| `memory.ingest.document_link_paths` | list | `[]` | Documentation sources | +| `memory.semantic.enabled` | bool | `false` | Enable semantic search | +| `memory.semantic.backend` | str | `lancedb` | Vector database backend | +| `memory.semantic.embedding_model` | str | `BAAI/bge-small-en-v1.5` | fastembed embedding model identifier | +| `memory.semantic.embedding_provider` | str | `diagnostic` | Embedding provider (`diagnostic`, `fastembed`, `local_model`, `api`) | +| `memory.semantic.embedding_cache_dir` | str | `.codeclone/memory/fastembed` | Cached embeddings directory | +| `memory.semantic.index_path` | str | `.codeclone/memory/semantic_index.lance` | Semantic index path | +| `memory.semantic.dimension` | int | `256` | Embedding vector dimension (fastembed provider uses `384`) | +| `memory.semantic.max_results` | int | `20` | Maximum search results | +| `memory.semantic.index_audit` | bool | `true` | Audit index operations | +| `memory.projection_rebuild_policy` | str | `off` | Rebuild strategy when stale (`off`, `enqueue_when_stale`) | +| `memory.projection_rebuild_timeout_seconds` | int | `1800` | Worker timeout (seconds) | + +## Defaults + +CodeClone applies defaults at resolution time. Unset keys use their compiled defaults. Quality gates are opt-in: an unset `fail_health` leaves the health gate **disabled** (`-1`), and passing a bare `--fail-health` (no value) applies the built-in threshold of `60`. + +```mermaid +graph TD + A["pyproject.toml
[tool.codeclone]"] -->|Read config| B["Config resolver"] + B -->|Merge with defaults| C["Active configuration"] + C -->|Apply to pipeline| D["Analysis"] +``` + +## Validation + +Configuration is validated when CodeClone initializes: + +- **Type mismatch**: key value does not match declared type → error +- **Path validation**: `baseline`, `audit_path`, `intent_registry_path` must be writable or creatable +- **Range validation**: `fail_health` must be 0–100; retention days must be positive +- **Retention policy**: audit and intent records respect `*_retention_days` settings; records older than the configured age are automatically purged on cleanup +- **Setup safety**: `codeclone setup apply` refuses filesystem writes without explicit `--yes` confirmation or `--dry-run` preview; `--plan-id` binding prevents stale plans from applying + +## Examples + +### Minimal configuration + +```toml +[tool.codeclone] +# Use all defaults +``` + +### Strict health gates + +```toml +[tool.codeclone] +fail_health = 85 +fail_on_new = true +fail_cycles = true +fail_dead_code = true +``` + +### Extended audit trail + +```toml +[tool.codeclone] +audit_enabled = true +audit_payloads = "full" +audit_retention_days = 90 +intent_registry_retention_days = 60 +``` + +### Custom analysis thresholds + +```toml +[tool.codeclone] +min_loc = 8 +min_stmt = 5 +min_typing_coverage = 95 +``` + +### Semantic memory with custom index + +```toml +[tool.codeclone.memory.semantic] +enabled = true +backend = "lancedb" +embedding_model = "BAAI/bge-small-en-v1.5" +index_path = ".codeclone/memory/index.lance" +max_results = 15 +``` diff --git a/docs/reference/exit-codes.md b/docs/reference/exit-codes.md new file mode 100644 index 00000000..f46cb58c --- /dev/null +++ b/docs/reference/exit-codes.md @@ -0,0 +1,73 @@ +--- +title: "Exit codes" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone exits with a numeric code that signals whether the run succeeded, encountered a configuration error, failed a quality gate, or crashed internally. These codes integrate with CI systems, shell scripts, and automated workflows. + +## When to use it + +- **CI pipelines:** branch protection rules that block on exit code 3 (gating failures) +- **Local verification:** checking whether a patch meets structural requirements +- **Automation:** conditional logic in deployment or merge workflows +- **Debugging:** distinguishing configuration problems from structural findings + +## Basic workflow + +Run CodeClone and check the exit code: + +```bash +codeclone --patch-verify --fail-on-new +echo $? # prints the exit code +``` + +Exit codes are deterministic: the same input always produces the same code. + +## Exit codes reference + +| Code | Meaning | Cause | Action | +|------|---------|-------|--------| +| **0** | Success | No gate violations and no errors. Findings may still be present — exit `0` means nothing enabled gated on them, not that the report is empty | Run succeeded; review findings if any | +| **2** | Contract error | Untrusted/invalid baseline, invalid output configuration, incompatible versions, unreadable sources in CI/gating mode | Fix configuration or baseline before proceeding | +| **3** | Gating failure | New clones, threshold violations, or metrics quality gate failures | Address findings or adjust thresholds before merge | +| **5** | Internal error | Unexpected exception | Report the error with `--debug` output | + +## Key commands + +**Understand why a run failed:** + +```bash +# Verbose output with details +codeclone --patch-verify --fail-on-new --verbose +# Exit code 3 + detailed finding listings + +# Debug internal crashes +codeclone --debug +# Exit code 5 + traceback and environment info +``` + +**Strict vs. relaxed gating:** + +```bash +# CI preset: fail fast on new clones, metrics regression +codeclone --ci + +# Relaxed profile: report findings but allow threshold violations +codeclone --patch-verify --strictness relaxed +``` + +## Common mistakes + +- **Treating code 2 as a gate failure:** Code 2 is a contract violation (broken baseline, bad config). Fix the configuration, don't adjust thresholds. +- **Ignoring code 5 in production:** Code 5 signals an unexpected internal error. Collect `--debug` output and report it. +- **Running without `--patch-verify` in CI:** Without `--patch-verify`, CodeClone runs full analysis and will not gate on new findings. Use `--ci` or `--patch-verify --fail-on-new` for blocking checks. + +## Next steps + +- Review the `--fail-*` gating flags in the [CLI reference](cli.md) to configure threshold and gating behavior. +- Use `codeclone --help` to see all available flags for exit-code control. diff --git a/docs/reference/json-output.md b/docs/reference/json-output.md new file mode 100644 index 00000000..2ced955c --- /dev/null +++ b/docs/reference/json-output.md @@ -0,0 +1,80 @@ +--- +title: "JSON output contracts" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone produces deterministic, schema-versioned JSON reports via the `--json` flag. The report captures the complete structural analysis state: clone findings, metrics, dependencies, health scores, and baseline-relative deltas. The schema version (`REPORT_SCHEMA_VERSION: 2.12`) is stable within a CodeClone release; breaking changes require a new major version. + +JSON output is designed for programmatic consumption—CI gates, metric dashboards, IDE integrations, and cross-repository analysis. Each field is deterministic: the same codebase analyzed twice produces byte-identical JSON (modulo timestamps). + +## When to use it + +Use `--json` for: +- **CI pipelines**: Parse findings, gate on thresholds, feed downstream tools +- **Dashboards**: Aggregate metrics across multiple repositories +- **IDE plugins**: Stream findings to editors without invoking full HTML rendering +- **Baseline comparisons**: Normalize reports across time for trend analysis +- **Machine learning**: Feed structured finding data into learning systems + +Do not use JSON when you need: +- Human-readable summaries (use `--text` or `--md`) +- Interactive exploration (use `--html`) +- SARIF compliance for static-analysis workflows (use `--sarif`) + +## Basic workflow + +```mermaid +graph LR + A["codeclone . --json"] -->|writes| B[".codeclone/report.json"] + B -->|parse| C["Extract findings,
metrics, baseline"] + C -->|filter| D["Gate decisions:
threshold checks,
new clone rules"] + D -->|report| E["CI pass/fail"] +``` + +Run analysis and generate JSON in one step: + +```bash +codeclone . --json [FILE] +``` + +If FILE is omitted, CodeClone writes to `.codeclone/report.json`. The report is valid JSON and contains these top-level keys: +- `report_schema_version` — schema version string (matches `REPORT_SCHEMA_VERSION`, currently `2.12`) +- `meta` — run metadata: `codeclone_version`, `project_name`, `scan_root`, `python_version`, `analysis_mode`, `analysis_thresholds`, `analysis_profile`, `baseline`, `cache`, `runtime` +- `inventory` — scanned inventory: `files`, `code` counts, and `file_registry` +- `findings` — object with `summary` and `groups[]`; each group carries type, locations, risk, and `novelty` (`new` / `known`) +- `metrics` — object with `summary` and per-family `families` (complexity, coupling, cohesion, …) +- `derived` — `suggestions`, `overview`, `hotlists`, `module_map`, `review_queue` +- `integrity` — deterministic `canonicalization` and content `digest` + +## Key commands + +| Command | Effect | Output | +|---------|--------|--------| +| `codeclone . --json` | Analyze and write to default path | `.codeclone/report.json` | +| `codeclone . --json FILE` | Analyze and write to custom path | `FILE` | +| `codeclone . --json --baseline FILE` | Compare against baseline | JSON with `novelty` (`new` / `known`) per finding group | +| `codeclone . --json --ci` | Preset for CI (quiet, color off) | `.codeclone/report.json` | +| `codeclone . --json --fail-on-new --ci` | Gate: fail if any new findings | Exit code 3 if violated | + +## Common mistakes + +1. **Assuming JSON is human-readable**: JSON is not designed for grep or manual review. Pipe to `jq` for filtering, or use `--md`/`--text` for human audiences. + +2. **Ignoring schema version**: Always validate `report_schema_version` matches your parser's supported schema. Breaking changes in a future major release may shift field names. + +3. **Not comparing against baseline**: The `novelty` field requires `--baseline FILE`. Without it, all findings appear novel. Always ground CI gates in baseline-aware decisions. + +4. **Parsing incomplete JSON during analysis**: JSON is written atomically after analysis completes. Do not attempt to parse `.codeclone/report.json` while `codeclone` is still running. + +5. **Mixing report formats in CI**: Do not parse `--json` output from HTML or Markdown reports. Each format is independent; use the format you requested. + +## Next steps + +- **Integrate with CI**: Read [Development guide](../guides/development.md) for baseline and gate setup in GitHub Actions, GitLab CI, or other platforms. +- **Script analysis**: Use `jq` to extract and filter finding groups: `jq '.findings.groups[] | select(.novelty == "new")' report.json` +- **Audit trail**: Combine JSON output with `--audit-json` to inspect the controller workspace history and decision logs. diff --git a/docs/reference/mcp-tools.md b/docs/reference/mcp-tools.md new file mode 100644 index 00000000..91f4911a --- /dev/null +++ b/docs/reference/mcp-tools.md @@ -0,0 +1,231 @@ +--- +title: "MCP tools reference" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## Overview + +CodeClone exposes structural analysis and change control through MCP (Model Context Protocol) tools. These tools enable AI assistants and agents to analyze Python repositories deterministically, manage edit scopes, and verify changes against architectural contracts. + +All MCP tools require an absolute repository root path. Relative paths like `'.'` are rejected. MCP respects cache policy settings (`reuse` or `off`); analysis runs are registered as the latest session-local run when complete. + +```mermaid +graph LR + A["analyze_repository"] --> B["Inspection: get_run_summary,
get_report_section, list_findings"] + B --> C["start_controlled_change"] + C --> D["Edit within scope"] + D --> E["analyze_repository (after-run)"] + E --> F["finish_controlled_change"] + F --> G["create_review_receipt"] +``` + +## Tool groups + +| Group | Purpose | Entry point | +|-------|---------|-------------| +| **Analysis** | Run deterministic CodeClone analysis on repositories or changed paths | `analyze_repository`, `analyze_changed_paths` | +| **Inspection** | Retrieve analysis results, implementation context, and summaries | `get_run_summary`, `get_report_section`, `get_implementation_context` | +| **Change Control** | Declare intent, compute blast radius, verify patches, and close edits | `start_controlled_change`, `finish_controlled_change`, `manage_change_intent`, `check_patch_contract`, `get_blast_radius` | +| **Triage** | Production-first review and finding discovery | `get_production_triage`, `list_findings`, `list_hotspots` | +| **Focused checks** | Narrower alternatives to `list_findings` for one finding family | `check_clones`, `check_cohesion`, `check_complexity`, `check_coupling`, `check_dead_code`, `get_finding`, `get_remediation` | +| **Engineering Memory** | Retrieve, govern, and inspect evidence-linked repository knowledge | `get_relevant_memory`, `manage_engineering_memory`, `query_engineering_memory`, `get_memory_projection_page` | +| **Audit and receipts** | Durable, replayable evidence from the audit trail | `create_review_receipt`, `get_review_receipt`, `get_patch_trail`, `get_blast_artifact`, `validate_review_claims` | +| **Navigation** | Drill into facets, compare runs, and manage session state | `get_implementation_context_page`, `compare_runs`, `clear_session_runs` | +| **Other** | Gates, PR summaries, and reviewed-finding bookkeeping | `evaluate_gates`, `generate_pr_summary`, `list_reviewed_findings`, `mark_finding_reviewed`, `query_platform_observability` | + +## Tools + +### Analysis Tools + +**`analyze_repository(root, cache_policy)`** +Run a deterministic CodeClone analysis on the repository at `root` and register the result as the latest MCP session run. Returns metrics, findings, and artifact locations. Use as the first step in any workflow. Cache policy accepts `reuse` (default, uses cache if fresh) or `off` (ignore cache). + +**`analyze_changed_paths(root, changed_paths | git_diff_ref, cache_policy)`** +Analyze only changed files from an explicit `changed_paths` list or a `git_diff_ref` revision (mutually exclusive). Faster than full analysis for PR-style review. Response includes a `next_tool` hint suggesting which inspection tool to use. + +### Inspection Tools + +**`get_run_summary(run_id)`** +Return a compact snapshot of a stored run (latest or specified by 8-char short id or full digest). Includes health score, top findings, and artifact locations. + +**`get_report_section(section, family, limit, ...)`** +Retrieve one canonical report section by name. Common sections: `inventory` (file registry), `findings` (grouped by family when specified), `metrics_detail` (with pagination). Prefer this over retrieving the full report. + +**`get_implementation_context(root, paths, symbols, include, ...)`** +Return deterministic, bounded implementation context for explicit repo-relative `paths` or `module:symbol` qualnames (via `symbols`) from an existing run. Projects module dependencies, API surfaces, callers, blast radius, cache origin, and workspace freshness. Does not authorize edits or re-analyze. + +**`get_implementation_context_page(root, run_id, context_projection_digest, facet, ...)`** +Fetch an exact implementation-context facet page (e.g., `public_surface`, `callers`, `trajectories`) from the session-local projection artifact. Requires the `context_projection_digest` returned by `get_implementation_context`. + +### Change Control Tools + +**`start_controlled_change(root, scope, intent, ...)`** +Pre-edit workflow: declare intent, check for concurrent edits, and compute blast radius. Requires a valid analysis run. Returns `intent_id` (pass to `finish_controlled_change`), blast radius, patch budget, and workspace state. Status may be `active` (proceed to edit), `queued` (wait for other edits), `blocked` (scope overlap), or `needs_analysis` (no run found). + +**`finish_controlled_change(intent_id, changed_files, after_run_id, ...)`** +Post-edit pipeline: perform hygiene checks, scope verification, contract validation, and claims review. Returns receipt, scope status, and `intent_id` cleared if accepted. Required after every edit declared with `start_controlled_change`. For Python structural changes, pass `after_run_id` from a fresh analysis run. + +**`manage_change_intent(action, ...)`** +Manage the agent change-intent lifecycle for the current MCP session and the optional workspace registry. Actions: `list_workspace`, `declare`, `get`, `check`, `clear`, `renew`, `promote`, `recover`, `gc_workspace`, `reset_workspace`. Used for queue/promote/recover flows that `start_controlled_change`/`finish_controlled_change` do not expose directly. + +**`check_patch_contract(mode, ...)`** +Pre-edit budget query (`mode="budget"`) or post-edit structural verification (`mode="verify"`). Composes stored runs, gate evaluation, run comparison, and the session-local change intent without running analysis or mutating repository state. + +**`get_blast_radius(paths)`** +Return the deterministic structural risk boundary for changing the given files: direct dependents, clone cohort members, coverage gaps, do-not-touch paths, and review-only context. Derived from the canonical report; no new analysis is performed. + +### Triage Tools + +**`get_production_triage(run_id)`** +Production-first triage view: health, cache freshness, production hotspots, and suggestions. Prioritizes issues likely to affect production over global metrics. Use as default first-pass review on noisy repositories. + +**`list_findings(run_id, family, scope, limit, ...)`** +List canonical finding groups with deterministic ordering and optional filters. Returns compact summary cards by default. Prefer `list_hotspots` or focused `check_*` tools for first-pass triage. + +**`list_hotspots(run_id, ...)`** +Return one of the derived CodeClone hotlists for the latest or specified run, using compact summary cards by default. Prefer this for first-pass triage before broader `list_findings` calls. + +### Focused Check Tools + +Narrower alternatives to `list_findings` when only one finding family is needed, all reading from a compatible stored run: + +**`check_clones`**, **`check_cohesion`**, **`check_complexity`**, **`check_coupling`**, **`check_dead_code`** +Return clone / cohesion / complexity / coupling / dead-code findings respectively. + +**`get_finding(finding_id, detail)`** +Return a single canonical finding group by short or full id. Unknown ids return a structured `status="not_found"` response instead of an error. + +**`get_remediation(finding_id)`** +Return actionable remediation guidance for a single finding. Returns `status="no_guidance"` instead of an error when no remediation packet exists. + +### Engineering Memory Tools + +**`get_relevant_memory(root, scope, intent_id, ...)`** +Return ranked, evidence-linked engineering memory for the declared edit scope. Read-only; does not mutate the memory database. + +**`manage_engineering_memory(root, action, ...)`** +Engineering memory governance for agents. Actions: `refresh_from_run`, `record_candidate`, `promote_experience`, `validate_claims`, `propose_from_receipt`, `rebuild_semantic_index`, `rebuild_trajectories`, `enqueue_projection_rebuild`, `projection_rebuild_status`, `run_projection_jobs_once`. Approve, reject, and archive are not available to agents. + +**`query_engineering_memory(mode, ...)`** +Mode-based engineering memory inspection router. Modes include `search`, `get`, `for_path`, `for_symbol`, `stale`, `drafts`, `coverage`, `status`, `trajectory_status`, `trajectory_search`, `trajectory_get`, `experience_get`, `trajectory_anomalies`, `trajectory_agents`, and `trajectory_dashboard`. Read-only. + +**`get_memory_projection_page(cursor, ...)`** +Return an exact page for a `get_relevant_memory` omitted tail using the digest-bound cursor returned in that response. Fails closed if the underlying memory projection no longer matches the cursor identity. + +### Audit and Receipt Tools + +**`create_review_receipt(...)`** +Generate a deterministic, auditable review receipt from stored MCP state: report provenance, intent scope, blast radius, reviewed findings, patch-contract status, and human decision points. Markdown or JSON output; does not mutate repository state. + +**`get_review_receipt(run_id, receipt_digest)`** +Fetch a durably stored review receipt from the audit trail exactly as it was created. Read-only. + +**`get_patch_trail(run_id, patch_trail_digest)`** +Fetch the full forensic patch trail from the audit trail: declared/changed/untouched files, scope check, verification, workspace hygiene, and evidence. Read-only. + +**`get_blast_artifact(run_id, blast_artifact_id, ...)`** +Fetch a durably stored start-time blast artifact from the audit trail, exactly as it was persisted when `start_controlled_change` produced its slim summary. Read-only. + +**`validate_review_claims(review_text, ...)`** +Validate cited review text against canonical report semantics: catches Security Surfaces called vulnerabilities, report-only signals called CI failures, known baseline debt called new, and other deterministic mischaracterizations. + +### Navigation Tools + +**`generate_pr_summary(run_id, changed_files, format)`** +Generate a PR-friendly CodeClone summary. Format `markdown` (default) produces LLM-facing compact output; `json` is for machine post-processing. + +**`compare_runs(run_id_a, run_id_b)`** +Compare two runs by finding groups and health deltas. Returns incomparable when repository roots or settings differ. + +**`help(topic)`** +Bounded workflow and contract guidance with doc links. `topic=overview` returns a compact topic index. Complexity level `compact` adds anti-patterns; `normal` (default) includes warnings. + +**`clear_session_runs()`** +Clear all in-memory analysis runs and ephemeral session state for the MCP server process. + +### Other Tools + +**`evaluate_gates(run_id, ...)`** +Evaluate CodeClone gate conditions against an existing run without modifying baselines or exiting the process. + +**`list_reviewed_findings(run_id)`** +List in-memory reviewed findings for the current or specified run. + +**`mark_finding_reviewed(finding_id, run_id)`** +Mark a finding reviewed in this MCP session only; cleared on process restart or `clear_session_runs`. + +**`query_platform_observability(section, ...)`** +Read-only sectioned diagnostics over CodeClone's own runtime telemetry (not part of user-facing repository analysis). Intended for CodeClone maintainers, not for user-facing quality claims about a repository. + +## Inputs and outputs + +### Inputs + +All tools accept an absolute repository root (required for most tools). Analysis tools accept a cache policy: `reuse` (use cached results if fresh) or `off` (ignore cache). Change-control tools require an existing analysis run and declare a `scope` dict specifying affected file patterns. Inspection tools take a `run_id` (8-char short id or full digest, or `latest` for the session-local run). + +### Outputs + +Analysis tools return a `run_id`, artifact locations (JSON, HTML, SARIF), and canonical result structures. Change-control tools return an `intent_id` for pairing `start` and `finish` calls. Inspection tools return structured data: summaries (JSON), sections (paginated results), or implementation-context projections. All responses include a `next_tool` hint when appropriate. + +## Error behavior + +| Condition | Response | +|-----------|----------| +| Absolute root not provided | Rejected at validation; no tool execution | +| No analysis run exists | `start_controlled_change` returns `status: "needs_analysis"` | +| Concurrent intent exists | `start_controlled_change` returns `status: "queued"` | +| Scope already dirty | `start_controlled_change` may return `status: "blocked"` unless recovery flag is set | +| Missing after-run evidence | `finish_controlled_change` returns `status: "unverified"` with `next_step` hint | +| Scope mismatch on finish | `finish_controlled_change` returns `status: "violated"` with details | + +If a step cannot proceed, the response includes `next_step` and/or `user_action_required` to guide recovery. + +## Examples + +### Analyze a repository + +```python +result = analyze_repository( + root="/absolute/path/to/repo", + cache_policy="reuse" +) +print(f"Run ID: {result['run_id']}") +print(f"Health: {result['health_score']}") +print(f"Report: {result['artifacts']['report_path']}") +``` + +### Declare a change intent and get blast radius + +```python +intent = start_controlled_change( + root="/absolute/path/to/repo", + scope={"allowed_files": ["src/services/auth.py"]}, + intent="refactor service authentication" +) +print(f"Intent ID: {intent['intent_id']}") +print(f"Edit allowed: {intent['edit_allowed']}") +print(f"Blast radius: {intent['blast_radius']}") +``` + +### Get production hotspots + +```python +triage = get_production_triage(run_id="latest") +for hotspot in triage['hotspots']: + print(f"{hotspot['path']}: {hotspot['issue']}") +``` + +### Finish a controlled change + +```python +receipt = finish_controlled_change( + intent_id="abcd1234", + changed_files=["src/services/auth.py"], + after_run_id="run_xyz789" +) +print(f"Status: {receipt['status']}") +print(f"Scope check: {receipt['scope_check']['status']}") +``` diff --git a/docs/reference/memory-record-types.md b/docs/reference/memory-record-types.md new file mode 100644 index 00000000..b7092f07 --- /dev/null +++ b/docs/reference/memory-record-types.md @@ -0,0 +1,113 @@ +--- +title: "Memory record types" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +Engineering Memory stores durable facts about your codebase as **records** — evidence-linked observations about architecture, risks, decisions, and changes. Each record has a type that signals its purpose and how agents should use it. + +Record types distinguish what kind of knowledge a note encodes: is it a historical fact, a risk, a decision we made, or a design rule? The type shapes how Memory surfaces and retrieves that fact for future agents. + +## When to use it + +Use record types when writing to Engineering Memory via MCP: + +- **During edits**: Record findings about tradeoffs, non-obvious root causes, or patterns to avoid next time. +- **After verification**: Document what you learned in a controlled change so the next agent doesn't repeat the diagnosis. +- **For team decisions**: Capture architectural choices, protocol rules, or integration constraints that affect future work. + +Don't write memory for trivial edits (typo fix, one-line fix, nothing to relearn). + +## Basic workflow + +```mermaid +graph LR + A["Identify trigger
(incident, complexity, decision)"] --> B["Choose record type"] + B --> C["Write via manage_engineering_memory
record_type=..."] + C --> D["Statement appears in
get_relevant_memory results"] + D --> E["Next agent reads,
avoids repeat"] +``` + +After `start_controlled_change` grants edit permission: + +1. Perform your work and encounter a trigger (complexity, decision, incident). +2. Select the record type that best fits. +3. Call `manage_engineering_memory(action='record_candidate', record_type='...', statement='...', subject_path='...')`. +4. Statement will be ranked and surfaced to future agents working in that scope. + +## Key commands + +Write a record immediately during edit: + +```python +manage_engineering_memory( + root="/path/to/repo", + action="record_candidate", + record_type="risk_note", + statement="Database migration requires fsync per commit; loss-intolerant stores use FULL, ephemeral intents use NORMAL.", + subject_path="codeclone/memory/schema.py" +) +``` + +Or batch memory candidates after `finish_controlled_change`: + +```python +finish_controlled_change( + intent_id="...", + changed_files=[...], + after_run_id="...", + propose_memory=True +) +``` + +Retrieve memory before editing: + +```python +get_relevant_memory(root="/path/to/repo", scope=[...]) +``` + +Query memory for specific lanes or paths: + +```python +query_engineering_memory(root="/path/to/repo", mode="for_path", path="codeclone/memory/...") +``` + +## Record types + +| Type | When to use | Example | +|------|-------------|---------| +| `architecture_decision` | Major structural choice affecting modules, layers, or interfaces. | "Memory store is SQLite, not PostgreSQL, because offline CLI agents must not require a server." | +| `change_rationale` | Why a specific change was made; what problem it solves; alternatives ruled out. | "Batch insert reduces N+1 writes in experience distiller from 18 per record to 1." | +| `contract_note` | Clarification or warning about a contract, schema, or API expectation not obvious from code. | "MemoryRecord.provenance fields must be populated at write time, never mutated later." | +| `contradiction_note` | Two records or observations that conflict; signals stale memory or outdated assumptions. | "Schema v1.6 docs say column X is required, but v1.7 migration makes it optional." | +| `document_link` | Reference to a related internal guide, spec, or decision doc outside chat. | "See specs/phase-21-FINISH-RESUME.md for detector wiring plan." | +| `human_note` | Explicit human-authored context (not inferred by agents). | "Product team decided: do not store PII in memory records." | +| `module_role` | The purpose or responsibility of a module within the architecture. | "codeclone/memory/embedding/ handles vector conversion and index writes only; does not query." | +| `protocol_rule` | A governance rule, convention, or hard requirement that applies across code. | "All schema migrations must reuse _add_column_if_missing + _record_schema_migration." | +| `public_surface` | An MCP tool, CLI command, or API surface and its contract. | "get_relevant_memory(root=..., scope=...) returns ranked, evidence-linked memory for an edit scope." | +| `risk_note` | A known fragility, performance issue, or correctness invariant to preserve. | "fastembed.embed() yields numpy.float32, not Python float; vector coercion must guard on numbers.Real." | +| `stale_marker` | A marker that a prior record is outdated or superseded by a newer one. | "Old: memory stores on disk-inventory anchors. Current: must be commit-anchored." | +| `test_anchor` | A test file or scenario that validates a critical invariant or contract. | "tests/test_memory_durability.py validates fsync and unclean exit recovery." | + +## Common mistakes + +**Overwriting memory instead of recording a note**: Memory is append-only. If you need to correct a fact, write a `contradiction_note` or `stale_marker`, not a replacement. Agents need to see the history. + +**Vague statements**: "Fixed a bug" or "Improved performance" are not specific enough. Include the root cause, the metric, and why it matters. + +**Treating chat as memory**: Chat context shrinks, sessions end, and the next agent won't see it. Use MCP `record_candidate` for anything the team needs to remember. + +**Forgetting `subject_path`**: Always specify the repo-relative file path you touched. Memory uses this to rank relevance for future scope declarations. + +**Ignoring corroboration_status**: When `get_relevant_memory` returns a record with `corroboration_status: "unverified"` or `"draft"`, do not assert it as established fact. Read the contradiction notes first. + +## Next steps + +- **Write your first record**: Use `manage_engineering_memory` with `record_type="change_rationale"` the next time you make a non-trivial edit. +- **Query memory before editing**: Call `get_relevant_memory(scope=[...])` to see what prior agents learned in that scope. +- **Read contradiction notes**: If `get_relevant_memory` flags a contradiction, investigate and record clarification or resolution. +- **See also**: [Engineering Memory concepts](../concepts/engineering-memory.md); MCP help: `help(topic="engineering_memory")`. diff --git a/docs/reference/observability.md b/docs/reference/observability.md new file mode 100644 index 00000000..4bffdc0c --- /dev/null +++ b/docs/reference/observability.md @@ -0,0 +1,52 @@ +--- +title: "Platform observability" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Platform observability + +Platform observability is a **development-only diagnostic** over CodeClone's +*own* runtime telemetry. It exists to help maintainers build and profile +CodeClone itself. + +!!! warning "Not a repository quality signal" + Observability measures CodeClone's runtime, not your code. High database + query counts, large MCP payloads, or a hot semantic reindex say nothing + about the quality or safety of the repository being analyzed. It never + affects reports, gates, baselines, memory facts, or edit authorization. + +## Disabled by default + +Observability is off unless you explicitly enable it through environment +variables. When disabled (or when no telemetry store exists), the diagnostic +returns an inert `status=disabled` / `status=no_store` envelope rather than an +error. + +| Environment variable | Purpose | +|----------------------|---------| +| `CODECLONE_OBSERVABILITY_ENABLED` | Master switch — turns telemetry capture on | +| `CODECLONE_OBSERVABILITY_PERSIST` | Persist captured spans to the local store | +| `CODECLONE_OBSERVABILITY_PROFILE` | Enable per-phase profiling detail | +| `CODECLONE_OBSERVABILITY_CAPTURE_PAYLOAD_SIZES` | Record payload sizes (numeric only) | +| `CODECLONE_OBSERVABILITY_CORRELATION_ID` / `CODECLONE_OBSERVABILITY_PARENT_OPERATION_ID` | Correlate spans across a chain | + +## How to read it + +The maintainer-facing surface is the MCP tool `query_platform_observability`, a +read-only sectioned slicer. It emits **numeric metrics only** — never raw SQL or +payloads. Start at the `summary` section and follow the recommended next +sections: + +`summary` · `slow_operations` · `memory_pipeline_cost` · `db_cost` · +`agent_context` · `mcp_tool_matrix` · `correlated_chains` · `costly_noops` · +`pipeline` · `analysis_phase_cost` + +`detail_level` accepts `compact`, `normal`, or `full` (aggregate sections +downgrade `full` to `normal`); `limit` clamps to `[1, 100]`. The +branded HTML cockpit remains the human-facing everything-view. + +For the maintainer-only skill, see `codeclone-platform-observability`; in an MCP +client, `help(topic="observability")` gives the compact contract. diff --git a/docs/reference/reports.md b/docs/reference/reports.md new file mode 100644 index 00000000..5759f2e4 --- /dev/null +++ b/docs/reference/reports.md @@ -0,0 +1,86 @@ +--- +title: "Report reference" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Report reference + +## What it is + +CodeClone generates structural analysis reports across five formats: JSON, HTML, Markdown, SARIF, and plain text. A report captures module dependencies, findings (clones, complexity, coupling, cohesion, coverage gaps, dead code), metrics, and file inventory at a specific moment in your codebase. + +Each report includes: + +- **Metrics**: lines of code, module counts, health score (aggregates complexity, coupling, cohesion, clones, coverage, dead code, and dependency depth) +- **Findings**: ranked structural issues by severity +- **Inventory**: detailed module and file registry with health contributions +- **Integrity** (JSON only): baseline alignment and cache freshness markers + +## When to use it + +Use reports to: + +- **Baseline integration**: Commit `codeclone.baseline.json` before major refactors to track regressions +- **CI gates**: Pipe JSON output to CI for automated threshold checks +- **Team review**: Share HTML reports for visual code-health assessment +- **Markdown integration**: Embed findings in pull-request descriptions +- **Machine parsing**: Consume JSON schema (v2.12) or SARIF for IDE plugins + +## Basic workflow + +```mermaid +graph LR + A["codeclone . --json FILE"] --> B["stores at FILE
default: .codeclone/report.json"] + A --> C["parsed by CI / IDE"] + D["codeclone . --html FILE"] --> E["interactive HTML
at FILE"] + F["codeclone . --md FILE"] --> G["Markdown sections
at FILE"] + H["baseline.json"] --> I["integrity: baseline_fresh,
fingerprint matches"] +``` + +## Key commands + +| Flag | Output | Default path | Use when | +|------|--------|--------------|----------| +| `--json FILE` | Structured report (schema v2.12) | `.codeclone/report.json` | Parsing or CI gates | +| `--html FILE` | Interactive dashboard | `.codeclone/report.html` | Team review or drill-down | +| `--md FILE` | Markdown findings and metrics | `.codeclone/report.md` | PR descriptions or docs | +| `--sarif FILE` | SARIF v2.1.0 format | `.codeclone/report.sarif` | IDE / SIEM integration | +| `--text FILE` | Plain-text summary | `.codeclone/report.txt` | Quick terminal review | + +All flags are bare command options: `codeclone . --json my-report.json` (not a "codeclone report" subcommand). + +## Examples + +**Generate JSON for CI:** +```bash +codeclone . --json report.json && jq '.metrics.summary.health.score' report.json +``` + +**Compare against baseline:** +```bash +codeclone . --json current.json +# Baseline is `codeclone.baseline.json` (auto-loaded) +# meta.baseline records loaded/status/fingerprint_version; meta.cache records freshness +``` + +**Export for IDE:** +```bash +codeclone . --sarif findings.sarif +# IDE plugin parses SARIF and highlights issues inline +``` + +## Common mistakes + +- **Assuming `--json` prints to stdout**: `codeclone . --json` (no path) writes to the default file `.codeclone/report.json`; it does not stream to stdout. Pass an explicit path to override the location. +- **Stale baseline**: Moving `codeclone.baseline.json` or regenerating without review breaks integrity. Baseline is a contract—commit it. +- **Ignoring health weights**: Health score balances 7 dimensions (clones 25%, complexity 20%, cohesion 15%, coupling 10%, coverage 10%, dead code 10%, dependencies 10%). A single high spike does not drive score alone. +- **Cache staleness**: If cache is stale (reported in integrity), re-run analysis. Cache is not refreshed automatically. + +## Next steps + +- Automate baseline updates in your CI: store `codeclone.baseline.json` per major branch +- Integrate JSON reports into dashboards (schema v2.12 is stable) +- Use MCP tools (`get_run_summary`, `get_report_section`, `compare_runs`) for programmatic access diff --git a/docs/reference/setup.md b/docs/reference/setup.md new file mode 100644 index 00000000..48f31695 --- /dev/null +++ b/docs/reference/setup.md @@ -0,0 +1,108 @@ +--- +title: "Setup command reference" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +`codeclone setup` is a readiness controller that probes your repository for CodeClone installation, configuration, and runtime dependencies. It generates a deterministic plan of recommended actions and applies them when confirmed. + +The setup commands do not modify your code. `setup apply` writes only two files: it merges the `[tool.codeclone]` section into your `pyproject.toml` and appends CodeClone cache paths to `.gitignore`. It does not create a baseline, cache, or audit/intent database — those artifacts are produced later by analysis and controlled-change runs. + +## When to use it + +Run `codeclone setup` to: +- **Check readiness** before first use: `codeclone setup status` +- **Diagnose issues** in detail: `codeclone setup doctor` +- **Preview changes** safely: `codeclone setup plan` then `codeclone setup apply --dry-run` +- **Apply configuration** interactively or non-interactively +- **Explore options** with guided workflow: `codeclone setup wizard` + +## Basic workflow + +```mermaid +graph LR + A["codeclone setup status"] -->|See issue| B["codeclone setup doctor"] + A -->|Ready to configure| C["codeclone setup plan"] + C -->|Review actions| D["codeclone setup apply"] + D -->|Verify result| A + A -->|Prefer guided flow| E["codeclone setup wizard"] +``` + +**Typical flow:** + +1. Check readiness: `codeclone setup status` +2. Preview recommended actions: `codeclone setup plan` +3. Apply interactively (requires TTY): `codeclone setup apply` +4. Verify: `codeclone setup status` + +**Non-interactive flow (CI/automation):** + +```bash +codeclone setup plan --root /path/to/repo +codeclone setup apply --yes --root /path/to/repo +``` + +## Key commands + +| Command | Purpose | Output | Notes | +|---------|---------|--------|-------| +| `status` | Probe readiness across 14 capabilities (4 groups), each scored on installation/configuration/runtime axes | Table of capabilities, readiness states | Default if no command given; read-only | +| `doctor` | Detailed diagnostics for each capability | Status table + per-capability evidence panels | Verbose; for troubleshooting | +| `plan` | Compute configuration actions without writing | Actions list with deterministic plan ID | Preview mode; no files changed | +| `apply` | Execute ready actions from current plan | Applied actions and result status | Requires confirmation (TTY) or `--yes` | +| `wizard` | Guided interactive workflow | Menu-driven steps: plan → confirm → apply | Combines plan, confirmation, and apply | + +**Common flags:** + +- `--root PATH` — Repository root (default: `.`) +- `--json` — Output as JSON (status, doctor, plan, apply) +- `--dry-run` (apply only) — Preview writes without modifying files +- `--yes` (apply only) — Skip confirmation prompt (required non-interactively) +- `--plan-id ID` (apply only) — Bind apply to a specific plan; refuses if stale + +## Common mistakes + +**"Apply requires confirmation"** + +Interactive apply needs a TTY or `--yes` flag. Provide confirmation: +```bash +codeclone setup apply --yes +``` + +**"Plan is stale"** + +Repository changed between `setup plan` and `setup apply`. Recompute: +```bash +codeclone setup plan # Get fresh plan_id +codeclone setup apply --plan-id +``` + +**Using apply-only flags on other commands** + +`--dry-run`, `--yes`, `--plan-id` only work with `apply`. Remove them or use `apply`: +```bash +# Wrong: +codeclone setup status --yes + +# Right: +codeclone setup apply --yes +``` + +**Missing pyproject.toml** + +Some actions (e.g., adding audit configuration) require `pyproject.toml`. Create one: +```bash +touch pyproject.toml +codeclone setup plan +``` + +## Next steps + +- Read the [engineering memory documentation](../concepts/engineering-memory.md) to understand memory initialization +- Use `codeclone setup --help` for all available flags +- Run `codeclone setup doctor` to diagnose any configuration issues +- After setup completes, run `codeclone . --update-baseline` to generate your baseline diff --git a/docs/reference/suppressions.md b/docs/reference/suppressions.md new file mode 100644 index 00000000..eed037be --- /dev/null +++ b/docs/reference/suppressions.md @@ -0,0 +1,76 @@ +--- +title: "Inline suppressions" +audience: public +doc_type: reference +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Inline suppressions + +CodeClone supports **explicit inline suppressions** for a small set of findings. +A suppression is local policy — an accepted, reviewed exception — not analysis +truth. Keep suppressions narrow and declaration-scoped; never use them to hide +broad classes of debt. + +## Syntax + +A suppression is a comment directive of the form: + +```python +# codeclone: ignore[[, ...]] +``` + +- The marker literal is `codeclone:` — comments without it are ignored by the parser. +- Rule IDs go inside `[ ... ]`, comma-separated. +- Whitespace around `codeclone`, `:`, `ignore`, and the brackets is tolerated. +- Unknown or malformed rule IDs inside the brackets are silently dropped (only + supported IDs take effect). + +## Supported rule IDs + +| Rule ID | Suppresses | +|---------|------------| +| `dead-code` | Dead-code (unused declaration) findings | +| `clone-cohort-drift` | Clone cohort-drift findings | +| `clone-guard-exit-divergence` | Clone guard/exit-divergence findings | + +Rule IDs are lowercase, `[a-z0-9]` with internal hyphens allowed. + +## Placement and binding + +A suppression is **declaration-scoped**. It binds to the nearest `def`, +`async def`, or `class` declaration (function, method, or class). Two placements +are supported: + +- **Leading** — on the line immediately above the declaration: + + ```python + # codeclone: ignore[dead-code] + def legacy_entrypoint() -> None: + ... + ``` + +- **Inline** — as a trailing comment on the declaration/header line: + + ```python + def legacy_entrypoint() -> None: # codeclone: ignore[dead-code] + ... + ``` + +Suppressions are **target-specific**: they apply only to the bound declaration. +They do not cascade to nested declarations and are not file-wide. + +## When to use them + +Use a suppression for an accepted dynamic or runtime false positive — for +example, a function that is only referenced through a plugin registry or reflective +dispatch that CodeClone cannot see statically. Do not use suppressions to silence +a broad class of findings; adjust configuration thresholds or fix the underlying +structure instead. + +## Related + +- [Configuration reference](configuration.md) — thresholds and gates +- [Reports](reports.md) and [JSON output](json-output.md) — how findings are reported +- MCP help: `help(topic="suppressions")` diff --git a/docs/releasing.md b/docs/releasing.md index eb88e489..ef151888 100644 --- a/docs/releasing.md +++ b/docs/releasing.md @@ -1,3 +1,11 @@ +--- +title: "Releasing & Storefront Sync" +audience: public +doc_type: runbook +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + - -# Your first governed edit - -CodeClone turns an ordinary edit into a *governed* edit: you declare what you -intend to change, edit inside that boundary, and the controller verifies the -patch and clears the intent with an auditable receipt. This tutorial walks one -small fix through the full cycle. - -**Before you start:** [install CodeClone and connect your agent](../getting-started.md). -Every call below uses an **absolute** repository root — relative roots like `.` -are rejected. - -## The cycle at a glance - -```text -analyze_repository → register a baseline "before" run -start_controlled_change → declare scope; get edit permission + blast radius -get_relevant_memory → load evidence-linked context for your scope -edit → change only files inside the declared scope -analyze_repository → an "after" run for structural verification -finish_controlled_change → scope check + verify + receipt + clear intent -``` - -## 1. Analyze — register a before-run - -```text -analyze_repository(root="/abs/path/to/repo") -``` - -Returns a `run_id` and a health snapshot. This run is the **before** state the -controller compares your edit against. Run it first — `start` needs an existing -run for the root. - -## 2. Declare your intent - -Say what you will touch *before you touch it*: - -```text -start_controlled_change( - root="/abs/path/to/repo", - scope={"allowed_files": ["myapp/formatting.py"]}, - intent="Fix default rounding in format_ratio", -) -``` - -Read three fields in the response: - -| Field | What it tells you | -|-------|-------------------| -| `edit_allowed` | `true` means you may edit — nothing before this authorizes a write | -| `scope.allowed_files` | the exact boundary; edits outside it are violations | -| `blast_radius.radius_level` | how far the change reaches (`low`/`medium`/`high`), plus dependents to review | - -Keep the returned `intent_id` — `finish` needs it. If the response is `queued`, -another agent holds an overlapping intent: wait and promote rather than editing. - -## 3. Load scoped memory - -```text -get_relevant_memory(root="/abs/path/to/repo", intent_id="") -``` - -Returns evidence-linked `records` (asserted facts), `trajectories` (past workflow -runs over these files), and `experiences` (recurring patterns). Read any stale or -contradiction notes before you edit — they flag context that has changed. Memory -informs your edit; it never authorizes one. - -## 4. Edit inside the boundary - -Make the change — and only inside `allowed_files`: - -```diff -- def format_ratio(value, digits=1): -+ def format_ratio(value, digits=2): -``` - -If the fix needs a file outside scope, **stop**. Re-run `start_controlled_change` -with a wider scope instead of silently editing extra files. - -## 5. Analyze again — the after-run - -```text -analyze_repository(root="/abs/path/to/repo") -``` - -For any Python change the controller needs this **after** run to verify there are -no structural regressions. Keep its `run_id`. - -## 6. Finish — verify and clear - -```text -finish_controlled_change( - intent_id="", - changed_files=["myapp/formatting.py"], - after_run_id="", -) -``` - -The controller runs hygiene, scope check, and structural verify, builds the patch -trail, and on success issues a receipt and clears the intent: - -| Field | Accept when | -|-------|-------------| -| `status` | `accepted` (or `accepted_with_external_changes`) | -| `scope_check.status` | `clean` or `expanded` | -| `intent_cleared` | `true` | - -## The completion gate - -Do **not** report the edit as done, verified, or ready until **all three** hold: - -1. `finish` returned `accepted`, -2. `scope_check.status` is `clean` (or `expanded`), and -3. `intent_cleared` is `true`. - -If `status` is `unverified` or `violated`, the intent stays active — follow the -`next_step` hint (often: re-run `analyze_repository` for a fresh after-run, then -`finish` again on the **same** `intent_id`). Never present an unverified patch as -finished. - -## Where to go next - -- [Change control recipe](../guide/mcp/workflows/change-control.md) — the how-to, - including queue, promote, and the atomic fallback path. -- [Structural Change Controller](../book/12-structural-change-controller/index.md) - — the normative contract: verification profiles, finish hygiene, receipts. -- [Engineering Memory](../guide/memory/overview.md) — what to record before you finish. diff --git a/docs/terms-of-use.md b/docs/terms-of-use.md index 8d686fa1..2d03f494 100644 --- a/docs/terms-of-use.md +++ b/docs/terms-of-use.md @@ -1,13 +1,22 @@ +--- +title: "Terms of Use" +audience: public +doc_type: legal +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + # Terms of Use These terms describe the intended operational and integration boundaries of CodeClone and its local integration surfaces, including the MCP server, -VS Code extension, Codex plugin, and Claude Desktop bundle. +VS Code extension, Cursor plugin, Claude Code plugin, Codex plugin, and +Claude Desktop bundle. ## Local-first execution model diff --git a/docs/troubleshooting/index.md b/docs/troubleshooting/index.md new file mode 100644 index 00000000..fbb6f9a1 --- /dev/null +++ b/docs/troubleshooting/index.md @@ -0,0 +1,70 @@ +--- +title: "Troubleshooting" +audience: public +doc_type: troubleshooting +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +# Troubleshooting + +## How to use this section + +CodeClone is a deterministic structural controller for Python repositories. When analysis produces unexpected results, findings, or errors, this guide helps you diagnose the issue. + +Each subsection below describes a symptom, what it might indicate, and the next steps to resolve it. Review the symptom that matches your situation, then follow the troubleshooting path. + +Most issues fall into one of these categories: + +- **Analysis configuration**: thresholds, scope, or input settings +- **Repository state**: cache, baseline, or file changes +- **Dependency or environment**: Python version, package versions, or system resources + +## Common symptoms + +| Symptom | Likely cause | Next step | +|---------|--------------|-----------| +| Analysis runs but finds many unexpected issues | Thresholds set too low or baseline is stale | Review `DEFAULT_COMPLEXITY_THRESHOLD`, `DEFAULT_COUPLING_THRESHOLD`, `DEFAULT_COHESION_THRESHOLD` in your configuration. Compare your baseline against the current repository state. | +| Report or cache files are missing or corrupted | Cache version mismatch or incomplete analysis run | Check `.codeclone/` directory. Clear `.codeclone/` and re-run analysis. Verify cache version matches `CACHE_VERSION` contract. | +| Analysis hangs or times out | Large repository or resource constraints | Check available disk space and memory. Reduce `DEFAULT_PROCESSES` if system is constrained. Verify no circular dependencies are blocking traversal. | +| Baseline size exceeds limits | Repository growth or overly broad baseline scope | Check baseline file size against `DEFAULT_MAX_BASELINE_SIZE_MB`. If exceeded, re-baseline with narrower scope or larger limit if intentional. | +| Findings disappear between runs | Cache invalidation or configuration drift | Verify configuration settings are consistent. Re-run with fresh cache to confirm findings are reproducible. | +| File paths in reports are incorrect or relative | Working directory or root path mismatch | Ensure you pass an absolute root path to analysis. Check current working directory when running CodeClone. | +| Health score calculation seems wrong | Metric weights or dependency calculation | Review `HEALTH_WEIGHTS` for each metric (clones, cohesion, complexity, coupling, coverage, dead_code, dependencies). Verify dependency depth is calculated correctly. | +| Git integration is not working | Repository state or intent workspace issues | Confirm you are in a valid Git repository. Check that no stale intents are blocking operations. Use change control tools to inspect intent state. | + +## Where to look next + +After identifying your symptom, follow these paths: + +**For configuration issues:** +- Review the defaults in your CodeClone configuration or `pyproject.toml`. +- Compare thresholds against your project's code volume and complexity. +- Re-run analysis with explicit threshold arguments to test different settings. + +**For baseline or cache issues:** +- Remove `.codeclone/` directory completely. +- Run a fresh analysis to regenerate baseline and cache. +- Compare the new baseline against previous versions if available. + +**For analysis or finding validation:** +- Inspect the JSON or Markdown report in `.codeclone/report.*`. +- Use targeted checks (clones, dead code, coupling, cohesion, complexity) instead of full analysis. +- Review specific findings with detailed context before acting on them. + +**For performance issues:** +- Measure analysis time with and without cache enabled. +- Reduce the number of processes if system resources are constrained. +- Check for circular dependency chains that may cause deep traversal. + +**For engineering memory or change control:** +- Review Engineering Memory records related to your scope or recent changes. +- Check active intents if you are using change control workflows. +- Validate patch contracts before and after edits. + +**Additional resources:** +- See [Development guide](../guides/development.md) for setup and environment details. +- Review [Engineering Memory](../concepts/engineering-memory.md) for how CodeClone stores and uses context. +- Check the [Example report](../examples/report.md) for interpreting analysis output. + +If you encounter issues not covered here, report them at: https://github.com/orenlab/codeclone/issues diff --git a/docs/troubleshooting/install.md b/docs/troubleshooting/install.md new file mode 100644 index 00000000..71923b82 --- /dev/null +++ b/docs/troubleshooting/install.md @@ -0,0 +1,129 @@ +--- +title: "Installation troubleshooting" +audience: public +doc_type: troubleshooting +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Installation troubleshooting + +## What it is + +CodeClone is distributed via PyPI and installed through Python package managers. This guide covers common installation issues, version selection, and resolution steps for both stable releases and prerelease versions. + +CodeClone 2.1 is available as an alpha prerelease (`2.1.0a1`). The stable release series uses standard semantic versioning. + +## When to use it + +Use this guide if you encounter: + +- Installation failures or dependency conflicts +- Version resolution issues (wrong release channel) +- Command-line tool not found after installation +- MCP server connectivity problems +- Permission or environment setup issues + +## Basic workflow + +```mermaid +graph TD + A["Identify your installer"] --> B["Determine release channel needed"] + B --> C{"Prerelease?"} + C -->|Stable| D["Use base command"] + C -->|Alpha/2.1| E["Add prerelease flag"] + D --> F["Verify installation"] + E --> F + F --> G{"Command works?"} + G -->|Yes| H["Installation complete"] + G -->|No| I["Troubleshoot by error type"] + I --> J["Consult error mapping below"] +``` + +## Key commands + +| Installer | Stable release | Prerelease (2.1) | +|-----------|---|---| +| **uv** | `uv tool install codeclone` | `uv tool install --prerelease allow codeclone` | +| **pip** | `pip install codeclone` | `pip install --pre codeclone` | +| **pipx** | `pipx install codeclone` | `pipx install --pre codeclone` | + +Verify installation with: +```bash +codeclone --version +``` + +For MCP server usage, confirm the bundled `codeclone-mcp` entry point is on your PATH: +```bash +codeclone-mcp --help +``` + +## Common mistakes + +### Mistake 1: Using pip's `--pre` syntax with uv + +**Error:** Installation resolves wrong version or fails. + +**Wrong:** +```bash +uv tool install --pre codeclone # This is pip syntax, not valid for uv +``` + +**Correct:** +```bash +uv tool install --prerelease allow codeclone # uv's correct flag +``` + +The `uv` package manager uses `--prerelease allow`, not `--pre`. + +### Mistake 2: Installing stable when prerelease is needed + +**Problem:** You need features in 2.1 alpha, but a plain `uv tool install codeclone` resolves the latest stable (1.x). + +**Solution:** +```bash +uv tool install --prerelease allow codeclone # Explicitly allow prerelease +``` + +### Mistake 3: Confusing MCP as a separate tool + +**Problem:** "MCP server not found" after installation. + +**Fact:** The MCP server is bundled with CodeClone. It does not require a separate install. + +**Check:** +```bash +codeclone-mcp --help # Confirms the bundled MCP server binary is installed +``` + +If the command is not found, ensure CodeClone was installed successfully and your PATH is set correctly. `codeclone-mcp` starts the server (default transport `stdio`); MCP clients launch it for you — you do not run it manually to "list tools". + +### Mistake 4: Wrong directory for CLI invocation + +**Problem:** "codeclone: command not found" despite successful installation. + +**Causes:** +- Virtual environment not activated (if using `pip` in a venv) +- Installation used `--user` flag; `~/.local/bin` not in PATH +- Shell cache needs refresh + +**Fix:** +```bash +# If using virtual environment, activate it first +source venv/bin/activate # Linux/macOS +# or +venv\Scripts\activate # Windows + +# Refresh shell cache +hash -r # bash/zsh +rehash # zsh only +``` + +## Next steps + +- **Run CodeClone:** `codeclone .` to analyze the current repository +- **Set up IDE:** Configure VS Code or other IDE integrations +- **Review reports:** Generated reports appear in `.codeclone/` by default +- **MCP integration:** Use CodeClone with Claude Code or other MCP clients + +For additional help, see [https://github.com/orenlab/codeclone/issues](https://github.com/orenlab/codeclone/issues). diff --git a/docs/troubleshooting/mcp.md b/docs/troubleshooting/mcp.md new file mode 100644 index 00000000..3ea98fcc --- /dev/null +++ b/docs/troubleshooting/mcp.md @@ -0,0 +1,67 @@ +--- +title: "MCP troubleshooting" +audience: public +doc_type: troubleshooting +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone's MCP interface exposes analysis, change control, and engineering memory as deterministic, workspace-aware tools. The MCP server runs alongside your development process, maintaining a single active change-control intent and caching analysis results within the server session. + +## When to use it + +Use MCP when you need: +- Bounded analysis before or after code edits +- Deterministic change-control verification with scope tracking +- Implementation context for understanding dependencies and APIs +- Production-first triage views over noisy codebases + +## Basic workflow + +The core MCP pattern follows three phases: + +```mermaid +graph TD + A["analyze_repository
(or analyze_changed_paths)"] -->|"run_id"| B["get_production_triage
or get_run_summary"] + B -->|"bounded inspection"| C["get_implementation_context
or list_findings"] + D["start_controlled_change"] -->|"intent_id"| E["(edit files)"] + E -->|"after_run_id"| F["finish_controlled_change"] + A -.->|"no analysis yet"| D + F -->|"status: accepted"| G["intent cleared"] +``` + +Analysis caches within the MCP session. Calling `analyze_repository` again on the same root reuses the prior run unless `cache_policy='off'` is set. + +## Key commands + +| Task | Tool | Notes | +|------|------|-------| +| Full analysis | `analyze_repository(root="/path")` | Requires absolute root; MCP rejects relative paths like '.' | +| PR-style review | `analyze_changed_paths(root="/path", git_diff_ref="...")` | Analyzes only changed files; includes `next_tool` hints | +| View results | `get_production_triage(run_id=...)` | Production hotspots first; preferred for noisy repos | +| Summary | `get_run_summary(run_id=...)` | Compact snapshot of health, cache freshness, findings | +| Explore scope | `get_implementation_context(root="/path", paths=[...])` | Bounded module, call graph, and blast-radius context | +| Declare intent | `start_controlled_change(root="/path", scope={...})` | Returns `intent_id`, blast radius, budget | +| Verify patch | `finish_controlled_change(intent_id=..., after_run_id=...)` | Scope check, hygiene, verification, receipt | +| List findings | `list_findings(run_id=..., family=...)` | Use focused checks first; avoid broad filters on first pass | +| PR summary | `generate_pr_summary(run_id=..., format='markdown')` | Compact LLM-facing summary of changed-file impact | + +## Common mistakes + +**Relative paths**: MCP tools require absolute repository roots. Passing `.` or `./src` will be rejected. Use the full filesystem path. + +**Session scope collision**: An MCP session holds exactly one active change-control intent. Calling `start_controlled_change` a second time before finishing the first one evicts the first intent with no recovery. Always call `finish_controlled_change` before starting a new change. + +**Missing after-run**: Python structural patches must provide `after_run_id` to `finish_controlled_change`. Re-analyze after editing and pass the new run ID; do not reuse the before-run analysis. + +**Cache assumptions**: `get_implementation_context_page` returns data from the saved session projection artifact. It does not recompute fresh context. If the result is `not_found` or `mismatch`, the page was not captured in the original analysis. + +**Concurrent intents**: If foreign change-control intents overlap your declared scope, either narrow your scope or queue behind the foreign intent. The MCP server reports the overlap in the `start_controlled_change` response. + +## Next steps + +- Read the [engineering memory documentation](../concepts/engineering-memory.md) to persist findings across sessions. +- Use `help(topic='change_control')` or `help(topic='overview')` in MCP for workflow guidance and anti-patterns. +- Check `get_blast_radius` directly if you need detailed impact scope before committing to a change. diff --git a/docs/troubleshooting/memory.md b/docs/troubleshooting/memory.md new file mode 100644 index 00000000..a01b6c78 --- /dev/null +++ b/docs/troubleshooting/memory.md @@ -0,0 +1,65 @@ +--- +title: "Engineering Memory troubleshooting" +audience: public +doc_type: troubleshooting +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +## What it is + +Engineering Memory is a local SQLite store that persists repository facts linked to CodeClone runs. It records decisions, risks, and evidence to help agents understand context across sessions. Unlike chat, memory survives conversation boundaries and remains anchored to specific commits and file paths. + +## When to use it + +**Use memory in these situations:** + +- You need facts from a prior CodeClone session to inform the current work +- You are recording a non-obvious debugging decision that the next agent should know +- A finding is stale or contradicted and you need to surface why +- You want to assert that a specific file change has a documented rationale + +**Do not use memory for:** temporary observations, chat summaries, or transient coordination state (those go in intent tracking, not memory). + +## Basic workflow + +```mermaid +graph LR + A["run\nanalyze_repository"] --> B["agent calls\nget_relevant_memory"] + B --> C{"memory\ncontains\nstale/\ncontradicted\nentries?"} + C -->|Yes| D["check\nstale records\nvia query_\nengineering_memory"] + C -->|No| E["proceed\nwith edit"] + D --> E + E --> F["after edit:\nrecord_candidate\nvia manage_\nengineering_memory"] + F --> G["finish_\ncontrolled_\nchange"] +``` + +## Key commands + +| Command | Tool | Purpose | +|---------|------|---------| +| `memory init` | CLI | Create or validate the memory store for a repository | +| `memory status` | CLI | Show memory schema version and store size | +| `get_relevant_memory` | MCP | Fetch ranked, evidence-linked facts for a declared scope | +| `query_engineering_memory` | MCP | Inspect memory by mode: `search`, `for_path`, `stale`, `coverage`, `trajectory_*` | +| `manage_engineering_memory` | MCP | Write or refresh: `record_candidate`, `refresh_from_run`, `rebuild_semantic_index` | +| `memory approve` | CLI | Promote a draft record to active (requires human review in VS Code) | + +## Common mistakes + +**Treating memory as a cache or inventory:** Memory is commit-anchored, not disk-anchored. A file deletion does not invalidate memory notes about that file; staleness is measured against the anchor commit, not inventory membership. + +**Ignoring stale warnings:** When `get_relevant_memory` returns records marked `stale`, investigate them first. Stale does not mean false; it means context has shifted. A stale decision may still apply. + +**Recording vague observations:** Memory statements must be specific and actionable. Avoid "this code is confusing" — instead: "UpdateSchema logic in schema_migrate.py must reuse _add_column_if_missing; schema-version bumps require version pin updates in tests." + +**Confusing memory with engineering decision records:** Memory records drift-sensitive repository facts, not product roadmaps or design philosophy. Use `record_type=change_rationale` for "why this fix happened," not for long-term strategic intent. + +## Next steps + +- **For stale records:** Use `query_engineering_memory(mode=stale)` to list staleness reasons, then decide whether to update, archive, or act on the note anyway. +- **For semantic search:** After semantic indexing completes, use `query_engineering_memory(mode=search, text="keyword")` to find related notes across the codebase. +- **For trajectory review:** Use `query_engineering_memory(mode=trajectory_search)` to inspect episode history of a specific file or decision. +- **Integration with MCP:** When writing memory, pass `root` (absolute path) and either `scope` or `intent_id`. MCP validates the store before returning ranked results. + +See [Engineering Memory concepts](../concepts/engineering-memory.md) for architecture and storage guarantees. diff --git a/docs/troubleshooting/reports.md b/docs/troubleshooting/reports.md new file mode 100644 index 00000000..4af18254 --- /dev/null +++ b/docs/troubleshooting/reports.md @@ -0,0 +1,73 @@ +--- +title: "Report troubleshooting" +audience: public +doc_type: troubleshooting +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +## What it is + +CodeClone analysis is written to disk as a report—a structured file containing findings, metrics, and integrity checksums. Reports exist in five formats (JSON, HTML, Markdown, SARIF, text) and are stored by default in `.codeclone/` alongside a baseline snapshot (`codeclone.baseline.json`) and an analysis cache. This guide helps you understand what each report contains, where they live, and how to troubleshoot missing or unexpected data. + +## When to use it + +Use this troubleshooting guide when: + +- A report doesn't exist after running analysis +- Report output format looks incomplete or malformed +- You need to compare two analyses to spot regressions +- Baseline and report are out of sync +- Report schema validation fails + +## Basic workflow + +```mermaid +graph LR + A["Run analysis
codeclone . --json"] -->|creates| B[".codeclone/report.json
+ cache + baseline"] + B -->|read via MCP| C["get_run_summary
get_report_section"] + C -->|compare| D["compare_runs
before/after"] + D -->|resolve drift| E["Baseline out of sync?
Use --update-baseline"] +``` + +Analysis produces three artifacts: +1. **Report** (`.codeclone/report.`) — findings, metrics, integrity data +2. **Baseline** (`codeclone.baseline.json`) — snapshot for regression detection +3. **Cache** (`.codeclone/cache.json`) — incremental state to speed up re-runs + +## Key commands + +| Command | Purpose | Notes | +|---------|---------|-------| +| `codeclone . --json` | Write JSON report to `.codeclone/report.json` | Default location; use `--json FILE` to override | +| `codeclone . --html` | Write HTML report with interactive UI | Requires rendering; open in browser | +| `codeclone . --md` | Write Markdown report | Good for CI logs and version control review | +| `codeclone . --update-baseline` | Snapshot current state as truth | Use after fixing real issues, not as a silence toggle | +| `codeclone . --sarif` | Write SARIF JSON for IDE integration | SARIF 2.1.0 format for GitHub/GitLab scanners | + +Each flag defaults to `.codeclone/report.` unless you provide a path argument. + +## Common mistakes + +**Mistake 1:** Assuming `--update-baseline` silences findings. +- **Reality:** It replaces the baseline snapshot. If your code still has the issues, they'll reappear on the next run. +- **Fix:** Only use `--update-baseline` after you've actually fixed the underlying structural problems. + +**Mistake 2:** Running analysis and not checking report integrity. +- **Reality:** Corrupted cache or missing dependencies can produce incomplete reports. +- **Check:** Open the report; verify `integrity` section shows `status: "ok"`. + +**Mistake 3:** Comparing runs across different repositories or commit hashes. +- **Reality:** `compare_runs` requires the same root and analysis scope. +- **Check:** Both runs must target the same repository and use compatible settings. + +**Mistake 4:** Forgetting report format applies to output only. +- **Reality:** Analysis is the same; format (JSON, HTML, text) doesn't change what findings you get. +- **Check:** If findings differ between formats, the cache may be stale—delete `.codeclone/cache.json` and re-run for a clean analysis. + +## Next steps + +- Read the examples guide for sample JSON structure and interpretation +- Use the MCP service tools (`get_run_summary`, `get_report_section`, `compare_runs`) to programmatically query reports +- Check Engineering Memory documentation for structural decisions about cache and baseline versioning +- If a report fails validation, ensure your CodeClone version matches the baseline schema version (currently `2.1`) diff --git a/docs/troubleshooting/setup.md b/docs/troubleshooting/setup.md new file mode 100644 index 00000000..9618d945 --- /dev/null +++ b/docs/troubleshooting/setup.md @@ -0,0 +1,100 @@ +--- +title: "Setup troubleshooting" +audience: public +doc_type: troubleshooting +status: draft +source_commit: "d88c17f0f19cf753b9d43870528e0747b3161b9c" +--- + +# Setup troubleshooting + +## What it is + +`codeclone setup` is a workflow for initializing CodeClone in your repository. It validates your environment (via `status` and `doctor`), generates a configuration plan (`plan`), and applies it safely (`apply`). The workflow prevents accidental writes through confirmation requirements and plan binding. + +## When to use it + +Use this guide when: + +- `codeclone setup` commands fail or behave unexpectedly +- You see warnings about missing dependencies or configuration +- You need to preview changes before applying them +- You're running setup in automation (CI/CD) + +## Basic workflow + +```mermaid +graph LR + A["codeclone setup status"] -->|read environment| B["View readiness"] + B -->|issues found| C["codeclone setup doctor"] + C -->|diagnose| D["Fix issues"] + D -->|ready| E["codeclone setup plan"] + E -->|preview| F["Review plan output"] + F -->|approved| G["codeclone setup apply --plan-id ID"] + G -->|write config| H["Setup complete"] + F -->|make changes| D +``` + +## Key commands + +| Command | Purpose | Common flags | +|---------|---------|--------------| +| `codeclone setup status` | Check readiness without making changes | `--json` for machine output | +| `codeclone setup doctor` | Diagnose issues preventing setup | `--json` for parseable format | +| `codeclone setup plan` | Generate a configuration plan | `--dry-run`, `--json` | +| `codeclone setup apply` | Write configuration to disk | `--yes`, `--plan-id ID`, `--dry-run` | +| `codeclone setup wizard` | Interactive guided setup | None typically needed | + +### Apply safety flags + +- **`--yes` / `-y`**: Skip confirmation prompt (required for non-interactive environments) +- **`--plan-id PLAN_ID`**: Bind apply to a previewed plan; exits with `status=stale_plan` (exit code 2) if the plan is outdated +- **`--dry-run`**: Preview writes without persisting to disk +- **`--root ROOT`**: Specify repository root (default: current directory) + +## Common mistakes + +**Mistake 1: Running apply without plan preview** + +```bash +# Bad: no way to review what will be written +codeclone setup apply --yes + +# Good: preview first, then apply +codeclone setup plan +codeclone setup apply --yes --plan-id +``` + +**Mistake 2: Ignoring plan staleness** + +If changes occur between `plan` and `apply`, the `--plan-id` check catches it: + +```bash +# Plan generated at timestamp T1 +codeclone setup plan --json +# ... (other changes made to repo) +# Apply fails if plan is stale +codeclone setup apply --yes --plan-id old-id +# Exit code 2: status=stale_plan +``` + +**Mistake 3: Missing --yes in CI/CD** + +```bash +# Bad: blocks forever waiting for interactive confirmation +codeclone setup apply + +# Good: explicitly confirm non-interactively +codeclone setup apply --yes +``` + +**Mistake 4: Assuming status indicates readiness** + +`status` and `doctor` report findings; readiness is determined internally. Use the output to guide fixes, then re-run status to verify. + +## Next steps + +- Review the `--help` output for each command: `codeclone setup apply --help` +- Use `--json` flags to parse output in scripts +- Report unexpected failures with `codeclone setup doctor --json` output +- For additional setup questions, check the main documentation site at https://orenlab.github.io/codeclone/ diff --git a/docs/troubleshooting/terminal-output.md b/docs/troubleshooting/terminal-output.md new file mode 100644 index 00000000..78d88c7a --- /dev/null +++ b/docs/troubleshooting/terminal-output.md @@ -0,0 +1,72 @@ +--- +title: "Terminal output troubleshooting" +audience: public +doc_type: troubleshooting +status: published +source_commit: "60eac9c367d74deeba1478521461addfedd8e681" +--- + +# Terminal output troubleshooting + +## What it is + +CodeClone's terminal output communicates analysis status, progress, and results through ANSI colors, Unicode progress indicators, and structured text. Understanding output modes and control flags helps you debug display issues, integrate with CI systems, and read findings efficiently. + +## When to use it + +Use this guide when: +- Terminal output is garbled, colorless, or missing entirely +- Progress indicators are broken or unreadable in CI logs +- You need to redirect or suppress output for scripting +- Colors or Unicode conflict with your terminal emulator or CI runner + +## Basic workflow + +```mermaid +graph LR + A["Run codeclone"] --> B{Check output} + B -->|Garbled or missing| C["Check terminal support"] + B -->|Too verbose| D["Use --quiet flag"] + B -->|CI environment| E["Use --no-color --no-progress"] + C --> F["Try --color or --no-color"] + D --> G["Verify gate status from exit code"] + E --> H["Review structured output only"] +``` + +## Key commands + +| Flag | Behavior | Use when | +|------|----------|----------| +| `--no-progress` | Suppress progress spinner and status lines | CI logs, log aggregation | +| `--progress` | Force-enable progress output | Terminal stalled, progress hidden | +| `--no-color` | Strip ANSI color codes | CI runner, non-TTY output, broken terminal | +| `--color` | Force-enable colors | Terminal emulator doesn't detect TTY | +| `--quiet` | Show only warnings, errors, summaries | Noisy output, integration scripts | +| `--verbose` | Include detailed clone identifiers | Debugging, NEW finding investigation | +| `--debug` | Print traceback and environment info | Internal error diagnosis | + +## Common mistakes + +**Mistake:** Running in CI with colors and progress enabled. +- Result: ANSI codes pollute logs, progress spinners hang the output. +- Fix: Use the `--ci` preset (equivalent to `--fail-on-new --no-color --quiet`; when a trusted metrics baseline is available it also enables metrics-regression gating) or pass `--no-progress --no-color` explicitly. + +**Mistake:** Expecting Unicode progress bar in non-UTF8 terminals. +- Result: Progress spinner displays as garbage characters. +- Fix: Use `--no-progress` or ensure terminal locale is UTF-8. + +**Mistake:** Piping output without `--quiet` or `--no-progress`. +- Result: Progress spinners block the pipe, output mixes with control characters. +- Fix: Add `--quiet --no-progress` when piping to files or other commands. + +**Mistake:** Suppressing all output with `--quiet` then ignoring exit codes. +- Result: Gating failures are silent, CI passes when it should fail. +- Fix: Always check `echo $?` or your CI runner's exit code variable in scripts. + +## Next steps + +- **Color and progress not working:** Verify your terminal supports ANSI (check `echo $TERM`) and try `--color --progress` explicitly. +- **Need structured output:** Use `--json`, `--html`, `--md`, or `--sarif` to generate reports independent of terminal output. +- **Integrate with CI:** Pass `--ci` or `--quiet --no-color --no-progress` depending on log retention needs. +- **Debug internal errors:** Use `--debug` and include the full traceback in issue reports. +- **Script integration:** Capture exit code with `$?` in bash or `$LASTEXITCODE` in PowerShell to gate on gating failures. diff --git a/extensions/claude-desktop-codeclone/README.md b/extensions/claude-desktop-codeclone/README.md index 130466bf..b3f88418 100644 --- a/extensions/claude-desktop-codeclone/README.md +++ b/extensions/claude-desktop-codeclone/README.md @@ -24,14 +24,14 @@ Recommended workspace-local setup: ```bash uv venv -uv pip install --python .venv/bin/python "codeclone[mcp]" +uv pip install --prerelease allow --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` Global fallback: ```bash -uv tool install "codeclone[mcp]" +uv tool install --prerelease allow "codeclone[mcp]" codeclone-mcp --help ``` diff --git a/extensions/vscode-codeclone/README.md b/extensions/vscode-codeclone/README.md index d4ffa452..3298c961 100644 --- a/extensions/vscode-codeclone/README.md +++ b/extensions/vscode-codeclone/README.md @@ -53,13 +53,13 @@ Install the `codeclone-mcp` launcher before enabling the extension. **Recommended (global tool via uv):** ```bash -uv tool install "codeclone[mcp]" +uv tool install --prerelease allow "codeclone[mcp]" ``` **Current environment only:** ```bash -uv pip install "codeclone[mcp]" +uv pip install --prerelease allow "codeclone[mcp]" ``` **Verify:** diff --git a/extensions/vscode-codeclone/src/extension.js b/extensions/vscode-codeclone/src/extension.js index c611e6e0..efb2405f 100644 --- a/extensions/vscode-codeclone/src/extension.js +++ b/extensions/vscode-codeclone/src/extension.js @@ -841,7 +841,7 @@ class CodeCloneController { primary.fallback = (await looksLikeCodeCloneRepo(folder.uri.fsPath)) ? normalizedLaunchSpec({ command: "uv", - args: ["run", "codeclone-mcp"], + args: ["run", "codeclone-mcp", ...governanceArgs], cwd: folder.uri.fsPath, source: "uvFallback", }) diff --git a/extensions/vscode-codeclone/src/support.js b/extensions/vscode-codeclone/src/support.js index 7612fbb2..5f51e7a0 100644 --- a/extensions/vscode-codeclone/src/support.js +++ b/extensions/vscode-codeclone/src/support.js @@ -9,7 +9,7 @@ const ANALYSIS_PROFILE_DEFAULTS = "defaults"; const ANALYSIS_PROFILE_DEEPER_REVIEW = "deeperReview"; const ANALYSIS_PROFILE_CUSTOM = "custom"; const MINIMUM_SUPPORTED_CODECLONE_VERSION = "2.0.0"; -const PREVIEW_INSTALL_COMMAND = 'uv tool install "codeclone[mcp]"'; +const PREVIEW_INSTALL_COMMAND = 'uv tool install --prerelease allow "codeclone[mcp]"'; const ANALYSIS_PROFILE_IDS = new Set([ ANALYSIS_PROFILE_DEFAULTS, ANALYSIS_PROFILE_DEEPER_REVIEW, diff --git a/extensions/vscode-codeclone/test/support.test.js b/extensions/vscode-codeclone/test/support.test.js index 3d8f3d1e..e7dcb5bc 100644 --- a/extensions/vscode-codeclone/test/support.test.js +++ b/extensions/vscode-codeclone/test/support.test.js @@ -401,5 +401,8 @@ test("minimum supported CodeClone version and install command stay aligned", () assert.equal(isMinimumSupportedCodeCloneVersion("2.0.1"), true); assert.equal(isMinimumSupportedCodeCloneVersion("2.0.0rc2"), false); assert.equal(isMinimumSupportedCodeCloneVersion("1.27.0"), false); - assert.equal(PREVIEW_INSTALL_COMMAND, 'uv tool install "codeclone[mcp]"'); + assert.equal( + PREVIEW_INSTALL_COMMAND, + 'uv tool install --prerelease allow "codeclone[mcp]"', + ); }); diff --git a/plugins/claude-code-codeclone/README.md b/plugins/claude-code-codeclone/README.md index a672730b..867b36a3 100644 --- a/plugins/claude-code-codeclone/README.md +++ b/plugins/claude-code-codeclone/README.md @@ -23,7 +23,7 @@ Inside an interactive Claude Code session, the equivalent commands are: Install the local MCP server separately: ```bash -uv tool install "codeclone[mcp]" +uv tool install --prerelease allow "codeclone[mcp]" codeclone-mcp --help ``` @@ -31,7 +31,7 @@ For a workspace-local environment: ```bash uv venv -uv pip install --python .venv/bin/python "codeclone[mcp]" +uv pip install --prerelease allow --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` @@ -43,14 +43,18 @@ Claude Code settings. Claude Code namespaces plugin skills with the plugin name: -| Skill | Invocation | -|---|---| -| Repository review | `/codeclone:codeclone-review` | -| Hotspot snapshot | `/codeclone:codeclone-hotspots` | -| Controlled repository edit | `/codeclone:codeclone-change-control` | -| Engineering Memory | `/codeclone:codeclone-engineering-memory` | -| Implementation context | `/codeclone:codeclone-implementation-context` | +| Skill | Invocation | +|------------------------------------------|-----------------------------------------------| +| Repository review | `/codeclone:codeclone-review` | +| Hotspot snapshot | `/codeclone:codeclone-hotspots` | +| Production triage | `/codeclone:codeclone-production-triage` | +| Architecture triage | `/codeclone:codeclone-architecture-triage` | +| Blast-radius inspection | `/codeclone:codeclone-blast-radius` | +| Controlled repository edit | `/codeclone:codeclone-change-control` | +| Engineering Memory | `/codeclone:codeclone-engineering-memory` | +| Implementation context | `/codeclone:codeclone-implementation-context` | | Platform Observability (maintainer-only) | `/codeclone:codeclone-platform-observability` | +| Repository setup (CLI) | `/codeclone:codeclone-setup` | The MCP server remains read-only with respect to source, baselines, cache, and canonical reports. Change control, audit, and Engineering Memory write only diff --git a/plugins/claude-code-codeclone/skills/codeclone-setup/SKILL.md b/plugins/claude-code-codeclone/skills/codeclone-setup/SKILL.md new file mode 100644 index 00000000..ba76f460 --- /dev/null +++ b/plugins/claude-code-codeclone/skills/codeclone-setup/SKILL.md @@ -0,0 +1,56 @@ +--- +name: codeclone-setup +description: Human repository setup readiness — status, plan, apply, and wizard over the CLI (not MCP). +--- + +# CodeClone Setup (CLI) + +Inspect and configure repository readiness **before** MCP change control or +full review workflows. This skill is **CLI-only** — no MCP tools, no +`start_controlled_change`, no `edit_allowed`. + +## When to use + +- New repo, missing `[tool.codeclone]`, audit disabled, or `.gitignore` lacks `.codeclone/`. +- Human or agent needs a **previewed** pyproject/gitignore fix without touching baselines or reports. +- Prefer menus: `codeclone setup wizard` (TTY + Rich). + +Do **not** use for governed code edits — use `codeclone-change-control`. + +## Loop + +``` +codeclone setup status # capability snapshot (default) +codeclone setup doctor # verbose probes +codeclone setup plan [--json] # read-only diff preview +codeclone setup apply [--dry-run] [--json] +codeclone setup wizard # interactive hub (no --json) +``` + +All commands accept `--root `. + +## What apply changes (bounded) + +- Round-trip merge into **`pyproject.toml`** `[tool.codeclone]` only: + - add section + default `baseline` when missing; + - set `audit_enabled = true` when section exists but audit is off. +- Append **`.codeclone/`** to `.gitignore` when not already covered. + +Never writes `codeclone.baseline.json`, `.codeclone/cache`, or report artifacts. + +## Exit codes (apply) + +| JSON `status` | Exit | +|--------------------------------|------| +| `applied`, `preview`, `noop` | 0 | +| `blocked` | 2 | +| `failed`, `partial` | 5 | + +(`preview` when `--dry-run`; `plan` uses `status=empty`.) + +## Rules + +- Run in the user's terminal (or subprocess), not via MCP. +- Mature repos may show **`plan` → empty** — expected. +- After setup, continue with `codeclone .` and MCP skills as needed. +- Full guide: docs site *Repository setup and readiness* (`docs/guide/setup/readiness-and-apply.md`). diff --git a/plugins/codeclone/README.md b/plugins/codeclone/README.md index 2a6a84d5..29ffd3a3 100644 --- a/plugins/codeclone/README.md +++ b/plugins/codeclone/README.md @@ -28,11 +28,12 @@ directly, including `Coverage Join` facts and the optional `coverage` help topic | `skills/codeclone-implementation-context/` | Bounded `get_implementation_context` playbook | | `skills/codeclone-engineering-memory/` | Engineering Memory retrieval and draft writes | | `skills/codeclone-platform-observability/` | Maintainer-only observer diagnostics (not for end-user repo review) | +| `skills/codeclone-setup/` | CLI repository readiness (status, plan, apply, wizard) | | `assets/` | Plugin branding | -Nine skills ship in the plugin (review, hotspots, production-triage, architecture-triage, +Ten skills ship in the plugin (review, hotspots, production-triage, architecture-triage, blast-radius, change-control, engineering-memory, implementation-context, -platform-observability). +platform-observability, setup). `plugin.json` keeps the machine identifier as lowercase `codeclone`; the user-facing label stays in `interface.displayName` as `CodeClone`. @@ -53,7 +54,7 @@ Recommended workspace-local setup: ```bash uv venv -uv pip install --python .venv/bin/python "codeclone[mcp]" +uv pip install --prerelease allow --python .venv/bin/python "codeclone[mcp]" .venv/bin/codeclone-mcp --help ``` @@ -62,7 +63,7 @@ If your workspace uses Poetry, install CodeClone into that Poetry environment. Global fallback: ```bash -uv tool install "codeclone[mcp]" +uv tool install --prerelease allow "codeclone[mcp]" codeclone-mcp --help ``` diff --git a/plugins/codeclone/skills/codeclone-setup/SKILL.md b/plugins/codeclone/skills/codeclone-setup/SKILL.md new file mode 100644 index 00000000..ba76f460 --- /dev/null +++ b/plugins/codeclone/skills/codeclone-setup/SKILL.md @@ -0,0 +1,56 @@ +--- +name: codeclone-setup +description: Human repository setup readiness — status, plan, apply, and wizard over the CLI (not MCP). +--- + +# CodeClone Setup (CLI) + +Inspect and configure repository readiness **before** MCP change control or +full review workflows. This skill is **CLI-only** — no MCP tools, no +`start_controlled_change`, no `edit_allowed`. + +## When to use + +- New repo, missing `[tool.codeclone]`, audit disabled, or `.gitignore` lacks `.codeclone/`. +- Human or agent needs a **previewed** pyproject/gitignore fix without touching baselines or reports. +- Prefer menus: `codeclone setup wizard` (TTY + Rich). + +Do **not** use for governed code edits — use `codeclone-change-control`. + +## Loop + +``` +codeclone setup status # capability snapshot (default) +codeclone setup doctor # verbose probes +codeclone setup plan [--json] # read-only diff preview +codeclone setup apply [--dry-run] [--json] +codeclone setup wizard # interactive hub (no --json) +``` + +All commands accept `--root `. + +## What apply changes (bounded) + +- Round-trip merge into **`pyproject.toml`** `[tool.codeclone]` only: + - add section + default `baseline` when missing; + - set `audit_enabled = true` when section exists but audit is off. +- Append **`.codeclone/`** to `.gitignore` when not already covered. + +Never writes `codeclone.baseline.json`, `.codeclone/cache`, or report artifacts. + +## Exit codes (apply) + +| JSON `status` | Exit | +|--------------------------------|------| +| `applied`, `preview`, `noop` | 0 | +| `blocked` | 2 | +| `failed`, `partial` | 5 | + +(`preview` when `--dry-run`; `plan` uses `status=empty`.) + +## Rules + +- Run in the user's terminal (or subprocess), not via MCP. +- Mature repos may show **`plan` → empty** — expected. +- After setup, continue with `codeclone .` and MCP skills as needed. +- Full guide: docs site *Repository setup and readiness* (`docs/guide/setup/readiness-and-apply.md`). diff --git a/plugins/cursor-codeclone/CHANGELOG.md b/plugins/cursor-codeclone/CHANGELOG.md index 265c8c7d..1de3bfdc 100644 --- a/plugins/cursor-codeclone/CHANGELOG.md +++ b/plugins/cursor-codeclone/CHANGELOG.md @@ -3,10 +3,12 @@ ## 0.1.0 - Initial Cursor plugin for CodeClone -- **Six skills:** `codeclone-production-triage`, `codeclone-hotspots`, - `codeclone-blast-radius`, `codeclone-review`, `codeclone-change-control`, - `codeclone-engineering-memory` (optional semantic search documented in skill + - server config) +- **Ten skills:** `codeclone-production-triage`, `codeclone-hotspots`, + `codeclone-blast-radius`, `codeclone-architecture-triage`, + `codeclone-review`, `codeclone-change-control`, + `codeclone-engineering-memory`, `codeclone-implementation-context`, + `codeclone-platform-observability`, `codeclone-setup` (optional semantic search documented in + skill + server config) - **One agent:** `codeclone-structural-reviewer` (`agents/structural-reviewer.md`) - **Three rules:** `codeclone-workflow.mdc`, `change-control-gate.mdc` (always), `codeclone-python.mdc` (glob `**/*.py`) diff --git a/plugins/cursor-codeclone/README.md b/plugins/cursor-codeclone/README.md index 1e4875c2..20250984 100644 --- a/plugins/cursor-codeclone/README.md +++ b/plugins/cursor-codeclone/README.md @@ -38,7 +38,7 @@ it is not the public installation route. ### Install the MCP launcher ```bash -uv tool install "codeclone[mcp]" +uv tool install --prerelease allow "codeclone[mcp]" ``` Verify: @@ -62,12 +62,14 @@ codeclone-mcp --help | **Change Control** | `/codeclone-change-control` | Intent-first edit workflow: declare, context, edit, verify, clear | | **Engineering Memory** | `/codeclone-engineering-memory` | Scope memory before edits, search, draft `record_candidate`, finish proposals | | **Platform Observability** | `/codeclone-platform-observability` | **Maintainer-only** — CodeClone runtime diagnostics (requires observer enable) | +| **Setup (CLI)** | `/codeclone-setup` | Repository readiness via terminal (`status`, `plan`, `apply`, `wizard`) — not MCP | ### Typical flow -1. `/codeclone-production-triage` — understand the current state. -2. `/codeclone-implementation-context` — bounded context around files you will touch. -3. `/codeclone-change-control` — edit with full structural verification. +1. Run `codeclone setup status` (or `/codeclone-setup`) when `[tool.codeclone]` is missing. +2. `/codeclone-production-triage` — understand the current state. +3. `/codeclone-implementation-context` — bounded context around files you will touch. +4. `/codeclone-change-control` — edit with full structural verification. --- @@ -89,9 +91,10 @@ Three rules ship in `rules/` (load via plugin discovery, not only manual symlink | `change-control-gate.mdc` | always | Hard gate: `start` / `finish`, memory before finish when required | | `codeclone-python.mdc` | `**/*.py` | Analyze before structural edits; respect blast radius | -Chat skill ids use the `name:` field in each `SKILL.md` (folders `production-triage/` -and `blast-radius/` differ from ids `codeclone-production-triage` and -`codeclone-blast-radius`). +Chat skill ids use the `name:` field in each `SKILL.md`. Current shipped folders +and skill ids both use the `codeclone-*` names shown above; legacy local +`production-triage/` and `blast-radius/` aliases are development leftovers and +should not be documented as install paths. --- diff --git a/plugins/cursor-codeclone/skills/codeclone-setup/SKILL.md b/plugins/cursor-codeclone/skills/codeclone-setup/SKILL.md new file mode 100644 index 00000000..ba76f460 --- /dev/null +++ b/plugins/cursor-codeclone/skills/codeclone-setup/SKILL.md @@ -0,0 +1,56 @@ +--- +name: codeclone-setup +description: Human repository setup readiness — status, plan, apply, and wizard over the CLI (not MCP). +--- + +# CodeClone Setup (CLI) + +Inspect and configure repository readiness **before** MCP change control or +full review workflows. This skill is **CLI-only** — no MCP tools, no +`start_controlled_change`, no `edit_allowed`. + +## When to use + +- New repo, missing `[tool.codeclone]`, audit disabled, or `.gitignore` lacks `.codeclone/`. +- Human or agent needs a **previewed** pyproject/gitignore fix without touching baselines or reports. +- Prefer menus: `codeclone setup wizard` (TTY + Rich). + +Do **not** use for governed code edits — use `codeclone-change-control`. + +## Loop + +``` +codeclone setup status # capability snapshot (default) +codeclone setup doctor # verbose probes +codeclone setup plan [--json] # read-only diff preview +codeclone setup apply [--dry-run] [--json] +codeclone setup wizard # interactive hub (no --json) +``` + +All commands accept `--root `. + +## What apply changes (bounded) + +- Round-trip merge into **`pyproject.toml`** `[tool.codeclone]` only: + - add section + default `baseline` when missing; + - set `audit_enabled = true` when section exists but audit is off. +- Append **`.codeclone/`** to `.gitignore` when not already covered. + +Never writes `codeclone.baseline.json`, `.codeclone/cache`, or report artifacts. + +## Exit codes (apply) + +| JSON `status` | Exit | +|--------------------------------|------| +| `applied`, `preview`, `noop` | 0 | +| `blocked` | 2 | +| `failed`, `partial` | 5 | + +(`preview` when `--dry-run`; `plan` uses `status=empty`.) + +## Rules + +- Run in the user's terminal (or subprocess), not via MCP. +- Mature repos may show **`plan` → empty** — expected. +- After setup, continue with `codeclone .` and MCP skills as needed. +- Full guide: docs site *Repository setup and readiness* (`docs/guide/setup/readiness-and-apply.md`). diff --git a/pyproject.toml b/pyproject.toml index b23c01ba..27e42905 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -25,6 +25,7 @@ dependencies = [ "pydantic>=2.13.4", "pygments>=2.20.0", "rich>=15.0.0", + "tomlkit>=0.13.2", "tomli>=2.0.1; python_version < '3.11'", ] @@ -87,7 +88,7 @@ semantic-local = [ analytics = [ "hdbscan>=0.8.0", "scikit-learn>=1.5.0", - "lancedb>=0.33.0", + "lancedb>=0.34.0", "fastembed>=0.8.0,<0.9", "umap-learn>=0.5.6; python_version < '3.14'", # umap-learn → pynndescent → numba/llvmlite. @@ -108,6 +109,11 @@ dev = [ "mypy>=1.20.1", "ruff>=0.15.20", "pre-commit>=4.5.1", + "ty>=0.0.56", + "PyYAML>=6.0.2", + "jsonschema>=4.23.0", + "types-PyYAML>=6.0.12.20250915", + "types-jsonschema>=4.23.0.20250516", ] [project.scripts] @@ -171,6 +177,9 @@ packages = [ "codeclone.scanner", "codeclone.surfaces", "codeclone.surfaces.cli", + "codeclone.surfaces.cli.ui", + "codeclone.surfaces.cli.setup", + "codeclone.surfaces.cli.setup.engine", "codeclone.surfaces.mcp", "codeclone.surfaces.mcp.messages", "codeclone.surfaces.mcp.tools", @@ -239,12 +248,12 @@ index_audit = true # project audit summaries when audit DB [tool.codeclone.memory.ingest] contract_constants_paths = ["codeclone/contracts/__init__.py"] document_link_paths = [ - "docs/guide/mcp/README.md", + "docs/concepts/mcp.md", "AGENTS.md", "CLAUDE.md", ] mcp_tool_schema_snapshot_path = "tests/fixtures/contract_snapshots/mcp_tool_schemas.json" -mcp_tool_count_doc_paths = ["docs/book/25-mcp-interface/index.md"] +mcp_tool_count_doc_paths = ["docs/reference/mcp-tools.md"] [tool.coverage.report] show_missing = true diff --git a/scripts/build_docs_example_report.py b/scripts/build_docs_example_report.py index 4bb96270..03716340 100644 --- a/scripts/build_docs_example_report.py +++ b/scripts/build_docs_example_report.py @@ -32,7 +32,7 @@ "report.sarif", "manifest.json", ) -_RELATIVE_LIVE_HREF = re.compile(r'href=(["\'])(?:\./)?live/([a-zA-Z0-9_.-]+)\1') +_RELATIVE_LIVE_HREF = re.compile(r'href=(["\'])(?:\.{1,2}/)?live/([a-zA-Z0-9_.-]+)\1') @dataclass(frozen=True) diff --git a/tests/_assertions.py b/tests/_assertions.py index 2ae539cf..2bb05c38 100644 --- a/tests/_assertions.py +++ b/tests/_assertions.py @@ -6,12 +6,20 @@ from __future__ import annotations +import re from collections.abc import Mapping +_ANSI_ESCAPE_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def strip_ansi(text: str) -> str: + return _ANSI_ESCAPE_RE.sub("", text) + def assert_contains_all(text: str, *needles: str) -> None: + normalized = strip_ansi(text) for needle in needles: - assert needle in text + assert needle in normalized def assert_contains_none(text: str, *needles: str) -> None: diff --git a/tests/fixtures/contract_snapshots/cli_help.txt b/tests/fixtures/contract_snapshots/cli_help.txt index f9a029c9..aa07a873 100644 --- a/tests/fixtures/contract_snapshots/cli_help.txt +++ b/tests/fixtures/contract_snapshots/cli_help.txt @@ -1,3 +1,8 @@ + o + /|\ Deterministic structural change control. CodeClone · Structural Change Controller +o o o +Run `codeclone --help --interactive-help` for a guided product tour. + usage: codeclone [--min-loc MIN_LOC] [--min-stmt MIN_STMT] [--processes PROCESSES] [--changed-only | --no-changed-only] [--diff-against GIT_REF] [--paths-from-git-diff GIT_REF] @@ -32,10 +37,10 @@ usage: codeclone [--min-loc MIN_LOC] [--min-stmt MIN_STMT] [--open-html-report | --no-open-html-report] [--no-progress] [--progress] [--no-color] [--color] [--quiet | --no-quiet] [--verbose | --no-verbose] [--debug | --no-debug] [-h] - [--version] + [--interactive-help] [--version] [root] -Structural code quality analysis for Python. +Deterministic Structural Change Controller for AI-assisted Python development. Target: root Project root directory to scan. @@ -197,6 +202,8 @@ Output and UI: General: -h, --help Show this help message and exit. + --interactive-help Open the guided CodeClone product tour. + Use together with --help in an interactive terminal. --version Print the CodeClone version and exit. Exit codes: diff --git a/tests/fixtures/contract_snapshots/mcp_tool_schemas.json b/tests/fixtures/contract_snapshots/mcp_tool_schemas.json index 3a88a28d..3ab3f092 100644 --- a/tests/fixtures/contract_snapshots/mcp_tool_schemas.json +++ b/tests/fixtures/contract_snapshots/mcp_tool_schemas.json @@ -1577,7 +1577,7 @@ "type": "string" }, "finding_id": { - "description": "Short or full canonical finding id.", + "description": "Short MCP finding id or full canonical finding id; get_finding returns status=not_found for unknown ids.", "title": "Finding Id", "type": "string" }, @@ -1868,6 +1868,12 @@ { "input_schema": { "properties": { + "format": { + "default": "structured", + "description": "Stored patch-trail output: structured (typed, default).", + "title": "Format", + "type": "string" + }, "patch_trail_digest": { "anyOf": [ { @@ -2038,7 +2044,7 @@ "type": "string" }, "finding_id": { - "description": "Short or full canonical finding id.", + "description": "Short MCP finding id or full canonical finding id; get_finding returns status=not_found for unknown ids.", "title": "Finding Id", "type": "string" }, @@ -2211,7 +2217,7 @@ "type": "string" }, "topic": { - "description": "workflow, analysis_profile, suppressions, baseline, coverage, latest_runs, review_state, changed_scope, change_control, trust_boundaries, engineering_memory, implementation_context, verification_profiles, observability", + "description": "overview, workflow, analysis_profile, suppressions, baseline, coverage, latest_runs, review_state, changed_scope, change_control, trust_boundaries, engineering_memory, implementation_context, verification_profiles, observability", "title": "Topic", "type": "string" } @@ -2815,6 +2821,20 @@ "record_type": { "anyOf": [ { + "enum": [ + "module_role", + "contract_note", + "test_anchor", + "document_link", + "risk_note", + "public_surface", + "contradiction_note", + "architecture_decision", + "change_rationale", + "protocol_rule", + "stale_marker", + "human_note" + ], "type": "string" }, { @@ -2896,7 +2916,7 @@ "input_schema": { "properties": { "finding_id": { - "description": "Short or full canonical finding id.", + "description": "Short MCP finding id or full canonical finding id; get_finding returns status=not_found for unknown ids.", "title": "Finding Id", "type": "string" }, @@ -2955,7 +2975,7 @@ } ], "default": null, - "description": "Optional filters: types, statuses, confidences, match_mode (any|all, search mode only), include_routine (trajectory_search, trajectory_anomalies, trajectory_agents, trajectory_dashboard; default false excludes run:* routine workflows).", + "description": "Optional filters: types, statuses, confidences, match_mode (any|all, search mode only), include_routine (trajectory_search, trajectory_anomalies, trajectory_agents, trajectory_dashboard; default false excludes run:* routine workflows). Unknown filter keys are rejected with a typed contract error.", "title": "Filters" }, "include_drafts": { @@ -3081,7 +3101,7 @@ }, "limit": { "default": 10, - "description": "Row cap per section; clamped to [1, 50], else 10.", + "description": "Row cap per section; clamped to [1, 100], else 10.", "title": "Limit", "type": "integer" }, @@ -3095,7 +3115,7 @@ } ], "default": null, - "description": "Reserved for detail sections; echoed in ignored_parameters.", + "description": "Selects the operation for section=operation_detail; echoed in ignored_parameters for aggregate sections that do not consume it.", "title": "Operation Id" }, "root": { @@ -3104,7 +3124,7 @@ "type": "string" }, "section": { - "description": "Telemetry section to project: summary | slow_operations | memory_pipeline_cost | db_cost | agent_context | mcp_tool_matrix | correlated_chains | costly_noops | pipeline | analysis_phase_cost.", + "description": "Telemetry section to project: summary | slow_operations | memory_pipeline_cost | db_cost | agent_context | mcp_tool_matrix | correlated_chains | costly_noops | pipeline | analysis_phase_cost | operation_detail (per-span detail for one operation_id) | span_detail (one span_id).", "title": "Section", "type": "string" }, @@ -3118,7 +3138,7 @@ } ], "default": null, - "description": "Reserved for detail sections; echoed in ignored_parameters.", + "description": "Selects the span for section=span_detail; echoed in ignored_parameters for aggregate sections that do not consume it.", "title": "Span Id" }, "window": { diff --git a/tests/fixtures/contract_snapshots/setup_snapshot_v1.json b/tests/fixtures/contract_snapshots/setup_snapshot_v1.json new file mode 100644 index 00000000..21fd692e --- /dev/null +++ b/tests/fixtures/contract_snapshots/setup_snapshot_v1.json @@ -0,0 +1,246 @@ +{ + "capabilities": [ + { + "availability": "built_in", + "configuration": "configured", + "evidence": [ + "probe:import:codeclone.core", + "probe:pyproject:tool.codeclone" + ], + "group": "core_analysis", + "id": "analysis", + "installation": "installed", + "label": "Repository analysis", + "readiness": "ready", + "reason": "", + "recommended_action": "", + "runtime": "not_required" + }, + { + "availability": "built_in", + "configuration": "configured", + "evidence": [ + "probe:baseline:trust", + "probe:path:baseline" + ], + "group": "core_analysis", + "id": "baseline", + "installation": "installed", + "label": "Baseline & trust", + "readiness": "ready", + "reason": "", + "recommended_action": "", + "runtime": "not_required" + }, + { + "availability": "built_in", + "configuration": "not_required", + "evidence": [], + "group": "core_analysis", + "id": "reports", + "installation": "installed", + "label": "Reports", + "readiness": "ready", + "reason": "", + "recommended_action": "", + "runtime": "not_required" + }, + { + "availability": "built_in", + "configuration": "unconfigured", + "evidence": [ + "probe:audit:enabled" + ], + "group": "governed_agent_workflows", + "id": "audit_and_intents", + "installation": "installed", + "label": "Audit & intents", + "readiness": "attention", + "reason": "Audit trail is disabled in pyproject configuration", + "recommended_action": "Set audit_enabled = true under [tool.codeclone] to record controller events.", + "runtime": "not_required" + }, + { + "availability": "optional_extra", + "configuration": "not_required", + "evidence": [ + "probe:find_spec:mcp" + ], + "group": "governed_agent_workflows", + "id": "controlled_change", + "installation": "missing", + "label": "Controlled changes", + "readiness": "optional", + "reason": "Requires codeclone[mcp]", + "recommended_action": "uv tool install \"codeclone[mcp]\" then configure MCP in your client", + "runtime": "not_required" + }, + { + "availability": "optional_extra", + "configuration": "not_required", + "evidence": [ + "probe:find_spec:mcp" + ], + "group": "governed_agent_workflows", + "id": "mcp_runtime", + "installation": "missing", + "label": "MCP runtime", + "readiness": "optional", + "reason": "Requires codeclone[mcp]", + "recommended_action": "CodeClone MCP support requires the optional 'mcp' extra. Install it with: pip install 'codeclone[mcp]'", + "runtime": "not_required" + }, + { + "availability": "optional_extra", + "configuration": "not_required", + "evidence": [ + "probe:capability:analytics:full" + ], + "group": "project_knowledge", + "id": "analytics_cockpit", + "installation": "missing", + "label": "Cockpit / analytics", + "readiness": "optional", + "reason": "Requires codeclone[analytics]", + "recommended_action": "uv sync --extra analytics", + "runtime": "not_required" + }, + { + "availability": "optional_extra", + "configuration": "not_required", + "evidence": [ + "probe:find_spec:defusedxml" + ], + "group": "project_knowledge", + "id": "coverage_evidence", + "installation": "missing", + "label": "Coverage evidence", + "readiness": "optional", + "reason": "Requires codeclone[coverage-xml]", + "recommended_action": "uv sync --extra coverage-xml", + "runtime": "not_required" + }, + { + "availability": "built_in", + "configuration": "unconfigured", + "evidence": [ + "probe:memory:status" + ], + "group": "project_knowledge", + "id": "engineering_memory", + "installation": "installed", + "label": "Engineering Memory", + "readiness": "attention", + "reason": "Engineering Memory store has not been created yet", + "recommended_action": "Run codeclone memory init after analysis to create the store.", + "runtime": "not_verified" + }, + { + "availability": "optional_extra", + "configuration": "not_required", + "evidence": [ + "probe:capability:embed" + ], + "group": "project_knowledge", + "id": "semantic_retrieval", + "installation": "missing", + "label": "Semantic retrieval", + "readiness": "optional", + "reason": "Requires semantic optional extras", + "recommended_action": "uv sync --extra semantic-local", + "runtime": "not_required" + }, + { + "availability": "built_in", + "configuration": "unconfigured", + "evidence": [ + "probe:pyproject:ci_flags", + "probe:pyproject:ci_flags:absent" + ], + "group": "team_and_release", + "id": "ci_policy", + "installation": "installed", + "label": "CI policy", + "readiness": "attention", + "reason": "CI gating is optional and is not currently enabled in configuration", + "recommended_action": "Enable ci / fail_on_new under [tool.codeclone] to gate changes in CI.", + "runtime": "not_required" + }, + { + "availability": "external_tool", + "configuration": "unconfigured", + "evidence": [ + "probe:file:.github/workflows", + "probe:file:.github/workflows:missing" + ], + "group": "team_and_release", + "id": "github_workflow", + "installation": "installed", + "label": "GitHub workflow", + "readiness": "attention", + "reason": "No GitHub Actions workflow referencing CodeClone found", + "recommended_action": "Add a GitHub Actions workflow that runs codeclone in CI.", + "runtime": "not_required" + }, + { + "availability": "external_tool", + "configuration": "unconfigured", + "evidence": [ + "probe:file:.pre-commit-config.yaml", + "probe:file:.pre-commit-config.yaml:missing" + ], + "group": "team_and_release", + "id": "pre_commit_hook", + "installation": "installed", + "label": "pre-commit hook", + "readiness": "attention", + "reason": "No pre-commit hook referencing CodeClone found", + "recommended_action": "Add a pre-commit hook entry for codeclone.", + "runtime": "not_required" + }, + { + "availability": "built_in", + "configuration": "configured", + "evidence": [ + "probe:gitignore:codeclone_cache" + ], + "group": "team_and_release", + "id": "workspace_hygiene", + "installation": "installed", + "label": "Workspace hygiene", + "readiness": "ready", + "reason": "", + "recommended_action": "", + "runtime": "not_required" + } + ], + "head_commit": null, + "install": { + "base": "installed", + "extras": { + "analytics": "missing", + "coverage-xml": "missing", + "mcp": "missing", + "perf": "missing", + "semantic-fastembed": "missing", + "semantic-lancedb": "missing", + "semantic-local": "missing", + "token-bench": "missing" + } + }, + "maturity": { + "connected": true, + "evidence_backed": false, + "governed": true, + "release_ready": true, + "team_ready": true + }, + "projection_kind": "setup_snapshot", + "recomputation": true, + "root": "", + "runtime": { + "codeclone_version": "", + "python_tag": "" + }, + "schema_version": "1" +} diff --git a/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json b/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json index b8eaff45..225f12c6 100644 --- a/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json +++ b/tests/fixtures/golden_v2/pyproject_defaults/golden_expected_cli_snapshot.json @@ -2,7 +2,7 @@ "meta": { "python_tag": "cp314" }, - "report_schema_version": "2.11", + "report_schema_version": "2.12", "project_name": "pyproject_defaults", "scan_root": ".", "baseline_status": "missing", diff --git a/tests/plugin_test_helpers.py b/tests/plugin_test_helpers.py index 0ff4e5bf..a33ac985 100644 --- a/tests/plugin_test_helpers.py +++ b/tests/plugin_test_helpers.py @@ -22,6 +22,7 @@ "codeclone-platform-observability", "codeclone-production-triage", "codeclone-review", + "codeclone-setup", ) CODEX_CURSOR_SYNC_SKILL_NAMES: Final[tuple[str, ...]] = CODEX_PLUGIN_SKILL_NAMES @@ -130,6 +131,10 @@ def assert_plugin_skills_match_codex( assert plugin_text == codex_text, skill_name +def repo_docs_source_available(root: Path) -> bool: + return (root / "docs" / "index.md").is_file() + + def assert_repo_doc_paths_exist(root: Path, *relative_paths: str) -> None: for relative in relative_paths: assert (root / relative).is_file(), relative @@ -148,14 +153,15 @@ def assert_codex_plugin_readme_contract(readme_text: str) -> None: "prefers a workspace `.venv`", "current Poetry environment", "without relying on `sh -lc`", - 'uv tool install "codeclone[mcp]"', + 'uv tool install --prerelease allow "codeclone[mcp]"', "codeclone-change-control", + "codeclone-setup", "codeclone-architecture-triage", "codeclone-implementation-context", "codeclone-production-triage", "codeclone-architecture-triage", "codeclone-blast-radius", - "Nine skills ship in the plugin", + "Ten skills ship in the plugin", "Structural Change Controller for AI-assisted Python", ) @@ -167,5 +173,5 @@ def assert_claude_code_plugin_readme_contract(readme_text: str) -> None: readme_text, "claude plugin marketplace add orenlab/codeclone-claude-code", "claude plugin install codeclone@orenlab-codeclone", - 'uv tool install "codeclone[mcp]"', + 'uv tool install --prerelease allow "codeclone[mcp]"', ) diff --git a/tests/test_analytics_cli.py b/tests/test_analytics_cli.py index e87ba4f6..c570f0da 100644 --- a/tests/test_analytics_cli.py +++ b/tests/test_analytics_cli.py @@ -250,6 +250,7 @@ def test_use_recommended_requires_sweep_before_capability_check( monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str], ) -> None: + monkeypatch.delenv("CODECLONE_OBSERVABILITY_ENABLED", raising=False) monkeypatch.setattr( analytics_cli, "_require_capability", @@ -263,7 +264,7 @@ def test_use_recommended_requires_sweep_before_capability_check( captured = capsys.readouterr() assert code == int(ExitCode.CONTRACT_ERROR) assert "--use-recommended requires --sweep" in captured.err - assert not (tmp_path / ".codeclone").exists() + assert not (tmp_path / ".codeclone" / "analytics").exists() def test_snapshot_stdout_is_json_and_bootstrap_precedes_handler( diff --git a/tests/test_analytics_foundation.py b/tests/test_analytics_foundation.py index b5c610f8..dbc7b9ca 100644 --- a/tests/test_analytics_foundation.py +++ b/tests/test_analytics_foundation.py @@ -65,7 +65,13 @@ from codeclone.analytics.contracts import ( INTENT_REPRESENTATION_DESCRIPTION, INTENT_REPRESENTATION_DESCRIPTION_WITH_FRAME, + ClusteringRunRecord, + ClusteringRunStatus, CorpusItemRecord, + CorpusLane, + CorpusSnapshotRecord, + ProfileBatchRecord, + ProfileBatchStatus, ) from codeclone.analytics.corpus.adapters import intent_historical from codeclone.analytics.corpus.adapters.intent_historical import ( @@ -91,8 +97,10 @@ ) from codeclone.analytics.exceptions import ( AnalyticsCapabilityError, + AnalyticsStoreError, AnalyticsWorkflowError, ) +from codeclone.analytics.store.sqlite import SqliteCorpusAnalyticsStore from codeclone.audit.reader import AuditRecord from codeclone.config.analytics import resolve_analytics_config from codeclone.memory.trajectory.models import Trajectory, TrajectoryStep @@ -150,6 +158,106 @@ def _corpus_item( ) +def _snapshot(*, lane: CorpusLane = "intent") -> CorpusSnapshotRecord: + return CorpusSnapshotRecord( + snapshot_id="snap", + lane=lane, + representation_kind=INTENT_REPRESENTATION_DESCRIPTION, + representation_version="1", + source_stores_json="{}", + source_schema_versions_json="{}", + record_count=0, + source_digest="source-digest", + created_at_utc="2026-01-01T00:00:00Z", + ) + + +def test_corpus_store_validates_snapshot_lane_on_write_and_read( + tmp_path: Path, +) -> None: + store = SqliteCorpusAnalyticsStore.open(tmp_path / "analytics.sqlite3") + try: + invalid = _snapshot(lane=cast(CorpusLane, "audit")) + with pytest.raises(AnalyticsStoreError, match="corpus analytics lane"): + store.insert_snapshot(invalid, ()) + + store._conn.execute( + """ + INSERT INTO corpus_snapshots ( + snapshot_id, lane, representation_kind, representation_version, + source_stores_json, source_schema_versions_json, record_count, + source_digest, created_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + ( + "corrupt", + "audit", + INTENT_REPRESENTATION_DESCRIPTION, + "1", + "{}", + "{}", + 0, + "source-digest", + "2026-01-01T00:00:00Z", + ), + ) + with pytest.raises(AnalyticsStoreError, match="corpus analytics lane"): + store.get_snapshot("corrupt") + finally: + store.close() + + +def test_corpus_store_validates_status_literals_before_write(tmp_path: Path) -> None: + store = SqliteCorpusAnalyticsStore.open(tmp_path / "analytics.sqlite3") + try: + run = ClusteringRunRecord( + clustering_run_id="run", + snapshot_id="snap", + embedding_generation_id="generation", + requested_parameters_json="{}", + effective_parameters_json="{}", + random_seed=0, + run_digest="digest", + recommended_by_heuristic=False, + selected_by_maintainer=False, + status=cast(ClusteringRunStatus, "done"), + created_at_utc="2026-01-01T00:00:00Z", + finished_at_utc=None, + error_message=None, + ) + with pytest.raises( + AnalyticsStoreError, + match=r"corpus analytics clustering_run\.status", + ): + store.insert_clustering_run(run) + + batch = ProfileBatchRecord( + profile_batch_id="batch", + snapshot_id="snap", + embedding_generation_id="generation", + profile_id="profile", + profile_manifest_digest="manifest", + candidate_space_digest="candidates", + started_at_utc="2026-01-01T00:00:00Z", + finished_at_utc=None, + status=cast(ProfileBatchStatus, "pending"), + candidate_count_planned=1, + candidate_count_succeeded=0, + candidate_count_failed=0, + recommended_clustering_run_id=None, + recommendation_rationale_json=None, + batch_max_cluster_count=None, + created_at_utc="2026-01-01T00:00:00Z", + ) + with pytest.raises( + AnalyticsStoreError, + match=r"corpus analytics profile_batch\.status", + ): + store.insert_profile_batch(batch) + finally: + store.close() + + def test_identity_keys() -> None: project_id = "proj-abc" intent_id = "intent-1" @@ -1660,10 +1768,10 @@ def test_check_capability_import_error_path( original = importlib_module.import_module - def _import(name: str) -> object: + def _import(name: str, package: str | None = None) -> object: if name == "fastembed": raise ImportError("missing") - return original(name) + return original(name, package) monkeypatch.setattr( "codeclone.analytics.capabilities.importlib.import_module", diff --git a/tests/test_analytics_integrity.py b/tests/test_analytics_integrity.py index a70b8bb1..97e507d6 100644 --- a/tests/test_analytics_integrity.py +++ b/tests/test_analytics_integrity.py @@ -31,6 +31,7 @@ validate_generation_metadata, validate_persisted_run, ) +from codeclone.analytics.mapping import copy_str_key_mapping from codeclone.analytics.store.protocols import CorpusStore, VectorGenerationStore from codeclone.analytics.store.vectors_lancedb import vector_digest, vector_row_key from codeclone.contracts import CORPUS_EMBEDDING_CONTRACT_VERSION @@ -582,3 +583,8 @@ def test_validity_shape_and_numeric_edges( ).failed_invariants == expected ) + + +def test_integrity_mapping_helper_rejects_non_string_keys() -> None: + with pytest.raises(AnalyticsWorkflowError, match="non-string key"): + copy_str_key_mapping({1: "lost", "kept": "value"}) diff --git a/tests/test_analytics_reporting.py b/tests/test_analytics_reporting.py index 917018b7..e9cf53f0 100644 --- a/tests/test_analytics_reporting.py +++ b/tests/test_analytics_reporting.py @@ -30,6 +30,7 @@ ProfileManifestSnapshotRecord, RunSelectionRecord, ) +from codeclone.analytics.corpus.adapters import intent_historical from codeclone.analytics.corpus.keys import membership_digest from codeclone.analytics.exceptions import AnalyticsWorkflowError from codeclone.analytics.export import json_export @@ -905,6 +906,19 @@ def test_html_projection_helper_empty_and_malformed_rows() -> None: assert html_report._ratio(None) == "unavailable" +def test_analytics_mapping_helpers_reject_non_string_keys() -> None: + invalid = {1: "lost", "kept": "value"} + + with pytest.raises(AnalyticsWorkflowError, match="non-string key"): + html_report._mapping(invalid) + with pytest.raises(AnalyticsWorkflowError, match="non-string key"): + interpret_report._mapping(invalid) + with pytest.raises(AnalyticsWorkflowError, match="non-string key"): + json_export._str_key_mapping(invalid) + with pytest.raises(AnalyticsWorkflowError, match="non-string key"): + intent_historical._str_key_mapping(invalid) + + def test_enrich_run_for_export_rejects_foreign_snapshot() -> None: store = _ReportStore() with pytest.raises(AnalyticsWorkflowError, match="does not belong"): @@ -919,8 +933,9 @@ def test_profile_summary_and_comparison_batch_errors() -> None: class _MissingManifestStore(_ProfileReportStore): def get_profile_manifest_snapshot( self, - _profile_manifest_digest: str, + profile_manifest_digest: str, ) -> ProfileManifestSnapshotRecord | None: + assert profile_manifest_digest return None missing_manifest = _MissingManifestStore() diff --git a/tests/test_audit_analysis_completed.py b/tests/test_audit_analysis_completed.py index 948cda17..395243bf 100644 --- a/tests/test_audit_analysis_completed.py +++ b/tests/test_audit_analysis_completed.py @@ -22,13 +22,14 @@ from codeclone.audit.reader import read_latest_analysis_run from codeclone.audit.schema import open_audit_db from codeclone.audit.writer import SqliteAuditWriter +from codeclone.contracts import REPORT_SCHEMA_VERSION def _default_analysis_summary() -> dict[str, object]: return { "focus": "repository", "mode": "full", - "schema": "2.11", + "schema": REPORT_SCHEMA_VERSION, "health": {"score": 91, "grade": "A"}, "findings": {"total": 12, "new": 1}, "inventory": {"files": 44, "lines": 1000, "functions": 200}, @@ -131,7 +132,7 @@ def test_emit_accepts_mcp_internal_summary_shape(tmp_path: Path) -> None: tmp_path, summary={ "analysis_mode": "full", - "report_schema_version": "2.11", + "report_schema_version": REPORT_SCHEMA_VERSION, "health": {"score": 88, "grade": "A"}, "findings_summary": {"total": 3, "new": 0}, "inventory": {"files": 10, "lines": 100, "functions": 5}, @@ -156,7 +157,7 @@ def test_emit_accepts_mcp_internal_summary_shape(tmp_path: Path) -> None: def test_analysis_completed_payload_from_report_document() -> None: payload = analysis_completed_payload_from_report( report_document={ - "report_schema_version": "2.11", + "report_schema_version": REPORT_SCHEMA_VERSION, "meta": { "runtime": {"analysis_mode": "full"}, "health_score": 82, @@ -190,7 +191,7 @@ def test_emit_analysis_completed_from_report_writes_row(tmp_path: Path) -> None: encoding="utf-8", ) report = { - "report_schema_version": "2.11", + "report_schema_version": REPORT_SCHEMA_VERSION, "meta": {"runtime": {"analysis_mode": "full"}, "health_score": 90}, "inventory": {"file_registry": {"items": ["x.py"]}}, "findings": {"total": 1}, @@ -233,7 +234,7 @@ def test_analysis_completed_payload_resolves_internal_summary_keys() -> None: def test_analysis_completed_payload_ignores_string_file_registry_items() -> None: payload = analysis_completed_payload_from_report( report_document={ - "report_schema_version": "2.11", + "report_schema_version": REPORT_SCHEMA_VERSION, "meta": {"runtime": {"analysis_mode": "full"}}, "inventory": {"file_registry": {"items": "not-a-list"}}, "findings": {"total": 0}, @@ -277,7 +278,7 @@ def test_emit_analysis_completed_from_report_custom_agent_fields( encoding="utf-8", ) report = { - "report_schema_version": "2.11", + "report_schema_version": REPORT_SCHEMA_VERSION, "meta": {"runtime": {"analysis_mode": "full"}, "health_score": 90}, "inventory": {"file_registry": {"items": ["x.py"]}}, "findings": {"total": 1}, diff --git a/tests/test_audit_events_coverage.py b/tests/test_audit_events_coverage.py index c6ae65aa..ae423870 100644 --- a/tests/test_audit_events_coverage.py +++ b/tests/test_audit_events_coverage.py @@ -7,10 +7,12 @@ from __future__ import annotations import json +from typing import cast from codeclone.audit.events import ( EVENT_ANALYSIS_COMPLETED, EVENT_BLAST_ARTIFACT_CREATED, + EVENT_BLAST_RADIUS, EVENT_INTENT_CLEARED, EVENT_INTENT_QUEUE_BLOCKED, EVENT_PATCH_TRAIL_COMPUTED, @@ -20,6 +22,7 @@ compact_payload_for_event, event_core_for_event, event_summary, + normalize_audit_surface, projection_supplement_facts_from_payload, ) @@ -225,3 +228,43 @@ def test_analysis_completed_summary_and_projection_supplement() -> None: json.dumps(["not", "mapping"]), ) assert supplement == {} + + +def test_normalize_audit_surface_and_scope_truncation_branches() -> None: + assert normalize_audit_surface(None, payload={"source": "cli"}) == "cli" + assert normalize_audit_surface(None, payload={"source": "mcp"}) == "mcp" + assert normalize_audit_surface("CLI") == "cli" + + blast_radius = compact_payload_for_event( + event_type=EVENT_BLAST_RADIUS, + payload={ + "radius_level": "low", + "direct_dependents": ["pkg/a.py"], + "transitive_dependents": [], + "clone_cohort_members": [], + "do_not_touch": [], + "review_context": [], + }, + ) + assert blast_radius["radius_level"] == "low" + assert blast_radius["direct_dependents"] == 1 + + declared = [f"pkg/file_{index}.py" for index in range(55)] + check_core = event_core_for_event( + _event( + "intent.checked", + status="clean", + declared_scope=declared, + actual_changed_files=["pkg/file_0.py"], + ) + ) + check_facts = _facts(check_core) + assert check_facts.get("paths_truncated") is True + declared_scope_paths = cast( + list[object], check_facts.get("declared_scope_paths", []) + ) + untouched_in_declared = cast( + list[object], check_facts.get("untouched_in_declared", []) + ) + assert len(declared_scope_paths) == 50 + assert len(untouched_in_declared) == 49 diff --git a/tests/test_benchmark.py b/tests/test_benchmark.py index 52c46be0..f1e176fd 100644 --- a/tests/test_benchmark.py +++ b/tests/test_benchmark.py @@ -17,6 +17,8 @@ RunMeasurement, Scenario, _comparison_metrics, + _load_benchmark_payload, + _require_json_object, _run_cli_once, _scenario_profile, _timing_regressions, @@ -299,6 +301,27 @@ def test_benchmark_inventory_validation_rejects_invalid_samples( ) +def test_load_benchmark_payload_accepts_json_object(tmp_path: Path) -> None: + path = tmp_path / "bench.json" + path.write_text('{"scenarios": []}', encoding="utf-8") + + assert _load_benchmark_payload(path) == {"scenarios": []} + + +def test_load_benchmark_payload_rejects_non_object(tmp_path: Path) -> None: + path = tmp_path / "bench.json" + path.write_text("[1, 2]", encoding="utf-8") + + with pytest.raises(RuntimeError, match="not an object"): + _load_benchmark_payload(path) + + +def test_require_json_object_preserves_mapping_identity() -> None: + payload: dict[str, object] = {"median": 1.5} + + assert _require_json_object(payload, message="expected object") is payload + + def test_benchmark_timing_regressions_accept_within_tolerance() -> None: baseline = _benchmark_payload( cold_full=1.0, diff --git a/tests/test_cache.py b/tests/test_cache.py index ad7dae1f..eae9d1ae 100644 --- a/tests/test_cache.py +++ b/tests/test_cache.py @@ -6,6 +6,7 @@ from __future__ import annotations +import hashlib import json import os from collections.abc import Callable @@ -81,13 +82,14 @@ _unit_dict_from_model, ) from codeclone.cache.integrity import as_str_dict as _as_str_dict -from codeclone.cache.integrity import sign_cache_payload +from codeclone.cache.integrity import canonical_json, sign_cache_payload from codeclone.cache.projection import ( runtime_filepath_from_wire, wire_filepath_from_runtime, ) from codeclone.cache.store import Cache, file_stat_signature from codeclone.cache.versioning import CacheStatus, _as_analysis_profile, _resolve_root +from codeclone.config.observability import ObservabilityConfig from codeclone.contracts.errors import CacheError from codeclone.core._types import _unit_to_group_item from codeclone.core.discovery import _decode_cached_function_relationship_facts @@ -104,6 +106,11 @@ SegmentUnit, Unit, ) +from codeclone.observability import bootstrap, operation, shutdown +from codeclone.observability.store.schema import ( + observability_store_path, + open_observability_store, +) from codeclone.utils.repo_paths import PathOutsideRepoError, RepoPathError @@ -197,6 +204,91 @@ def test_cache_roundtrip(tmp_path: Path) -> None: assert loaded.cache_schema_version == Cache._CACHE_VERSION +def test_cache_load_emits_observability_subspans(tmp_path: Path) -> None: + cache_path = tmp_path / "cache.json" + cache = Cache(cache_path, root=tmp_path) + cache.put_file_entry("x.py", {"mtime_ns": 1, "size": 10}, [], [], []) + cache.save() + + bootstrap(ObservabilityConfig(enabled=True), root=tmp_path) + try: + with operation(name="test.cache_load", surface="test"): + loaded = Cache(cache_path, root=tmp_path) + loaded.load() + finally: + shutdown() + + conn = open_observability_store(observability_store_path(tmp_path)) + try: + rows = conn.execute( + "SELECT name, counters_json FROM platform_spans ORDER BY rowid" + ).fetchall() + finally: + conn.close() + + counters_by_name = {name: json.loads(counters or "{}") for name, counters in rows} + assert "cache.stat" in counters_by_name + assert "cache.read_json" in counters_by_name + assert "cache.validate_envelope" in counters_by_name + assert "cache.decode_entries" in counters_by_name + assert "cache.segment_projection" in counters_by_name + assert counters_by_name["cache.stat"]["cache_file_bytes"] > 0 + assert counters_by_name["cache.read_json"]["cache_file_bytes"] > 0 + assert counters_by_name["cache.decode_entries"] == { + "cache_entries": 1, + "decoded_entries": 1, + } + + +def test_cache_release_loaded_entries_clears_clean_loaded_entries( + tmp_path: Path, +) -> None: + cache_path = tmp_path / "cache.json" + cache = Cache(cache_path) + cache.put_file_entry("x.py", {"mtime_ns": 1, "size": 10}, [], [], []) + cache.save() + + loaded = Cache(cache_path) + loaded.load() + assert loaded.get_file_entry("x.py") is not None + + assert loaded.release_loaded_entries() == 1 + assert loaded.get_file_entry("x.py") is None + assert loaded.load_status == CacheStatus.OK + assert loaded.cache_schema_version == Cache._CACHE_VERSION + + +def test_cache_release_loaded_entries_refuses_dirty_cache_by_default( + tmp_path: Path, +) -> None: + cache = Cache(tmp_path / "cache.json") + cache.put_file_entry("x.py", {"mtime_ns": 1, "size": 10}, [], [], []) + + assert cache.release_loaded_entries() == 0 + assert cache.get_file_entry("x.py") is not None + + +def test_cache_read_only_mode_suppresses_entry_writes(tmp_path: Path) -> None: + cache_path = tmp_path / "cache.json" + cache = Cache(cache_path) + cache.put_file_entry("x.py", {"mtime_ns": 1, "size": 10}, [], [], []) + cache.save() + + loaded = Cache(cache_path, write_enabled=False) + loaded.load() + + loaded.put_file_entry("y.py", {"mtime_ns": 2, "size": 20}, [], [], []) + assert loaded.prune_file_entries([]) == 0 + loaded.save() + + assert loaded.get_file_entry("x.py") is not None + assert loaded.get_file_entry("y.py") is None + reloaded = Cache(cache_path) + reloaded.load() + assert reloaded.get_file_entry("x.py") is not None + assert reloaded.get_file_entry("y.py") is None + + def test_cache_roundtrip_preserves_function_relationship_facts( tmp_path: Path, ) -> None: @@ -1361,6 +1453,42 @@ def test_cache_signature_validation_ignores_json_whitespace(tmp_path: Path) -> N assert loaded.get_file_entry("x.py") is not None +def test_cache_signature_matches_legacy_string_digest_for_unicode_payload() -> None: + cache = Cache(Path("cache.json")) + payload = _analysis_payload( + cache, + files={ + "unicodé.py": { + "st": [1, 10], + "rn": ["Ω", "é"], + } + }, + ) + legacy_digest = hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + + assert sign_cache_payload(payload) == legacy_digest + + +def test_cache_load_accepts_legacy_string_signed_unicode_payload( + tmp_path: Path, +) -> None: + cache_path = tmp_path / "cache.json" + cache = Cache(cache_path) + cache.put_file_entry("unicodé.py", {"mtime_ns": 1, "size": 10}, [], [], []) + cache.save() + + raw = json.loads(cache_path.read_text("utf-8")) + payload = cast(dict[str, object], raw["payload"]) + raw["sig"] = hashlib.sha256(canonical_json(payload).encode("utf-8")).hexdigest() + cache_path.write_text(json.dumps(raw, ensure_ascii=False, indent=2), "utf-8") + + loaded = Cache(cache_path) + loaded.load() + + assert loaded.load_warning is None + assert loaded.get_file_entry("unicodé.py") is not None + + def test_decode_wire_file_and_name_section_helpers_cover_valid_and_invalid() -> None: encoded = _encode_wire_file_entry( { @@ -2883,3 +3011,108 @@ def test_integrity_read_json_document_forwards_max_bytes(tmp_path: Path) -> None path = tmp_path / "doc.json" path.write_text('{"ok": true}', encoding="utf-8") assert read_json_document(path, max_bytes=64) == {"ok": True} + + +def test_canonicalize_helpers_reject_invalid_structural_shapes() -> None: + from codeclone.cache._canonicalize import ( + _as_str_value_dict, + _as_structural_group_dict, + _as_structural_occurrence_dict, + _as_typed_structural_finding_list, + _as_typed_structural_occurrence_list, + _decode_structural_findings_section, + ) + + assert _as_str_value_dict({"key": 1}) is None + assert ( + _as_structural_occurrence_dict({"qualname": "pkg.fn", "start": "x", "end": 1}) + is None + ) + assert ( + _as_typed_structural_occurrence_list( + [{"qualname": "pkg.fn", "start": 1, "end": "x"}] + ) + is None + ) + assert _as_typed_structural_occurrence_list("not-a-list") is None + assert ( + _as_structural_group_dict( + { + "finding_kind": "dup", + "finding_key": "k1", + "signature": {"kind": 1}, + "items": [{"qualname": "pkg.fn", "start": 1, "end": 2}], + } + ) + is None + ) + assert _as_typed_structural_finding_list([{"finding_kind": "dup"}]) is None + ok, findings = _decode_structural_findings_section(None) + assert ok is True and findings is None + bad, findings = _decode_structural_findings_section([{"finding_kind": "dup"}]) + assert bad is False and findings is None + + assert _as_structural_occurrence_dict(None) is None + assert ( + _as_structural_group_dict( + { + "finding_kind": "dup", + "finding_key": "k1", + "signature": {"kind": "sig"}, + "items": [None], + } + ) + is None + ) + assert _as_typed_structural_finding_list([None]) is None + + +def test_attach_optional_cache_sections_sets_relationship_facts() -> None: + base = cast( + CacheEntry, + { + "stat": {"mtime_ns": 1, "size": 1}, + "units": [], + "blocks": [], + "segments": [], + "class_metrics": [], + "module_deps": [], + "dead_candidates": [], + "referenced_names": [], + "referenced_qualnames": [], + "import_names": [], + "class_names": [], + }, + ) + attached = _attach_optional_cache_sections( + base, + function_relationship_facts=[ + { + "source_qualname": "pkg.mod:src", + "relationships": [ + { + "relation_kind": "calls", + "resolution_status": "resolved", + "origin_lane": "analysis", + "source_qualname": "pkg.mod:src", + "target_qualname": "pkg.mod:tgt", + "path": "pkg/mod.py", + "line": 1, + "expression": "tgt()", + "resolution_rule": "direct", + } + ], + } + ], + structural_findings=[ + { + "finding_kind": "dup", + "finding_key": "k1", + "signature": {"kind": "sig"}, + "items": [{"qualname": "pkg.mod:fn", "start": 1, "end": 2}], + } + ], + ) + relationships = attached["function_relationship_facts"][0]["relationships"] + assert relationships[0]["relation_kind"] == "calls" + assert attached["structural_findings"][0]["finding_kind"] == "dup" diff --git a/tests/test_claude_code_plugin.py b/tests/test_claude_code_plugin.py index 9be8a669..a8889645 100644 --- a/tests/test_claude_code_plugin.py +++ b/tests/test_claude_code_plugin.py @@ -8,12 +8,15 @@ import json from pathlib import Path +import pytest + from tests.plugin_test_helpers import ( CODEX_CLAUDE_SYNC_SKILL_NAMES, assert_claude_code_plugin_readme_contract, assert_plugin_skills_match_codex, assert_repo_doc_paths_exist, load_json, + repo_docs_source_available, ) @@ -66,8 +69,10 @@ def test_claude_code_marketplace_overlay_and_install_docs() -> None: assert marketplace["metadata"]["description"] assert marketplace["plugins"][0]["source"] == "./plugins/codeclone" assert_claude_code_plugin_readme_contract(readme) + if not repo_docs_source_available(root): + pytest.skip("repo docs source tree is not present") assert_repo_doc_paths_exist( root, - "docs/guide/integrations/claude-code/setup.md", - "docs/book/integrations/claude-code-plugin.md", + "docs/integrations/claude.md", + "docs/guides/agent-safe-change.md", ) diff --git a/tests/test_cli_audit.py b/tests/test_cli_audit.py index 161b3e47..074d843c 100644 --- a/tests/test_cli_audit.py +++ b/tests/test_cli_audit.py @@ -1110,6 +1110,16 @@ def test_reader_int_meta_value_error() -> None: # ── workflow.py: analysis.completed CLI emit ── +def test_is_report_document_type_guard_preserves_identity() -> None: + from codeclone.surfaces.cli.workflow import _is_report_document + + document: dict[str, object] = {"integrity": {"digest": {"value": "abc"}}} + value: object = document + assert _is_report_document(value) + assert value is document + assert not _is_report_document("not-a-dict") + + def test_report_digest_from_document_missing_integrity() -> None: from codeclone.surfaces.cli.workflow import _report_digest_from_document diff --git a/tests/test_cli_config.py b/tests/test_cli_config.py index 0d0e7a71..a586299d 100644 --- a/tests/test_cli_config.py +++ b/tests/test_cli_config.py @@ -7,6 +7,7 @@ from __future__ import annotations import argparse +import sys from pathlib import Path from types import SimpleNamespace from typing import Any @@ -369,3 +370,39 @@ def test_pyproject_loader_rejects_symlinks_and_invalid_ingest_table( ingest_obj="not-a-table", config_path=tmp_path / "pyproject.toml", ) + + +def test_pyproject_loader_analytics_and_object_table_validation( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.config.pyproject_loader import ( + ConfigValidationError, + _open_toml_file_no_follow, + _validate_nested_analytics_table, + copy_str_key_table, + ) + + config_path = tmp_path / "pyproject.toml" + config_path.write_text("[tool.codeclone]\n", encoding="utf-8") + + with pytest.raises(ConfigValidationError, match=r"analytics.*must be object"): + _validate_nested_analytics_table( + analytics_obj="bad", + root_path=tmp_path, + config_path=config_path, + ) + + with pytest.raises(ConfigValidationError, match="'memory' must be object"): + copy_str_key_table("bad", key="memory", config_path=config_path) + + with pytest.raises(ConfigValidationError, match="must contain only string keys"): + copy_str_key_table({1: "x"}, key="memory", config_path=config_path) + + monkeypatch.setattr( + loader_mod, + "sys", + SimpleNamespace(platform="win32", version_info=sys.version_info), + ) + with _open_toml_file_no_follow(config_path) as handle: + assert handle.read(1) diff --git a/tests/test_cli_help_snapshot.py b/tests/test_cli_help_snapshot.py index 740b3a39..1d23db6d 100644 --- a/tests/test_cli_help_snapshot.py +++ b/tests/test_cli_help_snapshot.py @@ -17,6 +17,12 @@ def test_cli_help_snapshot() -> None: root_dir = Path(__file__).resolve().parents[1] env = os.environ.copy() env["PYTHONPATH"] = str(root_dir) + os.pathsep + env.get("PYTHONPATH", "") + # The help banner mascot renders Unicode when stdout can encode it and ASCII + # otherwise (see ui.mascot.mascot_use_unicode), so without pinning the + # environment this snapshot would depend on the runner's stdout encoding. + # NO_COLOR forces the deterministic ASCII frame that the committed golden + # captures, keeping the contract stable across encodings and CI runners. + env["NO_COLOR"] = "1" result = subprocess.run( [sys.executable, "-m", "codeclone.main", "--help"], capture_output=True, diff --git a/tests/test_cli_inprocess.py b/tests/test_cli_inprocess.py index 4dc26b7b..0bce222d 100644 --- a/tests/test_cli_inprocess.py +++ b/tests/test_cli_inprocess.py @@ -41,6 +41,7 @@ from codeclone.report.gates.reasons import parse_metric_reason_entry from tests._assertions import ( assert_contains_all, + assert_contains_none, assert_mapping_entries, assert_missing_keys, ) @@ -211,9 +212,12 @@ def _assert_parallel_cli_exit( def _assert_after_summary(output: str, marker: str, *expected_parts: str) -> None: + from tests._assertions import strip_ansi + + normalized = strip_ansi(output) for expected_part in expected_parts: - assert expected_part in output - assert output.index("Summary") < output.index(marker) + assert expected_part in normalized + assert normalized.index("Summary") < normalized.index(marker) def _write_python_module( @@ -455,11 +459,13 @@ def _assert_baseline_failure_meta( if expected_message not in combined_output: assert "Invalid baseline" in combined_output or "not trusted" in combined_output if strict_fail: - assert "CI requires a trusted baseline" in out - assert "Run: codeclone . --update-baseline" in out + assert_contains_all(out, "CI requires a trusted baseline") + assert_contains_all(out, "Run: codeclone . --update-baseline") else: - assert "Baseline is not trusted for this run and will be ignored" in out - assert "Run: codeclone . --update-baseline" in out + assert_contains_all( + out, "Baseline is not trusted for this run and will be ignored" + ) + assert_contains_all(out, "Run: codeclone . --update-baseline") payload_out = json.loads(json_out.read_text("utf-8")) baseline_meta = _report_meta_baseline(payload_out) assert baseline_meta["status"] == expected_status @@ -467,11 +473,11 @@ def _assert_baseline_failure_meta( def _assert_fail_on_new_summary(out: str, *, include_blocks: bool = True) -> None: - assert "GATING FAILURE [new-clones]" in out - assert "new_function_clone_groups" in out + assert_contains_all(out, "GATING FAILURE [new-clones]") + assert_contains_all(out, "new_function_clone_groups") if include_blocks: - assert "new_block_clone_groups" in out - assert "codeclone . --update-baseline" in out + assert_contains_all(out, "new_block_clone_groups") + assert_contains_all(out, "codeclone . --update-baseline") def _patch_baseline_diff( @@ -578,7 +584,7 @@ def __exit__( args.append("--no-progress") _assert_cli_exit(monkeypatch, args, expected_code=5) out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out + assert_contains_all(out, "INTERNAL ERROR:") _SUMMARY_METRIC_MAP: dict[str, str] = { @@ -598,16 +604,22 @@ def __exit__( def _summary_metric(out: str, label: str) -> int: + from tests._assertions import strip_ansi + + normalized = strip_ansi(out) keyword = _SUMMARY_METRIC_MAP.get(label, label) - match = re.search(rf"(\d[\d,]*)\s+{re.escape(keyword)}", out) + match = re.search(rf"(\d[\d,]*)\s+{re.escape(keyword)}", normalized) if match: return int(match.group(1).replace(",", "")) - raise AssertionError(f"summary label not found: {label}\n{out}") + raise AssertionError(f"summary label not found: {label}\n{normalized}") def _compact_summary_metric(out: str, key: str) -> int: - match = re.search(rf"{re.escape(key)}=(\d+)", out) - assert match, f"compact summary key not found: {key}\n{out}" + from tests._assertions import strip_ansi + + normalized = strip_ansi(out) + match = re.search(rf"{re.escape(key)}=(\d+)", normalized) + assert match, f"compact summary key not found: {key}\n{normalized}" return int(match.group(1)) @@ -713,8 +725,8 @@ def _source_read_error_result(filepath: str) -> CliFileProcessResult: def _assert_unreadable_source_contract_error(out: str) -> None: - assert "CONTRACT ERROR:" in out - assert "could not be read in CI/gating mode" in out + assert_contains_all(out, "CONTRACT ERROR:") + assert_contains_all(out, "could not be read in CI/gating mode") def test_cli_main_no_progress_parallel( @@ -748,8 +760,8 @@ def f2(): ], ) out = capsys.readouterr().out - assert "Summary" in out - assert "func" in out + assert_contains_all(out, "Summary") + assert_contains_all(out, "func") def test_cli_default_cache_dir_uses_root( @@ -845,7 +857,7 @@ def test_cli_cache_not_shared_between_projects( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(root2), "--no-progress"]) out = capsys.readouterr().out - assert "Cache signature mismatch" not in out + assert_contains_none(out, "Cache signature mismatch") def test_cli_warns_on_legacy_cache( @@ -864,9 +876,9 @@ def test_cli_warns_on_legacy_cache( [str(root), "--baseline", str(baseline), "--no-progress"], ) out = capsys.readouterr().out - assert "Legacy cache file found at" in out - assert "Cache is now stored per-project" in out - assert "`.codeclone/` to .gitignore" in out + assert_contains_all(out, "Legacy cache file found at") + assert_contains_all(out, "Cache is now stored per-project") + assert_contains_all(out, "`.codeclone/` to .gitignore") def test_cli_warns_on_legacy_repo_workspace( @@ -933,7 +945,7 @@ def __str__(self) -> str: [str(root), "--baseline", str(baseline), "--no-progress"], ) out = capsys.readouterr().out - assert "Legacy cache file found at" in out + assert_contains_all(out, "Legacy cache file found at") def test_cli_no_legacy_warning_with_cache_override( @@ -954,7 +966,7 @@ def test_cli_no_legacy_warning_with_cache_override( ], ) out = capsys.readouterr().out - assert "Legacy cache file found at" not in out + assert_contains_none(out, "Legacy cache file found at") def test_cli_no_legacy_warning_when_legacy_missing( @@ -968,7 +980,7 @@ def test_cli_no_legacy_warning_when_legacy_missing( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(root), "--no-progress"]) out = capsys.readouterr().out - assert "Legacy cache file found at" not in out + assert_contains_none(out, "Legacy cache file found at") def test_cli_no_legacy_warning_when_paths_match( @@ -1026,7 +1038,7 @@ def prune_file_entries(self, existing_filepaths: object) -> int: _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(root), "--no-progress"]) out = capsys.readouterr().out - assert "Legacy cache file found at" not in out + assert_contains_none(out, "Legacy cache file found at") @pytest.mark.parametrize( @@ -1101,7 +1113,7 @@ def test_cli_main_progress_fallback( monkeypatch.setattr(core_parallelism, "ProcessPoolExecutor", _FailingExecutor) _run_main(monkeypatch, [str(tmp_path), "--processes", "2"]) out = capsys.readouterr().out - assert "falling back to sequential" in out + assert_contains_all(out, "falling back to sequential") def test_cli_main_no_progress_fallback( @@ -1115,7 +1127,7 @@ def test_cli_main_no_progress_fallback( monkeypatch.setattr(core_parallelism, "ProcessPoolExecutor", _FailingExecutor) _run_main(monkeypatch, [str(tmp_path), "--processes", "2", "--no-progress"]) out = capsys.readouterr().out - assert "falling back to sequential" in out + assert_contains_all(out, "falling back to sequential") def test_cli_main_no_progress_fallback_quiet( @@ -1140,7 +1152,7 @@ def test_cli_main_no_progress_fallback_quiet( ], ) out = capsys.readouterr().out - assert "Processing" not in out + assert_contains_none(out, "Processing") def test_cli_main_progress_path( @@ -1174,7 +1186,7 @@ def _boom(self: Path) -> Path: _run_main(monkeypatch, ["bad"]) assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out + assert_contains_all(out, "INTERNAL ERROR:") def test_cli_unexpected_grouping_failure_is_internal( @@ -1192,7 +1204,7 @@ def _boom(*_args: object, **_kwargs: object) -> object: _run_main(monkeypatch, [str(tmp_path), "--no-progress"]) assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out + assert_contains_all(out, "INTERNAL ERROR:") def test_cli_unexpected_html_render_failure_is_internal( @@ -1214,7 +1226,7 @@ def _boom(*_args: object, **_kwargs: object) -> str: ) assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out + assert_contains_all(out, "INTERNAL ERROR:") def test_cli_main_outputs( @@ -1254,9 +1266,12 @@ def test_cli_main_outputs( for artifact in (html_out, json_out, md_out, sarif_out, text_out): assert artifact.exists() out = capsys.readouterr().out + from tests._assertions import strip_ansi + + normalized = strip_ansi(out) for label in ("HTML", "JSON", "Markdown", "SARIF", "Text"): - assert label in out - assert out.index("Summary") < out.index("report saved:") + assert_contains_all(out, label) + assert normalized.index("Summary") < normalized.index("report saved:") def test_cli_open_html_report_opens_written_html( @@ -1290,7 +1305,7 @@ def _boom(*, path: Path) -> None: _run_parallel_main(monkeypatch, _open_html_report_args(tmp_path, html_out)) assert html_out.exists() out = capsys.readouterr().out - assert "Failed to open HTML report in browser" in out + assert_contains_all(out, "Failed to open HTML report in browser") assert re.search(r"cannot\s+open out\.html", out) is not None @@ -1383,7 +1398,7 @@ def test_cli_report_flag_contract_errors( ) assert exc.value.code == 2 out = capsys.readouterr().out - assert expected_message in out + assert_contains_all(out, expected_message) def test_cli_reports_include_audit_metadata_ok( @@ -1488,7 +1503,7 @@ def test_cli_reports_include_audit_metadata_fingerprint_mismatch( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "fingerprint version mismatch" in out + assert_contains_all(out, "fingerprint version mismatch") _assert_report_baseline_meta( payload, status="mismatch_fingerprint_version", @@ -1514,7 +1529,7 @@ def test_cli_reports_include_audit_metadata_schema_mismatch( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "schema version is newer than supported" in out + assert_contains_all(out, "schema version is newer than supported") _assert_report_baseline_meta( payload, status="mismatch_schema_version", @@ -1540,7 +1555,7 @@ def test_cli_reports_include_audit_metadata_python_mismatch( expect_exit_code=2, ) out = capsys.readouterr().out - assert "python tag mismatch" in out + assert_contains_all(out, "python tag mismatch") _assert_report_baseline_meta( payload, status="mismatch_python_version", @@ -1563,8 +1578,8 @@ def test_cli_reports_include_audit_metadata_invalid_baseline( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "Invalid baseline file" in out - assert "Baseline is not trusted for this run and will be ignored" in out + assert_contains_all(out, "Invalid baseline file") + assert_contains_all(out, "Baseline is not trusted for this run and will be ignored") _assert_report_baseline_meta(payload, status="invalid_json", loaded=False) @@ -1592,7 +1607,7 @@ def test_cli_reports_include_audit_metadata_legacy_baseline( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "legacy" in out + assert_contains_all(out, "legacy") _assert_report_baseline_meta(payload, status="missing_fields", loaded=False) @@ -1695,8 +1710,8 @@ def test_cli_shared_baseline_mismatch_is_reported_once_without_ci_label( out = capsys.readouterr().out assert out.count("Invalid baseline file") == 1 - assert "CI requires a trusted baseline" not in out - assert "Baseline-aware gates require a trusted baseline" in out + assert_contains_none(out, "CI requires a trusted baseline") + assert_contains_all(out, "Baseline-aware gates require a trusted baseline") def test_cli_reports_include_audit_metadata_integrity_failed( @@ -1721,8 +1736,8 @@ def test_cli_reports_include_audit_metadata_integrity_failed( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "integrity check failed" in out - assert "Baseline is not trusted for this run and will be ignored" in out + assert_contains_all(out, "integrity check failed") + assert_contains_all(out, "Baseline is not trusted for this run and will be ignored") _assert_report_baseline_meta(payload, status="integrity_failed", loaded=False) @@ -1743,8 +1758,8 @@ def test_cli_reports_include_audit_metadata_generator_mismatch( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "generator mismatch" in out - assert "Baseline is not trusted for this run and will be ignored" in out + assert_contains_all(out, "generator mismatch") + assert_contains_all(out, "Baseline is not trusted for this run and will be ignored") _assert_report_baseline_meta(payload, status="generator_mismatch", loaded=False) @@ -1804,8 +1819,14 @@ def test_cli_reports_include_audit_metadata_integrity_missing( extra_args=["--baseline", str(baseline_path)], ) out = capsys.readouterr().out - assert "missing required fields" in out or "Invalid baseline schema" in out - assert "Baseline is not trusted for this run and will be ignored" in out + from tests._assertions import strip_ansi + + normalized = strip_ansi(out) + assert ( + "missing required fields" in normalized + or "Invalid baseline schema" in normalized + ) + assert_contains_all(out, "Baseline is not trusted for this run and will be ignored") _assert_report_baseline_meta(payload_out, status="missing_fields", loaded=False) @@ -1827,8 +1848,8 @@ def test_cli_reports_include_audit_metadata_baseline_too_large( ], ) out = capsys.readouterr().out - assert "too large" in out - assert "Baseline is not trusted for this run and will be ignored" in out + assert_contains_all(out, "too large") + assert_contains_all(out, "Baseline is not trusted for this run and will be ignored") _assert_report_baseline_meta(payload, status="too_large", loaded=False) @@ -1888,7 +1909,7 @@ def f2(): ], ) out = capsys.readouterr().out - assert "Baseline is not trusted for this run and will be ignored" in out + assert_contains_all(out, "Baseline is not trusted for this run and will be ignored") assert _summary_metric(out, "New vs baseline") > 0 report = json.loads(json_out.read_text("utf-8")) assert _report_meta_baseline(report)["status"] == "generator_mismatch" @@ -1956,7 +1977,7 @@ def test_cli_invalid_baseline_fails_in_ci( expect_exit_code=2, ) out = capsys.readouterr().out - assert "Invalid baseline file" in out + assert_contains_all(out, "Invalid baseline file") _assert_report_baseline_meta(payload, status="invalid_json", loaded=False) @@ -1980,7 +2001,7 @@ def test_cli_too_large_baseline_fails_in_ci( expect_exit_code=2, ) out = capsys.readouterr().out - assert "too large" in out + assert_contains_all(out, "too large") _assert_report_baseline_meta(payload, status="too_large", loaded=False) @@ -2029,7 +2050,7 @@ def test_cli_reports_cache_used_false_on_warning( ], ) out = capsys.readouterr().out - assert expected_message in out + assert_contains_all(out, expected_message) _assert_report_cache_meta( payload, used=False, @@ -2061,7 +2082,7 @@ def test_cli_reports_cache_too_large_respects_max_size_flag( ], ) out = capsys.readouterr().out - assert "Cache file too large" in out + assert_contains_all(out, "Cache file too large") _assert_report_cache_meta( payload, used=False, @@ -2193,7 +2214,7 @@ def test_cli_cache_analysis_profile_compatibility( out = capsys.readouterr().out payload = json.loads(json_second.read_text("utf-8")) if expected_warning is not None: - assert expected_warning in out + assert_contains_all(out, expected_warning) _assert_report_cache_meta( payload, used=expected_cache_used, @@ -2239,8 +2260,7 @@ def test_cli_output_extension_validation( ) assert exc.value.code == 2 out = capsys.readouterr().out - assert f"Invalid {label} output extension" in out - assert expected in out + assert_contains_all(out, f"Invalid {label} output extension", expected) def test_cli_output_path_resolve_error_contract( @@ -2266,8 +2286,8 @@ def _raise_resolve( expected_code=2, ) out = capsys.readouterr().out - assert "CONTRACT ERROR:" in out - assert "Invalid HTML output path" in out + assert_contains_all(out, "CONTRACT ERROR:") + assert_contains_all(out, "Invalid HTML output path") def test_cli_report_write_error_is_contract_error( @@ -2299,8 +2319,8 @@ def _raise_write_text( expected_code=2, ) out = capsys.readouterr().out - assert "CONTRACT ERROR:" in out - assert "Failed to write HTML report" in out + assert_contains_all(out, "CONTRACT ERROR:") + assert_contains_all(out, "Failed to write HTML report") def test_cli_outputs_quiet_no_print( @@ -2335,7 +2355,7 @@ def test_cli_outputs_quiet_no_print( assert json_out.exists() assert text_out.exists() out = capsys.readouterr().out - assert "report saved" not in out + assert_contains_none(out, "report saved") def test_cli_shows_vscode_extension_tip_once_per_version( @@ -2496,7 +2516,7 @@ def test_cli_update_baseline_skips_version_check( ], ) out = capsys.readouterr().out - assert "Baseline updated" in out + assert_contains_all(out, "Baseline updated") def test_cli_update_baseline( @@ -2531,7 +2551,7 @@ def f2(): ], ) out = capsys.readouterr().out - assert "Baseline updated" in out + assert_contains_all(out, "Baseline updated") assert baseline.exists() @@ -2651,8 +2671,8 @@ def _raise_save(self: baseline.Baseline) -> None: expected_code=2, ) out = capsys.readouterr().out - assert "CONTRACT ERROR:" in out - assert "Failed to write baseline file" in out + assert_contains_all(out, "CONTRACT ERROR:") + assert_contains_all(out, "Failed to write baseline file") def test_cli_update_baseline_with_invalid_existing_file( @@ -2676,8 +2696,8 @@ def test_cli_update_baseline_with_invalid_existing_file( ], ) out = capsys.readouterr().out - assert "Baseline updated" in out - assert "Invalid baseline file" not in out + assert_contains_all(out, "Baseline updated") + assert_contains_none(out, "Invalid baseline file") payload = json.loads(baseline_path.read_text("utf-8")) meta = payload["meta"] assert isinstance(meta, dict) @@ -2705,8 +2725,8 @@ def test_cli_baseline_missing_warning( ], ) out = capsys.readouterr().out - assert "Baseline file not found" in out - assert "Run: codeclone . --update-baseline" in out + assert_contains_all(out, "Baseline file not found") + assert_contains_all(out, "Run: codeclone . --update-baseline") def test_cli_baseline_missing_fails_in_ci( @@ -2729,8 +2749,8 @@ def test_cli_baseline_missing_fails_in_ci( expected_code=2, ) out = capsys.readouterr().out - assert "Baseline file not found" in out - assert "CI requires a trusted baseline" in out + assert_contains_all(out, "Baseline file not found") + assert_contains_all(out, "CI requires a trusted baseline") def test_cli_new_clones_warning( @@ -2766,7 +2786,7 @@ def f2(): ], ) out = capsys.readouterr().out - assert "New clones detected but --fail-on-new not set" in out + assert_contains_all(out, "New clones detected but --fail-on-new not set") def test_cli_baseline_python_version_mismatch_warns( @@ -2787,8 +2807,8 @@ def test_cli_baseline_python_version_mismatch_warns( ], ) out = capsys.readouterr().out - assert "python tag mismatch" in out - assert "will be ignored" in out + assert_contains_all(out, "python tag mismatch") + assert_contains_all(out, "will be ignored") def test_cli_baseline_fingerprint_mismatch_fails( @@ -2815,7 +2835,7 @@ def test_cli_baseline_fingerprint_mismatch_fails( expected_code=2, ) out = capsys.readouterr().out - assert "fingerprint version mismatch" in out + assert_contains_all(out, "fingerprint version mismatch") def test_cli_baseline_missing_fields_fails( @@ -2848,7 +2868,7 @@ def test_cli_baseline_missing_fields_fails( expected_code=2, ) out = capsys.readouterr().out - assert "legacy (<=1.3.x)" in out + assert_contains_all(out, "legacy (<=1.3.x)") def test_cli_baseline_schema_version_mismatch_fails( @@ -2870,7 +2890,7 @@ def test_cli_baseline_schema_version_mismatch_fails( expect_exit_code=2, ) out = capsys.readouterr().out - assert "schema version is newer than supported" in out + assert_contains_all(out, "schema version is newer than supported") assert _report_meta_baseline(payload)["status"] == "mismatch_schema_version" @@ -2893,8 +2913,8 @@ def test_cli_baseline_schema_and_fingerprint_mismatch_status_prefers_schema( expect_exit_code=2, ) out = capsys.readouterr().out - assert "schema version is newer than supported" in out - assert "fingerprint version mismatch" not in out + assert_contains_all(out, "schema version is newer than supported") + assert_contains_none(out, "fingerprint version mismatch") assert _report_meta_baseline(payload)["status"] == "mismatch_schema_version" @@ -2916,8 +2936,8 @@ def test_cli_baseline_fingerprint_and_python_mismatch_status_prefers_fingerprint expect_exit_code=2, ) out = capsys.readouterr().out - assert "fingerprint version mismatch" in out - assert "Python version mismatch" not in out + assert_contains_all(out, "fingerprint version mismatch") + assert_contains_none(out, "Python version mismatch") assert _report_meta_baseline(payload)["status"] == "mismatch_fingerprint_version" @@ -2951,7 +2971,7 @@ def test_cli_negative_size_limits_fail_fast( _run_main(monkeypatch, ["--max-baseline-size-mb", "-1"]) assert exc.value.code == 2 out = capsys.readouterr().out - assert "non-negative integers" in out + assert_contains_all(out, "non-negative integers") def test_cli_main_fail_threshold( @@ -3099,9 +3119,9 @@ def f2(): ) assert exc.value.code == 3 out = capsys.readouterr().out - assert "GATING FAILURE [new-clones]" in out + assert_contains_all(out, "GATING FAILURE [new-clones]") _assert_fail_on_new_summary(out, include_blocks=False) - assert "CodeClone v" not in out + assert_contains_none(out, "CodeClone v") def test_cli_blocks_processing(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: @@ -3139,7 +3159,7 @@ def test_cli_cache_warning( ], ) out = capsys.readouterr().out - assert "Cache signature mismatch" in out + assert_contains_all(out, "Cache signature mismatch") def test_cli_cache_save_warning( @@ -3163,7 +3183,7 @@ def _raise_save(self: Cache) -> None: ], ) out = capsys.readouterr().out - assert "Failed to save cache" in out + assert_contains_all(out, "Failed to save cache") def test_cli_cache_save_warning_quiet( @@ -3193,7 +3213,7 @@ def _raise_save(self: Cache) -> None: ], ) out = capsys.readouterr().out - assert "Failed to save cache" in out + assert_contains_all(out, "Failed to save cache") def test_cli_invalid_root(monkeypatch: pytest.MonkeyPatch) -> None: @@ -3226,8 +3246,8 @@ def _raise_resolve( expected_code=2, ) out = capsys.readouterr().out - assert "CONTRACT ERROR:" in out - assert "Invalid baseline path" in out + assert_contains_all(out, "CONTRACT ERROR:") + assert_contains_all(out, "Invalid baseline path") def test_cli_discovery_cache_hit( @@ -3352,7 +3372,7 @@ def _source_read_error( ) captured = capsys.readouterr() combined = captured.out + captured.err - assert "CONTRACT ERROR:" not in combined + assert_contains_none(combined, "CONTRACT ERROR:") assert _summary_metric(captured.out, "Files skipped") == 1 payload = json.loads(json_out.read_text("utf-8")) assert _report_inventory_files(payload)["source_io_skipped"] == 1 @@ -3455,7 +3475,7 @@ def _diff( assert exc.value.code == 2 out = capsys.readouterr().out _assert_unreadable_source_contract_error(out) - assert "GATING FAILURE:" not in out + assert_contains_none(out, "GATING FAILURE:") def test_cli_unreadable_source_ci_shows_overflow_summary( @@ -3493,7 +3513,7 @@ def _source_read_error( assert exc.value.code == 2 out = capsys.readouterr().out _assert_unreadable_source_contract_error(out) - assert "... and 1 more" in out + assert_contains_all(out, "... and 1 more") def test_cli_report_meta_cache_path_resolve_oserror_fallback( @@ -3567,11 +3587,11 @@ def test_cli_ci_discovery_cache_hit( ], ) out = capsys.readouterr().out - assert "CodeClone v" not in out - assert "Summary" in out - assert "Analyzing" not in out - assert "\x1b[" not in out - assert "new=" in out + assert_contains_none(out, "CodeClone v") + assert_contains_all(out, "Summary") + assert_contains_none(out, "Analyzing") + assert_contains_none(out, "\x1b[") + assert_contains_all(out, "new=") assert _compact_summary_metric(out, "found") == 1 assert _compact_summary_metric(out, "analyzed") == 0 assert _compact_summary_metric(out, "cached") == 1 @@ -3609,14 +3629,14 @@ def test_cli_summary_format_stable( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(tmp_path), "--no-progress"]) out = capsys.readouterr().out - assert "Summary" in out + assert_contains_all(out, "Summary") assert out.count("Summary") == 1 - assert "Metrics" not in out - assert "Adoption" not in out - assert "Overloaded" not in out - assert "callables" in out - assert "Files parsed" not in out - assert "Input" not in out + assert_contains_none(out, "Metrics") + assert_contains_none(out, "Adoption") + assert_contains_none(out, "Overloaded") + assert_contains_all(out, "callables") + assert_contains_none(out, "Files parsed") + assert_contains_none(out, "Input") assert _summary_metric(out, "Files found") >= 0 assert _summary_metric(out, "analyzed") >= 0 assert _summary_metric(out, "from cache") >= 0 @@ -3679,9 +3699,9 @@ def test_cli_summary_with_api_surface_shows_public_api_line( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(tmp_path), "--no-progress", "--api-surface"]) out = capsys.readouterr().out - assert "Public API" in out - assert "symbols" in out - assert "modules" in out + assert_contains_all(out, "Public API") + assert_contains_all(out, "symbols") + assert_contains_all(out, "modules") def test_cli_ci_summary_includes_adoption_and_public_api_lines( @@ -3841,8 +3861,8 @@ def test_cli_public_api_breaking_count_stable_across_warm_cache( ) warm_out = capsys.readouterr().out - assert "1 breaking" in cold_out - assert "1 breaking" in warm_out + assert_contains_all(cold_out, "1 breaking") + assert_contains_all(warm_out, "1 breaking") def test_cli_api_surface_ignores_non_api_warm_cache( @@ -3888,7 +3908,7 @@ def test_cli_api_surface_ignores_non_api_warm_cache( assert _summary_metric(out, "analyzed") == 1 assert _summary_metric(out, "from cache") == 0 - assert "Public API" in out + assert_contains_all(out, "Public API") assert cast("dict[str, object]", api_surface_summary)["enabled"] is True assert cast("dict[str, object]", api_surface_summary)["public_symbols"] == 1 assert cast("dict[str, object]", api_surface_summary)["modules"] == 1 @@ -3904,7 +3924,8 @@ def test_cli_summary_no_color_has_no_ansi( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(tmp_path), "--no-progress", "--no-color"]) out = capsys.readouterr().out - assert "\x1b[" not in out + # --no-color suppresses palette colors; Rich may still emit bold/dim SGR codes. + assert not re.search(r"\x1b\[(?:3[0-7]|9[0-7]|38;5|48;5)", out) def test_cli_scan_failed_is_internal_error( @@ -3920,7 +3941,7 @@ def _boom(_root: str) -> Iterable[str]: _run_main(monkeypatch, [str(tmp_path)]) assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out + assert_contains_all(out, "INTERNAL ERROR:") def test_cli_scan_oserror_is_contract_error( @@ -3936,8 +3957,8 @@ def _boom(_root: str) -> Iterable[str]: _run_main(monkeypatch, [str(tmp_path)]) assert exc.value.code == 2 out = capsys.readouterr().out - assert "CONTRACT ERROR:" in out - assert "Scan failed" in out + assert_contains_all(out, "CONTRACT ERROR:") + assert_contains_all(out, "Scan failed") def test_cli_failed_files_report( @@ -3957,8 +3978,8 @@ def _bad_process( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(tmp_path), "--no-progress"]) out = capsys.readouterr().out - assert "files failed to process" in out - assert "and 2 more" in out + assert_contains_all(out, "files failed to process") + assert_contains_all(out, "and 2 more") def test_cli_failed_files_report_single( @@ -3978,8 +3999,8 @@ def _bad_process( _patch_parallel(monkeypatch) _run_main(monkeypatch, [str(tmp_path), "--no-progress"]) out = capsys.readouterr().out - assert "files failed to process" in out - assert "and 1 more" not in out + assert_contains_all(out, "files failed to process") + assert_contains_none(out, "and 1 more") def test_cli_worker_failed( @@ -3999,7 +4020,7 @@ def _boom(*_args: object, **_kwargs: object) -> CliFileProcessResult: _run_main(monkeypatch, [str(tmp_path), "--no-progress"]) assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out + assert_contains_all(out, "INTERNAL ERROR:") def test_cli_worker_failed_progress_sequential( @@ -4079,7 +4100,7 @@ def test_cli_fail_on_new_no_report_path( expected_code=3, ) out = capsys.readouterr().out - assert "\n report" not in out + assert_contains_none(out, "\n report") @pytest.mark.parametrize( @@ -4131,13 +4152,19 @@ def _diff( "Details (function clone hashes):" in out or "Function clone hashes:" in out ) else: - assert "Details (function clone hashes):" not in out - assert "Function clone hashes:" not in out + assert_contains_none(out, "Details (function clone hashes):") + assert_contains_none(out, "Function clone hashes:") if expect_block: - assert "Details (block clone hashes):" in out or "Block clone hashes:" in out + from tests._assertions import strip_ansi + + normalized = strip_ansi(out) + assert ( + "Details (block clone hashes):" in normalized + or "Block clone hashes:" in normalized + ) else: - assert "Details (block clone hashes):" not in out - assert "Block clone hashes:" not in out + assert_contains_none(out, "Details (block clone hashes):") + assert_contains_none(out, "Block clone hashes:") def test_cli_fail_on_new_verbose_and_report_path( @@ -4172,12 +4199,21 @@ def test_cli_fail_on_new_verbose_and_report_path( expected_code=3, ) out = capsys.readouterr().out - assert "report" in out - assert str(html_out) in out or html_out.name in out - assert "Details (function clone hashes):" in out or "Function clone hashes:" in out - assert "- fhash1" in out - assert "Details (block clone hashes):" in out or "Block clone hashes:" in out - assert "- bhash1" in out + from tests._assertions import strip_ansi + + normalized = strip_ansi(out) + assert_contains_all(out, "report") + assert str(html_out) in normalized or html_out.name in normalized + assert ( + "Details (function clone hashes):" in normalized + or "Function clone hashes:" in normalized + ) + assert_contains_all(out, "- fhash1") + assert ( + "Details (block clone hashes):" in normalized + or "Block clone hashes:" in normalized + ) + assert_contains_all(out, "- bhash1") def test_cli_fail_on_new_default_report_path( @@ -4213,8 +4249,8 @@ def test_cli_fail_on_new_default_report_path( expected_code=3, ) out = capsys.readouterr().out - assert "report" in out - assert ".codeclone/report.html" in out + assert_contains_all(out, "report") + assert_contains_all(out, ".codeclone/report.html") def test_cli_batch_result_none_no_progress( @@ -4228,7 +4264,7 @@ def test_cli_batch_result_none_no_progress( _patch_fixed_executor(monkeypatch, _FixedFuture(value=None)) _run_main(monkeypatch, [str(tmp_path), "--processes", "2", "--no-progress"]) out = capsys.readouterr().out - assert "Failed to process batch item" in out + assert_contains_all(out, "Failed to process batch item") def test_cli_batch_result_none_progress( @@ -4243,7 +4279,7 @@ def test_cli_batch_result_none_progress( _patch_fixed_executor(monkeypatch, _FixedFuture(value=None)) _run_main(monkeypatch, [str(tmp_path), "--processes", "2"]) out = capsys.readouterr().out - assert "Worker failed" in out + assert_contains_all(out, "Worker failed") def test_cli_failed_batch_item_no_progress( @@ -4257,7 +4293,7 @@ def test_cli_failed_batch_item_no_progress( _patch_fixed_executor(monkeypatch, _FixedFuture(error=RuntimeError("boom"))) _run_main(monkeypatch, [str(tmp_path), "--processes", "2", "--no-progress"]) out = capsys.readouterr().out - assert "Failed to process batch item" in out + assert_contains_all(out, "Failed to process batch item") def test_cli_failed_batch_item_progress( @@ -4272,7 +4308,7 @@ def test_cli_failed_batch_item_progress( _patch_fixed_executor(monkeypatch, _FixedFuture(error=RuntimeError("boom"))) _run_main(monkeypatch, [str(tmp_path), "--processes", "2"]) out = capsys.readouterr().out - assert "Worker failed" in out + assert_contains_all(out, "Worker failed") # --------------------------------------------------------------------------- diff --git a/tests/test_cli_mascot_help.py b/tests/test_cli_mascot_help.py new file mode 100644 index 00000000..98ed0c40 --- /dev/null +++ b/tests/test_cli_mascot_help.py @@ -0,0 +1,560 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy +# ruff: noqa: RUF001 + +from __future__ import annotations + +import io +import os +import sys +from typing import cast +from unittest.mock import patch + +import pytest + +from codeclone.config.argparse_builder import build_parser +from codeclone.contracts import ExitCode +from codeclone.surfaces.cli import workflow as cli_workflow +from codeclone.surfaces.cli.console import PlainConsole +from codeclone.surfaces.cli.ui.help_presenter import ( + interactive_help_requested, + static_help_mascot_lines, +) +from codeclone.surfaces.cli.ui.help_tour import HelpTourStep, run_interactive_help_tour +from codeclone.surfaces.cli.ui.mascot import Aster +from codeclone.surfaces.cli.ui.mascot_frames import ( + AsterAnimation, + AsterState, + animation_frames_for_kind, + animation_frames_for_state, + graph_pulse_animation_frames, +) +from codeclone.surfaces.cli.ui.progress_presenter import ProgressPresenter +from codeclone.surfaces.cli.ui.tour_panel import TourStatsLines, build_step_panel +from codeclone.surfaces.cli.ui.typewriter import TypewriterPanel + + +def _run_animated_rich_step( + *, + body: str, + tick_interval: float, + frame_interval: float, + char_interval: float, + min_read_pause: float, + cursor_blink_interval: float, +) -> list[float]: + from rich.console import Console + + from codeclone.surfaces.cli.console import make_query_console + from codeclone.surfaces.cli.ui import help_tour as help_tour_mod + + sleeps: list[float] = [] + step = HelpTourStep( + AsterState.SCANNING, + "Animated", + body, + animate=True, + animation=AsterAnimation.GRAPH_PULSE, + ) + presenter = ProgressPresenter(cast(Console, make_query_console(no_color=False))) + help_tour_mod._run_rich_step( + presenter, + step, + use_unicode=True, + sleep=lambda seconds: sleeps.append(seconds), + tick_interval=tick_interval, + frame_interval=frame_interval, + char_interval=char_interval, + min_read_pause=min_read_pause, + cursor_blink_interval=cursor_blink_interval, + ) + return sleeps + + +def test_static_help_mascot_lines_include_product_tagline() -> None: + lines = static_help_mascot_lines(use_unicode=True) + assert any("●" in line for line in lines) + assert any("Structural Change Controller" in line for line in lines) + assert lines[-1].startswith("Run `codeclone --help --interactive-help`") + + +def test_interactive_help_requested_detects_flags() -> None: + assert interactive_help_requested(["--help", "--interactive-help"]) + assert interactive_help_requested(["--interactive-help", "--help"]) + assert not interactive_help_requested(["--help"]) + assert not interactive_help_requested(["--interactive"]) + assert not interactive_help_requested(["-i"]) + + +def test_build_parser_print_help_prepends_mascot() -> None: + parser = build_parser("9.9.9") + buffer = io.StringIO() + parser.print_help(file=buffer) + text = buffer.getvalue() + assert "Structural Change Controller" in text + assert "--interactive-help" in text + assert "usage: codeclone" in text + assert "\x1b[" not in text + + +def test_help_action_runs_interactive_tour( + monkeypatch: pytest.MonkeyPatch, +) -> None: + calls: list[str] = [] + + def _fake_tour() -> int: + calls.append("tour") + return 0 + + monkeypatch.setattr( + "codeclone.surfaces.cli.ui.help_tour.run_interactive_help_tour", + _fake_tour, + ) + with pytest.raises(SystemExit) as exc: + build_parser("9.9.9").parse_args(["--help", "--interactive-help"]) + assert exc.value.code == 0 + assert calls == ["tour"] + + +def test_interactive_flag_requires_help() -> None: + with pytest.raises(SystemExit) as exc: + build_parser("9.9.9").parse_args(["--interactive-help"]) + assert exc.value.code == int(ExitCode.CONTRACT_ERROR) + + +def test_run_interactive_help_tour_plain_fallback() -> None: + from codeclone.surfaces.cli.console import PlainConsole + + console = PlainConsole() + assert run_interactive_help_tour(console=console, sleep=lambda _s: None) == 0 + + +def test_run_interactive_help_tour_non_tty_plain_fallback_does_not_sleep( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.console import PlainConsole + from codeclone.surfaces.cli.ui import help_tour + + sleeps: list[float] = [] + monkeypatch.setattr(help_tour, "_interactive_terminal_available", lambda: False) + + rc = help_tour.run_interactive_help_tour( + console=PlainConsole(), + steps=( + HelpTourStep( + AsterState.IDLE, + "Short", + "Plain fallback", + animate=False, + ), + ), + sleep=lambda seconds: sleeps.append(seconds), + ) + + assert rc == 0 + assert sleeps == [] + + +def test_progress_presenter_builds_group_with_mascot() -> None: + pytest.importorskip("rich") + from rich.console import Console + + console = Console(record=True, width=80, force_terminal=True) + presenter = ProgressPresenter( + console, + mascot_state=AsterState.SCANNING, + ) + renderable = presenter.build_renderable() + with console.capture() as capture: + console.print(renderable) + assert "Mapping repository structure" in capture.get() + + +def test_progress_presenter_keeps_mascot_height_stable() -> None: + pytest.importorskip("rich") + from codeclone.surfaces.cli.console import make_console + + console = make_console(no_color=False, width=80) + console.record = True + rendered_heights: set[int] = set() + for state in ( + AsterState.REPORTING, + AsterState.DEPENDENCIES, + AsterState.ANALYSIS, + AsterState.SUCCESS, + ): + presenter = ProgressPresenter(console, mascot_state=state) + with console.capture() as capture: + console.print(presenter.build_renderable()) + rendered_heights.add(len(capture.get().splitlines())) + assert rendered_heights == {5} + + +def test_cli_module_help_includes_mascot( + monkeypatch: pytest.MonkeyPatch, + capsys: pytest.CaptureFixture[str], +) -> None: + monkeypatch.setattr(sys, "argv", ["codeclone", "--help"]) + with pytest.raises(SystemExit) as exc: + cli_workflow.main() + assert exc.value.code == 0 + out = capsys.readouterr().out + assert "Structural Change Controller" in out + assert "\x1b[" not in out + + +def test_help_tour_step_dataclass() -> None: + step = HelpTourStep(AsterState.IDLE, "Title", "Body", animate=False) + assert step.title == "Title" + + +def test_progress_presenter_live_session_updates() -> None: + pytest.importorskip("rich") + from rich.console import Console + from rich.text import Text + + console = Console(record=True, width=80, force_terminal=True) + presenter = ProgressPresenter( + console, + mascot_state=AsterState.SCANNING, + ) + with presenter.live_context(transient=True) as live: + presenter.bind_live(live) + presenter.set_extra(Text("phase metrics")) + presenter.set_mascot( + AsterState.REPORTING, + message="Compiling reports…", + ) + presenter.clear_live() + assert presenter.mascot_state is AsterState.REPORTING + + +def test_aster_plain_lines_ascii_fallback() -> None: + lines = Aster(AsterState.SUCCESS, use_unicode=False).plain_lines() + assert any("Analysis complete" in line for line in lines) + + +def test_mascot_use_unicode_respects_no_color_and_encoding( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.ui import mascot as mascot_mod + + monkeypatch.setenv("NO_COLOR", "1") + assert mascot_mod.mascot_use_unicode() is False + + monkeypatch.delenv("NO_COLOR", raising=False) + assert mascot_mod.mascot_use_unicode(no_color=True) is False + + class _BrokenStdout: + encoding = "ascii" + + monkeypatch.setattr(os, "environ", {}) + monkeypatch.setattr(sys, "stdout", _BrokenStdout()) + assert mascot_mod.mascot_use_unicode() is False + + +def test_aster_plain_lines_short_middle_row_uses_body_only() -> None: + lines = Aster( + AsterState.IDLE, + message="Hi", + frame_lines=("top", "x", "bottom"), + use_unicode=False, + ).plain_lines() + assert any("Structural Change Controller" in line for line in lines) + + +def test_help_tour_rich_console_or_none_returns_none_for_non_rich_printer() -> None: + from codeclone.surfaces.cli.ui import help_tour as help_tour_mod + + class _RichCapablePlain(PlainConsole): + pass + + with patch.object(help_tour_mod, "supports_rich_console", return_value=True): + assert help_tour_mod._rich_console_or_none(_RichCapablePlain()) is None + + +def test_typewriter_panel_reveals_partial_text_with_cursor() -> None: + pytest.importorskip("rich") + from codeclone.surfaces.cli.console import make_console + + panel = TypewriterPanel( + text="hello", visible_chars=3, cursor_on=True, use_unicode=True + ) + console = make_console(no_color=False, width=80) + console.record = True + with console.capture() as capture: + console.print(panel) + rendered = capture.get() + assert "hel" in rendered + assert "▌" in rendered + panel_off = TypewriterPanel( + text="hello", visible_chars=3, cursor_on=False, use_unicode=True + ) + with console.capture() as capture_off: + console.print(panel_off) + assert "▌" not in capture_off.get() + + +def test_animation_frames_for_state_includes_analysis_phases() -> None: + scanning = animation_frames_for_state(AsterState.SCANNING, use_unicode=True) + analysis = animation_frames_for_state(AsterState.ANALYSIS, use_unicode=True) + cache = animation_frames_for_state(AsterState.CACHE, use_unicode=True) + control = animation_frames_for_state(AsterState.CONTROL, use_unicode=True) + blast = animation_frames_for_state(AsterState.BLAST_RADIUS, use_unicode=True) + assert scanning is not None and len(scanning) >= 4 + assert analysis is not None and len(analysis) >= 4 + assert cache is not None and len(cache) >= 3 + assert control is not None and "◉" in control[0][1] + assert blast is not None and "◉" in blast[0][1] + assert animation_frames_for_state(AsterState.IDLE, use_unicode=True) is None + + +def test_new_animation_kinds_are_distinct() -> None: + control = animation_frames_for_kind(AsterAnimation.CONTROL_GATE, use_unicode=True) + blast = animation_frames_for_kind(AsterAnimation.BLAST_RIPPLE, use_unicode=True) + branch = animation_frames_for_kind(AsterAnimation.ANALYSIS_BRANCH, use_unicode=True) + hub = animation_frames_for_kind(AsterAnimation.ORIGIN_HUB, use_unicode=True) + assert control is not None and "┌" in control[0][0] + assert blast is not None and "·" in blast[0][0] + assert branch is not None and "╱╲" in branch[-1][-1] + assert hub is not None and "╵" in hub[0][-1] + + +def test_help_tour_animated_steps_use_unique_animations() -> None: + from codeclone.surfaces.cli.ui.help_tour import _DEFAULT_STEPS + + seen: set[AsterAnimation] = set() + for step in _DEFAULT_STEPS: + if not step.animate or step.animation is None: + continue + assert step.animation not in seen, step.animation + seen.add(step.animation) + + +def test_graph_pulse_animation_has_four_frames() -> None: + frames = graph_pulse_animation_frames(use_unicode=True) + assert len(frames) == 4 + assert frames[0][0].strip() == "●─○─○─○" + assert frames[3][0].strip() == "○─○─○─●" + + +def test_scanning_animation_matches_canonical_four_frames() -> None: + from codeclone.surfaces.cli.ui.mascot_frames import scanning_animation_frames + + frames = scanning_animation_frames(use_unicode=True) + assert len(frames) == 4 + assert frames[0] == (" ● ", " ╱│ ", " ○ ") + assert frames[1] == (" ● ", " ╱│╲ ", " ○ ○ ") + assert frames[2] == (" ● ", " │╲ ", " ○ ") + assert frames[3] == (" ● ", " ╱│╲ ", " ○ ○ ○ ") + + +def test_resolve_animation_graph_pulse_four_frames() -> None: + from codeclone.surfaces.cli.ui.mascot_frames import ( + AsterAnimation, + AsterState, + resolve_animation, + ) + + frames = resolve_animation( + AsterState.SCANNING, + animation=AsterAnimation.GRAPH_PULSE, + animate=True, + use_unicode=True, + ) + assert frames is not None + assert len(frames) == 4 + assert frames[0][0].strip() == "●─○─○─○" + assert frames[3][0].strip() == "○─○─○─●" + + +def test_default_help_tour_has_comprehensive_step_count() -> None: + from codeclone.surfaces.cli.ui.help_tour import _DEFAULT_STEPS + + assert len(_DEFAULT_STEPS) >= 14 + + +def test_tour_stats_lines_render() -> None: + pytest.importorskip("rich") + from codeclone.surfaces.cli.console import make_console + + console = make_console(no_color=False, width=80) + console.record = True + panel = TourStatsLines(lines=("Files 741",)) + with console.capture() as capture: + console.print(panel) + assert "741" in capture.get() + + +def test_tour_step_panel_height_is_stable_with_or_without_stats() -> None: + pytest.importorskip("rich") + from codeclone.surfaces.cli.console import make_console + + console = make_console(no_color=False, width=80) + console.record = True + heights: set[int] = set() + for stats in ((), ("Files 741", "Parsed 741")): + panel = build_step_panel( + body="line one\nline two", + visible_chars=99, + cursor_on=False, + use_unicode=True, + stats=stats, + ) + with console.capture() as capture: + console.print(panel) + heights.add(len(capture.get().splitlines())) + assert heights == {10} + + +def test_run_rich_step_advances_animation_and_cursor( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("rich") + sleeps = _run_animated_rich_step( + body="abc", + tick_interval=0.01, + frame_interval=0.01, + char_interval=0.01, + min_read_pause=0.02, + cursor_blink_interval=0.01, + ) + assert len(sleeps) >= 8 + + +def test_run_interactive_help_tour_rich_step_uses_typewriter_ticks( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("rich") + from codeclone.surfaces.cli.console import make_query_console + + sleeps: list[float] = [] + steps = ( + HelpTourStep( + AsterState.IDLE, + "Short", + "ab", + animate=False, + ), + ) + console = make_query_console(no_color=False) + monkeypatch.setattr( + "codeclone.surfaces.cli.ui.help_tour._interactive_terminal_available", + lambda: True, + ) + monkeypatch.setattr( + "codeclone.surfaces.cli.ui.help_tour.supports_rich_console", + lambda _c: True, + ) + run_interactive_help_tour( + console=console, + steps=steps, + sleep=lambda seconds: sleeps.append(seconds), + tick_interval=0.01, + char_interval=0.01, + min_read_pause=0.05, + cursor_blink_interval=0.02, + ) + assert len(sleeps) > 5 + + +def test_render_plain_tour_sleeps_when_interactive_without_rich( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.console import PlainConsole + from codeclone.surfaces.cli.ui import help_tour as help_tour_mod + + sleeps: list[float] = [] + monkeypatch.setattr(help_tour_mod, "_interactive_terminal_available", lambda: True) + + help_tour_mod._render_plain_tour( + ( + HelpTourStep( + AsterState.IDLE, + "Plain pause", + "Body", + animate=False, + ), + ), + PlainConsole(), + sleep=lambda seconds: sleeps.append(seconds), + plain_step_pause=0.25, + ) + + assert sleeps == [0.25] + + +def test_run_rich_step_animation_frame_and_char_intervals( + monkeypatch: pytest.MonkeyPatch, +) -> None: + pytest.importorskip("rich") + sleeps = _run_animated_rich_step( + body="abcd", + tick_interval=0.05, + frame_interval=0.05, + char_interval=0.05, + min_read_pause=0.05, + cursor_blink_interval=0.05, + ) + assert len(sleeps) >= 6 + + +def test_mascot_frames_ascii_catalog_paths() -> None: + from codeclone.surfaces.cli.ui.mascot_frames import ( + AsterAnimation, + AsterState, + animation_frames_for_kind, + animation_frames_for_state, + graph_pulse_animation_frames, + resolve_animation, + scanning_animation_frames, + ) + + assert scanning_animation_frames(use_unicode=False) + assert graph_pulse_animation_frames(use_unicode=False) + assert animation_frames_for_kind( + AsterAnimation.GRAPH_PULSE, + use_unicode=False, + ) + assert animation_frames_for_state(AsterState.SCANNING, use_unicode=False) + assert resolve_animation( + AsterState.SCANNING, + animation=AsterAnimation.GRAPH_PULSE, + animate=True, + use_unicode=False, + ) + + +def test_run_interactive_help_tour_default_console_respects_no_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.console import PlainConsole + from codeclone.surfaces.cli.ui import help_tour + + calls: list[bool | None] = [] + + def _fake_console(*, no_color: bool | None = None) -> PlainConsole: + calls.append(no_color) + return PlainConsole() + + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setattr(help_tour, "make_query_console", _fake_console) + monkeypatch.setattr(help_tour, "_interactive_terminal_available", lambda: False) + + rc = help_tour.run_interactive_help_tour( + steps=( + HelpTourStep( + AsterState.IDLE, + "Short", + "Plain fallback", + animate=False, + ), + ), + sleep=lambda _seconds: None, + ) + + assert rc == 0 + assert calls == [None] diff --git a/tests/test_cli_memory_semantic.py b/tests/test_cli_memory_semantic.py index 1aaa0bc4..5bb0c1ce 100644 --- a/tests/test_cli_memory_semantic.py +++ b/tests/test_cli_memory_semantic.py @@ -9,6 +9,7 @@ import json from collections.abc import Sequence from pathlib import Path +from typing import cast import pytest @@ -29,9 +30,34 @@ from codeclone.memory.sqlite_store import SqliteEngineeringMemoryStore from codeclone.surfaces.cli.memory import _render_semantic_text, memory_main from codeclone.surfaces.cli.memory_render import memory_console +from tests._assertions import ( + assert_contains_all, + assert_contains_none, + assert_mapping_entries, + strip_ansi, +) from tests.memory_fixtures import make_module_record +def _assert_nested_lane_metric( + payload: dict[str, object], + *, + lane: str, + key: str, + expected: object, +) -> None: + lanes = cast(dict[str, object], payload["lanes"]) + lane_payload = cast(dict[str, object], lanes[lane]) + assert lane_payload[key] == expected + + +def _stdout_json(capsys: pytest.CaptureFixture[str]) -> dict[str, object]: + return cast( + dict[str, object], + json.loads(strip_ansi(capsys.readouterr().out)), + ) + + class _FakeSemanticIndex: def __init__(self) -> None: self.rows: list[SemanticRow] = [] @@ -125,9 +151,9 @@ def test_semantic_status_reports_unavailable_by_default( code = memory_main(["semantic", "status", "--root", str(tmp_path)]) out = capsys.readouterr().out.lower() assert code == 0 - assert "semantic index" in out + assert_contains_all(out, "semantic index") # default config has semantic disabled -> status reason "disabled" - assert "disabled" in out + assert_contains_all(out, "disabled") def test_semantic_rebuild_fails_clear_without_backend( @@ -136,7 +162,7 @@ def test_semantic_rebuild_fails_clear_without_backend( code = memory_main(["semantic", "rebuild", "--root", str(tmp_path)]) out = capsys.readouterr().out.lower() assert code != 0 - assert "semantic" in out + assert_contains_all(out, "semantic") assert "semantic-lancedb" in out or "disabled" in out @@ -148,7 +174,7 @@ def test_semantic_search_fails_clear_without_backend( ) out = capsys.readouterr().out.lower() assert code != 0 - assert "unavailable" in out + assert_contains_all(out, "unavailable") def _seed_semantic_repo( @@ -216,10 +242,10 @@ def test_memory_search_semantic_provider_unavailable_degrades_without_traceback( assert code == 0 out = capsys.readouterr().out - assert "semantic: off" in out + assert_contains_all(out, "semantic: off") assert "local_model embedding provider is not" in out.replace("\n", " ") assert "available yet" in out.replace("\n", " ") - assert "Traceback" not in out + assert_contains_none(out, "Traceback") def test_semantic_explicit_commands_fail_clear_when_provider_unavailable( @@ -235,12 +261,12 @@ def test_semantic_explicit_commands_fail_clear_when_provider_unavailable( assert code != 0 out = capsys.readouterr().out if command[1] == "rebuild": - assert "Semantic index rebuild unavailable" in out + assert_contains_all(out, "Semantic index rebuild unavailable") else: - assert "Semantic embedding provider unavailable" in out + assert_contains_all(out, "Semantic embedding provider unavailable") assert "local_model embedding provider is not" in out.replace("\n", " ") assert "available yet" in out.replace("\n", " ") - assert "Traceback" not in out + assert_contains_none(out, "Traceback") def test_semantic_status_reports_provider_unavailable( @@ -252,8 +278,8 @@ def test_semantic_status_reports_provider_unavailable( assert code == 0 out = capsys.readouterr().out - assert "semantic index: unavailable" in out - assert "provider: unavailable" in out + assert_contains_all(out, "semantic index: unavailable") + assert_contains_all(out, "provider: unavailable") assert "local_model embedding provider is not" in out.replace("\n", " ") assert "available yet" in out.replace("\n", " ") @@ -289,7 +315,7 @@ def embed(self, texts: Sequence[str]) -> list[list[float]]: assert code != 0 assert "unavailable" in out.lower() assert "model unavailable" in out.replace("\n", " ") - assert "Traceback" not in out + assert_contains_none(out, "Traceback") def test_semantic_search_hydrates_and_renders_json( @@ -307,14 +333,19 @@ def test_semantic_search_hydrates_and_renders_json( ["semantic", "search", "recover restart", "--root", str(tmp_path), "--json"] ) assert code == 0 - payload = json.loads(capsys.readouterr().out) - assert payload["semantic"]["diagnostic"] is True - assert payload["results"] - top = payload["results"][0] - assert top["source"] == "memory" - assert top["kind"] == "contract_note" - assert top["subject_path"] == "codeclone/x.py" - assert "recover after MCP restart" in top["preview"] + payload = _stdout_json(capsys) + semantic = cast(dict[str, object], payload["semantic"]) + assert semantic["diagnostic"] is True + results = cast(list[dict[str, object]], payload["results"]) + assert results + top = results[0] + assert_mapping_entries( + top, + source="memory", + kind="contract_note", + subject_path="codeclone/x.py", + ) + assert "recover after MCP restart" in cast(str, top["preview"]) def test_memory_search_semantic_flag_blends_ranking( @@ -331,8 +362,8 @@ def test_memory_search_semantic_flag_blends_ranking( code = memory_main(["search", "recover", "--root", str(tmp_path), "--semantic"]) assert code == 0 out = capsys.readouterr().out - assert "semantic: on" in out - assert "diagnostic" in out + assert_contains_all(out, "semantic: on") + assert_contains_all(out, "diagnostic") def test_semantic_search_text_renders_ranked_hits( @@ -351,10 +382,10 @@ def test_semantic_search_text_renders_ranked_hits( ) assert code == 0 out = capsys.readouterr().out - assert "Semantic matches for: recover restart" in out - assert "score=" in out - assert "subject: codeclone/x.py" in out - assert "recover after MCP restart" in out + assert_contains_all(out, "Semantic matches for: recover restart") + assert_contains_all(out, "score=") + assert_contains_all(out, "subject: codeclone/x.py") + assert_contains_all(out, "recover after MCP restart") def test_semantic_status_shows_provider_when_index_built( @@ -371,8 +402,8 @@ def test_semantic_status_shows_provider_when_index_built( code = memory_main(["semantic", "status", "--root", str(tmp_path)]) out = capsys.readouterr().out assert code == 0 - assert "semantic index: available" in out - assert "provider: diagnostic-hash" in out + assert_contains_all(out, "semantic index: available") + assert_contains_all(out, "provider: diagnostic-hash") def test_semantic_rebuild_requires_memory_database( @@ -388,8 +419,8 @@ def test_semantic_rebuild_requires_memory_database( code = memory_main(["semantic", "rebuild", "--root", str(tmp_path)]) out = capsys.readouterr().out assert code != 0 - assert "Engineering memory database not found" in out - assert "codeclone memory init" in out + assert_contains_all(out, "Engineering memory database not found") + assert_contains_all(out, "codeclone memory init") def test_memory_coverage_rejects_invalid_scope_path( @@ -427,7 +458,7 @@ def test_render_semantic_text_reports_no_matches( ) out = capsys.readouterr().out assert code == 0 - assert "(no matches)" in out + assert_contains_all(out, "(no matches)") def test_semantic_probe_skipped_when_disabled( @@ -436,7 +467,7 @@ def test_semantic_probe_skipped_when_disabled( code = memory_main(["semantic", "probe", "--root", str(tmp_path)]) out = capsys.readouterr().out.lower() assert code != 0 - assert "disabled" in out + assert_contains_all(out, "disabled") def test_semantic_probe_json_emits_payload( @@ -472,9 +503,9 @@ def test_semantic_probe_json_emits_payload( ) code = memory_main(["semantic", "probe", "--root", str(tmp_path), "--json"]) assert code == 0 - emitted = json.loads(capsys.readouterr().out) + emitted = _stdout_json(capsys) assert emitted["action"] == "probe_semantic_projections" - assert emitted["lanes"]["memory"]["documents"] == 1 + _assert_nested_lane_metric(emitted, lane="memory", key="documents", expected=1) def test_semantic_probe_text_renders_lane_percentiles( @@ -512,12 +543,12 @@ def test_semantic_probe_text_renders_lane_percentiles( code = memory_main(["semantic", "probe", "--root", str(tmp_path)]) out = capsys.readouterr().out assert code == 0 - assert "Semantic projection probe:" in out - assert "fastembed_tokenizer" in out - assert "memory: 2 documents" in out - assert "raw tokens p50/p95/max: 3/6/9" in out - assert "effective tokens p50/p95/max: 2/5/8" in out - assert "truncated: 1 (max_dropped=4)" in out + assert_contains_all(out, "Semantic projection probe:") + assert_contains_all(out, "fastembed_tokenizer") + assert_contains_all(out, "memory: 2 documents") + assert_contains_all(out, "raw tokens p50/p95/max: 3/6/9") + assert_contains_all(out, "effective tokens p50/p95/max: 2/5/8") + assert_contains_all(out, "truncated: 1 (max_dropped=4)") def test_semantic_probe_contract_error_suggests_memory_init( @@ -536,8 +567,8 @@ def _raise_contract(**_kwargs: object) -> object: code = memory_main(["semantic", "probe", "--root", str(tmp_path)]) out = capsys.readouterr().out assert code != 0 - assert "database not found" in out - assert "codeclone memory init" in out + assert_contains_all(out, "database not found") + assert_contains_all(out, "codeclone memory init") def test_semantic_probe_invalid_payload_reports_unavailable( @@ -553,4 +584,28 @@ def test_semantic_probe_invalid_payload_reports_unavailable( code = memory_main(["semantic", "probe", "--root", str(tmp_path)]) out = capsys.readouterr().out.lower() assert code != 0 - assert "invalid payload" in out + assert_contains_all(out, "invalid payload") + + +def test_memory_retrieval_parse_filters_rejects_invalid_shapes() -> None: + from codeclone.memory.exceptions import MemoryContractError + from codeclone.memory.retrieval import service as retrieval_service + + with pytest.raises(MemoryContractError, match="types must be a list"): + retrieval_service._parse_filters({"types": "not-a-list"}) + with pytest.raises(MemoryContractError, match="statuses must be a list"): + retrieval_service._parse_filters({"statuses": 1}) + with pytest.raises(MemoryContractError, match="confidences must be a list"): + retrieval_service._parse_filters({"confidences": {"bad": True}}) + with pytest.raises(MemoryContractError, match="match_mode must be"): + retrieval_service._parse_filters({"match_mode": "sometimes"}) + with pytest.raises(MemoryContractError, match="include_routine must be boolean"): + retrieval_service._parse_filters({"include_routine": "yes"}) + + with pytest.raises(MemoryContractError, match="lanes is invalid"): + retrieval_service._string_list({}, "lanes") + with pytest.raises(MemoryContractError, match="lanes is invalid"): + retrieval_service._string_list({"lanes": [1, 2]}, "lanes") + + with pytest.raises(MemoryContractError, match="record_type"): + retrieval_service._parse_filters({"types": ["not-a-record-type"]}) diff --git a/tests/test_cli_session_stats.py b/tests/test_cli_session_stats.py index 438d4def..28b37a2d 100644 --- a/tests/test_cli_session_stats.py +++ b/tests/test_cli_session_stats.py @@ -348,7 +348,7 @@ def test_session_stats_stale_quiet(tmp_path: Path) -> None: pid=2, start_epoch=1000000, status="active", - lease_seconds=1, + lease_seconds=MIN_LEASE_SECONDS, ) printer = _RecordingPrinter() diff --git a/tests/test_cli_setup.py b/tests/test_cli_setup.py new file mode 100644 index 00000000..681668ac --- /dev/null +++ b/tests/test_cli_setup.py @@ -0,0 +1,3132 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +import importlib +import importlib.util +import io +import json +import sys +import types +from collections.abc import Callable, Mapping +from contextlib import redirect_stdout +from pathlib import Path +from typing import Any, cast +from unittest.mock import patch + +import pytest + +from codeclone.audit.events import EVENT_PATCH_VERIFIED, AuditEvent, repo_root_digest +from codeclone.audit.writer import SqliteAuditWriter +from codeclone.config.pyproject_loader import load_pyproject_config +from codeclone.config.pyproject_writer import PyprojectWriterError +from codeclone.contracts import ExitCode +from codeclone.surfaces.cli.console import PlainConsole +from codeclone.surfaces.cli.setup import render as setup_render +from codeclone.surfaces.cli.setup.engine.apply import _ready_actions, apply_setup_plan +from codeclone.surfaces.cli.setup.engine.capabilities import ( + CAPABILITY_REGISTRY, + CapabilityAxes, + CapabilityMeta, + DiscoverContext, +) +from codeclone.surfaces.cli.setup.engine.discover import ( + _client_config_present, + build_setup_snapshot, +) +from codeclone.surfaces.cli.setup.engine.plan import build_setup_plan +from codeclone.surfaces.cli.setup.engine.rollup import ( + Readiness, + _axes_satisfied, + _optional_install_hint, + derive_readiness, + describe_capability, +) +from codeclone.surfaces.cli.setup.main import setup_main +from codeclone.surfaces.cli.setup.wizard import WizardPrompts, run_setup_wizard +from codeclone.surfaces.cli.types import PrinterLike +from codeclone.utils.json_io import json_text +from tests.test_cli_inprocess import _write_current_python_baseline + +_REPO_ROOT = Path(__file__).resolve().parents[1] +_GOLDEN_SNAPSHOT = ( + _REPO_ROOT / "tests" / "fixtures" / "contract_snapshots" / "setup_snapshot_v1.json" +) +_DISCOVER_SOURCE = ( + _REPO_ROOT / "codeclone" / "surfaces" / "cli" / "setup" / "engine" / "discover.py" +) +_WORKFLOW_SOURCE = _REPO_ROOT / "codeclone" / "surfaces" / "cli" / "workflow.py" + +_OPTIONAL_MODULES = frozenset( + { + "mcp", + "defusedxml", + "psutil", + "tiktoken", + "fastembed", + "lancedb", + "sklearn", + "hdbscan", + } +) + + +def _pop_modules_with_prefix(prefix: str) -> dict[str, types.ModuleType]: + removed: dict[str, types.ModuleType] = {} + for name in list(sys.modules): + if name == prefix or name.startswith(f"{prefix}."): + removed[name] = sys.modules.pop(name) + return removed + + +def _restore_modules(modules: Mapping[str, types.ModuleType]) -> None: + sys.modules.update(modules) + + +@pytest.fixture +def base_install_find_spec(monkeypatch: pytest.MonkeyPatch) -> None: + real_find_spec = importlib.util.find_spec + + def _fake_find_spec(name: str, package: object | None = None) -> object | None: + if name in _OPTIONAL_MODULES: + return None + return real_find_spec(name, cast(str | None, package)) + + monkeypatch.setattr(importlib.util, "find_spec", _fake_find_spec) + monkeypatch.setattr( + "codeclone.analytics.capabilities._package_available", + lambda _name: False, + ) + + +def _present( + meta: CapabilityMeta, + axes: CapabilityAxes, + readiness: str, + ctx: DiscoverContext, +) -> tuple[str, str, str]: + """Bridge for tests: readiness is authoritative (derive_readiness); this echoes + the given readiness and returns the presentation reason/action for it.""" + + reason, action = describe_capability( + meta, + axes, + cast(Readiness, readiness), + ctx, + ) + return readiness, reason, action + + +def _capability_rows(snapshot: dict[str, object]) -> list[dict[str, object]]: + raw = snapshot.get("capabilities") + assert isinstance(raw, list) + return [cast(dict[str, object], item) for item in raw if isinstance(item, dict)] + + +def _plan_actions(plan: dict[str, object]) -> list[dict[str, object]]: + raw = plan.get("actions") + assert isinstance(raw, list) + return [cast(dict[str, object], item) for item in raw if isinstance(item, dict)] + + +def _normalize_snapshot(snapshot: dict[str, object]) -> dict[str, object]: + parsed = json.loads(json.dumps(snapshot)) + if not isinstance(parsed, dict): + raise TypeError("expected dict snapshot") + normalized: dict[str, object] = parsed + normalized["root"] = "" + normalized["head_commit"] = None + runtime = normalized.get("runtime") + if isinstance(runtime, dict): + runtime_fields = cast(dict[str, object], runtime) + runtime_fields["python_tag"] = "" + runtime_fields["codeclone_version"] = "" + return normalized + + +def _build_canonical_fixture_root(root: Path) -> Path: + _write_minimal_pyproject(root / "pyproject.toml", audit_enabled=False) + _write_current_python_baseline(root / "codeclone.baseline.json") + (root / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + return root + + +def _write_minimal_pyproject(path: Path, *, audit_enabled: bool = False) -> None: + path.write_text( + "[tool.codeclone]\n" + 'baseline = "codeclone.baseline.json"\n' + f"audit_enabled = {'true' if audit_enabled else 'false'}\n", + encoding="utf-8", + ) + + +def test_lazy_load_setup_not_imported_with_main() -> None: + for name in list(sys.modules): + if name == "codeclone.surfaces.cli.setup" or name.startswith( + "codeclone.surfaces.cli.setup." + ): + del sys.modules[name] + import codeclone.main # noqa: F401 + + assert "codeclone.surfaces.cli.setup" not in sys.modules + + +def test_lazy_load_positive_path(monkeypatch: pytest.MonkeyPatch) -> None: + for name in list(sys.modules): + if name.startswith("codeclone.surfaces.cli.setup"): + del sys.modules[name] + monkeypatch.setattr(sys, "argv", ["codeclone", "setup", "status"]) + with pytest.raises(SystemExit) as exc: + import codeclone.surfaces.cli.workflow as workflow + + workflow.main() + assert exc.value.code == int(ExitCode.SUCCESS) + assert "codeclone.surfaces.cli.setup" in sys.modules + + +def test_workflow_has_single_deferred_setup_import() -> None: + text = _WORKFLOW_SOURCE.read_text(encoding="utf-8") + assert text.count("from .setup import setup_main") == 1 + + +def test_discover_does_not_import_mcp_surface() -> None: + source = _DISCOVER_SOURCE.read_text(encoding="utf-8") + assert "surfaces.mcp" not in source + assert "collect_session_snapshot" not in source + assert "run_memory_analysis_report" not in source + + +def test_import_setup_without_optional_extras( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + snapshot = build_setup_snapshot(tmp_path) + assert snapshot["schema_version"] == "1" + controlled = next( + item for item in _capability_rows(snapshot) if item["id"] == "controlled_change" + ) + assert controlled["readiness"] == "optional" + assert str(controlled["readiness"]) != "blocked" + + +def test_setup_json_matches_golden_snapshot( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + root = _build_canonical_fixture_root(tmp_path) + snapshot = build_setup_snapshot(root) + expected = json.loads(_GOLDEN_SNAPSHOT.read_text(encoding="utf-8")) + assert _normalize_snapshot(snapshot) == expected + + +def test_setup_json_stable_ordering( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + from codeclone.surfaces.cli.setup.engine.capabilities import GROUP_ORDER + + root = _build_canonical_fixture_root(tmp_path) + first = build_setup_snapshot(root) + second = build_setup_snapshot(root) + # Full byte-identical determinism on a fixed repo/install/config state (I-05). + assert first == second + assert json_text(first, sort_keys=True) == json_text(second, sort_keys=True) + first_caps = _capability_rows(first) + second_caps = _capability_rows(second) + assert first_caps == second_caps + groups = [str(item["group"]) for item in first_caps] + group_rank: dict[str, int] = { + group: index for index, group in enumerate(GROUP_ORDER) + } + assert groups == sorted(groups, key=lambda group: group_rank[group]) + # Global order is (group rank, then capability id lexicographically). + ordered_keys = [ + (group_rank[str(item["group"])], str(item["id"])) for item in first_caps + ] + assert ordered_keys == sorted(ordered_keys) + # And within each group, ids are strictly lexicographic. + for group in GROUP_ORDER: + ids_in_group = [ + str(item["id"]) for item in first_caps if item["group"] == group + ] + assert ids_in_group == sorted(ids_in_group) + # evidence lists are sorted within each capability. + for item in first_caps: + evidence = item["evidence"] + assert isinstance(evidence, list) + assert evidence == sorted(evidence) + + +def test_baseline_ready_with_trusted_baseline( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + _write_current_python_baseline(tmp_path / "codeclone.baseline.json") + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + snapshot = build_setup_snapshot(tmp_path) + baseline = next( + item for item in _capability_rows(snapshot) if item["id"] == "baseline" + ) + assert baseline["readiness"] == "ready" + + +def test_workspace_hygiene_attention_without_gitignore( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + snapshot = build_setup_snapshot(tmp_path) + hygiene = next( + item for item in _capability_rows(snapshot) if item["id"] == "workspace_hygiene" + ) + assert hygiene["readiness"] == "attention" + + +def test_malformed_pyproject_marks_analysis_invalid( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[tool.codeclone]\nmin_loc = "not-an-int"\n', + encoding="utf-8", + ) + snapshot = build_setup_snapshot(tmp_path) + rows = {str(item["id"]): item for item in _capability_rows(snapshot)} + # Every built-in required capability fails closed to blocked; in particular + # engineering_memory must not be masked as attention (no false "run init"). + for cap_id in ("analysis", "engineering_memory", "audit_and_intents", "ci_policy"): + assert rows[cap_id]["configuration"] == "invalid" + assert rows[cap_id]["readiness"] == "blocked" + + +def test_optional_extra_installed_invalid_config_is_attention_not_blocked() -> None: + meta = CapabilityMeta( + id="semantic_retrieval", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="semantic-local", + requires_config=True, + ) + axes = CapabilityAxes( + installation="installed", + configuration="invalid", + runtime="not_verified", + evidence=["probe:capability:embed"], + ) + assert derive_readiness(meta, axes) == "attention" + + +def test_readiness_r2_optional_when_extra_missing() -> None: + meta = CapabilityMeta( + id="mcp_runtime", + group="governed_agent_workflows", + availability="optional_extra", + optional_extra_name="mcp", + ) + axes = CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=["probe:find_spec:mcp"], + ) + assert derive_readiness(meta, axes) == "optional" + + +def test_default_subcommand_equals_status( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + buf_default = io.StringIO() + buf_status = io.StringIO() + with redirect_stdout(buf_default): + rc_default = setup_main(["--json", "--root", str(tmp_path)]) + with redirect_stdout(buf_status): + rc_status = setup_main(["status", "--json", "--root", str(tmp_path)]) + assert rc_default == rc_status == int(ExitCode.SUCCESS) + assert json.loads(buf_default.getvalue()) == json.loads(buf_status.getvalue()) + + +def test_json_writes_stdout_not_file( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + out = io.StringIO() + with redirect_stdout(out): + rc = setup_main(["--json", "--root", str(tmp_path)]) + assert rc == int(ExitCode.SUCCESS) + payload = json.loads(out.getvalue()) + assert payload["projection_kind"] == "setup_snapshot" + assert not (tmp_path / "--json").exists() + + +def test_head_commit_null_for_non_git_fixture( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + snapshot = build_setup_snapshot(tmp_path) + assert snapshot["head_commit"] is None + + +def test_setup_snapshot_has_no_edit_allowed_field( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + snapshot = build_setup_snapshot(tmp_path) + assert "edit_allowed" not in snapshot + + +def test_setup_does_not_invoke_subprocess( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + + def _forbidden(*_args: object, **_kwargs: object) -> None: + raise AssertionError("subprocess must not be invoked by setup") + + monkeypatch.setattr("subprocess.run", _forbidden) + monkeypatch.setattr("subprocess.Popen", _forbidden) + assert setup_main(["--json", "--root", str(tmp_path)]) == int(ExitCode.SUCCESS) + + +def test_non_ascii_repo_path( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + root = tmp_path / "répo-测试" + root.mkdir() + _write_minimal_pyproject(root / "pyproject.toml") + snapshot = build_setup_snapshot(root) + assert snapshot["root"] == str(root.resolve()) + + +def test_status_output_positioning_vocabulary( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + buf = io.StringIO() + with redirect_stdout(buf): + setup_main(["status", "--root", str(tmp_path)]) + text = buf.getvalue().lower() + assert "governed agent workflows" in text + assert "optional" in text or "attention" in text + assert "linter" not in text + assert "edit_allowed" not in text + + +def test_audit_populated_trail_is_ready( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + db_path = tmp_path / ".codeclone" / "db" / "audit.sqlite3" + db_path.parent.mkdir(parents=True) + writer = SqliteAuditWriter(db_path=db_path, payloads="compact", retention_days=30) + try: + writer.emit( + AuditEvent( + event_type=EVENT_PATCH_VERIFIED, + severity="info", + repo_root_digest=repo_root_digest(tmp_path), + agent_pid=123, + agent_label="setup-test", + run_id="abcdef123456", + intent_id="intent-abcdef12-001", + status="accepted", + ) + ) + finally: + writer.close() + snapshot = build_setup_snapshot(tmp_path) + audit = next( + item for item in _capability_rows(snapshot) if item["id"] == "audit_and_intents" + ) + # A populated audit trail is runtime-verified -> ready (never blocked). + assert audit["runtime"] == "verified" + assert audit["readiness"] == "ready" + + +def test_audit_enabled_empty_db_is_attention( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + db_path = tmp_path / ".codeclone" / "db" / "audit.sqlite3" + db_path.parent.mkdir(parents=True) + SqliteAuditWriter(db_path=db_path, payloads="compact", retention_days=30).close() + snapshot = build_setup_snapshot(tmp_path) + audit = next( + item for item in _capability_rows(snapshot) if item["id"] == "audit_and_intents" + ) + # DB exists but no events recorded yet -> attention, not ready, never blocked. + assert audit["readiness"] == "attention" + assert audit["runtime"] == "not_verified" + + +def test_setup_json_serialization_contract( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + snapshot = build_setup_snapshot(tmp_path) + rendered = json_text(snapshot, sort_keys=True, indent=True, trailing_newline=True) + assert rendered.endswith("\n") + roundtrip = json.loads(rendered) + assert roundtrip["schema_version"] == "1" + + +def test_setup_plan_proposes_pyproject_section( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', + encoding="utf-8", + ) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + + plan = build_setup_plan(tmp_path) + + assert plan["projection_kind"] == "setup_plan" + assert plan["read_only"] is True + assert plan["status"] == "ready" + assert isinstance(plan["plan_id"], str) and len(str(plan["plan_id"])) == 16 + actions = _plan_actions(plan) + merge = next(item for item in actions if item["kind"] == "pyproject_merge") + assert merge["capability_id"] == "analysis" + assert merge["changed_keys"] == ["baseline"] + preview = merge["preview"] + assert isinstance(preview, dict) + assert "tool.codeclone" in str(preview.get("unified_diff", "")) + + +def test_setup_plan_proposes_gitignore_append( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + + plan = build_setup_plan(tmp_path) + + gitignore = next( + item for item in _plan_actions(plan) if item["kind"] == "gitignore_append" + ) + assert gitignore["capability_id"] == "workspace_hygiene" + assert gitignore["lines"] == [".codeclone/"] + preview = gitignore["preview"] + assert isinstance(preview, dict) + assert ".codeclone/" in str(preview.get("unified_diff", "")) + + +def test_setup_plan_is_empty_when_satisfied( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + + plan = build_setup_plan(tmp_path) + + assert plan["status"] == "empty" + assert plan["actions"] == [] + + +def test_setup_plan_blocked_on_invalid_pyproject( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.codeclone]\nmin_loc = not-a-number\n", + encoding="utf-8", + ) + + plan = build_setup_plan(tmp_path) + + blockers = plan["blockers"] + assert isinstance(blockers, list) + blocker_rows = [ + cast(dict[str, object], item) for item in blockers if isinstance(item, dict) + ] + assert any(item.get("kind") == "invalid_pyproject" for item in blocker_rows) + assert not any( + item.get("kind") == "pyproject_merge" for item in _plan_actions(plan) + ) + + +def test_setup_plan_does_not_write_files( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + before = pyproject.read_text(encoding="utf-8") + + setup_main(["plan", "--root", str(tmp_path)]) + + assert pyproject.read_text(encoding="utf-8") == before + + +def test_setup_plan_json_has_no_edit_allowed( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', + encoding="utf-8", + ) + buf = io.StringIO() + with redirect_stdout(buf): + setup_main(["plan", "--json", "--root", str(tmp_path)]) + payload = json.loads(buf.getvalue()) + rendered = json.dumps(payload) + assert "edit_allowed" not in rendered + + +def test_setup_plan_idempotent( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', + encoding="utf-8", + ) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + + first = build_setup_plan(tmp_path) + second = build_setup_plan(tmp_path) + + assert first["plan_id"] == second["plan_id"] + + +def test_setup_apply_writes_pyproject_section( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', + encoding="utf-8", + ) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + + result = apply_setup_plan(tmp_path) + + assert result["projection_kind"] == "setup_apply" + assert result["status"] == "applied" + config = load_pyproject_config(tmp_path) + assert config["baseline"] == str(tmp_path / "codeclone.baseline.json") + assert "[tool.codeclone]" in (tmp_path / "pyproject.toml").read_text( + encoding="utf-8" + ) + + +def test_setup_apply_writes_gitignore( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + + result = apply_setup_plan(tmp_path) + + assert result["status"] == "applied" + assert ".codeclone/" in (tmp_path / ".gitignore").read_text(encoding="utf-8") + + +def test_setup_apply_noop_when_plan_empty( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + + result = apply_setup_plan(tmp_path) + + assert result["status"] == "noop" + assert result["results"] == [] + + +def test_setup_apply_dry_run_does_not_write( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + before = pyproject.read_text(encoding="utf-8") + + result = apply_setup_plan(tmp_path, dry_run=True) + + assert result["status"] == "preview" + assert result["dry_run"] is True + assert pyproject.read_text(encoding="utf-8") == before + + +def test_setup_apply_idempotent_when_already_satisfied( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + + first = apply_setup_plan(tmp_path) + second = apply_setup_plan(tmp_path) + + assert first["status"] == "noop" + assert second["status"] == "noop" + + +def test_setup_apply_main_exit_success( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + assert setup_main(["apply", "--yes", "--root", str(tmp_path)]) == int( + ExitCode.SUCCESS + ) + + +def test_setup_wizard_requires_tty( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + + assert setup_main(["wizard", "--root", str(tmp_path)]) == int( + ExitCode.CONTRACT_ERROR + ) + + +def test_setup_render_payload_default_console_respects_no_color( + monkeypatch: pytest.MonkeyPatch, +) -> None: + setup_main_mod = importlib.import_module("codeclone.surfaces.cli.setup.main") + calls: list[bool | None] = [] + rendered: list[PrinterLike] = [] + + def _fake_console(*, no_color: bool | None = None) -> PlainConsole: + calls.append(no_color) + return PlainConsole() + + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setattr(setup_main_mod, "make_query_console", _fake_console) + monkeypatch.setitem( + setup_main_mod._PAYLOAD_RENDERERS, + "status", + lambda console, _payload: rendered.append(console), + ) + + setup_main_mod._render_payload("status", {}) + + assert calls == [None] + assert len(rendered) == 1 + + +def test_setup_confirm_apply_default_console_respects_no_color( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + setup_main_mod = importlib.import_module("codeclone.surfaces.cli.setup.main") + calls: list[bool | None] = [] + + def _fake_console(*, no_color: bool | None = None) -> PlainConsole: + calls.append(no_color) + return PlainConsole() + + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setattr(sys.stdin, "isatty", lambda: True) + monkeypatch.setattr(sys.stdout, "isatty", lambda: True) + monkeypatch.setattr(setup_main_mod, "make_query_console", _fake_console) + monkeypatch.setattr( + setup_main_mod, + "build_setup_plan", + lambda _root: {"projection_kind": "setup_plan"}, + ) + monkeypatch.setattr(setup_main_mod, "render_setup_plan", lambda **_kwargs: None) + monkeypatch.setattr("builtins.input", lambda _prompt: "n") + + assert setup_main_mod._confirm_apply(tmp_path) is False + assert calls == [None] + + +def test_setup_apply_interactive_binds_confirmed_plan_id( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + setup_main_mod = importlib.import_module("codeclone.surfaces.cli.setup.main") + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + + monkeypatch.setattr(setup_main_mod, "_confirm_apply", lambda _root: "stale-plan") + + rc = setup_main_mod.setup_main(["apply", "--root", str(tmp_path)]) + + assert rc == int(ExitCode.CONTRACT_ERROR) + assert "[tool.codeclone]" not in pyproject.read_text(encoding="utf-8") + + +def test_setup_wizard_quit( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + choices = iter(["0"]) + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: next(choices), + confirm=lambda _message, _default: False, + ) + + assert run_setup_wizard(tmp_path, prompts=prompts) == int(ExitCode.SUCCESS) + + +def test_setup_wizard_default_console_respects_no_color( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup import wizard as wizard_mod + + calls: list[bool | None] = [] + + def _fake_console(*, no_color: bool | None = None) -> PlainConsole: + calls.append(no_color) + return PlainConsole() + + monkeypatch.setenv("NO_COLOR", "1") + monkeypatch.setattr(wizard_mod, "_interactive_terminal_available", lambda: True) + monkeypatch.setattr(wizard_mod, "make_query_console", _fake_console) + + assert wizard_mod.run_setup_wizard(tmp_path) == int(ExitCode.CONTRACT_ERROR) + assert calls == [None] + + +def test_setup_wizard_guided_apply( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', + encoding="utf-8", + ) + (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + choices = iter(["g", "0"]) + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: next(choices), + confirm=lambda _message, _default: True, + ) + + assert run_setup_wizard(tmp_path, prompts=prompts) == int(ExitCode.SUCCESS) + assert ".codeclone/" in (tmp_path / ".gitignore").read_text(encoding="utf-8") + assert "[tool.codeclone]" in (tmp_path / "pyproject.toml").read_text( + encoding="utf-8" + ) + + +def test_setup_wizard_guided_apply_skipped( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + before = pyproject.read_text(encoding="utf-8") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + choices = iter(["g", "0"]) + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: next(choices), + confirm=lambda _message, _default: False, + ) + + assert run_setup_wizard(tmp_path, prompts=prompts) == int(ExitCode.SUCCESS) + assert pyproject.read_text(encoding="utf-8") == before + + +def _fresh_apply_module() -> types.ModuleType: + return importlib.import_module("codeclone.surfaces.cli.setup.engine.apply") + + +def _fresh_setup_main() -> Callable[..., int]: + setup_main = importlib.import_module("codeclone.surfaces.cli.setup.main").setup_main + return cast(Callable[..., int], setup_main) + + +def _minimal_setup_snapshot(root: Path) -> dict[str, object]: + return { + "root": str(root), + "runtime": {"python_tag": "cp314", "codeclone_version": "2.1.0"}, + "maturity": { + "connected": False, + "governed": False, + "evidence_backed": False, + "team_ready": False, + "release_ready": False, + }, + "capabilities": [ + { + "id": "analysis", + "group": "project_knowledge", + "label": "Analysis", + "readiness": "attention", + "availability": "core", + "reason": "missing pyproject section", + "installation": "installed", + "configuration": "missing", + "runtime": "not_verified", + "evidence": ["probe:pyproject"], + "recommended_action": "run setup plan", + } + ], + } + + +def _rich_console() -> PrinterLike: + from rich.console import Console + + return cast(PrinterLike, Console(file=io.StringIO(), force_terminal=True)) + + +def test_setup_render_status_plain_and_rich(tmp_path: Path) -> None: + snapshot = _minimal_setup_snapshot(tmp_path) + plain = PlainConsole() + setup_render.render_setup_status(console=plain, snapshot=snapshot) + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_status(console=_rich_console(), snapshot=snapshot) + + +def test_setup_render_string_list_guard_requires_string_elements() -> None: + assert setup_render._is_string_list(["probe:pyproject"]) is True + assert setup_render._is_string_list(["probe:pyproject", 1]) is False + assert setup_render._is_string_list(("probe:pyproject",)) is False + + +def test_setup_render_doctor_plain_and_rich(tmp_path: Path) -> None: + snapshot = _minimal_setup_snapshot(tmp_path) + plain = PlainConsole() + setup_render.render_setup_doctor(console=plain, snapshot=snapshot) + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_doctor(console=_rich_console(), snapshot=snapshot) + + +def test_setup_render_plan_plain_and_rich(tmp_path: Path) -> None: + plan: dict[str, object] = { + "root": str(tmp_path), + "status": "ready", + "plan_id": "abcd1234ef567890", + "blockers": [{"kind": "invalid_pyproject", "reason": "bad min_loc"}], + "actions": [ + { + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "ready", + "preview": {"unified_diff": "+[tool.codeclone]\n"}, + } + ], + } + plain = PlainConsole() + setup_render.render_setup_plan(console=plain, plan=plan) + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_plan(console=_rich_console(), plan=plan) + setup_render.render_setup_plan( + console=_rich_console(), + plan={ + "status": "ready", + "plan_id": "preview00000000000", + "actions": [ + { + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "ready", + "preview": {"unified_diff": ""}, + }, + { + "kind": "gitignore_append", + "path": ".gitignore", + "status": "ready", + "preview": {"unified_diff": "+.codeclone/\n"}, + }, + ], + }, + ) + + empty_plan: dict[str, object] = {"status": "empty", "plan_id": "empty000000000000"} + setup_render.render_setup_plan(console=plain, plan=empty_plan) + + +def test_setup_render_apply_plain_and_rich(tmp_path: Path) -> None: + blocked: dict[str, object] = { + "status": "blocked", + "plan_id": "blocked0000000000", + "dry_run": False, + "results": [], + } + applied: dict[str, object] = { + "status": "applied", + "plan_id": "applied0000000000", + "dry_run": True, + "results": [ + { + "kind": "gitignore_append", + "path": ".gitignore", + "status": "applied", + "message": "ok", + } + ], + } + plain = PlainConsole() + setup_render.render_setup_apply(console=plain, result=blocked) + setup_render.render_setup_apply(console=plain, result=applied) + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_apply(console=_rich_console(), result=applied) + + +def test_setup_render_capability_table_and_helpers() -> None: + rows: list[Mapping[str, object]] = [ + {"label": "Analysis", "readiness": "ready", "reason": ""}, + ] + plain = PlainConsole() + setup_render.render_setup_capability_table(plain, rows) + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_capability_table(_rich_console(), rows) + assert setup_render.snapshot_capabilities({"capabilities": "not-a-list"}) == [] + assert setup_render.snapshot_capabilities({"capabilities": rows}) == rows + + +def test_setup_wizard_requires_rich_console( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + assert run_setup_wizard(tmp_path, console=PlainConsole()) == int( + ExitCode.CONTRACT_ERROR + ) + + +def test_setup_wizard_doctor_and_sphere( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + choices = iter(["d", "1", "0"]) + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: next(choices), + confirm=lambda _message, _default: False, + ) + assert run_setup_wizard(tmp_path, prompts=prompts) == int(ExitCode.SUCCESS) + + +def test_setup_wizard_guided_blocked_plan( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.codeclone]\nmin_loc = not-a-number\n", + encoding="utf-8", + ) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + choices = iter(["g", "0"]) + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: next(choices), + confirm=lambda _message, _default: True, + ) + assert run_setup_wizard(tmp_path, prompts=prompts) == int(ExitCode.CONTRACT_ERROR) + + +def test_setup_wizard_guided_empty_plan( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin.isatty", lambda: True) + monkeypatch.setattr("sys.stdout.isatty", lambda: True) + choices = iter(["g", "0"]) + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: next(choices), + confirm=lambda _message, _default: True, + ) + assert run_setup_wizard(tmp_path, prompts=prompts) == int(ExitCode.SUCCESS) + + +def test_setup_apply_blocked_and_failure_paths( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "pyproject.toml").write_text( + "[tool.codeclone]\nmin_loc = not-a-number\n", + encoding="utf-8", + ) + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + blocked = apply_setup_plan(tmp_path) + assert blocked["status"] == "blocked" + + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + + def _fail_merge(*_args: object, **_kwargs: object) -> None: + raise PyprojectWriterError("merge failed") + + apply_mod = _fresh_apply_module() + monkeypatch.setattr(apply_mod, "merge_tool_codeclone", _fail_merge) + failed = apply_mod.apply_setup_plan(tmp_path) + assert failed["status"] == "partial" + assert failed["results"][0]["status"] == "applied" + assert failed["results"][1]["status"] == "failed" + + +def test_setup_apply_unsupported_action_kind( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + plan = { + "root": str(tmp_path), + "plan_id": "test000000000000", + "status": "ready", + "actions": [ + { + "id": "bad-action", + "kind": "unknown_kind", + "path": "nowhere", + "status": "ready", + } + ], + } + apply_mod = _fresh_apply_module() + monkeypatch.setattr(apply_mod, "build_setup_plan", lambda _root: plan) + result = apply_mod.apply_setup_plan(tmp_path) + assert result["status"] == "failed" + + +def test_setup_main_invalid_root(tmp_path: Path) -> None: + missing = tmp_path / "missing-root" + assert setup_main(["status", "--root", str(missing)]) == int( + ExitCode.CONTRACT_ERROR + ) + + +def test_setup_main_apply_failed_exit( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + + def _fail_merge(*_args: object, **_kwargs: object) -> None: + raise PyprojectWriterError("merge failed") + + apply_mod = _fresh_apply_module() + monkeypatch.setattr(apply_mod, "merge_tool_codeclone", _fail_merge) + setup_main = _fresh_setup_main() + assert setup_main(["apply", "--yes", "--root", str(tmp_path)]) == int( + ExitCode.INTERNAL_ERROR + ) + + +def test_setup_main_doctor_command( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + buf = io.StringIO() + with redirect_stdout(buf): + rc = setup_main(["doctor", "--root", str(tmp_path)]) + assert rc == int(ExitCode.SUCCESS) + assert "doctor" in buf.getvalue().lower() or "analysis" in buf.getvalue().lower() + + +def test_setup_apply_dry_run_partial_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + + def _fail_merge(*_args: object, **_kwargs: object) -> None: + raise PyprojectWriterError("merge failed") + + apply_mod = _fresh_apply_module() + monkeypatch.setattr(apply_mod, "merge_tool_codeclone", _fail_merge) + result = apply_mod.apply_setup_plan(tmp_path, dry_run=True) + assert result["status"] == "preview" + assert result["dry_run"] is True + assert result["results"][-1]["status"] == "failed" + + +def test_setup_apply_gitignore_missing_lines(tmp_path: Path) -> None: + apply_mod = _fresh_apply_module() + action = { + "id": "gitignore_append:workspace_hygiene", + "kind": "gitignore_append", + "path": ".gitignore", + "status": "ready", + "lines": [], + } + row = apply_mod._apply_gitignore_append(tmp_path, action, dry_run=False) + assert row["status"] == "failed" + assert row["message"] == "missing lines" + + +def test_setup_rollup_optional_install_hints() -> None: + assert "codeclone[mcp]" in _optional_install_hint( + CapabilityMeta( + id="mcp_runtime", + group="governed_agent_workflows", + availability="optional_extra", + optional_extra_name="mcp", + ) + ) + assert _optional_install_hint( + CapabilityMeta( + id="semantic_retrieval", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="semantic-local", + ) + ).endswith("semantic-local") + assert ( + _optional_install_hint( + CapabilityMeta( + id="other", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="unknown-extra", + ) + ) + == "" + ) + + +def test_setup_rollup_axes_satisfied_branches() -> None: + meta = CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + requires_config=True, + requires_runtime_proof=True, + ) + assert not _axes_satisfied( + meta, + CapabilityAxes( + installation="unknown", + configuration="configured", + runtime="verified", + evidence=[], + ), + ) + assert not _axes_satisfied( + meta, + CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="verified", + evidence=[], + ), + ) + assert not _axes_satisfied( + meta, + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="unavailable", + evidence=[], + ), + ) + + +def test_setup_discover_audit_summary_oserror( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + db_path = tmp_path / ".codeclone" / "db" / "audit.sqlite3" + db_path.parent.mkdir(parents=True) + db_path.write_text("not sqlite", encoding="utf-8") + + def _raise_oserror(**_kwargs: object) -> None: + raise OSError("audit unreadable") + + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.read_audit_summary", + _raise_oserror, + ) + snapshot = build_setup_snapshot(tmp_path) + audit = next( + item for item in _capability_rows(snapshot) if item["id"] == "audit_and_intents" + ) + assert audit["readiness"] in {"attention", "optional", "ready", "blocked"} + + +def test_setup_main_payload_build_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + setup_main_mod = importlib.import_module("codeclone.surfaces.cli.setup.main") + + def _boom(_root: Path) -> dict[str, object]: + raise RuntimeError("snapshot failed") + + monkeypatch.setitem(setup_main_mod._PAYLOAD_BUILDERS, "status", _boom) + assert setup_main_mod.setup_main(["status", "--root", str(tmp_path)]) == int( + ExitCode.INTERNAL_ERROR + ) + + +def test_setup_apply_pyproject_merge_missing_updates(tmp_path: Path) -> None: + apply_mod = _fresh_apply_module() + row = apply_mod._apply_pyproject_merge( + tmp_path, + { + "id": "pyproject_merge:analysis", + "kind": "pyproject_merge", + "updates": "not-a-mapping", + }, + dry_run=False, + ) + assert row["status"] == "failed" + assert row["message"] == "missing updates" + + +def test_setup_apply_pyproject_merge_already_satisfied(tmp_path: Path) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + apply_mod = _fresh_apply_module() + row = apply_mod._apply_pyproject_merge( + tmp_path, + { + "id": "pyproject_merge:audit_and_intents", + "kind": "pyproject_merge", + "updates": {"audit_enabled": True}, + }, + dry_run=False, + ) + assert row["status"] == "skipped" + + +def test_setup_plan_missing_pyproject_blocker(tmp_path: Path) -> None: + plan = build_setup_plan(tmp_path) + blockers = plan["blockers"] + assert isinstance(blockers, list) + blocker_rows = [ + cast(dict[str, object], item) for item in blockers if isinstance(item, dict) + ] + assert any(item.get("kind") == "missing_pyproject" for item in blocker_rows) + + +def test_setup_rollup_derive_readiness_branches() -> None: + unsupported = CapabilityMeta( + id="legacy", + group="core_analysis", + availability="unsupported", + ) + axes = CapabilityAxes( + installation="installed", + configuration="configured", + runtime="verified", + evidence=[], + ) + assert derive_readiness(unsupported, axes) == "not_applicable" + + optional_unknown = CapabilityMeta( + id="mcp_runtime", + group="governed_agent_workflows", + availability="optional_extra", + optional_extra_name="mcp", + ) + assert ( + derive_readiness( + optional_unknown, + CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[], + ), + ) + == "attention" + ) + + built_in = CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + requires_config=True, + ) + assert ( + derive_readiness( + built_in, + CapabilityAxes( + installation="installed", + configuration="invalid", + runtime="verified", + evidence=[], + ), + ) + == "blocked" + ) + + assert ( + derive_readiness( + built_in, + CapabilityAxes( + installation="unknown", + configuration="unconfigured", + runtime="not_verified", + evidence=[], + ), + ) + == "attention" + ) + + assert ( + derive_readiness( + CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + requires_config=True, + requires_runtime_proof=True, + ), + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="unavailable", + evidence=[], + ), + ) + == "attention" + ) + + +def test_setup_wizard_default_prompts_rejects_plain_console() -> None: + from codeclone.surfaces.cli.setup.wizard import _default_wizard_prompts + + with pytest.raises(RuntimeError, match="Rich"): + _default_wizard_prompts(PlainConsole()) + + +def _capability_meta(capability_id: str) -> CapabilityMeta: + return next(item for item in CAPABILITY_REGISTRY if item.id == capability_id) + + +def _empty_discover_context(root: Path) -> DiscoverContext: + return DiscoverContext( + root_path=root, + config={}, + config_error=None, + has_codeclone_section=False, + baseline_path=root / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + + +def test_setup_presentation_handlers(tmp_path: Path) -> None: + ctx = _empty_discover_context(tmp_path) + mcp_meta = _capability_meta("mcp_runtime") + missing_axes = CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=[], + ) + readiness, reason, _action = _present( + mcp_meta, + missing_axes, + "optional", + ctx, + ) + assert readiness == "optional" + assert reason + + controlled_meta = _capability_meta("controlled_change") + readiness, _, _ = _present( + controlled_meta, + missing_axes, + "optional", + ctx, + ) + assert readiness == "optional" + + baseline_meta = _capability_meta("baseline") + readiness, _, _ = _present( + baseline_meta, + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_verified", + evidence=[], + ), + "ready", + ctx, + ) + assert readiness == "ready" + + +def test_setup_rollup_coverage_xml_hint() -> None: + hint = _optional_install_hint( + CapabilityMeta( + id="coverage_evidence", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="coverage-xml", + ) + ) + assert "coverage-xml" in hint + + +def test_setup_discover_audit_summary_exception( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + db_path = tmp_path / ".codeclone" / "db" / "audit.sqlite3" + db_path.parent.mkdir(parents=True) + db_path.write_text("x", encoding="utf-8") + + def _raise_runtime(**_kwargs: object) -> None: + raise RuntimeError("audit summary failed") + + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.read_audit_summary", + _raise_runtime, + ) + snapshot = build_setup_snapshot(tmp_path) + assert snapshot["schema_version"] == "1" + + +def test_setup_discover_client_config_from_cursor_mcp( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + cursor_dir = tmp_path / ".cursor" + cursor_dir.mkdir() + (cursor_dir / "mcp.json").write_text("{}", encoding="utf-8") + snapshot = build_setup_snapshot(tmp_path) + controlled = next( + item for item in _capability_rows(snapshot) if item["id"] == "controlled_change" + ) + assert controlled["readiness"] in {"attention", "optional", "ready"} + + +def test_setup_presentation_default_for_unknown_capability(tmp_path: Path) -> None: + ctx = _empty_discover_context(tmp_path) + unknown_meta = CapabilityMeta( + id="not_registered_capability", + group="core_analysis", + availability="built_in", + ) + readiness, reason, action = _present( + unknown_meta, + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="verified", + evidence=[], + ), + "attention", + ctx, + ) + assert readiness == "attention" + assert reason + assert action == "" + + +def test_setup_main_apply_blocked_exit_code() -> None: + setup_main_mod = importlib.import_module("codeclone.surfaces.cli.setup.main") + assert setup_main_mod._exit_code_for_apply("blocked") == int( + ExitCode.CONTRACT_ERROR + ) + assert setup_main_mod._exit_code_for_apply("stale_plan") == int( + ExitCode.CONTRACT_ERROR + ) + assert setup_main_mod._exit_code_for_apply("partial") == int( + ExitCode.INTERNAL_ERROR + ) + assert setup_main_mod._exit_code_for_apply("applied") == int(ExitCode.SUCCESS) + + +def test_setup_rollup_analytics_optional_hint() -> None: + hint = _optional_install_hint( + CapabilityMeta( + id="analytics_cockpit", + group="project_knowledge", + availability="optional_extra", + optional_extra_name="analytics", + ) + ) + assert hint + + +def test_setup_discover_vscode_settings_client_config( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + (vscode_dir / "settings.json").write_text( + '{"mcp.servers": {"codeclone": {}}}', + encoding="utf-8", + ) + snapshot = build_setup_snapshot(tmp_path) + maturity = cast(dict[str, object], snapshot["maturity"]) + assert maturity["connected"] is True + + +def test_setup_apply_gitignore_post_write_verification_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + apply_mod = _fresh_apply_module() + monkeypatch.setattr( + apply_mod, + "repo_gitignore_covers_codeclone_cache", + lambda _root: False, + ) + row = apply_mod._apply_gitignore_append( + tmp_path, + { + "id": "gitignore_append:workspace_hygiene", + "kind": "gitignore_append", + "lines": [".codeclone/"], + }, + dry_run=False, + ) + assert row["status"] == "failed" + assert row["message"] == "post-write verification failed" + + +def test_setup_derive_readiness_unconfigured_requires_config() -> None: + meta = CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + requires_config=True, + ) + assert ( + derive_readiness( + meta, + CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_verified", + evidence=[], + ), + ) + == "attention" + ) + + +def test_setup_discover_root_mcp_json_client_config( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / ".mcp.json").write_text("{}", encoding="utf-8") + snapshot = build_setup_snapshot(tmp_path) + maturity = cast(dict[str, object], snapshot["maturity"]) + assert maturity["connected"] is True + + +def test_setup_derive_readiness_optional_extra_invalid_config() -> None: + meta = CapabilityMeta( + id="mcp_runtime", + group="governed_agent_workflows", + availability="optional_extra", + optional_extra_name="mcp", + ) + assert ( + derive_readiness( + meta, + CapabilityAxes( + installation="installed", + configuration="invalid", + runtime="not_required", + evidence=[], + ), + ) + == "attention" + ) + + +def test_setup_presentation_default_ready_path(tmp_path: Path) -> None: + ctx = _empty_discover_context(tmp_path) + readiness, reason, action = _present( + CapabilityMeta( + id="not_registered_capability", + group="core_analysis", + availability="built_in", + ), + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="verified", + evidence=[], + ), + "ready", + ctx, + ) + assert readiness == "ready" + assert reason == "" + assert action == "" + + +def test_setup_apply_ignores_non_list_plan_actions( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + apply_mod = _fresh_apply_module() + monkeypatch.setattr( + apply_mod, + "build_setup_plan", + lambda _root: { + "root": str(tmp_path), + "plan_id": "bad0000000000000", + "status": "ready", + "actions": "not-a-list", + }, + ) + result = apply_mod.apply_setup_plan(tmp_path) + assert result["status"] == "noop" + + +def test_setup_apply_gitignore_read_oserror( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + gitignore = tmp_path / ".gitignore" + gitignore.write_text("node_modules/\n", encoding="utf-8") + apply_mod = _fresh_apply_module() + original_read_text = Path.read_text + + def _patched_read_text( + self: Path, + encoding: str | None = None, + errors: str | None = None, + ) -> str: + if self == gitignore: + raise OSError("read failed") + return original_read_text(self, encoding=encoding, errors=errors) + + monkeypatch.setattr(Path, "read_text", _patched_read_text) + row = apply_mod._apply_gitignore_append( + tmp_path, + { + "id": "gitignore_append:workspace_hygiene", + "kind": "gitignore_append", + "lines": [".codeclone/"], + }, + dry_run=False, + ) + assert row["status"] == "failed" + assert "read failed" in str(row["message"]) + + +def test_setup_discover_probe_unknown_capability(tmp_path: Path) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + ctx = _empty_discover_context(tmp_path) + probed = discover_mod._probe_capability( + CapabilityMeta( + id="capability_without_probe", + group="core_analysis", + availability="built_in", + ), + ctx, + ) + assert probed.axes.installation == "unknown" + assert probed.axes.evidence == ["probe:unknown:capability"] + + +def test_setup_apply_gitignore_write_oserror( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + apply_mod = _fresh_apply_module() + + def _raise_oserror(*_args: object, **_kwargs: object) -> None: + raise OSError("write failed") + + monkeypatch.setattr(apply_mod, "write_gitignore_text_atomically", _raise_oserror) + row = apply_mod._apply_gitignore_append( + tmp_path, + { + "id": "gitignore_append:workspace_hygiene", + "kind": "gitignore_append", + "lines": [".codeclone/"], + }, + dry_run=False, + ) + assert row["status"] == "failed" + assert row["message"] == "write failed" + + +def test_setup_discover_vscode_settings_symlink_refused( + tmp_path: Path, +) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir() + settings = vscode_dir / "settings.json" + settings.write_text('{"mcp.servers": {"codeclone": {}}}', encoding="utf-8") + assert _client_config_present(tmp_path) is True + # A symlinked settings file is refused (fail closed), so not detected. + settings.unlink() + settings.symlink_to(tmp_path / "does-not-exist.json") + assert _client_config_present(tmp_path) is False + + +def test_setup_rollup_axes_satisfied_invalid_configuration() -> None: + meta = CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + ) + assert not _axes_satisfied( + meta, + CapabilityAxes( + installation="installed", + configuration="invalid", + runtime="verified", + evidence=[], + ), + ) + + +def test_setup_discover_audit_resolve_and_summary_errors( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine.discover import build_discover_context + + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=True) + + def _resolve_oserror(**_kwargs: object) -> None: + raise OSError("audit path failed") + + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.resolve_audit_path", + _resolve_oserror, + ) + ctx = build_discover_context(tmp_path) + assert ctx.audit_db_exists is False + + db_path = tmp_path / ".codeclone" / "db" / "audit.sqlite3" + db_path.parent.mkdir(parents=True) + db_path.write_bytes(b"present") + + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.resolve_audit_path", + lambda **_kwargs: db_path, + ) + + def _summary_runtime_error(**_kwargs: object) -> None: + raise RuntimeError("summary failed") + + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.read_audit_summary", + _summary_runtime_error, + ) + ctx_after_summary_error = build_discover_context(tmp_path) + assert ctx_after_summary_error.audit_db_exists is False + assert ctx_after_summary_error.audit_summary_events == 0 + + +def test_setup_discover_analysis_find_spec_import_error( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + real_find_spec = importlib.util.find_spec + + def _raising_find_spec(name: str, package: object | None = None) -> object | None: + if name == "codeclone.core": + raise ImportError("blocked import") + return real_find_spec(name, cast(str | None, package)) + + monkeypatch.setattr(importlib.util, "find_spec", _raising_find_spec) + _write_minimal_pyproject(tmp_path / "pyproject.toml") + snapshot = build_setup_snapshot(tmp_path) + analysis = next( + item for item in _capability_rows(snapshot) if item["id"] == "analysis" + ) + assert analysis["installation"] == "unknown" + + +def test_setup_discover_github_and_pre_commit_probes( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + ctx = _empty_discover_context(tmp_path) + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "ci.yml").write_text("name: ci\n", encoding="utf-8") + (workflows / "codeclone.yml").write_text( + "uses: orenlab/codeclone-action\n", + encoding="utf-8", + ) + github_axes = discover_mod._probe_github_workflow(ctx) + assert github_axes.configuration == "configured" + + # A workflow that is a refused symlink is the only entry -> unknown install. + broken_root = tmp_path / "broken-workflows" + broken_root.mkdir() + broken_ctx = _empty_discover_context(broken_root) + only_unreadable = broken_root / ".github" / "workflows" + only_unreadable.mkdir(parents=True) + (only_unreadable / "broken.yml").symlink_to(broken_root / "missing.yml") + broken_axes = discover_mod._probe_github_workflow(broken_ctx) + assert broken_axes.installation == "unknown" + + pre_commit = tmp_path / ".pre-commit-config.yaml" + pre_commit.write_text("repos:\n - repo: local\n", encoding="utf-8") + assert discover_mod._probe_pre_commit_hook(ctx).configuration == "unconfigured" + + pre_commit.write_text("repos:\n - repo: codeclone\n", encoding="utf-8") + assert discover_mod._probe_pre_commit_hook(ctx).configuration == "configured" + + # A symlinked pre-commit config is refused -> unknown installation. + symlink_root = tmp_path / "symlink-precommit" + symlink_root.mkdir() + symlink_ctx = _empty_discover_context(symlink_root) + (symlink_root / ".pre-commit-config.yaml").symlink_to(symlink_root / "missing.yaml") + unreadable_pre_commit = discover_mod._probe_pre_commit_hook(symlink_ctx) + assert unreadable_pre_commit.installation == "unknown" + + +def test_setup_discover_semantic_and_ci_policy_probes( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + ctx = DiscoverContext( + root_path=tmp_path, + config={"semantic_enabled": True}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + memory_report=None, + ) + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.check_capability", + lambda name: type("Status", (), {"available": name == "embed"})(), + ) + semantic_axes = discover_mod._probe_semantic_retrieval(ctx) + assert semantic_axes.runtime == "not_verified" + + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / "pyproject.toml").write_text( + '[tool.codeclone]\nci = true\nbaseline = "missing.json"\n', + encoding="utf-8", + ) + ci_ctx = build_setup_snapshot(tmp_path) + ci_policy = next( + item for item in _capability_rows(ci_ctx) if item["id"] == "ci_policy" + ) + assert ci_policy["configuration"] == "unconfigured" + + +def test_setup_discover_baseline_status_oserror( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.baseline import Baseline + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + baseline_path = tmp_path / "codeclone.baseline.json" + baseline_path.write_text("{", encoding="utf-8") + + def _load_oserror(self: Baseline, **_kwargs: object) -> None: + raise OSError("baseline unreadable") + + monkeypatch.setattr(Baseline, "load", _load_oserror) + # An unreadable baseline is "unknown" (fail closed), not "invalid JSON". + assert discover_mod._probe_baseline_status(baseline_path) is None + + +def test_setup_discover_tool_codeclone_section_edge_cases(tmp_path: Path) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + (tmp_path / "pyproject.toml").write_text("tool = 1\n", encoding="utf-8") + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + (tmp_path / "pyproject.toml").write_text( + '[project]\nname = "demo"\n', encoding="utf-8" + ) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + +def test_setup_presentation_controlled_change_unconfigured(tmp_path: Path) -> None: + ctx = _empty_discover_context(tmp_path) + meta = _capability_meta("controlled_change") + readiness, reason, action = _present( + meta, + CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[], + ), + "attention", + ctx, + ) + assert readiness == "attention" + assert reason + assert action + + +def test_setup_presentation_default_optional_path(tmp_path: Path) -> None: + ctx = _empty_discover_context(tmp_path) + readiness, reason, action = _present( + CapabilityMeta( + id="not_registered_capability", + group="core_analysis", + availability="optional_extra", + optional_extra_name="mcp", + ), + CapabilityAxes( + installation="missing", + configuration="not_required", + runtime="not_required", + evidence=[], + ), + "optional", + ctx, + ) + assert readiness == "optional" + assert reason + assert action + + +def test_setup_presentation_baseline_untrusted(tmp_path: Path) -> None: + from codeclone.baseline.trust import BaselineStatus + + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=BaselineStatus.INVALID_JSON, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + meta = _capability_meta("baseline") + readiness, reason, action = _present( + meta, + CapabilityAxes( + installation="installed", + configuration="unconfigured", + runtime="not_required", + evidence=[], + ), + "attention", + ctx, + ) + assert readiness == "attention" + assert reason + assert action + + +# --------------------------------------------------------------------------- +# Isolation / permission regression (§17.2, §17.4, AC-01, AC-09) +# --------------------------------------------------------------------------- + + +def test_setup_main_does_not_import_mcp_surface( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + removed_mcp_modules = _pop_modules_with_prefix("codeclone.surfaces.mcp") + try: + buf = io.StringIO() + with redirect_stdout(buf): + assert setup_main(["status", "--json", "--root", str(tmp_path)]) == int( + ExitCode.SUCCESS + ) + assert not any( + name.startswith("codeclone.surfaces.mcp") for name in sys.modules + ) + finally: + _restore_modules(removed_mcp_modules) + + +def test_setup_does_not_create_intents( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + for command in ("status", "doctor", "plan"): + with redirect_stdout(io.StringIO()): + setup_main([command, "--root", str(tmp_path)]) + assert not (tmp_path / ".codeclone" / "intents").exists() + + +# --------------------------------------------------------------------------- +# Mutation safety: confirmation gate and plan-id binding (§3.3, TOCTOU) +# --------------------------------------------------------------------------- + + +def test_apply_refuses_without_yes_noninteractive( + tmp_path: Path, + base_install_find_spec: None, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + monkeypatch.setattr("sys.stdin.isatty", lambda: False) + monkeypatch.setattr("sys.stdout.isatty", lambda: False) + before = pyproject.read_text(encoding="utf-8") + rc = setup_main(["apply", "--root", str(tmp_path)]) + assert rc == int(ExitCode.CONTRACT_ERROR) + assert pyproject.read_text(encoding="utf-8") == before + + +def test_ready_actions_filters_ready_dict_items_and_preserves_identity() -> None: + ready_action: dict[str, object] = {"id": "b", "status": "ready"} + pending_action: dict[str, object] = {"id": "a", "status": "pending"} + plan = { + "actions": [ + "skip", + ready_action, + pending_action, + {"id": "c", "status": "ready"}, + ] + } + + ready = _ready_actions(plan) + + assert [item["id"] for item in ready] == ["b", "c"] + assert ready[0] is ready_action + + +def test_apply_stale_plan_id_refused_no_write( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") + result = apply_setup_plan(tmp_path, expected_plan_id="deadbeefdeadbeef") + assert result["status"] == "stale_plan" + assert "[tool.codeclone]" not in pyproject.read_text(encoding="utf-8") + + +def test_apply_matching_plan_id_proceeds( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text('[project]\nname = "demo"\n', encoding="utf-8") + (tmp_path / ".gitignore").write_text(".codeclone/\n", encoding="utf-8") + plan = build_setup_plan(tmp_path) + result = apply_setup_plan(tmp_path, expected_plan_id=str(plan["plan_id"])) + assert result["status"] == "applied" + + +# --------------------------------------------------------------------------- +# Flag validation (§10.9 grammar) +# --------------------------------------------------------------------------- + + +def test_flag_validation_rejects_dry_run_on_status( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + assert setup_main(["status", "--dry-run", "--root", str(tmp_path)]) == int( + ExitCode.CONTRACT_ERROR + ) + + +def test_flag_validation_rejects_wizard_json( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + assert setup_main(["wizard", "--json", "--root", str(tmp_path)]) == int( + ExitCode.CONTRACT_ERROR + ) + + +# --------------------------------------------------------------------------- +# Readiness honesty regressions (§8.5, §10.4.3) +# --------------------------------------------------------------------------- + + +def test_ci_policy_not_enabled_is_attention( + tmp_path: Path, + base_install_find_spec: None, +) -> None: + _write_minimal_pyproject(tmp_path / "pyproject.toml") + snapshot = build_setup_snapshot(tmp_path) + ci_policy = next( + item for item in _capability_rows(snapshot) if item["id"] == "ci_policy" + ) + assert ci_policy["configuration"] == "unconfigured" + assert ci_policy["readiness"] == "attention" + + +def test_semantic_without_store_is_attention( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + monkeypatch.setattr( + "codeclone.surfaces.cli.setup.engine.discover.check_capability", + lambda name: type( + "Status", (), {"available": name == "embed", "missing_packages": []} + )(), + ) + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + memory_report=None, + ) + axes = discover_mod._probe_semantic_retrieval(ctx) + # Packages present but no memory store to index -> unconfigured -> attention. + assert axes.installation == "installed" + assert axes.configuration == "unconfigured" + assert derive_readiness(_capability_meta("semantic_retrieval"), axes) == "attention" + + +def test_governed_maturity_reachable_when_mcp_configured( + tmp_path: Path, +) -> None: + # Regression for the maturity.governed inversion: installing AND configuring + # MCP must make controlled_change ready and governed True. + if importlib.util.find_spec("mcp") is None: + pytest.skip("mcp extra not installed in this environment") + _write_minimal_pyproject(tmp_path / "pyproject.toml") + (tmp_path / ".mcp.json").write_text("{}", encoding="utf-8") + snapshot = build_setup_snapshot(tmp_path) + controlled = next( + item for item in _capability_rows(snapshot) if item["id"] == "controlled_change" + ) + assert controlled["readiness"] == "ready" + maturity = cast(dict[str, object], snapshot["maturity"]) + assert maturity["governed"] is True + + +def test_setup_discover_analysis_missing_core_spec( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + monkeypatch.setattr(importlib.util, "find_spec", lambda _name: None) + axes = discover_mod._probe_analysis(_empty_discover_context(tmp_path)) + assert axes.installation == "unknown" + + +def test_setup_discover_baseline_symlink_and_untrusted_status( + tmp_path: Path, +) -> None: + from codeclone.baseline.trust import BaselineStatus + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + symlink_root = tmp_path / "baseline-symlink" + symlink_root.mkdir() + (symlink_root / "codeclone.baseline.json").symlink_to(symlink_root / "missing.json") + symlink_axes = discover_mod._probe_baseline( + DiscoverContext( + root_path=symlink_root, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=symlink_root / "codeclone.baseline.json", + baseline_status=BaselineStatus.OK, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + ) + assert symlink_axes.installation == "unknown" + + baseline_path = tmp_path / "codeclone.baseline.json" + baseline_path.write_text("{}", encoding="utf-8") + unreadable_axes = discover_mod._probe_baseline( + DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=baseline_path, + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + ) + assert unreadable_axes.installation == "unknown" + + untrusted_axes = discover_mod._probe_baseline( + DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=BaselineStatus.INVALID_JSON, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + ) + assert untrusted_axes.configuration == "unconfigured" + assert "probe:baseline:status:invalid_json" in untrusted_axes.evidence + + +def test_setup_discover_github_workflow_skips_non_yaml_and_reports_missing( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + ctx = _empty_discover_context(tmp_path) + workflows = tmp_path / ".github" / "workflows" + workflows.mkdir(parents=True) + (workflows / "README.md").write_text("not a workflow", encoding="utf-8") + (workflows / "ci.txt").write_text("still not yaml", encoding="utf-8") + missing_axes = discover_mod._probe_github_workflow(ctx) + assert missing_axes.configuration == "unconfigured" + assert "probe:file:.github/workflows:not_found" in missing_axes.evidence + + +def test_setup_discover_extra_installed_unknown_extra_returns_false() -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + assert discover_mod._extra_installed("not-a-real-extra") is False + + +def test_setup_plan_blocked_pyproject_merge_and_gitignore_read_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.config.pyproject_writer import PyprojectWriterError + from codeclone.surfaces.cli.setup.engine import plan as plan_mod + + _write_minimal_pyproject(tmp_path / "pyproject.toml") + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + gitignore_covers_cache=False, + ) + + def _raise_writer_error(*_args: object, **_kwargs: object) -> object: + raise PyprojectWriterError("blocked merge") + + monkeypatch.setattr(plan_mod, "merge_tool_codeclone", _raise_writer_error) + blocked = plan_mod._plan_pyproject_merge( + ctx, + capability_id="analysis", + updates={"min_loc": 7}, + ) + assert blocked is not None + assert blocked["status"] == "blocked" + + gitignore = tmp_path / ".gitignore" + + def _read_text(_self: object, *_args: object, **_kwargs: object) -> str: + raise OSError("permission denied") + + monkeypatch.setattr(type(gitignore), "is_file", lambda _self: True, raising=False) + monkeypatch.setattr(type(gitignore), "read_text", _read_text, raising=False) + blocked_gitignore = plan_mod._plan_gitignore_append(ctx) + assert blocked_gitignore is not None + assert blocked_gitignore["status"] == "blocked" + + +def test_setup_plan_status_derivation_and_empty_diff( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup.engine import plan as plan_mod + + assert plan_mod._derive_plan_status([], [{"code": "x"}]) == "blocked" + assert plan_mod._derive_plan_status([], []) == "empty" + assert plan_mod._unified_diff("", "", "pyproject.toml") == "" + + +def test_setup_wizard_helper_exit_paths_and_rich_requirement() -> None: + from codeclone.surfaces.cli.setup import wizard as wizard_mod + + assert wizard_mod._guided_plan_exit(PlainConsole(), "unknown-status") is None + assert wizard_mod._apply_result_exit("applied") is None + assert wizard_mod._apply_result_exit("blocked") == int(ExitCode.CONTRACT_ERROR) + assert wizard_mod._apply_result_exit("failed") == int(ExitCode.INTERNAL_ERROR) + with pytest.raises(RuntimeError, match="Rich"): + wizard_mod._default_wizard_prompts(PlainConsole()) + + +def test_setup_discover_controlled_change_unconfigured_client( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=True, + client_config_present=False, + ) + axes = discover_mod._probe_controlled_change(ctx) + assert axes.configuration == "unconfigured" + assert "probe:client:mcp_config:missing" in axes.evidence + + +def test_setup_discover_ci_policy_metrics_section_without_payload( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.baseline.metrics_baseline import MetricsBaselineSectionProbe + from codeclone.baseline.trust import BaselineStatus + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + (tmp_path / "pyproject.toml").write_text( + "[tool.codeclone]\n" + 'baseline = "codeclone.baseline.json"\n' + "ci = true\n" + "fail_on_new = true\n", + encoding="utf-8", + ) + (tmp_path / "codeclone.baseline.json").write_text("{}", encoding="utf-8") + ctx = DiscoverContext( + root_path=tmp_path, + config={"ci": True, "fail_on_new": True}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=BaselineStatus.OK, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + + monkeypatch.setattr( + discover_mod, + "probe_metrics_baseline_section", + lambda _path: MetricsBaselineSectionProbe( + has_metrics_section=True, + payload=None, + ), + ) + axes = discover_mod._probe_ci_policy(ctx) + assert axes.configuration == "unconfigured" + assert "probe:metrics_baseline:section" in axes.evidence + + +def test_setup_discover_baseline_status_maps_validation_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.baseline.clone_baseline import Baseline + from codeclone.baseline.trust import BaselineStatus + from codeclone.contracts.errors import BaselineValidationError + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + baseline_path = tmp_path / "codeclone.baseline.json" + baseline_path.write_text("{}", encoding="utf-8") + + def _raise_validation(self: Baseline, **_kwargs: object) -> None: + raise BaselineValidationError("invalid", status="invalid_json") + + monkeypatch.setattr(Baseline, "load", _raise_validation) + assert ( + discover_mod._probe_baseline_status(baseline_path) + == BaselineStatus.INVALID_JSON + ) + + +def test_setup_discover_tool_codeclone_section_tomli_and_read_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + (tmp_path / "pyproject.toml").write_text( + "[tool.codeclone]\nmin_loc = 5\n", + encoding="utf-8", + ) + assert discover_mod._tool_codeclone_section_present(tmp_path) is True + + monkeypatch.setattr(discover_mod, "sys", sys) + monkeypatch.setattr(discover_mod, "importlib", importlib) + monkeypatch.setattr(sys, "version_info", (3, 10, 0, "final", 0)) + + def _missing_tomli(_name: str) -> object: + raise ModuleNotFoundError("tomli") + + monkeypatch.setattr(importlib, "import_module", _missing_tomli) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + invalid = tmp_path / "pyproject.toml" + invalid.write_text("not valid toml", encoding="utf-8") + if sys.version_info >= (3, 11): + import tomllib + + monkeypatch.setattr( + tomllib, + "load", + lambda _handle: (_ for _ in ()).throw(ValueError("bad toml")), + ) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + +def test_setup_discover_safe_read_text_oserror(tmp_path: Path) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + target = tmp_path / "settings.json" + target.write_text("{}", encoding="utf-8") + target.chmod(0o000) + try: + text, existed = discover_mod._safe_read_text(target) + assert text is None + assert existed is True + finally: + target.chmod(0o644) + + +def test_setup_plan_gitignore_noop_when_append_is_unchanged( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import plan as plan_mod + + gitignore = tmp_path / ".gitignore" + gitignore.write_text(".cache/\n", encoding="utf-8") + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + gitignore_covers_cache=False, + ) + monkeypatch.setattr( + plan_mod, + "append_gitignore_line", + lambda before_text, _line: before_text, + ) + assert plan_mod._plan_gitignore_append(ctx) is None + + +def test_setup_plan_compute_plan_id_ignores_non_action_items() -> None: + from codeclone.surfaces.cli.setup.engine import plan as plan_mod + + plan_id = plan_mod._compute_plan_id( + { + "root": "/tmp/demo", + "head_commit": None, + "blockers": [], + "actions": ["not-an-action", {"id": "x", "status": "ready"}], + } + ) + assert len(plan_id) == 16 + + +def test_setup_rollup_guidance_branches(tmp_path: Path) -> None: + from codeclone.baseline.trust import BaselineStatus + from codeclone.memory.status_report import MemoryStatusReport + from codeclone.surfaces.cli.setup.engine import rollup as rollup_mod + + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=BaselineStatus.MISSING, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + baseline_meta = _capability_meta("baseline") + unreadable_reason, unreadable_action = rollup_mod._describe_baseline( + baseline_meta, + CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[], + ), + "attention", + ctx, + ) + assert unreadable_reason + assert "codeclone.baseline.json" in unreadable_action + + generic_reason, _generic_action = rollup_mod._describe_generic( + CapabilityMeta( + id="custom", + group="core_analysis", + availability="built_in", + ), + CapabilityAxes( + installation="unknown", + configuration="configured", + runtime="verified", + evidence=[], + ), + "attention", + ctx, + ) + assert generic_reason + + not_applicable_reason, _ = rollup_mod._describe_generic( + CapabilityMeta( + id="custom", + group="core_analysis", + availability="built_in", + ), + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="verified", + evidence=[], + ), + "not_applicable", + ctx, + ) + assert not_applicable_reason + + memory_meta = _capability_meta("engineering_memory") + empty_store_reason, _ = rollup_mod._describe_engineering_memory( + memory_meta, + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=[], + ), + "attention", + DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + memory_report=MemoryStatusReport( + db_path=tmp_path / ".codeclone/memory/engineering_memory.sqlite3", + schema_version="1.1", + project_id="proj-1", + project_root=str(tmp_path), + backend="sqlite", + git_available=False, + git_branch=None, + git_head=None, + last_analysis_fingerprint=None, + last_init_run_id=None, + record_count=0, + records_by_type={}, + records_by_status={}, + db_exists=True, + ), + ), + ) + assert empty_store_reason + + semantic_meta = _capability_meta("semantic_retrieval") + disabled_reason, _ = rollup_mod._describe_semantic_retrieval( + semantic_meta, + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=[], + ), + "attention", + DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + memory_report=MemoryStatusReport( + db_path=tmp_path / ".codeclone/memory/engineering_memory.sqlite3", + schema_version="1.1", + project_id="proj-1", + project_root=str(tmp_path), + backend="sqlite", + git_available=False, + git_branch=None, + git_head=None, + last_analysis_fingerprint=None, + last_init_run_id=None, + record_count=1, + records_by_type={"module_role": 1}, + records_by_status={"active": 1}, + db_exists=True, + ), + ), + ) + assert disabled_reason + + +def test_setup_wizard_sphere_and_mapping_helpers(tmp_path: Path) -> None: + from codeclone.surfaces.cli.setup import wizard as wizard_mod + from codeclone.surfaces.cli.setup.engine.capabilities import GROUP_ORDER + + console = _rich_console() + wizard_mod._render_sphere( + console, + {"capabilities": []}, + group=GROUP_ORDER[0], + ) + assert wizard_mod._group_summary({"capabilities": []}, GROUP_ORDER[0]) + assert wizard_mod._group_for_choice("999") is None + assert wizard_mod._mapping("not-a-mapping") == {} + + pytest.importorskip("rich") + from rich.console import Console + + rich_console = Console(file=io.StringIO(), force_terminal=True) + prompts = wizard_mod._default_wizard_prompts(cast(PrinterLike, rich_console)) + assert prompts.ask_choice is not None + assert prompts.confirm is not None + + +def test_setup_render_plain_branches(tmp_path: Path) -> None: + snapshot = _minimal_setup_snapshot(tmp_path) + plain = PlainConsole() + setup_render._render_status_plain(plain, snapshot) + setup_render._render_doctor_plain(plain, snapshot) + assert setup_render._availability_label("unknown_kind") == "unknown_kind" + + plan: dict[str, object] = { + "status": "blocked", + "plan_id": "plan00000000000000", + "blockers": ["not-a-mapping"], + "actions": [ + { + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "ready", + "preview": "not-a-mapping", + } + ], + } + setup_render._render_plan_plain(plain, plan) + setup_render._render_apply_plain( + plain, + {"status": "blocked", "plan_id": "blocked0000000000", "dry_run": False}, + ) + + +def test_setup_wizard_process_hub_choice_renders_sphere( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup import wizard as wizard_mod + + snapshot = _minimal_setup_snapshot(tmp_path) + console = _rich_console() + prompts = WizardPrompts( + ask_choice=lambda _message, _choices: "1", + confirm=lambda _message, _default: False, + ) + assert ( + wizard_mod._process_hub_choice( + "1", + root_path=tmp_path, + console=console, + snapshot=snapshot, + prompts=prompts, + ) + is None + ) + + +def test_setup_wizard_guided_apply_failure_returns_internal_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup import wizard as wizard_mod + + monkeypatch.setattr( + wizard_mod, + "build_setup_plan", + lambda _root: {"status": "ready", "plan_id": "plan-1", "actions": []}, + ) + monkeypatch.setattr( + wizard_mod, + "apply_setup_plan", + lambda *_args, **_kwargs: {"status": "failed"}, + ) + monkeypatch.setattr(wizard_mod, "render_setup_plan", lambda **_kwargs: None) + monkeypatch.setattr(wizard_mod, "render_setup_apply", lambda **_kwargs: None) + + exit_code = wizard_mod._run_guided_setup( + tmp_path, + console=_rich_console(), + prompts=WizardPrompts( + ask_choice=lambda _message, _choices: "g", + confirm=lambda _message, _default: True, + ), + ) + assert exit_code == int(ExitCode.INTERNAL_ERROR) + + +def test_setup_main_confirmation_gate_decline_and_non_tty( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + setup_main_mod = importlib.import_module("codeclone.surfaces.cli.setup.main") + + monkeypatch.setattr(setup_main_mod, "_confirm_apply", lambda _root: False) + plan_id, exit_code = setup_main_mod._confirmation_gate(tmp_path) + assert plan_id is None + assert exit_code == int(ExitCode.SUCCESS) + + monkeypatch.setattr(setup_main_mod, "_confirm_apply", lambda _root: None) + plan_id, exit_code = setup_main_mod._confirmation_gate(tmp_path) + assert plan_id is None + assert exit_code == int(ExitCode.CONTRACT_ERROR) + + +def test_setup_discover_tool_codeclone_python310_tomli_branches( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + (tmp_path / "pyproject.toml").write_text( + "[tool.codeclone]\nmin_loc = 3\n", + encoding="utf-8", + ) + monkeypatch.setattr(discover_mod, "sys", sys) + monkeypatch.setattr(discover_mod, "importlib", importlib) + monkeypatch.setattr(sys, "version_info", (3, 10, 0, "final", 0)) + + class _TomliNoLoad: + pass + + monkeypatch.setattr( + importlib, + "import_module", + lambda name: ( + _TomliNoLoad() if name == "tomli" else importlib.import_module(name) + ), + ) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + class _TomliBadLoad: + load = "not-callable" + + monkeypatch.setattr( + importlib, + "import_module", + lambda name: ( + _TomliBadLoad() if name == "tomli" else importlib.import_module(name) + ), + ) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + +def test_setup_discover_tool_codeclone_open_oserror_and_non_dict_payload( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import discover as discover_mod + + pyproject = tmp_path / "pyproject.toml" + pyproject.write_text("[tool.codeclone]\nmin_loc = 1\n", encoding="utf-8") + + real_open = Path.open + + def _open(self: Path, *args: Any, **kwargs: Any) -> Any: + if self == pyproject: + raise OSError("denied") + return real_open(self, *args, **kwargs) + + monkeypatch.setattr(Path, "open", _open) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + if sys.version_info >= (3, 11): + import tomllib + + monkeypatch.setattr(tomllib, "load", lambda _handle: ["not", "a", "dict"]) + assert discover_mod._tool_codeclone_section_present(tmp_path) is False + + +def test_setup_plan_pyproject_merge_noop_when_keys_unchanged( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup.engine import plan as plan_mod + + _write_minimal_pyproject(tmp_path / "pyproject.toml", audit_enabled=False) + ctx = DiscoverContext( + root_path=tmp_path, + config={"audit_enabled": False}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + assert ( + plan_mod._plan_pyproject_merge( + ctx, + capability_id="audit_and_intents", + updates={"audit_enabled": False}, + ) + is None + ) + + +def test_setup_plan_append_pyproject_action_skips_none_merge( + tmp_path: Path, +) -> None: + from codeclone.surfaces.cli.setup.engine import plan as plan_mod + + _write_minimal_pyproject(tmp_path / "pyproject.toml") + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=None, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + actions: list[dict[str, object]] = [] + plan_mod._append_pyproject_action( + actions, + ctx, + capability_id="analysis", + updates={"audit_enabled": False}, + ) + assert actions == [] + + +def test_setup_rollup_derive_readiness_conservative_default( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup.engine import rollup as rollup_mod + + meta = CapabilityMeta( + id="analysis", + group="core_analysis", + availability="built_in", + requires_config=True, + requires_runtime_proof=True, + ) + axes = CapabilityAxes( + installation="installed", + configuration="configured", + runtime="verified", + evidence=[], + ) + monkeypatch.setattr(rollup_mod, "_axes_satisfied", lambda *_args, **_kwargs: False) + assert rollup_mod.derive_readiness(meta, axes) == "attention" + + +def test_setup_rollup_baseline_untrusted_and_external_file_guidance( + tmp_path: Path, +) -> None: + from codeclone.baseline.trust import BaselineStatus + from codeclone.surfaces.cli.setup.engine import rollup as rollup_mod + + ctx = DiscoverContext( + root_path=tmp_path, + config={}, + config_error=None, + has_codeclone_section=True, + baseline_path=tmp_path / "codeclone.baseline.json", + baseline_status=BaselineStatus.GENERATOR_MISMATCH, + head_commit=None, + install_extras={}, + mcp_installed=False, + ) + reason, action = rollup_mod._describe_baseline( + _capability_meta("baseline"), + CapabilityAxes( + installation="installed", + configuration="configured", + runtime="not_required", + evidence=[], + ), + "attention", + ctx, + ) + assert reason + assert action + + github_reason, _ = rollup_mod._describe_github_workflow( + _capability_meta("github_workflow"), + CapabilityAxes( + installation="unknown", + configuration="not_required", + runtime="not_required", + evidence=[], + ), + "attention", + ctx, + ) + assert github_reason + + +def test_setup_wizard_default_prompts_invoke_rich_askers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.surfaces.cli.setup import wizard as wizard_mod + + pytest.importorskip("rich") + from rich.console import Console + + calls: list[str] = [] + + class _FakePrompt: + @staticmethod + def ask( + _message: str, + *, + choices: list[str], + show_choices: bool, + console: object, + ) -> str: + calls.append("choice") + return choices[0] + + class _FakeConfirm: + @staticmethod + def ask(_message: str, *, default: bool, console: object) -> bool: + calls.append("confirm") + return default + + import rich.prompt as rich_prompt_mod + + monkeypatch.setattr(rich_prompt_mod, "Prompt", _FakePrompt) + monkeypatch.setattr(rich_prompt_mod, "Confirm", _FakeConfirm) + + rich_console = Console(file=io.StringIO(), force_terminal=True) + prompts = wizard_mod._default_wizard_prompts(cast(PrinterLike, rich_console)) + assert prompts.ask_choice("pick", ["1", "2"]) == "1" + assert prompts.confirm("ok?", True) is True + assert calls == ["choice", "confirm"] + + +def test_setup_render_rich_and_plain_capability_reason_branches() -> None: + rows: list[Mapping[str, object]] = [ + {"label": "Analysis", "readiness": "ready", "reason": "needs config"}, + ] + plain = PlainConsole() + setup_render.render_setup_capability_table(plain, rows) + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_capability_table(_rich_console(), rows) + + blocked_plan: dict[str, object] = { + "status": "blocked", + "plan_id": "blocked0000000000", + "root": "/tmp", + "blockers": [{"kind": "invalid_pyproject", "reason": "bad"}], + "actions": [ + { + "kind": "pyproject_merge", + "path": "pyproject.toml", + "status": "ready", + "preview": {"unified_diff": "+line\n"}, + } + ], + } + with patch.object(setup_render, "supports_rich_console", return_value=True): + setup_render.render_setup_plan(console=_rich_console(), plan=blocked_plan) + setup_render.render_setup_apply( + console=_rich_console(), + result={ + "status": "blocked", + "plan_id": "blocked0000000000", + "dry_run": False, + "results": [], + }, + ) diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index 77ec2136..6d82c22a 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -10,6 +10,8 @@ from collections.abc import Iterable from pathlib import Path +from tests._assertions import assert_contains_all + def run_cli( args: Iterable[str], cwd: Path | None = None @@ -61,8 +63,7 @@ def f(): result = run_cli([str(tmp_path)], cwd=tmp_path) assert result.returncode == 0 - assert "Summary" in result.stdout - assert "func" in result.stdout + assert_contains_all(result.stdout, "Summary", "func") def test_cli_baseline_missing_warning(tmp_path: Path) -> None: @@ -75,8 +76,7 @@ def test_cli_baseline_missing_warning(tmp_path: Path) -> None: result = run_cli([str(tmp_path), "--baseline", str(baseline_file), "--no-progress"]) assert result.returncode == 0 - assert "Baseline file not found at" in result.stdout - assert baseline_file.name in result.stdout + assert_contains_all(result.stdout, "Baseline file not found at", baseline_file.name) def test_cli_update_baseline(tmp_path: Path) -> None: @@ -110,7 +110,7 @@ def f2(): ) assert result.returncode == 0 - assert "Baseline updated" in result.stdout + assert_contains_all(result.stdout, "Baseline updated") assert baseline_file.exists() content = baseline_file.read_text() assert "functions" in content @@ -130,4 +130,4 @@ def f2(): ] ) assert result2.returncode == 0 - assert "0 new" in result2.stdout + assert_contains_all(result2.stdout, "0 new") diff --git a/tests/test_cli_unit.py b/tests/test_cli_unit.py index 6059de02..fb495977 100644 --- a/tests/test_cli_unit.py +++ b/tests/test_cli_unit.py @@ -48,7 +48,7 @@ from codeclone.core.reporting import GatingResult from codeclone.core.worker import process_file from codeclone.models import HealthScore, ProjectMetrics -from tests._assertions import assert_contains_all +from tests._assertions import assert_contains_all, assert_contains_none class _RecordingPrinter: @@ -623,8 +623,8 @@ def __init__(self, *_args: object, **_kwargs: object) -> None: assert exc.value.code == 0 out = capsys.readouterr().out assert __version__ in out - assert "Scanning root" not in out - assert "Architectural duplication detector" not in out + assert_contains_none(out, "Scanning root") + assert_contains_none(out, "Architectural duplication detector") def test_cli_help_text_consistency( @@ -639,7 +639,11 @@ def test_cli_help_text_consistency( "usage: codeclone ", "[--version]", "[-h]", - "Structural code quality analysis for Python.", + "--interactive-help", + ( + "Deterministic Structural Change Controller for AI-assisted " + "Python development." + ), "Target:", "Analysis:", "--changed-only", @@ -681,7 +685,7 @@ def test_cli_help_text_consistency( ) for expected in expected_parts: assert expected in out - assert "\x1b[" not in out + assert_contains_none(out, "\x1b[") def test_report_path_origins_distinguish_bare_and_explicit_flags() -> None: @@ -785,13 +789,13 @@ def _boom() -> None: cli.main() assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out - assert "Unexpected exception." in out - assert "Reason: RuntimeError: boom" in out - assert "Next steps:" in out - assert "Re-run with --debug to include a traceback." in out - assert f"{ISSUES_URL}/new?template=bug_report.yml" in out - assert "Traceback:" not in out + assert_contains_all(out, "INTERNAL ERROR:") + assert_contains_all(out, "Unexpected exception.") + assert_contains_all(out, "Reason: RuntimeError: boom") + assert_contains_all(out, "Next steps:") + assert_contains_all(out, "Re-run with --debug to include a traceback.") + assert_contains_all(out, f"{ISSUES_URL}/new?template=bug_report.yml") + assert_contains_none(out, "Traceback:") def test_cli_internal_error_debug_flag_includes_traceback( @@ -806,10 +810,10 @@ def _boom() -> None: cli.main() assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out - assert "DEBUG DETAILS" in out - assert "Traceback:" in out - assert "Command: codeclone --debug" in out + assert_contains_all(out, "INTERNAL ERROR:") + assert_contains_all(out, "DEBUG DETAILS") + assert_contains_all(out, "Traceback:") + assert_contains_all(out, "Command: codeclone --debug") def test_cli_internal_error_debug_env_includes_traceback( @@ -825,9 +829,9 @@ def _boom() -> None: cli.main() assert exc.value.code == 5 out = capsys.readouterr().out - assert "INTERNAL ERROR:" in out - assert "DEBUG DETAILS" in out - assert "Traceback:" in out + assert_contains_all(out, "INTERNAL ERROR:") + assert_contains_all(out, "DEBUG DETAILS") + assert_contains_all(out, "Traceback:") def test_argument_parser_contract_error_marker_for_invalid_args( @@ -1468,7 +1472,7 @@ def test_print_summary_invariant_warning( new_clones_count=0, ) out = capsys.readouterr().out - assert "Summary accounting mismatch" in out + assert_contains_all(out, "Summary accounting mismatch") def test_compact_summary_labels_use_machine_scannable_keys() -> None: @@ -1718,10 +1722,10 @@ def test_print_changed_scope_uses_dedicated_block( ), ) out = capsys.readouterr().out - assert "Changed Scope" in out - assert "Paths" in out - assert "Findings" in out - assert "from git diff" in out + assert_contains_all(out, "Changed Scope") + assert_contains_all(out, "Paths") + assert_contains_all(out, "Findings") + assert_contains_all(out, "from git diff") def test_print_changed_scope_uses_compact_line_in_quiet_mode( @@ -1768,9 +1772,9 @@ def test_print_metrics_in_quiet_mode_includes_overloaded_modules( ), ) out = capsys.readouterr().out - assert "overloaded_modules=3" in out - assert "Adoption" not in out - assert "Public API" not in out + assert_contains_all(out, "overloaded_modules=3") + assert_contains_none(out, "Adoption") + assert_contains_none(out, "Public API") def test_print_metrics_in_quiet_mode_includes_security_surfaces( @@ -2029,6 +2033,87 @@ def test_configure_metrics_mode_forces_api_surface_for_api_break_gate() -> None: assert args.api_surface is True +def test_prepare_metrics_mode_and_ui_invokes_hooks_and_skips_banner_when_quiet( + tmp_path: Path, +) -> None: + calls: list[tuple[str, object]] = [] + + def _configure_metrics_mode( + *, + args: object, + metrics_baseline_exists: bool, + ) -> None: + calls.append(("configure", metrics_baseline_exists)) + + def _print_banner(*, root: Path) -> None: + calls.append(("banner", root)) + + args = Namespace( + update_baseline=False, + skip_metrics=False, + update_metrics_baseline=False, + quiet=False, + no_progress=False, + ) + baseline_path = tmp_path / "codeclone.baseline.json" + + cli_runtime.prepare_metrics_mode_and_ui( + args=args, + root_path=tmp_path, + baseline_path=baseline_path, + baseline_exists=False, + metrics_baseline_path=baseline_path, + metrics_baseline_exists=False, + configure_metrics_mode=_configure_metrics_mode, + print_banner=_print_banner, + ) + + assert calls == [("configure", False), ("banner", tmp_path)] + assert args.no_progress is False + + args.quiet = True + calls.clear() + cli_runtime.prepare_metrics_mode_and_ui( + args=args, + root_path=tmp_path, + baseline_path=baseline_path, + baseline_exists=False, + metrics_baseline_path=baseline_path, + metrics_baseline_exists=False, + configure_metrics_mode=_configure_metrics_mode, + print_banner=_print_banner, + ) + + assert calls == [("configure", False)] + assert args.no_progress is True + + +def test_prepare_metrics_mode_and_ui_skips_optional_hooks_when_none( + tmp_path: Path, +) -> None: + args = Namespace( + update_baseline=False, + skip_metrics=False, + update_metrics_baseline=False, + quiet=False, + no_progress=False, + ) + baseline_path = tmp_path / "codeclone.baseline.json" + + cli_runtime.prepare_metrics_mode_and_ui( + args=args, + root_path=tmp_path, + baseline_path=baseline_path, + baseline_exists=False, + metrics_baseline_path=baseline_path, + metrics_baseline_exists=False, + configure_metrics_mode=None, + print_banner=None, + ) + + assert args.no_progress is False + + def test_probe_metrics_baseline_section_for_non_object_payload(tmp_path: Path) -> None: path = tmp_path / "baseline.json" path.write_text("[]", "utf-8") @@ -2799,3 +2884,54 @@ def test_print_verbose_clone_hashes_prints_sorted_values() -> None: " - a-hash", " - b-hash", ] + + +def test_resolve_intent_registry_db_path_maps_repo_path_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.config.intent_registry import ( + IntentRegistryConfigError, + resolve_intent_registry_db_path, + ) + from codeclone.utils.repo_paths import RepoPathError + + def _raise_repo_path_error(*_args: object, **_kwargs: object) -> Path: + raise RepoPathError("invalid segment") + + monkeypatch.setattr( + "codeclone.config.intent_registry.resolve_under_repo_root", + _raise_repo_path_error, + ) + with pytest.raises(IntentRegistryConfigError, match="invalid intent_registry_path"): + resolve_intent_registry_db_path( + root_path=tmp_path, + value=".codeclone/db/intents.sqlite3", + ) + + +def test_resolve_memory_state_path_maps_repo_path_error( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.config.memory import _resolve_memory_state_path + from codeclone.utils.repo_paths import RepoPathError + + root = tmp_path / "repo" + root.mkdir() + + def _raise_repo_path_error(*_args: object, **_kwargs: object) -> Path: + raise RepoPathError("invalid segment") + + monkeypatch.setattr( + "codeclone.config.memory.resolve_under_repo_root", + _raise_repo_path_error, + ) + with pytest.raises( + ValueError, match=r"Invalid tool\.codeclone\.memory\.semantic\.index_path" + ): + _resolve_memory_state_path( + key="memory.semantic.index_path", + value=".codeclone/memory/semantic_index.lance", + root_path=root, + ) diff --git a/tests/test_codex_plugin.py b/tests/test_codex_plugin.py index a32d4780..a8584001 100644 --- a/tests/test_codex_plugin.py +++ b/tests/test_codex_plugin.py @@ -7,12 +7,15 @@ from pathlib import Path +import pytest + from tests.assertion_helpers import assert_all_contained from tests.plugin_test_helpers import ( assert_codex_manifest_interface, assert_codex_plugin_readme_contract, assert_repo_doc_paths_exist, load_json, + repo_docs_source_available, ) @@ -118,6 +121,7 @@ def test_codex_plugin_skill_exists() -> None: architecture_triage_skill_path = ( plugin_root / "skills" / "codeclone-architecture-triage" / "SKILL.md" ) + setup_skill_path = plugin_root / "skills" / "codeclone-setup" / "SKILL.md" skill_text = skill_path.read_text(encoding="utf-8") hotspot_skill_text = hotspot_skill_path.read_text(encoding="utf-8") change_control_skill_text = change_control_skill_path.read_text(encoding="utf-8") @@ -137,6 +141,7 @@ def test_codex_plugin_skill_exists() -> None: architecture_triage_skill_text = architecture_triage_skill_path.read_text( encoding="utf-8" ) + setup_skill_text = setup_skill_path.read_text(encoding="utf-8") manifest = load_json(plugin_root / ".codex-plugin" / "plugin.json") assert isinstance(manifest, dict) @@ -282,6 +287,18 @@ def test_codex_plugin_skill_exists() -> None: "responsibility overload has no B", ), ) + assert_all_contained( + setup_skill_text, + *( + "name: codeclone-setup", + "CLI-only", + "codeclone setup status", + "codeclone setup plan", + "codeclone setup apply", + "codeclone setup wizard", + "not MCP", + ), + ) assert "Use MCP tools only." in manifest["instructions"] assert "help(topic=change_control" in manifest["instructions"] @@ -298,8 +315,11 @@ def test_codex_plugin_readme_and_docs_exist() -> None: plugin_root = root / "plugins" / "codeclone" readme_text = (plugin_root / "README.md").read_text(encoding="utf-8") assert_codex_plugin_readme_contract(readme_text) + if not repo_docs_source_available(root): + pytest.skip("repo docs source tree is not present") assert_repo_doc_paths_exist( root, - "docs/guide/integrations/codex/setup.md", + "docs/integrations/codex.md", + "docs/guides/setup-project.md", "docs/terms-of-use.md", ) diff --git a/tests/test_docs_build_contract.py b/tests/test_docs_build_contract.py index 4d5a9516..2530aa7b 100644 --- a/tests/test_docs_build_contract.py +++ b/tests/test_docs_build_contract.py @@ -9,6 +9,8 @@ import subprocess from pathlib import Path +import pytest + from tests.docs_script_loader import load_script_module _REPO_ROOT = Path(__file__).resolve().parents[1] @@ -16,7 +18,13 @@ _LINT_SCRIPT = _REPO_ROOT / "scripts" / "lint_admonitions.py" +def _require_docs_source() -> None: + if not (_DOCS_ROOT / "index.md").is_file(): + pytest.skip("repo docs source tree is not present") + + def test_docs_admonition_indentation_is_valid() -> None: + _require_docs_source() lint = load_script_module( module_name="lint_admonitions", script_path=_LINT_SCRIPT, @@ -29,6 +37,7 @@ def test_docs_admonition_indentation_is_valid() -> None: def test_docs_build_strict() -> None: + _require_docs_source() result = subprocess.run( [ "uv", @@ -49,6 +58,7 @@ def test_docs_build_strict() -> None: def test_sample_report_built_page_has_absolute_artifact_links() -> None: + _require_docs_source() site_root = _REPO_ROOT / "site" build = subprocess.run( [ diff --git a/tests/test_docs_example_report.py b/tests/test_docs_example_report.py index 3235e10e..04451b50 100644 --- a/tests/test_docs_example_report.py +++ b/tests/test_docs_example_report.py @@ -11,6 +11,8 @@ from pathlib import Path from unittest.mock import patch +import pytest + def _load_docs_report_namespace() -> dict[str, object]: script_path = ( @@ -37,10 +39,11 @@ def test_sample_report_markdown_links_match_zensical_site_url() -> None: assert callable(read_site_url) assert callable(published_artifact_href) repo_root = Path(__file__).resolve().parents[1] + report_path = repo_root / "docs" / "examples" / "report.md" + if not report_path.is_file(): + pytest.skip("repo docs source tree is not present") site_url = read_site_url(repo_root) - report_md = (repo_root / "docs" / "examples" / "report.md").read_text( - encoding="utf-8" - ) + report_md = report_path.read_text(encoding="utf-8") artifact_names = module["_ARTIFACT_NAMES"] assert isinstance(artifact_names, tuple) for artifact in artifact_names: diff --git a/tests/test_docs_ia_contract.py b/tests/test_docs_ia_contract.py index 5a88cae0..3157a1d9 100644 --- a/tests/test_docs_ia_contract.py +++ b/tests/test_docs_ia_contract.py @@ -6,13 +6,22 @@ from __future__ import annotations +import importlib +import re +import sys from pathlib import Path +from typing import cast + +import pytest _REPO_ROOT = Path(__file__).resolve().parents[1] _DOCS = _REPO_ROOT / "docs" +_ZENSICAL = _REPO_ROOT / "zensical.toml" -_GUIDE_MAX_LINES = 200 -_CONTRACT_SPLIT_MAX_LINES = 200 +pytestmark = pytest.mark.skipif( + not (_DOCS / "index.md").is_file(), + reason="repo docs source tree is not present", +) _REMOVED_LEGACY_STUBS = ( "mcp.md", @@ -27,29 +36,32 @@ "book/25-mcp-interface.md", ) -_GUIDE_GLOBS = ("guide/**/*.md",) - -_CONTRACT_SPLIT_GLOBS = ( - "book/12-structural-change-controller/*.md", - "book/13-engineering-memory/*.md", - "book/25-mcp-interface/**/*.md", +_LEGACY_TOP_LEVEL_DIRS = ("book", "guide") + +_PUBLIC_NAV_PAGES = ( + "index.md", + "getting-started.md", + "examples/report.md", + "concepts/controlled-change.md", + "concepts/engineering-memory.md", + "guides/agent-safe-change.md", + "guides/engineering-memory-workflow.md", + "integrations/claude.md", + "integrations/cursor.md", + "integrations/codex.md", + "integrations/vscode.md", + "reference/cli.md", + "reference/mcp-tools.md", + "troubleshooting/index.md", + "privacy-policy.md", + "terms-of-use.md", ) _COMPLEX_SURFACE_PAGES = ( - "book/26-platform-observability.md", - "book/13-engineering-memory/experience-layer.md", - "book/13-engineering-memory/trajectory-quality-and-passport.md", - "guide/observability/diagnostics.md", - "guide/memory/trajectories-and-experiences.md", -) - -_REQUIRED_NAV_PAGES = ( - "book/26-platform-observability.md", - "book/13-engineering-memory/experience-layer.md", - "book/13-engineering-memory/trajectory-quality-and-passport.md", - "book/25-mcp-interface/tools/platform-observability.md", - "guide/observability/diagnostics.md", - "guide/memory/trajectories-and-experiences.md", + "concepts/controlled-change.md", + "concepts/engineering-memory.md", + "guides/agent-safe-change.md", + "guides/engineering-memory-workflow.md", ) @@ -57,11 +69,31 @@ def _line_count(path: Path) -> int: return len(path.read_text(encoding="utf-8").splitlines()) +def _collect_nav_targets(nav: object) -> list[str]: + targets: list[str] = [] + if isinstance(nav, list): + for entry in nav: + targets.extend(_collect_nav_targets(entry)) + return targets + if isinstance(nav, dict): + for value in nav.values(): + if isinstance(value, str): + targets.append(value) + else: + targets.extend(_collect_nav_targets(value)) + return targets + + def test_legacy_redirect_stub_pages_removed() -> None: present = [rel for rel in _REMOVED_LEGACY_STUBS if (_DOCS / rel).exists()] assert present == [], f"remove legacy stub pages: {present}" +def test_legacy_book_and_guide_trees_removed() -> None: + present = [name for name in _LEGACY_TOP_LEVEL_DIRS if (_DOCS / name).exists()] + assert present == [], f"legacy docs trees should not exist: {present}" + + def test_no_redirect_stub_markers_in_docs() -> None: violations: list[str] = [] for path in sorted(_DOCS.rglob("*.md")): @@ -71,34 +103,34 @@ def test_no_redirect_stub_markers_in_docs() -> None: assert violations == [], "\n".join(violations) -def test_guide_leaves_within_line_budget() -> None: - violations: list[str] = [] - for pattern in _GUIDE_GLOBS: - for path in sorted(_DOCS.glob(pattern)): - count = _line_count(path) - if count > _GUIDE_MAX_LINES: - rel = path.relative_to(_DOCS) - violations.append( - f"{rel}: {count} lines (max {_GUIDE_MAX_LINES})", - ) - assert violations == [], "\n".join(violations) +def _load_zensical_config() -> dict[str, object]: + text = _ZENSICAL.read_text(encoding="utf-8") + if sys.version_info >= (3, 11): + tomllib = importlib.import_module("tomllib") + return cast(dict[str, object], tomllib.loads(text)) + tomli = importlib.import_module("tomli") + return cast(dict[str, object], tomli.loads(text)) -def test_contract_split_leaves_within_line_budget() -> None: - violations: list[str] = [] - for pattern in _CONTRACT_SPLIT_GLOBS: - for path in sorted(_DOCS.glob(pattern)): - count = _line_count(path) - if count > _CONTRACT_SPLIT_MAX_LINES: - rel = path.relative_to(_DOCS) - violations.append( - f"{rel}: {count} lines (max {_CONTRACT_SPLIT_MAX_LINES})", - ) - assert violations == [], "\n".join(violations) +def test_zensical_nav_targets_exist() -> None: + config = _load_zensical_config() + project = cast(dict[str, object], config["project"]) + nav = project["nav"] + missing = [ + rel + for rel in sorted(set(_collect_nav_targets(nav))) + if not (_DOCS / rel).is_file() + ] + assert missing == [], f"zensical nav targets missing on disk: {missing}" + +def test_public_nav_pages_exist() -> None: + missing = [rel for rel in _PUBLIC_NAV_PAGES if not (_DOCS / rel).is_file()] + assert missing == [], f"public docs pages missing: {missing}" -def test_change_control_workflow_has_single_mermaid_diagram() -> None: - path = _DOCS / "guide/mcp/workflows/change-control.md" + +def test_agent_safe_change_workflow_has_single_mermaid_diagram() -> None: + path = _DOCS / "guides/agent-safe-change.md" text = path.read_text(encoding="utf-8") count = text.count("```mermaid") assert count == 1, f"expected one mermaid block, found {count}" @@ -114,28 +146,24 @@ def test_complex_surfaces_have_visual_contracts() -> None: def test_new_surfaces_are_reachable_from_navigation() -> None: - nav = (_REPO_ROOT / "zensical.toml").read_text(encoding="utf-8") - missing = [rel for rel in _REQUIRED_NAV_PAGES if rel not in nav] + nav_text = _ZENSICAL.read_text(encoding="utf-8") + missing = [rel for rel in _PUBLIC_NAV_PAGES if rel not in nav_text] assert missing == [], f"pages missing from navigation: {missing}" -def test_observability_and_memory_guides_cross_link_contracts() -> None: +def test_engineering_memory_guides_cross_link_contracts() -> None: pairs = ( ( - "guide/observability/diagnostics.md", - "../../book/26-platform-observability.md", - ), - ( - "guide/memory/trajectories-and-experiences.md", - "../../book/13-engineering-memory/experience-layer.md", + "guides/engineering-memory-workflow.md", + "docs/concepts/engineering-memory.md", ), ( - "book/26-platform-observability.md", - "../guide/observability/diagnostics.md", + "concepts/engineering-memory.md", + "engineering-memory-workflow.md", ), ( - "book/13-engineering-memory/experience-layer.md", - "../../guide/memory/trajectories-and-experiences.md", + "concepts/controlled-change.md", + "engineering-memory.md", ), ) missing = [ @@ -144,3 +172,17 @@ def test_observability_and_memory_guides_cross_link_contracts() -> None: if target not in (_DOCS / rel).read_text(encoding="utf-8") ] assert missing == [], f"missing required cross-links: {missing}" + + +def test_integration_docs_reference_mcp_workflow_tools() -> None: + expectations = { + "integrations/codex.md": ("start_controlled_change", "analyze_repository"), + "integrations/claude.md": ("start_controlled_change", "analyze_repository"), + "integrations/cursor.md": ("start_controlled_change", "analyze_repository"), + "integrations/vscode.md": ("codeclone-mcp", "MCP"), + } + for rel, needles in expectations.items(): + text = (_DOCS / rel).read_text(encoding="utf-8") + for needle in needles: + assert needle in text, f"{rel} missing {needle}" + assert re.search(r"docs/(book|guide)/", text) is None, rel diff --git a/tests/test_extractor.py b/tests/test_extractor.py index 23657773..36604775 100644 --- a/tests/test_extractor.py +++ b/tests/test_extractor.py @@ -2224,6 +2224,39 @@ def orphan(): assert _dead_qualnames_from_source(source) == ("pkg.mod:orphan",) +def test_runtime_reachability_rejects_invalid_router_methods_and_cast_factory() -> None: + source = """ +from typing import cast +from starlette.routing import Router + +router = Router() + +def _short_cast(): + return cast(object) + +def _invalid_factory(*args, **kwargs): + return router.not_a_route(*args, **kwargs) + +@router.not_a_route("/bad") +def invalid_direct(request): + return request + +@_short_cast() +def short_cast_handler(request): + return request + +@_invalid_factory("/factory-bad") +def invalid_factory_handler(request): + return request +""" + + facts = _runtime_reachability_from_source(source) + by_target = {fact.target_qualname: fact for fact in facts} + assert "pkg.mod:invalid_direct" not in by_target + assert "pkg.mod:short_cast_handler" not in by_target + assert "pkg.mod:invalid_factory_handler" not in by_target + + def test_runtime_reachability_covers_starlette_click_group_and_celery_aliases() -> None: source = """ import click diff --git a/tests/test_file_lock.py b/tests/test_file_lock.py index 4a824c42..02306a75 100644 --- a/tests/test_file_lock.py +++ b/tests/test_file_lock.py @@ -63,6 +63,26 @@ def test_advisory_file_lock_rejects_symlink_target(tmp_path: Path) -> None: raise AssertionError("symlink lock must not be acquired") +def test_open_lock_file_uses_append_mode_on_windows( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + lock_path = tmp_path / "memory.lock" + opened: list[str] = [] + + def _open(_self: object, mode: str, **_kwargs: object) -> io.BytesIO: + opened.append(mode) + return io.BytesIO(b"") + + monkeypatch.setattr("codeclone.utils.file_lock.sys.platform", "win32") + monkeypatch.setattr(type(lock_path), "open", _open, raising=False) + + handle = file_lock._open_lock_file(lock_path) + handle.close() + + assert opened == ["a+b"] + + def test_file_lock_windows_branches(monkeypatch: pytest.MonkeyPatch) -> None: fake_msvcrt = types.SimpleNamespace(LK_NBLCK=1, LK_UNLCK=2, calls=[]) diff --git a/tests/test_github_action_helpers.py b/tests/test_github_action_helpers.py index dd778d5d..ba54bb57 100644 --- a/tests/test_github_action_helpers.py +++ b/tests/test_github_action_helpers.py @@ -181,6 +181,14 @@ def test_render_pr_comment_uses_canonical_report_summary() -> None: ) +def test_mapping_returns_json_object_unchanged() -> None: + action_impl = _load_action_impl() + payload: dict[str, object] = {"status": "ok", "count": 1} + + assert action_impl._mapping(payload) is payload + assert action_impl._mapping(None) == {} + + def test_resolve_install_target_uses_repo_source_for_local_action_checkout( tmp_path: Path, ) -> None: diff --git a/tests/test_mcp_context_governance.py b/tests/test_mcp_context_governance.py index 7a83a6dc..73cc3fa1 100644 --- a/tests/test_mcp_context_governance.py +++ b/tests/test_mcp_context_governance.py @@ -499,6 +499,14 @@ def test_context_governance_enforcement_truth_table() -> None: }, label +def test_as_mapping_or_none_preserves_mapping_identity() -> None: + mapping: dict[str, object] = {"facet": "memory_record", "shown": 0} + value: object = mapping + result = governance_mod._as_mapping_or_none(value) + assert result is mapping + assert governance_mod._as_mapping_or_none("bad") is None + + def test_context_governance_has_no_tokenizer_dependency() -> None: source = inspect.getsource(governance_mod) diff --git a/tests/test_mcp_memory_management.py b/tests/test_mcp_memory_management.py index 1beda4dd..82e1916b 100644 --- a/tests/test_mcp_memory_management.py +++ b/tests/test_mcp_memory_management.py @@ -135,6 +135,17 @@ def test_mcp_manage_memory_validation_errors(tmp_path: Path) -> None: action="record_candidate", statement="missing type", ) + with pytest.raises( + MCPServiceContractError, + match="Invalid Engineering Memory record_type", + ): + service.manage_engineering_memory( + root=root_str, + action="record_candidate", + record_type="decision", + statement="bad type", + subject_path="pkg/mod.py", + ) with pytest.raises(MCPServiceContractError, match="validate_claims requires"): service.manage_engineering_memory( root=root_str, diff --git a/tests/test_mcp_server.py b/tests/test_mcp_server.py index d2204d93..e2b05a57 100644 --- a/tests/test_mcp_server.py +++ b/tests/test_mcp_server.py @@ -811,15 +811,18 @@ def test_mcp_server_tool_roundtrip_and_resources(tmp_path: Path) -> None: assert claim_guard["valid"] is True assert claim_guard["citations_found"] == 1 assert "## CodeClone Agent Review Receipt" in str(receipt["content"]) - # 34.3 dedup: the duplicate nested typed receipt is omitted by default and is - # reachable via the get_review_receipt drill-down pointer. + # The duplicate nested typed receipt is omitted by default. A durable + # drill-down pointer is only advertised when the audit event was persisted. assert "receipt" not in receipt - retrieval = cast("dict[str, object]", receipt["receipt_retrieval"]) - assert retrieval["tool"] == "get_review_receipt" - assert ( - retrieval["receipt_digest"] - == cast("dict[str, object]", receipt["receipt_digest"])["value"] - ) + if "receipt_retrieval" in receipt: + retrieval = cast("dict[str, object]", receipt["receipt_retrieval"]) + assert retrieval["tool"] == "get_review_receipt" + assert ( + retrieval["receipt_digest"] + == cast("dict[str, object]", receipt["receipt_digest"])["value"] + ) + else: + assert receipt["receipt_retrieval_unavailable"] == "audit_write_failed" run_summary_resource = list( asyncio.run(server.read_resource(f"codeclone://runs/{run_id}/summary")) diff --git a/tests/test_mcp_service.py b/tests/test_mcp_service.py index 4df0ed59..533fef4f 100644 --- a/tests/test_mcp_service.py +++ b/tests/test_mcp_service.py @@ -12,14 +12,15 @@ import os import sqlite3 import subprocess -from collections import OrderedDict +from argparse import Namespace +from collections import OrderedDict, UserDict from collections.abc import Mapping from dataclasses import replace from datetime import timedelta from pathlib import Path from types import SimpleNamespace from typing import Any, Literal, cast -from unittest.mock import patch +from unittest.mock import Mock, patch import pytest @@ -32,6 +33,7 @@ import codeclone.surfaces.mcp._patch_contract as mcp_patch_contract_mod import codeclone.surfaces.mcp._review_receipt as mcp_review_receipt_mod import codeclone.surfaces.mcp._session_baseline as mcp_baseline_mod +import codeclone.surfaces.mcp._session_blast_radius_mixin as mcp_blast_session_mod import codeclone.surfaces.mcp._session_context_mixin as mcp_context_session_mod import codeclone.surfaces.mcp._session_finding_mixin as mcp_finding_mod import codeclone.surfaces.mcp._session_helpers as mcp_helpers_mod @@ -553,8 +555,9 @@ class _RecordingAuditWriter: def __init__(self) -> None: self.events: list[AuditEvent] = [] - def emit(self, event: AuditEvent) -> None: + def emit(self, event: AuditEvent) -> int: self.events.append(event) + return len(self.events) def close(self) -> None: return None @@ -649,6 +652,25 @@ def _payload_dicts( return tuple(cast("dict[str, object]", payload[key]) for key in keys) +def _install_decorate_counter( + service: CodeCloneMCPService, + monkeypatch: pytest.MonkeyPatch, +) -> list[str]: + decorated_ids: list[str] = [] + original_decorate = service._decorate_finding + + def counting_decorate( + record: MCPRunRecord, + finding: Mapping[str, object], + **kwargs: Any, + ) -> dict[str, object]: + decorated_ids.append(str(finding.get("id", ""))) + return original_decorate(record, finding, **kwargs) + + monkeypatch.setattr(service, "_decorate_finding", counting_decorate) + return decorated_ids + + def _two_clone_fixture_roots(tmp_path: Path) -> tuple[Path, Path]: first_root = tmp_path / "first" second_root = tmp_path / "second" @@ -878,6 +900,25 @@ def _analyze_quality_repository( return service, summary +def _analyze_multi_clone_repository(root: Path) -> CodeCloneMCPService: + _write_clone_fixture(root) + _write_clone_variant_fixture( + root, + relative_dir="pkg_extra", + module_name="more_dup.py", + seed=20, + ) + service = CodeCloneMCPService(history_limit=4) + service.analyze_repository( + MCPAnalysisRequest( + root=str(root), + respect_pyproject=False, + cache_policy="off", + ) + ) + return service + + def _file_registry(payload: dict[str, object]) -> dict[str, object]: inventory = cast("dict[str, object]", payload["inventory"]) return cast("dict[str, object]", inventory["file_registry"]) @@ -2485,17 +2526,52 @@ def test_mcp_session_audit_emit_swallows_writer_errors( monkeypatch: pytest.MonkeyPatch, ) -> None: service = mcp_session_mod.MCPSession(history_limit=4) + counters: list[tuple[str, int]] = [] def raise_writer(_root: Path) -> _RecordingAuditWriter: raise RuntimeError("audit unavailable") monkeypatch.setattr(service, "_audit_writer_for_root", raise_writer) + monkeypatch.setattr( + "codeclone.observability.record_counter", + lambda key, value=1: counters.append((key, value)), + ) - service._audit_emit( - root=tmp_path, - event_type="intent.declared", - severity="warn", - payload={"status": "active"}, + assert ( + service._audit_emit( + root=tmp_path, + event_type="intent.declared", + severity="warn", + payload={"status": "active"}, + ) + is None + ) + assert counters == [("audit.emit_dropped", 1)] + + +def test_mcp_session_audit_emit_swallows_counter_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = mcp_session_mod.MCPSession(history_limit=4) + + def raise_writer(_root: Path) -> _RecordingAuditWriter: + raise RuntimeError("audit unavailable") + + def raise_counter(_key: str, _value: int = 1) -> None: + raise RuntimeError("observability unavailable") + + monkeypatch.setattr(service, "_audit_writer_for_root", raise_writer) + monkeypatch.setattr("codeclone.observability.record_counter", raise_counter) + + assert ( + service._audit_emit( + root=tmp_path, + event_type="intent.declared", + severity="warn", + payload={"status": "active"}, + ) + is None ) @@ -2635,11 +2711,11 @@ def test_mcp_service_help_returns_bounded_semantic_guidance() -> None: "doc_links": [ { "title": "MCP interface contract", - "url": "https://orenlab.github.io/codeclone/book/25-mcp-interface/", + "url": "https://orenlab.github.io/codeclone/concepts/mcp/", }, { "title": "MCP usage guide", - "url": "https://orenlab.github.io/codeclone/guide/mcp/", + "url": "https://orenlab.github.io/codeclone/guides/agent-safe-change/", }, ], "anti_patterns": [ @@ -2668,6 +2744,76 @@ def test_mcp_service_help_returns_bounded_semantic_guidance() -> None: ] +def test_mcp_context_governance_adds_top_level_continuation_pointer() -> None: + payload = mcp_context_governance_mod.attach_memory_retrieval_context_governance( + { + "records": [{"id": "mem-1"}], + "continuation": { + "lanes": { + "records": { + "page": { + "cursor": "cursor-records-1", + "next_offset": 1, + } + } + } + }, + }, + detail_level="compact", + max_records=20, + evidence_omitted={ + "records": { + "total": 3, + "shown": 1, + "omitted": 2, + "reason": "response_budget", + "drill_down": { + "tool": "get_memory_projection_page", + "cursor_path": "continuation.lanes.records.page.cursor", + "snapshot_identity": "memory projection cursor", + }, + } + }, + ) + + continuation = cast("dict[str, object]", payload["_continuation"]) + lanes = cast("list[dict[str, object]]", continuation["lanes"]) + + assert continuation["required"] is True + assert lanes == [ + { + "lane": "records", + "reason": "response_budget", + "shown": 1, + "total": 3, + "omitted": 2, + "tool": "get_memory_projection_page", + "cursor_path": "continuation.lanes.records.page.cursor", + "snapshot_identity": "memory projection cursor", + "cursor": "cursor-records-1", + } + ] + + +def test_mcp_service_help_overview_returns_topic_index() -> None: + service = CodeCloneMCPService(history_limit=4) + + overview = service.get_help(topic="overview", detail="normal") + topics = cast("list[dict[str, object]]", overview["topics"]) + + assert overview["topic"] == "overview" + assert overview["detail"] == "normal" + assert "index" in str(overview["warnings"]) + assert "get_production_triage" in cast( + "list[str]", + overview["recommended_tools"], + ) + assert "overview" not in {str(item["topic"]) for item in topics} + assert {"workflow", "engineering_memory", "implementation_context"}.issubset( + {str(item["topic"]) for item in topics} + ) + + def test_mcp_service_help_covers_analysis_profiles() -> None: service = CodeCloneMCPService(history_limit=4) @@ -2681,15 +2827,15 @@ def test_mcp_service_help_covers_analysis_profiles() -> None: assert compact["doc_links"] == [ { "title": "Config and defaults", - "url": "https://orenlab.github.io/codeclone/book/10-config-and-defaults/", + "url": "https://orenlab.github.io/codeclone/reference/configuration/", }, { "title": "Core pipeline", - "url": "https://orenlab.github.io/codeclone/book/03-core-pipeline/", + "url": "https://orenlab.github.io/codeclone/concepts/structural-analysis/", }, { "title": "MCP interface contract", - "url": "https://orenlab.github.io/codeclone/book/25-mcp-interface/", + "url": "https://orenlab.github.io/codeclone/concepts/mcp/", }, ] assert normal["topic"] == "analysis_profile" @@ -3127,16 +3273,30 @@ def test_mcp_service_lists_findings_and_hotspots(tmp_path: Path) -> None: assert findings_total >= 1 first = cast("list[dict[str, object]]", findings["items"])[0] assert str(first["id"]).startswith("fn:") + assert first["id"] == first["short_id"] + assert str(first["canonical_id"]).startswith("clone:function:") + assert first["html_anchor"] == f"finding-{first['canonical_id']}" + assert first["novelty"] in {"new", "known"} assert first["kind"] == "function_clone" finding = service.get_finding(finding_id=str(first["id"])) assert finding["id"] == first["id"] + assert finding["short_id"] == first["id"] + assert finding["canonical_id"] == first["canonical_id"] + assert finding["html_anchor"] == f"finding-{finding['canonical_id']}" assert "remediation" in finding hotspots = service.list_hotspots(kind="highest_spread") assert hotspots["run_id"] == summary["run_id"] assert cast(int, hotspots["total"]) >= 1 + filtered_hotspots = service.list_hotspots( + kind="highest_spread", + changed_paths=["does/not/exist.py"], + ) + assert filtered_hotspots["total"] == 0 + assert filtered_hotspots["empty_reason"] == "changed_paths_filter_excluded_all" + def test_mcp_service_hotspot_resources_and_triage_are_production_first( tmp_path: Path, @@ -3892,6 +4052,76 @@ def test_mcp_service_list_findings_detail_levels_slim_and_full_payloads( ) +def test_mcp_service_list_findings_decorates_only_requested_page( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service, summary = _analyze_quality_repository(tmp_path) + run_id = str(summary["run_id"]) + baseline = service.list_findings(run_id=run_id, family="all", limit=50) + assert cast(int, baseline["total"]) > 1 + + decorated_ids = _install_decorate_counter(service, monkeypatch) + + payload = service.list_findings( + run_id=run_id, + family="all", + detail_level="summary", + limit=1, + ) + + assert payload["returned"] == 1 + assert cast(int, payload["total"]) == cast(int, baseline["total"]) + assert len(decorated_ids) == 1 + + +def test_mcp_service_list_hotspots_decorates_only_requested_page( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _analyze_multi_clone_repository(tmp_path) + baseline = service.list_hotspots(kind="highest_priority", limit=50) + assert cast(int, baseline["total"]) > 1 + + decorated_ids = _install_decorate_counter(service, monkeypatch) + + payload = service.list_hotspots(kind="highest_priority", limit=1) + + assert payload["returned"] == 1 + assert cast(int, payload["total"]) == cast(int, baseline["total"]) + assert len(decorated_ids) == 1 + + +def test_mcp_service_production_triage_decorates_only_returned_hotspots( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = _analyze_multi_clone_repository(tmp_path) + baseline = service.get_production_triage(max_hotspots=10) + baseline_hotspots = _mapping_child(baseline, "top_hotspots") + assert cast(int, baseline_hotspots["available"]) > 1 + + decorated_ids: list[str] = [] + original_decorate = service._decorate_finding + + def counting_decorate( + record: MCPRunRecord, + finding: Mapping[str, object], + **kwargs: Any, + ) -> dict[str, object]: + decorated_ids.append(str(finding.get("id", ""))) + return original_decorate(record, finding, **kwargs) + + monkeypatch.setattr(service, "_decorate_finding", counting_decorate) + + payload = service.get_production_triage(max_hotspots=1) + top_hotspots = _mapping_child(payload, "top_hotspots") + + assert top_hotspots["returned"] == 1 + assert top_hotspots["available"] == baseline_hotspots["available"] + assert len(decorated_ids) == 1 + + def test_mcp_service_run_store_evicts_old_runs(tmp_path: Path) -> None: first_root, second_root = _two_clone_fixture_roots(tmp_path) service = CodeCloneMCPService(history_limit=1) @@ -3935,8 +4165,19 @@ def test_mcp_service_reports_contract_errors_for_resources_and_findings( with pytest.raises(MCPServiceContractError): service.get_report_section(section=cast("object", "unknown")) - with pytest.raises(MCPFindingNotFoundError): - service.get_finding(run_id=run_id, finding_id="missing") + missing = service.get_finding(run_id=run_id, finding_id="missing") + assert missing == { + "status": "not_found", + "run_id": run_id, + "finding_id": "missing", + "detail_level": "normal", + "accepted_id_forms": ["short_id", "canonical_id"], + "next_tool": "list_hotspots", + "message": ( + "Finding id was not found in this MCP run. Use list_hotspots, " + "list_findings, or a focused check_* tool to obtain current ids." + ), + } with pytest.raises(MCPServiceContractError): service.read_resource("bad://resource") with pytest.raises(MCPServiceContractError): @@ -4059,10 +4300,6 @@ def test_mcp_service_root_and_helper_contract_errors( ) ) - with pytest.raises(MCPServiceError): - mcp_helpers_mod._load_report_document("{") - with pytest.raises(MCPServiceError): - mcp_helpers_mod._load_report_document("[]") with pytest.raises(MCPServiceError): mcp_helpers_mod._report_digest({}) @@ -4251,6 +4488,90 @@ def test_mcp_service_rejects_refresh_cache_policy_in_read_only_mode( ) +def test_mcp_build_cache_suppresses_cache_entry_writes(tmp_path: Path) -> None: + args = Namespace( + max_cache_size_mb=64, + min_loc=10, + min_stmt=6, + block_min_loc=20, + block_min_stmt=8, + segment_min_loc=20, + segment_min_stmt=10, + api_surface=False, + ) + + cache = mcp_helpers_mod._build_cache( + root_path=tmp_path, + args=args, + cache_path=tmp_path / "cache.json", + policy="off", + ) + cache.put_file_entry("x.py", {"mtime_ns": 1, "size": 10}, [], [], []) + cache.save() + + assert cache.get_file_entry("x.py") is None + assert not (tmp_path / "cache.json").exists() + + +def test_mcp_analyze_releases_cache_before_report_without_json_roundtrip( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + import codeclone.core.reporting as core_reporting_mod + + _write_clone_fixture(tmp_path) + events: list[str] = [] + original_release = Cache.release_loaded_entries + original_report = cast(Any, mcp_session_mod).report + + def _record_release(self: Cache, *, allow_dirty: bool = False) -> int: + events.append("release") + return original_release(self, allow_dirty=allow_dirty) + + def _record_report(**kwargs: Any) -> object: + events.append("report") + return original_report(**kwargs) + + forbidden_report_json = Mock( + side_effect=AssertionError( + "MCP analyze_repository must not serialize report JSON" + ) + ) + forbidden_report_load = Mock( + side_effect=AssertionError("MCP analyze_repository must not parse report JSON") + ) + + monkeypatch.setattr(Cache, "release_loaded_entries", _record_release) + monkeypatch.setattr(mcp_session_mod, "report", _record_report) + monkeypatch.setattr( + core_reporting_mod, + "render_json_report_document", + forbidden_report_json, + ) + monkeypatch.setattr( + mcp_shared_mod, + "_load_report_document_payload", + forbidden_report_load, + ) + + service = CodeCloneMCPService(history_limit=4) + summary = service.analyze_repository( + MCPAnalysisRequest( + root=str(tmp_path), + respect_pyproject=False, + cache_policy="off", + ) + ) + + assert summary["run_id"] + assert events == ["release", "report"] + assert forbidden_report_json.call_count == 0 + assert forbidden_report_load.call_count == 0 + assert service.get_report_section(section="all")["report_schema_version"] == ( + REPORT_SCHEMA_VERSION + ) + + def test_mcp_service_all_section_and_optional_path_overrides(tmp_path: Path) -> None: _write_clone_fixture(tmp_path) service = CodeCloneMCPService(history_limit=4) @@ -4580,7 +4901,7 @@ def test_mcp_service_short_finding_ids_remain_unique_for_overlapping_clones( assert resolved["id"] == finding_id -def test_mcp_service_reports_missing_json_artifact(tmp_path: Path) -> None: +def test_mcp_service_reports_missing_report_document(tmp_path: Path) -> None: _write_clone_fixture(tmp_path) service = CodeCloneMCPService(history_limit=4) service_module = cast( @@ -4597,12 +4918,13 @@ def _fake_report(**kwargs: Any) -> object: md=artifacts.md, sarif=artifacts.sarif, text=artifacts.text, + report_document=None, ) monkeypatch = pytest.MonkeyPatch() monkeypatch.setattr(mcp_session_mod, "report", _fake_report) try: - with pytest.raises(MCPServiceError): + with pytest.raises(MCPServiceError, match="canonical report document"): service.analyze_repository( MCPAnalysisRequest( root=str(tmp_path), @@ -4899,6 +5221,65 @@ def test_mcp_service_get_blast_radius_uses_cache_and_include_filter( service.get_blast_radius(files=("pkg/a.py",), depth="full") +def test_mcp_service_blast_radius_cache_keys_intent_scope( + tmp_path: Path, +) -> None: + service = CodeCloneMCPService(history_limit=2) + record = _blast_radius_run_record(tmp_path) + service._runs.register(record) + + with patch( + "codeclone.surfaces.mcp._session_blast_radius_mixin.compute_blast_radius", + wraps=mcp_blast_radius_mod.compute_blast_radius, + ) as compute: + first = service._blast_radius_result( + record=record, + files=("pkg/a.py",), + depth="direct", + forbidden_patterns=("pkg/z.py", "pkg/c.py", "pkg/c.py"), + allowed_scope=("pkg/b.py", "pkg/a.py"), + ) + second = service._blast_radius_result( + record=record, + files=("pkg/a.py",), + depth="direct", + forbidden_patterns=("pkg/c.py", "pkg/z.py"), + allowed_scope=("pkg/a.py", "pkg/b.py", "pkg/a.py"), + ) + third = service._blast_radius_result( + record=record, + files=("pkg/a.py",), + depth="direct", + forbidden_patterns=("pkg/c.py", "pkg/z.py"), + allowed_scope=("pkg/a.py",), + ) + + assert first is second + assert third is not first + assert compute.call_count == 2 + assert len(service._blast_radius_cache) == 2 + + +def test_mcp_service_blast_radius_cache_is_bounded( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(mcp_blast_session_mod, "MAX_BLAST_RADIUS_CACHE_ENTRIES", 2) + service = CodeCloneMCPService(history_limit=2) + record = _blast_radius_run_record(tmp_path) + service._runs.register(record) + + for index in range(3): + service._blast_radius_result( + record=record, + files=("pkg/a.py",), + depth="direct", + forbidden_patterns=(f"pkg/forbidden_{index}.py",), + ) + + assert len(service._blast_radius_cache) == 2 + + def test_mcp_service_manage_change_intent_lifecycle(tmp_path: Path) -> None: service = CodeCloneMCPService(history_limit=2) record = _blast_radius_run_record(tmp_path) @@ -7555,6 +7936,32 @@ def test_mcp_service_create_review_receipt_minimal_and_deterministic( service.create_review_receipt(run_id="receipt12", format="yaml") +def test_mcp_service_create_review_receipt_does_not_claim_lookup_when_audit_fails( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = CodeCloneMCPService(history_limit=2) + record = _patch_contract_run_record( + tmp_path, + run_id="receiptfail123456", + digest="receipt-fail-digest", + include_regression=False, + complexity=6, + health=92, + ) + service._runs.register(record) + + def raise_writer(_root: Path) -> _RecordingAuditWriter: + raise RuntimeError("audit unavailable") + + monkeypatch.setattr(service, "_audit_writer_for_root", raise_writer) + markdown = service.create_review_receipt(run_id="receiptfail") + + assert "## CodeClone Agent Review Receipt" in str(markdown["content"]) + assert "receipt_retrieval" not in markdown + assert markdown["receipt_retrieval_unavailable"] == "audit_write_failed" + + def test_mcp_service_create_review_receipt_full_post_edit_workflow( tmp_path: Path, ) -> None: @@ -8250,7 +8657,7 @@ def test_mcp_service_additional_projection_and_error_branches( ) ).startswith("design:coupling:") - original_session_get = service.session.get_finding + original_service_get = service.session._service_get_finding original_runs_get = service._runs.get original_resolve = service.session._resolve_canonical_finding_id monkeypatch.setattr( @@ -8260,7 +8667,7 @@ def test_mcp_service_additional_projection_and_error_branches( ) monkeypatch.setattr( service.session, - "get_finding", + "_service_get_finding", lambda **kwargs: {"id": "no-remediation"}, ) monkeypatch.setattr(service._runs, "get", lambda run_id=None: record) @@ -8270,7 +8677,7 @@ def test_mcp_service_additional_projection_and_error_branches( ) assert no_guidance["status"] == "no_guidance" assert no_guidance["remediation"] is None - monkeypatch.setattr(service.session, "get_finding", original_session_get) + monkeypatch.setattr(service.session, "_service_get_finding", original_service_get) monkeypatch.setattr( service.session, "_resolve_canonical_finding_id", original_resolve ) @@ -9556,14 +9963,25 @@ def test_mcp_service_payload_and_resolution_helper_fallbacks( "_base_findings", lambda _record: [{"id": "design:cohesion:pkg.mod:Other"}], ) - with pytest.raises(MCPFindingNotFoundError, match="missing-finding"[:8]): - service.get_finding( - run_id="missing-finding", finding_id="design:cohesion:Runner" - ) + missing_finding = service.get_finding( + run_id="missing-finding", + finding_id="design:cohesion:Runner", + ) + assert { + "status": missing_finding["status"], + "run_id": missing_finding["run_id"], + "finding_id": missing_finding["finding_id"], + "canonical_id": missing_finding["canonical_id"], + } == { + "status": "not_found", + "run_id": "missing-", + "finding_id": "design:cohesion:Runner", + "canonical_id": "design:cohesion:pkg.mod:Runner", + } monkeypatch.setattr( service.session, - "get_finding", + "_service_get_finding", lambda **_kwargs: {"id": "design:cohesion:pkg.mod:Runner"}, ) no_guidance = service.get_remediation( @@ -10522,6 +10940,59 @@ def test_mcp_workflow_start_replays_identical_request(tmp_path: Path) -> None: _assert_start_context_governance(replay, enforced=False) +def test_mcp_workflow_start_reuses_dirty_snapshot_for_replay_and_declare( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + _init_git_readme(tmp_path) + service = CodeCloneMCPService(history_limit=4) + _register_docs_patch_run(service, tmp_path) + snapshot = mcp_workspace_hygiene_mod.DirtySnapshot( + git_available=True, + captured_at_utc="2026-06-14T00:00:00Z", + entries=( + mcp_workspace_hygiene_mod.DirtySnapshotEntry( + path="README.md", + status_xy=" M", + digest="a" * 64, + digest_status="ok", + ), + ), + ) + calls: list[Path] = [] + + def _collect_dirty_snapshot(root: Path) -> mcp_workspace_hygiene_mod.DirtySnapshot: + calls.append(root) + return snapshot + + monkeypatch.setattr( + mcp_workspace_hygiene_mod, + "collect_dirty_snapshot", + _collect_dirty_snapshot, + ) + + started = service.start_controlled_change( + root=str(tmp_path), + scope={"allowed_files": ["README.md"]}, + intent="update readme", + ) + + assert calls == [tmp_path.resolve()] + dirty_summary = cast("dict[str, object]", started["dirty_snapshot"]) + assert dirty_summary["paths_count"] == 1 + persisted = mcp_workspace_intents_mod.find_workspace_intent( + root=tmp_path, + intent_id=str(started["intent_id"]), + apply_lazy_close=False, + ) + assert persisted is not None + assert persisted.dirty_snapshot == snapshot.to_payload() + replay_entry = next(iter(service._start_replay_cache.values())) + assert replay_entry[ + "workspace_state_digest" + ] == workflow_mod._start_workspace_state_digest_from_snapshot(snapshot) + + def test_mcp_workflow_start_replay_rejects_workspace_drift(tmp_path: Path) -> None: _init_git_readme(tmp_path) service = CodeCloneMCPService(history_limit=4) @@ -10707,7 +11178,6 @@ def test_mcp_workflow_finish_controlled_change_evidence_and_docs_path( assert cast("dict[str, object]", cleared["summary"])["receipt"] == "created" receipt_payload = cast("dict[str, object]", cleared["receipt"]) receipt_digest = cast("dict[str, object]", receipt_payload["receipt_digest"]) - retrieval = cast("dict[str, object]", receipt_payload["receipt_retrieval"]) assert { "format": receipt_payload["format"], "top_receipt_version": receipt_payload["receipt_version"], @@ -10716,7 +11186,8 @@ def test_mcp_workflow_finish_controlled_change_evidence_and_docs_path( "has_content": isinstance(receipt_payload["content"], str), # The duplicate nested typed receipt is omitted by default. "has_nested_typed": "receipt" in receipt_payload, - "retrieval_tool": retrieval["tool"], + "has_retrieval": "receipt_retrieval" in receipt_payload, + "retrieval_unavailable": receipt_payload["receipt_retrieval_unavailable"], } == { "format": "markdown", "top_receipt_version": "1", @@ -10724,11 +11195,9 @@ def test_mcp_workflow_finish_controlled_change_evidence_and_docs_path( "digest_kind": "receipt_v1", "has_content": True, "has_nested_typed": False, - "retrieval_tool": "get_review_receipt", + "has_retrieval": False, + "retrieval_unavailable": "audit_write_failed", } - # The drill-down pointer carries the canonical digest for fetching the omitted - # typed receipt; reachability is exercised in test_review_receipt_retrieval. - assert retrieval["receipt_digest"] == receipt_digest["value"] context_governance = cast("dict[str, object]", cleared["context_governance"]) assert { "contract_version": context_governance["contract_version"], @@ -10762,12 +11231,15 @@ def test_mcp_workflow_finish_controlled_change_evidence_and_docs_path( "digest_kind": response_digest["kind"], "receipt_retrieval_blocked": "receipt_retrieval_unavailable" in enforcement_blocked["response_budget"], + "patch_trail_retrieval_blocked": "patch_trail_retrieval_unavailable" + in enforcement_blocked["response_budget"], } == { "tool": "finish_controlled_change", "budget_scope": "whole_response", "evidence_policy": "response_budget_with_durable_artifact_lookup", "digest_kind": "finish_projection_v1", - "receipt_retrieval_blocked": False, + "receipt_retrieval_blocked": True, + "patch_trail_retrieval_blocked": True, } assert ( cast("dict[str, object]", context_governance["capabilities"])[ @@ -10778,20 +11250,48 @@ def test_mcp_workflow_finish_controlled_change_evidence_and_docs_path( assert isinstance(context_governance["estimated"], int) -def test_mcp_workflow_finish_python_structural_and_receipt_edges( +def test_mcp_workflow_finish_keeps_evidence_inline_when_audit_lookup_fails( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, ) -> None: - service = CodeCloneMCPService(history_limit=4) - before, _after = _patch_contract_before_after_records( - tmp_path, - before_health=88, - ) - service._runs.register(before) - intent_id = str( - service.start_controlled_change( - root=str(tmp_path), - scope={"allowed_files": ["pkg/a.py"]}, + service, intent_id = _seed_docs_intent(tmp_path) + + def raise_writer(_root: Path) -> _RecordingAuditWriter: + raise RuntimeError("audit unavailable") + + monkeypatch.setattr(service, "_audit_writer_for_root", raise_writer) + finished = service.finish_controlled_change( + intent_id=intent_id, + changed_files=["README.md"], + ) + + assert finished["status"] == "accepted" + receipt = cast("dict[str, object]", finished["receipt"]) + assert "receipt_retrieval" not in receipt + assert receipt["receipt_retrieval_unavailable"] == "audit_write_failed" + assert isinstance(receipt["content"], str) + + patch_trail = cast("dict[str, object]", finished["patch_trail"]) + assert patch_trail["retrieval_unavailable"] == "audit_write_failed" + assert "retrieval" not in patch_trail + evidence = cast("dict[str, object]", patch_trail["evidence"]) + assert "patch_trail_audit_sequence" not in evidence + + +def test_mcp_workflow_finish_python_structural_and_receipt_edges( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = CodeCloneMCPService(history_limit=4) + before, _after = _patch_contract_before_after_records( + tmp_path, + before_health=88, + ) + service._runs.register(before) + intent_id = str( + service.start_controlled_change( + root=str(tmp_path), + scope={"allowed_files": ["pkg/a.py"]}, intent="edit pkg.a", )["intent_id"] ) @@ -11015,6 +11515,55 @@ def test_mcp_workflow_helper_messages_and_validators() -> None: assert summary["claims"] == "skipped_not_recommended" +def test_mcp_patch_changed_file_evidence_prefers_explicit_changed_files( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + service = CodeCloneMCPService(history_limit=4) + calls: list[str] = [] + + def normalize_changed_paths(**kwargs: object) -> tuple[str, ...]: + calls.append("changed_files") + assert kwargs["root_path"] == tmp_path + assert kwargs["paths"] == ["pkg/a.py"] + return ("pkg/a.py",) + + def git_diff_paths(**kwargs: object) -> tuple[str, ...]: + calls.append("diff_ref") + assert kwargs["root_path"] == tmp_path + assert kwargs["git_diff_ref"] == "HEAD~1" + return ("pkg/from_diff.py",) + + monkeypatch.setattr(service, "_normalize_changed_paths", normalize_changed_paths) + monkeypatch.setattr(service, "_git_diff_paths", git_diff_paths) + + assert service._patch_changed_file_evidence( + root_path=tmp_path, + changed_files=["pkg/a.py"], + diff_ref="HEAD~1", + ) == ("pkg/a.py",) + assert calls == ["changed_files"] + + calls.clear() + assert service._patch_changed_file_evidence( + root_path=tmp_path, + changed_files=None, + diff_ref="HEAD~1", + ) == ("pkg/from_diff.py",) + assert calls == ["diff_ref"] + + calls.clear() + assert ( + service._patch_changed_file_evidence( + root_path=tmp_path, + changed_files=None, + diff_ref=None, + ) + is None + ) + assert calls == [] + + def test_mcp_finish_response_budget_omits_retrievable_advisory_lanes() -> None: payload: dict[str, object] = { "intent_id": "intent-1", @@ -11568,6 +12117,14 @@ def test_mcp_intent_renew_and_workflow_helper_edges(tmp_path: Path) -> None: {"blocked_by": [{"ownership": "foreign_active"}]}, )["concurrent_intents"] == [{"ownership": "foreign_active"}] assert workflow_mod._as_conflict_list("not-a-list") == [] + conflict = {"ownership": "foreign_active", "count": 1} + assert workflow_mod._as_conflict_list([conflict]) == [ + {"ownership": "foreign_active", "count": 1} + ] + user_mapping = UserDict({1: "numeric-key", "scope": "docs"}) + assert workflow_mod._as_conflict_list([user_mapping]) == [ + {"1": "numeric-key", "scope": "docs"} + ] with pytest.raises(MCPServiceContractError): workflow_mod._validated_dirty_scope_policy("invalid") dirty_hygiene = WorkspaceHygieneResult( @@ -11873,6 +12430,24 @@ def test_mcp_list_workspace_includes_dirty_summary( assert "README.md" in cast("list[str]", summary["dirty_paths_sample"]) +def test_mcp_list_workspace_intents_can_skip_dirty_summary( + tmp_path: Path, +) -> None: + subprocess.run(["git", "init"], cwd=tmp_path, check=True, capture_output=True) + (tmp_path / "README.md").write_text("hello\n", encoding="utf-8") + service = CodeCloneMCPService(history_limit=2) + default_payload = service._list_workspace_intents(root=str(tmp_path)) + assert "workspace_dirty_summary" in default_payload + skipped_payload = service._list_workspace_intents( + root=str(tmp_path), include_dirty_summary=False + ) + assert "workspace_dirty_summary" not in skipped_payload + # Skipping the summary must not perturb the registry-identity fields the + # start-replay digest hashes. + for key in ("workspace_intents", "own_pid", "own_start_epoch"): + assert skipped_payload[key] == default_payload[key] + + def test_mcp_verify_rejects_identical_before_after_run(tmp_path: Path) -> None: service = CodeCloneMCPService(history_limit=4) _after, declared = _seed_patch_contract_intent(service, tmp_path, before_health=85) @@ -12137,6 +12712,164 @@ def test_measure_payload_ignores_invalid_context_governance_estimate() -> None: assert tokens > 17 +def test_measure_payload_ignores_mismatched_context_governance_contract() -> None: + from codeclone.surfaces.mcp._context_governance import ( + CONTEXT_GOVERNANCE_CONTRACT_VERSION, + CONTEXT_GOVERNANCE_ESTIMATOR, + ) + from codeclone.surfaces.mcp.payloads import measure_payload + + _baseline_byte_size, _baseline_tokens = measure_payload({"items": list(range(4))}) + _wrong_version_byte_size, wrong_version_tokens = measure_payload( + { + "items": list(range(4)), + "context_governance": { + "contract_version": "0.0", + "estimator": CONTEXT_GOVERNANCE_ESTIMATOR, + "estimated": 3, + }, + } + ) + _wrong_estimator_byte_size, wrong_estimator_tokens = measure_payload( + { + "items": list(range(4)), + "context_governance": { + "contract_version": CONTEXT_GOVERNANCE_CONTRACT_VERSION, + "estimator": "legacy-estimator", + "estimated": 3, + }, + } + ) + + assert wrong_version_tokens != 3 + assert wrong_estimator_tokens != 3 + assert wrong_version_tokens > 0 + assert wrong_estimator_tokens > 0 + + +def test_hotspot_empty_reason_reports_no_findings_in_run(tmp_path: Path) -> None: + service = CodeCloneMCPService(history_limit=2) + record = _dummy_run_record(tmp_path, "run-empty") + reason = service._hotspot_empty_reason( + record=record, + kind="most_actionable", + detail_level="summary", + changed_paths=(), + exclude_reviewed=False, + ) + assert reason == "no_findings_in_run" + + +def test_list_hotspots_reports_all_items_reviewed_empty_reason( + tmp_path: Path, +) -> None: + _write_clone_fixture(tmp_path) + service = CodeCloneMCPService(history_limit=2) + summary = service.analyze_repository( + MCPAnalysisRequest( + root=str(tmp_path), + respect_pyproject=False, + cache_policy="off", + ) + ) + hotspots_before = service.list_hotspots( + kind="highest_spread", + run_id=summary["run_id"], + exclude_reviewed=False, + ) + for item in cast("list[dict[str, object]]", hotspots_before["items"]): + service.mark_finding_reviewed( + finding_id=str(item["id"]), + run_id=summary["run_id"], + ) + hotspots = service.list_hotspots( + kind="highest_spread", + run_id=summary["run_id"], + exclude_reviewed=True, + ) + assert hotspots["total"] == 0 + assert hotspots["empty_reason"] == "all_items_reviewed" + + +def test_hotspot_empty_reason_reports_kind_specific_paths(tmp_path: Path) -> None: + service = CodeCloneMCPService(history_limit=2) + base_document: dict[str, object] = { + "findings": { + "groups": { + "clones": { + "functions": [ + { + "id": "clone:function:g1", + "locations": [{"path": "pkg/dup.py"}], + } + ], + "blocks": [], + "segments": [], + } + } + }, + "derived": {"hotlists": {}}, + } + record = replace( + _dummy_run_record(tmp_path, "hotspot-empty-reasons"), + report_document=base_document, + ) + service._runs.register(record) + + assert ( + service._hotspot_empty_reason( + record=record, + kind="highest_priority", + detail_level="summary", + changed_paths=(), + exclude_reviewed=False, + ) + == "no_ranked_findings" + ) + assert ( + service._hotspot_empty_reason( + record=record, + kind="highest_spread", + detail_level="summary", + changed_paths=(), + exclude_reviewed=False, + ) + == "no_spread_hotspots" + ) + assert ( + service._hotspot_empty_reason( + record=record, + kind="most_actionable", + detail_level="summary", + changed_paths=(), + exclude_reviewed=False, + ) + == "no_items_above_actionability_threshold" + ) + + filtered_record = replace( + record, + report_document={ + **base_document, + "derived": { + "hotlists": { + "highest_spread_ids": ["clone:function:missing"], + } + }, + }, + ) + assert ( + service._hotspot_empty_reason( + record=filtered_record, + kind="highest_spread", + detail_level="summary", + changed_paths=(), + exclude_reviewed=False, + ) + == "hotlist_items_filtered_or_unavailable" + ) + + def test_mcp_payload_paginate_and_finding_resolution() -> None: from codeclone.surfaces.mcp.payloads import ( PageWindow, @@ -12212,10 +12945,15 @@ def test_mcp_state_optional_payload_and_pruning_edges(tmp_path: Path) -> None: service._spread_max_cache[stale.run_id] = 1 from codeclone.analysis.blast_radius import BlastRadiusResult - service._blast_radius_cache[(stale.run_id, ("README.md",), "direct")] = cast( - "BlastRadiusResult", - {}, - ) + service._blast_radius_cache[ + ( + stale.run_id, + ("README.md",), + "direct", + (), + (), + ) + ] = cast("BlastRadiusResult", {}) service._runs.clear() service._prune_session_state() @@ -12615,6 +13353,20 @@ def test_get_report_section_module_map_paginates_graph_lanes(tmp_path: Path) -> ) +def test_get_report_section_module_map_requires_derived_payload( + tmp_path: Path, +) -> None: + service = CodeCloneMCPService(history_limit=4) + service._runs.register( + replace( + _dummy_run_record(tmp_path, "empty-derived-run"), + report_document={"derived": {}}, + ) + ) + with pytest.raises(MCPServiceContractError, match=r"module_map.*not available"): + service.get_report_section(run_id="empty-derived-run", section="module_map") + + def test_get_report_section_module_map_clones_only_returns_shell( tmp_path: Path, ) -> None: @@ -12983,6 +13735,18 @@ def test_workflow_start_blast_and_finish_governance_helper_branches( ) assert direct_omitted["omitted"] == 2 assert workflow_mod._start_blast_omission_row("lane", "not-a-mapping") is None + omission_row = workflow_mod._start_blast_omission_row( + "direct_dependents", + {"total": "2", "shown": 0, "truncated": True, "retrieval": {}}, + ) + assert omission_row is not None + assert omission_row[1]["omitted"] == 2 + truncated_only = workflow_mod._start_blast_omission_row( + "review_context", + {"total": 2, "shown": 2, "truncated": True, "retrieval": {}}, + ) + assert truncated_only is not None + assert truncated_only[1]["omitted"] == 0 assert workflow_mod._start_non_negative_int(True) == 0 assert workflow_mod._start_non_negative_int("12") == 12 @@ -13084,27 +13848,153 @@ def test_workflow_start_blast_and_finish_governance_helper_branches( assert ( workflow_mod._patch_trail_retrievable( { - "patch_trail_digest": "trail" * 16, - "retrieval": {"tool": "get_patch_trail"}, + "patch_trail": { + "patch_trail_digest": "trail" * 16, + "retrieval": {"tool": "get_patch_trail"}, + } } ) is False ) assert ( workflow_mod._patch_trail_retrievable( - {"patch_trail_digest": " ", "evidence": {}} + { + "patch_trail": { + "patch_trail_digest": " ", + "evidence": {}, + } + } ) is False ) assert ( workflow_mod._patch_trail_retrievable( { - "patch_trail_digest": "trail" * 16, - "evidence": "bad", + "patch_trail": { + "patch_trail_digest": "trail" * 16, + "evidence": "bad", + } } ) is False ) + assert ( + workflow_mod._patch_trail_retrievable( + { + "patch_trail": { + "patch_trail_digest": "trail" * 16, + "evidence": {"patch_trail_audit_sequence": 3}, + } + } + ) + is True + ) + + blocker_payload = { + "receipt": { + "content": "body", + "receipt_retrieval": {"tool": "other"}, + }, + "patch_trail": { + "patch_trail_digest": "trail" * 16, + "evidence": {}, + }, + } + assert workflow_mod._finish_retrieval_blockers(blocker_payload) == [ + "receipt_retrieval_unavailable", + "patch_trail_retrieval_unavailable", + ] + shrink_payload: dict[str, object] = { + "receipt": {"content": "body"}, + "patch_trail": { + "patch_trail_digest": "trail" * 16, + "evidence": {"patch_trail_audit_sequence": 4}, + "schema_version": "1", + }, + } + workflow_mod._shrink_finish_lane(shrink_payload, "receipt_content") + assert "content" not in cast("dict[str, object]", shrink_payload["receipt"]) + workflow_mod._shrink_finish_lane(shrink_payload, "patch_trail") + compact_trail = cast("dict[str, object]", shrink_payload["patch_trail"]) + retrieval = cast("dict[str, object]", compact_trail["retrieval"]) + assert retrieval["tool"] == "get_patch_trail" + assert ( + workflow_mod._next_reducible_finish_lane( + { + "receipt": { + "content": "body", + "receipt_retrieval": { + "tool": "get_review_receipt", + "receipt_digest": "r" * 64, + }, + "receipt_digest": {"value": "r" * 64}, + } + } + ) + == "receipt_content" + ) + governed_finish = workflow_mod._attach_finish_governance( + { + **blocker_payload, + "context_governance": { + "enforcement_blocked": {"response_budget": []}, + }, + }, + evidence_omitted=None, + ) + enforcement_blocked = cast( + "dict[str, object]", + cast("dict[str, object]", governed_finish["context_governance"])[ + "enforcement_blocked" + ], + ) + assert "patch_trail_retrieval_unavailable" in cast( + "list[str]", enforcement_blocked["response_budget"] + ) + omitted = workflow_mod._finish_governance_omitted( + { + "receipt": { + "run_id": "run12345", + "content": "body", + "receipt_digest": {"value": "r" * 64}, + }, + "patch_trail": { + "patch_trail_digest": "t" * 64, + "schema_version": "1", + }, + }, + response_budget_lanes={"receipt_content", "patch_trail"}, + ) + assert omitted is not None + assert "receipt.content" in omitted + assert "patch_trail" in omitted + + no_receipt_dict: dict[str, object] = {"receipt": "bad"} + workflow_mod._shrink_finish_lane(no_receipt_dict, "receipt_content") + workflow_mod._shrink_finish_lane({"patch_trail": "bad"}, "patch_trail") + compact_without_audit = workflow_mod._compact_patch_trail_reference( + { + "patch_trail_digest": "trail" * 16, + "schema_version": "1", + "evidence": {}, + } + ) + assert "patch_trail_audit_sequence" not in cast( + "dict[str, object]", compact_without_audit["retrieval"] + ) + assert ( + workflow_mod._receipt_digest_value( + {"receipt_retrieval": {"receipt_digest": "d" * 64}} + ) + == "d" * 64 + ) + assert ( + workflow_mod._finish_governance_omitted( + {"receipt": {"content": "only"}}, + response_budget_lanes={"receipt_content"}, + ) + is None + ) assert workflow_mod._receipt_content_omission({"receipt": {"content": "x"}}) is None assert workflow_mod._patch_trail_omission({"patch_trail": {}}) is None assert workflow_mod._receipt_digest_value({}) == "" @@ -13200,6 +14090,22 @@ def find_trajectory_patch_trails_for_lookup( def close(self) -> None: self.closed = True + def install_lookup_store( + responses: list[tuple[list[dict[str, object]], int]], + ) -> None: + monkeypatch.setattr( + audit_mod, + "open_memory_db_readonly", + lambda _db: _LookupStore(responses=responses), + ) + monkeypatch.setattr( + audit_mod, + "find_trajectory_patch_trails_for_lookup", + lambda conn, **kwargs: conn.find_trajectory_patch_trails_for_lookup( + **kwargs + ), + ) + ok_row: dict[str, object] = { "payload": { "schema_version": "1", @@ -13210,11 +14116,7 @@ def close(self) -> None: "run_id": "run12345", "created_at_utc": "2026-01-01T00:00:00Z", } - monkeypatch.setattr( - audit_mod, - "SqliteEngineeringMemoryStore", - lambda _db: _LookupStore(responses=[([ok_row], 0)]), - ) + install_lookup_store([([ok_row], 0)]) status, count, artifact = audit_mod._memory_patch_trail_lookup( root_path=tmp_path, run_id="run12345", @@ -13224,11 +14126,7 @@ def close(self) -> None: assert count == 1 assert artifact is not None - monkeypatch.setattr( - audit_mod, - "SqliteEngineeringMemoryStore", - lambda _db: _LookupStore(responses=[([ok_row, ok_row], 0)]), - ) + install_lookup_store([([ok_row, ok_row], 0)]) ambiguous = audit_mod._memory_patch_trail_lookup( root_path=tmp_path, run_id="run12345", @@ -13236,15 +14134,11 @@ def close(self) -> None: ) assert ambiguous[:2] == ("ambiguous", 2) - monkeypatch.setattr( - audit_mod, - "SqliteEngineeringMemoryStore", - lambda _db: _LookupStore( - responses=[ - ([], 0), - ([{"payload": {}, "patch_trail_digest": "e" * 64}], 0), - ] - ), + install_lookup_store( + [ + ([], 0), + ([{"payload": {}, "patch_trail_digest": "e" * 64}], 0), + ] ) mismatch = audit_mod._memory_patch_trail_lookup( root_path=tmp_path, @@ -13253,11 +14147,7 @@ def close(self) -> None: ) assert mismatch[0] == "digest_mismatch" - monkeypatch.setattr( - audit_mod, - "SqliteEngineeringMemoryStore", - lambda _db: _LookupStore(responses=[([], 2)]), - ) + install_lookup_store([([], 2)]) malformed = audit_mod._memory_patch_trail_lookup( root_path=tmp_path, run_id=None, @@ -13265,11 +14155,7 @@ def close(self) -> None: ) assert malformed == ("malformed_stored_patch_trail", 0, None) - monkeypatch.setattr( - audit_mod, - "SqliteEngineeringMemoryStore", - lambda _db: _LookupStore(responses=[([], 0)]), - ) + install_lookup_store([([], 0)]) empty_root = tmp_path / "no-memory-root" empty_root.mkdir() assert audit_mod._memory_patch_trail_lookup( @@ -13752,3 +14638,38 @@ class _UnknownOwnership: ) is False ) + + +def test_context_governance_helper_edges() -> None: + with pytest.raises(ValueError, match="digest kind"): + mcp_context_governance_mod.context_governance_digest(" ", {"ok": True}) + + assert ( + mcp_context_governance_mod._continuation_lane( + {"cursor": "lane-1"}, + lane="memory.records", + omission="not-a-mapping", + ) + is None + ) + + lane = mcp_context_governance_mod._continuation_lane( + {"nested": {"cursor": "lane-2"}}, + lane="memory.records", + omission={ + "reason": "response_budget", + "shown": 0, + "total": 3, + "drill_down": {"cursor_path": "nested.cursor"}, + }, + ) + assert lane is not None + assert lane["cursor"] == "lane-2" + + assert mcp_context_governance_mod._resolve_dotted_path({}, "..bad") is None + assert ( + mcp_context_governance_mod._resolve_dotted_path({"items": []}, "items.id") + is None + ) + + assert mcp_context_governance_mod._continuation_payload({}, {}) is None diff --git a/tests/test_memory_cli_analysis.py b/tests/test_memory_cli_analysis.py index 9cdff810..de864fb6 100644 --- a/tests/test_memory_cli_analysis.py +++ b/tests/test_memory_cli_analysis.py @@ -12,10 +12,11 @@ import pytest +import codeclone.surfaces.cli.memory_analysis as memory_analysis_mod from codeclone.contracts import DEFAULT_JSON_REPORT_PATH from codeclone.memory.report_trust import CachedReportTrust +from codeclone.surfaces.cli.console import _rich_progress_symbols from codeclone.surfaces.cli.memory_analysis import ( - _rich_progress_symbols, load_report_for_memory_init, run_memory_analysis_report, ) @@ -107,9 +108,11 @@ def test_load_report_without_cached_file_runs_fresh(tmp_path: Path) -> None: fresh.assert_called_once_with(root_path=root) -def test_rich_progress_symbols_exports_types() -> None: +def test_memory_analysis_reuses_console_rich_progress_symbols() -> None: symbols = _rich_progress_symbols() assert len(symbols) == 5 + assert memory_analysis_mod._rich_progress_symbols is _rich_progress_symbols + assert symbols == _rich_progress_symbols() def test_run_memory_analysis_report_raises_when_document_missing( diff --git a/tests/test_memory_enums.py b/tests/test_memory_enums.py new file mode 100644 index 00000000..c4d0da58 --- /dev/null +++ b/tests/test_memory_enums.py @@ -0,0 +1,35 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +import pytest + +from codeclone.memory import enums as memory_enums + + +@pytest.mark.parametrize( + ("validator", "field"), + [ + (memory_enums.validate_memory_record_type, "record_type"), + (memory_enums.validate_memory_status, "record_status"), + (memory_enums.validate_memory_confidence, "record_confidence"), + (memory_enums.validate_memory_origin, "record_origin"), + (memory_enums.validate_memory_ingest_source, "record_ingest_source"), + (memory_enums.validate_subject_kind, "subject_kind"), + (memory_enums.validate_subject_relation, "subject_relation"), + (memory_enums.validate_evidence_kind, "evidence_kind"), + (memory_enums.validate_link_relation, "link_relation"), + (memory_enums.validate_ingestion_mode, "ingestion_mode"), + (memory_enums.validate_ingestion_run_status, "ingestion_run_status"), + ], +) +def test_memory_enum_validators_reject_unknown_values( + validator: object, + field: str, +) -> None: + with pytest.raises(ValueError, match=field): + validator("not-a-valid-enum-value", field=field) # type: ignore[operator] diff --git a/tests/test_memory_jobs_coverage.py b/tests/test_memory_jobs_coverage.py index f9eda965..b8e6e896 100644 --- a/tests/test_memory_jobs_coverage.py +++ b/tests/test_memory_jobs_coverage.py @@ -67,6 +67,12 @@ from .memory_fixtures import cli_memory_repo +def _mock_store_without_latest_done_job() -> MagicMock: + store = MagicMock() + store.connection.execute.return_value.fetchone.return_value = None + return store + + def test_spawn_projection_jobs_worker_success(tmp_path: Path) -> None: root = tmp_path / "repo" root.mkdir() @@ -279,7 +285,7 @@ def test_store_list_and_latest_done_projection_job(tmp_path: Path) -> None: def test_run_projection_job_failed_and_skipped() -> None: project = MagicMock() config = MagicMock() - store = MagicMock() + store = _mock_store_without_latest_done_job() with ( patch( "codeclone.memory.jobs.worker.execute_trajectory_rebuild", @@ -780,7 +786,7 @@ def test_run_projection_job_suppresses_bootstrap_span_when_delayed( project = MagicMock() project.id = "project" config = MagicMock() - store = MagicMock() + store = _mock_store_without_latest_done_job() skipped = {"status": "skipped"} patches = ( patch( diff --git a/tests/test_memory_render_plain.py b/tests/test_memory_render_plain.py index d22c9d50..6e6d4a87 100644 --- a/tests/test_memory_render_plain.py +++ b/tests/test_memory_render_plain.py @@ -239,3 +239,33 @@ def test_memory_render_init_dry_run_skips_empty_count_maps( ) out = capsys.readouterr().out assert "planned records" not in out + + +class _PlainTextStub: + def __init__(self, text: str, style: str = "") -> None: + self.text = text + self.style = style + + +def test_search_row_preserves_payload_objects_without_coercion() -> None: + from rich.text import Text as RichText + + numpy = pytest.importorskip("numpy") + weight = numpy.float32(2.5) + payload: dict[str, object] = {"weight": weight} + record: dict[str, object] = { + "type": "risk_note", + "status": "active", + "statement": "note", + "payload": payload, + } + + row = memory_render._search_row( + 1, + record, + cast(type[RichText], _PlainTextStub), + ) + + assert row[0] == "1" + assert record["payload"] is payload + assert payload["weight"] is weight diff --git a/tests/test_memory_retrieval_continuation.py b/tests/test_memory_retrieval_continuation.py index 35455a33..4ef6c54d 100644 --- a/tests/test_memory_retrieval_continuation.py +++ b/tests/test_memory_retrieval_continuation.py @@ -6,9 +6,9 @@ from __future__ import annotations -from collections.abc import Mapping +from collections.abc import Callable, Mapping from pathlib import Path -from typing import cast +from typing import TypeGuard, cast import pytest @@ -326,7 +326,7 @@ def test_memory_continuation_page_emits_next_cursor_for_remaining_items() -> Non assert page["response_complete"] is False assert "next" in page next_ref = page["next"] - assert isinstance(next_ref, dict) + assert _is_object_mapping(next_ref) assert next_ref["offset"] == 2 @@ -525,9 +525,11 @@ def _tampered_cursor(payload: dict[str, object]) -> str: return _encode_cursor(signed) -def _continuation_request_resolver(payload: Mapping[str, object]) -> object: +def _continuation_request_resolver( + payload: Mapping[str, object], +) -> Callable[[str], object]: internal = payload.get("_memory_projection_request") - if not isinstance(internal, Mapping): + if not _is_object_mapping(internal): raise AssertionError("expected _memory_projection_request in payload") digest = memory_projection_request_digest(internal) digest_value = str(digest["value"]) @@ -539,6 +541,10 @@ def _resolve(value: str) -> dict[str, object] | None: return _resolve +def _is_object_mapping(value: object) -> TypeGuard[Mapping[str, object]]: + return isinstance(value, Mapping) + + def _lane_page(payload: dict[str, object], lane: str) -> dict[str, object]: continuation = cast("dict[str, object]", payload["continuation"]) lanes = cast("dict[str, object]", continuation["lanes"]) diff --git a/tests/test_memory_retrieval_modes.py b/tests/test_memory_retrieval_modes.py index 37ba103c..d01de76a 100644 --- a/tests/test_memory_retrieval_modes.py +++ b/tests/test_memory_retrieval_modes.py @@ -9,12 +9,85 @@ from pathlib import Path from typing import cast +import pytest + +from codeclone.memory.exceptions import MemoryContractError from codeclone.memory.governance import record_candidate +from codeclone.memory.models import generate_memory_id from codeclone.memory.retrieval import query_engineering_memory +from codeclone.memory.retrieval.service import get_relevant_memory +from codeclone.report.meta import current_report_timestamp_utc from .memory_fixtures import memory_store, seed_path_subject_record +def _insert_legacy_memory_record_type( + store: object, + *, + project_id: str, + record_id: str, + record_type: str, + path: str, + schema_version: str = "1.7", +) -> None: + from codeclone.memory.sqlite_store import SqliteEngineeringMemoryStore + + assert isinstance(store, SqliteEngineeringMemoryStore) + now = current_report_timestamp_utc() + store._conn.execute( + """ + INSERT INTO memory_records( + id, project_id, identity_key, type, status, confidence, origin, + ingest_source, statement, summary, payload_json, created_at_utc, + updated_at_utc, last_verified_at_utc, expires_at_utc, created_by, + verified_by, approved_by, approved_at_utc, report_digest, + code_fingerprint, stale_reason, created_on_branch, created_at_commit, + verified_on_branch, verified_at_commit, schema_version + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? + ) + """, + ( + record_id, + project_id, + f"{record_type}:path:{path}:legacy", + record_type, + "active", + "inferred", + "agent", + "agent", + "legacy non-canonical memory record", + None, + None, + now, + now, + None, + None, + "legacy-test", + None, + None, + None, + None, + None, + None, + None, + None, + None, + None, + schema_version, + ), + ) + store._conn.execute( + """ + INSERT INTO memory_subjects(id, memory_id, subject_kind, subject_key, relation) + VALUES (?, ?, ?, ?, ?) + """, + (generate_memory_id(prefix="subj"), record_id, "path", path, "about"), + ) + store._conn.commit() + + def test_query_engineering_memory_get_stale_drafts_coverage( tmp_path: Path, ) -> None: @@ -128,6 +201,116 @@ def test_query_engineering_memory_get_missing_record(tmp_path: Path) -> None: assert result["status"] == "not_found" +def test_memory_retrieval_quarantines_legacy_invalid_record_type( + tmp_path: Path, +) -> None: + with memory_store(tmp_path) as (root, project, store, db_path): + record_id = "mem-legacy-invalid-type" + _insert_legacy_memory_record_type( + store, + project_id=project.id, + record_id=record_id, + record_type="decision", + path="pkg/legacy.py", + ) + + for_path = query_engineering_memory( + store, + project_id=project.id, + root_path=root, + backend="sqlite", + db_path=db_path, + mode="for_path", + path="pkg/legacy.py", + max_results=10, + include_stale=True, + ) + get_payload = query_engineering_memory( + store, + project_id=project.id, + root_path=root, + backend="sqlite", + db_path=db_path, + mode="get", + record_id=record_id, + ) + relevant = get_relevant_memory( + store, + project_id=project.id, + scope_paths=("pkg/legacy.py",), + scope_resolved_from="test", + max_records=5, + include_stale=True, + ) + + assert for_path["status"] == "ok" + payload = for_path["payload"] + assert isinstance(payload, dict) + assert payload["record_count"] == 0 + assert get_payload["status"] == "not_found" + assert relevant["record_count"] == 0 + + +def test_memory_retrieval_quarantines_legacy_record_schema_version( + tmp_path: Path, +) -> None: + with memory_store(tmp_path) as (root, project, store, db_path): + record_id = "mem-legacy-invalid-schema" + _insert_legacy_memory_record_type( + store, + project_id=project.id, + record_id=record_id, + record_type="risk_note", + path="pkg/legacy.py", + schema_version="0", + ) + + payload = query_engineering_memory( + store, + project_id=project.id, + root_path=root, + backend="sqlite", + db_path=db_path, + mode="for_path", + path="pkg/legacy.py", + max_results=10, + include_stale=True, + ) + get_payload = query_engineering_memory( + store, + project_id=project.id, + root_path=root, + backend="sqlite", + db_path=db_path, + mode="get", + record_id=record_id, + ) + + assert payload["status"] == "ok" + assert isinstance(payload["payload"], dict) + assert payload["payload"]["record_count"] == 0 + assert get_payload["status"] == "not_found" + + +def test_query_engineering_memory_rejects_invalid_filter_literals( + tmp_path: Path, +) -> None: + with ( + memory_store(tmp_path) as (root, project, store, db_path), + pytest.raises(MemoryContractError, match="record_type"), + ): + query_engineering_memory( + store, + project_id=project.id, + root_path=root, + backend="sqlite", + db_path=db_path, + mode="search", + query="legacy", + filters={"types": ["decision"]}, + ) + + def test_handle_semantic_search_disabled_block(tmp_path: Path) -> None: from codeclone.memory.retrieval.service import _handle_semantic_search_mode diff --git a/tests/test_memory_retrieval_service_coverage.py b/tests/test_memory_retrieval_service_coverage.py index c3925ee5..eb147f12 100644 --- a/tests/test_memory_retrieval_service_coverage.py +++ b/tests/test_memory_retrieval_service_coverage.py @@ -13,6 +13,7 @@ import pytest +from codeclone.memory.enums import MemoryConfidence, MemoryStatus from codeclone.memory.exceptions import MemoryContractError from codeclone.memory.experience.models import Experience from codeclone.memory.models import MemoryEvidence, MemoryRecord, MemorySubject @@ -25,15 +26,19 @@ from .memory_fixtures import memory_store -def _record(*, status: str = "active", confidence: str = "verified") -> MemoryRecord: +def _record( + *, + status: MemoryStatus = "active", + confidence: MemoryConfidence = "verified", +) -> MemoryRecord: now = current_report_timestamp_utc() return MemoryRecord( id="mem-1", project_id="proj", identity_key="id-1", type="contract_note", - status=status, # type: ignore[arg-type] - confidence=confidence, # type: ignore[arg-type] + status=status, + confidence=confidence, origin="system", ingest_source="analysis", statement="hello", @@ -57,13 +62,14 @@ def _record(*, status: str = "active", confidence: str = "verified") -> MemoryRe ) -def _stub_store(records: list[MemoryRecord]) -> object: - return SimpleNamespace( +def _stub_store(records: list[MemoryRecord]) -> SqliteEngineeringMemoryStore: + store = SimpleNamespace( find_record=lambda _record_id: None, list_subjects_for_memory=lambda _record_id: [], count_evidence_for_memory=lambda _record_id: 0, query_records=lambda _query: records, ) + return cast(SqliteEngineeringMemoryStore, store) def test_record_visible_serialize_and_parse_filters_branches() -> None: @@ -113,6 +119,15 @@ def test_record_visible_serialize_and_parse_filters_branches() -> None: assert mode == "all" assert include_routine is False + with pytest.raises(MemoryContractError) as exc_info: + retrieval_service._parse_filters({"typo": ["contract_note"]}) + message = str(exc_info.value) + assert "Unknown memory filter key(s): typo." in message + assert ( + "Allowed keys: types, statuses, confidences, match_mode, include_routine." + in message + ) + def test_retrieval_service_error_and_fallback_branches() -> None: with pytest.raises(TypeError, match="Path instances"): @@ -125,7 +140,7 @@ def test_retrieval_service_error_and_fallback_branches() -> None: with pytest.raises(MemoryContractError, match="mode=get requires record_id"): retrieval_service._handle_get_mode( - _stub_store([]), # type: ignore[arg-type] + _stub_store([]), mode="get", project_id="proj", record_id=None, @@ -145,7 +160,7 @@ def test_retrieval_service_error_and_fallback_branches() -> None: def test_for_symbol_and_unknown_mode_paths() -> None: store_with_records = _stub_store([_record()]) got = retrieval_service._fetch_for_symbol_mode_records( - store_with_records, # type: ignore[arg-type] + store_with_records, project_id="proj", symbol="pkg.mod.symbol", filter_types=(), @@ -155,7 +170,7 @@ def test_for_symbol_and_unknown_mode_paths() -> None: assert len(got) == 1 empty = retrieval_service._fetch_for_symbol_mode_records( - _stub_store([]), # type: ignore[arg-type] + _stub_store([]), project_id="proj", symbol="nosplit", filter_types=(), @@ -165,7 +180,7 @@ def test_for_symbol_and_unknown_mode_paths() -> None: assert empty == () fallback = retrieval_service._records_for_list_mode( - _stub_store([]), # type: ignore[arg-type] + _stub_store([]), mode="unknown", project_id="proj", path=None, @@ -259,6 +274,7 @@ def test_compact_record_subjects_are_bounded_and_scope_relevant() -> None: } compact_subjects = compact["subjects"] assert isinstance(compact_subjects, list) + compact_subjects = cast("list[dict[str, object]]", compact_subjects) assert len(compact_subjects) == retrieval_service.COMPACT_MEMORY_SUBJECT_LIMIT assert compact_subjects[0]["subject_key"] == "pkg/service.py" full_subjects = full.get("subjects") diff --git a/tests/test_memory_schema.py b/tests/test_memory_schema.py index 2597b79b..d5b1a980 100644 --- a/tests/test_memory_schema.py +++ b/tests/test_memory_schema.py @@ -7,6 +7,7 @@ from __future__ import annotations import sqlite3 +from dataclasses import replace from pathlib import Path from unittest.mock import patch @@ -14,13 +15,178 @@ from codeclone.contracts import ENGINEERING_MEMORY_SCHEMA_VERSION from codeclone.memory.exceptions import MemorySchemaError +from codeclone.memory.identity import make_identity_key +from codeclone.memory.models import ( + MemoryRecord, + generate_memory_id, + payload_json_text, +) +from codeclone.memory.project import resolve_project_identity from codeclone.memory.schema import ( create_schema_v1, ensure_schema, get_meta, open_memory_db, ) +from codeclone.memory.schema_meta import set_meta from codeclone.memory.schema_migrate import migrate_memory_schema +from codeclone.memory.sqlite_store import SqliteEngineeringMemoryStore +from codeclone.report.meta import current_report_timestamp_utc + + +def _memory_record( + *, + project_id: str, + record_id: str = "mem-legacy-supported", + path: str = "pkg/legacy.py", + discriminator: str = "legacy-supported", + statement: str = "legacy migration statement", +) -> MemoryRecord: + now = current_report_timestamp_utc() + return MemoryRecord( + id=record_id, + project_id=project_id, + identity_key=make_identity_key( + type="risk_note", + subject_kind="path", + subject_key=path, + discriminator=discriminator, + ), + type="risk_note", + status="active", + confidence="verified", + origin="agent", + ingest_source="agent", + statement=statement, + summary=None, + payload={"path": path}, + created_at_utc=now, + updated_at_utc=now, + last_verified_at_utc=now, + expires_at_utc=None, + created_by="legacy-test", + verified_by=None, + approved_by=None, + approved_at_utc=None, + report_digest=None, + code_fingerprint=None, + stale_reason=None, + created_on_branch=None, + created_at_commit=None, + verified_on_branch=None, + verified_at_commit=None, + ) + + +def _insert_project(conn: sqlite3.Connection, *, project_id: str, root: Path) -> None: + now = current_report_timestamp_utc() + conn.execute( + """ + INSERT INTO memory_projects( + id, root, git_remote, git_branch, git_head, python_tag, + created_at_utc, updated_at_utc + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (project_id, str(root), None, None, None, None, now, now), + ) + + +def _insert_record_row( + conn: sqlite3.Connection, + record: MemoryRecord, + *, + schema_version: str, + payload_json: str | None = None, + include_fts: bool = True, +) -> None: + conn.execute( + """ + INSERT INTO memory_records( + id, project_id, identity_key, type, status, confidence, origin, + ingest_source, statement, summary, payload_json, created_at_utc, + updated_at_utc, last_verified_at_utc, expires_at_utc, created_by, + verified_by, approved_by, approved_at_utc, report_digest, + code_fingerprint, stale_reason, created_on_branch, created_at_commit, + verified_on_branch, verified_at_commit, schema_version + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, + ?, ?, ?, ? + ) + """, + ( + record.id, + record.project_id, + record.identity_key, + record.type, + record.status, + record.confidence, + record.origin, + record.ingest_source, + record.statement, + record.summary, + ( + payload_json + if payload_json is not None + else payload_json_text(record.payload) + ), + record.created_at_utc, + record.updated_at_utc, + record.last_verified_at_utc, + record.expires_at_utc, + record.created_by, + record.verified_by, + record.approved_by, + record.approved_at_utc, + record.report_digest, + record.code_fingerprint, + record.stale_reason, + record.created_on_branch, + record.created_at_commit, + record.verified_on_branch, + record.verified_at_commit, + schema_version, + ), + ) + conn.execute( + """ + INSERT INTO memory_subjects(id, memory_id, subject_kind, subject_key, relation) + VALUES (?, ?, ?, ?, ?) + """, + ( + generate_memory_id(prefix="subj"), + record.id, + "path", + "pkg/legacy.py", + "about", + ), + ) + if include_fts: + conn.execute( + """ + INSERT INTO memory_records_fts( + memory_id, project_id, record_type, ingest_source, status, search_text + ) VALUES (?, ?, ?, ?, ?, ?) + """, + ( + record.id, + record.project_id, + record.type, + record.ingest_source, + record.status, + f"{record.statement} pkg/legacy.py", + ), + ) + conn.commit() + + +def _record_schema_test_connection(tmp_path: Path) -> tuple[str, sqlite3.Connection]: + root = tmp_path / "repo" + root.mkdir() + project = resolve_project_identity(root) + conn = sqlite3.connect(tmp_path / "memory.sqlite3") + create_schema_v1(conn) + _insert_project(conn, project_id=project.id, root=root) + return project.id, conn def test_open_memory_db_enables_foreign_keys(tmp_path: Path) -> None: @@ -50,8 +216,6 @@ def test_ensure_schema_migrates_1_0_to_1_1(tmp_path: Path) -> None: db_path = tmp_path / "memory.sqlite3" conn = sqlite3.connect(db_path) try: - from codeclone.memory.schema import set_meta - create_schema_v1(conn) set_meta(conn, "schema_version", "1.0") conn.commit() @@ -65,6 +229,219 @@ def test_ensure_schema_migrates_1_0_to_1_1(tmp_path: Path) -> None: conn.close() +def test_ensure_schema_reconciles_supported_legacy_record_rows( + tmp_path: Path, +) -> None: + root = tmp_path / "repo" + root.mkdir() + project = resolve_project_identity(root) + db_path = tmp_path / "memory.sqlite3" + record = _memory_record(project_id=project.id) + conn = sqlite3.connect(db_path) + try: + create_schema_v1(conn) + _insert_project(conn, project_id=project.id, root=root) + _insert_record_row(conn, record, schema_version="1.6") + set_meta(conn, "schema_version", "1.6") + conn.commit() + + ensure_schema(conn) + ensure_schema(conn) + + assert get_meta(conn, "schema_version") == ENGINEERING_MEMORY_SCHEMA_VERSION + row = conn.execute( + "SELECT schema_version FROM memory_records WHERE id=?", + (record.id,), + ).fetchone() + assert row == (ENGINEERING_MEMORY_SCHEMA_VERSION,) + finally: + conn.close() + + store = SqliteEngineeringMemoryStore(db_path) + try: + loaded = store.find_record(record.id) + assert loaded is not None + assert loaded.statement == "legacy migration statement" + by_identity = store.find_by_identity_key(project.id, record.identity_key) + assert by_identity is not None + assert by_identity.id == record.id + + search_hits = store.search_records( + project_id=project.id, + statement_query="legacy migration", + limit=5, + ) + assert [hit.id for hit in search_hits] == [record.id] + + active_records = store.list_records_for_project( + project.id, + statuses=("active",), + ) + assert [item.id for item in active_records] == [record.id] + + updated = store.upsert_record( + replace(record, statement="legacy migration revised") + ) + assert updated.action == "updated" + assert updated.revision_written is True + count, schema_version = store.connection.execute( + """ + SELECT COUNT(*), MIN(schema_version) + FROM memory_records + WHERE project_id=? AND identity_key=? + """, + (project.id, record.identity_key), + ).fetchone() + assert count == 1 + assert schema_version == ENGINEERING_MEMORY_SCHEMA_VERSION + + store.mark_stale(record.id, reason="migration-test") + stale_records = store.list_records_for_project( + project.id, + statuses=("stale",), + ) + assert [item.id for item in stale_records] == [record.id] + finally: + store.close() + + reopened = SqliteEngineeringMemoryStore(db_path) + try: + rows = reopened.connection.execute( + """ + SELECT id, schema_version + FROM memory_records + WHERE project_id=? AND identity_key=? + """, + (project.id, record.identity_key), + ).fetchall() + assert [tuple(row) for row in rows] == [ + (record.id, ENGINEERING_MEMORY_SCHEMA_VERSION) + ] + finally: + reopened.close() + + +def test_ensure_schema_reconciles_current_meta_legacy_record_rows( + tmp_path: Path, +) -> None: + root = tmp_path / "repo" + root.mkdir() + project = resolve_project_identity(root) + db_path = tmp_path / "memory.sqlite3" + record = _memory_record(project_id=project.id) + conn = sqlite3.connect(db_path) + try: + create_schema_v1(conn) + _insert_project(conn, project_id=project.id, root=root) + _insert_record_row(conn, record, schema_version="1.5") + + ensure_schema(conn) + + row = conn.execute( + "SELECT schema_version FROM memory_records WHERE id=?", + (record.id,), + ).fetchone() + assert row == (ENGINEERING_MEMORY_SCHEMA_VERSION,) + finally: + conn.close() + + +def test_record_schema_reconcile_keeps_unsupported_and_malformed_rows_quarantined( + tmp_path: Path, +) -> None: + from codeclone.memory.schema_migrate import reconcile_memory_record_schema_versions + + project_id, conn = _record_schema_test_connection(tmp_path) + unsupported = _memory_record( + project_id=project_id, + record_id="mem-unsupported", + discriminator="unsupported", + ) + malformed = _memory_record( + project_id=project_id, + record_id="mem-malformed", + discriminator="malformed", + ) + try: + _insert_record_row(conn, unsupported, schema_version="0") + _insert_record_row(conn, malformed, schema_version="1.6", payload_json="[]") + + assert reconcile_memory_record_schema_versions(conn) == 0 + + rows = conn.execute( + """ + SELECT id, schema_version + FROM memory_records + ORDER BY id ASC + """ + ).fetchall() + assert rows == [("mem-malformed", "1.6"), ("mem-unsupported", "0")] + finally: + conn.close() + + +def test_record_schema_reconcile_rolls_back_on_unexpected_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.memory import schema_migrate + + project_id, conn = _record_schema_test_connection(tmp_path) + first = _memory_record( + project_id=project_id, + record_id="mem-first", + discriminator="first", + ) + second = _memory_record( + project_id=project_id, + record_id="mem-second", + discriminator="second", + ) + try: + _insert_record_row(conn, first, schema_version="1.6") + _insert_record_row(conn, second, schema_version="1.6") + + real_write = schema_migrate._write_current_record_schema_version + calls = 0 + + def fail_after_first_write( + inner_conn: sqlite3.Connection, + *, + record_id: str, + legacy_version: object, + current_version: str, + ) -> None: + nonlocal calls + real_write( + inner_conn, + record_id=record_id, + legacy_version=legacy_version, + current_version=current_version, + ) + calls += 1 + if calls == 1: + raise RuntimeError("forced migration write failure") + + monkeypatch.setattr( + schema_migrate, + "_write_current_record_schema_version", + fail_after_first_write, + ) + with pytest.raises(RuntimeError, match="forced migration write failure"): + schema_migrate.reconcile_memory_record_schema_versions(conn) + + rows = conn.execute( + """ + SELECT id, schema_version + FROM memory_records + ORDER BY id ASC + """ + ).fetchall() + assert rows == [("mem-first", "1.6"), ("mem-second", "1.6")] + finally: + conn.close() + + def test_ensure_schema_rejects_unsupported_version(tmp_path: Path) -> None: db_path = tmp_path / "memory.sqlite3" conn = open_memory_db(db_path) @@ -101,8 +478,6 @@ def test_migrate_memory_schema_noop_when_already_current(tmp_path: Path) -> None def test_ensure_schema_raises_on_unsupported_version(tmp_path: Path) -> None: - from codeclone.memory.schema import create_schema_v1, ensure_schema, set_meta - db_path = tmp_path / "memory.sqlite3" conn = sqlite3.connect(db_path) try: diff --git a/tests/test_memory_sqlite_store_coverage.py b/tests/test_memory_sqlite_store_coverage.py index cef321d7..5f784092 100644 --- a/tests/test_memory_sqlite_store_coverage.py +++ b/tests/test_memory_sqlite_store_coverage.py @@ -368,6 +368,44 @@ def test_upsert_skips_human_origin_record(tmp_path: Path) -> None: store.close() +def test_sqlite_store_search_match_mode_and_literal_row_validation( + tmp_path: Path, +) -> None: + from codeclone.memory import sqlite_store as sqlite_store_mod + + root = tmp_path / "repo" + root.mkdir() + project = resolve_project_identity(root) + store = SqliteEngineeringMemoryStore(tmp_path / "memory.sqlite3") + try: + store.initialize(project) + with pytest.raises(ValueError, match="search_match_mode"): + store.search_records( + project_id=project.id, + statement_query="fact", + match_mode="invalid", # type: ignore[arg-type] + ) + finally: + store.close() + + with pytest.raises(ValueError, match="Invalid Engineering Memory record_type"): + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + conn.execute("CREATE TABLE memory_records (type TEXT)") + conn.execute( + "INSERT INTO memory_records (type) VALUES (?)", + ("not-a-real-type",), + ) + row = conn.execute("SELECT type FROM memory_records").fetchone() + assert row is not None + sqlite_store_mod._literal_from_row( + row, + column="type", + field="record_type", + allowed=sqlite_store_mod._MEMORY_RECORD_TYPES, + ) + + def test_open_sqlite_db_rejects_invalid_synchronous(tmp_path: Path) -> None: from codeclone.utils.sqlite_store import open_sqlite_db diff --git a/tests/test_memory_store.py b/tests/test_memory_store.py index db352f2f..a63b3f36 100644 --- a/tests/test_memory_store.py +++ b/tests/test_memory_store.py @@ -9,10 +9,16 @@ from dataclasses import replace from pathlib import Path +import pytest + from codeclone.memory.identity import make_identity_key from codeclone.memory.models import ( + IngestionRun, + MemoryEvidence, + MemoryLink, MemoryRecord, MemoryRevision, + MemorySubject, generate_memory_id, ) from codeclone.memory.project import resolve_project_identity @@ -104,11 +110,87 @@ def test_store_crud_upsert_and_revision(tmp_path: Path) -> None: store.close() +def test_store_validates_database_bound_memory_inputs(tmp_path: Path) -> None: + root = tmp_path / "repo" + root.mkdir() + project = resolve_project_identity(root) + store = SqliteEngineeringMemoryStore(tmp_path / "memory.sqlite3") + try: + store.initialize(project) + record = _sample_record(project_id=project.id) + invalid_record = replace(record, type="decision") # type: ignore[arg-type] + with pytest.raises(ValueError, match=r"record: type"): + store.upsert_record(invalid_record) + + store.upsert_record(record) + invalid_subject = MemorySubject( + id=generate_memory_id(prefix="subj"), + memory_id=record.id, + subject_kind="repo_file", # type: ignore[arg-type] + subject_key="pkg/mod.py", + relation="about", + ) + with pytest.raises(ValueError, match=r"subject: subject_kind"): + store.write_subject(invalid_subject) + + invalid_evidence = MemoryEvidence( + id=generate_memory_id(prefix="evid"), + memory_id=record.id, + evidence_kind="unknown", # type: ignore[arg-type] + ref="test", + locator=None, + quote=None, + digest=None, + created_at_utc=current_report_timestamp_utc(), + ) + with pytest.raises(ValueError, match=r"evidence: evidence_kind"): + store.write_evidence(invalid_evidence) + + invalid_link = MemoryLink( + id=generate_memory_id(prefix="link"), + project_id=project.id, + from_memory_id=record.id, + to_memory_id=record.id, + relation="references", # type: ignore[arg-type] + created_by="test", + created_at_utc=current_report_timestamp_utc(), + ) + with pytest.raises(ValueError, match=r"link: relation"): + store.write_link(invalid_link) + + with pytest.raises(ValueError, match="record_status"): + store.update_record_status(record.id, status="pending") + + empty_statement = replace(record, statement="") + with pytest.raises(ValueError, match=r"record: statement"): + store.upsert_record(empty_statement) + + wrong_schema = replace(record, id=generate_memory_id(), schema_version="0") + with pytest.raises(ValueError, match=r"record: schema_version"): + store.upsert_record(wrong_schema) + + invalid_run = IngestionRun( + id=generate_memory_id(prefix="run"), + project_id=project.id, + mode="refresh", + started_at_utc=current_report_timestamp_utc(), + finished_at_utc=None, + status="completed", + analysis_fingerprint=None, + report_digest=None, + branch=None, + commit=None, + records_created=-1, + ) + with pytest.raises(ValueError, match=r"ingestion_run: records_created"): + store.write_ingestion_run(invalid_run) + finally: + store.close() + + def test_write_subject_is_idempotent_and_prune_removes_duplicates( tmp_path: Path, ) -> None: - from codeclone.memory.models import MemorySubject - root = tmp_path / "repo" root.mkdir() project = resolve_project_identity(root) diff --git a/tests/test_memory_trajectory_store.py b/tests/test_memory_trajectory_store.py index 0111e15b..88429dc5 100644 --- a/tests/test_memory_trajectory_store.py +++ b/tests/test_memory_trajectory_store.py @@ -108,6 +108,34 @@ def test_rebuild_trajectories_from_audit_is_idempotent(tmp_path: Path) -> None: assert store.latest_trajectory_projection_run(project_id=project.id) is not None +def test_trajectory_store_rejects_corrupt_persisted_literals( + tmp_path: Path, +) -> None: + with memory_store(tmp_path) as (root, project, store, _db_path): + audit_db = tmp_path / "audit.sqlite3" + _write_workflow_events(root, audit_db) + store.rebuild_trajectories_from_audit( + project=project, + root_path=root, + audit_db_path=audit_db, + ) + trajectory_id = store.list_trajectories(project_id=project.id)[0].id + + store._conn.execute( + "UPDATE memory_trajectories SET outcome=? WHERE id=?", + ("maybe", trajectory_id), + ) + with pytest.raises(ValueError, match="trajectory outcome"): + store.find_trajectory(trajectory_id) + + store._conn.execute( + "UPDATE memory_trajectories SET outcome=?, labels_json=? WHERE id=?", + ("accepted", '["unknown_label"]', trajectory_id), + ) + with pytest.raises(ValueError, match="trajectory label"): + store.find_trajectory(trajectory_id) + + def test_find_trajectories_by_ids_batch_matches_single_path(tmp_path: Path) -> None: from codeclone.memory.trajectory import store as trajectory_store @@ -367,3 +395,98 @@ def test_find_trajectory_patch_trails_for_lookup_run_filter_and_malformed( ) assert len(by_intent) == 1 assert by_intent[0].workflow_id == "intent:intent-test-001" + + +def test_trajectory_literal_parsers_accept_known_values() -> None: + from codeclone.memory.trajectory import store as trajectory_store + + for outcome in ( + "accepted", + "accepted_with_external_changes", + "violated", + "blocked", + "abandoned", + "partial", + ): + assert trajectory_store._trajectory_outcome(outcome) == outcome + + for tier in ("corrected", "verified", "incident", "partial", "routine"): + assert trajectory_store._trajectory_quality_tier(tier) == tier + + for label in ( + "analysis_observed", + "baseline_abuse_detected", + "change_control_workflow", + "claim_guard_failed", + "claim_validated", + "external_changes_accepted", + "foreign_conflict_seen", + "hook_blocked", + "memory_used", + "patch_trail_recorded", + "queue_used", + "receipt_issued", + "recovered", + "scope_clean", + "scope_expanded", + "verified_finish", + ): + assert trajectory_store._trajectory_label(label) == label + + parsed = trajectory_store._labels_from_json('["scope_clean", "verified_finish"]') + assert parsed == ("scope_clean", "verified_finish") + + +def test_trajectory_literal_parsers_reject_invalid_values() -> None: + from codeclone.memory.trajectory import store as trajectory_store + + with pytest.raises(ValueError, match="trajectory outcome"): + trajectory_store._trajectory_outcome("not-an-outcome") + with pytest.raises(ValueError, match="quality_tier"): + trajectory_store._trajectory_quality_tier("not-a-tier") + with pytest.raises(ValueError, match="trajectory label"): + trajectory_store._trajectory_label("not-a-label") + with pytest.raises(ValueError, match="labels_json"): + trajectory_store._labels_from_json('{"not": "a-list"}') + + +def test_trajectory_run_id_token_matching_handles_missing_values() -> None: + from codeclone.memory.trajectory import store as trajectory_store + + assert trajectory_store._run_id_token_matches(None, "abc") is False + assert trajectory_store._run_id_token_matches("abc", "") is False + assert trajectory_store._run_id_token_matches("abcdef", "abc") is True + assert trajectory_store._run_id_token_matches("abc", "abcdef") is True + + +def test_memory_schema_migrate_payload_and_text_helpers( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from codeclone.memory import schema_migrate as schema_migrate_mod + + assert schema_migrate_mod._strict_payload_json(None) is None + assert schema_migrate_mod._strict_payload_json("") is None + assert schema_migrate_mod._strict_payload_json('{"ok": true}') == {"ok": True} + + with pytest.raises(TypeError, match="payload_json must be text"): + schema_migrate_mod._strict_payload_json(123) + with pytest.raises(ValueError, match="must decode to an object"): + schema_migrate_mod._strict_payload_json("[]") + + import orjson + + monkeypatch.setattr( + orjson, + "loads", + lambda _value: {1: "x"}, + ) + monkeypatch.setattr(schema_migrate_mod, "orjson", orjson) + with pytest.raises(ValueError, match="keys must be strings"): + schema_migrate_mod._strict_payload_json('{"ignored": true}') + + with pytest.raises(ValueError, match="name must be non-empty"): + schema_migrate_mod._required_text({"name": ""}, "name") + + assert schema_migrate_mod._optional_text({"name": None}, "name") is None + with pytest.raises(ValueError, match="name must be text"): + schema_migrate_mod._optional_text({"name": 1}, "name") diff --git a/tests/test_module_map.py b/tests/test_module_map.py index 3db52b36..a5bbc4b0 100644 --- a/tests/test_module_map.py +++ b/tests/test_module_map.py @@ -199,6 +199,65 @@ def test_payload_is_order_independent() -> None: assert first == second +def test_wide_namespace_falls_back_to_package_depth_two() -> None: + # P1>28 and P2>28 with module_count<=80 -> final fallback row (line 821). + mods = [f"r{root}.s{sub}.leaf" for root in range(30) for sub in range(2)] + edges = [(mods[index], mods[index + 1]) for index in range(len(mods) - 1)] + module_map: Any = _build_derived_module_map(_payload(edges=edges)) + assert module_map["summary"]["module_count"] == 60 + assert module_map["default_zoom"] == "packages" + assert module_map["graph_packages"]["package_depth"] == 2 + + +def test_self_loop_edges_are_ignored_at_module_zoom() -> None: + module_map: Any = _build_derived_module_map( + _payload(edges=[("pkg.a", "pkg.a"), ("pkg.a", "pkg.b")]) + ) + edge_pairs = { + (edge["source"], edge["target"]) + for edge in module_map["graph_modules"]["edges"] + } + assert ("pkg.a", "pkg.a") not in edge_pairs + assert ("pkg.a", "pkg.b") in edge_pairs + + +def test_unwind_signals_cover_import_instability_and_sink_paths() -> None: + module_map: Any = _build_derived_module_map( + _payload( + edges=[("sink.mod", "other.mod"), ("unstable.mod", "other.mod")], + chains=[["chain.a", "chain.b"]], + overloaded=[ + _overloaded( + "chain.b", + fan_in=3, + fan_out=1, + candidate_status="non_candidate", + ), + _overloaded( + "sink.mod", + fan_in=20, + fan_out=2, + instability=0.8, + candidate_status="ranked_only", + candidate_reasons=["repeated_import_pressure"], + ), + _overloaded( + "unstable.mod", + fan_in=2, + fan_out=4, + instability=0.9, + candidate_status="non_candidate", + ), + ], + ) + ) + rows = {row["module"]: row for row in module_map["unwind_candidates"]} + assert "repeated_import_pressure" in rows["sink.mod"]["signals"] + assert "high_instability" in rows["unstable.mod"]["signals"] + assert "central_sink" in rows["sink.mod"]["signals"] + assert "chain_bottleneck" in rows["chain.b"]["signals"] + + def test_ranked_only_population_has_no_candidate_overlay() -> None: overloaded = [ _overloaded("a.b", candidate_status="ranked_only"), diff --git a/tests/test_observability_cli_pipeline.py b/tests/test_observability_cli_pipeline.py index 6a014ba0..636c3c8c 100644 --- a/tests/test_observability_cli_pipeline.py +++ b/tests/test_observability_cli_pipeline.py @@ -6,6 +6,9 @@ from __future__ import annotations +import os +import subprocess +import sys from argparse import Namespace from collections.abc import Iterator from pathlib import Path @@ -41,6 +44,7 @@ ) from codeclone.observability.store.writer import write_operation from codeclone.surfaces.cli.observability import observability_main +from tests.test_observability_query import _seed_future_observability_schema @pytest.fixture(autouse=True) @@ -137,9 +141,18 @@ def _analysis() -> AnalysisResult: class _FakeCache: load_warning: str | None = None + def __init__(self) -> None: + self.saved = False + self.release_calls: list[bool] = [] + def save(self) -> None: + self.saved = True return None + def release_loaded_entries(self, *, allow_dirty: bool = False) -> int: + self.release_calls.append(allow_dirty) + return 0 + def _run_observed_pipeline( tmp_path: Path, @@ -152,6 +165,27 @@ def _run_observed_pipeline( ) +def test_cli_pipeline_releases_cache_after_save( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + cache = _FakeCache() + monkeypatch.setattr(cli, "discover", lambda **_kw: _discovery(("a.py",))) + monkeypatch.setattr(cli, "process", lambda **_kw: _processing()) + monkeypatch.setattr(cli, "analyze", lambda **_kw: _analysis()) + args = Namespace( + quiet=True, no_progress=True, blast_radius=False, patch_verify=False + ) + + cli._run_analysis_stages( + args=args, + boot=_boot(tmp_path, args), + cache=cast(Cache, cache), + ) + + assert cache.saved is True + assert cache.release_calls == [False] + + def _read_span_rows(tmp_path: Path) -> tuple[list[Any], dict[str, object]]: conn = open_observability_store(observability_store_path(tmp_path)) try: @@ -265,6 +299,55 @@ def test_cli_pipeline_cache_only_keeps_legacy_process_counters( } +def test_cli_main_emits_io_and_report_spans(tmp_path: Path) -> None: + repo = tmp_path / "repo" + repo.mkdir() + (repo / "sample.py").write_text("def add(a, b):\n return a + b\n", "utf-8") + env = os.environ.copy() + env["CODECLONE_OBSERVABILITY_ENABLED"] = "1" + + result = subprocess.run( + [ + sys.executable, + "-m", + "codeclone.main", + str(repo), + "--quiet", + "--no-progress", + "--cache-path", + str(repo / ".codeclone" / "test-cache.json"), + ], + cwd=repo, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stdout + result.stderr + conn = open_observability_store(observability_store_path(repo)) + try: + rows = conn.execute( + "SELECT s.name, s.operation_id FROM platform_spans s " + "JOIN platform_operations o ON o.operation_id=s.operation_id " + "WHERE o.name='cli.analyze'" + ).fetchall() + finally: + conn.close() + + names = {row[0] for row in rows} + assert { + "pipeline.baseline", + "pipeline.cache_load", + "pipeline.bootstrap", + "pipeline.discover", + "pipeline.process", + "pipeline.analyze", + "pipeline.report", + } <= names + assert len({row[1] for row in rows}) == 1 + + def test_observability_cli_help_and_stdout_trace( tmp_path: Path, capsys: pytest.CaptureFixture[str], @@ -351,3 +434,17 @@ def test_observability_cli_missing_store_and_file_outputs( assert html_path.is_file() assert f"Wrote {json_path}" in out assert f"Wrote {html_path}" in out + + +def test_observability_cli_future_schema_reports_internal_error( + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + _seed_future_observability_schema(tmp_path) + + code = observability_main(["trace", "--root", str(tmp_path)]) + out = capsys.readouterr().out + + assert code == int(ExitCode.INTERNAL_ERROR) + assert "INTERNAL ERROR" in out + assert "newer than this CodeClone build" in out diff --git a/tests/test_observability_correlation.py b/tests/test_observability_correlation.py index d73a5d76..b3a48394 100644 --- a/tests/test_observability_correlation.py +++ b/tests/test_observability_correlation.py @@ -18,6 +18,7 @@ import codeclone.memory.jobs.spawn as spawn import codeclone.memory.jobs.worker as worker from codeclone.config.observability import ObservabilityConfig +from codeclone.memory.models import MemoryProject from codeclone.observability import ( bootstrap, counting_connection_factory, @@ -38,6 +39,31 @@ def _reset_runtime() -> Iterator[None]: shutdown() +def _empty_projection_jobs_connection() -> sqlite3.Connection: + conn = sqlite3.connect(":memory:") + conn.execute( + """ + CREATE TABLE memory_projection_jobs( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL, + job_kind TEXT NOT NULL, + status TEXT NOT NULL, + trigger TEXT NOT NULL, + requested_at_utc TEXT NOT NULL, + started_at_utc TEXT, + finished_at_utc TEXT, + claimed_by TEXT, + attempt INTEGER NOT NULL DEFAULT 0, + stimulus_json TEXT NOT NULL, + result_json TEXT, + error_message TEXT, + flush_claimed_by TEXT + ) + """ + ) + return conn + + def test_current_operation_context(tmp_path: Path) -> None: assert current_operation_context() is None # disabled bootstrap(ObservabilityConfig(enabled=True), root=tmp_path) @@ -68,14 +94,26 @@ def test_run_projection_job_links_under_finish( bootstrap(ObservabilityConfig(enabled=True), root=tmp_path) store = MagicMock() + store.connection = _empty_projection_jobs_connection() + project = MemoryProject( + id="proj-test", + root=str(tmp_path), + git_remote=None, + git_branch=None, + git_head=None, + python_tag=None, + created_at_utc="2026-01-01T00:00:00.000000Z", + updated_at_utc="2026-01-01T00:00:00.000000Z", + ) worker.run_projection_job( store, job_id="j1", root_path=tmp_path, config=MagicMock(), - project=MagicMock(), + project=project, stimulus={}, ) + store.connection.close() shutdown() conn = open_observability_store(observability_store_path(tmp_path)) @@ -166,7 +204,18 @@ def test_mcp_analyze_repository_emits_pipeline_spans(tmp_path: Path) -> None: finally: conn.close() names = {row[0] for row in rows} - assert {"pipeline.discover", "pipeline.process", "pipeline.analyze"} <= names + # IO work (baseline read, cache load) and the post-analyze report build/serialize + # are now first-class spans, so mcp.analyze is no longer blind between/around the + # bootstrap/discover/process/analyze stages. + assert { + "pipeline.baseline", + "pipeline.cache_load", + "pipeline.bootstrap", + "pipeline.discover", + "pipeline.process", + "pipeline.analyze", + "pipeline.report", + } <= names def test_db_query_counter_attaches_to_active_span(tmp_path: Path) -> None: diff --git a/tests/test_observability_query.py b/tests/test_observability_query.py index 0bb8132a..58e1d447 100644 --- a/tests/test_observability_query.py +++ b/tests/test_observability_query.py @@ -6,6 +6,7 @@ from __future__ import annotations +import sqlite3 from pathlib import Path from typing import cast @@ -30,6 +31,26 @@ def _texts(value: object) -> list[str]: return cast("list[str]", value) +def _seed_future_observability_schema(root: Path) -> None: + path = observability_store_path(root) + path.parent.mkdir(parents=True) + conn = sqlite3.connect(path) + try: + conn.executescript( + """ + CREATE TABLE platform_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT INTO platform_meta(key, value) + VALUES('schema_version', '999.0'); + """ + ) + conn.commit() + finally: + conn.close() + + def _seed(tmp_path: Path) -> None: conn = open_observability_store(observability_store_path(tmp_path)) try: @@ -117,6 +138,21 @@ def _seed(tmp_path: Path) -> None: conn.close() +def test_query_platform_observability_future_schema_returns_inert_envelope( + tmp_path: Path, +) -> None: + _seed_future_observability_schema(tmp_path) + + result = query_platform_observability( + root=tmp_path, + section="summary", + ) + + assert result["status"] == "incompatible_schema" + assert result["rows"] == [] + assert "newer than this CodeClone build" in str(result["error"]) + + def _seed_analysis_phases(tmp_path: Path) -> None: conn = open_observability_store(observability_store_path(tmp_path)) try: @@ -157,10 +193,14 @@ def _seed_analysis_phases(tmp_path: Path) -> None: def test_summary_returns_envelope_diagnostics_and_routing(tmp_path: Path) -> None: _seed(tmp_path) out = query_platform_observability(root=tmp_path, section="summary") - assert out["surface"] == "platform_observability" - assert out["user_facing"] is False - assert out["operations"] == 4 - assert out["costly_noops"] == 1 + expected_scalars = { + "surface": "platform_observability", + "status": "ok", + "user_facing": False, + "operations": 4, + "costly_noops": 1, + } + assert {key: out[key] for key in expected_scalars} == expected_scalars assert out["context_pressure_units"] == out["context_pressure_tokens"] kinds = {d["kind"] for d in _rows(out["top_diagnostics"])} assert {"memory", "db", "context"} <= kinds @@ -211,6 +251,43 @@ def test_analysis_phase_cost_section_and_summary_routing(tmp_path: Path) -> None assert "analysis" in diagnostics +def test_analysis_diagnostic_helper_branches() -> None: + from codeclone.observability.views import AggregatesView, AnalysisPhaseRow + + assert query_mod._analysis_diagnostic(AggregatesView(operation_count=0)) is None + assert ( + query_mod._analysis_diagnostic( + AggregatesView( + operation_count=1, + analysis_phases=( + AnalysisPhaseRow( + phase="unit_cfg", + worker_elapsed_ms=1.0, + share_permille=100, + verdict="balanced", + ), + ), + ) + ) + is None + ) + heavy = query_mod._analysis_diagnostic( + AggregatesView( + operation_count=1, + analysis_phases=( + AnalysisPhaseRow( + phase="unit_cfg", + worker_elapsed_ms=2500.0, + share_permille=833, + verdict="phase_heavy", + ), + ), + ) + ) + assert heavy is not None + assert heavy["kind"] == "analysis" + + def test_summary_does_not_embed_raw_trace(tmp_path: Path) -> None: _seed(tmp_path) out = query_platform_observability(root=tmp_path, section="summary") @@ -242,6 +319,68 @@ def test_detail_selectors_ignored_and_echoed(tmp_path: Path) -> None: assert out["ignored_parameters"] == ["operation_id", "span_id"] +def test_operation_detail_projects_per_span_progression(tmp_path: Path) -> None: + _seed(tmp_path) + out = query_platform_observability( + root=tmp_path, + section="operation_detail", + detail_level="full", + operation_id="J", + ) + # Per-object detail sections support full and consume operation_id. + assert out["status"] == "ok" + assert out["detail_level"] == "full" + assert out["operation_id"] == "J" + assert "ignored_parameters" not in out + assert out["span_count"] == 2 + spans = {cast("str", s["span_id"]): s for s in _rows(out["spans"])} + assert spans["r"]["name"] == "memory.semantic.rebuild" + assert spans["r"]["rss_delta_mb"] == 440.0 + counters = cast("dict[str, int]", spans["r"]["counters"]) + assert counters["db_queries"] == 1370 + + +def test_span_detail_projects_one_span_by_id(tmp_path: Path) -> None: + _seed(tmp_path) + out = query_platform_observability( + root=tmp_path, section="span_detail", span_id="d" + ) + assert out["status"] == "ok" + assert out["span_id"] == "d" + assert out["operation_id"] == "J" + counters = cast("dict[str, int]", out["counters"]) + assert counters["experiences_distilled"] == 47 + + +def test_detail_sections_fail_closed_on_missing_or_unknown_selector( + tmp_path: Path, +) -> None: + _seed(tmp_path) + missing = query_platform_observability(root=tmp_path, section="operation_detail") + assert missing["status"] == "invalid_selector" + missing_span = query_platform_observability(root=tmp_path, section="span_detail") + assert missing_span["status"] == "invalid_selector" + assert missing_span["error"] == "span_detail requires span_id" + not_found = query_platform_observability( + root=tmp_path, section="span_detail", span_id="nope" + ) + assert not_found["status"] == "not_found" + missing_operation = query_platform_observability( + root=tmp_path, + section="operation_detail", + operation_id="missing-op", + ) + assert missing_operation["status"] == "not_found" + + +def test_aggregate_rows_expose_ids_for_drilldown(tmp_path: Path) -> None: + _seed(tmp_path) + out = query_platform_observability( + root=tmp_path, section="slow_operations", detail_level="normal" + ) + assert all("operation_id" in row for row in _rows(out["rows"])) + + def test_absent_store_is_inert_not_error(tmp_path: Path) -> None: out = query_platform_observability(root=tmp_path, section="summary") assert out["status"] in {"disabled", "no_store"} @@ -249,6 +388,16 @@ def test_absent_store_is_inert_not_error(tmp_path: Path) -> None: assert out["user_facing"] is False +def test_empty_store_reports_empty_status(tmp_path: Path) -> None: + conn = open_observability_store(observability_store_path(tmp_path)) + conn.close() + + out = query_platform_observability(root=tmp_path, section="summary") + + assert out["status"] == "empty" + assert out["operations"] == 0 + + def test_disabled_vs_no_store_split( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -273,7 +422,7 @@ def test_limit_is_clamped_and_floored(tmp_path: Path) -> None: big = query_platform_observability( root=tmp_path, section="db_cost", detail_level="normal", limit=10000 ) - assert any("clamped to 50" in w for w in _texts(big["warnings"])) + assert any("clamped to 100" in w for w in _texts(big["warnings"])) bad = query_platform_observability(root=tmp_path, section="db_cost", limit=0) assert any("invalid" in w for w in _texts(bad["warnings"])) @@ -348,7 +497,7 @@ def test_projection_helpers_and_diagnostic_edges( ) warnings: list[str] = [] - assert query_mod._resolve_detail("verbose", warnings) == "compact" + assert query_mod._resolve_detail("verbose", "summary", warnings) == "compact" assert warnings sentinel = object() @@ -359,7 +508,8 @@ def _build_trace(_conn: object, **kwargs: object) -> object: return sentinel monkeypatch.setattr(query_mod, "build_trace_view", _build_trace) - assert query_mod._build_trace(object(), "corr-1") is sentinel + conn = cast(sqlite3.Connection, object()) + assert query_mod._build_trace(conn, "corr-1") is sentinel assert calls == [{"correlation_id": "corr-1"}] empty_child = OperationView( diff --git a/tests/test_observability_reader.py b/tests/test_observability_reader.py index 0b0eaa67..85e6b99b 100644 --- a/tests/test_observability_reader.py +++ b/tests/test_observability_reader.py @@ -503,3 +503,29 @@ def test_build_trace_view_on_empty_store_returns_empty_window(tmp_path: Path) -> read.close() assert trace.aggregates.operation_count == 0 assert trace.operation_tree == () + + +def test_reader_counter_and_optional_float_helpers() -> None: + import sqlite3 + + from codeclone.observability.store.reader import _optional_float, _parse_counters + + assert _parse_counters(None) == {} + assert _parse_counters(b"[]") == {} + assert _parse_counters(b'{"hits": 3, "bad": true, "ok": "4"}') == { + "hits": 3, + "ok": 4, + } + + conn = sqlite3.connect(":memory:") + conn.row_factory = sqlite3.Row + try: + row = conn.execute("SELECT NULL AS value").fetchone() + assert row is not None + assert _optional_float(row, "missing") is None + assert _optional_float(row, "value") is None + numeric = conn.execute("SELECT 1.5 AS value").fetchone() + assert numeric is not None + assert _optional_float(numeric, "value") == 1.5 + finally: + conn.close() diff --git a/tests/test_observability_store.py b/tests/test_observability_store.py index a78708ef..0d5796ca 100644 --- a/tests/test_observability_store.py +++ b/tests/test_observability_store.py @@ -17,7 +17,10 @@ ProfileSample, SpanRecord, ) -from codeclone.observability.store.reader import build_trace_view +from codeclone.observability.store.reader import ( + build_trace_view, + open_observability_store_readonly, +) from codeclone.observability.store.schema import ( observability_store_path, open_observability_store, @@ -69,6 +72,98 @@ def test_store_records_schema_version(tmp_path: Path) -> None: conn.close() +def test_store_uses_standard_sqlite_pragmas(tmp_path: Path) -> None: + conn = open_observability_store(observability_store_path(tmp_path)) + try: + assert conn.execute("PRAGMA busy_timeout").fetchone()[0] == 5000 + assert conn.execute("PRAGMA journal_mode").fetchone()[0].lower() == "wal" + finally: + conn.close() + + +def test_store_rejects_future_schema_without_overwriting_meta( + tmp_path: Path, +) -> None: + path = observability_store_path(tmp_path) + path.parent.mkdir(parents=True) + conn = sqlite3.connect(path) + try: + conn.executescript( + """ + CREATE TABLE platform_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT INTO platform_meta(key, value) + VALUES('schema_version', '999.0'); + """ + ) + conn.commit() + finally: + conn.close() + + with pytest.raises(RuntimeError, match="newer than this CodeClone build"): + open_observability_store(path) + + conn = sqlite3.connect(path) + try: + row = conn.execute( + "SELECT value FROM platform_meta WHERE key='schema_version'" + ).fetchone() + assert row == ("999.0",) + finally: + conn.close() + + +def test_store_rejects_malformed_schema_version(tmp_path: Path) -> None: + path = observability_store_path(tmp_path) + path.parent.mkdir(parents=True) + conn = sqlite3.connect(path) + try: + conn.executescript( + """ + CREATE TABLE platform_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT INTO platform_meta(key, value) + VALUES('schema_version', 'not-a-version'); + """ + ) + conn.commit() + finally: + conn.close() + + with pytest.raises( + RuntimeError, + match="Unsupported Platform Observability schema version", + ): + open_observability_store(path) + + +def test_readonly_store_rejects_future_schema(tmp_path: Path) -> None: + path = observability_store_path(tmp_path) + path.parent.mkdir(parents=True) + conn = sqlite3.connect(path) + try: + conn.executescript( + """ + CREATE TABLE platform_meta ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL + ); + INSERT INTO platform_meta(key, value) + VALUES('schema_version', '999.0'); + """ + ) + conn.commit() + finally: + conn.close() + + with pytest.raises(RuntimeError, match="newer than this CodeClone build"): + open_observability_store_readonly(tmp_path) + + def test_write_operation_persists_op_and_spans(tmp_path: Path) -> None: conn = open_observability_store(observability_store_path(tmp_path)) try: diff --git a/tests/test_options_spec_coverage.py b/tests/test_options_spec_coverage.py index 736fec58..e672e549 100644 --- a/tests/test_options_spec_coverage.py +++ b/tests/test_options_spec_coverage.py @@ -15,6 +15,17 @@ from codeclone.config.resolver import collect_explicit_cli_dests, resolve_config from codeclone.config.spec import PYPROJECT_OPTIONS, TESTABLE_CLI_OPTIONS, OptionSpec +_PARSEABLE_CLI_OPTIONS = tuple( + option for option in TESTABLE_CLI_OPTIONS if option.dest != "interactive_help" +) +_CONFIG_DEFAULTS_DOC = Path("docs/book/10-config-and-defaults.md") + + +def _read_config_defaults_doc() -> str: + if not _CONFIG_DEFAULTS_DOC.is_file(): + pytest.skip("repo docs source tree is not present") + return _CONFIG_DEFAULTS_DOC.read_text(encoding="utf-8") + def _option_id(option: OptionSpec) -> str: if option.flags: @@ -78,7 +89,7 @@ def test_pyproject_option_count_matches_declared_specs() -> None: assert len(pyproject_keys) == len(set(pyproject_keys)) -@pytest.mark.parametrize("option", TESTABLE_CLI_OPTIONS, ids=_option_id) +@pytest.mark.parametrize("option", _PARSEABLE_CLI_OPTIONS, ids=_option_id) def test_option_specs_have_cli_parsing_coverage(option: OptionSpec) -> None: parser = build_parser("2.0.0") argv, expected = _cli_sample(option) @@ -119,7 +130,7 @@ def test_option_specs_have_pyproject_loading_coverage( def test_config_defaults_doc_covers_exact_pyproject_key_set() -> None: - text = Path("docs/book/10-config-and-defaults.md").read_text(encoding="utf-8") + text = _read_config_defaults_doc() # Scope to the core [tool.codeclone] table; the "Engineering Memory (nested # tables)" section below documents the separate [tool.codeclone.memory*] # namespace, which the doc itself marks as not part of the root key set. @@ -135,6 +146,6 @@ def test_config_defaults_doc_covers_exact_pyproject_key_set() -> None: def test_config_defaults_doc_explains_coverage_pyproject_to_cli_mapping() -> None: - text = Path("docs/book/10-config-and-defaults.md").read_text(encoding="utf-8") + text = _read_config_defaults_doc() assert "`coverage_xml` is the `[tool.codeclone]` key" in text assert "`--coverage FILE`" in text diff --git a/tests/test_packaging.py b/tests/test_packaging.py index 7f9378b9..2324e15d 100644 --- a/tests/test_packaging.py +++ b/tests/test_packaging.py @@ -51,3 +51,17 @@ def test_setuptools_packages_match_codeclone_subpackages() -> None: "Remove stale setuptools entries (no matching codeclone package dir): " + ", ".join(orphan) ) + + +def test_setuptools_packages_are_codeclone_only() -> None: + """Internal maintainer tooling must not ship as a top-level wheel package.""" + + repo_root = Path(__file__).resolve().parents[1] + declared = _load_setuptools_packages(repo_root) + non_codeclone = sorted( + package for package in declared if not package.startswith("codeclone") + ) + assert non_codeclone == [], ( + "Remove non-codeclone packages from [tool.setuptools].packages: " + + ", ".join(non_codeclone) + ) diff --git a/tests/test_patch_trail_retrieval.py b/tests/test_patch_trail_retrieval.py index 4b8accf7..b2891faa 100644 --- a/tests/test_patch_trail_retrieval.py +++ b/tests/test_patch_trail_retrieval.py @@ -21,6 +21,7 @@ import pytest +import codeclone.memory.sqlite_store as memory_sqlite_store from codeclone.audit import ( DEFAULT_AUDIT_PATH, EVENT_PATCH_TRAIL_COMPUTED, @@ -215,6 +216,7 @@ def test_get_patch_trail_structured_post_clear(tmp_path: Path) -> None: def test_get_patch_trail_falls_back_to_memory_trajectory_store( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: root = tmp_path / "repo" root.mkdir() @@ -265,6 +267,11 @@ def test_get_patch_trail_falls_back_to_memory_trajectory_store( finally: store.close() + def fail_writable_open(_path: Path) -> sqlite3.Connection: + raise AssertionError("patch-trail retrieval must use read-only memory access") + + monkeypatch.setattr(memory_sqlite_store, "open_memory_db", fail_writable_open) + service = CodeCloneMCPService(history_limit=4) out = service.get_patch_trail( root=str(root), diff --git a/tests/test_paths_gitignore.py b/tests/test_paths_gitignore.py index 4a781ffe..a829c17c 100644 --- a/tests/test_paths_gitignore.py +++ b/tests/test_paths_gitignore.py @@ -32,6 +32,11 @@ (".codeclone/**", True), ("**/.codeclone/", True), ("**/.codeclone/**", True), + ("nested/.cache/codeclone", True), + ("nested/.cache/codeclone/**", True), + ("build.codeclone", False), + ("myapp.codeclone/**", False), + ("build.cache/codeclone", False), (".cache/*", False), ("node_modules/", False), ("", False), @@ -68,6 +73,9 @@ def test_repo_gitignore_covers_codeclone_cache(tmp_path: Path) -> None: (tmp_path / ".gitignore").write_text("node_modules/\n", encoding="utf-8") assert repo_gitignore_covers_codeclone_cache(tmp_path) is False + (tmp_path / ".gitignore").write_text("build.codeclone\n", encoding="utf-8") + assert repo_gitignore_covers_codeclone_cache(tmp_path) is False + (tmp_path / ".gitignore").write_text(".cache/\n", encoding="utf-8") assert repo_gitignore_covers_codeclone_cache(tmp_path) is True diff --git a/tests/test_pipeline_metrics.py b/tests/test_pipeline_metrics.py index 6757796d..32521fdd 100644 --- a/tests/test_pipeline_metrics.py +++ b/tests/test_pipeline_metrics.py @@ -1223,7 +1223,13 @@ def test_discovery_cache_runtime_row_parser_rejects_invalid_enums() -> None: _runtime_reachability_target_kind, ) + assert _runtime_reachability_confidence("high") == "high" + assert _runtime_reachability_confidence("medium") == "medium" + assert _runtime_reachability_confidence("low") == "low" assert _runtime_reachability_confidence("bogus") is None + assert _runtime_reachability_target_kind("function") == "function" + assert _runtime_reachability_target_kind("class") == "class" + assert _runtime_reachability_target_kind("method") == "method" assert _runtime_reachability_target_kind("bogus") is None assert ( _runtime_reachability_from_cache_row( diff --git a/tests/test_pipeline_process.py b/tests/test_pipeline_process.py index b7dd1b5a..b17e9b88 100644 --- a/tests/test_pipeline_process.py +++ b/tests/test_pipeline_process.py @@ -406,6 +406,27 @@ def save(self) -> None: assert cache.calls == 1 +def test_process_respects_read_only_cache_writer( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + filepath, boot, discovery = _build_single_file_process_case(tmp_path) + cache = Cache(tmp_path / "cache.json", root=tmp_path, write_enabled=False) + monkeypatch.setattr( + core_worker, + "process_file", + _stub_process_file( + expected_root=str(tmp_path), + expected_filepath=filepath, + ), + ) + + result = process(boot=boot, discovery=discovery, cache=cache) + + assert result.files_analyzed == 1 + assert result.source_stats_by_file + assert cache.get_file_entry(filepath) is None + + def test_process_cache_put_file_entry_type_error_is_raised( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/tests/test_pyproject_writer.py b/tests/test_pyproject_writer.py new file mode 100644 index 00000000..d3ee46da --- /dev/null +++ b/tests/test_pyproject_writer.py @@ -0,0 +1,493 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +import errno +import os +import stat +import types +from pathlib import Path + +import pytest + +from codeclone.config import pyproject_writer as pyproject_writer_mod +from codeclone.config.pyproject_loader import ( + ConfigValidationError, + load_pyproject_config, +) +from codeclone.config.pyproject_writer import ( + PyprojectWriterError, + apply_tool_codeclone_updates, + merge_tool_codeclone, + read_pyproject_document, + serialize_pyproject_document, + validate_tool_codeclone_updates, + write_pyproject_text_atomically, +) +from codeclone.utils import atomic_write as atomic_write_mod +from codeclone.utils.atomic_write import validate_atomic_target, write_text_atomically + + +def _permission_bits(path: Path) -> int: + return stat.S_IMODE(path.stat().st_mode) + + +def _write_pyproject(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + + +def test_merge_creates_tool_codeclone_section(tmp_path: Path) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", + '[project]\nname = "demo"\n', + ) + + result = merge_tool_codeclone( + tmp_path, + {"audit_enabled": True, "baseline": "codeclone.baseline.json"}, + ) + + assert result.changed_keys == ("audit_enabled", "baseline") + assert result.created_section is True + assert result.dry_run is False + config = load_pyproject_config(tmp_path) + assert config["audit_enabled"] is True + assert config["baseline"] == str(tmp_path / "codeclone.baseline.json") + + +def test_merge_preserves_comments_round_trip(tmp_path: Path) -> None: + original = ( + "[project]\n" + 'name = "demo"\n\n' + "# keep this comment\n" + "[tool.codeclone]\n" + 'baseline = "codeclone.baseline.json"\n' + "ci = false\n" + ) + _write_pyproject(tmp_path / "pyproject.toml", original) + + merge_tool_codeclone(tmp_path, {"ci": True}) + + written = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + assert "# keep this comment" in written + assert "ci = true" in written + assert load_pyproject_config(tmp_path)["ci"] is True + + +def test_merge_idempotent_when_values_unchanged(tmp_path: Path) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", + "[tool.codeclone]\naudit_enabled = true\n", + ) + before = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + result = merge_tool_codeclone(tmp_path, {"audit_enabled": True}) + + assert result.changed_keys == () + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == before + + +def test_merge_dry_run_does_not_write(tmp_path: Path) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", + "[tool.codeclone]\naudit_enabled = false\n", + ) + before = (tmp_path / "pyproject.toml").read_text(encoding="utf-8") + + result = merge_tool_codeclone( + tmp_path, + {"audit_enabled": True}, + dry_run=True, + ) + + assert result.changed_keys == ("audit_enabled",) + assert result.preview_text is not None + assert "audit_enabled = true" in result.preview_text + assert (tmp_path / "pyproject.toml").read_text(encoding="utf-8") == before + + +def test_merge_rejects_unknown_key(tmp_path: Path) -> None: + _write_pyproject(tmp_path / "pyproject.toml", "[tool.codeclone]\n") + + with pytest.raises(PyprojectWriterError, match="Unknown key"): + merge_tool_codeclone(tmp_path, {"not_a_real_key": True}) + + +def test_merge_rejects_invalid_value(tmp_path: Path) -> None: + _write_pyproject(tmp_path / "pyproject.toml", "[tool.codeclone]\n") + + with pytest.raises(PyprojectWriterError, match="expected int"): + merge_tool_codeclone(tmp_path, {"min_loc": "not-an-int"}) + + +def test_merge_rejects_nested_table_updates(tmp_path: Path) -> None: + _write_pyproject(tmp_path / "pyproject.toml", "[tool.codeclone]\n") + + with pytest.raises(PyprojectWriterError, match=r"Nested tool\.codeclone"): + merge_tool_codeclone(tmp_path, {"memory": {"enabled": True}}) + + +def test_merge_rejects_missing_pyproject(tmp_path: Path) -> None: + with pytest.raises(PyprojectWriterError, match="not found"): + merge_tool_codeclone(tmp_path, {"audit_enabled": True}) + + +def test_merge_rejects_symlinked_pyproject(tmp_path: Path) -> None: + real_config = tmp_path / "actual.toml" + _write_pyproject(real_config, "[tool.codeclone]\n") + config_link = tmp_path / "pyproject.toml" + try: + config_link.symlink_to(real_config) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlink unavailable: {exc}") + + with pytest.raises(ConfigValidationError, match="must not be a symlink"): + merge_tool_codeclone(tmp_path, {"audit_enabled": True}) + + +def test_read_and_serialize_round_trip(tmp_path: Path) -> None: + original = ( + "[project]\n" + 'name = "demo"\n\n' + "# header comment\n" + "[tool.codeclone]\n" + "audit_enabled = true\n" + ) + _write_pyproject(tmp_path / "pyproject.toml", original) + + document = read_pyproject_document(tmp_path) + reserialized = serialize_pyproject_document(document) + + assert "# header comment" in reserialized + assert reserialized.endswith("\n") + + +def test_validate_tool_codeclone_updates_empty() -> None: + assert validate_tool_codeclone_updates(root_path=Path("/tmp"), updates={}) == {} + + +def test_apply_tool_codeclone_updates_empty(tmp_path: Path) -> None: + _write_pyproject(tmp_path / "pyproject.toml", "[tool.codeclone]\n") + document = read_pyproject_document(tmp_path) + assert apply_tool_codeclone_updates(document, {}) == () + + +def test_serialize_pyproject_document_adds_trailing_newline(tmp_path: Path) -> None: + _write_pyproject(tmp_path / "pyproject.toml", "[project]\nname = 'demo'") + document = read_pyproject_document(tmp_path) + assert serialize_pyproject_document(document).endswith("\n") + + +def test_merge_no_op_when_updates_match_current(tmp_path: Path) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", + "[tool.codeclone]\naudit_enabled = true\n", + ) + result = merge_tool_codeclone(tmp_path, {"audit_enabled": True}) + assert result.changed_keys == () + + +def test_apply_tool_codeclone_updates_rejects_invalid_tool_table( + tmp_path: Path, +) -> None: + _write_pyproject(tmp_path / "pyproject.toml", 'tool = "not-a-table"\n') + document = read_pyproject_document(tmp_path) + with pytest.raises(PyprojectWriterError, match="tool' must be a table"): + apply_tool_codeclone_updates(document, {"audit_enabled": True}) + + +def test_apply_tool_codeclone_updates_rejects_invalid_codeclone_table( + tmp_path: Path, +) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", + "[tool]\ncodeclone = 'not-a-table'\n", + ) + document = read_pyproject_document(tmp_path) + with pytest.raises( + PyprojectWriterError, + match=r"tool\.codeclone' must be a table", + ): + apply_tool_codeclone_updates(document, {"audit_enabled": True}) + + +def test_write_pyproject_text_atomically_rejects_symlink(tmp_path: Path) -> None: + real = tmp_path / "real.toml" + real.write_text("[tool.codeclone]\n", encoding="utf-8") + link = tmp_path / "pyproject.toml" + try: + link.symlink_to(real) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlink unavailable: {exc}") + with pytest.raises(PyprojectWriterError, match="symlink"): + write_pyproject_text_atomically(link, "[tool.codeclone]\n") + + +def test_merge_rejects_written_payload_validation_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + pyproject = tmp_path / "pyproject.toml" + _write_pyproject(pyproject, "[tool.codeclone]\n") + before = pyproject.read_text(encoding="utf-8") + writes: list[str] = [] + + def _reject_preview(*, root_path: Path, text: str) -> None: + raise PyprojectWriterError( + "Merged pyproject.toml failed validation before write" + ) + + def _write(_config_path: Path, text: str) -> None: + writes.append(text) + + monkeypatch.setattr( + "codeclone.config.pyproject_writer._validate_pyproject_text_before_write", + _reject_preview, + ) + monkeypatch.setattr( + "codeclone.config.pyproject_writer.write_pyproject_text_atomically", + _write, + ) + + with pytest.raises(PyprojectWriterError, match="failed validation before write"): + merge_tool_codeclone(tmp_path, {"audit_enabled": True}) + assert pyproject.read_text(encoding="utf-8") == before + assert writes == [] + + +def test_validate_pyproject_text_before_write_uses_loader_contract( + tmp_path: Path, +) -> None: + (tmp_path / "pyproject.toml").write_text("[tool.codeclone]\n", encoding="utf-8") + text = '[tool.codeclone]\nmin_loc = "not-an-int"\n' + + with pytest.raises(PyprojectWriterError, match="failed validation before write"): + pyproject_writer_mod._validate_pyproject_text_before_write( + root_path=tmp_path, + text=text, + ) + + +def test_apply_tool_codeclone_updates_skips_unchanged_key(tmp_path: Path) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", "[tool.codeclone]\naudit_enabled = true\n" + ) + document = read_pyproject_document(tmp_path) + assert apply_tool_codeclone_updates(document, {"audit_enabled": True}) == () + + +def test_merge_empty_updates_is_noop(tmp_path: Path) -> None: + _write_pyproject( + tmp_path / "pyproject.toml", "[tool.codeclone]\naudit_enabled = true\n" + ) + result = merge_tool_codeclone(tmp_path, {}) + assert result.changed_keys == () + + +def test_write_text_atomically_and_validate_target(tmp_path: Path) -> None: + target = tmp_path / "out.txt" + write_text_atomically(target, "hello\n") + assert target.read_text(encoding="utf-8") == "hello\n" + + link = tmp_path / "link.txt" + try: + link.symlink_to(target) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"symlink unavailable: {exc}") + with pytest.raises(OSError, match="symlink"): + validate_atomic_target(link) + + parent_link = tmp_path / "parent-link" + nested = parent_link / "nested.txt" + try: + parent_link.symlink_to(tmp_path, target_is_directory=True) + except (OSError, NotImplementedError) as exc: + pytest.skip(f"directory symlink unavailable: {exc}") + with pytest.raises(OSError, match="symlink directory"): + validate_atomic_target(nested) + + +def test_write_text_atomically_preserves_existing_mode(tmp_path: Path) -> None: + target = tmp_path / "out.txt" + target.write_text("before\n", encoding="utf-8") + target.chmod(0o644) + + write_text_atomically(target, "after\n") + + assert target.read_text(encoding="utf-8") == "after\n" + assert _permission_bits(target) == 0o644 + + +def test_write_pyproject_text_atomically_preserves_existing_mode( + tmp_path: Path, +) -> None: + config_path = tmp_path / "pyproject.toml" + config_path.write_text("[tool.codeclone]\nmin_loc = 4\n", encoding="utf-8") + config_path.chmod(0o644) + + write_pyproject_text_atomically( + config_path, + "[tool.codeclone]\nmin_loc = 5\n", + ) + + assert _permission_bits(config_path) == 0o644 + assert load_pyproject_config(tmp_path)["min_loc"] == 5 + + +def test_write_text_atomically_fsyncs_parent_after_replace( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + events: list[tuple[str, str]] = [] + real_replace = os.replace + + def _replace(src: Path, dst: Path) -> None: + events.append(("replace", str(dst))) + real_replace(src, dst) + + def _fsync_parent(path: Path) -> None: + events.append(("fsync_parent", str(path.parent))) + + monkeypatch.setattr("codeclone.utils.atomic_write.os.replace", _replace) + monkeypatch.setattr( + "codeclone.utils.atomic_write._fsync_parent_directory", + _fsync_parent, + ) + + write_text_atomically(target, "hello\n") + + assert target.read_text(encoding="utf-8") == "hello\n" + assert events[-2:] == [ + ("replace", str(target)), + ("fsync_parent", str(tmp_path)), + ] + + +def test_fsync_parent_directory_uses_directory_fd( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + events: list[tuple[str, int | str]] = [] + + def _open(path: object, _flags: int) -> int: + events.append(("open", str(path))) + return 4242 + + def _fsync(fd_num: int) -> None: + events.append(("fsync", fd_num)) + + def _close(fd_num: int) -> None: + events.append(("close", fd_num)) + + monkeypatch.setattr("codeclone.utils.atomic_write.os.open", _open) + monkeypatch.setattr("codeclone.utils.atomic_write.os.fsync", _fsync) + monkeypatch.setattr("codeclone.utils.atomic_write.os.close", _close) + + atomic_write_mod._fsync_parent_directory(target) + + assert events == [ + ("open", str(tmp_path)), + ("fsync", 4242), + ("close", 4242), + ] + + +def test_write_text_atomically_ignores_unsupported_parent_fsync( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + + def _open(_path: object, _flags: int) -> int: + raise OSError(errno.EINVAL, "directory fsync unsupported") + + monkeypatch.setattr("codeclone.utils.atomic_write.os.open", _open) + + atomic_write_mod._fsync_parent_directory(target) + + +def test_write_text_atomically_cleans_up_temp_on_replace_failure( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + + def _boom(_src: object, _dst: object) -> None: + raise OSError("replace failed") + + monkeypatch.setattr("codeclone.utils.atomic_write.os.replace", _boom) + with pytest.raises(OSError, match="replace failed"): + write_text_atomically(target, "hello\n") + assert not target.exists() + assert list(tmp_path.glob("*.tmp")) == [] + + +def test_chmod_open_file_falls_back_when_fchmod_missing( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + chmod_calls: list[tuple[object, int]] = [] + shim = types.SimpleNamespace( + chmod=lambda path, mode: chmod_calls.append((path, mode)), + ) + + monkeypatch.setattr(atomic_write_mod, "os", shim) + + atomic_write_mod._chmod_open_file(0, target, 0o644) + + assert chmod_calls == [(target, 0o644)] + + +def test_fsync_parent_directory_reraises_unsupported_open_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + + def _open(_path: object, _flags: int) -> int: + raise OSError(errno.EROFS, "read-only filesystem") + + monkeypatch.setattr("codeclone.utils.atomic_write.os.open", _open) + + with pytest.raises(OSError, match="read-only filesystem"): + atomic_write_mod._fsync_parent_directory(target) + + +def test_fsync_parent_directory_reraises_unsupported_fsync_errors( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + + def _fsync(_fd: int) -> None: + raise OSError(errno.EROFS, "read-only filesystem") + + monkeypatch.setattr("codeclone.utils.atomic_write.os.open", lambda *_args: 9) + monkeypatch.setattr("codeclone.utils.atomic_write.os.fsync", _fsync) + monkeypatch.setattr("codeclone.utils.atomic_write.os.close", lambda _fd: None) + + with pytest.raises(OSError, match="read-only filesystem"): + atomic_write_mod._fsync_parent_directory(target) + + +def test_fsync_parent_directory_ignores_unsupported_fsync_errno( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + target = tmp_path / "out.txt" + + def _fsync(_fd: int) -> None: + raise OSError(errno.EINVAL, "directory fsync unsupported") + + monkeypatch.setattr("codeclone.utils.atomic_write.os.open", lambda *_args: 9) + monkeypatch.setattr("codeclone.utils.atomic_write.os.fsync", _fsync) + monkeypatch.setattr("codeclone.utils.atomic_write.os.close", lambda _fd: None) + + atomic_write_mod._fsync_parent_directory(target) diff --git a/tests/test_report.py b/tests/test_report.py index b4697cf4..0ee63653 100644 --- a/tests/test_report.py +++ b/tests/test_report.py @@ -7,6 +7,7 @@ import ast import json from collections.abc import Callable, Collection, Mapping, Sequence +from copy import deepcopy from hashlib import sha256 from pathlib import Path from typing import cast @@ -31,6 +32,7 @@ ) from codeclone.report.blocks import prepare_block_report_groups from codeclone.report.document.builder import build_report_document +from codeclone.report.document.integrity import _build_integrity_payload from codeclone.report.explain import build_block_group_facts from codeclone.report.html.sections._structural import ( _finding_why_template_html, @@ -104,6 +106,38 @@ def to_json_report( return render_json_report_document(payload) +def _expected_integrity_canonical_payload( + payload: Mapping[str, object], +) -> dict[str, object]: + inventory = dict(cast(Mapping[str, object], payload["inventory"])) + files = dict(cast(Mapping[str, object], inventory.get("files", {}))) + for key in ("analyzed", "cached", "source_io_skipped"): + files.pop(key, None) + inventory["files"] = files + inventory.pop("cache", None) + return { + "report_schema_version": payload["report_schema_version"], + "meta": { + key: value + for key, value in cast(Mapping[str, object], payload["meta"]).items() + if key != "runtime" + }, + "inventory": inventory, + "findings": payload["findings"], + "metrics": payload["metrics"], + } + + +def _canonical_digest(payload: Mapping[str, object]) -> str: + canonical_json = json.dumps( + payload, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return sha256(canonical_json).hexdigest() + + def to_text_report( *, meta: Mapping[str, object], @@ -884,23 +918,9 @@ def test_report_json_integrity_matches_canonical_sections() -> None: {"codeclone_version": "1.4.0"}, ) ) - canonical_payload = { - "report_schema_version": payload["report_schema_version"], - "meta": { - key: value for key, value in payload["meta"].items() if key != "runtime" - }, - "inventory": payload["inventory"], - "findings": payload["findings"], - "metrics": payload["metrics"], - } - canonical_json = json.dumps( - canonical_payload, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") + canonical_payload = _expected_integrity_canonical_payload(payload) assert payload["integrity"]["canonicalization"] == { - "version": "1", + "version": "2", "scope": "canonical_only", "sections": [ "report_schema_version", @@ -909,14 +929,83 @@ def test_report_json_integrity_matches_canonical_sections() -> None: "findings", "metrics", ], + "excluded": [ + "meta.runtime", + "inventory.cache", + "inventory.files.analyzed", + "inventory.files.cached", + "inventory.files.source_io_skipped", + "findings.*.display_facts", + ], } assert payload["integrity"]["digest"] == { "verified": True, "algorithm": "sha256", - "value": sha256(canonical_json).hexdigest(), + "value": _canonical_digest(canonical_payload), } +def test_report_json_integrity_ignores_cache_execution_provenance() -> None: + payload = json.loads( + to_json_report( + {}, + {}, + {}, + { + "codeclone_version": "1.4.0", + "cache_used": True, + "cache_status": "ok", + "cache_schema_version": CACHE_VERSION, + }, + inventory={ + "files": { + "total_found": 2, + "analyzed": 0, + "cached": 2, + "skipped": 0, + "source_io_skipped": 2, + }, + "code": { + "functions": 0, + "methods": 0, + "classes": 0, + "parsed_lines": 12, + }, + }, + ) + ) + cache_off_payload = deepcopy(payload) + cache_off_inventory = cast( + dict[str, object], + cache_off_payload["inventory"], + ) + cache_off_inventory["cache"] = { + "path": ".codeclone/cache.json", + "path_scope": "relative", + "used": False, + "status": "disabled", + "schema_version": None, + } + cache_off_files = cast(dict[str, object], cache_off_inventory["files"]) + cache_off_files.update( + { + "analyzed": 2, + "cached": 0, + "source_io_skipped": 0, + } + ) + cache_off_payload["integrity"] = _build_integrity_payload( + report_schema_version=str(cache_off_payload["report_schema_version"]), + meta=cast(Mapping[str, object], cache_off_payload["meta"]), + inventory=cast(Mapping[str, object], cache_off_payload["inventory"]), + findings=cast(Mapping[str, object], cache_off_payload["findings"]), + metrics=cast(Mapping[str, object], cache_off_payload["metrics"]), + ) + + assert payload["inventory"] != cache_off_payload["inventory"] + assert payload["integrity"]["digest"] == cache_off_payload["integrity"]["digest"] + + def test_report_json_integrity_ignores_derived_changes() -> None: base_args: tuple[ dict[str, list[dict[str, object]]], @@ -1496,6 +1585,47 @@ def test_report_overview_materializes_source_breakdown_and_hotlist_cards() -> No assert test_fixture_hotspots[0]["title"] == "Function clone group (Type-2)" +@pytest.mark.parametrize( + ("summary_value", "expected"), + [ + (3, 3), + ("4", 4), + ("not-a-count", 0), + (1.5, 0), + (True, 1), + (None, 0), + ], +) +def test_report_overview_metric_summary_count_uses_contract_coercion( + summary_value: object, + expected: int, +) -> None: + metrics: dict[str, object] = { + "dead_code": {"summary": {"high_confidence": summary_value}} + } + assert ( + overview_mod._metric_summary_count( + metrics, + "dead_code", + "high_confidence", + ) + == expected + ) + + +def test_report_overview_metric_summary_count_uses_fallback_key() -> None: + metrics: dict[str, object] = {"dead_code": {"summary": {"critical": "2"}}} + assert ( + overview_mod._metric_summary_count( + metrics, + "dead_code", + "high_confidence", + fallback_key="critical", + ) + == 2 + ) + + def test_report_overview_clone_summary_variants() -> None: assert ( overview_mod._clone_summary_from_group( diff --git a/tests/test_report_contract_coverage.py b/tests/test_report_contract_coverage.py index eb634fad..c797f860 100644 --- a/tests/test_report_contract_coverage.py +++ b/tests/test_report_contract_coverage.py @@ -1920,6 +1920,26 @@ def test_derived_module_branches() -> None: "code/a.py" ) + from codeclone.report.document.derived import _finding_review_summary + + singular = _finding_review_summary( + { + "count": 1, + "spread": {"files": 1, "functions": 1}, + "source_scope": {"dominant_kind": "production"}, + } + ) + plural = _finding_review_summary( + { + "count": 3, + "spread": {"files": 2, "functions": 4}, + "source_scope": {"dominant_kind": "tests"}, + } + ) + assert "occurrence" in singular and "occurrences" not in singular + assert "functions" in plural and "files" in plural + assert plural.endswith("tests") + def test_overview_module_branches() -> None: suggestion = Suggestion( diff --git a/tests/test_security.py b/tests/test_security.py index 30589a99..92249b00 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -168,7 +168,7 @@ def test_markdown_and_sarif_projections_do_not_emit_raw_html_tags( tmp_path: Path, ) -> None: report_payload: dict[str, object] = { - "report_schema_version": "2.11", + "report_schema_version": "2.12", "meta": {"generator": {"name": "codeclone", "version": "2.1.0"}}, "inventory": {"files": 0, "lines": 0, "functions": 0, "classes": 0}, "findings": { diff --git a/tests/test_semantic_embedding.py b/tests/test_semantic_embedding.py index 9cd74e96..9e387d24 100644 --- a/tests/test_semantic_embedding.py +++ b/tests/test_semantic_embedding.py @@ -324,6 +324,38 @@ def test_fastembed_provider_fails_clear_on_string_vector( embed_query(provider, "bad vector") +def test_fastembed_provider_coerces_numpy_float32_vectors( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Real fastembed TextEmbedding.embed() yields numpy float32 1-D arrays; + # iterating one produces numpy.float32 scalars, which are NOT Python + # float/int subclasses. The provider must still coerce them to Python + # floats instead of rejecting every real embedding as "non-numeric". + numpy = pytest.importorskip("numpy") + vector = numpy.asarray([0.5] * 384, dtype=numpy.float32) + provider, _created = _resolve_fastembed_provider(monkeypatch, vectors=[vector]) + + result = embed_query(provider, "numpy vector") + + assert len(result) == 384 + assert all(type(value) is float for value in result) + assert result == [0.5] * 384 + + +def test_fastembed_provider_rejects_non_real_vector_element( + monkeypatch: pytest.MonkeyPatch, +) -> None: + # A non-Real element *inside* an iterable vector (here a str) must be + # rejected cleanly as "non-numeric" rather than surfacing a bare ValueError + # from float("bad"). This pins the element-level numbers.Real branch, which + # the whole-vector str guard (vectors=["bad"], caught as "non-iterable") + # never reaches. + provider, _created = _resolve_fastembed_provider(monkeypatch, vectors=[["bad"]]) + + with pytest.raises(MemorySemanticUnavailableError, match="non-numeric"): + embed_query(provider, "bad element") + + def test_fastembed_provider_fails_clear_when_extra_missing( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -786,6 +818,19 @@ class _Encoding: assert provider_mod._encoding_length(_Encoding()) == 3 assert provider_mod._encoding_length(object()) == 0 + class _StringIds: + def __init__(self) -> None: + self.ids = "abc" + + class _BoolTokenIds: + def __init__(self) -> None: + self.ids = [1, True, 3] + + assert provider_mod._encoding_token_ids(object()) == [] + assert provider_mod._encoding_token_ids(_StringIds()) == [] + assert provider_mod._encoding_token_ids(_BoolTokenIds()) == [] + assert provider_mod._encoding_token_ids(_Encoding()) == [1, 2, 3] + def test_fastembed_tokenizer_max_length_rejects_non_positive() -> None: from codeclone.memory.embedding import fastembed_provider as provider_mod diff --git a/tests/test_sqlite_readonly_openers.py b/tests/test_sqlite_readonly_openers.py index 1ebce2a9..a2ee3bc1 100644 --- a/tests/test_sqlite_readonly_openers.py +++ b/tests/test_sqlite_readonly_openers.py @@ -20,7 +20,11 @@ ) from codeclone.audit.validation import AuditSchemaError from codeclone.contracts import CORPUS_ANALYTICS_STORE_SCHEMA_VERSION -from codeclone.memory.schema import open_memory_db, open_memory_db_readonly +from codeclone.memory.schema import ( + MemorySchemaError, + open_memory_db, + open_memory_db_readonly, +) from codeclone.surfaces.mcp._workspace_intent_schema import ( IntentRegistrySchemaError, open_intent_registry_db, @@ -148,6 +152,31 @@ def test_analytics_readonly_opener_requires_writable_migration( migrated.close() +def test_memory_readonly_opener_requires_writable_migration( + tmp_path: Path, +) -> None: + db_path = tmp_path / "memory.sqlite3" + writable = open_memory_db(db_path) + try: + writable.execute( + "UPDATE memory_meta SET value='1.0' WHERE key='schema_version'" + ) + writable.commit() + finally: + writable.close() + + with pytest.raises(MemorySchemaError, match="requires writable"): + open_memory_db_readonly(db_path) + + raw = sqlite3.connect(db_path) + try: + assert raw.execute( + "SELECT value FROM memory_meta WHERE key='schema_version'" + ).fetchone() == ("1.0",) + finally: + raw.close() + + def test_analytics_schema_rejects_orphan_embedding_metadata(tmp_path: Path) -> None: conn = open_analytics_db(tmp_path / "analytics.sqlite3") try: diff --git a/tests/test_utils_payload_narrow.py b/tests/test_utils_payload_narrow.py new file mode 100644 index 00000000..fa7ddf9a --- /dev/null +++ b/tests/test_utils_payload_narrow.py @@ -0,0 +1,58 @@ +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at https://mozilla.org/MPL/2.0/. +# SPDX-License-Identifier: MPL-2.0 +# Copyright (c) 2026 Den Rozhnovskiy + +from __future__ import annotations + +import pytest + +from codeclone.utils import payload_narrow + + +def test_is_payload_dict_preserves_dict_identity() -> None: + record: dict[str, object] = {"type": "module_role"} + value: object = record + assert payload_narrow.is_payload_dict(value) + assert value is record + assert not payload_narrow.is_payload_dict("not-a-dict") + + +def test_is_record_mapping_preserves_mapping_identity() -> None: + record: dict[str, object] = {"type": "module_role", "statement": "hello"} + value: object = record + assert payload_narrow.is_record_mapping(value) + assert value is record + assert not payload_narrow.is_record_mapping("not-a-mapping") + + +def test_mapping_items_from_list_preserves_mapping_identity() -> None: + numpy = pytest.importorskip("numpy") + weight = numpy.float32(2.5) + record: dict[str, object] = {"weight": weight} + items = payload_narrow.mapping_items_from_list([record]) + assert len(items) == 1 + assert items[0] is record + assert items[0]["weight"] is weight + + +def test_dict_items_from_list_preserves_dict_identity() -> None: + record: dict[str, object] = {"trajectory_id": "t-1"} + items = payload_narrow.dict_items_from_list([record]) + assert len(items) == 1 + assert items[0] is record + + +def test_nested_payload_dict_preserves_dict_identity() -> None: + nested: dict[str, object] = {"p50": 1} + assert payload_narrow.nested_payload_dict(nested) is nested + assert payload_narrow.nested_payload_dict("bad") == {} + + +def test_mapping_items_from_list_rejects_non_list_input() -> None: + assert payload_narrow.mapping_items_from_list({"not": "a list"}) == [] + + +def test_dict_items_from_list_rejects_non_list_input() -> None: + assert payload_narrow.dict_items_from_list("not-a-list") == [] diff --git a/tests/test_workspace_hygiene.py b/tests/test_workspace_hygiene.py index c7aaf14d..5015a3be 100644 --- a/tests/test_workspace_hygiene.py +++ b/tests/test_workspace_hygiene.py @@ -29,6 +29,7 @@ collect_dirty_paths, collect_dirty_snapshot, dirty_snapshot_from_payload, + dirty_summary_from_snapshot, evaluate_scoped_hygiene, finish_hygiene_check, workspace_dirty_summary, @@ -233,6 +234,89 @@ def test_finish_hygiene_check_blocks_unacknowledged_dirty(tmp_path: Path) -> Non assert hygiene.finish_block_reason == "missing_evidence" +def test_finish_hygiene_check_uses_single_snapshot_read(tmp_path: Path) -> None: + # Contract: finish derives git availability, the blocking-scope edit gate, + # and foreign overlaps from ONE collect_dirty_snapshot read. It must not + # call evaluate_scoped_hygiene or an extra collect_dirty_paths. + store = get_workspace_intent_store(tmp_path) + snapshot_calls = 0 + real_snapshot = hygiene_mod.collect_dirty_snapshot + + def _counting_snapshot(root: Path) -> DirtySnapshot: + nonlocal snapshot_calls + snapshot_calls += 1 + return real_snapshot(root) + + def _fail_scoped(**_: object) -> WorkspaceHygieneResult: + raise AssertionError("finish must not call evaluate_scoped_hygiene") + + def _fail_dirty_paths(*_: object, **__: object) -> object: + raise AssertionError("finish must not call collect_dirty_paths") + + with ( + _mock_git_porcelain(" M pkg/a.py\n"), + patch.object(hygiene_mod, "collect_dirty_snapshot", _counting_snapshot), + patch.object(hygiene_mod, "evaluate_scoped_hygiene", _fail_scoped), + patch.object(hygiene_mod, "collect_dirty_paths", _fail_dirty_paths), + ): + hygiene = finish_hygiene_check( + root=tmp_path, + allowed_files=["pkg/a.py"], + allowed_related=[], + resolved_files=["pkg/a.py"], + store=store, + own_pid=22222, + own_start_epoch=400, + own_intent_id="intent-own-001", + ) + assert snapshot_calls == 1 + assert hygiene.git_available is True + assert hygiene.blocks_edit is True + assert hygiene.dirty_paths == ("pkg/a.py",) + + +def test_dirty_summary_from_snapshot_matches_workspace_dirty_summary() -> None: + # dirty_summary_from_snapshot must produce the identical shape/values as + # workspace_dirty_summary over the same path set (sample/count/truncated). + paths = tuple(f"pkg/file_{index}.py" for index in range(12)) + entries = tuple( + DirtySnapshotEntry( + path=path, + status_xy=" M", + digest="a" * 64, + digest_status="ok", + ) + for path in paths + ) + snapshot = DirtySnapshot(git_available=True, captured_at_utc="x", entries=entries) + summary = dirty_summary_from_snapshot(snapshot) + with patch.object( + hygiene_mod, + "collect_dirty_paths", + return_value=hygiene_mod.DirtyPathsResult( + git_available=True, + dirty_paths=paths, + ), + ): + reference = workspace_dirty_summary(root=Path("/tmp/root")) + assert summary == reference + assert summary["dirty_paths_count"] == 12 + assert summary["sample_truncated"] is True + assert len(cast(list[str], summary["dirty_paths_sample"])) == 10 + + +def test_dirty_summary_from_snapshot_degraded_envelope() -> None: + expected = { + "git_available": False, + "dirty_paths_count": 0, + "dirty_paths_sample": [], + "sample_truncated": False, + } + assert dirty_summary_from_snapshot(None) == expected + unavailable = DirtySnapshot(git_available=False, captured_at_utc="x", entries=()) + assert dirty_summary_from_snapshot(unavailable) == expected + + def test_finish_hygiene_check_allows_preexisting_unscoped_dirty( tmp_path: Path, ) -> None: @@ -637,6 +721,94 @@ def test_evaluate_scoped_hygiene_marks_dirty_in_blocking_scope(tmp_path: Path) - assert hygiene.blocks_edit is True +def test_evaluate_scoped_hygiene_reuses_snapshot_without_git(tmp_path: Path) -> None: + # When a snapshot is supplied, scoped paths are derived from it and no extra + # git read (collect_dirty_paths) is issued. + store = get_workspace_intent_store(tmp_path) + snapshot = DirtySnapshot( + git_available=True, + captured_at_utc="x", + entries=( + DirtySnapshotEntry( + path="pkg/a.py", + status_xy=" M", + digest="a" * 64, + digest_status="ok", + ), + DirtySnapshotEntry( + path="tests/test_a.py", + status_xy=" M", + digest="b" * 64, + digest_status="ok", + ), + ), + ) + + def _fail_dirty_paths(*_args: object, **_kwargs: object) -> None: + raise AssertionError("must not read git when a snapshot is supplied") + + with patch.object(hygiene_mod, "collect_dirty_paths", _fail_dirty_paths): + hygiene = evaluate_scoped_hygiene( + root=tmp_path, + allowed_files=["pkg/a.py"], + allowed_related=["tests/test_a.py"], + store=store, + own_pid=22222, + own_start_epoch=400, + dirty_snapshot=snapshot, + ) + # Both dirty paths are within the evaluation scope (blocking plus related); + # the related-only path surfaces as outside the blocking scope. + assert hygiene.dirty_paths == ("pkg/a.py", "tests/test_a.py") + assert hygiene.dirty_paths_in_scope == ("pkg/a.py",) + assert hygiene.dirty_paths_outside_scope == ("tests/test_a.py",) + assert hygiene.blocks_edit is True + + +def test_evaluate_scoped_hygiene_snapshot_matches_fresh_read(tmp_path: Path) -> None: + # Snapshot-derived scoped paths must equal a fresh scoped git read. + store = get_workspace_intent_store(tmp_path) + porcelain = " M pkg/a.py\n M pkg/b.py\n M tests/test_a.py\n" + with _mock_git_porcelain(porcelain): + fresh = evaluate_scoped_hygiene( + root=tmp_path, + allowed_files=["pkg/a.py"], + allowed_related=["tests/test_a.py"], + store=store, + own_pid=22222, + own_start_epoch=400, + ) + snapshot = collect_dirty_snapshot(tmp_path) + reused = evaluate_scoped_hygiene( + root=tmp_path, + allowed_files=["pkg/a.py"], + allowed_related=["tests/test_a.py"], + store=store, + own_pid=22222, + own_start_epoch=400, + dirty_snapshot=snapshot, + ) + assert reused.dirty_paths == fresh.dirty_paths + assert reused.dirty_paths_in_scope == fresh.dirty_paths_in_scope + assert reused.dirty_paths_outside_scope == fresh.dirty_paths_outside_scope + assert reused.blocks_edit == fresh.blocks_edit + + +def test_evaluate_scoped_hygiene_snapshot_git_unavailable(tmp_path: Path) -> None: + store = get_workspace_intent_store(tmp_path) + snapshot = DirtySnapshot(git_available=False, captured_at_utc="x", entries=()) + hygiene = evaluate_scoped_hygiene( + root=tmp_path, + allowed_files=["pkg/a.py"], + store=store, + own_pid=22222, + own_start_epoch=400, + dirty_snapshot=snapshot, + ) + assert hygiene.git_available is False + assert hygiene.blocks_edit is False + + def test_finish_hygiene_check_returns_early_when_git_unavailable( tmp_path: Path, ) -> None: diff --git a/tests/test_workspace_hygiene_digest.py b/tests/test_workspace_hygiene_digest.py index d3bc719a..5d212e86 100644 --- a/tests/test_workspace_hygiene_digest.py +++ b/tests/test_workspace_hygiene_digest.py @@ -6,6 +6,7 @@ from __future__ import annotations +import hashlib from pathlib import Path from types import SimpleNamespace from unittest.mock import patch @@ -164,6 +165,57 @@ def test_dirty_entry_digest_and_git_diff_bytes_edge_branches( assert _git_diff_bytes(tmp_path, ["diff", "--", "a.py"]) is None +def test_dirty_entry_digest_status_aware_skip_is_byte_identical( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + # Contract lock: skipping the guaranteed-empty diff side (by porcelain XY) + # must produce byte-identical digests, because real git returns b"" for the + # clean side. The guaranteed-empty side must not be invoked at all. + calls: list[list[str]] = [] + + def _fake_diff(_root: Path, args: list[str]) -> bytes: + calls.append(list(args)) + return b"CACHED" if "--cached" in args else b"WORKTREE" + + monkeypatch.setattr( + "codeclone.surfaces.mcp._workspace_hygiene._git_diff_bytes", + _fake_diff, + ) + + def _expected(status_xy: str, path: str, cached: bytes, worktree: bytes) -> str: + digest = hashlib.sha256() + digest.update(status_xy.encode("utf-8", "surrogateescape")) + digest.update(b"\0") + digest.update(path.encode("utf-8", "surrogateescape")) + digest.update(b"\0cached\0") + digest.update(cached) + digest.update(b"\0worktree\0") + digest.update(worktree) + return digest.hexdigest() + + # Unstaged-only (" M"): X==' ' skips the cached side -> substitute b"". + calls.clear() + digest, status = _dirty_entry_digest(tmp_path, "pkg/a.py", " M") + assert status == "ok" + assert not any("--cached" in call for call in calls) + assert digest == _expected(" M", "pkg/a.py", b"", b"WORKTREE") + + # Staged-only ("M "): Y==' ' skips the worktree side -> substitute b"". + calls.clear() + digest, status = _dirty_entry_digest(tmp_path, "pkg/a.py", "M ") + assert status == "ok" + assert calls and all("--cached" in call for call in calls) + assert digest == _expected("M ", "pkg/a.py", b"CACHED", b"") + + # Both sides dirty ("MM"): neither side is skipped. + calls.clear() + digest, status = _dirty_entry_digest(tmp_path, "pkg/a.py", "MM") + assert status == "ok" + assert len(calls) == 2 + assert digest == _expected("MM", "pkg/a.py", b"CACHED", b"WORKTREE") + + def test_untracked_digest_handles_missing_and_open_errors( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -343,17 +395,9 @@ class _Completed: ) assert _git_diff_bytes(tmp_path, ["diff"]) == b"bin" - monkeypatch.setattr( - "codeclone.surfaces.mcp._workspace_hygiene.evaluate_scoped_hygiene", - lambda **kwargs: WorkspaceHygieneResult( - git_available=True, - dirty_paths=("pkg/a.py",), - dirty_paths_in_scope=("pkg/a.py",), - dirty_paths_outside_scope=(), - foreign_dirty_overlaps=(), - blocks_edit=False, - ), - ) + # Finish derives git availability from the single finish snapshot: when + # collect_dirty_snapshot reports git unavailable, finish_hygiene_check + # returns the degraded envelope (there is no second scoped read to consult). monkeypatch.setattr( "codeclone.surfaces.mcp._workspace_hygiene.collect_dirty_snapshot", lambda _root: DirtySnapshot( @@ -370,4 +414,4 @@ class _Completed: own_start_epoch=1, own_intent_id="intent-a", ) - assert result.git_available is True + assert result.git_available is False diff --git a/tests/test_workspace_intent_models.py b/tests/test_workspace_intent_models.py index 6d7943c0..230d06ab 100644 --- a/tests/test_workspace_intent_models.py +++ b/tests/test_workspace_intent_models.py @@ -117,9 +117,9 @@ def test_workspace_intent_document_rejects_naive_timestamp() -> None: def test_workspace_intent_document_rejects_invalid_dirty_snapshot() -> None: - from tests.test_workspace_intents import _record + from tests.test_workspace_intents import _record, _signed_payload_with - record = replace( + payload = _signed_payload_with( _record(), dirty_snapshot={ "git_available": True, @@ -133,7 +133,7 @@ def test_workspace_intent_document_rejects_invalid_dirty_snapshot() -> None: }, }, ) - assert parse_workspace_document(signed_payload_dict_from_record(record)) is None + assert parse_workspace_document(payload) is None @pytest.mark.parametrize( @@ -185,10 +185,10 @@ def test_workspace_intent_document_rejects_invalid_dirty_snapshot() -> None: def test_workspace_intent_document_dirty_snapshot_validation_messages( dirty_snapshot: dict[str, object], ) -> None: - from tests.test_workspace_intents import _record + from tests.test_workspace_intents import _record, _signed_payload_with - record = replace(_record(), dirty_snapshot=dirty_snapshot) - assert parse_workspace_document(signed_payload_dict_from_record(record)) is None + payload = _signed_payload_with(_record(), dirty_snapshot=dirty_snapshot) + assert parse_workspace_document(payload) is None def test_signed_payload_json_roundtrip_via_pydantic() -> None: diff --git a/tests/test_workspace_intent_sqlite_store.py b/tests/test_workspace_intent_sqlite_store.py index 6df2e8a9..3e4c720d 100644 --- a/tests/test_workspace_intent_sqlite_store.py +++ b/tests/test_workspace_intent_sqlite_store.py @@ -7,6 +7,8 @@ from __future__ import annotations import json +import subprocess +import sys from collections.abc import Iterator from contextlib import contextmanager from dataclasses import replace @@ -509,6 +511,72 @@ def test_sqlite_payload_roundtrip_preserves_integrity(sqlite_root: Path) -> None ) +def test_sqlite_store_serializes_cross_process_writers(sqlite_root: Path) -> None: + db_path = sqlite_root / DEFAULT_INTENT_REGISTRY_DB_PATH + start_marker = sqlite_root / "start-writers" + script = r""" +import sys +import time +from dataclasses import replace +from pathlib import Path + +from codeclone.surfaces.mcp._workspace_intent_store import SqliteWorkspaceIntentStore +from tests.test_workspace_intents import _record + +db_path = Path(sys.argv[1]) +idx = int(sys.argv[2]) +start_marker = Path(sys.argv[3]) +deadline = time.monotonic() + 5 +while not start_marker.exists(): + if time.monotonic() > deadline: + raise SystemExit("start marker timeout") + time.sleep(0.01) + +store = SqliteWorkspaceIntentStore(db_path=db_path, retention_days=7) +try: + record = _record( + intent_id=f"intent-cross-process-{idx:03d}", + pid=40_000 + idx, + start_epoch=1_000 + idx, + ) + record = replace( + record, + declared_at_utc="2026-05-29T20:00:00Z", + expires_at_utc="2026-05-29T21:00:00Z", + lease_renewed_at_utc="2026-05-29T20:00:00Z", + ) + raise SystemExit(0 if store.write(record) else 2) +finally: + store.close() +""" + processes = [ + subprocess.Popen( + [sys.executable, "-c", script, str(db_path), str(idx), str(start_marker)], + stderr=subprocess.PIPE, + stdout=subprocess.PIPE, + text=True, + ) + for idx in range(6) + ] + start_marker.write_text("go\n", encoding="utf-8") + results = [ + (*process.communicate(timeout=10), process.returncode) for process in processes + ] + + failures = [ + (stdout, stderr, returncode) + for stdout, stderr, returncode in results + if returncode != 0 + ] + assert failures == [] + + with _open_sqlite_store(sqlite_root) as store: + records = store.list_records_raw() + assert [record.intent_id for record in records] == [ + f"intent-cross-process-{idx:03d}" for idx in range(6) + ] + + def test_gc_status_for_reason_maps_orphaned_status() -> None: assert gc_status_for_reason("orphaned") == "orphaned" assert gc_status_for_reason("expired") == "expired" diff --git a/tests/test_workspace_intents.py b/tests/test_workspace_intents.py index 797f6d5b..89df639d 100644 --- a/tests/test_workspace_intents.py +++ b/tests/test_workspace_intents.py @@ -199,7 +199,7 @@ def test_workspace_intent_validation_rejects_tampered_and_invalid_paths( "forbidden": [], } invalid = _record(scope=invalid_scope) - signed = workspace_intents.signed_payload(invalid) + signed = _signed_payload_with(invalid) assert workspace_intents.validate_workspace_record(signed) is None traversal_scope: dict[str, object] = { @@ -209,9 +209,7 @@ def test_workspace_intent_validation_rejects_tampered_and_invalid_paths( } traversal = _record(scope=traversal_scope) assert ( - workspace_intents.validate_workspace_record( - workspace_intents.signed_payload(traversal) - ) + workspace_intents.validate_workspace_record(_signed_payload_with(traversal)) is None ) @@ -1245,7 +1243,7 @@ def test_max_length_boundary(self) -> None: def test_validate_workspace_record_rejects_traversal_intent_id() -> None: """validate_workspace_record rejects intent_id with path separators.""" malicious = _record(intent_id="../../etc/passwd") - payload = workspace_intents.signed_payload(malicious) + payload = _signed_payload_with(malicious) assert workspace_intents.validate_workspace_record(payload) is None diff --git a/uv.lock b/uv.lock index 71a12ead..de410eaf 100644 --- a/uv.lock +++ b/uv.lock @@ -10,15 +10,6 @@ resolution-markers = [ "python_full_version < '3.11'", ] -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - [[package]] name = "annotated-types" version = "0.7.0" @@ -44,42 +35,43 @@ wheels = [ [[package]] name = "ast-serialize" -version = "0.5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/9d/09e27731bd5864a9ce04e3244074e674bb8936bf62b45e0357248717adac/ast_serialize-0.5.0.tar.gz", hash = "sha256:5880091bfe6f4f986f22866375c2e884843e7a0b6343ae41aeea659613d879b6", size = 61157, upload-time = "2026-05-17T17:48:29.429Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/9a/13dde51ba9e15f8b97957ab7cb0120d0e381524d651c6bd630b9c359227f/ast_serialize-0.5.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8f5c14f169eb0972c0c21bada5358b23d6047c76583b005234f865b11f1fa00a", size = 1183520, upload-time = "2026-05-17T17:47:30.831Z" }, - { url = "https://files.pythonhosted.org/packages/37/de/5a7f0a9fe68944f536632a5af84676739c7d2582be42deb082634bf3a754/ast_serialize-0.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7d1a2de9de5be04652f0ed60738356ef94f66db37924a9499fffe98dc491aa0b", size = 1175779, upload-time = "2026-05-17T17:47:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/9c/81/0bb853e76e4f6e9a1855d569003c59e19ffac45f7079d91505d1bb212f92/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:be5173fb66f9b49026d9d5a2ff0fc7c7009077107c0eb285b2d60fdf1fe10bd1", size = 1233750, upload-time = "2026-05-17T17:47:34.731Z" }, - { url = "https://files.pythonhosted.org/packages/e5/d3/4cf705beeccc08754d0bbda99aefff26110e209b9a07ac8a6b60eec48531/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f8015cd071ac1339924ee2b8098c93e00e155f30a16f40ec9816fcf84f4753f6", size = 1235942, upload-time = "2026-05-17T17:47:36.287Z" }, - { url = "https://files.pythonhosted.org/packages/26/c8/ee097e437ea27dd2b8b227865c875492b585650a5802a22d82b304c8201b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5499e8797edff2a9186aa313ed382c6b422e798e9332d9953badcee6e69a88f2", size = 1442517, upload-time = "2026-05-17T17:47:38.17Z" }, - { url = "https://files.pythonhosted.org/packages/ff/bd/68063442838f1ba68ec72b5436430bc75b3bb17a1a3c3063f09b0c05ae2b/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:6848f2a093fb5548751a9a09bff8fcd229e2bbeb0e3331f391b6ae6d26cd9903", size = 1254081, upload-time = "2026-05-17T17:47:39.826Z" }, - { url = "https://files.pythonhosted.org/packages/50/e2/1e520793bc6a4e4524a6ab022391e827825eaa0c3811828bfdc6852eca26/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:832d4c998e0b091fd60a6d6bceee535483c4d490de9ba85003af835225719261", size = 1259910, upload-time = "2026-05-17T17:47:41.369Z" }, - { url = "https://files.pythonhosted.org/packages/4e/e1/49b60f467979979cfe6913b43948ff25bca971ad0591d181812f163a988e/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:16db7c62ec0b8efe1d7afd283a388d8f74f2605d56032e5a37747d2de8dba027", size = 1250678, upload-time = "2026-05-17T17:47:43.702Z" }, - { url = "https://files.pythonhosted.org/packages/74/ba/66ab9555de6275677566f6574e5ef6c29cb185ea866f643bc06f8280a8ee/ast_serialize-0.5.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:baf5eb061eb5bccade4128ad42da33787d72f6013809cd1b590376ece8b3c937", size = 1301603, upload-time = "2026-05-17T17:47:46.256Z" }, - { url = "https://files.pythonhosted.org/packages/66/42/6aca9b9abc710014b2be9059689e5dd1679339e78f567ffb4d255a9e2050/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:104e4a35bd7c124173c41760ef9aaea17ddb3f86c65cb643671d59afbe3ee94c", size = 1410332, upload-time = "2026-05-17T17:47:47.899Z" }, - { url = "https://files.pythonhosted.org/packages/47/68/2f76594432a22581ecf878b5e75a9b8601c24b2241cf0bbeb1e21fcf370c/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:36be371028fc1675acb38a331bde160dbab7ff907fdf00b67eb6911aa106951b", size = 1509979, upload-time = "2026-05-17T17:47:50.942Z" }, - { url = "https://files.pythonhosted.org/packages/40/ac/a93c9b58292653f6c595752f677a08e608f903b710594909e9231a389b3b/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:061ee58bdb52341c8201a6df41182a977736bae3b7ded87ca7176ca25a8a47ab", size = 1505002, upload-time = "2026-05-17T17:47:54.093Z" }, - { url = "https://files.pythonhosted.org/packages/14/2e/b278f68c497ee2f1d1576cbbef8db5281cd4a5f2db040537592ac9c8862e/ast_serialize-0.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b15219e9cdc9f53f6f4cb51c009203507228226148c05c5e8fe451c28b435eb3", size = 1456231, upload-time = "2026-05-17T17:47:56.311Z" }, - { url = "https://files.pythonhosted.org/packages/0b/43/419be1c566a4c504cd8fd60ce2f84e790f295495c0f327cfaeadf3d51012/ast_serialize-0.5.0-cp314-cp314t-win32.whl", hash = "sha256:842d1c004bb466c7df036f95fabef789570541922b10976b12f5592a69cf0b38", size = 1058668, upload-time = "2026-05-17T17:47:58.305Z" }, - { url = "https://files.pythonhosted.org/packages/03/6f/c9d4d549295ed05111aeb8853232d1afd9d0a179fddb01eeffbb3a4a6842/ast_serialize-0.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b0c06d760909b095cc466356dfccd05a1c7233a6ca191c020dca2c6a6f16c24c", size = 1101075, upload-time = "2026-05-17T17:48:00.35Z" }, - { url = "https://files.pythonhosted.org/packages/d0/8e/d00c5ab30c58222e07d62956fca86c59d91b9ad32997e633c38b526623a3/ast_serialize-0.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:787baedb0262cc49e8ce37cc15c00ae818e46a165a3b36f5e21ed174998104cb", size = 1075347, upload-time = "2026-05-17T17:48:01.753Z" }, - { url = "https://files.pythonhosted.org/packages/e0/9e/dc2530acb3a60dc6e46d65abf27d1d9f86721694757906a148d90a6860de/ast_serialize-0.5.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:0668aa9459cfa8c9c49ddd2163ebcf43088ba045ef7492af6fe22e0098303101", size = 1191380, upload-time = "2026-05-17T17:48:03.738Z" }, - { url = "https://files.pythonhosted.org/packages/26/0a/bd3d18a582f273d6c843d16bb9e22e9e16365ff7991e92f18f798e9f1224/ast_serialize-0.5.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:bf683d6363edf2b39eed6b6d4fe22d34b6203867a67e27134d9e2a2680c4bc4a", size = 1183879, upload-time = "2026-05-17T17:48:05.463Z" }, - { url = "https://files.pythonhosted.org/packages/40/ae/1f919100f8620887af58fcc381c61a1f218cdf89c6e155f87b213e61010a/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9cc22cf0c9be65e71cf88fda130af60d61eb4a79370ad4cfe7900d48a4aa2211", size = 1244529, upload-time = "2026-05-17T17:48:07.008Z" }, - { url = "https://files.pythonhosted.org/packages/c6/ca/6376559dcce707cdbc1d0d9a13c8d3baaaa501e949ce0ebdc4230cd881aa/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f66173891548c9f2726bf27957b41cabce12fa679dc6da505ddbde4d4b3b31cf", size = 1240560, upload-time = "2026-05-17T17:48:08.46Z" }, - { url = "https://files.pythonhosted.org/packages/35/b2/a620e206b5aeb7efbf2710336df57d457cffbb3991076bbcc1147ef9abd4/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e42d729ef2be96a14efbad355093284739e3670ece3e534f82cc8832790911d9", size = 1451172, upload-time = "2026-05-17T17:48:09.922Z" }, - { url = "https://files.pythonhosted.org/packages/fa/e0/4ad5c04c24a40481b2935ce9a0ccdb6023dc8b667167d06ae530cc3512f2/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b725026bafa801dbd7310eb13a75f0a2e370e7e51b2cb225f9d21fcfadf919ee", size = 1265072, upload-time = "2026-05-17T17:48:11.469Z" }, - { url = "https://files.pythonhosted.org/packages/b2/71/4d1d479aa56d0101c40e17720c3d6ac2af7269ea0487a80b18e7bfd1a5b7/ast_serialize-0.5.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b54f60c1d78767a53b67eaa663f0dfac3afe606aa07f1301572f588b73d64809", size = 1270488, upload-time = "2026-05-17T17:48:13.575Z" }, - { url = "https://files.pythonhosted.org/packages/6d/4f/0de1bbe06f6edef9fde4ed12ca8e7b3ec7e6e2bd4e672c5af487f7957665/ast_serialize-0.5.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:27d51654fc240a1e87e742d353d98eb45b75f62f129086b3596ab53df2ac2a43", size = 1260702, upload-time = "2026-05-17T17:48:15.141Z" }, - { url = "https://files.pythonhosted.org/packages/75/61/e00872439cfdddcc3c1b6cdaa6e5d904ba8e26a18807c67c4e14409d0ca8/ast_serialize-0.5.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2782c36237c46dd1674542f2109740ea5ea485a169bf1431939ada0434e17934", size = 1311182, upload-time = "2026-05-17T17:48:16.779Z" }, - { url = "https://files.pythonhosted.org/packages/76/8e/699a5b955f7926956c95e9e1d74132acad73c2fe7a426f94da89123c20aa/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1943db345233cc7194a470f13afa9c59772c0b123dea0c9414c4d4ca54369759", size = 1421410, upload-time = "2026-05-17T17:48:18.527Z" }, - { url = "https://files.pythonhosted.org/packages/a9/ae/d5b7626874478997adc7a29ab28accf21e596fb590c944290401dfd0b29e/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:df1c00022cbbcb064bfaa505aa9c9295362443ce5dacb459d1331d3da353f887", size = 1516587, upload-time = "2026-05-17T17:48:20.133Z" }, - { url = "https://files.pythonhosted.org/packages/0c/ce/b59e02a82d9c4244d64cde502e0b00e83e38816abe19155ceb5437402c7f/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:cae65289fc456fde04af979a2be09302ef5d8ab92ef23e596d6746dc267ada27", size = 1515171, upload-time = "2026-05-17T17:48:21.921Z" }, - { url = "https://files.pythonhosted.org/packages/8b/38/d8d90042747d05aa08d4efcf1c99035a5f670a6bf4c214d31644392afbca/ast_serialize-0.5.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:239a4c354e8d676e9d94631d1d4a64edc6b266f86ff3a5a80aedd344f342c01d", size = 1464668, upload-time = "2026-05-17T17:48:23.544Z" }, - { url = "https://files.pythonhosted.org/packages/dd/51/5b840c4df7334104cecffa28f23904fe81ca89ca223d2450e288de39fd3c/ast_serialize-0.5.0-cp39-abi3-win32.whl", hash = "sha256:143a4ef63285a075871908fda3672dc21864b83a8ec3ee12304aa3e4c5387b9a", size = 1068311, upload-time = "2026-05-17T17:48:25.027Z" }, - { url = "https://files.pythonhosted.org/packages/41/11/ca5672c7d491825bc4cd6702dea106a6b60d928707712ec257c7833ae476/ast_serialize-0.5.0-cp39-abi3-win_amd64.whl", hash = "sha256:cf25572c526add400f26a4750dc6ce0c3bb93fc1f75e7ae0cad4ce4f2cd5c590", size = 1108931, upload-time = "2026-05-17T17:48:26.591Z" }, - { url = "https://files.pythonhosted.org/packages/45/19/cc8bd127d28a43da249aa955cfd164cf8fd534e79e42cea96c4854d72fd0/ast_serialize-0.5.0-cp39-abi3-win_arm64.whl", hash = "sha256:92a31c9c20d25a076edaeec76b128a3535d74a24f340b9a8a7e96c9b86dc9642", size = 1081181, upload-time = "2026-05-17T17:48:28.122Z" }, +version = "0.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/58/ad/0d70a3a2d6e01968d985415259e8ec7ad3f777903f9b1c1f3c8c44642c60/ast_serialize-0.6.0.tar.gz", hash = "sha256:aadd3ffcf4858c9726bf3515f7b199c7eadbe504f96028e4a87172c0da65a8fe", size = 61489, upload-time = "2026-06-30T20:02:55.555Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3f/12/3e5f575f156555547c250a8b0d1347517a3a20fc7f4492e9703a69d4f45e/ast_serialize-0.6.0-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:a7520b672827885bafeae7501f684d14d47d17e5f45256f9df547686cca52264", size = 1177640, upload-time = "2026-06-30T20:02:06.708Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a4/921a9e27951627983b0f368859ea00f8330a551dc0bf4c2fdcb11855a98b/ast_serialize-0.6.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:a14191beec7e0c078d2fc1f6edc0aee88bcd4db9f18e1bc9f8052b559c22dddc", size = 1168111, upload-time = "2026-06-30T20:02:08.366Z" }, + { url = "https://files.pythonhosted.org/packages/00/69/950cf404de7b8782cf95e5c1237e25e2aa46177b287f39f9eeddf481fd6f/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32ef62ec34cf6be20ad77d4799556638fbdf187f3ae10698dfb20ef9f2c89516", size = 1227656, upload-time = "2026-06-30T20:02:09.843Z" }, + { url = "https://files.pythonhosted.org/packages/4c/a8/46f8f6a6479d9d2273980957bb091a506c55f5b95d3c029ee58518a78407/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:13b7769970a39983b0adf2f38917b1cd3b8946f76df045756c3d741bc689f089", size = 1227706, upload-time = "2026-06-30T20:02:11.367Z" }, + { url = "https://files.pythonhosted.org/packages/b7/b9/9ac415bda0a40e49eab8fea3b2741c19c98bb84d57d62c4cfc6230eb67be/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6f7a408601bb3edaefb3bc67a4c01f5235e3253653b6a5729a2ee2382b35341c", size = 1431705, upload-time = "2026-06-30T20:02:12.737Z" }, + { url = "https://files.pythonhosted.org/packages/e5/06/8807115d441444879f7561b5eede5ac18fc80392f11826d61ccf31f503b1/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8670bfa51208a2c0c8d138928e40e998fab158f9200d53bb80c088b5b8eda7b8", size = 1249533, upload-time = "2026-06-30T20:02:14.571Z" }, + { url = "https://files.pythonhosted.org/packages/3e/c0/c2ba82ef9618650357d9421a1fdb27ffec862a7f57e8e2de82a3ccd11e12/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a4826809eb8597a8cd59fd924b6d7c285b8969a1e0007e2cb652cab62376270f", size = 1252619, upload-time = "2026-06-30T20:02:16.219Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a7/fa31d52dd4102cede29fb9634e98d214129b2783b4f95528c6dc6a8f6587/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:577a6c189068686869f5f1ddc38363f3ae1808a4753b577266f9202071a7bb66", size = 1242983, upload-time = "2026-06-30T20:02:17.813Z" }, + { url = "https://files.pythonhosted.org/packages/b1/20/ddf742b5ad3c4bafd3466f2265037cfd99bc1b9a5ee46a5d58c90d523242/ast_serialize-0.6.0-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:085de7f62dc9cc247eb01e965a362707d1d90b1d89a82c5bf78301a60a3c417b", size = 1296148, upload-time = "2026-06-30T20:02:19.146Z" }, + { url = "https://files.pythonhosted.org/packages/24/cb/9f6f217cce8b3b632c5568b478d195a35e79dce4dbe309438cb89ba6ea4f/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9f8a8b78b13173de6a9ec22111d9be674874cd5bdccda04f14ae5ebc2bef403a", size = 1403826, upload-time = "2026-06-30T20:02:20.696Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f8/9d16d4f0107a183924425cc0e7618d8bf76f96b45afa9ff19f924ed1ad57/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f2ff3baffc3a29c1f15bc9098aa0c09763410262d5e6cef42116f7356c184554", size = 1502943, upload-time = "2026-06-30T20:02:22.034Z" }, + { url = "https://files.pythonhosted.org/packages/80/dd/bbc1c38756350dddf7e24acae1c9482ef42051c267417e019aecc1ed4075/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0067b25fce104eaae5b88383de9ab803faeb671831e14ca698b771b356e2600f", size = 1497632, upload-time = "2026-06-30T20:02:23.517Z" }, + { url = "https://files.pythonhosted.org/packages/42/7e/9daffefcf5b97e6bb4c3e0b3c024c1aee9722f23d3cf7cd2ff80d6fb4a40/ast_serialize-0.6.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:c617417f9cbb0cb144f6283c3cbe0d2e0f01beaf9f608f662b21191058a626ec", size = 1448858, upload-time = "2026-06-30T20:02:24.889Z" }, + { url = "https://files.pythonhosted.org/packages/e5/1f/f9baaab81a677ea0af7d2458cac2f94ebcc85958f8a3c15ba9d9e5dab653/ast_serialize-0.6.0-cp314-cp314t-win32.whl", hash = "sha256:5337cb256dcea3df9288205213d1601581536526b8f4da44b6974f1180f3252a", size = 1052600, upload-time = "2026-06-30T20:02:26.263Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1f/41b535866519512d8cf6669cb2cff7823b7672bb6279c0333b4ff89d7d9f/ast_serialize-0.6.0-cp314-cp314t-win_amd64.whl", hash = "sha256:2d947e45cafc4b09bd7528917fa84c517654a43de173c79785574b7b3068ac24", size = 1095570, upload-time = "2026-06-30T20:02:27.639Z" }, + { url = "https://files.pythonhosted.org/packages/50/64/e472fe3e3a2d33d874b987e8518aedf24562919e3b6161a4fa1797e89c0f/ast_serialize-0.6.0-cp314-cp314t-win_arm64.whl", hash = "sha256:6e15ec740436e1a0d62de848641abe5f3a2f89a7f94907d534795ac91bbacf14", size = 1067267, upload-time = "2026-06-30T20:02:28.949Z" }, + { url = "https://files.pythonhosted.org/packages/52/19/ac8348ae8711c9b5ae834634f635780cab62a0f5e6f988882e048b89c2ae/ast_serialize-0.6.0-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:093cb8bb91b720d8523580498d031791bb1bbaa048599c3d21085d380e11a596", size = 1185367, upload-time = "2026-06-30T20:02:30.427Z" }, + { url = "https://files.pythonhosted.org/packages/c1/f6/ec7ec652c51db77c2f61d8573338e13e4704303265ccc658cb4031d9f354/ast_serialize-0.6.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:e61580a69faf47e3689795367ed211f2a10fd741478cc0f36a0f128793360aad", size = 1178657, upload-time = "2026-06-30T20:02:31.964Z" }, + { url = "https://files.pythonhosted.org/packages/6f/02/613a7534a41d0122f37d1e0c64aa8ac78bfb831f8c92f6db057a311abb3c/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:305802f2ce2a7c4e87835078ea85c58b586ddda8095b92fe2ead9364ae19c80a", size = 1238620, upload-time = "2026-06-30T20:02:33.664Z" }, + { url = "https://files.pythonhosted.org/packages/4d/21/087957bba486242afc52f49b2d9e21c9dad00289356cf9efe67084015a9d/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c7b8b8f0c42f752ea00b2b7d7c090b3f80d9c1c5c75cadf16423790a0cc74081", size = 1236075, upload-time = "2026-06-30T20:02:34.936Z" }, + { url = "https://files.pythonhosted.org/packages/82/04/78128bbb170071c2c72a210a181f1c00e11cc1cec60a8beef747b07f9201/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:cd5b91b9e6f2356ace3a556963b0cd783b395fbbb0bb17b4defc283415466e77", size = 1441348, upload-time = "2026-06-30T20:02:36.245Z" }, + { url = "https://files.pythonhosted.org/packages/64/64/62fb99d6faf199b4c3e5b08a07136e9a0d7664bb249c6de3670e5b63e9b6/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4d6ef91590258ada18909b9caea344dac4de2013906b035473cd674a43f4b790", size = 1258580, upload-time = "2026-06-30T20:02:37.53Z" }, + { url = "https://files.pythonhosted.org/packages/ca/87/b4d6c38e0ccd5e85dc54cecdf933a152c60b28fe5d993a6d8a72fa6d5896/ast_serialize-0.6.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dcbed41e9386059fc0261d602445ede0976c2ecec2939688bcbcb9ed0b6f28b7", size = 1261693, upload-time = "2026-06-30T20:02:39.123Z" }, + { url = "https://files.pythonhosted.org/packages/0e/4b/3676ca2191f39bafb75f93f99b2f429ec464586158fece2165f3572805dc/ast_serialize-0.6.0-cp39-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:cdc4e6f930b9090c2f92c9036ad12ffb8e6e44d4a5ba06f1458a05d60f203f7b", size = 1252517, upload-time = "2026-06-30T20:02:40.511Z" }, + { url = "https://files.pythonhosted.org/packages/f3/58/494ef8c4b4acb2f4a265ac934caf45f792a08fe27d6b853de35ad991941a/ast_serialize-0.6.0-cp39-abi3-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:897ac47b5637be41c0c07061c8a912fafa967ef1dc73fa115e4bfa70882a093b", size = 1304843, upload-time = "2026-06-30T20:02:41.961Z" }, + { url = "https://files.pythonhosted.org/packages/b1/f2/13736d920ab3d49bbee80ef1a277dd7b7aaf3b3545efd9d2a8114fe05525/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c4af9a1386166e40ed01464991806f89038a2d89782576c7774876fa77034e32", size = 1413698, upload-time = "2026-06-30T20:02:44.179Z" }, + { url = "https://files.pythonhosted.org/packages/a8/5a/e046f3899e2acba4677d7427b76431443a1aa1a0e583dfb05b55b69d55cf/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:c901adbd750029b9ac4ad3d6aa56853e0ad4875119fbf52b7b8298afc223828b", size = 1512209, upload-time = "2026-06-30T20:02:45.584Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c7/e42aaca7bb2d22a7c06d5a8c7930086c5a334e93d716e6fa5e6647a4515f/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:3ae22a366b752ab4496191525b78b097b5b72d531752e3c1dd7e383a8f2c8a1a", size = 1508464, upload-time = "2026-06-30T20:02:46.942Z" }, + { url = "https://files.pythonhosted.org/packages/95/93/5524a3dc6c3f593de3228ed9cbef73afa047625b7000ec21b7f58e6eb4d4/ast_serialize-0.6.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:4ed29121da8b3fdc291002801a1de0f76248fa07dce89157a5f277842cf6126e", size = 1457164, upload-time = "2026-06-30T20:02:48.294Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c0/36a6ffb4d653cf621427b4c4928671f53ad800c453474de2b82564a44ad9/ast_serialize-0.6.0-cp39-abi3-pyemscripten_2026_0_wasm32.whl", hash = "sha256:b1dac4e09d341c1300ba69cdcbe62867b32a8c75d90db9bf4d083bec3b039f0b", size = 863014, upload-time = "2026-06-30T20:02:49.742Z" }, + { url = "https://files.pythonhosted.org/packages/09/c7/7d5ad8b49e1278e1c2a1e0274bd7850560b3f09313aa00c13bc8d5544792/ast_serialize-0.6.0-cp39-abi3-win32.whl", hash = "sha256:82c312a7844d2fdeb4d5c48bd3d215bf940dafd4704e1a9bcf252a99010a99b1", size = 1063165, upload-time = "2026-06-30T20:02:50.98Z" }, + { url = "https://files.pythonhosted.org/packages/47/ae/6710c14ecb276031cf10249f6adf5a59e2d3fdb3b5183bd59f70524067ee/ast_serialize-0.6.0-cp39-abi3-win_amd64.whl", hash = "sha256:113b58346f9ceb664352032770caca817d4a3c86f611c6088e6ef65ddaa70f0e", size = 1101444, upload-time = "2026-06-30T20:02:52.554Z" }, + { url = "https://files.pythonhosted.org/packages/66/40/c53deb2cd0c9b0fb636d24d9f40924cf2e65028e6b20b10cd5c1eeb2c730/ast_serialize-0.6.0-cp39-abi3-win_arm64.whl", hash = "sha256:ccd132fe8db56f61fe743b1f644d01b8d65b83248a8da506f3132bda86d6ed5e", size = 1072965, upload-time = "2026-06-30T20:02:54.097Z" }, ] [[package]] @@ -127,84 +119,112 @@ wheels = [ [[package]] name = "cffi" -version = "2.0.0" +version = "2.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pycparser", marker = "implementation_name != 'PyPy'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/93/d7/516d984057745a6cd96575eea814fe1edd6646ee6efd552fb7b0921dec83/cffi-2.0.0-cp310-cp310-macosx_10_13_x86_64.whl", hash = "sha256:0cf2d91ecc3fcc0625c2c530fe004f82c110405f101548512cce44322fa8ac44", size = 184283, upload-time = "2025-09-08T23:22:08.01Z" }, - { url = "https://files.pythonhosted.org/packages/9e/84/ad6a0b408daa859246f57c03efd28e5dd1b33c21737c2db84cae8c237aa5/cffi-2.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:f73b96c41e3b2adedc34a7356e64c8eb96e03a3782b535e043a986276ce12a49", size = 180504, upload-time = "2025-09-08T23:22:10.637Z" }, - { url = "https://files.pythonhosted.org/packages/50/bd/b1a6362b80628111e6653c961f987faa55262b4002fcec42308cad1db680/cffi-2.0.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:53f77cbe57044e88bbd5ed26ac1d0514d2acf0591dd6bb02a3ae37f76811b80c", size = 208811, upload-time = "2025-09-08T23:22:12.267Z" }, - { url = "https://files.pythonhosted.org/packages/4f/27/6933a8b2562d7bd1fb595074cf99cc81fc3789f6a6c05cdabb46284a3188/cffi-2.0.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3e837e369566884707ddaf85fc1744b47575005c0a229de3327f8f9a20f4efeb", size = 216402, upload-time = "2025-09-08T23:22:13.455Z" }, - { url = "https://files.pythonhosted.org/packages/05/eb/b86f2a2645b62adcfff53b0dd97e8dfafb5c8aa864bd0d9a2c2049a0d551/cffi-2.0.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:5eda85d6d1879e692d546a078b44251cdd08dd1cfb98dfb77b670c97cee49ea0", size = 203217, upload-time = "2025-09-08T23:22:14.596Z" }, - { url = "https://files.pythonhosted.org/packages/9f/e0/6cbe77a53acf5acc7c08cc186c9928864bd7c005f9efd0d126884858a5fe/cffi-2.0.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9332088d75dc3241c702d852d4671613136d90fa6881da7d770a483fd05248b4", size = 203079, upload-time = "2025-09-08T23:22:15.769Z" }, - { url = "https://files.pythonhosted.org/packages/98/29/9b366e70e243eb3d14a5cb488dfd3a0b6b2f1fb001a203f653b93ccfac88/cffi-2.0.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:fc7de24befaeae77ba923797c7c87834c73648a05a4bde34b3b7e5588973a453", size = 216475, upload-time = "2025-09-08T23:22:17.427Z" }, - { url = "https://files.pythonhosted.org/packages/21/7a/13b24e70d2f90a322f2900c5d8e1f14fa7e2a6b3332b7309ba7b2ba51a5a/cffi-2.0.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:cf364028c016c03078a23b503f02058f1814320a56ad535686f90565636a9495", size = 218829, upload-time = "2025-09-08T23:22:19.069Z" }, - { url = "https://files.pythonhosted.org/packages/60/99/c9dc110974c59cc981b1f5b66e1d8af8af764e00f0293266824d9c4254bc/cffi-2.0.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:e11e82b744887154b182fd3e7e8512418446501191994dbf9c9fc1f32cc8efd5", size = 211211, upload-time = "2025-09-08T23:22:20.588Z" }, - { url = "https://files.pythonhosted.org/packages/49/72/ff2d12dbf21aca1b32a40ed792ee6b40f6dc3a9cf1644bd7ef6e95e0ac5e/cffi-2.0.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:8ea985900c5c95ce9db1745f7933eeef5d314f0565b27625d9a10ec9881e1bfb", size = 218036, upload-time = "2025-09-08T23:22:22.143Z" }, - { url = "https://files.pythonhosted.org/packages/e2/cc/027d7fb82e58c48ea717149b03bcadcbdc293553edb283af792bd4bcbb3f/cffi-2.0.0-cp310-cp310-win32.whl", hash = "sha256:1f72fb8906754ac8a2cc3f9f5aaa298070652a0ffae577e0ea9bd480dc3c931a", size = 172184, upload-time = "2025-09-08T23:22:23.328Z" }, - { url = "https://files.pythonhosted.org/packages/33/fa/072dd15ae27fbb4e06b437eb6e944e75b068deb09e2a2826039e49ee2045/cffi-2.0.0-cp310-cp310-win_amd64.whl", hash = "sha256:b18a3ed7d5b3bd8d9ef7a8cb226502c6bf8308df1525e1cc676c3680e7176739", size = 182790, upload-time = "2025-09-08T23:22:24.752Z" }, - { url = "https://files.pythonhosted.org/packages/12/4a/3dfd5f7850cbf0d06dc84ba9aa00db766b52ca38d8b86e3a38314d52498c/cffi-2.0.0-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:b4c854ef3adc177950a8dfc81a86f5115d2abd545751a304c5bcf2c2c7283cfe", size = 184344, upload-time = "2025-09-08T23:22:26.456Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8b/f0e4c441227ba756aafbe78f117485b25bb26b1c059d01f137fa6d14896b/cffi-2.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2de9a304e27f7596cd03d16f1b7c72219bd944e99cc52b84d0145aefb07cbd3c", size = 180560, upload-time = "2025-09-08T23:22:28.197Z" }, - { url = "https://files.pythonhosted.org/packages/b1/b7/1200d354378ef52ec227395d95c2576330fd22a869f7a70e88e1447eb234/cffi-2.0.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:baf5215e0ab74c16e2dd324e8ec067ef59e41125d3eade2b863d294fd5035c92", size = 209613, upload-time = "2025-09-08T23:22:29.475Z" }, - { url = "https://files.pythonhosted.org/packages/b8/56/6033f5e86e8cc9bb629f0077ba71679508bdf54a9a5e112a3c0b91870332/cffi-2.0.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:730cacb21e1bdff3ce90babf007d0a0917cc3e6492f336c2f0134101e0944f93", size = 216476, upload-time = "2025-09-08T23:22:31.063Z" }, - { url = "https://files.pythonhosted.org/packages/dc/7f/55fecd70f7ece178db2f26128ec41430d8720f2d12ca97bf8f0a628207d5/cffi-2.0.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6824f87845e3396029f3820c206e459ccc91760e8fa24422f8b0c3d1731cbec5", size = 203374, upload-time = "2025-09-08T23:22:32.507Z" }, - { url = "https://files.pythonhosted.org/packages/84/ef/a7b77c8bdc0f77adc3b46888f1ad54be8f3b7821697a7b89126e829e676a/cffi-2.0.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:9de40a7b0323d889cf8d23d1ef214f565ab154443c42737dfe52ff82cf857664", size = 202597, upload-time = "2025-09-08T23:22:34.132Z" }, - { url = "https://files.pythonhosted.org/packages/d7/91/500d892b2bf36529a75b77958edfcd5ad8e2ce4064ce2ecfeab2125d72d1/cffi-2.0.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8941aaadaf67246224cee8c3803777eed332a19d909b47e29c9842ef1e79ac26", size = 215574, upload-time = "2025-09-08T23:22:35.443Z" }, - { url = "https://files.pythonhosted.org/packages/44/64/58f6255b62b101093d5df22dcb752596066c7e89dd725e0afaed242a61be/cffi-2.0.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05d0c237b3349096d3981b727493e22147f934b20f6f125a3eba8f994bec4a9", size = 218971, upload-time = "2025-09-08T23:22:36.805Z" }, - { url = "https://files.pythonhosted.org/packages/ab/49/fa72cebe2fd8a55fbe14956f9970fe8eb1ac59e5df042f603ef7c8ba0adc/cffi-2.0.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:94698a9c5f91f9d138526b48fe26a199609544591f859c870d477351dc7b2414", size = 211972, upload-time = "2025-09-08T23:22:38.436Z" }, - { url = "https://files.pythonhosted.org/packages/0b/28/dd0967a76aab36731b6ebfe64dec4e981aff7e0608f60c2d46b46982607d/cffi-2.0.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:5fed36fccc0612a53f1d4d9a816b50a36702c28a2aa880cb8a122b3466638743", size = 217078, upload-time = "2025-09-08T23:22:39.776Z" }, - { url = "https://files.pythonhosted.org/packages/2b/c0/015b25184413d7ab0a410775fdb4a50fca20f5589b5dab1dbbfa3baad8ce/cffi-2.0.0-cp311-cp311-win32.whl", hash = "sha256:c649e3a33450ec82378822b3dad03cc228b8f5963c0c12fc3b1e0ab940f768a5", size = 172076, upload-time = "2025-09-08T23:22:40.95Z" }, - { url = "https://files.pythonhosted.org/packages/ae/8f/dc5531155e7070361eb1b7e4c1a9d896d0cb21c49f807a6c03fd63fc877e/cffi-2.0.0-cp311-cp311-win_amd64.whl", hash = "sha256:66f011380d0e49ed280c789fbd08ff0d40968ee7b665575489afa95c98196ab5", size = 182820, upload-time = "2025-09-08T23:22:42.463Z" }, - { url = "https://files.pythonhosted.org/packages/95/5c/1b493356429f9aecfd56bc171285a4c4ac8697f76e9bbbbb105e537853a1/cffi-2.0.0-cp311-cp311-win_arm64.whl", hash = "sha256:c6638687455baf640e37344fe26d37c404db8b80d037c3d29f58fe8d1c3b194d", size = 177635, upload-time = "2025-09-08T23:22:43.623Z" }, - { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" }, - { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" }, - { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" }, - { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" }, - { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" }, - { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" }, - { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" }, - { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" }, - { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" }, - { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" }, - { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" }, - { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" }, - { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" }, - { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" }, - { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" }, - { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" }, - { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" }, - { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" }, - { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" }, - { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" }, - { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" }, - { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" }, - { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" }, - { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" }, - { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" }, - { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" }, - { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" }, - { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" }, - { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" }, - { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" }, - { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" }, - { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" }, - { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" }, - { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" }, - { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" }, - { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" }, - { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" }, - { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" }, - { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" }, - { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/57/5f/ff100cae70ebe9d8df1c01a00e510e45d9adb5c1fdda84791b199141de97/cffi-2.1.0.tar.gz", hash = "sha256:efc1cdd798b1aaf39b4610bba7aad28c9bea9b910f25c784ccf9ec1fa719d1f9", size = 531036, upload-time = "2026-07-06T21:34:30.382Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/e9/6d7724983b3d5a0908dbf74f64038ade77c18646ff6636ec7894fd392ce1/cffi-2.1.0-cp310-cp310-macosx_10_15_x86_64.whl", hash = "sha256:b65f590ef2a44640f9a05dbb548a429b4ade77913ce683ac8b1480777658a6c0", size = 183837, upload-time = "2026-07-06T21:32:09.655Z" }, + { url = "https://files.pythonhosted.org/packages/69/aa/24580a278de21fd7322635556334d9b535f1cbc00b0a3919447cdf464c65/cffi-2.1.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:164bff1657b2a74f0b6d54e11c9b375bc97b931f2ca9c43fcf875838da1570dd", size = 184226, upload-time = "2026-07-06T21:32:11.196Z" }, + { url = "https://files.pythonhosted.org/packages/88/a9/02cae418ec4beb282ace11958d9d4737793439d561fadc7e6d56f2e2b354/cffi-2.1.0-cp310-cp310-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:c941bb58d5a6e1c3892d86e42927ed6c180302f07e6d395d08c416e594b98b46", size = 211107, upload-time = "2026-07-06T21:32:12.328Z" }, + { url = "https://files.pythonhosted.org/packages/3b/30/c806937ed5e4c2c7ac30d9d6b76b5dc57ff8b75d83800d9bb11a8253cf2a/cffi-2.1.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a016194dbe13d14ee9556e734b772d8d67b947092b268d757fd4290e3ba2dfc2", size = 218733, upload-time = "2026-07-06T21:32:13.67Z" }, + { url = "https://files.pythonhosted.org/packages/f9/cf/398272b8bbfd58aa314fda5a7f1cdbb26d1d78ae324a11211521315dd1f0/cffi-2.1.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:03e9810d18c646077e501f661b682fbf5dee4676048527ca3cffe66faa9960dd", size = 205543, upload-time = "2026-07-06T21:32:15.148Z" }, + { url = "https://files.pythonhosted.org/packages/45/ca/f91641185cdd90c36d317a9dc7f85e88ef8682d8b300977baff5e23c35d8/cffi-2.1.0-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:19c54ac121cad98450b4896fa9a43ee0180d57bc4bc911a33db6cab1efab6cd3", size = 205460, upload-time = "2026-07-06T21:32:16.479Z" }, + { url = "https://files.pythonhosted.org/packages/38/66/04781a77b411f0bb5b234d62c1814754ab75ebe455ccff1b08e8d7aae98f/cffi-2.1.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:4d433a51f1870e43a13b6732f92aaf540ff77c2015097c78556f75a2d6c030e0", size = 218760, upload-time = "2026-07-06T21:32:17.98Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9a/bb1d5ed9c3fcae158e9f6391bf309c95d98c2ac37ed56573228471d0af5e/cffi-2.1.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3d7f118b5adbfdfead90c25822690b02bc8074fba949bb7858bec4ebd55adb43", size = 221230, upload-time = "2026-07-06T21:32:19.407Z" }, + { url = "https://files.pythonhosted.org/packages/41/aa/3c1409cdd26094efacd1c36c66e0a6eb9d4296e4fd4f9901b8b2042f4323/cffi-2.1.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:c5f5df567f6eb216de69be06ce55c8b714090fae02b18a3b40da8163b8c5fa9c", size = 213524, upload-time = "2026-07-06T21:32:20.828Z" }, + { url = "https://files.pythonhosted.org/packages/fa/75/74dfb7c3fc6ebbd408038476bd4c1d7e925c62614e7b9c534ecc34218288/cffi-2.1.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:11b3fb55f4f8ad92274ed26705f65d8f91457de71f5380061eb6d125a768fecd", size = 220341, upload-time = "2026-07-06T21:32:21.9Z" }, + { url = "https://files.pythonhosted.org/packages/70/b6/9003c33a3e7d2c1306f5962e646457dcfe5a8cd8fce6bbe02d7af25db783/cffi-2.1.0-cp310-cp310-win32.whl", hash = "sha256:9d72af0cf10a76a600a9690078fe31c63b9588c8e86bf9fd353f713c84b5db0f", size = 174578, upload-time = "2026-07-06T21:32:23.073Z" }, + { url = "https://files.pythonhosted.org/packages/8a/26/710688310447531c7a22f857c7f79d9855ec18b03e04494ced723fb37e2f/cffi-2.1.0-cp310-cp310-win_amd64.whl", hash = "sha256:fb62edb5bb52cca65fab91a63afa7561607120d26090a7e8fda6fb9f064726da", size = 185071, upload-time = "2026-07-06T21:32:24.671Z" }, + { url = "https://files.pythonhosted.org/packages/d3/67/85c89a59ba36a671e79638f44d466749f08179266a57e4f2ffdf92174072/cffi-2.1.0-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:02cb7ff33ded4f1532476731f89ede53e2e488a8e6205515a82144246ffa7dcc", size = 183845, upload-time = "2026-07-06T21:32:26.32Z" }, + { url = "https://files.pythonhosted.org/packages/ea/dd/e3b0baa2d3d6a857ac72b7efbf18e32e487c9cdafcc13049ad765495b15e/cffi-2.1.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f5bce581e6b8c235e566a14768a943b172ada3ed73537bb0c0be1edee312d4e7", size = 184186, upload-time = "2026-07-06T21:32:28.025Z" }, + { url = "https://files.pythonhosted.org/packages/65/68/9f3ef890cf3c6ab97bd531c5677f67613d302165d16f8142b2811782a614/cffi-2.1.0-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:30b65779d598c370374fefabf138d456fd6f3216bfa7bedfab1ba82025b0cd93", size = 211892, upload-time = "2026-07-06T21:32:29.565Z" }, + { url = "https://files.pythonhosted.org/packages/22/d7/1a74539db16d8bfd839ff1515948948efbb162e574650fd3d846896eea95/cffi-2.1.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88023dfe18799507b73f1dbb0d14326a17465de1bc9c9c7655c22845e9ddc3a2", size = 218793, upload-time = "2026-07-06T21:32:30.951Z" }, + { url = "https://files.pythonhosted.org/packages/ec/d1/9a5b7169499e8e8d8e636de70b97ac7c9447104d2ff1a2cd94790cea5162/cffi-2.1.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:0a96b74cda968eebbad56d973efe5098974f0a9fb323865bf99ea1fd24e3e64c", size = 205737, upload-time = "2026-07-06T21:32:32.216Z" }, + { url = "https://files.pythonhosted.org/packages/ba/b0/e131a9c41f10607926278453d9596163594fe1c4ebc46efe3b5e5b34eb84/cffi-2.1.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a5781494d4d400a3f47f8f1da94b324f6e6b440a53387774002890a2a2f4b50f", size = 204909, upload-time = "2026-07-06T21:32:33.655Z" }, + { url = "https://files.pythonhosted.org/packages/fb/d2/4398416cd699b35167947c6e22aca52c47e69ad5695073c9f1f2c52e04aa/cffi-2.1.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aa7a1b53a2a4452ada2d1b5dade9960b2522f1e61293a811a077439e39029565", size = 217883, upload-time = "2026-07-06T21:32:35.173Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/d4fe77b589e5e82d43ebc809bf2e6474afe8e48e32ea050b9357645b6471/cffi-2.1.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9d8272c0e483b024e1b9ad029821470ed8ec65631dbd90217469da0e7cd89f1c", size = 221251, upload-time = "2026-07-06T21:32:36.527Z" }, + { url = "https://files.pythonhosted.org/packages/22/f0/a2fc43084c0433caf7f461bccc013e28f848d04ee1c5ed7fce71423cf4d9/cffi-2.1.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7762faa47e8ff7eb80bd261d9a7d8eea2d8baa69de5e95b70c1f338bbe712f02", size = 214250, upload-time = "2026-07-06T21:32:37.852Z" }, + { url = "https://files.pythonhosted.org/packages/04/8c/b925975448cf20634a9fbd5efceb807219db452653648d2897c0989cab2d/cffi-2.1.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:89095c1968b4ba8285840e131bf2891b09ae137fe2146905acae0354fbce1b5e", size = 219441, upload-time = "2026-07-06T21:32:39.146Z" }, + { url = "https://files.pythonhosted.org/packages/eb/da/5c4918a2d61d86fa927d716cb3d8e4626ef8dc8f605a599d32f33897f59a/cffi-2.1.0-cp311-cp311-win32.whl", hash = "sha256:64c753a0f87a256020004f37a1c8c02c480e725f910f0b2a0f3f07debd1b2479", size = 174496, upload-time = "2026-07-06T21:32:40.467Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c8/6c2de1d55cf35ef8b92885d5ef280790f0fb9634d87ea1cc315176aecd61/cffi-2.1.0-cp311-cp311-win_amd64.whl", hash = "sha256:4f26194e3d95e06501b942642855aed4f953d55e95d7d01b7c4483db3ecff458", size = 185113, upload-time = "2026-07-06T21:32:41.761Z" }, + { url = "https://files.pythonhosted.org/packages/9e/4e/e8d7cb5783f1841a3c8fb3a7735838d7484d08ec08c9f984b14cac1ac0e9/cffi-2.1.0-cp311-cp311-win_arm64.whl", hash = "sha256:35aaea0c7ee0e58a5cd8c2fd1a48fdf7ece0d2699b7ecdda08194e9ce5dd9b3d", size = 179927, upload-time = "2026-07-06T21:32:42.961Z" }, + { url = "https://files.pythonhosted.org/packages/1e/85/990925db5df586ec90beb97529c853497e7f85ba0234830447faf41c3057/cffi-2.1.0-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:df2b82571a1b30f58a87bf4e5a9e78d2b1eff6c6ce8fd3aa3757221f93f0863f", size = 184829, upload-time = "2026-07-06T21:32:44.324Z" }, + { url = "https://files.pythonhosted.org/packages/4b/92/e7bb136ad6b5352603732cf907ef862ca103f20f2031c1735a46300c20c9/cffi-2.1.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:78474632761faa0fb96f30b1c928c84ebcf68713cbb80d15bab09dfe61640fde", size = 184728, upload-time = "2026-07-06T21:32:45.683Z" }, + { url = "https://files.pythonhosted.org/packages/c3/c0/d1ec30ffb370f748f2fb54425972bfef9871e0132e82fb589c46b6676049/cffi-2.1.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:5972433ad71a9e46516584ef60a0fda12d9dc459938d1539c3ddecf9bdc1368d", size = 214815, upload-time = "2026-07-06T21:32:48.557Z" }, + { url = "https://files.pythonhosted.org/packages/1b/dc/5620cf930688be01f2d673804291de757a934c90b946dbdc3d84130c2ea4/cffi-2.1.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b6422532152adf4e59b110cb2808cee7a033800952f5c036b4af047ee43199e7", size = 222429, upload-time = "2026-07-06T21:32:49.848Z" }, + { url = "https://files.pythonhosted.org/packages/4b/a4/77b53abbf7a1e0beb9637edbef2a94d15f9c822f591e85d439ffd91519a6/cffi-2.1.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:46b1c8db8f6122420f32d02fffb924c2fe9bc772d228c7c711748fff56aabb2b", size = 210315, upload-time = "2026-07-06T21:32:51.221Z" }, + { url = "https://files.pythonhosted.org/packages/58/0c/f528df19cc94b675087324d4760d9e6d5bfae97d6217aa4fac43de4f5fcc/cffi-2.1.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9fafc5aa2e2a39aaf7f8cc0c1f044a9b07fca12e558dca53a3cc5c654ad67a7", size = 208859, upload-time = "2026-07-06T21:32:52.512Z" }, + { url = "https://files.pythonhosted.org/packages/62/f2/c9522a81c32132799a1972c39f5c5f8b4c8b9f00488a23feaa6c06f07741/cffi-2.1.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1e9f50d192a3e525b15a75ab5114e442d83d657b7ec29182a991bc9a88fd3a66", size = 221844, upload-time = "2026-07-06T21:32:53.704Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/bd53988b9833e8f8ad539d26f4c07a6b3f6bcb1e9e02e7ca038250b3428d/cffi-2.1.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:98fff996e983a36d3aa2eca83af40c5821202e7e6f32d13ae94e3d2286f10cfe", size = 225287, upload-time = "2026-07-06T21:32:54.907Z" }, + { url = "https://files.pythonhosted.org/packages/79/99/0d0fd37f055224085f42bbb2c022d002e17dde4a97972822327b07d84101/cffi-2.1.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:379de10ce1ba048b1448599d1b37b24caee16309d1ac98d3982fc997f768700b", size = 223681, upload-time = "2026-07-06T21:32:56.329Z" }, + { url = "https://files.pythonhosted.org/packages/b0/80/c138990aa2a70b1a269f6e06348729836d733d6f970867943f61d367f8cc/cffi-2.1.0-cp312-cp312-win32.whl", hash = "sha256:9b8f0f26ca4e7513c534d351eca551947d053fac438f2a04ac96d882909b0d3a", size = 175269, upload-time = "2026-07-06T21:32:57.777Z" }, + { url = "https://files.pythonhosted.org/packages/a8/eb/f636456ff21a83fc13c032b58cc5dde061691546ac79efa284b2989b7982/cffi-2.1.0-cp312-cp312-win_amd64.whl", hash = "sha256:c97f080ea627e2863524c5af3836e2270b5f5dfff1f104392b959f8df0c5d384", size = 185881, upload-time = "2026-07-06T21:32:59.253Z" }, + { url = "https://files.pythonhosted.org/packages/dd/2c/400ea43e721727dca8a65c4521390e9196757caba4a45643acb2b63271b8/cffi-2.1.0-cp312-cp312-win_arm64.whl", hash = "sha256:6d194185eabd279f1c05ebe3504265ddfc5ad2b58d0714f7db9f01da592e9eb6", size = 180088, upload-time = "2026-07-06T21:33:02.278Z" }, + { url = "https://files.pythonhosted.org/packages/96/88/a996879e2eeccb815f6e3a5967b12a308257412acec882039d386bd2aa7b/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:10537b1df4967ca26d21e5072d7d54188354483b91dc75058968d3f0cf13fbda", size = 194331, upload-time = "2026-07-06T21:33:03.697Z" }, + { url = "https://files.pythonhosted.org/packages/58/85/7ae00d5c8dd6266f4e944c3db630f3c5c9a98b61d469c714d848b1d8138a/cffi-2.1.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:a95b05f9baf29b91171b3a8bd2020b028835243e7b0ff6bb23e2a3c228518b1b", size = 196966, upload-time = "2026-07-06T21:33:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e9/45c3a76ad8d43ad9261f4c95436da61128d3ca545d72b9612c0ab5be0b1c/cffi-2.1.0-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:15faec4adfff450819f3aee0e2e02c812de6edb88203aa58807955db2003472a", size = 184795, upload-time = "2026-07-06T21:33:06.699Z" }, + { url = "https://files.pythonhosted.org/packages/84/4c/82f132cb4418ee6d953d982b19191e87e2a6372c8a4ce36e50b69d6ade4a/cffi-2.1.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:716ff8ec22f20b4d988b12884086bcef0fc99737043e503f7a3935a6be99b1ea", size = 184746, upload-time = "2026-07-06T21:33:08.071Z" }, + { url = "https://files.pythonhosted.org/packages/a0/1c/4ed5a0e5bdca6cbc275556de3328dd1b76fd0c11cc13c88fe66d1d8715f2/cffi-2.1.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:63960549e4f8dc41e31accb97b975abaecfc44c03e396c093a6436763c2ea7db", size = 214747, upload-time = "2026-07-06T21:33:09.671Z" }, + { url = "https://files.pythonhosted.org/packages/3a/a6/e879bb68cc23a2bc9ba8f4b7d8019f0c2694bad2ab6c4a3701d429439f58/cffi-2.1.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ff067a8d8d880e7809e4ac88eb009bb848870115317b306666502ccad30b147f", size = 222392, upload-time = "2026-07-06T21:33:10.896Z" }, + { url = "https://files.pythonhosted.org/packages/88/f6/01890cfd63c08f8eb96a8319b0443690197d240a8bd6346048cf7bde9190/cffi-2.1.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3b926723c13eba9f81d2ef3820d63aeceec3b2d4639906047bf675cb8a7a500d", size = 210285, upload-time = "2026-07-06T21:33:12.251Z" }, + { url = "https://files.pythonhosted.org/packages/a6/cf/2b684132056f438567b61e19d690dd31cd0921ace051e0a458be6074369e/cffi-2.1.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:47ff3a8bfd8cb9da1af7524b965127095055654c177fcfc7578debcb015eecd0", size = 208801, upload-time = "2026-07-06T21:33:13.617Z" }, + { url = "https://files.pythonhosted.org/packages/6f/08/f2e7d62c460faae0926f2d6e423694aa409ced3bc1fe2927a0a6e5f05416/cffi-2.1.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:799416bae98336e400981ff6e532d67d5c709cfb30afb79865a1315f94b0e224", size = 221808, upload-time = "2026-07-06T21:33:15.466Z" }, + { url = "https://files.pythonhosted.org/packages/38/37/04f54b8e63a02f3d908332c9effbf8c366167c6f733ed8a3d4f79b7e2a1e/cffi-2.1.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:961be50688f7fba2fa65f63712d3b9b341a22311f5253460ce933f52f0de1c8c", size = 225241, upload-time = "2026-07-06T21:33:16.869Z" }, + { url = "https://files.pythonhosted.org/packages/a9/d6/c72eecca433cd3e681c65ed313ab4835d9d4a379704d0f628a6a05f51c2e/cffi-2.1.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bf5c6cf48238b0eb4c086978c492ad1cbc22373fc5b2d7353b3a598ce6db887a", size = 223588, upload-time = "2026-07-06T21:33:18.239Z" }, + { url = "https://files.pythonhosted.org/packages/c6/4b/e706f67279140f92939da3475ad610df18bfd52d50f14953a8e5fede71d5/cffi-2.1.0-cp313-cp313-win32.whl", hash = "sha256:db3eb7d46527159a878ec3460e9d40615bc25ba337d477db681aea6e4f05c5d2", size = 175248, upload-time = "2026-07-06T21:33:19.799Z" }, + { url = "https://files.pythonhosted.org/packages/5a/47/59eb7975cb0e4ef0afa764ea945b29a5bb4537a9f771cb7d6c8a5dd74c95/cffi-2.1.0-cp313-cp313-win_amd64.whl", hash = "sha256:8e74a6135550c4748af665b1b1118b6aab33b1fc6a16f9aff630af107c3b4512", size = 185717, upload-time = "2026-07-06T21:33:21.47Z" }, + { url = "https://files.pythonhosted.org/packages/5a/af/34fee85c48f8d94efc8597bc09470c9dd274c145f1c12e0fbc6ab6d38d74/cffi-2.1.0-cp313-cp313-win_arm64.whl", hash = "sha256:2282cd5e38aa8accd03e99d1256af8411c84cdbee6a89d841b563fdbd1f3e50f", size = 180114, upload-time = "2026-07-06T21:33:22.515Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f0/81478e482afa03f6d18dc8f2afb5edc45b3080853b634b5ed91961be0998/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:d2117334c3af3bdcb9a88522b844a2bdb5efdc4f71c6c822df55486ae1c3347a", size = 194142, upload-time = "2026-07-06T21:33:23.657Z" }, + { url = "https://files.pythonhosted.org/packages/7d/95/8de304305cd9204974b0ca051b86d307cafca13aa575a0ef1b44d92c0d8c/cffi-2.1.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:702c436735fbe99d59ada02a1f65cfc0d31c0ee8b7290912f8fbc5cd1e4b16c3", size = 196819, upload-time = "2026-07-06T21:33:25.007Z" }, + { url = "https://files.pythonhosted.org/packages/20/71/7c8372d30e42415602ed9f268f7cfd66f1b855fed881ecd168bcb45dbc0b/cffi-2.1.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:1ff3456eab0d889592d1936d6125bbfbc7ae4d3354a700f8bd80450a66445d4d", size = 184965, upload-time = "2026-07-06T21:33:26.605Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5c/584e626835f0375c928176c04137c96927165cb8733cdb3150ec04e5ee5e/cffi-2.1.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c4165821e131d6d4ca444347c2b694e2311bcfa3fe5a861cc72968f28867beac", size = 184952, upload-time = "2026-07-06T21:33:27.823Z" }, + { url = "https://files.pythonhosted.org/packages/2e/d2/065fcae1c73979fac8e054462478d0ff8a29c40cdc2ed7ea5676a061df53/cffi-2.1.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:276f20fffd7b396e12516ba8edf9509210ac248cbbc5acbc39cd512f9f59ebe6", size = 222353, upload-time = "2026-07-06T21:33:29.178Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a5/e8bbb1ce5b3ac2f53ad6a10bde44318a5a8d99d4f4a000d44a6e39aeb3e4/cffi-2.1.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:7d5980a3433d4b71a5e120f9dd551403d7824e31e2e67124fe2769c404c06913", size = 210051, upload-time = "2026-07-06T21:33:30.534Z" }, + { url = "https://files.pythonhosted.org/packages/28/ed/c127d3ac36e899c965e3361357c3befacd6578c03f40125183e41c3b219e/cffi-2.1.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:6ca4919c6e4f89aa99c42510b42cf54596892c00b3f9077f6bdd1505e24b9c8d", size = 208630, upload-time = "2026-07-06T21:33:31.753Z" }, + { url = "https://files.pythonhosted.org/packages/cc/d7/97d3136f81db489ec8d1d67748c110d6c994268fd7528014aa9f2b085e4e/cffi-2.1.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d53d10f7da99ae46f7373b9150393e9c5eab9b224909982b43832668de4779f5", size = 221593, upload-time = "2026-07-06T21:33:33.044Z" }, + { url = "https://files.pythonhosted.org/packages/d3/27/93195977168ee63aed233a1a0993a2178798654d1f4bddcdd321d6fd3b21/cffi-2.1.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c351efb95e832a853a29361675f33a7ce53de1a109cd73fd47af0712213aa4ce", size = 225146, upload-time = "2026-07-06T21:33:34.224Z" }, + { url = "https://files.pythonhosted.org/packages/b3/c1/6dbd291ee2ae5a50a034aa057207081f545923bbf15dad4511e985aafff5/cffi-2.1.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:dbf7c7a88e2bac086f06d14577332760bdeecc42bdec8ac4077f6260557d9326", size = 223240, upload-time = "2026-07-06T21:33:35.57Z" }, + { url = "https://files.pythonhosted.org/packages/0f/6f/ade5ce9863a57992a6ea3d0d10d7e29b8749fc127204b3d493d667b2815f/cffi-2.1.0-cp314-cp314-win32.whl", hash = "sha256:1854b724d00f6654c742097d5387569021be12d3a0f770eae1df8f8acfcc6acd", size = 177723, upload-time = "2026-07-06T21:33:51.626Z" }, + { url = "https://files.pythonhosted.org/packages/41/de/92b9eeed4ae4a21d6fd9b2a2c8505cbed573299902ea73981cc13f7ff62c/cffi-2.1.0-cp314-cp314-win_amd64.whl", hash = "sha256:1b96bfe2c4bd825681b7d311ad6d9b7280a091f43e8f63da5729638083cd3bfb", size = 187937, upload-time = "2026-07-06T21:33:53.403Z" }, + { url = "https://files.pythonhosted.org/packages/2e/1a/cc6ae6c2913a03aab8898eee57963cf1035b8df5872ed8b9115fcc7e2be8/cffi-2.1.0-cp314-cp314-win_arm64.whl", hash = "sha256:7d28dff1db6764108bc30788d85d61c876beff416d9a49cb9dd7c5a9f34f5804", size = 183001, upload-time = "2026-07-06T21:33:54.74Z" }, + { url = "https://files.pythonhosted.org/packages/14/f0/134c00ce0779ec86dea2aa1aac69339c2741a8045072676763512363a2ea/cffi-2.1.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7ea6b3e2c4250ff1de21c630fe72d0f63eb95c2c32ffbf64a358cf4a8836d714", size = 188538, upload-time = "2026-07-06T21:33:36.792Z" }, + { url = "https://files.pythonhosted.org/packages/50/d8/3b86aba791cb610d24e8a3e1b2cd529e71fa15096b04e4d4e360049d4a4c/cffi-2.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6af371f3767faeffc6ac1ef57cdfd25844403e9d3f476c5537caee499de96376", size = 188230, upload-time = "2026-07-06T21:33:38.011Z" }, + { url = "https://files.pythonhosted.org/packages/14/d0/117dcd9209255ad8571fbc8c92ef32593a1d294dcec91ddc4e4db50606f2/cffi-2.1.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:eb4e8997a49aa2c08a3e43c9045d224448b8941d88e7ac163c7d383e560cbf98", size = 223899, upload-time = "2026-07-06T21:33:39.514Z" }, + { url = "https://files.pythonhosted.org/packages/b6/3d/f20f8b886b254e3ad10e15cd4186d3aed49f3e6a35ab37aab9f8f25f7c03/cffi-2.1.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:bf01d8c84cbea96b944c73b22182e6c7c432b3475632b8111dbfdc95ddad6e13", size = 211652, upload-time = "2026-07-06T21:33:40.851Z" }, + { url = "https://files.pythonhosted.org/packages/28/3b/fad54de07260b93ddeef4b96d0131d57ea900675df1d410ae1deee52d7a6/cffi-2.1.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:33eb1ad83ebe8f313e0df035c406227d55a79456704a863fad9842136af5ad7d", size = 210755, upload-time = "2026-07-06T21:33:42.183Z" }, + { url = "https://files.pythonhosted.org/packages/cc/82/3d5c705acb7abbba9bbd7d79b8e62e0f25b6120eb7ae6ac49f1b721722fe/cffi-2.1.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ac0f1a2d0cfa7eea3f2aaf006ab6e70e8feeb16b75d65b7e5939982ca2f11056", size = 223933, upload-time = "2026-07-06T21:33:43.603Z" }, + { url = "https://files.pythonhosted.org/packages/6c/d0/47e338384ab6b1004241002fa616301020cea4fc95f283506565d252f276/cffi-2.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c16914df9fb7f500e440e6875fa23ff5e0b31db01fa9c06af98d59a91f0dc2e4", size = 226749, upload-time = "2026-07-06T21:33:45.046Z" }, + { url = "https://files.pythonhosted.org/packages/70/25/65bd5b58ea4bfdfc15cde02cb5365f89ef8ab8b2adfb8fe5c4bd4233382f/cffi-2.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5ecbd0499275d57506d397eebe1981cee87b47fcd9ef5c22cab7ed7644a39a94", size = 225703, upload-time = "2026-07-06T21:33:46.374Z" }, + { url = "https://files.pythonhosted.org/packages/dc/78/aa01ac599a8a4322533d45a1f9bc93b338276d2d59dabbe7c6d92a775c81/cffi-2.1.0-cp314-cp314t-win32.whl", hash = "sha256:7d034dcffa09e9a46c93fa3a3be402096cb5354ac6e41ab8e5cc9cd8b642ad76", size = 182857, upload-time = "2026-07-06T21:33:47.696Z" }, + { url = "https://files.pythonhosted.org/packages/b9/26/d00496b22de4d4228f32dde94ad996f350c8aad676d63bcca0743c8dea4d/cffi-2.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:0582a58f3051372229ca8e7f5f589f9e5632678208d8636fea3676711fdf7fe5", size = 194065, upload-time = "2026-07-06T21:33:48.953Z" }, + { url = "https://files.pythonhosted.org/packages/d5/dd/0c7dbf815a579ff005008a2d815a55d6bb047c349eef536d9dc53d3f0a8d/cffi-2.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:510aeeeac94811b138077451da1fb18b308a5feab47dd2b603af55804155e1c8", size = 186404, upload-time = "2026-07-06T21:33:50.309Z" }, + { url = "https://files.pythonhosted.org/packages/55/c7/8c8c50cb11c6750051daf12164098a9a6f027ac4356967fd4d800a07f242/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:2e9dabb9abcb7ad15938c7196ad5c1718a4e6d33cc79b4c0209bdb64c4a54a5c", size = 194121, upload-time = "2026-07-06T21:33:56.109Z" }, + { url = "https://files.pythonhosted.org/packages/99/e2/67680bf19a6b60d2bb7ff83baefa2a4c3d2d7dc0f3277034b802e1fc504c/cffi-2.1.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:37f525a7e7e50c017fdebe58b787be310ad59357ae43a053943a6e1a6c526001", size = 196820, upload-time = "2026-07-06T21:33:57.288Z" }, + { url = "https://files.pythonhosted.org/packages/ed/da/4bbe583a3b3a5c8c60892124fe17f3fa3656523faf0d3484eae90f091853/cffi-2.1.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:95f2954c2c9473d892eca6e0409f3568b37ab62a8eedb122461f73cc273476e3", size = 184936, upload-time = "2026-07-06T21:33:58.765Z" }, + { url = "https://files.pythonhosted.org/packages/e5/4b/1f4c36ab273980d7aa75bb126ea4f8971f24a96108acad3a0a084028c57b/cffi-2.1.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:cdf2448aab5f661c9315308ec8b93f4e8a1a67a3c733f8631067a2b67d5913dc", size = 185045, upload-time = "2026-07-06T21:34:00.085Z" }, + { url = "https://files.pythonhosted.org/packages/ef/c3/ad299dc38f3583f8d916b299f028af418a9ec98bc695fcbebeae7420691c/cffi-2.1.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:90bec57cf82089383bd06a605b3eb8daebf7e5a668520beaf6e327a83a947699", size = 222342, upload-time = "2026-07-06T21:34:01.814Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d8/df4543cc087245044ed02ef3ad8e0a26619d0075ac7a77a12dc81177851b/cffi-2.1.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6274dcb2d15cef48daa73ed1be5a40d501d74dccd0cd6db364776d12cb6ba022", size = 210073, upload-time = "2026-07-06T21:34:03.255Z" }, + { url = "https://files.pythonhosted.org/packages/2c/0e/fac738d73728c6cea2a88a2883dca54892496cbba88a1dc1f2909cb8a6f5/cffi-2.1.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:2b71d409cccee78310ab5dec549aed052aaea483346e282c7b02362596e01bb0", size = 208551, upload-time = "2026-07-06T21:34:04.433Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3f/0b04a700dd64f465c93020253a793a82c9b4dff9961f48facd0df945d9b8/cffi-2.1.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7d3538f9c0e50670f4deb93dbb696576e60590369cae2faf7de681e597a8a1f1", size = 221649, upload-time = "2026-07-06T21:34:06.157Z" }, + { url = "https://files.pythonhosted.org/packages/5d/7c/b7379a5704c79eda57ce075869ba70a0368d1c850f803b3c0d078d39dcaf/cffi-2.1.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:8f9ec95b8a043d3dfbc74d9abc6f7baf524dd27a8dc160b0a32ff9cdab650c28", size = 225203, upload-time = "2026-07-06T21:34:07.489Z" }, + { url = "https://files.pythonhosted.org/packages/5a/02/d5e6c43ea85c41bda2a184a3418f195fe7cf602967a8d2b94e085b83deef/cffi-2.1.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:af5e2915d41fe6c961694d7bfdc8562942638200f3ce2765dfb8b745cf997629", size = 223263, upload-time = "2026-07-06T21:34:08.712Z" }, + { url = "https://files.pythonhosted.org/packages/2c/d8/772b8259bf75749adffb1c546828978381fb516f60cf701f6c83daf60c85/cffi-2.1.0-cp315-cp315-win32.whl", hash = "sha256:0a42c688d19fca6e095a53c6a6e2295a5b050a8b289f109adab02a9e61a25de6", size = 177696, upload-time = "2026-07-06T21:34:26.355Z" }, + { url = "https://files.pythonhosted.org/packages/2f/dd/afa2191fc6d57fedd26e5844a2fe2fcc0bbfa00961bbaa5a41e4921e7cca/cffi-2.1.0-cp315-cp315-win_amd64.whl", hash = "sha256:bccbbb5ee76a61f9d99b5bf3846a51d7fca4b6a732fe46f89295610edaf41853", size = 187914, upload-time = "2026-07-06T21:34:27.58Z" }, + { url = "https://files.pythonhosted.org/packages/05/ef/6cd4f8c671517162379dc79cfae5aea9106bc38abb89628d5c16adf6a838/cffi-2.1.0-cp315-cp315-win_arm64.whl", hash = "sha256:8d35c139744adb3e727cd51b1a18324bbe44b8bd41bf8322bca4d41289f48eda", size = 183004, upload-time = "2026-07-06T21:34:28.905Z" }, + { url = "https://files.pythonhosted.org/packages/11/b6/12fc55092817a5faa26fb8c40c7f9d662e11a46ee248c137aafc42517d92/cffi-2.1.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:f9912624a0c0b834b7520d7769b3644453aabc0a7e1c839da7359f050750e9bc", size = 188378, upload-time = "2026-07-06T21:34:09.926Z" }, + { url = "https://files.pythonhosted.org/packages/8d/2e/cdac88979f295fde5daa69622c7d2111e56e7ceb94f211357fbe452339e4/cffi-2.1.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:df92f2aba50eb4d96718b68ef76f2e57a57b54f2fa62333496d16c6d585a85ca", size = 188319, upload-time = "2026-07-06T21:34:11.101Z" }, + { url = "https://files.pythonhosted.org/packages/e0/27/1d0b408497e41a74795af122d7b603c418c5fed0171450f899afd04e594f/cffi-2.1.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0520e1f4c35f44e209cbbb421b67eec42e6a157f59444dfb6058874ff3610e5d", size = 223904, upload-time = "2026-07-06T21:34:12.606Z" }, + { url = "https://files.pythonhosted.org/packages/8b/31/e115c985105dd7ffb32444505f18ceb874bb42d992af05d5dced7ecf1980/cffi-2.1.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:3681e031db29958a7502f5c0c9d6bbc4c36cb20f7b104086fa642d1799631ff8", size = 211554, upload-time = "2026-07-06T21:34:13.987Z" }, + { url = "https://files.pythonhosted.org/packages/5a/67/9e6e09409336d9e515c58367e7cfcf4f89df06ad25252675595a58eb59d5/cffi-2.1.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:762f99479dcb369f60ab9017ad4ab97a36a1dd7c1ee5a3b15db0f4b8659120cd", size = 210795, upload-time = "2026-07-06T21:34:15.972Z" }, + { url = "https://files.pythonhosted.org/packages/19/e5/d3cc82a4a0be7902af279c04181ad038449c096734464a5ae1de3e1401bd/cffi-2.1.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0611e7ebf90573a535ebdc33ae9da222d037853983e13359f580fab781ca017f", size = 223843, upload-time = "2026-07-06T21:34:17.509Z" }, + { url = "https://files.pythonhosted.org/packages/b9/65/b434abc97ce7cecc2c640fde160507c0ecc7e21544b483ba3325d2e2ea17/cffi-2.1.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:86cf8755a791f72c85dc287128cc62d4f24d392e3f1e15837245623f4a33cccc", size = 226773, upload-time = "2026-07-06T21:34:19.05Z" }, + { url = "https://files.pythonhosted.org/packages/b5/9f/d4dc66ca651eb1145a133314cda721abf13cfac3d28c4a0402263ae6ad75/cffi-2.1.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:ba00f661f8ba35d075c937174e27c2c421cec3942fd2e0ea3e66996757c0fdd9", size = 225719, upload-time = "2026-07-06T21:34:20.576Z" }, + { url = "https://files.pythonhosted.org/packages/68/5a/e536c528bc8057496c360c0978559a2dc45653f89dd6151078aa7d8fca1a/cffi-2.1.0-cp315-cp315t-win32.whl", hash = "sha256:cb96698e3c7413d906ce83f8ffd245ec1bd94707541f299d0ce4d6b0193e982b", size = 182760, upload-time = "2026-07-06T21:34:22.059Z" }, + { url = "https://files.pythonhosted.org/packages/d3/0b/0ffe8b82d3875bced5fa1e7986a7a46b748262a40ab7f60b475eb9fb1bb3/cffi-2.1.0-cp315-cp315t-win_amd64.whl", hash = "sha256:f146d154428a2523f9cc7936c02353c2459b8f6cf07d3cd1ee1c0a611109c5d5", size = 193769, upload-time = "2026-07-06T21:34:23.589Z" }, + { url = "https://files.pythonhosted.org/packages/a0/17/1073b53b68c9b5ca6914adf5f8bf55aacc2d3be102418c90700160ea8605/cffi-2.1.0-cp315-cp315t-win_arm64.whl", hash = "sha256:cbb7640ce37159548d2147b5b8c241f962143d4c71231431820783f4dc78f210", size = 186405, upload-time = "2026-07-06T21:34:24.857Z" }, ] [[package]] @@ -218,107 +238,89 @@ wheels = [ [[package]] name = "charset-normalizer" -version = "3.4.7" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/08/0f303cb0b529e456bb116f2d50565a482694fbb94340bf56d44677e7ed03/charset_normalizer-3.4.7-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cdd68a1fb318e290a2077696b7eb7a21a49163c455979c639bf5a5dcdc46617d", size = 315182, upload-time = "2026-04-02T09:25:40.673Z" }, - { url = "https://files.pythonhosted.org/packages/24/47/b192933e94b546f1b1fe4df9cc1f84fcdbf2359f8d1081d46dd029b50207/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e17b8d5d6a8c47c85e68ca8379def1303fd360c3e22093a807cd34a71cd082b8", size = 209329, upload-time = "2026-04-02T09:25:42.354Z" }, - { url = "https://files.pythonhosted.org/packages/c2/b4/01fa81c5ca6141024d89a8fc15968002b71da7f825dd14113207113fabbd/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:511ef87c8aec0783e08ac18565a16d435372bc1ac25a91e6ac7f5ef2b0bff790", size = 231230, upload-time = "2026-04-02T09:25:44.281Z" }, - { url = "https://files.pythonhosted.org/packages/20/f7/7b991776844dfa058017e600e6e55ff01984a063290ca5622c0b63162f68/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:007d05ec7321d12a40227aae9e2bc6dca73f3cb21058999a1df9e193555a9dcc", size = 225890, upload-time = "2026-04-02T09:25:45.475Z" }, - { url = "https://files.pythonhosted.org/packages/20/e7/bed0024a0f4ab0c8a9c64d4445f39b30c99bd1acd228291959e3de664247/charset_normalizer-3.4.7-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf29836da5119f3c8a8a70667b0ef5fdca3bb12f80fd06487cfa575b3909b393", size = 216930, upload-time = "2026-04-02T09:25:46.58Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ab/b18f0ab31cdd7b3ddb8bb76c4a414aeb8160c9810fdf1bc62f269a539d87/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:12d8baf840cc7889b37c7c770f478adea7adce3dcb3944d02ec87508e2dcf153", size = 202109, upload-time = "2026-04-02T09:25:48.031Z" }, - { url = "https://files.pythonhosted.org/packages/82/e5/7e9440768a06dfb3075936490cb82dbf0ee20a133bf0dd8551fa096914ec/charset_normalizer-3.4.7-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d560742f3c0d62afaccf9f41fe485ed69bd7661a241f86a3ef0f0fb8b1a397af", size = 214684, upload-time = "2026-04-02T09:25:49.245Z" }, - { url = "https://files.pythonhosted.org/packages/71/94/8c61d8da9f062fdf457c80acfa25060ec22bf1d34bbeaca4350f13bcfd07/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:b14b2d9dac08e28bb8046a1a0434b1750eb221c8f5b87a68f4fa11a6f97b5e34", size = 212785, upload-time = "2026-04-02T09:25:50.671Z" }, - { url = "https://files.pythonhosted.org/packages/66/cd/6e9889c648e72c0ab2e5967528bb83508f354d706637bc7097190c874e13/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:bc17a677b21b3502a21f66a8cc64f5bfad4df8a0b8434d661666f8ce90ac3af1", size = 203055, upload-time = "2026-04-02T09:25:51.802Z" }, - { url = "https://files.pythonhosted.org/packages/92/2e/7a951d6a08aefb7eb8e1b54cdfb580b1365afdd9dd484dc4bee9e5d8f258/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:750e02e074872a3fad7f233b47734166440af3cdea0add3e95163110816d6752", size = 232502, upload-time = "2026-04-02T09:25:53.388Z" }, - { url = "https://files.pythonhosted.org/packages/58/d5/abcf2d83bf8e0a1286df55cd0dc1d49af0da4282aa77e986df343e7de124/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4e5163c14bffd570ef2affbfdd77bba66383890797df43dc8b4cc7d6f500bf53", size = 214295, upload-time = "2026-04-02T09:25:54.765Z" }, - { url = "https://files.pythonhosted.org/packages/47/3a/7d4cd7ed54be99973a0dc176032cba5cb1f258082c31fa6df35cff46acfc/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:6ed74185b2db44f41ef35fd1617c5888e59792da9bbc9190d6c7300617182616", size = 227145, upload-time = "2026-04-02T09:25:55.904Z" }, - { url = "https://files.pythonhosted.org/packages/1d/98/3a45bf8247889cf28262ebd3d0872edff11565b2a1e3064ccb132db3fbb0/charset_normalizer-3.4.7-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:94e1885b270625a9a828c9793b4d52a64445299baa1fea5a173bf1d3dd9a1a5a", size = 218884, upload-time = "2026-04-02T09:25:57.074Z" }, - { url = "https://files.pythonhosted.org/packages/ad/80/2e8b7f8915ed5c9ef13aa828d82738e33888c485b65ebf744d615040c7ea/charset_normalizer-3.4.7-cp310-cp310-win32.whl", hash = "sha256:6785f414ae0f3c733c437e0f3929197934f526d19dfaa75e18fdb4f94c6fb374", size = 148343, upload-time = "2026-04-02T09:25:58.199Z" }, - { url = "https://files.pythonhosted.org/packages/35/1b/3b8c8c77184af465ee9ad88b5aea46ea6b2e1f7b9dc9502891e37af21e30/charset_normalizer-3.4.7-cp310-cp310-win_amd64.whl", hash = "sha256:6696b7688f54f5af4462118f0bfa7c1621eeb87154f77fa04b9295ce7a8f2943", size = 159174, upload-time = "2026-04-02T09:25:59.322Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/feb40dca40dbb21e0a908801782d9288c64fc8d8e562c2098e9994c8c21b/charset_normalizer-3.4.7-cp310-cp310-win_arm64.whl", hash = "sha256:66671f93accb62ed07da56613636f3641f1a12c13046ce91ffc923721f23c008", size = 147805, upload-time = "2026-04-02T09:26:00.756Z" }, - { url = "https://files.pythonhosted.org/packages/c2/d7/b5b7020a0565c2e9fa8c09f4b5fa6232feb326b8c20081ccded47ea368fd/charset_normalizer-3.4.7-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:7641bb8895e77f921102f72833904dcd9901df5d6d72a2ab8f31d04b7e51e4e7", size = 309705, upload-time = "2026-04-02T09:26:02.191Z" }, - { url = "https://files.pythonhosted.org/packages/5a/53/58c29116c340e5456724ecd2fff4196d236b98f3da97b404bc5e51ac3493/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202389074300232baeb53ae2569a60901f7efadd4245cf3a3bf0617d60b439d7", size = 206419, upload-time = "2026-04-02T09:26:03.583Z" }, - { url = "https://files.pythonhosted.org/packages/b2/02/e8146dc6591a37a00e5144c63f29fb7c97a734ea8a111190783c0e60ab63/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:30b8d1d8c52a48c2c5690e152c169b673487a2a58de1ec7393196753063fcd5e", size = 227901, upload-time = "2026-04-02T09:26:04.738Z" }, - { url = "https://files.pythonhosted.org/packages/fb/73/77486c4cd58f1267bf17db420e930c9afa1b3be3fe8c8b8ebbebc9624359/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:532bc9bf33a68613fd7d65e4b1c71a6a38d7d42604ecf239c77392e9b4e8998c", size = 222742, upload-time = "2026-04-02T09:26:06.36Z" }, - { url = "https://files.pythonhosted.org/packages/a1/fa/f74eb381a7d94ded44739e9d94de18dc5edc9c17fb8c11f0a6890696c0a9/charset_normalizer-3.4.7-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2fe249cb4651fd12605b7288b24751d8bfd46d35f12a20b1ba33dea122e690df", size = 214061, upload-time = "2026-04-02T09:26:08.347Z" }, - { url = "https://files.pythonhosted.org/packages/dc/92/42bd3cefcf7687253fb86694b45f37b733c97f59af3724f356fa92b8c344/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:65bcd23054beab4d166035cabbc868a09c1a49d1efe458fe8e4361215df40265", size = 199239, upload-time = "2026-04-02T09:26:09.823Z" }, - { url = "https://files.pythonhosted.org/packages/4c/3d/069e7184e2aa3b3cddc700e3dd267413dc259854adc3380421c805c6a17d/charset_normalizer-3.4.7-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:08e721811161356f97b4059a9ba7bafb23ea5ee2255402c42881c214e173c6b4", size = 210173, upload-time = "2026-04-02T09:26:10.953Z" }, - { url = "https://files.pythonhosted.org/packages/62/51/9d56feb5f2e7074c46f93e0ebdbe61f0848ee246e2f0d89f8e20b89ebb8f/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e060d01aec0a910bdccb8be71faf34e7799ce36950f8294c8bf612cba65a2c9e", size = 209841, upload-time = "2026-04-02T09:26:12.142Z" }, - { url = "https://files.pythonhosted.org/packages/d2/59/893d8f99cc4c837dda1fe2f1139079703deb9f321aabcb032355de13b6c7/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:38c0109396c4cfc574d502df99742a45c72c08eff0a36158b6f04000043dbf38", size = 200304, upload-time = "2026-04-02T09:26:13.711Z" }, - { url = "https://files.pythonhosted.org/packages/7d/1d/ee6f3be3464247578d1ed5c46de545ccc3d3ff933695395c402c21fa6b77/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:1c2a768fdd44ee4a9339a9b0b130049139b8ce3c01d2ce09f67f5a68048d477c", size = 229455, upload-time = "2026-04-02T09:26:14.941Z" }, - { url = "https://files.pythonhosted.org/packages/54/bb/8fb0a946296ea96a488928bdce8ef99023998c48e4713af533e9bb98ef07/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:1a87ca9d5df6fe460483d9a5bbf2b18f620cbed41b432e2bddb686228282d10b", size = 210036, upload-time = "2026-04-02T09:26:16.478Z" }, - { url = "https://files.pythonhosted.org/packages/9a/bc/015b2387f913749f82afd4fcba07846d05b6d784dd16123cb66860e0237d/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d635aab80466bc95771bb78d5370e74d36d1fe31467b6b29b8b57b2a3cd7d22c", size = 224739, upload-time = "2026-04-02T09:26:17.751Z" }, - { url = "https://files.pythonhosted.org/packages/17/ab/63133691f56baae417493cba6b7c641571a2130eb7bceba6773367ab9ec5/charset_normalizer-3.4.7-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ae196f021b5e7c78e918242d217db021ed2a6ace2bc6ae94c0fc596221c7f58d", size = 216277, upload-time = "2026-04-02T09:26:18.981Z" }, - { url = "https://files.pythonhosted.org/packages/06/6d/3be70e827977f20db77c12a97e6a9f973631a45b8d186c084527e53e77a4/charset_normalizer-3.4.7-cp311-cp311-win32.whl", hash = "sha256:adb2597b428735679446b46c8badf467b4ca5f5056aae4d51a19f9570301b1ad", size = 147819, upload-time = "2026-04-02T09:26:20.295Z" }, - { url = "https://files.pythonhosted.org/packages/20/d9/5f67790f06b735d7c7637171bbfd89882ad67201891b7275e51116ed8207/charset_normalizer-3.4.7-cp311-cp311-win_amd64.whl", hash = "sha256:8e385e4267ab76874ae30db04c627faaaf0b509e1ccc11a95b3fc3e83f855c00", size = 159281, upload-time = "2026-04-02T09:26:21.74Z" }, - { url = "https://files.pythonhosted.org/packages/ca/83/6413f36c5a34afead88ce6f66684d943d91f233d76dd083798f9602b75ae/charset_normalizer-3.4.7-cp311-cp311-win_arm64.whl", hash = "sha256:d4a48e5b3c2a489fae013b7589308a40146ee081f6f509e047e0e096084ceca1", size = 147843, upload-time = "2026-04-02T09:26:22.901Z" }, - { url = "https://files.pythonhosted.org/packages/0c/eb/4fc8d0a7110eb5fc9cc161723a34a8a6c200ce3b4fbf681bc86feee22308/charset_normalizer-3.4.7-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:eca9705049ad3c7345d574e3510665cb2cf844c2f2dcfe675332677f081cbd46", size = 311328, upload-time = "2026-04-02T09:26:24.331Z" }, - { url = "https://files.pythonhosted.org/packages/f8/e3/0fadc706008ac9d7b9b5be6dc767c05f9d3e5df51744ce4cc9605de7b9f4/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6178f72c5508bfc5fd446a5905e698c6212932f25bcdd4b47a757a50605a90e2", size = 208061, upload-time = "2026-04-02T09:26:25.568Z" }, - { url = "https://files.pythonhosted.org/packages/42/f0/3dd1045c47f4a4604df85ec18ad093912ae1344ac706993aff91d38773a2/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1421b502d83040e6d7fb2fb18dff63957f720da3d77b2fbd3187ceb63755d7b", size = 229031, upload-time = "2026-04-02T09:26:26.865Z" }, - { url = "https://files.pythonhosted.org/packages/dc/67/675a46eb016118a2fbde5a277a5d15f4f69d5f3f5f338e5ee2f8948fcf43/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:edac0f1ab77644605be2cbba52e6b7f630731fc42b34cb0f634be1a6eface56a", size = 225239, upload-time = "2026-04-02T09:26:28.044Z" }, - { url = "https://files.pythonhosted.org/packages/4b/f8/d0118a2f5f23b02cd166fa385c60f9b0d4f9194f574e2b31cef350ad7223/charset_normalizer-3.4.7-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5649fd1c7bade02f320a462fdefd0b4bd3ce036065836d4f42e0de958038e116", size = 216589, upload-time = "2026-04-02T09:26:29.239Z" }, - { url = "https://files.pythonhosted.org/packages/b1/f1/6d2b0b261b6c4ceef0fcb0d17a01cc5bc53586c2d4796fa04b5c540bc13d/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:203104ed3e428044fd943bc4bf45fa73c0730391f9621e37fe39ecf477b128cb", size = 202733, upload-time = "2026-04-02T09:26:30.5Z" }, - { url = "https://files.pythonhosted.org/packages/6f/c0/7b1f943f7e87cc3db9626ba17807d042c38645f0a1d4415c7a14afb5591f/charset_normalizer-3.4.7-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:298930cec56029e05497a76988377cbd7457ba864beeea92ad7e844fe74cd1f1", size = 212652, upload-time = "2026-04-02T09:26:31.709Z" }, - { url = "https://files.pythonhosted.org/packages/38/dd/5a9ab159fe45c6e72079398f277b7d2b523e7f716acc489726115a910097/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:708838739abf24b2ceb208d0e22403dd018faeef86ddac04319a62ae884c4f15", size = 211229, upload-time = "2026-04-02T09:26:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/d5/ff/531a1cad5ca855d1c1a8b69cb71abfd6d85c0291580146fda7c82857caa1/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:0f7eb884681e3938906ed0434f20c63046eacd0111c4ba96f27b76084cd679f5", size = 203552, upload-time = "2026-04-02T09:26:34.845Z" }, - { url = "https://files.pythonhosted.org/packages/c1/4c/a5fb52d528a8ca41f7598cb619409ece30a169fbdf9cdce592e53b46c3a6/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4dc1e73c36828f982bfe79fadf5919923f8a6f4df2860804db9a98c48824ce8d", size = 230806, upload-time = "2026-04-02T09:26:36.152Z" }, - { url = "https://files.pythonhosted.org/packages/59/7a/071feed8124111a32b316b33ae4de83d36923039ef8cf48120266844285b/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:aed52fea0513bac0ccde438c188c8a471c4e0f457c2dd20cdbf6ea7a450046c7", size = 212316, upload-time = "2026-04-02T09:26:37.672Z" }, - { url = "https://files.pythonhosted.org/packages/fd/35/f7dba3994312d7ba508e041eaac39a36b120f32d4c8662b8814dab876431/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:fea24543955a6a729c45a73fe90e08c743f0b3334bbf3201e6c4bc1b0c7fa464", size = 227274, upload-time = "2026-04-02T09:26:38.93Z" }, - { url = "https://files.pythonhosted.org/packages/8a/2d/a572df5c9204ab7688ec1edc895a73ebded3b023bb07364710b05dd1c9be/charset_normalizer-3.4.7-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bb6d88045545b26da47aa879dd4a89a71d1dce0f0e549b1abcb31dfe4a8eac49", size = 218468, upload-time = "2026-04-02T09:26:40.17Z" }, - { url = "https://files.pythonhosted.org/packages/86/eb/890922a8b03a568ca2f336c36585a4713c55d4d67bf0f0c78924be6315ca/charset_normalizer-3.4.7-cp312-cp312-win32.whl", hash = "sha256:2257141f39fe65a3fdf38aeccae4b953e5f3b3324f4ff0daf9f15b8518666a2c", size = 148460, upload-time = "2026-04-02T09:26:41.416Z" }, - { url = "https://files.pythonhosted.org/packages/35/d9/0e7dffa06c5ab081f75b1b786f0aefc88365825dfcd0ac544bdb7b2b6853/charset_normalizer-3.4.7-cp312-cp312-win_amd64.whl", hash = "sha256:5ed6ab538499c8644b8a3e18debabcd7ce684f3fa91cf867521a7a0279cab2d6", size = 159330, upload-time = "2026-04-02T09:26:42.554Z" }, - { url = "https://files.pythonhosted.org/packages/9e/5d/481bcc2a7c88ea6b0878c299547843b2521ccbc40980cb406267088bc701/charset_normalizer-3.4.7-cp312-cp312-win_arm64.whl", hash = "sha256:56be790f86bfb2c98fb742ce566dfb4816e5a83384616ab59c49e0604d49c51d", size = 147828, upload-time = "2026-04-02T09:26:44.075Z" }, - { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" }, - { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" }, - { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" }, - { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" }, - { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" }, - { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" }, - { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" }, - { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" }, - { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" }, - { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" }, - { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" }, - { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" }, - { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" }, - { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" }, - { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" }, - { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" }, - { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" }, - { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" }, - { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" }, - { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" }, - { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" }, - { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" }, - { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" }, - { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" }, - { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" }, - { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" }, - { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" }, - { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" }, - { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" }, - { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" }, - { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" }, - { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" }, - { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" }, - { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" }, - { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" }, - { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" }, - { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" }, - { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" }, - { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" }, - { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" }, - { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" }, - { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" }, - { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" }, - { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" }, - { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" }, - { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" }, +version = "3.4.9" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ad/81/8e983840c6e5b93b33c2ba81aa3d52c2e42f0e9a690ce7607a2e61da4a5c/charset_normalizer-3.4.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd6280cf040f233bd7d3407b743b4b4c74f70e8e1c4199cb112a62c941c0772a", size = 322240, upload-time = "2026-07-07T14:32:36.236Z" }, + { url = "https://files.pythonhosted.org/packages/de/d1/b4319dc3229d8272fba305e206fc0a148e2de8d4087917ce62ae6382f359/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa99adc8f081b475a12843953db36831eaf83ec33eb46a90629ca6a5de45a616", size = 216475, upload-time = "2026-07-07T14:32:38.142Z" }, + { url = "https://files.pythonhosted.org/packages/80/33/6c99c1b3e6b8bf730e1bc809b9a2608f224145069114c479a2e9e1494346/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c1225416b463483160e4af85d5fc3a9690ccb53fd4b1865a6437825f5ede3209", size = 238670, upload-time = "2026-07-07T14:32:39.658Z" }, + { url = "https://files.pythonhosted.org/packages/7f/f4/ffbb83546e1f198ecc70ecd372b65cf2b50f9068b380abd67640f17a8e18/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:16d10d789dd9bcca1173c95af82c58433122564b7bc39385124be735a35cbe99", size = 233476, upload-time = "2026-07-07T14:32:41.155Z" }, + { url = "https://files.pythonhosted.org/packages/e8/5f/b98b8da398637b551e427e7be922bdec19177dc54d6811dcdaa503f23aac/charset_normalizer-3.4.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9bb41182d93ea91f60b4bc8fbf4c820c69ef8a12ab2d917f3f1834f1acad07e8", size = 223817, upload-time = "2026-07-07T14:32:42.592Z" }, + { url = "https://files.pythonhosted.org/packages/36/31/a276bb2e66243072a3fd06fdcab9cbb61a305b02143d70d2bda21d888fa8/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_armv7l.whl", hash = "sha256:bcf74c1df76758a395bf0af608c04c82257523f55c9868b334f06270d0f2112b", size = 207974, upload-time = "2026-07-07T14:32:44.258Z" }, + { url = "https://files.pythonhosted.org/packages/5e/be/7ee4453d7e88dfbc4104ccd34900b9f2c7c17dac22881865fe0e82424a25/charset_normalizer-3.4.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b5314963fce9b0b12743891de876e724997864ee22aa496f903f426c7e2fa5b2", size = 221655, upload-time = "2026-07-07T14:32:45.64Z" }, + { url = "https://files.pythonhosted.org/packages/1d/85/181c652953eb5276d198f375b1dd641047392050098100a3a02d6534f657/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:e9701d0049d92c16703a42771b98d560b95248949f23f8cf7b4eddd201814fb9", size = 219229, upload-time = "2026-07-07T14:32:47.376Z" }, + { url = "https://files.pythonhosted.org/packages/0c/e7/aaf6da33fc9f4691cda8f7efbc9f69179d3d39ec8a4799baf273ee1d8db0/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_armv7l.whl", hash = "sha256:65a7ff3f705e57d392f7261b6d0550fe137c3019477431f1c355e0db0a7d3e15", size = 209704, upload-time = "2026-07-07T14:32:48.855Z" }, + { url = "https://files.pythonhosted.org/packages/63/01/f2fb3bd3a73be48b173ee0c6aa8d2497af97d5663a8c4c4b491de4c62f7a/charset_normalizer-3.4.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:79580094b00d1789d1f93ea55bc43cb2f611910c72235b7657f3482ddcc1b22d", size = 226243, upload-time = "2026-07-07T14:32:50.239Z" }, + { url = "https://files.pythonhosted.org/packages/c4/02/c57a22739fe05246b0b5783b3bfb6afaac4eebb46f3ececdfb2f048f780e/charset_normalizer-3.4.9-cp310-cp310-win32.whl", hash = "sha256:432786d3561e69aeeae6c7e8648964ce0ad05736120135601f87ac26b9c83381", size = 150935, upload-time = "2026-07-07T14:32:51.676Z" }, + { url = "https://files.pythonhosted.org/packages/37/8d/ca39a7559a4797505530d084fd3a49a2c959efbbbff146302fb7be4e3b35/charset_normalizer-3.4.9-cp310-cp310-win_amd64.whl", hash = "sha256:8c041122946b7ba21bb32c45b1aa57b1be35527690aeb3c5c234521085632eee", size = 162314, upload-time = "2026-07-07T14:32:53.193Z" }, + { url = "https://files.pythonhosted.org/packages/01/da/a44bd7a13d426e69e4894557106cd58669097bfad4a8681123b618fbfc5d/charset_normalizer-3.4.9-cp310-cp310-win_arm64.whl", hash = "sha256:375b83ed0aecfce76c16d198fbc21f3b11b337d68662bea0a995046682a11419", size = 153075, upload-time = "2026-07-07T14:32:54.554Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, + { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, + { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, + { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, + { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, + { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, + { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, + { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, + { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, + { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, + { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, + { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, + { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, + { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, + { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, + { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, + { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, + { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, + { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, + { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, + { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, + { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, + { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, + { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, + { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, + { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, + { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, + { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, + { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, + { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, + { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, + { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, + { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, + { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, + { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, + { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, + { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, + { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, + { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, + { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, + { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, + { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, + { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, + { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, + { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, + { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, + { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, + { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, + { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, + { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, + { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, + { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, + { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, + { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, + { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, + { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, + { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, + { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, + { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, + { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, + { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, + { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, ] [[package]] @@ -344,6 +346,7 @@ dependencies = [ { name = "pygments" }, { name = "rich" }, { name = "tomli", marker = "python_full_version < '3.11'" }, + { name = "tomlkit" }, ] [package.optional-dependencies] @@ -363,12 +366,17 @@ coverage-xml = [ ] dev = [ { name = "build" }, + { name = "jsonschema" }, { name = "mypy" }, { name = "pre-commit" }, { name = "pytest" }, { name = "pytest-cov" }, + { name = "pyyaml" }, { name = "ruff" }, { name = "twine" }, + { name = "ty" }, + { name = "types-jsonschema" }, + { name = "types-pyyaml" }, ] mcp = [ { name = "httpx" }, @@ -400,7 +408,8 @@ requires-dist = [ { name = "fastembed", marker = "extra == 'semantic-local'", specifier = ">=0.8.0,<0.9" }, { name = "hdbscan", marker = "extra == 'analytics'", specifier = ">=0.8.0" }, { name = "httpx", marker = "extra == 'mcp'", specifier = ">=0.27.1,<1" }, - { name = "lancedb", marker = "extra == 'analytics'", specifier = ">=0.33.0" }, + { name = "jsonschema", marker = "extra == 'dev'", specifier = ">=4.23.0" }, + { name = "lancedb", marker = "extra == 'analytics'", specifier = ">=0.34.0" }, { name = "lancedb", marker = "extra == 'semantic-lancedb'", specifier = ">=0.33.0" }, { name = "lancedb", marker = "extra == 'semantic-local'", specifier = ">=0.33.0" }, { name = "llvmlite", marker = "python_full_version < '3.14' and extra == 'analytics'", specifier = ">=0.44.0,<0.48" }, @@ -416,12 +425,17 @@ requires-dist = [ { name = "pynndescent", marker = "python_full_version < '3.14' and extra == 'analytics'", specifier = ">=0.6.0" }, { name = "pytest", marker = "extra == 'dev'", specifier = ">=9.1.0" }, { name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=7.1.0" }, + { name = "pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.2" }, { name = "rich", specifier = ">=15.0.0" }, { name = "ruff", marker = "extra == 'dev'", specifier = ">=0.15.20" }, { name = "scikit-learn", marker = "extra == 'analytics'", specifier = ">=1.5.0" }, { name = "tiktoken", marker = "extra == 'token-bench'", specifier = ">=0.13.0" }, { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=2.0.1" }, + { name = "tomlkit", specifier = ">=0.13.2" }, { name = "twine", marker = "extra == 'dev'", specifier = ">=6.2.0" }, + { name = "ty", marker = "extra == 'dev'", specifier = ">=0.0.56" }, + { name = "types-jsonschema", marker = "extra == 'dev'", specifier = ">=4.23.0.20250516" }, + { name = "types-pyyaml", marker = "extra == 'dev'", specifier = ">=6.0.12.20250915" }, { name = "umap-learn", marker = "python_full_version < '3.14' and extra == 'analytics'", specifier = ">=0.5.6" }, ] provides-extras = ["mcp", "token-bench", "coverage-xml", "semantic-lancedb", "semantic-fastembed", "semantic-local", "analytics", "perf", "dev"] @@ -449,100 +463,100 @@ wheels = [ [[package]] name = "coverage" -version = "7.14.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/b4/91/0a7c28934e50d8ac9a7b117712d176f2953c3170bccced5eaacfa3e96175/coverage-7.14.3.tar.gz", hash = "sha256:1a7563a443f3d53fdeb040ec8c9f7466aed7ca3dc5891aa09d3ca3625fa4387f", size = 924398, upload-time = "2026-06-22T23:10:25.584Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ec/bd/b01188f0de73ee8b6597cf20c63fccd898ad31405772f15165cb61a62c00/coverage-7.14.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:360bec1f58e7243e3405d3bdf7a1a8115aa9b448d54dc7cd6f7b7e0e9406b62e", size = 220378, upload-time = "2026-06-22T23:07:38.925Z" }, - { url = "https://files.pythonhosted.org/packages/33/eb/f7aa3cb46500b709070c8d12335446971ec8b8c2ea155fea05d2000b4b1f/coverage-7.14.3-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ed68faa5e85de2f3e400bc3f122e5c82735a58c8bb24b9f63a2215954ba17b2d", size = 220895, upload-time = "2026-06-22T23:07:41.536Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c0/b41b8499fc9060ca40ad2a197d301155be1ead398f0f0bfdb27b2b4a660f/coverage-7.14.3-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:830c1fca669c572dec37ce9c838224ee45aac5be0f6961edf871e82e49d6537c", size = 247631, upload-time = "2026-06-22T23:07:43.244Z" }, - { url = "https://files.pythonhosted.org/packages/da/bb/e9ecea1307c6a549c223842cccbd5d55193cc27b82f26338782d4355047c/coverage-7.14.3-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a64caee2193563601dbaaa55fe2dcf597debef04a2f8f1fa8a07aa4bb7ac7a1e", size = 249460, upload-time = "2026-06-22T23:07:45.147Z" }, - { url = "https://files.pythonhosted.org/packages/59/cb/3821542809b7b726296fd364ed1c23d10a5770f1469957010c3b4bc5d408/coverage-7.14.3-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0096fd7559178f0cc9cf088f2dbd2a02ef85bacaa69732c633517286b4494610", size = 251324, upload-time = "2026-06-22T23:07:46.875Z" }, - { url = "https://files.pythonhosted.org/packages/76/27/f34f66f0ff152189ccc7b3f0582cf7909e239cb3b8c214362ed2149719b8/coverage-7.14.3-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6197e5a00183c11a8ce7c6abd18be1a9189fd8399084ffc95196f4f0db4f2137", size = 253237, upload-time = "2026-06-22T23:07:48.352Z" }, - { url = "https://files.pythonhosted.org/packages/22/81/aa363fa95d14fc892bd5de80edadc8d7cce584a0f6376f6336e492618e67/coverage-7.14.3-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7dfe427045520d6abca33687dfef767b4f635015893a1816c5decb12eb72ce18", size = 248344, upload-time = "2026-06-22T23:07:49.896Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/dc8a149441a3fea611cbbaf46bb12099adbe08f69903df1794581b0504b8/coverage-7.14.3-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:9a3f142070eb7b82fc4085a55d887396f9c4e21250bccebe2ba22502c45b9647", size = 249365, upload-time = "2026-06-22T23:07:51.464Z" }, - { url = "https://files.pythonhosted.org/packages/f8/a2/0004127deee122e020be24a4d86ce72fa14ae28198811b945aabf91293b5/coverage-7.14.3-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:64b2055bb6e0dc945af35cdeceb3633e6ed9273475ef3af85592410fd6803803", size = 247369, upload-time = "2026-06-22T23:07:53.064Z" }, - { url = "https://files.pythonhosted.org/packages/1e/72/3654c004f4df4f0c5a9643d9abaed5b26e5d3c1d0ecabe788786cb425efa/coverage-7.14.3-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:1551b4caac3e3ec9f2bfcec6bf3776e01c0edbdd2e240431a50ca1a1aac72c27", size = 251182, upload-time = "2026-06-22T23:07:54.789Z" }, - { url = "https://files.pythonhosted.org/packages/a5/2f/7bdcdf1e7c4d0632648852768063c25582a0a747bb5f8036a04e211e7eb7/coverage-7.14.3-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:583d50d59142f8549470bd6390471d0fe8b8c8d69d6a0f28ac71e05380cef640", size = 247639, upload-time = "2026-06-22T23:07:56.254Z" }, - { url = "https://files.pythonhosted.org/packages/03/dc/0e01b071f69021d262a51ce39345dd6bc194465db0acfc7b34fd89e6b787/coverage-7.14.3-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:e0bb8a6bc7015efdf8a928753b25da1b9ca2d6f24ef04d2ee0688e486f32aae7", size = 248242, upload-time = "2026-06-22T23:07:57.692Z" }, - { url = "https://files.pythonhosted.org/packages/1c/51/08279e6ebe3479bf705db5fdc1a968e44ba1567e4cbc567f76b45f5e646e/coverage-7.14.3-cp310-cp310-win32.whl", hash = "sha256:d48400185564042287dc487c1f016a3397f18ab4f4c5d5ec36edc218f7ffa35b", size = 222431, upload-time = "2026-06-22T23:07:59.094Z" }, - { url = "https://files.pythonhosted.org/packages/40/2f/5c56670781fee5722ef0c415a74750c9a033bfacdb9d07b1493a0308108d/coverage-7.14.3-cp310-cp310-win_amd64.whl", hash = "sha256:eadea7aba74e40adee867a8c0eec17b820b061d308a4b014f7a0e118c2b0aa61", size = 223059, upload-time = "2026-06-22T23:08:00.662Z" }, - { url = "https://files.pythonhosted.org/packages/f1/24/efb17eb94018dd3415d0e8a76a4786a866e8964aa9c50f033399d23939c2/coverage-7.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:e574801e1d643561594aa021206c46d80b257e9853087090ba97bed8b0a509d3", size = 220501, upload-time = "2026-06-22T23:08:02.182Z" }, - { url = "https://files.pythonhosted.org/packages/76/93/32f1bfca6cdd34259c8af42820a034b7a28dfb44969a13ed38c17e0ba5b0/coverage-7.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:f82b6bb7d75a2613e85d07cefa3a8c973d0544a8993337f6e2728e4a1e94c305", size = 221008, upload-time = "2026-06-22T23:08:03.701Z" }, - { url = "https://files.pythonhosted.org/packages/eb/88/0d0f974855ff905d15a64f7873d00bdc4182e2736267486c6634f4af293c/coverage-7.14.3-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:a2335ea5fed26af2e831094964fa3f8fae60b45f7e37fcc2d3b615b2add3ad87", size = 251420, upload-time = "2026-06-22T23:08:05.211Z" }, - { url = "https://files.pythonhosted.org/packages/39/7f/117dd2ec65e4140576f8ef991d88220f9b806769f7a8c20e0550c0f924e2/coverage-7.14.3-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fbb8c3a98e779013786ae01d229662aeacbc77100efbd3f2f245219ace5af700", size = 253331, upload-time = "2026-06-22T23:08:06.672Z" }, - { url = "https://files.pythonhosted.org/packages/87/55/f0bd6d6538e3f16829fb8a44b6c0d2fe9da638bbfdd6a20f8b5da8f4fa81/coverage-7.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac082660de8f429ba0ea363595abb838998570b9a7546777c60f413ab902bbde", size = 255441, upload-time = "2026-06-22T23:08:08.208Z" }, - { url = "https://files.pythonhosted.org/packages/1e/98/aa71f7879019c846a8a9662579ea4484b0202cf1e252ffeed647075e7eca/coverage-7.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8ac012839ff7e396030f1e94e10553a431d14e4de2ab65cb3acb72bbd5628ca2", size = 257398, upload-time = "2026-06-22T23:08:09.749Z" }, - { url = "https://files.pythonhosted.org/packages/f3/4f/5fd367e59844190f5965015d7bee899e67a89d13eb2760118479bf836f2f/coverage-7.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5952f8c1bda2a5347154450379316e6dfa4d934d62ca35f6784451e6f55074fb", size = 251558, upload-time = "2026-06-22T23:08:11.37Z" }, - { url = "https://files.pythonhosted.org/packages/8f/de/5383a6ee5a6376701fe07d980fa8e4a66c0c377fead16712720340d701a3/coverage-7.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8cf0f2509acb4619e2471a1951089054dd58ebea7a912066d2ea56dd4c24ca4a", size = 253134, upload-time = "2026-06-22T23:08:13.04Z" }, - { url = "https://files.pythonhosted.org/packages/01/99/09542b1a99f788e3daec7f0fadc288821e71aca9ea298d51bfa1ba79fed5/coverage-7.14.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:2e41fd3aab806770008279a93879b0924b16247e09ab537c043d08bbca53b4ab", size = 251195, upload-time = "2026-06-22T23:08:14.606Z" }, - { url = "https://files.pythonhosted.org/packages/02/9d/722fe8c13f0fbb064491b9e8656e56a606286792e5068c47ca1042e773e8/coverage-7.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:f0a47095963cfe054e0df178daca95aec21e680d6076da807c3add28dfe920f7", size = 254959, upload-time = "2026-06-22T23:08:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/fb/58/943627179ff1d82da9e54d0a5b0bb907bb19cf19515599ccd921de50b469/coverage-7.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:a090cbf9521e78ffdb2fcf448b72902afe9f5923ff6a12d5c0d0120200348af9", size = 250914, upload-time = "2026-06-22T23:08:18.03Z" }, - { url = "https://files.pythonhosted.org/packages/a5/d4/803efcbf9ae5567454a0c71e983589529448e2704ee0da2dc0163d482f18/coverage-7.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4d310baf69a4fbe8a098ce727e4808a34866ac718a6f759ae659cbd3221358bc", size = 251824, upload-time = "2026-06-22T23:08:19.704Z" }, - { url = "https://files.pythonhosted.org/packages/32/79/3f78ea9563132746eed5cecb75d2e576f9d8fec45a47242b5ae0950b82a3/coverage-7.14.3-cp311-cp311-win32.whl", hash = "sha256:74fdd718d88fe144f4579b8747873a07ec3f04cb837d5faec5a25d9e22fa31a8", size = 222594, upload-time = "2026-06-22T23:08:21.311Z" }, - { url = "https://files.pythonhosted.org/packages/85/22/9ebbc5a2ab42ac5d0eea1f48648629e1de9bbe41ec243ed6b93d55a5a53f/coverage-7.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:cc96aa922e21d4bc5d5ed3c915cef27dfcbc13686f47d5e378d647fbfba655a2", size = 223073, upload-time = "2026-06-22T23:08:23.318Z" }, - { url = "https://files.pythonhosted.org/packages/71/af/69d5fcc16cb555153f99cec5467922f226be0369f7335a9506856d2a7bd0/coverage-7.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:c66f9f9d4f1e9712eb9b1de5310f881d4e2188cfcba5065e1a8490f38687f2c4", size = 222617, upload-time = "2026-06-22T23:08:25.054Z" }, - { url = "https://files.pythonhosted.org/packages/bd/b0/8a911f6ffe6974dac4df95b468ab9a2899d0e59f0f99a489afeec39f00bc/coverage-7.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3d74ff26299c4879ce3a4d826f9d3d4d556fd285fde7bbce3c0ef5a8ab1cec24", size = 220672, upload-time = "2026-06-22T23:08:26.621Z" }, - { url = "https://files.pythonhosted.org/packages/36/16/0fc0cb52538783dbbae0934b834f5a58fd5354380ee6cad4a07b15dc845d/coverage-7.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:96150a9cf3468ea20f0bc5d0e21b3df8972c31480ef90fa7614b773cc6429665", size = 221035, upload-time = "2026-06-22T23:08:28.372Z" }, - { url = "https://files.pythonhosted.org/packages/77/e2/421ccfbb48335ac49e93301478cf5d623b0c2bf1c0cadd8e2b2fc6c0c710/coverage-7.14.3-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:27d07a46500ba23515b838dbcf52512026af04090755cf6cc64166d88c9b9a1a", size = 252540, upload-time = "2026-06-22T23:08:30.226Z" }, - { url = "https://files.pythonhosted.org/packages/06/c2/05b8c890097c61a7f4406b35396b997a635200ded0339eda83dfbe526c5f/coverage-7.14.3-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:621e13c6108234d7960aaf5762ab5c3c00f33c30c15af06dcbff0c73bf112727", size = 255274, upload-time = "2026-06-22T23:08:31.876Z" }, - { url = "https://files.pythonhosted.org/packages/dc/be/b6d9efe447f8ba3c3c854195f326bd64c54b907d936cd2fdebf8767ec72e/coverage-7.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4b60ca6d8af70473491a15a343cbabab2e8f9ea66a4376e81c7aa24876a6f977", size = 256389, upload-time = "2026-06-22T23:08:33.843Z" }, - { url = "https://files.pythonhosted.org/packages/d4/3c/f26e50acc429e608bc534ac06f0a3c169019c798178ec5e9de3dbc0df9c9/coverage-7.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c90a7cdd5e380e1ce02f19792e2ac2fbfbf177e35a27e69fd3e873b30d895c0c", size = 258648, upload-time = "2026-06-22T23:08:35.481Z" }, - { url = "https://files.pythonhosted.org/packages/9e/a2/01c1fabf816c8e1dae197e258edf878a3d3ddc86fbda34b76e5794277d8f/coverage-7.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5d788e5fd55347eef06ca0732c77d04a264de67e8ff24631270cdff3767a60cf", size = 252949, upload-time = "2026-06-22T23:08:37.562Z" }, - { url = "https://files.pythonhosted.org/packages/89/c6/941166dd79c31fd44a13063780ae8d552eee0089a0a0930b9bdb7df554ed/coverage-7.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:62c7f79db2851c95ef020e5d28b97afde3daf9f7febcd35b53e05638f729063f", size = 254310, upload-time = "2026-06-22T23:08:39.174Z" }, - { url = "https://files.pythonhosted.org/packages/10/31/80b1fd028201a961033ce95be3cd1e39e521b3762e6b4a1ac1616cb291e7/coverage-7.14.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:90f7608aeb5d9b60b523b9fb2a4ee1973867cc4865a3f26fe6c7577073b70205", size = 252453, upload-time = "2026-06-22T23:08:40.84Z" }, - { url = "https://files.pythonhosted.org/packages/5f/85/c3d9addd94c4b524f3f4af0232075f5fe7170ce99a1386edff803e5934db/coverage-7.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:1e3b91f9c4740aeb571ecf82e5e8d8e4ab62d34fcb5a5d4e5baa38c6f7d2857c", size = 256522, upload-time = "2026-06-22T23:08:42.494Z" }, - { url = "https://files.pythonhosted.org/packages/91/14/e5a0575f73795af3a7a9ae13dadf812e17d32422896839987dc3f86947e1/coverage-7.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c946099774a7699de03cbd0ff0a64e21aed4525eed9d959adde4afe6d15758ef", size = 252023, upload-time = "2026-06-22T23:08:44.243Z" }, - { url = "https://files.pythonhosted.org/packages/38/9b/9652ee531937ce3b8a63a8896885b2b4a2d56adc30e53c9540c666286d88/coverage-7.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:16b206e521feb8b7133a45754643dead0538489cf8b783b90cf5f4e3299625fd", size = 253893, upload-time = "2026-06-22T23:08:46.113Z" }, - { url = "https://files.pythonhosted.org/packages/b1/05/42678841c8c38e4b08bdfc48269f5a16dfbf5806000fe6a89b4cece3c691/coverage-7.14.3-cp312-cp312-win32.whl", hash = "sha256:ea3169c7116eb6cdf7608c6c7da9ecfcb3da40688e3a510fac2d1d2bafd6dc35", size = 222734, upload-time = "2026-06-22T23:08:47.858Z" }, - { url = "https://files.pythonhosted.org/packages/df/87/07a4fcee55177a25f1b52331a8e92cf4f2c53b1a9c75ce2981fd59c684ad/coverage-7.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:7ea52fc08f007bcc494d4bb3df3851e95843d881860ba38fe2c64dc100db5e7d", size = 223266, upload-time = "2026-06-22T23:08:49.494Z" }, - { url = "https://files.pythonhosted.org/packages/aa/34/2b8b66a989282ea7b370beb49f50bab29470dc30bb0b03935b6b802782f7/coverage-7.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:8cec0ad652ec57790970d817490105bd917d783c2f7b38d6b58a0ca312e1a336", size = 222655, upload-time = "2026-06-22T23:08:51.766Z" }, - { url = "https://files.pythonhosted.org/packages/a9/83/7fefbf5df23ed2b7f489907564a7b34b9b07098128e12e0fdfa92626e456/coverage-7.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:47968988b367990ae4ab17523790c38cd125e02c6bfd379b6022be2d40bdc38c", size = 220699, upload-time = "2026-06-22T23:08:53.522Z" }, - { url = "https://files.pythonhosted.org/packages/31/e6/38c3653ff6d56d704b29241362387ca824e38e15b76fdcb7096538195790/coverage-7.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:0ee68f5c34812780f3a7063382c0a9fcbb99985b7ddcdcaa626e4f3fb2e0783a", size = 221068, upload-time = "2026-06-22T23:08:55.571Z" }, - { url = "https://files.pythonhosted.org/packages/20/86/4f5c45d51c5cd10a128933f0fd235393c9146abbfd2ce2dfa68b3267ead3/coverage-7.14.3-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:fa9e5c6857a7e80fa22ace5cf3550ae392bbfc322f1d8dd2d2d5a8be38cec027", size = 252060, upload-time = "2026-06-22T23:08:57.464Z" }, - { url = "https://files.pythonhosted.org/packages/82/50/dfce42eff2cecabcd5a9bbad5489449c87db3415f408d23ffee417ce01f6/coverage-7.14.3-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:98a0859b0e98e43e1178a9402e19c8127766b14f7109a374d976e5a62c0e5c73", size = 254657, upload-time = "2026-06-22T23:08:59.453Z" }, - { url = "https://files.pythonhosted.org/packages/ba/d2/639ceb1bc8038fd0d66768278d5dc22df3391918b8278c2a21aa2602a531/coverage-7.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69918344541ed9c8368566c2adc03c0e33d4550d7faa87d1b35e49b6a3286ea9", size = 255892, upload-time = "2026-06-22T23:09:01.291Z" }, - { url = "https://files.pythonhosted.org/packages/8b/96/002094a10e113512500dc1e10430a449417e17b0f90f7d496bcb820208b7/coverage-7.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b7f300ac92cd4b570724c8ffbbd0c130fee298d2447f41d5a3abf58976fae1de", size = 258026, upload-time = "2026-06-22T23:09:03.017Z" }, - { url = "https://files.pythonhosted.org/packages/0b/ec/286a5d2fad9c4bee59bd724feeb7d5bf8303c6c9200b51d1dd945a9c72b0/coverage-7.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:11a7ec9f97ab950f4c5af62229befc7faf208fdbc0116d3902d7e306cf2c5abd", size = 252285, upload-time = "2026-06-22T23:09:04.773Z" }, - { url = "https://files.pythonhosted.org/packages/d9/7d/a17753a0b12dd48d0d50f5fab079ad99d3be1eac790494d89f3a417ca0b9/coverage-7.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a571bd889cd36c5922ce8e42e059f9d37d02301531d11374afa4c87a578625d5", size = 254023, upload-time = "2026-06-22T23:09:06.513Z" }, - { url = "https://files.pythonhosted.org/packages/86/ef/a76c6ceba6a2c313f905310abf2701d534cada22d372db11731831e9e209/coverage-7.14.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:de76caefc8deabb0dd1678b6a980be97d14c8d87e213ac194dbf8b09e96d63fb", size = 251989, upload-time = "2026-06-22T23:09:08.382Z" }, - { url = "https://files.pythonhosted.org/packages/d9/39/353013a75fec0fb49f7553519f9d52b4441e902e5178c93f38eb6c07cedb/coverage-7.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d20a15c622194234161535459affa8f7905830391c9ccfa060d495dbfe3a1c7f", size = 256144, upload-time = "2026-06-22T23:09:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/29/0e/613878555d734def11c5b20a2701a15cb3781b9e9ea749da27c5f436e928/coverage-7.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:b488bd4b23397db62e7a9459129d01ff06a846582a732efd24834b24a6ada498", size = 251808, upload-time = "2026-06-22T23:09:12.057Z" }, - { url = "https://files.pythonhosted.org/packages/af/76/359c058c9cfdcf1e8b107663881225b03b364a320017eda24a2a66e55102/coverage-7.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6a3693b4153394d265f44fb855fdc80e72403024d4d6f91c4871b334d028e4e0", size = 253579, upload-time = "2026-06-22T23:09:13.858Z" }, - { url = "https://files.pythonhosted.org/packages/1d/d9/4ba2f060933a30ebe363cef9f67a365b0a317e580c0d5d9169d56a73ef1c/coverage-7.14.3-cp313-cp313-win32.whl", hash = "sha256:338b19131ab1a6b767b462bfcbaa692e7ae22f24463e39d49b02a83410ff6b37", size = 222741, upload-time = "2026-06-22T23:09:15.636Z" }, - { url = "https://files.pythonhosted.org/packages/76/e8/196ebc25d8f34c06d43a6e9c8513c9266ef8dbf3b5672beb1a00cf5e29fa/coverage-7.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:b3d77f7f196abdef7e01415de1bce09f216189e83e58159cfeef2b92d0464994", size = 223283, upload-time = "2026-06-22T23:09:17.478Z" }, - { url = "https://files.pythonhosted.org/packages/7c/af/51d2aac6417523a286f10fb25f09eb9518a84df9f1151e93ff6871f34849/coverage-7.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:e6230e688c7c3e65cedd41a774eb4ec221adc6bfee13768231015b702d5e4150", size = 222678, upload-time = "2026-06-22T23:09:19.7Z" }, - { url = "https://files.pythonhosted.org/packages/61/56/14e3b97facbfa1304dd19e676e26599ad359f04714bed32f7f1c5a88efdc/coverage-7.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:605ab2b566a22bd94834529d66d295c364aba84afd3e5498285c7a524017b1fc", size = 220741, upload-time = "2026-06-22T23:09:21.616Z" }, - { url = "https://files.pythonhosted.org/packages/12/1d/db378b5cca433b90b893f26dab728b280ddd89f272a1fdfed4aeaa05c686/coverage-7.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a3c2134809e80fac091bfed18a6991b5a5eb5df5ae32b17ac4f4f99864b73dd7", size = 221068, upload-time = "2026-06-22T23:09:23.452Z" }, - { url = "https://files.pythonhosted.org/packages/47/f0/3f8421b20d9c4fcd39be9a8ca3c3fda8bc204b44efbd09fede153afd3e2f/coverage-7.14.3-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:c02efd507227bde9969cab0db8f48890eb3b5dcad6afac57a4792df4133543ce", size = 252117, upload-time = "2026-06-22T23:09:25.458Z" }, - { url = "https://files.pythonhosted.org/packages/27/ca/59ea35fb99743549ec8b37eff141ece4431fea590c89e536ed8032ef45cf/coverage-7.14.3-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:1bb93c2aa61d2a5b38f1526546d95cf4132cb681e541a337bf8dfd092be816e5", size = 254622, upload-time = "2026-06-22T23:09:27.523Z" }, - { url = "https://files.pythonhosted.org/packages/c8/25/ec6de51ae7493b92a1cf74d1b763121c29636759167e2a593ba4db5881e4/coverage-7.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f502e948e03e866538048bba081c075caaa62e5bda6ea5b7432e45f587eb462a", size = 255968, upload-time = "2026-06-22T23:09:29.43Z" }, - { url = "https://files.pythonhosted.org/packages/5d/05/c8bfc77823f42b4664fb25842f13b567022f6f84a4c83c8ecbb16734b7cb/coverage-7.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9973ef2463f8e6cfb61a6324126bb3e17d67a85f22f58d856e583ea2e3ca6501", size = 258284, upload-time = "2026-06-22T23:09:31.397Z" }, - { url = "https://files.pythonhosted.org/packages/f6/15/1d1b242027124a32b26ef01f82018b8c4ef34ef174aa6aeba7b1eeef48e8/coverage-7.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9be4e7d4c5ca0427889f8f9d614bd630c2be741b1de7699bca3b2b6c0e41003e", size = 252143, upload-time = "2026-06-22T23:09:33.256Z" }, - { url = "https://files.pythonhosted.org/packages/74/b6/d2a9842fd2a5d7d27f1ac851c043a734a494ad75402c5331db3da79ed691/coverage-7.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a574912f3bde4b0619f6e97d01aa590b70998859244793769eb3a6df78ee56d3", size = 253976, upload-time = "2026-06-22T23:09:35.351Z" }, - { url = "https://files.pythonhosted.org/packages/fd/30/e1600ddf7e226db5558bb5323d2186fff00f505c4b764643ec89ce5d8175/coverage-7.14.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:e343fb086c9cd780b38622fea7c369acd64c1a0724312149b5d769c387a2b1f5", size = 251942, upload-time = "2026-06-22T23:09:37.313Z" }, - { url = "https://files.pythonhosted.org/packages/d9/2c/9159de64f9dd648e324328d588a44cfab1e331eb5259ce1141afe2a92dfb/coverage-7.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:3c68df8e61f1e09633fefc7538297145623957a048534368c9d212782aa5e845", size = 256220, upload-time = "2026-06-22T23:09:39.165Z" }, - { url = "https://files.pythonhosted.org/packages/91/67/b7f536cc2c124f48e91b22fbb741d2261f4e3d310faf6f76007f47566e5d/coverage-7.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3e5b550a128419373c2f6cec28a244207013ef15f5cbcff6a5ca09d1dfaaf027", size = 251756, upload-time = "2026-06-22T23:09:41.056Z" }, - { url = "https://files.pythonhosted.org/packages/dd/ec/f3718038e2d4860c715a55428377ca7f6c75872caf98cabd982e1d76967d/coverage-7.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2bfc4dd0a912329eccc7484a7d0b2a38032b38c40663b1e1ac595f10c457954b", size = 253413, upload-time = "2026-06-22T23:09:43.306Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a5/91f11efeef89b3cc9b30461128db15b0511ef813ab889a7b7ab636b3a497/coverage-7.14.3-cp314-cp314-win32.whl", hash = "sha256:0423d64c013057a06e70f070f073cec4b0cbc7d2b27f3c7007292f2ff1d52965", size = 222946, upload-time = "2026-06-22T23:09:45.261Z" }, - { url = "https://files.pythonhosted.org/packages/58/fd/98ac9f524d9ec378de831c034dbdeb544ca7ef7d2d9c9996daf232a037fd/coverage-7.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:92c22e19ce64ca3f2ad751f16f14df1468b4c231bd6af97185063a9c292a0cb3", size = 223436, upload-time = "2026-06-22T23:09:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/b4/a0/7cd612d650a772a0ae80144443406bf61981c896c3d57c9e6e79fb2cdbd1/coverage-7.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:41de778bd41780586e2b04912079c73089ab5d839624e28db3bdb26de638da92", size = 222861, upload-time = "2026-06-22T23:09:49.384Z" }, - { url = "https://files.pythonhosted.org/packages/55/57/017353fab573779c0d00448e47d102edd36c792f7b6f233a4d89a7a08384/coverage-7.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8427f370ca67db4c975d2a26acfc0e5783ca0b52444dbc50278ace0f35445949", size = 221474, upload-time = "2026-06-22T23:09:51.417Z" }, - { url = "https://files.pythonhosted.org/packages/69/92/90cf1f1a5c468a9c1b7ba2716e0e205293ad9b02f5f573a6de4318b15ba1/coverage-7.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d8e88f335544a47e22ae2e45b344772925ec65166555c958720d5ed971880891", size = 221738, upload-time = "2026-06-22T23:09:53.487Z" }, - { url = "https://files.pythonhosted.org/packages/a4/c0/4df964fa539f8399fd7679c09c472d73744de334686fd3f01e3a2465ce4e/coverage-7.14.3-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:beaab199b9e5ceaf5a225e16a9d4df136f2a1eae0a5c20de1e277c8a5225f388", size = 263101, upload-time = "2026-06-22T23:09:55.895Z" }, - { url = "https://files.pythonhosted.org/packages/06/76/e5d33b2576ae3bf2be2058cd1cae57774b61e400f2c3c58f3783dc2ffb4a/coverage-7.14.3-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3ff255799f5a1676c71c1c32ec01fd043aa09d57b3d95764b24992757184784", size = 265225, upload-time = "2026-06-22T23:09:57.904Z" }, - { url = "https://files.pythonhosted.org/packages/61/d2/e52419afe391a39ba27fdefaf0737d8e34bf03faef6ab3b3006545bbd0d0/coverage-7.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:878832eaac515b62decfa76965aed558775f86bf1fc8cca76993c0c84ae31aed", size = 267643, upload-time = "2026-06-22T23:09:59.938Z" }, - { url = "https://files.pythonhosted.org/packages/58/7a/f2625d8d5006b6b20fba5afaef00b24a763fe96476ea798a3076cbc1f84e/coverage-7.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:611e62cb9386096d81b63e0a05330750268617231e7bd598e1fe77482a2c58a5", size = 268762, upload-time = "2026-06-22T23:10:01.943Z" }, - { url = "https://files.pythonhosted.org/packages/7d/bf/908024006bba57127354d74e938954b9c3cd765cc2e0412dc9c37b415cda/coverage-7.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:02c41de2a88011b893050fc9830267d927a50a215f7ad5ec17349db7090ccf26", size = 262208, upload-time = "2026-06-22T23:10:03.954Z" }, - { url = "https://files.pythonhosted.org/packages/34/a0/d4f9296441b909817442fdb26bd77a698f08272ec683a7394b00eb2e47a0/coverage-7.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:526ce9721116af23b1065089f0b75046fe521e7772ab94b641cd66b7a0421889", size = 265096, upload-time = "2026-06-22T23:10:05.936Z" }, - { url = "https://files.pythonhosted.org/packages/e8/da/4ae4f3f4e477b56a4ce1e5c48a35eff38a94b50130ce5bdc897024741cfc/coverage-7.14.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:e4ed44705ca4bead6fc977a8b741f2145608289b33c8a9b42a95d0f15aedbf4d", size = 262699, upload-time = "2026-06-22T23:10:07.973Z" }, - { url = "https://files.pythonhosted.org/packages/d8/7a/6927148073ff32856d78baa77b4ddc07a9be7e90020f9db0661c4ca523a1/coverage-7.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:2415902f385a23dcc4ccd26e0ba803249a169af6a930c003a4c715eeb9a5444e", size = 266433, upload-time = "2026-06-22T23:10:10.145Z" }, - { url = "https://files.pythonhosted.org/packages/f7/a7/774f658dbe9c4c3f5daa86a87e0459ac3832e4e3cc67affe078547f727b9/coverage-7.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:b75ee850fc2d7c831e883220c445b035f2224de2ba6103f1e56dbd237ab913f7", size = 261547, upload-time = "2026-06-22T23:10:12.191Z" }, - { url = "https://files.pythonhosted.org/packages/3d/14/a0c18c0376c43cbf973f43ef6ca20019c950597180e6396232f7b6a27102/coverage-7.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:dc9b4e35e7c3920e925ba7f14886fd5fbe481232754624e832ddba66c7535635", size = 263859, upload-time = "2026-06-22T23:10:14.492Z" }, - { url = "https://files.pythonhosted.org/packages/10/ac/43a3d0f460af524b131a6191805bc5d18b806ab4e828fbf82e8c8c3af446/coverage-7.14.3-cp314-cp314t-win32.whl", hash = "sha256:7b27c822a8161afbe48e99f1adfb098d270ae7e0f7d7b0555ce110529bdb69cc", size = 223250, upload-time = "2026-06-22T23:10:16.758Z" }, - { url = "https://files.pythonhosted.org/packages/3f/5f/d5e5c56b0712e96ce8f69fe7dbf229ff938b437bc50862743c8a0d2cea84/coverage-7.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:39e1dbbb6ff2c338e0196a482558a792a1de3aa64261196f5cdb3da016ad9cda", size = 224082, upload-time = "2026-06-22T23:10:19.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/35/947cbd5be1d3bcbbdc43d6791de8a56c6501903311d42915ae06a82815f0/coverage-7.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:68520c90babfa2d560eca6d497921ed3a4f469623bd709733124491b2aa8ef3f", size = 223400, upload-time = "2026-06-22T23:10:21.24Z" }, - { url = "https://files.pythonhosted.org/packages/eb/e3/a0aa32bfa3a081951f60a23bc0e7b512891ef0eecda1153cf1d8ba36c6b1/coverage-7.14.3-py3-none-any.whl", hash = "sha256:fb7e18afb6e903c1a92401a2f0501ac277dca527bb9ca6fe1f691a8a0026a0e8", size = 212469, upload-time = "2026-06-22T23:10:23.405Z" }, +version = "7.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/cc/8b/adeb62ea8951f13c4c7fef2e7a85e1a06b499c8d8237ea589d496029e53f/coverage-7.15.0.tar.gz", hash = "sha256:9ac3fe7a1435986463eaa8ee253ae2f2a268709ba4ae5c7dd1f52a05391ad78f", size = 925362, upload-time = "2026-07-02T13:10:50.535Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/97/c52dc440c390b6cfa87be9432b141a956e2d56d9b9f5fc8bd71c5f471722/coverage-7.15.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:50913d4bf5ddafa6ca3693da5e4dd833dd1b772e0283c99ca7f7d287db67331a", size = 220539, upload-time = "2026-07-02T13:08:19.252Z" }, + { url = "https://files.pythonhosted.org/packages/3f/26/602de8c2aec7e2e3e99ebfb8e04ba65598f746275396eea5f6794ff4673f/coverage-7.15.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:359e141ccd33893ce3f1ad5525f8b96083003677c82182e5907d62d4ea5799fc", size = 221058, upload-time = "2026-07-02T13:08:21.013Z" }, + { url = "https://files.pythonhosted.org/packages/fc/13/ebab0743138891c1d646d61e247ec29639afcbb6c4e1905e6a0f0c75291a/coverage-7.15.0-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:3200b6204935f928c64b2ca1f923ab8c1acb7c9de45ec61569711b34d25cccaf", size = 247797, upload-time = "2026-07-02T13:08:22.474Z" }, + { url = "https://files.pythonhosted.org/packages/d3/b7/b6ffb9e042aa48dc4144a8a65529affaec8dca0685309353614a2a7386ad/coverage-7.15.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:be616bf61346883b2cfdc5178669647e03531d81ab761a7e378558b7e8bcb628", size = 249626, upload-time = "2026-07-02T13:08:23.803Z" }, + { url = "https://files.pythonhosted.org/packages/9c/06/243ff05b652333d8e3d060c11223efc2723b19cacf6605e433fa686ab5d4/coverage-7.15.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cc7bafc3fe1059463a8fdd97ca79972d6e2bf819d775c7d54991b5b1971201d6", size = 251493, upload-time = "2026-07-02T13:08:25.397Z" }, + { url = "https://files.pythonhosted.org/packages/d3/2b/867faa17030a806114dae388b32a3fa929d8cd4bf39226fbc11f6e6bb705/coverage-7.15.0-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:b713aa7fcf325a01d4184d848acb46fd84f78fdb0978470c636b23a06a753d91", size = 253406, upload-time = "2026-07-02T13:08:26.842Z" }, + { url = "https://files.pythonhosted.org/packages/94/c0/d789ce18f6605afc4895db75723424be2ef494282f77f61d8e5832923183/coverage-7.15.0-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e38e6fba2d56652fdfaf0231f8f78aeb805234a912de25dc291ee5cce5b8faa4", size = 248512, upload-time = "2026-07-02T13:08:28.398Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b6/b2673c30739f4a2e06649a0a38ad8b093c4d865462dc7bab0e9524a2c3b1/coverage-7.15.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:884499f42e382675be80770391983b90e0c0c774d87dbeeebf5f991cf6612b20", size = 249532, upload-time = "2026-07-02T13:08:29.731Z" }, + { url = "https://files.pythonhosted.org/packages/3c/2e/acd79e9a41beabee92b623afe4f30b549916f48566271475f2907e752828/coverage-7.15.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:840481b12e083dbcbafab14794a8781a958edf327c8d3d70b4eee42f9b8253aa", size = 247537, upload-time = "2026-07-02T13:08:31.173Z" }, + { url = "https://files.pythonhosted.org/packages/12/d4/2d301c4d1b3238d7c88b70ab9d13fd53ed9505662a7ff1b46ba1e2e4e3c3/coverage-7.15.0-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:276646e9481703d09f854f3b2f018f24e19fd7049ae670a92570043eb97203b1", size = 251348, upload-time = "2026-07-02T13:08:32.63Z" }, + { url = "https://files.pythonhosted.org/packages/35/bb/c67708b2bc00f32e12805ec23d5fa677a0a51652f449341a89f9d6b1b715/coverage-7.15.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:4de4b4d3f5545aa6c60dc4efd9c63b5b5dcc3bf00fe83146b2bdfffb8f6613bd", size = 247806, upload-time = "2026-07-02T13:08:33.931Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/57c4f653c47a6e917748f8938e389e72fbcae44e3643cd906664f0477a13/coverage-7.15.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:5c504097b2a89b1e85bc6070d920df77daec701337e3aeef2c17775a5dd0ca90", size = 248410, upload-time = "2026-07-02T13:08:35.189Z" }, + { url = "https://files.pythonhosted.org/packages/6c/94/bb083041aef828903668f134273f319f2bd49224962875359c52faa5497f/coverage-7.15.0-cp310-cp310-win32.whl", hash = "sha256:f6e80ed91f98316e86b9c137206b04b2bcfbffccbdff49bd2eb09dddb1cf14e0", size = 222588, upload-time = "2026-07-02T13:08:36.486Z" }, + { url = "https://files.pythonhosted.org/packages/ef/94/a09d8ee618956f626741b0734854bac4425a00e10c0565f5abca64e7e751/coverage-7.15.0-cp310-cp310-win_amd64.whl", hash = "sha256:b3b3e22030f3f6f5e01a5ce69936552a5c0f6992b7698777377b99041961031f", size = 223214, upload-time = "2026-07-02T13:08:37.885Z" }, + { url = "https://files.pythonhosted.org/packages/ae/23/82e910835ef4b8391047025e1d53aa48d66029f444eb8b25373c849bf503/coverage-7.15.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:003fff99412ea848c0aaebcc78ed2b6ce7d8a1227ed17e68470672770b78a02a", size = 220662, upload-time = "2026-07-02T13:08:39.205Z" }, + { url = "https://files.pythonhosted.org/packages/6d/0d/c7b213dde2f1579de5231062b386d8413f79c11667eb58c39319b25991da/coverage-7.15.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:5cbd804bf2784ce7b45114516050f346ecd50f960c4bb630a7ee9e1d78fa2118", size = 221168, upload-time = "2026-07-02T13:08:40.471Z" }, + { url = "https://files.pythonhosted.org/packages/33/77/d000aeedfac085088337b3c7becdad328474b1f8a9e4c9368a0c99605d68/coverage-7.15.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:8773e15c23305b58882a4611fb9b2755977eae0dc2e515366a1b6c98866cc4c2", size = 251587, upload-time = "2026-07-02T13:08:42.033Z" }, + { url = "https://files.pythonhosted.org/packages/cc/e0/86787c56b9df17afd370d5e293515dd4d9a107a561d13054873eefad8ecc/coverage-7.15.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f50e40081494c1dc4239ebb202014cbcc3306ea96fb6302a34c8cc0967fc5ae8", size = 253497, upload-time = "2026-07-02T13:08:43.387Z" }, + { url = "https://files.pythonhosted.org/packages/3f/02/181bc917359299c07dead6270f94e411151c8b60cec905c33499da69afe6/coverage-7.15.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:daf96f37f5fc3a7b6c6da862eb4aee61c426bd63da236ed4a73ef0e503b4bca5", size = 255607, upload-time = "2026-07-02T13:08:44.897Z" }, + { url = "https://files.pythonhosted.org/packages/b9/35/ca5e7427699913da6788c4f910e73ab16c5f4b59ec5d3a999dce2a45112f/coverage-7.15.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:51aa20f6ae2788fd197747766edf4cd8234fd9423309b934257fa6b21a592723", size = 257563, upload-time = "2026-07-02T13:08:46.334Z" }, + { url = "https://files.pythonhosted.org/packages/0b/4d/b8220bacc2fc3c4e9078e27c32e99fb411479a4718a72bdd00036a9891c8/coverage-7.15.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:03d1f922757662eb7af586e77834792274cff776bc7b1d1a0b66a49ea9d84735", size = 251726, upload-time = "2026-07-02T13:08:47.941Z" }, + { url = "https://files.pythonhosted.org/packages/c4/e4/2e145da1991d72189b9c3cf7eca05c716ee7080d099aaea6757cfc7df008/coverage-7.15.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a6d6acc9a7666245e6133dd15144ca038a85a9cd5026bb06d6bbae9e77440dc9", size = 253301, upload-time = "2026-07-02T13:08:49.5Z" }, + { url = "https://files.pythonhosted.org/packages/72/28/d2c841d698bf762e481f08bd4839d370246b6d9b61dab085a7b20b201a08/coverage-7.15.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1ac2c4c27c7df851dc9a017c2d7de00b69147e84ba3d96f37a530b0b6fb51035", size = 251361, upload-time = "2026-07-02T13:08:51.304Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ed/55d9ffde994fba3897c0c783f77a7d053b0c18787f6892ed5b0aed73f469/coverage-7.15.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b761a1d504fd4bd1f20f418753964dca9f5862a511fc854dac58296b3b223671", size = 255129, upload-time = "2026-07-02T13:08:52.661Z" }, + { url = "https://files.pythonhosted.org/packages/1d/c0/ecbf33b8c460ea2718aeb813e2df8140d0370e5f67261c31524ceb0a2a8d/coverage-7.15.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:e43b045e11c16e897895758ae90e4a90cf99e93d58549e2f90c0e2272e155695", size = 251081, upload-time = "2026-07-02T13:08:54.188Z" }, + { url = "https://files.pythonhosted.org/packages/a9/de/fb87b4261f54448dd2b9504ef19a58be42cef0d9520595fbfe1219b15234/coverage-7.15.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:589b54513e901739f4b4582c705ce96b80c96f57641b1464607e2367a270e540", size = 251988, upload-time = "2026-07-02T13:08:55.726Z" }, + { url = "https://files.pythonhosted.org/packages/df/27/3494d5f291b9a4cb868f73c11221a8bd2d5bd761a8f9acea61ff57128dd1/coverage-7.15.0-cp311-cp311-win32.whl", hash = "sha256:106781b8482749162d0b47056937ba0933508e5d9447f65a5e7d5c422f0d6bb4", size = 222754, upload-time = "2026-07-02T13:08:57.091Z" }, + { url = "https://files.pythonhosted.org/packages/2a/ee/cd4847ebc9be6a9c0123d763645a6f1f3be6b8c58c962706368b79cbac07/coverage-7.15.0-cp311-cp311-win_amd64.whl", hash = "sha256:821e92b3631d762a339695824cadbbc73020354eba2a23a551a99ad34938fbe6", size = 223225, upload-time = "2026-07-02T13:08:58.594Z" }, + { url = "https://files.pythonhosted.org/packages/57/37/5011581aa7f2be498b97dcc7c9902192442a42f4f9a748aeadb3d6506b42/coverage-7.15.0-cp311-cp311-win_arm64.whl", hash = "sha256:309990eb5fb8014b9f67cb211f7fd41876ec8a88a88d3ae76de0ed1d611e3640", size = 222774, upload-time = "2026-07-02T13:09:00.074Z" }, + { url = "https://files.pythonhosted.org/packages/2a/74/fd4c0901137c4f8d81a76ada99e43c65163b4c94a02ece107a4ec0c6b615/coverage-7.15.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b75ee5e8cb7575636ac598719b4307ac529ec8fcd79608a35c3cd4d4dada812d", size = 220838, upload-time = "2026-07-02T13:09:02.084Z" }, + { url = "https://files.pythonhosted.org/packages/0f/2e/2347583467bd7f0402635101a916961915cc68fce652cd0db5f173ea04fc/coverage-7.15.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffb31267816b93b075302248cc1737506081b4f163df4401e9df1a6424aafabe", size = 221197, upload-time = "2026-07-02T13:09:03.617Z" }, + { url = "https://files.pythonhosted.org/packages/f0/17/99fa688541ae1d6e84543a0e544f83de0c944815b63e9e7b1ed411d15036/coverage-7.15.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:e4d0bb73455bf97ab243a8f12c37c686ccf1c13bb614b7b85f1d062f06f42b2c", size = 252705, upload-time = "2026-07-02T13:09:05.059Z" }, + { url = "https://files.pythonhosted.org/packages/fb/02/6a95a5cd83b74839017ef9cf48d2d8c9ae60af919e17a3f336e6f9f1b7bd/coverage-7.15.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:20d9ccc4ebd0edc434d86dfd2a1dd2a8efa6b6b3073d0485a394fee86459ebb4", size = 255441, upload-time = "2026-07-02T13:09:06.559Z" }, + { url = "https://files.pythonhosted.org/packages/67/f2/406f6c57d600f68185942422c4c00f1a3255d60aee6e5fd961425cd9987e/coverage-7.15.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20c8a976c365c8cb12f0cbd099508772ea41fb5fa80657a8506df0e11bd278c5", size = 256556, upload-time = "2026-07-02T13:09:08.197Z" }, + { url = "https://files.pythonhosted.org/packages/74/8e/d3fa48489c15ecdec1ba48fd61f68798555dddd2f6716f9ad42adeb1a2a9/coverage-7.15.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f948fd5ba1b9cbca91f0ae08b4c1ce2b139509149a435e2585d056d57d70bf01", size = 258815, upload-time = "2026-07-02T13:09:09.691Z" }, + { url = "https://files.pythonhosted.org/packages/47/2e/2d40ddd110462c6a2769677cf7f1c119a52b45f568978fc6c98e4cc0dd0f/coverage-7.15.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f58185f06edf6ad68ec9fb155d63ef650c82f3fbd7e1770e2867751fb13158f4", size = 253117, upload-time = "2026-07-02T13:09:11.212Z" }, + { url = "https://files.pythonhosted.org/packages/51/c0/310782f0d7c3cb2b5ac05ba8d205fe91f24a36f6bf3256098f1782181c38/coverage-7.15.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:02adc79a920c73c647c5d117f55747df7f2de94571884758ce8bc58e04f0a796", size = 254475, upload-time = "2026-07-02T13:09:13.029Z" }, + { url = "https://files.pythonhosted.org/packages/86/f7/702da6c275f8ae6ade423d2877243122932c9b27f5403003b9ef8c927d12/coverage-7.15.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:6eb7c300fbed667fd6e3588eba71c1904cdb06110ca6fdf908c26bdd88b8e382", size = 252619, upload-time = "2026-07-02T13:09:14.699Z" }, + { url = "https://files.pythonhosted.org/packages/fb/84/c5b15a7e5ecba4e56218d772d99fe80a63e63f8d11f12783723a6005ab45/coverage-7.15.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:b5fb23fa2de9dce1f5c36c09066d8fcda16cd96e8e26686caa2d7cb9b567d65c", size = 256689, upload-time = "2026-07-02T13:09:16.103Z" }, + { url = "https://files.pythonhosted.org/packages/95/2f/c8b07559b57701230c61b23a953858c052890c12ef568d81780c6c46e92e/coverage-7.15.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cec79341dbe6281484024979976d0c7f22beae08b4a254655decd25d42cbe766", size = 252189, upload-time = "2026-07-02T13:09:17.828Z" }, + { url = "https://files.pythonhosted.org/packages/6b/80/6d2f049dd3fd3dbfd60b62ba6b2162a04009e2c002ce70b24cf3878dec7a/coverage-7.15.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:6c664c5444b1d970b1b2a450e21fb19ee5c9cfdf151ded2dda37260031cca0da", size = 254059, upload-time = "2026-07-02T13:09:19.304Z" }, + { url = "https://files.pythonhosted.org/packages/ce/92/b0287a2c42031d25c628f815f89a3cd9f8268ee78bb1252c9356cda1c689/coverage-7.15.0-cp312-cp312-win32.whl", hash = "sha256:5f764a3fa339bde6b3aa97657f5a6a3a9451e4a5b4ea98a2892c773a43525f77", size = 222893, upload-time = "2026-07-02T13:09:20.812Z" }, + { url = "https://files.pythonhosted.org/packages/a9/69/e34c481915fecb499b3146975061dac528752e37706edc1804f32c822469/coverage-7.15.0-cp312-cp312-win_amd64.whl", hash = "sha256:52f9a4d2c4c56c8848bc2f524916698354b0211488b38c49ad9ae54f6cafbff6", size = 223429, upload-time = "2026-07-02T13:09:22.315Z" }, + { url = "https://files.pythonhosted.org/packages/fe/98/6e878f0b571d32684ef3f38d7c03db241ca5b82a5da8a5391596a8f209c4/coverage-7.15.0-cp312-cp312-win_arm64.whl", hash = "sha256:31e5c3e70c85307ea35a12964e2e40f56ca2ee4b1c8c721ccf4609d17071080b", size = 222810, upload-time = "2026-07-02T13:09:23.812Z" }, + { url = "https://files.pythonhosted.org/packages/76/04/145a3748098bcc86b631a85408d2c3dc5c104e0bd86d605468239b25b6c4/coverage-7.15.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5be4caf3b28836f078abe700f8944dac4a65d78f16d6c600c89cb624e5535782", size = 220863, upload-time = "2026-07-02T13:09:25.371Z" }, + { url = "https://files.pythonhosted.org/packages/a4/5c/4ed55708fed2c64b63c9bc5715daef670872202101938869b7fe5d5fbb8f/coverage-7.15.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:dd58ad1404704303ca8d4f4b8a1095e7cbc7040ef17a66df1e6619aa10176430", size = 221230, upload-time = "2026-07-02T13:09:26.897Z" }, + { url = "https://files.pythonhosted.org/packages/7b/19/3a80b97d3b2a5c77a01ae359c6bed20c13738fe3d9380f08616d4fec0281/coverage-7.15.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bbcbb317c2e5ded5b21104af81c29f391be2af98d065693ffbe8d23949b948e5", size = 252227, upload-time = "2026-07-02T13:09:28.543Z" }, + { url = "https://files.pythonhosted.org/packages/a1/fa/b70062750686bd7da454da27927622f48bbac6990ac7a4c4a4653e7b0036/coverage-7.15.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:27f31ecb458da3f859aab3f15ada871eb7a7768807d88df4a9f186bb17737970", size = 254823, upload-time = "2026-07-02T13:09:30.177Z" }, + { url = "https://files.pythonhosted.org/packages/a9/09/dad6a75a2e561b9dc5086a8c5257a7591d584246f67e23e70d2995b89ab6/coverage-7.15.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fb759be317fdc62e0f56bffdf61cfcb45c7761ad6b71e3e583e71a67ae753c", size = 256059, upload-time = "2026-07-02T13:09:31.979Z" }, + { url = "https://files.pythonhosted.org/packages/e6/e7/b5d2941fa9564573d44b693a871ff3156f0c42cbefe977a09fa7fdc59971/coverage-7.15.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d5cf007add5ab4bb8fa9f4c77e3732127c9e6cad501d7db43355fbfafca0be84", size = 258190, upload-time = "2026-07-02T13:09:34.035Z" }, + { url = "https://files.pythonhosted.org/packages/7c/1d/8e895bcde3c57ccd46d896dda5f2b3d5df761a1b0c6c9d450d175dedc632/coverage-7.15.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cc78d9843bd576fbe2118248258d485e968dc535f95ed504a7b0867ba9b51389", size = 252456, upload-time = "2026-07-02T13:09:35.765Z" }, + { url = "https://files.pythonhosted.org/packages/14/4c/f6997da343ddeb959be82c3b05322793f92c071ad45f7cb8a96336e2dd5f/coverage-7.15.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a263060f1de0b4b74b4e089c2a70b8003b3781c733329a9c8fd54995328f9950", size = 254192, upload-time = "2026-07-02T13:09:37.445Z" }, + { url = "https://files.pythonhosted.org/packages/17/27/a0bc09d032267b9da89d95a2d874cfbef2a5aebbf0e87cf7aba221d79a99/coverage-7.15.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:c48decf16e0dfd5b049c7d5e82200c23c08126719142998d4f172444e3d0529e", size = 252153, upload-time = "2026-07-02T13:09:39.422Z" }, + { url = "https://files.pythonhosted.org/packages/54/c0/77fc233d9fba07b244c40948c53fe27308b8f21732fb3417f87fbd6fd992/coverage-7.15.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:08fb028000ed0aaa0a4cbdfbb98be7cb42f370db973fbbb469733505ab20e13e", size = 256310, upload-time = "2026-07-02T13:09:41.006Z" }, + { url = "https://files.pythonhosted.org/packages/d5/24/601cecfb5825becacb8d45219a018a3b55b9dbaec624efdb0ea249d08be2/coverage-7.15.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:fb7dc0c3b7d8a1077abea0b8546ebc5e26d6ef6ecefc2f0f5ad2b8a53bdad837", size = 251974, upload-time = "2026-07-02T13:09:42.733Z" }, + { url = "https://files.pythonhosted.org/packages/47/1e/6f45e5a5b3d5484318d368702af6716b5ab8913b0428bec981a562fcf296/coverage-7.15.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6cb3602054ccbe9f0d8c2dc04bbeba90d5719236e2cd06e042ddd6d3fc7b6e37", size = 253745, upload-time = "2026-07-02T13:09:44.376Z" }, + { url = "https://files.pythonhosted.org/packages/8e/db/4df027a77bd11d0e527f44c53557c76e54ad027413d0304252ea3a78d67e/coverage-7.15.0-cp313-cp313-win32.whl", hash = "sha256:0bf781da64326b677be344df505171435b6f58716108606621d5d27d964fff8b", size = 222902, upload-time = "2026-07-02T13:09:46.122Z" }, + { url = "https://files.pythonhosted.org/packages/a0/10/0355894d34e231f2c5449e71287e81a50793a325df2e2b027b7bcd9dfd19/coverage-7.15.0-cp313-cp313-win_amd64.whl", hash = "sha256:2c57a275078ee3fa185f83e400f765bc764a549de66d99b47881645cbd4ea629", size = 223444, upload-time = "2026-07-02T13:09:47.687Z" }, + { url = "https://files.pythonhosted.org/packages/06/ef/bb725f263befaaff851203ab338e68af15e195d7f7b5f323162532d9b6a8/coverage-7.15.0-cp313-cp313-win_arm64.whl", hash = "sha256:3812c61afc6685c7999b39320779ab8f43b7a3081fdb0def39976e56fbdb9a21", size = 222839, upload-time = "2026-07-02T13:09:49.717Z" }, + { url = "https://files.pythonhosted.org/packages/4f/9c/1e3ca54f72a3185ece06c58d871099898c48f0ed6430d17b6ab75f0d180a/coverage-7.15.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:41cb79af843222e11da87127ad0ecbfa878abadd0f770a4a99391a27d3887324", size = 220906, upload-time = "2026-07-02T13:09:51.339Z" }, + { url = "https://files.pythonhosted.org/packages/09/37/f718613d83b274880382f6b67e78f3802549ae39b0b3e65ae5b5974df56e/coverage-7.15.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7d2008989ef8fe54188d3f3bfa2e3099b025af11e90a6a1b9e7dc433d04263d8", size = 221239, upload-time = "2026-07-02T13:09:53.138Z" }, + { url = "https://files.pythonhosted.org/packages/a7/ce/22bae91e0b75445f68d365c7643ed0aa4880bbf77450ee74ca65bdae53a7/coverage-7.15.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:769e8ece11a596315ebf5aa7ec383aeeed016c091d2bf6363ffb996d41529092", size = 252286, upload-time = "2026-07-02T13:09:54.996Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1e/bec5e32aa508615d9d7a2790effb25fb4dc28606e995816afe400b25ece3/coverage-7.15.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:65a6b6164ee5c39e2f3803f314292d6c61a607ba7fee253d1e03c42dc3903502", size = 254789, upload-time = "2026-07-02T13:09:56.678Z" }, + { url = "https://files.pythonhosted.org/packages/17/29/0e865435b4354e4a7c03b1b7920046d31d0a273d55decefea27e011cb9bf/coverage-7.15.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:75128817f95a5c45bb01d65fd2d8b9cb54bbe03d81608fb70e3e14b437ad56c2", size = 256135, upload-time = "2026-07-02T13:09:58.343Z" }, + { url = "https://files.pythonhosted.org/packages/84/ff/33a870b58a13325d62fc0a6c8f01fa0ff667cef60c7498e2382a147dfa18/coverage-7.15.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9887bb428fe2d4cd4bee89bac1a6c9932f484afd5b36fbd4ff6ea5f825bb1f5e", size = 258449, upload-time = "2026-07-02T13:10:00.057Z" }, + { url = "https://files.pythonhosted.org/packages/18/7b/6fffe596bf3ddba8462758d02c5dad730fd91055a6634aa2e4226229181a/coverage-7.15.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0bfc0be1f702042207a93a00523b1065ee1fe951e96edf311581c0bbc2e34888", size = 252313, upload-time = "2026-07-02T13:10:01.946Z" }, + { url = "https://files.pythonhosted.org/packages/58/1b/11468dd6c1676ab831a70cb9a8d4e198e8607fa0b7220ab918b73fe9bfbd/coverage-7.15.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f64627d55def5a43282d70e08396672692f77e4da610a5bb8bb4060b432b6859", size = 254142, upload-time = "2026-07-02T13:10:04.065Z" }, + { url = "https://files.pythonhosted.org/packages/79/41/29328e21d16b1b95092c30dd700e08cf915bd3734f836df8f3bdb0e8fa9f/coverage-7.15.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:2c6f0fa473003905c6d5bac328ee4eba9fbea654f15bc24b8a3274b23363fa99", size = 252108, upload-time = "2026-07-02T13:10:06.11Z" }, + { url = "https://files.pythonhosted.org/packages/9b/de/05ccfb990439655b35afbfd8e0d13fe66677565a7d4eb38c3f5ef2635e1c/coverage-7.15.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2bcf9afaf064172c6ec3c58a325a9957ad1178c05dd934e25f253321776e0676", size = 256385, upload-time = "2026-07-02T13:10:08.141Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/486828a3d2695ea7a2609f17ff572f6b01905e608379440a11da4b8dffbe/coverage-7.15.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:baf06bc987115d6fb938d403f7eab684a057766c490367999a2b71a6883110c6", size = 251923, upload-time = "2026-07-02T13:10:10.179Z" }, + { url = "https://files.pythonhosted.org/packages/18/c7/03582b6715f078e5e558354c87616d945b9894cda2dace8e4009b17035e4/coverage-7.15.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f0405f2ff97b1c4c0e782cb32e02f32369bcf2e6b618b591d67e1ea754575dfe", size = 253580, upload-time = "2026-07-02T13:10:12.052Z" }, + { url = "https://files.pythonhosted.org/packages/db/dc/9e578bbaf2ecb4959a81b7e7601ad8cca772cba2892e8d144cb749b4a71a/coverage-7.15.0-cp314-cp314-win32.whl", hash = "sha256:ab282853ed5fbd64bbb162f19cb8fcb7087187508a6374b4f9c34ec1577c4e8f", size = 223107, upload-time = "2026-07-02T13:10:13.994Z" }, + { url = "https://files.pythonhosted.org/packages/ae/3e/c8c3b75d8dbe0e35f7b0cc3ff5e949fc59500f70b21d0398813f66740664/coverage-7.15.0-cp314-cp314-win_amd64.whl", hash = "sha256:3bb3040e9f4bbe26fcb0cd7cc85ac63e630d3f3a9c74f027abf4caa27e706663", size = 223597, upload-time = "2026-07-02T13:10:15.906Z" }, + { url = "https://files.pythonhosted.org/packages/cd/bc/3cbc9fb036eb388519bccd521f783499c39b64256013fbc362782f196fe1/coverage-7.15.0-cp314-cp314-win_arm64.whl", hash = "sha256:346771144d34f7fa84ec28386f78e0f31653f33cf35e19d253d5b35f9e8201da", size = 223020, upload-time = "2026-07-02T13:10:17.844Z" }, + { url = "https://files.pythonhosted.org/packages/28/00/199c4a8d656dff63102577a056c0fce2ff6a79e40adac092fc986c49cbf1/coverage-7.15.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d34a010905fb6401324ba016b5da03d574967f7b21ce48ea41e66f0f1f95f641", size = 221638, upload-time = "2026-07-02T13:10:19.703Z" }, + { url = "https://files.pythonhosted.org/packages/ba/8e/9d0092c96a3d3a26951ecc7020826aa57bcb1b119ca81acbba996884ab13/coverage-7.15.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bb25d825d885ca8036795dacfc3924d33091fc76d71ebc99420c6b79e77d96fa", size = 221903, upload-time = "2026-07-02T13:10:21.514Z" }, + { url = "https://files.pythonhosted.org/packages/6d/b4/c0ca3028f42c9a08e51feb4561ef1192e5de99797cd1db5b04590c215bda/coverage-7.15.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:94c9686bfe8a9a6810297aecbd99beaa3445f9e8dc2f80b1382cca0d86b64461", size = 263267, upload-time = "2026-07-02T13:10:23.261Z" }, + { url = "https://files.pythonhosted.org/packages/5f/aa/a375e3846e5d3c013dc600b2a3231089055c73d77f5393dd2192a8d64da6/coverage-7.15.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9bd671c25f9d85f09d7ec481d0e43d5139f486c06a37139847a7ce569788af72", size = 265390, upload-time = "2026-07-02T13:10:25.152Z" }, + { url = "https://files.pythonhosted.org/packages/92/e1/5783cdabb797305e1c9e4809fea496d31834c51fa772514f73dc148bcfc9/coverage-7.15.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:110cbdf8d2e216577312cf06ccf85539c0e5a5420ef747e4a4719b5e483c88cd", size = 267811, upload-time = "2026-07-02T13:10:27.249Z" }, + { url = "https://files.pythonhosted.org/packages/85/31/96d8bbf58b8e9193bc8389574a91a0db48355ee98feb66aa6bf8d1b32eea/coverage-7.15.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2c5d4619214f1d9993e7b00a8600d14614b7e9d84e89507460b126aa5e6559e5", size = 268928, upload-time = "2026-07-02T13:10:29.242Z" }, + { url = "https://files.pythonhosted.org/packages/5e/7a/5294567e811a1cb7eda93140c628fa050d66189da28da320f93d1d815c73/coverage-7.15.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:781a704516e2d8346fbbd5be6c6f3412dd824785146528b3a01816f26c081007", size = 262378, upload-time = "2026-07-02T13:10:31.107Z" }, + { url = "https://files.pythonhosted.org/packages/69/3f/3f48538421f899f28946f90a3d272136a4686e1abf461cc9249a783ee0f3/coverage-7.15.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd4a1b44bcb65ee29e947ac92bbee04956df3a6bfc6143641bb6cae7ede00fc9", size = 265263, upload-time = "2026-07-02T13:10:32.942Z" }, + { url = "https://files.pythonhosted.org/packages/ce/d3/092df15efcab8a9c1467ee960eb8019bbad3f9300d115d89ea6195f369ff/coverage-7.15.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:0e4950c9d6d3e39c64c991814ff315e2d0b9cb8152363594212c9e55208c0a8f", size = 262866, upload-time = "2026-07-02T13:10:35.104Z" }, + { url = "https://files.pythonhosted.org/packages/e5/ab/0254d2b88665efb2c57ad368cc77ab5de3435bd8d5add4729c1b0e79431e/coverage-7.15.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:fe9c87ff42e5472d80d21704972e1f96e104a0a599d77c5e35db5a3c562e2571", size = 266599, upload-time = "2026-07-02T13:10:37.05Z" }, + { url = "https://files.pythonhosted.org/packages/a8/79/1cfa4023e489ce6fbc7be4a5d442dbc375edb4f4fda39a352cedb53263c2/coverage-7.15.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f00d5ae1dd2fe13fb8186e3e7d37bcbd8b25c0d764ff7d1b32cef9be058510a8", size = 261714, upload-time = "2026-07-02T13:10:38.966Z" }, + { url = "https://files.pythonhosted.org/packages/b7/eb/fee5c8665656be63f497418d410484637c438172568688e8ac92e06574e7/coverage-7.15.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:363ab38cc78b615f11c9cac3cf1d7eef950c18b9fdedfb9066f59461dcf84d68", size = 264025, upload-time = "2026-07-02T13:10:40.789Z" }, + { url = "https://files.pythonhosted.org/packages/ab/99/63005db722f91edc81abc16302f9cc2f6228c1679e46e15be9ae144b14d0/coverage-7.15.0-cp314-cp314t-win32.whl", hash = "sha256:54fd9c53a5fafff509195f1b6a3f9be615d8e8362a3629ff1de23d270c03c86b", size = 223413, upload-time = "2026-07-02T13:10:42.597Z" }, + { url = "https://files.pythonhosted.org/packages/c1/e8/2bc6181c4fb06f1a6b981eb85330cc57bfad7e3f710fc9c9d350013ba228/coverage-7.15.0-cp314-cp314t-win_amd64.whl", hash = "sha256:87b47553097ba185ed964866078e7e63adea9f5f51b5f39691c34f30afd21080", size = 224245, upload-time = "2026-07-02T13:10:44.47Z" }, + { url = "https://files.pythonhosted.org/packages/79/b8/4d959bf9cc45d0cfed2f4d35cafcab978cdb6ea02eb5100009cd740632a3/coverage-7.15.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aeefb2dd178fe7eee79f0ad25d75855cb35ee9ed472db2c5ea06f5b4fd00cec5", size = 223558, upload-time = "2026-07-02T13:10:46.368Z" }, + { url = "https://files.pythonhosted.org/packages/52/30/21b2ad45959cd50e909e02ebac1e30b4ceb7162e91c11d4c570223a458b7/coverage-7.15.0-py3-none-any.whl", hash = "sha256:56da6a4cbe8f7e9e80bd072ca9cefe67d7106a440a7ec06519ec6507ac94ad19", size = 212632, upload-time = "2026-07-02T13:10:48.641Z" }, ] [package.optional-dependencies] @@ -683,11 +697,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.29.4" +version = "3.29.6" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e6/dc/be6cbe99670cd6e4ad387123647cb08e0c32975e223f82551e914c5568a6/filelock-3.29.4.tar.gz", hash = "sha256:10cdb3656fc44541cdf30652a93fb10ec6b05325620eb316bd26893e4201538a", size = 63028, upload-time = "2026-06-13T16:12:00.744Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e0/c8/35bdf04fb30755e2ed758f877edf3eb4a243c2463d3a258cc28b18b7a6e2/filelock-3.29.6.tar.gz", hash = "sha256:895c532ef3f4ef04972b9446a8c4e2931a5c399ff3c4be4c9369f2639b80f793", size = 70301, upload-time = "2026-07-06T23:08:08.577Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/13/37/a065dc3bd6e49423a6532c642ca7378d3f467b1ef44c2800c937af7f9739/filelock-3.29.4-py3-none-any.whl", hash = "sha256:dac1648087d5115554850d113e7dd8c83ab2d38e3435dde2d4f163847e57b767", size = 42757, upload-time = "2026-06-13T16:11:59.582Z" }, + { url = "https://files.pythonhosted.org/packages/bb/49/7467c2946ccd9617f7da38187071bdc45bb9a95df51f4d63d6622432ce4e/filelock-3.29.6-py3-none-any.whl", hash = "sha256:14d5f5597d2e0c4dbd774cfb6d8132da1db44da83732aab679d54f7dcf97ab65", size = 45478, upload-time = "2026-07-06T23:08:07.197Z" }, ] [[package]] @@ -821,7 +835,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.21.0" +version = "1.22.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -832,12 +846,11 @@ dependencies = [ { name = "packaging" }, { name = "pyyaml" }, { name = "tqdm" }, - { name = "typer" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8f/77/ce3331f40cb2d021fe9b24c46c41e72faf74493621138e5eddac12bf5e1c/huggingface_hub-1.21.0.tar.gz", hash = "sha256:a44f222cd8f2f7c2eade30b5e7a04cac984a3235fa61ea87a0a5a31db77d561f", size = 861572, upload-time = "2026-06-25T13:09:26.356Z" } +sdist = { url = "https://files.pythonhosted.org/packages/77/ea/dc54b4dda5841cb3a7812a178695be776e7c15c597887c2ed892f17d015a/huggingface_hub-1.22.0.tar.gz", hash = "sha256:e2dfe5fe1ec3b87ba2709aa34555b23e3f3f6ad4d7255238e13ddb8348e6bbfa", size = 914232, upload-time = "2026-07-03T09:46:44.685Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/85/b505a99a133d9f99d21af182af416e9baef70bdeef019983479651e494c2/huggingface_hub-1.21.0-py3-none-any.whl", hash = "sha256:eadaa3678c512c82aea69e8675d90a184861e68de32f1105668628b4dce0e7cd", size = 721078, upload-time = "2026-06-25T13:09:24.402Z" }, + { url = "https://files.pythonhosted.org/packages/64/9c/a1a377265abd8b823a2c661c665028ccb6b9fba1ca9d08e52ff679c20ecd/huggingface_hub-1.22.0-py3-none-any.whl", hash = "sha256:b09e19309ae09ee0a71892701c4fe70af39ab4e00817321dc62f2289a977249b", size = 765085, upload-time = "2026-07-03T09:46:42.832Z" }, ] [[package]] @@ -966,7 +979,7 @@ dependencies = [ { name = "jsonschema-specifications" }, { name = "referencing" }, { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/b3/fc/e067678238fa451312d4c62bf6e6cf5ec56375422aee02f9cb5f909b3047/jsonschema-4.26.0.tar.gz", hash = "sha256:0c26707e2efad8aa1bfc5b7ce170f3fccc2e4918ff85989ba9ffa9facb2be326", size = 366583, upload-time = "2026-01-07T13:41:07.246Z" } wheels = [ @@ -1005,19 +1018,19 @@ wheels = [ [[package]] name = "lance-namespace" -version = "0.8.6" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "lance-namespace-urllib3-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/af/12/f7ab93b29be3edbf5fc3610714bf2d06088e7f4524bfb38dfd6852458b08/lance_namespace-0.8.6.tar.gz", hash = "sha256:18232e721c8188145f4ec9389cc2dfbeeabf54a619d94885ea1b3375bee9f4af", size = 11529, upload-time = "2026-06-12T17:36:41.651Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/81/4cf8d0412e1f37b2bfa70d0aeb9c7ae4ab73607534e44d60b55efb485306/lance_namespace-0.9.0.tar.gz", hash = "sha256:f738b641cc615b17323baa4eb47900f184688739ee3d2ea9fe39396b9588e53d", size = 11637, upload-time = "2026-07-01T07:42:41.78Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/1b/5b1668ee2dc8910965f390640359112a31157092fcf8e000b89c79b58708/lance_namespace-0.8.6-py3-none-any.whl", hash = "sha256:571eae34f9aad70e5b05020416c2860889b9ec82993ccd0eb015e7b39c3ea309", size = 13383, upload-time = "2026-06-12T17:36:43.456Z" }, + { url = "https://files.pythonhosted.org/packages/e1/fe/f38747c9610ade83dd9a99a0470b9432b6f21ce4e2bb5524edbe66f626fd/lance_namespace-0.9.0-py3-none-any.whl", hash = "sha256:f785ff10927e4ce0db69986576670fedd37f8a33521e8a4630c6be22db8061b2", size = 13501, upload-time = "2026-07-01T07:42:39.372Z" }, ] [[package]] name = "lance-namespace-urllib3-client" -version = "0.8.6" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "pydantic" }, @@ -1025,14 +1038,14 @@ dependencies = [ { name = "typing-extensions" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/80/fb224b4a89c1c1638cde949cb6cce6c3aca7759effbfea46a3d9c3960b21/lance_namespace_urllib3_client-0.8.6.tar.gz", hash = "sha256:b6fb1d306e74a7576e5309919020be744527de484a63dbf5eed10f8b368548df", size = 228772, upload-time = "2026-06-12T17:36:42.609Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/c3/32d0e2618549ace857c80a457e5915ef3e1145661baff876c8a5ec27be5b/lance_namespace_urllib3_client-0.9.0.tar.gz", hash = "sha256:cf796fa5307fa4dde91fe4bec2af28b90ba79191852d4394e8fe44276538e40f", size = 235805, upload-time = "2026-07-01T07:42:42.563Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c5/90/1e27de15cd1b16785a1c7312beb0a59e75c8344a815f600f58173a565bd1/lance_namespace_urllib3_client-0.8.6-py3-none-any.whl", hash = "sha256:9d78249c3fb15aa3d15d668f78f04a275af3d08d800a7027492f37996ac4968b", size = 369950, upload-time = "2026-06-12T17:36:40.438Z" }, + { url = "https://files.pythonhosted.org/packages/cc/ab/c8754da0a1efc817f8480100cfe12b7e04034df834759de8ecf02beff3cc/lance_namespace_urllib3_client-0.9.0-py3-none-any.whl", hash = "sha256:be819c8cffb1e460a3a504dbf52d1ca009560a48e7202b8c4279998e4adf9fe4", size = 405586, upload-time = "2026-07-01T07:42:40.503Z" }, ] [[package]] name = "lancedb" -version = "0.33.0" +version = "0.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "deprecation" }, @@ -1046,97 +1059,97 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/09/2f/d5a4b2a5bb1f800936c76a6d8a4daf127a86fcab621eeb70b574a5adc774/lancedb-0.33.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:d4eaf6fa7c2eac619208f1d396f4de635ee0f535673067118a31c1181575c48b", size = 48338115, upload-time = "2026-05-28T20:37:55.88Z" }, - { url = "https://files.pythonhosted.org/packages/07/12/31787b93a856b2c31382c7771dc22fb05575b70b87c9efe454269f4f0948/lancedb-0.33.0-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6c6c2402ed2744245ae76c4167c0461da0a7a80f1608e0ec491c1548ea2b4302", size = 51162262, upload-time = "2026-05-28T20:37:59.101Z" }, - { url = "https://files.pythonhosted.org/packages/49/b7/081cc29f8e06bf12191b99ab3fe702aceebdb0914476b821a8c0445cacc8/lancedb-0.33.0-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7ebf1ffad811e6254a93931a79489ba1f21f48564bdfa06abae846f5fcaaf3e8", size = 54381368, upload-time = "2026-05-28T20:38:02.2Z" }, - { url = "https://files.pythonhosted.org/packages/1c/bd/e0f4bd621f10ecf96a801b0166e87799ed7ca5a9dbabcef9a6c766a58ef3/lancedb-0.33.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:13da39f80adfea59e5831fe64e4166b2d70a2f843e6507bf644c4fe4c350087c", size = 51188986, upload-time = "2026-05-28T20:38:05.375Z" }, - { url = "https://files.pythonhosted.org/packages/d9/1a/a8647a432ac6aa59cdce1fc061a7050ea4278bcab364539b78af2ecf72d2/lancedb-0.33.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:21b712825f0a00225e8974a41352c4ea84b0899ef8c23b17f672fadc38bd8346", size = 54440958, upload-time = "2026-05-28T20:38:08.474Z" }, - { url = "https://files.pythonhosted.org/packages/08/6c/d0cc8da784cd7ed3b4940a5d1f3e7702e2d99a0a348ba81a376eed782810/lancedb-0.33.0-cp39-abi3-win_amd64.whl", hash = "sha256:4ba78c6202b0f6c2ce8edc7aa470e550d2da56271c7cbdd10428613f1f7126f9", size = 58751944, upload-time = "2026-05-28T20:38:11.549Z" }, + { url = "https://files.pythonhosted.org/packages/df/f7/5262b9aa593f790757163c0165ab0da1dda054758901bea7e4f02c9cb633/lancedb-0.34.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:c462f2e6f933cad659fd0179394eaab578acbc9151fe2ef41bc29b36ecca5058", size = 52654213, upload-time = "2026-07-02T17:13:31.102Z" }, + { url = "https://files.pythonhosted.org/packages/69/99/05ea0d32229ebea695193ff20c15d6ecae25785ad82a9d4723d98832a284/lancedb-0.34.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:48829e88e708947d0520454ab9e4f8efa35f3e3626469eadd3a6e061b89cb223", size = 55434501, upload-time = "2026-07-02T17:13:34.81Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4e/4325c13d5afa93c466428a5a0f168ad4d96f5eb4a77bbe7c5100d39c9897/lancedb-0.34.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:05ba8a5b58e064edfbe5be71b1abf2e411b4eaf295d1a173dcb1a55c5bfb5285", size = 58659359, upload-time = "2026-07-02T17:13:38.424Z" }, + { url = "https://files.pythonhosted.org/packages/d9/5d/8ca165f1386caf6c4d1c515afd52f345b66432264eecfdfb7fd33eefd9af/lancedb-0.34.0-cp39-abi3-win_amd64.whl", hash = "sha256:51cbc11808f9e3332819b9367c975b3a888541447a8e7bea09c57c852a279153", size = 63530726, upload-time = "2026-07-02T17:13:41.612Z" }, ] [[package]] name = "librt" -version = "0.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/40/08/9e7f6b5d2b5bed6ad055cdd5925f192bb403a51280f86b56554d9d0699a2/librt-0.11.0.tar.gz", hash = "sha256:075dc3ef4458a278e0195cbf6ac9d38808d9b906c5a6c7f7f79c3888276a3fb1", size = 200139, upload-time = "2026-05-10T18:17:25.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/83/10/37fd9e9ba96cb0bd742dfb20fc3d082e54bdbec759d7300df927f360ef07/librt-0.11.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:6e94ebfcfa2d5e9926d6c3b9aa4617ffc42a845b4321fb84021b872358c82a0f", size = 141706, upload-time = "2026-05-10T18:15:16.129Z" }, - { url = "https://files.pythonhosted.org/packages/cf/72/1b1466f358e4a0b728051f69bc27e67b432c6eaa2e05b88db49d3785ae0d/librt-0.11.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:ae627397a2f351560440d872d6f7c8dbb4072e57868e7b2fc5b8b430fe489d45", size = 142605, upload-time = "2026-05-10T18:15:18.148Z" }, - { url = "https://files.pythonhosted.org/packages/ca/85/ed26dd2f6bc9a0baf48306433e579e8d354d70b2bcb78134ed950a5d0e1e/librt-0.11.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dc329359321b67d24efdf4bc69012b0597001649544db662c001db5a0184794c", size = 476555, upload-time = "2026-05-10T18:15:19.569Z" }, - { url = "https://files.pythonhosted.org/packages/66/fe/11891191c0e0a3fd617724e891f6e67a71a7658974a892b9a9a97fdb2977/librt-0.11.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:7e82e642ab0f7608ce2fe53d76ca2280a9ee33a1b06556142c7c6fe80a86fc33", size = 468434, upload-time = "2026-05-10T18:15:20.87Z" }, - { url = "https://files.pythonhosted.org/packages/6f/50/5ec949d7f9ce1a07af903aa3e13abb98b717923bdead6e719b2f824ccc07/librt-0.11.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88145c15c67731d54283d135b03244028c750cc9edc334a96a4f5950ebdb2884", size = 496918, upload-time = "2026-05-10T18:15:22.616Z" }, - { url = "https://files.pythonhosted.org/packages/ea/c4/177336c7524e34875a38bf668e88b193a6723a4eb4045d07f74df6e1506c/librt-0.11.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9d36a51b3d93320b686588e27123f4995804dbf1bce81df78c02fc3c6eea9280", size = 490334, upload-time = "2026-05-10T18:15:24.2Z" }, - { url = "https://files.pythonhosted.org/packages/13/1f/da3112f7569eda3b49f9a2629bae1fe059812b6085df16c885f6454dff49/librt-0.11.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:d00f3ac06a2a8b246327f11e186a53a100a4d5c7ed52346367e5ec751d51586c", size = 511287, upload-time = "2026-05-10T18:15:26.226Z" }, - { url = "https://files.pythonhosted.org/packages/fa/94/03fec301522e172d105581431223be56b27594ff46440ebfbb658a3735d5/librt-0.11.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:461bbceede621f1ffb8839755f8663e886087ee7af16294cab7fb4d782c62eeb", size = 517202, upload-time = "2026-05-10T18:15:27.965Z" }, - { url = "https://files.pythonhosted.org/packages/b7/6e/339f6e5a7b413ce014f1917a756dae630fe59cc99f34153205b1cb540901/librt-0.11.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:0cad8a4d6a8ff03c9b76f9414caccd78e7cfbc8a2e12fa334d8e1d9932753783", size = 497517, upload-time = "2026-05-10T18:15:29.614Z" }, - { url = "https://files.pythonhosted.org/packages/cd/43/acdd5ce317cb46e8253ca9bfbdb8b12e68a24d745949336a7f3d5fb79ba0/librt-0.11.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f37aa505b3cf60701562eddb32df74b12a9e380c207fd8b06dd157a943ac7ea0", size = 538878, upload-time = "2026-05-10T18:15:30.928Z" }, - { url = "https://files.pythonhosted.org/packages/29/b5/7a25bb12e3172839f647f196b3e988318b7bb1ca7501732a225c4dce2ec0/librt-0.11.0-cp310-cp310-win32.whl", hash = "sha256:94663a21534637f0e787ec2a2a756022df6e5b7b2335a5cdd7d8e33d68a2af89", size = 100070, upload-time = "2026-05-10T18:15:32.551Z" }, - { url = "https://files.pythonhosted.org/packages/c6/0d/ebbcf4d77999c02c937b05d2b90ff4cd4dcc7e9a365ba132329ac1fe7a0f/librt-0.11.0-cp310-cp310-win_amd64.whl", hash = "sha256:dec7db73758c2b54953fd8b7fe348c45188fe26b39ee18446196edd08453a5d4", size = 117918, upload-time = "2026-05-10T18:15:33.678Z" }, - { url = "https://files.pythonhosted.org/packages/fe/87/2bf31fe17587b29e3f93ec31421e2b1e1c3e349b8bf6c7c313dbad1d5340/librt-0.11.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:93d95bd45b7d58343d8b90d904450a545144eec19a002511163426f8ab1fae29", size = 141092, upload-time = "2026-05-10T18:15:34.795Z" }, - { url = "https://files.pythonhosted.org/packages/cf/08/5c5bf772920b7ebac6e32bc91a643e0ab3870199c0b542356d3baa83970a/librt-0.11.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ee278c769a713638cdacd4c0436d72156e75df3ebc0166ab2b9dc43acc386c9", size = 142035, upload-time = "2026-05-10T18:15:36.242Z" }, - { url = "https://files.pythonhosted.org/packages/06/20/662a03d254e5b000d838e8b345d83303ddb768c080fd488e40634c0fa66b/librt-0.11.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f230cb1cbc9faaa616f9a678f530ebcf186e414b6bcbd88b960e4ba1b92428d5", size = 475022, upload-time = "2026-05-10T18:15:37.56Z" }, - { url = "https://files.pythonhosted.org/packages/de/f3/aa81523e45184c6ec23dc7f63263362ec55f80a09d424c012359ecbe7e35/librt-0.11.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:5d63c855d86938d9de93e265c9bd8c705b51ec494de5738340ee93767a686e4b", size = 467273, upload-time = "2026-05-10T18:15:39.182Z" }, - { url = "https://files.pythonhosted.org/packages/6b/6f/59c74b560ca8853834d5501d589c8a2519f4184f273a085ffd0f37a1cc47/librt-0.11.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:993f028be9e96a08d31df3479ac80d99be374d17f3b78e4796b3fd3c913d4e89", size = 497083, upload-time = "2026-05-10T18:15:40.634Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7b/5aa4d2c9600a719401160bf7055417df0b2a47439b9d88286ce45e56b65f/librt-0.11.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:258d73a0aa66a055e65b2e4d1b8cdb23b9d132c5bb915d9547d804fcaed116cc", size = 489139, upload-time = "2026-05-10T18:15:41.934Z" }, - { url = "https://files.pythonhosted.org/packages/d6/31/9143803d7da6856a69153785768c4936864430eec0fd9461c3ea527d9922/librt-0.11.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:0827efe7854718f04aaddf6496e96960a956e676fe1d0f04eb41511fd8ad06d5", size = 508442, upload-time = "2026-05-10T18:15:43.206Z" }, - { url = "https://files.pythonhosted.org/packages/2f/5a/bce08184488426bda4ccc2c4964ac048c8f68ae89bd7120082eef4233cfd/librt-0.11.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:7753e57d6e12d019c0d8786f1c09c709f4c3fcc57c3887b24e36e6c06ec938b7", size = 514230, upload-time = "2026-05-10T18:15:44.761Z" }, - { url = "https://files.pythonhosted.org/packages/89/8c/bb5e213d254b7505a0e658da199d8ab719086632ce09eef311ab27976523/librt-0.11.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:11bd19822431cc21af9f27374e7ae2e58103c7d98bda823536a6c47f6bb2bb3d", size = 494231, upload-time = "2026-05-10T18:15:46.308Z" }, - { url = "https://files.pythonhosted.org/packages/9d/fb/541cdad5b1ab1300398c74c4c9a497b88e5074c21b1244c8f49731d3a284/librt-0.11.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:22bdf239b219d3993761a148ffa134b19e52e9989c84f845d5d7b71d70a17412", size = 537585, upload-time = "2026-05-10T18:15:47.629Z" }, - { url = "https://files.pythonhosted.org/packages/8f/f2/464bb69295c320cb06bddb4f14a4ec67934ee14b2bffb12b19fb7ab287ba/librt-0.11.0-cp311-cp311-win32.whl", hash = "sha256:46c60b61e308eb535fbd6fa622b1ee1bb2815691c1ad9c98bf7b84952ec3bc8d", size = 100509, upload-time = "2026-05-10T18:15:49.157Z" }, - { url = "https://files.pythonhosted.org/packages/6d/e7/a17ee1788f9e4fbf548c19f4afa07c92089b9e24fef6cb2410863781ef4c/librt-0.11.0-cp311-cp311-win_amd64.whl", hash = "sha256:902e546ff044f579ff1c953ff5fce97b636fe9e3943996b2177710c6ef076f73", size = 118628, upload-time = "2026-05-10T18:15:50.345Z" }, - { url = "https://files.pythonhosted.org/packages/cc/c7/6c766214f9f9903bcfcfbef97d807af8d8f5aa3502d247858ab17582d212/librt-0.11.0-cp311-cp311-win_arm64.whl", hash = "sha256:65ac3bc20f78aa0ee5ae84baa68917f89fef4af63e941084dd019a0d0e749f0c", size = 103122, upload-time = "2026-05-10T18:15:52.068Z" }, - { url = "https://files.pythonhosted.org/packages/8b/d0/07c77e067f0838949b43bd89232c29d72efebb9d2801a9750184eb706b71/librt-0.11.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b87504f1690a23b9a2cca841191a04f83895d4fc2dd04df91d82b1a04ca2ad46", size = 144147, upload-time = "2026-05-10T18:15:53.227Z" }, - { url = "https://files.pythonhosted.org/packages/7a/24/8493538fa4f62f982686398a5b8f68008138a75086abdea19ade64bf4255/librt-0.11.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40071fc5fe0ce8daa6de616702314a01e1250711682b0523d6ab8d4525910cb3", size = 143614, upload-time = "2026-05-10T18:15:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/ff/1e/f8bad050810d9171f34a1648ed910e56814c2ba61639f2bd53c6377ae24b/librt-0.11.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:137e79445c896a0ea7b265f52d23954e05b64222ee1af69e2cb34219067cbb67", size = 485538, upload-time = "2026-05-10T18:15:56.117Z" }, - { url = "https://files.pythonhosted.org/packages/c0/fe/3594ebfbaf03084ba4b120c9ba5c3183fd938a48725e9bbe6ff0a5159ad8/librt-0.11.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:cca6644054e78746d8d4ef238681f9c34ff8b584fe6b988ecebb8db3b15e622a", size = 479623, upload-time = "2026-05-10T18:15:57.544Z" }, - { url = "https://files.pythonhosted.org/packages/b0/da/5d1876984b3746c85dbd219dbfcb73c85f54ee263fd32e5b2a632ec14571/librt-0.11.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d5b0eea49f5562861ee8d757a32ef7d559c1d35be2aaaa1ec28941d74c9ffc8a", size = 513082, upload-time = "2026-05-10T18:15:58.805Z" }, - { url = "https://files.pythonhosted.org/packages/19/6e/55bdf5d5ca00c3e18430690bf2c953d8d3ffd3c337418173d33dec985dc9/librt-0.11.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0d1029d7e1ae1a7e647ed6fb5df8c4ce2dffefb7a9f5fd1376a4554d96dac09f", size = 508105, upload-time = "2026-05-10T18:16:00.2Z" }, - { url = "https://files.pythonhosted.org/packages/07/10/f1f23a7c595ee90ece4d35c851e5d104b1311a887ed1b4ac4c35bbd13da8/librt-0.11.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:bc3ce6b33c5828d9e80592011a5c584cb2ce86edbc4088405f70da47dc1d1b3b", size = 522268, upload-time = "2026-05-10T18:16:01.708Z" }, - { url = "https://files.pythonhosted.org/packages/b6/02/5720f5697a7f54b78b3aefbe20df3a48cedcff1276618c4aa481177942ed/librt-0.11.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:936c5995f3514a42111f20099397d8177c79b4d7e70961e396c6f5a0a3566766", size = 527348, upload-time = "2026-05-10T18:16:03.496Z" }, - { url = "https://files.pythonhosted.org/packages/50/db/b4a47c6f91db4ff76348a0b3dd0cc65e090a078b765a810a62ff9434c3d3/librt-0.11.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9bc0ca6ad9381cbe8e4aa6e5726e4c80c78115a6e9723c599ed1d73e092bc49d", size = 516294, upload-time = "2026-05-10T18:16:05.173Z" }, - { url = "https://files.pythonhosted.org/packages/9e/58/9384b2f4eb1ed1d273d40948a7c5c4b2360213b402ef3be4641c06299f9c/librt-0.11.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:070aa8c26c0a74774317a72df8851facc7f0f012a5b406557ac56992d92e1ec8", size = 553608, upload-time = "2026-05-10T18:16:06.839Z" }, - { url = "https://files.pythonhosted.org/packages/21/7b/5aa8848a7c6a9278c79375146da1812e695754ceec5f005e6043461a7315/librt-0.11.0-cp312-cp312-win32.whl", hash = "sha256:6bf14feb84b05ae945277395451998c89c54d0def4070eb5c08de544930b245a", size = 101879, upload-time = "2026-05-10T18:16:08.103Z" }, - { url = "https://files.pythonhosted.org/packages/37/33/8a745436944947575b584231750a41417de1a38cf6a2e9251d1065651c09/librt-0.11.0-cp312-cp312-win_amd64.whl", hash = "sha256:75672f0bc524ede266287d532d7923dbce94c7514ad07627bac3d0c6d92cc4d9", size = 119831, upload-time = "2026-05-10T18:16:09.174Z" }, - { url = "https://files.pythonhosted.org/packages/59/67/a6739ac96e28b7855808bdb0370e250606104a859750d209e5a0716fe7ab/librt-0.11.0-cp312-cp312-win_arm64.whl", hash = "sha256:2f10cf143e4a9bb0f4f5af568a00df94a2d69ef41c2579584454bb0fe5cc642c", size = 103470, upload-time = "2026-05-10T18:16:10.369Z" }, - { url = "https://files.pythonhosted.org/packages/82/61/e59168d4d0bf2bf90f4f0caf7a001bfc60254c3af4586013b04dc3ef517b/librt-0.11.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:78dc31f7fdfe9c9d0eb0e8f42d139db230e826415bbcabd9f0e9faaaee909894", size = 144119, upload-time = "2026-05-10T18:16:11.771Z" }, - { url = "https://files.pythonhosted.org/packages/61/fd/caa1d60b12f7dd79ccea23054e06eeaebe266a5f52c40a6b651069200ce5/librt-0.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fa475675db22290c3158e1d42326d0f5a65f04f44a0e68c3630a25b53560fb9c", size = 143565, upload-time = "2026-05-10T18:16:13.334Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a9/dc744f5c2b4978d48db970be29f22716d3413d28b14ad99740817315cf2c/librt-0.11.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:621db29691044bdeda22e789e482e1b0f3a985d90e3426c9c6d17606416205ea", size = 485395, upload-time = "2026-05-10T18:16:14.729Z" }, - { url = "https://files.pythonhosted.org/packages/8f/21/7f8e97a1e4dae952a5a95948f6f8507a173bc1e669f54340bba6ca1ca31b/librt-0.11.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:a9010e2ed5b3a9e158c5fd966b3ab7e834bb3d3aacc8f66c91dd4b57a3799230", size = 479383, upload-time = "2026-05-10T18:16:16.321Z" }, - { url = "https://files.pythonhosted.org/packages/a6/6d/d8ee9c114bebf2c50e29ec2aa940826fccb62a645c3e4c18760987d0e16d/librt-0.11.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7c39513d8b7477a2e1ed8c43fc21c524e8d5a0f8d4e8b7b074dbdbe7820a08e2", size = 513010, upload-time = "2026-05-10T18:16:17.647Z" }, - { url = "https://files.pythonhosted.org/packages/f0/43/0b5708af2bd30a46400e72ba6bdaa8f066f15fb9a688527e34220e8d6c06/librt-0.11.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7aef3cf1d5af86e770ab04bfd993dfc4ae8b8c17f66fb77dd4a7d50de7bbb1a3", size = 508433, upload-time = "2026-05-10T18:16:19.309Z" }, - { url = "https://files.pythonhosted.org/packages/4a/50/356187247d09013490481033183b3532b58acf8028bcb34b2b56a375c9b2/librt-0.11.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:557183ddc36babe46b27dd60facbd5adb4492181a5be887587d57cda6e092f21", size = 522595, upload-time = "2026-05-10T18:16:20.642Z" }, - { url = "https://files.pythonhosted.org/packages/40/e7/c6ac4240899c7f3248079d5a9900debe0dadb3fdeaf856684c987105ba47/librt-0.11.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:83d3e1f72bd42f6c5c0b7daec530c3f829bd02db42c70b8ddf0c2d90a2459930", size = 527255, upload-time = "2026-05-10T18:16:22.352Z" }, - { url = "https://files.pythonhosted.org/packages/eb/b5/a81322dbeedeeaf9c1ee6f001734d28a09d8383ac9e6779bc24bbd0743c6/librt-0.11.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:4ce1f21fbe589bc1afd7872dece84fb0e1144f794a288e58a10d2c54a55c43be", size = 516847, upload-time = "2026-05-10T18:16:23.627Z" }, - { url = "https://files.pythonhosted.org/packages/ae/66/6e6323787d592b55204a42595ff1102da5115601b53a7e9ddebc889a6da5/librt-0.11.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:970b09f7044ea2b64c9da42fd3d335666518cfd1c6e8a182c95da73d0214b41e", size = 553920, upload-time = "2026-05-10T18:16:25.025Z" }, - { url = "https://files.pythonhosted.org/packages/9c/21/623f8ca230857102066d9ca8c6c1734995908c4d0d1bee7bb2ef0021cb33/librt-0.11.0-cp313-cp313-win32.whl", hash = "sha256:78fddc31cd4d3caa897ad5d31f856b1faadc9474021ad6cb182b9018793e254e", size = 101898, upload-time = "2026-05-10T18:16:26.649Z" }, - { url = "https://files.pythonhosted.org/packages/b3/1d/b4ebd44dd723f768469007515cb92251e0ae286c94c140f374801140fa74/librt-0.11.0-cp313-cp313-win_amd64.whl", hash = "sha256:8ca8aa88751a775870b764e93bad5135385f563cb8dcee399abf034ea4d3cb47", size = 119812, upload-time = "2026-05-10T18:16:27.859Z" }, - { url = "https://files.pythonhosted.org/packages/3b/e4/b2f4ca7965ca373b491cdb4bc25cdb30c1649ca81a8782056a83850292a9/librt-0.11.0-cp313-cp313-win_arm64.whl", hash = "sha256:96f044bb325fd9cf1a723015638c219e9143f0dfbc0ca54c565df2b7fc748b44", size = 103448, upload-time = "2026-05-10T18:16:29.066Z" }, - { url = "https://files.pythonhosted.org/packages/29/eb/dbce197da4e227779e56b5735f2decc3eb36e55a1cdbf1bd65d6639d76c1/librt-0.11.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4a017a95e5837dc15a8c5661d60e05daa96b90908b1aa6b7acdf443cd25c8ebd", size = 143345, upload-time = "2026-05-10T18:16:30.674Z" }, - { url = "https://files.pythonhosted.org/packages/76/a3/254bebd0c11c8ba684018efb8006ff22e466abce445215cca6c778e7d9de/librt-0.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:b1ecbd9819deccc39b7542bf4d2a740d8a620694d39989e58661d3763458f8d4", size = 143131, upload-time = "2026-05-10T18:16:32.037Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3f/f77d6122d21ac7bf6ae8a7dfced1bd2a7ac545d3273ebdcaf8042f6d619f/librt-0.11.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7da327dacd7be8f8ec36547373550744a3cc0e536d54665cd83f8bcd961200e8", size = 477024, upload-time = "2026-05-10T18:16:33.493Z" }, - { url = "https://files.pythonhosted.org/packages/ac/0a/2c996dadebaa7d9bbbd43ef2d4f3e66b6da545f838a41694ef6172cebec8/librt-0.11.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:0dc56b1f8d06e60db362cc3fdae206681817f86ce4725d34511473487f12a34b", size = 474221, upload-time = "2026-05-10T18:16:34.864Z" }, - { url = "https://files.pythonhosted.org/packages/0a/7e/f5d92af8486b8272c23b3e686b46ff72d89c8169585eb61eef01a2ac7147/librt-0.11.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:05fb8fb2ab90e21c8d12ea240d744ad514da9baf381ebfa70d91d20d21713175", size = 505174, upload-time = "2026-05-10T18:16:36.705Z" }, - { url = "https://files.pythonhosted.org/packages/af/1a/cb0734fe86398eb33193ab753b7326255c74cac5eb09e76b9b16536e7adb/librt-0.11.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cae74872be221df4374d10fec61f93ed1513b9546ea84f2c0bf73ab3e9bd0b03", size = 497216, upload-time = "2026-05-10T18:16:38.418Z" }, - { url = "https://files.pythonhosted.org/packages/18/06/094820f91558b66e29943c0ec41c9914f460f48dd51fc503c3101e10842d/librt-0.11.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:32bcc918c0148eb7e3d57385125bac7e5f9e4359d05f07448b09f6f778c2f31c", size = 513921, upload-time = "2026-05-10T18:16:39.848Z" }, - { url = "https://files.pythonhosted.org/packages/0b/c2/00de9018871a282f530cacb457d5ec0428f6ac7e6fedde9aff7468d9fb04/librt-0.11.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:f9743fc99135d5f78d2454435615f6dec0473ca507c26ce9d92b10b562a280d3", size = 520850, upload-time = "2026-05-10T18:16:41.471Z" }, - { url = "https://files.pythonhosted.org/packages/51/9d/64631832348fd1834fb3a61b996434edddaaf25a31d03b0a76273159d2cf/librt-0.11.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5ba067f4aadae8fda802d91d2124c90c42195ff32d9161d3549e6d05cfe26f96", size = 504237, upload-time = "2026-05-10T18:16:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ec/ae5525eb16edc827a044e7bb8777a455ff95d4bca9379e7e6bddd7383647/librt-0.11.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:de3bf945454d032f9e390b85c4072e0a0570bf825421c8be0e71209fa65e1abe", size = 546261, upload-time = "2026-05-10T18:16:44.408Z" }, - { url = "https://files.pythonhosted.org/packages/5a/09/adce371f27ca039411da9659f7430fcc2ba6cd0c7b3e4467a0f091be7fa9/librt-0.11.0-cp314-cp314-win32.whl", hash = "sha256:d2277a05f6dcb9fd13db9566aac4fabd68c3ea1ea46ee5567d4eef8efa495a2f", size = 96965, upload-time = "2026-05-10T18:16:46.039Z" }, - { url = "https://files.pythonhosted.org/packages/d6/ee/8ac720d98548f173c7ce2e632a7ca94673f74cacd5c8162a84af5b35958a/librt-0.11.0-cp314-cp314-win_amd64.whl", hash = "sha256:ab73e8db5e3f564d812c1f5c3a175930a5f9bc96ccb5e3b22a34d7858b401cf7", size = 115151, upload-time = "2026-05-10T18:16:47.133Z" }, - { url = "https://files.pythonhosted.org/packages/94/20/c900cf14efeb09b6bef2b2dff20779f73464b97fd58d1c6bccc379588ae3/librt-0.11.0-cp314-cp314-win_arm64.whl", hash = "sha256:aea3caa317752e3a466fa8af45d91ee0ea8c7fdd96e42b0a8dd9b76a7931eba1", size = 98850, upload-time = "2026-05-10T18:16:48.597Z" }, - { url = "https://files.pythonhosted.org/packages/0c/71/944bfe4b64e12abffcd3c15e1cce07f72f3d55655083786285f4dedeb532/librt-0.11.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d1b36540d7aaf9b9101b3a6f376c8d8e9f7a9aec93ed05918f2c69d493ffef72", size = 151138, upload-time = "2026-05-10T18:16:49.839Z" }, - { url = "https://files.pythonhosted.org/packages/b6/10/99e64a5c86989357fda078c8143c533389585f6473b7439172dd8f3b3b2d/librt-0.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:efbb343ab2ce3540f4ecbe6315d677ed70f37cd9a72b1e58066c918ca83acbaa", size = 151976, upload-time = "2026-05-10T18:16:51.062Z" }, - { url = "https://files.pythonhosted.org/packages/21/31/5072ad880946d83e5ea4147d6d018c78eefce85b77819b19bdd0ee229435/librt-0.11.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0dd688aab3f7914d3e6e5e3554978e0383312fb8e771d84be008a35b9ee548", size = 557927, upload-time = "2026-05-10T18:16:52.632Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8d/70b5fb7cfbab60edbe7381614ab985da58e144fbf465c86d44c95f43cdca/librt-0.11.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:f5fb36b8c6c63fdcbb1d526d94c0d1331610d43f4118cc1beb4efef4f3faacb2", size = 539698, upload-time = "2026-05-10T18:16:53.934Z" }, - { url = "https://files.pythonhosted.org/packages/fa/a3/ba3495a0b3edbd24a4cae0d1d3c64f39a9fc45d06e812101289b50c1a619/librt-0.11.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4a9a237d13addb93715b6fee74023d5ee3469b53fce527626c0e088aa585805f", size = 577162, upload-time = "2026-05-10T18:16:55.589Z" }, - { url = "https://files.pythonhosted.org/packages/f7/db/36e25fb81f99937ff1b96612a1dc9fd66f039cb9cc3aee12c01fac31aab9/librt-0.11.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5ddd17bd87b2c56ddd60e546a7984a2e64c4e8eab92fb4cf3830a48ad5469d51", size = 566494, upload-time = "2026-05-10T18:16:56.975Z" }, - { url = "https://files.pythonhosted.org/packages/33/0d/3f622b47f0b013eeb9cf4cc07ae9bfe378d832a4eec998b2b209fe84244d/librt-0.11.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd43992b4473d42f12ff9e68326079f0696d9d4e6000e8f39a0238d482ba6ee2", size = 596858, upload-time = "2026-05-10T18:16:58.374Z" }, - { url = "https://files.pythonhosted.org/packages/a9/02/71b90bc93039c46a2000651f6ad60122b114c8f54c4ad306e0e96f5b75ad/librt-0.11.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:f8e3e8056dd674e279741485e2e512d6e9a751c7455809d0114e6ebf8d781085", size = 590318, upload-time = "2026-05-10T18:16:59.676Z" }, - { url = "https://files.pythonhosted.org/packages/04/04/418cb3f75621e2b761fb1ab0f017f4d70a1a72a6e7c74ee4f7e8d198c2f3/librt-0.11.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c1f708d8ae9c56cf38a903c44297243d2ec83fd82b396b977e0144a3e76217e3", size = 575115, upload-time = "2026-05-10T18:17:01.007Z" }, - { url = "https://files.pythonhosted.org/packages/cc/2c/5a2183ac58dd911f26b5d7e7d7d8f1d87fcecdddd99d6c12169a258ff62c/librt-0.11.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:0add982e0e7b9fc14cf4b33789d5f13f66581889b88c2f58099f6ce8f92617bd", size = 617918, upload-time = "2026-05-10T18:17:02.682Z" }, - { url = "https://files.pythonhosted.org/packages/15/1f/dc6771a52592a4451be6effa200cbfc9cec61e4393d3033d81a9d307961d/librt-0.11.0-cp314-cp314t-win32.whl", hash = "sha256:2b481d846ac894c4e8403c5fd0e87c5d11d6499e404b474602508a224ff531c8", size = 103562, upload-time = "2026-05-10T18:17:03.99Z" }, - { url = "https://files.pythonhosted.org/packages/62/4a/7d1415567027286a75ba1093ec4aca11f073e0f559c530cf3e0a757ad55c/librt-0.11.0-cp314-cp314t-win_amd64.whl", hash = "sha256:28edb433edde181112a908c78907af28f964eabc15f4dd16c9d66c834302677c", size = 124327, upload-time = "2026-05-10T18:17:05.465Z" }, - { url = "https://files.pythonhosted.org/packages/ce/62/b40b382fa0c66fee1478073eb8db352a4a6beda4a1adccf1df911d8c289c/librt-0.11.0-cp314-cp314t-win_arm64.whl", hash = "sha256:dee008f20b542e3cd162ba338a7f9ec0f6d23d395f66fe8aeeec3c9d067ea253", size = 102572, upload-time = "2026-05-10T18:17:06.809Z" }, +version = "0.12.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/e0/dbd0f2a68a1c1a1991eb7921ff6014465d56608cdc9a9fb468a616210a37/librt-0.12.0.tar.gz", hash = "sha256:cb26faedbd09c6130e9c1b64d8000efec5076ffd18d606c6cd1cf02730e6d8b0", size = 203841, upload-time = "2026-06-30T16:14:29.671Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e1/66/c9d88366893b4b0df6b5375c27ebc9f14c43419d9e244b493be20e85bc74/librt-0.12.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:fe3547407bbce45c09885591f90168325c5a31a6795b9a13f6b9ff3d25093d93", size = 144398, upload-time = "2026-06-30T16:12:03.947Z" }, + { url = "https://files.pythonhosted.org/packages/bd/f2/9be1c6da204701163ec3aaedbf893d2f656b363d8fa302af536ce6471eb4/librt-0.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5925eca673207204a3adca040a91bdd3738fc7ba48da647ccd55732692a35736", size = 148924, upload-time = "2026-06-30T16:12:05.583Z" }, + { url = "https://files.pythonhosted.org/packages/d8/f3/256824ee27649c6e0a693db25d391f97b43b52364f8efb466014a564bbc7/librt-0.12.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0f9ef097a7711465a204454c69658bbb6b2a6be9bdef0eeeba9a042016d00688", size = 479654, upload-time = "2026-06-30T16:12:07.175Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3f/f4adbb3f293a04bd3dc2eb91d814f5b1e221e6b4522585696ba6901a0b9a/librt-0.12.0-cp310-cp310-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:57abc8b65edf1a8e80e5472c81c108a7527202e5febfda9e00a684dbaeae534e", size = 472318, upload-time = "2026-06-30T16:12:08.758Z" }, + { url = "https://files.pythonhosted.org/packages/9d/b5/362c93f7b43d4ef84a3d5f156c8d4eeddb22badcf5529a1281c387abbbd7/librt-0.12.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:51e6f53732a8ae5012a3b6ae092da2933be74ec4169d16038f4af87a0019afea", size = 501555, upload-time = "2026-06-30T16:12:10.623Z" }, + { url = "https://files.pythonhosted.org/packages/24/1d/2d6abf059c3a4b88a6668e7bb81af332b14463028ac8f2b08a1212eb1ebc/librt-0.12.0-cp310-cp310-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:edb5f06cdb38d6ef9fd7ae06d62962d65c881b5f965d5e8a6c53e59c15ae4338", size = 494118, upload-time = "2026-06-30T16:12:12.503Z" }, + { url = "https://files.pythonhosted.org/packages/39/c1/f91f3094be2c76361d88aca613d8b7586d15b6026714d59d2e3dc0e35f44/librt-0.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:1473ef42263dfee7553a5c460f11730a4409acf0d52629b284eb1e6b13eb460a", size = 516318, upload-time = "2026-06-30T16:12:14.192Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e2/5211af94252458cbed7a6250163dff9c5a84aec29609121c828375a3b319/librt-0.12.0-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:1d6f69a06295fb6ad8dcf92b4b2d15d211842005e86eedce64d88e0633592f58", size = 522294, upload-time = "2026-06-30T16:12:15.879Z" }, + { url = "https://files.pythonhosted.org/packages/90/9b/de31f5b9fdf7fa3699c4bbbecf82ebd52013d5d6b500b70b07b0ebacbd51/librt-0.12.0-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:3275d0270cd07ca9c2e140ae4da34e24a0350e98c6e3815dce96ead67cf0487d", size = 502494, upload-time = "2026-06-30T16:12:17.394Z" }, + { url = "https://files.pythonhosted.org/packages/b1/48/22c18dff89f3900dddb3e470e6f7febcda37ff3667b73097a848c9a608b2/librt-0.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a4834462ec68613024d063c7efe9b188e350d40fda9ba937372039883d2a8051", size = 543422, upload-time = "2026-06-30T16:12:19.006Z" }, + { url = "https://files.pythonhosted.org/packages/52/7b/74691b4b55944227245fffef063714e3ab9707ab1111eb0068512b428c7c/librt-0.12.0-cp310-cp310-win32.whl", hash = "sha256:bcf9b55ac089e8cf201d2146833e1097812c15dcea61911e84d6a2904cf78893", size = 97642, upload-time = "2026-06-30T16:12:20.386Z" }, + { url = "https://files.pythonhosted.org/packages/c5/dc/7f8fa369a1f7cc9b090fecd373659ada0e9bab1ae4a3ac9f163eabd04977/librt-0.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:c0a122002f7e0d5c93e84465c4b3fe86621402b7b92f1e2bc0784ebe67793112", size = 117583, upload-time = "2026-06-30T16:12:21.829Z" }, + { url = "https://files.pythonhosted.org/packages/9e/ab/628490f42d1eba82f3c7e5821aa62013e6df7f525b7a9e92c048f8d1cc1c/librt-0.12.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3f13c1e8563102c2b17581cf37fcb2c6dae7ad485ccea93ae46258998c25f9a1", size = 143821, upload-time = "2026-06-30T16:12:23.248Z" }, + { url = "https://files.pythonhosted.org/packages/38/5f/793e8b6f4b6ac16e7d7198478c0af3670606fbb535c768d5f3e954781423/librt-0.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d1ddff067610a122387024c4df527493b909d41e54a6e5b2d0e6c1041d6dfa09", size = 148442, upload-time = "2026-06-30T16:12:24.582Z" }, + { url = "https://files.pythonhosted.org/packages/ad/92/c780fe37a9e0982f3bd8fd9a631d6b95d09a5a7201c6c50366ce843b7e42/librt-0.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c8dc7ebb5f3eec062398e9d0ef1938acd21b589e74286c4a8906d0183318d91b", size = 478276, upload-time = "2026-06-30T16:12:26.101Z" }, + { url = "https://files.pythonhosted.org/packages/41/bb/226d444bc20d7dff4a19ec6c1ff2c13a76385eebddb59c9c00c923b67536/librt-0.12.0-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:198de569ea9d5f6f33808f1c00cc3db9de62bf4d6deafa3b052bd08255083038", size = 472337, upload-time = "2026-06-30T16:12:27.83Z" }, + { url = "https://files.pythonhosted.org/packages/12/79/98ac0840ee90a75d4e1155c79062860b12ccca508587ff2119fc086965f2/librt-0.12.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e958678a8bca56016aedc891b391c0e0813ea382a874b54a2c1b313c1d232720", size = 502087, upload-time = "2026-06-30T16:12:29.443Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/a6b1a0d080606a7f5f646b79a1496f21d709f8563877759ace9ce5adad73/librt-0.12.0-cp311-cp311-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575a6eca68c8437ed4a8e0f534e31d74b562ba1049a0ee4b5f09e114bcc21be1", size = 493202, upload-time = "2026-06-30T16:12:31.077Z" }, + { url = "https://files.pythonhosted.org/packages/69/cf/e1b036b45f2fc272205ee18bf272b47e8d684bf1a75af26db440c7504359/librt-0.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:86f241c50dc9e9a3f0db6dbb37a607c8205aa87b920802dabbd50b70d40f6939", size = 514139, upload-time = "2026-06-30T16:12:33.032Z" }, + { url = "https://files.pythonhosted.org/packages/40/34/b193b3e6985469a2f8afa86c90012329c86480b6ff4f2e4bd7b5b937e134/librt-0.12.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:113417b934fbf38220a9c7fe94578cefbe7dbb047adcb75aa197905af2b13724", size = 519486, upload-time = "2026-06-30T16:12:34.996Z" }, + { url = "https://files.pythonhosted.org/packages/31/9e/7de4947b1695f247c813f833e3c1e7b77b52e52a7dba2c35411cf806b58e/librt-0.12.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:762f17c0eb6b5d74e269126996cea8a89e35ab6464c5151619163abcd8623ae2", size = 499609, upload-time = "2026-06-30T16:12:36.663Z" }, + { url = "https://files.pythonhosted.org/packages/59/11/f3730e04e758b1fbf215359062ad2d5b6bd0b0ab5ac46b1c140628795be7/librt-0.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:6aa93b3bd7f7588c628f6e9bf66485d3467fd9a1ccdb8975b770178f39f35697", size = 542205, upload-time = "2026-06-30T16:12:38.56Z" }, + { url = "https://files.pythonhosted.org/packages/1f/8f/710453617eabe20e18433864f335534c8aff63fbc68d8cd9dbc70a3d08f6/librt-0.12.0-cp311-cp311-win32.whl", hash = "sha256:aaa04b44d4fe86d824616b1f9c13e34c7c01ec0c96dd2abc4f59423696f788e2", size = 98067, upload-time = "2026-06-30T16:12:40.102Z" }, + { url = "https://files.pythonhosted.org/packages/42/53/401bff50a56e95daf151d911c99adf5732af2190e8f4d11886c9a229103c/librt-0.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:9aaeeddb8e7e4ae3bb9f944e0e618418cb91c0071d5ddbfcc3584b3cf59d39f0", size = 118346, upload-time = "2026-06-30T16:12:41.388Z" }, + { url = "https://files.pythonhosted.org/packages/e5/9a/a3a9078fe88bfc2d2d99dcf1c18593938ae830089cf84c3b2532a6c49d63/librt-0.12.0-cp311-cp311-win_arm64.whl", hash = "sha256:18a2402fa3123ab76ecca670e6fb33038fde7c1e91181b885226ec4d30af2c2c", size = 104760, upload-time = "2026-06-30T16:12:43.112Z" }, + { url = "https://files.pythonhosted.org/packages/d5/1a/5bec493821b0e85b91de4f234912b50133d1aedb875048eef27938ec3f96/librt-0.12.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:9bce19aa7c05f91c989f9da7b567f81d21d57a2e6501e2b811aa0f3f79614c1a", size = 146756, upload-time = "2026-06-30T16:12:44.395Z" }, + { url = "https://files.pythonhosted.org/packages/b9/d0/cc04b48a57c1f275387f5578847214c4a6c21bfb24c6c8c8d6ba753fe403/librt-0.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b0ace09f5bf4d982fe726015f102fb856658b41580597104e301e630ed1d8d86", size = 145537, upload-time = "2026-06-30T16:12:45.95Z" }, + { url = "https://files.pythonhosted.org/packages/9e/10/c02325556beb2aa158c9e549ddade8cc9a23b36cdad14756dbed730c1ff1/librt-0.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d007efe9243ede81ce75990ad7aa172da1e2024144b3eff17ba46a5fff1fff3c", size = 488637, upload-time = "2026-06-30T16:12:47.658Z" }, + { url = "https://files.pythonhosted.org/packages/cb/9e/7b49ca1c30baa9c8df96024aa09a97c35a97455e36004c9b5311703c56f3/librt-0.12.0-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:ad324a5e4858388a4864915b90a42efc8b374376393f14b9940f2454e791912b", size = 483651, upload-time = "2026-06-30T16:12:49.283Z" }, + { url = "https://files.pythonhosted.org/packages/4d/71/03c8c8cec39645fda451132ff9d6d662fc5aea42a1a188a77a4fddb35906/librt-0.12.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:10a40cf74cdd97b6f8f905056db73f5d459783de2ca04c6ebd1bf47652818e7e", size = 518359, upload-time = "2026-06-30T16:12:50.999Z" }, + { url = "https://files.pythonhosted.org/packages/e0/ec/a9f357f94bbcba92277d22af22cff42ef706ae5d9d6d58b69bebf3a67954/librt-0.12.0-cp312-cp312-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:92e61c09de95217ae02a9d17f4f66cf073253cdc51bcfdc0f15c62c9a70baa85", size = 509510, upload-time = "2026-06-30T16:12:52.631Z" }, + { url = "https://files.pythonhosted.org/packages/7a/34/717055325d028743aa01a7691ad59a63352a26a8ff2e7eeb0c9249514150/librt-0.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0461344061d6fc3718940f5855d95647831cef6d03a6c7506897f98222784ad4", size = 527302, upload-time = "2026-06-30T16:12:54.244Z" }, + { url = "https://files.pythonhosted.org/packages/95/f8/7612eeedb3395d92f7c6a84dca5f15e282d650483a4dc01aa5b9cffdfda3/librt-0.12.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:e6dfe89074732c9287b3c0f5a6af575c9ede380a788013876cc7b14fe0da0361", size = 532568, upload-time = "2026-06-30T16:12:55.74Z" }, + { url = "https://files.pythonhosted.org/packages/79/1e/a9afe85d5bb8b65dc27be3809ed1d69082079e1e9717fd2c66aa9939600c/librt-0.12.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:9efed79d51ad1383bba0855f613cca7aa91c943e709af2413ac7f4bb9936ce08", size = 521579, upload-time = "2026-06-30T16:12:57.884Z" }, + { url = "https://files.pythonhosted.org/packages/b3/1e/93aebb219d52c37ea578f83b0588cd7b040974e464d4e435086a48b4dc4d/librt-0.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:1eac6cc0e23e448fb3c1446ed85ff796afb616eed5897c978d35dbec030b7c7c", size = 558743, upload-time = "2026-06-30T16:12:59.577Z" }, + { url = "https://files.pythonhosted.org/packages/3c/85/1680c0ec332f238e3145c5608d313ab0a43281e210a5dd87e3bc3cc25631/librt-0.12.0-cp312-cp312-win32.whl", hash = "sha256:0ab8ee0210047ae86ca023ccfbfe3df82077fd1c9bc021aebbf37d993ef64af0", size = 99200, upload-time = "2026-06-30T16:13:01.015Z" }, + { url = "https://files.pythonhosted.org/packages/30/0e/abca12d8904875aa2ad66327390a3f7b1b75ebc43c0a00fc763cecf32ea5/librt-0.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:51c8bfa12632c81b94401c101bcedd0c56c3a1f8fa3273ca3472b28cd2f54003", size = 119390, upload-time = "2026-06-30T16:13:02.493Z" }, + { url = "https://files.pythonhosted.org/packages/32/a5/4203481b6d3a3bb348c82ac71abf1fcb4cb3ae8422a24a8dee4cd3ac5bd7/librt-0.12.0-cp312-cp312-win_arm64.whl", hash = "sha256:5eebd451f5def089369ba6d8ff0291303d035e8154f9f26f7633835c5b029ade", size = 105117, upload-time = "2026-06-30T16:13:03.952Z" }, + { url = "https://files.pythonhosted.org/packages/f2/87/568d948c8079c9ff3c9e8110cf85f1eb70218e1209af29d0b7b89aa4a60c/librt-0.12.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8d9a55760a34ae5ce70434aabb6a6c61c6c44a0ec58ca1cfd9cd86e4745d417d", size = 146808, upload-time = "2026-06-30T16:13:05.417Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1d/bea471ecea210088847bb5f3c4b4b424d596518934c06679b78ca85d6e63/librt-0.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:ff0b197e338b4cf432873e0d6ef025213fdea85311ec4d87d2ea88c28adf2409", size = 145503, upload-time = "2026-06-30T16:13:07.023Z" }, + { url = "https://files.pythonhosted.org/packages/eb/9e/984ad422b56de95fdce158f06b051655373784ebea0aba9a7fcbc41614d1/librt-0.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7e69f120a20b69e2539d603bbd4d62db38399b10f8bf73a1cf445038a621e8af", size = 488421, upload-time = "2026-06-30T16:13:08.492Z" }, + { url = "https://files.pythonhosted.org/packages/50/03/1a2f94009b07ea71f8e1a4cfe53370565b56da9caa341b89e0699325e9f5/librt-0.12.0-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:fde3cde595e947fc8e755b0a21f919a1622483d07c662d00496e040773d22591", size = 483488, upload-time = "2026-06-30T16:13:10.169Z" }, + { url = "https://files.pythonhosted.org/packages/aa/3b/084bdc295823fbb6ab91670047adf8f420787f9e8794bf2d140b66dc196b/librt-0.12.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d977447315fa09ea4e8c7ae9b4e22f7659b5128161c1fd55ff786b5349f73503", size = 518428, upload-time = "2026-06-30T16:13:11.681Z" }, + { url = "https://files.pythonhosted.org/packages/c9/22/5a307390b93a115ffbecd95c64eecb4e56269680e45e9415ada7285f2cf4/librt-0.12.0-cp313-cp313-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7ffac8a67e4143cea9a549d4822b93bc0bbaad73fc25aa0ab0ba5ec27d178677", size = 509744, upload-time = "2026-06-30T16:13:13.217Z" }, + { url = "https://files.pythonhosted.org/packages/b5/90/83f3cb6184f5d669660717b4b2e317c9ddaccf7ca5bb97f2196deac1a3b7/librt-0.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:94af1ed773ff104ef08ef3d669a0ba9d3a5916c609eb698cffe5d5476d66ff9b", size = 527749, upload-time = "2026-06-30T16:13:15.277Z" }, + { url = "https://files.pythonhosted.org/packages/7d/3b/f162be5cc88d47378e3a20776fe425fa1c2bece755da15e2783ebf06d3d6/librt-0.12.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:548199d21d22fb26398dfbbe0ba953a52465c66f3a49f38e6fddce1b127faf53", size = 532582, upload-time = "2026-06-30T16:13:17.074Z" }, + { url = "https://files.pythonhosted.org/packages/c9/28/6c5d2f6b7232fd24f284fc4cab37a459fe69a9096a09942f44cc5c55e073/librt-0.12.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c8f1f413b966a9dd3ecf80cd337b0ad7bb3de2474a4ff448ed3ebabfc3f803fc", size = 522235, upload-time = "2026-06-30T16:13:18.823Z" }, + { url = "https://files.pythonhosted.org/packages/a9/1c/bd115360587fdc22c8ae8fac14c040a556b442e2965d4370d2cf274c8b95/librt-0.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:55f13f95b629be5b6ab38918e439bf14169d6f9a8deaae55e0c14e12fb0c74b9", size = 559055, upload-time = "2026-06-30T16:13:20.509Z" }, + { url = "https://files.pythonhosted.org/packages/fe/5a/c26f49f576437014825a86faea3cec60c1ed17f976abd567b6c12b8e35a7/librt-0.12.0-cp313-cp313-pyemscripten_2025_0_wasm32.whl", hash = "sha256:8b2dc079dfe29e77a47a19073d2040fa4879aa3656501f1650f8402ddce0313c", size = 79809, upload-time = "2026-06-30T16:13:22.401Z" }, + { url = "https://files.pythonhosted.org/packages/69/0b/a55244261d9ad7375ac039b8af06d42602722e2e8b8d8d6b86e4a3888c02/librt-0.12.0-cp313-cp313-win32.whl", hash = "sha256:da58944be8270f2bfee628a9a2a60c1cf6a12c8bea8e2c9b6edf3e5414ca7793", size = 99308, upload-time = "2026-06-30T16:13:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/c9/bf/ed9465e58d44c5a5637795547d0841c8934aab905ea452cac1adf14672cf/librt-0.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:1db4be3037e4ce065a071fa7deee93e78ebc25f448340a02a6c1c0b82c37e383", size = 119438, upload-time = "2026-06-30T16:13:25.188Z" }, + { url = "https://files.pythonhosted.org/packages/c0/44/3cad652aeb892e6e8ffe48d0fafa2bc652f28ec7ed3f4403fcbb1be4f948/librt-0.12.0-cp313-cp313-win_arm64.whl", hash = "sha256:05fd2542892ad770b5dd45003fd080477cf220b611d3ee59b0792097eb0873a9", size = 105118, upload-time = "2026-06-30T16:13:26.533Z" }, + { url = "https://files.pythonhosted.org/packages/0e/51/3a0e05618c12423b6fc5141b590ec02a6efb645833edc8736a6c7b46d1ec/librt-0.12.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:b37ee42e09722284a6d9288fe44a191f7276060a3195939bb77c6502058dbb34", size = 145579, upload-time = "2026-06-30T16:13:27.909Z" }, + { url = "https://files.pythonhosted.org/packages/77/9e/fd399d099dfb4020f3f7c34e7e6210c389fa89f7d79ca92f5afb0395f278/librt-0.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:ade11988728b3e4768dadc5696e82c60e9b35fc95335a9b4d1f5d69e753ccec7", size = 150139, upload-time = "2026-06-30T16:13:29.357Z" }, + { url = "https://files.pythonhosted.org/packages/7a/ee/610239fbd8c4b005443664c5d4c3bc1717daedd8c71369bf45011aa87194/librt-0.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f351ed425380e39bd86df382578aa5b8c5b98e2e265112de7379e7d030258150", size = 480457, upload-time = "2026-06-30T16:13:30.78Z" }, + { url = "https://files.pythonhosted.org/packages/0c/10/ceddc9010f26c541444be36e1153a79b64626694db2d33a524c719fa3e46/librt-0.12.0-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:857d2163e088c868967717ace8e980017fd868a735f3de010412af02bdc30319", size = 479002, upload-time = "2026-06-30T16:13:32.398Z" }, + { url = "https://files.pythonhosted.org/packages/4e/f1/b1523d9718e8192e5403e6b41a02742e17ba554369f0729b9f30ab590e2d/librt-0.12.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2befc80aa5f2f5b93f28abaaf11feff6677931dd548320e44c52deaa9399744", size = 510527, upload-time = "2026-06-30T16:13:34.615Z" }, + { url = "https://files.pythonhosted.org/packages/f6/0e/0f3ff43befb18a531615736791e52fb67eaa71ff7b89e6e5f7004b64cc6e/librt-0.12.0-cp314-cp314-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:be3694dcfa97c6715dd19ac73d3e1b21a805514a5785663e57fecacd3ff64e5a", size = 500988, upload-time = "2026-06-30T16:13:36.408Z" }, + { url = "https://files.pythonhosted.org/packages/a8/1a/0278ea4a9e599dc507c43839a87f2c764ad04bf69418e2d763d58659e55f/librt-0.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2d5f67e86f45638843d025b0828f2e9e55fc45ff9180d2618ccdeaf72a796050", size = 519318, upload-time = "2026-06-30T16:13:37.883Z" }, + { url = "https://files.pythonhosted.org/packages/59/55/090e10e62be2f35265e41601337f83ac9f83be9aca1bf92692e3a82effdd/librt-0.12.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:64572c85e4ab7d572c9b72cd76b5f90b21181b1459fa6b1aac6f8958c4fcff31", size = 527127, upload-time = "2026-06-30T16:13:39.682Z" }, + { url = "https://files.pythonhosted.org/packages/1f/34/8052c9ec678be6ba751279947831f089aa69b009000b985ce91d1979669a/librt-0.12.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:8b961912b0e688c1eb4658a46bdb0606b31918d65597fbe7356ca83aa653ffcc", size = 509766, upload-time = "2026-06-30T16:13:41.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/f8/8761b36189e9ec8dc20b49fa84cef22852c6c41fcda56f760f7fc1360da5/librt-0.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:722375903e3f079436a7a33da51ce73931536dd041f9feb01536f05d8e010c96", size = 552043, upload-time = "2026-06-30T16:13:43.197Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/7283971ef6b70269938b49c7b25f670ec6325d252265fbcc996f9b364379/librt-0.12.0-cp314-cp314-pyemscripten_2026_0_wasm32.whl", hash = "sha256:a5a96a8f536b65ef1bf910c09e7e71647edde5111f6e1b51f413c6fba5bfe71b", size = 79472, upload-time = "2026-06-30T16:13:44.64Z" }, + { url = "https://files.pythonhosted.org/packages/c3/5e/b30940dea935e8ac5bd0e0abb1985f5274590d557ac3a252ca0d5392ce52/librt-0.12.0-cp314-cp314-win32.whl", hash = "sha256:8ffc99c356f1777c506e1b69dc303879153ae2640ba15b8f3d4448bc87139149", size = 94246, upload-time = "2026-06-30T16:13:45.962Z" }, + { url = "https://files.pythonhosted.org/packages/7d/4e/0af9fe63f35fa304da3b05688f30ff6a329bcc59581b1cc51dc87fd30141/librt-0.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:1e68fb20798f455cda41d20a306a23c901218883f17a4bab1ed6e1331b265fb7", size = 114951, upload-time = "2026-06-30T16:13:47.279Z" }, + { url = "https://files.pythonhosted.org/packages/b1/8e/843c495d7db35e13b84cd533898fa89145c40dc255da0bc316d53d631464/librt-0.12.0-cp314-cp314-win_arm64.whl", hash = "sha256:2df534f97916cf38ec9b1ddafeb68ae1a4cd4a54775ff26a797026774c0517cf", size = 100562, upload-time = "2026-06-30T16:13:48.699Z" }, + { url = "https://files.pythonhosted.org/packages/75/30/c686d0f978d5fd6867c5bbad96b015c9445746764d1c228e16a2d30d9382/librt-0.12.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:c09e581b1c2b8a62b809d4f4bd101ca3de93791e5b0ed1a14085d911be3dee3f", size = 153897, upload-time = "2026-06-30T16:13:50.017Z" }, + { url = "https://files.pythonhosted.org/packages/40/46/f6f2d77ce46628b48fb5280709013b5109cf3a2c46a2472093cdfc03519d/librt-0.12.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:976888d0d831402086e641018bcc3208e0a38f0835789da91f72894b2cb4161f", size = 156391, upload-time = "2026-06-30T16:13:51.462Z" }, + { url = "https://files.pythonhosted.org/packages/c2/46/cd790c7e19e460779471530ffab454541d6ea4a3b7d338cad7f16ff96995/librt-0.12.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:563c37cdb41d08fe1e3f08b201abac0e317ca18e88b91285466ee0a585797520", size = 564151, upload-time = "2026-06-30T16:13:53.146Z" }, + { url = "https://files.pythonhosted.org/packages/54/12/724559a15fb023cbdef7aee1e81fbfbc3ee22fd09009baa816cea63e3a60/librt-0.12.0-cp314-cp314t-manylinux2014_i686.manylinux_2_17_i686.manylinux_2_28_i686.whl", hash = "sha256:b97eb1a3140e279cc76f85b0fb92b7eb3dfbe0471260ee878bc9dc4bf9a0d649", size = 546002, upload-time = "2026-06-30T16:13:54.665Z" }, + { url = "https://files.pythonhosted.org/packages/4b/7e/f9d8c257ab4909f101c7c13734367749e782fd8625545f0343502c2f09f1/librt-0.12.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:06e0623351ab9904cf628245f99c714586f4dd23dc740b88c8bc670d8401a847", size = 584204, upload-time = "2026-06-30T16:13:56.301Z" }, + { url = "https://files.pythonhosted.org/packages/9b/33/64665810575ac23b6cb6ef364de51309b7803620c12885b6e895ebc29591/librt-0.12.0-cp314-cp314t-manylinux_2_34_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:da12f017b2e404554be14d466cd992459feaa44f252b0f18d909a85266ce1237", size = 573688, upload-time = "2026-06-30T16:13:58.1Z" }, + { url = "https://files.pythonhosted.org/packages/0f/01/27522995c6627455abc7a939d57535fb1a7836d398ccedb3d7585f46039e/librt-0.12.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d97f31003a5c86b9e78155a829572c3a26484064fb7ac1d9695fe628bd93d029", size = 604719, upload-time = "2026-06-30T16:13:59.831Z" }, + { url = "https://files.pythonhosted.org/packages/ee/1f/099e61b1b688551d6d2ce9d4d2ae2242a938759db8551e6cbac7f7176ee5/librt-0.12.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:bd43a6c69876aef4f04eaae3d3b99b0be64755fda274002fa445b92480bf664e", size = 598183, upload-time = "2026-06-30T16:14:01.457Z" }, + { url = "https://files.pythonhosted.org/packages/bf/c1/050400249665503bdd5b83cec518fa7b183b609341c8dcd58161775c4226/librt-0.12.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:c01755c72fca1dc6b8d5c2ed228b8e7b2ffe184675c22f0f05ebd8fe188b9250", size = 582559, upload-time = "2026-06-30T16:14:03.29Z" }, + { url = "https://files.pythonhosted.org/packages/da/d1/eef8f0e6722518b65a3d3bcd9309f9f44e208ce5d6728070820f988e7078/librt-0.12.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:625ae561d5fa36400856dcc27464400d047bc2d5e3446be88f437b03fefd72e4", size = 626375, upload-time = "2026-06-30T16:14:04.957Z" }, + { url = "https://files.pythonhosted.org/packages/8b/78/f0bb41a6f2bbd3c77bdcc66980dc0d69ca1192a0ecec25377afcc5e6db73/librt-0.12.0-cp314-cp314t-win32.whl", hash = "sha256:8d73191883553ee0739741544bf3b00aba2a1224e45d9580b30cbc29e21dc03b", size = 97752, upload-time = "2026-06-30T16:14:06.555Z" }, + { url = "https://files.pythonhosted.org/packages/92/24/e279c27972ab051a070237cfa45728fa51670c3f22f1a4d391711e9f4c31/librt-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:e1cbb037324e759f0afa270229731ff0047772667f3cb38ef5df2cabf0175ede", size = 119562, upload-time = "2026-06-30T16:14:07.908Z" }, + { url = "https://files.pythonhosted.org/packages/06/e6/42a475bfca683b0cd5366f6dd06580062b7e567bb8534d225c877c2f14f3/librt-0.12.0-cp314-cp314t-win_arm64.whl", hash = "sha256:bca1472acbd473eff61059b4409f802c5a1bcb4cd0344d06f939df9c4c125d40", size = 104282, upload-time = "2026-06-30T16:14:09.29Z" }, ] [[package]] @@ -1432,11 +1445,11 @@ wheels = [ [[package]] name = "narwhals" -version = "2.22.1" +version = "2.23.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493, upload-time = "2026-06-05T12:34:34.051Z" } +sdist = { url = "https://files.pythonhosted.org/packages/e8/ac/66ed1fc6e38a0c0f330627ec5c5d597990d6159b6712b82af0ad2c65f06c/narwhals-2.23.0.tar.gz", hash = "sha256:13e7ff5b4bb4a2f77b907c2e4d8a76e273dfc1323a3c997440a2f9fd26aed408", size = 656209, upload-time = "2026-07-01T11:21:53.278Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815, upload-time = "2026-06-05T12:34:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/f4/4e/afc8c31605cb8be1d3bb4438c4d979daa104dab6306cd2b87abe9c3a7299/narwhals-2.23.0-py3-none-any.whl", hash = "sha256:769e7b9ab102c93d8fa019f6b4cd1a657909b04a20bf6210e5a35aae06814ae9", size = 458938, upload-time = "2026-07-01T11:21:51.677Z" }, ] [[package]] @@ -1864,100 +1877,96 @@ wheels = [ [[package]] name = "pillow" -version = "12.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/8c/21/c2bcdd5906101a30244eaffc1b6e6ce71a31bd0742a01eb89e660ebfac2d/pillow-12.2.0.tar.gz", hash = "sha256:a830b1a40919539d07806aa58e1b114df53ddd43213d9c8b75847eee6c0182b5", size = 46987819, upload-time = "2026-04-01T14:46:17.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/3a/aa/d0b28e1c811cd4d5f5c2bfe2e022292bd255ae5744a3b9ac7d6c8f72dd75/pillow-12.2.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:a4e8f36e677d3336f35089648c8955c51c6d386a13cf6ee9c189c5f5bd713a9f", size = 5354355, upload-time = "2026-04-01T14:42:15.402Z" }, - { url = "https://files.pythonhosted.org/packages/27/8e/1d5b39b8ae2bd7650d0c7b6abb9602d16043ead9ebbfef4bc4047454da2a/pillow-12.2.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e589959f10d9824d39b350472b92f0ce3b443c0a3442ebf41c40cb8361c5b97", size = 4695871, upload-time = "2026-04-01T14:42:18.234Z" }, - { url = "https://files.pythonhosted.org/packages/f0/c5/dcb7a6ca6b7d3be41a76958e90018d56c8462166b3ef223150360850c8da/pillow-12.2.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a52edc8bfff4429aaabdf4d9ee0daadbbf8562364f940937b941f87a4290f5ff", size = 6269734, upload-time = "2026-04-01T14:42:20.608Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f1/aa1bb13b2f4eba914e9637893c73f2af8e48d7d4023b9d3750d4c5eb2d0c/pillow-12.2.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:975385f4776fafde056abb318f612ef6285b10a1f12b8570f3647ad0d74b48ec", size = 8076080, upload-time = "2026-04-01T14:42:23.095Z" }, - { url = "https://files.pythonhosted.org/packages/a1/2a/8c79d6a53169937784604a8ae8d77e45888c41537f7f6f65ed1f407fe66d/pillow-12.2.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bd9c0c7a0c681a347b3194c500cb1e6ca9cab053ea4d82a5cf45b6b754560136", size = 6382236, upload-time = "2026-04-01T14:42:25.82Z" }, - { url = "https://files.pythonhosted.org/packages/b5/42/bbcb6051030e1e421d103ce7a8ecadf837aa2f39b8f82ef1a8d37c3d4ebc/pillow-12.2.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:88d387ff40b3ff7c274947ed3125dedf5262ec6919d83946753b5f3d7c67ea4c", size = 7070220, upload-time = "2026-04-01T14:42:28.68Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e1/c2a7d6dd8cfa6b231227da096fd2d58754bab3603b9d73bf609d3c18b64f/pillow-12.2.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:51c4167c34b0d8ba05b547a3bb23578d0ba17b80a5593f93bd8ecb123dd336a3", size = 6493124, upload-time = "2026-04-01T14:42:31.579Z" }, - { url = "https://files.pythonhosted.org/packages/5f/41/7c8617da5d32e1d2f026e509484fdb6f3ad7efaef1749a0c1928adbb099e/pillow-12.2.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:34c0d99ecccea270c04882cb3b86e7b57296079c9a4aff88cb3b33563d95afaa", size = 7194324, upload-time = "2026-04-01T14:42:34.615Z" }, - { url = "https://files.pythonhosted.org/packages/2d/de/a777627e19fd6d62f84070ee1521adde5eeda4855b5cf60fe0b149118bca/pillow-12.2.0-cp310-cp310-win32.whl", hash = "sha256:b85f66ae9eb53e860a873b858b789217ba505e5e405a24b85c0464822fe88032", size = 6376363, upload-time = "2026-04-01T14:42:37.19Z" }, - { url = "https://files.pythonhosted.org/packages/e7/34/fc4cb5204896465842767b96d250c08410f01f2f28afc43b257de842eed5/pillow-12.2.0-cp310-cp310-win_amd64.whl", hash = "sha256:673aa32138f3e7531ccdbca7b3901dba9b70940a19ccecc6a37c77d5fdeb05b5", size = 7083523, upload-time = "2026-04-01T14:42:39.62Z" }, - { url = "https://files.pythonhosted.org/packages/2d/a0/32852d36bc7709f14dc3f64f929a275e958ad8c19a6deba9610d458e28b3/pillow-12.2.0-cp310-cp310-win_arm64.whl", hash = "sha256:3e080565d8d7c671db5802eedfb438e5565ffa40115216eabb8cd52d0ecce024", size = 2463318, upload-time = "2026-04-01T14:42:42.063Z" }, - { url = "https://files.pythonhosted.org/packages/68/e1/748f5663efe6edcfc4e74b2b93edfb9b8b99b67f21a854c3ae416500a2d9/pillow-12.2.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:8be29e59487a79f173507c30ddf57e733a357f67881430449bb32614075a40ab", size = 5354347, upload-time = "2026-04-01T14:42:44.255Z" }, - { url = "https://files.pythonhosted.org/packages/47/a1/d5ff69e747374c33a3b53b9f98cca7889fce1fd03d79cdc4e1bccc6c5a87/pillow-12.2.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:71cde9a1e1551df7d34a25462fc60325e8a11a82cc2e2f54578e5e9a1e153d65", size = 4695873, upload-time = "2026-04-01T14:42:46.452Z" }, - { url = "https://files.pythonhosted.org/packages/df/21/e3fbdf54408a973c7f7f89a23b2cb97a7ef30c61ab4142af31eee6aebc88/pillow-12.2.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f490f9368b6fc026f021db16d7ec2fbf7d89e2edb42e8ec09d2c60505f5729c7", size = 6280168, upload-time = "2026-04-01T14:42:49.228Z" }, - { url = "https://files.pythonhosted.org/packages/d3/f1/00b7278c7dd52b17ad4329153748f87b6756ec195ff786c2bdf12518337d/pillow-12.2.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8bd7903a5f2a4545f6fd5935c90058b89d30045568985a71c79f5fd6edf9b91e", size = 8088188, upload-time = "2026-04-01T14:42:51.735Z" }, - { url = "https://files.pythonhosted.org/packages/ad/cf/220a5994ef1b10e70e85748b75649d77d506499352be135a4989c957b701/pillow-12.2.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3997232e10d2920a68d25191392e3a4487d8183039e1c74c2297f00ed1c50705", size = 6394401, upload-time = "2026-04-01T14:42:54.343Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bd/e51a61b1054f09437acfbc2ff9106c30d1eb76bc1453d428399946781253/pillow-12.2.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e74473c875d78b8e9d5da2a70f7099549f9eb37ded4e2f6a463e60125bccd176", size = 7079655, upload-time = "2026-04-01T14:42:56.954Z" }, - { url = "https://files.pythonhosted.org/packages/6b/3d/45132c57d5fb4b5744567c3817026480ac7fc3ce5d4c47902bc0e7f6f853/pillow-12.2.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:56a3f9c60a13133a98ecff6197af34d7824de9b7b38c3654861a725c970c197b", size = 6503105, upload-time = "2026-04-01T14:42:59.847Z" }, - { url = "https://files.pythonhosted.org/packages/7d/2e/9df2fc1e82097b1df3dce58dc43286aa01068e918c07574711fcc53e6fb4/pillow-12.2.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:90e6f81de50ad6b534cab6e5aef77ff6e37722b2f5d908686f4a5c9eba17a909", size = 7203402, upload-time = "2026-04-01T14:43:02.664Z" }, - { url = "https://files.pythonhosted.org/packages/bd/2e/2941e42858ebb67e50ae741473de81c2984e6eff7b397017623c676e2e8d/pillow-12.2.0-cp311-cp311-win32.whl", hash = "sha256:8c984051042858021a54926eb597d6ee3012393ce9c181814115df4c60b9a808", size = 6378149, upload-time = "2026-04-01T14:43:05.274Z" }, - { url = "https://files.pythonhosted.org/packages/69/42/836b6f3cd7f3e5fa10a1f1a5420447c17966044c8fbf589cc0452d5502db/pillow-12.2.0-cp311-cp311-win_amd64.whl", hash = "sha256:6e6b2a0c538fc200b38ff9eb6628228b77908c319a005815f2dde585a0664b60", size = 7082626, upload-time = "2026-04-01T14:43:08.557Z" }, - { url = "https://files.pythonhosted.org/packages/c2/88/549194b5d6f1f494b485e493edc6693c0a16f4ada488e5bd974ed1f42fad/pillow-12.2.0-cp311-cp311-win_arm64.whl", hash = "sha256:9a8a34cc89c67a65ea7437ce257cea81a9dad65b29805f3ecee8c8fe8ff25ffe", size = 2463531, upload-time = "2026-04-01T14:43:10.743Z" }, - { url = "https://files.pythonhosted.org/packages/58/be/7482c8a5ebebbc6470b3eb791812fff7d5e0216c2be3827b30b8bb6603ed/pillow-12.2.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2d192a155bbcec180f8564f693e6fd9bccff5a7af9b32e2e4bf8c9c69dbad6b5", size = 5308279, upload-time = "2026-04-01T14:43:13.246Z" }, - { url = "https://files.pythonhosted.org/packages/d8/95/0a351b9289c2b5cbde0bacd4a83ebc44023e835490a727b2a3bd60ddc0f4/pillow-12.2.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f3f40b3c5a968281fd507d519e444c35f0ff171237f4fdde090dd60699458421", size = 4695490, upload-time = "2026-04-01T14:43:15.584Z" }, - { url = "https://files.pythonhosted.org/packages/de/af/4e8e6869cbed569d43c416fad3dc4ecb944cb5d9492defaed89ddd6fe871/pillow-12.2.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:03e7e372d5240cc23e9f07deca4d775c0817bffc641b01e9c3af208dbd300987", size = 6284462, upload-time = "2026-04-01T14:43:18.268Z" }, - { url = "https://files.pythonhosted.org/packages/e9/9e/c05e19657fd57841e476be1ab46c4d501bffbadbafdc31a6d665f8b737b6/pillow-12.2.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b86024e52a1b269467a802258c25521e6d742349d760728092e1bc2d135b4d76", size = 8094744, upload-time = "2026-04-01T14:43:20.716Z" }, - { url = "https://files.pythonhosted.org/packages/2b/54/1789c455ed10176066b6e7e6da1b01e50e36f94ba584dc68d9eebfe9156d/pillow-12.2.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7371b48c4fa448d20d2714c9a1f775a81155050d383333e0a6c15b1123dda005", size = 6398371, upload-time = "2026-04-01T14:43:23.443Z" }, - { url = "https://files.pythonhosted.org/packages/43/e3/fdc657359e919462369869f1c9f0e973f353f9a9ee295a39b1fea8ee1a77/pillow-12.2.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:62f5409336adb0663b7caa0da5c7d9e7bdbaae9ce761d34669420c2a801b2780", size = 7087215, upload-time = "2026-04-01T14:43:26.758Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f8/2f6825e441d5b1959d2ca5adec984210f1ec086435b0ed5f52c19b3b8a6e/pillow-12.2.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:01afa7cf67f74f09523699b4e88c73fb55c13346d212a59a2db1f86b0a63e8c5", size = 6509783, upload-time = "2026-04-01T14:43:29.56Z" }, - { url = "https://files.pythonhosted.org/packages/67/f9/029a27095ad20f854f9dba026b3ea6428548316e057e6fc3545409e86651/pillow-12.2.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fc3d34d4a8fbec3e88a79b92e5465e0f9b842b628675850d860b8bd300b159f5", size = 7212112, upload-time = "2026-04-01T14:43:32.091Z" }, - { url = "https://files.pythonhosted.org/packages/be/42/025cfe05d1be22dbfdb4f264fe9de1ccda83f66e4fc3aac94748e784af04/pillow-12.2.0-cp312-cp312-win32.whl", hash = "sha256:58f62cc0f00fd29e64b29f4fd923ffdb3859c9f9e6105bfc37ba1d08994e8940", size = 6378489, upload-time = "2026-04-01T14:43:34.601Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7b/25a221d2c761c6a8ae21bfa3874988ff2583e19cf8a27bf2fee358df7942/pillow-12.2.0-cp312-cp312-win_amd64.whl", hash = "sha256:7f84204dee22a783350679a0333981df803dac21a0190d706a50475e361c93f5", size = 7084129, upload-time = "2026-04-01T14:43:37.213Z" }, - { url = "https://files.pythonhosted.org/packages/10/e1/542a474affab20fd4a0f1836cb234e8493519da6b76899e30bcc5d990b8b/pillow-12.2.0-cp312-cp312-win_arm64.whl", hash = "sha256:af73337013e0b3b46f175e79492d96845b16126ddf79c438d7ea7ff27783a414", size = 2463612, upload-time = "2026-04-01T14:43:39.421Z" }, - { url = "https://files.pythonhosted.org/packages/4a/01/53d10cf0dbad820a8db274d259a37ba50b88b24768ddccec07355382d5ad/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:8297651f5b5679c19968abefd6bb84d95fe30ef712eb1b2d9b2d31ca61267f4c", size = 4100837, upload-time = "2026-04-01T14:43:41.506Z" }, - { url = "https://files.pythonhosted.org/packages/0f/98/f3a6657ecb698c937f6c76ee564882945f29b79bad496abcba0e84659ec5/pillow-12.2.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:50d8520da2a6ce0af445fa6d648c4273c3eeefbc32d7ce049f22e8b5c3daecc2", size = 4176528, upload-time = "2026-04-01T14:43:43.773Z" }, - { url = "https://files.pythonhosted.org/packages/69/bc/8986948f05e3ea490b8442ea1c1d4d990b24a7e43d8a51b2c7d8b1dced36/pillow-12.2.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:766cef22385fa1091258ad7e6216792b156dc16d8d3fa607e7545b2b72061f1c", size = 3640401, upload-time = "2026-04-01T14:43:45.87Z" }, - { url = "https://files.pythonhosted.org/packages/34/46/6c717baadcd62bc8ed51d238d521ab651eaa74838291bda1f86fe1f864c9/pillow-12.2.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5d2fd0fa6b5d9d1de415060363433f28da8b1526c1c129020435e186794b3795", size = 5308094, upload-time = "2026-04-01T14:43:48.438Z" }, - { url = "https://files.pythonhosted.org/packages/71/43/905a14a8b17fdb1ccb58d282454490662d2cb89a6bfec26af6d3520da5ec/pillow-12.2.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56b25336f502b6ed02e889f4ece894a72612fe885889a6e8c4c80239ff6e5f5f", size = 4695402, upload-time = "2026-04-01T14:43:51.292Z" }, - { url = "https://files.pythonhosted.org/packages/73/dd/42107efcb777b16fa0393317eac58f5b5cf30e8392e266e76e51cff28c3d/pillow-12.2.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f1c943e96e85df3d3478f7b691f229887e143f81fedab9b20205349ab04d73ed", size = 6280005, upload-time = "2026-04-01T14:43:54.242Z" }, - { url = "https://files.pythonhosted.org/packages/a8/68/b93e09e5e8549019e61acf49f65b1a8530765a7f812c77a7461bca7e4494/pillow-12.2.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:03f6fab9219220f041c74aeaa2939ff0062bd5c364ba9ce037197f4c6d498cd9", size = 8090669, upload-time = "2026-04-01T14:43:57.335Z" }, - { url = "https://files.pythonhosted.org/packages/4b/6e/3ccb54ce8ec4ddd1accd2d89004308b7b0b21c4ac3d20fa70af4760a4330/pillow-12.2.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5cdfebd752ec52bf5bb4e35d9c64b40826bc5b40a13df7c3cda20a2c03a0f5ed", size = 6395194, upload-time = "2026-04-01T14:43:59.864Z" }, - { url = "https://files.pythonhosted.org/packages/67/ee/21d4e8536afd1a328f01b359b4d3997b291ffd35a237c877b331c1c3b71c/pillow-12.2.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eedf4b74eda2b5a4b2b2fb4c006d6295df3bf29e459e198c90ea48e130dc75c3", size = 7082423, upload-time = "2026-04-01T14:44:02.74Z" }, - { url = "https://files.pythonhosted.org/packages/78/5f/e9f86ab0146464e8c133fe85df987ed9e77e08b29d8d35f9f9f4d6f917ba/pillow-12.2.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:00a2865911330191c0b818c59103b58a5e697cae67042366970a6b6f1b20b7f9", size = 6505667, upload-time = "2026-04-01T14:44:05.381Z" }, - { url = "https://files.pythonhosted.org/packages/ed/1e/409007f56a2fdce61584fd3acbc2bbc259857d555196cedcadc68c015c82/pillow-12.2.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1e1757442ed87f4912397c6d35a0db6a7b52592156014706f17658ff58bbf795", size = 7208580, upload-time = "2026-04-01T14:44:08.39Z" }, - { url = "https://files.pythonhosted.org/packages/23/c4/7349421080b12fb35414607b8871e9534546c128a11965fd4a7002ccfbee/pillow-12.2.0-cp313-cp313-win32.whl", hash = "sha256:144748b3af2d1b358d41286056d0003f47cb339b8c43a9ea42f5fea4d8c66b6e", size = 6375896, upload-time = "2026-04-01T14:44:11.197Z" }, - { url = "https://files.pythonhosted.org/packages/3f/82/8a3739a5e470b3c6cbb1d21d315800d8e16bff503d1f16b03a4ec3212786/pillow-12.2.0-cp313-cp313-win_amd64.whl", hash = "sha256:390ede346628ccc626e5730107cde16c42d3836b89662a115a921f28440e6a3b", size = 7081266, upload-time = "2026-04-01T14:44:13.947Z" }, - { url = "https://files.pythonhosted.org/packages/c3/25/f968f618a062574294592f668218f8af564830ccebdd1fa6200f598e65c5/pillow-12.2.0-cp313-cp313-win_arm64.whl", hash = "sha256:8023abc91fba39036dbce14a7d6535632f99c0b857807cbbbf21ecc9f4717f06", size = 2463508, upload-time = "2026-04-01T14:44:16.312Z" }, - { url = "https://files.pythonhosted.org/packages/4d/a4/b342930964e3cb4dce5038ae34b0eab4653334995336cd486c5a8c25a00c/pillow-12.2.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:042db20a421b9bafecc4b84a8b6e444686bd9d836c7fd24542db3e7df7baad9b", size = 5309927, upload-time = "2026-04-01T14:44:18.89Z" }, - { url = "https://files.pythonhosted.org/packages/9f/de/23198e0a65a9cf06123f5435a5d95cea62a635697f8f03d134d3f3a96151/pillow-12.2.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:dd025009355c926a84a612fecf58bb315a3f6814b17ead51a8e48d3823d9087f", size = 4698624, upload-time = "2026-04-01T14:44:21.115Z" }, - { url = "https://files.pythonhosted.org/packages/01/a6/1265e977f17d93ea37aa28aa81bad4fa597933879fac2520d24e021c8da3/pillow-12.2.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:88ddbc66737e277852913bd1e07c150cc7bb124539f94c4e2df5344494e0a612", size = 6321252, upload-time = "2026-04-01T14:44:23.663Z" }, - { url = "https://files.pythonhosted.org/packages/3c/83/5982eb4a285967baa70340320be9f88e57665a387e3a53a7f0db8231a0cd/pillow-12.2.0-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d362d1878f00c142b7e1a16e6e5e780f02be8195123f164edf7eddd911eefe7c", size = 8126550, upload-time = "2026-04-01T14:44:26.772Z" }, - { url = "https://files.pythonhosted.org/packages/4e/48/6ffc514adce69f6050d0753b1a18fd920fce8cac87620d5a31231b04bfc5/pillow-12.2.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c727a6d53cb0018aadd8018c2b938376af27914a68a492f59dfcaca650d5eea", size = 6433114, upload-time = "2026-04-01T14:44:29.615Z" }, - { url = "https://files.pythonhosted.org/packages/36/a3/f9a77144231fb8d40ee27107b4463e205fa4677e2ca2548e14da5cf18dce/pillow-12.2.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:efd8c21c98c5cc60653bcb311bef2ce0401642b7ce9d09e03a7da87c878289d4", size = 7115667, upload-time = "2026-04-01T14:44:32.773Z" }, - { url = "https://files.pythonhosted.org/packages/c1/fc/ac4ee3041e7d5a565e1c4fd72a113f03b6394cc72ab7089d27608f8aaccb/pillow-12.2.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9f08483a632889536b8139663db60f6724bfcb443c96f1b18855860d7d5c0fd4", size = 6538966, upload-time = "2026-04-01T14:44:35.252Z" }, - { url = "https://files.pythonhosted.org/packages/c0/a8/27fb307055087f3668f6d0a8ccb636e7431d56ed0750e07a60547b1e083e/pillow-12.2.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dac8d77255a37e81a2efcbd1fc05f1c15ee82200e6c240d7e127e25e365c39ea", size = 7238241, upload-time = "2026-04-01T14:44:37.875Z" }, - { url = "https://files.pythonhosted.org/packages/ad/4b/926ab182c07fccae9fcb120043464e1ff1564775ec8864f21a0ebce6ac25/pillow-12.2.0-cp313-cp313t-win32.whl", hash = "sha256:ee3120ae9dff32f121610bb08e4313be87e03efeadfc6c0d18f89127e24d0c24", size = 6379592, upload-time = "2026-04-01T14:44:40.336Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c4/f9e476451a098181b30050cc4c9a3556b64c02cf6497ea421ac047e89e4b/pillow-12.2.0-cp313-cp313t-win_amd64.whl", hash = "sha256:325ca0528c6788d2a6c3d40e3568639398137346c3d6e66bb61db96b96511c98", size = 7085542, upload-time = "2026-04-01T14:44:43.251Z" }, - { url = "https://files.pythonhosted.org/packages/00/a4/285f12aeacbe2d6dc36c407dfbbe9e96d4a80b0fb710a337f6d2ad978c75/pillow-12.2.0-cp313-cp313t-win_arm64.whl", hash = "sha256:2e5a76d03a6c6dcef67edabda7a52494afa4035021a79c8558e14af25313d453", size = 2465765, upload-time = "2026-04-01T14:44:45.996Z" }, - { url = "https://files.pythonhosted.org/packages/bf/98/4595daa2365416a86cb0d495248a393dfc84e96d62ad080c8546256cb9c0/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:3adc9215e8be0448ed6e814966ecf3d9952f0ea40eb14e89a102b87f450660d8", size = 4100848, upload-time = "2026-04-01T14:44:48.48Z" }, - { url = "https://files.pythonhosted.org/packages/0b/79/40184d464cf89f6663e18dfcf7ca21aae2491fff1a16127681bf1fa9b8cf/pillow-12.2.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:6a9adfc6d24b10f89588096364cc726174118c62130c817c2837c60cf08a392b", size = 4176515, upload-time = "2026-04-01T14:44:51.353Z" }, - { url = "https://files.pythonhosted.org/packages/b0/63/703f86fd4c422a9cf722833670f4f71418fb116b2853ff7da722ea43f184/pillow-12.2.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:6a6e67ea2e6feda684ed370f9a1c52e7a243631c025ba42149a2cc5934dec295", size = 3640159, upload-time = "2026-04-01T14:44:53.588Z" }, - { url = "https://files.pythonhosted.org/packages/71/e0/fb22f797187d0be2270f83500aab851536101b254bfa1eae10795709d283/pillow-12.2.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2bb4a8d594eacdfc59d9e5ad972aa8afdd48d584ffd5f13a937a664c3e7db0ed", size = 5312185, upload-time = "2026-04-01T14:44:56.039Z" }, - { url = "https://files.pythonhosted.org/packages/ba/8c/1a9e46228571de18f8e28f16fabdfc20212a5d019f3e3303452b3f0a580d/pillow-12.2.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:80b2da48193b2f33ed0c32c38140f9d3186583ce7d516526d462645fd98660ae", size = 4695386, upload-time = "2026-04-01T14:44:58.663Z" }, - { url = "https://files.pythonhosted.org/packages/70/62/98f6b7f0c88b9addd0e87c217ded307b36be024d4ff8869a812b241d1345/pillow-12.2.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:22db17c68434de69d8ecfc2fe821569195c0c373b25cccb9cbdacf2c6e53c601", size = 6280384, upload-time = "2026-04-01T14:45:01.5Z" }, - { url = "https://files.pythonhosted.org/packages/5e/03/688747d2e91cfbe0e64f316cd2e8005698f76ada3130d0194664174fa5de/pillow-12.2.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:7b14cc0106cd9aecda615dd6903840a058b4700fcb817687d0ee4fc8b6e389be", size = 8091599, upload-time = "2026-04-01T14:45:04.5Z" }, - { url = "https://files.pythonhosted.org/packages/f6/35/577e22b936fcdd66537329b33af0b4ccfefaeabd8aec04b266528cddb33c/pillow-12.2.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8cbeb542b2ebc6fcdacabf8aca8c1a97c9b3ad3927d46b8723f9d4f033288a0f", size = 6396021, upload-time = "2026-04-01T14:45:07.117Z" }, - { url = "https://files.pythonhosted.org/packages/11/8d/d2532ad2a603ca2b93ad9f5135732124e57811d0168155852f37fbce2458/pillow-12.2.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4bfd07bc812fbd20395212969e41931001fd59eb55a60658b0e5710872e95286", size = 7083360, upload-time = "2026-04-01T14:45:09.763Z" }, - { url = "https://files.pythonhosted.org/packages/5e/26/d325f9f56c7e039034897e7380e9cc202b1e368bfd04d4cbe6a441f02885/pillow-12.2.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9aba9a17b623ef750a4d11b742cbafffeb48a869821252b30ee21b5e91392c50", size = 6507628, upload-time = "2026-04-01T14:45:12.378Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f7/769d5632ffb0988f1c5e7660b3e731e30f7f8ec4318e94d0a5d674eb65a4/pillow-12.2.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:deede7c263feb25dba4e82ea23058a235dcc2fe1f6021025dc71f2b618e26104", size = 7209321, upload-time = "2026-04-01T14:45:15.122Z" }, - { url = "https://files.pythonhosted.org/packages/6a/7a/c253e3c645cd47f1aceea6a8bacdba9991bf45bb7dfe927f7c893e89c93c/pillow-12.2.0-cp314-cp314-win32.whl", hash = "sha256:632ff19b2778e43162304d50da0181ce24ac5bb8180122cbe1bf4673428328c7", size = 6479723, upload-time = "2026-04-01T14:45:17.797Z" }, - { url = "https://files.pythonhosted.org/packages/cd/8b/601e6566b957ca50e28725cb6c355c59c2c8609751efbecd980db44e0349/pillow-12.2.0-cp314-cp314-win_amd64.whl", hash = "sha256:4e6c62e9d237e9b65fac06857d511e90d8461a32adcc1b9065ea0c0fa3a28150", size = 7217400, upload-time = "2026-04-01T14:45:20.529Z" }, - { url = "https://files.pythonhosted.org/packages/d6/94/220e46c73065c3e2951bb91c11a1fb636c8c9ad427ac3ce7d7f3359b9b2f/pillow-12.2.0-cp314-cp314-win_arm64.whl", hash = "sha256:b1c1fbd8a5a1af3412a0810d060a78b5136ec0836c8a4ef9aa11807f2a22f4e1", size = 2554835, upload-time = "2026-04-01T14:45:23.162Z" }, - { url = "https://files.pythonhosted.org/packages/b6/ab/1b426a3974cb0e7da5c29ccff4807871d48110933a57207b5a676cccc155/pillow-12.2.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:57850958fe9c751670e49b2cecf6294acc99e562531f4bd317fa5ddee2068463", size = 5314225, upload-time = "2026-04-01T14:45:25.637Z" }, - { url = "https://files.pythonhosted.org/packages/19/1e/dce46f371be2438eecfee2a1960ee2a243bbe5e961890146d2dee1ff0f12/pillow-12.2.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:d5d38f1411c0ed9f97bcb49b7bd59b6b7c314e0e27420e34d99d844b9ce3b6f3", size = 4698541, upload-time = "2026-04-01T14:45:28.355Z" }, - { url = "https://files.pythonhosted.org/packages/55/c3/7fbecf70adb3a0c33b77a300dc52e424dc22ad8cdc06557a2e49523b703d/pillow-12.2.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5c0a9f29ca8e79f09de89293f82fc9b0270bb4af1d58bc98f540cc4aedf03166", size = 6322251, upload-time = "2026-04-01T14:45:30.924Z" }, - { url = "https://files.pythonhosted.org/packages/1c/3c/7fbc17cfb7e4fe0ef1642e0abc17fc6c94c9f7a16be41498e12e2ba60408/pillow-12.2.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1610dd6c61621ae1cf811bef44d77e149ce3f7b95afe66a4512f8c59f25d9ebe", size = 8127807, upload-time = "2026-04-01T14:45:33.908Z" }, - { url = "https://files.pythonhosted.org/packages/ff/c3/a8ae14d6defd2e448493ff512fae903b1e9bd40b72efb6ec55ce0048c8ce/pillow-12.2.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0a34329707af4f73cf1782a36cd2289c0368880654a2c11f027bcee9052d35dd", size = 6433935, upload-time = "2026-04-01T14:45:36.623Z" }, - { url = "https://files.pythonhosted.org/packages/6e/32/2880fb3a074847ac159d8f902cb43278a61e85f681661e7419e6596803ed/pillow-12.2.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8e9c4f5b3c546fa3458a29ab22646c1c6c787ea8f5ef51300e5a60300736905e", size = 7116720, upload-time = "2026-04-01T14:45:39.258Z" }, - { url = "https://files.pythonhosted.org/packages/46/87/495cc9c30e0129501643f24d320076f4cc54f718341df18cc70ec94c44e1/pillow-12.2.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:fb043ee2f06b41473269765c2feae53fc2e2fbf96e5e22ca94fb5ad677856f06", size = 6540498, upload-time = "2026-04-01T14:45:41.879Z" }, - { url = "https://files.pythonhosted.org/packages/18/53/773f5edca692009d883a72211b60fdaf8871cbef075eaa9d577f0a2f989e/pillow-12.2.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f278f034eb75b4e8a13a54a876cc4a5ab39173d2cdd93a638e1b467fc545ac43", size = 7239413, upload-time = "2026-04-01T14:45:44.705Z" }, - { url = "https://files.pythonhosted.org/packages/c9/e4/4b64a97d71b2a83158134abbb2f5bd3f8a2ea691361282f010998f339ec7/pillow-12.2.0-cp314-cp314t-win32.whl", hash = "sha256:6bb77b2dcb06b20f9f4b4a8454caa581cd4dd0643a08bacf821216a16d9c8354", size = 6482084, upload-time = "2026-04-01T14:45:47.568Z" }, - { url = "https://files.pythonhosted.org/packages/ba/13/306d275efd3a3453f72114b7431c877d10b1154014c1ebbedd067770d629/pillow-12.2.0-cp314-cp314t-win_amd64.whl", hash = "sha256:6562ace0d3fb5f20ed7290f1f929cae41b25ae29528f2af1722966a0a02e2aa1", size = 7225152, upload-time = "2026-04-01T14:45:50.032Z" }, - { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, - { url = "https://files.pythonhosted.org/packages/4e/b7/2437044fb910f499610356d1352e3423753c98e34f915252aafecc64889f/pillow-12.2.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0538bd5e05efec03ae613fd89c4ce0368ecd2ba239cc25b9f9be7ed426b0af1f", size = 5273969, upload-time = "2026-04-01T14:45:55.538Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f4/8316e31de11b780f4ac08ef3654a75555e624a98db1056ecb2122d008d5a/pillow-12.2.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:394167b21da716608eac917c60aa9b969421b5dcbbe02ae7f013e7b85811c69d", size = 4659674, upload-time = "2026-04-01T14:45:58.093Z" }, - { url = "https://files.pythonhosted.org/packages/d4/37/664fca7201f8bb2aa1d20e2c3d5564a62e6ae5111741966c8319ca802361/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:5d04bfa02cc2d23b497d1e90a0f927070043f6cbf303e738300532379a4b4e0f", size = 5288479, upload-time = "2026-04-01T14:46:01.141Z" }, - { url = "https://files.pythonhosted.org/packages/49/62/5b0ed78fce87346be7a5cfcfaaad91f6a1f98c26f86bdbafa2066c647ef6/pillow-12.2.0-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0c838a5125cee37e68edec915651521191cef1e6aa336b855f495766e77a366e", size = 7032230, upload-time = "2026-04-01T14:46:03.874Z" }, - { url = "https://files.pythonhosted.org/packages/c3/28/ec0fc38107fc32536908034e990c47914c57cd7c5a3ece4d8d8f7ffd7e27/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4a6c9fa44005fa37a91ebfc95d081e8079757d2e904b27103f4f5fa6f0bf78c0", size = 5355404, upload-time = "2026-04-01T14:46:06.33Z" }, - { url = "https://files.pythonhosted.org/packages/5e/8b/51b0eddcfa2180d60e41f06bd6d0a62202b20b59c68f5a132e615b75aecf/pillow-12.2.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:25373b66e0dd5905ed63fa3cae13c82fbddf3079f2c8bf15c6fb6a35586324c1", size = 6002215, upload-time = "2026-04-01T14:46:08.83Z" }, - { url = "https://files.pythonhosted.org/packages/bc/60/5382c03e1970de634027cee8e1b7d39776b778b81812aaf45b694dfe9e28/pillow-12.2.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:bfa9c230d2fe991bed5318a5f119bd6780cda2915cca595393649fc118ab895e", size = 7080946, upload-time = "2026-04-01T14:46:11.734Z" }, +version = "12.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1c/3d/bb7fca845737cf9d7dbde16ed1843984665ff2e0a518f5db43e77ec540b9/pillow-12.3.0.tar.gz", hash = "sha256:3b8182a766685eaa002637e28b4ec8d6b18819a0c71f579bf0dbaa5830297cce", size = 47025035, upload-time = "2026-07-01T11:56:38.965Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/25/c2/669d88644cddb1485bd9534e63e8cf476c8e51cb3c3a1297677023505c0e/pillow-12.3.0-cp310-cp310-macosx_10_10_x86_64.whl", hash = "sha256:6c0016e7b354317c4e9e525b937ac8596c38d2d232b419529b9cd7a1cd46e39a", size = 5392418, upload-time = "2026-07-01T11:53:27.808Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ba/3762f376a2948e3036488d773a146e0ae6ecc2ca03ac20e2615bd0b2ba02/pillow-12.3.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:bcc33feacfaefce60c12fd500a277533bdc02b10a19f7f6d348763d8140bbba7", size = 4785287, upload-time = "2026-07-01T11:53:29.761Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/b5d688cc9c52d4482f3d5bcab6ce20bc2a74a85d2343841c907444a3be2c/pillow-12.3.0-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5594fc43d548a7ed94949d139aa1341b270f1863f11cfd37f5a6c8b778a6b67f", size = 6253754, upload-time = "2026-07-01T11:53:32.298Z" }, + { url = "https://files.pythonhosted.org/packages/4e/89/36f4cd76cf4baf05c50ababb976249153f18c959171c7f6ba09a6f217260/pillow-12.3.0-cp310-cp310-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f0606c8bf2cdefea14a43530f7657cbbb7ecf1c4222512492ef4a4434a9501ec", size = 6925605, upload-time = "2026-07-01T11:53:34.487Z" }, + { url = "https://files.pythonhosted.org/packages/eb/c0/4de58cf6633b9e3a6061ef4be6fb91fc3c90b812ece886f531e3c523d777/pillow-12.3.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:85f998ea1848bc6757289e739cfbdda3a04adfd58b02fc018ce54d754a5ce468", size = 6327788, upload-time = "2026-07-01T11:53:36.433Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/14d53682a19550dbbaf3b598f807d5457646c510805a44c7d7891cd1cd1a/pillow-12.3.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:25b9b82bb22e6e2b3cd07b39c68b7b862001226cb3dff7130d1cb914121b39ed", size = 7036288, upload-time = "2026-07-01T11:53:38.712Z" }, + { url = "https://files.pythonhosted.org/packages/38/1d/36279e3c77efe034e4cc2b0393ee74ffdb5a62391dacbf9b916154f5f0b8/pillow-12.3.0-cp310-cp310-win32.whl", hash = "sha256:37dc8f7bbb66efe481bb60defacef820c950c24713fb44962ed6aa2a50966de1", size = 6472396, upload-time = "2026-07-01T11:53:40.781Z" }, + { url = "https://files.pythonhosted.org/packages/48/7c/8fa0039574c476d7c6fa57dd7c32a130436877c6ec1e5ce1cc8ec44878c1/pillow-12.3.0-cp310-cp310-win_amd64.whl", hash = "sha256:300557495eb45ebb8aec96c2da9c4be642fbf7cd937278b4013ba894ea8eb0eb", size = 7226887, upload-time = "2026-07-01T11:53:42.764Z" }, + { url = "https://files.pythonhosted.org/packages/fa/17/e324be141d173c1c919428066c3259f21c1b8982e564e01a4a81e96dbdcf/pillow-12.3.0-cp310-cp310-win_arm64.whl", hash = "sha256:514435a37670e3e5e08f3945b68718b6ed329bb84367777e16f9f4dfe1e61a0f", size = 2568039, upload-time = "2026-07-01T11:53:45.372Z" }, + { url = "https://files.pythonhosted.org/packages/fb/c8/0a78b0e02d7ac54bc03e5321c9220da52f0c2ea83b21f7c40e7f3169c502/pillow-12.3.0-cp311-cp311-macosx_10_10_x86_64.whl", hash = "sha256:00808c5e14ef63ac5161091d242999076604ff74b883423a11e5d7bbb38bf756", size = 5392415, upload-time = "2026-07-01T11:53:47.162Z" }, + { url = "https://files.pythonhosted.org/packages/b2/5b/a02d30018abd97ced9f5a6c63d28597694a00d066516b9c1c6de45859fc9/pillow-12.3.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:37d6d0a00072fd2948eb22bce7e1475f34569d90c87c59f7a2ec59541b77f7a6", size = 4785266, upload-time = "2026-07-01T11:53:49.079Z" }, + { url = "https://files.pythonhosted.org/packages/c8/98/766667a4be768150a202836acd9fad19c06824ca86c4286d3cf6b274964e/pillow-12.3.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bcb46e2f9feff8d06323983bd83ed00c201fdcab3d74973e7072a889b3979fcd", size = 6263814, upload-time = "2026-07-01T11:53:51.32Z" }, + { url = "https://files.pythonhosted.org/packages/3b/2d/ede717bc1144f63886c21fd349bb95860b0d1a21149ff16f2bb362b612b6/pillow-12.3.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23d27a3e0307ec2244cc51e7287b919aa68d097504ebe19df4e76a98a3eea5bd", size = 6934408, upload-time = "2026-07-01T11:53:53.487Z" }, + { url = "https://files.pythonhosted.org/packages/a3/48/9c58b685e69d49c31af6c8eb9012055fab7e665785165c84796e2c73ce72/pillow-12.3.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4f883547d4b7f0495ebe7056b0cc2aea76094e7a4abc8e933540f3271df27d9c", size = 6337160, upload-time = "2026-07-01T11:53:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fa/dc2a5c0ba6df93f67c31d34b808b7ce440b40cdbf96f0b81cde1d1e6fa93/pillow-12.3.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:236ff70b9312fb68943c703aa842ca6a758abfa45ac187a5e7c1452e96ef72b5", size = 7045172, upload-time = "2026-07-01T11:53:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/86/a5/444817a4d4c4c2417df00513086ca196f388d8f9ef40c2e4ccd1ad1af54b/pillow-12.3.0-cp311-cp311-win32.whl", hash = "sha256:10e41f0fbf1eec8cfd234b8fe17a4caac7c9d0db4c204d3c173a8f9f6ef3232b", size = 6472232, upload-time = "2026-07-01T11:53:59.767Z" }, + { url = "https://files.pythonhosted.org/packages/63/c6/4bad1b18d132a50b27e1365e1ab163616f7a5bb56d330f66f9d1d9d4f9d4/pillow-12.3.0-cp311-cp311-win_amd64.whl", hash = "sha256:8e95e1385e4998ae9694eeaa4730ba5457ff61185b3a55e2e7bea0880aef452a", size = 7233653, upload-time = "2026-07-01T11:54:02.066Z" }, + { url = "https://files.pythonhosted.org/packages/fd/16/00f91ab7760dc842f5aad55217e80fc4a7067a0604535249bc8a2d6d9870/pillow-12.3.0-cp311-cp311-win_arm64.whl", hash = "sha256:ebaea975e03d3141d9d3a507df75c9b3ec90fa9d2ffd07567b3a978d9d790b26", size = 2568195, upload-time = "2026-07-01T11:54:04.622Z" }, + { url = "https://files.pythonhosted.org/packages/37/bf/fb3ebff8ddcb76aac5a01389251bbbb9519922a9b520d8247c1ca864a25d/pillow-12.3.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:ba09209fbe443b4acccebe845d8a138b89a8f4fbaeedd44953490b5315d5e965", size = 5345969, upload-time = "2026-07-01T11:54:06.397Z" }, + { url = "https://files.pythonhosted.org/packages/d8/66/9a386a92561f402389a4fc70c18838bf6d35eb5eb5c6850b4b2dc64f5048/pillow-12.3.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ffd0c5368496f41b0944be820fcb7a838aa6e623d250b01acf2643939c3f99d7", size = 4780323, upload-time = "2026-07-01T11:54:09.351Z" }, + { url = "https://files.pythonhosted.org/packages/25/27/ac8f99618ffd3dde21db0f4d4b1d2ab00c0880595bfd17df103f7f39fd0c/pillow-12.3.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d9c7f76c0673154f044e9d78c8655fb4213f6ca31a836df48b40fe5d187717b9", size = 6266838, upload-time = "2026-07-01T11:54:11.71Z" }, + { url = "https://files.pythonhosted.org/packages/84/21/a35af28dcc61f37ed850a2d64c65c701321dfbf25085e469d5559360cbbf/pillow-12.3.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:78cb2c6865a35ab8ff8b75fd122f6033b92a62c82801110e48ddd6c936a45d91", size = 6940830, upload-time = "2026-07-01T11:54:13.732Z" }, + { url = "https://files.pythonhosted.org/packages/eb/51/8b08617af3ad95e33ce6d7dd2c99ed6c8298f7fb131636303956be022e25/pillow-12.3.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e491916b378fba47242221bb9ead245211b70d504f495d105d17b14a24b4907c", size = 6344383, upload-time = "2026-07-01T11:54:15.756Z" }, + { url = "https://files.pythonhosted.org/packages/1d/72/cf78ac9780bb93c28328f408973845a309d4d145041665f734572ced1b52/pillow-12.3.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:0dd2064cbc55aaec028ef5fbb60fa47bb6c3e7918e07ff17935284b227a9d2df", size = 7052934, upload-time = "2026-07-01T11:54:17.721Z" }, + { url = "https://files.pythonhosted.org/packages/20/20/25e0f4dc178a6bc0696793720055519a0de89e7661dae886992decbd2f81/pillow-12.3.0-cp312-cp312-win32.whl", hash = "sha256:dbce0b29841537a2fa4a214c2bbf14de3587c9680caa9b4e217568472490b28f", size = 6472684, upload-time = "2026-07-01T11:54:19.839Z" }, + { url = "https://files.pythonhosted.org/packages/45/89/da2f7971a317f83d807fdd4065c0af40208e59e692cc43d315a71a0e96d1/pillow-12.3.0-cp312-cp312-win_amd64.whl", hash = "sha256:a2b55dd6b2a4c4b7d87ffa56bdb33fdc5fdb9a462173861a7bc097f17d91cb09", size = 7227137, upload-time = "2026-07-01T11:54:22.025Z" }, + { url = "https://files.pythonhosted.org/packages/de/47/4845a0a6c0dbf1db8456bd9fc791f13c5ced7ced20606d08a0aacfd25b49/pillow-12.3.0-cp312-cp312-win_arm64.whl", hash = "sha256:331b624368d4f1d069149002f25f44bc61c8919ce8ddb3c45bdad8f6e2d89510", size = 2568267, upload-time = "2026-07-01T11:54:24.051Z" }, + { url = "https://files.pythonhosted.org/packages/9d/ac/31fb64e1e7efb5a4b50cd3d92049ba89ac6e4d8d3bb6a74e15048ca3353e/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:21900ce7ba264168cd50defae43cd75d25c833ad4ad6e73ffc5596d12e25ac89", size = 4161684, upload-time = "2026-07-01T11:54:25.934Z" }, + { url = "https://files.pythonhosted.org/packages/87/b4/9805e23d2b4d77842b468513841fda254ee42f0289d25088340e4ff46e2d/pillow-12.3.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:4e8c2a84d977f50b9daed6eeaf3baef67d00d5d74d932288f02cb94518ee3ace", size = 4255487, upload-time = "2026-07-01T11:54:27.935Z" }, + { url = "https://files.pythonhosted.org/packages/df/39/ecf519435a200c693fe053a6ee4d835b41cf963a4dfc2551c4e637cb2a71/pillow-12.3.0-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:ae26d61dfa7a47befdc7572b521024e8745f3d809bd95ca9505a7bba9ef849ec", size = 3696433, upload-time = "2026-07-01T11:54:29.813Z" }, + { url = "https://files.pythonhosted.org/packages/42/92/2fc3ffad878ae8dd5469ec1bc8eb83b71f48e13efdf68f02709003982a32/pillow-12.3.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:7a743ff716f746fc19a9557f60dab1600d4613255f8a7aeb3cdde4db7eb15a66", size = 5345889, upload-time = "2026-07-01T11:54:31.97Z" }, + { url = "https://files.pythonhosted.org/packages/10/76/8803c13605b763d33d156c4678fc77f8443389c0c51c8aef707bb02015f4/pillow-12.3.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d69141514cc30b774ceea5e3ed3a6635c8d8a96edf664689b890f4089111fb35", size = 4780109, upload-time = "2026-07-01T11:54:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1f/01/e18aff37cb0b4aac47ac90f016d347a49aca667ef97f190b06ac2aabc928/pillow-12.3.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f7401aebd7f581d7f83a439d87d474999317ee099218e5ad25d125290990ba65", size = 6263736, upload-time = "2026-07-01T11:54:36.131Z" }, + { url = "https://files.pythonhosted.org/packages/f7/62/de5bdd77d935331f4f802edc11e4d82950f642caad6cb2f949837b8560e2/pillow-12.3.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0847a763afefb695bc912d7c131e7e0632d4edc1d8698f58ddabec8e46b8b6d3", size = 6937129, upload-time = "2026-07-01T11:54:38.216Z" }, + { url = "https://files.pythonhosted.org/packages/70/4d/105627a13300c5e0df1d174230b32fd1273062c96f7745fd552b945d1e1d/pillow-12.3.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:571b9fcb07b97ef3a492028fb3d2dc0993ca23a06138b0315286566d29ef718a", size = 6339562, upload-time = "2026-07-01T11:54:40.354Z" }, + { url = "https://files.pythonhosted.org/packages/6b/1d/f13de01a553988ab895ba1c722e06cf3144d4f57656fd5b81b6d881f1179/pillow-12.3.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:756c768d0c9c2955feb7a56c37ea24aea2e369f8d36a88da270b6a9f19e62b5e", size = 7049439, upload-time = "2026-07-01T11:54:42.489Z" }, + { url = "https://files.pythonhosted.org/packages/c9/f9/066794cca041b969964f779ee5fa66a9498bbf34248ac39c5d7954e4198f/pillow-12.3.0-cp313-cp313-win32.whl", hash = "sha256:a876864214e136f0eb367788dbd7df045f4806801518e2cfe9e13229cfe06d8f", size = 6473287, upload-time = "2026-07-01T11:54:44.9Z" }, + { url = "https://files.pythonhosted.org/packages/a6/9b/7a58e61d62be561da3a356fe2384d4059a6345fc130e23ef1c36a5b81d24/pillow-12.3.0-cp313-cp313-win_amd64.whl", hash = "sha256:1cca606cd25738df4ed873d5ad46bbdb3d83b5cbca291f6b4ff13a4df6b0bbe8", size = 7239691, upload-time = "2026-07-01T11:54:47.141Z" }, + { url = "https://files.pythonhosted.org/packages/aa/b0/c4ed4f0ef8f8fa5ee8351537db6650bb8189f7e118842978dd6589065692/pillow-12.3.0-cp313-cp313-win_arm64.whl", hash = "sha256:b629de27fda84b42cde7edef0d85f13b958b47f6e9bbcbba9b673c562a89bd8b", size = 2568185, upload-time = "2026-07-01T11:54:49.137Z" }, + { url = "https://files.pythonhosted.org/packages/dc/01/001f65b68192f0228cc1dbbc8d2530ab5d58b61037ba0587f946fea607cd/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:9cf95fe4d0f84c82d282745d9bb08ad9f926efa00be4697e767b814ce40d4330", size = 4161736, upload-time = "2026-07-01T11:54:51.156Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d2/0219746d0fd16fc8a84498e79452375be3797d3ce4044596ce565164b84f/pillow-12.3.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:8728f216dcdb6e6d555cf971cb34076139ad74b31fc2c14da4fafc741c5f6217", size = 4255435, upload-time = "2026-07-01T11:54:53.414Z" }, + { url = "https://files.pythonhosted.org/packages/c8/02/8d0bc62ef0302318c46ff2a512822d2610e81c7aa46c9b3abe6cbaca5ad0/pillow-12.3.0-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:a45650e8ce7fafffd731db8550230db6b0d306d181a90b67d3e6bca2f1990930", size = 3696262, upload-time = "2026-07-01T11:54:55.739Z" }, + { url = "https://files.pythonhosted.org/packages/85/e2/73c77d218410b14f5f2d565e8a998d5317b7b9c75368d29985139f7a46f0/pillow-12.3.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ba54cfebe86920a559a7c4d6b9050791c20513650a1952ebe3368c7dc70306f8", size = 5350344, upload-time = "2026-07-01T11:54:57.657Z" }, + { url = "https://files.pythonhosted.org/packages/c7/da/32c752228ae345f489e3a42499d817b6c3996da7e8a3bc7a04fc806b243b/pillow-12.3.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e158cb00350dc278f3b91551101aa7d12415a66ebf2c91d8d5ac14e56ddd3ad0", size = 4780131, upload-time = "2026-07-01T11:54:59.713Z" }, + { url = "https://files.pythonhosted.org/packages/b1/9d/8b2c807dbef61a5197c047afe99823787eb66f63daf9fb2432f91d6f0462/pillow-12.3.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e9aeb04d6aef139de265b29683e119b638208f88cf73cdd1658aa07221165321", size = 6263757, upload-time = "2026-07-01T11:55:01.778Z" }, + { url = "https://files.pythonhosted.org/packages/5c/44/c85361f65dbe00eea8576ee467c768d25129989efb76e94f205e9ca9bb46/pillow-12.3.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:251bf95b67017e27b13d82f5b326234ca62d70f9cf4c2b9032de2358a3b12c7b", size = 6936962, upload-time = "2026-07-01T11:55:03.93Z" }, + { url = "https://files.pythonhosted.org/packages/18/7e/e483414b35800b86b6f08dbbc7803fb5cd52c4d6f897f47d53ea2c7e6f65/pillow-12.3.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fe3cca2e4e8a592be0f269a1ca4835c25199d9f3ce815c8491048f785b0a0198", size = 6339171, upload-time = "2026-07-01T11:55:05.989Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f4/68c491844841ede6bed70189546b3ee9731cf9f2cbad396faff5e1ccba45/pillow-12.3.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:23aceaa007d6172b02c277f0cd359c79492bbb14f7072b4ede9fbcaf20648130", size = 7048116, upload-time = "2026-07-01T11:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/a3/34/77f3f793fed8efc7d243f21b33c5a3f0d1c97ee70346d3db855587e155ff/pillow-12.3.0-cp314-cp314-win32.whl", hash = "sha256:af8d94b0db561cf68b88a267c5c44b49e134f525d0dc2cb7ed413a66bc23559a", size = 6467209, upload-time = "2026-07-01T11:55:10.408Z" }, + { url = "https://files.pythonhosted.org/packages/f1/e0/492879f69d94f91f60fc8cd05ba03650e9520afebb2fb7aa12777d7c7f38/pillow-12.3.0-cp314-cp314-win_amd64.whl", hash = "sha256:fdafc9cce40277e0f7a0feabce0ee50dd2fa1800f3b38015e51296b5e814048d", size = 7237707, upload-time = "2026-07-01T11:55:12.745Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ac/6b11f2875f1c2ac040d84e1bbf9cf22a88038f901ca1037898b280b38365/pillow-12.3.0-cp314-cp314-win_arm64.whl", hash = "sha256:e91206ee562682b51b98ef4b26a6ef48fd84e15fd4c4bc5ec768eb641d206838", size = 2565995, upload-time = "2026-07-01T11:55:14.736Z" }, + { url = "https://files.pythonhosted.org/packages/52/69/c2208e56af9bfc1913afb24020297a691eb1d4ef688474c8a04913f65e04/pillow-12.3.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:164b31cd1a0490ab6efae01aa5df49da7061be0af1b30e035b6e9a1bfe34ee6e", size = 5352503, upload-time = "2026-07-01T11:55:17.076Z" }, + { url = "https://files.pythonhosted.org/packages/07/70/e5686d753e898a45d778ff1718dba8516ead6ab6b95d85fc8c4b70650cf2/pillow-12.3.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5afb51d599ea772b8365ae807ae557f18bccfe46ab261fd1c2a9ed700fc6eb17", size = 4782956, upload-time = "2026-07-01T11:55:19.448Z" }, + { url = "https://files.pythonhosted.org/packages/d5/37/25c6692f06927ee973ff18c8d9ee98ad0b4d84ee67a09610c2dd1447958e/pillow-12.3.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3edce1d53195db527e0191f84b71d02022de0540bf43a16ed734ed7537b07385", size = 6322855, upload-time = "2026-07-01T11:55:21.613Z" }, + { url = "https://files.pythonhosted.org/packages/cc/91/420637fcb8f1bc11029e403b4538e6694744428d8246118e45719f944556/pillow-12.3.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bf16ba1b4d0b6b7c8e534936632270cf70eb00dbe09005bc345b2677b726855c", size = 6989642, upload-time = "2026-07-01T11:55:24.006Z" }, + { url = "https://files.pythonhosted.org/packages/10/08/b94d7811281ccf0d143a1cf768d1c49e1e54af63e7b708ab2ee3eb87face/pillow-12.3.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:24870b09b224f7ae3c39ed07d10e819d06f8720bc551847b1d623832b5b0e28d", size = 6391281, upload-time = "2026-07-01T11:55:26.252Z" }, + { url = "https://files.pythonhosted.org/packages/d2/87/24233f785f55474dc02ce3e739c5528a77e3a862e9333d1dd7a25cc31f70/pillow-12.3.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:30f2aa603c41533cc25c05acd0da21636e84a315768feb631c937177db558931", size = 7096716, upload-time = "2026-07-01T11:55:28.318Z" }, + { url = "https://files.pythonhosted.org/packages/23/26/fcb2f6e37175b04f53570b59937867e2b80ee1685e744023153028fc14f9/pillow-12.3.0-cp314-cp314t-win32.whl", hash = "sha256:4b0a7fe987b14c31ebda6083f74f22b561fd3739bc0ac51e019622e3d72668c7", size = 6474125, upload-time = "2026-07-01T11:55:30.956Z" }, + { url = "https://files.pythonhosted.org/packages/90/de/3634abee5f1c9e13c56787b7d5517b0ba8d6de51700b95578cf338349c9f/pillow-12.3.0-cp314-cp314t-win_amd64.whl", hash = "sha256:962864dc93511324d51ddbb5b9f8731bf71675b93ca612a07441896f4688fb8c", size = 7242939, upload-time = "2026-07-01T11:55:34.044Z" }, + { url = "https://files.pythonhosted.org/packages/ce/2a/fd13f8eb24de5714a6eb444a3d67e2842c6c576e159a43793adf23051351/pillow-12.3.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0740a512dc522224c77d9aa5a8d70d8b7d73fb91f2c21125d8d025d3b8990e45", size = 2567506, upload-time = "2026-07-01T11:55:35.988Z" }, + { url = "https://files.pythonhosted.org/packages/5d/dc/8fdce34ec725a33c81c6ba122b904d6b9024e50ea9ac7bede62fab54506c/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:0feb2e9d6ad6c9e3c06effe9d00f3f1e618a6643273576b016f591e9315a7139", size = 4162063, upload-time = "2026-07-01T11:55:37.941Z" }, + { url = "https://files.pythonhosted.org/packages/76/66/2044b9a63d3b84ff048228dfcb7cd9bf0df983e8470971bf7d4c57b693de/pillow-12.3.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:9e881fca225083806662a5c43d627d215f258ff43c890f831966c7d7ba9c7402", size = 4255549, upload-time = "2026-07-01T11:55:40.022Z" }, + { url = "https://files.pythonhosted.org/packages/52/7e/1f67e6f4ece6b582ee4b539decbcc9f848dc245a93ed8cd7338bafef72f1/pillow-12.3.0-cp315-cp315-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:4998562bf62a445225f22e07c896bb04b35b1b1f2eb6d760584c9c51d7a5f78c", size = 3696331, upload-time = "2026-07-01T11:55:41.98Z" }, + { url = "https://files.pythonhosted.org/packages/12/40/d306fc2c8e4d45d7f175c77edca7063be7b86fe7fe6e68f4353bf71d808c/pillow-12.3.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:dc624f6bc473dacdf7ef7eb8678d0d08edf15cd94fad6ae5c7d6cc67a4e4902f", size = 5350370, upload-time = "2026-07-01T11:55:44.028Z" }, + { url = "https://files.pythonhosted.org/packages/dd/44/668fb1437e8ce420f62d6106eb66e44a5971602a4d794615bdf79315d82d/pillow-12.3.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:71d6097b330eea8fd15097780c8e89cb1a8ce7838669f48c5bacd6f663dd4701", size = 4780147, upload-time = "2026-07-01T11:55:46.073Z" }, + { url = "https://files.pythonhosted.org/packages/0c/08/93fa2e70e30a2d81547e481b6ee2bb9522117221fb1e0ce4b5df70967677/pillow-12.3.0-cp315-cp315-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:28ce87c5ab450a9dd970b52e5aca5fe63ed432d18a2eaddd1979a00a1ba24ace", size = 6273659, upload-time = "2026-07-01T11:55:48.264Z" }, + { url = "https://files.pythonhosted.org/packages/f8/6d/043e96ff814fc31a33077e4cba86082167db520c93632afdf2042febbb0c/pillow-12.3.0-cp315-cp315-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6b02afb9b97f65fbca5f31db6a2a3ba21aa93030225f150fa3f249717e938fb4", size = 6947439, upload-time = "2026-07-01T11:55:50.503Z" }, + { url = "https://files.pythonhosted.org/packages/af/92/ba71d2ee2ac0edf3fa33bd9d5ee9ee080da70b1766f3ca3934f9938ddac9/pillow-12.3.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:1182d52bc2d5e5d7d0949503aa7e36d12f42205dc287e4883f407b1988820d39", size = 6353577, upload-time = "2026-07-01T11:55:52.697Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ce/e63064e2122923ff687c8ad792d0d736a7b3920a56a46982e81a7fdd25d6/pillow-12.3.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:e795b7eb908249c4e43c7c99fac7c2c75dab0c43566e37db472a355f63693d71", size = 7060394, upload-time = "2026-07-01T11:55:55.149Z" }, + { url = "https://files.pythonhosted.org/packages/54/76/a09cc3ccc8d773a7283d34c38bec1708f9e3cc932093cbc4c5e71ac4060b/pillow-12.3.0-cp315-cp315-win32.whl", hash = "sha256:57b3d78c95ba9059768b10e28b813002261d3f3dfc55cc48b0c988f625175827", size = 6467375, upload-time = "2026-07-01T11:55:57.769Z" }, + { url = "https://files.pythonhosted.org/packages/3e/03/1846c49ba3b1d5550392a4bbd06d6fb4578e1cd91a803198b5c90f5f7d53/pillow-12.3.0-cp315-cp315-win_amd64.whl", hash = "sha256:fa4ecea169a355be7a3ade2c783e2ed12f0e40d2c5621cda8b3297faf7fbb9f5", size = 7237048, upload-time = "2026-07-01T11:55:59.975Z" }, + { url = "https://files.pythonhosted.org/packages/fb/bb/89f35dcc79610423f9f195504d7def7f0d1416a711541b42867e25fe3412/pillow-12.3.0-cp315-cp315-win_arm64.whl", hash = "sha256:877c3f311ff35410f690861c4409e7ccbf0cd2f878e50628a28e5a0bb689e658", size = 2566006, upload-time = "2026-07-01T11:56:02.143Z" }, + { url = "https://files.pythonhosted.org/packages/30/88/707027ba09942dfa2c28759b5c222d769290a41c6d20ea60ec250801941f/pillow-12.3.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:e9871b1ffbfa9656b60aeee92ed5136a5742696006fa322b29ea3d8da0ecc9cf", size = 5352509, upload-time = "2026-07-01T11:56:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/b0/6d/00352fa25332c2569cd387851f568cc5a4b75a9adbfb37ac4fbce4c02eec/pillow-12.3.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:53aa02d20d10c3d814d536aa4e5ac9b84ca0ff5a88377963b085ad6822f93e64", size = 4783167, upload-time = "2026-07-01T11:56:06.631Z" }, + { url = "https://files.pythonhosted.org/packages/13/4f/9e049dfa21af7c22427275720e2490267ba8138120add5c4c574deb69782/pillow-12.3.0-cp315-cp315t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:446c34dcc4324b084a53b705127dc15717b22c5e140ae0a3c38349d4efec071e", size = 6329237, upload-time = "2026-07-01T11:56:08.868Z" }, + { url = "https://files.pythonhosted.org/packages/36/16/cf6eeaae8d0fce8dd390a33437cf68c5d5bd73834a2bc6e2f14efda0ab45/pillow-12.3.0-cp315-cp315t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cf1845d02ad822a369a49f2bb9345b1614744267682e7a03527dc3bf6eea1777", size = 6997047, upload-time = "2026-07-01T11:56:11.379Z" }, + { url = "https://files.pythonhosted.org/packages/1e/69/dbf769bdd55f48bf5733cac28edc6364ffaa072ec9ba336266e4fe66be55/pillow-12.3.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:186941b6aef820ad110fb01fb06eb925374dc3a21b17e37ec9a53b250c6fe2d1", size = 6400440, upload-time = "2026-07-01T11:56:13.908Z" }, + { url = "https://files.pythonhosted.org/packages/a0/e1/ffc9cfc2eea0d178da8018e18e959301ad9d6bc9f3edb7181e748a474b97/pillow-12.3.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:f13c32a3abd6079a66d9526e18dad9b6d280384d49d7c54040cd57b6424041d9", size = 7105895, upload-time = "2026-07-01T11:56:16.575Z" }, + { url = "https://files.pythonhosted.org/packages/18/f0/a5595c1e8c3ae44b9828cb2f0fa8155e5095ef04d6327b8f61cf44a3df85/pillow-12.3.0-cp315-cp315t-win32.whl", hash = "sha256:1657923d2d45afb66526e5b933e5b3052e6bdea196c90d3abb2424e18c77dae8", size = 6474384, upload-time = "2026-07-01T11:56:18.855Z" }, + { url = "https://files.pythonhosted.org/packages/e4/04/62bcd9f844984c5938d3b05264a61d797a29d3e0812341a8204af70bbdee/pillow-12.3.0-cp315-cp315t-win_amd64.whl", hash = "sha256:8cd2f7bdda092d99c9fc2fb7391354f306d01443d22785d0cbfafa2e2c8bb418", size = 7243537, upload-time = "2026-07-01T11:56:21.214Z" }, + { url = "https://files.pythonhosted.org/packages/3d/68/1f3066acedf37673694a7141381d8f811ae97f30d34413d236abe7d489f1/pillow-12.3.0-cp315-cp315t-win_arm64.whl", hash = "sha256:06ff022112bc9cbf83b60f8e028d94ad87b60621706487e65f673de61610ab59", size = 2567491, upload-time = "2026-07-01T11:56:23.506Z" }, + { url = "https://files.pythonhosted.org/packages/75/18/2e8b40223153ccbc60df07f9e8928dc0c76202aa4e55ae9f53962b6510d6/pillow-12.3.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:b3c777e849237620b022f7f297dd67705f9f5cf1685f09f02e46f93e92725468", size = 5302510, upload-time = "2026-07-01T11:56:25.736Z" }, + { url = "https://files.pythonhosted.org/packages/46/3e/51fabf59d5ab801ceab709453d3ab6b180083496579549de4c45ced6528a/pillow-12.3.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:b343699e8308bdc51978310e1c959c584e7869cc8c40780058c87da7781a1e94", size = 4736058, upload-time = "2026-07-01T11:56:28.041Z" }, + { url = "https://files.pythonhosted.org/packages/bf/20/22fe9384b7949e25fb1293bcfc84fb82590ff4ea6b37c95b24d26d793d86/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbd139c8447d25dd750ab79ee274cc5e1fe80fc56340ab10b18a195e1b6eca3e", size = 5237776, upload-time = "2026-07-01T11:56:30.263Z" }, + { url = "https://files.pythonhosted.org/packages/08/14/f6ba68107680ffa74b39985f3f30884e41318fbc4250caa423c79b4788bb/pillow-12.3.0-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e7e480451b9fa137494bccd3a7d69adbe8ac65a87d97be61e11f1b1050a5bac3", size = 5860358, upload-time = "2026-07-01T11:56:32.68Z" }, + { url = "https://files.pythonhosted.org/packages/36/54/0169bc772ec491108b62f644f8ecf1fe5d8ae5ebafde2ee2142210166903/pillow-12.3.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:04f01d28a6aaff387bf842a13be313df23ba0597a44f1a976c9feb3c6ff4711a", size = 7231786, upload-time = "2026-07-01T11:56:35.046Z" }, ] [[package]] @@ -2419,15 +2428,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.4.2" +version = "1.4.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0b/1a/cbbaf13b730abb0a16b964d984e19f2fe520c21a4dc664051359a3f5a9e7/python_discovery-1.4.2.tar.gz", hash = "sha256:8f3746c4b4968d22afbb97d36e1a0e5b66e6c0f297290f2e95f05b9b8bf18690", size = 70277, upload-time = "2026-06-11T16:10:42.383Z" } +sdist = { url = "https://files.pythonhosted.org/packages/66/26/8b004cc36f430345136f6f00fa1aa9ed596c8ed1e8504625fa79522ff39c/python_discovery-1.4.3.tar.gz", hash = "sha256:ad57d7045a862460d4a235986c33f13ed707d3aeb9153fa47eb7dfd0d4673289", size = 70438, upload-time = "2026-07-03T13:21:51.621Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1a/82/a70006589557f267f15bd384c0642ad49f0d97b690c3a05b166b9dcbad3b/python_discovery-1.4.2-py3-none-any.whl", hash = "sha256:475803f53b7b2ed6e490e27373f9d8340f7d2eebf9acdaf645d7d714c97bb500", size = 33886, upload-time = "2026-06-11T16:10:41.192Z" }, + { url = "https://files.pythonhosted.org/packages/28/78/9b77ecb4644d1bbea94d29abf78f21c47eca6eb79e9745b702ec0bed2e19/python_discovery-1.4.3-py3-none-any.whl", hash = "sha256:b6e1e4a7d9e3f6948c39746ffe8218225162d738ba39d05ab1d2f6c1cac4878c", size = 33885, upload-time = "2026-07-03T13:21:50.174Z" }, ] [[package]] @@ -2567,7 +2576,7 @@ source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "attrs" }, { name = "rpds-py", version = "0.30.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, - { name = "rpds-py", version = "2026.5.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, + { name = "rpds-py", version = "2026.6.3", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/22/f5/df4e9027acead3ecc63e50fe1e36aca1523e1719559c499951bb4b53188f/referencing-0.37.0.tar.gz", hash = "sha256:44aefc3142c5b842538163acb373e24cce6632bd54bdb01b21ad5863489f50d8", size = 78036, upload-time = "2025-10-13T15:30:48.871Z" } @@ -2577,123 +2586,123 @@ wheels = [ [[package]] name = "regex" -version = "2026.5.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/dc/0e/49aee608ad09480e7fd276898c99ec6192985fa331abe4eb3a986094490b/regex-2026.5.9.tar.gz", hash = "sha256:a8234aa23ec39894bfe4a3f1b85616a7032481964a13ac6fc9f10de4f6fca270", size = 416074, upload-time = "2026-05-09T23:15:19.37Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fe/ed/0ad2c8edf634918eb4484365d3819fa7bd7f58daf807fe7fb21812c316e5/regex-2026.5.9-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:a9e1328e17c84c1a5d22ec9f785ecef4a967fab9a42b6a8dc3bcbebd0a0c9e44", size = 489438, upload-time = "2026-05-09T23:11:29.374Z" }, - { url = "https://files.pythonhosted.org/packages/89/a9/4ed972ad263963b860b7c3e86e0e1bcc791def47b43b8c8efe57e710f139/regex-2026.5.9-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bfe1ce50cbfb569d74e1e4337da6468961f31dbea55fd85aa5de59c0947a805a", size = 291270, upload-time = "2026-05-09T23:11:33.254Z" }, - { url = "https://files.pythonhosted.org/packages/16/81/075930d9fa28c4ea1f53398dd015ee7c882f623539759113cda1257f4b82/regex-2026.5.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:15ee42209947f4ca045412eae98416317238163618ace2a8e54f99586a466733", size = 289198, upload-time = "2026-05-09T23:11:35.769Z" }, - { url = "https://files.pythonhosted.org/packages/d4/c8/5cdfbf0b5dc6599e1b6131eff43262e5275d4ec3469ce10216061659aadb/regex-2026.5.9-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b4bb445ff3f725f59df8f6014edb547ee928ec7023a774f6a39a3f953038cbb2", size = 784765, upload-time = "2026-05-09T23:11:37.689Z" }, - { url = "https://files.pythonhosted.org/packages/cd/ca/ae5fd6edc59b7f84b904b31d6ec39a860cbcecd10f64bd5a062ca83a4864/regex-2026.5.9-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:446ddd671e43ab535810c4b21cff7104945c701d4a14d1e6d1cd6f4e445a8bea", size = 852115, upload-time = "2026-05-09T23:11:39.973Z" }, - { url = "https://files.pythonhosted.org/packages/f6/ce/a91cf555afb51f3b74a182e24ba073b91ea7bb64592fc4b315c111bb19fd/regex-2026.5.9-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b92817338591505f282cf3864c145244b1edcf5381d237038df955001091538", size = 899503, upload-time = "2026-05-09T23:11:42.48Z" }, - { url = "https://files.pythonhosted.org/packages/55/7f/725a0a2b245a4cf0c4bab29d0e97c74285d94136a65d1b55a6459a583502/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6b8a143aca6c39b446ea8092cde25cc8fe9304d4f5fecfbc1a9dbb0282703c2", size = 794093, upload-time = "2026-05-09T23:11:44.681Z" }, - { url = "https://files.pythonhosted.org/packages/e3/2a/996efbd59ce6b5d4a09e3af6180ceb62af171f4a9a6fb557d2f0ae0d462b/regex-2026.5.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:0f03aa6898aaaac4592479821df16e68e8d0e29e903e65d8f2dfb2f19028a989", size = 786234, upload-time = "2026-05-09T23:11:46.882Z" }, - { url = "https://files.pythonhosted.org/packages/4b/0a/8731e8b8806174c9cdd5903f80a14990331c1f42fc4209b540952e9e010d/regex-2026.5.9-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ed457d8e98ae812ed7732bef7bf78de78e834eae0372a74e23ca90ef21d910f9", size = 769895, upload-time = "2026-05-09T23:11:49.324Z" }, - { url = "https://files.pythonhosted.org/packages/9a/0b/932473194bd563f342a412ae2ffbbd6da608306a2bc4e99249a41c2b0b92/regex-2026.5.9-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:71b61c5bfe1c806332defc42ad6c780b3c55f661986d7f40283a3a88274b4c00", size = 774991, upload-time = "2026-05-09T23:11:51.261Z" }, - { url = "https://files.pythonhosted.org/packages/98/80/9523d196010031df25f7177ee0a467efbee436324038e5d99def17a57515/regex-2026.5.9-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:3b1e39888c5e0c7d92cea4fc777396c4a90363b05de75d02eb459a4752200808", size = 848790, upload-time = "2026-05-09T23:11:53.232Z" }, - { url = "https://files.pythonhosted.org/packages/3c/07/56987b35e89edf47e4a38cf2845aeee476bfa688a6bdbd3e820cda461dc1/regex-2026.5.9-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:6ba42b2e7e7f46cf68cc6a5ca36fa07959f9bbd9c6bdcc47b6ee76549a590248", size = 757679, upload-time = "2026-05-09T23:11:55.82Z" }, - { url = "https://files.pythonhosted.org/packages/04/2a/ff713fff0c566507c06a4ce2dc0ae8e7eeebc88811a95fc81cf1e7d534dd/regex-2026.5.9-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:c010eb8caca74bdb40c07498d7ece26b4428fd3f04aa8a72c9ac6f79e8faaac6", size = 837116, upload-time = "2026-05-09T23:11:57.934Z" }, - { url = "https://files.pythonhosted.org/packages/77/90/df6d982b03e3614785c6937ba51b57f6733d97d2ee1c9bc7531dbfab3a54/regex-2026.5.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a6a563446a41adc451393dc6b8e6ad87979efaee3c8738690a8d1b08ebead1b4", size = 782081, upload-time = "2026-05-09T23:11:59.607Z" }, - { url = "https://files.pythonhosted.org/packages/c7/8a/4e88a5f7c3e98489aac4dd23142723d907b2a595b4a6abcbacabefeded09/regex-2026.5.9-cp310-cp310-win32.whl", hash = "sha256:954cc214c04663ee6d266fc61739cad83054683048de65c5bd1d640ad28098ac", size = 266247, upload-time = "2026-05-09T23:12:01.116Z" }, - { url = "https://files.pythonhosted.org/packages/6a/40/4b224cb0582b2dca1786726e6cdabe26abbf757d7f6718332f186da155d2/regex-2026.5.9-cp310-cp310-win_amd64.whl", hash = "sha256:b310768746dd314ea6e2ff4cc89ef215426813396ff4e94ee8e6f7096c8b6e03", size = 278416, upload-time = "2026-05-09T23:12:03.2Z" }, - { url = "https://files.pythonhosted.org/packages/12/4d/014fbe803204cab0947ee428f09f658a29632053dde1d3c6176bb4f0fd4c/regex-2026.5.9-cp310-cp310-win_arm64.whl", hash = "sha256:19c16ceb4a267a8789e25733e583983eeab9f0f8664e66b0bd1c5d21f14c2d4b", size = 270413, upload-time = "2026-05-09T23:12:04.649Z" }, - { url = "https://files.pythonhosted.org/packages/c2/dc/c1f2df4027e82fc54b5a473e4b250f5139faca49a0fbe29a48668d228f34/regex-2026.5.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ccf5249114cc3e772ecdd88a98a86eca0fd74c61ce32a94743758c083fc05d48", size = 489445, upload-time = "2026-05-09T23:12:06.111Z" }, - { url = "https://files.pythonhosted.org/packages/03/d2/59f01110660081cce9c0bc30ebd0b5ee250dacf658e3248ed92f01e0e8ee/regex-2026.5.9-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:46f1326ca6e65b0879d23ca302c0f2415aad42ff0309b9c818e7949fe19a41d8", size = 291271, upload-time = "2026-05-09T23:12:07.731Z" }, - { url = "https://files.pythonhosted.org/packages/58/b6/14b2c84ff90ddb370c81d27503f4a0fcf071496416f4855f6cc8c5d81c35/regex-2026.5.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:ef31cbfe458e21c6122ba8150ff060e0c7789ed0d26eb423f25472584920b555", size = 289212, upload-time = "2026-05-09T23:12:09.266Z" }, - { url = "https://files.pythonhosted.org/packages/03/d0/4db86529117320de0c84afd90e70bb47434625875e34fcef9d8c127c5b16/regex-2026.5.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:992604d02e6d9c6d786c24a706a71ecffe1020fc1ef264044474cd81fa2c3919", size = 792310, upload-time = "2026-05-09T23:12:11.416Z" }, - { url = "https://files.pythonhosted.org/packages/07/78/fe4800cd322f862ecffd2d553409b20d80650e5ed71b9d178f853d020b82/regex-2026.5.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c9411dd64ca95477225734a93dfc8583b51916b8d5942f99d6cac21e09965451", size = 861721, upload-time = "2026-05-09T23:12:13.681Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d0/b3618a895dd8feb897c61bb2954edd265e1767d82a01d53065d5871127a3/regex-2026.5.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3dd4a3ff360dfb836fecdb93a4598f9d6e2ac81e3e397125145c6221bf58cf4c", size = 906460, upload-time = "2026-05-09T23:12:15.443Z" }, - { url = "https://files.pythonhosted.org/packages/33/6f/1481597e859ef19508b345eec4afd1416ed6e6b459c75a64026ef193aecf/regex-2026.5.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2a661a7d270a61f7cf460caee8b9fa2d5ef9e5c681234bcb9e0fe14f488e7dfc", size = 799843, upload-time = "2026-05-09T23:12:16.892Z" }, - { url = "https://files.pythonhosted.org/packages/73/59/955734c803f59108deccba3597ae440c76b62a652733c0006e6243758420/regex-2026.5.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f079e50a0d3cc3cd5091fa9ff45869a2e6b2cd35895731edafb0327901a8d86d", size = 773610, upload-time = "2026-05-09T23:12:19.127Z" }, - { url = "https://files.pythonhosted.org/packages/68/8f/70c04a236d651c81881dac42ef8538bddda6121434509d0a22d9e601503b/regex-2026.5.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:4ebe8f0b5ec5a5024dc4a4c59f444c4e9afc5f2abdbb8962065b75d27fb971f9", size = 781645, upload-time = "2026-05-09T23:12:20.806Z" }, - { url = "https://files.pythonhosted.org/packages/1d/96/05c7434d88185e5d27fe54aeb74df86bd77cd79f52f0b4eae54faa8fea70/regex-2026.5.9-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:97cf3bc1b7d7d2306772ec07366c80d9df00ff79e79cea32898883a646d2fae2", size = 854473, upload-time = "2026-05-09T23:12:22.465Z" }, - { url = "https://files.pythonhosted.org/packages/4e/c1/6e3d8202d981f3117004bf341ee74893ba4ba8a9fbaf4b94615846550a08/regex-2026.5.9-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:0f9eede6a5cbdc02d4978090186390936e1776a7d1359b21e41014c609880bcf", size = 763311, upload-time = "2026-05-09T23:12:24.351Z" }, - { url = "https://files.pythonhosted.org/packages/93/c7/e7737f1526b3fb32bd4c337fd6c71c3ebb5c8296fc34d11197e0955d2e35/regex-2026.5.9-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:01f0f5f55f4b64dacec85dc116d3c05fd23ad3ff037bbc73a2085775953c2611", size = 844593, upload-time = "2026-05-09T23:12:26.341Z" }, - { url = "https://files.pythonhosted.org/packages/a5/27/0daffb1a535bb39f422c3d200f4ab023c71110ad66a32b366bee708baba0/regex-2026.5.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1268eddd8486dc561d08eee1156e40aa3a8fe10f4bdec8fa653b455fcbffd12c", size = 789167, upload-time = "2026-05-09T23:12:27.975Z" }, - { url = "https://files.pythonhosted.org/packages/ce/fc/294fe4fac4f2ed67207b17471815870c1c45b3a489e08e0ac96daea16ef6/regex-2026.5.9-cp311-cp311-win32.whl", hash = "sha256:8676474c07469d6f33dd1085ca2cd45f65785f32518f2b20e36d9953ca07f994", size = 266249, upload-time = "2026-05-09T23:12:30.141Z" }, - { url = "https://files.pythonhosted.org/packages/d0/b0/8dce459f6245bcf8f6e9f23ac9569f1a0f15c131cc0745e82b43226204cf/regex-2026.5.9-cp311-cp311-win_amd64.whl", hash = "sha256:246de9d60aa3f8538b519834dd95cbf276ea263d6a7bd5a3666dc3fa0230505b", size = 278423, upload-time = "2026-05-09T23:12:31.676Z" }, - { url = "https://files.pythonhosted.org/packages/db/8d/f9aeff6ad63a3ef720386f2907e6d34a35a510a6e498ebad28b0fb3f6ab6/regex-2026.5.9-cp311-cp311-win_arm64.whl", hash = "sha256:d726ca3f0d76969bf1e8e477d160d3d666bbf999f6860bd314889e5345782046", size = 270420, upload-time = "2026-05-09T23:12:33.194Z" }, - { url = "https://files.pythonhosted.org/packages/50/9b/6550044bc44e17c84d312c031c2ec42fbdb6a4ec4e29093be3a172d08772/regex-2026.5.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:57eeeb05db7979413dec5438f2db21d7ecbba787cde7a711df1a6f6df672aa06", size = 490451, upload-time = "2026-05-09T23:12:34.72Z" }, - { url = "https://files.pythonhosted.org/packages/1e/95/fc7ba4303b5a0f92446a12ee6778ef2c6c799233f5060042a31bf390cfe9/regex-2026.5.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:398c521292f4c7fb807001dcd54694d3a1fcafc179a36ad9cc56f98df85930b6", size = 292112, upload-time = "2026-05-09T23:12:36.285Z" }, - { url = "https://files.pythonhosted.org/packages/54/4b/ee27938d1b2c443e89a9a10e00d2d19aa5ee300cd3d61140644e93bb083e/regex-2026.5.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f7a7c26137296beba7784de6eba69c6a93a63ccebc385e4962fe67e267a91225", size = 289599, upload-time = "2026-05-09T23:12:38.089Z" }, - { url = "https://files.pythonhosted.org/packages/d8/dd/ba103dc19614e25f3880800ca67ce093d6e21b325d72b8383c7bf906e9fa/regex-2026.5.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6441cc660d76107934a09c22167200839a0e89604a6297f78a974e66e931d2c0", size = 796732, upload-time = "2026-05-09T23:12:40.062Z" }, - { url = "https://files.pythonhosted.org/packages/cf/e7/f035b4fd858b050b0080bf302968dc0f59ba34e391872d54936758e6844e/regex-2026.5.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:91328f1c23d47595ca3ef0a7557fa129c5a23404b775c770697d2f35b33e0107", size = 865440, upload-time = "2026-05-09T23:12:42.059Z" }, - { url = "https://files.pythonhosted.org/packages/0a/51/8cd301ecc899aea28124357f729f4272f44de7806fc7ca02490bfbe253e8/regex-2026.5.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:93a7860539414dddaefba2b40f8771765ae17949d4c7182b876ce429e11a8309", size = 912329, upload-time = "2026-05-09T23:12:44.373Z" }, - { url = "https://files.pythonhosted.org/packages/cc/1e/3fbe2fa1e8cebd62f3bb7d3321cff1640aca2e240b51d9bd624aad949260/regex-2026.5.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dd2810d22146b6d838acc5ec15602cb6b47920aa4e33015df3868eedfd20bab8", size = 801239, upload-time = "2026-05-09T23:12:46.268Z" }, - { url = "https://files.pythonhosted.org/packages/17/2f/6f6008682bf2cf98040a0d3153a8e557b6ab728d7713d045cee4ce544ab8/regex-2026.5.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:daff2bdbaf1d23e52fdff7c0b7bc2048b68f978df6a4d107ac981f94caef2e66", size = 777054, upload-time = "2026-05-09T23:12:48.051Z" }, - { url = "https://files.pythonhosted.org/packages/19/2b/eee0d20a6842ba04df4b8847a920b57ef56853f14ef85405473e586b605a/regex-2026.5.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:4eeb011098fcb77af513dcef521a3dbecbf8849b1e38940759d293b7a93f5026", size = 785098, upload-time = "2026-05-09T23:12:49.851Z" }, - { url = "https://files.pythonhosted.org/packages/4a/98/6fc1e6410feefb92159edaed5041992bfe390e8d26c721865434acbca558/regex-2026.5.9-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:ea9c8ecfa1b73c73b626534d6626e5340d429630943672b8480724f44e84b962", size = 860095, upload-time = "2026-05-09T23:12:51.666Z" }, - { url = "https://files.pythonhosted.org/packages/18/a3/bd855e0f2cb1a978ecf6fa6bb69632dd9c3f6ea3b81cde62fde14c9daec7/regex-2026.5.9-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:cd2846168eb9ee3c513902bc8225409cb1caab31d04728b145171fa1625d9621", size = 765762, upload-time = "2026-05-09T23:12:53.413Z" }, - { url = "https://files.pythonhosted.org/packages/dc/66/0ae8c092e60b14c79d24f8e0b7f0aea5bfbffdcab00b5483d13404d3c3a5/regex-2026.5.9-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:39617fb0cde9c0e6306dc70e3bfc096f3da793219879f7ae7aa341a69fbdcf6d", size = 852100, upload-time = "2026-05-09T23:12:55.256Z" }, - { url = "https://files.pythonhosted.org/packages/21/de/8dfde60fc1b21c946a893ba273403b72617edb261370cb1087099a83f088/regex-2026.5.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:fd03c4f0e33280d15cae17159b899245d6b7c53d21def19b263b39655061f5ce", size = 789479, upload-time = "2026-05-09T23:12:57.573Z" }, - { url = "https://files.pythonhosted.org/packages/c3/1c/bdcc98f9a4af4fdd166c74941174619ccff4726d3ce32faa8e9a2ecd38dd/regex-2026.5.9-cp312-cp312-win32.whl", hash = "sha256:164eba9b755ea6f244b0d881196fbc1fac09714e9782c9e2732b813142033c8e", size = 266699, upload-time = "2026-05-09T23:12:59.14Z" }, - { url = "https://files.pythonhosted.org/packages/78/87/240d36864f9e48ace85f72e79ced97ceb7f27ce87739a947dcb834b4e6bc/regex-2026.5.9-cp312-cp312-win_amd64.whl", hash = "sha256:86f40a5d6444db30a125c9c9177e6b25dad981cbc37451fd838f145e6edac92e", size = 277783, upload-time = "2026-05-09T23:13:00.789Z" }, - { url = "https://files.pythonhosted.org/packages/4f/b5/7b30f312b0669dff5beebe5b0989dc2d1a312b1a44fab852199c387a5b96/regex-2026.5.9-cp312-cp312-win_arm64.whl", hash = "sha256:96f5f58b54a063d7ea9dca08e1cf57bfe10499c4d579ee672da284f57f5f0070", size = 270513, upload-time = "2026-05-09T23:13:02.426Z" }, - { url = "https://files.pythonhosted.org/packages/aa/da/797e91ecec6f84135da778ddce78c20e0af5d2a15c26f87a81bc3eadb6db/regex-2026.5.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:d626b84406444b165fc0ba981604edea39f0588ff1f92baa23fe50799ea9afdb", size = 490303, upload-time = "2026-05-09T23:13:04.382Z" }, - { url = "https://files.pythonhosted.org/packages/44/da/bf30abaaa737b58f4a4b8c4a03659e02fd92092c822e0197ed9e0daab917/regex-2026.5.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d7bdc0ab8f3dd7e1b4f9ab88634e13374669db86bb3c72e8292f07ae313f539f", size = 292019, upload-time = "2026-05-09T23:13:06.022Z" }, - { url = "https://files.pythonhosted.org/packages/2d/e7/d0eaf5713828417b9e5648cf81fa9bacd4961f6ab98c380c2034f8716e35/regex-2026.5.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a8820737949116ffff55fe18f9fc644530063ba6ebfcb8314239416e78f1347c", size = 289468, upload-time = "2026-05-09T23:13:08.214Z" }, - { url = "https://files.pythonhosted.org/packages/d3/9b/b3fdd62b003baa1a9b593cd8c8699c9651c2e80cc21a5c715707983c42d7/regex-2026.5.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aa0fbdbac82cb3e4450d0ccde7d7a35607f4cb2dd9fba4b8b69bfaf8c9fa6aed", size = 796749, upload-time = "2026-05-09T23:13:10.573Z" }, - { url = "https://files.pythonhosted.org/packages/d4/30/66ab84588765f5b4b271a9ca09ef7ce2b87caa95176ec3d2ad65d7bc4902/regex-2026.5.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:57e8915c7986aa33d25e4d3629cef711cd2863f2961b10409f0c04cb8b7d9020", size = 865445, upload-time = "2026-05-09T23:13:12.523Z" }, - { url = "https://files.pythonhosted.org/packages/1a/89/f05169e8588aac365f35ffc7f3bc3184f095ef4cfded7cfaa3c7fd5dbd89/regex-2026.5.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:508f56a89ba9cb26e4168cbc37dbd60a28d82430a9e18ad1d25fe0883c314ca2", size = 912322, upload-time = "2026-05-09T23:13:14.281Z" }, - { url = "https://files.pythonhosted.org/packages/30/e1/c93444052cf41581f3c884ab3fb5823daf0992f11cd4388d4275ca610558/regex-2026.5.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b6d189041f15691cfa2b6c4290448ec221244d225b3f5fe9e7771b34ffcdf6e2", size = 801269, upload-time = "2026-05-09T23:13:16.569Z" }, - { url = "https://files.pythonhosted.org/packages/50/fe/0cf96b882f540e62e8b9956599798203d599c44cf4c77917ca27400ff69b/regex-2026.5.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e82db382b44d0111b22601c509c89f64434816c9e0eef9d1989cda8cc6ff1c04", size = 777085, upload-time = "2026-05-09T23:13:18.675Z" }, - { url = "https://files.pythonhosted.org/packages/23/5c/d78d4924e7fc875557b9e9b768423925fdfaac5549d06da7810019a9bd26/regex-2026.5.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:2acfb48634f64996b57f90f39afa692ff362162722581921fe92239a59960f3c", size = 785153, upload-time = "2026-05-09T23:13:20.525Z" }, - { url = "https://files.pythonhosted.org/packages/bf/e0/5214774090e7b4524dcea3e3c4aa74141d43043f8beb49c1599db1c8b53a/regex-2026.5.9-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:d29eebfc9525db68cad3c97eedd7f754fa265aa5cd0cf4f863b2421e1b48fc9f", size = 860164, upload-time = "2026-05-09T23:13:22.263Z" }, - { url = "https://files.pythonhosted.org/packages/6e/e1/4a57a83350319b1271f0d7a249b8672513ed928b237a741631270de6caea/regex-2026.5.9-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:debb893095e944091c16e641a6e33c1b0f4cb61ab945ec5afbf53ce7068834d8", size = 765731, upload-time = "2026-05-09T23:13:24.277Z" }, - { url = "https://files.pythonhosted.org/packages/12/f4/499e74a20c156fc75836ee04a72a38d1a063978f600937f9760467beb1b0/regex-2026.5.9-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:d659eee77986549c9ea45b861c7567e44d6287c3dc9a4565478853f7b9fe2ff6", size = 852062, upload-time = "2026-05-09T23:13:26.125Z" }, - { url = "https://files.pythonhosted.org/packages/5b/92/7eebc0d0a01e78629695f342ba17e0deaff8fb45e79cc0d7b98287da6e3e/regex-2026.5.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:2efa205e6d98b24d1f3ab395c11aa15cdf10935bca283d0285e0499c284fba21", size = 789577, upload-time = "2026-05-09T23:13:27.814Z" }, - { url = "https://files.pythonhosted.org/packages/05/a4/018e71f7d2ad48c1ebe6d3ae0026f9b7cb4802fd15c7cc02fdf724355102/regex-2026.5.9-cp313-cp313-win32.whl", hash = "sha256:f3844f134e834076677dd369976e9f5068679fcb8e50102fdf6b7ac96a3ec127", size = 266691, upload-time = "2026-05-09T23:13:29.549Z" }, - { url = "https://files.pythonhosted.org/packages/e6/1d/861a93719fb9ee7dbfc3761b3797b7a3e112a5d42c6129459d2d741be9b5/regex-2026.5.9-cp313-cp313-win_amd64.whl", hash = "sha256:3527bb4942d2c14552155406cdedd906567456821848aed1cb4933a391bf5eca", size = 277747, upload-time = "2026-05-09T23:13:31.859Z" }, - { url = "https://files.pythonhosted.org/packages/d9/c6/0a2436ae4da1ba76e51cb98943c6838a9a721faa40ebe2dce07694ae34e3/regex-2026.5.9-cp313-cp313-win_arm64.whl", hash = "sha256:56a33f191f17d8c417f99945ebdc1e691d3af9605d86ec68c7e54a57e3e17af6", size = 270500, upload-time = "2026-05-09T23:13:33.525Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e9/d21346f7b60ed58789371358ed66b09d00f832e1bd7c06e55d9da5679882/regex-2026.5.9-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:01f28d868834624c934b8d2e0aa1c8341337e37831f4a012f18a5afcba4cbaf3", size = 494172, upload-time = "2026-05-09T23:13:35.935Z" }, - { url = "https://files.pythonhosted.org/packages/c4/43/fd1177a2032037c681baecdb3422ee4e1424aec4e4f470ef47793d325274/regex-2026.5.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:48036f6374aaa79eb3b754ec29c61d1c6b1606749d705a13f8854fa2539671f6", size = 293952, upload-time = "2026-05-09T23:13:38.307Z" }, - { url = "https://files.pythonhosted.org/packages/f2/7d/9fbf919768368d3f8a4f6c692cf2aa61e482b2b81ec6a298ace4cbf02480/regex-2026.5.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b96350aa424e79d4fd6b567b344dcbe2b2d6bfc48dfe7717587e1fa6d43da6ff", size = 292314, upload-time = "2026-05-09T23:13:40.353Z" }, - { url = "https://files.pythonhosted.org/packages/e2/6c/e41bfeecb589716843e7c4df09ba46ff2a42961457afece19059d85caeef/regex-2026.5.9-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8f3af7a4903c5c04a11a196a5aa75cdd7dd3f8508132f9fb3259d9f5908e3b88", size = 811681, upload-time = "2026-05-09T23:13:42.543Z" }, - { url = "https://files.pythonhosted.org/packages/87/83/a5c1c525fba0aa656e88ad0face0b1829788ef4c2fb6b26df58aa1151b84/regex-2026.5.9-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7e87577720152d2caae19fe2baaf1f8d5ca12091e9e229f03915c37d1e4b9178", size = 871135, upload-time = "2026-05-09T23:13:44.326Z" }, - { url = "https://files.pythonhosted.org/packages/18/d4/80882e799e440dd878b0979cbebf8fa4d54624a332c83037c7a701649e3f/regex-2026.5.9-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c8b9b9d294cfea3cd19c718ade7cc93492b2c4991abd9a68d0b3477ae6d8e100", size = 917265, upload-time = "2026-05-09T23:13:47.295Z" }, - { url = "https://files.pythonhosted.org/packages/ae/ff/8db60211e2286e396aad7dc7725356c502bff0901ea05bd6cdc2e1a042b9/regex-2026.5.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:728d8bfd28a8845c8b6bc5dc7ce010453d206396786c0765c2740cb65f37791e", size = 816311, upload-time = "2026-05-09T23:13:49.885Z" }, - { url = "https://files.pythonhosted.org/packages/4c/47/742ef579c61730f8d268e5cf1f9ce0e37e2ea041ad0f5644724f2378e463/regex-2026.5.9-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7e30b874d341fac767d7df5a0870540541c2c054b80cfaac116e8d367a8a7ff2", size = 785498, upload-time = "2026-05-09T23:13:52.25Z" }, - { url = "https://files.pythonhosted.org/packages/7f/ab/cb0999802dcb0fb95b1ab005e8d4163d8afdd67efc2cb6b6630ac13f8cb1/regex-2026.5.9-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fd190e88a895a8901325fad284a3f74ea52b1da8525b76cc811fa9b1edf0ce2b", size = 801348, upload-time = "2026-05-09T23:13:54.127Z" }, - { url = "https://files.pythonhosted.org/packages/7d/62/8ca59a24c55bc34d166eefaf3717bd77772f329fdbf984d86581e0a3571c/regex-2026.5.9-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:8e76e8161ad00694cfce6767d5dea860c6391ac5b83e5c3a39661e696f11fc7e", size = 866493, upload-time = "2026-05-09T23:13:56.067Z" }, - { url = "https://files.pythonhosted.org/packages/8d/3d/30f2ae62cef3278bb5bb821f467277a55fb73f01032cf85997e15e8289a8/regex-2026.5.9-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ddda5340e6c01a293027dd46232fa79eaff1b48058ce7a98f572b6445b088041", size = 772811, upload-time = "2026-05-09T23:13:57.867Z" }, - { url = "https://files.pythonhosted.org/packages/d8/ae/7d2089bcd78ad0c0161bc684339df50032acb438a7bd3305e7ddb1193cec/regex-2026.5.9-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:205109e96b3cf5adf8f4cd62bedde9487feb282b9497a3535451e5a24cd706a0", size = 856584, upload-time = "2026-05-09T23:13:59.679Z" }, - { url = "https://files.pythonhosted.org/packages/a9/29/92ff47f75990131ea4f24ba17819e5a9d141e10819807e09addd73409af6/regex-2026.5.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:dfbe4579b9f08036aa7d101d1835437a20783574ac66327e6b29b4018a138081", size = 803453, upload-time = "2026-05-09T23:14:01.978Z" }, - { url = "https://files.pythonhosted.org/packages/04/99/eff29f1037dcab36702c9ee5d6858cf1ce2336ea8ea2987f64245b99ea5e/regex-2026.5.9-cp313-cp313t-win32.whl", hash = "sha256:ed2c9e8068b614c574d8d30e543d617cf5379b0535d46f97ef00e904745a08b5", size = 269951, upload-time = "2026-05-09T23:14:03.661Z" }, - { url = "https://files.pythonhosted.org/packages/0e/9d/8870b8981d27b22cda77bb26a5ac7ebfa9c7d9e0dea195a834a82380e748/regex-2026.5.9-cp313-cp313t-win_amd64.whl", hash = "sha256:b46b0f094dc1d3b90356c85a0bd2c9bafc4a6a190b9d6f8ddd5a033b6e088ed4", size = 281240, upload-time = "2026-05-09T23:14:05.56Z" }, - { url = "https://files.pythonhosted.org/packages/72/b1/3379415e8f135c13ac551353397cc4fe97b4978f3cac73c5fcbcded548b8/regex-2026.5.9-cp313-cp313t-win_arm64.whl", hash = "sha256:872acc074bd29ffc9913ecdfedf6ea77502312ca44a4aa0d3779089c6069d8de", size = 272383, upload-time = "2026-05-09T23:14:07.843Z" }, - { url = "https://files.pythonhosted.org/packages/13/3e/9c3cd292d8808b3645a2ce517e200179b6d0e903f176300bd8b542e14de5/regex-2026.5.9-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:1bd7587a2948b4085195d5a3374eaf4a425dc3e55784c038175355ecf3bbbf8a", size = 490376, upload-time = "2026-05-09T23:14:09.64Z" }, - { url = "https://files.pythonhosted.org/packages/60/70/d43ee8a2ca0a8b68d167f21658b85520ac0574617c7f320367c5047f7556/regex-2026.5.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:dea2e88e1cce4522496cce630e11e67b98b7076620bc4336c3f674bc21a375f4", size = 291964, upload-time = "2026-05-09T23:14:11.424Z" }, - { url = "https://files.pythonhosted.org/packages/21/91/9d50b433828d8e74196904e168a43abf1e6e88b2a15d47ed742456720c37/regex-2026.5.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:2099f7e7ff7b6aa3192312650a56e91cc091e49d50b04e4f6f8b6e28b3b27f1c", size = 289682, upload-time = "2026-05-09T23:14:13.123Z" }, - { url = "https://files.pythonhosted.org/packages/3e/d2/b835e3cafbb9d977736912436259ff551d60919f7d7b3d37d46659c63564/regex-2026.5.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ecd353045824e4477562a2ac718c25799cdaaa41f7aa925a806a8a3e6848a5b9", size = 796996, upload-time = "2026-05-09T23:14:14.923Z" }, - { url = "https://files.pythonhosted.org/packages/2c/a6/9f992d00019166b9de01c546dd4549bc679f2a68df11b877740b0760b7c2/regex-2026.5.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:65c8c8c37377794bd5b2f3ebe51919042bf17aec802e23c833d89782ed0c78af", size = 866089, upload-time = "2026-05-09T23:14:17.757Z" }, - { url = "https://files.pythonhosted.org/packages/e0/08/4d32af657e049b19cb62b02e46e38fe1518797bfb2203ee93a510b21b0dc/regex-2026.5.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5b73ab8afcf66c622db143d1c6fda4e58e4d537ee4f125229ad47b1ab80f34c0", size = 911530, upload-time = "2026-05-09T23:14:20.353Z" }, - { url = "https://files.pythonhosted.org/packages/d9/27/2af43dd1dc201d1fecefda64a45f4ad0995855b92724f795a777b402ee69/regex-2026.5.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0de5cf193997384ed2ca6f1cd4f78055b255d93d82d5a8cd6ba0d11c10b167e4", size = 800643, upload-time = "2026-05-09T23:14:22.265Z" }, - { url = "https://files.pythonhosted.org/packages/a4/dd/23a249047013b5321d4a60c4d2437462086f601b061776a525e5fba2a59f/regex-2026.5.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d641a8c9a61618047796d572a39a79b26167b0411d2c3031937b2fe2d081e2cf", size = 777223, upload-time = "2026-05-09T23:14:24.179Z" }, - { url = "https://files.pythonhosted.org/packages/94/6a/e85ed9538cd19586d0465076a4578a12e093ce776d15f3f8ce92733a8dd6/regex-2026.5.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:24b2355ef5cc9aa5b8f07d17704face1c166fdcc2290fa7bd6e6c925655a8346", size = 785760, upload-time = "2026-05-09T23:14:26.065Z" }, - { url = "https://files.pythonhosted.org/packages/2a/c4/f25473209438638e947c55f9156fd8f236f74169229028cc99116380868e/regex-2026.5.9-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a24852d3c29ad9e47593593d8a247c44ccc3d0548ef12c822d6ed0810affe676", size = 860891, upload-time = "2026-05-09T23:14:28.17Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f7/f4f86e3c74419c37370e91f150ae0c2ef7d34b2e0e4cdd5da046a02e4022/regex-2026.5.9-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:916714069da19329ef7de197dcbc77bb3104145c7c2c864dbfbe318f46b88b14", size = 765891, upload-time = "2026-05-09T23:14:30.06Z" }, - { url = "https://files.pythonhosted.org/packages/26/70/704d8e13765939146b1cd0ef4e2feb71d7929727d2290f026eed10095955/regex-2026.5.9-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:fa411799ca8da32a8d38d020a88faa5b6f91657d284761352940ecf9f7c3bbdd", size = 851380, upload-time = "2026-05-09T23:14:32.123Z" }, - { url = "https://files.pythonhosted.org/packages/26/29/1a13582a8460038edc38e49f64ceb0dd7c60f5caba77571f4bf6601965d9/regex-2026.5.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e6da47d679b7010ef27556b6e0f99771b744936db1792a10ceac6547ae1503e", size = 789350, upload-time = "2026-05-09T23:14:34.799Z" }, - { url = "https://files.pythonhosted.org/packages/73/56/3dcafe34fc72e271d62ad9a291801e88a1457bb251c132f15fcc2e5aad1a/regex-2026.5.9-cp314-cp314-win32.whl", hash = "sha256:98bd73080e8756255137e1bd3f3f00295bbc5aa383c0e0f973920e9134d7c4ad", size = 272130, upload-time = "2026-05-09T23:14:36.729Z" }, - { url = "https://files.pythonhosted.org/packages/d0/9c/02eebf0be95efe416c664db7fb8b6b05b7a0b06a7544f2884f2558b0526f/regex-2026.5.9-cp314-cp314-win_amd64.whl", hash = "sha256:ff8d372ac2acdc048d1c19916f27ee61bc5722728458ba6ca5052f2c72d51763", size = 280999, upload-time = "2026-05-09T23:14:39.126Z" }, - { url = "https://files.pythonhosted.org/packages/70/5a/1dd1abee76cb7a846a0bcf42fdc87e5720c3c33c24f3e37814310a513d9f/regex-2026.5.9-cp314-cp314-win_arm64.whl", hash = "sha256:e1d93bf647916292e8edcec150c07ddf3dc50179ccaf770c04a7f9e452155372", size = 273500, upload-time = "2026-05-09T23:14:41.059Z" }, - { url = "https://files.pythonhosted.org/packages/86/c1/c5f619b0057a7965cb78ec559c1d7a45ce8c99a35bea95483d64959a93d9/regex-2026.5.9-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:83d0ee4a57d1c87cb549e195ec300b8f0ec3a82eba66d835e4e2ed8634fe4499", size = 494269, upload-time = "2026-05-09T23:14:42.869Z" }, - { url = "https://files.pythonhosted.org/packages/05/2c/5d01f1aee33de4bbe60c8452945bfc8477ca7c5ae4450f6bfe711036cb36/regex-2026.5.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:d3d7eb5c9a7f6df82ed3cfac9beb93882a5cbcb5b8b157b56cb2b3b276574ac1", size = 293954, upload-time = "2026-05-09T23:14:44.822Z" }, - { url = "https://files.pythonhosted.org/packages/7a/fe/e8988b2ae2108c6ef71bd4aa8d87fbe257976dd0810e826cd75f701c68b6/regex-2026.5.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:075160bf16658e16d35233300b8453aac25de4cbea808d22348b6979668e924d", size = 292405, upload-time = "2026-05-09T23:14:47.211Z" }, - { url = "https://files.pythonhosted.org/packages/79/34/d2b0937faa7859263f7f0a3c6b103a1296306be6952dc173d0154e9a2f49/regex-2026.5.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45375819235558a4ff1c4971dc32881f022613abdb180128f5cb4768c1765a1c", size = 811855, upload-time = "2026-05-09T23:14:49.21Z" }, - { url = "https://files.pythonhosted.org/packages/80/fe/daf53a47457a8486db66c66c01ceb9c2303eecee3f87197f1e77eb1a736d/regex-2026.5.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ead4b163ac30a29574510cd4b3e2e985ac5290c05fc7095557d6a5f403fc31b5", size = 871189, upload-time = "2026-05-09T23:14:51.555Z" }, - { url = "https://files.pythonhosted.org/packages/1c/75/058fc4470cbfbf57d800aff1a0022b929a3f9fa553ee10a0cdf2070eb31f/regex-2026.5.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8c6e4218fbdfbcd4f6c19efca40930d24a621bf4b48cb76bc6640543bd28ef20", size = 917485, upload-time = "2026-05-09T23:14:53.633Z" }, - { url = "https://files.pythonhosted.org/packages/88/e7/179cfda3a28bc843b5c6cfe7f79f23489c791ed95f151083803660878432/regex-2026.5.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6351571c8a42b505eb555c0dc47d740d0fb66977dc142919eea6f4325b7c56a0", size = 816369, upload-time = "2026-05-09T23:14:56.198Z" }, - { url = "https://files.pythonhosted.org/packages/41/90/6f0cc422071688266d344fca8462d787cba0a2c144acb25721f9a61ec265/regex-2026.5.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:002205cafd2a9e78c6290c7d1df277bf3277b3b7a30e0b4bb0dac2e2e3f7cb2d", size = 785869, upload-time = "2026-05-09T23:14:58.602Z" }, - { url = "https://files.pythonhosted.org/packages/02/67/a31f1760f09c27b251ef39e9beb541f462cf977381d067faa764c2c0e393/regex-2026.5.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8abd33fef90b2a9efac5557d6033ca82d1195ed3a15fea5af15ba7b463c6a63b", size = 801427, upload-time = "2026-05-09T23:15:00.642Z" }, - { url = "https://files.pythonhosted.org/packages/e3/c4/1a80654597b6bc1e1ea0494824c31200e8a956abe290afae9b19a166a148/regex-2026.5.9-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:31037c82eccb44b7ea2e9e221d7c01429430e989a1f4b91ea5a855f6017b509a", size = 866482, upload-time = "2026-05-09T23:15:03.384Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/960724e06482c08466ff5611e242e86f80062949cdf6b4b9cc317b9dd93d/regex-2026.5.9-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5604dfd046dc37eca90250fc3be938b076c8059fa772ac0ed6f499b0f0fb0415", size = 773022, upload-time = "2026-05-09T23:15:05.625Z" }, - { url = "https://files.pythonhosted.org/packages/50/a8/a9979c3e7918280e93159ebcab5ef1a65116dd4f3bd6091be0eae4a126e8/regex-2026.5.9-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:0e1b1b4e496afbb24f4a62aba855ee4f88f25578927697b340702e48c9ee6bc2", size = 856642, upload-time = "2026-05-09T23:15:07.966Z" }, - { url = "https://files.pythonhosted.org/packages/fe/d4/a9b732f2f0072c0ab12227483abb24fffcb9f73f8a2b203df0a6d0434735/regex-2026.5.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:be3372b9df6ddecff6486d37e19095a7b4973137caf5512407a89f4455361f41", size = 803552, upload-time = "2026-05-09T23:15:10.215Z" }, - { url = "https://files.pythonhosted.org/packages/d5/fe/1b3113817447a1d4155e4ac76d2e072f42c0bcba2f43fa8a0e756ea2cd91/regex-2026.5.9-cp314-cp314t-win32.whl", hash = "sha256:3ddd90103f9e5c471c49c7852ecc1fe27c7e45eb99e977aefe7caa4e779f4f58", size = 275746, upload-time = "2026-05-09T23:15:12.609Z" }, - { url = "https://files.pythonhosted.org/packages/92/73/93d42045302636c91f2e5ef588b65b84b01428f28ec77de256b1dfdfbe5c/regex-2026.5.9-cp314-cp314t-win_amd64.whl", hash = "sha256:ca518ed29c46eecba6010b15f1b9a479314d2de409536e71b6a13aa04e3b8a77", size = 285685, upload-time = "2026-05-09T23:15:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/da/80/35b4c33c804a165a7f55289afda3ea9e3eb6d15800341a2d66455c0f1f30/regex-2026.5.9-cp314-cp314t-win_arm64.whl", hash = "sha256:5e41809d2683fcde7d5a8c87a6567ba1fb1ce0de9f31bff578de00a4b2d76daa", size = 275713, upload-time = "2026-05-09T23:15:16.98Z" }, +version = "2026.6.28" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f1/05/e4f219230e11e774a6c9987d2ab0d0c6b8573e13a17e143d0015bee710ef/regex-2026.6.28.tar.gz", hash = "sha256:3cb4b6c5cb3060cc31efdc1fbb27c25fb9b29044afd87e40601a1c4d9db54342", size = 416101, upload-time = "2026-06-28T19:56:55.302Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d8/dc/f7a8c9cf0768f704153d358fae2bc883199bc4ea1e4aa458f1be9d0ef2ce/regex-2026.6.28-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:b83932645630965fd860fdb70ebbf964bf3e8007f08851ea424d01f8d35454a8", size = 489471, upload-time = "2026-06-28T19:53:06.385Z" }, + { url = "https://files.pythonhosted.org/packages/44/b3/9786a4a2133e2f1cc5897ed3d2da3da29ff54b775ffa38bc5935fc24be82/regex-2026.6.28-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e81f1952355042e517dc9861ce65c676e4a098f42402993c40461786d1f794d4", size = 291294, upload-time = "2026-06-28T19:53:09.232Z" }, + { url = "https://files.pythonhosted.org/packages/dd/1f/bfe5b529257f0853aa6b94146e0f6462f4d45aa4f3c05d5a828f415dfd40/regex-2026.6.28-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2097591101d70bcc108af64c46f6066bb698ee067fec5f75beac0be317639311", size = 289216, upload-time = "2026-06-28T19:53:10.682Z" }, + { url = "https://files.pythonhosted.org/packages/25/56/f615165e90ac5f3b72b249240643439520bbac0ac60a9de06868528eba4c/regex-2026.6.28-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:31d7538a614b5842bf53ce329d07b43f97754ca7e6db8d69f347e071bce1c953", size = 784787, upload-time = "2026-06-28T19:53:12.393Z" }, + { url = "https://files.pythonhosted.org/packages/04/94/c9e3ad31b3d5fbe1228fee8319e0c02a5460296624f220d08764547fe6ae/regex-2026.6.28-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f5561e47bbe2b75373b695326507743fcdd4d2cc7f5022312024ccf39fa094e0", size = 852137, upload-time = "2026-06-28T19:53:14.287Z" }, + { url = "https://files.pythonhosted.org/packages/c0/77/d506a428e446466ee298f5425a774737d0671d070425ed794bb3314d60c6/regex-2026.6.28-cp310-cp310-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c10f2c5a55ab3dd8318d8ad5f11b530e2691c0edebebde7713066f484902c3fb", size = 899525, upload-time = "2026-06-28T19:53:15.987Z" }, + { url = "https://files.pythonhosted.org/packages/aa/72/becc00d839f19401f10a20168b44711c7b02f7f62bba875b2d8f98417435/regex-2026.6.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23f7e0cc60c72486b42a685f1ff4eec90d50d4fb05e4f9c7d5363b03aa02600d", size = 794116, upload-time = "2026-06-28T19:53:17.372Z" }, + { url = "https://files.pythonhosted.org/packages/fa/11/ea2ca423eeaac2e18077a18b058614e9201f130750df2126d444e39acab2/regex-2026.6.28-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:76493755f79a88d5ed2c9e63a41d3c05997e0a7ffbe76ed8c4ded8be35b8b14c", size = 786257, upload-time = "2026-06-28T19:53:18.712Z" }, + { url = "https://files.pythonhosted.org/packages/6d/9e/f5bf7ecbd14ff2086f015c54dc24fd0d74ba5327fef0de479213f8128615/regex-2026.6.28-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:ff0f41a00f23ea5054acb61901380c41813d813eee3f80f800995710bcc52ecd", size = 769914, upload-time = "2026-06-28T19:53:20.564Z" }, + { url = "https://files.pythonhosted.org/packages/43/04/f9040a5360a06241ba5b7f2e6f1c6184e104a84e6f6522535700e94bf8e2/regex-2026.6.28-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:3c60b297292e7e1ef5d02a4759f9e452ee4c8bb95e168d8fd0b5db01bd806f9f", size = 775013, upload-time = "2026-06-28T19:53:22.067Z" }, + { url = "https://files.pythonhosted.org/packages/73/97/4e46f7abf2f864319d2bcac609af3c0532968c66a3364337778fd232b83c/regex-2026.6.28-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:a7cf03c87f7b9cbc25a8894cf9be83818406677b6b391b003ec7c884923387b5", size = 848814, upload-time = "2026-06-28T19:53:24.575Z" }, + { url = "https://files.pythonhosted.org/packages/f7/b8/3d1f995727799a1e2e693e397acb7358094606e5591b6b5fd3128d2d1409/regex-2026.6.28-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:418208ea0af51cfed4f46eb9b1ea7cfc990ca284f0084ecbd951460fb089421e", size = 757702, upload-time = "2026-06-28T19:53:26.215Z" }, + { url = "https://files.pythonhosted.org/packages/20/10/fd5653b8572910a4fe9055f8959b070d7d9443c94ce986529fcdb5fb2a3c/regex-2026.6.28-cp310-cp310-musllinux_1_2_s390x.whl", hash = "sha256:7635fa2cddb917a6bbfac7890602573d2d8c4e470703b0640e6f86a988817ec3", size = 837140, upload-time = "2026-06-28T19:53:27.655Z" }, + { url = "https://files.pythonhosted.org/packages/5d/31/da77e3ef7b594a2aacbd03ce3d0050f33ab3e021df50c6901467c9006511/regex-2026.6.28-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7bb96c13d6cf5880d31bbef84ca701a64d738aa491c2b79975cc33f8ad00a31e", size = 782105, upload-time = "2026-06-28T19:53:29.375Z" }, + { url = "https://files.pythonhosted.org/packages/cd/4d/c379001448d0f58b6946f168d4af96ad60a16c1553259c27b0df8701b640/regex-2026.6.28-cp310-cp310-win32.whl", hash = "sha256:56f05194c4843957dd8b3af87eb0c52d8cf0509e7f18e172d727f5f8ff840646", size = 266728, upload-time = "2026-06-28T19:53:31.813Z" }, + { url = "https://files.pythonhosted.org/packages/f3/8f/cb656529efa87d74cce0d69e606c745537016da3bdfae78f342af2242ee3/regex-2026.6.28-cp310-cp310-win_amd64.whl", hash = "sha256:70710927033af3b54369f17aaba1343b97a23d0b1aa994fa1512b08b1b8c136a", size = 277901, upload-time = "2026-06-28T19:53:33.293Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ac/d35ccc309c9409406445ab2ef0b56f6a341a916ccff49ff9ac5cc6bb8e9b/regex-2026.6.28-cp310-cp310-win_arm64.whl", hash = "sha256:ed7b30185ee3f8b9b053b0be567b4d226016e2afbebc17fde1c6a4580937b688", size = 276880, upload-time = "2026-06-28T19:53:35.029Z" }, + { url = "https://files.pythonhosted.org/packages/72/db/9051b36294bdbabaa9c7db57db0fbcdfbd17f7a106c539bb423d0323faea/regex-2026.6.28-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:a71b51dd08b9b62f055fafab3dee8af8bd2ec81b373a44caef18d6c5ca28f43a", size = 489481, upload-time = "2026-06-28T19:53:36.684Z" }, + { url = "https://files.pythonhosted.org/packages/35/3f/24097a3c3ff30f9a639888900faaecabcf5f54a5bc9c851c297e11b349ef/regex-2026.6.28-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9c26a47770d30a0f85c01e261d2a3ebc342c4af6fd666dbd8c1fe4cbf3adf726", size = 291292, upload-time = "2026-06-28T19:53:38.39Z" }, + { url = "https://files.pythonhosted.org/packages/5e/cc/e0d762a189cfb4e8926d16e691720690d139a977b38fdb80230c259332ab/regex-2026.6.28-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e5efbc1af38f97e300d43028e5a92e752d924bcfb7f465d8669d5d5a6e78c233", size = 289232, upload-time = "2026-06-28T19:53:40.181Z" }, + { url = "https://files.pythonhosted.org/packages/4b/c8/ca0ac7f09cc88ca61e0c61c53f7db29334f660ffba5d0b52378e7c44723c/regex-2026.6.28-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f1758df6fdd8c800620a5638958720e8a635e1da49a2f09df2dd63e94a24ec4a", size = 792332, upload-time = "2026-06-28T19:53:41.782Z" }, + { url = "https://files.pythonhosted.org/packages/8e/92/04ae94cbe0dd1f478b2aef6c46f995bb6946d3e338d4b28605478b66a2b7/regex-2026.6.28-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ad73ecf20c1ef5c975639f8bf845a9370fcf7dada7edc1e3b0bca20e2f8202f6", size = 861743, upload-time = "2026-06-28T19:53:43.261Z" }, + { url = "https://files.pythonhosted.org/packages/4c/ec/024d7638c807679ff8a0e6081d01d66c7762339af1cac71e45911587ff9a/regex-2026.6.28-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4d80c798b0eec6ea3d45f8816a1e8886c5664615d347d89e8c075b576a1b5a5d", size = 906481, upload-time = "2026-06-28T19:53:44.948Z" }, + { url = "https://files.pythonhosted.org/packages/cd/fd/93bfe5af45f0be4fa8983945455c0e6924e1aeb879cde227958869c1e71c/regex-2026.6.28-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a361feeaf1b6ba1df060f2ff5c5947092edf537a35ce78e76387ac56d3e0f4a4", size = 799867, upload-time = "2026-06-28T19:53:46.997Z" }, + { url = "https://files.pythonhosted.org/packages/ee/fd/e5d965d41f2398c8ce0f37a4652f03bb297fd009bb796d390134225dda12/regex-2026.6.28-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8b92366d9c8bba9642989534073662abdd9b41faf7603a7ae71597833f3b88f0", size = 773632, upload-time = "2026-06-28T19:53:48.892Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d9/ff39afaec92b9ee2dba0302a4783976005091681069808938c31cf8df3b6/regex-2026.6.28-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:11251768cc23f097dd61b18f67966e70f74da822784d17e12a444eb6b29d4288", size = 781669, upload-time = "2026-06-28T19:53:50.693Z" }, + { url = "https://files.pythonhosted.org/packages/45/4e/e2fd4bb8228e10c24af2d7ff867182372190e498eab9fd29cbe54c403c95/regex-2026.6.28-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:ad5c67786145ec28a71a267d9f9d92bdc8d70d65541eea852c253f520a01f918", size = 854497, upload-time = "2026-06-28T19:53:52.323Z" }, + { url = "https://files.pythonhosted.org/packages/72/7c/f0340384a973082979064156d05f3d2cc1dced7371efcd7a1b45726a1a8a/regex-2026.6.28-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:f1da438e739765c3e85175ede05816cbede3caaacb1e0680568bda6119bfdfca", size = 763335, upload-time = "2026-06-28T19:53:54.024Z" }, + { url = "https://files.pythonhosted.org/packages/e1/32/90ce0d0898e205506cc22b9c81cfb16b722e06ca5f50fad51c053c2a727b/regex-2026.6.28-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d98b639046e51c5de64d9f77351532105e99ca271cb6f7640e1f903d6ab63032", size = 844615, upload-time = "2026-06-28T19:53:56.216Z" }, + { url = "https://files.pythonhosted.org/packages/6a/ef/55abb149599dce1ade687170557129524011eeb3d92afe02429cea7754a2/regex-2026.6.28-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1e164ace4dbab5c6ad4a4ac7c41a2638fe226d0c770a86f2eb041f594bac6ee7", size = 789193, upload-time = "2026-06-28T19:53:57.791Z" }, + { url = "https://files.pythonhosted.org/packages/f8/ea/cf7f6f6f152e52fdad978b913bf24c14df647eca0f81ef31f3aee0be8982/regex-2026.6.28-cp311-cp311-win32.whl", hash = "sha256:3169a3159e4d99d9ae85ff0ed90ef3b8906cc3152653b6078b842ace6c8f72c3", size = 266731, upload-time = "2026-06-28T19:53:59.938Z" }, + { url = "https://files.pythonhosted.org/packages/c6/cf/a48d8e8d406b22481cad146f48fa0dfca3c5f402b91f26d8e5a0fe4f513d/regex-2026.6.28-cp311-cp311-win_amd64.whl", hash = "sha256:5977295b0a74e8241df8a4b3b27b12412a831f6fa32ee8b755039592cd768c3d", size = 277918, upload-time = "2026-06-28T19:54:01.502Z" }, + { url = "https://files.pythonhosted.org/packages/89/b2/a222392207db7ed86281a732a99f7cf7f2bb35d332799e892b8510be000e/regex-2026.6.28-cp311-cp311-win_arm64.whl", hash = "sha256:f5fbaef40c3e9282ccee4b075f5600a0d858aa0c34147732f1baa69c8188a95d", size = 276876, upload-time = "2026-06-28T19:54:03.411Z" }, + { url = "https://files.pythonhosted.org/packages/da/21/44aa415873032056c43eac21c67285deb2cf66cddb2a964c3cdc8f803efc/regex-2026.6.28-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:81cc5793ad33a10444445e8d29d3c73e752c8fb2e120772d70fcb6d41df40fe1", size = 490480, upload-time = "2026-06-28T19:54:05.392Z" }, + { url = "https://files.pythonhosted.org/packages/8b/5f/30d4116093c2128099f78b6990dfc1698fdbf3ee528f1e1c647378034c79/regex-2026.6.28-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:e18225243250a1f7d7e5e5d883f3b96465cd79031acf5c6db902b7025f2125d9", size = 292137, upload-time = "2026-06-28T19:54:07.088Z" }, + { url = "https://files.pythonhosted.org/packages/cb/0e/ca20a0e0de49837e6337603a91ab77556aa27033ac5b975615d98698cfb3/regex-2026.6.28-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ecd1638b1c2db1f2d01c182a4b0d3e2e88b0e99910320a745c1727ee3638ddab", size = 289623, upload-time = "2026-06-28T19:54:08.762Z" }, + { url = "https://files.pythonhosted.org/packages/50/11/c013422a7e2c59946df8ac93e792a4922c98287f2a2181341603c78a5d98/regex-2026.6.28-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4303ebe16b74eeb3fe2715745023266fea92fd44a23f3e7bb2fb48c7a7bbc195", size = 796756, upload-time = "2026-06-28T19:54:10.616Z" }, + { url = "https://files.pythonhosted.org/packages/b0/95/1309645a0e1ee6fb91d954501da57a0b33d50ad2a9acb313702851a7054e/regex-2026.6.28-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56b856b70b96c381d837f609eee442a1bd320cd2159f5c294b679552fb1a7eaf", size = 865465, upload-time = "2026-06-28T19:54:12.742Z" }, + { url = "https://files.pythonhosted.org/packages/20/06/491802db47c6f5e2904ffa2518ad3ac27fe6bbf5a66d73210a95cc080d47/regex-2026.6.28-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f74675ab76ab1d005ffba4dee308e53e89efc22be6e9f9fae5b539a3f81bdff2", size = 912350, upload-time = "2026-06-28T19:54:14.508Z" }, + { url = "https://files.pythonhosted.org/packages/5e/60/3ba57840bcc7e2367090360de0c15a5ba6ad22be89314251105f2e943f43/regex-2026.6.28-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90581684565a93f7258af1e5d3f41ef20d7d7c61f2a428183a342bcb65485e38", size = 801261, upload-time = "2026-06-28T19:54:16.432Z" }, + { url = "https://files.pythonhosted.org/packages/eb/27/af1eb74e9a78c782b3e450b611a595e44906da8a5107e1227f4a7fd0480b/regex-2026.6.28-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:28f9e6c28f9b90f6f784595a33240a57e181e61b6ee3dc259b25c61e356d1aa3", size = 777072, upload-time = "2026-06-28T19:54:18.128Z" }, + { url = "https://files.pythonhosted.org/packages/20/18/fdd4c883a39e3ed00d669062af1135809bfd3281bf528150849fbd68825b/regex-2026.6.28-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:378a71d861fc7c8806b04ac5b133d53c0e774f92f5d9663a539872d3fa2b0417", size = 785119, upload-time = "2026-06-28T19:54:20.314Z" }, + { url = "https://files.pythonhosted.org/packages/1c/79/0aabe34b8482dcadf64355f70f96e22eba5ec6c1efb33563f89654f4061c/regex-2026.6.28-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:4cc199874ecd6267a49b111052250825bfe19b5101b23b2ba80f54efa3e0994e", size = 860118, upload-time = "2026-06-28T19:54:22.368Z" }, + { url = "https://files.pythonhosted.org/packages/a8/2c/c973323306a27c9db7d160e9584eb7e0ece2a96224ccb0d39060558b31f9/regex-2026.6.28-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:b916a10431494ef4b4d62c6c89cab6426af7873125b8cd6c15811bf5fc58eec8", size = 765786, upload-time = "2026-06-28T19:54:24.265Z" }, + { url = "https://files.pythonhosted.org/packages/e3/df/9ca3e378e352242a4cb45573a5e9162c3ee791507702a23966fa559e36b5/regex-2026.6.28-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:2e27727fba075f1e4409416d2f537d4c30fc11f012ea507f7bd74d3e19ecb57a", size = 852120, upload-time = "2026-06-28T19:54:25.972Z" }, + { url = "https://files.pythonhosted.org/packages/a2/3e/3e31e255c4971f53cbce6306b5e3c76cbd3735a54f419bb3b2f194e9f68c/regex-2026.6.28-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:700fc6a7844bb2c4149292ac79d1df8841a00acd4d45cd32c1ebc7bcc1fd0da8", size = 789503, upload-time = "2026-06-28T19:54:27.678Z" }, + { url = "https://files.pythonhosted.org/packages/72/01/d36561c21c3033d7eeb31d51b491916817de7861acefccc5fc9db8a5037c/regex-2026.6.28-cp312-cp312-win32.whl", hash = "sha256:03376d60b6a11aecb88a79fa2be06b40faa01c6693bc31ef69435cd4818b9463", size = 267109, upload-time = "2026-06-28T19:54:29.316Z" }, + { url = "https://files.pythonhosted.org/packages/a0/59/bbbb0591f38b18c65977cd65ce64749eba1c1996c99ac04e900fc30c0dcb/regex-2026.6.28-cp312-cp312-win_amd64.whl", hash = "sha256:fbd2ded482bf99e6651992bbfcde460272724d4bbc49ef3d6b46d9312867ec84", size = 277711, upload-time = "2026-06-28T19:54:31.143Z" }, + { url = "https://files.pythonhosted.org/packages/86/06/be4f6b337d773ae5739a1bc238f97c16926e72017243735853c030f4c628/regex-2026.6.28-cp312-cp312-win_arm64.whl", hash = "sha256:37294d3d7ddb64c7e89184b2894e0f8f0a19c514bc59513d71fe692c3a8d5fc6", size = 277022, upload-time = "2026-06-28T19:54:32.97Z" }, + { url = "https://files.pythonhosted.org/packages/b6/53/d5c1b3cc0b5a0c985563ad6fac93d73ff2b300cb84342d89f044625d6bc7/regex-2026.6.28-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:b295a83426e0e44e9e60fde99789e181bd26788a1890ae7fe2a24c69bb6246ca", size = 490329, upload-time = "2026-06-28T19:54:35.775Z" }, + { url = "https://files.pythonhosted.org/packages/8d/9f/0c3503e819e91ca0e7a901a8e989ebf840ac7c7aea20b1fc7f31b6759f77/regex-2026.6.28-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0c31665c0deb5c111557a1cac8c27bd5629e2f9e7fd5058900a03576c33b601c", size = 292039, upload-time = "2026-06-28T19:54:37.977Z" }, + { url = "https://files.pythonhosted.org/packages/bb/7f/cd004e13fcad23b3794a82307dfd222e6365eb7f598bd3caab148a830bff/regex-2026.6.28-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:6bf295f2c59de77d1ea7de053607ae4dc9ceb3d57bbb6c7ec51ef4acc4ccff94", size = 289488, upload-time = "2026-06-28T19:54:39.545Z" }, + { url = "https://files.pythonhosted.org/packages/73/4c/293fb34586fbcdc47eac436069e9c11f71fae5dadfd4889b475d7d2e5f7a/regex-2026.6.28-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:17c077586770f67e05bbffeba07fbee6b2b22244f4d4caf8d94e59d574befe04", size = 796772, upload-time = "2026-06-28T19:54:41.347Z" }, + { url = "https://files.pythonhosted.org/packages/92/fa/c0cd1a90b7d12d9dc155cfc8bdea8df9720988ea5b07e8fa1eccbd0ab2dd/regex-2026.6.28-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0e6cb5a61486f9062397d2e189573b39d38ecfaed698fd9fb6e2756a8ebb8762", size = 865467, upload-time = "2026-06-28T19:54:43.485Z" }, + { url = "https://files.pythonhosted.org/packages/4e/db/0b479973046d005a1eaea299d5d536aeecb9488a16d9cbb8286338102e2d/regex-2026.6.28-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e86e91a2664f44c3a4e363a7d78fb17c27d5046882e30ea5a877f5e89b28d2ba", size = 912345, upload-time = "2026-06-28T19:54:46.091Z" }, + { url = "https://files.pythonhosted.org/packages/5b/5b/d65adfbd02f32212431bca1f06d1e2eb763a20b12978b454bafaf23dacb7/regex-2026.6.28-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4dfd1331c49233998d84fc5f1f4436cf7a435a7655f6cf0f490229bb5c7254e5", size = 801291, upload-time = "2026-06-28T19:54:48.3Z" }, + { url = "https://files.pythonhosted.org/packages/fc/09/2103686defaf9a0a31c1663782359d5b45f42524c64cca681f5481e44a5e/regex-2026.6.28-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:cadea12805a1bce0b091c302b814207be26fb60a9c0e7f9ad2f9e21790a429fe", size = 777106, upload-time = "2026-06-28T19:54:50.326Z" }, + { url = "https://files.pythonhosted.org/packages/85/5a/b57593c0aa23ed269ec332fbcf07852abcb6b746e811d9464e0d09b4e25f/regex-2026.6.28-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:5f2c1682b67ad5d2376498f2a5a2a8f782fa2e4a06d0465b5e357799806e8a20", size = 785175, upload-time = "2026-06-28T19:54:52.172Z" }, + { url = "https://files.pythonhosted.org/packages/79/59/c36e756ad29bf14d7b6c6d7138952476b21f6160286cedb98ac13481c993/regex-2026.6.28-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:64e142eb55e84868087da1375d7c36ff97d55010951849f515322a91d5fef1b4", size = 860186, upload-time = "2026-06-28T19:54:54.11Z" }, + { url = "https://files.pythonhosted.org/packages/61/66/49808aea0da9649c300139360708fb91b7144be1f962fcebf96755fde948/regex-2026.6.28-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:abb4daabe7be63273787a62dfd6164dadf8f7a63fbec3d2730e5e5e7126d858c", size = 765754, upload-time = "2026-06-28T19:54:56.04Z" }, + { url = "https://files.pythonhosted.org/packages/be/c5/52bbd436cf2200decdf48825fa38363eaaeebb77011ea9928a1ef9e0b9f2/regex-2026.6.28-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:ec2b2ad00ab8c16a2798cc8db80c53c4d5b8b3a2441f6cbaef06625f5ca25854", size = 852085, upload-time = "2026-06-28T19:54:57.988Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c3/0390b66e3019497143fe768b3ba567b64d8b24f3812d09506deb86f4a0f0/regex-2026.6.28-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:bfc9677982c914d9085b8e1c3b3ae6e88f139fb56531c2416d6c8f338093c22b", size = 789600, upload-time = "2026-06-28T19:54:59.977Z" }, + { url = "https://files.pythonhosted.org/packages/88/fd/ab5b03653a244975069fed93d73f4f5f7484c03a84cedb238292510d7182/regex-2026.6.28-cp313-cp313-win32.whl", hash = "sha256:bf54bc693fc4e0530e666ba5ec4bcba14dbe8f66b7cfc15c27317d1a6e40b9a5", size = 267088, upload-time = "2026-06-28T19:55:02.159Z" }, + { url = "https://files.pythonhosted.org/packages/68/55/21022f7d3143210ae8d4ff905c45306237b657375cc0b97883f49db3d423/regex-2026.6.28-cp313-cp313-win_amd64.whl", hash = "sha256:e128feaf65bf3d9eb91bec92322a8f7e4835e9c798f3e9ea4b69f4def85620e3", size = 277680, upload-time = "2026-06-28T19:55:04.185Z" }, + { url = "https://files.pythonhosted.org/packages/b6/99/7f664804f1aef924542b0b233996b78b3e4d0a52d9951358aac99f129f51/regex-2026.6.28-cp313-cp313-win_arm64.whl", hash = "sha256:695873e0ea8d3815ea9e92e2c68faf039cc450e2c0a62a31afe2049eb11be767", size = 277017, upload-time = "2026-06-28T19:55:06.29Z" }, + { url = "https://files.pythonhosted.org/packages/cb/e1/9eb83518e159d719fd681c4932dc2aaff855ce72451e1d05d69466f25a96/regex-2026.6.28-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:189dbf9fc4252d9f1352bf4bd1bef885edb6cc4b7341df202a65f821aaa3891c", size = 494195, upload-time = "2026-06-28T19:55:08.292Z" }, + { url = "https://files.pythonhosted.org/packages/fd/e2/e259c5f2f7be269d0e2fb54275c1fa6a13fb47019f389c3f3ae457447825/regex-2026.6.28-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:9277a4c6503390aa39cb4483b87ec0384faee0850a23b5cea33d008b5d8d83f1", size = 293976, upload-time = "2026-06-28T19:55:10.014Z" }, + { url = "https://files.pythonhosted.org/packages/8d/4e/9bdf444014d22b045d0c82ca114fac7e07a597b5b5331b7c4ce6328426e2/regex-2026.6.28-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:17eddca4e8ea9af0b5739314776cdf0172a49731ab61f2e1ea66e066ddd46c97", size = 292340, upload-time = "2026-06-28T19:55:11.88Z" }, + { url = "https://files.pythonhosted.org/packages/fd/3a/f49b11e59cbfe187ace0053a460bd72a0169b8cd52e7db9421a074ce7a43/regex-2026.6.28-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e4466b8641e00c697aab5a73150150d2b2ea96b131c595691f42031abafd9f4d", size = 811704, upload-time = "2026-06-28T19:55:13.612Z" }, + { url = "https://files.pythonhosted.org/packages/2f/fb/ad04c39e149bf8b6cf357df5fff78341733ec366780a00c803a36735818c/regex-2026.6.28-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9cfcd4b0bdcf768c498415c170d1ed2a25a99bf0b65fa253bbd02f68ceba6475", size = 871157, upload-time = "2026-06-28T19:55:15.797Z" }, + { url = "https://files.pythonhosted.org/packages/7f/64/0e5ba31c11eb8ef7aac19a690c1211fc9aa9990caf09565785ebb0081b9a/regex-2026.6.28-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:80c7adf1ef647f6b1e8aa2ca280e517174cd08bdf7a2e412cdfb68bd6a0917cb", size = 917287, upload-time = "2026-06-28T19:55:18.692Z" }, + { url = "https://files.pythonhosted.org/packages/11/75/6b78df2b858c2fcbbc4858fdc3f2975cf2703be374b2842db7d2c32591a7/regex-2026.6.28-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a043f5770e82283a22aed4cefef1a4e0f9dd8fd7184cb6ce0ad2e579e2134a9e", size = 816333, upload-time = "2026-06-28T19:55:20.973Z" }, + { url = "https://files.pythonhosted.org/packages/b4/01/ecfe665a3694d5eda9f3ec686c856438ada0943947b6005e90556a1e2cdf/regex-2026.6.28-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3bd630a8dba06b55254ea5ee862194edab52ec783100d2ef1cd15a9c512fee27", size = 785518, upload-time = "2026-06-28T19:55:23.003Z" }, + { url = "https://files.pythonhosted.org/packages/b4/0a/88f9cd88ff1e82881605c4ffd62d77ee67d051232cfe6f8e9a64b86cf0e8/regex-2026.6.28-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b77207e3cee13086f1906a6a2a12b41244c577e8ad9370d4b35ae1d548d354f3", size = 801371, upload-time = "2026-06-28T19:55:24.888Z" }, + { url = "https://files.pythonhosted.org/packages/a8/97/601483732f93275482ceb9fed57813dfed7c47d3a019db6ec4a3bb6e23e0/regex-2026.6.28-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:6de82c268e5d101ee9e3ffd869924aa9a371e3a21e752cf4fa17b6ce50d219f7", size = 866517, upload-time = "2026-06-28T19:55:27.232Z" }, + { url = "https://files.pythonhosted.org/packages/81/ed/385c2a0351b994a693453c1d1a6e9af9eb35db3c9460d76b5078acd70c62/regex-2026.6.28-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:b15859e3908544fb99cf47341dcf0bfd089147d258c4c4d8a29e5b087f8085cb", size = 772834, upload-time = "2026-06-28T19:55:29.154Z" }, + { url = "https://files.pythonhosted.org/packages/06/bc/bbf4a5b3b29770d7f307d3c28b5b1bca0105b0cb424be0a4eb1339bc92cf/regex-2026.6.28-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:c91487a917edd48a1ea646fdf60d7936d304f0e686fa7ea8326e47efca51d816", size = 856606, upload-time = "2026-06-28T19:55:32.186Z" }, + { url = "https://files.pythonhosted.org/packages/28/26/51d74fff82f682819979249f8d700267108ba5dc4eb284b0e11b9c85e4b3/regex-2026.6.28-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:c4ac65f3e3a99fd8f3a4a74e7a6610acd1ce9dfe9b8a03d346a4922380d68aeb", size = 803475, upload-time = "2026-06-28T19:55:34.328Z" }, + { url = "https://files.pythonhosted.org/packages/7c/3e/6be10cefdc813533fe604dbf5d3c77d2638e7ee658b2749ebadc113b6b2e/regex-2026.6.28-cp313-cp313t-win32.whl", hash = "sha256:3f6316f258bc7e6c9c2acbe9954947bbd397a81be3742a637a555f1855d6618d", size = 269126, upload-time = "2026-06-28T19:55:36.565Z" }, + { url = "https://files.pythonhosted.org/packages/3c/3c/32cda905ea1a6eeeb798291c294d8ec66ee0efe0cdba28b061e248b1d396/regex-2026.6.28-cp313-cp313t-win_amd64.whl", hash = "sha256:1484bdd6fba28422df9b5ebb04055b2e1b680e8e4f08490bb21ff0f3cc50d0ab", size = 279961, upload-time = "2026-06-28T19:55:38.456Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b9/69f4e5cd6fbe0bb420cb2dbae441ca118f2495bdda522a74da75aa9829e7/regex-2026.6.28-cp313-cp313t-win_arm64.whl", hash = "sha256:3f15020f0b69cafe57baa067ff65b29acef68ff6b1670a53bef1ca11d708e02d", size = 279266, upload-time = "2026-06-28T19:55:40.62Z" }, + { url = "https://files.pythonhosted.org/packages/3b/fb/fad3b810a5bb1e09b9e5d6913fc6ba88cab738fdf283196827a3c59a4c10/regex-2026.6.28-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:f7c032b0c8a73739ff8ff1aaf30c281fa19c17bf7f1543256c8507390db7807c", size = 490407, upload-time = "2026-06-28T19:55:42.724Z" }, + { url = "https://files.pythonhosted.org/packages/d6/52/b8c79d12276d93e90e707e939b396034c04980caf1235312ef790f8e11fc/regex-2026.6.28-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:f6710f512c57b84f127a23d0f59560a03b64136eff419ae1be5ab557577fe5e3", size = 291988, upload-time = "2026-06-28T19:55:44.549Z" }, + { url = "https://files.pythonhosted.org/packages/23/d2/6a911f18279daa8d7bb8b20d771ddb6ef31fabd35f5921f9d3ba21640e80/regex-2026.6.28-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c0013958f427bd82509a186b9ff206d66cb8d60a81fc797a4c717afd18c5b0ba", size = 289704, upload-time = "2026-06-28T19:55:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/fd/22/ad1955c47c669291a05804d53d7071cc0732dfdf166857be38003cedc2d1/regex-2026.6.28-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:94f06cdcd6421f8e194ad312ea608020381250df9b8a57661c1b57e9e5273878", size = 797017, upload-time = "2026-06-28T19:55:48.166Z" }, + { url = "https://files.pythonhosted.org/packages/e5/67/a83159ff8703ab4d0c2cf99e76ebf289b7b4a501623241d09f88f3614f80/regex-2026.6.28-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:ec9689392f7494ff4e3f8e7e8522f9158f11023f337eaaf04a64542fc45bbf26", size = 866112, upload-time = "2026-06-28T19:55:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/09/7bff2d6dbbd77421b3274aa51db1c887381cbc5b6eda93598c3e882ea345/regex-2026.6.28-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:aa084684e6d2078bf6139e374d1fc2af5ddc1ac7122759a2db716d68169f6fd0", size = 911554, upload-time = "2026-06-28T19:55:53.707Z" }, + { url = "https://files.pythonhosted.org/packages/29/44/ae59c3826e7ba492e56795cdf74ea2a7b5b7c5ea116afb79ee4956a5dff1/regex-2026.6.28-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:40455e6840dc4e96a6fe50f4cedc957de2752c954d91e789812be55d49be199a", size = 800665, upload-time = "2026-06-28T19:55:55.875Z" }, + { url = "https://files.pythonhosted.org/packages/d6/19/6fd033d2ab00f35d445aaeaf3307c1e721424dcbfd48f6f65c857cb939cf/regex-2026.6.28-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:530b5c223b9ca5dd8370ac502e080aee0e4ded32be987c6564b425fb5523d581", size = 777243, upload-time = "2026-06-28T19:55:57.909Z" }, + { url = "https://files.pythonhosted.org/packages/4e/9d/99730f26df4938049ab1e652ca75e967b4c6739444e18d9707bfdb8af20c/regex-2026.6.28-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8e0ed273ecd1a89be84466c1749bfe58609cc2a32b5d5e05006c4625ba96411b", size = 785784, upload-time = "2026-06-28T19:56:00.072Z" }, + { url = "https://files.pythonhosted.org/packages/48/49/105cd57162f5fc5c04cc917a1388a060cf8427e5c14353cd9044660fbf4d/regex-2026.6.28-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:0ab0d5344311fc8e8667078942056c3b9c9b4a4b1cc99f2eb8a5af54554f4acc", size = 860914, upload-time = "2026-06-28T19:56:02.017Z" }, + { url = "https://files.pythonhosted.org/packages/a2/a5/788245a95b69018f58bff2f4fd27d007cacaea088cdb390979743f1b2571/regex-2026.6.28-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:eacb79625323d9f7e7925366b917f492b8356fad58f5dc4fa12ff8c21d8f4ca9", size = 765915, upload-time = "2026-06-28T19:56:05.021Z" }, + { url = "https://files.pythonhosted.org/packages/ca/01/292065a39a004b05e67a337b18213670a7cb919d6856ac2d7df7f1a10dbb/regex-2026.6.28-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:20f4d87702702aa1d572721e146f301660c50eef6fd6cb596e48a22b0ace17db", size = 851404, upload-time = "2026-06-28T19:56:07.251Z" }, + { url = "https://files.pythonhosted.org/packages/98/9e/a93d865db0e13483ae1a01d81e2ce16d4a7fe2f9b9fe4aac4cc08590b136/regex-2026.6.28-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1e693940a3b9e6d6e4dc2a54ecaa74b74934f77af1ef95f518a74261ef7cc1bc", size = 789373, upload-time = "2026-06-28T19:56:09.894Z" }, + { url = "https://files.pythonhosted.org/packages/82/0c/38b1685ad4017d78efbc8fa7dbbf96d8113b53750c8aa2d3609defd46605/regex-2026.6.28-cp314-cp314-win32.whl", hash = "sha256:234a51e20ebc18ab83b2c0600cf28f2e884560a0e00f743878f0b7d8e7c4cf03", size = 272496, upload-time = "2026-06-28T19:56:11.83Z" }, + { url = "https://files.pythonhosted.org/packages/55/50/e19f261ff9ba9b50722a529e09b1743ecf65eb348be99d0fd2cd7fcede1c/regex-2026.6.28-cp314-cp314-win_amd64.whl", hash = "sha256:7b15c437bc4604f03ceb3f8d37eae2f8930e320e1bc556b259848c639d9eec1a", size = 280754, upload-time = "2026-06-28T19:56:13.758Z" }, + { url = "https://files.pythonhosted.org/packages/36/b8/c9e68f3a9e33be73f20990b2c065b144ff2d0aa242608a950d8c4f3b56e8/regex-2026.6.28-cp314-cp314-win_arm64.whl", hash = "sha256:c6e6f790d01380a74ad564f216c533b86504afb61bf66f2b2e11e7f1a3e287a7", size = 280979, upload-time = "2026-06-28T19:56:15.928Z" }, + { url = "https://files.pythonhosted.org/packages/03/e6/21c425a37880c650d007c4171c6a80325446d830d85f5fbf335e7205b1e7/regex-2026.6.28-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3527a72adcbe9e3600f1553b497d397c1a371d227580d41d96c3c5964109b65c", size = 494282, upload-time = "2026-06-28T19:56:18.049Z" }, + { url = "https://files.pythonhosted.org/packages/07/50/6647a7ccf5ffff995ba955a0b7d766440f4e58ce1666549c8ee998f2b972/regex-2026.6.28-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:a644f6408692812f5ead82519eed680e08d5d546fddbd9f7d9514e3c73899aa5", size = 293977, upload-time = "2026-06-28T19:56:20.145Z" }, + { url = "https://files.pythonhosted.org/packages/8c/dc/a3e141a4eaf125e50f63105570c01fa477c06ac5259dcfa95e9b90760e84/regex-2026.6.28-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8e2fae6bb883648346f84db270dc9aafc29d8e895f62b88a75ccc83b09519820", size = 292432, upload-time = "2026-06-28T19:56:22.345Z" }, + { url = "https://files.pythonhosted.org/packages/35/ee/2ac1a6b9f167f8ff69f5a789938cc103b60cff41b24a6990daced8b88e34/regex-2026.6.28-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:debe623e09cee97ef9404575e936c610aac9bb08358c5099aaef14644a6871f2", size = 811877, upload-time = "2026-06-28T19:56:25.056Z" }, + { url = "https://files.pythonhosted.org/packages/df/7b/9a5505ee92180bcae300b1018b9ff3d3c19962436e66f2505f255e9fde35/regex-2026.6.28-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc579c91fb4605773483a8d940b136bcc5b854fff44fa14a1572a038f46563f1", size = 871212, upload-time = "2026-06-28T19:56:27.352Z" }, + { url = "https://files.pythonhosted.org/packages/24/4d/d61a702a9f9d1bd29b22cbef1aed6d477baa961232a7eb4d91b7775b0b3e/regex-2026.6.28-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7c42be203d84ecf7d487ff23f8a61ef0eb0534fa0fc317a2fce8c065d20618f", size = 917507, upload-time = "2026-06-28T19:56:29.762Z" }, + { url = "https://files.pythonhosted.org/packages/d4/60/1308066f5966b65fbb6905b99ba37e9f1cd753dd0ac08485f8257334ee92/regex-2026.6.28-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e8184b4e2fdaf9cdfe77e38f15a4d9dc149168c9c29eb0ea17c5481d3bb80546", size = 816389, upload-time = "2026-06-28T19:56:32.043Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5c/57ce2cb8d714ee0b7f11c7ee4cfe2af66df2b90f147feadcb538609a3a02/regex-2026.6.28-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:697f103104f5872d64078d8eeac59979960be8ee76115a2d3f31096312e2a400", size = 785890, upload-time = "2026-06-28T19:56:34.492Z" }, + { url = "https://files.pythonhosted.org/packages/ff/fd/1d5350d3a8a327bff0fccacb911732baf7b5b6f5529c0e3fa602a23e7dad/regex-2026.6.28-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:714d2b1aa29beef0ddfcdc72ad0771c05326551a8bb0680b0ddf74bfaad87387", size = 801451, upload-time = "2026-06-28T19:56:36.749Z" }, + { url = "https://files.pythonhosted.org/packages/f3/79/3c9e4f8a0306e030ad5a43bbbc01625fb28d58a813bc52d42fd1cc63fb2e/regex-2026.6.28-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0f09f62e450cc2f113018cc8412aeea3a120a04e1ca7e801a0d441583f9a3b06", size = 866504, upload-time = "2026-06-28T19:56:38.994Z" }, + { url = "https://files.pythonhosted.org/packages/65/12/f747de475b54f4709efb24dd0fbc8467c64cec91f5db0d047b079646ee78/regex-2026.6.28-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:731ea12d5aeb2577eaef2393d6428b995f76eb35f68a89e03e15a97719d1de19", size = 773047, upload-time = "2026-06-28T19:56:41.061Z" }, + { url = "https://files.pythonhosted.org/packages/58/3c/f02f860e0500c1b2d61a79dec7e214b37fb9656281dcddc92397edf96678/regex-2026.6.28-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:51e952c8783eabd4706d0f63922f219bcfc1bef9b8cb35941c0d1a0396578858", size = 856665, upload-time = "2026-06-28T19:56:43.466Z" }, + { url = "https://files.pythonhosted.org/packages/4d/6c/28b3fa222513484be9dee26b7222bda109056c43ea28aa2314262ca48816/regex-2026.6.28-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43248fe4c0ab8fbb223588a0795b11268940072c97bba30ea8f9b49d8cdfde34", size = 803573, upload-time = "2026-06-28T19:56:45.791Z" }, + { url = "https://files.pythonhosted.org/packages/fa/f0/8f86cf1a1fd85c5ab0c503c9fe4607ad4ad48978b2d8b435d94465e134c7/regex-2026.6.28-cp314-cp314t-win32.whl", hash = "sha256:fc1eddc25ad23c0f1344ab280d961ac595ead48292d7c779497975942373f493", size = 274515, upload-time = "2026-06-28T19:56:47.948Z" }, + { url = "https://files.pythonhosted.org/packages/0f/de/f8613c03b36786ddef2c930d28f9bcae861fcd541cc9203a870956cf1e83/regex-2026.6.28-cp314-cp314t-win_amd64.whl", hash = "sha256:ede8d8e53b6dde0a50f7eca902f0af76d87ab02a55aba7542da68ae3e5dfe83d", size = 283650, upload-time = "2026-06-28T19:56:50.614Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f3/f5ec86839bbabe33b6dee649b62ff9a445d43de6b0ad780cf6b83c56f61e/regex-2026.6.28-cp314-cp314t-win_arm64.whl", hash = "sha256:4da6f6a72f8700b97a1a765e837fb7d5750bfd9f13acea7bae498f573e3a70a8", size = 283338, upload-time = "2026-06-28T19:56:52.879Z" }, ] [[package]] @@ -2872,7 +2881,7 @@ wheels = [ [[package]] name = "rpds-py" -version = "2026.5.1" +version = "2026.6.3" source = { registry = "https://pypi.org/simple" } resolution-markers = [ "python_full_version >= '3.15'", @@ -2881,137 +2890,123 @@ resolution-markers = [ "python_full_version == '3.12.*'", "python_full_version == '3.11.*'", ] -sdist = { url = "https://files.pythonhosted.org/packages/2e/43/25a8dcd3feedd735039a8f0b5b7e3b118232b5eae288c4fd9ab200d41094/rpds_py-2026.5.1.tar.gz", hash = "sha256:07b24fea40541e28570e5b795a4a38fbdcd12550c06bd0748005ecc8116ca256", size = 64459, upload-time = "2026-05-28T12:02:13.232Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/4f/a0/acf8b6fc20bfdcd3a45bd3f57680fb198e157b7e997b9123b10763798bd2/rpds_py-2026.5.1-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:3397a5ed7174dc2786bb214030232fc36fe8e5584fec43a9952cc542b1a12036", size = 355609, upload-time = "2026-05-28T11:58:50.78Z" }, - { url = "https://files.pythonhosted.org/packages/b6/95/f8203fd997484b1690a6869cd0e503b6c3c6be55b0ecc36d1a491fe742f0/rpds_py-2026.5.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:99ab6ba7bfa2cb0f96a04e3652355bf04e3f51aceb1e943b8541dab7ba4828cc", size = 348460, upload-time = "2026-05-28T11:58:52.374Z" }, - { url = "https://files.pythonhosted.org/packages/33/8c/b47326ad2f0be545a5e5c1a55937a12afaea7d392ba2837bb9680f57e6c9/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d0efbe45632665e53e3db8fe1e5692db58fc5cb9bab4459d570b83efefe11164", size = 381031, upload-time = "2026-05-28T11:58:53.775Z" }, - { url = "https://files.pythonhosted.org/packages/22/0b/e83bbd97ffac6f6389b605cd4e1c8ac5761dc7e977769c9255d8c5adb7bd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:01d17b29c0c23d82b1f4751147ec49cf451f1fc2554eb9ef5f957e55d2656ead", size = 387121, upload-time = "2026-05-28T11:58:55.243Z" }, - { url = "https://files.pythonhosted.org/packages/fd/0e/d285d1bc8864245919c61e1ca82263e4a66d337759c3a4cef72766ff9afc/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:7559f72b94ae52659086c595dfa017cde03155f7832071d30959049052cb3ece", size = 501026, upload-time = "2026-05-28T11:58:56.788Z" }, - { url = "https://files.pythonhosted.org/packages/86/06/ccb2109a1e543437b5e43816f2b43b9554cc6783145528a4e3711e05c011/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9e25b7088f9ccbfc0dfcaa52bf969300ca229e10ecf758974ebcbb080a4b37bb", size = 391865, upload-time = "2026-05-28T11:58:58.298Z" }, - { url = "https://files.pythonhosted.org/packages/3d/33/237173db1cfef10105b3839a24de00eb8d2a523711add4632447cdf0aedd/rpds_py-2026.5.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:613fc4ee9eaef26dc5840666214dd6fbcebcf32f46e76f4abc473059f4e13dda", size = 378012, upload-time = "2026-05-28T11:58:59.589Z" }, - { url = "https://files.pythonhosted.org/packages/97/64/1eae54e34d5161f9969295e80bd6b62a55f2b6ac5f2a5b60d02c2140e758/rpds_py-2026.5.1-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:85264a90ff4c05c1568dd65f5921c837614b67c60358fb4c17df3b7f2e90690a", size = 391111, upload-time = "2026-05-28T11:59:01.104Z" }, - { url = "https://files.pythonhosted.org/packages/d8/34/5bb334a5a0f65d77869217c4654f34c78a7d11b93938a3c076a2edeafc52/rpds_py-2026.5.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fe71bca7d547acb17027c7fd1624ff8aae623499c498d3e7011182c4de5c25e0", size = 409225, upload-time = "2026-05-28T11:59:02.433Z" }, - { url = "https://files.pythonhosted.org/packages/16/0f/007ec21283b5b040b4ec3bd95e0402591e22bfa7d5c93dfe01c465c2d2d7/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:a05fa4f41f37ec97c9c260441a940450a192f78d774d2b097eee1379f1e1246a", size = 556487, upload-time = "2026-05-28T11:59:04.012Z" }, - { url = "https://files.pythonhosted.org/packages/ff/10/5437c94508169b6b22d8418fef7a66e9ffb5f3b9e9c94460f2eedafe06ff/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df1d2a1996755b24b9ecee92cb4d36c28f86f464a6a173349c26bab41e94b8c2", size = 620798, upload-time = "2026-05-28T11:59:05.485Z" }, - { url = "https://files.pythonhosted.org/packages/e0/d5/9937dce4d6bda74157b954e7d1460db05a22f5929dccfeeba1ed27a93df0/rpds_py-2026.5.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:8895840ac4809e5f60c88fd07617cd71326e73d6e5a8aa783c5c0f7c24985de2", size = 584053, upload-time = "2026-05-28T11:59:06.837Z" }, - { url = "https://files.pythonhosted.org/packages/6c/31/750617dd0ae1752471bf43f9e41d263398fae7cde7849d23b8574a70e617/rpds_py-2026.5.1-cp311-cp311-win32.whl", hash = "sha256:3684a59b158a7683aaeb8e25352e9a9dd2122cec78f2d8530266e4f91b4c7b3f", size = 214390, upload-time = "2026-05-28T11:59:08.402Z" }, - { url = "https://files.pythonhosted.org/packages/3c/bb/3dcab0e1d9516303f2eb672a5d6f62eca5a69e2886301e9c8c54b520c39b/rpds_py-2026.5.1-cp311-cp311-win_amd64.whl", hash = "sha256:7bd530e6a530bb3ea892f194fafa455f3516ac25ecf7143fd33c09be62b0470a", size = 231097, upload-time = "2026-05-28T11:59:09.786Z" }, - { url = "https://files.pythonhosted.org/packages/49/d6/c6bbf5cb1cf12b9732df8074b57f6ef8341ba884c95d40632ae8bddb44e4/rpds_py-2026.5.1-cp311-cp311-win_arm64.whl", hash = "sha256:0a5ae4dbe43c1076983b72616496919872ae7bbe7a1e21cc48336bc3154d130b", size = 226361, upload-time = "2026-05-28T11:59:11.079Z" }, - { url = "https://files.pythonhosted.org/packages/d4/e7/a78582dc57caa592dcc7d4fb69b61390561e908eb3d2f5df5928a8e354c0/rpds_py-2026.5.1-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3abe24a66e57adcfa645d718063a5fa5103ecc71ddbf26d78af8f9368018ff1d", size = 353040, upload-time = "2026-05-28T11:59:12.531Z" }, - { url = "https://files.pythonhosted.org/packages/a3/43/35e3f136343aef451e545ce8c38d36c2f93c0ed88703db8b64ba2b205c68/rpds_py-2026.5.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:58b1d94308ddf0b1982f61f2eb54bf92997c9ece8a8093ef014250f4a517906c", size = 345775, upload-time = "2026-05-28T11:59:13.827Z" }, - { url = "https://files.pythonhosted.org/packages/20/e1/0f2160c5982d3157734d5cb3ed63d8b2d583a73c9864f77b666449f32cf8/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fa92420128dadce7f54bd73ba1825a273e9268fe9e35dbf7e6362890efa4e08", size = 376329, upload-time = "2026-05-28T11:59:15.271Z" }, - { url = "https://files.pythonhosted.org/packages/d0/11/ee0ba42aff83bf4effdbc576673c6be64c5e173978c3f6d537e94482f77d/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:ca653c6546386227cd9800d1bef6a348099acf8db4250341da6d90f663d6dfcb", size = 383539, upload-time = "2026-05-28T11:59:16.665Z" }, - { url = "https://files.pythonhosted.org/packages/11/df/d94aa6a499d4ac40afe2d7620f2c597fd3c0f182e854ad7cf3f596a81cb6/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:66c93681c4729e4e3ecba31b8179fae083ff3118841672835140338b4b9867c1", size = 494674, upload-time = "2026-05-28T11:59:17.991Z" }, - { url = "https://files.pythonhosted.org/packages/1f/75/33d30f43bb2f458de11979486a591b1bf6e5651765ed1704c6197c2dc773/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:40ff257542e04796880e011e15cd4dc21c2599975df2aaa8f2c8495ca574e1a5", size = 389268, upload-time = "2026-05-28T11:59:19.434Z" }, - { url = "https://files.pythonhosted.org/packages/f4/1e/2c9096fc19d5fd084b0184ca2b651e659aa0a37e6fdbecf6ece47f147fe1/rpds_py-2026.5.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b6825cc329b290e93c5f6a9be2393118a763f6ccf6abd83704e0c102ca583644", size = 376280, upload-time = "2026-05-28T11:59:21Z" }, - { url = "https://files.pythonhosted.org/packages/b9/e5/61ec9f8be8211ea7f48448195549e4aaf02004083475493b0e137702ecb2/rpds_py-2026.5.1-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:de42116e69cb53b911cc34aee5ab98f36c597b822545045d49e938818b99e5e4", size = 387233, upload-time = "2026-05-28T11:59:22.454Z" }, - { url = "https://files.pythonhosted.org/packages/0d/ca/bcec1005c4f4a234f92a29078631fee49206c7265ccae966f18fd332e80e/rpds_py-2026.5.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c0f920015df2a504bebaba6d4c31ccf3fcf942f92655c086da30b671aad19aa6", size = 405009, upload-time = "2026-05-28T11:59:23.845Z" }, - { url = "https://files.pythonhosted.org/packages/72/e6/4d5718c5cf26c522dc7c9999e238da1e77380b81d0c5d1df11e271ddfeb1/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:0408a24e44feb919423dc6d9da677cb5cddb894d2ca9e763967d156d9c60fab4", size = 553113, upload-time = "2026-05-28T11:59:25.184Z" }, - { url = "https://files.pythonhosted.org/packages/d4/25/2ee807bdb3e1f0b7eddf7782acd5665a8b5205a331a7d7244a52c4812fd9/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:cea68bcd53467561ae2f96a6bdad1544299ba97b5b0ddcd5ac3d376e5c781c24", size = 618838, upload-time = "2026-05-28T11:59:26.749Z" }, - { url = "https://files.pythonhosted.org/packages/6a/c1/7d4c26f167f8c41501cc073d30ee22082b16ce358cf5b00ec97cbc7804ea/rpds_py-2026.5.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:4be8b1d2a705cc37d08256004e1d07de143fa0075c8e85a3df020b776f62b732", size = 582436, upload-time = "2026-05-28T11:59:28.11Z" }, - { url = "https://files.pythonhosted.org/packages/04/1d/9d12b0a337bab46f4769f8857f4007e3b2d639e14f9a44a0efe157696e64/rpds_py-2026.5.1-cp312-cp312-win32.whl", hash = "sha256:6736718bd4fc49cbcb538ba30516fdbef161522acefb739657d48b97bd864fed", size = 212734, upload-time = "2026-05-28T11:59:29.689Z" }, - { url = "https://files.pythonhosted.org/packages/c5/93/e4116f2de7f56bc7406a76033dc501811ddeb22b7f056b92d632871ebb0c/rpds_py-2026.5.1-cp312-cp312-win_amd64.whl", hash = "sha256:0a7d1eec967df0e9b22614a5e177622e0c89611d03727fa0cb48e45028907870", size = 229045, upload-time = "2026-05-28T11:59:31.033Z" }, - { url = "https://files.pythonhosted.org/packages/cb/53/6c3419d85eb2ec5938a37627c585b42d76a63bb731d6e42ed4b079ebf486/rpds_py-2026.5.1-cp312-cp312-win_arm64.whl", hash = "sha256:1841d067089e117142d79b98aa0df2f08b52f2ecc1819dd2700636c0db74a473", size = 223967, upload-time = "2026-05-28T11:59:32.318Z" }, - { url = "https://files.pythonhosted.org/packages/6c/32/14c961ad295f490eb0849ada8b79683e93a59b9de3afdd983eaf55fa6867/rpds_py-2026.5.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:efef4ac29c6ff495531eb17ee705b62841ecaa291b7c7077e848ea03e237164d", size = 352787, upload-time = "2026-05-28T11:59:33.655Z" }, - { url = "https://files.pythonhosted.org/packages/ca/bb/d1b85117967c11191441a7274ae616c65d93901d082c588f89a50a8da5ae/rpds_py-2026.5.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c39f5b67a8a2e67179ada2a954227d670fe65fa9098457f698f56ddf248709b3", size = 345179, upload-time = "2026-05-28T11:59:35Z" }, - { url = "https://files.pythonhosted.org/packages/7c/46/d84105f062e626a1b233f863907288a4708c2d833b8b4c6fb2764bc080c0/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b5c30f3f04eef4fbd362226a6f31d7c8895ca4fbb6e0b790f6890a98d8da8559", size = 376173, upload-time = "2026-05-28T11:59:36.43Z" }, - { url = "https://files.pythonhosted.org/packages/e2/ae/469d7959ce5b1201e1de135dc735b86db3b35dd0d1734f6a44246d5f061c/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:277f6c82f0580848796c7ecc8a7173aa3bfb928e4ff831261c2f60a81dc270db", size = 383162, upload-time = "2026-05-28T11:59:37.995Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a2/57853d31a1116a561aa072794602ad3f6341e18d70a8523f1bd5b9fc1e5a/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63c2c4c213f1a4e3f3de28ecab029dbdee976324e729c0d7a55211be72576b02", size = 495093, upload-time = "2026-05-28T11:59:39.453Z" }, - { url = "https://files.pythonhosted.org/packages/99/63/3a8eabcad9314b7daf5c65f451d2c33d989235cd8a5762186cf2c3f5a4f8/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3350ec808fb538fe71a1f94dfaa0e29c598dfad805ce49f0caec5ae3183c652b", size = 389829, upload-time = "2026-05-28T11:59:40.896Z" }, - { url = "https://files.pythonhosted.org/packages/4b/25/05678d97fc25e2622df14dc530fb82023174ecfff6733991ed0d78f167bd/rpds_py-2026.5.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b1b964e3ab599e718dc46c018d104b1ebc007cbc6567d827c94a687fca56d77e", size = 374786, upload-time = "2026-05-28T11:59:42.626Z" }, - { url = "https://files.pythonhosted.org/packages/88/d1/8c90b6431e80a3b91b284a5c7c8c0c4f9c006444d90477a740d6e0f9c694/rpds_py-2026.5.1-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:19cb09fab7b7fc96b2a6e28f2e34b72a3705ff27b37edb77455316e5d3f3dc9b", size = 386920, upload-time = "2026-05-28T11:59:44.124Z" }, - { url = "https://files.pythonhosted.org/packages/ff/99/4638f672ab356682d633ee0da9255f5b67ce6efd0b85eb94ad3e255e65a5/rpds_py-2026.5.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:abe76bcdba31e576cb83eeb8797aa0d882b738fef6dc65d0601fc753806a5b46", size = 405059, upload-time = "2026-05-28T11:59:47.177Z" }, - { url = "https://files.pythonhosted.org/packages/66/3f/3546524b6eb4cc2e1f363a3d638fa52f6c24faae3500c25fb488b02f1740/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:8bff7073db3899158fff55ebf57b113a67030af26f80a18978f9f0aa60250ddf", size = 553030, upload-time = "2026-05-28T11:59:48.603Z" }, - { url = "https://files.pythonhosted.org/packages/c6/c3/7b3388c796fcf471bd17194242d4dc1a7608567c0fa422bcc1c5e79f9c1e/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:8ba264fa49be666cd9cc56bf34ec7002fb3d27a4aee5bcb4d43d0d18feb1bb6f", size = 618975, upload-time = "2026-05-28T11:59:50.314Z" }, - { url = "https://files.pythonhosted.org/packages/61/1e/a3cb07f2795075d1d88efddae2f541359fde5f08c81ee114c29c2949c90a/rpds_py-2026.5.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4860b603ddda0475a8885499b3729e90229d480105b42651962a5397d995fa89", size = 581178, upload-time = "2026-05-28T11:59:51.673Z" }, - { url = "https://files.pythonhosted.org/packages/a1/74/e758c03a5ef46f04c37f2651a2893db846d569ba8a7bca469d4b58939bcd/rpds_py-2026.5.1-cp313-cp313-win32.whl", hash = "sha256:7944270ae71383f6e2657dd7d5ce4eeb4ac2d0059a6738f0510583d462ab4842", size = 212481, upload-time = "2026-05-28T11:59:53.148Z" }, - { url = "https://files.pythonhosted.org/packages/70/ec/a2aca432db9c7359b40fa393eeeaa0d166c2f70175be956e75fa24197c44/rpds_py-2026.5.1-cp313-cp313-win_amd64.whl", hash = "sha256:88647f43a73c4e01be19b04ceef0c8d3a1958153604d13c773becd8016f2a0cf", size = 228519, upload-time = "2026-05-28T11:59:54.505Z" }, - { url = "https://files.pythonhosted.org/packages/29/60/a73bfdd45b096574556acf303bbd9fa9eed36ca8a818b514e2a5d5fe2b9d/rpds_py-2026.5.1-cp313-cp313-win_arm64.whl", hash = "sha256:453895624ecf7db7063b1004e44037522bbaef9ff6a945e59bc71662d7a03abd", size = 223446, upload-time = "2026-05-28T11:59:56.081Z" }, - { url = "https://files.pythonhosted.org/packages/18/e2/408105fd611823f00882aea810f3989a30d26b1bab8b6beb20f98c724e0e/rpds_py-2026.5.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:b4e4bc98639ec915f512fde3aa7a95e0041d95d9c3cc86eea841fa63cb1e8600", size = 355287, upload-time = "2026-05-28T11:59:57.448Z" }, - { url = "https://files.pythonhosted.org/packages/8d/58/5c4a43436843c90d0f6d19f82c200c80e3843ca9fa07b237623327f6d384/rpds_py-2026.5.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cacedb7a6e167680acba45ad5716e89067d225dc80da0d7040cae8c81d4572fa", size = 347033, upload-time = "2026-05-28T11:59:58.881Z" }, - { url = "https://files.pythonhosted.org/packages/fb/c2/1a71acdacaf4e259b10278fb87b039ded3cf80041bcd89dd8a3ea702ded6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68700371c5d7ae1412862ddfa719090925c93ecf351c566d66f09d04b136ea00", size = 376891, upload-time = "2026-05-28T12:00:00.516Z" }, - { url = "https://files.pythonhosted.org/packages/c2/c8/535f3d9b65addd8e28aa87b83c6e526799c3717a88273db8ea795beeef7a/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:296c799becfa849c779c8725494fe9ed94959ed886787df4364b058465bad7f0", size = 385646, upload-time = "2026-05-28T12:00:02.394Z" }, - { url = "https://files.pythonhosted.org/packages/1c/91/dc033f313345c354ade914dbe73cdb90b615a4409ea02430d5356794f3d8/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:d3858b908218ee108d0bbfb2095ccc237648053c9bf98affad7cb079acaf1d97", size = 498830, upload-time = "2026-05-28T12:00:04.189Z" }, - { url = "https://files.pythonhosted.org/packages/27/fc/90fcbea459dbb8ddc18a2e0fd1de9412b48bc84ffff2db771cf714bacfd6/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:4fb8d2e7cb2f850b169806d61d1b991738acec96500a75c30f49caf064ce7cef", size = 392830, upload-time = "2026-05-28T12:00:05.797Z" }, - { url = "https://files.pythonhosted.org/packages/b2/1d/46cd11a228c9750684a798d98f878be6f614aa762438da7378f035e79e35/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:27b74c10ed6a8f190f4287f53bcfea348b92a84a9c9f70d30183d1e6172d580d", size = 379613, upload-time = "2026-05-28T12:00:07.433Z" }, - { url = "https://files.pythonhosted.org/packages/24/4a/d9b0c6af3a1de03eb93741bbe8be2bdce84d8fda8224f3005451d86df389/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_31_riscv64.whl", hash = "sha256:b9a6528956191c48c52294a592dbd4a8386d7048bdb25c0efcb6b966466c6d83", size = 388183, upload-time = "2026-05-28T12:00:09.227Z" }, - { url = "https://files.pythonhosted.org/packages/c5/b4/db7aaabdda6d020afc87d981bcc2f57a434c7dec60ecfc2ab3dd50b20351/rpds_py-2026.5.1-cp313-cp313t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:af03e34e860047bc7a352b842856fcf78798fbb81132cc98bd2f907ab4eb9cd2", size = 408578, upload-time = "2026-05-28T12:00:10.779Z" }, - { url = "https://files.pythonhosted.org/packages/08/d6/070f6a41cbb343e2ac4171859bf3f3623e0ab002f72619d6d505313ec2de/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:fea6e836d10abbe191d557d33bd58bd5987725fe63aa1eefe557d230209855bd", size = 553573, upload-time = "2026-05-28T12:00:12.443Z" }, - { url = "https://files.pythonhosted.org/packages/75/ab/1a71ea3589c4345dac0a0518f0e6a031cb42689277851b683c46d27463a5/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:fc0c0f878ea770a0a8a462456c5ad36fc9fe6358e6b76fdadc7f17575e0b8bf1", size = 620861, upload-time = "2026-05-28T12:00:14.09Z" }, - { url = "https://files.pythonhosted.org/packages/8a/22/9bf80a56069c0c443fcfefac639a86a744550a2898817a6dfd3e26654924/rpds_py-2026.5.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:e0b360f316d966b048b085857630b3cc51f3db2f07b06f440eac8f695374d1e3", size = 585633, upload-time = "2026-05-28T12:00:15.66Z" }, - { url = "https://files.pythonhosted.org/packages/da/68/3b2c0a75c9e04125696f84ebdbbf304acf5a40b58ba4481cdb98a922c3ba/rpds_py-2026.5.1-cp313-cp313t-win32.whl", hash = "sha256:a2999883eedf72fdfb7520b92c7d4ec2572a71ff40239377aa604cc529eecafc", size = 210074, upload-time = "2026-05-28T12:00:17.291Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8b/609157d5a25d37d4f29f92840ba531f416907c34ae5c5739dd21fc2bef98/rpds_py-2026.5.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e07be2a9d7122bd6e82dea89814ef8dc893feb1aae97fec1630f3263bbb30e55", size = 228635, upload-time = "2026-05-28T12:00:18.73Z" }, - { url = "https://files.pythonhosted.org/packages/d4/6f/19c1918a4b590d8de87e712e4abe4b3875771eff60216fb6153cf6665c68/rpds_py-2026.5.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:1f2c391c3059798093b65df23aca2cac150460ae9c630d99dec83d703d9485b9", size = 349756, upload-time = "2026-05-28T12:00:20.217Z" }, - { url = "https://files.pythonhosted.org/packages/e5/60/a06fe7da34eca79dacbf958a2ba0c6eea85bc2b29de20080bf40f72f66fa/rpds_py-2026.5.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:413b424f7c4ee65ab5e5be91f5731be0f8b41a1ee2b12dfe810d716312e95a78", size = 343831, upload-time = "2026-05-28T12:00:21.711Z" }, - { url = "https://files.pythonhosted.org/packages/bf/ec/b2333b97b90e2a6ef6ca8ad386ee284968e74bcfe113b3f1a8d9036429a9/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2c595a1d9255dce0599e13130d1440ab2506654f2b50294226ee06402f8fef63", size = 375127, upload-time = "2026-05-28T12:00:23.326Z" }, - { url = "https://files.pythonhosted.org/packages/14/7f/e00aae54067f2b488c4637961d5f58204d470795fc791085fa3f15060d2e/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1c27c5f6102eac8c03e7595a00827a53b271ba40a53b59ff8709170e0855ea4a", size = 379034, upload-time = "2026-05-28T12:00:24.89Z" }, - { url = "https://files.pythonhosted.org/packages/be/cc/423999bbb8ae8dc93c77fc1d5e984ade5eb89d237d3bb884ccfa72ae2890/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:6c7fcf61d44cacecaf3aea542b0e053db77972a4573e7ceda16fb2b399161195", size = 490823, upload-time = "2026-05-28T12:00:26.676Z" }, - { url = "https://files.pythonhosted.org/packages/0f/aa/c671bf660f12e68d3c52ff86c7066ed1372df5a0f4f2ff584e419b8207e7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2c817a189d4ee14290420e5ff051e4dd6baa13f3edf84685071dee07a6d538ee", size = 388144, upload-time = "2026-05-28T12:00:28.577Z" }, - { url = "https://files.pythonhosted.org/packages/19/c8/d63bb75b68afe77b229e3021c6031bcaf01da5db5b0e69d0d10f9ba679a7/rpds_py-2026.5.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:21846aac0ed2e0589f38c12dc44e77bb64e494b771eadbcf169cba00566ba7ba", size = 371959, upload-time = "2026-05-28T12:00:30.304Z" }, - { url = "https://files.pythonhosted.org/packages/82/35/c51122014d8274ff37dc606d60049c3db7d83da02b5b282511e5a906a9a6/rpds_py-2026.5.1-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:b317c87a13f769a4e787819bd508aaa5d69aa09b0880de9af6d3a8a54571cdec", size = 383558, upload-time = "2026-05-28T12:00:31.764Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f9/2790cb99c136a5363acdeacf5c27c56f3de0d4118a1f48fca83404c99c89/rpds_py-2026.5.1-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:ce87129d9f2c14fa6c4a8601fb80eb4488c80d38a20cd13758ef11123e14995d", size = 402789, upload-time = "2026-05-28T12:00:33.247Z" }, - { url = "https://files.pythonhosted.org/packages/e5/1b/e4fb584f8c75d35c38150ff6a332cda949e6f97acba1f4fd123b14ab56fe/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:9cdddb6c1207d284d94fd1530adf57fbd797fe7c4b8704ba85f49414f2557e7d", size = 551405, upload-time = "2026-05-28T12:00:34.819Z" }, - { url = "https://files.pythonhosted.org/packages/d8/f7/a6731b4216cb3793ea1af5391da240f5683dacc0d13e034fe5fc3503f240/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:4e237e139f94d3c036fd28eb9f564c99055476ff4ff05cd42be55ce349b5aa02", size = 616975, upload-time = "2026-05-28T12:00:36.268Z" }, - { url = "https://files.pythonhosted.org/packages/2c/ea/2e051a81d95d8e63f4b35a1c463a87e8766bc3d083c067c5dfb6bf220747/rpds_py-2026.5.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ed0954b524873214369184a9c82b0eaa45a3fbb9a798cd95b17e0d98499e7ea0", size = 578701, upload-time = "2026-05-28T12:00:37.82Z" }, - { url = "https://files.pythonhosted.org/packages/65/56/b5f6fdb2083e32bca8a8993d89e70db114b4756c9e2c38421328126689d2/rpds_py-2026.5.1-cp314-cp314-win32.whl", hash = "sha256:2d88621d6a7d4dfa633d21abe90f280bb205274e16b1d1e61c6ad4640b2453b7", size = 209806, upload-time = "2026-05-28T12:00:39.492Z" }, - { url = "https://files.pythonhosted.org/packages/fb/80/65a5aa96c155e611d1ed844e4e1f57f3e36b021f396d9f8585d756e6b90d/rpds_py-2026.5.1-cp314-cp314-win_amd64.whl", hash = "sha256:cef8ac28d26f4dda3533060c20fbf80a325458fa9fd23ea72a73cdfa8e978838", size = 225985, upload-time = "2026-05-28T12:00:40.94Z" }, - { url = "https://files.pythonhosted.org/packages/27/7c/ad185212e87b05f196daef92bc5f3caf07298eb47c295b5585c3dd3093ac/rpds_py-2026.5.1-cp314-cp314-win_arm64.whl", hash = "sha256:eaaea962c68cdc68d4a533ba985ab8e9484277910bbfaa2ab3ef7732667bfed8", size = 221219, upload-time = "2026-05-28T12:00:43.15Z" }, - { url = "https://files.pythonhosted.org/packages/23/58/e14ae18759020334646b031e708ab4158d653a938822bfb7b95ef2e93aa3/rpds_py-2026.5.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:21942f52dbbd5f8758bf021213d28bd45c39e873e65e2407faf5f1846f5761ad", size = 352148, upload-time = "2026-05-28T12:00:44.638Z" }, - { url = "https://files.pythonhosted.org/packages/31/9b/5f4a1e2f960bca3ac5d052b139dd31eed97b259f9d909173821760d542e8/rpds_py-2026.5.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:f414556f6e3958300ff941e40c9f97e3dc9774ddd1b3434c475d73dd354bbed3", size = 345196, upload-time = "2026-05-28T12:00:46.14Z" }, - { url = "https://files.pythonhosted.org/packages/1a/71/1d9574d6a2fa20ab60eaa55c7467f5aa20cbc770f341a05f09c0876f59e2/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ef1013a8625c74043210190b246f5b1551e09757c1f356c6e4160ef96c5bc081", size = 374981, upload-time = "2026-05-28T12:00:47.531Z" }, - { url = "https://files.pythonhosted.org/packages/0c/9a/37e99f4915a80aa71670263c1267f7ae0af95f53a3f61e6c3bdc016d4515/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cc68e231a77a5f0d774ae278a1f8e55c0456501820847c1e4efb3829f3441df6", size = 379961, upload-time = "2026-05-28T12:00:49.216Z" }, - { url = "https://files.pythonhosted.org/packages/a8/ff/6e73f74b89d2e0715e0fc86b7dde893f9a61ae2f9b256ff3bdfe41ac4e94/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9baffb505aff33acc69b422a19f77806680f3c8632227d79f48de8a810d1c2c5", size = 495965, upload-time = "2026-05-28T12:00:51.111Z" }, - { url = "https://files.pythonhosted.org/packages/ea/e0/425faba25f59d74d4638b267f7c7a80e8649d2ef4db10a19b0c4a71e6e6f/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b8d2f912928d426e8cfa396f7f3f8d29a59e6689c86dcca3c420730c1096322b", size = 389526, upload-time = "2026-05-28T12:00:52.77Z" }, - { url = "https://files.pythonhosted.org/packages/c6/76/7a41960e3fddae47fab43a28684d5da981401dffd88253de0944148654cb/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90f628283be835db980c941767d41c9a27b5239e54ba0a9c1335247e82406964", size = 376190, upload-time = "2026-05-28T12:00:54.215Z" }, - { url = "https://files.pythonhosted.org/packages/27/60/5f38dc70824fc6951b51d35377e577a3a3a4c81a6769cc5a2de25ebe0ad1/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:1ebb2f0ab7e16132995a72de805170e0203df0c3dd22e1ef1cd1fdd90bd7a131", size = 383921, upload-time = "2026-05-28T12:00:55.673Z" }, - { url = "https://files.pythonhosted.org/packages/60/1a/d60a38caa1505f4b9483c3fbbde12c94e1079154f4f401a6da96f7e77621/rpds_py-2026.5.1-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f3df3d16ded76f1f8c9cdebd0e1ea55fdf4c23b812de189814da7cf229c22a81", size = 404766, upload-time = "2026-05-28T12:00:57.518Z" }, - { url = "https://files.pythonhosted.org/packages/87/ff/602fd3f174d6425f0bce05ad0dfbec0e96b38d0f7d08a79af5aa20083885/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:9af8905b8f854990e40d5206aa5ac58d9b0fe0b7f351ff2bb086c20f6c8c6a47", size = 551343, upload-time = "2026-05-28T12:00:58.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/c1/1be13327acdbead3eca1fde03b6a34dbb011f1e864e217f0d32cc1779a7f/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:036a36a87fb1cd3b214d11c4b3c4f7d2ddad933625dca1c900b56a057c07740a", size = 618502, upload-time = "2026-05-28T12:01:00.656Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d7/afb49b49d7f2be8b7ba1a9f0977fa5168003437b93086726f066544e8351/rpds_py-2026.5.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:62ae3853454fe9ef283a03c96c2d835d39e84b14643a9d62c82ef0fb87d702ca", size = 581916, upload-time = "2026-05-28T12:01:02.22Z" }, - { url = "https://files.pythonhosted.org/packages/25/d1/dbef8c1f8a10f07beb62b5f054e20099fd9924b3ec001b8f0b6ac7813a85/rpds_py-2026.5.1-cp314-cp314t-win32.whl", hash = "sha256:6c3d771a46ec18b12af06ce36243a9a80b07a5d0515236332d90863ca8bb326a", size = 207855, upload-time = "2026-05-28T12:01:03.821Z" }, - { url = "https://files.pythonhosted.org/packages/2a/72/bfa4e61ab8e7dc1c8adf397e05e6cbdd4239357bd72b248d3de662f23915/rpds_py-2026.5.1-cp314-cp314t-win_amd64.whl", hash = "sha256:c93c629be4636cf54337bd5f06c104d55e42ced54d681f6fe21ae510a65116f6", size = 225422, upload-time = "2026-05-28T12:01:05.194Z" }, - { url = "https://files.pythonhosted.org/packages/27/3a/7b5da92b640f67b6717ccafc83cdd06bfa7ff2395c3685c68922bb54d703/rpds_py-2026.5.1-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:3574b55c604b8f75dacb007136508bbc0db406e626301778096a133327e7f2fb", size = 349576, upload-time = "2026-05-28T12:01:06.722Z" }, - { url = "https://files.pythonhosted.org/packages/d7/8a/2aafd7ad355a1bd48ca76e2262b74b15e6432b5a1efe150efd4d779cd55d/rpds_py-2026.5.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:94068eb3ae6d43f5a786b7db96a406a34e6d5c24489feef32fd6e8946ea7b291", size = 343640, upload-time = "2026-05-28T12:01:08.441Z" }, - { url = "https://files.pythonhosted.org/packages/f7/7d/6c9523c1abbe840a1b7fba3c516d48e1d3487cc80fea4366c4071cf56784/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f3a5b10e8ce894825f380a8f1b6444cf73c294dfea62afbb2d13e3a9e630cec1", size = 375322, upload-time = "2026-05-28T12:01:09.934Z" }, - { url = "https://files.pythonhosted.org/packages/5a/5d/0b7b03fb1dc509321f01de3149784ab773e34c8573022029af8076afcb9c/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:fc09f82e63d4bcd58149572f857a431bae851dc747e313c3b5bdf7abb907fda8", size = 379066, upload-time = "2026-05-28T12:01:11.48Z" }, - { url = "https://files.pythonhosted.org/packages/d7/e2/8ef6012999ebf1cb1c22f876d9ce5e63d960fd4631d2af3202d3f480aa25/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e10464d17df3b582745c25cec695cb9558bca2cb6ddb631aee1787fc72c767b2", size = 494586, upload-time = "2026-05-28T12:01:13.051Z" }, - { url = "https://files.pythonhosted.org/packages/80/af/1eeb029bec67582c226b7809172207cd005073af4ebd906e65ff494f4983/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ba05adbf15d994c38ec0b7ab32e858e5110c21e9009a00a86545fd220f84e038", size = 388415, upload-time = "2026-05-28T12:01:14.631Z" }, - { url = "https://files.pythonhosted.org/packages/18/23/ffbe10711c4d766c1cab0557d6906c074f795814863c67b351355d29354a/rpds_py-2026.5.1-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:77c004fdc7b891967106f78ddfd7b076bfe6813c6139c6fff6aed3bcaa960b26", size = 372427, upload-time = "2026-05-28T12:01:16.153Z" }, - { url = "https://files.pythonhosted.org/packages/bd/3a/30ba4a6ad457e5b070c18d742a33fb77d8d922b565cc881f8a5313d63bfe/rpds_py-2026.5.1-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:83bcf894486c9d78dd290d3c0124ff6dd8875d3025e2090a8ec49fcc37c55fdd", size = 383615, upload-time = "2026-05-28T12:01:17.809Z" }, - { url = "https://files.pythonhosted.org/packages/d3/69/62e242b53ce39c0814bd24e1a6e6eba6c92be716277745f317f9540a2e7b/rpds_py-2026.5.1-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c3df104083952a0e0c6f10de33e440eabe98fb6317d23e1a58c68f6df08d01b9", size = 402786, upload-time = "2026-05-28T12:01:19.419Z" }, - { url = "https://files.pythonhosted.org/packages/38/c1/a770b9c186928a1ed0f7e6d7ae50e7f3950ed23e3f9e366dbc8e38cb55de/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:980450826cf22e133c57e0835070bdd0dd3f73b9b708c3ce223def2cb9469e14", size = 551583, upload-time = "2026-05-28T12:01:21.013Z" }, - { url = "https://files.pythonhosted.org/packages/21/7c/68e8579b95375b70d2a963103c42e705856cdb98569258bd807f4423891c/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:205dde846f24332ab0c1188699a043b8d165b79bb84529ce272c45048ff6be01", size = 616941, upload-time = "2026-05-28T12:01:22.548Z" }, - { url = "https://files.pythonhosted.org/packages/70/a1/a6135aed5730ff03ab957182259987ac11e55fb392a28dc6f0592048a280/rpds_py-2026.5.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:3966b82dd563176396df030f3dd52a6e54cb69b718e95e78bd555ed3d1e0185d", size = 578349, upload-time = "2026-05-28T12:01:24.118Z" }, - { url = "https://files.pythonhosted.org/packages/09/6e/f24201a76a84e6c49d0bdfdfcb735210e21701e9b21c5bfc0ba497dd62f6/rpds_py-2026.5.1-cp315-cp315-win32.whl", hash = "sha256:7818f8d0a415be74d2be3590b0a1c1f463a642f4d0217e7d10602dceef5b79aa", size = 209922, upload-time = "2026-05-28T12:01:25.522Z" }, - { url = "https://files.pythonhosted.org/packages/9e/e4/966bc240bb0485fc265278f6de44d05834bf0b3618886e0b22e33d54c49a/rpds_py-2026.5.1-cp315-cp315-win_amd64.whl", hash = "sha256:b3cc20c0d800af78fd0fac68086e28c1856cec51ea528bb81ea851aa40d39325", size = 226003, upload-time = "2026-05-28T12:01:27.062Z" }, - { url = "https://files.pythonhosted.org/packages/5c/5c/a15a59269cd5e74472734516c73795c15eccfc841b3d4b0228c3f53f19d0/rpds_py-2026.5.1-cp315-cp315-win_arm64.whl", hash = "sha256:3609e9939a8a76cd904cf98a3f1f13b5dc7e150adeaee89e0ea09652ea213e16", size = 221245, upload-time = "2026-05-28T12:01:28.51Z" }, - { url = "https://files.pythonhosted.org/packages/e0/22/135ce03804e179a71ceb13be095deda4a279bc88f7a6b8fa161c5ad44e12/rpds_py-2026.5.1-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:5d333a7127d4b307601ac37792bee01bb95c867cbfacf21b6375b804d6bbd723", size = 352015, upload-time = "2026-05-28T12:01:30.214Z" }, - { url = "https://files.pythonhosted.org/packages/3b/5f/f1f6d2652eb9d848f6eb369d8db83a2da6249bb49ad2c2a48f45d54538d3/rpds_py-2026.5.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:b5f077b44a4f7808520f66dae234988d867deb9aed9be5da057ce9ba831b2a41", size = 345016, upload-time = "2026-05-28T12:01:31.656Z" }, - { url = "https://files.pythonhosted.org/packages/88/66/b74182775691ea2290c99e52ac8d5db844e56fbec90ce421f107658c8314/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55d8f9b7b78c9538fc9e04e82ec0e888ff0c3cffcfad152c77e57cd09351a98a", size = 374775, upload-time = "2026-05-28T12:01:33.136Z" }, - { url = "https://files.pythonhosted.org/packages/ff/8f/15e5a61d9f0a43902d36561d4f07cae6ae9f4716be825159fd72717f33af/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e3a8ae58895ac107ed934a6bf51e5846f95c53b9b940c2c6d310838fd5846358", size = 380270, upload-time = "2026-05-28T12:01:34.574Z" }, - { url = "https://files.pythonhosted.org/packages/02/c3/f859b12763a80540cdf2af0f15b19904cf756a71d7bdd3f82ff3e5b1bbf9/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0957cf3c2b8632ec7aaebffebea8005b353cc2a237b6e2ae3c2cac0820704cfb", size = 495285, upload-time = "2026-05-28T12:01:36.127Z" }, - { url = "https://files.pythonhosted.org/packages/1c/c7/ff27c2ac8411d30b03b1829fd88cae8dad1a4d0da48dd25e57c4038042e6/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c396c1304de421050b3681ea70f371874b54d41b0151e96109758144c231e30b", size = 389581, upload-time = "2026-05-28T12:01:37.635Z" }, - { url = "https://files.pythonhosted.org/packages/6e/67/fe92ee32a6cc05c77228a2f8b1762e7124f386ec20ff83d0757b762d58d0/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:aad1bff7f666b9598e573815affd666aac6a13a585dde336f843e33350c7fadc", size = 376041, upload-time = "2026-05-28T12:01:39.307Z" }, - { url = "https://files.pythonhosted.org/packages/f8/91/b4d6685c27aba55bd82f25b278be8237038117d05f9659a6213ad3408130/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:656a042550878f12d45752452d47094b7cfe5ad1e9d7b87b5a22ad3ae5ff8015", size = 383946, upload-time = "2026-05-28T12:01:41.043Z" }, - { url = "https://files.pythonhosted.org/packages/bd/79/2c1d832a53c8e0f8e98fc970ec257b950fecd4f62be2ab7182b500a0cbc8/rpds_py-2026.5.1-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:73c4bd4f70294737b5206a3e8e30ccadbf8a60301831c8ea23eec5dbeea1ecfa", size = 405526, upload-time = "2026-05-28T12:01:43.032Z" }, - { url = "https://files.pythonhosted.org/packages/78/c4/c98117b03c6a8581ab2c2dfccfe9a5ad82bd8128a3c28b46a6ad2d97c393/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:43bca78665423cabae77146f2fe7ce55272b6c8d55d82cca83effd42c7e13972", size = 551165, upload-time = "2026-05-28T12:01:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c1/bc479ca069200af730881b1bd525e3114b2b391a351509fcb1b772f28086/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:42d0f20e85e549c870749d0e247f0c10d318a45b7e9676d575d2dcb04a1b2e66", size = 618778, upload-time = "2026-05-28T12:01:46.337Z" }, - { url = "https://files.pythonhosted.org/packages/77/65/38ab2f90df44c2febfb63cc10ced40763d9b4bc94d173e734528663fe7f5/rpds_py-2026.5.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:b1be5c35683684d5331b93600c210e8367c254683d8a6df6bd21bd2da3a334fb", size = 581839, upload-time = "2026-05-28T12:01:48.109Z" }, - { url = "https://files.pythonhosted.org/packages/15/2d/ce1f605fe036aadd460e5822e578c6c7ec3a860936cca37d6e0f299daa77/rpds_py-2026.5.1-cp315-cp315t-win32.whl", hash = "sha256:75808f6c38ce7749bb68cc2770161aae5045e6c6f6781a9782e74b93304399df", size = 207866, upload-time = "2026-05-28T12:01:49.648Z" }, - { url = "https://files.pythonhosted.org/packages/79/cb/966040123eb102371559746908ef2c9471f4d43e17ec9a645a2258dab64b/rpds_py-2026.5.1-cp315-cp315t-win_amd64.whl", hash = "sha256:90bd6630002a1c7f09e7843dd79f0d24f3d2897cc25a753480917865d14f15b3", size = 225441, upload-time = "2026-05-28T12:01:51.408Z" }, - { url = "https://files.pythonhosted.org/packages/42/56/3fe0fb34820ff667be791b3a3c22b85e8bcba54e9c832f47438c191fa7be/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:edf2765d84e42447f112ad877af8fe1db0089aaec5b28e88d6eab45e7fe99cea", size = 357151, upload-time = "2026-05-28T12:01:53.43Z" }, - { url = "https://files.pythonhosted.org/packages/8b/f2/3eb9ccdb9f143b8c9b003978898cb497f942a324c077401e6b8834238e63/rpds_py-2026.5.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:ad3773236e95f7f33991eb125224b7da66f206504d032a253a02da7e134519fb", size = 350195, upload-time = "2026-05-28T12:01:54.901Z" }, - { url = "https://files.pythonhosted.org/packages/a7/24/dbda232bc4f3ed732120692ab0d2c8402cb020516556d8bee622dcef2413/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a04df86b3f0fade39ec8fd0e0aab089b1da9fbd2b48df778a57ef96f5e7d38df", size = 381850, upload-time = "2026-05-28T12:01:56.601Z" }, - { url = "https://files.pythonhosted.org/packages/40/30/32e769839a358f78810c234f160f2cc21d1e4e47e1c0e0e0d535be5a0219/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6142dbd80c4df62a5d899f0d616d417f84e0bc8d32526c8e5589019d75d028a7", size = 387899, upload-time = "2026-05-28T12:01:58.212Z" }, - { url = "https://files.pythonhosted.org/packages/ab/86/ec84d243aadb3b34b71dd26a010d0930b2d284ff5fc9a69fec53810ee6fd/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0b35217adefe87f2fe4db7e9766cabe84744bfe9616d9667be18988928c7f2dc", size = 501618, upload-time = "2026-05-28T12:01:59.888Z" }, - { url = "https://files.pythonhosted.org/packages/74/25/b60e52686bbff777a64f9e4f4d3dd57980dc846913777177a2c92e4937aa/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b95d5e11fc712b752081183a55a244c03cd00570489edd7014d8899f8ceb8162", size = 394003, upload-time = "2026-05-28T12:02:01.482Z" }, - { url = "https://files.pythonhosted.org/packages/9b/c7/b3a6a588cc2219510ef3f42e207483a93950bedd1e3a0fd4015c95cff9e5/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:141c9498daf2ace9eda35d2b0e376f9ea8b058d84f2aef4f96fccfd449a2f251", size = 379778, upload-time = "2026-05-28T12:02:03.197Z" }, - { url = "https://files.pythonhosted.org/packages/31/00/c7dba3fc8a3da8cb3f6db1eb3386be4d79c2e97c6890d20eb9ac66ae8c43/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:6f249f8b860a200ad35193af961183ebe9132710484e6f6ce0cf89fd83c63a9a", size = 392359, upload-time = "2026-05-28T12:02:04.817Z" }, - { url = "https://files.pythonhosted.org/packages/93/dd/472ba494c70753f93745992c99855bee0636daf74e6984e5e003f150316f/rpds_py-2026.5.1-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e4abbf391a70be864920858bf360f4fb380577c9a0f732438a1996726e2c195b", size = 412820, upload-time = "2026-05-28T12:02:06.401Z" }, - { url = "https://files.pythonhosted.org/packages/1d/6f/93831a3bfe789542ed0c1d0d74b78b440f055d6dc3ea4640eba2d95e6e23/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:c74005a7bb87752acf351c93897ec63ad77a07a0da7ecad9c050e32e7286ba34", size = 557243, upload-time = "2026-05-28T12:02:08.013Z" }, - { url = "https://files.pythonhosted.org/packages/1f/ff/0b3d604614ffc77522c6b288fdbce68957eb583da1002aa65ba38ac0ee40/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:8213afbe8a3a906fb9acb2014423fe3359ee783d0bf90995f70623a3217bfa6c", size = 623541, upload-time = "2026-05-28T12:02:09.661Z" }, - { url = "https://files.pythonhosted.org/packages/ea/ea/e7b0251441da9adfeaebcf29601d10f2a1455fcf0772fae9e7e19032bd96/rpds_py-2026.5.1-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8c43a8a973270fd173bf48cdf80bbe66312421cba68d40845034f174f2389049", size = 586326, upload-time = "2026-05-28T12:02:11.47Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/aa/2a/9618a122aeb2a169a28b03889a2995fe297588964333d4a7d67bdf46e147/rpds_py-2026.6.3.tar.gz", hash = "sha256:1cebd1337c242e4ec2293e541f712b2da849b29f48f0c293684b71c0632625d4", size = 64051, upload-time = "2026-06-30T07:17:53.009Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/94/1f/a2dca5ffdbf1d475ffc4e80e4d5d720ff3a00f691795910116960ee12511/rpds_py-2026.6.3-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:7b689145a1485c335569bd056464f3243a29af7ed3871c7be31ad624ba239bc7", size = 342174, upload-time = "2026-06-30T07:14:54.821Z" }, + { url = "https://files.pythonhosted.org/packages/4d/dc/323d08583c0832911768663d1944f0107fcd4088704858d84b5e06d105a0/rpds_py-2026.6.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:db08f45aecde626498fb3df07bcf6d2ec040af42e859a4f5040d79c200342911", size = 345513, upload-time = "2026-06-30T07:14:56.515Z" }, + { url = "https://files.pythonhosted.org/packages/0b/2a/e31989834d18d2f26ec1d2774c5b1eb3331df4ea8ada525175294c94b48a/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:acc992ab27b15f852c76755eb2ab7dce86585ddadba6fa5946e58556088845b4", size = 373783, upload-time = "2026-06-30T07:14:57.736Z" }, + { url = "https://files.pythonhosted.org/packages/87/fe/e80107ee3639585c9941c17d6a42cd65325022f656c023191fce78c324c8/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7f88d653e7b3b779d71ae7454e20dcc9b6bae903f33c269db9f2be41bda3f261", size = 378316, upload-time = "2026-06-30T07:14:59.077Z" }, + { url = "https://files.pythonhosted.org/packages/22/6f/81e3adf81acfb6fa694de2a6e4e7d8863121e3e0799e0a7725e6cf5679c4/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e52655eaf81e32593abedaa4bfe33170c8cfedf3365ed9be6e11e07f148f0278", size = 499423, upload-time = "2026-06-30T07:15:00.488Z" }, + { url = "https://files.pythonhosted.org/packages/2d/9a/41263969df0ce3d9af2a96d5005a288200af1989aed3354bfceb5fc0b21f/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:dfcc8b909769d19db55c7cc9541eb64b9b774b1057ffffb4f1048070475bb9f9", size = 386077, upload-time = "2026-06-30T07:15:01.911Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/7e98f468bd50346faff5b10e5297374b443bfdddacc8e9fbc65984539597/rpds_py-2026.6.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9c1255b302953c86a486b81d330d5ee1d5bd937691ce271b6be0ef0e299eaab7", size = 371315, upload-time = "2026-06-30T07:15:03.317Z" }, + { url = "https://files.pythonhosted.org/packages/99/3c/2b973b4d371906a134b03decfea7f5d9835a2c6d263454392e15b64b5b18/rpds_py-2026.6.3-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:8d2294a31386bfa251d8c8a39472beee17db67d4f1a6eabea665d35c9a4461c3", size = 383502, upload-time = "2026-06-30T07:15:04.627Z" }, + { url = "https://files.pythonhosted.org/packages/98/2a/12e2799500af0a307bca76b63361c51f9fe479223561489c29eea1f2ee41/rpds_py-2026.6.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f8f23ead891a3b762f35ab3b04623da7056545b48aa60d59957e6789914545da", size = 402673, upload-time = "2026-06-30T07:15:05.856Z" }, + { url = "https://files.pythonhosted.org/packages/2d/e3/21e5872d165fe08be4f229e3d5ee9d90019c0bf0e5538de60dbd54009450/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:421aba32367055614287a4292b6a17f1939c9452299f7a0209c117e990b646d4", size = 549964, upload-time = "2026-06-30T07:15:07.159Z" }, + { url = "https://files.pythonhosted.org/packages/1a/d0/5ee0fe36844297de8123bee27bc12078c1a7416ad9f1b8a8ca18d6b0c0ac/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:1e5822dfc2f0d4ab7e745eaa6d85945069329beeccef965af3f3bb26058fcab6", size = 615446, upload-time = "2026-06-30T07:15:08.531Z" }, + { url = "https://files.pythonhosted.org/packages/b1/80/1ea5873cb683f2fbe5f21b23ea1f6d179ead19f3c5b249b7eb5dca568ef2/rpds_py-2026.6.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:83e35b57523816c8613fd0776b40cd8bb9f596b37ddd2692eb4a6bb5ab2f8c93", size = 576975, upload-time = "2026-06-30T07:15:09.97Z" }, + { url = "https://files.pythonhosted.org/packages/c9/e1/90ef639217a5ddb15b7f4f61b1c33911fd044ad03c311bafdd2bcab85582/rpds_py-2026.6.3-cp311-cp311-win32.whl", hash = "sha256:de3eceba0b683bcbb1ab93da016d0270df1f9ae7be716b40214c5dafac6ea45a", size = 204453, upload-time = "2026-06-30T07:15:11.324Z" }, + { url = "https://files.pythonhosted.org/packages/f2/b7/b7a1695d7af36f521fb11e80d6d3adbd744f73b921859bd3c2a2c0dc706f/rpds_py-2026.6.3-cp311-cp311-win_amd64.whl", hash = "sha256:2c54a076ca4d370980ab57bc0e31df57bbe8d41340436a90ef8b1219a3cbb127", size = 223219, upload-time = "2026-06-30T07:15:12.476Z" }, + { url = "https://files.pythonhosted.org/packages/d7/a2/145afacf796e4506062825941176ad9445c2dcf2b3b6a1f13d3030a15e19/rpds_py-2026.6.3-cp311-cp311-win_arm64.whl", hash = "sha256:168c733a7112e071bb7a66460e667edfcff06c017a3c523f7a8a8e08d0140804", size = 219137, upload-time = "2026-06-30T07:15:13.631Z" }, + { url = "https://files.pythonhosted.org/packages/5c/be/2e8974163072e7bab7df1a5acd54c4498e75e35d6d18b864d3a9d5dadc92/rpds_py-2026.6.3-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:a0811d33247c3d6128a3001d763f2aa056bb3425204335400ac54f89eec3a0d0", size = 343691, upload-time = "2026-06-30T07:15:14.96Z" }, + { url = "https://files.pythonhosted.org/packages/a4/73/319dfa745dd668efe89309141ded489126461fcecd2b8f3a3cda185129b6/rpds_py-2026.6.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:538949e262e46caa31ac01bdb3c1e8f642622922cacbabbae6a8445d9dc33eaf", size = 338542, upload-time = "2026-06-30T07:15:16.267Z" }, + { url = "https://files.pythonhosted.org/packages/21/63/4239893be1c4d09b709b1a8f6be4188f0870084ff547f46606b8a75f1b03/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:55927d532399c2c646100ff7feb48eaa940ad70f42cd68e1328f3ded9f81ca24", size = 368180, upload-time = "2026-06-30T07:15:17.62Z" }, + { url = "https://files.pythonhosted.org/packages/1c/ca/9c5de382225234ceb37b1844ebdb140db12b2a278bb9efe2fcd19f6c82ce/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f56f1695bc5c0871cbc33dc0130fcf503aab0c57dcc5a6700a4f49eba4f2652e", size = 375067, upload-time = "2026-06-30T07:15:18.952Z" }, + { url = "https://files.pythonhosted.org/packages/87/dc/863f69d1bf04ade34b7fe0d59b9fdf6f0135fe2d7cbca74f1d665589559d/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:270b293dae9058fc9fcedab50f13cebf46fb8ed1d1d54e0521a9da5d6b211975", size = 490509, upload-time = "2026-06-30T07:15:20.434Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ef/eac16a12048b45ec7c7fa94f2be3438a5f26bf9cc8580b18a1cfd609b7f6/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:127565fead0a10943b282957bd5447804ff3160ad79f2ad2635e6d249e380680", size = 382754, upload-time = "2026-06-30T07:15:21.831Z" }, + { url = "https://files.pythonhosted.org/packages/04/8f/d2f3f532616be4d06c316ef119683e832bd3d41e112bf3a88f4151c95b17/rpds_py-2026.6.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ecabd69db66de867690f9797f2f8fa27ba501bbc24540cbdbdc649cd15888ba6", size = 366189, upload-time = "2026-06-30T07:15:23.371Z" }, + { url = "https://files.pythonhosted.org/packages/e3/29/41a7b0e98a4b44cd676ab7598419623373eb43b20be68c084935c1a8cf88/rpds_py-2026.6.3-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:58eadac9cd119677b60e1cf8ac4052f35949d71b8a9e5556efccbe82533cf22a", size = 377750, upload-time = "2026-06-30T07:15:24.659Z" }, + { url = "https://files.pythonhosted.org/packages/2e/05/ecda0bec46f9a1565090bcdc941d023f6a25aff85fda28f89f8d19878152/rpds_py-2026.6.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:7491ee23305ac3eb59e492b6945881f5cd77a6f731061a3f25b77fd40f9e99a4", size = 395576, upload-time = "2026-06-30T07:15:25.987Z" }, + { url = "https://files.pythonhosted.org/packages/68/a8/6ed52f03ee6cb854ce78785cc9a9a672eb880e83fd7224d471f667d151f1/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:2c99f7e8ccb3dd6e3e4bfeac657a7b208c9bac8075f4b078c02d7404c34107fa", size = 543807, upload-time = "2026-06-30T07:15:27.356Z" }, + { url = "https://files.pythonhosted.org/packages/8f/d6/156c0d3eea27ba09b92562ba2364ba124c0a061b199e17eac637cd25a5e2/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:62698275682bf121181861295c9181e789030a2d516071f5b8f3c23c170cd0fc", size = 611187, upload-time = "2026-06-30T07:15:28.931Z" }, + { url = "https://files.pythonhosted.org/packages/f1/31/774212ed989c62f7f310220089f9b0a3fb8f40f5443d1727abd5d9f52bc9/rpds_py-2026.6.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a214c993455f99a89aaeadc9b21241900037adc9d97203e374d75513c5911822", size = 573030, upload-time = "2026-06-30T07:15:30.553Z" }, + { url = "https://files.pythonhosted.org/packages/c9/50/22f73127a41f1ce4f87fe39aadfb9a126345801c274aa93ae88456249327/rpds_py-2026.6.3-cp312-cp312-win32.whl", hash = "sha256:501f9f04a588d6a09179368c57071301445191767c64e4b52a6aa9871f1ef5ed", size = 202185, upload-time = "2026-06-30T07:15:32.027Z" }, + { url = "https://files.pythonhosted.org/packages/04/3a/f0ee4d4dde9d3b69dedf1b5f74e7a40017046d55052d173e418c6a94f960/rpds_py-2026.6.3-cp312-cp312-win_amd64.whl", hash = "sha256:2c958bf94822e9290a40aaf2a822d4bc5c88099093e3948ad6c571eca9272e5f", size = 220394, upload-time = "2026-06-30T07:15:33.359Z" }, + { url = "https://files.pythonhosted.org/packages/f3/83/3382fe37f809b59f02aac04dbc4e765b480b46ee0227ed516e3bdc4d3dfc/rpds_py-2026.6.3-cp312-cp312-win_arm64.whl", hash = "sha256:22bffe6042b9bcb0822bcd1955ec00e245daf17b4344e4ed8e9551b976b63e96", size = 215753, upload-time = "2026-06-30T07:15:34.778Z" }, + { url = "https://files.pythonhosted.org/packages/a4/9e/b818ee580026ec578138e961027a68820c40afeb1ec8f6819b54fb99e196/rpds_py-2026.6.3-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:3cfe765c1da0072636ca06628261e0ea05688e160d5c8a03e0217c3854037223", size = 343012, upload-time = "2026-06-30T07:15:36.005Z" }, + { url = "https://files.pythonhosted.org/packages/f3/6b/686d9dc4359a8f163cfbbf89ee0b4e586431de22fe8248edb63a8cf50d49/rpds_py-2026.6.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f4d78253f6996be4901669ad25319f842f740eccf4d58e3c7f3dd39e6dde1d8f", size = 338203, upload-time = "2026-06-30T07:15:37.462Z" }, + { url = "https://files.pythonhosted.org/packages/9e/9b/069aa329940f8207615e091f5eedbbd40e1e15eac68a0790fd05ccdf796c/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:54f45a148e28767bf343d33a684693c70e451c6f4c0e9904709a723fafbdfc1f", size = 367984, upload-time = "2026-06-30T07:15:39.008Z" }, + { url = "https://files.pythonhosted.org/packages/14/db/34c203e4becff3703e4d3bc121842c00b8689197f398161203a880052f4e/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:842e7b070435622248c7a2c44ae53fa1440e073cc3023bc919fed570884097a7", size = 374815, upload-time = "2026-06-30T07:15:40.253Z" }, + { url = "https://files.pythonhosted.org/packages/ee/7d/8071067d2cc453d916ad836e828c943f575e8a44612537759002a1e07381/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8020133a74bd81b4572dd8e4be028a6b1ebcd70e6726edc3918008c08bee6ee6", size = 490545, upload-time = "2026-06-30T07:15:41.729Z" }, + { url = "https://files.pythonhosted.org/packages/a3/42/da06c5aa8f0484ff07f270787434204d9f4535e2f8c3b51ed402267e63c3/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cdc7e35386f3847df728fbcb5e887e2d79c19e2fa1eba9e51b6621d23e3243af", size = 382828, upload-time = "2026-06-30T07:15:43.327Z" }, + { url = "https://files.pythonhosted.org/packages/57/d7/fe978efc2ae50abe48eb7464668ea99f53c010c60aeebb7b35ad27f23661/rpds_py-2026.6.3-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:acac386b453c2516111b50985d60ce46e7fadb5ea71ae7b25f4c946935bf27cf", size = 365678, upload-time = "2026-06-30T07:15:44.992Z" }, + { url = "https://files.pythonhosted.org/packages/69/9d/1d8922e1990b2a6eb532b6ff53d3e73d2b3bbffc84116c75826bee73dfc6/rpds_py-2026.6.3-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:425560c6fa0415f27261727bb20bd097568485e5eb0c121f1949417d1c516885", size = 377811, upload-time = "2026-06-30T07:15:46.523Z" }, + { url = "https://files.pythonhosted.org/packages/b1/3d/198dceafb4fb034a6a47347e1b0735d34e0bd4a50be4e898d408ee66cb14/rpds_py-2026.6.3-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a550fb4950a06dde3beb4721f5ad4b25bf4513784665b0a8522c792e2bd822a4", size = 395382, upload-time = "2026-06-30T07:15:47.955Z" }, + { url = "https://files.pythonhosted.org/packages/1f/f1/13968e49655d40b6b19d8b9140296bbc6f1d86b3f0f6c346cf9f1adddf4b/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4f4bca01b63096f606e095734dd56e74e175f94cfbf24ff3d63281cec61f7bb7", size = 543832, upload-time = "2026-06-30T07:15:49.33Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ab/289bcb1b90bd3e40a2900c561fa0e2087345ecbb094f0b870f2345142b7c/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:ccffae9a092a00deb7efd545fe5e2c33c33b88e7c054337e9a74c179347d0b7d", size = 611011, upload-time = "2026-06-30T07:15:50.847Z" }, + { url = "https://files.pythonhosted.org/packages/1e/16/5043105e679436ccfbc8e5e0dd2d663ed18a8b8113515fd06a5e5d77c83e/rpds_py-2026.6.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1cf01971c4f2c5553b772a542e4aaf191789cd331bc2cd4ff0e6e65ba49e1e97", size = 572431, upload-time = "2026-06-30T07:15:52.394Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/adab103321c0a6565d5ae1c2998349bc3ee175b82ccc5ae8fc04cc413075/rpds_py-2026.6.3-cp313-cp313-win32.whl", hash = "sha256:8c3d1e9c15b9d51ca0391e13da1a25a0a4df3c58a37c9dc368e0736cf7f69df0", size = 201710, upload-time = "2026-06-30T07:15:53.894Z" }, + { url = "https://files.pythonhosted.org/packages/7b/ed/a03b09668e74e5dabbf2e211f6468e1820c0552f7b0500082da31841bf7b/rpds_py-2026.6.3-cp313-cp313-win_amd64.whl", hash = "sha256:9250a9a0a6fd4648b3f868da8d91a4c52b5811a62df58e753d50ae4454a36f80", size = 219454, upload-time = "2026-06-30T07:15:55.25Z" }, + { url = "https://files.pythonhosted.org/packages/27/17/b8642c12930b71bc2b25831f6708ccf0f75abcd11883932ec9ce54ba3a78/rpds_py-2026.6.3-cp313-cp313-win_arm64.whl", hash = "sha256:900a67df3fd1660b035a4761c4ce73c382ea6b35f90f9863c36c6fd8bf8b09bb", size = 215063, upload-time = "2026-06-30T07:15:56.573Z" }, + { url = "https://files.pythonhosted.org/packages/b6/36/7fbe9dcdaf857fb3f63c2a2284b62492d95f5e8334e947e5fb6e7f68c9be/rpds_py-2026.6.3-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:931908d9fc855d8f74783377822be318edb6dcb19e47169dc038f9a1bf60b06e", size = 344510, upload-time = "2026-06-30T07:15:57.921Z" }, + { url = "https://files.pythonhosted.org/packages/ba/54/f785cc3d3f60839ca57a5af4927a9f347b07b2799c373fc20f7949f87c7e/rpds_py-2026.6.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:d7469697dce35be237db177d42e2a2ee26e6dcc5fc052078a6fefabd288c6edd", size = 339495, upload-time = "2026-06-30T07:15:59.238Z" }, + { url = "https://files.pythonhosted.org/packages/63/ef/d4cdaf309e6b095b43597103cf8c0b951d6cca2acce68c474f75ec12e0c7/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bcfbcf66006befb9fd2aeaa9e01feaf881b4dc330a02ba07d2322b1c11be7b5d", size = 369454, upload-time = "2026-06-30T07:16:01.021Z" }, + { url = "https://files.pythonhosted.org/packages/96/4a/9559a68b7ee15db09d7981212e8c2e219d2a1d6d4faa0391d813c3496a36/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:847927daf4cffbd4e90e42bc890069897101edd015f956cb8721b3473372edda", size = 374583, upload-time = "2026-06-30T07:16:02.287Z" }, + { url = "https://files.pythonhosted.org/packages/ef/75/8964aa7d2c6e8ac43eba8eb6e6b0fdda1f46d39f2fc3e6aa9f2cb17f485d/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c1ef08a82bfe327cc156da694660f599923e2e6665b6d81c9c2d0ac9ffc8", size = 492919, upload-time = "2026-06-30T07:16:03.723Z" }, + { url = "https://files.pythonhosted.org/packages/8f/97/6908094ac804115e65aedfd90f1b5fee4eebebd3f6c4cfc5419939267565/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ae50181a047c871561212bb97f7932a2d45fb53e947bd9b57ebad85b529cbc53", size = 383725, upload-time = "2026-06-30T07:16:05.305Z" }, + { url = "https://files.pythonhosted.org/packages/d1/9c/0d1fdc2e7aba23e290d603bc494e97bd205bae262ce33c6b32a69768ed5e/rpds_py-2026.6.3-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:dc319e5a1de4b6913aac94bf6a2f9e847371e0a140a43dd4991db1a09bc2d504", size = 367255, upload-time = "2026-06-30T07:16:07.086Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/f0209ca4a9ed074bc8acb44dfd0e81c3122e94c9689f5645b7973a866719/rpds_py-2026.6.3-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:e4316bf32babbed84e691e352faf967ce2f0f024174a8643c37c94a1080374fc", size = 379060, upload-time = "2026-06-30T07:16:08.525Z" }, + { url = "https://files.pythonhosted.org/packages/c6/8d/f1cc54c616b9d8897de8738aac148d20afca93f68187475fe194d09a71b9/rpds_py-2026.6.3-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8c6e5a2f750cc71c3e3b11d71661f21d6f9bc6cebc6564b1466417a1ec03ec77", size = 395960, upload-time = "2026-06-30T07:16:09.989Z" }, + { url = "https://files.pythonhosted.org/packages/fb/04/aafff00f73aeca2945f734f1d483c64ab8f472d0864ab02377fd8e89c3b2/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:4470ce197d4090875cf6affbf1f853338387428df97c4fb7b7106317b8214698", size = 545356, upload-time = "2026-06-30T07:16:11.816Z" }, + { url = "https://files.pythonhosted.org/packages/fd/cc/e229663b9e4ddac5a4acbe9085dd80a71af2a5d356b8b39d6bff233f24b0/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:ea964164cc9afa72d4d9b23cc28dafae93693c0a53e0b42acbff15b22c3f9ddd", size = 612319, upload-time = "2026-06-30T07:16:13.586Z" }, + { url = "https://files.pythonhosted.org/packages/e3/7a/8a0e6d3e6cd066af108b71b43122c3fe158dd9eb86acac626593a2582eb1/rpds_py-2026.6.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:639c8929aa0afe81be836b04de888460d6bed38b9c54cfc18da8f6bfabf5af5d", size = 573508, upload-time = "2026-06-30T07:16:15.23Z" }, + { url = "https://files.pythonhosted.org/packages/87/03/2a69ab618a789cf6cf85c86bb844c62d090e700ab1a2aa676b3741b6c516/rpds_py-2026.6.3-cp314-cp314-win32.whl", hash = "sha256:882076c00c0a608b131187055ddc5ae29f2e7eaf870d6168980420d58528a5c8", size = 202504, upload-time = "2026-06-30T07:16:16.893Z" }, + { url = "https://files.pythonhosted.org/packages/85/62/a3892ba945f4e24c78f352e5de3c7620d8479f73f211406a97263d13c7d2/rpds_py-2026.6.3-cp314-cp314-win_amd64.whl", hash = "sha256:0be972be84cfcaf46c8c6edf690ca0f154ac17babf1f6a955a51579b34ad2dc5", size = 220380, upload-time = "2026-06-30T07:16:18.108Z" }, + { url = "https://files.pythonhosted.org/packages/3d/e7/c2bd44dc831931815ad11ebb5f430b5a0a4d3caa9de837107876c30c3432/rpds_py-2026.6.3-cp314-cp314-win_arm64.whl", hash = "sha256:2a9c6f195058cb45335e8cc3802745c603d716eb96bc9625950c1aac71c0c703", size = 215976, upload-time = "2026-06-30T07:16:19.654Z" }, + { url = "https://files.pythonhosted.org/packages/79/9c/fff7b74bce9a091ec9a012a03f9ff5f69364eaf9451060dfc4486da2ffdd/rpds_py-2026.6.3-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:f90938e92afda60266da758ee7d363447f7f0138c9559f9e1811629580582d90", size = 346840, upload-time = "2026-06-30T07:16:21.268Z" }, + { url = "https://files.pythonhosted.org/packages/e9/44/77bcb1168b33704908295533d27f10eb811e9e3e193e8993dc99572211d3/rpds_py-2026.6.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ec829541c45bca16e61c7ae50c20501f213605beb75d1aba91a6ee37fbbb56a4", size = 340282, upload-time = "2026-06-30T07:16:22.875Z" }, + { url = "https://files.pythonhosted.org/packages/87/3c/7a9081c7c9e645b39efe19e4ffbeccd80add246327cd9b888aecffd72317/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:afd70d95892096cdb26f15a00c45907b17817577aa8d1c76b2dcc2788391f9e9", size = 370403, upload-time = "2026-06-30T07:16:24.415Z" }, + { url = "https://files.pythonhosted.org/packages/f7/69/af47021eb7dad6ff3396cb001c08f0f3c4d06c20253f75be6421a59fe6b7/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:29dfa0533a5d4c94d4dfa1b694fcb56c9c63aad8330ffdd816fd225d0a7a162f", size = 376055, upload-time = "2026-06-30T07:16:26.111Z" }, + { url = "https://files.pythonhosted.org/packages/81/fc/a3bcf517084396a6dd258c592567a3c011ba4557f2fde23dceaf26e74f2e/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:af05d726809bff6b141be124d4c7ce998f9c9c7f30edb1f46c07aa103d540b41", size = 494419, upload-time = "2026-06-30T07:16:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/c9/eb/13d529d1788135425c7bf207f8463458ca5d92e43f3f701365b83e9dffc1/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9826217f048f620d9a712672818bf231442c1b35d96b227a07eabd11b4bb6945", size = 384848, upload-time = "2026-06-30T07:16:29.183Z" }, + { url = "https://files.pythonhosted.org/packages/8e/f4/b7ac49f30013aba8f7b9566b1dd07e81de95e708c1374b7bacc5b9bc5c9c/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:536bceea4fa4acf7e1c61da2b5786304367c816c8895be71b8f537c480b0ea1f", size = 371369, upload-time = "2026-06-30T07:16:30.912Z" }, + { url = "https://files.pythonhosted.org/packages/31/86/6260bafa622f788b07ddec0e52d810305c8b9b0b8c27f58a2ab04bf62b4f/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:bc0011654b91cc4fb2ae701bec0a0ba1e552c0714247fa7af6c59e0ccfa3a4e1", size = 379673, upload-time = "2026-06-30T07:16:32.486Z" }, + { url = "https://files.pythonhosted.org/packages/19/c3/03f1ee79a047b48daeca157c89a18509cde22b6b951d642b9b0af1be660a/rpds_py-2026.6.3-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:539d75de9e0d536c84ff18dfeb805398e58227001ce09231a26a08b9aed1ee0e", size = 397500, upload-time = "2026-06-30T07:16:34.471Z" }, + { url = "https://files.pythonhosted.org/packages/f0/95/8ed0cd8c377dca12aea498f119fe639fc474d1461545c39d2b5872eb1c0f/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:166cf54d9f44fc6ceb53c7860258dde44a81406646de79f8ed3234fca3b6e538", size = 545978, upload-time = "2026-06-30T07:16:36.45Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/0eb57f0eaa83f8fc152a7e03de968ab77e1f00732bebc892b190c6eebde7/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:d34c20167764fbcf927194d532dd7e0c56772f0a5f943fa5ef9e9afbba8fb9db", size = 613350, upload-time = "2026-06-30T07:16:38.213Z" }, + { url = "https://files.pythonhosted.org/packages/5b/de/e0674bdbc3ef7634989b3f854c3f34bc1f587d36e5bfdc5c378d57034619/rpds_py-2026.6.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ea7bb13b7c9a29791f87a0387ba7d3ad3a6d783d827e4d3f27b40a0ff44495e2", size = 576486, upload-time = "2026-06-30T07:16:39.797Z" }, + { url = "https://files.pythonhosted.org/packages/f2/f6/21101359743cd136ada781e8210a85769578422ba460672eea0e29739200/rpds_py-2026.6.3-cp314-cp314t-win32.whl", hash = "sha256:6de4744d05bd1aa1be4ed7ea1189e3979196808008113bbbf899a460966b925e", size = 201068, upload-time = "2026-06-30T07:16:41.316Z" }, + { url = "https://files.pythonhosted.org/packages/a6/b2/9574d4d44f7760c2aa32d92a0a4f41698e33f5b204a0bf5c9758f52c79d5/rpds_py-2026.6.3-cp314-cp314t-win_amd64.whl", hash = "sha256:c7b9a2f8f4d8e90af72571d3d495deebdd7e3c75451f5b41719aee166e940fc2", size = 220600, upload-time = "2026-06-30T07:16:43.091Z" }, + { url = "https://files.pythonhosted.org/packages/08/ae/f23a2697e6ee6340a578b0f136be6483657bef0c6f9497b752bb5c0964bb/rpds_py-2026.6.3-cp315-cp315-macosx_10_12_x86_64.whl", hash = "sha256:e059c5dde6452b44424bd1834557556c226b57781dee1227af23518459722b13", size = 344726, upload-time = "2026-06-30T07:16:44.5Z" }, + { url = "https://files.pythonhosted.org/packages/c3/63/e7b3a1a5358dd32c930a1062d8e15b67fd6e8922e81df9e91706d66ee5c8/rpds_py-2026.6.3-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2f7c26fbc5acd2522b95d4177fe4710ffd8e9b20529e703ffbf8db4d93903f05", size = 339587, upload-time = "2026-06-30T07:16:46.255Z" }, + { url = "https://files.pythonhosted.org/packages/ec/64/10a85681916ca55fffb91b0a211f84e34297c109243484dd6394660a8a7c/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a3086b538543802f84c843911242db20447de00d8752dd0efc936dbcf02218ba", size = 369585, upload-time = "2026-06-30T07:16:48.101Z" }, + { url = "https://files.pythonhosted.org/packages/76/c2/baf95c7c38823e12ba34407c5f5767a89e5cf2233895e56f608167ae9493/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8f2e5c5ee828d42cb11760761c0af6507927bec42d0ad5458f97c9203b054617", size = 375479, upload-time = "2026-06-30T07:16:49.93Z" }, + { url = "https://files.pythonhosted.org/packages/6a/94/0aad06c72d65101e11d33528d438cda99a39ce0da99466e156158f2541d3/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ed0c1e5d10cdc7135537988c74a0188da68e2f3c30813ba3744ab1e42e0480f9", size = 492418, upload-time = "2026-06-30T07:16:51.641Z" }, + { url = "https://files.pythonhosted.org/packages/b5/17/de3f5a479a1f056535d7489819639d8cd591ea6281d700390b43b1abd745/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c2642a7603ec0b16ed77da4555db3b4b472341904873788327c0b0d7b95f1bb", size = 384123, upload-time = "2026-06-30T07:16:53.622Z" }, + { url = "https://files.pythonhosted.org/packages/46/7d/bf09bd1b145bb2671c03e1e6d1ab8651858d90d8c7dfeadd85a37a934fd8/rpds_py-2026.6.3-cp315-cp315-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e4320744c1ffdd95a603def63344bfab2d33edeab301c5007e7de9f9f5b3885", size = 367351, upload-time = "2026-06-30T07:16:55.241Z" }, + { url = "https://files.pythonhosted.org/packages/a3/ea/1bb734f314b8be319149ddee80b18bd41372bdcfbdf88d28131c0cd37719/rpds_py-2026.6.3-cp315-cp315-manylinux_2_31_riscv64.whl", hash = "sha256:a9f4645593036b81bbdb36b9c8e0ea0d1c3fee968c4d59db0344c14087ef143a", size = 378827, upload-time = "2026-06-30T07:16:56.841Z" }, + { url = "https://files.pythonhosted.org/packages/4b/93/d9611e5b25e26df9a3649813ed66193ace9347a7c7fc4ab7cf70e94851c0/rpds_py-2026.6.3-cp315-cp315-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e55d236be29255554da47abe5c577637db7c24a02b8b46f0ca9524c855801868", size = 395966, upload-time = "2026-06-30T07:16:58.557Z" }, + { url = "https://files.pythonhosted.org/packages/c3/cb/99d77e16e5534ae1d90629bbe419ba6ee170833a6a85e3aa1cc41726fbbc/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:24e9c5386e16669b674a69c156c8eeefcb578f3b3397b713b08e6d60f3c7b187", size = 545680, upload-time = "2026-06-30T07:17:00.164Z" }, + { url = "https://files.pythonhosted.org/packages/59/15/11a29755f790cef7a2f755e8e14f4f0c33f39489e1893a632a2eee59672b/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:c60924535c75f1566b6eb75b5c31a48a43fef04fa2d0d201acbad8a9969c6107", size = 611853, upload-time = "2026-06-30T07:17:01.962Z" }, + { url = "https://files.pythonhosted.org/packages/68/86/0c27547e21644da938fb530f7e1a8148dd24d02db07e7a5f2567a17ce710/rpds_py-2026.6.3-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:38a2fea2787428f811719ceb9114cb78964a3138838320c29ac39526c79c16ba", size = 573715, upload-time = "2026-06-30T07:17:03.693Z" }, + { url = "https://files.pythonhosted.org/packages/29/71/4d8fcf700931815594bce892255bbd973b94efaf0fc1932b0590df18d886/rpds_py-2026.6.3-cp315-cp315-win32.whl", hash = "sha256:d483fe17f01ad64b7bf7cc38fcefff1ca9fb83f8c2b2542b68f97ffe0611b369", size = 202864, upload-time = "2026-06-30T07:17:05.746Z" }, + { url = "https://files.pythonhosted.org/packages/eb/62/b577562de0edbb55b2be85ce5fd09c33e386b9b13eee09833af4240fd5c4/rpds_py-2026.6.3-cp315-cp315-win_amd64.whl", hash = "sha256:67e3a721ffc5d8d2210d3671872298c4a84e4b8035cfe42ffd7cde35d772b146", size = 220430, upload-time = "2026-06-30T07:17:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/c8/95/d6d0b2509825141eef60669a5739eec88dbc6a48053d6c92993a5704defe/rpds_py-2026.6.3-cp315-cp315-win_arm64.whl", hash = "sha256:6e84adbcf4bf841aed8116a8264b9f50b4cb3e7bd89b516122e616ac56ca269e", size = 215877, upload-time = "2026-06-30T07:17:09.008Z" }, + { url = "https://files.pythonhosted.org/packages/b7/bf/f3ea278f0afd615c1d0f19cb69043a41526e2bb600c2b536eb192218eb27/rpds_py-2026.6.3-cp315-cp315t-macosx_10_12_x86_64.whl", hash = "sha256:ae6dd8f10bd17aad820876d24caec9efdafd80a318d16c0a48edb5e136902c6b", size = 346933, upload-time = "2026-06-30T07:17:10.762Z" }, + { url = "https://files.pythonhosted.org/packages/9d/29/9907bdf1c5346763cf10b7f6852aad86652168c259def904cbe0082c5864/rpds_py-2026.6.3-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:bdbd97738551fca3917c1bd7188bec1920bb520104f28e7e1007f9ceb17b7690", size = 340274, upload-time = "2026-06-30T07:17:12.266Z" }, + { url = "https://files.pythonhosted.org/packages/6f/2c/8e03767b5778ef25cebf74a7a91a2c3806f8eced4c92cb7406bbe060756d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8b95977e7211527ab0ba576e286d023389fbeeb32a6b7b771665d333c60e5342", size = 370763, upload-time = "2026-06-30T07:17:14.107Z" }, + { url = "https://files.pythonhosted.org/packages/2e/e1/df2a7e1ba2efd796af26194250b8d42c821b46592311595162af9ef0528d/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:d15fde0e6fb0d88a60d221204873743e5d9f0b7d29165e62cd86d0413ad74ba6", size = 376467, upload-time = "2026-06-30T07:17:15.76Z" }, + { url = "https://files.pythonhosted.org/packages/6b/de/8a0814d1946af29cb068fb259aa8622f856df1d0bab58429448726b537f5/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a136d453475ac0fcbda502ef1e6504bd28d6d904700915d278deeab0d00fe140", size = 496689, upload-time = "2026-06-30T07:17:17.308Z" }, + { url = "https://files.pythonhosted.org/packages/df/f3/f19e0c852ba13694f5a79f3b719331051573cb5693feacf8a88ffffc3a71/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f826877d462181e5eb1c26a0026b8d0cab05d99844ecb6d8bf3627a2ca0c0442", size = 385340, upload-time = "2026-06-30T07:17:18.928Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ae/7ec3a9d2d4351f99e37bcb06b6b6f954512646bfdbf9742e1de727865daf/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:79486287de1730dbaff3dbd124d0ca4d2ef7f9d29bf2544f1f93c09b5bcbbd12", size = 372179, upload-time = "2026-06-30T07:17:20.539Z" }, + { url = "https://files.pythonhosted.org/packages/d3/ac/9cee911dff2aaa9a5a8354f6610bf2e6a616de9197c5fff4f54f82585f1e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_31_riscv64.whl", hash = "sha256:808345f53cb952433ca2816f1604ff3515608a81784954f38d4452acfe8e61d5", size = 379993, upload-time = "2026-06-30T07:17:22.212Z" }, + { url = "https://files.pythonhosted.org/packages/83/6b/7c2a07ba88d1e9a936612f7a5d067467ed03d971d5a06f7d309dff044a7e/rpds_py-2026.6.3-cp315-cp315t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1967debc37f64f2c4dc90a7f563aec558b471966e12adcac4e1c4240496b6ebf", size = 398909, upload-time = "2026-06-30T07:17:23.66Z" }, + { url = "https://files.pythonhosted.org/packages/97/0b/776ffcb66783637b0031f6d58d6fb55913c8b5abf00aeecd46bf933fb477/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:f0840b5b17057f7fd918b76183a4b5a0635f43e14eb2ce60dce1d4ee4707ea00", size = 546584, upload-time = "2026-06-30T07:17:25.264Z" }, + { url = "https://files.pythonhosted.org/packages/55/33/ba3bc04d7092bd553c9b2b195624992d2cc4f3de1f380b7b93cbee67bd79/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:faa679d19a6696fd54259ad321251ad77a13e70e03dd834daa762a44fb6196ef", size = 614357, upload-time = "2026-06-30T07:17:26.888Z" }, + { url = "https://files.pythonhosted.org/packages/8b/71/14edf065f04630b1a8472f7653cad03f6c478bcf95ea0e6aed55451e33ea/rpds_py-2026.6.3-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:23a439f31ccbeff1574e24889128821d1f7917470e830cf6544dced1c662262a", size = 576533, upload-time = "2026-06-30T07:17:28.546Z" }, + { url = "https://files.pythonhosted.org/packages/ba/76/65002b08596c389105720a8c0d22298b8dc25a4baf89b2ce431343c8b1de/rpds_py-2026.6.3-cp315-cp315t-win32.whl", hash = "sha256:913ca42ccad3f8cc6e292b587ae8ae49c8c823e5dce51a736252fc7c7cdfa577", size = 201204, upload-time = "2026-06-30T07:17:30.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/97/d855d6b3c322d1f27e26f5241c42016b56cf01377ea8ed348285f54652f0/rpds_py-2026.6.3-cp315-cp315t-win_amd64.whl", hash = "sha256:ae3d4fe8c0b9213624fdce7279d70e3b148b682ca20719ebd193a23ebfa47324", size = 220719, upload-time = "2026-06-30T07:17:31.788Z" }, + { url = "https://files.pythonhosted.org/packages/b4/9c/f0d19ac587fd0e4ab6b72cda355e9c5a6166b01ef7e064e437aef8eb9fef/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:4cf2d36a2357e4d07bb5a4f98801265327b48256867816cfd2ceb001e9754a8f", size = 349791, upload-time = "2026-06-30T07:17:33.315Z" }, + { url = "https://files.pythonhosted.org/packages/38/c7/1d49d204c9fd2ee6c537601dc4c1ba921e03363ca576bfab94a00254ac9a/rpds_py-2026.6.3-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:30c6dc199b24a5e3e81d50da0f00858c5bbdb2617a750395687f4339c5818171", size = 352842, upload-time = "2026-06-30T07:17:34.897Z" }, + { url = "https://files.pythonhosted.org/packages/ac/e5/c0b5dc93cd0d4c06ce1f438907649514e2ea077bcd911e3154a51e96c38e/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9891e594296ab9dada6551c8e7b387b2721f27a67eecd528412e8906247a7b90", size = 382094, upload-time = "2026-06-30T07:17:36.514Z" }, + { url = "https://files.pythonhosted.org/packages/0d/54/ec0e907b4ca8d541112db352409bd15f871c9b243e0c92c9b5a46ae96f01/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b5c2dc92304aa48a4a60443b548bb12f12e119d4b72f314015e67b9e1be97fca", size = 388662, upload-time = "2026-06-30T07:17:38.235Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f4/921c22a4fd0f1c1ac13a3996ffbf0aa67951e2c8ad0d1d9574938a2932e8/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:127e08c0642d880cf32ca47ec2a4a77b901f7e2dd1ad9762adb13955d72ffcc9", size = 504896, upload-time = "2026-06-30T07:17:39.689Z" }, + { url = "https://files.pythonhosted.org/packages/0b/1b/a114b972cefa1ab1cdb3c7bb177cd3844a12826c507c722d3a73516dbbaf/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8bb68f03f395eb793220b45c097bd4d8c32944393da0fad8b999efac0868fc8c", size = 391545, upload-time = "2026-06-30T07:17:41.336Z" }, + { url = "https://files.pythonhosted.org/packages/4e/98/af9b3db77d47fcbe6c8c1f36e2c2147ec70292819e99c325f871584a1c11/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:a3450b693fde92133e9f51060568a4c31fcca76d5e53bbd611e689ca446517e9", size = 380059, upload-time = "2026-06-30T07:17:42.857Z" }, + { url = "https://files.pythonhosted.org/packages/c9/ba/0efd8668b97c1d26a61566386c636a7a7a09829e474fdf807caa15a2c844/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_31_riscv64.whl", hash = "sha256:5e8d07bddee435a2ff6f1920e18feff28d0bc4533e42f4bf6927fbd073312c41", size = 393235, upload-time = "2026-06-30T07:17:44.637Z" }, + { url = "https://files.pythonhosted.org/packages/62/90/8c139ee9690f73b0829f32647de6f40d826f8f443af6fa72644f96351aac/rpds_py-2026.6.3-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:3a83ae6c67b7676b9878378547ca8e93ed77a580037bcbcd1d32f739e1e6089c", size = 413008, upload-time = "2026-06-30T07:17:46.225Z" }, + { url = "https://files.pythonhosted.org/packages/9c/97/0043896fdd7828ce09a1d9a8b06433714d0960fc4ff3fc4aa72b666b764e/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_aarch64.whl", hash = "sha256:2bfd04c19ddbd6640de0b51894d764bd2758854d5b75bd102d2ef10cb9c293a9", size = 558118, upload-time = "2026-06-30T07:17:47.759Z" }, + { url = "https://files.pythonhosted.org/packages/f6/40/02355f0e134f783a8f9814c4680a1bd311d37671577a5964ea838573ff37/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_i686.whl", hash = "sha256:ca6546b66be9dc4738b1b043d5ebd5488c66c578c5ff0fd0e8065313fe3afb76", size = 623138, upload-time = "2026-06-30T07:17:49.355Z" }, + { url = "https://files.pythonhosted.org/packages/10/85/48f0abdcef5cce4e034c7a5b0ceeceba0b01bf0d942824f4bb720afe2dec/rpds_py-2026.6.3-pp311-pypy311_pp73-musllinux_1_2_x86_64.whl", hash = "sha256:8e65860d238379ed982fd9ba690579b5e95af2f4840f99c772816dbe573cb826", size = 586486, upload-time = "2026-06-30T07:17:51.141Z" }, ] [[package]] @@ -3342,15 +3337,6 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/b7/46/f5af3402b579fd5e11573ce652019a67074317e18c1935cc0b4ba9b35552/secretstorage-3.5.0-py3-none-any.whl", hash = "sha256:0ce65888c0725fcb2c5bc0fdb8e5438eece02c523557ea40ce0703c266248137", size = 15554, upload-time = "2025-11-23T19:02:51.545Z" }, ] -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - [[package]] name = "six" version = "1.17.0" @@ -3549,16 +3535,25 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "tomlkit" +version = "0.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/51/db/03eaf4331631ef6b27d6e3c9b68c54dc6f0d63d87201fed600cc409307fd/tomlkit-0.15.0.tar.gz", hash = "sha256:7d1a9ecba3086638211b13814ea79c90dd54dd11993564376f3aa92271f5c7a3", size = 161875, upload-time = "2026-05-10T07:38:22.245Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/6a/43/8bd850ee71a191bf072e31302c73a66be413fecdd98fdcd111ecbcce13ca/tomlkit-0.15.0-py3-none-any.whl", hash = "sha256:4dbc8f0fc024412b57ced8757ac7461305126a648ff8c2c807fcb8e133a78738", size = 41328, upload-time = "2026-05-10T07:38:23.517Z" }, +] + [[package]] name = "tqdm" -version = "4.68.3" +version = "4.68.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/87/d7/0535a28b1f5f24f6612fb3ff1e89fb1a8d160fee0f976e0aa6803862134b/tqdm-4.68.3.tar.gz", hash = "sha256:00dfa48452b6b6cfae3dd9885636c23d3422d1ec97c66d96818cbd5e0821d482", size = 170596, upload-time = "2026-06-17T07:36:52.105Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ae/5f/57ff8b434839e70dab45601284ea413e947a63799891b7553e5960a793a8/tqdm-4.68.4.tar.gz", hash = "sha256:19829c9673638f2a0b8617da4cdcb927e831cd88bcfcb6e78d42a4d1af131520", size = 792418, upload-time = "2026-07-07T09:58:18.369Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/8e/bb97bb0c71802080bfc8952937d174e49cfc50de5c951dd47b2496f0dcdb/tqdm-4.68.3-py3-none-any.whl", hash = "sha256:39832cc2def2789a6f29df83f172db7416cea70052c0907a57801c5f2fdccb03", size = 78337, upload-time = "2026-06-17T07:36:50.132Z" }, + { url = "https://files.pythonhosted.org/packages/22/2a/5e5e750890ada51017d18d0d4c30da696e5b5bd3180947729927628fc3cb/tqdm-4.68.4-py3-none-any.whl", hash = "sha256:5168118b2368f48c561afda8020fd79195b1bdb0bdf8086b88442c267a315dc2", size = 676612, upload-time = "2026-07-07T09:58:16.256Z" }, ] [[package]] @@ -3582,27 +3577,58 @@ wheels = [ ] [[package]] -name = "typer" -version = "0.25.1" +name = "ty" +version = "0.0.56" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/55/07/fb29aea5235b0aa8ecfc4d1cc6ddf9fba8b863d67d96c6d345694d644c43/ty-0.0.56.tar.gz", hash = "sha256:84d114dc3796361c0fc72945016eabd74d46b9ee64f198cb0e485719704681e5", size = 6050123, upload-time = "2026-07-01T16:44:56.036Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/48/bce79e7ca5c1cc529d3e0d37ddd1121aea4b68a4f749974ad1cc77161871/ty-0.0.56-py3-none-linux_armv6l.whl", hash = "sha256:186d4a53e15747c947e1ec3d7eec8e345d8e40a1ca10e634c585db52497e87dd", size = 11643066, upload-time = "2026-07-01T16:44:18.374Z" }, + { url = "https://files.pythonhosted.org/packages/80/d1/22555d8a1d719661f10050f3865d877bbf497da908961c75fe22217dd18a/ty-0.0.56-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:aae1a980fd9535da0469b7ba2b2e1b54a907743a5e0f442dd57eee9f5bfd034c", size = 11407487, upload-time = "2026-07-01T16:44:20.956Z" }, + { url = "https://files.pythonhosted.org/packages/cf/2d/b3b7a74ce8bc59ef48843ad80179bb0d9598bbd6cfc0d11d519bdf6b1352/ty-0.0.56-py3-none-macosx_11_0_arm64.whl", hash = "sha256:afd3058c0a6c5f241e814734f133008c93ee805f61c9cf4ce7412b8822b5d9ad", size = 10962270, upload-time = "2026-07-01T16:44:22.959Z" }, + { url = "https://files.pythonhosted.org/packages/64/ac/6c2fd7de0304a8a7218a756af74f7e62a5e8540fdb175e0a869e51042345/ty-0.0.56-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:058b52f7a823ac13aae3cae30809dd6b5145794b64d8478f9ef38c75d79b4483", size = 11471406, upload-time = "2026-07-01T16:44:25.327Z" }, + { url = "https://files.pythonhosted.org/packages/50/b6/11d861156861c03c7726b74558f9a0e0092661aff83a4fda1279df28c425/ty-0.0.56-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:2c66e00c1522add1f2bbdd2e45828c953b35c306b7bef03ec9169c75a63699a0", size = 11445612, upload-time = "2026-07-01T16:44:27.531Z" }, + { url = "https://files.pythonhosted.org/packages/fb/ba/09df108582090f3c0770ec4bc8675affed60248f6793a78d909be16211d9/ty-0.0.56-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:40903d71c669a30691b5a5d5728056c7877a1bd6be4f233a38883a8b28cf34d7", size = 12093889, upload-time = "2026-07-01T16:44:29.548Z" }, + { url = "https://files.pythonhosted.org/packages/d7/f7/dbb4b4ccb69cd64c209ae55b1ab788ace8222c2bc1f6845be9e7cbedbf25/ty-0.0.56-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:63fe3947fe0c46c69a7d950e6832ee70a9ec17321fefbff3d2e3c20baf9e5bd0", size = 12666337, upload-time = "2026-07-01T16:44:31.586Z" }, + { url = "https://files.pythonhosted.org/packages/86/e9/73f903fe4a3d9ea02f26f57c1eb07e3b1029ec92b0e8c2364718893440e3/ty-0.0.56-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:71a0c1a72f9854532e710e119b6871ffe4542c8a65146f1f65dcd78fecd885b4", size = 12280247, upload-time = "2026-07-01T16:44:33.637Z" }, + { url = "https://files.pythonhosted.org/packages/d6/90/cebd222495832f1a00dcd321ba25f3cab804221a4991b992c2178bec68ee/ty-0.0.56-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:70d1665596494e24d8ebd198438872b5a56ec3cae5f2bcf6c673be797acc4e3c", size = 11991107, upload-time = "2026-07-01T16:44:36.122Z" }, + { url = "https://files.pythonhosted.org/packages/b7/07/8f7337a07250f42d975cdb6decf47fc5b421e6c7da5e3e7be1e85f63a7e5/ty-0.0.56-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:778f99e51558afc1dbbe48ee38ab6aae7b31390ed8c1a1ef1499b295e9f1e82f", size = 12298970, upload-time = "2026-07-01T16:44:38.243Z" }, + { url = "https://files.pythonhosted.org/packages/3c/b9/a52cd59034a48f5f18c6b155cc2cc36861d874b6d0af204b12c898024c3d/ty-0.0.56-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:867bc5708e0066bb4ff6c7db524bd5deea2676c62bfe71d3303138b3be850af0", size = 11425683, upload-time = "2026-07-01T16:44:40.473Z" }, + { url = "https://files.pythonhosted.org/packages/1d/2e/48e42d33357d52eefb695c0c3fcfc96879b73668a7447d1d1e0ad774fedc/ty-0.0.56-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:a6012f4189c928edb330a37deb9930f982380bd4aa7c4b8e0428eec9651c7551", size = 11469258, upload-time = "2026-07-01T16:44:42.513Z" }, + { url = "https://files.pythonhosted.org/packages/d5/01/ad1b4138be1e3fa97863af3925aa2134f17a593240c35dc38c3429fb5ad1/ty-0.0.56-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8ee83de1a7ff4cc32837ec06134ce391d441bc5b35ecd8d3cfe053f120f3e4c1", size = 11758736, upload-time = "2026-07-01T16:44:44.567Z" }, + { url = "https://files.pythonhosted.org/packages/09/34/9d81967ff240eaa57e9249728ef7b7790747cf6d3c9a98ec86b2cfdcc8ee/ty-0.0.56-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:62619b3b0e2c6248ef30d3f0e2f2217ae9893040585be07f32324242f197cd6f", size = 12100242, upload-time = "2026-07-01T16:44:46.584Z" }, + { url = "https://files.pythonhosted.org/packages/c3/36/f51d4666d2de6cf33c1f3a1fcc4bb6b70b197dd6ceaa491eef71d78fe8e8/ty-0.0.56-py3-none-win32.whl", hash = "sha256:b30687bb5cd9729d34c889a289edf32770388d9bb05243e534e723fb45e0381b", size = 11093759, upload-time = "2026-07-01T16:44:49.171Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b4/8fb5d4acfa4afb152245b20fa263069a7547bd1f8e4bfca4eda280c897d7/ty-0.0.56-py3-none-win_amd64.whl", hash = "sha256:ad4c8c47b6f4e3f9ed3fc0b1a5d650088d229e17dd8f63c1826d6bbe94cc3235", size = 12100327, upload-time = "2026-07-01T16:44:51.26Z" }, + { url = "https://files.pythonhosted.org/packages/b8/fc/6a183e71edde90d0c35c2303f23f7a45b6891d1a2c45daf7b8f869831e19/ty-0.0.56-py3-none-win_arm64.whl", hash = "sha256:57538f273d444a5f1293fa7860e967178afe3917611fc5eff16b64e1204fe0d6", size = 11538780, upload-time = "2026-07-01T16:44:53.8Z" }, +] + +[[package]] +name = "types-jsonschema" +version = "4.26.0.20260518" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-doc" }, - { name = "click" }, - { name = "rich" }, - { name = "shellingham" }, + { name = "referencing" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/e4/51/9aed62104cea109b820bbd6c14245af756112017d309da813ef107d42e7e/typer-0.25.1.tar.gz", hash = "sha256:9616eb8853a09ffeabab1698952f33c6f29ffdbceb4eaeecf571880e8d7664cc", size = 122276, upload-time = "2026-04-30T19:32:16.964Z" } +sdist = { url = "https://files.pythonhosted.org/packages/dd/46/73b6a5d61a61015c4248030a8cb07e5bdddb4041430fae9e585a68692578/types_jsonschema-4.26.0.20260518.tar.gz", hash = "sha256:e1dd53dc97a64f5eccdd6fa9839666e09bb500a8ebba2db6fdaf1789faea81a6", size = 16638, upload-time = "2026-05-18T06:06:44.106Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/07/d5/134f8a147dcecda10db7f60cfc6af0578a25a5c53c87b3907a64385e0184/types_jsonschema-4.26.0.20260518-py3-none-any.whl", hash = "sha256:30b30a518c7fe335df85c919fcbcc631b69c03d4a4b5b632fa916bea03065307", size = 16072, upload-time = "2026-05-18T06:06:43.264Z" }, +] + +[[package]] +name = "types-pyyaml" +version = "6.0.12.20260518" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b8/83/4a1afc3fbfcf5b8d46fc390cd95ed6b0dc9010a265f4e9f46314efffa37a/types_pyyaml-6.0.12.20260518.tar.gz", hash = "sha256:d917f83fb38462550338c1297faedd860b3ec83912b96b1e3d73255f7473e466", size = 17850, upload-time = "2026-05-18T06:01:58.675Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/f9/2b3ff4e56e5fa7debfaf9eb135d0da96f3e9a1d5b27222223c7296336e5f/typer-0.25.1-py3-none-any.whl", hash = "sha256:75caa44ed46a03fb2dab8808753ffacdbfea88495e74c85a28c5eefcf5f39c89", size = 58409, upload-time = "2026-04-30T19:32:18.271Z" }, + { url = "https://files.pythonhosted.org/packages/06/a2/c01db32be2ae7d6a1689972f3c492b149ee4e164b12fdfd9f64b50888215/types_pyyaml-6.0.12.20260518-py3-none-any.whl", hash = "sha256:d2150f75a231c9fe9c7463bd29487d93e60bac90400287351384bc2284eba7cd", size = 20312, upload-time = "2026-05-18T06:01:57.368Z" }, ] [[package]] name = "typing-extensions" -version = "4.15.0" +version = "4.16.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, + { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] [[package]] @@ -3649,21 +3675,21 @@ wheels = [ [[package]] name = "uvicorn" -version = "0.49.0" +version = "0.50.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, { name = "h11" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c4/1f/fa18009dea8469069cca78a4e877a008ab78f08b064bfc9ab891579077ff/uvicorn-0.49.0.tar.gz", hash = "sha256:ebf4271aa580d9de97f93192d4595176df6e91f9aae919ca73e4fc07df1e66a3", size = 91284, upload-time = "2026-06-03T22:01:30.448Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9f/f6/cc9aadc0e481344a42095d222bfa764122fb8cfba708d1922917bd8bfb01/uvicorn-0.50.2.tar.gz", hash = "sha256:b92bf03509b82bcb9d49e7335b4fd364518ad021c2dc18b4e6a2fec8c955a0bb", size = 93716, upload-time = "2026-07-06T10:38:31.984Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/88/fa/e1388bbcf24ef3274f45c0c1c7b501fd14971037c1b6ee23610553307497/uvicorn-0.49.0-py3-none-any.whl", hash = "sha256:ba3d14c3ee7e41c6c654c46c9eb489d33213cdd30aa1696eab1374337c13f68f", size = 71376, upload-time = "2026-06-03T22:01:29.037Z" }, + { url = "https://files.pythonhosted.org/packages/aa/f0/7c228ee10c7ab8fd3a21d06579a6f7c6075c6ce72594a20fb5d2f206ff24/uvicorn-0.50.2-py3-none-any.whl", hash = "sha256:4ae72a385630bcc17a0adb8290f26c993865e0b43a2114c2aab96420172c056a", size = 72846, upload-time = "2026-07-06T10:38:30.543Z" }, ] [[package]] name = "virtualenv" -version = "21.5.1" +version = "21.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -3672,9 +3698,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/a5/81f987504738e6defeed61ec1c47e2aefab3c35d8eeb87e1b3f38cf28254/virtualenv-21.5.1.tar.gz", hash = "sha256:dca3bf98275a59c652b69d68e73433e597d977c2da9198882479d1a7188009c8", size = 4578798, upload-time = "2026-06-16T16:23:58.603Z" } +sdist = { url = "https://files.pythonhosted.org/packages/74/65/ec1d92091671e6407d3e7c1f5801413bb7b2b57630a50cae7750456ba0ed/virtualenv-21.6.0.tar.gz", hash = "sha256:e18a4d750f2b64dea73e72ffde3922f3c52365fabdc8157ebd3da20d031c4734", size = 5526111, upload-time = "2026-07-06T22:49:56.972Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/2c/02/3623e6169bed617ed1e2d372f7c69f92ec28d54c4dfc997055c8578ec148/virtualenv-21.5.1-py3-none-any.whl", hash = "sha256:55aa670b67bbfb991b03fda39bd3276d92c419d702376e98c5df1c9989a26783", size = 4558820, upload-time = "2026-06-16T16:23:56.963Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e7/2fbd0cc1653c53eed8f10670538bb547de2b3e37aacad283faa82a71094b/virtualenv-21.6.0-py3-none-any.whl", hash = "sha256:bce9d097950fef9d81129b333babfb7767072850c2f1acce0ec536708401bfd1", size = 5506216, upload-time = "2026-07-06T22:49:54.941Z" }, ] [[package]] diff --git a/zensical.toml b/zensical.toml index 36689c0a..e98989da 100644 --- a/zensical.toml +++ b/zensical.toml @@ -18,152 +18,56 @@ extra_css = ["assets/stylesheets/extra.css"] nav = [ { "Home" = "index.md" }, { "Get started" = [ - { "1. Install & first run" = "getting-started.md" }, - { "2. Your first governed edit" = "start/first-governed-edit.md" }, + { "Getting started" = "getting-started.md" }, { "Example report" = "examples/report.md" }, ] }, - { "Guide" = [ - { "Overview" = "guide/README.md" }, - { "Explanation" = [ - { "How CodeClone works" = "guide/explanation/how-it-works.md" }, - ] }, - { "Change control" = [ - { "Overview" = "guide/change-control/overview.md" }, - { "Agent edit cycle" = "guide/change-control/agent-cycle.md" }, - { "Queue & recovery" = "guide/change-control/queue-and-recovery.md" }, - { "Atomic debug path" = "guide/change-control/atomic-debug.md" }, - ] }, - { "Engineering Memory" = [ - { "Overview" = "guide/memory/overview.md" }, - { "Trajectories & Experiences" = "guide/memory/trajectories-and-experiences.md" }, - ] }, - { "MCP" = [ - { "Overview" = "guide/mcp/README.md" }, - { "Client setup" = "guide/mcp/client-setup.md" }, - { "Server & transport" = "guide/mcp/server-and-transport.md" }, - { "Architecture" = "guide/mcp/architecture.md" }, - { "Workflows" = [ - { "Analyze & triage" = "guide/mcp/workflows/analyze-and-triage.md" }, - { "Drill down & checks" = "guide/mcp/workflows/drill-down-and-checks.md" }, - { "Change control" = "guide/mcp/workflows/change-control.md" }, - { "Memory recipes" = "guide/mcp/workflows/memory-recipes.md" }, - { "Coverage join & session markers" = "guide/mcp/workflows/session-and-coverage.md" }, - { "Observability (maintainer)" = "guide/mcp/workflows/observability-recipes.md" }, - ] }, - { "Prompt patterns" = "guide/mcp/prompts.md" }, - { "Payload cheat sheet" = "guide/mcp/payload-cheatsheet.md" }, - { "Troubleshooting" = "guide/mcp/troubleshooting.md" }, - ] }, - { "Integrations" = [ - { "VS Code" = "guide/integrations/vscode/setup.md" }, - { "Cursor" = "guide/integrations/cursor/install-and-skills.md" }, - { "Claude Code" = "guide/integrations/claude-code/setup.md" }, - { "Codex" = "guide/integrations/codex/setup.md" }, - { "Claude Desktop" = "guide/integrations/claude-desktop/setup.md" }, - { "SARIF export" = "guide/integrations/sarif/export.md" }, - { "GitHub Action" = "book/integrations/github-action.md" }, - ] }, + { "Concepts" = [ + { "Structural analysis" = "concepts/structural-analysis.md" }, + { "Health score" = "concepts/health-score.md" }, + { "Blast radius" = "concepts/blast-radius.md" }, + { "Controlled change" = "concepts/controlled-change.md" }, + { "Edit permission and edit_allowed" = "concepts/edit-allowed.md" }, + { "Engineering Memory" = "concepts/engineering-memory.md" }, + { "Review receipts" = "concepts/review-receipts.md" }, + { "MCP interface" = "concepts/mcp.md" }, + { "Reports and baselines" = "concepts/reports.md" }, + { "Project setup" = "concepts/project-setup.md" }, ] }, - { "Contracts" = [ - { "Overview" = "book/README.md" }, - { "Foundations" = [ - { "Intro" = "book/00-intro.md" }, - { "Terminology" = "book/01-terminology.md" }, - { "Architecture Map" = "book/02-architecture-map.md" }, - ] }, - { "Pipeline & data" = [ - { "Core Pipeline" = "book/03-core-pipeline.md" }, - { "CFG Semantics" = "book/04-cfg-semantics.md" }, - { "Report" = "book/05-report.md" }, - { "HTML Render" = "book/06-html-render.md" }, - { "Baseline" = "book/07-baseline.md" }, - { "Cache" = "book/08-cache.md" }, - ] }, - { "Contracts & config" = [ - { "Exit Codes" = "book/09-exit-codes.md" }, - { "Config and Defaults" = "book/10-config-and-defaults.md" }, - { "CLI" = "book/11-cli.md" }, - ] }, - { "Change control" = [ - { "Overview" = "book/12-structural-change-controller/index.md" }, - { "CLI queries" = "book/12-structural-change-controller/cli-controller-queries.md" }, - { "Blast radius & receipt" = "book/12-structural-change-controller/blast-radius-and-receipt.md" }, - { "Intent registry & queue" = "book/12-structural-change-controller/intent-registry-and-queue.md" }, - { "Verification profiles" = "book/12-structural-change-controller/verification-profiles.md" }, - { "Patch contract verify" = "book/12-structural-change-controller/patch-contract-verify.md" }, - { "Workflow tools" = "book/12-structural-change-controller/workflow-tools.md" }, - { "Finish controlled change" = "book/12-structural-change-controller/finish-controlled-change.md" }, - { "Finish hygiene" = "book/12-structural-change-controller/finish-hygiene.md" }, - { "Patch Trail" = "book/12-structural-change-controller/patch-trail.md" }, - { "Payload semantics" = "book/12-structural-change-controller/payload-semantics.md" }, - { "Token budget" = "book/12-structural-change-controller/token-budget.md" }, - { "Claim Guard" = "book/14-claim-guard.md" }, - ] }, - { "Engineering Memory" = [ - { "Overview" = "book/13-engineering-memory/index.md" }, - { "Trust & lifecycle" = "book/13-engineering-memory/trust-and-lifecycle.md" }, - { "Bootstrap & config" = "book/13-engineering-memory/bootstrap-and-config.md" }, - { "CLI surface" = "book/13-engineering-memory/cli-surface.md" }, - { "MCP surface" = "book/13-engineering-memory/mcp-surface.md" }, - { "Agent contracts" = "book/13-engineering-memory/agent-contracts.md" }, - { "Staleness" = "book/13-engineering-memory/staleness-and-anchors.md" }, - { "FTS search" = "book/13-engineering-memory/search-fts.md" }, - { "Semantic search" = "book/13-engineering-memory/search-semantic.md" }, - { "Trajectory" = "book/13-engineering-memory/trajectory-and-patch-trail.md" }, - { "Trajectory quality & passport" = "book/13-engineering-memory/trajectory-quality-and-passport.md" }, - { "Trajectory labels" = "book/13-engineering-memory/trajectory-labels.md" }, - { "Experience Layer" = "book/13-engineering-memory/experience-layer.md" }, - { "Projection jobs" = "book/13-engineering-memory/projection-jobs.md" }, - { "Scope & invariants" = "book/13-engineering-memory/scope-and-invariants.md" }, - ] }, - { "Quality signals" = [ - { "Health Score" = "book/15-health-score.md" }, - { "Metrics and Gates" = "book/16-metrics-and-quality-gates.md" }, - { "Dead Code" = "book/17-dead-code-contract.md" }, - { "Suggestions and Clone Typing" = "book/18-suggestions-and-clone-typing.md" }, - { "Inline Suppressions" = "book/19-inline-suppressions.md" }, - { "Benchmarking" = "book/20-benchmarking.md" }, - ] }, - { "System properties" = [ - { "Security Model" = "book/21-security-model.md" }, - { "Determinism" = "book/22-determinism.md" }, - { "Testing as Spec" = "book/23-testing-as-spec.md" }, - { "Compatibility and Versioning" = "book/24-compatibility-and-versioning.md" }, - { "Platform Observability" = "book/26-platform-observability.md" }, - { "Corpus Analytics" = "book/27-corpus-analytics.md" }, - ] }, - { "MCP interface" = [ - { "Overview" = "book/25-mcp-interface/index.md" }, - { "Tools" = [ - { "Analysis" = "book/25-mcp-interface/tools/analysis.md" }, - { "Implementation context" = "book/25-mcp-interface/tools/implementation-context.md" }, - { "Help topics" = "book/25-mcp-interface/tools/help-and-topics.md" }, - { "Report & findings" = "book/25-mcp-interface/tools/report-and-findings.md" }, - { "Checks" = "book/25-mcp-interface/tools/checks.md" }, - { "Workflow" = "book/25-mcp-interface/tools/workflow.md" }, - { "Atomic change control" = "book/25-mcp-interface/tools/atomic-change-control.md" }, - { "Session & memory" = "book/25-mcp-interface/tools/session-and-memory.md" }, - { "Platform observability" = "book/25-mcp-interface/tools/platform-observability.md" }, - { "IDE governance" = "book/25-mcp-interface/tools/ide-governance.md" }, - ] }, - { "Resources" = "book/25-mcp-interface/resources.md" }, - { "Payload conventions" = "book/25-mcp-interface/payload-conventions.md" }, - { "Determinism & tests" = "book/25-mcp-interface/determinism-and-tests.md" }, - ] }, - { "Integrations" = [ - { "VS Code" = "book/integrations/vs-code-extension.md" }, - { "Cursor plugin" = "book/integrations/cursor-plugin.md" }, - { "Claude Code plugin" = "book/integrations/claude-code-plugin.md" }, - { "Codex plugin" = "book/integrations/codex-plugin.md" }, - { "Claude Desktop" = "book/integrations/claude-desktop-bundle.md" }, - { "GitHub Action" = "book/integrations/github-action.md" }, - { "SARIF" = "book/integrations/sarif.md" }, - ] }, - { "Appendix" = [ - { "Status Enums" = "book/appendix/a-status-enums.md" }, - { "Schema Layouts" = "book/appendix/b-schema-layouts.md" }, - { "Error Catalog" = "book/appendix/c-error-catalog.md" }, - ] }, + { "Guides" = [ + { "Run the first analysis" = "guides/first-analysis.md" }, + { "Agent-safe change workflow" = "guides/agent-safe-change.md" }, + { "Review a change" = "guides/review-a-change.md" }, + { "Engineering Memory workflow" = "guides/engineering-memory-workflow.md" }, + { "Set up a project" = "guides/setup-project.md" }, + { "CI integration" = "guides/ci-integration.md" }, + { "Development guide" = "guides/development.md" }, + ] }, + { "Integrations" = [ + { "Claude" = "integrations/claude.md" }, + { "Cursor" = "integrations/cursor.md" }, + { "Codex" = "integrations/codex.md" }, + { "VS Code" = "integrations/vscode.md" }, + ] }, + { "Reference" = [ + { "CLI reference" = "reference/cli.md" }, + { "MCP tools reference" = "reference/mcp-tools.md" }, + { "Configuration reference" = "reference/configuration.md" }, + { "Report reference" = "reference/reports.md" }, + { "Memory record types" = "reference/memory-record-types.md" }, + { "JSON output contracts" = "reference/json-output.md" }, + { "Exit codes" = "reference/exit-codes.md" }, + { "Setup command reference" = "reference/setup.md" }, + { "Inline suppressions" = "reference/suppressions.md" }, + { "Platform observability" = "reference/observability.md" }, + ] }, + { "Troubleshooting" = [ + { "Overview" = "troubleshooting/index.md" }, + { "Installation" = "troubleshooting/install.md" }, + { "MCP" = "troubleshooting/mcp.md" }, + { "Engineering Memory" = "troubleshooting/memory.md" }, + { "Setup" = "troubleshooting/setup.md" }, + { "Reports" = "troubleshooting/reports.md" }, + { "Terminal output" = "troubleshooting/terminal-output.md" }, ] }, { "Legal & plans" = [ { "Privacy Policy" = "privacy-policy.md" }, @@ -171,11 +75,6 @@ nav = [ { "Plans and Retention" = "plans-and-retention.md" }, ] }, { "Maintainers" = [ - { "Diagnostics" = [ - { "Maintainer workflow" = "guide/observability/maintainer-workflow.md" }, - { "Quick diagnostics" = "guide/observability/diagnostics.md" }, - { "Corpus Analytics" = "guide/analytics/overview.md" }, - ] }, { "Docs site" = [ { "Publishing the docs site" = "publishing.md" }, { "Releasing & storefront sync" = "releasing.md" },