From 2f76e53c4f3fbb90297edc47168906f18c1f7e8c Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 17 Mar 2026 21:51:05 +0100 Subject: [PATCH 1/3] feat(code-review): add review scope modes --- CHANGELOG.md | 14 ++ docs/modules/code-review.md | 26 ++- .../.openspec.yaml | 2 + .../CHANGE_VALIDATION.md | 54 ++++++ .../TDD_EVIDENCE.md | 106 +++++++++++ .../design.md | 71 +++++++ .../proposal.md | 57 ++++++ .../specs/review-cli-contracts/spec.md | 21 +++ .../specs/review-run-command/spec.md | 59 ++++++ .../tasks.md | 30 +++ .../specfact-code-review/module-package.yaml | 6 +- .../specfact_code_review/review/commands.py | 15 ++ .../src/specfact_code_review/run/commands.py | 122 +++++++++++- .../src/specfact_code_review/run/runner.py | 4 +- .../tools/semgrep_runner.py | 173 +++++++++++------- .../specfact-code-review-run.scenarios.yaml | 38 ++++ .../specfact_code_review/run/test_commands.py | 158 ++++++++++++++++ .../specfact_code_review/run/test_runner.py | 29 +++ .../tools/test_semgrep_runner.py | 54 +++++- 19 files changed, 956 insertions(+), 83 deletions(-) create mode 100644 openspec/changes/code-review-10-review-scope-modes/.openspec.yaml create mode 100644 openspec/changes/code-review-10-review-scope-modes/CHANGE_VALIDATION.md create mode 100644 openspec/changes/code-review-10-review-scope-modes/TDD_EVIDENCE.md create mode 100644 openspec/changes/code-review-10-review-scope-modes/design.md create mode 100644 openspec/changes/code-review-10-review-scope-modes/proposal.md create mode 100644 openspec/changes/code-review-10-review-scope-modes/specs/review-cli-contracts/spec.md create mode 100644 openspec/changes/code-review-10-review-scope-modes/specs/review-run-command/spec.md create mode 100644 openspec/changes/code-review-10-review-scope-modes/tasks.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 77aadd3..177052a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,20 @@ All notable changes to this repository will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project follows SemVer for bundle versions. +## [0.44.0] - 2026-03-17 + +### Added + +- Add `--scope changed|full` and repeatable repo-relative `--path` filters to + `specfact code review run` for deterministic changed-only, full-repository, + and subtree-limited review selection. + +### Changed + +- Keep changed-only auto-discovery as the default, allow explicit test subtrees + to opt matching tests back into scope, and extend the review-run docs plus + cli-contract scenarios to cover the new targeting controls. + ## [0.43.0] - 2026-03-16 ### Added diff --git a/docs/modules/code-review.md b/docs/modules/code-review.md index 25d1c85..b91715a 100644 --- a/docs/modules/code-review.md +++ b/docs/modules/code-review.md @@ -17,6 +17,10 @@ Options: - `--score-only`: print only the integer `reward_delta` - `--fix`: apply Ruff autofixes and re-run the review before printing results - `--no-tests`: skip the targeted TDD gate +- `--scope changed|full`: choose changed-only or full-repository auto-discovery + when positional files are omitted +- `--path PATH`: repeatable repo-relative subtree filter for auto-discovered + review targets - `--include-tests/--exclude-tests`: include or exclude changed test files when review scope is auto-detected from `git diff` - `--include-noise/--suppress-noise`: include or suppress known low-signal @@ -34,7 +38,27 @@ git ls-files --others --exclude-standard Only existing Python files from tracked or untracked workspace changes are reviewed. Test files under `tests/` are excluded by default for auto-detected review scope unless you pass `--include-tests` or answer yes in -`--interactive` mode. +`--interactive` mode. Explicit `--path tests/...` filters count as intentional +targeting and keep matching tests in scope even with the default test +exclusion. + +Use `--scope full` to review the governed repository file set instead of just +current changes: + +```bash +specfact code review run --scope full +``` + +Use repeatable `--path` filters to limit either scope to one package or a +focused source-plus-test slice: + +```bash +specfact code review run --scope full --path packages/specfact-code-review +specfact code review run --scope changed --path packages/specfact-code-review --path tests/unit/specfact_code_review +``` + +Positional `FILES...` cannot be mixed with `--scope` or `--path`. Choose one +targeting style per invocation. With default noise suppression, the review also hides known low-signal test findings such as: diff --git a/openspec/changes/code-review-10-review-scope-modes/.openspec.yaml b/openspec/changes/code-review-10-review-scope-modes/.openspec.yaml new file mode 100644 index 0000000..bea0667 --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-17 diff --git a/openspec/changes/code-review-10-review-scope-modes/CHANGE_VALIDATION.md b/openspec/changes/code-review-10-review-scope-modes/CHANGE_VALIDATION.md new file mode 100644 index 0000000..3fc4d42 --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/CHANGE_VALIDATION.md @@ -0,0 +1,54 @@ +## Verification Report: code-review-10-review-scope-modes + +### Summary + +| Dimension | Status | +| --- | --- | +| Completeness | 4/4 apply-ready artifacts present (`proposal`, `design`, `specs`, `tasks`) | +| Correctness | Scope-mode and path-filter requirements align with upstream `code-review-10-review-scope-modes` | +| Coherence | Proposal, design, and tasks consistently keep changed-only as the default auto-scope | + +### Validation Checks + +- Reviewed upstream `specfact-cli` proposal, design, and spec delta for `code-review-10-review-scope-modes`. +- Reviewed local artifacts: `proposal.md`, `design.md`, `tasks.md`, and delta specs under `specs/`. +- Checked current modules-repo review-run capabilities from `code-review-08-review-run-integration` to ensure this is a requirement change on an existing command, not a new command surface. + +### CRITICAL + +None. + +### WARNING + +- The derived change assumes the bundle command can identify a stable governed + repository file set for `--scope full`. If existing runtime logic only + supports changed-file discovery, implementation may need a new helper to keep + full-review selection deterministic across unit and e2e tests. + Recommendation: verify current target-resolution boundaries before coding and + centralize scope resolution rather than duplicating it across command tests + and runtime entry points. + +### SUGGESTION + +- Keep cli-val scenarios narrow and representative. One changed-only example, + one full-review example, one subtree-filter example, and one invalid + invocation are enough to prove the contract without making scenario YAML noisy. + +### Dependency Analysis + +- Modified capabilities: `review-run-command`, `review-cli-contracts` +- Primary implementation touchpoint: `packages/specfact-code-review/src/specfact_code_review/run/` +- Primary regression surface: `tests/unit/`, `tests/e2e/`, and `tests/cli-contracts/` +- Upstream source dependency: `nold-ai/specfact-cli` change `code-review-10-review-scope-modes` + +### Final Assessment + +No critical issues found. The derived change is internally consistent, matches +the upstream intent, and is ready for implementation after strict OpenSpec +validation passes. + +### OpenSpec Validation + +- **Status**: Pass +- **Command**: `openspec validate code-review-10-review-scope-modes --strict` +- **Issues Found/Fixed**: 0 diff --git a/openspec/changes/code-review-10-review-scope-modes/TDD_EVIDENCE.md b/openspec/changes/code-review-10-review-scope-modes/TDD_EVIDENCE.md new file mode 100644 index 0000000..c25637c --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/TDD_EVIDENCE.md @@ -0,0 +1,106 @@ +# TDD Evidence: code-review-10 review scope modes + +## Failing + +### 2026-03-17 `tests/unit/specfact_code_review/run/test_commands.py` + +Command: + +```bash +HATCH_DATA_DIR=/tmp/hatch-data \ +HATCH_CACHE_DIR=/tmp/hatch-cache \ +VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata \ +hatch run pytest tests/unit/specfact_code_review/run/test_commands.py -q +``` + +Result: `5 failed, 14 passed` + +Key failures before implementation: + +- `test_run_command_supports_full_scope_and_path_filters` + - exit code was `2` because `--scope` was not supported yet +- `test_run_command_supports_changed_scope_with_repeatable_path_filters` + - exit code was `2` because `--scope`/`--path` were not supported yet +- `test_run_command_rejects_scope_mixed_with_positional_files` + - CLI returned the generic option error instead of the governed mixed-targeting message +- `test_run_command_rejects_path_mixed_with_positional_files` + - CLI returned the generic option error instead of the governed mixed-targeting message +- `test_run_command_fails_when_scope_and_paths_match_no_files` + - CLI returned the generic option error instead of an actionable empty-scope failure + +## Passing + +### 2026-03-17 Focused scope-mode tests + +Commands: + +```bash +HATCH_DATA_DIR=/tmp/hatch-data \ +HATCH_CACHE_DIR=/tmp/hatch-cache \ +VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata \ +hatch run pytest tests/unit/specfact_code_review/run/test_commands.py tests/unit/specfact_code_review/run/test_runner.py -q + +HATCH_DATA_DIR=/tmp/hatch-data \ +HATCH_CACHE_DIR=/tmp/hatch-cache \ +VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata \ +hatch run pytest tests/e2e/specfact_code_review/test_review_run_e2e.py -q + +HATCH_DATA_DIR=/tmp/hatch-data \ +HATCH_CACHE_DIR=/tmp/hatch-cache \ +VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata \ +hatch run validate-cli-contracts +``` + +Results: + +- `tests/unit/specfact_code_review/run/test_commands.py` and `tests/unit/specfact_code_review/run/test_runner.py`: `36 passed` +- `tests/e2e/specfact_code_review/test_review_run_e2e.py`: `2 passed` +- CLI contract validation: `Validated 3 CLI contract scenario files.` + +### 2026-03-17 SpecFact dogfood review + +Command: + +```bash +SPECFACT_ALLOW_UNSIGNED=1 \ +HATCH_DATA_DIR=/tmp/hatch-data \ +HATCH_CACHE_DIR=/tmp/hatch-cache \ +VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata \ +hatch run specfact code review run \ + --scope changed \ + --path packages/specfact-code-review \ + --path tests/unit/specfact_code_review \ + --json \ + --out /tmp/code-review-10-report.json +``` + +Result: + +- verdict: `PASS` +- score: `115` +- findings: `0` + +### 2026-03-17 Worktree quality gates + +Commands completed successfully: + +```bash +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run format +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run type-check +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run lint +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run check-bundle-imports +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run smart-test +HATCH_DATA_DIR=/tmp/hatch-data HATCH_CACHE_DIR=/tmp/hatch-cache VIRTUALENV_OVERRIDE_APP_DATA=/tmp/virtualenv-appdata hatch run contract-test +``` + +Results: + +- `type-check`: `0 errors, 0 warnings, 0 notes` +- `lint`: `10.00/10` +- `smart-test`: `383 passed` +- `contract-test`: `384 passed` + +Pending: + +- `verify-modules-signature --require-signature --payload-from-filesystem --enforce-version-bump` + - blocked until the final `packages/specfact-code-review/module-package.yaml` changes in this worktree are re-signed diff --git a/openspec/changes/code-review-10-review-scope-modes/design.md b/openspec/changes/code-review-10-review-scope-modes/design.md new file mode 100644 index 0000000..3f8ba3d --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/design.md @@ -0,0 +1,71 @@ +# Design: code-review scope modes for modules repo + +## Context + +`code-review-08-review-run-integration` gave the modules repo a working +`specfact code review run` command, but its implicit changed-file discovery is +too narrow for stepwise review debt cleanup. The upstream `specfact-cli` +contract now defines two auto-discovery modes, `changed` and `full`, plus +repo-relative path filters that can recursively narrow either mode. + +This repository owns the runtime command implementation, test fixtures, cli-val +scenario YAML, and bundle docs. The design therefore focuses on deterministic +file selection inside the bundle rather than on higher-level GitHub or +automation wiring. + +## Goals / Non-Goals + +**Goals:** +- Keep default no-argument behavior aligned with existing changed-file review +- Add `--scope changed|full` and repeatable `--path` filters at the bundle + command boundary +- Make subtree filtering recursive so users can review one package or test area + at a time +- Cover the new behavior with unit, e2e, and cli-val fixtures + +**Non-Goals:** +- Changing scoring, verdict mapping, or ledger integration +- Adding globbing or exclusion syntax beyond repo-relative path prefixes +- Reworking the internal runner ordering established in `code-review-08` + +## Decisions + +### Decision: Resolve scope before filtering by path + +The bundle should first compute the candidate file set from the selected scope, +then filter that set by any `--path` prefixes. That keeps the behavior stable +between tests and runtime, and makes it easy to explain to users. + +### Decision: Reject positional files mixed with scope controls + +Positional files already provide an explicit review target. Mixing them with +`--scope` or `--path` would create ambiguous precedence rules, so the command +should fail fast and tell the user to choose one targeting style. + +### Decision: Treat `--path` values as repo-relative recursive path segments + +Users asked for folder and subfolder limiting. Prefix-based matching is enough +for that need, but matching must happen on normalized path boundaries rather +than raw strings. That keeps `--path packages/specfact-code-review` scoped to +that package and its descendants instead of accidentally including sibling +paths such as `packages/specfact-code-review-old`, while still avoiding new +pattern syntax in the CLI contract. + +### Decision: Explicit path filters may opt matched tests back in + +The existing changed-only workflow excludes tests by default unless users pass +`--include-tests`. For scope-mode filtering, explicit `--path tests/...` +selection should count as intentional targeting, so matching test files remain +reviewable even when the global include-tests toggle stays at its default. + +## Risks / Trade-offs + +- [Risk] Full-review mode may surface far more findings than users expect. + Mitigation: keep it opt-in and document subtree filtering prominently. +- [Risk] Scope/path selection may vary between unit tests and e2e execution if + repo root detection is inconsistent. + Mitigation: centralize target resolution in one helper and exercise it + through both direct tests and installed-command tests. +- [Risk] cli-val scenarios could lag behind actual command behavior. + Mitigation: update review-run scenario YAML in the same change as the command + implementation and validate them together. diff --git a/openspec/changes/code-review-10-review-scope-modes/proposal.md b/openspec/changes/code-review-10-review-scope-modes/proposal.md new file mode 100644 index 0000000..7277cf2 --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/proposal.md @@ -0,0 +1,57 @@ +# Change: Reapply code-review-10 review scope modes in modules repo + +## Why + +The upstream `specfact-cli` change `code-review-10-review-scope-modes` defines +the governed contract for explicit changed-only vs. full review modes plus +repo-relative path filtering. The modules repository needs a matching active +change because the bundle-owned `specfact code review run` implementation, +runtime fixtures, and cli-val scenarios live here. + +Without this derived change, the upstream contract would exist only on paper: +the installed `specfact-code-review` bundle would still lack the runtime +behavior needed to review a full repo or a limited subtree in a controlled way. + +## What Changes + +- Extend the `specfact-code-review` bundle command to support `--scope changed` + and `--scope full`. +- Add repeatable `--path` filtering for repo-relative files and subfolders in + auto-discovery mode. +- Keep changed-only auto-discovery as the default behavior when users do not + pass positional files or an explicit scope. +- Treat explicit `--path tests/...` filters as an intentional opt-in for those + matched test files without changing the default auto-scope exclusion of tests. +- Add targeted tests, fixtures, and cli-val scenarios for changed-only reviews, + full reviews, filtered subtree reviews, and invalid scope combinations. +- Update bundle docs, changelog, and signed package metadata to describe the + new review-scope controls. + +## Capabilities + +### New Capabilities + +None. + +### Modified Capabilities + +- `review-run-command`: add explicit review-scope selection and repo-relative + path filtering to the bundle command +- `review-cli-contracts`: extend review-run scenario coverage for changed-only, + full-review, and subtree-limited invocations + +## Impact + +- Affects `packages/specfact-code-review/src/specfact_code_review/run/` command + parsing and target-file resolution +- Expands runtime validation in `tests/unit/`, `tests/e2e/`, and + `tests/cli-contracts/` +- Requires documentation, changelog, and module metadata updates for the + `specfact-code-review` bundle + +## Source Tracking + + +- **Source Change**: `code-review-10-review-scope-modes` +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: re-applied in modules repo diff --git a/openspec/changes/code-review-10-review-scope-modes/specs/review-cli-contracts/spec.md b/openspec/changes/code-review-10-review-scope-modes/specs/review-cli-contracts/spec.md new file mode 100644 index 0000000..dc63780 --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/specs/review-cli-contracts/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: cli-val scenarios exist for review command groups +The modules repository SHALL define cli-val-compatible scenario YAML files for +the `specfact code review run`, `ledger`, and `rules` command groups, including +scope-mode and path-filter coverage for the review run command. + +#### Scenario: review-run scenarios cover success, scope selection, and error paths +- **GIVEN** `tests/cli-contracts/specfact-code-review-run.scenarios.yaml` +- **WHEN** it is validated +- **THEN** it includes success coverage, changed-only and full-review scope examples, subtree-filtered examples, and an error or anti-pattern scenario + +#### Scenario: ledger scenarios cover update, status, and reset guardrails +- **GIVEN** `tests/cli-contracts/specfact-code-review-ledger.scenarios.yaml` +- **WHEN** it is validated +- **THEN** it includes `update`, `status`, and reset guardrail coverage + +#### Scenario: rules scenarios cover init, show, and update +- **GIVEN** `tests/cli-contracts/specfact-code-review-rules.scenarios.yaml` +- **WHEN** it is validated +- **THEN** it includes the supported rules subcommands diff --git a/openspec/changes/code-review-10-review-scope-modes/specs/review-run-command/spec.md b/openspec/changes/code-review-10-review-scope-modes/specs/review-run-command/spec.md new file mode 100644 index 0000000..52e4b40 --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/specs/review-run-command/spec.md @@ -0,0 +1,59 @@ +## 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 explicit `changed` and `full` auto-discovery modes plus repo-relative +path filtering, and emits a governed `ReviewReport` with correct exit codes. + +#### Scenario: Clean review fixture returns PASS +- **GIVEN** `tests/fixtures/review/clean_module.py` and its matching tests +- **WHEN** `specfact code review run tests/fixtures/review/clean_module.py` is executed +- **THEN** the report verdict is `PASS` and the process exits `0` + +#### Scenario: Dirty review fixture returns FAIL +- **GIVEN** `tests/fixtures/review/dirty_module.py` with blocking findings +- **WHEN** `specfact code review run tests/fixtures/review/dirty_module.py` is executed +- **THEN** the report verdict is `FAIL` and the process exits `1` + +#### Scenario: JSON output writes a valid ReviewReport to a file +- **GIVEN** a review run over one or more files +- **WHEN** `specfact code review run --json` is executed +- **THEN** the command writes a valid `ReviewReport` JSON payload to the selected output path +- **AND** stdout reports that output path instead of printing the full JSON payload + +#### Scenario: Score-only output emits only reward delta +- **GIVEN** a review run over one or more files +- **WHEN** `specfact code review run --score-only` is executed +- **THEN** stdout contains only the integer reward delta and a trailing newline + +#### Scenario: Default auto-discovery reviews changed files +- **GIVEN** no positional file arguments and no explicit `--scope` +- **WHEN** `specfact code review run` is executed +- **THEN** the command reviews files from the changed-file set for the current repo + +#### Scenario: Full scope reviews the governed repository file set +- **GIVEN** no positional file arguments +- **WHEN** `specfact code review run --scope full` is executed +- **THEN** the command reviews the governed repository Python file set instead of only changed files +- **AND** test files remain excluded by default unless users opt in with `--include-tests` or explicitly target test subtrees with `--path tests/...` + +#### Scenario: Full scope can be limited to a package subtree +- **GIVEN** no positional file arguments +- **WHEN** `specfact code review run --scope full --path packages/specfact-code-review` is executed +- **THEN** only reviewable files under that package path and its subfolders are included + +#### Scenario: Changed scope can be limited to test and source subtrees +- **GIVEN** changed files exist in multiple repository areas +- **WHEN** `specfact code review run --scope changed --path packages/specfact-code-review --path tests/unit/specfact_code_review` is executed +- **THEN** only changed files under those repo-relative prefixes are reviewed + +#### Scenario: Empty filtered scope fails fast +- **GIVEN** the selected scope and path filters match no reviewable files +- **WHEN** `specfact code review run` is executed +- **THEN** the command exits non-zero with an actionable empty-scope message + +#### Scenario: Positional files cannot be mixed with auto-scope controls +- **GIVEN** one or more positional file arguments +- **WHEN** `specfact code review run src/example.py --scope full --path src` is executed +- **THEN** the command rejects the invocation and instructs the user to choose positional files or auto-scope controls diff --git a/openspec/changes/code-review-10-review-scope-modes/tasks.md b/openspec/changes/code-review-10-review-scope-modes/tasks.md new file mode 100644 index 0000000..7d3d8dc --- /dev/null +++ b/openspec/changes/code-review-10-review-scope-modes/tasks.md @@ -0,0 +1,30 @@ +# Tasks: code-review-10 review scope modes + +## 1. Rehydrate scope and confirm prerequisites + +- [x] 1.1 Reapply upstream `code-review-10-review-scope-modes` in this modules repository +- [x] 1.2 Confirm `code-review-08-review-run-integration` behavior is the baseline for the bundle command +- [x] 1.3 Confirm the upstream default remains changed-only auto-discovery for automation compatibility + +## 2. Add failing tests and fixtures first + +- [x] 2.1 Add or extend unit tests for `--scope changed` and `--scope full` +- [x] 2.2 Add failing tests for repeatable `--path` subtree filtering in both scope modes +- [x] 2.3 Add failing tests for empty-scope and invalid targeting combinations +- [x] 2.4 Update cli-val `specfact-code-review-run` scenarios for changed-only, full-review, and subtree-filtered runs +- [x] 2.5 Run the new tests before implementation and record failing evidence in `TDD_EVIDENCE.md` + +## 3. Implement bundle scope selection + +- [x] 3.1 Extend `packages/specfact-code-review/src/specfact_code_review/run/commands.py` with `--scope` and repeatable `--path` +- [x] 3.2 Add or update helper logic for deterministic candidate-file selection and recursive subtree filtering +- [x] 3.3 Reject invocations that mix positional files with auto-scope controls +- [x] 3.4 Emit actionable failures when selected filters leave no reviewable files + +## 4. Validate and document the change + +- [x] 4.1 Record passing evidence for unit, e2e, and cli-val checks in `TDD_EVIDENCE.md` +- [x] 4.2 Update `docs/modules/code-review.md` with changed-only, full-review, and subtree-review examples +- [x] 4.3 Bump the `specfact-code-review` bundle minor version and update `CHANGELOG.md` +- [x] 4.4 Re-sign and verify the updated module package metadata +- [x] 4.5 Run format, type-check, lint, yaml-lint, contract-test, smart-test, targeted tests, runtime/docs validation, signed-manifest verification, and the final `test` gate in the worktree diff --git a/packages/specfact-code-review/module-package.yaml b/packages/specfact-code-review/module-package.yaml index 7f1b1e8..b8d331a 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.43.1 +version: 0.44.0 commands: - code tier: official @@ -22,5 +22,5 @@ description: Official SpecFact code review bundle package. category: codebase bundle_group_command: code integrity: - checksum: sha256:584e3ed7743470072dff872bff6b0f453baf70ee2ba31ad6c71fb724ca9e5c78 - signature: 47win2dUSo6BCLqRgtB7Z/dEQuvUJOdj78GzRj/kKroUV2VdIFLQX+PSIAgjhxW72hn+GI2hbnUmWuciSJ+uCg== + checksum: sha256:4821d747f0341fb8d7a4843619c0485e2a9b96ea9476c980ce9e3f2aba6a3e31 + signature: fCpAGDYn06PnRUz/LVMmxaVcgnffGUXFc+f7gti4imXQrwerPFg7IfvkFRRropzI1LmmKgh/8YSo64+bSSWXAQ== 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 bb7ef90..ff73483 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 @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import Literal import typer from icontract.errors import ViolationError @@ -21,6 +22,7 @@ def _friendly_run_command_error(exc: ValueError | ViolationError) -> str: for expected in ( "Use either --json or --score-only, not both.", "Use --out together with --json.", + "Choose positional files or auto-scope controls, not both.", ): if expected in message: return expected @@ -40,6 +42,17 @@ def _resolve_include_tests(*, files: list[Path], include_tests: bool | None, int @review_app.command("run") def _run( files: list[Path] = typer.Argument(None, metavar="FILES..."), + *, + scope: Literal["changed", "full"] | None = typer.Option( + None, + "--scope", + help="Auto-discovery scope when positional files are omitted: changed or full.", + ), + path_filters: list[Path] | None = typer.Option( + None, + "--path", + help="Repeatable repo-relative path prefix used to limit auto-discovered review files.", + ), include_tests: bool | None = typer.Option( None, "--include-tests/--exclude-tests", @@ -75,6 +88,8 @@ def _run( exit_code, output = run_command( files, include_tests=resolved_include_tests, + scope=scope, + path_filters=path_filters, include_noise=include_noise, json_output=json_output, out=out, 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 07230d0..b083bdf 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 @@ -5,7 +5,9 @@ import subprocess import sys from collections import defaultdict +from collections.abc import Iterable from pathlib import Path +from typing import Literal from beartype import beartype from icontract import ensure, require @@ -18,6 +20,7 @@ console = Console() progress_console = Console(stderr=True) +AutoScope = Literal["changed", "full"] def _is_test_file(file_path: Path) -> bool: @@ -58,10 +61,114 @@ def _changed_files_from_git_diff(*, include_tests: bool) -> list[Path]: return [file_path for file_path in deduped_python_files if not _is_test_file(file_path)] -def _resolve_files(files: list[Path], *, include_tests: bool) -> list[Path]: - resolved = files or _changed_files_from_git_diff(include_tests=include_tests) +def _all_python_files_from_git() -> list[Path]: + tracked_files = _git_file_list( + ["git", "ls-files", "--cached"], + error_message="Unable to determine tracked repository files from `git ls-files --cached`.", + ) + untracked_files = _git_file_list( + ["git", "ls-files", "--others", "--exclude-standard"], + error_message="Unable to determine untracked files from `git ls-files --others --exclude-standard`.", + ) + python_files = [ + file_path + for file_path in [*tracked_files, *untracked_files] + if file_path.suffix == ".py" and file_path.is_file() + ] + return list(dict.fromkeys(python_files)) + + +def _path_filter_matches(file_path: Path, path_filter: Path) -> bool: + return file_path == path_filter or path_filter in file_path.parents + + +def _filtered_files(files: Iterable[Path], *, path_filters: list[Path]) -> list[Path]: + if not path_filters: + return list(files) + normalized_filters = [path_filter for path_filter in path_filters if str(path_filter).strip()] + for path_filter in normalized_filters: + if path_filter.is_absolute(): + raise ValueError(f"Path filters must be repo-relative: {path_filter}") + return [ + file_path + for file_path in files + if any(_path_filter_matches(file_path, path_filter) for path_filter in normalized_filters) + ] + + +def _auto_scope_message(*, scope: AutoScope, path_filters: list[Path]) -> str: + parts = [f"--scope {scope}", *(f"--path {path_filter}" for path_filter in path_filters)] + return " ".join(parts) + + +def _raise_if_targeting_styles_conflict( + files: list[Path], + *, + scope: AutoScope | None, + path_filters: list[Path], +) -> None: + if files and (scope is not None or path_filters): + raise ValueError("Choose positional files or auto-scope controls, not both.") + + +def _resolve_positional_files(files: list[Path]) -> list[Path]: + if files: + return files + raise ValueError("No Python files to review were provided or detected from tracked or untracked changes.") + + +def _resolve_auto_discovered_files( + *, + include_tests: bool, + scope: AutoScope, + path_filters: list[Path], +) -> list[Path]: + if scope == "full": + return _resolve_full_scope_files(include_tests=include_tests, path_filters=path_filters) + return _resolve_changed_scope_files(include_tests=include_tests, path_filters=path_filters) + + +def _resolve_full_scope_files(*, include_tests: bool, path_filters: list[Path]) -> list[Path]: + resolved = _all_python_files_from_git() + if not include_tests and not path_filters: + return [file_path for file_path in resolved if not _is_test_file(file_path)] + return resolved + + +def _resolve_changed_scope_files(*, include_tests: bool, path_filters: list[Path]) -> list[Path]: + changed_include_tests = include_tests or bool(path_filters) + return _changed_files_from_git_diff(include_tests=changed_include_tests) + + +def _raise_for_empty_auto_scope(*, scope: AutoScope, path_filters: list[Path]) -> None: + auto_scope_message = _auto_scope_message(scope=scope, path_filters=path_filters) + raise ValueError( + f"No reviewable files matched the selected auto-scope controls ({auto_scope_message}). " + "Adjust --scope/--path or pass positional files." + ) + + +def _resolve_files( + files: list[Path], + *, + include_tests: bool, + scope: AutoScope | None, + path_filters: list[Path], +) -> list[Path]: + _raise_if_targeting_styles_conflict(files, scope=scope, path_filters=path_filters) + if files: + resolved = _resolve_positional_files(files) + else: + selected_scope: AutoScope = scope or "changed" + resolved = _resolve_auto_discovered_files( + include_tests=include_tests, + scope=selected_scope, + path_filters=path_filters, + ) + resolved = _filtered_files(resolved, path_filters=path_filters) + if not resolved: - raise ValueError("No Python files to review were provided or detected from tracked or untracked changes.") + _raise_for_empty_auto_scope(scope=scope or "changed", path_filters=path_filters) missing = [file_path for file_path in resolved if not file_path.is_file()] if missing: @@ -195,6 +302,8 @@ def run_command( files: list[Path] | None = None, *, include_tests: bool = False, + scope: AutoScope | None = None, + path_filters: list[Path] | None = None, include_noise: bool = False, json_output: bool = False, out: Path | None = None, @@ -203,7 +312,12 @@ def run_command( fix: bool = False, ) -> tuple[int, str | None]: """Execute a governed review run over the provided files.""" - resolved_files = _resolve_files(files or [], include_tests=include_tests) + resolved_files = _resolve_files( + files or [], + include_tests=include_tests, + scope=scope, + path_filters=path_filters or [], + ) report = _run_review_with_progress( resolved_files, no_tests=no_tests, 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 639e12e..ca1d90f 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 @@ -122,7 +122,7 @@ def _pytest_targets(test_files: list[Path]) -> list[Path]: if len(test_files) <= 1: return test_files common_root = Path(os.path.commonpath([str(test_file) for test_file in test_files])) - if common_root.is_dir() and common_root.parts[:2] == ("tests", "unit"): + if common_root.is_dir() and common_root.parts[:2] == ("tests", "unit") and len(common_root.parts) > 3: return [common_root] return test_files @@ -147,7 +147,7 @@ def _run_pytest_with_coverage(test_files: list[Path]) -> tuple[subprocess.Comple capture_output=True, text=True, check=False, - timeout=30, + timeout=120, env=_pytest_env(), ) return result, coverage_path diff --git a/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py b/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py index 539bba6..3d1af20 100644 --- a/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py +++ b/packages/specfact-code-review/src/specfact_code_review/tools/semgrep_runner.py @@ -7,6 +7,7 @@ import subprocess import tempfile from pathlib import Path +from typing import Literal, cast from beartype import beartype from icontract import ensure, require @@ -22,6 +23,8 @@ "print-in-src": "architecture", } SEMGREP_TIMEOUT_SECONDS = 90 +SEMGREP_RETRY_ATTEMPTS = 2 +SemgrepCategory = Literal["clean_code", "architecture"] def _normalize_path_variants(path_value: str | Path) -> set[str]: @@ -76,6 +79,101 @@ def _resolve_config_path() -> Path: raise FileNotFoundError(f"Semgrep config not found at {config_path}") +def _run_semgrep_command(files: list[Path]) -> subprocess.CompletedProcess[str]: + with tempfile.TemporaryDirectory(prefix="semgrep-home-") as temp_home: + semgrep_home = Path(temp_home) + semgrep_log_dir = semgrep_home / ".semgrep" + semgrep_log_dir.mkdir(parents=True, exist_ok=True) + env = os.environ.copy() + env["HOME"] = str(semgrep_home) + env["XDG_CONFIG_HOME"] = str(semgrep_home / ".config") + env["XDG_CACHE_HOME"] = str(semgrep_home / ".cache") + env["SEMGREP_SETTINGS_FILE"] = str(semgrep_log_dir / "settings.yml") + env.setdefault("SEMGREP_SEND_METRICS", "off") + return subprocess.run( + [ + "semgrep", + "--disable-version-check", + "--config", + str(_resolve_config_path()), + "--json", + *(str(file_path) for file_path in files), + ], + capture_output=True, + text=True, + check=False, + timeout=SEMGREP_TIMEOUT_SECONDS, + env=env, + ) + + +def _load_semgrep_results(files: list[Path]) -> list[object]: + last_error: Exception | None = None + for _attempt in range(SEMGREP_RETRY_ATTEMPTS): + try: + result = _run_semgrep_command(files) + payload = json.loads(result.stdout) + if not isinstance(payload, dict): + raise ValueError("semgrep output must be an object") + raw_results = payload.get("results", []) + if not isinstance(raw_results, list): + raise ValueError("semgrep results must be a list") + return raw_results + except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: + last_error = exc + if last_error is None: + raise ValueError("semgrep returned no usable results") + raise last_error + + +def _category_for_rule(rule: str) -> SemgrepCategory | None: + category = SEMGREP_RULE_CATEGORY.get(rule) + if category in {"clean_code", "architecture"}: + return cast(SemgrepCategory, category) + return None + + +def _finding_from_result(item: dict[str, object], *, allowed_paths: set[str]) -> ReviewFinding | None: + filename = item["path"] + if not isinstance(filename, str): + raise ValueError("semgrep filename must be a string") + if _normalize_path_variants(filename).isdisjoint(allowed_paths): + return None + + raw_rule = item["check_id"] + if not isinstance(raw_rule, str): + raise ValueError("semgrep rule must be a string") + rule = _normalize_rule_id(raw_rule) + category = _category_for_rule(rule) + if category is None: + return None + + start = item["start"] + if not isinstance(start, dict): + raise ValueError("semgrep start location must be an object") + line = start["line"] + if not isinstance(line, int): + raise ValueError("semgrep line must be an integer") + + extra = item["extra"] + if not isinstance(extra, dict): + raise ValueError("semgrep extra payload must be an object") + message = extra["message"] + if not isinstance(message, str): + raise ValueError("semgrep message must be a string") + + return ReviewFinding( + category=category, + severity="warning", + tool="semgrep", + rule=rule, + file=filename, + line=line, + message=message, + fixable=False, + ) + + @beartype @require(lambda files: isinstance(files, list), "files must be a list") @require(lambda files: all(isinstance(file_path, Path) for file_path in files), "files must contain Path instances") @@ -90,37 +188,7 @@ def run_semgrep(files: list[Path]) -> list[ReviewFinding]: return [] try: - with tempfile.TemporaryDirectory(prefix="semgrep-home-") as temp_home: - semgrep_home = Path(temp_home) - semgrep_log_dir = semgrep_home / ".semgrep" - semgrep_log_dir.mkdir(parents=True, exist_ok=True) - env = os.environ.copy() - env["HOME"] = str(semgrep_home) - env["XDG_CONFIG_HOME"] = str(semgrep_home / ".config") - env["XDG_CACHE_HOME"] = str(semgrep_home / ".cache") - env["SEMGREP_SETTINGS_FILE"] = str(semgrep_log_dir / "settings.yml") - env.setdefault("SEMGREP_SEND_METRICS", "off") - result = subprocess.run( - [ - "semgrep", - "--disable-version-check", - "--config", - str(_resolve_config_path()), - "--json", - *(str(file_path) for file_path in files), - ], - capture_output=True, - text=True, - check=False, - timeout=SEMGREP_TIMEOUT_SECONDS, - env=env, - ) - payload = json.loads(result.stdout) - if not isinstance(payload, dict): - raise ValueError("semgrep output must be an object") - raw_results = payload.get("results", []) - if not isinstance(raw_results, list): - raise ValueError("semgrep results must be a list") + raw_results = _load_semgrep_results(files) except (FileNotFoundError, OSError, ValueError, json.JSONDecodeError, subprocess.TimeoutExpired) as exc: return _tool_error(files[0], f"Unable to parse Semgrep output: {exc}") @@ -130,46 +198,9 @@ def run_semgrep(files: list[Path]) -> list[ReviewFinding]: for item in raw_results: if not isinstance(item, dict): raise ValueError("semgrep finding must be an object") - filename = item["path"] - if not isinstance(filename, str): - raise ValueError("semgrep filename must be a string") - if _normalize_path_variants(filename).isdisjoint(allowed_paths): - continue - - raw_rule = item["check_id"] - if not isinstance(raw_rule, str): - raise ValueError("semgrep rule must be a string") - rule = _normalize_rule_id(raw_rule) - category = SEMGREP_RULE_CATEGORY.get(rule) - if category is None: - continue - - start = item["start"] - if not isinstance(start, dict): - raise ValueError("semgrep start location must be an object") - line = start["line"] - if not isinstance(line, int): - raise ValueError("semgrep line must be an integer") - - extra = item["extra"] - if not isinstance(extra, dict): - raise ValueError("semgrep extra payload must be an object") - message = extra["message"] - if not isinstance(message, str): - raise ValueError("semgrep message must be a string") - - findings.append( - ReviewFinding( - category=category, - severity="warning", - tool="semgrep", - rule=rule, - file=filename, - line=line, - message=message, - fixable=False, - ) - ) + finding = _finding_from_result(item, allowed_paths=allowed_paths) + if finding is not None: + findings.append(finding) except (KeyError, TypeError, ValueError) as exc: return _tool_error(files[0], f"Unable to parse Semgrep finding payload: {exc}") diff --git a/tests/cli-contracts/specfact-code-review-run.scenarios.yaml b/tests/cli-contracts/specfact-code-review-run.scenarios.yaml index e815dee..7be31de 100644 --- a/tests/cli-contracts/specfact-code-review-run.scenarios.yaml +++ b/tests/cli-contracts/specfact-code-review-run.scenarios.yaml @@ -9,6 +9,32 @@ scenarios: exit_code: 0 stdout_contains: - '"overall_verdict":"PASS"' + - name: full-scope-package-subtree + type: pattern + argv: + - --scope + - full + - --path + - packages/specfact-code-review + - --json + expect: + exit_code: 0 + stdout_contains: + - '"run_id":' + - name: changed-scope-subtree + type: pattern + argv: + - --scope + - changed + - --path + - packages/specfact-code-review + - --path + - tests/unit/specfact_code_review + - --json + expect: + exit_code: 0 + stdout_contains: + - '"run_id":' - name: missing-file-errors type: anti-pattern argv: @@ -17,3 +43,15 @@ scenarios: exit_code: 2 stderr_contains: - not found + - name: positional-files-cannot-mix-with-scope + type: anti-pattern + argv: + - tests/fixtures/review/clean_module.py + - --scope + - full + - --path + - tests/fixtures/review + expect: + exit_code: 2 + stderr_contains: + - choose positional files or auto-scope controls diff --git a/tests/unit/specfact_code_review/run/test_commands.py b/tests/unit/specfact_code_review/run/test_commands.py index 92dd259..ed9795c 100644 --- a/tests/unit/specfact_code_review/run/test_commands.py +++ b/tests/unit/specfact_code_review/run/test_commands.py @@ -28,6 +28,13 @@ def _report(*, score: int = 85) -> ReviewReport: ) +def _write_repo_file(repo_root: Path, relative_path: str, *, content: str = "VALUE = 1\n") -> Path: + file_path = repo_root / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + file_path.write_text(content, encoding="utf-8") + return Path(relative_path) + + def test_run_command_json_output_uses_review_report(monkeypatch: Any, tmp_path: Path) -> None: monkeypatch.setattr( "specfact_code_review.run.commands.run_review", @@ -105,6 +112,96 @@ def fake_run_review(files: list[Path], **_kwargs: Any) -> ReviewReport: assert out.exists() +def test_run_command_supports_full_scope_and_path_filters(monkeypatch: Any, tmp_path: Path) -> None: + package_file = _write_repo_file( + tmp_path, + "packages/specfact-code-review/src/specfact_code_review/run/commands.py", + ) + _write_repo_file(tmp_path, "packages/specfact-backlog/src/specfact_backlog/commands.py") + monkeypatch.chdir(tmp_path) + + recorded: dict[str, list[Path]] = {} + monkeypatch.setattr( + "specfact_code_review.run.commands._all_python_files_from_git", + lambda: [package_file, Path("packages/specfact-backlog/src/specfact_backlog/commands.py")], + raising=False, + ) + + def fake_run_review(files: list[Path], **_kwargs: Any) -> ReviewReport: + recorded["files"] = files + return _report() + + monkeypatch.setattr("specfact_code_review.run.commands.run_review", fake_run_review) + + result = runner.invoke( + app, + [ + "review", + "run", + "--scope", + "full", + "--path", + "packages/specfact-code-review", + "--json", + "--out", + "review-report.json", + ], + ) + + assert result.exit_code == 0 + assert recorded["files"] == [package_file] + + +def test_run_command_supports_changed_scope_with_repeatable_path_filters(monkeypatch: Any, tmp_path: Path) -> None: + package_file = _write_repo_file( + tmp_path, + "packages/specfact-code-review/src/specfact_code_review/run/commands.py", + ) + test_file = _write_repo_file( + tmp_path, + "tests/unit/specfact_code_review/run/test_commands.py", + content="def test_scope_paths() -> None:\n assert True\n", + ) + _write_repo_file(tmp_path, "packages/specfact-backlog/src/specfact_backlog/commands.py") + monkeypatch.chdir(tmp_path) + + recorded: dict[str, list[Path]] = {} + monkeypatch.setattr( + "specfact_code_review.run.commands._changed_files_from_git_diff", + lambda *, include_tests: [ + package_file, + test_file, + Path("packages/specfact-backlog/src/specfact_backlog/commands.py"), + ], + ) + + def fake_run_review(files: list[Path], **_kwargs: Any) -> ReviewReport: + recorded["files"] = files + return _report() + + monkeypatch.setattr("specfact_code_review.run.commands.run_review", fake_run_review) + + result = runner.invoke( + app, + [ + "review", + "run", + "--scope", + "changed", + "--path", + "packages/specfact-code-review", + "--path", + "tests/unit/specfact_code_review", + "--json", + "--out", + "review-report.json", + ], + ) + + assert result.exit_code == 0 + assert recorded["files"] == [package_file, test_file] + + def test_run_command_rejects_out_without_json(tmp_path: Path) -> None: out = tmp_path / "review-report.json" result = runner.invoke(app, ["review", "run", "--out", str(out), "tests/fixtures/review/clean_module.py"]) @@ -141,6 +238,38 @@ def test_run_command_rejects_json_and_score_only_together() -> None: assert "not both" in result.output +def test_run_command_rejects_scope_mixed_with_positional_files() -> None: + result = runner.invoke( + app, + [ + "review", + "run", + "tests/fixtures/review/clean_module.py", + "--scope", + "full", + ], + ) + + assert result.exit_code == 2 + assert "choose positional files or auto-scope controls" in result.output.lower() + + +def test_run_command_rejects_path_mixed_with_positional_files() -> None: + result = runner.invoke( + app, + [ + "review", + "run", + "tests/fixtures/review/clean_module.py", + "--path", + "tests/fixtures/review", + ], + ) + + assert result.exit_code == 2 + assert "choose positional files or auto-scope controls" in result.output.lower() + + def test_run_command_fix_mode_applies_fixes_before_second_run(monkeypatch: Any) -> None: calls: list[str] = [] @@ -188,6 +317,35 @@ def test_run_command_default_output_renders_findings(monkeypatch: Any) -> None: assert "Rendered output report." in result.output +def test_run_command_fails_when_scope_and_paths_match_no_files(monkeypatch: Any, tmp_path: Path) -> None: + package_file = _write_repo_file( + tmp_path, + "packages/specfact-code-review/src/specfact_code_review/run/commands.py", + ) + monkeypatch.chdir(tmp_path) + monkeypatch.setattr( + "specfact_code_review.run.commands._all_python_files_from_git", + lambda: [package_file], + raising=False, + ) + + result = runner.invoke( + app, + [ + "review", + "run", + "--scope", + "full", + "--path", + "packages/specfact-backlog", + ], + ) + + assert result.exit_code == 2 + assert "no reviewable files" in result.output.lower() + assert "--scope full" in result.output + + def test_changed_files_from_git_diff_filters_python_files(monkeypatch: Any, tmp_path: Path) -> None: python_file = tmp_path / "example.py" python_file.write_text("VALUE = 1\n", encoding="utf-8") diff --git a/tests/unit/specfact_code_review/run/test_runner.py b/tests/unit/specfact_code_review/run/test_runner.py index a82f5fa..cd902f3 100644 --- a/tests/unit/specfact_code_review/run/test_runner.py +++ b/tests/unit/specfact_code_review/run/test_runner.py @@ -260,6 +260,35 @@ def test_run_review_suppresses_global_duplicate_code_noise_by_default(monkeypatc assert report.findings == [] +def test_pytest_targets_collapses_only_specific_subdirectories(tmp_path: Path) -> None: + run_tests = tmp_path / "tests/unit/specfact_code_review/run" + run_tests.mkdir(parents=True) + first = run_tests / "test_commands.py" + second = run_tests / "test_runner.py" + first.write_text("def test_one():\n assert True\n", encoding="utf-8") + second.write_text("def test_two():\n assert True\n", encoding="utf-8") + + assert _pytest_targets([first.relative_to(tmp_path), second.relative_to(tmp_path)]) == [ + Path("tests/unit/specfact_code_review/run") + ] + + +def test_pytest_targets_keeps_files_when_common_root_is_too_broad(tmp_path: Path) -> None: + run_tests = tmp_path / "tests/unit/specfact_code_review/run" + review_tests = tmp_path / "tests/unit/specfact_code_review/review" + run_tests.mkdir(parents=True) + review_tests.mkdir(parents=True) + first = run_tests / "test_commands.py" + second = review_tests / "test_commands.py" + first.write_text("def test_one():\n assert True\n", encoding="utf-8") + second.write_text("def test_two():\n assert True\n", encoding="utf-8") + + assert _pytest_targets([first.relative_to(tmp_path), second.relative_to(tmp_path)]) == [ + Path("tests/unit/specfact_code_review/run/test_commands.py"), + Path("tests/unit/specfact_code_review/review/test_commands.py"), + ] + + def test_run_review_can_include_global_duplicate_code_noise(monkeypatch: MonkeyPatch) -> None: duplicate_code_finding = ReviewFinding( category="style", diff --git a/tests/unit/specfact_code_review/tools/test_semgrep_runner.py b/tests/unit/specfact_code_review/tools/test_semgrep_runner.py index bae4ca5..70f0de1 100644 --- a/tests/unit/specfact_code_review/tools/test_semgrep_runner.py +++ b/tests/unit/specfact_code_review/tools/test_semgrep_runner.py @@ -111,7 +111,57 @@ def test_run_semgrep_returns_empty_list_for_clean_file(tmp_path: Path, monkeypat findings = run_semgrep([file_path]) - assert findings == [] + assert not findings + + +def test_run_semgrep_ignores_unsupported_rules(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + file_path = tmp_path / "target.py" + payload = { + "results": [ + { + "check_id": "unsupported-rule", + "path": str(file_path), + "start": {"line": 3}, + "extra": {"message": "Ignored rule."}, + } + ] + } + monkeypatch.setattr( + subprocess, + "run", + Mock(return_value=completed_process("semgrep", stdout=json.dumps(payload), returncode=1)), + ) + + findings = run_semgrep([file_path]) + + assert not findings + + +def test_run_semgrep_retries_after_transient_parse_failure(tmp_path: Path, monkeypatch: MonkeyPatch) -> None: + file_path = tmp_path / "target.py" + payload = { + "results": [ + { + "check_id": "unguarded-nested-access", + "path": str(file_path), + "start": {"line": 2}, + "extra": {"message": "Nested attribute access can fail."}, + } + ] + } + run_mock = Mock( + side_effect=[ + completed_process("semgrep", stdout="", returncode=2), + completed_process("semgrep", stdout=json.dumps(payload), returncode=1), + ] + ) + monkeypatch.setattr(subprocess, "run", run_mock) + + findings = run_semgrep([file_path]) + + assert len(findings) == 1 + assert findings[0].rule == "unguarded-nested-access" + assert run_mock.call_count == 2 @pytest.mark.parametrize(("fixture_name", "expected_rule"), list(BAD_FIXTURE_RULES.items())) @@ -132,4 +182,4 @@ def test_each_good_fixture_triggers_no_findings(fixture_name: str) -> None: findings = run_semgrep([FIXTURE_ROOT / fixture_name]) - assert findings == [] + assert not findings From e7cfd3f11cebbf923a9f67780f46005f5d94cc45 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 17 Mar 2026 22:02:31 +0100 Subject: [PATCH 2/3] test(code-review): stabilize styled CLI assertion --- tests/unit/specfact_code_review/run/test_commands.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit/specfact_code_review/run/test_commands.py b/tests/unit/specfact_code_review/run/test_commands.py index ed9795c..432b71d 100644 --- a/tests/unit/specfact_code_review/run/test_commands.py +++ b/tests/unit/specfact_code_review/run/test_commands.py @@ -343,7 +343,8 @@ def test_run_command_fails_when_scope_and_paths_match_no_files(monkeypatch: Any, assert result.exit_code == 2 assert "no reviewable files" in result.output.lower() - assert "--scope full" in result.output + assert "scope" in result.output.lower() + assert "full" in result.output.lower() def test_changed_files_from_git_diff_filters_python_files(monkeypatch: Any, tmp_path: Path) -> None: From f32d8765edc8ea3065690acc1e42d50774304812 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 17 Mar 2026 21:07:01 +0000 Subject: [PATCH 3/3] chore(registry): publish changed modules [skip ci] --- registry/index.json | 6 +++--- .../modules/specfact-code-review-0.44.0.tar.gz | Bin 0 -> 23634 bytes .../specfact-code-review-0.44.0.tar.gz.sha256 | 1 + .../specfact-code-review-0.44.0.tar.sig | 1 + 4 files changed, 5 insertions(+), 3 deletions(-) create mode 100644 registry/modules/specfact-code-review-0.44.0.tar.gz create mode 100644 registry/modules/specfact-code-review-0.44.0.tar.gz.sha256 create mode 100644 registry/signatures/specfact-code-review-0.44.0.tar.sig diff --git a/registry/index.json b/registry/index.json index b18d1e5..fec36af 100644 --- a/registry/index.json +++ b/registry/index.json @@ -73,9 +73,9 @@ }, { "id": "nold-ai/specfact-code-review", - "latest_version": "0.43.1", - "download_url": "modules/specfact-code-review-0.43.1.tar.gz", - "checksum_sha256": "678e79fa4004b3293f9f673e0ac74be55fcb2c1036af3982138e6b09685a3176", + "latest_version": "0.44.0", + "download_url": "modules/specfact-code-review-0.44.0.tar.gz", + "checksum_sha256": "6b0f48495c45c9fe2f0127ce5a76e4cdd60915f9080bfe68d224169718373643", "tier": "official", "publisher": { "name": "nold-ai", diff --git a/registry/modules/specfact-code-review-0.44.0.tar.gz b/registry/modules/specfact-code-review-0.44.0.tar.gz new file mode 100644 index 0000000000000000000000000000000000000000..3fb2ea84bac42709e9697ade7248a573b1e3e7d7 GIT binary patch literal 23634 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 literal 0 HcmV?d00001 diff --git a/registry/modules/specfact-code-review-0.44.0.tar.gz.sha256 b/registry/modules/specfact-code-review-0.44.0.tar.gz.sha256 new file mode 100644 index 0000000..3e1a00e --- /dev/null +++ b/registry/modules/specfact-code-review-0.44.0.tar.gz.sha256 @@ -0,0 +1 @@ +6b0f48495c45c9fe2f0127ce5a76e4cdd60915f9080bfe68d224169718373643 diff --git a/registry/signatures/specfact-code-review-0.44.0.tar.sig b/registry/signatures/specfact-code-review-0.44.0.tar.sig new file mode 100644 index 0000000..068e9a4 --- /dev/null +++ b/registry/signatures/specfact-code-review-0.44.0.tar.sig @@ -0,0 +1 @@ +fCpAGDYn06PnRUz/LVMmxaVcgnffGUXFc+f7gti4imXQrwerPFg7IfvkFRRropzI1LmmKgh/8YSo64+bSSWXAQ==