diff --git a/docs/bundles/code-review/run.md b/docs/bundles/code-review/run.md index 6b11b39..bc14db0 100644 --- a/docs/bundles/code-review/run.md +++ b/docs/bundles/code-review/run.md @@ -39,6 +39,8 @@ The pipeline reviews **`.py`** and **`.pyi`** only. The **`--focus docs`** facet | `--score-only` | Print just the reward delta integer | | `--no-tests` | Skip the TDD gate | | `--fix` | Apply Ruff autofixes, then rerun the review | +| `--preview-fixes` | For **`--focus simplify`**, compute non-mutating patch evidence for supported safe-mechanical simplification fixers | +| `--with-mutation` | For **`--focus simplify`**, record opt-in mutation proof evidence for cleanup candidates; unavailable tooling is inconclusive | | `--interactive` | Prompt for scope decisions before execution | | `--instructions` | Print AI-facing simplify / clean-code workflow instructions and exit without running review | @@ -53,6 +55,9 @@ The Typer entrypoint validates **review flags** first: it raises **`typer.BadPar - **`--include-tests` with `--exclude-tests`**: pick at most one test inclusion mode. Runtime error: **`Cannot use both --include-tests and --exclude-tests`** - **`--out` without `--json`**: **`--out`** is accepted only when **`--json`** is also set. Runtime error: **`Use --out together with --json.`** - **`--json` with `--score-only`**: do not combine JSON report output with score-only mode (**`Use either --json or --score-only, not both.`**). +- **`--preview-fixes` with `--fix`**: preview is non-mutating and cannot be combined with write mode. Runtime error: **`Cannot combine --preview-fixes with --fix.`** +- **`--preview-fixes` without simplify focus**: preview evidence is scoped to cleanup findings. Runtime error: **`Use --preview-fixes only with --focus simplify.`** +- **`--with-mutation` without simplify focus**: mutation proof is scoped to cleanup candidates. Runtime error: **`Use --with-mutation only with --focus simplify.`** **Supported targeting:** either pass **positional file paths** for a fixed review set (the pipeline still only reviews Python sources it accepts, such as **`.py`** / **`.pyi`**), or omit files and use **`--scope`** / **`--path`** (and related test flags) for auto-discovery — do not mix positional paths with **`--scope`** or **`--path`**. @@ -90,15 +95,18 @@ specfact code review run --scope changed --mode shadow --json --out /tmp/review- ### `--focus` facets (repeatable) -Use **`--focus`** with **`source`**, **`tests`**, **`docs`**, and/or **`simplify`** (union of facets, then intersect with scope). Do not combine **`--focus`** with **`--include-tests`** or **`--exclude-tests`**. The **`simplify`** facet produces simplification-focused reports: advisory **`ai_bloat`** findings plus high-confidence **`dry`** and **`kiss`** findings that carry deterministic metadata such as **`rewrite_hint`**, **`canonical_pattern`**, **`intent_key`**, **`estimated_deletion_lines`**, and **`related_locations`**. Simplification-focused findings are score-neutral and non-blocking. +Use **`--focus`** with **`source`**, **`tests`**, **`docs`**, and/or **`simplify`** (union of facets, then intersect with scope). Do not combine **`--focus`** with **`--include-tests`** or **`--exclude-tests`**. The **`simplify`** facet produces simplification-focused reports: advisory **`ai_bloat`** findings plus high-confidence **`dry`** and **`kiss`** findings that carry deterministic metadata such as **`rewrite_hint`**, **`canonical_pattern`**, **`intent_key`**, **`estimated_deletion_lines`**, and **`related_locations`**. Simplification-focused JSON also includes **`cleanup_forecast`**, **`signal_trace`**, **`preserve_reasons`**, and **`remediation_packet`** fields when available. ```bash specfact code review run --scope changed --focus tests specfact code review run --scope full --path packages/specfact-code-review --focus source specfact code review run --scope full --focus docs -specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json +specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review.json +specfact code review run --scope changed --focus simplify --with-mutation --json --out .specfact/code-review.json ``` +Use the canonical `.specfact/code-review.json` path unless every consumer in your workflow has been updated to read a custom simplify report path. + ### AI instructions fallback When an IDE does not support bundled prompts or skills, print the same guided simplify workflow for an AI assistant: @@ -107,7 +115,7 @@ When an IDE does not support bundled prompts or skills, print the same guided si specfact code review run --instructions ``` -The output explains how to remove AI bloat and apply clean-code simplifications using SpecFact evidence, including `safe_mechanical`, `needs_tests`, `design_judgment`, and `preserve` handling, patch previews, conservative keep/skip defaults, and per-file validation. It also tells assistants how to handle clean PR branches where `--scope changed` has no worktree files: find branch-delta Python files with a base-ref diff such as `git diff --name-only ...HEAD -- '*.py' '*.pyi'`, review those files as explicit positional files, and treat findings without `guidance_kind` as unguided advisories rather than auto-fix input. +The output explains how to remove AI bloat and apply clean-code simplifications using SpecFact evidence, including `cleanup_forecast`, `safe_mechanical`, `needs_tests`, `design_judgment`, `preserve`, `remediation_packet`, patch previews, conservative keep/skip defaults, and per-file validation. It also tells assistants how to handle clean PR branches where `--scope changed` has no worktree files: find branch-delta Python files with a base-ref diff such as `git diff --name-only ...HEAD -- '*.py' '*.pyi'`, review those files as explicit positional files, and treat findings without `guidance_kind` as unguided advisories rather than auto-fix input. `ai_bloat` findings are cleanup signals, not proof of AI authorship. ### Positional files (explicit Python paths) @@ -137,10 +145,10 @@ The built-in `specfact/ai-bloat-patterns` policy pack is parallel to `specfact/c Use `--focus simplify` when producing the IDE simplification queue: ```bash -specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json +specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review.json ``` -Simplify-focused reports keep advisory `ai_bloat` findings plus high-confidence `dry` and `kiss` findings that include deterministic simplification metadata. Metadata fields such as `rewrite_hint`, `canonical_pattern`, `intent_key`, `estimated_deletion_lines`, and `related_locations` are additive; legacy consumers can keep reading the original finding fields. Simplification findings remain score-neutral and non-blocking. +Simplify-focused reports keep advisory `ai_bloat` findings plus high-confidence `dry` and `kiss` findings that include deterministic simplification metadata. Metadata fields such as `rewrite_hint`, `canonical_pattern`, `intent_key`, `estimated_deletion_lines`, `related_locations`, `signal_trace`, `preserve_reasons`, and `remediation_packet` are additive; legacy consumers can keep reading the original finding fields. The report-level `cleanup_forecast` summarizes reviewed LOC, estimated deletion ranges, guidance-kind totals, normalized AI-bloat density, weighted bloat points, and cleanup-yield LOC per KLOC. Simplification findings remain score-neutral; enforce mode blocks only unresolved safe-mechanical cleanup candidates. ## Related diff --git a/docs/modules/code-review.md b/docs/modules/code-review.md index efe5ad8..6693606 100644 --- a/docs/modules/code-review.md +++ b/docs/modules/code-review.md @@ -59,6 +59,11 @@ Options (aligned with `specfact code review run --help`): - `--score-only`: print only the integer `reward_delta` - `--no-tests`: skip the targeted TDD gate - `--fix`: apply Ruff autofixes and re-run the review before printing results +- `--preview-fixes`: with **`--focus simplify`**, compute non-mutating patch + evidence for supported safe-mechanical simplification fixers +- `--with-mutation`: with **`--focus simplify`**, record opt-in mutation proof + evidence for cleanup candidates; missing mutation tooling is recorded as + inconclusive - `--interactive`: ask whether changed test files should be included before auto-detected review runs - `--instructions`: print AI-facing simplify / clean-code workflow instructions @@ -75,6 +80,9 @@ The command rejects incompatible mixes (same rules as the bundle run guide): Typ - **`--include-tests` with `--exclude-tests`**: pick at most one test inclusion mode. - **`--out` without `--json`**: **`--out`** is accepted only when **`--json`** is also set. - **`--json` with `--score-only`**: pick one, not both (**`--json`** cannot be used with **`--score-only`**). +- **`--preview-fixes` with `--fix`**: preview is non-mutating and cannot be combined with write mode. +- **`--preview-fixes` without `--focus simplify`**: preview evidence is scoped to cleanup findings. +- **`--with-mutation` without `--focus simplify`**: mutation proof is scoped to cleanup candidates. When `FILES` is omitted, the command falls back to: @@ -114,7 +122,7 @@ guide (same Typer surface as this section). The review pipeline also emits `ai_bloat` findings for code shapes commonly amplified by AI-assisted generation: manual append loops, passthrough lambdas, identity `try/except`, one-call wrappers, speculative `Optional[...] = None` parameters, duplicate terminal guards, long low-branch functions, and redundant intermediates. -These findings are `severity=info`, advisory-only, and score-neutral. They are written to `.specfact/code-review.json` when the report includes all severities; for simplification queues, write `.specfact/code-review-simplify.json` with `--focus simplify` so `/specfact.08-simplify` can filter them by `category=ai_bloat` for per-change confirmed rewrites. They do not claim AI authorship; they identify simplification candidates. +These findings are `severity=info`, advisory-only, and score-neutral. They are written to `.specfact/code-review.json` when the report includes all severities; for simplification queues, write `.specfact/code-review-simplify.json` with `--focus simplify` so `/specfact.08-simplify` can filter them by `category=ai_bloat` for per-change confirmed rewrites. Simplify JSON now includes `cleanup_forecast` at report level plus per-finding `signal_trace`, `preserve_reasons`, and `remediation_packet` where available. They do not claim AI authorship; they identify simplification candidates. For the lowest-friction AI onboarding path, start with the built-in instruction printer instead of requiring a user to install IDE prompts or skills first: @@ -125,8 +133,9 @@ specfact code review run --instructions Paste that output into any AI coding assistant and ask it to simplify or remove AI bloat with SpecFact. The instructions explain the expected report file, -`guidance_kind` handling, patch-preview decision cards, conservative defaults -for `design_judgment`, and per-file validation. They also cover clean PR +`cleanup_forecast`, `guidance_kind`, `remediation_packet` handling, +patch-preview decision cards, conservative defaults for `design_judgment`, +and per-file validation. They also cover clean PR branches where `--scope changed` has no worktree files: the assistant should find branch-delta Python files with a base-ref diff such as `git diff --name-only ...HEAD -- '*.py' '*.pyi'`, review those files @@ -190,6 +199,19 @@ specfact code review run --score-only packages/specfact-code-review/src/specfact specfact code review run --fix packages/specfact-code-review/src/specfact_code_review/run/commands.py ``` +For simplify-focused cleanup, prefer a JSON-first preview loop before writing: + +```bash +specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review-simplify.json +``` + +Inspect `cleanup_forecast` to estimate cleanup yield and sort by +`guidance_kind`. For each finding, use `remediation_packet` as the portable AI +IDE contract. The preview evidence reports patch deltas without editing tracked +files. Use `--with-mutation` only when you explicitly want candidate-scoped +mutation evidence; missing or timed-out tooling is inconclusive, not proof that +deletion is safe. + ## Tool runners The `specfact-code-review` bundle now includes internal runners that translate tool diff --git a/docs/quickstart-ai-bloat.md b/docs/quickstart-ai-bloat.md index 50ddb77..0880a30 100644 --- a/docs/quickstart-ai-bloat.md +++ b/docs/quickstart-ai-bloat.md @@ -10,7 +10,7 @@ expertise_level: [beginner, intermediate] # AI bloat quickstart -Use the Code Review bundle to detect bloat patterns commonly produced by AI-assisted coding, then use the Project bundle's `/specfact.08-simplify` prompt to review each cleanup with confirmation. +Use the Code Review bundle to detect bloat patterns commonly produced by AI-assisted coding, estimate cleanup impact, and hand structured remediation packets to any AI IDE. The Project bundle's `/specfact.08-simplify` prompt can still drive the confirmed rewrite loop. ## 1. Install and refresh prompts @@ -20,17 +20,17 @@ specfact module install nold-ai/specfact-project specfact init ide ``` -## 2. Run review with full JSON evidence +## 2. Run simplify review with cleanup forecast evidence ```bash -specfact code review run --json --out .specfact/code-review.json +specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review-simplify.json ``` -Omit `--level` for this report. `--level error` intentionally filters info-level findings, including `ai_bloat`, out of the command output. +Omit `--level` for this report. `--level error` intentionally filters info-level findings, including `ai_bloat`, out of the command output. `--preview-fixes` is non-mutating: it adds patch forecast evidence without editing tracked files. ## 3. Inspect the signal -Look for findings where `category` is `ai_bloat`. They are `severity=info`, advisory-only, and score-neutral. +Look first at `cleanup_forecast`. It summarizes reviewed LOC, low/expected/high deletion estimates, guidance-kind totals, AI-bloat density, weighted bloat points, and cleanup-yield LOC per KLOC. Then inspect findings where `category` is `ai_bloat`. They are `severity=info`, advisory-only, and score-neutral. Example output from the implementation dry run for this change: the AST detector found advisory `ai_bloat` candidates across `specfact-code-review` and `specfact-project` package sources, with no automatic rewrites applied. `/specfact.08-simplify` is the human-confirmed rewrite path. @@ -38,13 +38,18 @@ Example output from the implementation dry run for this change: the AST detector { "category": "ai_bloat", "severity": "info", - "rule": "ai-bloat.identity-try-except" + "rule": "ai-bloat.identity-try-except", + "guidance_kind": "safe_mechanical", + "remediation_packet": { + "safe_to_autofix": true, + "validation_plan": ["run targeted tests", "rerun simplify review"] + } } ``` ## 4. Simplify in the IDE -Run `/specfact.08-simplify`. The prompt reads `.specfact/code-review.json`, groups findings by file and rule, and asks before applying each edit. +Run `/specfact.08-simplify` or pass `.specfact/code-review-simplify.json` to your AI IDE. The JSON is the contract: sort by `guidance_kind`, use each `remediation_packet`, preserve anything with `preserve_reasons`, and ask before editing `design_judgment` findings. Example cleanup: @@ -83,7 +88,7 @@ def double(values: list[int]) -> list[int]: ## 5. Re-run review ```bash -specfact code review run --json --out .specfact/code-review.json +specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json ``` Use the new report to confirm accepted simplifications cleared the corresponding `ai_bloat` findings. This is bloat-shape detection, not AI-authorship detection. diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 4291b67..14e1b0c 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -103,6 +103,7 @@ The architecture pillar remains active because `architecture-02-well-architected | code-review + project | 03 | code-review-ai-bloat-detection | [#269](https://github.com/nold-ai/specfact-cli-modules/issues/269) | Parent Feature: [#175](https://github.com/nold-ai/specfact-cli-modules/issues/175); Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162); no known blockers | | code-review + project | 04 | code-review-11-simplification-feedback-loop | [#276](https://github.com/nold-ai/specfact-cli-modules/issues/276) | Parent Feature: [#275](https://github.com/nold-ai/specfact-cli-modules/issues/275); Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162); blocked by `code-review-ai-bloat-detection` / [#269](https://github.com/nold-ai/specfact-cli-modules/issues/269) | | code-review + project | 05 | code-review-12-guided-simplification-enforcement | [#286](https://github.com/nold-ai/specfact-cli-modules/issues/286) | Parent Feature: [#275](https://github.com/nold-ai/specfact-cli-modules/issues/275); Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162); blocked by `code-review-11-simplification-feedback-loop` / [#276](https://github.com/nold-ai/specfact-cli-modules/issues/276) | +| code-review + project | 06 | code-review-13-cleanup-forecast-agent-handoff | [#297](https://github.com/nold-ai/specfact-cli-modules/issues/297) | Parent Feature: [#275](https://github.com/nold-ai/specfact-cli-modules/issues/275); Epic: [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162); blocked by `code-review-12-guided-simplification-enforcement` / [#286](https://github.com/nold-ai/specfact-cli-modules/issues/286) | ### Documentation restructure diff --git a/openspec/changes/code-review-12-guided-simplification-enforcement/TDD_EVIDENCE.md b/openspec/changes/code-review-12-guided-simplification-enforcement/TDD_EVIDENCE.md index 6d93fb6..70beec0 100644 --- a/openspec/changes/code-review-12-guided-simplification-enforcement/TDD_EVIDENCE.md +++ b/openspec/changes/code-review-12-guided-simplification-enforcement/TDD_EVIDENCE.md @@ -124,6 +124,6 @@ `0.47.25` for `specfact-code-review` and `0.41.16` for `specfact-project` were intermediate local refreshes produced with `hatch run sign-modules --changed-only --base-ref origin/dev --bump-version patch --allow-unsigned --payload-from-filesystem`, because no private signing key is available in the local worktree. The reviewed PR #289 head shipped `specfact-code-review` `0.47.26` and `specfact-project` `0.41.17`; the signing/publish follow-up used the same payload mode through `python scripts/sign-modules.py --changed-only --base-ref "$MERGE_BASE" --bump-version patch --payload-from-filesystem` and the publish workflow's same-version signing path. `hatch run verify-modules-signature --payload-from-filesystem --require-signature --enforce-version-bump --version-check-base origin/main` passed for that shipped head, verifying the final module manifest checksums and signatures. -This PR #289 follow-up changes the `specfact-code-review` source payload again, so the local manifest is refreshed to `0.47.27` with `hatch run sign-modules --changed-only --base-ref origin/dev --bump-version patch --allow-unsigned --payload-from-filesystem`. CI must restore the cryptographic signature with the repository private key before the follow-up lands on `main`. +This PR #289 follow-up changed the `specfact-code-review` source payload again and refreshed the manifest to `0.47.27` with `hatch run sign-modules --changed-only --base-ref origin/dev --bump-version patch --allow-unsigned --payload-from-filesystem`. The publish/sign follow-up produced `registry/modules/specfact-code-review-0.47.27.tar.gz.sha256`, `registry/signatures/specfact-code-review-0.47.27.tar.sig`, and the `registry/index.json` entry for `0.47.27`. -The `packages/specfact-code-review/module-package.yaml` `integrity.checksum` covers the canonical module source payload, while `registry/modules/specfact-code-review-0.47.26.tar.gz.sha256` covers the published tarball artifact. These digests are intentionally different; the registry sidecar matches the `0.47.26` tarball SHA256, and the manifest signature verifier validates the source-payload checksum/signature. The next publish step will produce the corresponding `0.47.27` registry artifact after signing. +The `packages/specfact-code-review/module-package.yaml` `integrity.checksum` covers the canonical module source payload, while `registry/modules/specfact-code-review-0.47.27.tar.gz.sha256` covers the published tarball artifact. These digests are intentionally different; the registry sidecar matches the `0.47.27` tarball SHA256, and the manifest signature verifier validates the source-payload checksum/signature. diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/.openspec.yaml b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/.openspec.yaml new file mode 100644 index 0000000..6894814 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-24 diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/README.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/README.md new file mode 100644 index 0000000..90554e1 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/README.md @@ -0,0 +1,3 @@ +# code-review-13-cleanup-forecast-agent-handoff + +Cleanup forecast, AI-bloat index, remediation packets, and AI IDE handoff for code review. diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/TDD_EVIDENCE.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/TDD_EVIDENCE.md new file mode 100644 index 0000000..bae7b93 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/TDD_EVIDENCE.md @@ -0,0 +1,55 @@ +# TDD Evidence: code-review-13-cleanup-forecast-agent-handoff + +## Failing-before evidence + +Command: + +```bash +hatch run pytest tests/unit/specfact_code_review/run/test_findings.py tests/unit/specfact_code_review/run/test_runner.py tests/unit/specfact_code_review/run/test_commands.py tests/unit/specfact_code_review/review/test_commands.py -q +``` + +Result before implementation: + +- Exit code: 2 +- Collection failed because `AiBloatIndex` and `_preserve_reasons_for_finding` did not exist yet. + +## Passing evidence + +Targeted implementation command: + +```bash +hatch run pytest tests/unit/specfact_code_review/run/test_cleanup_evidence.py tests/unit/specfact_code_review/run/test_forecast.py tests/unit/specfact_code_review/run/test_findings.py tests/unit/specfact_code_review/run/test_runner.py tests/unit/specfact_code_review/run/test_commands.py tests/unit/specfact_code_review/review/test_commands.py -q +``` + +Result after implementation: + +- Exit code: 0 +- 137 passed + +Docs and packaged-resource parity command: + +```bash +hatch run pytest tests/unit/docs/test_code_review_docs_parity.py tests/unit/specfact_code_review/rules/test_updater.py tests/unit/test_guided_simplify_resources.py -q +``` + +Result: + +- Exit code: 0 +- 22 passed + +Required final gates: + +- `hatch run format` — exit code 0 +- `hatch run type-check` — exit code 0 +- `hatch run lint` — exit code 0 +- `hatch run yaml-lint` — exit code 0 +- `hatch run check-bundle-imports` — exit code 0 +- `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump` — exit code 0 +- `hatch run specfact code review run --bug-hunt --json --out .specfact/code-review.json --scope changed` — exit code 0 +- `openspec validate code-review-13-cleanup-forecast-agent-handoff --strict` — exit code 0 + +Full suite wrappers: + +- `hatch run contract-test -- tests/cli-contracts/specfact-code-review-run.scenarios.yaml` — exit code 0, 785 passed, 2 warnings +- `hatch run smart-test` — exit code 0, 785 passed, 2 warnings +- `hatch run test -- -q` — exit code 0, 785 passed, 2 warnings diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/design.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/design.md new file mode 100644 index 0000000..b0edb4d --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/design.md @@ -0,0 +1,37 @@ +## Context + +The current simplify report already has per-finding `estimated_deletion_lines`, `guidance_kind`, `recommended_action`, `safety_checks`, and action status. That is enough for an expert to inspect individual findings, but it is not enough for a user or AI IDE to decide where to start, estimate cleanup impact, or distinguish a safe patch preview from a judgment call. + +The next layer should stay deterministic and Python-first. CPG, Joern, and polyglot clone analysis remain follow-up work. This change should improve the current runner without adding a heavy default dependency path or turning bloat advisories into proof of AI authorship. + +## Decisions + +- `cleanup_forecast` is derived from reviewed Python LOC and guided simplification metadata. It reports raw finding counts and normalized metrics so teams can compare repositories and PRs without over-weighting file size. +- Forecast weights are fixed in V1: `safe_mechanical=1.0`, `needs_tests=0.6`, `design_judgment=0.25`, and `preserve=0.0`. +- `--preview-fixes` is non-mutating. It may create temporary files or in-memory diffs, but it must not edit tracked sources. +- `--with-mutation` is explicit and valid only with `--focus simplify`. Timeouts and tool absence are inconclusive evidence, not proof that cleanup is safe. +- Preserve reasons short-circuit automatic cleanup. The closed taxonomy uses the emitted enum tokens `contract_lambda`, `public_api`, `protocol_member`, `cli_callback`, `compat_shim`, `spec_linked`, `domain_wrapper`, and `load_bearing`. +- `remediation_packet` is the universal handoff surface. IDE prompts and skills may summarize it, but the JSON is authoritative. + +## Data Shape + +`cleanup_forecast` should include: + +- `reviewed_loc`: production, test, and total Python LOC for the reviewed file set. +- `estimated_deletion_lines`: low, expected, high, plus totals by guidance kind. +- `ai_bloat_index`: findings per KLOC, weighted bloat points per KLOC, and cleanup-yield LOC per KLOC. +- `by_guidance_kind`: counts and estimated deletion lines for each guidance kind. +- `by_action_status`: lifecycle counts when present. + +Finding additions should be optional: + +- `signal_trace`: deterministic source signals, including tool name, fired flag, score/value, evidence reference, and explanation. +- `preserve_reasons`: closed-list preserve reasons with evidence refs. +- `remediation_packet`: plain-language issue, recommended action, why it may need to stay, safety checks, validation plan, safe-to-autofix flag, and optional patch forecast refs. + +## Risks + +- **Forecasts can look like guarantees.** Mitigation: use low/expected/high ranges and label deletion estimates as non-binding until preview or mutation evidence exists. +- **Mutation can be slow or flaky.** Mitigation: keep it opt-in, candidate-scoped, and inconclusive on timeout. +- **Preserve detection can hide real bloat.** Mitigation: preserve only blocks automatic cleanup; it can still be reported as kept with rationale. +- **JSON growth can break consumers.** Mitigation: additive fields only, keep original required fields and legacy validation intact. diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/proposal.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/proposal.md new file mode 100644 index 0000000..49533cc --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/proposal.md @@ -0,0 +1,48 @@ +## Why + +`code-review-12-guided-simplification-enforcement` made simplify findings safer for humans and agents to interpret, but the report still does not quantify cleanup impact or package each recommendation as a portable handoff artifact. Developers need to know how much code is likely removable, how much risk each cleanup carries, and what proof an AI IDE should use before editing. + +This change turns `specfact code review run --focus simplify` into a cleanup forecast and remediation handoff loop. It keeps the current conservative guidance model, adds normalized bloat metrics, records deterministic preserve signals, and emits remediation packets that any AI IDE or LLM can consume without relying on vendor-specific prompts. + +## What Changes + +- Add a `cleanup_forecast` report summary with reviewed LOC, estimated removable LOC ranges, guidance-kind breakdowns, AI-bloat density, weighted bloat points, and cleanup-yield metrics. +- Extend findings with optional `signal_trace`, `preserve_reasons`, and `remediation_packet` fields so each cleanup recommendation carries explainable evidence and an AI-ready action contract. +- Add `--preview-fixes` for simplify-focused runs to compute non-mutating patch and numstat forecasts for supported safe-mechanical fixers. +- Add opt-in `--with-mutation` for mutation-backed proof on simplify candidates without making mutation testing part of the default review path. +- Expand preserve detection for contracts, public APIs, protocol/ABC members, Typer/Click callbacks, compatibility shims, explicit preserve markers, and load-bearing mutation evidence. +- Update modules docs, quickstart, the packaged `specfact-code-review` skill, and command-contract coverage to present the JSON report as the universal AI IDE handoff artifact. + +## Capabilities + +### New Capabilities + +- `cleanup-forecast-review`: Quantified cleanup forecasting and AI-bloat index reporting for simplify-focused review runs. +- `ai-ide-remediation-handoff`: Portable remediation packets for AI IDEs and headless agents. + +### Modified Capabilities + +- `review-finding-model`: Add optional evidence and handoff fields while preserving legacy report compatibility. +- `review-run-command`: Add non-mutating preview and opt-in mutation proof flags. +- `guided-simplification-review`: Use stronger preserve and proof signals before cleanup recommendations are applied. +- `review-cli-contracts`: Cover the new flags, invalid combinations, and JSON output shape. +- `house-rules-skill`: Teach agents to use cleanup forecasts and remediation packets. + +## Impact + +- **Affected bundle:** `packages/specfact-code-review`. +- **Affected docs:** Code Review bundle/module pages and AI bloat quickstart. +- **Affected JSON:** `.specfact/code-review.json` receives additive optional fields; existing required fields remain compatible. Custom simplify report paths are allowed only when downstream consumers have been updated to read them. +- **Affected command surface:** `specfact code review run` gains `--preview-fixes` and `--with-mutation`. +- **Release impact:** `specfact-code-review` version, registry entry, and signatures must be refreshed if packaged assets or manifests change. + +## Source Tracking + + +- **Modules Epic:** [#162](https://github.com/nold-ai/specfact-cli-modules/issues/162) +- **Parent Feature:** [#275](https://github.com/nold-ai/specfact-cli-modules/issues/275) +- **GitHub Issue:** [#297](https://github.com/nold-ai/specfact-cli-modules/issues/297) +- **Repository:** nold-ai/specfact-cli-modules +- **Prior Baseline:** [#286](https://github.com/nold-ai/specfact-cli-modules/issues/286) / `code-review-12-guided-simplification-enforcement` +- **Last Synced Status:** synced +- **Sanitized:** false diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/ai-ide-remediation-handoff/spec.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/ai-ide-remediation-handoff/spec.md new file mode 100644 index 0000000..5c6ebc3 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/ai-ide-remediation-handoff/spec.md @@ -0,0 +1,19 @@ +## ADDED Requirements + +### Requirement: Review JSON is the portable AI IDE handoff contract + +The Code Review bundle SHALL expose cleanup guidance through machine-readable JSON so Claude, Codex, Cursor, Copilot, and other assistants can act without vendor-specific prompt assumptions. + +#### Scenario: Remediation packets guide AI cleanup + +- **WHEN** a simplify-focused report contains cleanup findings +- **THEN** each actionable finding SHALL include or be able to derive a remediation packet +- **AND** the packet SHALL state whether the finding may be auto-fixed, needs tests, needs design judgment, or should be preserved +- **AND** the packet SHALL include a validation plan for any accepted cleanup + +#### Scenario: AI instructions prioritize the JSON contract + +- **WHEN** `specfact code review run --instructions` is executed +- **THEN** the instructions SHALL tell assistants to generate simplify evidence first +- **AND** they SHALL tell assistants to sort findings by `guidance_kind`, inspect `cleanup_forecast`, and follow remediation packets before editing +- **AND** they SHALL prohibit treating `ai_bloat` findings as proof of AI authorship diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/cleanup-forecast-review/spec.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/cleanup-forecast-review/spec.md new file mode 100644 index 0000000..31b04e2 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/cleanup-forecast-review/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: Simplify review reports include cleanup forecasts + +Simplify-focused review reports SHALL include a cleanup forecast that quantifies likely cleanup impact without treating estimates as guaranteed deletions. + +#### Scenario: Forecast summarizes reviewed LOC and deletion estimates + +- **WHEN** `specfact code review run --focus simplify --json` emits guided simplification findings +- **THEN** the report SHALL include `cleanup_forecast.reviewed_loc` +- **AND** it SHALL include low, expected, and high estimated deletion-line totals +- **AND** it SHALL include deletion estimates grouped by `guidance_kind` +- **AND** legacy report consumers SHALL still be able to ignore the new field + +#### Scenario: Forecast exposes normalized AI-bloat index + +- **WHEN** a cleanup forecast is present +- **THEN** it SHALL include normalized metrics per KLOC for finding density, weighted bloat points, and cleanup yield +- **AND** the default weights SHALL be `safe_mechanical=1.0`, `needs_tests=0.6`, `design_judgment=0.25`, and `preserve=0.0` +- **AND** preserve findings SHALL contribute no weighted bloat points + +### Requirement: Cleanup forecasts distinguish advice from proof + +The cleanup forecast SHALL distinguish estimate-only signals from previewed or mutation-backed proof. + +#### Scenario: Preview evidence upgrades forecast confidence + +- **WHEN** `--preview-fixes` computes a patch forecast for safe-mechanical findings +- **THEN** the cleanup forecast SHALL include preview evidence for affected findings +- **AND** the preview SHALL report added, removed, and net line counts without editing tracked files + +#### Scenario: Mutation evidence is opt-in + +- **WHEN** `--with-mutation` is not provided +- **THEN** the review SHALL NOT run mutation testing +- **AND** the report SHALL NOT imply mutation-backed proof exists + +- **WHEN** `--with-mutation` is provided for simplify focus +- **THEN** mutation outcomes SHALL be recorded as evidence for candidate findings +- **AND** timeouts or unavailable mutation tooling SHALL be recorded as inconclusive rather than safe cleanup proof diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/review-finding-model/spec.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/review-finding-model/spec.md new file mode 100644 index 0000000..01db8a1 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/review-finding-model/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: ReviewFinding schema supports additive simplification metadata + +The `ReviewFinding` model SHALL accept optional simplification metadata while preserving the existing governed finding fields and category/severity validation. The report schema version SHALL advance additively when simplification metadata, guided simplification metadata, cleanup forecast metadata, or AI IDE handoff metadata is emitted. + +#### Scenario: Finding carries signal trace evidence + +- **WHEN** a `ReviewFinding` payload includes `signal_trace` +- **THEN** model validation SHALL accept deterministic signal entries with tool/source name, fired status, optional score/value, evidence references, and explanation +- **AND** legacy finding payloads without `signal_trace` SHALL remain valid + +#### Scenario: Finding carries preserve reasons + +- **WHEN** a `ReviewFinding` payload includes `preserve_reasons` +- **THEN** each reason SHALL come from a closed taxonomy of preserve contexts +- **AND** the finding SHALL NOT be considered safe for automatic cleanup while a preserve reason is present +- **AND** the preserve reason SHALL include enough evidence for a developer or AI agent to explain why cleanup was not applied + +#### Scenario: Finding carries remediation packet + +- **WHEN** a simplify-focused finding includes `remediation_packet` +- **THEN** the packet SHALL include a plain-language issue, recommended action, possible keep reason, safety checks, validation plan, and safe-to-autofix flag +- **AND** the packet MAY include patch forecast references when preview evidence exists +- **AND** AI IDE prompts and skills SHALL treat the JSON packet as authoritative over prompt prose diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/review-run-command/spec.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/review-run-command/spec.md new file mode 100644 index 0000000..4719078 --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/specs/review-run-command/spec.md @@ -0,0 +1,26 @@ +## MODIFIED Requirements + +### Requirement: End-to-End `specfact code review run` in modules repo + +The `specfact-code-review` bundle SHALL provide a fully wired `specfact code review run` command that orchestrates the existing tool runners, supports scoped file selection, emits governed review reports, and provides simplify-specific cleanup forecast and handoff controls. + +#### Scenario: Run command previews simplify fixes without mutating files + +- **WHEN** `specfact code review run --focus simplify --preview-fixes --json --out ` is executed +- **THEN** the command SHALL compute preview evidence for supported safe-mechanical simplification fixers +- **AND** it SHALL write the forecast evidence to the JSON report +- **AND** it SHALL NOT edit tracked source files + +#### Scenario: Run command rejects preview and fix together + +- **WHEN** `specfact code review run --focus simplify --preview-fixes --fix` is executed +- **THEN** the command SHALL fail before review execution with a clear invalid-combination error + +#### Scenario: Run command scopes mutation proof to simplify focus + +- **WHEN** `specfact code review run --with-mutation` is executed without `--focus simplify` +- **THEN** the command SHALL fail before review execution with a clear invalid-combination error + +- **WHEN** `specfact code review run --focus simplify --with-mutation` is executed +- **THEN** the command SHALL run mutation proof only for candidate cleanup findings +- **AND** it SHALL record mutation outcomes in the report without making mutation proof part of the default review path diff --git a/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/tasks.md b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/tasks.md new file mode 100644 index 0000000..987638a --- /dev/null +++ b/openspec/changes/code-review-13-cleanup-forecast-agent-handoff/tasks.md @@ -0,0 +1,44 @@ +## 1. GitHub readiness and OpenSpec setup + +- [x] 1.1 Create OpenSpec change `code-review-13-cleanup-forecast-agent-handoff`. +- [x] 1.2 Create GitHub issue [#297](https://github.com/nold-ai/specfact-cli-modules/issues/297), link it under Feature [#275](https://github.com/nold-ai/specfact-cli-modules/issues/275), and label it with `enhancement`, `codebase`, `openspec`, and `change-proposal`. +- [x] 1.3 Confirm issue project assignment, open/Todo state, parent linkage, blocked-by relationship, source tracking, and absence of implementation concurrency. +- [x] 1.4 Add `openspec/CHANGE_ORDER.md` row as order 06, blocked by [#286](https://github.com/nold-ai/specfact-cli-modules/issues/286). +- [x] 1.5 Validate the OpenSpec change with `openspec validate code-review-13-cleanup-forecast-agent-handoff --strict`. + +## 2. Spec-first failing tests + +- [x] 2.1 Add model tests for `cleanup_forecast`, `signal_trace`, `preserve_reasons`, and `remediation_packet` compatibility. +- [x] 2.2 Add forecast tests for reviewed LOC, estimated deletion ranges, guidance-kind totals, and AI-bloat index weights. +- [x] 2.3 Add preserve-detection tests for icontract, public API exports, Protocol/ABC members, Typer/Click callbacks, compatibility shims, explicit markers, and mutation load-bearing evidence. +- [x] 2.4 Add CLI tests for `--preview-fixes`, `--with-mutation`, and invalid combinations with non-simplify focus. +- [x] 2.5 Add command-contract and docs parity tests for new flags and report fields. +- [x] 2.6 Record failing-before evidence in `TDD_EVIDENCE.md`. + +## 3. Review model and forecast implementation + +- [x] 3.1 Extend `ReviewReport` with additive `cleanup_forecast` and schema version derivation. +- [x] 3.2 Extend `ReviewFinding` with additive evidence and handoff fields. +- [x] 3.3 Compute reviewed LOC and forecast metrics from the resolved review file set. +- [x] 3.4 Keep scoring and merge-quality verdict behavior unchanged outside simplify-specific enforcement. + +## 4. Preview, preserve, and mutation proof + +- [x] 4.1 Implement non-mutating patch forecast support for existing safe-mechanical simplification fixers. +- [x] 4.2 Implement preserve-reason detection before automatic cleanup eligibility is calculated. +- [x] 4.3 Add opt-in mutation proof scaffolding for simplify candidates, treating tool absence or timeout as inconclusive. +- [x] 4.4 Ensure `--fix` still mutates only deterministic safe-mechanical findings and records action evidence. + +## 5. AI IDE handoff and docs + +- [x] 5.1 Emit remediation packets suitable for Claude, Codex, Cursor, Copilot, or headless agents. +- [x] 5.2 Update `--instructions` and packaged skill guidance to prioritize cleanup forecast and remediation packets. +- [x] 5.3 Update modules docs and AI bloat quickstart for the new JSON-first cleanup workflow. +- [x] 5.4 Coordinate with the paired core docs change before final wording is published. + +## 6. Packaging, signatures, and verification + +- [x] 6.1 Bump affected module versions when packaged resources change. +- [x] 6.2 Refresh registry metadata and module manifest integrity/signatures. +- [x] 6.3 Re-run targeted tests and record passing evidence in `TDD_EVIDENCE.md`. +- [x] 6.4 Run required gates for touched scope: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run check-bundle-imports`, `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump`, `hatch run contract-test`, relevant `hatch run smart-test`, relevant `hatch run test`, and `hatch run specfact code review run --bug-hunt --json --out .specfact/code-review.json --scope changed`. diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index a4f2d38..6ba44a9 100644 --- a/packages/specfact-code-review/module-package.yaml +++ b/packages/specfact-code-review/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-code-review -version: 0.47.27 +version: 0.47.34 commands: - code tier: official @@ -23,5 +23,5 @@ description: Official SpecFact code review bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:a86d8cfde2035059414370bdadd323dcdcf02bd5104e3b30b252bc350a96cd98 - signature: F/Ld51xKWPkQWWS8c00cs9UTbe/kJJ6chBh08sFuKTlAwpRTydf8//wLVaDuI2jt4jX0CwYaHnBqoTd0ELMLAQ== + checksum: sha256:a82faca89f091d03ebf0692377dd33f830fc11c15e104c0faeccbfffc66aa7f0 + signature: KbRpwF9Kj9/Eqb8AuGaGzjXiKUoKXEfsI49pHPHsgs++6Ps29RIsdLcuSb0ckRZLSyUApYWzuG2einzSbOIvCw== diff --git a/packages/specfact-code-review/src/specfact_code_review/resources/skills/specfact-code-review/SKILL.md b/packages/specfact-code-review/src/specfact_code_review/resources/skills/specfact-code-review/SKILL.md index a642508..d28ec14 100644 --- a/packages/specfact-code-review/src/specfact_code_review/resources/skills/specfact-code-review/SKILL.md +++ b/packages/specfact-code-review/src/specfact_code_review/resources/skills/specfact-code-review/SKILL.md @@ -8,9 +8,10 @@ Updated: 2026-05-22 | Module: nold-ai/specfact-code-review Use this skill as an interactive cleanup coach, not a raw lint executor. When a user says "remove AI bloat", "simplify", "apply clean code", "fix SpecFact review", or similar, run the SpecFact review workflow, explain decisions in the user's language, show exact patch previews, and validate after small changes. ## DO - Treat `specfact code review run --help` as authoritative; use `--instructions` as the fallback AI workflow when prompts/skills are unavailable -- For simplification queues, run `specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json` -- Ask for walkthrough level when interactive: vibe coder, junior developer, senior/pro, or headless agent; auto-adjust if obvious -- For vibe coders, present each finding as a decision card: plain-language issue, why it might need to stay, exact patch preview, validation plan, and recommended choice +- For simplification queues, run `specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review-simplify.json` +- Inspect `cleanup_forecast` first, then treat each finding's `remediation_packet` as the portable AI IDE contract +- Preserve anything with `preserve_reasons`; those reasons block automatic cleanup even when a shorter patch exists +- Ask for walkthrough level when interactive; for vibe coders, present each finding as a decision card with issue, keep reason, patch preview, validation plan, and recommendation - Interpret `guidance_kind`: `safe_mechanical` may apply after local safety checks, `needs_tests` requires tests first, `design_judgment` needs human choice with intent evidence, `preserve` means keep and log `preserve_reason` - For `design_judgment`, inspect API, callback, framework hook, adapter, public symbol, CLI boundary, compatibility shim, and readability intent; if intent is unclear, default to keep or skip - Log each simplification action as recommended, applied, kept, skipped, failed, with evidence of improvement or preserved contract @@ -22,12 +23,10 @@ Use this skill as an interactive cleanup coach, not a raw lint executor. When a - Delete unused private helpers and speculative abstractions quickly (YAGNI) - Extract repeated function shapes once the second copy appears (DRY) - Split persistence and transport concerns instead of mixing `repository.*` with `http_client.*` (SOLID) -- Add @require/@ensure (icontract) + @beartype to all new public APIs -- Run hatch run contract-test-contracts before any commit -- Write the test file BEFORE the feature file (TDD-first) +- Add @require/@ensure (icontract) + @beartype to all new public APIs; write tests before feature code ## DON'T - Don't copy prompt templates into AI IDEs when this installed skill can carry the reusable workflow guidance -- Don't treat simplification findings as AI-authorship proof or apply batch rewrites without explicit user approval +- Don't treat simplification findings as AI-authorship proof, guaranteed LOC removal, or permission for batch rewrites without explicit approval - Don't ask non-expert users to infer code intent from a raw warning; provide the evidence and safest recommendation - Don't apply `design_judgment` findings just because the patch looks shorter - Don't enable known noisy findings unless you explicitly want strict/full review output diff --git a/packages/specfact-code-review/src/specfact_code_review/review/commands.py b/packages/specfact-code-review/src/specfact_code_review/review/commands.py index c3cc721..8440ba9 100644 --- a/packages/specfact-code-review/src/specfact_code_review/review/commands.py +++ b/packages/specfact-code-review/src/specfact_code_review/review/commands.py @@ -31,26 +31,31 @@ Use this when the user asks to remove AI bloat, simplify code, apply clean-code patterns, reduce boilerplate, or act on SpecFact review findings. 1. Generate evidence first: - specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json + specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review.json + + Keep the canonical .specfact/code-review.json path unless every downstream consumer has been updated to read a custom simplify report path. If the worktree is clean on a PR branch and --scope changed finds no files, review the branch-delta Python files as explicit positional files and omit --scope. Find them with the PR base ref, for example: git diff --name-only ...HEAD -- '*.py' '*.pyi' -2. Treat guidance_kind as the action contract: +2. Inspect cleanup_forecast before editing. Use reviewed_loc, estimated_deletion_lines, ai_bloat_index, and by_guidance_kind to decide where cleanup will actually pay off. These estimates are cleanup forecasts, not guarantees. + +3. Sort findings by guidance_kind before editing, then treat guidance_kind and remediation_packet as the action contract: - safe_mechanical: apply only after local safety checks pass. - needs_tests: add or identify targeted tests before changing behavior. - design_judgment: inspect intent evidence and ask before editing. - preserve: keep by default and record preserve_reason. Findings without guidance_kind are unguided advisories: summarize them separately, do not auto-apply them, and ask before using them as refactor input. + Prefer each finding's remediation_packet over prose instructions because the JSON report is the portable AI IDE handoff contract. -3. For vibe-coder or junior users, present each finding as a decision card: +4. For vibe-coder or junior users, present each finding as a decision card: Finding, plain-language issue, why it might need to stay, exact patch preview or small before/after proposal, validation plan, recommended choice. -4. For design_judgment findings, check API, callback, framework hook, adapter, public symbol, CLI boundary, compatibility shim, and readability intent. If intent is unclear, default to keep or skip. +5. For design_judgment findings, check API, callback, framework hook, adapter, public symbol, CLI boundary, compatibility shim, and readability intent. If intent is unclear, default to keep or skip. -5. Apply one file at a time. After each accepted file or very small batch, run targeted tests or rerun: - specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json +6. Apply one file at a time. After each accepted file or very small batch, run targeted tests or rerun: + specfact code review run --scope changed --focus simplify --json --out .specfact/code-review.json -6. Log every action as recommended, applied, kept, skipped, or failed with evidence. Never batch-apply design_judgment findings just because the patch is shorter. +7. Log every action as recommended, applied, kept, skipped, or failed with evidence. Never batch-apply design_judgment findings just because the patch is shorter. Never treat ai_bloat findings as proof of AI authorship; they are cleanup signals only, not proof of AI authorship. """ @@ -142,6 +147,16 @@ def run( score_only: bool = typer.Option(False, "--score-only"), no_tests: bool = typer.Option(False, "--no-tests"), fix: bool = typer.Option(False, "--fix"), + preview_fixes: bool = typer.Option( + False, + "--preview-fixes", + help="Preview supported safe-mechanical simplify fixes without editing tracked files.", + ), + with_mutation: bool = typer.Option( + False, + "--with-mutation", + help="Record opt-in mutation proof evidence for simplify cleanup candidates.", + ), interactive: bool = typer.Option(False, "--interactive"), instructions: bool = typer.Option( False, @@ -182,6 +197,8 @@ def run( score_only=score_only, no_tests=no_tests, fix=fix, + preview_fixes=preview_fixes, + with_mutation=with_mutation, ) except (ValueError, ViolationError) as exc: raise typer.BadParameter(_friendly_run_command_error(exc)) from exc diff --git a/packages/specfact-code-review/src/specfact_code_review/rules/updater.py b/packages/specfact-code-review/src/specfact_code_review/rules/updater.py index 38c36b4..e7ee03b 100644 --- a/packages/specfact-code-review/src/specfact_code_review/rules/updater.py +++ b/packages/specfact-code-review/src/specfact_code_review/rules/updater.py @@ -32,8 +32,9 @@ "- Use this skill when asked to run, interpret, or act on SpecFact code review in Codex CLI or another AI IDE", "- Treat `specfact code review run --help` as authoritative; self-heal stale options by checking help " "before changing workflow", - "- For simplification queues, run `specfact code review run --scope changed --focus simplify --json " - "--out .specfact/code-review-simplify.json`", + "- For simplification queues, run `specfact code review run --scope changed --focus simplify " + "--preview-fixes --json --out .specfact/code-review-simplify.json`", + "- Inspect `cleanup_forecast`, then follow each finding's `remediation_packet`; preserve reasons block autofix", "- Ask for walkthrough level when interactive: vibe coder, junior developer, senior/pro, or headless agent; " "auto-adjust if obvious", "- Interpret `guidance_kind`: `safe_mechanical` may apply after local safety checks, `needs_tests` requires " @@ -61,6 +62,7 @@ "- Don't copy prompt templates into AI IDEs when this installed skill can carry the reusable workflow guidance", "- Don't treat simplification findings as AI-authorship proof or apply batch rewrites without explicit " "user approval", + "- Don't treat cleanup forecasts as guaranteed LOC removal; validate previews and tests first", "- Don't enable known noisy findings unless you explicitly want strict/full review output", "- Don't use bare except: or except Exception: pass", "- Don't add # noqa / # type: ignore without inline justification", diff --git a/packages/specfact-code-review/src/specfact_code_review/run/cleanup_evidence.py b/packages/specfact-code-review/src/specfact_code_review/run/cleanup_evidence.py new file mode 100644 index 0000000..7b3b8fd --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/run/cleanup_evidence.py @@ -0,0 +1,186 @@ +"""Cleanup preview and mutation evidence helpers for review runs.""" + +from __future__ import annotations + +import shutil +import tempfile +from collections.abc import Callable +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + +from specfact_code_review.run.findings import ( + EvidenceRef, + RemediationPacket, + ReviewFinding, + ReviewReport, + SignalTraceEntry, +) +from specfact_code_review.run.forecast import build_cleanup_forecast + + +ApplySimplificationFixes = Callable[[ReviewReport], list[ReviewFinding]] + + +@beartype +@require(lambda files: isinstance(files, list), "files must be a list") +@ensure(lambda result: isinstance(result, ReviewReport), "result must be a review report") +def with_previewed_simplification_findings( + report: ReviewReport, + files: list[Path], + apply_simplification_fixes: ApplySimplificationFixes, +) -> ReviewReport: + previewed_findings = _preview_simplification_fixes(report, apply_simplification_fixes) + if not previewed_findings: + return with_refreshed_cleanup_forecast(report, files) + replacements = {(finding.file, finding.line, finding.rule): finding for finding in previewed_findings} + findings = [replacements.get((finding.file, finding.line, finding.rule), finding) for finding in report.findings] + return with_refreshed_cleanup_forecast(report.model_copy(update={"findings": findings}), files) + + +@beartype +@require(lambda files: isinstance(files, list), "files must be a list") +@ensure(lambda result: isinstance(result, ReviewReport), "result must be a review report") +def with_mutation_evidence(report: ReviewReport, files: list[Path]) -> ReviewReport: + findings = [_with_mutation_signal(finding) for finding in report.findings] + return with_refreshed_cleanup_forecast(report.model_copy(update={"findings": findings}), files) + + +@beartype +@require(lambda files: isinstance(files, list), "files must be a list") +@ensure(lambda result: isinstance(result, ReviewReport), "result must be a review report") +def with_refreshed_cleanup_forecast(report: ReviewReport, files: list[Path]) -> ReviewReport: + return report.model_copy( + update={ + "cleanup_forecast": build_cleanup_forecast(report.findings, files), + "schema_version": "1.3", + } + ) + + +def _preview_simplification_fixes( + report: ReviewReport, + apply_simplification_fixes: ApplySimplificationFixes, +) -> list[ReviewFinding]: + previewed: list[ReviewFinding] = [] + for finding in _fixable_simplifications_by_stable_line_order(report.findings): + preview = _preview_single_simplification(finding, apply_simplification_fixes) + if preview is not None: + previewed.append(preview) + return previewed + + +def _preview_single_simplification( + finding: ReviewFinding, + apply_simplification_fixes: ApplySimplificationFixes, +) -> ReviewFinding | None: + source_path = Path(finding.file) + try: + before = source_path.read_text(encoding="utf-8") + except OSError: + return None + with tempfile.TemporaryDirectory(prefix="specfact-review-preview-") as tmpdir: + preview_path = Path(tmpdir) / source_path.name + if not _write_preview_source(preview_path, before): + return None + preview_finding = finding.model_copy(update={"file": str(preview_path)}) + if not apply_simplification_fixes(_report_for_preview(preview_finding)): + return None + try: + after = preview_path.read_text(encoding="utf-8") + except OSError: + return None + added, removed = _line_delta(before, after) + return _with_patch_preview(finding, added=added, removed=removed) + + +def _fixable_simplifications_by_stable_line_order(findings: list[ReviewFinding]) -> list[ReviewFinding]: + return sorted( + [finding for finding in findings if finding.is_safe_mechanical_simplification()], + key=lambda finding: (finding.file, -finding.line, finding.rule), + ) + + +def _write_preview_source(path: Path, source: str) -> bool: + try: + path.write_text(source, encoding="utf-8") + except OSError: + return False + return True + + +def _report_for_preview(finding: ReviewFinding) -> ReviewReport: + return ReviewReport( + run_id="preview", + score=85, + findings=[finding], + summary="Preview simplification fix.", + ) + + +def _line_delta(before: str, after: str) -> tuple[int, int]: + before_count = len(before.splitlines()) + after_count = len(after.splitlines()) + return max(0, after_count - before_count), max(0, before_count - after_count) + + +def _with_patch_preview(finding: ReviewFinding, *, added: int, removed: int) -> ReviewFinding: + patch_ref = f"preview:{finding.file}:{finding.line}" + signal_trace = [ + *(finding.signal_trace or []), + SignalTraceEntry( + tool="specfact", + source="preview_fixes", + fired=True, + score=1.0, + value=f"added={added}; removed={removed}; net={added - removed}", + evidence_refs=[EvidenceRef(path=finding.file, start_line=finding.line)], + explanation="Non-mutating preview computed a safe-mechanical patch forecast.", + ), + ] + packet = _packet_with_patch_ref(finding, patch_ref) + return ReviewFinding(**{**finding.model_dump(), "signal_trace": signal_trace, "remediation_packet": packet}) + + +def _packet_with_patch_ref(finding: ReviewFinding, patch_ref: str) -> RemediationPacket: + if finding.remediation_packet is None: + return RemediationPacket( + issue=finding.message, + recommended_action=finding.recommended_action or "inspect", + possible_keep_reason=finding.preserve_reason, + safety_checks=finding.safety_checks or ["inspect the surrounding behavior before editing"], + validation_plan=["run targeted tests", "rerun simplify review"], + safe_to_autofix=finding.is_safe_mechanical_simplification() and finding.fixable, + patch_forecast_refs=[patch_ref], + ) + refs = list(finding.remediation_packet.patch_forecast_refs or []) + if patch_ref not in refs: + refs.append(patch_ref) + return finding.remediation_packet.model_copy(update={"patch_forecast_refs": refs}) + + +def _with_mutation_signal(finding: ReviewFinding) -> ReviewFinding: + if not finding.is_safe_mechanical_simplification(): + return finding + value = "inconclusive: mutation scaffolding only" + explanation = "Mutation tooling was available, but candidate-scoped execution is not configured yet." + if not _mutation_tool_available(): + value = "inconclusive: mutmut unavailable" + explanation = "Mutation proof was requested, but mutmut is not installed." + signal_trace = [ + *(finding.signal_trace or []), + SignalTraceEntry( + tool="mutmut", + source="mutation", + fired=False, + value=value, + evidence_refs=[EvidenceRef(path=finding.file, start_line=finding.line)], + explanation=explanation, + ), + ] + return ReviewFinding(**{**finding.model_dump(), "signal_trace": signal_trace}) + + +def _mutation_tool_available() -> bool: + return shutil.which("mutmut") is not None diff --git a/packages/specfact-code-review/src/specfact_code_review/run/commands.py b/packages/specfact-code-review/src/specfact_code_review/run/commands.py index a2b9805..02a5e8c 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/commands.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/commands.py @@ -16,6 +16,10 @@ from rich.console import Console from rich.table import Table +from specfact_code_review.run.cleanup_evidence import ( + with_mutation_evidence, + with_previewed_simplification_findings, +) from specfact_code_review.run.findings import EvidenceRef, ReviewFinding, ReviewReport from specfact_code_review.run.runner import ReviewFocus, run_review @@ -63,6 +67,8 @@ class ReviewRunRequest: score_only: bool = False no_tests: bool = False fix: bool = False + preview_fixes: bool = False + with_mutation: bool = False bug_hunt: bool = False review_mode: ReviewRunMode = "enforce" review_level: ReviewLevelFilter | None = None @@ -75,6 +81,8 @@ class _ReviewLoopFlags: no_tests: bool include_noise: bool fix: bool + preview_fixes: bool + with_mutation: bool progress_callback: Callable[[str], None] | None bug_hunt: bool review_mode: ReviewRunMode @@ -321,6 +329,17 @@ def _with_applied_simplification_findings(report: ReviewReport, applied_findings return ReviewReport(**data) +def _with_simplify_enforce_verdict(report: ReviewReport, flags: _ReviewLoopFlags) -> ReviewReport: + if ( + flags.review_focus == "simplify" + and flags.review_mode == "enforce" + and report.simplification_summary is not None + and report.simplification_summary.blocking_simplification_count > 0 + ): + return report.model_copy(update={"overall_verdict": "FAIL", "ci_exit_code": 1}) + return report + + def _fixable_simplifications_by_stable_line_order(findings: list[ReviewFinding]) -> list[ReviewFinding]: indexed_findings = [ (index, finding) @@ -636,6 +655,8 @@ def _emit_progress(description: str) -> None: no_tests=flags.no_tests, include_noise=flags.include_noise, fix=flags.fix, + preview_fixes=flags.preview_fixes, + with_mutation=flags.with_mutation, progress_callback=_emit_progress, bug_hunt=flags.bug_hunt, review_mode=flags.review_mode, @@ -654,6 +675,8 @@ def _run_review_with_status( no_tests=flags.no_tests, include_noise=flags.include_noise, fix=False, + preview_fixes=False, + with_mutation=False, progress_callback=status.update, bug_hunt=flags.bug_hunt, review_mode=flags.review_mode, @@ -671,7 +694,13 @@ def _run_review_with_status( status.update("Re-running review after autofixes...") report = _run_review_once(files, base) report = _with_applied_simplification_findings(report, applied_simplification_findings) - return report + if flags.preview_fixes: + status.update("Previewing safe mechanical simplification fixes...") + report = with_previewed_simplification_findings(report, files, _apply_simplification_fixes) + if flags.with_mutation: + status.update("Recording mutation proof evidence...") + report = with_mutation_evidence(report, files) + return _with_simplify_enforce_verdict(report, flags) def _run_review_once(files: list[Path], flags: _ReviewLoopFlags) -> ReviewReport: @@ -713,7 +742,11 @@ def _run_review_once(files: list[Path], flags: _ReviewLoopFlags) -> ReviewReport focus=flags.review_focus, ) report = _with_applied_simplification_findings(report, applied_simplification_findings) - return report + if flags.preview_fixes: + report = with_previewed_simplification_findings(report, files, _apply_simplification_fixes) + if flags.with_mutation: + report = with_mutation_evidence(report, files) + return _with_simplify_enforce_verdict(report, flags) def _as_auto_scope(value: object) -> AutoScope | None: @@ -830,6 +863,8 @@ def _get_optional_param(name: str, validator: Callable[[object], object], defaul score_only=_get_bool_param("score_only"), no_tests=_get_bool_param("no_tests"), fix=_get_bool_param("fix"), + preview_fixes=_get_bool_param("preview_fixes"), + with_mutation=_get_bool_param("with_mutation"), bug_hunt=_get_bool_param("bug_hunt"), review_mode=_as_review_mode(request_kwargs.pop("review_mode", "enforce")), review_level=_as_review_level(request_kwargs.pop("review_level", None)), @@ -863,6 +898,12 @@ def _validate_review_request(request: ReviewRunRequest) -> None: raise InvalidOptionCombinationError("Use either --json or --score-only, not both.") if not request.json_output and request.out is not None: raise MissingOutForJsonError("Use --out together with --json.") + if request.preview_fixes and request.fix: + raise InvalidOptionCombinationError("Cannot combine --preview-fixes with --fix.") + if request.preview_fixes and request.review_focus != "simplify": + raise InvalidOptionCombinationError("Use --preview-fixes only with --focus simplify.") + if request.with_mutation and request.review_focus != "simplify": + raise InvalidOptionCombinationError("Use --with-mutation only with --focus simplify.") def _normalize_review_request(request: ReviewRunRequest) -> ReviewRunRequest: @@ -879,6 +920,8 @@ def _normalize_review_request(request: ReviewRunRequest) -> ReviewRunRequest: score_only=request.score_only, no_tests=request.no_tests, fix=request.fix, + preview_fixes=request.preview_fixes, + with_mutation=request.with_mutation, bug_hunt=request.bug_hunt, review_mode=request.review_mode, review_level=request.review_level, @@ -931,6 +974,8 @@ def run_command( no_tests=request.no_tests, include_noise=request.include_noise, fix=request.fix, + preview_fixes=request.preview_fixes, + with_mutation=request.with_mutation, progress_callback=None, bug_hunt=request.bug_hunt, review_mode=request.review_mode, diff --git a/packages/specfact-code-review/src/specfact_code_review/run/findings.py b/packages/specfact-code-review/src/specfact_code_review/run/findings.py index 50a00d1..3bad107 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/findings.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/findings.py @@ -29,6 +29,16 @@ VALID_SEVERITIES = ("error", "warning", "info") GUIDANCE_KINDS = ("safe_mechanical", "needs_tests", "design_judgment", "preserve") ACTION_STATUSES = ("recommended", "applied", "kept", "skipped", "failed") +PRESERVE_REASONS = ( + "contract_lambda", + "protocol_member", + "public_api", + "compat_shim", + "cli_callback", + "domain_wrapper", + "spec_linked", + "load_bearing", +) PASS = "PASS" PASS_WITH_ADVISORY = "PASS_WITH_ADVISORY" FAIL = "FAIL" @@ -67,6 +77,131 @@ def _validate_invariants(self) -> EvidenceRef: return self +class SignalTraceEntry(BaseModel): + """Deterministic source signal that contributed to a cleanup finding.""" + + tool: str = Field(..., description="Tool or analysis layer that produced the signal.") + source: str = Field(..., description="Stable signal or rule source identifier.") + fired: bool = Field(..., description="Whether the signal fired for this finding.") + score: float | None = Field(default=None, description="Optional normalized signal score.") + value: str | int | float | bool | None = Field(default=None, description="Optional raw signal value.") + evidence_refs: list[EvidenceRef] | None = Field(default=None, description="Evidence backing the signal.") + explanation: str = Field(..., description="Short explanation of the signal.") + + @field_validator("tool", "source", "explanation") + @classmethod + def _validate_non_empty_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("value must not be empty") + return value + + +class PreserveReasonEvidence(BaseModel): + """Closed-taxonomy reason that prevents automatic cleanup.""" + + reason: Literal[ + "contract_lambda", + "protocol_member", + "public_api", + "compat_shim", + "cli_callback", + "domain_wrapper", + "spec_linked", + "load_bearing", + ] = Field(..., description="Closed preserve-reason taxonomy value.") + evidence_refs: list[EvidenceRef] = Field(..., min_length=1, description="Evidence for the preserve reason.") + explanation: str = Field(..., description="Why this context must be preserved.") + + @field_validator("explanation") + @classmethod + def _validate_non_empty_text(cls, value: str) -> str: + if not value.strip(): + raise ValueError("value must not be empty") + return value + + +class RemediationPacket(BaseModel): + """Portable AI IDE handoff contract for one cleanup finding.""" + + issue: str = Field(..., description="Plain-language issue description.") + recommended_action: str = Field(..., description="Recommended cleanup action.") + possible_keep_reason: str | None = Field(default=None, description="Why the code might need to stay.") + safety_checks: list[str] = Field(..., min_length=1, description="Checks required before editing.") + validation_plan: list[str] = Field(..., min_length=1, description="Validation steps after editing.") + safe_to_autofix: bool = Field(..., description="Whether an agent may apply this automatically.") + patch_forecast_refs: list[str] | None = Field(default=None, description="Patch preview references when present.") + + @field_validator("issue", "recommended_action", "possible_keep_reason") + @classmethod + def _validate_non_empty_text(cls, value: str | None) -> str | None: + if value is not None and not value.strip(): + raise ValueError("value must not be empty") + return value + + @field_validator("safety_checks", "validation_plan", "patch_forecast_refs") + @classmethod + def _validate_non_empty_entries(cls, value: list[str] | None) -> list[str] | None: + if value is not None and any(not item.strip() for item in value): + raise ValueError("entries must not be empty") + return value + + +class ReviewedLoc(BaseModel): + """Reviewed Python LOC split by production and tests.""" + + production: int = Field(..., ge=0) + tests: int = Field(..., ge=0) + total: int = Field(..., ge=0) + + @model_validator(mode="after") + def _validate_total_matches_parts(self) -> ReviewedLoc: + if self.total != self.production + self.tests: + raise ValueError("reviewed_loc.total must equal production + tests") + return self + + +class DeletionEstimate(BaseModel): + """Non-binding deletion-line range.""" + + low: int = Field(..., ge=0) + expected: int = Field(..., ge=0) + high: int = Field(..., ge=0) + + @model_validator(mode="after") + def _validate_ordering(self) -> DeletionEstimate: + if not self.low <= self.expected <= self.high: + raise ValueError("estimated_deletion_lines must satisfy low <= expected <= high") + return self + + +class AiBloatIndex(BaseModel): + """Normalized cleanup metrics per KLOC.""" + + findings_per_kloc: float = Field(..., ge=0.0) + weighted_bloat_points_per_kloc: float = Field(..., ge=0.0) + cleanup_yield_loc_per_kloc: float = Field(..., ge=0.0) + + +class GuidanceKindForecast(BaseModel): + """Forecast aggregate for one guidance kind.""" + + count: int = Field(..., ge=0) + estimated_deletion_lines: int = Field(..., ge=0) + weight: float = Field(default=0.0, ge=0.0) + + +class CleanupForecast(BaseModel): + """Aggregate cleanup impact forecast for simplify-focused reviews.""" + + reviewed_loc: ReviewedLoc + estimated_deletion_lines: DeletionEstimate + ai_bloat_index: AiBloatIndex + by_guidance_kind: dict[str, GuidanceKindForecast] = Field(default_factory=dict) + by_action_status: dict[str, int] = Field(default_factory=dict) + preview_evidence_count: int = Field(default=0, ge=0) + mutation_evidence_count: int = Field(default=0, ge=0) + + class ReviewFinding(BaseModel): """Structured representation of a code-review finding.""" @@ -156,6 +291,18 @@ class ReviewFinding(BaseModel): before_ref: EvidenceRef | None = Field(default=None, description="Evidence reference before an applied action.") after_ref: EvidenceRef | None = Field(default=None, description="Evidence reference after an applied action.") improvement: str | None = Field(default=None, description="Evidence-backed improvement summary.") + signal_trace: list[SignalTraceEntry] | None = Field( + default=None, + description="Optional deterministic signal trace for cleanup findings.", + ) + preserve_reasons: list[PreserveReasonEvidence] | None = Field( + default=None, + description="Optional closed-taxonomy preserve reasons that block automatic cleanup.", + ) + remediation_packet: RemediationPacket | None = Field( + default=None, + description="Optional portable cleanup handoff packet for AI IDEs.", + ) @field_validator( "tool", @@ -186,6 +333,13 @@ def _validate_safety_checks(cls, value: list[str] | None) -> list[str] | None: raise ValueError("safety_checks entries must not be empty") return value + @field_validator("signal_trace", "preserve_reasons") + @classmethod + def _validate_non_empty_evidence_list(cls, value: list[object] | None) -> list[object] | None: + if value is not None and not value: + raise ValueError("evidence lists must not be empty when provided") + return value + @model_validator(mode="after") def _validate_guided_metadata(self) -> ReviewFinding: guided_fields = ( @@ -238,6 +392,9 @@ def has_simplification_metadata(self) -> bool: self.before_ref, self.after_ref, self.improvement, + self.signal_trace, + self.preserve_reasons, + self.remediation_packet, ) ) @@ -251,7 +408,17 @@ def has_guided_simplification_metadata(self) -> bool: @ensure(lambda result: isinstance(result, bool)) def is_safe_mechanical_simplification(self) -> bool: """Return whether the finding is an unresolved safe mechanical simplification.""" - return self.guidance_kind == "safe_mechanical" and self.action_status in {None, "recommended", "failed"} + return ( + self.guidance_kind == "safe_mechanical" + and self.action_status in {None, "recommended", "failed"} + and not self.preserve_reasons + ) + + @beartype + @ensure(lambda result: isinstance(result, bool)) + def has_cleanup_handoff_metadata(self) -> bool: + """Return whether this finding carries cleanup forecast or handoff metadata.""" + return self.signal_trace is not None or self.preserve_reasons is not None or self.remediation_packet is not None @beartype @ensure(lambda result: isinstance(result, bool)) @@ -302,6 +469,10 @@ class ReviewReport(BaseModel): default=None, description="Aggregate simplification guidance and action-status evidence.", ) + cleanup_forecast: CleanupForecast | None = Field( + default=None, + description="Aggregate cleanup forecast for simplify-focused review runs.", + ) house_rules_updates: list[str] = Field(default_factory=list, description="Suggested house-rules updates.") @field_validator("schema_version", "run_id", "summary") @@ -322,7 +493,11 @@ def _normalize_timestamp(cls, value: datetime) -> datetime: def _derive_governance_fields(self) -> ReviewReport: if self.simplification_summary is None: self.simplification_summary = _build_simplification_summary(self.findings) - if self.simplification_summary is not None: + if self.cleanup_forecast is not None or any( + finding.has_cleanup_handoff_metadata() for finding in self.findings + ): + self.schema_version = "1.3" + elif self.simplification_summary is not None: self.schema_version = "1.2" elif any(finding.has_simplification_metadata() for finding in self.findings): self.schema_version = "1.1" diff --git a/packages/specfact-code-review/src/specfact_code_review/run/forecast.py b/packages/specfact-code-review/src/specfact_code_review/run/forecast.py new file mode 100644 index 0000000..fe239b0 --- /dev/null +++ b/packages/specfact-code-review/src/specfact_code_review/run/forecast.py @@ -0,0 +1,138 @@ +"""Cleanup forecast metrics for simplify-focused review reports.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + +from specfact_code_review.run.findings import ( + AiBloatIndex, + CleanupForecast, + DeletionEstimate, + GuidanceKindForecast, + ReviewedLoc, + ReviewFinding, +) + + +_CLEANUP_FORECAST_WEIGHTS = { + "safe_mechanical": 1.0, + "needs_tests": 0.6, + "design_judgment": 0.25, + "preserve": 0.0, +} + + +@dataclass +class _CleanupForecastTotals: + by_guidance_kind: dict[str, GuidanceKindForecast] + by_action_status: dict[str, int] + low: int = 0 + expected: float = 0.0 + high: int = 0 + weighted_points: float = 0.0 + + +@beartype +@require(lambda files: isinstance(files, list), "files must be a list") +@require(lambda findings: isinstance(findings, list), "findings must be a list") +@ensure(lambda result: isinstance(result, CleanupForecast), "result must be a cleanup forecast") +def build_cleanup_forecast(findings: list[ReviewFinding], files: list[Path]) -> CleanupForecast: + reviewed_loc = _reviewed_loc_for_files(files) + guided = [finding for finding in findings if finding.guidance_kind is not None] + totals = _cleanup_forecast_totals(guided) + kloc = max(reviewed_loc.total / 1000.0, 0.001) + expected_lines = round(totals.expected) + return CleanupForecast( + reviewed_loc=reviewed_loc, + estimated_deletion_lines=DeletionEstimate(low=totals.low, expected=expected_lines, high=totals.high), + ai_bloat_index=AiBloatIndex( + findings_per_kloc=round(len(guided) / kloc, 3), + weighted_bloat_points_per_kloc=round(totals.weighted_points / kloc, 3), + cleanup_yield_loc_per_kloc=round(expected_lines / kloc, 3), + ), + by_guidance_kind=totals.by_guidance_kind, + by_action_status=totals.by_action_status, + preview_evidence_count=sum( + 1 + for finding in guided + if finding.remediation_packet is not None and finding.remediation_packet.patch_forecast_refs + ), + mutation_evidence_count=sum( + 1 + for finding in guided + if finding.signal_trace is not None and any(signal.source == "mutation" for signal in finding.signal_trace) + ), + ) + + +def _reviewed_loc_for_files(files: list[Path]) -> ReviewedLoc: + production = 0 + tests = 0 + for file_path in files: + if file_path.suffix not in {".py", ".pyi"}: + continue + try: + loc = _count_python_loc(file_path) + except (OSError, UnicodeDecodeError): + continue + if _is_test_path(file_path): + tests += loc + else: + production += loc + return ReviewedLoc(production=production, tests=tests, total=production + tests) + + +def _is_test_path(file_path: Path) -> bool: + name = file_path.name.lower() + stem = file_path.stem.lower() + if name.startswith("test_") or stem.endswith("_test") or name.endswith((".spec.py", ".spec.pyi")): + return True + for part in file_path.parts[:-1]: + normalized = part.lower().replace("-", "_") + if normalized in {"test", "tests"}: + return True + if normalized.startswith(("test_", "tests_")) or normalized.endswith(("_test", "_tests")): + return True + return False + + +def _count_python_loc(file_path: Path) -> int: + return sum( + 1 + for line in file_path.read_text(encoding="utf-8").splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + + +def _cleanup_forecast_totals(guided: list[ReviewFinding]) -> _CleanupForecastTotals: + totals = _CleanupForecastTotals(by_guidance_kind={}, by_action_status={}) + for finding in guided: + _add_cleanup_forecast_finding(totals, finding) + return totals + + +def _add_cleanup_forecast_finding(totals: _CleanupForecastTotals, finding: ReviewFinding) -> None: + guidance_kind = finding.guidance_kind or "design_judgment" + deletion_lines = finding.estimated_deletion_lines or 0 + weight = _CLEANUP_FORECAST_WEIGHTS.get(guidance_kind, 0.0) + current = totals.by_guidance_kind.get( + guidance_kind, + GuidanceKindForecast(count=0, estimated_deletion_lines=0, weight=weight), + ) + totals.by_guidance_kind[guidance_kind] = GuidanceKindForecast( + count=current.count + 1, + estimated_deletion_lines=current.estimated_deletion_lines + deletion_lines, + weight=weight, + ) + if finding.action_status is not None: + totals.by_action_status[finding.action_status] = totals.by_action_status.get(finding.action_status, 0) + 1 + if guidance_kind == "safe_mechanical": + totals.low += deletion_lines + if guidance_kind != "preserve": + totals.high += deletion_lines + totals.expected += deletion_lines * weight + totals.weighted_points += weight diff --git a/packages/specfact-code-review/src/specfact_code_review/run/runner.py b/packages/specfact-code-review/src/specfact_code_review/run/runner.py index 0f72cbf..1ebfdec 100644 --- a/packages/specfact-code-review/src/specfact_code_review/run/runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/run/runner.py @@ -12,7 +12,7 @@ from collections.abc import Callable, Iterable from contextlib import suppress from dataclasses import dataclass -from functools import partial +from functools import lru_cache, partial from pathlib import Path from typing import Literal, cast from uuid import uuid4 @@ -21,7 +21,16 @@ from icontract import ensure, require from specfact_code_review._review_utils import normalize_path_variants, tool_error -from specfact_code_review.run.findings import ReviewFinding, ReviewReport +from specfact_code_review.run.findings import ( + CleanupForecast, + EvidenceRef, + PreserveReasonEvidence, + RemediationPacket, + ReviewFinding, + ReviewReport, + SignalTraceEntry, +) +from specfact_code_review.run.forecast import build_cleanup_forecast from specfact_code_review.run.scorer import score_review from specfact_code_review.tools.ai_bloat_runner import run_ai_bloat from specfact_code_review.tools.ast_clean_code_runner import run_ast_clean_code @@ -345,6 +354,305 @@ def _filter_findings_by_focus(findings: list[ReviewFinding], focus: ReviewFocus raise ValueError(f"Unsupported review focus: {focus}") +def _enrich_cleanup_findings(findings: list[ReviewFinding]) -> list[ReviewFinding]: + return [_enriched_cleanup_finding(finding) for finding in findings] + + +def _enriched_cleanup_finding(finding: ReviewFinding) -> ReviewFinding: + if finding.guidance_kind is None: + return finding + preserve_reasons = list(finding.preserve_reasons or []) + preserve_reasons.extend( + reason + for reason in _preserve_reasons_for_finding(finding, load_bearing=False) + if reason not in preserve_reasons + ) + updates: dict[str, object] = { + "signal_trace": _signal_trace_for_finding(finding), + } + if preserve_reasons: + updates.update( + { + "guidance_kind": "preserve", + "recommended_action": "keep", + "estimated_deletion_lines": 0, + "action_status": "kept", + "preserve_reason": "; ".join(reason.explanation for reason in preserve_reasons), + "preserve_reasons": preserve_reasons, + } + ) + candidate = finding.model_copy(update=updates) + return candidate.model_copy(update={"remediation_packet": _remediation_packet_for_finding(candidate)}) + + +def _signal_trace_for_finding(finding: ReviewFinding) -> list[SignalTraceEntry]: + existing = list(finding.signal_trace or []) + if existing: + return existing + return [ + SignalTraceEntry( + tool=finding.tool, + source=finding.rule, + fired=True, + score=1.0 if finding.confidence == "high" else None, + value=finding.canonical_pattern, + evidence_refs=[EvidenceRef(path=finding.file, start_line=finding.line)], + explanation=f"{finding.tool} emitted {finding.rule}.", + ) + ] + + +def _remediation_packet_for_finding(finding: ReviewFinding) -> RemediationPacket: + possible_keep_reason = finding.preserve_reason + if possible_keep_reason is None and finding.guidance_kind in {"design_judgment", "needs_tests"}: + possible_keep_reason = "Keep the current shape if tests, API compatibility, or domain readability need it." + return RemediationPacket( + issue=finding.message, + recommended_action=finding.recommended_action or "inspect", + possible_keep_reason=possible_keep_reason, + safety_checks=finding.safety_checks or ["inspect the surrounding behavior before editing"], + validation_plan=["run targeted tests for the touched file", "rerun specfact code review with --focus simplify"], + safe_to_autofix=finding.is_safe_mechanical_simplification() and finding.fixable, + ) + + +def _preserve_reasons_for_finding(finding: ReviewFinding, *, load_bearing: bool) -> list[PreserveReasonEvidence]: + reasons: list[PreserveReasonEvidence] = [] + evidence_ref = EvidenceRef(path=finding.file, start_line=finding.line) + if load_bearing: + reasons.append( + PreserveReasonEvidence( + reason="load_bearing", + evidence_refs=[evidence_ref], + explanation="Mutation proof indicates this code is load-bearing.", + ) + ) + parsed = _get_parsed_source(finding.file) + if parsed is None: + return reasons + tree, lines = parsed + function_node = _function_containing_line(tree, finding.line) + class_node = _class_containing_line(tree, finding.line) + public_names = _module_all_names(tree) + if function_node is not None: + if _has_contract_decorator(function_node): + reasons.append( + PreserveReasonEvidence( + reason="contract_lambda", + evidence_refs=[evidence_ref], + explanation="Function is protected by an icontract-style contract decorator.", + ) + ) + if function_node.name in public_names: + reasons.append( + PreserveReasonEvidence( + reason="public_api", + evidence_refs=[evidence_ref], + explanation="Function is exported through __all__ and is public API.", + ) + ) + if _has_cli_decorator(function_node): + reasons.append( + PreserveReasonEvidence( + reason="cli_callback", + evidence_refs=[evidence_ref], + explanation="Function is registered as a Typer or Click callback.", + ) + ) + if _has_preserve_marker(lines, function_node.lineno): + reasons.append( + PreserveReasonEvidence( + reason="compat_shim", + evidence_refs=[evidence_ref], + explanation="Function has an explicit specfact preserve compatibility marker.", + ) + ) + if _has_spec_marker(lines, function_node.lineno): + reasons.append( + PreserveReasonEvidence( + reason="spec_linked", + evidence_refs=[evidence_ref], + explanation="Function has an explicit spec requirement marker.", + ) + ) + if class_node is not None and _is_protocol_or_abstract_member(class_node, function_node): + reasons.append( + PreserveReasonEvidence( + reason="protocol_member", + evidence_refs=[evidence_ref], + explanation="Finding is inside an abstract Protocol or ABC member contract.", + ) + ) + return _dedupe_preserve_reasons(reasons) + + +def _get_parsed_source(file_path: str) -> tuple[ast.Module, list[str]] | None: + try: + source = Path(file_path).read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError): + return None + return _parse_source(file_path, source) + + +@lru_cache(maxsize=256) +def _parse_source(file_path: str, source: str) -> tuple[ast.Module, list[str]] | None: + try: + tree = ast.parse(source, filename=file_path) + except SyntaxError: + return None + return tree, source.splitlines() + + +def _dedupe_preserve_reasons(reasons: list[PreserveReasonEvidence]) -> list[PreserveReasonEvidence]: + deduped: list[PreserveReasonEvidence] = [] + seen: set[str] = set() + for reason in reasons: + if reason.reason in seen: + continue + seen.add(reason.reason) + deduped.append(reason) + return deduped + + +def _function_containing_line(tree: ast.AST, line: int) -> ast.FunctionDef | ast.AsyncFunctionDef | None: + functions = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef) + and node.lineno <= line <= (node.end_lineno or node.lineno) + ] + return max(functions, key=lambda node: node.lineno, default=None) + + +def _class_containing_line(tree: ast.AST, line: int) -> ast.ClassDef | None: + classes = [ + node + for node in ast.walk(tree) + if isinstance(node, ast.ClassDef) and node.lineno <= line <= (node.end_lineno or node.lineno) + ] + return max(classes, key=lambda node: node.lineno, default=None) + + +def _module_all_names(tree: ast.Module) -> set[str]: + names: set[str] = set() + for node in tree.body: + exported_values = _module_all_assignment_values(node) + if exported_values is None: + continue + for item in exported_values: + item_name = _string_constant_value(item) + if item_name is not None: + names.add(item_name) + return names + + +def _module_all_assignment_values(node: ast.stmt) -> list[ast.expr] | None: + if not isinstance(node, ast.Assign): + return None + if not any(isinstance(target, ast.Name) and target.id == "__all__" for target in node.targets): + return None + if isinstance(node.value, ast.List | ast.Tuple | ast.Set): + return list(node.value.elts) + return None + + +def _string_constant_value(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Str) and isinstance(node.s, str): + return node.s + return None + + +def _decorator_full_name(node: ast.AST) -> str: + target = node.func if isinstance(node, ast.Call) else node + if isinstance(target, ast.Name): + return target.id + if isinstance(target, ast.Attribute): + prefix = _decorator_full_name(target.value) + return f"{prefix}.{target.attr}" if prefix else target.attr + return "" + + +def _has_contract_decorator(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + contract_names = {"require", "ensure", "invariant", "icontract.require", "icontract.ensure", "icontract.invariant"} + return any(_decorator_full_name(decorator) in contract_names for decorator in function_node.decorator_list) + + +def _has_cli_decorator(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + return any( + _decorator_full_name(decorator).split(".")[-1] in {"command", "callback"} + for decorator in function_node.decorator_list + ) + + +def _has_abstractmethod(function_node: ast.FunctionDef | ast.AsyncFunctionDef | None) -> bool: + if function_node is None: + return False + return any( + _decorator_full_name(decorator).split(".")[-1] == "abstractmethod" for decorator in function_node.decorator_list + ) + + +def _is_protocol_or_abstract_member( + class_node: ast.ClassDef, + function_node: ast.FunctionDef | ast.AsyncFunctionDef | None, +) -> bool: + if function_node is None: + return False + if _has_abstractmethod(function_node): + return True + return _has_base_named(class_node, {"Protocol"}) and _is_stub_function(function_node) + + +def _is_stub_function(function_node: ast.FunctionDef | ast.AsyncFunctionDef) -> bool: + body = [statement for statement in function_node.body if not _is_docstring_statement(statement)] + return all(_is_stub_statement(statement) for statement in body) + + +def _is_docstring_statement(statement: ast.stmt) -> bool: + return ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Constant) + and isinstance(statement.value.value, str) + ) + + +def _is_stub_statement(statement: ast.stmt) -> bool: + if isinstance(statement, ast.Pass): + return True + return ( + isinstance(statement, ast.Expr) + and isinstance(statement.value, ast.Constant) + and statement.value.value is Ellipsis + ) + + +def _has_base_named(class_node: ast.ClassDef, names: set[str]) -> bool: + return any(_base_name(base).rsplit(".", maxsplit=1)[-1] in names for base in class_node.bases) + + +def _base_name(node: ast.AST) -> str: + if isinstance(node, ast.Name): + return node.id + if isinstance(node, ast.Attribute): + prefix = _base_name(node.value) + return f"{prefix}.{node.attr}" if prefix else node.attr + if isinstance(node, ast.Subscript): + return _base_name(node.value) + return "" + + +def _has_preserve_marker(lines: list[str], line: int) -> bool: + context = "\n".join(lines[max(0, line - 3) : line]) + return "specfact: preserve(" in context + + +def _has_spec_marker(lines: list[str], line: int) -> bool: + context = "\n".join(lines[max(0, line - 3) : line]) + return "# spec:" in context or "# specfact: requirement(" in context + + def _collect_tdd_inputs(files: list[Path]) -> tuple[list[Path], list[Path], list[ReviewFinding]]: source_files = [file_path for file_path in files if _expected_test_path(file_path) is not None] findings: list[ReviewFinding] = [] @@ -633,6 +941,10 @@ def run_review( findings = _filter_findings_by_review_level(findings, review_options.review_level) findings = _filter_findings_by_focus(findings, review_options.focus) + cleanup_forecast: CleanupForecast | None = None + if review_options.focus == "simplify": + findings = _enrich_cleanup_findings(findings) + cleanup_forecast = build_cleanup_forecast(findings, files) score = score_review( findings=findings, @@ -648,6 +960,7 @@ def run_review( score=score.score, findings=findings, summary=_summary_for_findings(findings), + cleanup_forecast=cleanup_forecast, ) if review_options.review_mode == "shadow": return report.model_copy(update={"ci_exit_code": 0}) diff --git a/packages/specfact-project/module-package.yaml b/packages/specfact-project/module-package.yaml index 089eec4..06b52b6 100644 --- a/packages/specfact-project/module-package.yaml +++ b/packages/specfact-project/module-package.yaml @@ -1,5 +1,5 @@ name: nold-ai/specfact-project -version: 0.41.17 +version: 0.41.18 commands: - project - plan @@ -27,5 +27,5 @@ core_compatibility: '>=0.40.0,<1.0.0' description: Official SpecFact project bundle package. bundle_group_command: project integrity: - checksum: sha256:98760f97c05a8bb7606931dd50b2c885c8d98309332cdab6bc5b2d5153564cdc - signature: Hp8M0QWi1OO1+gCPil5K0MbUy3tY3xg8R3OzdHRbeJFWnYPaB8GMuEpEkN163OVjzEqToTKYnifLpGqfgJt9CA== + checksum: sha256:4f2905d81e5287a7bfa81a93fe50ac17d6f55f5d5b7ea6b2d328ed83869d99a2 + signature: 55f2vT+jV/u0xcxWFgQnEZ1urtuJjfOhXYNo1tOUrnPZGhHWAhLaBGuwbYYb1V2FeUbFPdUxbbkx/TjbcLvZDA== diff --git a/packages/specfact-project/resources/prompts/specfact.08-simplify.md b/packages/specfact-project/resources/prompts/specfact.08-simplify.md index 32bec99..2547719 100644 --- a/packages/specfact-project/resources/prompts/specfact.08-simplify.md +++ b/packages/specfact-project/resources/prompts/specfact.08-simplify.md @@ -56,16 +56,18 @@ If this slash prompt or the installed skill is unavailable in another AI IDE, te Read `.specfact/code-review-simplify.json`. If it is missing, ask the user to run: ```bash -specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json +specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review-simplify.json ``` -Explain that this report is the evidence file: it lists candidate cleanups, the safety checks, and the preserve reasons the assistant must use before touching code. Do not edit files until the report exists. +Explain that this report is the evidence file: it lists candidate cleanups, cleanup forecast, safety checks, remediation packets, and preserve reasons the assistant must use before touching code. Do not edit files until the report exists. + +Inspect `cleanup_forecast` first. Use reviewed LOC, deletion estimate ranges, AI-bloat index, weighted bloat points, cleanup yield, and guidance-kind totals to decide where cleanup has the highest likely payoff. Treat estimates as forecasts, not guaranteed LOC removal. If the report contains no findings where `category == "ai_bloat"` and no findings with simplification metadata such as `intent_key`, `rewrite_hint`, `canonical_pattern`, or `guidance_kind`, report that there are no simplification candidates and stop without editing files. ### Step 2: Group Candidates -Group findings by `intent_key` first when present, then by file or domain and rule. For each candidate, inspect the referenced source location, inspect any related locations from `related_locations`, and capture small surrounding snippets before proposing a rewrite. +Group findings by `intent_key` first when present, then by file or domain and rule. For each candidate, inspect the referenced source location, inspect any related locations from `related_locations`, and capture small surrounding snippets before proposing a rewrite. When `remediation_packet` is present, treat it as authoritative for the issue, recommended action, possible keep reason, safety checks, validation plan, and `safe_to_autofix`. Use `guidance_kind` as the action contract: @@ -74,6 +76,8 @@ Use `guidance_kind` as the action contract: - `design_judgment`: inspect intent evidence first, explain tradeoffs in plain language, default to keep/skip when intent is unclear, and ask before editing. - `preserve`: keep by default; record the `preserve_reason` as a false-positive or intentional-pattern note. +If `preserve_reasons` is present, do not autofix the finding even when a shorter patch exists. + For vibe-coder and junior walkthroughs, present findings as a decision card instead of a raw lint warning: ```text @@ -120,7 +124,7 @@ Compare the new report with the prior findings and summarize which `ai_bloat` or Use the CLI as the verification source: ```bash -specfact code review run --scope changed --focus simplify --json --out .specfact/code-review-simplify.json +specfact code review run --scope changed --focus simplify --preview-fixes --json --out .specfact/code-review-simplify.json specfact code review run --scope changed --bug-hunt --json --out .specfact/code-review-bughunt.json ``` diff --git a/registry/index.json b/registry/index.json index fdbdee2..33fd5bf 100644 --- a/registry/index.json +++ b/registry/index.json @@ -2,9 +2,9 @@ "modules": [ { "id": "nold-ai/specfact-project", - "latest_version": "0.41.17", - "download_url": "modules/specfact-project-0.41.17.tar.gz", - "checksum_sha256": "bb9a30df380bc23cb915f74ae5a49d89782e85dc845b358b6938d056b4ad1f04", + "latest_version": "0.41.18", + "download_url": "modules/specfact-project-0.41.18.tar.gz", + "checksum_sha256": "58d5652c6c5e609af1acd661ad816250a251ed80c965f301b40939fc263a51cd", "tier": "official", "publisher": { "name": "nold-ai", @@ -78,9 +78,9 @@ }, { "id": "nold-ai/specfact-code-review", - "latest_version": "0.47.27", - "download_url": "modules/specfact-code-review-0.47.27.tar.gz", - "checksum_sha256": "1f7167cc3f973ebb1a60613655ef22b57a75054044f586f89de73c904b74d740", + "latest_version": "0.47.34", + "download_url": "modules/specfact-code-review-0.47.34.tar.gz", + "checksum_sha256": "ef92215e883adbec39972bf7cee5d71a8ecb1a410a34e514b812ed7c556d4047", "core_compatibility": ">=0.44.0,<1.0.0", "tier": "official", "publisher": { diff --git a/registry/modules/specfact-backlog-0.39.0.tar.gz.sha256 b/registry/modules/specfact-backlog-0.39.0.tar.gz.sha256 new file mode 100644 index 0000000..812d403 --- /dev/null +++ b/registry/modules/specfact-backlog-0.39.0.tar.gz.sha256 @@ -0,0 +1 @@ +a59c07672ba1fdf02d8dc03d0171d03429e521ca98d383169404127fd1d9ea13 diff --git a/registry/modules/specfact-backlog-0.40.0.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.0.tar.gz.sha256 new file mode 100644 index 0000000..4859412 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.0.tar.gz.sha256 @@ -0,0 +1 @@ +c180966b5488bd7185e3d58201916e9cd8e626d3eace96c542c4caaf827e72a3 diff --git a/registry/modules/specfact-backlog-0.40.1.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.1.tar.gz.sha256 new file mode 100644 index 0000000..5e6723e --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.1.tar.gz.sha256 @@ -0,0 +1 @@ +5303882e03bebd7796c56d0e32c5357f74db77853f9f101fa6e7c568ea113f9f diff --git a/registry/modules/specfact-backlog-0.40.10.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.10.tar.gz.sha256 new file mode 100644 index 0000000..e6d9329 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.10.tar.gz.sha256 @@ -0,0 +1 @@ +2a2fc2dd4ec89faa02336b7e7510d33b8cb96766054fd3f6eae4304c6c5d6460 diff --git a/registry/modules/specfact-backlog-0.40.11.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.11.tar.gz.sha256 new file mode 100644 index 0000000..8460765 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.11.tar.gz.sha256 @@ -0,0 +1 @@ +ec95c9041af8a9217ec3317b1bb190c518cfd4d16d264ea8726248f3fe592463 diff --git a/registry/modules/specfact-backlog-0.40.13.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.13.tar.gz.sha256 new file mode 100644 index 0000000..94bcb08 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.13.tar.gz.sha256 @@ -0,0 +1 @@ +ad41442be4bdd883b0d2c1797692c8a8e0de82682e5675a42c57e92526960352 diff --git a/registry/modules/specfact-backlog-0.40.14.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.14.tar.gz.sha256 new file mode 100644 index 0000000..ca8d073 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.14.tar.gz.sha256 @@ -0,0 +1 @@ +4e50523ad1118ba0d18cd1134ccfe1e2824856517de773edf1f91f27677e4677 diff --git a/registry/modules/specfact-backlog-0.40.15.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.15.tar.gz.sha256 new file mode 100644 index 0000000..bb93007 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.15.tar.gz.sha256 @@ -0,0 +1 @@ +bdf9f0ccf3e18c05e26821ecc4cef94d9a76ecfe97554a2a0387a6c9eadc2ffa diff --git a/registry/modules/specfact-backlog-0.40.2.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.2.tar.gz.sha256 new file mode 100644 index 0000000..ada23f7 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.2.tar.gz.sha256 @@ -0,0 +1 @@ +07e2ed09eb7a780350513415f1ada910101592304a2440cbcceddc09b0797a14 diff --git a/registry/modules/specfact-backlog-0.40.3.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.3.tar.gz.sha256 new file mode 100644 index 0000000..aada58f --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.3.tar.gz.sha256 @@ -0,0 +1 @@ +dab751f2993d992a2bc92cd9b9ba4c70128c5b7072098162efce3910da330ab8 diff --git a/registry/modules/specfact-backlog-0.40.4.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.4.tar.gz.sha256 new file mode 100644 index 0000000..1988e7e --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.4.tar.gz.sha256 @@ -0,0 +1 @@ +71729cca43246ecf0ca2ecd597ac91c934ea7d3af84bccfee90207b448efc830 diff --git a/registry/modules/specfact-backlog-0.40.5.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.5.tar.gz.sha256 new file mode 100644 index 0000000..aa5768d --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.5.tar.gz.sha256 @@ -0,0 +1 @@ +2e63cf5a0385a384543da384f5cda35f76349a38b82a0f044986fe50747f7c0f diff --git a/registry/modules/specfact-backlog-0.40.6.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.6.tar.gz.sha256 new file mode 100644 index 0000000..edc9816 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.6.tar.gz.sha256 @@ -0,0 +1 @@ +55e18cc73e540d335a79436842908fb0d6f32be664443a8b47fff76238b4f011 diff --git a/registry/modules/specfact-backlog-0.40.7.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.7.tar.gz.sha256 new file mode 100644 index 0000000..b3b510e --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.7.tar.gz.sha256 @@ -0,0 +1 @@ +d91040de02cf04e4392a077bcb7117a195994e5ffd45dcb94ef7e8a378a6f02c diff --git a/registry/modules/specfact-backlog-0.40.8.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.8.tar.gz.sha256 new file mode 100644 index 0000000..a471407 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.8.tar.gz.sha256 @@ -0,0 +1 @@ +6640bd7c94d9193d70053fd6b390eedb12c63f8e0107987ab95b95fb36825289 diff --git a/registry/modules/specfact-backlog-0.40.9.tar.gz.sha256 b/registry/modules/specfact-backlog-0.40.9.tar.gz.sha256 new file mode 100644 index 0000000..6bfbfb1 --- /dev/null +++ b/registry/modules/specfact-backlog-0.40.9.tar.gz.sha256 @@ -0,0 +1 @@ +9893e81390c8531d2b7c74ca711d652c6d9688cb88adae3467fc7f22a9a9d70a diff --git a/registry/modules/specfact-code-review-0.47.28.tar.gz b/registry/modules/specfact-code-review-0.47.28.tar.gz new file mode 100644 index 0000000..adf70ff Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.28.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.28.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.28.tar.gz.sha256 new file mode 100644 index 0000000..043c920 --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.28.tar.gz.sha256 @@ -0,0 +1 @@ +968386b7d14223a47f76de0aa1289e7b4a2aabe486a5c256a40feedcec3988bd diff --git a/registry/modules/specfact-code-review-0.47.29.tar.gz b/registry/modules/specfact-code-review-0.47.29.tar.gz new file mode 100644 index 0000000..6bf9353 Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.29.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.29.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.29.tar.gz.sha256 new file mode 100644 index 0000000..f04d69a --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.29.tar.gz.sha256 @@ -0,0 +1 @@ +e4856920ab47db60d58800b0662ac878ce43cb32d4cb2938cda3cd750e68cb51 diff --git a/registry/modules/specfact-code-review-0.47.30.tar.gz b/registry/modules/specfact-code-review-0.47.30.tar.gz new file mode 100644 index 0000000..6a4a1da Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.30.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.30.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.30.tar.gz.sha256 new file mode 100644 index 0000000..771796b --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.30.tar.gz.sha256 @@ -0,0 +1 @@ +8e9db9d4659f6bd71d572e213a186f188e20ddfa1108838d18cfb4decca5761e diff --git a/registry/modules/specfact-code-review-0.47.31.tar.gz b/registry/modules/specfact-code-review-0.47.31.tar.gz new file mode 100644 index 0000000..6c0444d Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.31.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.31.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.31.tar.gz.sha256 new file mode 100644 index 0000000..5d4c31b --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.31.tar.gz.sha256 @@ -0,0 +1 @@ +407f28ae9bc776eb914a0907f64a3724ba080ca9cc781d49379cefb7f3ff1d3f diff --git a/registry/modules/specfact-code-review-0.47.32.tar.gz b/registry/modules/specfact-code-review-0.47.32.tar.gz new file mode 100644 index 0000000..46a6645 Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.32.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.32.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.32.tar.gz.sha256 new file mode 100644 index 0000000..1666528 --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.32.tar.gz.sha256 @@ -0,0 +1 @@ +ba6746be977a29e9ae29f85dc6f56e84cd68e0af8c69746fc4793cc1c544675e diff --git a/registry/modules/specfact-code-review-0.47.33.tar.gz b/registry/modules/specfact-code-review-0.47.33.tar.gz new file mode 100644 index 0000000..67ffe22 Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.33.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.33.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.33.tar.gz.sha256 new file mode 100644 index 0000000..e0beb48 --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.33.tar.gz.sha256 @@ -0,0 +1 @@ +a7292727661e95e9ab7dfaf4846b477ddaa3ae2002c6351e8c7bd9fcf9f8807e diff --git a/registry/modules/specfact-code-review-0.47.34.tar.gz b/registry/modules/specfact-code-review-0.47.34.tar.gz new file mode 100644 index 0000000..c5cfa92 Binary files /dev/null and b/registry/modules/specfact-code-review-0.47.34.tar.gz differ diff --git a/registry/modules/specfact-code-review-0.47.34.tar.gz.sha256 b/registry/modules/specfact-code-review-0.47.34.tar.gz.sha256 new file mode 100644 index 0000000..927c36a --- /dev/null +++ b/registry/modules/specfact-code-review-0.47.34.tar.gz.sha256 @@ -0,0 +1 @@ +ef92215e883adbec39972bf7cee5d71a8ecb1a410a34e514b812ed7c556d4047 diff --git a/registry/modules/specfact-codebase-0.39.0.tar.gz.sha256 b/registry/modules/specfact-codebase-0.39.0.tar.gz.sha256 new file mode 100644 index 0000000..30fd938 --- /dev/null +++ b/registry/modules/specfact-codebase-0.39.0.tar.gz.sha256 @@ -0,0 +1 @@ +1f70e1470af9889c955ae848231878ad7d495a2d9c8d27719acc866bd7ffb33d diff --git a/registry/modules/specfact-codebase-0.40.0.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.0.tar.gz.sha256 new file mode 100644 index 0000000..6be22e7 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.0.tar.gz.sha256 @@ -0,0 +1 @@ +a25a664a9c637014ab8b47d50741228cee411a8bb73e77e7ffb70500e7a99d34 diff --git a/registry/modules/specfact-codebase-0.40.1.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.1.tar.gz.sha256 new file mode 100644 index 0000000..234af91 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.1.tar.gz.sha256 @@ -0,0 +1 @@ +a262025d4098b4913ca694ca1ab17b25500cc3098899785209647a41002433fb diff --git a/registry/modules/specfact-codebase-0.40.10.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.10.tar.gz.sha256 new file mode 100644 index 0000000..d51a8b6 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.10.tar.gz.sha256 @@ -0,0 +1 @@ +247787bf00522865a640945e3d7154b8722ad46b778770864601051d0700bc36 diff --git a/registry/modules/specfact-codebase-0.40.11.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.11.tar.gz.sha256 new file mode 100644 index 0000000..03188bf --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.11.tar.gz.sha256 @@ -0,0 +1 @@ +926d1bc91e715e450342bd0eb001a6c26a3b5359091c8404580146bdcb766652 diff --git a/registry/modules/specfact-codebase-0.40.12.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.12.tar.gz.sha256 new file mode 100644 index 0000000..17f8172 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.12.tar.gz.sha256 @@ -0,0 +1 @@ +093bd3f80d5abe7f969e6a130d45fe62560188c0ec7419773555a93ba13fb531 diff --git a/registry/modules/specfact-codebase-0.40.13.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.13.tar.gz.sha256 new file mode 100644 index 0000000..f203d7f --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.13.tar.gz.sha256 @@ -0,0 +1 @@ +075f6ac03febab05c10a2a153255fc85cecaedce74b748660fd6c5db6e78214f diff --git a/registry/modules/specfact-codebase-0.40.14.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.14.tar.gz.sha256 new file mode 100644 index 0000000..f97be0d --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.14.tar.gz.sha256 @@ -0,0 +1 @@ +1f383286507730a546ae7fd629253e7c7477fa42d84d0284c8bb72b468a212c4 diff --git a/registry/modules/specfact-codebase-0.40.2.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.2.tar.gz.sha256 new file mode 100644 index 0000000..293ac33 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.2.tar.gz.sha256 @@ -0,0 +1 @@ +8f0e63d795737cb95bc9b9bb94393ad510a568fe5051a3389527cb20630d1a2b diff --git a/registry/modules/specfact-codebase-0.40.3.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.3.tar.gz.sha256 new file mode 100644 index 0000000..8f8dd3c --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.3.tar.gz.sha256 @@ -0,0 +1 @@ +90d6627b9cca28f528d75044e3c1110ade8b7a3bc96a2ca95bc6ee9716d8353c diff --git a/registry/modules/specfact-codebase-0.40.4.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.4.tar.gz.sha256 new file mode 100644 index 0000000..7e0614b --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.4.tar.gz.sha256 @@ -0,0 +1 @@ +5742effa6fc0f8f23b61e762d351af5839176174c154c5f3849b34199faad71f diff --git a/registry/modules/specfact-codebase-0.40.5.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.5.tar.gz.sha256 new file mode 100644 index 0000000..0951317 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.5.tar.gz.sha256 @@ -0,0 +1 @@ +0777050f23e0ee2f750a767b6b4ade142391279412b64351b708fe891f065fba diff --git a/registry/modules/specfact-codebase-0.40.6.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.6.tar.gz.sha256 new file mode 100644 index 0000000..d9fba0d --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.6.tar.gz.sha256 @@ -0,0 +1 @@ +145ec268bebf6a560bd40b2098591205b09903d83805b740baed36f2e5a53fc9 diff --git a/registry/modules/specfact-codebase-0.40.7.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.7.tar.gz.sha256 new file mode 100644 index 0000000..d2323a8 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.7.tar.gz.sha256 @@ -0,0 +1 @@ +5a5cb5965c1c2271bb8ce44a183e9470955c5be1daf45deead8c29a2298136c7 diff --git a/registry/modules/specfact-codebase-0.40.8.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.8.tar.gz.sha256 new file mode 100644 index 0000000..d599519 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.8.tar.gz.sha256 @@ -0,0 +1 @@ +36fc309889c7794bba8328324a84a777965b661d933a31472bdcb07e494d2ce8 diff --git a/registry/modules/specfact-codebase-0.40.9.tar.gz.sha256 b/registry/modules/specfact-codebase-0.40.9.tar.gz.sha256 new file mode 100644 index 0000000..922eed5 --- /dev/null +++ b/registry/modules/specfact-codebase-0.40.9.tar.gz.sha256 @@ -0,0 +1 @@ +2efcfba76d5368c325995e5bbcbaf0a1d73f7b253d051697d19d460e31d0a81a diff --git a/registry/modules/specfact-codebase-0.41.0.tar.gz.sha256 b/registry/modules/specfact-codebase-0.41.0.tar.gz.sha256 new file mode 100644 index 0000000..1a71397 --- /dev/null +++ b/registry/modules/specfact-codebase-0.41.0.tar.gz.sha256 @@ -0,0 +1 @@ +af22a5607ce46c1dfe1dc9aaba5b2ceafabdec8f924e4dad5be103600f837a02 diff --git a/registry/modules/specfact-govern-0.39.0.tar.gz.sha256 b/registry/modules/specfact-govern-0.39.0.tar.gz.sha256 new file mode 100644 index 0000000..e7e8ed7 --- /dev/null +++ b/registry/modules/specfact-govern-0.39.0.tar.gz.sha256 @@ -0,0 +1 @@ +00d1fb60364b5545fd44a318ca13b556e8345582e0ff9c2185df6a4a5db2a2bc diff --git a/registry/modules/specfact-govern-0.40.0.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.0.tar.gz.sha256 new file mode 100644 index 0000000..d90b718 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.0.tar.gz.sha256 @@ -0,0 +1 @@ +15fa85a158d2e7b0fd7bda2a626bb30c3e1523bc364af1d6d0506d10704be012 diff --git a/registry/modules/specfact-govern-0.40.1.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.1.tar.gz.sha256 new file mode 100644 index 0000000..98e97c2 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.1.tar.gz.sha256 @@ -0,0 +1 @@ +a12af83810a7336572f2b95afcfa44c37467be42463d071ce49a84a14cea55cd diff --git a/registry/modules/specfact-govern-0.40.10.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.10.tar.gz.sha256 new file mode 100644 index 0000000..4b1ecf9 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.10.tar.gz.sha256 @@ -0,0 +1 @@ +8594ce61bddda858b0d4c98c4837fbde3786f97f7c43d3ff39708206f97492e0 diff --git a/registry/modules/specfact-govern-0.40.11.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.11.tar.gz.sha256 new file mode 100644 index 0000000..4f9d48b --- /dev/null +++ b/registry/modules/specfact-govern-0.40.11.tar.gz.sha256 @@ -0,0 +1 @@ +2b384a2d6008945441532b6c96b8c2e5ea1265bfea6630ebc16bf5a1bae6edba diff --git a/registry/modules/specfact-govern-0.40.12.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.12.tar.gz.sha256 new file mode 100644 index 0000000..5d4043d --- /dev/null +++ b/registry/modules/specfact-govern-0.40.12.tar.gz.sha256 @@ -0,0 +1 @@ +50530b16edc7ea7f6a1b818c3c126bb2fbbdeca92e857c315bbf311314247daf diff --git a/registry/modules/specfact-govern-0.40.13.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.13.tar.gz.sha256 new file mode 100644 index 0000000..3f149ea --- /dev/null +++ b/registry/modules/specfact-govern-0.40.13.tar.gz.sha256 @@ -0,0 +1 @@ +efbc86967cd8796c51c839f2611279ada54216c026bf7879e090926b8665d72f diff --git a/registry/modules/specfact-govern-0.40.2.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.2.tar.gz.sha256 new file mode 100644 index 0000000..aa4d64d --- /dev/null +++ b/registry/modules/specfact-govern-0.40.2.tar.gz.sha256 @@ -0,0 +1 @@ +26052f0d95fcd29a4614f15d818ad28ab678bed16919a4ca3cc8d5c712906048 diff --git a/registry/modules/specfact-govern-0.40.3.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.3.tar.gz.sha256 new file mode 100644 index 0000000..fe973d3 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.3.tar.gz.sha256 @@ -0,0 +1 @@ +fb09d2bf687d905cc3bf53f4c85de77a7d14728c842539e0e004e9bc8b89161c diff --git a/registry/modules/specfact-govern-0.40.4.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.4.tar.gz.sha256 new file mode 100644 index 0000000..4a1b054 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.4.tar.gz.sha256 @@ -0,0 +1 @@ +4aaee86092079224f9ff9a1a12fb162b95ec265464a1bc8250458da92634ff0e diff --git a/registry/modules/specfact-govern-0.40.5.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.5.tar.gz.sha256 new file mode 100644 index 0000000..c13a964 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.5.tar.gz.sha256 @@ -0,0 +1 @@ +c23b31234c350a5e4747beea13649041ea8946b34b537f955ba78f919b371e51 diff --git a/registry/modules/specfact-govern-0.40.6.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.6.tar.gz.sha256 new file mode 100644 index 0000000..3cb6e64 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.6.tar.gz.sha256 @@ -0,0 +1 @@ +064153f07c3de334614d5b90212c9d39643cd9bb84e155b19a05cebb0639ea8d diff --git a/registry/modules/specfact-govern-0.40.7.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.7.tar.gz.sha256 new file mode 100644 index 0000000..dba9892 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.7.tar.gz.sha256 @@ -0,0 +1 @@ +a155843e4b96073516c39fb0e72f64c8dac81977f95c2a1f91786198d1f4520a diff --git a/registry/modules/specfact-govern-0.40.8.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.8.tar.gz.sha256 new file mode 100644 index 0000000..f8346da --- /dev/null +++ b/registry/modules/specfact-govern-0.40.8.tar.gz.sha256 @@ -0,0 +1 @@ +814de7dff193c67bca4159ff9ada1f2900a27457943c00e4a9fcc41236d35dc9 diff --git a/registry/modules/specfact-govern-0.40.9.tar.gz.sha256 b/registry/modules/specfact-govern-0.40.9.tar.gz.sha256 new file mode 100644 index 0000000..a46f252 --- /dev/null +++ b/registry/modules/specfact-govern-0.40.9.tar.gz.sha256 @@ -0,0 +1 @@ +24f5014cb8c99c5eb1127a0766e6254a9f3509205fe0c625886e43aaaf500774 diff --git a/registry/modules/specfact-project-0.39.0.tar.gz.sha256 b/registry/modules/specfact-project-0.39.0.tar.gz.sha256 new file mode 100644 index 0000000..d4f97fd --- /dev/null +++ b/registry/modules/specfact-project-0.39.0.tar.gz.sha256 @@ -0,0 +1 @@ +7c7daea2401ca9618676a3f1369fd14ad65e008c692ed19b442fa9fc1ea93365 diff --git a/registry/modules/specfact-project-0.40.0.tar.gz.sha256 b/registry/modules/specfact-project-0.40.0.tar.gz.sha256 new file mode 100644 index 0000000..2de5576 --- /dev/null +++ b/registry/modules/specfact-project-0.40.0.tar.gz.sha256 @@ -0,0 +1 @@ +fc1dc72db983d0350d257c710f315dc1d89b5955bc1a409bb65ecb2ad4e72b66 diff --git a/registry/modules/specfact-project-0.40.1.tar.gz.sha256 b/registry/modules/specfact-project-0.40.1.tar.gz.sha256 new file mode 100644 index 0000000..86fff47 --- /dev/null +++ b/registry/modules/specfact-project-0.40.1.tar.gz.sha256 @@ -0,0 +1 @@ +6ce5de74b117b2e241980eaf42c27aea50ac7ed17cf98009f3c165095a7be304 diff --git a/registry/modules/specfact-project-0.40.11.tar.gz.sha256 b/registry/modules/specfact-project-0.40.11.tar.gz.sha256 new file mode 100644 index 0000000..52c9c48 --- /dev/null +++ b/registry/modules/specfact-project-0.40.11.tar.gz.sha256 @@ -0,0 +1 @@ +6ad021243359379ef49e45736ac6a012e94afc61d9dccbd55572bd376b55e1c2 diff --git a/registry/modules/specfact-project-0.40.12.tar.gz.sha256 b/registry/modules/specfact-project-0.40.12.tar.gz.sha256 new file mode 100644 index 0000000..1216910 --- /dev/null +++ b/registry/modules/specfact-project-0.40.12.tar.gz.sha256 @@ -0,0 +1 @@ +d8d7d68cbc09b6822d2a61a6c4fe650076029b725a364bb5d394d449623eba07 diff --git a/registry/modules/specfact-project-0.40.13.tar.gz.sha256 b/registry/modules/specfact-project-0.40.13.tar.gz.sha256 new file mode 100644 index 0000000..e882a2d --- /dev/null +++ b/registry/modules/specfact-project-0.40.13.tar.gz.sha256 @@ -0,0 +1 @@ +7c7d8b7acd577670542f7cfaded1dad9c7b18badf1aec7ef8da8bcead678df31 diff --git a/registry/modules/specfact-project-0.40.14.tar.gz.sha256 b/registry/modules/specfact-project-0.40.14.tar.gz.sha256 new file mode 100644 index 0000000..2899a34 --- /dev/null +++ b/registry/modules/specfact-project-0.40.14.tar.gz.sha256 @@ -0,0 +1 @@ +35f04dd9d804312c148d1743b2e64ed3c2d6f3a9572f94b503a9505397574d9b diff --git a/registry/modules/specfact-project-0.40.15.tar.gz.sha256 b/registry/modules/specfact-project-0.40.15.tar.gz.sha256 new file mode 100644 index 0000000..c8410fe --- /dev/null +++ b/registry/modules/specfact-project-0.40.15.tar.gz.sha256 @@ -0,0 +1 @@ +caff2eaef50ab09cc783f06043930c925c769a36ea947e713c10af587d6b1927 diff --git a/registry/modules/specfact-project-0.40.2.tar.gz.sha256 b/registry/modules/specfact-project-0.40.2.tar.gz.sha256 new file mode 100644 index 0000000..e97d975 --- /dev/null +++ b/registry/modules/specfact-project-0.40.2.tar.gz.sha256 @@ -0,0 +1 @@ +123bb72ac14015e6b585c1f7095365a88e3580e16b41b238c559e4e1182c25ff diff --git a/registry/modules/specfact-project-0.40.20.tar.gz.sha256 b/registry/modules/specfact-project-0.40.20.tar.gz.sha256 new file mode 100644 index 0000000..78a0f21 --- /dev/null +++ b/registry/modules/specfact-project-0.40.20.tar.gz.sha256 @@ -0,0 +1 @@ +080d1133ce61ff7b7945124b9cfd7e58ddd9df76a663ea48c2bd1a2f6ea43dab diff --git a/registry/modules/specfact-project-0.40.3.tar.gz.sha256 b/registry/modules/specfact-project-0.40.3.tar.gz.sha256 new file mode 100644 index 0000000..7d74054 --- /dev/null +++ b/registry/modules/specfact-project-0.40.3.tar.gz.sha256 @@ -0,0 +1 @@ +3e19b2b0b1cedc3005265239cf30b1b42309dfdc53c213f14e0c8297a3f6c0fc diff --git a/registry/modules/specfact-project-0.40.4.tar.gz.sha256 b/registry/modules/specfact-project-0.40.4.tar.gz.sha256 new file mode 100644 index 0000000..67739aa --- /dev/null +++ b/registry/modules/specfact-project-0.40.4.tar.gz.sha256 @@ -0,0 +1 @@ +e90bdf20c3053488f92409330f80875286db237a2c25a6a5f00f1044b8ae67cd diff --git a/registry/modules/specfact-project-0.40.5.tar.gz.sha256 b/registry/modules/specfact-project-0.40.5.tar.gz.sha256 new file mode 100644 index 0000000..397c325 --- /dev/null +++ b/registry/modules/specfact-project-0.40.5.tar.gz.sha256 @@ -0,0 +1 @@ +b76d6aaf7128dde79b22a22bfe7f07fe86947c9199f6ed9f7d0e8ec61d833a14 diff --git a/registry/modules/specfact-project-0.40.6.tar.gz.sha256 b/registry/modules/specfact-project-0.40.6.tar.gz.sha256 new file mode 100644 index 0000000..167ddcc --- /dev/null +++ b/registry/modules/specfact-project-0.40.6.tar.gz.sha256 @@ -0,0 +1 @@ +352c60a09916ce36c4a6d32daad79acd3809c00081ae00ad345e48440adb4092 diff --git a/registry/modules/specfact-project-0.40.7.tar.gz.sha256 b/registry/modules/specfact-project-0.40.7.tar.gz.sha256 new file mode 100644 index 0000000..0933c54 --- /dev/null +++ b/registry/modules/specfact-project-0.40.7.tar.gz.sha256 @@ -0,0 +1 @@ +daca73a59dfbd674da3dedddbdca01a4138321f26ad53066e7cb6c73afc9bfa8 diff --git a/registry/modules/specfact-project-0.40.8.tar.gz.sha256 b/registry/modules/specfact-project-0.40.8.tar.gz.sha256 new file mode 100644 index 0000000..e9e6fba --- /dev/null +++ b/registry/modules/specfact-project-0.40.8.tar.gz.sha256 @@ -0,0 +1 @@ +8a6a2a67cf8d26cfe0fcb3c83f91f36f800c7c480db56732057a55bc81644a72 diff --git a/registry/modules/specfact-project-0.40.9.tar.gz.sha256 b/registry/modules/specfact-project-0.40.9.tar.gz.sha256 new file mode 100644 index 0000000..362ce7d --- /dev/null +++ b/registry/modules/specfact-project-0.40.9.tar.gz.sha256 @@ -0,0 +1 @@ +0e69a8ce6b0ff665b34dff18b77894684202ae2f60e23c85f998a8a4dbb81b84 diff --git a/registry/modules/specfact-project-0.41.18.tar.gz b/registry/modules/specfact-project-0.41.18.tar.gz new file mode 100644 index 0000000..132895b Binary files /dev/null and b/registry/modules/specfact-project-0.41.18.tar.gz differ diff --git a/registry/modules/specfact-project-0.41.18.tar.gz.sha256 b/registry/modules/specfact-project-0.41.18.tar.gz.sha256 new file mode 100644 index 0000000..bde46f1 --- /dev/null +++ b/registry/modules/specfact-project-0.41.18.tar.gz.sha256 @@ -0,0 +1 @@ +58d5652c6c5e609af1acd661ad816250a251ed80c965f301b40939fc263a51cd diff --git a/registry/modules/specfact-spec-0.39.0.tar.gz.sha256 b/registry/modules/specfact-spec-0.39.0.tar.gz.sha256 new file mode 100644 index 0000000..41588c2 --- /dev/null +++ b/registry/modules/specfact-spec-0.39.0.tar.gz.sha256 @@ -0,0 +1 @@ +6be5b1ac7b3cd60b51dcb8f45ffa41be3d7f5bcd0429ab70d81b2eb3bbf8367d diff --git a/registry/modules/specfact-spec-0.40.0.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.0.tar.gz.sha256 new file mode 100644 index 0000000..9ab07c6 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.0.tar.gz.sha256 @@ -0,0 +1 @@ +c0d9536577529ed54f09c687c2edfc5c2a078e7aeb7a4cb1e0379077e8dc7f16 diff --git a/registry/modules/specfact-spec-0.40.1.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.1.tar.gz.sha256 new file mode 100644 index 0000000..1932021 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.1.tar.gz.sha256 @@ -0,0 +1 @@ +6afc7770e8c7adbcc0e5ad5747092139d12d38967cde72320d00200647af803b diff --git a/registry/modules/specfact-spec-0.40.10.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.10.tar.gz.sha256 new file mode 100644 index 0000000..2d0204a --- /dev/null +++ b/registry/modules/specfact-spec-0.40.10.tar.gz.sha256 @@ -0,0 +1 @@ +487873085d7bca3221e96f88bdbc5564cc5529086098e422203b21bf96336ce1 diff --git a/registry/modules/specfact-spec-0.40.11.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.11.tar.gz.sha256 new file mode 100644 index 0000000..d60fb39 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.11.tar.gz.sha256 @@ -0,0 +1 @@ +a0729d64dc8a3b3bd883d49dc69d4452b6a8fc4fc68665359b2b9b978b9befd1 diff --git a/registry/modules/specfact-spec-0.40.12.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.12.tar.gz.sha256 new file mode 100644 index 0000000..be2c631 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.12.tar.gz.sha256 @@ -0,0 +1 @@ +ee5e7bebe3815516c00ba8d5c8015c3512186782cd7a9a87642111aad8a1e140 diff --git a/registry/modules/specfact-spec-0.40.13.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.13.tar.gz.sha256 new file mode 100644 index 0000000..cd3a27f --- /dev/null +++ b/registry/modules/specfact-spec-0.40.13.tar.gz.sha256 @@ -0,0 +1 @@ +8c8004144bba37a49a7099f74f903b75819929fb0846d5e1d8dab8ffe5c97dd7 diff --git a/registry/modules/specfact-spec-0.40.2.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.2.tar.gz.sha256 new file mode 100644 index 0000000..5ea31ca --- /dev/null +++ b/registry/modules/specfact-spec-0.40.2.tar.gz.sha256 @@ -0,0 +1 @@ +734baa9da1f2ea3eb92093aaaa46068b079b040101ed97f3514b95995f5e59a1 diff --git a/registry/modules/specfact-spec-0.40.3.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.3.tar.gz.sha256 new file mode 100644 index 0000000..eb72f82 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.3.tar.gz.sha256 @@ -0,0 +1 @@ +5fba7afc3e553289f4afcb8851d460de5fc35a240d125af412b1b46f13bdcd7f diff --git a/registry/modules/specfact-spec-0.40.4.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.4.tar.gz.sha256 new file mode 100644 index 0000000..d7d6726 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.4.tar.gz.sha256 @@ -0,0 +1 @@ +50c15e4b6ef9e38e41cf6a398721791e52d7769146813c8986219a39c88ef171 diff --git a/registry/modules/specfact-spec-0.40.5.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.5.tar.gz.sha256 new file mode 100644 index 0000000..ce6d27c --- /dev/null +++ b/registry/modules/specfact-spec-0.40.5.tar.gz.sha256 @@ -0,0 +1 @@ +cd88073eb64152d4071af925c91d84f137a69669da4022c97b5dcd59160c5d05 diff --git a/registry/modules/specfact-spec-0.40.6.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.6.tar.gz.sha256 new file mode 100644 index 0000000..4391a89 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.6.tar.gz.sha256 @@ -0,0 +1 @@ +050382abf1cb7ddc172c33261f0cc6a70261c791e5af5488692af4e374f9c787 diff --git a/registry/modules/specfact-spec-0.40.7.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.7.tar.gz.sha256 new file mode 100644 index 0000000..cc862c8 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.7.tar.gz.sha256 @@ -0,0 +1 @@ +de0236d638d5ea5b638c3f3cdbfb87a06af0ac91299981a8f81aff9d9b972660 diff --git a/registry/modules/specfact-spec-0.40.8.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.8.tar.gz.sha256 new file mode 100644 index 0000000..bd33437 --- /dev/null +++ b/registry/modules/specfact-spec-0.40.8.tar.gz.sha256 @@ -0,0 +1 @@ +930f27a4f82a27828397343ab2f93091f1561fc31684dd3bf45331f416b62d78 diff --git a/registry/modules/specfact-spec-0.40.9.tar.gz.sha256 b/registry/modules/specfact-spec-0.40.9.tar.gz.sha256 new file mode 100644 index 0000000..167586c --- /dev/null +++ b/registry/modules/specfact-spec-0.40.9.tar.gz.sha256 @@ -0,0 +1 @@ +2df922f15f812b6e8022e9019e88a5bbde3625af85564454af0d58653a7bf30a diff --git a/registry/signatures/specfact-code-review-0.47.33.tar.sig b/registry/signatures/specfact-code-review-0.47.33.tar.sig new file mode 100644 index 0000000..c21a815 --- /dev/null +++ b/registry/signatures/specfact-code-review-0.47.33.tar.sig @@ -0,0 +1 @@ +E+HysNIipVI6A4pUPe589gib71fALeQQupQpB2gUWspSaBANiVA05FHmSOBOTdjSEmd8YfTwfArh3KQy6i/tCA== diff --git a/registry/signatures/specfact-code-review-0.47.34.tar.sig b/registry/signatures/specfact-code-review-0.47.34.tar.sig new file mode 100644 index 0000000..0960142 --- /dev/null +++ b/registry/signatures/specfact-code-review-0.47.34.tar.sig @@ -0,0 +1 @@ +KbRpwF9Kj9/Eqb8AuGaGzjXiKUoKXEfsI49pHPHsgs++6Ps29RIsdLcuSb0ckRZLSyUApYWzuG2einzSbOIvCw== diff --git a/tests/cli-contracts/specfact-code-review-run.scenarios.yaml b/tests/cli-contracts/specfact-code-review-run.scenarios.yaml index 43e6314..e5cd60e 100644 --- a/tests/cli-contracts/specfact-code-review-run.scenarios.yaml +++ b/tests/cli-contracts/specfact-code-review-run.scenarios.yaml @@ -168,3 +168,24 @@ scenarios: exit_code: 2 stderr_contains: - Cannot combine --focus with --include-tests or --exclude-tests + - name: preview-fixes-cannot-combine-with-fix + type: anti-pattern + argv: + - tests/fixtures/review/clean_module.py + - --focus + - simplify + - --preview-fixes + - --fix + expect: + exit_code: 2 + stderr_contains: + - Cannot combine --preview-fixes with --fix + - name: mutation-proof-requires-simplify-focus + type: anti-pattern + argv: + - tests/fixtures/review/clean_module.py + - --with-mutation + expect: + exit_code: 2 + stderr_contains: + - Use --with-mutation only with --focus simplify diff --git a/tests/unit/specfact_code_review/review/test_commands.py b/tests/unit/specfact_code_review/review/test_commands.py index 6bb6131..0f0e726 100644 --- a/tests/unit/specfact_code_review/review/test_commands.py +++ b/tests/unit/specfact_code_review/review/test_commands.py @@ -41,9 +41,13 @@ def _fail_run_command(_files: list[Path], **_kwargs: object) -> tuple[int, str | assert "branch-delta Python files" in result.output assert "git diff --name-only ...HEAD" in result.output assert "Findings without guidance_kind are unguided advisories" in result.output + assert "Sort findings by guidance_kind before editing" in result.output assert "exact patch preview" in result.output assert "default to keep or skip" in result.output assert "specfact code review run --scope changed --focus simplify" in result.output + assert "cleanup_forecast" in result.output + assert "remediation_packet" in result.output + assert "not proof of AI authorship" in result.output def test_review_run_interactive_prompts_for_test_inclusion(monkeypatch: Any) -> None: diff --git a/tests/unit/specfact_code_review/run/test_cleanup_evidence.py b/tests/unit/specfact_code_review/run/test_cleanup_evidence.py new file mode 100644 index 0000000..14f80a8 --- /dev/null +++ b/tests/unit/specfact_code_review/run/test_cleanup_evidence.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specfact_code_review.run.cleanup_evidence import ( + with_mutation_evidence, + with_previewed_simplification_findings, + with_refreshed_cleanup_forecast, +) +from specfact_code_review.run.findings import RemediationPacket, ReviewFinding, ReviewReport + + +def _finding(file_path: Path) -> ReviewFinding: + return ReviewFinding( + category="ai_bloat", + severity="info", + tool="ast", + rule="ai-bloat.redundant-intermediate", + file=str(file_path), + line=2, + message="Simplify local code.", + fixable=True, + confidence="high", + rewrite_hint="Inline the temporary.", + canonical_pattern="one-use-temporary", + estimated_deletion_lines=1, + guidance_kind="safe_mechanical", + recommended_action="inline", + clean_code_principle="kiss", + rationale="The local variable is assigned once and returned.", + safety_checks=["same expression is returned"], + action_status="recommended", + ) + + +def _report(finding: ReviewFinding) -> ReviewReport: + return ReviewReport(run_id="review", score=90, findings=[finding], summary="Simplify") + + +def test_with_previewed_simplification_findings_refreshes_forecast_without_fixable_findings(tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + finding = _finding(source).model_copy(update={"guidance_kind": "preserve", "preserve_reason": "public_api"}) + + refreshed = with_previewed_simplification_findings(_report(finding), [source], lambda report: []) + + assert refreshed.cleanup_forecast is not None + assert refreshed.findings[0].guidance_kind == "preserve" + + +def test_with_refreshed_cleanup_forecast_preserves_shadow_ci_exit(tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + report = ReviewReport( + run_id="review", + score=90, + findings=[ + ReviewFinding( + category="tool_error", + severity="error", + tool="ast", + rule="tool_error", + file=str(source), + line=1, + message="Unable to parse Python source.", + fixable=False, + ) + ], + summary="Shadow mode.", + ).model_copy(update={"ci_exit_code": 0}) + + refreshed = with_refreshed_cleanup_forecast(report, [source]) + + assert refreshed.ci_exit_code == 0 + assert refreshed.cleanup_forecast is not None + + +def test_with_previewed_simplification_findings_records_patch_ref(tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text( + "def total(values: list[int]) -> int:\n result = sum(values)\n return result\n", encoding="utf-8" + ) + finding = _finding(source) + + def _apply(report: ReviewReport) -> list[ReviewFinding]: + preview_path = Path(report.findings[0].file) + preview_path.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + return [report.findings[0]] + + previewed = with_previewed_simplification_findings(_report(finding), [source], _apply) + + assert previewed.findings[0].remediation_packet is not None + assert previewed.findings[0].remediation_packet.patch_forecast_refs == [f"preview:{source}:2"] + assert source.read_text(encoding="utf-8").count("result") == 2 + + +def test_with_previewed_simplification_findings_keeps_existing_packet_refs(tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text( + "def total(values: list[int]) -> int:\n result = sum(values)\n return result\n", encoding="utf-8" + ) + packet = RemediationPacket( + issue="Simplify local code.", + recommended_action="inline", + safety_checks=["compare expression"], + validation_plan=["run targeted tests"], + safe_to_autofix=True, + patch_forecast_refs=["preview:previous.py:1"], + ) + finding = _finding(source).model_copy(update={"remediation_packet": packet}) + + def _apply(report: ReviewReport) -> list[ReviewFinding]: + preview_path = Path(report.findings[0].file) + preview_path.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + return [report.findings[0]] + + previewed = with_previewed_simplification_findings(_report(finding), [source], _apply) + + assert previewed.findings[0].remediation_packet is not None + assert previewed.findings[0].remediation_packet.patch_forecast_refs == [ + "preview:previous.py:1", + f"preview:{source}:2", + ] + + +def test_with_mutation_evidence_keeps_non_safe_findings_without_signal(tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + finding = _finding(source).model_copy(update={"guidance_kind": "needs_tests"}) + + report = with_mutation_evidence(_report(finding), [source]) + + assert report.findings[0].signal_trace is None + + +def test_with_mutation_evidence_records_inconclusive_signal(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + monkeypatch.setattr("specfact_code_review.run.cleanup_evidence._mutation_tool_available", lambda: False) + + report = with_mutation_evidence(_report(_finding(source)), [source]) + + assert report.findings[0].signal_trace is not None + assert report.findings[0].signal_trace[-1].source == "mutation" + assert report.findings[0].signal_trace[-1].value == "inconclusive: mutmut unavailable" + + +def test_with_mutation_evidence_records_scaffolding_signal(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + source = tmp_path / "sample.py" + source.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + monkeypatch.setattr("specfact_code_review.run.cleanup_evidence._mutation_tool_available", lambda: True) + + report = with_mutation_evidence(_report(_finding(source)), [source]) + + assert report.findings[0].signal_trace is not None + assert report.findings[0].signal_trace[-1].value == "inconclusive: mutation scaffolding only" diff --git a/tests/unit/specfact_code_review/run/test_commands.py b/tests/unit/specfact_code_review/run/test_commands.py index e8bbee2..3858d01 100644 --- a/tests/unit/specfact_code_review/run/test_commands.py +++ b/tests/unit/specfact_code_review/run/test_commands.py @@ -1,5 +1,7 @@ from __future__ import annotations +import json +import re import subprocess from datetime import UTC, datetime from pathlib import Path @@ -23,6 +25,11 @@ "ai-bloat.redundant-intermediate": "inline", "ai-bloat.verbose-bool-return": "collapse", } +ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return ANSI_RE.sub("", text) def _report(*, score: int = 85) -> ReviewReport: @@ -312,6 +319,152 @@ def fake_run_review(files: list[Path], **kwargs: Any) -> ReviewReport: assert recorded == {"files": [package_file], "focus": "simplify"} +def test_run_command_rejects_preview_fixes_with_fix() -> None: + result = runner.invoke( + app, + [ + "review", + "run", + "--focus", + "simplify", + "--preview-fixes", + "--fix", + "tests/fixtures/review/clean_module.py", + ], + ) + + assert result.exit_code == 2 + assert "Cannot combine --preview-fixes with --fix" in _strip_ansi(result.output) + + +def test_run_command_rejects_preview_fixes_without_simplify_focus() -> None: + result = runner.invoke( + app, + ["review", "run", "--preview-fixes", "tests/fixtures/review/clean_module.py"], + ) + + assert result.exit_code == 2 + assert "Use --preview-fixes only with --focus simplify" in _strip_ansi(result.output) + + +def test_run_command_rejects_with_mutation_without_simplify_focus() -> None: + result = runner.invoke( + app, + ["review", "run", "--with-mutation", "tests/fixtures/review/clean_module.py"], + ) + + assert result.exit_code == 2 + assert "Use --with-mutation only with --focus simplify" in _strip_ansi(result.output) + + +def test_preview_fixes_adds_patch_forecast_without_mutating_tracked_file(monkeypatch: Any, tmp_path: Path) -> None: + target = tmp_path / "sample.py" + source = "def total(values: list[int]) -> int:\n result = sum(values)\n return result\n" + target.write_text(source, encoding="utf-8") + report = _safe_mechanical_report(target, line=2, rule="ai-bloat.redundant-intermediate") + monkeypatch.setattr("specfact_code_review.run.commands.run_review", lambda files, **kwargs: report) + + exit_code, output = run_commands.run_command( + run_commands.ReviewRunRequest( + files=[target], + json_output=True, + out=tmp_path / "review-report.json", + focus_facets=("simplify",), + preview_fixes=True, + ) + ) + + assert exit_code == 1 + assert output == str(tmp_path / "review-report.json") + assert target.read_text(encoding="utf-8") == source + previewed = ReviewReport.model_validate_json((tmp_path / "review-report.json").read_text(encoding="utf-8")) + assert previewed.cleanup_forecast is not None + assert previewed.cleanup_forecast.preview_evidence_count == 1 + assert previewed.findings[0].remediation_packet is not None + assert previewed.findings[0].remediation_packet.patch_forecast_refs == [f"preview:{target}:2"] + + +def test_with_mutation_records_inconclusive_evidence_for_missing_tool(monkeypatch: Any, tmp_path: Path) -> None: + target = tmp_path / "sample.py" + target.write_text( + "def total(values: list[int]) -> int:\n result = sum(values)\n return result\n", encoding="utf-8" + ) + report = _safe_mechanical_report(target, line=2, rule="ai-bloat.redundant-intermediate") + monkeypatch.setattr("specfact_code_review.run.commands.run_review", lambda files, **kwargs: report) + monkeypatch.setattr("specfact_code_review.run.cleanup_evidence._mutation_tool_available", lambda: False) + + exit_code, output = run_commands.run_command( + run_commands.ReviewRunRequest( + files=[target], + json_output=True, + out=tmp_path / "review-report.json", + focus_facets=("simplify",), + with_mutation=True, + ) + ) + + assert exit_code == 1 + assert output == str(tmp_path / "review-report.json") + mutation_report = ReviewReport.model_validate_json((tmp_path / "review-report.json").read_text(encoding="utf-8")) + assert mutation_report.findings[0].signal_trace is not None + assert mutation_report.findings[0].signal_trace[-1].source == "mutation" + assert mutation_report.findings[0].signal_trace[-1].value == "inconclusive: mutmut unavailable" + + +def _blocking_shadow_report(target: Path) -> ReviewReport: + return ReviewReport( + run_id="review-run-001", + timestamp=datetime(2026, 3, 16, tzinfo=UTC), + score=85, + findings=[ + ReviewFinding( + category="tool_error", + severity="error", + tool="ast", + rule="tool_error", + file=str(target), + line=1, + message="Unable to parse Python source.", + fixable=False, + ) + ], + summary="Shadow-mode report with blocking finding.", + ).model_copy(update={"ci_exit_code": 0}) + + +@pytest.mark.parametrize("evidence_flag", ["preview_fixes", "with_mutation"]) +def test_cleanup_evidence_preserves_shadow_mode_ci_exit( + monkeypatch: Any, + tmp_path: Path, + evidence_flag: str, +) -> None: + target = tmp_path / "sample.py" + target.write_text("def total(values: list[int]) -> int:\n return sum(values)\n", encoding="utf-8") + monkeypatch.setattr( + "specfact_code_review.run.commands.run_review", lambda files, **kwargs: _blocking_shadow_report(target) + ) + monkeypatch.setattr("specfact_code_review.run.cleanup_evidence._mutation_tool_available", lambda: False) + + request = run_commands.ReviewRunRequest( + files=[target], + json_output=True, + out=tmp_path / "review-report.json", + focus_facets=("simplify",), + review_mode="shadow", + ) + if evidence_flag == "preview_fixes": + request = run_commands.ReviewRunRequest(**{**request.__dict__, "preview_fixes": True}) + else: + request = run_commands.ReviewRunRequest(**{**request.__dict__, "with_mutation": True}) + + exit_code, output = run_commands.run_command(request) + + assert exit_code == 0 + assert output == str(tmp_path / "review-report.json") + report_payload = json.loads((tmp_path / "review-report.json").read_text(encoding="utf-8")) + assert report_payload["ci_exit_code"] == 0 + + def test_apply_simplification_fixes_inlines_redundant_intermediate(tmp_path: Path) -> None: target = tmp_path / "sample.py" target.write_text( @@ -531,6 +684,8 @@ def test_run_review_once_applies_simplification_fixes_before_rerun(monkeypatch: no_tests=True, include_noise=False, fix=True, + preview_fixes=False, + with_mutation=False, progress_callback=None, bug_hunt=False, review_mode="enforce", diff --git a/tests/unit/specfact_code_review/run/test_findings.py b/tests/unit/specfact_code_review/run/test_findings.py index 847965c..1c15ebb 100644 --- a/tests/unit/specfact_code_review/run/test_findings.py +++ b/tests/unit/specfact_code_review/run/test_findings.py @@ -6,7 +6,19 @@ import pytest from pydantic import ValidationError -from specfact_code_review.run.findings import EvidenceRef, ReviewFinding, ReviewReport +from specfact_code_review.run.findings import ( + AiBloatIndex, + CleanupForecast, + DeletionEstimate, + EvidenceRef, + GuidanceKindForecast, + PreserveReasonEvidence, + RemediationPacket, + ReviewedLoc, + ReviewFinding, + ReviewReport, + SignalTraceEntry, +) class ReviewFindingPayload(TypedDict, total=False): @@ -49,6 +61,9 @@ class ReviewFindingPayload(TypedDict, total=False): before_ref: EvidenceRef after_ref: EvidenceRef improvement: str + signal_trace: list[SignalTraceEntry] + preserve_reasons: list[PreserveReasonEvidence] + remediation_packet: RemediationPacket def _finding_data(**overrides: Unpack[ReviewFindingPayload]) -> ReviewFindingPayload: @@ -65,6 +80,44 @@ def _finding_data(**overrides: Unpack[ReviewFindingPayload]) -> ReviewFindingPay return data +def _agent_payload_finding() -> ReviewFinding: + return ReviewFinding( + **_finding_data( + category="ai_bloat", + severity="info", + tool="ast", + rule="ai-bloat.redundant-intermediate", + file="src/example.py", + line=1, + message="Simplify local code.", + fixable=True, + signal_trace=[ + SignalTraceEntry( + tool="ast", + source="ai-bloat.redundant-intermediate", + fired=True, + explanation="AST pattern matched a redundant intermediate assignment.", + ) + ], + preserve_reasons=[ + PreserveReasonEvidence( + reason="public_api", + evidence_refs=[EvidenceRef(path="src/example.py", start_line=1)], + explanation="Public API boundary.", + ) + ], + remediation_packet=RemediationPacket( + issue="Simplify local code.", + recommended_action="inspect", + possible_keep_reason="Public API boundary.", + safety_checks=["verify public behavior"], + validation_plan=["run targeted tests"], + safe_to_autofix=False, + ), + ) + ) + + def test_review_finding_accepts_valid_values() -> None: finding = ReviewFinding(**_finding_data()) @@ -138,6 +191,86 @@ def test_review_finding_accepts_guided_simplification_metadata() -> None: assert finding.is_safe_mechanical_simplification() +def test_review_finding_accepts_cleanup_handoff_metadata() -> None: + finding = ReviewFinding( + **_finding_data( + category="ai_bloat", + severity="info", + rule="ai-bloat.redundant-intermediate", + confidence="high", + rewrite_hint="Inline the one-use temporary into the return statement.", + canonical_pattern="one-use-temporary", + estimated_deletion_lines=1, + guidance_kind="safe_mechanical", + recommended_action="inline", + clean_code_principle="kiss", + rationale="The local variable is assigned once and read only by the following return.", + safety_checks=["same expression is returned", "temporary has no later reads"], + action_status="recommended", + signal_trace=[ + SignalTraceEntry( + tool="ast", + source="ai-bloat.redundant-intermediate", + fired=True, + score=1.0, + value="one-use-temporary", + evidence_refs=[EvidenceRef(path="src/example.py", start_line=12)], + explanation="AST pattern matched a one-use temporary.", + ) + ], + preserve_reasons=[ + PreserveReasonEvidence( + reason="public_api", + evidence_refs=[EvidenceRef(path="src/example.py", start_line=12)], + explanation="Exported in __all__.", + ) + ], + remediation_packet=RemediationPacket( + issue="One-use temporary can be inlined.", + recommended_action="inline", + possible_keep_reason="Keep only if readability would regress.", + safety_checks=["same expression is returned"], + validation_plan=["run targeted tests", "rerun simplify review"], + safe_to_autofix=False, + patch_forecast_refs=["preview:src/example.py:12"], + ), + ) + ) + + assert finding.signal_trace is not None + assert finding.signal_trace[0].tool == "ast" + assert finding.preserve_reasons is not None + assert finding.preserve_reasons[0].reason == "public_api" + assert finding.remediation_packet is not None + assert not finding.remediation_packet.safe_to_autofix + assert not finding.is_safe_mechanical_simplification() + + +def test_review_finding_rejects_unknown_preserve_reason() -> None: + with pytest.raises(ValidationError): + ReviewFinding( + **_finding_data( + category="ai_bloat", + severity="info", + guidance_kind="safe_mechanical", + recommended_action="inline", + clean_code_principle="kiss", + rationale="The local rewrite is safe.", + safety_checks=["same expression is returned"], + preserve_reasons=cast( + Any, + [ + { + "reason": "unknown_reason", + "evidence_refs": [EvidenceRef(path="src/example.py", start_line=12)], + "explanation": "Not in taxonomy.", + } + ], + ), + ) + ) + + def test_review_finding_accepts_guided_metadata_without_action_status() -> None: finding = ReviewFinding( **_finding_data( @@ -362,6 +495,62 @@ def test_review_report_uses_schema_1_2_and_summary_when_guided_metadata_is_prese assert report.simplification_summary.blocking_simplification_count == 1 +def test_review_report_uses_schema_1_3_when_cleanup_forecast_is_present() -> None: + report = ReviewReport( + run_id="run-cleanup-forecast", + timestamp=datetime(2026, 5, 24, tzinfo=UTC), + score=85, + findings=[], + summary="Cleanup forecast.", + cleanup_forecast=CleanupForecast( + reviewed_loc=ReviewedLoc(production=80, tests=20, total=100), + estimated_deletion_lines=DeletionEstimate(low=2, expected=5, high=8), + ai_bloat_index=AiBloatIndex( + findings_per_kloc=20.0, + weighted_bloat_points_per_kloc=16.0, + cleanup_yield_loc_per_kloc=50.0, + ), + by_guidance_kind={ + "safe_mechanical": GuidanceKindForecast(count=2, estimated_deletion_lines=2), + "needs_tests": GuidanceKindForecast(count=1, estimated_deletion_lines=5), + }, + by_action_status={"recommended": 3}, + ), + ) + + assert report.schema_version == "1.3" + assert report.cleanup_forecast is not None + assert report.cleanup_forecast.ai_bloat_index.weighted_bloat_points_per_kloc == 16.0 + + +def test_review_report_uses_schema_1_3_when_finding_agent_payload_is_present() -> None: + report = ReviewReport( + run_id="run-cleanup-handoff", + timestamp=datetime(2026, 5, 24, tzinfo=UTC), + score=85, + findings=[_agent_payload_finding()], + summary="Cleanup agent payload.", + ) + + assert report.schema_version == "1.3" + assert report.findings[0].signal_trace is not None + assert report.findings[0].preserve_reasons is not None + assert report.findings[0].remediation_packet is not None + + +def test_reviewed_loc_rejects_total_mismatch() -> None: + with pytest.raises(ValidationError, match=r"reviewed_loc.total must equal production \+ tests"): + ReviewedLoc(production=80, tests=20, total=90) + + +def test_deletion_estimate_rejects_inverted_range() -> None: + with pytest.raises(ValidationError, match="estimated_deletion_lines must satisfy low <= expected <= high"): + DeletionEstimate(low=6, expected=5, high=10) + + with pytest.raises(ValidationError, match="estimated_deletion_lines must satisfy low <= expected <= high"): + DeletionEstimate(low=1, expected=5, high=4) + + def test_review_report_counts_failed_safe_mechanical_findings_as_blocking() -> None: report = ReviewReport( run_id="run-guided-simplify", diff --git a/tests/unit/specfact_code_review/run/test_forecast.py b/tests/unit/specfact_code_review/run/test_forecast.py new file mode 100644 index 0000000..fac9884 --- /dev/null +++ b/tests/unit/specfact_code_review/run/test_forecast.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +from pathlib import Path +from typing import Literal, cast + +from pytest import MonkeyPatch + +from specfact_code_review.run.findings import ReviewFinding +from specfact_code_review.run.forecast import build_cleanup_forecast + + +def _finding(*, guidance_kind: str, deletion_lines: int) -> ReviewFinding: + return ReviewFinding( + category="ai_bloat", + severity="info", + tool="ast", + rule="ai-bloat.redundant-intermediate", + file="src/example.py", + line=1, + message="Simplify local code.", + confidence="high", + rewrite_hint="Inline the temporary.", + canonical_pattern="one-use-temporary", + estimated_deletion_lines=deletion_lines, + guidance_kind=cast( + Literal["safe_mechanical", "needs_tests", "design_judgment", "preserve"], + guidance_kind, + ), + recommended_action="keep" if guidance_kind == "preserve" else "inline", + clean_code_principle="api_stability" if guidance_kind == "preserve" else "kiss", + rationale="The local variable is assigned once and returned.", + safety_checks=["same expression is returned"], + preserve_reason="Public compatibility boundary." if guidance_kind == "preserve" else None, + action_status="recommended", + ) + + +def test_build_cleanup_forecast_counts_loc_and_weighted_bloat(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + source = Path("src/example.py") + source.parent.mkdir() + source.write_text("# comment\n\nvalue = 1\nprint(value)\n", encoding="utf-8") + test_file = Path("tests/test_example.py") + test_file.parent.mkdir() + test_file.write_text("def test_example():\n assert True\n", encoding="utf-8") + + forecast = build_cleanup_forecast( + [ + _finding(guidance_kind="safe_mechanical", deletion_lines=2), + _finding(guidance_kind="needs_tests", deletion_lines=3), + _finding(guidance_kind="design_judgment", deletion_lines=4), + _finding(guidance_kind="preserve", deletion_lines=5), + ], + [source, test_file], + ) + + assert forecast.reviewed_loc.production == 2 + assert forecast.reviewed_loc.tests == 2 + assert forecast.estimated_deletion_lines.low == 2 + assert forecast.estimated_deletion_lines.expected == 5 + assert forecast.estimated_deletion_lines.high == 9 + assert forecast.by_guidance_kind["safe_mechanical"].weight == 1.0 + assert forecast.by_guidance_kind["needs_tests"].weight == 0.6 + assert forecast.by_guidance_kind["design_judgment"].weight == 0.25 + assert forecast.by_guidance_kind["preserve"].weight == 0.0 + assert forecast.by_guidance_kind["preserve"].estimated_deletion_lines == 5 + assert forecast.ai_bloat_index.weighted_bloat_points_per_kloc == 462.5 + + +def test_build_cleanup_forecast_classifies_test_paths_without_substring_false_positive( + tmp_path: Path, monkeypatch: MonkeyPatch +) -> None: + monkeypatch.chdir(tmp_path) + test_file = Path("unit_tests/test_example.py") + test_file.parent.mkdir() + test_file.write_text("def test_example():\n assert True\n", encoding="utf-8") + production_file = Path("src/contest/example.py") + production_file.parent.mkdir(parents=True) + production_file.write_text("def score() -> int:\n return 1\n", encoding="utf-8") + + forecast = build_cleanup_forecast([], [test_file, production_file]) + + assert forecast.reviewed_loc.production == 2 + assert forecast.reviewed_loc.tests == 2 + + +def test_build_cleanup_forecast_skips_undecodable_python_files(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + monkeypatch.chdir(tmp_path) + source = Path("legacy.py") + source.write_bytes(b"\xff\xfe\x00") + + forecast = build_cleanup_forecast([_finding(guidance_kind="safe_mechanical", deletion_lines=2)], [source]) + + assert forecast.reviewed_loc.total == 0 + assert forecast.estimated_deletion_lines.expected == 2 diff --git a/tests/unit/specfact_code_review/run/test_runner.py b/tests/unit/specfact_code_review/run/test_runner.py index 061b5b7..4c6bdc5 100644 --- a/tests/unit/specfact_code_review/run/test_runner.py +++ b/tests/unit/specfact_code_review/run/test_runner.py @@ -7,11 +7,13 @@ from pathlib import Path from typing import Literal +import pytest from pytest import MonkeyPatch from specfact_code_review.run.findings import ReviewFinding, ReviewReport from specfact_code_review.run.runner import ( _coverage_findings, + _preserve_reasons_for_finding, _pytest_python_executable, _pytest_targets, _run_pytest_with_coverage, @@ -226,7 +228,7 @@ def test_run_review_simplify_focus_keeps_only_simplification_queue(monkeypatch: ("ai_bloat", "high"), ("dry", "high"), ] - assert report.schema_version == "1.1" + assert report.schema_version == "1.3" assert report.overall_verdict == "PASS" @@ -256,11 +258,63 @@ def test_run_review_simplify_enforce_fails_only_safe_mechanical_recommendations( review_mode="enforce", ) - assert report.schema_version == "1.2" + assert report.schema_version == "1.3" assert report.overall_verdict == "FAIL" assert report.ci_exit_code == 1 assert report.simplification_summary is not None assert report.simplification_summary.blocking_simplification_count == 1 + assert report.cleanup_forecast is not None + assert report.cleanup_forecast.by_guidance_kind["safe_mechanical"].count == 1 + assert report.cleanup_forecast.by_guidance_kind["needs_tests"].count == 1 + assert report.cleanup_forecast.by_guidance_kind["preserve"].count == 1 + assert report.cleanup_forecast.ai_bloat_index.weighted_bloat_points_per_kloc >= 0.0 + + +def test_run_review_simplify_forecast_counts_loc_and_weighted_bloat(monkeypatch: MonkeyPatch, tmp_path: Path) -> None: + monkeypatch.chdir(tmp_path) + source = Path("src/example.py") + source.parent.mkdir(parents=True) + source.write_text( + "def one() -> int:\n" + " value = 1\n" + " return value\n" + "\n" + "# ignored comment\n" + "def two() -> bool:\n" + " if True:\n" + " return True\n" + " return False\n", + encoding="utf-8", + ) + test_file = Path("tests/test_example.py") + test_file.parent.mkdir(parents=True) + test_file.write_text("def test_example() -> None:\n assert True\n", encoding="utf-8") + safe = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical") + needs_tests = _simplification_finding(category="ai_bloat", guidance_kind="needs_tests") + design = _simplification_finding(category="ai_bloat", guidance_kind="design_judgment") + preserve = _simplification_finding(category="ai_bloat", guidance_kind="preserve") + monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_semgrep_bugs", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_ai_bloat", lambda files: [safe, needs_tests]) + monkeypatch.setattr("specfact_code_review.run.runner.run_ast_clean_code", lambda files: [design, preserve]) + monkeypatch.setattr("specfact_code_review.run.runner.run_basedpyright", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_pylint", lambda files: []) + monkeypatch.setattr("specfact_code_review.run.runner.run_contract_check", lambda files, **_: []) + monkeypatch.setattr("specfact_code_review.run.runner._evaluate_tdd_gate", lambda files: ([], None)) + + report = run_review([source, test_file], no_tests=True, focus="simplify") + + assert report.cleanup_forecast is not None + assert report.cleanup_forecast.reviewed_loc.production == 7 + assert report.cleanup_forecast.reviewed_loc.tests == 2 + assert report.cleanup_forecast.estimated_deletion_lines.low == 3 + assert report.cleanup_forecast.estimated_deletion_lines.expected == 6 + assert report.cleanup_forecast.estimated_deletion_lines.high == 9 + assert report.cleanup_forecast.ai_bloat_index.findings_per_kloc == pytest.approx(444.444, abs=0.001) + assert report.cleanup_forecast.ai_bloat_index.weighted_bloat_points_per_kloc == pytest.approx(205.556, abs=0.001) + assert report.cleanup_forecast.ai_bloat_index.cleanup_yield_loc_per_kloc == pytest.approx(666.667, abs=0.001) def test_run_review_simplify_enforce_passes_design_and_preserve_guidance(monkeypatch: MonkeyPatch) -> None: @@ -293,6 +347,124 @@ def test_run_review_simplify_enforce_passes_design_and_preserve_guidance(monkeyp assert report.simplification_summary.blocking_simplification_count == 0 +def test_preserve_detection_covers_contract_public_protocol_cli_compat_and_load_bearing(tmp_path: Path) -> None: + source = tmp_path / "api.py" + source_text = ( + "from typing import Protocol\n" + "import typer\n" + "from abc import ABC, abstractmethod\n" + "\n" + "__all__ = ['exported']\n" + "app = typer.Typer()\n" + "\n" + "@icontract.require(lambda value: value > 0)\n" + "def contracted(value: int) -> int:\n" + " return value\n" + "\n" + "def exported() -> None:\n" + " return None\n" + "\n" + "class Handler(Protocol):\n" + " def handle(self, payload: str) -> str: ...\n" + "\n" + "class BaseHandler(ABC):\n" + " @abstractmethod\n" + " def abstract_handle(self, payload: str) -> str:\n" + " raise NotImplementedError\n" + "\n" + " def concrete_helper(self, payload: str) -> str:\n" + " result = payload.strip()\n" + " return result\n" + "\n" + "@app.command()\n" + "def cli_main() -> None:\n" + " return None\n" + "\n" + "# specfact: preserve(compat)\n" + "def shim() -> None:\n" + " return None\n" + ) + source.write_text(source_text, encoding="utf-8") + + def line_containing(text: str) -> int: + return next(index for index, line in enumerate(source_text.splitlines(), start=1) if text in line) + + finding_lines = { + "contract_lambda": line_containing("return value"), + "public_api": line_containing("return None"), + "protocol_member": line_containing("def handle"), + "cli_callback": line_containing("def cli_main"), + "compat_shim": line_containing("def shim"), + } + + for expected_reason, line in finding_lines.items(): + finding = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical").model_copy( + update={"file": str(source), "line": line} + ) + reasons = _preserve_reasons_for_finding(finding, load_bearing=False) + assert expected_reason in {reason.reason for reason in reasons} + + abstract_finding = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical").model_copy( + update={"file": str(source), "line": line_containing("raise NotImplementedError")} + ) + abstract_reasons = _preserve_reasons_for_finding(abstract_finding, load_bearing=False) + assert "protocol_member" in {reason.reason for reason in abstract_reasons} + + concrete_finding = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical").model_copy( + update={"file": str(source), "line": line_containing("return result")} + ) + concrete_reasons = _preserve_reasons_for_finding(concrete_finding, load_bearing=False) + assert "protocol_member" not in {reason.reason for reason in concrete_reasons} + + load_bearing_finding = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical").model_copy( + update={"file": str(source), "line": line_containing("def exported")} + ) + reasons = _preserve_reasons_for_finding(load_bearing_finding, load_bearing=True) + assert "load_bearing" in {reason.reason for reason in reasons} + + +def test_preserve_detection_treats_docstring_only_protocol_method_as_stub(tmp_path: Path) -> None: + source = tmp_path / "api.py" + source_text = ( + "from typing import Protocol\n" + "\n" + "class Handler(Protocol):\n" + " def handle(self, payload: str) -> str:\n" + ' """Handle the payload."""\n' + ) + source.write_text(source_text, encoding="utf-8") + + finding = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical").model_copy( + update={"file": str(source), "line": 4} + ) + reasons = _preserve_reasons_for_finding(finding, load_bearing=False) + + assert "protocol_member" in {reason.reason for reason in reasons} + + +def test_preserve_detection_reloads_source_after_file_mutation(tmp_path: Path) -> None: + source = tmp_path / "api.py" + source.write_text( + "from typing import Protocol\n\nclass Handler(Protocol):\n def handle(self, payload: str) -> str: ...\n", + encoding="utf-8", + ) + finding = _simplification_finding(category="ai_bloat", guidance_kind="safe_mechanical").model_copy( + update={"file": str(source), "line": 4} + ) + reasons = _preserve_reasons_for_finding(finding, load_bearing=False) + assert "protocol_member" in {reason.reason for reason in reasons} + + source.write_text( + "def helper(payload: str) -> str:\n result = payload.strip()\n return result\n", + encoding="utf-8", + ) + changed_finding = finding.model_copy(update={"line": 3}) + + changed_reasons = _preserve_reasons_for_finding(changed_finding, load_bearing=False) + + assert "protocol_member" not in {reason.reason for reason in changed_reasons} + + def test_run_review_simplify_focus_preserves_tool_errors(monkeypatch: MonkeyPatch) -> None: monkeypatch.setattr("specfact_code_review.run.runner.run_ruff", lambda files: []) monkeypatch.setattr("specfact_code_review.run.runner.run_radon", lambda files: [])