diff --git a/CHANGELOG.md b/CHANGELOG.md index 55ddfca7..d2939cfc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,7 @@ All notable changes to this project will be documented in this file. - Reference doc [Thorough Codebase Validation](docs/reference/thorough-codebase-validation.md) covering quick check (`specfact repro`), thorough contract-decorated (`hatch run contract-test-full`), sidecar for unmodified code, and dogfooding (repro + contract-test-full on specfact-cli). - Unit test and TDD evidence for CrossHair per-path timeout passthrough. - **Init module discovery alignment** (backlog-core-01): `specfact init` now uses the same module discovery roots as command registration (`discover_all_package_metadata()`), so `--list-modules`, `--enable-module`, and `--disable-module` operate on all discovered modules including workspace-level ones (e.g. `modules/backlog-core/`). Closes [#116](https://github.com/nold-ai/specfact-cli/issues/116) scope for init-module-discovery-alignment. +- **Patch mode module** (patch-mode-01, [#177](https://github.com/nold-ai/specfact-cli/issues/177)): `specfact patch apply ` for local apply with preflight; `specfact patch apply --write --yes` for explicit upstream write orchestration and idempotency (`check_idempotent` / `mark_applied`). ### Changed @@ -27,24 +28,11 @@ All notable changes to this project will be documented in this file. - `specfact repro --crosshair-per-path-timeout 0` (or negative) now fails with a clear error instead of being silently ignored; CLI rejects non-positive CrossHair per-path timeout values. --- - ## [Unreleased] ### Added -- **Patch mode module** (patch-mode-01, [#177](https://github.com/nold-ai/specfact-cli/issues/177)): `specfact patch apply ` for local apply with preflight; `specfact patch apply --write --yes` for upstream write with confirmation and idempotency. Pipeline: `generate_unified_diff`, `apply_patch_local`, `apply_patch_write`, `check_idempotent` / `mark_applied`. - ---- - -## [0.34.0] - 2026-02-18 - -### Added - -- **Init module discovery alignment** (backlog-core-01): `specfact init` now uses the same module discovery roots as command registration (`discover_all_package_metadata()`), so `--list-modules`, `--enable-module`, and `--disable-module` operate on all discovered modules including workspace-level ones (e.g. `modules/backlog-core/`). Closes [#116](https://github.com/nold-ai/specfact-cli/issues/116) scope for init-module-discovery-alignment. - -### Changed - -- `specfact init` module state and validation now build from `discover_all_package_metadata()` instead of `discover_package_metadata(get_modules_root())`, aligning enable/disable and list-modules with runtime command discovery. +- None yet. --- diff --git a/docs/reference/commands.md b/docs/reference/commands.md index c54d4523..c8d08fa3 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -4098,6 +4098,39 @@ specfact backlog refine ado \ --iteration "Project\\Release 1\\Sprint 1" ``` +#### `patch apply` + +Apply a unified diff patch locally with preflight validation, or run explicit upstream-write orchestration. + +```bash +specfact patch apply [OPTIONS] +``` + +**Options:** + +- `--dry-run` - Validate patch applicability only; do not apply locally +- `--write` - Run upstream write orchestration path (requires confirmation) +- `--yes`, `-y` - Confirm `--write` operation explicitly + +**Behavior:** + +- Local mode (`specfact patch apply `) runs preflight then applies the patch to local files. +- `--write` never runs unless `--yes` is provided. +- Repeated `--write --yes` invocations for the same patch are idempotent and skip duplicate writes. + +**Examples:** + +```bash +# Apply patch locally after preflight +specfact patch apply backlog.diff + +# Validate patch only +specfact patch apply backlog.diff --dry-run + +# Run explicit upstream write orchestration +specfact patch apply backlog.diff --write --yes +``` + **Pre-built Templates:** - `user_story_v1` - User story format (As a / I want / So that / Acceptance Criteria) diff --git a/openspec/changes/verification-01-wave1-delta-closure/CHANGE_VALIDATION.md b/openspec/changes/verification-01-wave1-delta-closure/CHANGE_VALIDATION.md new file mode 100644 index 00000000..630d040b --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/CHANGE_VALIDATION.md @@ -0,0 +1,63 @@ +# Change Validation: verification-01-wave1-delta-closure + +- **Validated on (UTC):** 2026-02-18T21:34:59Z +- **Workflow:** /wf-validate-change (proposal-stage dry-run validation) +- **Strict command:** `openspec validate verification-01-wave1-delta-closure --strict` +- **Result:** PASS + +## Scope Summary + +- **Change type:** Delta verification closure for previously merged Wave 1 scope. +- **Modified capabilities:** `bundle-mapping`, `patch-mode`, `cli-output`. +- **Declared dependencies:** existing Wave 1 changes `#177`, `#163`, `#116`, `#121`. +- **Primary targets:** + - `src/specfact_cli/modules/backlog/src/commands.py` + - `modules/bundle-mapper/src/bundle_mapper/*` + - `src/specfact_cli/modules/patch_mode/src/patch_mode/*` + - `docs/reference/commands.md` + - `docs/guides/backlog-refinement.md` + - `CHANGELOG.md` + +## Dependency and Integration Analysis (Dry-Run) + +### 1) bundle-mapper runtime integration + +- `--auto-bundle` is exposed in backlog refine command options, but runtime currently ends with a pending integration message instead of executing mapping hooks. +- Evidence: + - option exists: `src/specfact_cli/modules/backlog/src/commands.py:2658` + - pending placeholder path: `src/specfact_cli/modules/backlog/src/commands.py:3600` + - hooks module is currently a stub docstring: `modules/bundle-mapper/src/bundle_mapper/commands/__init__.py:1` +- Integration impact: refine/import orchestration, OpenSpec bundle assignment flow, mapping history persistence. + +### 2) patch-mode behavioral completion + +- CLI command surface is present and discoverable, but applier implementation currently behaves as a stub success path. +- Evidence: + - command entrypoint exists: `src/specfact_cli/modules/patch_mode/src/patch_mode/commands/apply.py` + - local apply returns `True` after read/validation without patch execution: `src/specfact_cli/modules/patch_mode/src/patch_mode/pipeline/applier.py:14` + - write apply returns `True` after read/confirmation without provider write orchestration: `src/specfact_cli/modules/patch_mode/src/patch_mode/pipeline/applier.py:31` +- Integration impact: patch pipeline trust model, adapter writeback orchestration, idempotency marker semantics. + +### 3) release docs/changelog parity + +- Documentation currently states auto-bundle behavior as operational while runtime is pending. +- `CHANGELOG.md` has duplicate `0.34.0` sections and patch-mode details placed under `Unreleased`. +- Evidence: + - docs claim auto-bundle import: `docs/reference/commands.md:3986`, `docs/guides/backlog-refinement.md:438` + - runtime pending message: `src/specfact_cli/modules/backlog/src/commands.py:3600` + - duplicate release headings: `CHANGELOG.md:11`, `CHANGELOG.md:39` + +## Breaking-Change Risk Assessment + +- **Proposal-stage only:** no production code modifications were performed during validation. +- **Expected implementation risk:** medium. + - `bundle-mapper` completion changes refine/import behavior paths but should be additive when `--auto-bundle` is explicitly requested. + - `patch-mode` completion may alter command side-effects; confirmation/idempotency contracts must remain explicit to avoid accidental writes. + - docs/changelog updates are non-runtime but release-governance critical. +- **Compatibility posture:** target behavior is extension/completion of existing command contracts; no mandatory public signature removals are proposed. + +## Strict Validation Outcome + +- Required artifacts present: `proposal.md`, `tasks.md`, and `specs/*/spec.md`. +- Strict OpenSpec validation passed for `verification-01-wave1-delta-closure`. +- Change is ready for implementation-phase intake after TDD-first execution. diff --git a/openspec/changes/verification-01-wave1-delta-closure/TDD_EVIDENCE.md b/openspec/changes/verification-01-wave1-delta-closure/TDD_EVIDENCE.md new file mode 100644 index 00000000..d6615c30 --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/TDD_EVIDENCE.md @@ -0,0 +1,27 @@ +# TDD Evidence - verification-01-wave1-delta-closure + +## Pre-Implementation Failing Run + +- Timestamp: 2026-02-18 23:00:00 UTC +- Command: + - `hatch run pytest tests/unit/specfact_cli/modules/test_patch_mode.py tests/unit/commands/test_backlog_bundle_mapping_delta.py tests/unit/docs/test_release_docs_parity.py -q` +- Result: **FAILED** (9 failed, 12 passed) + +### Failure Summary + +- Patch local apply is still a stub path: + - valid unified diff did not modify target file + - invalid patch returned success instead of failure +- Patch write path did not fail for invalid patch orchestration preflight. +- Backlog bundle-mapper runtime hooks were missing (`_route_bundle_mapping_decision`, `_apply_bundle_mappings_for_items`, dependency loader). +- Changelog/docs parity issues remained: + - duplicate `0.34.0` headers in `CHANGELOG.md` + - patch-mode entry remained in `Unreleased` + - command reference lacked `specfact patch apply` documentation. + +## Post-Implementation Passing Run + +- Timestamp: 2026-02-18 23:06:00 UTC +- Command: + - `hatch run pytest tests/unit/specfact_cli/modules/test_patch_mode.py tests/unit/commands/test_backlog_bundle_mapping_delta.py tests/unit/docs/test_release_docs_parity.py -q` +- Result: **PASSED** (21 passed) diff --git a/openspec/changes/verification-01-wave1-delta-closure/proposal.md b/openspec/changes/verification-01-wave1-delta-closure/proposal.md new file mode 100644 index 00000000..b718157a --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/proposal.md @@ -0,0 +1,52 @@ +# Change: Wave 1 Delta Closure for Verification Gaps + +## Why + +Wave 1 changes were merged into `dev` for release `v0.34.0`, but post-merge verification identified implementation-to-spec and docs-to-reality gaps that affect trust and adoption: + +- `bundle-mapper-01` engine code exists, but `--auto-bundle` flow is not wired end-to-end in backlog refine/import runtime paths. +- `patch-mode-01` command surface exists, but local/apply and upstream/write paths are still lightweight stubs rather than operational patch pipeline behavior. +- release documentation is out of sync with runtime: duplicate `0.34.0` changelog entries and missing `specfact patch` reference coverage. + +This delta closes those gaps so shipped behavior, OpenSpec requirements, and user-facing documentation are aligned. + +## What Changes + +- **EXTEND** `bundle-mapper` integration so `--auto-bundle` activates real mapping flow in backlog refine/import paths (confidence routing, interactive fallback, persistence of learned mappings). +- **EXTEND** `patch-mode` apply/write pipeline so `specfact patch apply ` performs effective local patch application and `--write` performs explicit, confirmed upstream write orchestration with idempotency safeguards. +- **EXTEND** documentation and changelog governance so command/reference docs and `CHANGELOG.md` reflect shipped command surfaces and release entries without duplication. +- **EXTEND** verification evidence for this delta with strict OpenSpec validation and dependency impact analysis report. + +## Capabilities + +- **bundle-mapping**: Runtime hook completion for `--auto-bundle` in backlog refine/import with confidence-based routing and mapping persistence. +- **patch-mode**: Operational local apply and explicit upstream write behavior (confirmed + idempotent) aligned with patch-mode acceptance scenarios. +- **cli-output**: Release/changelog/documentation parity for shipped command surfaces (including patch command and corrected release sectioning). + +## Impact + +- **Affected specs**: + - `bundle-mapping` (modified) + - `patch-mode` (modified) + - `cli-output` (modified) +- **Affected code**: + - `modules/bundle-mapper/src/bundle_mapper/*` + - `src/specfact_cli/modules/backlog/src/commands.py` + - `src/specfact_cli/modules/patch_mode/src/patch_mode/*` + - `docs/reference/commands.md` + - `docs/guides/backlog-refinement.md` + - `CHANGELOG.md` +- **Integration points**: + - Backlog ceremony/refine/import flows + - Patch-mode command pipeline + - OpenSpec/doc release reporting and command reference parity + +--- + +## Source Tracking + + +- **GitHub Issue**: #276 +- **Issue URL**: +- **Last Synced Status**: proposed +- **Sanitized**: false diff --git a/openspec/changes/verification-01-wave1-delta-closure/specs/bundle-mapping/spec.md b/openspec/changes/verification-01-wave1-delta-closure/specs/bundle-mapping/spec.md new file mode 100644 index 00000000..974780d3 --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/specs/bundle-mapping/spec.md @@ -0,0 +1,13 @@ +## MODIFIED Requirements + +### Requirement: Confidence-Based Routing + +The system SHALL route bundle mappings based on confidence thresholds: auto-assign (>=0.8), prompt user (0.5-0.8), require explicit selection (<0.5). + +#### Scenario: Refine/import `--auto-bundle` executes runtime mapping flow + +- **GIVEN** `bundle-mapper` module is installed and a user runs backlog refine/import with `--auto-bundle` +- **WHEN** items are processed for OpenSpec bundle assignment +- **THEN** `BundleMapper` confidence scoring is executed for each item +- **AND** confidence routing behavior is enforced (auto/prompt/explicit selection) instead of placeholder or no-op import messaging +- **AND** resulting mapping decision is persisted via configured mapping history/rules storage. diff --git a/openspec/changes/verification-01-wave1-delta-closure/specs/cli-output/spec.md b/openspec/changes/verification-01-wave1-delta-closure/specs/cli-output/spec.md new file mode 100644 index 00000000..553c97a4 --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/specs/cli-output/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: Command Reference Completeness + +The system SHALL keep command reference documentation aligned with shipped CLI command surfaces for each release. + +#### Scenario: Shipped patch command documented in command reference + +- **GIVEN** `specfact patch` command group is available in release builds +- **WHEN** command reference documentation is published for that release +- **THEN** reference docs include `specfact patch apply` options and usage semantics +- **AND** docs do not describe unavailable command variants as fully implemented behavior. + +### Requirement: Changelog Release Integrity + +The project SHALL maintain one canonical section per released version and accurate placement of released capabilities. + +#### Scenario: Release section has no duplicate version headers + +- **GIVEN** release `v0.34.0` is merged and published +- **WHEN** maintainers review `CHANGELOG.md` +- **THEN** there is a single `0.34.0` section +- **AND** features shipped in that release are listed under that release (not left under `Unreleased`). diff --git a/openspec/changes/verification-01-wave1-delta-closure/specs/patch-mode/spec.md b/openspec/changes/verification-01-wave1-delta-closure/specs/patch-mode/spec.md new file mode 100644 index 00000000..04b84186 --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/specs/patch-mode/spec.md @@ -0,0 +1,25 @@ +## MODIFIED Requirements + +### Requirement: Apply locally with preflight + +The system SHALL provide `specfact patch apply ` that applies the patch locally with a preflight check; user confirmation or explicit flag required. + +#### Scenario: Local apply performs real patch operation + +- **GIVEN** a valid unified diff patch file +- **WHEN** the user runs `specfact patch apply ` +- **THEN** preflight validation runs before apply +- **AND** the patch is actually applied to local target files (not a stub success path) +- **AND** command exits non-zero on patch apply failure. + +### Requirement: Write upstream with explicit confirmation + +The system SHALL provide `specfact patch apply --write` (or equivalent) that updates upstream (GitHub/ADO) only with explicit user confirmation; idempotent for posted comments/updates (no duplicates). + +#### Scenario: Write orchestration is explicit, confirmed, and idempotent + +- **GIVEN** upstream write mode is requested +- **WHEN** the user runs `specfact patch apply --write --yes` +- **THEN** upstream write path executes only after confirmation +- **AND** repeated invocation with the same operation key does not create duplicate writes/comments +- **AND** failures in write orchestration surface clear non-zero error outcomes. diff --git a/openspec/changes/verification-01-wave1-delta-closure/tasks.md b/openspec/changes/verification-01-wave1-delta-closure/tasks.md new file mode 100644 index 00000000..41956526 --- /dev/null +++ b/openspec/changes/verification-01-wave1-delta-closure/tasks.md @@ -0,0 +1,34 @@ +## 1. Git Workflow and Scope Lock + +- [ ] 1.1 Create worktree branch `feature/verification-01-wave1-delta-closure` from `origin/dev` and run all implementation steps in that worktree. +- [x] 1.2 Confirm this change only covers Wave 1 delta-closure gaps (bundle-mapper wiring, patch-mode behavior completion, docs/changelog parity). + +## 2. Spec and Validation Baseline + +- [x] 2.1 Validate OpenSpec change artifacts: `openspec validate verification-01-wave1-delta-closure --strict`. +- [x] 2.2 Produce/update `openspec/changes/verification-01-wave1-delta-closure/CHANGE_VALIDATION.md` with dependency and breaking-change analysis. + +## 3. Tests First (TDD Hard Gate) + +- [x] 3.1 Add/extend tests for bundle-mapper runtime hook behavior in backlog refine/import (`--auto-bundle` confidence routing and user fallback behavior). +- [x] 3.2 Add/extend tests for patch-mode local apply and upstream write orchestration (confirmation gate + idempotency behavior). +- [x] 3.3 Add/extend docs/changelog parity tests or lint guards where applicable. +- [x] 3.4 Run targeted tests and capture a failing pre-implementation run in `TDD_EVIDENCE.md`. + +## 4. Implement Delta Scope + +- [x] 4.1 Implement bundle-mapper hook wiring for backlog refine/import runtime paths. +- [x] 4.2 Implement patch-mode local apply semantics and explicit upstream write path aligned with acceptance criteria. +- [x] 4.3 Update docs and changelog to match actual shipped command behavior and remove duplicate release sections. + +## 5. Verify and Quality Gates + +- [x] 5.1 Re-run targeted tests and capture passing post-implementation evidence in `TDD_EVIDENCE.md`. +- [ ] 5.2 Run quality gates in order: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, `hatch run smart-test`. +- [x] 5.3 Re-run `openspec validate verification-01-wave1-delta-closure --strict` and ensure no errors. + +## 6. Sync and Delivery + +- [ ] 6.1 Sync proposal updates to GitHub issue in `nold-ai/specfact-cli` and ensure required labels (`enhancement`, `openspec`, `change-proposal`). +- [ ] 6.2 Update `openspec/CHANGE_ORDER.md` entry status/metadata for this change. +- [ ] 6.3 Open PR to `dev` with validation evidence and test results. diff --git a/src/specfact_cli/modules/backlog/src/commands.py b/src/specfact_cli/modules/backlog/src/commands.py index bb93996c..d03aea1b 100644 --- a/src/specfact_cli/modules/backlog/src/commands.py +++ b/src/specfact_cli/modules/backlog/src/commands.py @@ -889,6 +889,133 @@ def _is_patch_mode_available() -> bool: return False +@beartype +def _load_bundle_mapper_runtime_dependencies() -> ( + tuple[ + type[Any], + Callable[[BacklogItem, str, Path | None], None], + Callable[[Path | None], dict[str, Any]], + Callable[[Any, list[str]], str | None] | None, + ] + | None +): + """Load optional bundle-mapper runtime dependencies.""" + try: + from bundle_mapper.mapper.engine import BundleMapper + from bundle_mapper.mapper.history import load_bundle_mapping_config, save_user_confirmed_mapping + from bundle_mapper.ui.interactive import ask_bundle_mapping + + return (BundleMapper, save_user_confirmed_mapping, load_bundle_mapping_config, ask_bundle_mapping) + except ImportError: + return None + + +@beartype +def _route_bundle_mapping_decision( + mapping: Any, + *, + available_bundle_ids: list[str], + auto_assign_threshold: float, + confirm_threshold: float, + prompt_callback: Callable[[Any, list[str]], str | None] | None, +) -> str | None: + """Apply confidence routing rules to one computed mapping.""" + primary_bundle = getattr(mapping, "primary_bundle_id", None) + confidence = float(getattr(mapping, "confidence", 0.0)) + + if primary_bundle and confidence >= auto_assign_threshold: + return str(primary_bundle) + if prompt_callback is None: + return str(primary_bundle) if primary_bundle else None + if confidence >= confirm_threshold: + return prompt_callback(mapping, available_bundle_ids) + return prompt_callback(mapping, available_bundle_ids) + + +@beartype +def _derive_available_bundle_ids(bundle_path: Path | None) -> list[str]: + """Derive available bundle IDs from explicit bundle path and local project bundles.""" + candidates: list[str] = [] + if bundle_path: + if bundle_path.is_dir(): + candidates.append(bundle_path.name) + else: + # Avoid treating common manifest filenames (bundle.yaml) as bundle IDs. + stem = bundle_path.stem.strip() + if stem and stem.lower() != "bundle": + candidates.append(stem) + elif bundle_path.parent.name not in {".specfact", "projects", ""}: + candidates.append(bundle_path.parent.name) + + projects_dir = Path.cwd() / ".specfact" / "projects" + if projects_dir.exists(): + for child in sorted(projects_dir.iterdir()): + if child.is_dir(): + candidates.append(child.name) + + deduped: list[str] = [] + seen: set[str] = set() + for candidate in candidates: + normalized = candidate.strip() + if not normalized or normalized in seen: + continue + seen.add(normalized) + deduped.append(normalized) + return deduped + + +@beartype +def _resolve_bundle_mapping_config_path() -> Path | None: + """Resolve mapping history/rules config path, separate from bundle manifest path.""" + config_dir = os.environ.get("SPECFACT_CONFIG_DIR") + if config_dir: + return Path(config_dir) / "config.yaml" + if (Path.cwd() / ".specfact").exists(): + return Path.cwd() / ".specfact" / "config.yaml" + return None + + +@beartype +def _apply_bundle_mappings_for_items( + *, + items: list[BacklogItem], + available_bundle_ids: list[str], + config_path: Path | None, +) -> dict[str, str]: + """Execute bundle mapping flow for refined items and persist selected mappings.""" + runtime_deps = _load_bundle_mapper_runtime_dependencies() + if runtime_deps is None: + return {} + + bundle_mapper_cls, save_user_confirmed_mapping, load_bundle_mapping_config, ask_bundle_mapping = runtime_deps + cfg = load_bundle_mapping_config(config_path) + auto_assign_threshold = float(cfg.get("auto_assign_threshold", 0.8)) + confirm_threshold = float(cfg.get("confirm_threshold", 0.5)) + + mapper = bundle_mapper_cls( + available_bundle_ids=available_bundle_ids, + config_path=config_path, + bundle_spec_keywords={}, + ) + + selected_by_item_id: dict[str, str] = {} + for item in items: + mapping = mapper.compute_mapping(item) + selected = _route_bundle_mapping_decision( + mapping, + available_bundle_ids=available_bundle_ids, + auto_assign_threshold=auto_assign_threshold, + confirm_threshold=confirm_threshold, + prompt_callback=ask_bundle_mapping, + ) + if not selected: + continue + selected_by_item_id[str(item.id)] = selected + save_user_confirmed_mapping(item, selected, config_path) + + return selected_by_item_id + + @beartype def _build_comment_fetch_progress_description(index: int, total: int, item_id: str) -> str: """Build progress text while fetching per-item comments.""" @@ -3151,6 +3278,7 @@ def refine( # Process each item refined_count = 0 + refined_items: list[BacklogItem] = [] skipped_count = 0 cancelled = False comments_by_item_id: dict[str, list[str]] = {} @@ -3536,6 +3664,7 @@ def _on_write_comment_progress(index: int, total: int, item: BacklogItem) -> Non console.print("\n[yellow]Preview mode: Refinement will NOT be written to backlog[/yellow]") console.print("[yellow]Use --write flag to explicitly opt-in to writeback[/yellow]") refined_count += 1 # Count as refined for preview purposes + refined_items.append(item) continue if write: @@ -3562,6 +3691,7 @@ def _on_write_comment_progress(index: int, total: int, item: BacklogItem) -> Non openspec_comment=openspec_comment, ) refined_count += 1 + refined_items.append(item) else: console.print("[yellow]Refinement rejected - not writing to backlog[/yellow]") skipped_count += 1 @@ -3569,6 +3699,7 @@ def _on_write_comment_progress(index: int, total: int, item: BacklogItem) -> Non # Preview mode but user didn't explicitly set --write console.print("[yellow]Preview mode: Use --write to update backlog[/yellow]") refined_count += 1 + refined_items.append(item) except ValueError as e: console.print(f"[red]Validation failed: {e}[/red]") @@ -3577,7 +3708,7 @@ def _on_write_comment_progress(index: int, total: int, item: BacklogItem) -> Non continue # OpenSpec bundle import (if requested) - if (bundle or auto_bundle) and refined_count > 0: + if (bundle or auto_bundle) and refined_items: console.print("\n[bold]OpenSpec Bundle Import:[/bold]") try: # Determine bundle path @@ -3591,16 +3722,28 @@ def _on_write_comment_progress(index: int, total: int, item: BacklogItem) -> Non if not bundle_path.exists(): bundle_path = current_dir / "bundle.yaml" - if bundle_path and bundle_path.exists(): - console.print( - f"[green]Importing {refined_count} refined items to OpenSpec bundle: {bundle_path}[/green]" - ) - # TODO: Implement actual import logic using import command functionality + config_path = _resolve_bundle_mapping_config_path() + available_bundle_ids = _derive_available_bundle_ids( + bundle_path if bundle_path and bundle_path.exists() else None + ) + mapped = _apply_bundle_mappings_for_items( + items=refined_items, + available_bundle_ids=available_bundle_ids, + config_path=config_path, + ) + if not mapped: + if _load_bundle_mapper_runtime_dependencies() is None: + console.print( + "[yellow]⚠ bundle-mapper module not available; skipping runtime mapping flow.[/yellow]" + ) + else: + console.print("[yellow]⚠ No bundle assignments were selected.[/yellow]") + else: console.print( - "[yellow]⚠ OpenSpec bundle import integration pending (use import command separately)[/yellow]" + f"[green]Mapped {len(mapped)}/{len(refined_items)} refined item(s) using confidence routing.[/green]" ) - else: - console.print("[yellow]⚠ Bundle path not found. Skipping import.[/yellow]") + for item_id, selected_bundle in mapped.items(): + console.print(f"[dim]- {item_id} -> {selected_bundle}[/dim]") except Exception as e: console.print(f"[yellow]⚠ Failed to import to OpenSpec bundle: {e}[/yellow]") diff --git a/src/specfact_cli/modules/patch_mode/src/patch_mode/pipeline/applier.py b/src/specfact_cli/modules/patch_mode/src/patch_mode/pipeline/applier.py index d4f59881..4252e3cd 100644 --- a/src/specfact_cli/modules/patch_mode/src/patch_mode/pipeline/applier.py +++ b/src/specfact_cli/modules/patch_mode/src/patch_mode/pipeline/applier.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess from pathlib import Path from beartype import beartype @@ -19,9 +20,23 @@ def apply_patch_local(patch_file: Path, dry_run: bool = False) -> bool: return False if not raw.strip(): return False + check_result = subprocess.run( + ["git", "apply", "--check", str(patch_file)], + check=False, + capture_output=True, + text=True, + ) + if check_result.returncode != 0: + return False if dry_run: return True - return True + apply_result = subprocess.run( + ["git", "apply", str(patch_file)], + check=False, + capture_output=True, + text=True, + ) + return apply_result.returncode == 0 @beartype @@ -32,11 +47,7 @@ def apply_patch_write(patch_file: Path, confirmed: bool) -> bool: """Update upstream only with explicit confirmation; idempotent. Returns True on success.""" if not confirmed: return False - try: - patch_file.read_text(encoding="utf-8") - except OSError: - return False - return True + return apply_patch_local(patch_file, dry_run=True) @beartype diff --git a/tests/unit/commands/test_backlog_bundle_mapping_delta.py b/tests/unit/commands/test_backlog_bundle_mapping_delta.py new file mode 100644 index 00000000..aec55873 --- /dev/null +++ b/tests/unit/commands/test_backlog_bundle_mapping_delta.py @@ -0,0 +1,114 @@ +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.modules.backlog.src import commands as backlog_commands + + +def _item(item_id: str, *, tags: list[str] | None = None) -> BacklogItem: + return BacklogItem( + id=item_id, + provider="github", + url=f"https://example.com/issues/{item_id}", + title=f"Item {item_id}", + body_markdown="Body", + state="open", + tags=tags or [], + assignees=[], + ) + + +class _FakeMapping: + def __init__(self, primary_bundle_id: str | None, confidence: float) -> None: + self.primary_bundle_id = primary_bundle_id + self.confidence = confidence + self.candidates: list[tuple[str, float]] = [] + self.explained_reasoning = "test" + + +def test_route_bundle_mapping_auto_assign_high_confidence() -> None: + called = {"prompted": False} + + def _prompt(_mapping: _FakeMapping, _bundles: list[str]) -> str | None: + called["prompted"] = True + return None + + selected = backlog_commands._route_bundle_mapping_decision( + _FakeMapping("alpha", 0.91), + available_bundle_ids=["alpha", "beta"], + auto_assign_threshold=0.8, + confirm_threshold=0.5, + prompt_callback=_prompt, + ) + assert selected == "alpha" + assert called["prompted"] is False + + +def test_route_bundle_mapping_prompts_in_medium_band() -> None: + def _prompt(_mapping: _FakeMapping, _bundles: list[str]) -> str | None: + return "beta" + + selected = backlog_commands._route_bundle_mapping_decision( + _FakeMapping("alpha", 0.62), + available_bundle_ids=["alpha", "beta"], + auto_assign_threshold=0.8, + confirm_threshold=0.5, + prompt_callback=_prompt, + ) + assert selected == "beta" + + +def test_apply_bundle_mapping_runtime_persists_mapping_history(tmp_path: Path, monkeypatch) -> None: + saved: list[tuple[str, str, Path | None]] = [] + + class _FakeMapper: + def __init__(self, available_bundle_ids, config_path=None, bundle_spec_keywords=None): + self.available_bundle_ids = available_bundle_ids + + def compute_mapping(self, _item: BacklogItem) -> _FakeMapping: + return _FakeMapping("core-platform", 0.95) + + def _fake_save(item: BacklogItem, bundle_id: str, config_path: Path | None = None) -> None: + saved.append((item.id, bundle_id, config_path)) + + def _fake_load(_config_path: Path | None = None) -> dict[str, float]: + return {"auto_assign_threshold": 0.8, "confirm_threshold": 0.5} + + monkeypatch.setattr( + backlog_commands, + "_load_bundle_mapper_runtime_dependencies", + lambda: (_FakeMapper, _fake_save, _fake_load, None), + ) + + mapped = backlog_commands._apply_bundle_mappings_for_items( + items=[_item("42", tags=["bundle:core-platform"])], + available_bundle_ids=["core-platform"], + config_path=tmp_path / "config.yaml", + ) + + assert mapped == {"42": "core-platform"} + assert saved == [("42", "core-platform", tmp_path / "config.yaml")] + + +def test_derive_available_bundle_ids_does_not_use_bundle_yaml_stem( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + specfact_dir = tmp_path / ".specfact" + specfact_dir.mkdir() + monkeypatch.chdir(tmp_path) + bundle_yaml = specfact_dir / "bundle.yaml" + bundle_yaml.write_text("manifest: true\n", encoding="utf-8") + ids = backlog_commands._derive_available_bundle_ids(bundle_yaml) + assert "bundle" not in ids + + +def test_resolve_bundle_mapping_config_path_uses_project_specfact_dir( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + (tmp_path / ".specfact").mkdir() + monkeypatch.chdir(tmp_path) + monkeypatch.delenv("SPECFACT_CONFIG_DIR", raising=False) + assert backlog_commands._resolve_bundle_mapping_config_path() == tmp_path / ".specfact" / "config.yaml" diff --git a/tests/unit/docs/test_release_docs_parity.py b/tests/unit/docs/test_release_docs_parity.py new file mode 100644 index 00000000..75fe73d2 --- /dev/null +++ b/tests/unit/docs/test_release_docs_parity.py @@ -0,0 +1,27 @@ +from __future__ import annotations + +from pathlib import Path + + +def _repo_file(path: str) -> Path: + return Path(__file__).resolve().parents[3] / path + + +def test_changelog_has_single_0340_release_header() -> None: + changelog = _repo_file("CHANGELOG.md").read_text(encoding="utf-8") + assert changelog.count("## [0.34.0] - 2026-02-18") == 1 + + +def test_patch_mode_is_not_left_under_unreleased() -> None: + changelog = _repo_file("CHANGELOG.md").read_text(encoding="utf-8") + unreleased_start = changelog.find("## [Unreleased]") + next_release_start = changelog.find("\n## [", unreleased_start + 1) + unreleased_block = changelog[unreleased_start:next_release_start] + assert "Patch mode module" not in unreleased_block + + +def test_command_reference_documents_patch_apply() -> None: + commands_doc = _repo_file("docs/reference/commands.md").read_text(encoding="utf-8") + assert "specfact patch apply" in commands_doc + assert "--write" in commands_doc + assert "--dry-run" in commands_doc diff --git a/tests/unit/specfact_cli/modules/test_patch_mode.py b/tests/unit/specfact_cli/modules/test_patch_mode.py index 872f2eec..21dbfecb 100644 --- a/tests/unit/specfact_cli/modules/test_patch_mode.py +++ b/tests/unit/specfact_cli/modules/test_patch_mode.py @@ -4,6 +4,7 @@ from pathlib import Path +import pytest from typer.testing import CliRunner from specfact_cli.modules.patch_mode.src.patch_mode.commands.apply import app as patch_app @@ -37,11 +38,22 @@ def test_generate_with_target_path(self) -> None: class TestApplyPatchLocal: """Scenario: Apply patch locally with preflight; no upstream write.""" - def test_apply_local_success(self, tmp_path: Path) -> None: + def test_apply_local_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Given a patch file, When patch apply , Then applies locally; no upstream.""" + target = tmp_path / "sample.txt" + target.write_text("old\n", encoding="utf-8") patch_file = tmp_path / "p.diff" - patch_file.write_text("--- a\n+++ b\n+line\n") - result = runner.invoke(patch_app, [str(patch_file)]) + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-old ++new +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + result = runner.invoke(patch_app, [str(patch_file)], catch_exceptions=False) assert result.exit_code == 0 assert "Applied patch locally" in result.stdout or "apply" in result.stdout.lower() @@ -58,12 +70,62 @@ def test_preflight_check_empty_fails(self, tmp_path: Path) -> None: f.write_text("") assert preflight_check(f) is False - def test_apply_patch_local_returns_true_for_valid_file(self, tmp_path: Path) -> None: + def test_apply_patch_local_returns_true_for_valid_file( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: """Given valid patch file, When apply_patch_local, Then returns True.""" + target = tmp_path / "sample.txt" + target.write_text("before\n", encoding="utf-8") patch_file = tmp_path / "x.diff" - patch_file.write_text("+content\n") + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-before ++after +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) assert apply_patch_local(patch_file, dry_run=False) is True + def test_apply_patch_local_applies_real_file_change(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Given valid unified diff, When apply_patch_local, Then target file content changes.""" + target = tmp_path / "sample.txt" + target.write_text("hello\n", encoding="utf-8") + patch_file = tmp_path / "real.diff" + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-hello ++hi +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + assert apply_patch_local(patch_file, dry_run=False) is True + assert target.read_text(encoding="utf-8") == "hi\n" + + def test_apply_patch_local_returns_false_on_invalid_patch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """Given invalid patch, When apply_patch_local, Then returns False.""" + target = tmp_path / "sample.txt" + target.write_text("hello\n", encoding="utf-8") + patch_file = tmp_path / "invalid.diff" + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-does-not-match ++hi +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + assert apply_patch_local(patch_file, dry_run=False) is False + class TestApplyPatchWrite: """Scenario: Write patch upstream with explicit confirmation; idempotent.""" @@ -76,20 +138,61 @@ def test_apply_write_without_yes_skips(self, tmp_path: Path) -> None: assert result.exit_code == 0 assert "skip" in result.stdout.lower() or "yes" in result.stdout.lower() - def test_apply_write_with_yes_succeeds(self, tmp_path: Path) -> None: + def test_apply_write_with_yes_succeeds(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Given patch file, When patch apply --write --yes, Then updates upstream (idempotent).""" + target = tmp_path / "sample.txt" + target.write_text("old\n", encoding="utf-8") patch_file = tmp_path / "w.diff" - patch_file.write_text("+line\n") + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-old ++new +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) result = runner.invoke(patch_app, [str(patch_file), "--write", "--yes"]) assert result.exit_code == 0 assert "Wrote" in result.stdout or "write" in result.stdout.lower() or "Applied" in result.stdout - def test_apply_patch_write_confirmed_success(self, tmp_path: Path) -> None: + def test_apply_patch_write_confirmed_success(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """apply_patch_write with confirmed=True and valid file returns True.""" + target = tmp_path / "sample.txt" + target.write_text("base\n", encoding="utf-8") patch_file = tmp_path / "z.diff" - patch_file.write_text("+content\n") + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-base ++updated +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) assert apply_patch_write(patch_file, confirmed=True) is True + def test_apply_patch_write_returns_false_on_invalid_patch( + self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """apply_patch_write fails when orchestration preflight fails.""" + target = tmp_path / "sample.txt" + target.write_text("hello\n", encoding="utf-8") + patch_file = tmp_path / "bad.diff" + patch_file.write_text( + """--- a/sample.txt ++++ b/sample.txt +@@ -1 +1 @@ +-wrong ++updated +""", + encoding="utf-8", + ) + monkeypatch.chdir(tmp_path) + assert apply_patch_write(patch_file, confirmed=True) is False + class TestIdempotency: """Idempotent: no duplicate posted comments/updates."""