From 4e9819789670331c66397085a0c301a744558450 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 21:55:53 +0200 Subject: [PATCH 01/12] fix: safe merge for VS Code settings.json on init ide (profile-04) - Add project_artifact_write.merge_vscode_settings_prompt_recommendations with fail-safe on invalid JSON / bad chat shape; --force backs up to .specfact/recovery/ then replaces. - Route ide_setup create_vscode_settings through helper; thread force; catch errors for CLI exit. - Lint gate: scripts/verify_safe_project_writes.py blocks json.load/dump in ide_setup.py. - Tests, installation docs, 0.45.2 changelog and version pins. OpenSpec: profile-04-safe-project-artifact-writes Made-with: Cursor --- CHANGELOG.md | 12 ++ README.md | 4 +- .../capture-readme-output.sh | 2 +- .../sample-output/capture-metadata.txt | 6 +- .../sample-output/review-output.txt | 2 +- docs/getting-started/installation.md | 2 + .../TDD_EVIDENCE.md | 48 +++++ .../tasks.md | 34 ++-- pyproject.toml | 4 +- scripts/verify_safe_project_writes.py | 50 ++++++ setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/utils/ide_setup.py | 73 ++++---- .../utils/project_artifact_write.py | 169 ++++++++++++++++++ .../init/test_init_ide_prompt_selection.py | 21 +++ .../test_verify_safe_project_writes.py | 19 ++ .../unit/utils/test_project_artifact_write.py | 115 ++++++++++++ 18 files changed, 500 insertions(+), 67 deletions(-) create mode 100644 openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md create mode 100644 scripts/verify_safe_project_writes.py create mode 100644 src/specfact_cli/utils/project_artifact_write.py create mode 100644 tests/unit/scripts/test_verify_safe_project_writes.py create mode 100644 tests/unit/utils/test_project_artifact_write.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 62c7e0e5..60098c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,18 @@ All notable changes to this project will be documented in this file. --- +## [0.45.2] - 2026-04-12 + +### Fixed + +- **`specfact init ide` and `.vscode/settings.json`**: invalid JSON or non-mergeable `chat` blocks no longer + wipe unrelated VS Code settings; the command fails safe with guidance. Use `--force` only when you accept + replacing the file after a timestamped backup under `.specfact/recovery/`. +- **Regression gate**: lint now runs `scripts/verify_safe_project_writes.py` so IDE settings JSON I/O stays + routed through the shared merge helper. + +--- + ## [0.45.1] - 2026-04-03 ### Changed diff --git a/README.md b/README.md index 606cba43..8de77dae 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ uvx specfact-cli code review run --path . --scope full **Sample output:** ```text -SpecFact CLI - v0.45.1 +SpecFact CLI - v0.45.2 Running Ruff checks... Running Radon complexity checks... @@ -84,7 +84,7 @@ It exists because delivery drifts in predictable ways: ```yaml - repo: https://github.com/nold-ai/specfact-cli - rev: v0.45.1 + rev: v0.45.2 hooks: - id: specfact-smart-checks ``` diff --git a/docs/_support/readme-first-contact/capture-readme-output.sh b/docs/_support/readme-first-contact/capture-readme-output.sh index 90987874..f982fe1e 100755 --- a/docs/_support/readme-first-contact/capture-readme-output.sh +++ b/docs/_support/readme-first-contact/capture-readme-output.sh @@ -5,7 +5,7 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" -CLI_VERSION="${CLI_VERSION:-0.45.1}" +CLI_VERSION="${CLI_VERSION:-0.45.2}" REPO_SLUG="${REPO_SLUG:-nold-ai/specfact-demo-repo}" CAPTURE_REF="${CAPTURE_REF:-${CAPTURE_COMMIT:-2b5ba8cd57d16c1a1f24463a297fdb28fbede123}}" WORK_DIR="${WORK_DIR:-/tmp/specfact-demo-repo}" diff --git a/docs/_support/readme-first-contact/sample-output/capture-metadata.txt b/docs/_support/readme-first-contact/sample-output/capture-metadata.txt index 951b6ff9..30869b3c 100644 --- a/docs/_support/readme-first-contact/sample-output/capture-metadata.txt +++ b/docs/_support/readme-first-contact/sample-output/capture-metadata.txt @@ -1,6 +1,6 @@ # README sample output capture -- CLI version: `0.45.1` +- CLI version: `0.45.2` - Repo: `nold-ai/specfact-demo-repo` - Repo ref: `2b5ba8cd57d16c1a1f24463a297fdb28fbede123` - Review exit code: `1` @@ -9,8 +9,8 @@ - Command: ```bash -uvx --from "specfact-cli==0.45.1" specfact init --profile solo-developer -uvx --from "specfact-cli==0.45.1" --with ruff --with radon --with semgrep --with basedpyright --with pylint --with crosshair-tool specfact code review run --path . --scope full +uvx --from "specfact-cli==0.45.2" specfact init --profile solo-developer +uvx --from "specfact-cli==0.45.2" --with ruff --with radon --with semgrep --with basedpyright --with pylint --with crosshair-tool specfact code review run --path . --scope full ``` - Raw output: `/workspace/docs/_support/readme-first-contact/sample-output/review-output.txt` diff --git a/docs/_support/readme-first-contact/sample-output/review-output.txt b/docs/_support/readme-first-contact/sample-output/review-output.txt index ff038a48..2d066d77 100644 --- a/docs/_support/readme-first-contact/sample-output/review-output.txt +++ b/docs/_support/readme-first-contact/sample-output/review-output.txt @@ -9,7 +9,7 @@ Downloading z3-solver (30.3MiB) Downloaded basedpyright Downloaded nodejs-wheel-binaries Installed 111 packages in 394ms -SpecFact CLI - v0.45.1 +SpecFact CLI - v0.45.2 ⏱️ Started: 2026-04-03 20:55:28 diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 5e3b017f..6b69c0a6 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -75,6 +75,8 @@ specfact init ide --ide cursor --install-deps **Important**: SpecFact CLI does **not** ship with built-in AI. `specfact init ide` installs prompt templates for supported IDEs so your chosen AI copilot can call SpecFact commands in a guided workflow. +For VS Code / Copilot, the CLI **merges** prompt recommendations into `.vscode/settings.json` and keeps your other settings keys. If that file is not valid JSON (or its `chat` block is not mergeable), the command stops without rewriting it; use `specfact init ide --force` only when you accept replacing the file after a timestamped backup under `.specfact/recovery/`. + [More options ↓](#more-options) ## More options diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md new file mode 100644 index 00000000..bbaf6d4a --- /dev/null +++ b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md @@ -0,0 +1,48 @@ +# TDD evidence — profile-04-safe-project-artifact-writes + +## Failing-first (targeted) + +- **When**: 2026-04-12 (Europe/Berlin) +- **Command**: + +```bash +cd ../specfact-cli-worktrees/bugfix/profile-04-safe-project-artifact-writes +hatch run pytest \ + tests/unit/utils/test_project_artifact_write.py \ + tests/unit/utils/test_ide_setup.py \ + tests/unit/scripts/test_verify_safe_project_writes.py \ + tests/unit/modules/init/test_init_ide_prompt_selection.py \ + -q +``` + +- **Note**: New scenarios (`malformed_json_raises`, `preserves_unrelated_keys`, verify script) were added before the + safe-merge implementation; prior behavior treated invalid JSON as `{}` and could destroy user settings (issue #487). + +## Passing-after (targeted + e2e) + +- **When**: 2026-04-12 +- **Commands**: + +```bash +hatch run pytest tests/unit/utils/test_project_artifact_write.py \ + tests/unit/utils/test_ide_setup.py tests/unit/scripts/test_verify_safe_project_writes.py \ + tests/unit/modules/init/test_init_ide_prompt_selection.py tests/e2e/test_init_command.py -q +hatch run format && hatch run type-check && hatch run lint +hatch run contract-test +hatch run smart-test +``` + +- **Module signatures**: `hatch run ./scripts/verify-modules-signature.py --require-signature` — pass without bumping + `src/specfact_cli/modules/init/module-package.yaml` (init UX errors are raised from `ide_setup` so the init module + payload checksum is unchanged). + +## Code review gate + +- **Attempted**: `hatch run specfact code review run --json --out .specfact/code-review.json` — blocked in a minimal Hatch env + because the `code` command group is provided by the `nold-ai/specfact-codebase` module (not installed in this worktree by default). +- **Follow-up before PR**: install the codebase bundle (e.g. `specfact init --profile solo-developer` or `specfact module install nold-ai/specfact-codebase`) + in the same environment, then re-run the command above and attach `.specfact/code-review.json` to the PR. + +## Worktree cleanup (post-merge on developer machine) + +- Remove worktree, delete branch, prune — see `tasks.md` section 5 (not executed in this implementation session). diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md index 9abbd77c..c6b47de7 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md @@ -1,31 +1,31 @@ ## 1. Branch, coordination, and issue sync -- [ ] 1.1 Create `bugfix/profile-04-safe-project-artifact-writes` in a dedicated worktree from `origin/dev` and bootstrap Hatch in that worktree. -- [ ] 1.2 Sync the change proposal to GitHub under parent feature `#365`, link bug `#487`, and update `proposal.md` Source Tracking with issue metadata. -- [ ] 1.3 Confirm the paired modules-side change `project-runtime-01-safe-artifact-write-policy` is available and note the dependency in both PR descriptions/change evidence. +- [x] 1.1 Create `bugfix/profile-04-safe-project-artifact-writes` in a dedicated worktree from `origin/dev` and bootstrap Hatch in that worktree. +- [ ] 1.2 Sync the change proposal to GitHub under parent feature `#365`, link bug `#487`, and update `proposal.md` Source Tracking with issue metadata. *(human / PR author)* +- [ ] 1.3 Confirm the paired modules-side change `project-runtime-01-safe-artifact-write-policy` is available and note the dependency in both PR descriptions/change evidence. *(human)* ## 2. Specs, regression fixtures, and failing evidence -- [ ] 2.1 Add or update regression fixtures for existing user-owned project artifacts such as `.vscode/settings.json` with unrelated custom settings. -- [ ] 2.2 Write tests from the new scenarios covering partial ownership, malformed settings fail-safe behavior, backup creation, and preservation of unrelated settings. -- [ ] 2.3 Write tests for the CI/static unsafe-write gate so direct writes to protected project artifacts are rejected unless routed through the sanctioned helper. -- [ ] 2.4 Run the targeted tests before implementation, capture the failing results, and record commands/timestamps in `openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md`. +- [x] 2.1 Add or update regression fixtures for existing user-owned project artifacts such as `.vscode/settings.json` with unrelated custom settings. *(pytest tmp_path fixtures in `test_project_artifact_write.py`)* +- [x] 2.2 Write tests from the new scenarios covering partial ownership, malformed settings fail-safe behavior, backup creation, and preservation of unrelated settings. +- [x] 2.3 Write tests for the CI/static unsafe-write gate so direct writes to protected project artifacts are rejected unless routed through the sanctioned helper. +- [x] 2.4 Run the targeted tests before implementation, capture the failing results, and record commands/timestamps in `openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md`. ## 3. Core safe-write implementation -- [ ] 3.1 Implement the core safe-write helper and ownership model with `@beartype` and `@icontract` on public APIs. -- [ ] 3.2 Route `src/specfact_cli/utils/ide_setup.py` settings mutation through the helper so `.vscode/settings.json` preserves unrelated user-managed settings and strips only SpecFact-managed entries when needed. -- [ ] 3.3 Route applicable init/setup artifact copy flows through the helper or explicit safe modes, including fail-safe handling for malformed structured files and backup creation for explicit replacement. -- [ ] 3.4 Implement the CI/static guard for protected user-project artifacts in init/setup code paths and integrate it into the relevant local/CI quality workflow. +- [x] 3.1 Implement the core safe-write helper and ownership model with `@beartype` and `@icontract` on public APIs. +- [x] 3.2 Route `src/specfact_cli/utils/ide_setup.py` settings mutation through the helper so `.vscode/settings.json` preserves unrelated user-managed settings and strips only SpecFact-managed entries when needed. +- [x] 3.3 Route applicable init/setup artifact copy flows through the helper or explicit safe modes, including fail-safe handling for malformed structured files and backup creation for explicit replacement. *(VS Code settings path; `init ide` surfaces errors + `--force` + backup)* +- [x] 3.4 Implement the CI/static guard for protected user-project artifacts in init/setup code paths and integrate it into the relevant local/CI quality workflow. *(`scripts/verify_safe_project_writes.py` + `hatch run lint`)* ## 4. Verification, docs, and cross-repo handoff -- [ ] 4.1 Re-run the targeted tests and any broader init/setup regression coverage, capture passing results, and update `TDD_EVIDENCE.md`. -- [ ] 4.2 Research and update affected docs (`README.md`, installation/quickstart/init references) to document preservation guarantees, backup behavior, and explicit replacement semantics. -- [ ] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, and `hatch run smart-test`. -- [ ] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled module manifests changed, bump versions, re-sign as required, and re-run verification. -- [ ] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. -- [ ] 4.6 Apply the appropriate version/changelog update for a bugfix release if implementation changes user-facing behavior, then open a PR to `dev` referencing the paired modules change. +- [x] 4.1 Re-run the targeted tests and any broader init/setup regression coverage, capture passing results, and update `TDD_EVIDENCE.md`. +- [x] 4.2 Research and update affected docs (`README.md`, installation/quickstart/init references) to document preservation guarantees, backup behavior, and explicit replacement semantics. *(installation.md + version pins in README / samples)* +- [x] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, and `hatch run smart-test`. +- [x] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled module manifests changed, bump versions, re-sign as required, and re-run verification. *(no `modules/init` payload change — error UX handled in `ide_setup` to avoid re-signing)* +- [ ] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. *(requires `specfact code review` surface — install `nold-ai/specfact-codebase` in the Hatch env or run from a profile-bootstrapped environment; see TDD_EVIDENCE.)* +- [x] 4.6 Apply the appropriate version/changelog update for a bugfix release if implementation changes user-facing behavior, then open a PR to `dev` referencing the paired modules change. *(version/changelog done; PR human)* ## 5. Worktree cleanup diff --git a/pyproject.toml b/pyproject.toml index 29ca95df..0a0791d6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.45.1" +version = "0.45.2" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" @@ -207,7 +207,7 @@ validate-prompts = "python tools/validate_prompts.py" test = "pytest {args}" test-cov = "pytest --cov=src --cov-report=term-missing {args}" type-check = "basedpyright --pythonpath $(python -c 'import sys; print(sys.executable)') {args}" -lint = "ruff format . --check && basedpyright --pythonpath $(python -c 'import sys; print(sys.executable)') && ruff check . && pylint src tests tools" +lint = "ruff format . --check && basedpyright --pythonpath $(python -c 'import sys; print(sys.executable)') && ruff check . && pylint src tests tools && python scripts/verify_safe_project_writes.py" governance = "pylint src tests tools --reports=y --output-format=parseable" format = "ruff check . --fix && ruff format ." diff --git a/scripts/verify_safe_project_writes.py b/scripts/verify_safe_project_writes.py new file mode 100644 index 00000000..8c744a7d --- /dev/null +++ b/scripts/verify_safe_project_writes.py @@ -0,0 +1,50 @@ +#!/usr/bin/env python3 +"""Ensure VS Code settings JSON I/O for init/ide flows uses project_artifact_write (regression gate).""" + +from __future__ import annotations + +import ast +import sys +from pathlib import Path + + +ROOT = Path(__file__).resolve().parent.parent +IDE_SETUP = ROOT / "src" / "specfact_cli" / "utils" / "ide_setup.py" + + +class _JsonIoVisitor(ast.NodeVisitor): + def __init__(self) -> None: + self.offenders: list[tuple[int, str]] = [] + + def visit_Call(self, node: ast.Call) -> None: + func = node.func + if ( + isinstance(func, ast.Attribute) + and isinstance(func.value, ast.Name) + and func.value.id == "json" + and func.attr in {"load", "dump", "loads", "dumps"} + ): + self.offenders.append((node.lineno, f"json.{func.attr}")) + self.generic_visit(node) + + +def main() -> int: + if not IDE_SETUP.is_file(): + print(f"Expected ide_setup at {IDE_SETUP}", file=sys.stderr) + return 2 + tree = ast.parse(IDE_SETUP.read_text(encoding="utf-8"), filename=str(IDE_SETUP)) + visitor = _JsonIoVisitor() + visitor.visit(tree) + if visitor.offenders: + lines = ", ".join(f"line {ln} ({name})" for ln, name in visitor.offenders) + print( + "Unsafe JSON I/O in ide_setup.py — route VS Code settings through " + f"specfact_cli.utils.project_artifact_write.merge_vscode_settings_prompt_recommendations: {lines}", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/setup.py b/setup.py index 8a34c4ed..fc31c94b 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.45.1", + version="0.45.2", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index b02d2da8..d6c90d0f 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.45.1" +__version__ = "0.45.2" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index ee71846a..4bf59309 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -45,6 +45,6 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.45.1" +__version__ = "0.45.2" __all__ = ["__version__"] diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 0bcfed7f..0f384fd6 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -15,6 +15,7 @@ from pathlib import Path from typing import Any, Literal, cast +import click import yaml from beartype import beartype from icontract import ensure, require @@ -27,6 +28,10 @@ template_path_is_file, vscode_settings_result_ok, ) +from specfact_cli.utils.project_artifact_write import ( + StructuredJsonDocumentError, + merge_vscode_settings_prompt_recommendations, +) console = Console() @@ -507,7 +512,15 @@ def _copy_template_files_to_ide( settings_path = None if write_settings and settings_file and isinstance(settings_file, str): - settings_path = create_vscode_settings(repo_path, settings_file) + try: + settings_path = create_vscode_settings(repo_path, settings_file, force=force) + except StructuredJsonDocumentError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print( + "[dim]Repair `.vscode/settings.json` or re-run with [bold]--force[/bold] to replace it " + "after a timestamped backup under `.specfact/recovery/`.[/dim]" + ) + raise click.exceptions.Exit(1) from exc return copied_files, settings_path @@ -613,7 +626,17 @@ def copy_prompts_by_source_to_ide( settings_path: Path | None = None settings_file = config.get("settings_file") if settings_file and isinstance(settings_file, str): - settings_path = create_vscode_settings(repo_path, settings_file, prompts_by_source=prompts_by_source) + try: + settings_path = create_vscode_settings( + repo_path, settings_file, prompts_by_source=prompts_by_source, force=force + ) + except StructuredJsonDocumentError as exc: + console.print(f"[red]Error:[/red] {exc}") + console.print( + "[dim]Repair `.vscode/settings.json` or re-run with [bold]--force[/bold] to replace it " + "after a timestamped backup under `.specfact/recovery/`.[/dim]" + ) + raise click.exceptions.Exit(1) from exc return all_copied, settings_path @@ -873,6 +896,7 @@ def create_vscode_settings( settings_file: str, *, prompts_by_source: dict[str, list[Path]] | None = None, + force: bool = False, ) -> Path | None: """ Create or merge VS Code settings.json with prompt file recommendations. @@ -886,6 +910,8 @@ def create_vscode_settings( leave stale recommendations; other ``.github/prompts/`` entries and paths outside that folder are preserved. When ``None``, recommendations follow the full discovered catalog (or legacy flat fallbacks). + force: When True, invalid or non-mergeable ``settings.json`` is replaced after a timestamped + backup under ``.specfact/recovery/`` (explicit replace path). Returns: Path to settings file, or None if not VS Code/Copilot @@ -895,12 +921,6 @@ def create_vscode_settings( >>> settings is not None True """ - import json - - settings_path = repo_path / settings_file - settings_dir = settings_path.parent - settings_dir.mkdir(parents=True, exist_ok=True) - if prompts_by_source is not None: prompt_files = _finalize_vscode_prompt_recommendation_paths( repo_path, _vscode_prompt_recommendation_paths_from_sources(prompts_by_source) @@ -910,37 +930,14 @@ def create_vscode_settings( repo_path, _vscode_prompt_paths_from_full_catalog(repo_path) ) - # Load existing settings or create new - if settings_path.exists(): - try: - with open(settings_path, encoding="utf-8") as f: - existing_settings = json.load(f) - except (json.JSONDecodeError, FileNotFoundError): - existing_settings = {} - else: - existing_settings = {} - - # Merge chat.promptFilesRecommendations - if "chat" not in existing_settings: - existing_settings["chat"] = {} - - chat_block = existing_settings["chat"] - chat_dict: dict[str, Any] = cast(dict[str, Any], chat_block) if isinstance(chat_block, dict) else {} - existing_recommendations = chat_dict.get("promptFilesRecommendations", []) - if prompts_by_source is not None: - existing_recommendations = _strip_specfact_github_prompt_recommendations( - list(existing_recommendations) if isinstance(existing_recommendations, list) else [], - ) - merged_recommendations = list(set(existing_recommendations + prompt_files)) - chat_dict["promptFilesRecommendations"] = merged_recommendations - existing_settings["chat"] = chat_dict - - # Write merged settings - with open(settings_path, "w", encoding="utf-8") as f: - json.dump(existing_settings, f, indent=4) - f.write("\n") + settings_path = merge_vscode_settings_prompt_recommendations( + repo_path, + settings_file, + prompt_files, + strip_specfact_github_from_existing=prompts_by_source is not None, + explicit_replace_unparseable=force, + ) - # Ensure file exists before returning (satisfies contract) if not settings_path.exists(): console.print(f"[yellow]Warning:[/yellow] Settings file not created: {settings_path}") return None diff --git a/src/specfact_cli/utils/project_artifact_write.py b/src/specfact_cli/utils/project_artifact_write.py new file mode 100644 index 00000000..8c70a275 --- /dev/null +++ b/src/specfact_cli/utils/project_artifact_write.py @@ -0,0 +1,169 @@ +"""Safe writes into user-owned project artifacts (init/setup trust boundary).""" + +from __future__ import annotations + +import json +import re +import shutil +from datetime import UTC, datetime +from enum import StrEnum +from pathlib import Path +from typing import Any, Final, cast + +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.common import get_bridge_logger + + +_logger = get_bridge_logger(__name__) + +RECOVERY_SUBDIR: Final[str] = ".specfact/recovery" + + +class ProjectArtifactWriteError(RuntimeError): + """Blocked or unsafe write into a user project artifact.""" + + +class StructuredJsonDocumentError(ProjectArtifactWriteError): + """JSON settings cannot be merged without data loss or repair.""" + + +class ProjectWriteMode(StrEnum): + """Declared write semantics for a project artifact (policy surface).""" + + CREATE_ONLY = "create_only" + MERGE_STRUCTURED = "merge_structured" + EXPLICIT_REPLACE = "explicit_replace" + + +@beartype +def _is_specfact_github_prompt_path(path: str) -> bool: + """True for SpecFact-managed GitHub prompt recommendations (strip on selective export).""" + normalized = path.replace("\\", "/").lstrip("./") + if not normalized.startswith("github/prompts/"): + return False + name = Path(normalized).name + return bool(name.startswith("specfact") and name.endswith(".prompt.md")) + + +@beartype +def _strip_specfact_github_prompt_recommendations(paths: list[str]) -> list[str]: + """Remove prior SpecFact-managed ``.github/prompts/`` entries; keep team-owned paths.""" + return [p for p in paths if not _is_specfact_github_prompt_path(p)] + + +@beartype +def _ordered_unique_strings(items: list[str]) -> list[str]: + seen: set[str] = set() + out: list[str] = [] + for item in items: + if item in seen: + continue + seen.add(item) + out.append(item) + return out + + +@beartype +@require(lambda repo_root: repo_root.exists() and repo_root.is_dir()) +@require(lambda source: source.is_file()) +@ensure(lambda result: result.is_file()) +def backup_file_to_recovery(repo_root: Path, source: Path) -> Path: + """Copy ``source`` into ``.specfact/recovery`` with a UTC timestamp suffix.""" + recovery_dir = (repo_root / RECOVERY_SUBDIR).resolve() + recovery_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", source.name) + dest = recovery_dir / f"{safe_name}.{stamp}.bak" + shutil.copy2(source, dest) + return dest + + +@beartype +@require(lambda repo_path: repo_path.exists() and repo_path.is_dir()) +@require(lambda settings_relative: settings_relative.strip() != "") +@require(lambda prompt_files: all(isinstance(p, str) for p in prompt_files)) +@ensure(lambda result: result.exists() and result.is_file()) +def merge_vscode_settings_prompt_recommendations( + repo_path: Path, + settings_relative: str, + prompt_files: list[str], + *, + strip_specfact_github_from_existing: bool, + explicit_replace_unparseable: bool, +) -> Path: + """ + Merge SpecFact ``chat.promptFilesRecommendations`` into VS Code ``settings.json``. + + Preserves all other top-level keys and non-SpecFact recommendation paths. On invalid JSON or + unusable ``chat`` / ``promptFilesRecommendations`` shape, raises ``StructuredJsonDocumentError`` + unless ``explicit_replace_unparseable`` is True (backup, then recoverable rewrite). + """ + settings_path = (repo_path / settings_relative).resolve() + settings_path.parent.mkdir(parents=True, exist_ok=True) + backup_path: Path | None = None + + if not settings_path.exists(): + payload: dict[str, Any] = {"chat": {"promptFilesRecommendations": list(prompt_files)}} + settings_path.write_text(json.dumps(payload, indent=4) + "\n", encoding="utf-8") + return settings_path + + raw_text = settings_path.read_text(encoding="utf-8") + loaded: Any + try: + loaded = json.loads(raw_text) + except json.JSONDecodeError as exc: + if not explicit_replace_unparseable: + raise StructuredJsonDocumentError( + f"Cannot merge into {settings_path}: invalid JSON ({exc.msg} at line {exc.lineno} col {exc.colno}). " + "Fix the file or re-run with --force to replace it after a backup under .specfact/recovery/." + ) from exc + backup_path = backup_file_to_recovery(repo_path, settings_path) + _logger.info("Backed up unparseable settings to %s", backup_path) + loaded = {} + + if not isinstance(loaded, dict): + if not explicit_replace_unparseable: + raise StructuredJsonDocumentError( + f"Cannot merge into {settings_path}: root value must be a JSON object, not {type(loaded).__name__}." + ) + if backup_path is None: + backup_path = backup_file_to_recovery(repo_path, settings_path) + _logger.info("Backed up settings before replace to %s", backup_path) + loaded = {} + + chat_block = loaded.get("chat", {}) + if not isinstance(chat_block, dict): + if not explicit_replace_unparseable: + raise StructuredJsonDocumentError( + f'Cannot merge into {settings_path}: "chat" must be a JSON object, not {type(chat_block).__name__}.' + ) + if backup_path is None: + backup_path = backup_file_to_recovery(repo_path, settings_path) + _logger.info("Backed up settings before chat coercion to %s", backup_path) + chat_block = {} + loaded["chat"] = chat_block + + existing_recommendations = chat_block.get("promptFilesRecommendations", []) + if not isinstance(existing_recommendations, list): + if not explicit_replace_unparseable: + raise StructuredJsonDocumentError( + f'Cannot merge into {settings_path}: "chat.promptFilesRecommendations" must be a JSON array.' + ) + if backup_path is None: + backup_path = backup_file_to_recovery(repo_path, settings_path) + _logger.info("Backed up settings before recommendations coercion to %s", backup_path) + existing_recommendations = [] + + recs_as_strings = [str(x) for x in existing_recommendations] + if strip_specfact_github_from_existing: + recs_as_strings = _strip_specfact_github_prompt_recommendations(recs_as_strings) + + merged_list = _ordered_unique_strings([*recs_as_strings, *prompt_files]) + chat_block = cast(dict[str, Any], chat_block) + chat_block["promptFilesRecommendations"] = merged_list + loaded["chat"] = chat_block + + settings_path.write_text(json.dumps(loaded, indent=4) + "\n", encoding="utf-8") + return settings_path diff --git a/tests/unit/modules/init/test_init_ide_prompt_selection.py b/tests/unit/modules/init/test_init_ide_prompt_selection.py index fe81b7c7..c4f3ba32 100644 --- a/tests/unit/modules/init/test_init_ide_prompt_selection.py +++ b/tests/unit/modules/init/test_init_ide_prompt_selection.py @@ -152,6 +152,27 @@ def test_parse_prompts_option_core_token(tmp_path: Path) -> None: assert out == {PROMPT_SOURCE_CORE: [p]} +def test_init_ide_malformed_vscode_settings_exits_nonzero(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + import specfact_cli.utils.ide_setup as ide_setup_module + + monkeypatch.setattr(ide_setup_module, "_module_prompt_sources_catalog", lambda _rp: {}) + prompts = tmp_path / "resources" / "prompts" + prompts.mkdir(parents=True) + (prompts / "specfact.01-import.md").write_text("---\ndescription: A\n---\n# A\n", encoding="utf-8") + + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + (vscode_dir / "settings.json").write_text("{not-json", encoding="utf-8") + + runner = CliRunner() + result = runner.invoke( + app, + ["init", "ide", "--repo", str(tmp_path), "--ide", "vscode", "--prompts", "core"], + ) + assert result.exit_code == 1 + assert "invalid json" in result.stdout.lower() or "cannot merge" in result.stdout.lower() + + def test_init_ide_invalid_prompts_token_exits_nonzero(tmp_path: Path) -> None: prompts = tmp_path / "resources" / "prompts" prompts.mkdir(parents=True) diff --git a/tests/unit/scripts/test_verify_safe_project_writes.py b/tests/unit/scripts/test_verify_safe_project_writes.py new file mode 100644 index 00000000..3563c40c --- /dev/null +++ b/tests/unit/scripts/test_verify_safe_project_writes.py @@ -0,0 +1,19 @@ +"""Tests for scripts/verify_safe_project_writes.py.""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + + +def test_verify_safe_project_writes_passes_on_repo() -> None: + """Gate must succeed while ide_setup routes settings through project_artifact_write.""" + script = Path(__file__).resolve().parents[3] / "scripts" / "verify_safe_project_writes.py" + completed = subprocess.run( + [sys.executable, str(script)], + check=False, + capture_output=True, + text=True, + ) + assert completed.returncode == 0, completed.stderr diff --git a/tests/unit/utils/test_project_artifact_write.py b/tests/unit/utils/test_project_artifact_write.py new file mode 100644 index 00000000..722366a6 --- /dev/null +++ b/tests/unit/utils/test_project_artifact_write.py @@ -0,0 +1,115 @@ +"""Tests for safe project artifact writes (VS Code settings merge).""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + +from specfact_cli.utils.ide_setup import PROMPT_SOURCE_CORE, create_vscode_settings +from specfact_cli.utils.project_artifact_write import ( + StructuredJsonDocumentError, + backup_file_to_recovery, + merge_vscode_settings_prompt_recommendations, +) + + +def test_merge_vscode_settings_creates_file_when_missing(tmp_path: Path) -> None: + """New repo: write only managed recommendations.""" + out = merge_vscode_settings_prompt_recommendations( + tmp_path, + ".vscode/settings.json", + [".github/prompts/specfact.01-import.prompt.md"], + strip_specfact_github_from_existing=False, + explicit_replace_unparseable=False, + ) + assert out.exists() + data = json.loads(out.read_text(encoding="utf-8")) + assert data["chat"]["promptFilesRecommendations"] == [".github/prompts/specfact.01-import.prompt.md"] + + +def test_backup_file_to_recovery_writes_under_specfact(tmp_path: Path) -> None: + src = tmp_path / "sample.json" + src.write_text('{"a": 1}', encoding="utf-8") + dest = backup_file_to_recovery(tmp_path, src) + assert dest.is_file() + assert ".specfact/recovery" in str(dest.relative_to(tmp_path)) + assert dest.read_text(encoding="utf-8") == '{"a": 1}' + + +def test_create_vscode_settings_malformed_json_raises_and_leaves_file(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + garbage = "{ not json ,\n" + settings_path.write_text(garbage, encoding="utf-8") + prompt = tmp_path / "specfact.01-import.md" + prompt.write_text("---\n---\n", encoding="utf-8") + with pytest.raises(StructuredJsonDocumentError): + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, + force=False, + ) + assert settings_path.read_text(encoding="utf-8") == garbage + + +def test_create_vscode_settings_preserves_unrelated_keys(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + original = { + "python.defaultInterpreterPath": "/usr/bin/python3", + "chat": {"otherSetting": True, "promptFilesRecommendations": []}, + } + settings_path.write_text(json.dumps(original), encoding="utf-8") + prompt = tmp_path / "specfact.01-import.md" + prompt.write_text("---\n---\n", encoding="utf-8") + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, + force=False, + ) + data = json.loads(settings_path.read_text(encoding="utf-8")) + assert data["python.defaultInterpreterPath"] == "/usr/bin/python3" + assert data["chat"]["otherSetting"] is True + assert ".github/prompts/specfact.01-import.prompt.md" in data["chat"]["promptFilesRecommendations"] + + +def test_create_vscode_settings_force_replaces_unparseable_with_backup(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + settings_path.write_text("{broken", encoding="utf-8") + prompt = tmp_path / "specfact.01-import.md" + prompt.write_text("---\n---\n", encoding="utf-8") + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, + force=True, + ) + recovery = tmp_path / ".specfact" / "recovery" + assert recovery.is_dir() + assert any(recovery.glob("settings.json.*.bak")) + data = json.loads(settings_path.read_text(encoding="utf-8")) + assert ".github/prompts/specfact.01-import.prompt.md" in data["chat"]["promptFilesRecommendations"] + + +def test_create_vscode_settings_chat_not_object_raises_without_force(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + settings_path.write_text(json.dumps({"chat": "invalid"}), encoding="utf-8") + prompt = tmp_path / "specfact.01-import.md" + prompt.write_text("---\n---\n", encoding="utf-8") + with pytest.raises(StructuredJsonDocumentError): + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, + force=False, + ) From ed1639991406c22ae37e0e59e70c39bb8de49ed8 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:10:14 +0200 Subject: [PATCH 02/12] fix(profile-04): satisfy review gate, pin setuptools for semgrep - Refactor project_artifact_write merge path (KISS); icontract predicates - Deduplicate ide_setup prompt helpers; import from project_artifact_write - verify_safe_project_writes: ast.walk, contracts, beartype - Pin setuptools<82 for Semgrep pkg_resources chain - Update TDD_EVIDENCE and tasks checklist Made-with: Cursor --- CHANGELOG.md | 2 + .../TDD_EVIDENCE.md | 15 +- .../tasks.md | 2 +- pyproject.toml | 6 +- scripts/verify_safe_project_writes.py | 29 ++- src/specfact_cli/utils/contract_predicates.py | 14 ++ src/specfact_cli/utils/ide_setup.py | 14 -- .../utils/project_artifact_write.py | 182 ++++++++++++------ tests/unit/utils/test_contract_predicates.py | 10 + tests/unit/utils/test_ide_setup.py | 2 +- 10 files changed, 181 insertions(+), 95 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 60098c42..39694e56 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,8 @@ All notable changes to this project will be documented in this file. replacing the file after a timestamped backup under `.specfact/recovery/`. - **Regression gate**: lint now runs `scripts/verify_safe_project_writes.py` so IDE settings JSON I/O stays routed through the shared merge helper. +- **Dev / Semgrep**: Hatch and `[dev]` extras pin `setuptools<82` so Semgrep’s OpenTelemetry import chain still + resolves `pkg_resources` (setuptools 82+ may omit it). --- diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md index bbaf6d4a..7823818f 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md @@ -38,10 +38,17 @@ hatch run smart-test ## Code review gate -- **Attempted**: `hatch run specfact code review run --json --out .specfact/code-review.json` — blocked in a minimal Hatch env - because the `code` command group is provided by the `nold-ai/specfact-codebase` module (not installed in this worktree by default). -- **Follow-up before PR**: install the codebase bundle (e.g. `specfact init --profile solo-developer` or `specfact module install nold-ai/specfact-codebase`) - in the same environment, then re-run the command above and attach `.specfact/code-review.json` to the PR. +- **Pass (2026-04-12)**: after `hatch run specfact module install nold-ai/specfact-codebase` and + `hatch run specfact module install nold-ai/specfact-code-review` (user scope), run: + +```bash +hatch run specfact code review run --json --out .specfact/code-review.json \ + src/specfact_cli/utils/project_artifact_write.py \ + src/specfact_cli/utils/ide_setup.py \ + scripts/verify_safe_project_writes.py +``` + +- Report: `.specfact/code-review.json` (exit 0, no blocking findings after merge-helper refactor + setuptools pin). ## Worktree cleanup (post-merge on developer machine) diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md index c6b47de7..359e0c17 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md @@ -24,7 +24,7 @@ - [x] 4.2 Research and update affected docs (`README.md`, installation/quickstart/init references) to document preservation guarantees, backup behavior, and explicit replacement semantics. *(installation.md + version pins in README / samples)* - [x] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, and `hatch run smart-test`. - [x] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled module manifests changed, bump versions, re-sign as required, and re-run verification. *(no `modules/init` payload change — error UX handled in `ide_setup` to avoid re-signing)* -- [ ] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. *(requires `specfact code review` surface — install `nold-ai/specfact-codebase` in the Hatch env or run from a profile-bootstrapped environment; see TDD_EVIDENCE.)* +- [x] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. - [x] 4.6 Apply the appropriate version/changelog update for a bugfix release if implementation changes user-facing behavior, then open a PR to `dev` referencing the paired modules change. *(version/changelog done; PR human)* ## 5. Worktree cleanup diff --git a/pyproject.toml b/pyproject.toml index 0a0791d6..78f81cf6 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -104,7 +104,7 @@ contracts = [ ] dev = [ - "setuptools>=69.0.0", + "setuptools>=69.0.0,<82", "pytest>=8.4.2", "pytest-cov>=7.0.0", "pytest-mock>=3.15.1", @@ -172,8 +172,8 @@ specfact-cli = "specfact_cli.cli:cli_main" # Alias for uvx compatibility [tool.hatch.envs.default] python = "3.12" dependencies = [ - # Semgrep pulls opentelemetry; some versions import pkg_resources (setuptools) - "setuptools>=69.0.0", + # Semgrep pulls opentelemetry; setuptools 82+ may omit pkg_resources (breaks semgrep import chain) + "setuptools>=69.0.0,<82", "pip-tools", "pytest", "pytest-cov", diff --git a/scripts/verify_safe_project_writes.py b/scripts/verify_safe_project_writes.py index 8c744a7d..b498cf7b 100644 --- a/scripts/verify_safe_project_writes.py +++ b/scripts/verify_safe_project_writes.py @@ -7,16 +7,23 @@ import sys from pathlib import Path +from beartype import beartype +from icontract import ensure, require + ROOT = Path(__file__).resolve().parent.parent IDE_SETUP = ROOT / "src" / "specfact_cli" / "utils" / "ide_setup.py" -class _JsonIoVisitor(ast.NodeVisitor): - def __init__(self) -> None: - self.offenders: list[tuple[int, str]] = [] +def _repo_layout_ok() -> bool: + return ROOT.is_dir() + - def visit_Call(self, node: ast.Call) -> None: +def _collect_json_io_offenders(tree: ast.AST) -> list[tuple[int, str]]: + offenders: list[tuple[int, str]] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue func = node.func if ( isinstance(func, ast.Attribute) @@ -24,19 +31,21 @@ def visit_Call(self, node: ast.Call) -> None: and func.value.id == "json" and func.attr in {"load", "dump", "loads", "dumps"} ): - self.offenders.append((node.lineno, f"json.{func.attr}")) - self.generic_visit(node) + offenders.append((node.lineno, f"json.{func.attr}")) + return offenders +@beartype +@require(_repo_layout_ok) +@ensure(lambda result: result in (0, 1, 2)) def main() -> int: if not IDE_SETUP.is_file(): print(f"Expected ide_setup at {IDE_SETUP}", file=sys.stderr) return 2 tree = ast.parse(IDE_SETUP.read_text(encoding="utf-8"), filename=str(IDE_SETUP)) - visitor = _JsonIoVisitor() - visitor.visit(tree) - if visitor.offenders: - lines = ", ".join(f"line {ln} ({name})" for ln, name in visitor.offenders) + offenders = _collect_json_io_offenders(tree) + if offenders: + lines = ", ".join(f"line {ln} ({name})" for ln, name in offenders) print( "Unsafe JSON I/O in ide_setup.py — route VS Code settings through " f"specfact_cli.utils.project_artifact_write.merge_vscode_settings_prompt_recommendations: {lines}", diff --git a/src/specfact_cli/utils/contract_predicates.py b/src/specfact_cli/utils/contract_predicates.py index da1977ce..2106a5c5 100644 --- a/src/specfact_cli/utils/contract_predicates.py +++ b/src/specfact_cli/utils/contract_predicates.py @@ -52,6 +52,20 @@ def file_path_exists(file_path: Path) -> bool: return file_path.exists() +@require(lambda settings_relative: isinstance(settings_relative, str)) +@ensure(lambda result: isinstance(result, bool)) +@beartype +def settings_relative_nonblank(settings_relative: str) -> bool: + return settings_relative.strip() != "" + + +@require(lambda prompt_files: isinstance(prompt_files, list)) +@ensure(lambda result: isinstance(result, bool)) +@beartype +def prompt_files_all_strings(prompt_files: list[str]) -> bool: + return all(isinstance(item, str) for item in prompt_files) + + @require(lambda template_path: isinstance(template_path, Path)) @ensure(lambda result: isinstance(result, bool)) @beartype diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 0f384fd6..27adfd9d 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -820,20 +820,6 @@ def _finalize_vscode_prompt_recommendation_paths(repo_path: Path, prompt_files: return prompt_files -def _is_specfact_github_prompt_path(path: str) -> bool: - """True for SpecFact-managed GitHub prompt recommendations (strip on selective export); keeps team paths.""" - normalized = path.replace("\\", "/").lstrip("./") - if not normalized.startswith("github/prompts/"): - return False - name = Path(normalized).name - return name.startswith("specfact") and name.endswith(".prompt.md") - - -def _strip_specfact_github_prompt_recommendations(paths: list[str]) -> list[str]: - """Remove prior SpecFact-managed ``.github/prompts/`` entries before merging a selective export; keep other paths.""" - return [p for p in paths if not _is_specfact_github_prompt_path(p)] - - @beartype @require(repo_path_exists, "Repo path must exist") @require(repo_path_is_dir, "Repo path must be a directory") diff --git a/src/specfact_cli/utils/project_artifact_write.py b/src/specfact_cli/utils/project_artifact_write.py index 8c70a275..bd6143b0 100644 --- a/src/specfact_cli/utils/project_artifact_write.py +++ b/src/specfact_cli/utils/project_artifact_write.py @@ -14,6 +14,13 @@ from icontract import ensure, require from specfact_cli.common import get_bridge_logger +from specfact_cli.utils.contract_predicates import ( + file_path_is_file, + prompt_files_all_strings, + repo_path_exists, + repo_path_is_dir, + settings_relative_nonblank, +) _logger = get_bridge_logger(__name__) @@ -65,52 +72,29 @@ def _ordered_unique_strings(items: list[str]) -> list[str]: return out -@beartype -@require(lambda repo_root: repo_root.exists() and repo_root.is_dir()) -@require(lambda source: source.is_file()) -@ensure(lambda result: result.is_file()) -def backup_file_to_recovery(repo_root: Path, source: Path) -> Path: - """Copy ``source`` into ``.specfact/recovery`` with a UTC timestamp suffix.""" - recovery_dir = (repo_root / RECOVERY_SUBDIR).resolve() - recovery_dir.mkdir(parents=True, exist_ok=True) - stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") - safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", source.name) - dest = recovery_dir / f"{safe_name}.{stamp}.bak" - shutil.copy2(source, dest) - return dest +def _write_new_vscode_settings_file(settings_path: Path, prompt_files: list[str]) -> None: + payload: dict[str, Any] = {"chat": {"promptFilesRecommendations": list(prompt_files)}} + settings_path.write_text(json.dumps(payload, indent=4) + "\n", encoding="utf-8") -@beartype -@require(lambda repo_path: repo_path.exists() and repo_path.is_dir()) -@require(lambda settings_relative: settings_relative.strip() != "") -@require(lambda prompt_files: all(isinstance(p, str) for p in prompt_files)) -@ensure(lambda result: result.exists() and result.is_file()) -def merge_vscode_settings_prompt_recommendations( +def _ensure_backup( repo_path: Path, - settings_relative: str, - prompt_files: list[str], - *, - strip_specfact_github_from_existing: bool, - explicit_replace_unparseable: bool, + settings_path: Path, + backup_path: Path | None, ) -> Path: - """ - Merge SpecFact ``chat.promptFilesRecommendations`` into VS Code ``settings.json``. + if backup_path is not None: + return backup_path + return backup_file_to_recovery(repo_path, settings_path) - Preserves all other top-level keys and non-SpecFact recommendation paths. On invalid JSON or - unusable ``chat`` / ``promptFilesRecommendations`` shape, raises ``StructuredJsonDocumentError`` - unless ``explicit_replace_unparseable`` is True (backup, then recoverable rewrite). - """ - settings_path = (repo_path / settings_relative).resolve() - settings_path.parent.mkdir(parents=True, exist_ok=True) - backup_path: Path | None = None - if not settings_path.exists(): - payload: dict[str, Any] = {"chat": {"promptFilesRecommendations": list(prompt_files)}} - settings_path.write_text(json.dumps(payload, indent=4) + "\n", encoding="utf-8") - return settings_path - - raw_text = settings_path.read_text(encoding="utf-8") - loaded: Any +def _load_root_dict_from_settings_text( + settings_path: Path, + repo_path: Path, + raw_text: str, + explicit_replace_unparseable: bool, + backup_path: Path | None, +) -> tuple[dict[str, Any], Path | None]: + out_backup = backup_path try: loaded = json.loads(raw_text) except json.JSONDecodeError as exc: @@ -119,29 +103,42 @@ def merge_vscode_settings_prompt_recommendations( f"Cannot merge into {settings_path}: invalid JSON ({exc.msg} at line {exc.lineno} col {exc.colno}). " "Fix the file or re-run with --force to replace it after a backup under .specfact/recovery/." ) from exc - backup_path = backup_file_to_recovery(repo_path, settings_path) - _logger.info("Backed up unparseable settings to %s", backup_path) - loaded = {} + out_backup = _ensure_backup(repo_path, settings_path, out_backup) + _logger.info("Backed up unparseable settings to %s", out_backup) + return {}, out_backup - if not isinstance(loaded, dict): - if not explicit_replace_unparseable: - raise StructuredJsonDocumentError( - f"Cannot merge into {settings_path}: root value must be a JSON object, not {type(loaded).__name__}." - ) - if backup_path is None: - backup_path = backup_file_to_recovery(repo_path, settings_path) - _logger.info("Backed up settings before replace to %s", backup_path) - loaded = {} + if isinstance(loaded, dict): + return loaded, out_backup - chat_block = loaded.get("chat", {}) + if not explicit_replace_unparseable: + raise StructuredJsonDocumentError( + f"Cannot merge into {settings_path}: root value must be a JSON object, not {type(loaded).__name__}." + ) + out_backup = _ensure_backup(repo_path, settings_path, out_backup) + _logger.info("Backed up settings before replace to %s", out_backup) + return {}, out_backup + + +def _merge_chat_and_recommendations( + loaded: dict[str, Any], + settings_path: Path, + repo_path: Path, + explicit_replace_unparseable: bool, + backup_path: Path | None, + strip_specfact_github_from_existing: bool, + prompt_files: list[str], +) -> None: + out_backup = backup_path + if "chat" not in loaded: + loaded["chat"] = {} + chat_block = loaded["chat"] if not isinstance(chat_block, dict): if not explicit_replace_unparseable: raise StructuredJsonDocumentError( f'Cannot merge into {settings_path}: "chat" must be a JSON object, not {type(chat_block).__name__}.' ) - if backup_path is None: - backup_path = backup_file_to_recovery(repo_path, settings_path) - _logger.info("Backed up settings before chat coercion to %s", backup_path) + out_backup = _ensure_backup(repo_path, settings_path, out_backup) + _logger.info("Backed up settings before chat coercion to %s", out_backup) chat_block = {} loaded["chat"] = chat_block @@ -151,9 +148,8 @@ def merge_vscode_settings_prompt_recommendations( raise StructuredJsonDocumentError( f'Cannot merge into {settings_path}: "chat.promptFilesRecommendations" must be a JSON array.' ) - if backup_path is None: - backup_path = backup_file_to_recovery(repo_path, settings_path) - _logger.info("Backed up settings before recommendations coercion to %s", backup_path) + out_backup = _ensure_backup(repo_path, settings_path, out_backup) + _logger.info("Backed up settings before recommendations coercion to %s", out_backup) existing_recommendations = [] recs_as_strings = [str(x) for x in existing_recommendations] @@ -161,9 +157,71 @@ def merge_vscode_settings_prompt_recommendations( recs_as_strings = _strip_specfact_github_prompt_recommendations(recs_as_strings) merged_list = _ordered_unique_strings([*recs_as_strings, *prompt_files]) - chat_block = cast(dict[str, Any], chat_block) - chat_block["promptFilesRecommendations"] = merged_list - loaded["chat"] = chat_block + chat_typed = cast(dict[str, Any], chat_block) + chat_typed["promptFilesRecommendations"] = merged_list + loaded["chat"] = chat_typed + + +@beartype +@require(repo_path_exists, "Repo path must exist") +@require(repo_path_is_dir, "Repo path must be a directory") +@require(file_path_is_file, "file_path must be an existing file") +@ensure(lambda result: result.is_file()) +def backup_file_to_recovery(repo_path: Path, file_path: Path) -> Path: + """Copy ``file_path`` into ``.specfact/recovery`` with a UTC timestamp suffix.""" + recovery_dir = (repo_path / RECOVERY_SUBDIR).resolve() + recovery_dir.mkdir(parents=True, exist_ok=True) + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file_path.name) + dest = recovery_dir / f"{safe_name}.{stamp}.bak" + shutil.copy2(file_path, dest) + return dest + +@beartype +@require(repo_path_exists, "Repo path must exist") +@require(repo_path_is_dir, "Repo path must be a directory") +@require(settings_relative_nonblank, "settings_relative must be non-empty") +@require(prompt_files_all_strings, "prompt_files must be a list of str") +@ensure(lambda result: result.exists() and result.is_file()) +def merge_vscode_settings_prompt_recommendations( + repo_path: Path, + settings_relative: str, + prompt_files: list[str], + *, + strip_specfact_github_from_existing: bool, + explicit_replace_unparseable: bool, +) -> Path: + """ + Merge SpecFact ``chat.promptFilesRecommendations`` into VS Code ``settings.json``. + + Preserves all other top-level keys and non-SpecFact recommendation paths. On invalid JSON or + unusable ``chat`` / ``promptFilesRecommendations`` shape, raises ``StructuredJsonDocumentError`` + unless ``explicit_replace_unparseable`` is True (backup, then recoverable rewrite). + """ + settings_path = (repo_path / settings_relative).resolve() + settings_path.parent.mkdir(parents=True, exist_ok=True) + + if not settings_path.exists(): + _write_new_vscode_settings_file(settings_path, prompt_files) + return settings_path + + raw_text = settings_path.read_text(encoding="utf-8") + loaded, backup_path = _load_root_dict_from_settings_text( + settings_path, + repo_path, + raw_text, + explicit_replace_unparseable, + None, + ) + _merge_chat_and_recommendations( + loaded, + settings_path, + repo_path, + explicit_replace_unparseable, + backup_path, + strip_specfact_github_from_existing, + prompt_files, + ) settings_path.write_text(json.dumps(loaded, indent=4) + "\n", encoding="utf-8") return settings_path diff --git a/tests/unit/utils/test_contract_predicates.py b/tests/unit/utils/test_contract_predicates.py index 71a7304b..ddd54b98 100644 --- a/tests/unit/utils/test_contract_predicates.py +++ b/tests/unit/utils/test_contract_predicates.py @@ -31,3 +31,13 @@ def test_vscode_settings_result_ok(tmp_path: Path) -> None: p.write_text("{}", encoding="utf-8") assert cp.vscode_settings_result_ok(p) is True assert cp.vscode_settings_result_ok(None) is True + + +def test_settings_relative_nonblank() -> None: + assert cp.settings_relative_nonblank(".vscode/settings.json") is True + assert cp.settings_relative_nonblank(" ") is False + + +def test_prompt_files_all_strings() -> None: + assert cp.prompt_files_all_strings([]) is True + assert cp.prompt_files_all_strings(["a", "b"]) is True diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index e1fd9146..bfe8aebb 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -11,7 +11,6 @@ PROMPT_SOURCE_CORE, SPECFACT_COMMANDS, _flat_export_glob_pattern_for_prune, - _is_specfact_github_prompt_path, copy_templates_to_ide, create_vscode_settings, detect_ide, @@ -22,6 +21,7 @@ read_template, write_ide_prompt_export_state, ) +from specfact_cli.utils.project_artifact_write import _is_specfact_github_prompt_path class TestDetectIDE: From 3a76aacb434ad233e231d14f86c6701eff578bb3 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:19:00 +0200 Subject: [PATCH 03/12] ci: run safe-write verifier in PR orchestrator lint job Match hatch run lint by invoking scripts/verify_safe_project_writes.py after ruff/basedpyright/pylint. Use set -euo pipefail so the first lint failure is not masked by later commands. Made-with: Cursor --- .github/workflows/pr-orchestrator.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index c7f022a1..cc1d153b 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -516,7 +516,7 @@ jobs: if-no-files-found: ignore linting: - name: Linting (ruff, pylint) + name: Linting (ruff, pylint, safe-write guard) runs-on: ubuntu-latest needs: [changes, verify-module-signatures] if: needs.changes.outputs.code_changed == 'true' && needs.changes.outputs.skip_tests_dev_to_main != 'true' @@ -545,10 +545,12 @@ jobs: mkdir -p logs/lint LINT_LOG="logs/lint/lint_$(date -u +%Y%m%d_%H%M%S).log" { + set -euo pipefail ruff format . --check python -m basedpyright --pythonpath "$(python -c 'import sys; print(sys.executable)')" ruff check . pylint src tests tools + python scripts/verify_safe_project_writes.py } 2>&1 | tee "$LINT_LOG" exit "${PIPESTATUS[0]:-$?}" - name: Upload lint logs From 75cf8645afafda0a33b8a8a71e3064c62d5f75f0 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:23:38 +0200 Subject: [PATCH 04/12] fix(profile-04): address CodeRabbit review (docs, guard, contracts, tests) - Wrap installation.md VS Code merge paragraph to <=120 chars per line - tasks 4.7 + TDD_EVIDENCE: openspec validate --strict sign-off - verify_safe_project_writes: detect from-json import and aliases - settings_relative_nonblank: reject absolute paths and .. segments - ide_setup: _handle_structured_json_document_error for duplicate handlers - ProjectWriteMode docstring (reserved policy surface); backup stamp + collision loop - Tests: malformed settings preserved on init ide exit; force+chat coercion; AST guard tests Made-with: Cursor --- docs/getting-started/installation.md | 5 +++- .../TDD_EVIDENCE.md | 4 +++ .../tasks.md | 1 + scripts/verify_safe_project_writes.py | 21 ++++++++++++++- src/specfact_cli/utils/contract_predicates.py | 8 +++++- src/specfact_cli/utils/ide_setup.py | 25 +++++++++-------- .../utils/project_artifact_write.py | 13 +++++++-- .../init/test_init_ide_prompt_selection.py | 4 ++- .../test_verify_safe_project_writes.py | 27 +++++++++++++++++++ tests/unit/utils/test_contract_predicates.py | 2 ++ .../unit/utils/test_project_artifact_write.py | 21 +++++++++++++++ 11 files changed, 112 insertions(+), 19 deletions(-) diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 6b69c0a6..cb704419 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -75,7 +75,10 @@ specfact init ide --ide cursor --install-deps **Important**: SpecFact CLI does **not** ship with built-in AI. `specfact init ide` installs prompt templates for supported IDEs so your chosen AI copilot can call SpecFact commands in a guided workflow. -For VS Code / Copilot, the CLI **merges** prompt recommendations into `.vscode/settings.json` and keeps your other settings keys. If that file is not valid JSON (or its `chat` block is not mergeable), the command stops without rewriting it; use `specfact init ide --force` only when you accept replacing the file after a timestamped backup under `.specfact/recovery/`. +For VS Code / Copilot, the CLI **merges** prompt recommendations into `.vscode/settings.json` and keeps your other +settings keys. If that file is not valid JSON (or its `chat` block is not mergeable), the command stops without +rewriting it; use `specfact init ide --force` only when you accept replacing the file after a timestamped backup under +`.specfact/recovery/`. [More options ↓](#more-options) diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md index 7823818f..55d43952 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md @@ -50,6 +50,10 @@ hatch run specfact code review run --json --out .specfact/code-review.json \ - Report: `.specfact/code-review.json` (exit 0, no blocking findings after merge-helper refactor + setuptools pin). +## OpenSpec strict validation + +- **Pass (2026-04-12)**: `openspec validate profile-04-safe-project-artifact-writes --strict` — exit 0 (recorded at sign-off per `tasks.md` 4.7). + ## Worktree cleanup (post-merge on developer machine) - Remove worktree, delete branch, prune — see `tasks.md` section 5 (not executed in this implementation session). diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md index 359e0c17..96de0536 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md @@ -26,6 +26,7 @@ - [x] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled module manifests changed, bump versions, re-sign as required, and re-run verification. *(no `modules/init` payload change — error UX handled in `ide_setup` to avoid re-signing)* - [x] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. - [x] 4.6 Apply the appropriate version/changelog update for a bugfix release if implementation changes user-facing behavior, then open a PR to `dev` referencing the paired modules change. *(version/changelog done; PR human)* +- [x] 4.7 Run strict OpenSpec validation before sign-off: `openspec validate profile-04-safe-project-artifact-writes --strict`; fix any validation errors until it passes; record the successful run command and timestamp in `TDD_EVIDENCE.md` (or PR notes). ## 5. Worktree cleanup diff --git a/scripts/verify_safe_project_writes.py b/scripts/verify_safe_project_writes.py index b498cf7b..b0d46234 100644 --- a/scripts/verify_safe_project_writes.py +++ b/scripts/verify_safe_project_writes.py @@ -14,22 +14,41 @@ ROOT = Path(__file__).resolve().parent.parent IDE_SETUP = ROOT / "src" / "specfact_cli" / "utils" / "ide_setup.py" +_JSON_IO_NAMES = frozenset({"load", "dump", "loads", "dumps"}) + def _repo_layout_ok() -> bool: return ROOT.is_dir() +def _json_import_aliases(tree: ast.AST) -> dict[str, str]: + """Map local function names to labels like ``json.loads`` for ``from json import ...``.""" + aliases: dict[str, str] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.ImportFrom) or node.module != "json": + continue + for alias in node.names: + if alias.name in _JSON_IO_NAMES: + local = alias.asname or alias.name + aliases[local] = f"json.{alias.name}" + return aliases + + def _collect_json_io_offenders(tree: ast.AST) -> list[tuple[int, str]]: + import_aliases = _json_import_aliases(tree) offenders: list[tuple[int, str]] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func + if isinstance(func, ast.Name) and func.id in import_aliases: + offenders.append((node.lineno, import_aliases[func.id])) + continue if ( isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) and func.value.id == "json" - and func.attr in {"load", "dump", "loads", "dumps"} + and func.attr in _JSON_IO_NAMES ): offenders.append((node.lineno, f"json.{func.attr}")) return offenders diff --git a/src/specfact_cli/utils/contract_predicates.py b/src/specfact_cli/utils/contract_predicates.py index 2106a5c5..8656e338 100644 --- a/src/specfact_cli/utils/contract_predicates.py +++ b/src/specfact_cli/utils/contract_predicates.py @@ -56,7 +56,13 @@ def file_path_exists(file_path: Path) -> bool: @ensure(lambda result: isinstance(result, bool)) @beartype def settings_relative_nonblank(settings_relative: str) -> bool: - return settings_relative.strip() != "" + stripped = settings_relative.strip() + if stripped == "": + return False + path = Path(stripped) + if path.is_absolute(): + return False + return all(part != ".." for part in path.parts) @require(lambda prompt_files: isinstance(prompt_files, list)) diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 27adfd9d..7db560aa 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -13,7 +13,7 @@ import site import sys from pathlib import Path -from typing import Any, Literal, cast +from typing import Any, Literal, NoReturn, cast import click import yaml @@ -473,6 +473,15 @@ def _prune_flat_specfact_exports_not_in_expected( console.print(f"[yellow]Could not remove stale export {p}:[/yellow] {exc}") +def _handle_structured_json_document_error(exc: StructuredJsonDocumentError, cons: Console) -> NoReturn: + cons.print(f"[red]Error:[/red] {exc}") + cons.print( + "[dim]Repair `.vscode/settings.json` or re-run with [bold]--force[/bold] to replace it " + "after a timestamped backup under `.specfact/recovery/`.[/dim]" + ) + raise click.exceptions.Exit(1) from exc + + def _copy_template_files_to_ide( repo_path: Path, ide: str, @@ -515,12 +524,7 @@ def _copy_template_files_to_ide( try: settings_path = create_vscode_settings(repo_path, settings_file, force=force) except StructuredJsonDocumentError as exc: - console.print(f"[red]Error:[/red] {exc}") - console.print( - "[dim]Repair `.vscode/settings.json` or re-run with [bold]--force[/bold] to replace it " - "after a timestamped backup under `.specfact/recovery/`.[/dim]" - ) - raise click.exceptions.Exit(1) from exc + _handle_structured_json_document_error(exc, console) return copied_files, settings_path @@ -631,12 +635,7 @@ def copy_prompts_by_source_to_ide( repo_path, settings_file, prompts_by_source=prompts_by_source, force=force ) except StructuredJsonDocumentError as exc: - console.print(f"[red]Error:[/red] {exc}") - console.print( - "[dim]Repair `.vscode/settings.json` or re-run with [bold]--force[/bold] to replace it " - "after a timestamped backup under `.specfact/recovery/`.[/dim]" - ) - raise click.exceptions.Exit(1) from exc + _handle_structured_json_document_error(exc, console) return all_copied, settings_path diff --git a/src/specfact_cli/utils/project_artifact_write.py b/src/specfact_cli/utils/project_artifact_write.py index bd6143b0..4cdaa2b8 100644 --- a/src/specfact_cli/utils/project_artifact_write.py +++ b/src/specfact_cli/utils/project_artifact_write.py @@ -37,7 +37,12 @@ class StructuredJsonDocumentError(ProjectArtifactWriteError): class ProjectWriteMode(StrEnum): - """Declared write semantics for a project artifact (policy surface).""" + """Declared write semantics for a project artifact (policy surface). + + Reserved for future write-dispatch routing (CREATE_ONLY, MERGE_STRUCTURED, EXPLICIT_REPLACE). Not yet wired into + call sites; kept so policy enums stay aligned with the OpenSpec safe-artifact-write narrative without churning + public module layout later. + """ CREATE_ONLY = "create_only" MERGE_STRUCTURED = "merge_structured" @@ -171,9 +176,13 @@ def backup_file_to_recovery(repo_path: Path, file_path: Path) -> Path: """Copy ``file_path`` into ``.specfact/recovery`` with a UTC timestamp suffix.""" recovery_dir = (repo_path / RECOVERY_SUBDIR).resolve() recovery_dir.mkdir(parents=True, exist_ok=True) - stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ") + stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%S.%fZ") safe_name = re.sub(r"[^A-Za-z0-9._-]+", "_", file_path.name) dest = recovery_dir / f"{safe_name}.{stamp}.bak" + suffix = 0 + while dest.exists(): + suffix += 1 + dest = recovery_dir / f"{safe_name}.{stamp}.{suffix}.bak" shutil.copy2(file_path, dest) return dest diff --git a/tests/unit/modules/init/test_init_ide_prompt_selection.py b/tests/unit/modules/init/test_init_ide_prompt_selection.py index c4f3ba32..9968bfb8 100644 --- a/tests/unit/modules/init/test_init_ide_prompt_selection.py +++ b/tests/unit/modules/init/test_init_ide_prompt_selection.py @@ -162,7 +162,8 @@ def test_init_ide_malformed_vscode_settings_exits_nonzero(tmp_path: Path, monkey vscode_dir = tmp_path / ".vscode" vscode_dir.mkdir(parents=True) - (vscode_dir / "settings.json").write_text("{not-json", encoding="utf-8") + malformed = "{not-json" + (vscode_dir / "settings.json").write_text(malformed, encoding="utf-8") runner = CliRunner() result = runner.invoke( @@ -171,6 +172,7 @@ def test_init_ide_malformed_vscode_settings_exits_nonzero(tmp_path: Path, monkey ) assert result.exit_code == 1 assert "invalid json" in result.stdout.lower() or "cannot merge" in result.stdout.lower() + assert (vscode_dir / "settings.json").read_text(encoding="utf-8") == malformed def test_init_ide_invalid_prompts_token_exits_nonzero(tmp_path: Path) -> None: diff --git a/tests/unit/scripts/test_verify_safe_project_writes.py b/tests/unit/scripts/test_verify_safe_project_writes.py index 3563c40c..66e92200 100644 --- a/tests/unit/scripts/test_verify_safe_project_writes.py +++ b/tests/unit/scripts/test_verify_safe_project_writes.py @@ -2,11 +2,38 @@ from __future__ import annotations +import ast +import importlib.util import subprocess import sys from pathlib import Path +def _load_verify_module() -> object: + root = Path(__file__).resolve().parents[3] + path = root / "scripts" / "verify_safe_project_writes.py" + spec = importlib.util.spec_from_file_location("_verify_safe_project_writes", path) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_collect_json_io_flags_from_json_import_loads() -> None: + mod = _load_verify_module() + tree = ast.parse('from json import loads\nloads("{}")') + offenders = mod._collect_json_io_offenders(tree) + assert offenders + assert any(name == "json.loads" for _, name in offenders) + + +def test_collect_json_io_flags_aliased_json_import() -> None: + mod = _load_verify_module() + tree = ast.parse('from json import dump as dumper\nx = None\ndumper(x, open("f","w"))') + offenders = mod._collect_json_io_offenders(tree) + assert any(name == "json.dump" for _, name in offenders) + + def test_verify_safe_project_writes_passes_on_repo() -> None: """Gate must succeed while ide_setup routes settings through project_artifact_write.""" script = Path(__file__).resolve().parents[3] / "scripts" / "verify_safe_project_writes.py" diff --git a/tests/unit/utils/test_contract_predicates.py b/tests/unit/utils/test_contract_predicates.py index ddd54b98..98b76004 100644 --- a/tests/unit/utils/test_contract_predicates.py +++ b/tests/unit/utils/test_contract_predicates.py @@ -36,6 +36,8 @@ def test_vscode_settings_result_ok(tmp_path: Path) -> None: def test_settings_relative_nonblank() -> None: assert cp.settings_relative_nonblank(".vscode/settings.json") is True assert cp.settings_relative_nonblank(" ") is False + assert cp.settings_relative_nonblank("/abs/settings.json") is False + assert cp.settings_relative_nonblank(".vscode/../settings.json") is False def test_prompt_files_all_strings() -> None: diff --git a/tests/unit/utils/test_project_artifact_write.py b/tests/unit/utils/test_project_artifact_write.py index 722366a6..2308c9a3 100644 --- a/tests/unit/utils/test_project_artifact_write.py +++ b/tests/unit/utils/test_project_artifact_write.py @@ -113,3 +113,24 @@ def test_create_vscode_settings_chat_not_object_raises_without_force(tmp_path: P prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, force=False, ) + + +def test_create_vscode_settings_chat_not_object_force_coerces_with_backup(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + settings_path.write_text(json.dumps({"chat": "invalid"}), encoding="utf-8") + prompt = tmp_path / "specfact.01-import.md" + prompt.write_text("---\n---\n", encoding="utf-8") + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={PROMPT_SOURCE_CORE: [prompt]}, + force=True, + ) + recovery = tmp_path / ".specfact" / "recovery" + assert recovery.is_dir() + assert any(recovery.glob("settings.json.*.bak")) + data = json.loads(settings_path.read_text(encoding="utf-8")) + assert isinstance(data["chat"], dict) + assert ".github/prompts/specfact.01-import.prompt.md" in data["chat"]["promptFilesRecommendations"] From 13d12d62c2fa0f9124f041d927069bf7e98656c6 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:35:49 +0200 Subject: [PATCH 05/12] fix(profile-04): JSON5 settings, repo containment, review follow-ups - merge_vscode_settings: resolve containment before mkdir/write; JSON5 load/dump (JSONC comments; trailing_commas=False for strict JSON output) - ide_setup: empty prompts_by_source skips catalog fallback (_finalize allow_empty_fallback) - verify_safe_project_writes: detect import json as js attribute calls - contract_predicates: prompt_files_all_strings accepts list[Any] for mixed-type checks - Tests: symlink escape, JSONC merge, empty export strip, import-json-as-js guard - tasks.md / TDD_EVIDENCE: wrap lines to <=120 chars; CHANGELOG + json5 dep + setup.py sync Made-with: Cursor --- CHANGELOG.md | 5 ++ .../TDD_EVIDENCE.md | 21 +++-- .../tasks.md | 65 ++++++++++----- pyproject.toml | 2 + scripts/verify_safe_project_writes.py | 32 +++---- setup.py | 1 + src/specfact_cli/utils/contract_predicates.py | 3 +- src/specfact_cli/utils/ide_setup.py | 23 +++-- .../utils/project_artifact_write.py | 27 ++++-- .../test_verify_safe_project_writes.py | 7 ++ tests/unit/utils/test_contract_predicates.py | 2 + .../unit/utils/test_project_artifact_write.py | 83 +++++++++++++++++++ 12 files changed, 217 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 39694e56..f4d7f928 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,11 @@ All notable changes to this project will be documented in this file. - **`specfact init ide` and `.vscode/settings.json`**: invalid JSON or non-mergeable `chat` blocks no longer wipe unrelated VS Code settings; the command fails safe with guidance. Use `--force` only when you accept replacing the file after a timestamped backup under `.specfact/recovery/`. +- **VS Code settings path**: resolved settings paths must stay inside the repository root (blocks symlink + escape); settings are parsed with **JSON5** so JSONC-style comments and trailing commas load correctly. + Serialized output is canonical JSON (comments from the original file are not preserved on rewrite). +- **`create_vscode_settings`**: an explicit empty `prompts_by_source` mapping no longer falls back to the + full prompt catalog when finalizing recommendations. - **Regression gate**: lint now runs `scripts/verify_safe_project_writes.py` so IDE settings JSON I/O stays routed through the shared merge helper. - **Dev / Semgrep**: Hatch and `[dev]` extras pin `setuptools<82` so Semgrep’s OpenTelemetry import chain still diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md index 55d43952..2daf78d5 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md @@ -15,8 +15,9 @@ hatch run pytest \ -q ``` -- **Note**: New scenarios (`malformed_json_raises`, `preserves_unrelated_keys`, verify script) were added before the - safe-merge implementation; prior behavior treated invalid JSON as `{}` and could destroy user settings (issue #487). +- **Note**: New scenarios (`malformed_json_raises`, `preserves_unrelated_keys`, verify script) were added + before the safe-merge implementation; prior behavior treated invalid JSON as `{}` and could destroy user + settings (issue #487). ## Passing-after (targeted + e2e) @@ -32,9 +33,9 @@ hatch run contract-test hatch run smart-test ``` -- **Module signatures**: `hatch run ./scripts/verify-modules-signature.py --require-signature` — pass without bumping - `src/specfact_cli/modules/init/module-package.yaml` (init UX errors are raised from `ide_setup` so the init module - payload checksum is unchanged). +- **Module signatures**: `hatch run ./scripts/verify-modules-signature.py --require-signature` — pass without + bumping `src/specfact_cli/modules/init/module-package.yaml` (init UX errors are raised from `ide_setup` so + the init module payload checksum is unchanged). ## Code review gate @@ -48,12 +49,16 @@ hatch run specfact code review run --json --out .specfact/code-review.json \ scripts/verify_safe_project_writes.py ``` -- Report: `.specfact/code-review.json` (exit 0, no blocking findings after merge-helper refactor + setuptools pin). +- Report: `.specfact/code-review.json` (exit 0, no blocking findings after merge-helper refactor + setuptools + pin). ## OpenSpec strict validation -- **Pass (2026-04-12)**: `openspec validate profile-04-safe-project-artifact-writes --strict` — exit 0 (recorded at sign-off per `tasks.md` 4.7). +- **Pass (2026-04-12)**: + `openspec validate profile-04-safe-project-artifact-writes --strict` — exit 0 (recorded at sign-off per + `tasks.md` 4.7). ## Worktree cleanup (post-merge on developer machine) -- Remove worktree, delete branch, prune — see `tasks.md` section 5 (not executed in this implementation session). +- Remove worktree, delete branch, prune — see `tasks.md` section 5 (not executed in this implementation + session). diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md index 96de0536..e9947e0d 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md @@ -1,36 +1,63 @@ ## 1. Branch, coordination, and issue sync -- [x] 1.1 Create `bugfix/profile-04-safe-project-artifact-writes` in a dedicated worktree from `origin/dev` and bootstrap Hatch in that worktree. -- [ ] 1.2 Sync the change proposal to GitHub under parent feature `#365`, link bug `#487`, and update `proposal.md` Source Tracking with issue metadata. *(human / PR author)* -- [ ] 1.3 Confirm the paired modules-side change `project-runtime-01-safe-artifact-write-policy` is available and note the dependency in both PR descriptions/change evidence. *(human)* +- [x] 1.1 Create `bugfix/profile-04-safe-project-artifact-writes` in a dedicated worktree from + `origin/dev` and bootstrap Hatch in that worktree. +- [ ] 1.2 Sync the change proposal to GitHub under parent feature `#365`, link bug `#487`, and update + `proposal.md` Source Tracking with issue metadata. *(human / PR author)* +- [ ] 1.3 Confirm the paired modules-side change `project-runtime-01-safe-artifact-write-policy` is + available and note the dependency in both PR descriptions/change evidence. *(human)* ## 2. Specs, regression fixtures, and failing evidence -- [x] 2.1 Add or update regression fixtures for existing user-owned project artifacts such as `.vscode/settings.json` with unrelated custom settings. *(pytest tmp_path fixtures in `test_project_artifact_write.py`)* -- [x] 2.2 Write tests from the new scenarios covering partial ownership, malformed settings fail-safe behavior, backup creation, and preservation of unrelated settings. -- [x] 2.3 Write tests for the CI/static unsafe-write gate so direct writes to protected project artifacts are rejected unless routed through the sanctioned helper. -- [x] 2.4 Run the targeted tests before implementation, capture the failing results, and record commands/timestamps in `openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md`. +- [x] 2.1 Add or update regression fixtures for existing user-owned project artifacts such as + `.vscode/settings.json` with unrelated custom settings. *(pytest tmp_path fixtures in + `test_project_artifact_write.py`)* +- [x] 2.2 Write tests from the new scenarios covering partial ownership, malformed settings fail-safe + behavior, backup creation, and preservation of unrelated settings. +- [x] 2.3 Write tests for the CI/static unsafe-write gate so direct writes to protected project + artifacts are rejected unless routed through the sanctioned helper. +- [x] 2.4 Run the targeted tests before implementation, capture the failing results, and record + commands/timestamps in `openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md`. ## 3. Core safe-write implementation -- [x] 3.1 Implement the core safe-write helper and ownership model with `@beartype` and `@icontract` on public APIs. -- [x] 3.2 Route `src/specfact_cli/utils/ide_setup.py` settings mutation through the helper so `.vscode/settings.json` preserves unrelated user-managed settings and strips only SpecFact-managed entries when needed. -- [x] 3.3 Route applicable init/setup artifact copy flows through the helper or explicit safe modes, including fail-safe handling for malformed structured files and backup creation for explicit replacement. *(VS Code settings path; `init ide` surfaces errors + `--force` + backup)* -- [x] 3.4 Implement the CI/static guard for protected user-project artifacts in init/setup code paths and integrate it into the relevant local/CI quality workflow. *(`scripts/verify_safe_project_writes.py` + `hatch run lint`)* +- [x] 3.1 Implement the core safe-write helper and ownership model with `@beartype` and `@icontract` + on public APIs. +- [x] 3.2 Route `src/specfact_cli/utils/ide_setup.py` settings mutation through the helper so + `.vscode/settings.json` preserves unrelated user-managed settings and strips only SpecFact-managed + entries when needed. +- [x] 3.3 Route applicable init/setup artifact copy flows through the helper or explicit safe modes, + including fail-safe handling for malformed structured files and backup creation for explicit + replacement. *(VS Code settings path; `init ide` surfaces errors + `--force` + backup)* +- [x] 3.4 Implement the CI/static guard for protected user-project artifacts in init/setup code paths + and integrate it into the relevant local/CI quality workflow. *(`scripts/verify_safe_project_writes.py` + + `hatch run lint`)* ## 4. Verification, docs, and cross-repo handoff -- [x] 4.1 Re-run the targeted tests and any broader init/setup regression coverage, capture passing results, and update `TDD_EVIDENCE.md`. -- [x] 4.2 Research and update affected docs (`README.md`, installation/quickstart/init references) to document preservation guarantees, backup behavior, and explicit replacement semantics. *(installation.md + version pins in README / samples)* -- [x] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, `hatch run yaml-lint`, `hatch run contract-test`, and `hatch run smart-test`. -- [x] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled module manifests changed, bump versions, re-sign as required, and re-run verification. *(no `modules/init` payload change — error UX handled in `ide_setup` to avoid re-signing)* -- [x] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final review command/timestamp in `TDD_EVIDENCE.md` or PR notes. -- [x] 4.6 Apply the appropriate version/changelog update for a bugfix release if implementation changes user-facing behavior, then open a PR to `dev` referencing the paired modules change. *(version/changelog done; PR human)* -- [x] 4.7 Run strict OpenSpec validation before sign-off: `openspec validate profile-04-safe-project-artifact-writes --strict`; fix any validation errors until it passes; record the successful run command and timestamp in `TDD_EVIDENCE.md` (or PR notes). +- [x] 4.1 Re-run the targeted tests and any broader init/setup regression coverage, capture passing + results, and update `TDD_EVIDENCE.md`. +- [x] 4.2 Research and update affected docs (`README.md`, installation/quickstart/init references) to + document preservation guarantees, backup behavior, and explicit replacement semantics. + *(installation.md + version pins in README / samples)* +- [x] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, + `hatch run yaml-lint`, `hatch run contract-test`, and `hatch run smart-test`. +- [x] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled + module manifests changed, bump versions, re-sign as required, and re-run verification. *(no + `modules/init` payload change — error UX handled in `ide_setup` to avoid re-signing)* +- [x] 4.5 Ensure `.specfact/code-review.json` is fresh, remediate all findings, and record the final + review command/timestamp in `TDD_EVIDENCE.md` or PR notes. +- [x] 4.6 Apply the appropriate version/changelog update for a bugfix release if implementation changes + user-facing behavior, then open a PR to `dev` referencing the paired modules change. + *(version/changelog done; PR human)* +- [x] 4.7 Run strict OpenSpec validation before sign-off: + `openspec validate profile-04-safe-project-artifact-writes --strict`; fix any validation errors until + it passes; record the successful run command and timestamp in `TDD_EVIDENCE.md` (or PR notes). ## 5. Worktree cleanup -- [ ] 5.1 Remove the worktree used for this change (for example `git worktree remove ../specfact-cli-worktrees/bugfix/profile-04-safe-project-artifact-writes`). +- [ ] 5.1 Remove the worktree used for this change (for example + `git worktree remove ../specfact-cli-worktrees/bugfix/profile-04-safe-project-artifact-writes`). - [ ] 5.2 Delete the local branch after merge (`git branch -d bugfix/profile-04-safe-project-artifact-writes`). - [ ] 5.3 Prune stale worktree metadata (`git worktree prune`). - [ ] 5.4 Record cleanup completion in `TDD_EVIDENCE.md` alongside the 4.x verification notes. diff --git a/pyproject.toml b/pyproject.toml index 78f81cf6..14d60939 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -84,6 +84,8 @@ dependencies = [ # Schema validation "jsonschema>=4.23.0", + # VS Code settings.json is often JSON with comments (JSONC); parse/emit via JSON5 subset + "json5>=0.9.28", # Contract-First (runtime decorators; exploration tools are optional extra `contracts`) "icontract>=2.7.1", # Design-by-contract decorators diff --git a/scripts/verify_safe_project_writes.py b/scripts/verify_safe_project_writes.py index b0d46234..c73c5157 100644 --- a/scripts/verify_safe_project_writes.py +++ b/scripts/verify_safe_project_writes.py @@ -21,33 +21,37 @@ def _repo_layout_ok() -> bool: return ROOT.is_dir() -def _json_import_aliases(tree: ast.AST) -> dict[str, str]: - """Map local function names to labels like ``json.loads`` for ``from json import ...``.""" - aliases: dict[str, str] = {} +def _json_bindings(tree: ast.AST) -> tuple[dict[str, str], frozenset[str]]: + """``from json import`` function aliases (local name -> ``json.attr``) and ``import json`` module locals.""" + func_aliases: dict[str, str] = {} + module_locals: set[str] = set() for node in ast.walk(tree): - if not isinstance(node, ast.ImportFrom) or node.module != "json": - continue - for alias in node.names: - if alias.name in _JSON_IO_NAMES: - local = alias.asname or alias.name - aliases[local] = f"json.{alias.name}" - return aliases + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "json": + module_locals.add(alias.asname or "json") + elif isinstance(node, ast.ImportFrom) and node.module == "json": + for alias in node.names: + if alias.name in _JSON_IO_NAMES: + local = alias.asname or alias.name + func_aliases[local] = f"json.{alias.name}" + return func_aliases, frozenset(module_locals) def _collect_json_io_offenders(tree: ast.AST) -> list[tuple[int, str]]: - import_aliases = _json_import_aliases(tree) + func_aliases, module_locals = _json_bindings(tree) offenders: list[tuple[int, str]] = [] for node in ast.walk(tree): if not isinstance(node, ast.Call): continue func = node.func - if isinstance(func, ast.Name) and func.id in import_aliases: - offenders.append((node.lineno, import_aliases[func.id])) + if isinstance(func, ast.Name) and func.id in func_aliases: + offenders.append((node.lineno, func_aliases[func.id])) continue if ( isinstance(func, ast.Attribute) and isinstance(func.value, ast.Name) - and func.value.id == "json" + and func.value.id in module_locals and func.attr in _JSON_IO_NAMES ): offenders.append((node.lineno, f"json.{func.attr}")) diff --git a/setup.py b/setup.py index fc31c94b..5b04fe4c 100644 --- a/setup.py +++ b/setup.py @@ -31,6 +31,7 @@ "gitpython>=3.1.45", "ruamel.yaml>=0.18.16", "jsonschema>=4.23.0", + "json5>=0.9.28", "icontract>=2.7.1", "beartype>=0.22.4", "watchdog>=6.0.0", diff --git a/src/specfact_cli/utils/contract_predicates.py b/src/specfact_cli/utils/contract_predicates.py index 8656e338..94354f82 100644 --- a/src/specfact_cli/utils/contract_predicates.py +++ b/src/specfact_cli/utils/contract_predicates.py @@ -3,6 +3,7 @@ from __future__ import annotations from pathlib import Path +from typing import Any from beartype import beartype from icontract import ensure, require @@ -68,7 +69,7 @@ def settings_relative_nonblank(settings_relative: str) -> bool: @require(lambda prompt_files: isinstance(prompt_files, list)) @ensure(lambda result: isinstance(result, bool)) @beartype -def prompt_files_all_strings(prompt_files: list[str]) -> bool: +def prompt_files_all_strings(prompt_files: list[Any]) -> bool: return all(isinstance(item, str) for item in prompt_files) diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 7db560aa..d585145c 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -809,8 +809,18 @@ def _vscode_prompt_paths_from_full_catalog(repo_path: Path) -> list[str]: return [f".github/prompts/{name}" for name in sorted(merged.keys())] -def _finalize_vscode_prompt_recommendation_paths(repo_path: Path, prompt_files: list[str]) -> list[str]: - """Fall back to flat discovery or command list when namespaced paths are empty.""" +def _finalize_vscode_prompt_recommendation_paths( + repo_path: Path, + prompt_files: list[str], + *, + allow_empty_fallback: bool = True, +) -> list[str]: + """Fall back to flat discovery or command list when namespaced paths are empty. + + When ``allow_empty_fallback`` is False, an empty ``prompt_files`` list is returned as-is (explicit empty export). + """ + if not prompt_files and not allow_empty_fallback: + return [] if not prompt_files: discovered_flat = discover_prompt_template_files(repo_path) prompt_files = [f".github/prompts/{template_path.stem}.prompt.md" for template_path in discovered_flat] @@ -907,9 +917,12 @@ def create_vscode_settings( True """ if prompts_by_source is not None: - prompt_files = _finalize_vscode_prompt_recommendation_paths( - repo_path, _vscode_prompt_recommendation_paths_from_sources(prompts_by_source) - ) + if not prompts_by_source: + prompt_files = _finalize_vscode_prompt_recommendation_paths(repo_path, [], allow_empty_fallback=False) + else: + prompt_files = _finalize_vscode_prompt_recommendation_paths( + repo_path, _vscode_prompt_recommendation_paths_from_sources(prompts_by_source) + ) else: prompt_files = _finalize_vscode_prompt_recommendation_paths( repo_path, _vscode_prompt_paths_from_full_catalog(repo_path) diff --git a/src/specfact_cli/utils/project_artifact_write.py b/src/specfact_cli/utils/project_artifact_write.py index 4cdaa2b8..1e685eb9 100644 --- a/src/specfact_cli/utils/project_artifact_write.py +++ b/src/specfact_cli/utils/project_artifact_write.py @@ -2,7 +2,6 @@ from __future__ import annotations -import json import re import shutil from datetime import UTC, datetime @@ -10,6 +9,7 @@ from pathlib import Path from typing import Any, Final, cast +import json5 from beartype import beartype from icontract import ensure, require @@ -79,7 +79,8 @@ def _ordered_unique_strings(items: list[str]) -> list[str]: def _write_new_vscode_settings_file(settings_path: Path, prompt_files: list[str]) -> None: payload: dict[str, Any] = {"chat": {"promptFilesRecommendations": list(prompt_files)}} - settings_path.write_text(json.dumps(payload, indent=4) + "\n", encoding="utf-8") + text = json5.dumps(payload, indent=4, quote_keys=True, trailing_commas=False) + "\n" + settings_path.write_text(text, encoding="utf-8") def _ensure_backup( @@ -101,11 +102,11 @@ def _load_root_dict_from_settings_text( ) -> tuple[dict[str, Any], Path | None]: out_backup = backup_path try: - loaded = json.loads(raw_text) - except json.JSONDecodeError as exc: + loaded = json5.loads(raw_text) + except ValueError as exc: if not explicit_replace_unparseable: raise StructuredJsonDocumentError( - f"Cannot merge into {settings_path}: invalid JSON ({exc.msg} at line {exc.lineno} col {exc.colno}). " + f"Cannot merge into {settings_path}: invalid JSON/JSONC ({exc}). " "Fix the file or re-run with --force to replace it after a backup under .specfact/recovery/." ) from exc out_backup = _ensure_backup(repo_path, settings_path, out_backup) @@ -204,11 +205,22 @@ def merge_vscode_settings_prompt_recommendations( """ Merge SpecFact ``chat.promptFilesRecommendations`` into VS Code ``settings.json``. - Preserves all other top-level keys and non-SpecFact recommendation paths. On invalid JSON or + Preserves all other top-level keys and non-SpecFact recommendation paths. On invalid JSON/JSONC or unusable ``chat`` / ``promptFilesRecommendations`` shape, raises ``StructuredJsonDocumentError`` unless ``explicit_replace_unparseable`` is True (backup, then recoverable rewrite). + + Parses with JSON5 (comments and trailing commas). Serialized output is canonical JSON5/JSON without + preserving original comment text or formatting from the input file. """ + repo_root = repo_path.resolve() settings_path = (repo_path / settings_relative).resolve() + try: + settings_path.relative_to(repo_root) + except ValueError as exc: + raise ProjectArtifactWriteError( + f"Refusing to write VS Code settings outside the repository: {settings_path}" + ) from exc + settings_path.parent.mkdir(parents=True, exist_ok=True) if not settings_path.exists(): @@ -232,5 +244,6 @@ def merge_vscode_settings_prompt_recommendations( strip_specfact_github_from_existing, prompt_files, ) - settings_path.write_text(json.dumps(loaded, indent=4) + "\n", encoding="utf-8") + out_text = json5.dumps(loaded, indent=4, quote_keys=True, trailing_commas=False) + "\n" + settings_path.write_text(out_text, encoding="utf-8") return settings_path diff --git a/tests/unit/scripts/test_verify_safe_project_writes.py b/tests/unit/scripts/test_verify_safe_project_writes.py index 66e92200..d642eed0 100644 --- a/tests/unit/scripts/test_verify_safe_project_writes.py +++ b/tests/unit/scripts/test_verify_safe_project_writes.py @@ -34,6 +34,13 @@ def test_collect_json_io_flags_aliased_json_import() -> None: assert any(name == "json.dump" for _, name in offenders) +def test_collect_json_io_flags_import_json_as_module_alias() -> None: + mod = _load_verify_module() + tree = ast.parse('import json as js\nx = None\njs.dump(x, open("f","w"))') + offenders = mod._collect_json_io_offenders(tree) + assert any(name == "json.dump" for _, name in offenders) + + def test_verify_safe_project_writes_passes_on_repo() -> None: """Gate must succeed while ide_setup routes settings through project_artifact_write.""" script = Path(__file__).resolve().parents[3] / "scripts" / "verify_safe_project_writes.py" diff --git a/tests/unit/utils/test_contract_predicates.py b/tests/unit/utils/test_contract_predicates.py index 98b76004..12512a95 100644 --- a/tests/unit/utils/test_contract_predicates.py +++ b/tests/unit/utils/test_contract_predicates.py @@ -43,3 +43,5 @@ def test_settings_relative_nonblank() -> None: def test_prompt_files_all_strings() -> None: assert cp.prompt_files_all_strings([]) is True assert cp.prompt_files_all_strings(["a", "b"]) is True + assert cp.prompt_files_all_strings(["a", 1]) is False + assert cp.prompt_files_all_strings(["a", None]) is False diff --git a/tests/unit/utils/test_project_artifact_write.py b/tests/unit/utils/test_project_artifact_write.py index 2308c9a3..7993e173 100644 --- a/tests/unit/utils/test_project_artifact_write.py +++ b/tests/unit/utils/test_project_artifact_write.py @@ -3,18 +3,101 @@ from __future__ import annotations import json +import shutil +import uuid from pathlib import Path import pytest from specfact_cli.utils.ide_setup import PROMPT_SOURCE_CORE, create_vscode_settings from specfact_cli.utils.project_artifact_write import ( + ProjectArtifactWriteError, StructuredJsonDocumentError, backup_file_to_recovery, merge_vscode_settings_prompt_recommendations, ) +def test_merge_vscode_settings_rejects_path_outside_repo(tmp_path: Path) -> None: + escape_root = tmp_path.parent / f"sfw_escape_{uuid.uuid4().hex[:12]}" + escape_root.mkdir(exist_ok=True) + vscode_link = tmp_path / ".vscode" + if not hasattr(vscode_link, "symlink_to"): + shutil.rmtree(escape_root, ignore_errors=True) + pytest.skip("symlink_to not available") + try: + vscode_link.symlink_to(escape_root, target_is_directory=True) + except OSError: + shutil.rmtree(escape_root, ignore_errors=True) + pytest.skip("symlinks not supported") + try: + (escape_root / "settings.json").write_text("{}", encoding="utf-8") + with pytest.raises(ProjectArtifactWriteError, match="outside the repository"): + merge_vscode_settings_prompt_recommendations( + tmp_path, + ".vscode/settings.json", + [".github/prompts/specfact.01-import.prompt.md"], + strip_specfact_github_from_existing=False, + explicit_replace_unparseable=False, + ) + finally: + if vscode_link.is_symlink(): + vscode_link.unlink(missing_ok=True) + shutil.rmtree(escape_root, ignore_errors=True) + + +def test_merge_vscode_settings_accepts_jsonc_comments(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + settings_path.write_text( + """{ + // keep + "python.defaultInterpreterPath": "/x", + "chat": {"promptFilesRecommendations": []} +} +""", + encoding="utf-8", + ) + out = merge_vscode_settings_prompt_recommendations( + tmp_path, + ".vscode/settings.json", + [".github/prompts/specfact.01-import.prompt.md"], + strip_specfact_github_from_existing=False, + explicit_replace_unparseable=False, + ) + data = json.loads(out.read_text(encoding="utf-8")) + assert data["python.defaultInterpreterPath"] == "/x" + assert ".github/prompts/specfact.01-import.prompt.md" in data["chat"]["promptFilesRecommendations"] + + +def test_create_vscode_settings_empty_prompts_by_source_strips_specfact_paths(tmp_path: Path) -> None: + vscode_dir = tmp_path / ".vscode" + vscode_dir.mkdir(parents=True) + settings_path = vscode_dir / "settings.json" + settings_path.write_text( + json.dumps( + { + "chat": { + "promptFilesRecommendations": [ + ".github/prompts/specfact.01-import.prompt.md", + ".github/prompts/team.prompt.md", + ] + } + } + ), + encoding="utf-8", + ) + create_vscode_settings( + tmp_path, + ".vscode/settings.json", + prompts_by_source={}, + force=False, + ) + data = json.loads(settings_path.read_text(encoding="utf-8")) + assert data["chat"]["promptFilesRecommendations"] == [".github/prompts/team.prompt.md"] + + def test_merge_vscode_settings_creates_file_when_missing(tmp_path: Path) -> None: """New repo: write only managed recommendations.""" out = merge_vscode_settings_prompt_recommendations( From 002bacd6a25152e463760a9a38569844d4e1724c Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:43:42 +0200 Subject: [PATCH 06/12] docs(profile-04): tasks pre-flight + full pytest; narrow icontract ensure - tasks 1.1: hatch env create then smart-test-status and contract-test-status - tasks 4.3: add hatch test --cover -v to quality gates - TDD_EVIDENCE: shorter module-signatures and report lines (<=120 cols) - project_artifact_write: isinstance(result, Path) in @ensure postconditions Made-with: Cursor --- .../TDD_EVIDENCE.md | 9 +++++---- .../profile-04-safe-project-artifact-writes/tasks.md | 5 +++-- src/specfact_cli/utils/project_artifact_write.py | 4 ++-- 3 files changed, 10 insertions(+), 8 deletions(-) diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md index 2daf78d5..455bffde 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md @@ -33,9 +33,10 @@ hatch run contract-test hatch run smart-test ``` -- **Module signatures**: `hatch run ./scripts/verify-modules-signature.py --require-signature` — pass without - bumping `src/specfact_cli/modules/init/module-package.yaml` (init UX errors are raised from `ide_setup` so - the init module payload checksum is unchanged). +- **Module signatures**: run + `hatch run ./scripts/verify-modules-signature.py --require-signature` — pass without bumping + `src/specfact_cli/modules/init/module-package.yaml` (init UX errors are raised from `ide_setup` so the init + module payload checksum is unchanged). ## Code review gate @@ -49,7 +50,7 @@ hatch run specfact code review run --json --out .specfact/code-review.json \ scripts/verify_safe_project_writes.py ``` -- Report: `.specfact/code-review.json` (exit 0, no blocking findings after merge-helper refactor + setuptools +- Report: `.specfact/code-review.json` (exit 0, no blocking findings after merge-helper refactor and setuptools pin). ## OpenSpec strict validation diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md index e9947e0d..1882d1cb 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/tasks.md @@ -1,7 +1,8 @@ ## 1. Branch, coordination, and issue sync - [x] 1.1 Create `bugfix/profile-04-safe-project-artifact-writes` in a dedicated worktree from - `origin/dev` and bootstrap Hatch in that worktree. + `origin/dev`; run `hatch env create`, then pre-flight status checks `hatch run smart-test-status` and + `hatch run contract-test-status`. - [ ] 1.2 Sync the change proposal to GitHub under parent feature `#365`, link bug `#487`, and update `proposal.md` Source Tracking with issue metadata. *(human / PR author)* - [ ] 1.3 Confirm the paired modules-side change `project-runtime-01-safe-artifact-write-policy` is @@ -41,7 +42,7 @@ document preservation guarantees, backup behavior, and explicit replacement semantics. *(installation.md + version pins in README / samples)* - [x] 4.3 Run quality gates: `hatch run format`, `hatch run type-check`, `hatch run lint`, - `hatch run yaml-lint`, `hatch run contract-test`, and `hatch run smart-test`. + `hatch run yaml-lint`, `hatch run contract-test`, `hatch run smart-test`, and `hatch test --cover -v`. - [x] 4.4 Run `hatch run ./scripts/verify-modules-signature.py --require-signature`; if any bundled module manifests changed, bump versions, re-sign as required, and re-run verification. *(no `modules/init` payload change — error UX handled in `ide_setup` to avoid re-signing)* diff --git a/src/specfact_cli/utils/project_artifact_write.py b/src/specfact_cli/utils/project_artifact_write.py index 1e685eb9..5686c827 100644 --- a/src/specfact_cli/utils/project_artifact_write.py +++ b/src/specfact_cli/utils/project_artifact_write.py @@ -172,7 +172,7 @@ def _merge_chat_and_recommendations( @require(repo_path_exists, "Repo path must exist") @require(repo_path_is_dir, "Repo path must be a directory") @require(file_path_is_file, "file_path must be an existing file") -@ensure(lambda result: result.is_file()) +@ensure(lambda result: isinstance(result, Path) and result.is_file()) def backup_file_to_recovery(repo_path: Path, file_path: Path) -> Path: """Copy ``file_path`` into ``.specfact/recovery`` with a UTC timestamp suffix.""" recovery_dir = (repo_path / RECOVERY_SUBDIR).resolve() @@ -193,7 +193,7 @@ def backup_file_to_recovery(repo_path: Path, file_path: Path) -> Path: @require(repo_path_is_dir, "Repo path must be a directory") @require(settings_relative_nonblank, "settings_relative must be non-empty") @require(prompt_files_all_strings, "prompt_files must be a list of str") -@ensure(lambda result: result.exists() and result.is_file()) +@ensure(lambda result: isinstance(result, Path) and result.exists() and result.is_file()) def merge_vscode_settings_prompt_recommendations( repo_path: Path, settings_relative: str, From d88b87364b3e955243776468d7207d198bd9e715 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:48:42 +0200 Subject: [PATCH 07/12] fix: clear specfact code review on safe-write modules - verify_safe_project_writes: flatten json binding helpers; stderr writes instead of print; inline Import/ImportFrom loops to drop duplicate-shape DRY - project_artifact_write: _VscodeChatMergeContext dataclass (KISS param count); typed chat_body cast before .get for pyright Made-with: Cursor --- scripts/verify_safe_project_writes.py | 32 +++++++--- .../utils/project_artifact_write.py | 61 ++++++++++--------- 2 files changed, 56 insertions(+), 37 deletions(-) diff --git a/scripts/verify_safe_project_writes.py b/scripts/verify_safe_project_writes.py index c73c5157..b2947c21 100644 --- a/scripts/verify_safe_project_writes.py +++ b/scripts/verify_safe_project_writes.py @@ -17,10 +17,27 @@ _JSON_IO_NAMES = frozenset({"load", "dump", "loads", "dumps"}) +def _write_stderr(message: str) -> None: + sys.stderr.write(message + "\n") + + def _repo_layout_ok() -> bool: return ROOT.is_dir() +def _register_json_module_alias(alias: ast.alias, module_locals: set[str]) -> None: + if alias.name != "json": + return + module_locals.add(alias.asname or "json") + + +def _register_json_from_func_alias(alias: ast.alias, func_aliases: dict[str, str]) -> None: + if alias.name not in _JSON_IO_NAMES: + return + local = alias.asname or alias.name + func_aliases[local] = f"json.{alias.name}" + + def _json_bindings(tree: ast.AST) -> tuple[dict[str, str], frozenset[str]]: """``from json import`` function aliases (local name -> ``json.attr``) and ``import json`` module locals.""" func_aliases: dict[str, str] = {} @@ -28,13 +45,11 @@ def _json_bindings(tree: ast.AST) -> tuple[dict[str, str], frozenset[str]]: for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: - if alias.name == "json": - module_locals.add(alias.asname or "json") - elif isinstance(node, ast.ImportFrom) and node.module == "json": + _register_json_module_alias(alias, module_locals) + continue + if isinstance(node, ast.ImportFrom) and node.module == "json": for alias in node.names: - if alias.name in _JSON_IO_NAMES: - local = alias.asname or alias.name - func_aliases[local] = f"json.{alias.name}" + _register_json_from_func_alias(alias, func_aliases) return func_aliases, frozenset(module_locals) @@ -63,16 +78,15 @@ def _collect_json_io_offenders(tree: ast.AST) -> list[tuple[int, str]]: @ensure(lambda result: result in (0, 1, 2)) def main() -> int: if not IDE_SETUP.is_file(): - print(f"Expected ide_setup at {IDE_SETUP}", file=sys.stderr) + _write_stderr(f"Expected ide_setup at {IDE_SETUP}") return 2 tree = ast.parse(IDE_SETUP.read_text(encoding="utf-8"), filename=str(IDE_SETUP)) offenders = _collect_json_io_offenders(tree) if offenders: lines = ", ".join(f"line {ln} ({name})" for ln, name in offenders) - print( + _write_stderr( "Unsafe JSON I/O in ide_setup.py — route VS Code settings through " f"specfact_cli.utils.project_artifact_write.merge_vscode_settings_prompt_recommendations: {lines}", - file=sys.stderr, ) return 1 return 0 diff --git a/src/specfact_cli/utils/project_artifact_write.py b/src/specfact_cli/utils/project_artifact_write.py index 5686c827..f5dfab10 100644 --- a/src/specfact_cli/utils/project_artifact_write.py +++ b/src/specfact_cli/utils/project_artifact_write.py @@ -4,6 +4,7 @@ import re import shutil +from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from pathlib import Path @@ -125,47 +126,49 @@ def _load_root_dict_from_settings_text( return {}, out_backup -def _merge_chat_and_recommendations( - loaded: dict[str, Any], - settings_path: Path, - repo_path: Path, - explicit_replace_unparseable: bool, - backup_path: Path | None, - strip_specfact_github_from_existing: bool, - prompt_files: list[str], -) -> None: - out_backup = backup_path +@dataclass(frozen=True, slots=True) +class _VscodeChatMergeContext: + settings_path: Path + repo_path: Path + explicit_replace_unparseable: bool + initial_backup_path: Path | None + strip_specfact_github_from_existing: bool + prompt_files: tuple[str, ...] + + +def _merge_chat_and_recommendations(loaded: dict[str, Any], ctx: _VscodeChatMergeContext) -> None: + out_backup = ctx.initial_backup_path if "chat" not in loaded: loaded["chat"] = {} chat_block = loaded["chat"] if not isinstance(chat_block, dict): - if not explicit_replace_unparseable: + if not ctx.explicit_replace_unparseable: raise StructuredJsonDocumentError( - f'Cannot merge into {settings_path}: "chat" must be a JSON object, not {type(chat_block).__name__}.' + f'Cannot merge into {ctx.settings_path}: "chat" must be a JSON object, not {type(chat_block).__name__}.' ) - out_backup = _ensure_backup(repo_path, settings_path, out_backup) + out_backup = _ensure_backup(ctx.repo_path, ctx.settings_path, out_backup) _logger.info("Backed up settings before chat coercion to %s", out_backup) chat_block = {} loaded["chat"] = chat_block - existing_recommendations = chat_block.get("promptFilesRecommendations", []) + chat_body: dict[str, Any] = cast(dict[str, Any], loaded["chat"]) + existing_recommendations = chat_body.get("promptFilesRecommendations", []) if not isinstance(existing_recommendations, list): - if not explicit_replace_unparseable: + if not ctx.explicit_replace_unparseable: raise StructuredJsonDocumentError( - f'Cannot merge into {settings_path}: "chat.promptFilesRecommendations" must be a JSON array.' + f'Cannot merge into {ctx.settings_path}: "chat.promptFilesRecommendations" must be a JSON array.' ) - out_backup = _ensure_backup(repo_path, settings_path, out_backup) + out_backup = _ensure_backup(ctx.repo_path, ctx.settings_path, out_backup) _logger.info("Backed up settings before recommendations coercion to %s", out_backup) existing_recommendations = [] recs_as_strings = [str(x) for x in existing_recommendations] - if strip_specfact_github_from_existing: + if ctx.strip_specfact_github_from_existing: recs_as_strings = _strip_specfact_github_prompt_recommendations(recs_as_strings) - merged_list = _ordered_unique_strings([*recs_as_strings, *prompt_files]) - chat_typed = cast(dict[str, Any], chat_block) - chat_typed["promptFilesRecommendations"] = merged_list - loaded["chat"] = chat_typed + merged_list = _ordered_unique_strings([*recs_as_strings, *ctx.prompt_files]) + chat_body["promptFilesRecommendations"] = merged_list + loaded["chat"] = chat_body @beartype @@ -237,12 +240,14 @@ def merge_vscode_settings_prompt_recommendations( ) _merge_chat_and_recommendations( loaded, - settings_path, - repo_path, - explicit_replace_unparseable, - backup_path, - strip_specfact_github_from_existing, - prompt_files, + _VscodeChatMergeContext( + settings_path=settings_path, + repo_path=repo_path, + explicit_replace_unparseable=explicit_replace_unparseable, + initial_backup_path=backup_path, + strip_specfact_github_from_existing=strip_specfact_github_from_existing, + prompt_files=tuple(prompt_files), + ), ) out_text = json5.dumps(loaded, indent=4, quote_keys=True, trailing_commas=False) + "\n" settings_path.write_text(out_text, encoding="utf-8") From 05c2fb0c735ba2c3f25fb316f13113280870922a Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 22:55:39 +0200 Subject: [PATCH 08/12] docs(profile-04): record hatch test --cover -v in TDD_EVIDENCE Align passing evidence with tasks.md 4.3 full-suite coverage gate. Made-with: Cursor --- .../profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md index 455bffde..8cfc4991 100644 --- a/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md +++ b/openspec/changes/profile-04-safe-project-artifact-writes/TDD_EVIDENCE.md @@ -31,8 +31,13 @@ hatch run pytest tests/unit/utils/test_project_artifact_write.py \ hatch run format && hatch run type-check && hatch run lint hatch run contract-test hatch run smart-test +hatch test --cover -v ``` +- **Full suite + coverage (`tasks.md` 4.3)**: same worktree; `hatch test --cover -v` — **exit 0**. Pytest summary: + `2450 passed, 9 skipped in 358.61s (0:05:58)`. Coverage footer (pytest-cov): `TOTAL ... 62%` on the combined + `src/` + `tools/` table (see run log for per-file lines). + - **Module signatures**: run `hatch run ./scripts/verify-modules-signature.py --require-signature` — pass without bumping `src/specfact_cli/modules/init/module-package.yaml` (init UX errors are raised from `ide_setup` so the init From 91c9e22fd5208aa84cc6bf9d949a3222981845ff Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 23:15:22 +0200 Subject: [PATCH 09/12] fix: reduce KISS blockers (bridge sync contexts, tools, partial adapters) Refactors high-parameter call sites into dataclasses/context objects and splits hot spots (export devops pipeline, smart_test_coverage incremental run, suggest_frontmatter, crosshair summary loop). API: BridgeSync.export_change_proposals_to_devops(adapter_type, ExportChangeProposalsOptions | None); LoggerSetup.create_logger(name, LoggerCreateOptions | None); run_crosshair(path, CrosshairRunOptions | None). Full specfact code review --scope full still reports error-severity kiss/radon findings (remaining nesting/LOC and param counts in ADO, analyzers, generators, source_scanner, module_installer, bundle-mapper, etc.). Gate PASS requires follow-up. Made-with: Cursor --- scripts/check_doc_frontmatter.py | 134 +- scripts/verify-bundle-published.py | 26 +- src/specfact_cli/adapters/github.py | 138 +- src/specfact_cli/common/logger_setup.py | 96 +- src/specfact_cli/common/logging_utils.py | 5 +- src/specfact_cli/sync/__init__.py | 3 +- src/specfact_cli/sync/bridge_sync.py | 1581 +++++++++-------- .../validators/contract_validator.py | 50 +- .../validators/sidecar/crosshair_runner.py | 48 +- .../validators/sidecar/crosshair_summary.py | 29 +- .../validators/sidecar/orchestrator.py | 20 +- .../sync/test_multi_adapter_backlog_sync.py | 40 +- tests/integration/test_devops_github_sync.py | 152 +- .../sidecar/test_crosshair_runner.py | 8 +- .../sidecar/test_crosshair_runner_env.py | 8 +- tools/contract_first_smart_test.py | 94 +- tools/smart_test_coverage.py | 340 ++-- 17 files changed, 1507 insertions(+), 1265 deletions(-) diff --git a/scripts/check_doc_frontmatter.py b/scripts/check_doc_frontmatter.py index 5f3651b4..772c19ce 100755 --- a/scripts/check_doc_frontmatter.py +++ b/scripts/check_doc_frontmatter.py @@ -10,6 +10,7 @@ import re import subprocess import sys +from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -397,61 +398,77 @@ def validate_glob_patterns(patterns: list[str]) -> bool: return True +@dataclass +class _AgentRuleFrontmatterDraft: + layout_val: str = "default" + permalink_val: str = "" + description_val: str | None = None + keywords_val: list[str] | None = None + audience_val: list[str] | None = None + expertise_val: list[str] | None = None + + +def _parse_str_field(existing: dict[str, Any], key: str, current: str) -> str: + val = existing.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return current + + +def _parse_optional_str_field(existing: dict[str, Any], key: str) -> str | None: + val = existing.get(key) + if isinstance(val, str) and val.strip(): + return val.strip() + return None + + +def _parse_str_list_field(existing: dict[str, Any], key: str) -> list[str] | None: + val = existing.get(key) + if isinstance(val, list) and all(isinstance(x, str) for x in val): + return list(val) + return None + + +def _load_existing_agent_rule_frontmatter_overrides(path: Path, default_permalink: str) -> _AgentRuleFrontmatterDraft: + draft = _AgentRuleFrontmatterDraft(permalink_val=default_permalink) + if not path.is_file(): + return draft + try: + existing = parse_frontmatter(path) + except OSError: + return draft + draft.layout_val = _parse_str_field(existing, "layout", draft.layout_val) + draft.permalink_val = _parse_str_field(existing, "permalink", draft.permalink_val) + draft.description_val = _parse_optional_str_field(existing, "description") + draft.keywords_val = _parse_str_list_field(existing, "keywords") + draft.audience_val = _parse_str_list_field(existing, "audience") + draft.expertise_val = _parse_str_list_field(existing, "expertise_level") + return draft + + +def _agent_rule_optional_frontmatter_lines(draft: _AgentRuleFrontmatterDraft) -> str: + parts: list[str] = [] + if draft.description_val is not None: + parts.append(f"description: {_yaml_flow_inline(draft.description_val)}\n") + if draft.keywords_val is not None: + parts.append(f"keywords: {_yaml_flow_inline(draft.keywords_val)}\n") + if draft.audience_val is not None: + parts.append(f"audience: {_yaml_flow_inline(draft.audience_val)}\n") + if draft.expertise_val is not None: + parts.append(f"expertise_level: {_yaml_flow_inline(draft.expertise_val)}\n") + return "".join(parts) + + @beartype @require(lambda path: isinstance(path, Path), "Path must be Path object") @ensure(lambda result: isinstance(result, str), "Must return string") -def suggest_frontmatter(path: Path) -> str: - """Return a suggested frontmatter block for a document.""" - rel = _rel_posix(path) - if rel.startswith(AGENT_RULES_DIR): - rel_under = rel[len(AGENT_RULES_DIR) :] - rule_slug = _agent_rules_path_slug(rel_under) - canonical_id = _agent_rules_canonical_id(rel_under) - default_permalink = _agent_rules_default_permalink(rule_slug) - layout_val = "default" - permalink_val = default_permalink - description_val: str | None = None - keywords_val: list[str] | None = None - audience_val: list[str] | None = None - expertise_val: list[str] | None = None - if path.is_file(): - try: - existing = parse_frontmatter(path) - except OSError: - pass - else: - lv = existing.get("layout") - if isinstance(lv, str) and lv.strip(): - layout_val = lv.strip() - pv = existing.get("permalink") - if isinstance(pv, str) and pv.strip(): - permalink_val = pv.strip() - dv = existing.get("description") - if isinstance(dv, str) and dv.strip(): - description_val = dv.strip() - kv = existing.get("keywords") - if isinstance(kv, list) and all(isinstance(x, str) for x in kv): - keywords_val = list(kv) - av = existing.get("audience") - if isinstance(av, list) and all(isinstance(x, str) for x in av): - audience_val = list(av) - ev = existing.get("expertise_level") - if isinstance(ev, list) and all(isinstance(x, str) for x in ev): - expertise_val = list(ev) - title_guess = path.stem.replace("-", " ").title().replace('"', '\\"') - optional_lines = "" - if description_val is not None: - optional_lines += f"description: {_yaml_flow_inline(description_val)}\n" - if keywords_val is not None: - optional_lines += f"keywords: {_yaml_flow_inline(keywords_val)}\n" - if audience_val is not None: - optional_lines += f"audience: {_yaml_flow_inline(audience_val)}\n" - if expertise_val is not None: - optional_lines += f"expertise_level: {_yaml_flow_inline(expertise_val)}\n" - return f"""--- -layout: {_yaml_plain_or_quoted_scalar(layout_val)} +def _format_agent_rules_suggested_frontmatter(path: Path, canonical_id: str, draft: _AgentRuleFrontmatterDraft) -> str: + title_guess = path.stem.replace("-", " ").title().replace('"', '\\"') + optional_lines = _agent_rule_optional_frontmatter_lines(draft) + return f"""--- +layout: {_yaml_plain_or_quoted_scalar(draft.layout_val)} title: "{title_guess}" -permalink: {_yaml_plain_or_quoted_scalar(permalink_val)} +permalink: {_yaml_plain_or_quoted_scalar(draft.permalink_val)} {optional_lines}id: {canonical_id} doc_owner: specfact-cli tracks: @@ -471,6 +488,21 @@ def suggest_frontmatter(path: Path) -> str: depends_on: [] --- """ + + +@beartype +@require(lambda path: isinstance(path, Path), "Path must be Path object") +@ensure(lambda result: isinstance(result, str), "Must return string") +def suggest_frontmatter(path: Path) -> str: + """Return a suggested frontmatter block for a document.""" + rel = _rel_posix(path) + if rel.startswith(AGENT_RULES_DIR): + rel_under = rel[len(AGENT_RULES_DIR) :] + rule_slug = _agent_rules_path_slug(rel_under) + canonical_id = _agent_rules_canonical_id(rel_under) + default_permalink = _agent_rules_default_permalink(rule_slug) + draft = _load_existing_agent_rule_frontmatter_overrides(path, default_permalink) + return _format_agent_rules_suggested_frontmatter(path, canonical_id, draft) return f"""--- title: "{path.stem}" doc_owner: specfact-cli diff --git a/scripts/verify-bundle-published.py b/scripts/verify-bundle-published.py index 527383d5..3f92f11f 100755 --- a/scripts/verify-bundle-published.py +++ b/scripts/verify-bundle-published.py @@ -37,6 +37,7 @@ import tarfile import tempfile from collections.abc import Iterable +from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -82,26 +83,17 @@ def _resolve_registry_index_path() -> Path: return repo_root / "specfact-cli-modules" / "registry" / "index.json" +@dataclass class BundleCheckResult: """Lightweight container for per-bundle verification results.""" - def __init__( - self, - module_name: str, - bundle_id: str, - version: str | None, - signature_ok: bool, - download_ok: bool | None, - status: str, - message: str = "", - ) -> None: - self.module_name = module_name - self.bundle_id = bundle_id - self.version = version - self.signature_ok = signature_ok - self.download_ok = download_ok - self.status = status - self.message = message + module_name: str + bundle_id: str + version: str | None + signature_ok: bool + download_ok: bool | None + status: str + message: str = "" @beartype diff --git a/src/specfact_cli/adapters/github.py b/src/specfact_cli/adapters/github.py index 2c13047d..ed07b541 100644 --- a/src/specfact_cli/adapters/github.py +++ b/src/specfact_cli/adapters/github.py @@ -17,6 +17,7 @@ import shutil import subprocess from collections.abc import Iterator +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any, cast @@ -48,6 +49,41 @@ ) +@dataclass +class _IssueBodyRenderInput: + title: str + description: str + rationale: str + impact: str + change_id: str + raw_body: str | None + preserved_sections: list[str] | None = None + + +@dataclass +class _IssueStatusCommentInput: + proposal_data: dict[str, Any] + repo_owner: str + repo_name: str + issue_number: int + code_repo_path: Path | None + payload: dict[str, Any] + current_state: str + status: str + title: str + + +@dataclass(frozen=True) +class _SignificantChangeCommentInput: + repo_owner: str + repo_name: str + issue_number: int + change_id: str + title: str + description: str + rationale: str + + console = Console() @@ -317,17 +353,15 @@ def _collect_issue_body_lines(section_title: str, section_body: str) -> list[str lines.append("") return lines - def _render_issue_body( - self, - title: str, - description: str, - rationale: str, - impact: str, - change_id: str, - raw_body: str | None, - preserved_sections: list[str] | None = None, - ) -> str: + def _render_issue_body(self, body_in: _IssueBodyRenderInput) -> str: """Render GitHub issue body from proposal fields and optional preserved sections.""" + title = body_in.title + description = body_in.description + rationale = body_in.rationale + impact = body_in.impact + change_id = body_in.change_id + raw_body = body_in.raw_body + preserved_sections = body_in.preserved_sections if raw_body: return raw_body @@ -1435,7 +1469,9 @@ def _create_issue_from_proposal( if raw_title: title = raw_title - body = self._render_issue_body(title, description, rationale, impact, change_id, raw_body) + body = self._render_issue_body( + _IssueBodyRenderInput(title, description, rationale, impact, change_id, raw_body) + ) # Check for API token before making request if not self.api_token: @@ -1697,7 +1733,9 @@ def _update_issue_body( current_body, current_title, current_state = self._fetch_issue_snapshot(repo_owner, repo_name, issue_number) preserved_sections = self._preserved_issue_sections(current_body, change_id) - body = self._render_issue_body(title, description, rationale, impact, change_id, raw_body, preserved_sections) + body = self._render_issue_body( + _IssueBodyRenderInput(title, description, rationale, impact, change_id, raw_body, preserved_sections) + ) # Update issue body via GitHub API PATCH url = f"{self.base_url}/repos/{repo_owner}/{repo_name}/issues/{issue_number}" @@ -1715,24 +1753,28 @@ def _update_issue_body( response = self._request_with_retry(lambda: requests.patch(url, json=payload, headers=headers, timeout=30)) issue_data = response.json() self._add_issue_status_comment( - proposal_data, - repo_owner, - repo_name, - issue_number, - code_repo_path, - payload, - current_state, - status, - title, + _IssueStatusCommentInput( + proposal_data, + repo_owner, + repo_name, + issue_number, + code_repo_path, + payload, + current_state, + status, + title, + ) ) self._add_significant_change_comment( - repo_owner, - repo_name, - issue_number, - change_id, - title, - description, - rationale, + _SignificantChangeCommentInput( + repo_owner, + repo_name, + issue_number, + change_id, + title, + description, + rationale, + ) ) return { @@ -1765,19 +1807,17 @@ def _issue_body_update_payload( payload["state_reason"] = state_reason return payload - def _add_issue_status_comment( - self, - proposal_data: dict[str, Any], - repo_owner: str, - repo_name: str, - issue_number: int, - code_repo_path: Path | None, - payload: dict[str, Any], - current_state: str, - status: str, - title: str, - ) -> None: + def _add_issue_status_comment(self, comment_in: _IssueStatusCommentInput) -> None: """Add or refresh the status comment when closing or re-syncing applied issues.""" + proposal_data = comment_in.proposal_data + repo_owner = comment_in.repo_owner + repo_name = comment_in.repo_name + issue_number = comment_in.issue_number + code_repo_path = comment_in.code_repo_path + payload = comment_in.payload + current_state = comment_in.current_state + status = comment_in.status + title = comment_in.title if not self._should_add_issue_status_comment(payload, current_state, status): return source_tracking = proposal_data.get("source_tracking", {}) @@ -1808,17 +1848,15 @@ def _status_comment_note(comment_text: str, payload: dict[str, Any], current_sta f"{comment_text}\n\n*Note: This issue was updated from an OpenSpec change proposal with status `{status}`.*" ) - def _add_significant_change_comment( - self, - repo_owner: str, - repo_name: str, - issue_number: int, - change_id: str, - title: str, - description: str, - rationale: str, - ) -> None: + def _add_significant_change_comment(self, sig: _SignificantChangeCommentInput) -> None: """Add a review nudge when proposal text indicates a significant change.""" + repo_owner = sig.repo_owner + repo_name = sig.repo_name + issue_number = sig.issue_number + change_id = sig.change_id + title = sig.title + description = sig.description + rationale = sig.rationale if not self._is_significant_issue_update(title, description, rationale): return comment_text = ( diff --git a/src/specfact_cli/common/logger_setup.py b/src/specfact_cli/common/logger_setup.py index 355aee85..693d5df8 100644 --- a/src/specfact_cli/common/logger_setup.py +++ b/src/specfact_cli/common/logger_setup.py @@ -10,6 +10,7 @@ import os import re import sys +from dataclasses import dataclass from logging.handlers import QueueHandler, QueueListener, RotatingFileHandler from queue import Queue from typing import Any, Literal @@ -282,6 +283,31 @@ def format(self, record: logging.LogRecord) -> str: return formatted_message +@dataclass +class _FileOutputPipelineConfig: + logger_name: str + logger: logging.Logger + level: int + log_format: MessageFlowFormatter + log_file: str + use_rotating_file: bool + append_mode: bool + + +@dataclass +class LoggerCreateOptions: + """Options for :meth:`LoggerSetup.create_logger`.""" + + log_file: str | None = None + agent_name: str | None = None + log_level: str | None = None + session_id: str | None = None + use_rotating_file: bool = True + append_mode: bool = True + preserve_test_format: bool = False + emit_to_console: bool = True + + class LoggerSetup: """ Utility class for standardized logging setup across all agents @@ -407,16 +433,14 @@ def _reuse_or_teardown_cached_logger( return existing_logger @classmethod - def _attach_file_output_pipeline( - cls, - logger_name: str, - logger: logging.Logger, - level: int, - log_format: MessageFlowFormatter, - log_file: str, - use_rotating_file: bool, - append_mode: bool, - ) -> None: + def _attach_file_output_pipeline(cls, cfg: _FileOutputPipelineConfig) -> None: + logger_name = cfg.logger_name + logger = cfg.logger + level = cfg.level + log_format = cfg.log_format + log_file = cfg.log_file + use_rotating_file = cfg.use_rotating_file + append_mode = cfg.append_mode log_queue = Queue(-1) cls._log_queues[logger_name] = log_queue @@ -519,27 +543,27 @@ def _maybe_add_direct_console_handler( @classmethod @beartype @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string") - @require( - lambda log_level: log_level is None or (isinstance(log_level, str) and len(log_level) > 0), - "Log level must be None or non-empty string", - ) @ensure(lambda result: isinstance(result, logging.Logger), "Must return Logger instance") def create_logger( cls, name: str, - log_file: str | None = None, - agent_name: str | None = None, - log_level: str | None = None, - session_id: str | None = None, - use_rotating_file: bool = True, - append_mode: bool = True, - preserve_test_format: bool = False, - emit_to_console: bool = True, + options: LoggerCreateOptions | None = None, ) -> logging.Logger: """ Creates a new logger or returns an existing one with the specified configuration. This method is process-safe and suitable for multi-agent environments. """ + opts = options or LoggerCreateOptions() + log_file = opts.log_file + agent_name = opts.agent_name + log_level = opts.log_level + if log_level is not None and (not isinstance(log_level, str) or not log_level.strip()): + raise ValueError("log_level must be None or a non-empty string") + session_id = opts.session_id + use_rotating_file = opts.use_rotating_file + append_mode = opts.append_mode + preserve_test_format = opts.preserve_test_format + emit_to_console = opts.emit_to_console logger_name = name reused = cls._reuse_or_teardown_cached_logger(logger_name, log_file, log_level) if reused is not None: @@ -566,13 +590,15 @@ def create_logger( if log_file: cls._attach_file_output_pipeline( - logger_name, - logger, - level, - log_format, - log_file, - use_rotating_file, - append_mode, + _FileOutputPipelineConfig( + logger_name, + logger, + level, + log_format, + log_file, + use_rotating_file, + append_mode, + ) ) else: cls._attach_console_queue_pipeline( @@ -774,11 +800,13 @@ def setup_logger( # Use the LoggerSetup class for consistent logging setup return LoggerSetup.create_logger( agent_name, - log_file=log_file, - agent_name=agent_name, - log_level=log_level, - session_id=session_id, - use_rotating_file=use_rotating_file, + LoggerCreateOptions( + log_file=log_file, + agent_name=agent_name, + log_level=log_level, + session_id=session_id, + use_rotating_file=use_rotating_file, + ), ) diff --git a/src/specfact_cli/common/logging_utils.py b/src/specfact_cli/common/logging_utils.py index 44bed0bb..a805872b 100644 --- a/src/specfact_cli/common/logging_utils.py +++ b/src/specfact_cli/common/logging_utils.py @@ -35,7 +35,7 @@ def get_bridge_logger(name: str, level: str = "INFO") -> logging.Logger: def _try_common_logger(name: str, level: str) -> logging.Logger | None: try: - from specfact_cli.common.logger_setup import LoggerSetup # type: ignore[import] + from specfact_cli.common.logger_setup import LoggerCreateOptions, LoggerSetup # type: ignore[import] except ImportError: return None try: @@ -44,4 +44,5 @@ def _try_common_logger(name: str, level: str) -> logging.Logger | None: emit_to_console = is_debug_mode() except ImportError: emit_to_console = False - return LoggerSetup.create_logger(name, log_level=level, emit_to_console=emit_to_console) + + return LoggerSetup.create_logger(name, LoggerCreateOptions(log_level=level, emit_to_console=emit_to_console)) diff --git a/src/specfact_cli/sync/__init__.py b/src/specfact_cli/sync/__init__.py index 0ae71237..0144b5f7 100644 --- a/src/specfact_cli/sync/__init__.py +++ b/src/specfact_cli/sync/__init__.py @@ -7,7 +7,7 @@ from specfact_cli.models.capabilities import ToolCapabilities from specfact_cli.sync.bridge_probe import BridgeProbe -from specfact_cli.sync.bridge_sync import BridgeSync, SyncOperation, SyncResult +from specfact_cli.sync.bridge_sync import BridgeSync, ExportChangeProposalsOptions, SyncOperation, SyncResult from specfact_cli.sync.bridge_watch import BridgeWatch, BridgeWatchEventHandler from specfact_cli.sync.repository_sync import RepositorySync, RepositorySyncResult from specfact_cli.sync.watcher import FileChange, SyncEventHandler, SyncWatcher @@ -18,6 +18,7 @@ "BridgeSync", "BridgeWatch", "BridgeWatchEventHandler", + "ExportChangeProposalsOptions", "FileChange", "RepositorySync", "RepositorySyncResult", diff --git a/src/specfact_cli/sync/bridge_sync.py b/src/specfact_cli/sync/bridge_sync.py index 0e82eb98..5678acf9 100644 --- a/src/specfact_cli/sync/bridge_sync.py +++ b/src/specfact_cli/sync/bridge_sync.py @@ -123,6 +123,296 @@ class SyncResult: warnings: list[str] +@dataclass(frozen=True) +class ExportChangeProposalsOptions: + """Keyword options for :meth:`BridgeSync.export_change_proposals_to_devops`.""" + + repo_owner: str | None = None + repo_name: str | None = None + api_token: str | None = None + use_gh_cli: bool = True + sanitize: bool | None = None + target_repo: str | None = None + interactive: bool = False + change_ids: list[str] | None = None + export_to_tmp: bool = False + import_from_tmp: bool = False + tmp_file: Path | None = None + update_existing: bool = False + track_code_changes: bool = False + add_progress_comment: bool = False + code_repo_path: Path | None = None + include_archived: bool = False + ado_org: str | None = None + ado_project: str | None = None + ado_base_url: str | None = None + ado_work_item_type: str | None = None + + +@dataclass +class _AlignmentReportContentInput: + adapter_name: str + external_feature_ids: set[str] + specfact_feature_ids: set[str] + aligned: set[str] + gaps_in_specfact: set[str] + gaps_in_external: set[str] + coverage: float + + +@dataclass +class _AdoWorkItemVerifyInput: + issue_number: str | int | None + target_entry: dict[str, Any] | None + adapter_type: str + adapter: Any + ado_org: str | None + ado_project: str | None + + +@dataclass +class _GithubIssueSearchInput: + proposal: dict[str, Any] + change_id: str + adapter_type: str + repo_owner: str | None + repo_name: str | None + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + warnings: list[str] + target_entry: dict[str, Any] | None + issue_number: str | int | None + + +@dataclass +class _AdoIssueSearchInput: + proposal: dict[str, Any] + change_id: str + adapter_type: str + adapter: Any + ado_org: str | None + ado_project: str | None + source_tracking_list: list[dict[str, Any]] + target_entry: dict[str, Any] | None + issue_number: str | int | None + + +@dataclass +class _RemoteIssueResolutionInput: + proposal: dict[str, Any] + change_id: str + adapter_type: str + adapter: Any + repo_owner: str | None + repo_name: str | None + ado_org: str | None + ado_project: str | None + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + warnings: list[str] + target_entry: dict[str, Any] | None + issue_number: str | int | None + + +@dataclass +class _RecordCreatedIssueInput: + result: dict[str, Any] + adapter_type: str + ado_org: str | None + ado_project: str | None + repo_owner: str | None + repo_name: str | None + target_repo: str | None + should_sanitize: bool | None + + +@dataclass +class _DevOpsAdapterKwargsInput: + adapter_type: str + repo_owner: str | None + repo_name: str | None + api_token: str | None + use_gh_cli: bool + ado_org: str | None + ado_project: str | None + ado_base_url: str | None + ado_work_item_type: str | None + + +@dataclass +class _IssueUpdatePayload: + proposal: dict[str, Any] + target_entry: dict[str, Any] | None + issue_number: str | int | None + adapter: Any + adapter_type: str + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + source_tracking_raw: dict[str, Any] | list[dict[str, Any]] + repo_owner: str | None + repo_name: str | None + ado_org: str | None + ado_project: str | None + update_existing: bool + import_from_tmp: bool + tmp_file: Path | None + should_sanitize: bool | None + track_code_changes: bool + add_progress_comment: bool + code_repo_path: Path | None + operations: list[SyncOperation] + errors: list[str] + warnings: list[str] + + +@dataclass(frozen=True) +class _ExportIterationTracking: + source_tracking_list: list[dict[str, Any]] + source_tracking_raw: dict[str, Any] | list[dict[str, Any]] + + +@dataclass +class _ChangeProposalExportLoopContext: + adapter: Any + adapter_type: str + target_repo: str | None + repo_owner: str | None + repo_name: str | None + ado_org: str | None + ado_project: str | None + update_existing: bool + import_from_tmp: bool + export_to_tmp: bool + tmp_file: Path | None + should_sanitize: bool | None + sanitizer: Any + track_code_changes: bool + add_progress_comment: bool + code_repo_path: Path | None + operations: list[SyncOperation] + errors: list[str] + warnings: list[str] + + +@dataclass +class _BundleAdapterExportInput: + proposal: Any + proposal_dict: dict[str, Any] + target_entry: dict[str, Any] | None + adapter: Any + adapter_type: str + bridge_config: Any + bundle_name: str + target_repo: str | None + update_existing: bool + entries: list[dict[str, Any]] + operations: list[SyncOperation] + errors: list[str] + + +@dataclass +class _BundleSingleExportInput: + proposal: Any + adapter: Any + adapter_type: str + bridge_config: Any + bundle_name: str + target_repo: str | None + update_existing: bool + operations: list[SyncOperation] + errors: list[str] + + +@dataclass +class _WorkItemVerifyInput: + issue_number: str | int | None + target_entry: dict[str, Any] | None + adapter_type: str + adapter: Any + ado_org: str | None + ado_project: str | None + + +@dataclass +class _FetchIssueSyncStateInput: + adapter_type: str + issue_num: str | int + repo_owner: str | None + repo_name: str | None + ado_org: str | None + ado_project: str | None + proposal_title: str + proposal_status: str + + +@dataclass +class _PushIssueBodyInput: + proposal: dict[str, Any] + target_entry: dict[str, Any] + adapter: Any + import_from_tmp: bool + tmp_file: Path | None + repo_owner: str | None + repo_name: str | None + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + current_hash: str + content_or_meta_changed: bool + needs_comment_for_applied: bool + operations: list[Any] + errors: list[str] + + +@dataclass +class _IssueContentUpdateInput: + proposal: dict[str, Any] + target_entry: dict[str, Any] + issue_number: str | int + adapter: Any + adapter_type: str + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + repo_owner: str | None + repo_name: str | None + ado_org: str | None + ado_project: str | None + import_from_tmp: bool + tmp_file: Path | None + operations: list[Any] + errors: list[str] + + +@dataclass +class _EmitCodeChangeProgressInput: + proposal: dict[str, Any] + change_id: str + target_entry: dict[str, Any] | None + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + progress_data: dict[str, Any] + adapter: Any + should_sanitize: bool | None + operations: list[Any] + errors: list[str] + warnings: list[str] + + +@dataclass +class _CodeChangeTrackingInput: + proposal: dict[str, Any] + target_entry: dict[str, Any] | None + target_repo: str | None + source_tracking_list: list[dict[str, Any]] + adapter: Any + track_code_changes: bool + add_progress_comment: bool + code_repo_path: Path | None + should_sanitize: bool | None + operations: list[Any] + errors: list[str] + warnings: list[str] + + class BridgeSync: """ Adapter-agnostic bidirectional sync using bridge configuration. @@ -181,17 +471,15 @@ def _render_alignment_gaps(self, gaps: set[str], heading: str) -> None: gaps_table.add_row(feature_id) console.print(gaps_table) - def _build_alignment_report_content( - self, - adapter_name: str, - external_feature_ids: set[str], - specfact_feature_ids: set[str], - aligned: set[str], - gaps_in_specfact: set[str], - gaps_in_external: set[str], - coverage: float, - ) -> str: + def _build_alignment_report_content(self, snap: _AlignmentReportContentInput) -> str: """Build markdown content for a saved alignment report.""" + adapter_name = snap.adapter_name + external_feature_ids = snap.external_feature_ids + specfact_feature_ids = snap.specfact_feature_ids + aligned = snap.aligned + gaps_in_specfact = snap.gaps_in_specfact + gaps_in_external = snap.gaps_in_external + coverage = snap.coverage return f"""# Alignment Report: SpecFact vs {adapter_name} ## Summary @@ -548,13 +836,15 @@ def generate_alignment_report(self, bundle_name: str, output_file: Path | None = # Save to file if requested if output_file: report_content = self._build_alignment_report_content( - adapter_name, - external_feature_ids, - specfact_feature_ids, - aligned, - gaps_in_specfact, - gaps_in_external, - coverage, + _AlignmentReportContentInput( + adapter_name, + external_feature_ids, + specfact_feature_ids, + aligned, + gaps_in_specfact, + gaps_in_external, + coverage, + ) ) output_file.parent.mkdir(parents=True, exist_ok=True) output_file.write_text(report_content, encoding="utf-8") @@ -603,16 +893,17 @@ def _bridge_sync_filter_devops_proposals( def _bridge_sync_verify_ado_tracked_work_item( self, - issue_number: str | int | None, - target_entry: dict[str, Any] | None, - adapter_type: str, - adapter: Any, - ado_org: str | None, - ado_project: str | None, + verify: _AdoWorkItemVerifyInput, proposal: dict[str, Any], warnings: list[str], ) -> tuple[str | int | None, bool, dict[str, Any] | None]: """Clear ADO source_id when the tracked work item no longer exists.""" + issue_number = verify.issue_number + target_entry = verify.target_entry + adapter_type = verify.adapter_type + adapter = verify.adapter + ado_org = verify.ado_org + ado_project = verify.ado_project work_item_was_deleted = False if not issue_number or not target_entry: return issue_number, work_item_was_deleted, target_entry @@ -660,17 +951,18 @@ def _bridge_sync_clear_corrupted_tracking_entry( def _bridge_sync_try_github_issue_by_search( self, - proposal: dict[str, Any], - change_id: str, - adapter_type: str, - repo_owner: str | None, - repo_name: str | None, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - warnings: list[str], - target_entry: dict[str, Any] | None, - issue_number: str | int | None, + search: _GithubIssueSearchInput, ) -> tuple[dict[str, Any] | None, str | int | None, list[dict[str, Any]]]: + proposal = search.proposal + change_id = search.change_id + adapter_type = search.adapter_type + repo_owner = search.repo_owner + repo_name = search.repo_name + target_repo = search.target_repo + source_tracking_list = search.source_tracking_list + warnings = search.warnings + target_entry = search.target_entry + issue_number = search.issue_number if target_entry or adapter_type.lower() != "github" or not repo_owner or not repo_name: return target_entry, issue_number, source_tracking_list found_entry, found_issue_number = self._search_existing_github_issue( @@ -684,16 +976,17 @@ def _bridge_sync_try_github_issue_by_search( def _bridge_sync_try_ado_issue_by_search( self, - proposal: dict[str, Any], - change_id: str, - adapter_type: str, - adapter: Any, - ado_org: str | None, - ado_project: str | None, - source_tracking_list: list[dict[str, Any]], - target_entry: dict[str, Any] | None, - issue_number: str | int | None, + search: _AdoIssueSearchInput, ) -> tuple[dict[str, Any] | None, str | int | None, list[dict[str, Any]]]: + proposal = search.proposal + change_id = search.change_id + adapter_type = search.adapter_type + adapter = search.adapter + ado_org = search.ado_org + ado_project = search.ado_project + source_tracking_list = search.source_tracking_list + target_entry = search.target_entry + issue_number = search.issue_number if ( target_entry or adapter_type.lower() != "ado" @@ -713,58 +1006,64 @@ def _bridge_sync_try_ado_issue_by_search( def _bridge_sync_resolve_remote_issue_by_search( self, - proposal: dict[str, Any], - change_id: str, - adapter_type: str, - adapter: Any, - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - warnings: list[str], - target_entry: dict[str, Any] | None, - issue_number: str | int | None, + resolve: _RemoteIssueResolutionInput, ) -> tuple[dict[str, Any] | None, str | int | None, list[dict[str, Any]]]: """Attach GitHub/ADO issues discovered by change-id search.""" + proposal = resolve.proposal + change_id = resolve.change_id + adapter_type = resolve.adapter_type + adapter = resolve.adapter + repo_owner = resolve.repo_owner + repo_name = resolve.repo_name + ado_org = resolve.ado_org + ado_project = resolve.ado_project + target_repo = resolve.target_repo + source_tracking_list = resolve.source_tracking_list + warnings = resolve.warnings + target_entry = resolve.target_entry + issue_number = resolve.issue_number target_entry, issue_number, source_tracking_list = self._bridge_sync_try_github_issue_by_search( - proposal, - change_id, - adapter_type, - repo_owner, - repo_name, - target_repo, - source_tracking_list, - warnings, - target_entry, - issue_number, + _GithubIssueSearchInput( + proposal, + change_id, + adapter_type, + repo_owner, + repo_name, + target_repo, + source_tracking_list, + warnings, + target_entry, + issue_number, + ) ) return self._bridge_sync_try_ado_issue_by_search( - proposal, - change_id, - adapter_type, - adapter, - ado_org, - ado_project, - source_tracking_list, - target_entry, - issue_number, + _AdoIssueSearchInput( + proposal, + change_id, + adapter_type, + adapter, + ado_org, + ado_project, + source_tracking_list, + target_entry, + issue_number, + ) ) def _bridge_sync_record_created_issue( self, proposal: dict[str, Any], - result: dict[str, Any], - adapter_type: str, - ado_org: str | None, - ado_project: str | None, - repo_owner: str | None, - repo_name: str | None, - target_repo: str | None, - should_sanitize: bool | None, + created: _RecordCreatedIssueInput, ) -> None: """Merge export result into proposal source_tracking for a newly created issue.""" + result = created.result + adapter_type = created.adapter_type + ado_org = created.ado_org + ado_project = created.ado_project + repo_owner = created.repo_owner + repo_name = created.repo_name + target_repo = created.target_repo + should_sanitize = created.should_sanitize source_tracking_list = self._normalize_source_tracking(proposal.get("source_tracking", {})) if adapter_type == "ado" and ado_org and ado_project: repo_identifier = target_repo or f"{ado_org}/{ado_project}" @@ -848,19 +1147,17 @@ def _bridge_sync_clone_and_maybe_sanitize_proposal( proposal_to_export["rationale"] = sanitized_rationale or original_rationale return proposal_to_export - def _bridge_sync_make_devops_adapter_kwargs( - self, - adapter_type: str, - repo_owner: str | None, - repo_name: str | None, - api_token: str | None, - use_gh_cli: bool, - ado_org: str | None, - ado_project: str | None, - ado_base_url: str | None, - ado_work_item_type: str | None, - ) -> dict[str, Any]: + def _bridge_sync_make_devops_adapter_kwargs(self, cfg: _DevOpsAdapterKwargsInput) -> dict[str, Any]: """Build kwargs for AdapterRegistry.get_adapter for supported DevOps adapters.""" + adapter_type = cfg.adapter_type + repo_owner = cfg.repo_owner + repo_name = cfg.repo_name + api_token = cfg.api_token + use_gh_cli = cfg.use_gh_cli + ado_org = cfg.ado_org + ado_project = cfg.ado_project + ado_base_url = cfg.ado_base_url + ado_work_item_type = cfg.ado_work_item_type lowered = adapter_type.lower() if lowered == "github": return { @@ -899,110 +1196,50 @@ def _bridge_sync_apply_change_id_filter( ) return [p for p in active_proposals if p.get("change_id") in valid_change_ids] - def _bridge_sync_update_existing_issue_then_save( - self, - proposal: dict[str, Any], - target_entry: dict[str, Any], - issue_number: str | int, - adapter: Any, - adapter_type: str, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - source_tracking_raw: dict[str, Any] | list[dict[str, Any]], - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - update_existing: bool, - import_from_tmp: bool, - tmp_file: Path | None, - should_sanitize: bool | None, - track_code_changes: bool, - add_progress_comment: bool, - code_repo_path: Path | None, - operations: list[SyncOperation], - errors: list[str], - warnings: list[str], - ) -> None: + def _bridge_sync_update_existing_issue_then_save(self, payload: _IssueUpdatePayload) -> None: """Run _update_existing_issue and persist proposal (shared by two branches).""" - self._update_existing_issue( - proposal=proposal, - target_entry=target_entry, - issue_number=issue_number, - adapter=adapter, - adapter_type=adapter_type, - target_repo=target_repo, - source_tracking_list=source_tracking_list, - source_tracking_raw=source_tracking_raw, - repo_owner=repo_owner, - repo_name=repo_name, - ado_org=ado_org, - ado_project=ado_project, - update_existing=update_existing, - import_from_tmp=import_from_tmp, - tmp_file=tmp_file, - should_sanitize=should_sanitize, - track_code_changes=track_code_changes, - add_progress_comment=add_progress_comment, - code_repo_path=code_repo_path, - operations=operations, - errors=errors, - warnings=warnings, - ) - self._save_openspec_change_proposal(proposal) + assert payload.target_entry is not None and payload.issue_number is not None + self._update_existing_issue(payload) + self._save_openspec_change_proposal(payload.proposal) - def _bridge_sync_if_tracked_update_and_return( + def _bridge_sync_if_tracked_update_and_return(self, payload: _IssueUpdatePayload) -> bool: + if not (payload.issue_number and payload.target_entry): + return False + self._bridge_sync_update_existing_issue_then_save(payload) + return True + + def _bridge_sync_issue_update_payload( self, proposal: dict[str, Any], target_entry: dict[str, Any] | None, issue_number: str | int | None, - adapter: Any, - adapter_type: str, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - source_tracking_raw: dict[str, Any] | list[dict[str, Any]], - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - update_existing: bool, - import_from_tmp: bool, - tmp_file: Path | None, - should_sanitize: bool | None, - track_code_changes: bool, - add_progress_comment: bool, - code_repo_path: Path | None, - operations: list[SyncOperation], - errors: list[str], - warnings: list[str], - ) -> bool: - if not (issue_number and target_entry): - return False - self._bridge_sync_update_existing_issue_then_save( - proposal, - target_entry, - issue_number, - adapter, - adapter_type, - target_repo, - source_tracking_list, - source_tracking_raw, - repo_owner, - repo_name, - ado_org, - ado_project, - update_existing, - import_from_tmp, - tmp_file, - should_sanitize, - track_code_changes, - add_progress_comment, - code_repo_path, - operations, - errors, - warnings, + tracking: _ExportIterationTracking, + ctx: _ChangeProposalExportLoopContext, + ) -> _IssueUpdatePayload: + return _IssueUpdatePayload( + proposal=proposal, + target_entry=target_entry, + issue_number=issue_number, + adapter=ctx.adapter, + adapter_type=ctx.adapter_type, + target_repo=ctx.target_repo, + source_tracking_list=tracking.source_tracking_list, + source_tracking_raw=tracking.source_tracking_raw, + repo_owner=ctx.repo_owner, + repo_name=ctx.repo_name, + ado_org=ctx.ado_org, + ado_project=ctx.ado_project, + update_existing=ctx.update_existing, + import_from_tmp=ctx.import_from_tmp, + tmp_file=ctx.tmp_file, + should_sanitize=ctx.should_sanitize, + track_code_changes=ctx.track_code_changes, + add_progress_comment=ctx.add_progress_comment, + code_repo_path=ctx.code_repo_path, + operations=ctx.operations, + errors=ctx.errors, + warnings=ctx.warnings, ) - return True def _bridge_sync_try_export_proposal_to_tmp( self, @@ -1030,33 +1267,20 @@ def _bridge_sync_export_new_change_proposal_remote( self, proposal: dict[str, Any], change_id: str, - import_from_tmp: bool, - tmp_file: Path | None, - should_sanitize: bool | None, - sanitizer: Any, - adapter: Any, - adapter_type: str, - ado_org: str | None, - ado_project: str | None, - repo_owner: str | None, - repo_name: str | None, - target_repo: str | None, - operations: list[SyncOperation], - errors: list[str], - warnings: list[str], + ctx: _ChangeProposalExportLoopContext, ) -> None: """Import/sanitize proposal payload and create a new remote change proposal artifact.""" - if import_from_tmp: + if ctx.import_from_tmp: proposal_to_export = self._bridge_sync_import_sanitized_proposal_from_tmp( - proposal, change_id, tmp_file, errors, warnings + proposal, change_id, ctx.tmp_file, ctx.errors, ctx.warnings ) if proposal_to_export is None: return else: proposal_to_export = self._bridge_sync_clone_and_maybe_sanitize_proposal( - proposal, bool(should_sanitize), sanitizer + proposal, bool(ctx.should_sanitize), ctx.sanitizer ) - result = adapter.export_artifact( + result = ctx.adapter.export_artifact( artifact_key="change_proposal", artifact_data=proposal_to_export, bridge_config=self.bridge_config, @@ -1064,16 +1288,18 @@ def _bridge_sync_export_new_change_proposal_remote( if isinstance(proposal, dict) and isinstance(result, dict): self._bridge_sync_record_created_issue( proposal, - result, - adapter_type, - ado_org, - ado_project, - repo_owner, - repo_name, - target_repo, - should_sanitize, + _RecordCreatedIssueInput( + result, + ctx.adapter_type, + ctx.ado_org, + ctx.ado_project, + ctx.repo_owner, + ctx.repo_name, + ctx.target_repo, + ctx.should_sanitize, + ), ) - operations.append( + ctx.operations.append( SyncOperation( artifact_key="change_proposal", feature_id=proposal.get("change_id", "unknown"), @@ -1086,46 +1312,40 @@ def _bridge_sync_export_new_change_proposal_remote( def _bridge_sync_export_single_change_proposal_iteration( self, proposal: dict[str, Any], - adapter: Any, - adapter_type: str, - target_repo: str | None, - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - update_existing: bool, - import_from_tmp: bool, - export_to_tmp: bool, - tmp_file: Path | None, - should_sanitize: bool | None, - sanitizer: Any, - track_code_changes: bool, - add_progress_comment: bool, - code_repo_path: Path | None, - operations: list[SyncOperation], - errors: list[str], - warnings: list[str], + ctx: _ChangeProposalExportLoopContext, ) -> None: """One loop iteration for export_change_proposals_to_devops.""" source_tracking_raw = proposal.get("source_tracking", {}) + target_repo = ctx.target_repo target_entry = self._find_source_tracking_entry(source_tracking_raw, target_repo) source_tracking_list = self._normalize_source_tracking(source_tracking_raw) + tracking = _ExportIterationTracking(source_tracking_list, source_tracking_raw) issue_number = target_entry.get("source_id") if target_entry else None work_item_was_deleted = False issue_number, work_item_was_deleted, target_entry = self._bridge_sync_verify_ado_tracked_work_item( - issue_number, target_entry, adapter_type, adapter, ado_org, ado_project, proposal, warnings + _AdoWorkItemVerifyInput( + issue_number, + target_entry, + ctx.adapter_type, + ctx.adapter, + ctx.ado_org, + ctx.ado_project, + ), + proposal, + ctx.warnings, ) if target_entry and not issue_number and not work_item_was_deleted: - if update_existing: + if ctx.update_existing: _, source_tracking_list = self._bridge_sync_clear_corrupted_tracking_entry( proposal, source_tracking_raw, source_tracking_list, target_entry ) + tracking = _ExportIterationTracking(source_tracking_list, source_tracking_raw) target_entry = None else: - warnings.append( + ctx.warnings.append( f"Skipping sync for '{proposal.get('change_id', 'unknown')}': " f"source_tracking entry exists for '{target_repo}' but missing source_id. " f"Use --update-existing to force update or manually fix source_tracking." @@ -1133,156 +1353,89 @@ def _bridge_sync_export_single_change_proposal_iteration( return if self._bridge_sync_if_tracked_update_and_return( - proposal, - target_entry, - issue_number, - adapter, - adapter_type, - target_repo, - source_tracking_list, - source_tracking_raw, - repo_owner, - repo_name, - ado_org, - ado_project, - update_existing, - import_from_tmp, - tmp_file, - should_sanitize, - track_code_changes, - add_progress_comment, - code_repo_path, - operations, - errors, - warnings, + self._bridge_sync_issue_update_payload(proposal, target_entry, issue_number, tracking, ctx) ): return change_id = proposal.get("change_id", "unknown") if target_entry and not target_entry.get("source_id") and not work_item_was_deleted: - warnings.append( + ctx.warnings.append( f"Skipping sync for '{change_id}': source_tracking entry exists for " f"'{target_repo}' but missing source_id. Use --update-existing to force update." ) return target_entry, issue_number, source_tracking_list = self._bridge_sync_resolve_remote_issue_by_search( - proposal, - change_id, - adapter_type, - adapter, - repo_owner, - repo_name, - ado_org, - ado_project, - target_repo, - source_tracking_list, - warnings, - target_entry, - issue_number, + _RemoteIssueResolutionInput( + proposal, + change_id, + ctx.adapter_type, + ctx.adapter, + ctx.repo_owner, + ctx.repo_name, + ctx.ado_org, + ctx.ado_project, + target_repo, + source_tracking_list, + ctx.warnings, + target_entry, + issue_number, + ) ) + tracking = _ExportIterationTracking(source_tracking_list, source_tracking_raw) if self._bridge_sync_if_tracked_update_and_return( - proposal, - target_entry, - issue_number, - adapter, - adapter_type, - target_repo, - source_tracking_list, - source_tracking_raw, - repo_owner, - repo_name, - ado_org, - ado_project, - update_existing, - import_from_tmp, - tmp_file, - should_sanitize, - track_code_changes, - add_progress_comment, - code_repo_path, - operations, - errors, - warnings, + self._bridge_sync_issue_update_payload(proposal, target_entry, issue_number, tracking, ctx) ): return - if self._bridge_sync_try_export_proposal_to_tmp(export_to_tmp, change_id, tmp_file, proposal, errors, warnings): + if self._bridge_sync_try_export_proposal_to_tmp( + ctx.export_to_tmp, change_id, ctx.tmp_file, proposal, ctx.errors, ctx.warnings + ): return - self._bridge_sync_export_new_change_proposal_remote( - proposal, - change_id, - import_from_tmp, - tmp_file, - should_sanitize, - sanitizer, - adapter, - adapter_type, - ado_org, - ado_project, - repo_owner, - repo_name, - target_repo, - operations, - errors, - warnings, - ) + self._bridge_sync_export_new_change_proposal_remote(proposal, change_id, ctx) def _bridge_sync_export_each_change_proposal( self, active_proposals: list[dict[str, Any]], - adapter: Any, - adapter_type: str, - target_repo: str | None, - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - update_existing: bool, - import_from_tmp: bool, - export_to_tmp: bool, - tmp_file: Path | None, - should_sanitize: bool | None, - sanitizer: Any, - track_code_changes: bool, - add_progress_comment: bool, - code_repo_path: Path | None, - operations: list[SyncOperation], - errors: list[str], - warnings: list[str], + ctx: _ChangeProposalExportLoopContext, ) -> None: """Create or update remote issues for each filtered proposal dict.""" for proposal in active_proposals: try: - self._bridge_sync_export_single_change_proposal_iteration( - proposal, - adapter, - adapter_type, - target_repo, - repo_owner, - repo_name, - ado_org, - ado_project, - update_existing, - import_from_tmp, - export_to_tmp, - tmp_file, - should_sanitize, - sanitizer, - track_code_changes, - add_progress_comment, - code_repo_path, - operations, - errors, - warnings, - ) + self._bridge_sync_export_single_change_proposal_iteration(proposal, ctx) except Exception as e: logger = logging.getLogger(__name__) logger.debug(f"Failed to sync proposal {proposal.get('change_id', 'unknown')}: {e}", exc_info=True) - errors.append(f"Failed to sync proposal {proposal.get('change_id', 'unknown')}: {e}") + ctx.errors.append(f"Failed to sync proposal {proposal.get('change_id', 'unknown')}: {e}") + + def _export_change_proposals_load_list( + self, include_archived: bool, warnings: list[str] + ) -> list[dict[str, Any]] | None: + try: + return self._read_openspec_change_proposals(include_archived=include_archived) + except Exception as e: + warnings.append(f"OpenSpec adapter not available: {e}. Skipping change proposal sync.") + return None + + def _export_change_proposals_append_filter_warnings( + self, filtered_count: int, should_sanitize: bool, active_len: int, warnings: list[str] + ) -> None: + if filtered_count <= 0: + return + if should_sanitize: + warnings.append( + f"Filtered out {filtered_count} proposal(s) with non-applied status " + f"(public repos only sync archived/completed proposals, regardless of source tracking). " + f"Only {active_len} applied proposal(s) will be synced." + ) + return + warnings.append( + f"Filtered out {filtered_count} proposal(s) without source tracking entry for target repo " + f"and inactive status. Only {active_len} proposal(s) will be synced." + ) @beartype @require(_bridge_config_set, "Bridge config must be set") @@ -1294,159 +1447,111 @@ def _bridge_sync_export_each_change_proposal( def export_change_proposals_to_devops( self, adapter_type: str, - repo_owner: str | None = None, - repo_name: str | None = None, - api_token: str | None = None, - use_gh_cli: bool = True, - sanitize: bool | None = None, - target_repo: str | None = None, - interactive: bool = False, - change_ids: list[str] | None = None, - export_to_tmp: bool = False, - import_from_tmp: bool = False, - tmp_file: Path | None = None, - update_existing: bool = False, - track_code_changes: bool = False, - add_progress_comment: bool = False, - code_repo_path: Path | None = None, - include_archived: bool = False, - ado_org: str | None = None, - ado_project: str | None = None, - ado_base_url: str | None = None, - ado_work_item_type: str | None = None, + options: ExportChangeProposalsOptions | None = None, ) -> SyncResult: """ Export OpenSpec change proposals to DevOps tools (export-only mode). - This method reads OpenSpec change proposals and creates/updates DevOps issues - (GitHub Issues, ADO Work Items, etc.) via the appropriate adapter. - - Args: - adapter_type: DevOps adapter type (github, ado, linear, jira) - repo_owner: Repository owner (for GitHub/ADO) - repo_name: Repository name (for GitHub/ADO) - api_token: API token (optional, uses env vars, gh CLI, or --github-token if not provided) - use_gh_cli: If True, try to get token from GitHub CLI (`gh auth token`) for GitHub adapter - sanitize: If True, sanitize content for public issues. If None, auto-detect based on repo setup. - target_repo: Target repository for issue creation (format: owner/repo). Default: same as code repo. - interactive: If True, use interactive mode for AI-assisted sanitization (requires slash command). - change_ids: Optional list of change proposal IDs to filter. If None, exports all active proposals. - export_to_tmp: If True, export proposal content to temporary file for LLM review. - import_from_tmp: If True, import sanitized content from temporary file after LLM review. - tmp_file: Optional custom temporary file path. Default: /specfact-proposal-.md. - - Returns: - SyncResult with operation details - - Note: - Requires OpenSpec bridge adapter to be implemented (dependency). - For now, this is a placeholder that will be fully implemented once - the OpenSpec adapter is available. + Pass fields via :class:`ExportChangeProposalsOptions` (defaults apply when ``options`` is omitted). """ from specfact_cli.adapters.registry import AdapterRegistry + from specfact_cli.utils.content_sanitizer import ContentSanitizer + + opt = options or ExportChangeProposalsOptions() + repo_owner = opt.repo_owner + repo_name = opt.repo_name + api_token = opt.api_token + use_gh_cli = opt.use_gh_cli + sanitize = opt.sanitize + target_repo = opt.target_repo + change_ids = opt.change_ids + export_to_tmp = opt.export_to_tmp + import_from_tmp = opt.import_from_tmp + tmp_file = opt.tmp_file + update_existing = opt.update_existing + track_code_changes = opt.track_code_changes + add_progress_comment = opt.add_progress_comment + code_repo_path = opt.code_repo_path + include_archived = opt.include_archived + ado_org = opt.ado_org + ado_project = opt.ado_project + ado_base_url = opt.ado_base_url + ado_work_item_type = opt.ado_work_item_type operations: list[SyncOperation] = [] errors: list[str] = [] warnings: list[str] = [] try: - # Get DevOps adapter from registry (adapter-agnostic) - # Get adapter to determine required kwargs adapter_class = AdapterRegistry._adapters.get(adapter_type.lower()) if not adapter_class: errors.append(f"Adapter '{adapter_type}' not found in registry") - return SyncResult(success=False, operations=[], errors=errors, warnings=warnings) - - adapter_kwargs = self._bridge_sync_make_devops_adapter_kwargs( - adapter_type, - repo_owner, - repo_name, - api_token, - use_gh_cli, - ado_org, - ado_project, - ado_base_url, - ado_work_item_type, - ) - - adapter = AdapterRegistry.get_adapter(adapter_type, **adapter_kwargs) + return SyncResult(success=False, operations=[], errors=errors, warnings=warnings) - # TODO: Read OpenSpec change proposals via OpenSpec adapter - # This requires the OpenSpec bridge adapter to be implemented first - # For now, this is a placeholder - try: - # Attempt to read OpenSpec change proposals - # This will fail gracefully if OpenSpec adapter is not available - change_proposals = self._read_openspec_change_proposals(include_archived=include_archived) - except Exception as e: - warnings.append(f"OpenSpec adapter not available: {e}. Skipping change proposal sync.") - return SyncResult( - success=True, # Not an error, just no proposals to sync - operations=operations, - errors=errors, - warnings=warnings, + adapter_kwargs = self._bridge_sync_make_devops_adapter_kwargs( + _DevOpsAdapterKwargsInput( + adapter_type, + repo_owner, + repo_name, + api_token, + use_gh_cli, + ado_org, + ado_project, + ado_base_url, + ado_work_item_type, ) + ) + adapter = AdapterRegistry.get_adapter(adapter_type, **adapter_kwargs) - # Determine if sanitization is needed (to determine if this is a public repo) - from specfact_cli.utils.content_sanitizer import ContentSanitizer + change_proposals = self._export_change_proposals_load_list(include_archived, warnings) + if change_proposals is None: + return SyncResult(success=True, operations=operations, errors=errors, warnings=warnings) sanitizer = ContentSanitizer() planning_repo = self._bridge_sync_effective_planning_repo() - should_sanitize = sanitizer.detect_sanitization_need( code_repo=self.repo_path, planning_repo=planning_repo, user_preference=sanitize, ) - # Derive target_repo from repo_owner/repo_name or ado_org/ado_project if not provided - if not target_repo: + derived_target_repo = target_repo + if not derived_target_repo: if adapter_type == "ado" and ado_org and ado_project: - target_repo = f"{ado_org}/{ado_project}" + derived_target_repo = f"{ado_org}/{ado_project}" elif repo_owner and repo_name: - target_repo = f"{repo_owner}/{repo_name}" + derived_target_repo = f"{repo_owner}/{repo_name}" active_proposals, filtered_count = self._bridge_sync_filter_devops_proposals( - change_proposals, should_sanitize, target_repo + change_proposals, should_sanitize, derived_target_repo + ) + self._export_change_proposals_append_filter_warnings( + filtered_count, should_sanitize, len(active_proposals), warnings ) - - if filtered_count > 0: - if should_sanitize: - warnings.append( - f"Filtered out {filtered_count} proposal(s) with non-applied status " - f"(public repos only sync archived/completed proposals, regardless of source tracking). " - f"Only {len(active_proposals)} applied proposal(s) will be synced." - ) - else: - warnings.append( - f"Filtered out {filtered_count} proposal(s) without source tracking entry for target repo " - f"and inactive status. Only {len(active_proposals)} proposal(s) will be synced." - ) - active_proposals = self._bridge_sync_apply_change_id_filter(active_proposals, change_ids, errors) - self._bridge_sync_export_each_change_proposal( - active_proposals, - adapter, - adapter_type, - target_repo, - repo_owner, - repo_name, - ado_org, - ado_project, - update_existing, - import_from_tmp, - export_to_tmp, - tmp_file, - should_sanitize, - sanitizer, - track_code_changes, - add_progress_comment, - code_repo_path, - operations, - errors, - warnings, + loop_ctx = _ChangeProposalExportLoopContext( + adapter=adapter, + adapter_type=adapter_type, + target_repo=derived_target_repo, + repo_owner=repo_owner, + repo_name=repo_name, + ado_org=ado_org, + ado_project=ado_project, + update_existing=update_existing, + import_from_tmp=import_from_tmp, + export_to_tmp=export_to_tmp, + tmp_file=tmp_file, + should_sanitize=should_sanitize, + sanitizer=sanitizer, + track_code_changes=track_code_changes, + add_progress_comment=add_progress_comment, + code_repo_path=code_repo_path, + operations=operations, + errors=errors, + warnings=warnings, ) + self._bridge_sync_export_each_change_proposal(active_proposals, loop_ctx) except Exception as e: errors.append(f"Export to DevOps failed: {e}") @@ -1931,21 +2036,19 @@ def _bridge_sync_build_bundle_proposal_dict( proposal_dict["raw_body"] = raw_body return proposal_dict - def _bridge_sync_run_bundle_adapter_export( - self, - proposal: Any, - proposal_dict: dict[str, Any], - target_entry: dict[str, Any] | None, - adapter: Any, - adapter_type: str, - bridge_config: Any, - bundle_name: str, - target_repo: str | None, - update_existing: bool, - entries: list[dict[str, Any]], - operations: list[SyncOperation], - errors: list[str], - ) -> None: + def _bridge_sync_run_bundle_adapter_export(self, export_bundle: _BundleAdapterExportInput) -> None: + proposal = export_bundle.proposal + proposal_dict = export_bundle.proposal_dict + target_entry = export_bundle.target_entry + adapter = export_bundle.adapter + adapter_type = export_bundle.adapter_type + bridge_config = export_bundle.bridge_config + bundle_name = export_bundle.bundle_name + target_repo = export_bundle.target_repo + update_existing = export_bundle.update_existing + entries = export_bundle.entries + operations = export_bundle.operations + errors = export_bundle.errors try: export_result: dict[str, Any] | Any = {} if target_entry and target_entry.get("source_id"): @@ -1998,21 +2101,19 @@ def _bridge_sync_run_bundle_adapter_export( except Exception as e: errors.append(f"Failed to export '{proposal.name}' to {adapter_type}: {e}") - def _bridge_sync_export_one_bundle_proposal( - self, - proposal: Any, - adapter: Any, - adapter_type: str, - bridge_config: Any, - bundle_name: str, - target_repo: str | None, - update_existing: bool, - operations: list[SyncOperation], - errors: list[str], - ) -> None: + def _bridge_sync_export_one_bundle_proposal(self, bundle_export: _BundleSingleExportInput) -> None: """Export a single ChangeProposal from a bundle to the backlog adapter.""" from specfact_cli.models.source_tracking import SourceTracking + proposal = bundle_export.proposal + adapter = bundle_export.adapter + adapter_type = bundle_export.adapter_type + bridge_config = bundle_export.bridge_config + bundle_name = bundle_export.bundle_name + target_repo = bundle_export.target_repo + update_existing = bundle_export.update_existing + operations = bundle_export.operations + errors = bundle_export.errors if proposal.source_tracking is None: proposal.source_tracking = SourceTracking(tool=adapter_type, source_metadata={}) @@ -2023,18 +2124,20 @@ def _bridge_sync_export_one_bundle_proposal( target_entry = self._bridge_sync_resolve_bundle_target_entry(entries, adapter_type, target_repo) proposal_dict = self._bridge_sync_build_bundle_proposal_dict(proposal, adapter_type, entries) self._bridge_sync_run_bundle_adapter_export( - proposal, - proposal_dict, - target_entry, - adapter, - adapter_type, - bridge_config, - bundle_name, - target_repo, - update_existing, - entries, - operations, - errors, + _BundleAdapterExportInput( + proposal, + proposal_dict, + target_entry, + adapter, + adapter_type, + bridge_config, + bundle_name, + target_repo, + update_existing, + entries, + operations, + errors, + ) ) @beartype @@ -2088,15 +2191,17 @@ def export_backlog_from_bundle( if change_ids and proposal.name not in change_ids: continue self._bridge_sync_export_one_bundle_proposal( - proposal, - adapter, - adapter_type, - bridge_config, - bundle_name, - target_repo, - update_existing, - operations, - errors, + _BundleSingleExportInput( + proposal, + adapter, + adapter_type, + bridge_config, + bundle_name, + target_repo, + update_existing, + operations, + errors, + ) ) if operations: @@ -2266,31 +2371,17 @@ def _dedupe_duplicate_sections(self, text: str) -> str: def _verify_work_item_exists( self, - issue_number: str | int | None, - target_entry: dict[str, Any] | None, - adapter_type: str, - adapter: Any, - ado_org: str | None, - ado_project: str | None, + verify: _WorkItemVerifyInput, proposal: dict[str, Any], warnings: list[str], ) -> tuple[str | int | None, bool]: - """ - Verify if work item/issue exists in external tool (handles deleted items). - - Args: - issue_number: Current issue/work item number - target_entry: Source tracking entry - adapter_type: Adapter type (github, ado, etc.) - adapter: Adapter instance - ado_org: ADO organization (for ADO adapter) - ado_project: ADO project (for ADO adapter) - proposal: Change proposal dict - warnings: Warnings list to append to - - Returns: - Tuple of (issue_number, work_item_was_deleted) - """ + """Verify if work item/issue exists in external tool (handles deleted items).""" + issue_number = verify.issue_number + target_entry = verify.target_entry + adapter_type = verify.adapter_type + adapter = verify.adapter + ado_org = verify.ado_org + ado_project = verify.ado_project work_item_was_deleted = False if issue_number and target_entry: @@ -2389,55 +2480,31 @@ def _search_existing_github_issue( return None, None - def _update_existing_issue( - self, - proposal: dict[str, Any], - target_entry: dict[str, Any], - issue_number: str | int, - adapter: Any, - adapter_type: str, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - source_tracking_raw: dict[str, Any] | list[dict[str, Any]], - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - update_existing: bool, - import_from_tmp: bool, - tmp_file: Path | None, - should_sanitize: bool | None, - track_code_changes: bool, - add_progress_comment: bool, - code_repo_path: Path | None, - operations: list[Any], - errors: list[str], - warnings: list[str], - ) -> None: - """ - Update existing issue/work item with new status, metadata, and content. - - Args: - proposal: Change proposal dict - target_entry: Source tracking entry for this repository - issue_number: Issue/work item number - adapter: Adapter instance - adapter_type: Adapter type (github, ado, etc.) - target_repo: Target repository identifier - source_tracking_list: Normalized source tracking list - source_tracking_raw: Original source tracking (dict or list) - repo_owner: Repository owner (for GitHub) - repo_name: Repository name (for GitHub) - ado_org: ADO organization (for ADO) - ado_project: ADO project (for ADO) - update_existing: Whether to update content when hash changes - import_from_tmp: Whether importing from temporary file - tmp_file: Temporary file path - should_sanitize: Whether to sanitize content - operations: Operations list to append to - errors: Errors list to append to - warnings: Warnings list to append to - """ + def _update_existing_issue(self, payload: _IssueUpdatePayload) -> None: + """Update existing issue/work item with new status, metadata, and content.""" + proposal = payload.proposal + target_entry = payload.target_entry + issue_number = payload.issue_number + adapter = payload.adapter + adapter_type = payload.adapter_type + target_repo = payload.target_repo + source_tracking_list = payload.source_tracking_list + source_tracking_raw = payload.source_tracking_raw + repo_owner = payload.repo_owner + repo_name = payload.repo_name + ado_org = payload.ado_org + ado_project = payload.ado_project + update_existing = payload.update_existing + import_from_tmp = payload.import_from_tmp + tmp_file = payload.tmp_file + should_sanitize = payload.should_sanitize + track_code_changes = payload.track_code_changes + add_progress_comment = payload.add_progress_comment + code_repo_path = payload.code_repo_path + operations = payload.operations + errors = payload.errors + warnings = payload.warnings + assert target_entry is not None and issue_number is not None # Issue exists - check if status changed or metadata needs update source_metadata = self._source_metadata_dict(target_entry) last_synced_status = source_metadata.get("last_synced_status") @@ -2488,38 +2555,42 @@ def _update_existing_issue( # Check if content changed (when update_existing is enabled) if update_existing: self._update_issue_content_if_needed( - proposal, - target_entry, - issue_number, - adapter, - adapter_type, - target_repo, - source_tracking_list, - repo_owner, - repo_name, - ado_org, - ado_project, - import_from_tmp, - tmp_file, - operations, - errors, + _IssueContentUpdateInput( + proposal, + target_entry, + issue_number, + adapter, + adapter_type, + target_repo, + source_tracking_list, + repo_owner, + repo_name, + ado_org, + ado_project, + import_from_tmp, + tmp_file, + operations, + errors, + ) ) # Code change tracking and progress comments (when enabled) if track_code_changes or add_progress_comment: self._handle_code_change_tracking( - proposal, - target_entry, - target_repo, - source_tracking_list, - adapter, - track_code_changes, - add_progress_comment, - code_repo_path, - should_sanitize, - operations, - errors, - warnings, + _CodeChangeTrackingInput( + proposal, + target_entry, + target_repo, + source_tracking_list, + adapter, + track_code_changes, + add_progress_comment, + code_repo_path, + should_sanitize, + operations, + errors, + warnings, + ) ) def _proposal_update_hash(self, proposal: dict[str, Any], import_from_tmp: bool, tmp_file: Path | None) -> str: @@ -2553,20 +2624,18 @@ def _proposal_update_payload( sanitized_content = sanitized_file.read_text(encoding="utf-8") return {**proposal, "description": sanitized_content, "rationale": ""} - def _fetch_issue_sync_state( - self, - adapter_type: str, - issue_num: str | int, - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - proposal_title: str, - proposal_status: str, - ) -> tuple[bool, bool, bool]: + def _fetch_issue_sync_state(self, fetch: _FetchIssueSyncStateInput) -> tuple[bool, bool, bool]: """Return title/state update flags and whether an applied comment is needed.""" from specfact_cli.adapters.registry import AdapterRegistry + adapter_type = fetch.adapter_type + issue_num = fetch.issue_num + repo_owner = fetch.repo_owner + repo_name = fetch.repo_name + ado_org = fetch.ado_org + ado_project = fetch.ado_project + proposal_title = fetch.proposal_title + proposal_status = fetch.proposal_status adapter_instance = AdapterRegistry.get_adapter(adapter_type) adapter_inst_any = cast(Any, adapter_instance) if not adapter_instance or not hasattr(adapter_instance, "api_token"): @@ -2723,23 +2792,21 @@ def _update_issue_content_hash( source_tracking_list, target_repo, updated_entry ) - def _push_issue_body_update_to_adapter( - self, - proposal: dict[str, Any], - target_entry: dict[str, Any], - adapter: Any, - import_from_tmp: bool, - tmp_file: Path | None, - repo_owner: str | None, - repo_name: str | None, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - current_hash: str, - content_or_meta_changed: bool, - needs_comment_for_applied: bool, - operations: list[Any], - errors: list[str], - ) -> None: + def _push_issue_body_update_to_adapter(self, push: _PushIssueBodyInput) -> None: + proposal = push.proposal + target_entry = push.target_entry + adapter = push.adapter + import_from_tmp = push.import_from_tmp + tmp_file = push.tmp_file + repo_owner = push.repo_owner + repo_name = push.repo_name + target_repo = push.target_repo + source_tracking_list = push.source_tracking_list + current_hash = push.current_hash + content_or_meta_changed = push.content_or_meta_changed + needs_comment_for_applied = push.needs_comment_for_applied + operations = push.operations + errors = push.errors try: proposal_for_update = self._proposal_update_payload(proposal, import_from_tmp, tmp_file) code_repo_path = self._find_code_repo_path(repo_owner, repo_name) if repo_owner and repo_name else None @@ -2768,44 +2835,22 @@ def _push_issue_body_update_to_adapter( except Exception as e: errors.append(f"Failed to update issue body for {proposal.get('change_id', 'unknown')}: {e}") - def _update_issue_content_if_needed( - self, - proposal: dict[str, Any], - target_entry: dict[str, Any], - issue_number: str | int, - adapter: Any, - adapter_type: str, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - repo_owner: str | None, - repo_name: str | None, - ado_org: str | None, - ado_project: str | None, - import_from_tmp: bool, - tmp_file: Path | None, - operations: list[Any], - errors: list[str], - ) -> None: - """ - Update issue/work item content if hash changed or title needs update. - - Args: - proposal: Change proposal dict - target_entry: Source tracking entry - issue_number: Issue/work item number - adapter: Adapter instance - adapter_type: Adapter type - target_repo: Target repository identifier - source_tracking_list: Source tracking list - repo_owner: Repository owner (for GitHub) - repo_name: Repository name (for GitHub) - ado_org: ADO organization (for ADO) - ado_project: ADO project (for ADO) - import_from_tmp: Whether importing from temporary file - tmp_file: Temporary file path - operations: Operations list to append to - errors: Errors list to append to - """ + def _update_issue_content_if_needed(self, refresh: _IssueContentUpdateInput) -> None: + """Update issue/work item content if hash changed or title needs update.""" + proposal = refresh.proposal + target_entry = refresh.target_entry + adapter = refresh.adapter + adapter_type = refresh.adapter_type + target_repo = refresh.target_repo + source_tracking_list = refresh.source_tracking_list + repo_owner = refresh.repo_owner + repo_name = refresh.repo_name + ado_org = refresh.ado_org + ado_project = refresh.ado_project + import_from_tmp = refresh.import_from_tmp + tmp_file = refresh.tmp_file + operations = refresh.operations + errors = refresh.errors current_hash = self._proposal_update_hash(proposal, import_from_tmp, tmp_file) # Get stored hash from target repository entry @@ -2821,33 +2866,37 @@ def _update_issue_content_if_needed( if issue_num: with contextlib.suppress(Exception): needs_title_update, needs_state_update, needs_comment_for_applied = self._fetch_issue_sync_state( - adapter_type, - issue_num, - repo_owner, - repo_name, - ado_org, - ado_project, - str(proposal.get("title", "")), - str(proposal.get("status", "proposed")), + _FetchIssueSyncStateInput( + adapter_type, + issue_num, + repo_owner, + repo_name, + ado_org, + ado_project, + str(proposal.get("title", "")), + str(proposal.get("status", "proposed")), + ) ) content_or_meta_changed = stored_hash != current_hash or needs_title_update or needs_state_update if content_or_meta_changed or needs_comment_for_applied: self._push_issue_body_update_to_adapter( - proposal, - target_entry, - adapter, - import_from_tmp, - tmp_file, - repo_owner, - repo_name, - target_repo, - source_tracking_list, - current_hash, - content_or_meta_changed, - needs_comment_for_applied, - operations, - errors, + _PushIssueBodyInput( + proposal, + target_entry, + adapter, + import_from_tmp, + tmp_file, + repo_owner, + repo_name, + target_repo, + source_tracking_list, + current_hash, + content_or_meta_changed, + needs_comment_for_applied, + operations, + errors, + ) ) def _bridge_sync_list_progress_comment_dicts(self, target_entry: dict[str, Any] | None) -> list[dict[str, Any]]: @@ -2901,22 +2950,20 @@ def _bridge_sync_resolve_progress_data( } return None - def _bridge_sync_emit_code_change_progress( - self, - proposal: dict[str, Any], - change_id: str, - target_entry: dict[str, Any] | None, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - progress_data: dict[str, Any], - adapter: Any, - should_sanitize: bool | None, - operations: list[Any], - errors: list[str], - warnings: list[str], - ) -> None: + def _bridge_sync_emit_code_change_progress(self, emit: _EmitCodeChangeProgressInput) -> None: from specfact_cli.utils.code_change_detector import calculate_comment_hash, format_progress_comment + proposal = emit.proposal + change_id = emit.change_id + target_entry = emit.target_entry + target_repo = emit.target_repo + source_tracking_list = emit.source_tracking_list + progress_data = emit.progress_data + adapter = emit.adapter + should_sanitize = emit.should_sanitize + operations = emit.operations + errors = emit.errors + warnings = emit.warnings sanitize_flag = should_sanitize if should_sanitize is not None else False comment_text = format_progress_comment(progress_data, sanitize=sanitize_flag) comment_hash = calculate_comment_hash(comment_text) @@ -2973,22 +3020,20 @@ def _bridge_sync_emit_code_change_progress( except Exception as e: errors.append(f"Failed to add progress comment for {change_id}: {e}") - def _handle_code_change_tracking( - self, - proposal: dict[str, Any], - target_entry: dict[str, Any] | None, - target_repo: str | None, - source_tracking_list: list[dict[str, Any]], - adapter: Any, - track_code_changes: bool, - add_progress_comment: bool, - code_repo_path: Path | None, - should_sanitize: bool | None, - operations: list[Any], - errors: list[str], - warnings: list[str], - ) -> None: + def _handle_code_change_tracking(self, tracking: _CodeChangeTrackingInput) -> None: """Handle code change tracking and add progress comments if enabled.""" + proposal = tracking.proposal + target_entry = tracking.target_entry + target_repo = tracking.target_repo + source_tracking_list = tracking.source_tracking_list + adapter = tracking.adapter + track_code_changes = tracking.track_code_changes + add_progress_comment = tracking.add_progress_comment + code_repo_path = tracking.code_repo_path + should_sanitize = tracking.should_sanitize + operations = tracking.operations + errors = tracking.errors + warnings = tracking.warnings change_id = proposal.get("change_id", "unknown") progress_data = self._bridge_sync_resolve_progress_data( track_code_changes=track_code_changes, @@ -3001,17 +3046,19 @@ def _handle_code_change_tracking( if not progress_data: return self._bridge_sync_emit_code_change_progress( - proposal, - change_id, - target_entry, - target_repo, - source_tracking_list, - progress_data, - adapter, - should_sanitize, - operations, - errors, - warnings, + _EmitCodeChangeProgressInput( + proposal, + change_id, + target_entry, + target_repo, + source_tracking_list, + progress_data, + adapter, + should_sanitize, + operations, + errors, + warnings, + ) ) def _update_source_tracking_entry( @@ -3705,6 +3752,20 @@ def sync_bidirectional(self, bundle_name: str, feature_ids: list[str] | None = N warnings=warnings, ) + def _append_specification_feature_ids(self, artifact: Any, feature_ids: list[str]) -> None: + pattern_parts = str(artifact.path_pattern).split("/") + if not pattern_parts: + return + base_dir = self.repo_path / pattern_parts[0] + if not base_dir.exists(): + return + for item in base_dir.iterdir(): + if not item.is_dir(): + continue + test_path = self.resolve_artifact_path("specification", item.name, "test") + if test_path.exists() or (item / "spec.md").exists(): + feature_ids.append(item.name) + @beartype @require(_bridge_config_set, "Bridge config must be set") @ensure(lambda result: isinstance(result, list), "Must return list") @@ -3720,20 +3781,10 @@ def _discover_feature_ids(self) -> list[str]: if self.bridge_config is None: return feature_ids - # Try to discover from first artifact pattern if "specification" in self.bridge_config.artifacts: - artifact = self.bridge_config.artifacts["specification"] - # Extract base directory from pattern (e.g., "specs/{feature_id}/spec.md" -> "specs") - pattern_parts = artifact.path_pattern.split("/") - if len(pattern_parts) > 0: - base_dir = self.repo_path / pattern_parts[0] - if base_dir.exists(): - # Find all subdirectories (potential feature IDs) - for item in base_dir.iterdir(): - if item.is_dir(): - # Check if it contains the expected artifact file - test_path = self.resolve_artifact_path("specification", item.name, "test") - if test_path.exists() or (item / "spec.md").exists(): - feature_ids.append(item.name) + self._append_specification_feature_ids( + self.bridge_config.artifacts["specification"], + feature_ids, + ) return feature_ids diff --git a/src/specfact_cli/validators/contract_validator.py b/src/specfact_cli/validators/contract_validator.py index 276e9756..9fdb254f 100644 --- a/src/specfact_cli/validators/contract_validator.py +++ b/src/specfact_cli/validators/contract_validator.py @@ -6,6 +6,8 @@ from __future__ import annotations +from dataclasses import dataclass + from beartype import beartype from icontract import ensure, require @@ -14,46 +16,20 @@ from specfact_cli.models.sdd import SDDManifest +@dataclass class ContractDensityMetrics: """Contract density metrics for a plan bundle.""" - def __init__( - self, - contracts_per_story: float, - invariants_per_feature: float, - architecture_facets: int, - total_contracts: int, - total_invariants: int, - total_stories: int, - total_features: int, - openapi_coverage_percent: float = 0.0, - features_with_openapi: int = 0, - total_openapi_contracts: int = 0, - ) -> None: - """Initialize contract density metrics. - - Args: - contracts_per_story: Average contracts per story - invariants_per_feature: Average invariants per feature - architecture_facets: Number of architecture facets - total_contracts: Total number of contracts - total_invariants: Total number of invariants - total_stories: Total number of stories - total_features: Total number of features - openapi_coverage_percent: Percentage of features with OpenAPI contracts - features_with_openapi: Number of features with OpenAPI contracts - total_openapi_contracts: Total number of OpenAPI contracts - """ - self.contracts_per_story = contracts_per_story - self.invariants_per_feature = invariants_per_feature - self.architecture_facets = architecture_facets - self.total_contracts = total_contracts - self.total_invariants = total_invariants - self.total_stories = total_stories - self.total_features = total_features - self.openapi_coverage_percent = openapi_coverage_percent - self.features_with_openapi = features_with_openapi - self.total_openapi_contracts = total_openapi_contracts + contracts_per_story: float + invariants_per_feature: float + architecture_facets: int + total_contracts: int + total_invariants: int + total_stories: int + total_features: int + openapi_coverage_percent: float = 0.0 + features_with_openapi: int = 0 + total_openapi_contracts: int = 0 @ensure(lambda result: isinstance(result, dict), "Must return dict") def to_dict(self) -> dict[str, float | int]: diff --git a/src/specfact_cli/validators/sidecar/crosshair_runner.py b/src/specfact_cli/validators/sidecar/crosshair_runner.py index eeaa0dcb..42e27d73 100644 --- a/src/specfact_cli/validators/sidecar/crosshair_runner.py +++ b/src/specfact_cli/validators/sidecar/crosshair_runner.py @@ -8,6 +8,7 @@ import os import subprocess +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -17,41 +18,48 @@ from specfact_cli.utils.env_manager import build_tool_command, detect_env_manager +@dataclass +class CrosshairRunOptions: + """Options for :func:`run_crosshair`.""" + + timeout: int = 60 + pythonpath: str | None = None + verbose: bool = False + repo_path: Path | None = None + inputs_path: Path | None = None + per_path_timeout: int | None = None + per_condition_timeout: int | None = None + python_cmd: str | None = None + + @beartype @require( lambda source_path: isinstance(source_path, Path) and source_path.exists(), "Source path must exist", ) -@require(lambda timeout: timeout > 0, "Timeout must be positive") +@require( + lambda source_path, options: (options if options is not None else CrosshairRunOptions()).timeout > 0, + "Timeout must be positive", +) @ensure(lambda result: isinstance(result, dict), "Must return dict") def run_crosshair( source_path: Path, - timeout: int = 60, - pythonpath: str | None = None, - verbose: bool = False, - repo_path: Path | None = None, - inputs_path: Path | None = None, - per_path_timeout: int | None = None, - per_condition_timeout: int | None = None, - python_cmd: str | None = None, + options: CrosshairRunOptions | None = None, ) -> dict[str, Any]: """ Run CrossHair on source code or harness. - Args: - source_path: Path to source file or module - timeout: Timeout in seconds - pythonpath: PYTHONPATH for execution - verbose: Enable verbose output - repo_path: Optional repository path for environment manager detection - inputs_path: Optional path to deterministic inputs JSON file - per_path_timeout: Optional timeout per execution path - per_condition_timeout: Optional timeout per condition - python_cmd: Optional Python command to use (e.g., venv Python path) - Returns: Dictionary with execution results """ + opts = options or CrosshairRunOptions() + timeout = opts.timeout + pythonpath = opts.pythonpath + verbose = opts.verbose + repo_path = opts.repo_path + per_path_timeout = opts.per_path_timeout + per_condition_timeout = opts.per_condition_timeout + python_cmd = opts.python_cmd # Preserve PATH and other environment variables, then override/add PYTHONPATH env = os.environ.copy() if pythonpath: diff --git a/src/specfact_cli/validators/sidecar/crosshair_summary.py b/src/specfact_cli/validators/sidecar/crosshair_summary.py index 59842d9f..e401f360 100644 --- a/src/specfact_cli/validators/sidecar/crosshair_summary.py +++ b/src/specfact_cli/validators/sidecar/crosshair_summary.py @@ -59,6 +59,22 @@ def _collect_counterexample_violations( return violation_details +def _append_rejected_line_violation( + line: str, + function_name_pattern: re.Pattern[str], + violation_details: list[dict[str, Any]], +) -> None: + if any(v["function"] in line for v in violation_details): + return + match = function_name_pattern.match(line) + if not match: + return + func_name = match.group(1).strip() + if "/" in func_name or func_name.startswith("/"): + return + violation_details.append({"function": func_name, "counterexample": {}, "raw": line.strip()}) + + def _count_lines_by_status( lines: list[str], confirmed_pattern: re.Pattern[str], @@ -73,15 +89,12 @@ def _count_lines_by_status( for line in lines: if confirmed_pattern.search(line): confirmed += 1 - elif rejected_pattern.search(line): + continue + if rejected_pattern.search(line): violations += 1 - if not any(v["function"] in line for v in violation_details): - match = function_name_pattern.match(line) - if match: - func_name = match.group(1).strip() - if "/" not in func_name and not func_name.startswith("/"): - violation_details.append({"function": func_name, "counterexample": {}, "raw": line.strip()}) - elif unknown_pattern.search(line): + _append_rejected_line_violation(line, function_name_pattern, violation_details) + continue + if unknown_pattern.search(line): not_confirmed += 1 return confirmed, not_confirmed, violations diff --git a/src/specfact_cli/validators/sidecar/orchestrator.py b/src/specfact_cli/validators/sidecar/orchestrator.py index 34db91c4..f94bdee6 100644 --- a/src/specfact_cli/validators/sidecar/orchestrator.py +++ b/src/specfact_cli/validators/sidecar/orchestrator.py @@ -20,7 +20,7 @@ from specfact_cli.utils.env_manager import detect_env_manager from specfact_cli.utils.terminal import get_progress_config from specfact_cli.validators.sidecar.contract_populator import populate_contracts -from specfact_cli.validators.sidecar.crosshair_runner import run_crosshair +from specfact_cli.validators.sidecar.crosshair_runner import CrosshairRunOptions, run_crosshair from specfact_cli.validators.sidecar.crosshair_summary import ( generate_summary_file, parse_crosshair_output, @@ -113,14 +113,16 @@ def _run_crosshair_phase(config: SidecarConfig, results: dict[str, Any]) -> None return crosshair_result = run_crosshair( config.paths.harness_path, - timeout=config.timeouts.crosshair, - pythonpath=config.pythonpath, - verbose=config.crosshair.verbose, - repo_path=config.repo_path, - inputs_path=config.paths.inputs_path if config.crosshair.use_deterministic_inputs else None, - per_path_timeout=config.timeouts.crosshair_per_path, - per_condition_timeout=config.timeouts.crosshair_per_condition, - python_cmd=config.python_cmd, + CrosshairRunOptions( + timeout=config.timeouts.crosshair, + pythonpath=config.pythonpath, + verbose=config.crosshair.verbose, + repo_path=config.repo_path, + inputs_path=config.paths.inputs_path if config.crosshair.use_deterministic_inputs else None, + per_path_timeout=config.timeouts.crosshair_per_path, + per_condition_timeout=config.timeouts.crosshair_per_condition, + python_cmd=config.python_cmd, + ), ) results["crosshair_results"]["harness"] = crosshair_result if crosshair_result.get("stdout") or crosshair_result.get("stderr"): diff --git a/tests/integration/sync/test_multi_adapter_backlog_sync.py b/tests/integration/sync/test_multi_adapter_backlog_sync.py index 5988262b..aa0c47d6 100644 --- a/tests/integration/sync/test_multi_adapter_backlog_sync.py +++ b/tests/integration/sync/test_multi_adapter_backlog_sync.py @@ -15,7 +15,7 @@ from specfact_cli.adapters.ado import AdoAdapter from specfact_cli.models.bridge import BridgeConfig from specfact_cli.models.change import ChangeTracking -from specfact_cli.sync.bridge_sync import BridgeSync +from specfact_cli.sync.bridge_sync import BridgeSync, ExportChangeProposalsOptions def _normalize_body(body: str) -> list[str]: @@ -86,11 +86,13 @@ def test_github_to_ado_round_trip_preserves_content( mock_gh_post.return_value = gh_post_response gh_result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-org", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, + "github", + ExportChangeProposalsOptions( + repo_owner="test-org", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + ), ) assert gh_result.success is True @@ -116,11 +118,13 @@ def test_github_to_ado_round_trip_preserves_content( mock_ado_patch.return_value = ado_patch_response ado_result = sync.export_change_proposals_to_devops( - adapter_type="ado", - api_token="ado-token", - ado_org="test-org", - ado_project="test-project", - ado_work_item_type="User Story", + "ado", + ExportChangeProposalsOptions( + api_token="ado-token", + ado_org="test-org", + ado_project="test-project", + ado_work_item_type="User Story", + ), ) assert ado_result.success is True @@ -226,12 +230,14 @@ def test_github_to_ado_round_trip_preserves_content( mock_gh_patch.return_value = gh_patch_response gh_update_result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-org", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, - update_existing=True, + "github", + ExportChangeProposalsOptions( + repo_owner="test-org", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + update_existing=True, + ), ) assert gh_update_result.success is True diff --git a/tests/integration/test_devops_github_sync.py b/tests/integration/test_devops_github_sync.py index a373484f..c9f809dc 100644 --- a/tests/integration/test_devops_github_sync.py +++ b/tests/integration/test_devops_github_sync.py @@ -16,7 +16,7 @@ from beartype import beartype from specfact_cli.models.bridge import BridgeConfig -from specfact_cli.sync.bridge_sync import BridgeSync +from specfact_cli.sync.bridge_sync import BridgeSync, ExportChangeProposalsOptions @pytest.fixture @@ -70,11 +70,13 @@ def test_end_to_end_issue_creation( with patch.object(sync, "_read_openspec_change_proposals", return_value=mock_proposals): result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + ), ) # Verify result @@ -128,11 +130,13 @@ def test_end_to_end_status_update( with patch.object(sync, "_read_openspec_change_proposals", return_value=mock_proposals): result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + ), ) # Verify result @@ -179,20 +183,24 @@ def test_idempotency_multiple_syncs( ): # First sync result1 = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + ), ) # Second sync (same proposal, same status) result2 = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + ), ) # Should not create duplicate issues @@ -234,11 +242,13 @@ def mock_environ_get(key, default=None): patch.object(sync, "_read_openspec_change_proposals", return_value=mock_proposals), ): result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token=None, # No token - use_gh_cli=False, # Disable gh CLI to test missing token error + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token=None, # No token + use_gh_cli=False, # Disable gh CLI to test missing token error + ), ) # Should fail with error about missing token @@ -274,11 +284,13 @@ def test_error_handling_invalid_repo( patch("specfact_cli.adapters.AdapterRegistry.get_adapter", return_value=mock_adapter), ): result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="invalid-owner", - repo_name="invalid-repo", - api_token="test-token", - use_gh_cli=False, + "github", + ExportChangeProposalsOptions( + repo_owner="invalid-owner", + repo_name="invalid-repo", + api_token="test-token", + use_gh_cli=False, + ), ) # Should fail with error @@ -333,12 +345,14 @@ def test_sanitization_different_repos( bridge_config.external_base_path = str(planning_repo) sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, - sanitize=True, # Force sanitization + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + sanitize=True, # Force sanitization + ), ) # Verify sanitization was applied (competitive analysis should be removed) @@ -450,13 +464,15 @@ def test_end_to_end_code_change_detection_and_comment( mock_get.return_value = mock_get_response result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, - track_code_changes=True, - code_repo_path=code_repo, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + track_code_changes=True, + code_repo_path=code_repo, + ), ) # Verify result @@ -603,13 +619,15 @@ def test_code_change_tracking_with_mocked_github_issues( with patch.object(sync, "_read_openspec_change_proposals", return_value=mock_proposals): result = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, - track_code_changes=True, - code_repo_path=code_repo, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + track_code_changes=True, + code_repo_path=code_repo, + ), ) # Verify result @@ -703,13 +721,15 @@ def test_code_change_tracking_idempotency( with patch.object(sync, "_read_openspec_change_proposals", return_value=mock_proposals): # First sync - should add comment result1 = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, - track_code_changes=True, - code_repo_path=code_repo, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + track_code_changes=True, + code_repo_path=code_repo, + ), ) assert result1.success is True @@ -728,13 +748,15 @@ def test_code_change_tracking_idempotency( # Second sync - should NOT add duplicate comment result2 = sync.export_change_proposals_to_devops( - adapter_type="github", - repo_owner="test-owner", - repo_name="test-repo", - api_token="test-token", - use_gh_cli=False, - track_code_changes=True, - code_repo_path=code_repo, + "github", + ExportChangeProposalsOptions( + repo_owner="test-owner", + repo_name="test-repo", + api_token="test-token", + use_gh_cli=False, + track_code_changes=True, + code_repo_path=code_repo, + ), ) assert result2.success is True diff --git a/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner.py b/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner.py index cd87d64f..f5b11eb5 100644 --- a/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner.py +++ b/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner.py @@ -7,7 +7,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from specfact_cli.validators.sidecar.crosshair_runner import run_crosshair +from specfact_cli.validators.sidecar.crosshair_runner import CrosshairRunOptions, run_crosshair def test_run_crosshair_not_found(tmp_path: Path) -> None: @@ -16,7 +16,7 @@ def test_run_crosshair_not_found(tmp_path: Path) -> None: source_path.write_text("def test(): pass\n") with patch("subprocess.run", side_effect=FileNotFoundError()): - result = run_crosshair(source_path, timeout=10) + result = run_crosshair(source_path, CrosshairRunOptions(timeout=10)) assert result["success"] is False assert "not found" in result["stderr"] @@ -29,7 +29,7 @@ def test_run_crosshair_timeout(tmp_path: Path) -> None: from subprocess import TimeoutExpired with patch("subprocess.run", side_effect=TimeoutExpired(cmd=["crosshair"], timeout=10)): - result = run_crosshair(source_path, timeout=10) + result = run_crosshair(source_path, CrosshairRunOptions(timeout=10)) assert result["success"] is False assert "timed out" in result["stderr"] @@ -45,6 +45,6 @@ def test_run_crosshair_success(tmp_path: Path) -> None: mock_proc.stderr = "" with patch("subprocess.run", return_value=mock_proc): - result = run_crosshair(source_path, timeout=10) + result = run_crosshair(source_path, CrosshairRunOptions(timeout=10)) assert result["success"] is True assert result["returncode"] == 0 diff --git a/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner_env.py b/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner_env.py index 2a637fab..90fd22e7 100644 --- a/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner_env.py +++ b/tests/unit/specfact_cli/validators/sidecar/test_crosshair_runner_env.py @@ -8,7 +8,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch -from specfact_cli.validators.sidecar.crosshair_runner import run_crosshair +from specfact_cli.validators.sidecar.crosshair_runner import CrosshairRunOptions, run_crosshair class TestCrosshairRunnerEnvironment: @@ -25,7 +25,7 @@ def test_preserves_path_environment(self) -> None: with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="", text=True) - run_crosshair(test_file, timeout=10) + run_crosshair(test_file, CrosshairRunOptions(timeout=10)) # Verify subprocess.run was called assert mock_run.called @@ -51,7 +51,7 @@ def test_adds_pythonpath_when_provided(self) -> None: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="", text=True) custom_pythonpath = "/custom/path" - run_crosshair(test_file, timeout=10, pythonpath=custom_pythonpath) + run_crosshair(test_file, CrosshairRunOptions(timeout=10, pythonpath=custom_pythonpath)) # Get the env dict passed to subprocess.run call_kwargs = mock_run.call_args[1] @@ -79,7 +79,7 @@ def test_preserves_other_environment_variables(self) -> None: with patch("subprocess.run") as mock_run: mock_run.return_value = MagicMock(returncode=0, stdout="", stderr="", text=True) - run_crosshair(test_file, timeout=10) + run_crosshair(test_file, CrosshairRunOptions(timeout=10)) # Get the env dict passed to subprocess.run call_kwargs = mock_run.call_args[1] diff --git a/tools/contract_first_smart_test.py b/tools/contract_first_smart_test.py index 2a5a4bbd..b0e1ad40 100644 --- a/tools/contract_first_smart_test.py +++ b/tools/contract_first_smart_test.py @@ -27,6 +27,7 @@ import subprocess import sys from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any @@ -38,6 +39,31 @@ logger = logging.getLogger(__name__) +@dataclass +class _CrosshairApplyContext: + file_key: str + file_hash: str | None + result: subprocess.CompletedProcess[str] + timed_out: bool + use_fast: bool + prefer_fast: bool + display_path: str + exploration_cache: dict[str, Any] + exploration_results: dict[str, Any] + signature_skips: list[str] + + +@dataclass +class _ExplorationErrorContext: + file_key: str + file_hash: str | None + exc: Exception + use_fast: bool + prefer_fast: bool + exploration_cache: dict[str, Any] + exploration_results: dict[str, Any] + + class ContractFirstTestManager(SmartCoverageManager): """Contract-first test manager extending the smart coverage system.""" @@ -471,20 +497,18 @@ def _log_crosshair_process_success( mode_label = "fast" if use_fast else "standard" logger.info(" CrossHair exploration passed for %s (%s)", display_path, mode_label) - def _apply_crosshair_result( - self, - file_key: str, - file_hash: str | None, - result: subprocess.CompletedProcess[str], - timed_out: bool, - use_fast: bool, - prefer_fast: bool, - display_path: str, - exploration_cache: dict[str, Any], - exploration_results: dict[str, Any], - signature_skips: list[str], - ) -> bool: + def _apply_crosshair_result(self, ctx: _CrosshairApplyContext) -> bool: """Update caches from a CrossHair run. Returns False when the overall exploration should fail.""" + file_key = ctx.file_key + file_hash = ctx.file_hash + result = ctx.result + timed_out = ctx.timed_out + use_fast = ctx.use_fast + prefer_fast = ctx.prefer_fast + display_path = ctx.display_path + exploration_cache = ctx.exploration_cache + exploration_results = ctx.exploration_results + signature_skips = ctx.signature_skips signature_detail = self._extract_signature_limitation_detail(result.stderr, result.stdout) is_signature_issue = signature_detail is not None @@ -548,16 +572,14 @@ def _exploration_record_timeout( "stderr": "CrossHair exploration timed out", } - def _exploration_record_error( - self, - file_key: str, - file_hash: str | None, - exc: Exception, - use_fast: bool, - prefer_fast: bool, - exploration_cache: dict[str, Any], - exploration_results: dict[str, Any], - ) -> None: + def _exploration_record_error(self, err: _ExplorationErrorContext) -> None: + file_key = err.file_key + file_hash = err.file_hash + exc = err.exc + use_fast = err.use_fast + prefer_fast = err.prefer_fast + exploration_cache = err.exploration_cache + exploration_results = err.exploration_results exploration_results[file_key] = { "return_code": -1, "stdout": "", @@ -675,16 +697,18 @@ def _run_contract_exploration( result, timed_out, use_fast, prefer_fast = self._run_crosshair_subprocess(file_path, use_fast) if not self._apply_crosshair_result( - file_key, - file_hash, - result, - timed_out, - use_fast, - prefer_fast, - display_path, - exploration_cache, - exploration_results, - signature_skips, + _CrosshairApplyContext( + file_key, + file_hash, + result, + timed_out, + use_fast, + prefer_fast, + display_path, + exploration_cache, + exploration_results, + signature_skips, + ) ): success = False @@ -693,7 +717,9 @@ def _run_contract_exploration( success = False except Exception as e: self._exploration_record_error( - file_key, file_hash, e, use_fast, prefer_fast, exploration_cache, exploration_results + _ExplorationErrorContext( + file_key, file_hash, e, use_fast, prefer_fast, exploration_cache, exploration_results + ) ) success = False diff --git a/tools/smart_test_coverage.py b/tools/smart_test_coverage.py index 571dfba7..99a802c1 100755 --- a/tools/smart_test_coverage.py +++ b/tools/smart_test_coverage.py @@ -35,6 +35,7 @@ import subprocess import sys from collections.abc import Callable +from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import Any, TextIO, cast @@ -64,6 +65,31 @@ class CoverageThresholdError(Exception): """Raised when coverage falls below the required threshold.""" +@dataclass +class _CompletedTestRunLog: + success: bool + test_level: str + test_count: int + coverage_percentage: float | None + tested_coverage_percentage: float | None + test_log_file: Path + coverage_log_file: Path + return_code: int | None + + +@dataclass +class _SmartCacheUpdate: + success: bool + test_count: int + coverage_percentage: float | None + enforce_threshold: bool = True + update_only: bool = False + updated_sources: list[Path] | None = None + updated_tests: list[Path] | None = None + updated_configs: list[Path] | None = None + update_coverage_in_cache: bool = True + + class SmartCoverageManager: _HATCH_ENV_BROKEN_MARKERS = ( "Failed to inspect Python interpreter", @@ -875,46 +901,36 @@ def _adjust_success_for_coverage_threshold( logger.info("This is expected for unit/folder tests. Full test run will enforce the threshold.") return True - def _log_completed_test_run( - self, - success: bool, - test_level: str, - test_count: int, - coverage_percentage: float | None, - tested_coverage_percentage: float | None, - test_log_file: Path, - coverage_log_file: Path, - return_code: int | None, - ) -> None: + def _log_completed_test_run(self, run: _CompletedTestRunLog) -> None: """Emit summary log lines after a leveled test run.""" - if success: - if coverage_percentage is None or tested_coverage_percentage is None: + if run.success: + if run.coverage_percentage is None or run.tested_coverage_percentage is None: logger.info( "%s tests completed: %d tests; line coverage not measured for this level", - test_level.title(), - test_count, + run.test_level.title(), + run.test_count, ) - elif test_level in ("unit", "folder") and tested_coverage_percentage > 0: + elif run.test_level in ("unit", "folder") and run.tested_coverage_percentage > 0: logger.info( "%s tests completed: %d tests, %.1f%% overall, %.1f%% tested code coverage", - test_level.title(), - test_count, - coverage_percentage, - tested_coverage_percentage, + run.test_level.title(), + run.test_count, + run.coverage_percentage, + run.tested_coverage_percentage, ) else: logger.info( "%s tests completed: %d tests, %.1f%% coverage", - test_level.title(), - test_count, - coverage_percentage, + run.test_level.title(), + run.test_count, + run.coverage_percentage, ) - logger.info("Full %s test log: %s", test_level, test_log_file) - logger.info("%s coverage log: %s", test_level.title(), coverage_log_file) + logger.info("Full %s test log: %s", run.test_level, run.test_log_file) + logger.info("%s coverage log: %s", run.test_level.title(), run.coverage_log_file) else: - logger.error("%s tests failed with exit code %s", test_level.title(), return_code) - logger.info("Check %s test log for details: %s", test_level, test_log_file) - logger.info("Check %s coverage log for details: %s", test_level, coverage_log_file) + logger.error("%s tests failed with exit code %s", run.test_level.title(), run.return_code) + logger.info("Check %s test log for details: %s", run.test_level, run.test_log_file) + logger.info("Check %s coverage log for details: %s", run.test_level, run.coverage_log_file) def _log_tested_coverage_vs_threshold(self, test_level: str, tested_coverage_percentage: float | None) -> None: if tested_coverage_percentage is None: @@ -1297,14 +1313,16 @@ def _run_tests(self, test_files: list[Path], test_level: str) -> tuple[bool, int ) self._log_tested_coverage_vs_threshold(test_level, tested_coverage_percentage) self._log_completed_test_run( - success, - test_level, - test_count, - coverage_percentage, - tested_coverage_percentage, - test_log_file, - coverage_log_file, - return_code, + _CompletedTestRunLog( + success, + test_level, + test_count, + coverage_percentage, + tested_coverage_percentage, + test_log_file, + coverage_log_file, + return_code, + ) ) # Cleanup generated test files after test run @@ -1406,22 +1424,20 @@ def _refresh_all_tracked_hashes( if h: config_file_hashes[str(file_path.relative_to(self.project_root))] = h - def _update_cache( - self, - success: bool, - test_count: int, - coverage_percentage: float | None, - enforce_threshold: bool = True, - update_only: bool = False, - updated_sources: list[Path] | None = None, - updated_tests: list[Path] | None = None, - updated_configs: list[Path] | None = None, - update_coverage_in_cache: bool = True, - ) -> None: + def _update_cache(self, update: _SmartCacheUpdate) -> None: """Update cache and hashes. If update_only is True, only update hashes for provided file lists (when their tests passed). Otherwise, refresh all known hashes. """ + success = update.success + test_count = update.test_count + coverage_percentage = update.coverage_percentage + enforce_threshold = update.enforce_threshold + update_only = update.update_only + updated_sources = update.updated_sources + updated_tests = update.updated_tests + updated_configs = update.updated_configs + update_coverage_in_cache = update.update_coverage_in_cache self._maybe_warn_subthreshold_non_full(success, enforce_threshold, coverage_percentage) # Prepare existing maps @@ -1656,13 +1672,15 @@ def _run_unit_tests(self) -> bool: # Update cache hashes only for files covered by successful unit batch if success: self._update_cache( - True, - test_count, - coverage_percentage, - enforce_threshold=False, - update_only=True, - updated_sources=modified_files, - updated_tests=unit_tests, + _SmartCacheUpdate( + True, + test_count, + coverage_percentage, + enforce_threshold=False, + update_only=True, + updated_sources=modified_files, + updated_tests=unit_tests, + ) ) logger.info("Unit tests completed: %d tests, %.1f%% coverage", test_count, coverage_percentage) else: @@ -1718,13 +1736,15 @@ def _run_folder_tests(self) -> bool: # Update cache only for files in modified folders when tests passed if success: self._update_cache( - True, - test_count, - coverage_percentage, - enforce_threshold=False, - update_only=True, - updated_sources=folder_files, - updated_tests=folder_tests, + _SmartCacheUpdate( + True, + test_count, + coverage_percentage, + enforce_threshold=False, + update_only=True, + updated_sources=folder_files, + updated_tests=folder_tests, + ) ) logger.info("Folder tests completed: %d tests, %.1f%% coverage", test_count, coverage_percentage) else: @@ -1753,13 +1773,15 @@ def _run_integration_tests(self) -> bool: # Update cache for integration tests (test file hashes only) if success: self._update_cache( - True, - test_count, - coverage_percentage, - enforce_threshold=False, - update_only=True, - updated_tests=integration_tests, - update_coverage_in_cache=False, + _SmartCacheUpdate( + True, + test_count, + coverage_percentage, + enforce_threshold=False, + update_only=True, + updated_tests=integration_tests, + update_coverage_in_cache=False, + ) ) if coverage_percentage is None: logger.info( @@ -1797,13 +1819,15 @@ def _run_e2e_tests(self) -> bool: # Update cache for e2e tests (test file hashes only) if success: self._update_cache( - True, - test_count, - coverage_percentage, - enforce_threshold=False, - update_only=True, - updated_tests=e2e_tests, - update_coverage_in_cache=False, + _SmartCacheUpdate( + True, + test_count, + coverage_percentage, + enforce_threshold=False, + update_only=True, + updated_tests=e2e_tests, + update_coverage_in_cache=False, + ) ) if coverage_percentage is None: logger.info( @@ -1824,57 +1848,35 @@ def _run_full_tests(self) -> bool: # Only refresh hashes if the full suite succeeded; otherwise keep prior baseline. if success: # Do not fail on low line coverage locally; contract-first layers are primary gates. - self._update_cache(True, test_count, coverage_percentage, enforce_threshold=False) + self._update_cache(_SmartCacheUpdate(True, test_count, coverage_percentage, enforce_threshold=False)) return success - def _run_changed_only(self) -> tuple[bool, bool]: - """Run only tests impacted by changes since last cached hashes. - - Returns: - (success, ran_any): ``ran_any`` is False when no mapped tests ran (incremental no-op). - - When there is no ``last_full_run`` baseline and incremental work would run nothing, - runs a one-time full suite to establish coverage/hash baseline (avoids zero cached coverage). - """ - # Collect modified items - modified_sources = self._get_modified_files() - modified_tests = self._get_modified_test_files() - - if modified_sources and self.cache.get("last_full_run"): - unmapped = [s for s in modified_sources if not self._get_unit_tests_for_files([s])] - if unmapped: - logger.info("Modified source(s) have no unit-mapped tests; running full suite to verify baseline.") - return self._run_full_tests(), True - - # Map modified sources to unit tests - unit_from_sources = self._get_unit_tests_for_files(modified_sources) - # Split modified tests by level - unit_direct, integ_direct, e2e_direct = self._split_tests_by_level(modified_tests) - - # Merge and deduplicate - def dedupe(paths: list[Path]) -> list[Path]: - seen: set[str] = set() - out: list[Path] = [] - for p in paths: - key = str(p.resolve()) - if key not in seen: - seen.add(key) - out.append(p) - return out - - unit_tests = dedupe(unit_from_sources + unit_direct) - integ_tests = dedupe(integ_direct) - e2e_tests = dedupe(e2e_direct) - - ran_any = False - overall_success = True + @staticmethod + def _dedupe_path_list(paths: list[Path]) -> list[Path]: + seen: set[str] = set() + out: list[Path] = [] + for path_item in paths: + key = str(path_item.resolve()) + if key not in seen: + seen.add(key) + out.append(path_item) + return out + + def _run_changed_only_maybe_full_for_unmapped(self, modified_sources: list[Path]) -> tuple[bool, bool] | None: + if not modified_sources or not self.cache.get("last_full_run"): + return None + unmapped = [s for s in modified_sources if not self._get_unit_tests_for_files([s])] + if not unmapped: + return None + logger.info("Modified source(s) have no unit-mapped tests; running full suite to verify baseline.") + return self._run_full_tests(), True - if unit_tests: - ran_any = True - ok, unit_count, unit_cov = self._run_tests(unit_tests, "unit") - if ok: - proven_sources = self._modified_sources_proven_by_unit_batch(modified_sources, unit_tests) - self._update_cache( + def _run_changed_only_unit_batch(self, modified_sources: list[Path], unit_tests: list[Path]) -> tuple[bool, bool]: + ok, unit_count, unit_cov = self._run_tests(unit_tests, "unit") + if ok: + proven_sources = self._modified_sources_proven_by_unit_batch(modified_sources, unit_tests) + self._update_cache( + _SmartCacheUpdate( True, unit_count, unit_cov, @@ -1883,12 +1885,14 @@ def dedupe(paths: list[Path]) -> list[Path]: updated_sources=proven_sources, updated_tests=unit_tests, ) - overall_success = overall_success and ok - if integ_tests: - ran_any = True - ok, integ_count, integ_cov = self._run_tests(integ_tests, "integration") - if ok: - self._update_cache( + ) + return ok, True + + def _run_changed_only_integ_batch(self, integ_tests: list[Path]) -> tuple[bool, bool]: + ok, integ_count, integ_cov = self._run_tests(integ_tests, "integration") + if ok: + self._update_cache( + _SmartCacheUpdate( True, integ_count, integ_cov, @@ -1897,12 +1901,14 @@ def dedupe(paths: list[Path]) -> list[Path]: updated_tests=integ_tests, update_coverage_in_cache=False, ) - overall_success = overall_success and ok - if e2e_tests: - ran_any = True - ok, e2e_count, e2e_cov = self._run_tests(e2e_tests, "e2e") - if ok: - self._update_cache( + ) + return ok, True + + def _run_changed_only_e2e_batch(self, e2e_tests: list[Path]) -> tuple[bool, bool]: + ok, e2e_count, e2e_cov = self._run_tests(e2e_tests, "e2e") + if ok: + self._update_cache( + _SmartCacheUpdate( True, e2e_count, e2e_cov, @@ -1911,18 +1917,58 @@ def dedupe(paths: list[Path]) -> list[Path]: updated_tests=e2e_tests, update_coverage_in_cache=False, ) + ) + return ok, True + + def _run_changed_only_when_idle(self) -> tuple[bool, bool]: + if not self.cache.get("last_full_run"): + logger.info("No incremental baseline; running full test suite once to establish cache…") + return self._run_full_tests(), True + if self._has_config_changes(): + logger.info("Configuration changed but no mapped tests to run; running full suite…") + return self._run_full_tests(), True + logger.info("No changed files detected that map to tests - skipping test execution") + return True, False + + def _run_changed_only(self) -> tuple[bool, bool]: + """Run only tests impacted by changes since last cached hashes. + + Returns: + (success, ran_any): ``ran_any`` is False when no mapped tests ran (incremental no-op). + + When there is no ``last_full_run`` baseline and incremental work would run nothing, + runs a one-time full suite to establish coverage/hash baseline (avoids zero cached coverage). + """ + modified_sources = self._get_modified_files() + modified_tests = self._get_modified_test_files() + + early = self._run_changed_only_maybe_full_for_unmapped(modified_sources) + if early is not None: + return early + + unit_from_sources = self._get_unit_tests_for_files(modified_sources) + unit_direct, integ_direct, e2e_direct = self._split_tests_by_level(modified_tests) + unit_tests = self._dedupe_path_list(unit_from_sources + unit_direct) + integ_tests = self._dedupe_path_list(integ_direct) + e2e_tests = self._dedupe_path_list(e2e_direct) + + ran_any = False + overall_success = True + if unit_tests: + ok, _ = self._run_changed_only_unit_batch(modified_sources, unit_tests) + overall_success = overall_success and ok + ran_any = True + if integ_tests: + ok, _ = self._run_changed_only_integ_batch(integ_tests) overall_success = overall_success and ok + ran_any = True + if e2e_tests: + ok, _ = self._run_changed_only_e2e_batch(e2e_tests) + overall_success = overall_success and ok + ran_any = True if not ran_any: - if not self.cache.get("last_full_run"): - logger.info("No incremental baseline; running full test suite once to establish cache…") - success = self._run_full_tests() - return success, True - if self._has_config_changes(): - logger.info("Configuration changed but no mapped tests to run; running full suite…") - return self._run_full_tests(), True - logger.info("No changed files detected that map to tests - skipping test execution") - return True, False + return self._run_changed_only_when_idle() return overall_success, True @@ -1933,7 +1979,7 @@ def force_full_run(self, test_level: str = "full") -> bool: logger.info("Forcing %s test run...", test_level) if test_level == "full": success, test_count, coverage_percentage = self._run_coverage_tests() - self._update_cache(success, test_count, coverage_percentage, enforce_threshold=True) + self._update_cache(_SmartCacheUpdate(success, test_count, coverage_percentage, enforce_threshold=True)) elif test_level == "auto": success = self.run_smart_tests("auto", force=True) else: @@ -1998,7 +2044,7 @@ def _cli_index_baseline(self) -> int: logger.info("Indexing current project hashes as baseline (no tests run)...") cur_cov = self.cache.get("coverage_percentage", 0.0) cur_cnt = self.cache.get("test_count", 0) - self._update_cache(True, cur_cnt, cur_cov, enforce_threshold=False, update_only=False) + self._update_cache(_SmartCacheUpdate(True, cur_cnt, cur_cov, enforce_threshold=False, update_only=False)) logger.info("Baseline updated. Future smart runs will consider only new changes.") return 0 From 5d4e1c972a3475744806d5809819f311218a5f2b Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 23:35:29 +0200 Subject: [PATCH 10/12] Fix review findings and sign modules --- modules/bundle-mapper/module-package.yaml | 6 +- .../src/bundle_mapper/mapper/engine.py | 54 ++-- src/specfact_cli/adapters/ado.py | 265 ++++++++++-------- src/specfact_cli/adapters/github.py | 51 ++-- src/specfact_cli/adapters/openspec_parser.py | 25 +- src/specfact_cli/adapters/speckit.py | 72 ++--- .../analyzers/ambiguity_scanner.py | 41 ++- src/specfact_cli/analyzers/code_analyzer.py | 158 ++++++----- src/specfact_cli/analyzers/graph_analyzer.py | 20 +- .../generators/contract_generator.py | 48 ++-- .../generators/openapi_extractor.py | 111 ++++---- .../generators/persona_exporter.py | 45 +-- .../generators/test_to_openapi.py | 60 ++-- src/specfact_cli/models/project.py | 60 ++-- .../modules/init/module-package.yaml | 6 +- .../modules/init/src/first_run_selection.py | 9 +- .../module_registry/module-package.yaml | 6 +- .../modules/module_registry/src/commands.py | 21 +- src/specfact_cli/registry/module_installer.py | 102 ++++--- src/specfact_cli/sync/bridge_watch.py | 42 ++- src/specfact_cli/utils/source_scanner.py | 198 +++++-------- .../validators/cli_first_validator.py | 30 +- .../validators/sidecar/frameworks/fastapi.py | 37 +-- tests/e2e/test_bundle_extraction_e2e.py | 7 +- tests/unit/registry/test_module_installer.py | 44 +-- .../test_bundle_dependency_install.py | 12 +- 26 files changed, 793 insertions(+), 737 deletions(-) diff --git a/modules/bundle-mapper/module-package.yaml b/modules/bundle-mapper/module-package.yaml index f3335d15..643d9265 100644 --- a/modules/bundle-mapper/module-package.yaml +++ b/modules/bundle-mapper/module-package.yaml @@ -1,5 +1,5 @@ name: bundle-mapper -version: 0.1.7 +version: 0.1.8 commands: [] category: core pip_dependencies: [] @@ -20,8 +20,8 @@ publisher: url: https://github.com/nold-ai/specfact-cli-modules email: hello@noldai.com integrity: - checksum: sha256:6b078e7855d9acd3ce9abf0464cdab7f22753dd2ce4b5fc7af111ef72bc50f02 - signature: v6/kVxxR/CNNnXkS2TTgeEAKPFS5ErPRf/GbwM0U9H20txu9kwZb6r5rQP9Spu5EZ+IdTs4JJ9cInicPwmE1Bw== + checksum: sha256:b52ab14496e35b5d4e8da7bbbd8573eacc909bf50e365f9948d8bb0b8e174ae1 + signature: SXjIFMZSrXD7jQ4MHpD39tKyNJVHWF1P25swAJdDzaEkQd9A+llFSWvz1v2RmkanIej+XG7uZszokIBzwscbCQ== dependencies: [] description: Map backlog items to best-fit modules using scoring heuristics. license: Apache-2.0 diff --git a/modules/bundle-mapper/src/bundle_mapper/mapper/engine.py b/modules/bundle-mapper/src/bundle_mapper/mapper/engine.py index c17ecee9..77d788b3 100644 --- a/modules/bundle-mapper/src/bundle_mapper/mapper/engine.py +++ b/modules/bundle-mapper/src/bundle_mapper/mapper/engine.py @@ -5,6 +5,7 @@ from __future__ import annotations import re +from dataclasses import dataclass from pathlib import Path from typing import Any, cast @@ -30,6 +31,14 @@ HISTORY_CAP = 10.0 +@dataclass(frozen=True, slots=True) +class _SignalContribution: + bundle_id: str + score: float + weight: float + source: str + + def _tokenize(text: str) -> set[str]: """Lowercase, split by non-alphanumeric.""" return set(re.findall(r"[a-z0-9]+", text.lower())) @@ -154,19 +163,16 @@ def _apply_signal_contribution( primary_bundle_id: str | None, weighted: float, reasons: list[str], - bundle_id: str, - score: float, - weight: float, - source: str, + signal: _SignalContribution, ) -> tuple[str | None, float]: """Apply one signal contribution to the primary score.""" - if bundle_id and score > 0: - contrib = weight * score + if signal.bundle_id and signal.score > 0: + contrib = signal.weight * signal.score if primary_bundle_id is None: - primary_bundle_id = bundle_id + primary_bundle_id = signal.bundle_id weighted += contrib - reasons.append(self._explain_score(bundle_id, score, source)) - elif bundle_id == primary_bundle_id: + reasons.append(self._explain_score(signal.bundle_id, signal.score, signal.source)) + elif signal.bundle_id == primary_bundle_id: weighted += contrib return primary_bundle_id, weighted @@ -210,19 +216,23 @@ def compute_mapping(self, item: BacklogItem) -> BundleMapping: primary_bundle_id, weighted, reasons, - explicit_bundle or "", - explicit_score, - WEIGHT_EXPLICIT, - "explicit_label", + _SignalContribution( + bundle_id=explicit_bundle or "", + score=explicit_score, + weight=WEIGHT_EXPLICIT, + source="explicit_label", + ), ) primary_bundle_id, weighted = self._apply_signal_contribution( primary_bundle_id, weighted, reasons, - hist_bundle or "", - hist_score, - WEIGHT_HISTORICAL, - "historical", + _SignalContribution( + bundle_id=hist_bundle or "", + score=hist_score, + weight=WEIGHT_HISTORICAL, + source="historical", + ), ) if content_list: @@ -231,10 +241,12 @@ def compute_mapping(self, item: BacklogItem) -> BundleMapping: primary_bundle_id, weighted, reasons, - best_content_bundle, - best_content_score, - WEIGHT_CONTENT, - "content_similarity", + _SignalContribution( + bundle_id=best_content_bundle, + score=best_content_score, + weight=WEIGHT_CONTENT, + source="content_similarity", + ), ) confidence = min(1.0, weighted) diff --git a/src/specfact_cli/adapters/ado.py b/src/specfact_cli/adapters/ado.py index 81e6cacd..2df769ea 100644 --- a/src/specfact_cli/adapters/ado.py +++ b/src/specfact_cli/adapters/ado.py @@ -13,6 +13,7 @@ import os import re from collections.abc import Callable, Mapping +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any, NoReturn, Protocol, cast @@ -51,6 +52,16 @@ console = Console() +@dataclass(frozen=True, slots=True) +class _AdoCreatedWorkItemRef: + work_item_id: Any + work_item_url: str + org: str + project: str + work_item_type: str + ado_state: str + + class _AccessTokenLike(Protocol): """Typed subset of Azure access token fields used by refresh logic.""" @@ -638,64 +649,129 @@ def __init__( # Don't default team to project here - will be resolved in _get_current_iteration if needed self.team = team self.auth_scheme: str | None = None + self._configure_api_token(api_token) - # Token resolution: explicit token > env var > stored token + # Base URL defaults to Azure DevOps Services (cloud) + # Normalize base_url: remove trailing slashes + # Note: For Azure DevOps Services (cloud), base_url should be "https://dev.azure.com" + # For Azure DevOps Server (on-premise), base_url might be "https://server" or "https://server/collection" + raw_base_url = base_url or "https://dev.azure.com" + self.base_url = raw_base_url.rstrip("/") + self.work_item_type = work_item_type + + def _configure_api_token(self, api_token: str | None) -> None: + """Resolve PAT / env / keyring token and set ``api_token`` and ``auth_scheme``.""" if api_token: self.api_token = api_token self.auth_scheme = "basic" - elif os.environ.get("AZURE_DEVOPS_TOKEN"): - self.api_token = os.environ.get("AZURE_DEVOPS_TOKEN") + return + env_tok = os.environ.get("AZURE_DEVOPS_TOKEN") + if env_tok: + self.api_token = env_tok self.auth_scheme = "basic" - elif stored_token := get_token("azure-devops", allow_expired=False): - # Valid, non-expired token found + return + stored_token = get_token("azure-devops", allow_expired=False) + if stored_token: self.api_token = stored_token.get("access_token") token_type = (stored_token.get("token_type") or "bearer").lower() self.auth_scheme = "bearer" if token_type == "bearer" else "basic" - elif stored_token_expired := get_token("azure-devops", allow_expired=True): - # Token exists but is expired - try to refresh using persistent cache - expires_at = stored_token_expired.get("expires_at", "unknown") - token_type = (stored_token_expired.get("token_type") or "bearer").lower() - if token_type == "bearer": - # OAuth token expired - try automatic refresh using persistent cache (like Azure CLI) - refreshed_token = self._try_refresh_oauth_token() - if refreshed_token: - self.api_token = refreshed_token.get("access_token") - self.auth_scheme = "bearer" - # Update stored token with refreshed token - set_token("azure-devops", refreshed_token) - debug_print(f"[dim]OAuth token automatically refreshed (was expired at {expires_at})[/dim]") - else: - # Refresh failed - provide helpful guidance - console.print( - f"[yellow]⚠[/yellow] Stored OAuth token expired at {expires_at}. " - "Attempting automatic refresh..." - ) - console.print("[yellow]⚠[/yellow] Automatic refresh failed. OAuth tokens expire after ~1 hour.") - console.print( - "[dim]Options:[/dim]\n" - " 1. Use a Personal Access Token (PAT) with longer expiration (up to 1 year):\n" - " - Create PAT: https://dev.azure.com/{org}/_usersSettings/tokens\n" - " - Store PAT: specfact backlog auth azure-devops --pat your_pat_token\n" - " 2. Re-authenticate: specfact backlog auth azure-devops\n" - " 3. Use --ado-token option with a valid token" - ) - self.api_token = None - self.auth_scheme = None - else: - # PAT token - no expiration tracking, assume still valid - self.api_token = stored_token_expired.get("access_token") - self.auth_scheme = "basic" - else: + return + stored_token_expired = get_token("azure-devops", allow_expired=True) + if not stored_token_expired: self.api_token = None self.auth_scheme = None + return + expires_at = stored_token_expired.get("expires_at", "unknown") + token_type = (stored_token_expired.get("token_type") or "bearer").lower() + if token_type != "bearer": + self.api_token = stored_token_expired.get("access_token") + self.auth_scheme = "basic" + return + refreshed_token = self._try_refresh_oauth_token() + if refreshed_token: + self.api_token = refreshed_token.get("access_token") + self.auth_scheme = "bearer" + set_token("azure-devops", refreshed_token) + debug_print(f"[dim]OAuth token automatically refreshed (was expired at {expires_at})[/dim]") + return + console.print(f"[yellow]⚠[/yellow] Stored OAuth token expired at {expires_at}. Attempting automatic refresh...") + console.print("[yellow]⚠[/yellow] Automatic refresh failed. OAuth tokens expire after ~1 hour.") + console.print( + "[dim]Options:[/dim]\n" + " 1. Use a Personal Access Token (PAT) with longer expiration (up to 1 year):\n" + " - Create PAT: https://dev.azure.com/{org}/_usersSettings/tokens\n" + " - Store PAT: specfact backlog auth azure-devops --pat your_pat_token\n" + " 2. Re-authenticate: specfact backlog auth azure-devops\n" + " 3. Use --ado-token option with a valid token" + ) + self.api_token = None + self.auth_scheme = None - # Base URL defaults to Azure DevOps Services (cloud) - # Normalize base_url: remove trailing slashes - # Note: For Azure DevOps Services (cloud), base_url should be "https://dev.azure.com" - # For Azure DevOps Server (on-premise), base_url might be "https://server" or "https://server/collection" - raw_base_url = base_url or "https://dev.azure.com" - self.base_url = raw_base_url.rstrip("/") - self.work_item_type = work_item_type + @staticmethod + def _work_item_id_from_source_tracking(source_tracking: Any, target_repo: str) -> Any: + if isinstance(source_tracking, dict): + return _as_str_dict(source_tracking).get("source_id") + if not isinstance(source_tracking, list): + return None + for entry in source_tracking: + if not isinstance(entry, dict): + continue + ed = _as_str_dict(entry) + entry_repo = ed.get("source_repo") + if entry_repo == target_repo: + return ed.get("source_id") + if entry_repo: + continue + source_url = ed.get("source_url", "") + if source_url and target_repo in source_url: + return ed.get("source_id") + return None + + def _ado_create_patch_document(self, title: str, body: str, ado_state: str) -> list[dict[str, Any]]: + return [ + {"op": "add", "path": "/fields/System.Title", "value": title}, + {"op": "add", "path": "/fields/System.Description", "value": body}, + {"op": "add", "path": "/fields/System.State", "value": ado_state}, + { + "op": "add", + "path": "/multilineFieldsFormat/System.Description", + "value": "Markdown", + }, + ] + + def _parse_ado_create_work_item_response(self, work_item_data: dict[str, Any]) -> tuple[Any, str]: + work_item_id = work_item_data.get("id") + _links_raw = work_item_data.get("_links", {}) + links = _as_str_dict(_links_raw) if isinstance(_links_raw, dict) else {} + html_raw = links.get("html", {}) + html = _as_str_dict(html_raw) if isinstance(html_raw, dict) else {} + return work_item_id, str(html.get("href", "")) + + def _merge_created_work_item_source_tracking( + self, + proposal_data: dict[str, Any], + created: _AdoCreatedWorkItemRef, + ) -> None: + source_tracking = proposal_data.get("source_tracking") + if not source_tracking: + return + tracking_update = { + "source_id": created.work_item_id, + "source_url": created.work_item_url, + "source_repo": f"{created.org}/{created.project}", + "source_metadata": { + "org": created.org, + "project": created.project, + "work_item_type": created.work_item_type, + "state": created.ado_state, + }, + } + if isinstance(source_tracking, dict): + st = _as_str_dict(source_tracking) + st.update(tracking_update) + return + if isinstance(source_tracking, list): + cast(list[dict[str, Any]], source_tracking).append(tracking_update) def _is_on_premise(self) -> bool: """ @@ -2150,18 +2226,7 @@ def _create_work_item_from_proposal( **self._auth_headers(), } - # Build JSON Patch document for work item creation - # Set multilineFieldsFormat to Markdown for proper rendering (ADO supports Markdown as of July 2025) - patch_document = [ - {"op": "add", "path": "/fields/System.Title", "value": title}, - {"op": "add", "path": "/fields/System.Description", "value": body}, - {"op": "add", "path": "/fields/System.State", "value": ado_state}, - { - "op": "add", - "path": "/multilineFieldsFormat/System.Description", - "value": "Markdown", - }, # Set format to Markdown - ] + patch_document = self._ado_create_patch_document(title, body, ado_state) try: response = self._request_with_retry( @@ -2177,47 +2242,19 @@ def _create_work_item_from_proposal( ) response.raise_for_status() work_item_data = cast(dict[str, Any], response.json()) - - work_item_id = work_item_data.get("id") - _links_raw = work_item_data.get("_links", {}) - links = _as_str_dict(_links_raw) if isinstance(_links_raw, dict) else {} - html_raw = links.get("html", {}) - html = _as_str_dict(html_raw) if isinstance(html_raw, dict) else {} - work_item_url = str(html.get("href", "")) - - # Store ADO metadata in source_tracking if provided - source_tracking = proposal_data.get("source_tracking") - if source_tracking: - if isinstance(source_tracking, dict): - st = _as_str_dict(source_tracking) - st.update( - { - "source_id": work_item_id, - "source_url": work_item_url, - "source_repo": f"{org}/{project}", - "source_metadata": { - "org": org, - "project": project, - "work_item_type": work_item_type, - "state": ado_state, - }, - } - ) - elif isinstance(source_tracking, list): - # Add new entry to list - cast(list[dict[str, Any]], source_tracking).append( - { - "source_id": work_item_id, - "source_url": work_item_url, - "source_repo": f"{org}/{project}", - "source_metadata": { - "org": org, - "project": project, - "work_item_type": work_item_type, - "state": ado_state, - }, - } - ) + work_item_id, work_item_url = self._parse_ado_create_work_item_response(work_item_data) + + self._merge_created_work_item_source_tracking( + proposal_data, + _AdoCreatedWorkItemRef( + work_item_id=work_item_id, + work_item_url=work_item_url, + org=org, + project=project, + work_item_type=work_item_type, + ado_state=ado_state, + ), + ) return { "work_item_id": work_item_id, @@ -2248,31 +2285,11 @@ def _update_work_item_status( Returns: Dict with updated work item data: {"work_item_id": int, "work_item_url": str, "state": str} """ - # Get work item ID from source_tracking - source_tracking = proposal_data.get("source_tracking", {}) - - # Normalize to find the entry for this repository target_repo = f"{org}/{project}" - work_item_id = None - - if isinstance(source_tracking, dict): - # Single dict entry (backward compatibility) - work_item_id = _as_str_dict(source_tracking).get("source_id") - elif isinstance(source_tracking, list): - # List of entries - find the one matching this repository - for entry in source_tracking: - if isinstance(entry, dict): - ed = _as_str_dict(entry) - entry_repo = ed.get("source_repo") - if entry_repo == target_repo: - work_item_id = ed.get("source_id") - break - # Backward compatibility: if no source_repo, try to extract from source_url - if not entry_repo: - source_url = ed.get("source_url", "") - if source_url and target_repo in source_url: - work_item_id = ed.get("source_id") - break + work_item_id = self._work_item_id_from_source_tracking( + proposal_data.get("source_tracking", {}), + target_repo, + ) if not work_item_id: msg = ( diff --git a/src/specfact_cli/adapters/github.py b/src/specfact_cli/adapters/github.py index ed07b541..f4392e59 100644 --- a/src/specfact_cli/adapters/github.py +++ b/src/specfact_cli/adapters/github.py @@ -157,6 +157,29 @@ def _get_github_token_from_gh_cli() -> str | None: return None +_GITHUB_GIT_CONFIG_URL_RE = re.compile(r"url\s*=\s*(https?://[^\s]+|ssh://[^\s]+|git://[^\s]+|git@[^:]+:[^\s]+)") + + +def _git_config_content_indicates_github(config_content: str) -> bool: + github_ssh_hosts = {"github.com", "ssh.github.com"} + for match in _GITHUB_GIT_CONFIG_URL_RE.finditer(config_content): + url_str = match.group(1) + if url_str.startswith("git@"): + host_part = url_str.split(":")[0].replace("git@", "").lower() + if host_part in github_ssh_hosts: + return True + continue + parsed = urlparse(url_str) + if not parsed.hostname: + continue + hostname_lower = parsed.hostname.lower() + if hostname_lower == "github.com": + return True + if parsed.scheme == "ssh" and hostname_lower == "ssh.github.com": + return True + return False + + class GitHubAdapter(BridgeAdapter, BacklogAdapterMixin, BacklogAdapter): """ GitHub bridge adapter implementing BridgeAdapter interface. @@ -805,38 +828,14 @@ def detect(self, repo_path: Path, bridge_config: BridgeConfig | None = None) -> Returns: True if GitHub repository detected, False otherwise """ - # Check for .git/config with GitHub remote git_config = repo_path / ".git" / "config" if git_config.exists(): try: - config_content = git_config.read_text(encoding="utf-8") - # Use proper URL parsing to avoid substring matching vulnerabilities - # Look for URL patterns in git config and validate the hostname - # Match: https?://, ssh://, git://, and scp-style git@host:path URLs - url_pattern = re.compile(r"url\s*=\s*(https?://[^\s]+|ssh://[^\s]+|git://[^\s]+|git@[^:]+:[^\s]+)") - # Official GitHub SSH hostnames - github_ssh_hosts = {"github.com", "ssh.github.com"} - for match in url_pattern.finditer(config_content): - url_str = match.group(1) - # Handle scp-style git@ format: git@github.com:user/repo.git or git@ssh.github.com:user/repo.git - if url_str.startswith("git@"): - host_part = url_str.split(":")[0].replace("git@", "").lower() - if host_part in github_ssh_hosts: - return True - else: - # Parse HTTP/HTTPS/SSH/GIT URLs properly - parsed = urlparse(url_str) - if parsed.hostname: - hostname_lower = parsed.hostname.lower() - # Check for GitHub hostnames (github.com for all schemes, ssh.github.com for SSH) - if hostname_lower == "github.com": - return True - if parsed.scheme == "ssh" and hostname_lower == "ssh.github.com": - return True + if _git_config_content_indicates_github(git_config.read_text(encoding="utf-8")): + return True except Exception: pass - # Check bridge config for external GitHub repo return bool(bridge_config and bridge_config.adapter.value == "github") @beartype diff --git a/src/specfact_cli/adapters/openspec_parser.py b/src/specfact_cli/adapters/openspec_parser.py index 51bfdc06..df737627 100644 --- a/src/specfact_cli/adapters/openspec_parser.py +++ b/src/specfact_cli/adapters/openspec_parser.py @@ -391,24 +391,19 @@ def _parse_delta_content(self, content: str) -> dict[str, Any]: current_section: str | None = None - # Parse markdown sections for line in content.splitlines(): if line.startswith("##"): - # Section header - normalize section name current_section = line.lstrip("#").strip().lower() - elif current_section: - # Process content based on current section - if current_section == "type": - # Extract type value (should be on the line after ## Type) - if line.strip(): - change_type = line.strip().upper() - elif current_section == "feature id" or current_section == "feature_id": - # Extract feature ID - if line.strip(): - feature_id = line.strip() - elif current_section == "content": - # Collect content - delta_content.append(line) + continue + if not current_section: + continue + stripped = line.strip() + if current_section == "type" and stripped: + change_type = stripped.upper() + elif current_section in ("feature id", "feature_id") and stripped: + feature_id = stripped + elif current_section == "content": + delta_content.append(line) return { "type": change_type, diff --git a/src/specfact_cli/adapters/speckit.py b/src/specfact_cli/adapters/speckit.py index 23b283ff..72f4f06d 100644 --- a/src/specfact_cli/adapters/speckit.py +++ b/src/specfact_cli/adapters/speckit.py @@ -11,6 +11,7 @@ import re import shutil import subprocess +from dataclasses import dataclass from pathlib import Path from typing import Any @@ -41,6 +42,18 @@ logger = get_bridge_logger(__name__) +@dataclass(frozen=True, slots=True) +class _SpeckitFeatureUpsert: + feature_key: str + feature_title: str + outcomes: list[str] + acceptance: list[str] + stories: list[Any] + spec_data: dict[str, Any] + spec_path: Path + bridge_config: BridgeConfig | None + + class SpecKitAdapter(BridgeAdapter): """ Spec-Kit bridge adapter implementing BridgeAdapter interface. @@ -493,23 +506,12 @@ def _build_speckit_source_tracking(self, spec_path: Path, bridge_config: BridgeC source_metadata["speckit_base_path"] = str(bridge_config.external_base_path) return SourceTracking(tool="speckit", source_metadata=source_metadata) - def _upsert_feature( - self, - project_bundle: ProjectBundle, - feature_key: str, - feature_title: str, - outcomes: list[str], - acceptance: list[str], - stories: list[Any], - spec_data: dict[str, Any], - spec_path: Path, - bridge_config: BridgeConfig | None, - ) -> None: + def _upsert_feature(self, project_bundle: ProjectBundle, payload: _SpeckitFeatureUpsert) -> None: """Insert a new Feature or update the existing one in project_bundle.features.""" from specfact_cli.models.plan import Feature from specfact_cli.utils.feature_keys import normalize_feature_key - normalized_key = normalize_feature_key(feature_key) + normalized_key = normalize_feature_key(payload.feature_key) existing_feature = None for key, feat in project_bundle.features.items(): if normalize_feature_key(key) == normalized_key: @@ -517,28 +519,28 @@ def _upsert_feature( break if existing_feature: - existing_feature.title = feature_title - existing_feature.outcomes = outcomes if outcomes else existing_feature.outcomes - existing_feature.acceptance = acceptance if acceptance else existing_feature.acceptance - existing_feature.stories = stories - existing_feature.constraints = spec_data.get("edge_cases", []) + existing_feature.title = payload.feature_title + existing_feature.outcomes = payload.outcomes if payload.outcomes else existing_feature.outcomes + existing_feature.acceptance = payload.acceptance if payload.acceptance else existing_feature.acceptance + existing_feature.stories = payload.stories + existing_feature.constraints = payload.spec_data.get("edge_cases", []) return feature = Feature( - key=feature_key, - title=feature_title, - outcomes=outcomes if outcomes else [f"Provides {feature_title} functionality"], - acceptance=acceptance if acceptance else [f"{feature_title} is functional"], - constraints=spec_data.get("edge_cases", []), - stories=stories, + key=payload.feature_key, + title=payload.feature_title, + outcomes=(payload.outcomes if payload.outcomes else [f"Provides {payload.feature_title} functionality"]), + acceptance=(payload.acceptance if payload.acceptance else [f"{payload.feature_title} is functional"]), + constraints=payload.spec_data.get("edge_cases", []), + stories=payload.stories, confidence=0.8, draft=False, source_tracking=None, contract=None, protocol=None, ) - feature.source_tracking = self._build_speckit_source_tracking(spec_path, bridge_config) - project_bundle.features[feature_key] = feature + feature.source_tracking = self._build_speckit_source_tracking(payload.spec_path, payload.bridge_config) + project_bundle.features[payload.feature_key] = feature def _import_specification( self, @@ -561,14 +563,16 @@ def _import_specification( self._upsert_feature( project_bundle, - feature_key, - feature_title, - outcomes, - acceptance, - stories, - spec_data, - spec_path, - bridge_config, + _SpeckitFeatureUpsert( + feature_key=feature_key, + feature_title=feature_title, + outcomes=outcomes, + acceptance=acceptance, + stories=stories, + spec_data=spec_data, + spec_path=spec_path, + bridge_config=bridge_config, + ), ) def _read_plan_title(self, plan_path: Path) -> str: diff --git a/src/specfact_cli/analyzers/ambiguity_scanner.py b/src/specfact_cli/analyzers/ambiguity_scanner.py index d5cc6412..edf6f7d8 100644 --- a/src/specfact_cli/analyzers/ambiguity_scanner.py +++ b/src/specfact_cli/analyzers/ambiguity_scanner.py @@ -9,6 +9,7 @@ import ast import re +from collections.abc import Callable from dataclasses import dataclass from enum import StrEnum from pathlib import Path @@ -165,30 +166,22 @@ def scan(self, plan_bundle: PlanBundle) -> AmbiguityReport: @ensure(lambda result: isinstance(result, list), "Must return list of findings") def _scan_category(self, plan_bundle: PlanBundle, category: TaxonomyCategory) -> list[AmbiguityFinding]: """Scan specific taxonomy category.""" - findings: list[AmbiguityFinding] = [] - - if category == TaxonomyCategory.FUNCTIONAL_SCOPE: - findings.extend(self._scan_functional_scope(plan_bundle)) - elif category == TaxonomyCategory.DATA_MODEL: - findings.extend(self._scan_data_model(plan_bundle)) - elif category == TaxonomyCategory.INTERACTION_UX: - findings.extend(self._scan_interaction_ux(plan_bundle)) - elif category == TaxonomyCategory.NON_FUNCTIONAL: - findings.extend(self._scan_non_functional(plan_bundle)) - elif category == TaxonomyCategory.INTEGRATION: - findings.extend(self._scan_integration(plan_bundle)) - elif category == TaxonomyCategory.EDGE_CASES: - findings.extend(self._scan_edge_cases(plan_bundle)) - elif category == TaxonomyCategory.CONSTRAINTS: - findings.extend(self._scan_constraints(plan_bundle)) - elif category == TaxonomyCategory.TERMINOLOGY: - findings.extend(self._scan_terminology(plan_bundle)) - elif category == TaxonomyCategory.COMPLETION_SIGNALS: - findings.extend(self._scan_completion_signals(plan_bundle)) - elif category == TaxonomyCategory.FEATURE_COMPLETENESS: - findings.extend(self._scan_feature_completeness(plan_bundle)) - - return findings + scanners: dict[TaxonomyCategory, Callable[[PlanBundle], list[AmbiguityFinding]]] = { + TaxonomyCategory.FUNCTIONAL_SCOPE: self._scan_functional_scope, + TaxonomyCategory.DATA_MODEL: self._scan_data_model, + TaxonomyCategory.INTERACTION_UX: self._scan_interaction_ux, + TaxonomyCategory.NON_FUNCTIONAL: self._scan_non_functional, + TaxonomyCategory.INTEGRATION: self._scan_integration, + TaxonomyCategory.EDGE_CASES: self._scan_edge_cases, + TaxonomyCategory.CONSTRAINTS: self._scan_constraints, + TaxonomyCategory.TERMINOLOGY: self._scan_terminology, + TaxonomyCategory.COMPLETION_SIGNALS: self._scan_completion_signals, + TaxonomyCategory.FEATURE_COMPLETENESS: self._scan_feature_completeness, + } + scanner = scanners.get(category) + if scanner is None: + return [] + return list(scanner(plan_bundle)) _BEHAVIORAL_PATTERNS: tuple[str, ...] = ( "can ", diff --git a/src/specfact_cli/analyzers/code_analyzer.py b/src/specfact_cli/analyzers/code_analyzer.py index 76428901..673bce0e 100644 --- a/src/specfact_cli/analyzers/code_analyzer.py +++ b/src/specfact_cli/analyzers/code_analyzer.py @@ -10,6 +10,7 @@ import subprocess from collections import defaultdict from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass, field from pathlib import Path from typing import Any, cast @@ -31,6 +32,16 @@ console = Console() +@dataclass +class _SemgrepFeatureBuckets: + api_endpoints: list[str] = field(default_factory=list) + data_models: list[str] = field(default_factory=list) + auth_patterns: list[str] = field(default_factory=list) + crud_operations: list[dict[str, str]] = field(default_factory=list) + anti_patterns: list[str] = field(default_factory=list) + code_smells: list[str] = field(default_factory=list) + + class CodeAnalyzer: """ Analyzes Python code to auto-derive plan bundles. @@ -704,6 +715,21 @@ def _extract_themes_from_imports(self, tree: ast.AST) -> None: themes = self._extract_themes_from_imports_parallel(tree) self.themes.update(themes) + @staticmethod + def _themes_for_import_module(module_name: str, theme_keywords: dict[str, str]) -> set[str]: + lowered = module_name.lower() + return {theme for keyword, theme in theme_keywords.items() if keyword in lowered} + + def _themes_for_import_node(self, node: ast.Import | ast.ImportFrom, theme_keywords: dict[str, str]) -> set[str]: + if isinstance(node, ast.Import): + found: set[str] = set() + for alias in node.names: + found.update(self._themes_for_import_module(alias.name, theme_keywords)) + return found + if isinstance(node, ast.ImportFrom) and node.module: + return self._themes_for_import_module(node.module, theme_keywords) + return set() + def _extract_themes_from_imports_parallel(self, tree: ast.AST) -> set[str]: """Extract themes from import statements (thread-safe, returns themes).""" themes: set[str] = set() @@ -726,15 +752,7 @@ def _extract_themes_from_imports_parallel(self, tree: ast.AST) -> set[str]: for node in ast.walk(tree): if isinstance(node, (ast.Import, ast.ImportFrom)): - if isinstance(node, ast.Import): - for alias in node.names: - for keyword, theme in theme_keywords.items(): - if keyword in alias.name.lower(): - themes.add(theme) - elif isinstance(node, ast.ImportFrom) and node.module: - for keyword, theme in theme_keywords.items(): - if keyword in node.module.lower(): - themes.add(theme) + themes.update(self._themes_for_import_node(node, theme_keywords)) return themes @@ -1098,54 +1116,63 @@ def _categorise_codesmell_finding( ) return ("codesmell", str(finding.get("message", ""))) if any(term in rule_id for term in terms) else ("", "") - def _apply_semgrep_findings_to_feature( - self, - feature: Feature, - api_endpoints: list[str], - data_models: list[str], - auth_patterns: list[str], - crud_operations: list[dict[str, str]], - anti_patterns: list[str], - code_smells: list[str], - ) -> None: + def _apply_semgrep_findings_to_feature(self, feature: Feature, buckets: _SemgrepFeatureBuckets) -> None: """ Apply categorised Semgrep findings to a feature by updating outcomes and constraints. Args: feature: Feature to update in-place - api_endpoints: Detected API endpoints (e.g. "GET /users") - data_models: Detected data model names - auth_patterns: Detected auth/permission descriptions - crud_operations: Detected CRUD operations as dicts with "operation" and "entity" keys - anti_patterns: Anti-pattern messages to add as constraints - code_smells: Code-smell/security messages to add as constraints + buckets: Categorised Semgrep finding lists (API, models, auth, CRUD, anti-patterns, smells). """ - if api_endpoints: - feature.outcomes.append(f"Exposes API endpoints: {', '.join(api_endpoints)}") - if data_models: - feature.outcomes.append(f"Defines data models: {', '.join(data_models)}") - if auth_patterns: - feature.outcomes.append(f"Requires authentication: {', '.join(auth_patterns)}") - if crud_operations: + if buckets.api_endpoints: + feature.outcomes.append(f"Exposes API endpoints: {', '.join(buckets.api_endpoints)}") + if buckets.data_models: + feature.outcomes.append(f"Defines data models: {', '.join(buckets.data_models)}") + if buckets.auth_patterns: + feature.outcomes.append(f"Requires authentication: {', '.join(buckets.auth_patterns)}") + if buckets.crud_operations: crud_str = ", ".join( - f"{op.get('operation', 'UNKNOWN')} {op.get('entity', 'unknown')}" for op in crud_operations + f"{op.get('operation', 'UNKNOWN')} {op.get('entity', 'unknown')}" for op in buckets.crud_operations ) feature.outcomes.append(f"Provides CRUD operations: {crud_str}") - if anti_patterns: - anti_str = "; ".join(anti_patterns[:3]) + if buckets.anti_patterns: + anti_str = "; ".join(buckets.anti_patterns[:3]) if anti_str: if feature.constraints: feature.constraints.append(f"Code quality: {anti_str}") else: feature.constraints = [f"Code quality: {anti_str}"] - if code_smells: - smell_str = "; ".join(code_smells[:3]) + if buckets.code_smells: + smell_str = "; ".join(buckets.code_smells[:3]) if smell_str: if feature.constraints: feature.constraints.append(f"Issues detected: {smell_str}") else: feature.constraints = [f"Issues detected: {smell_str}"] + def _accumulate_semgrep_finding_bucket(self, buckets: _SemgrepFeatureBuckets, category: str, value: str) -> None: + if category == "api": + buckets.api_endpoints.append(value) + self.themes.add("API") + return + if category == "model": + buckets.data_models.append(value) + self.themes.add("Database") + return + if category == "auth": + buckets.auth_patterns.append(value) + self.themes.add("Security") + return + if category == "crud": + op, _, entity = value.partition(":") + buckets.crud_operations.append({"operation": op, "entity": entity}) + return + if category == "antipattern": + buckets.anti_patterns.append(value) + return + if category == "codesmell": + buckets.code_smells.append(value) + def _enhance_feature_with_semgrep( self, feature: Feature, @@ -1175,35 +1202,13 @@ def _enhance_feature_with_semgrep( if not relevant_findings: return - api_endpoints: list[str] = [] - data_models: list[str] = [] - auth_patterns: list[str] = [] - crud_operations: list[dict[str, str]] = [] - anti_patterns: list[str] = [] - code_smells: list[str] = [] + buckets = _SemgrepFeatureBuckets() for finding in relevant_findings: category, value = self._categorise_semgrep_finding(finding) - if category == "api": - api_endpoints.append(value) - self.themes.add("API") - elif category == "model": - data_models.append(value) - self.themes.add("Database") - elif category == "auth": - auth_patterns.append(value) - self.themes.add("Security") - elif category == "crud": - op, _, entity = value.partition(":") - crud_operations.append({"operation": op, "entity": entity}) - elif category == "antipattern": - anti_patterns.append(value) - elif category == "codesmell": - code_smells.append(value) - - self._apply_semgrep_findings_to_feature( - feature, api_endpoints, data_models, auth_patterns, crud_operations, anti_patterns, code_smells - ) + self._accumulate_semgrep_finding_bucket(buckets, category, value) + + self._apply_semgrep_findings_to_feature(feature, buckets) # Confidence is already calculated with Semgrep evidence in _calculate_feature_confidence # No need to adjust here - this method only adds outcomes, constraints, and themes @@ -1714,6 +1719,16 @@ def _detect_async_patterns(self, tree: ast.AST, file_path: Path) -> list[str]: self.async_patterns[module_name].extend(async_methods) return async_methods + @staticmethod + def _function_name_holding_ast_subtree(tree: ast.AST, target: ast.AST) -> str | None: + for parent in ast.walk(tree): + if not isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): + continue + for child in ast.walk(parent): + if child is target: + return parent.name + return None + def _detect_async_patterns_parallel(self, tree: ast.AST, file_path: Path) -> list[str]: """ Detect async/await patterns in code (thread-safe version). @@ -1724,20 +1739,13 @@ def _detect_async_patterns_parallel(self, tree: ast.AST, file_path: Path) -> lis async_methods: list[str] = [] for node in ast.walk(tree): - # Check for async functions if isinstance(node, ast.AsyncFunctionDef): async_methods.append(node.name) - - # Check for await statements (even in sync functions) - if isinstance(node, ast.Await): - # Find containing function - for parent in ast.walk(tree): - if isinstance(parent, (ast.FunctionDef, ast.AsyncFunctionDef)): - for child in ast.walk(parent): - if child == node: - if parent.name not in async_methods: - async_methods.append(parent.name) - break + if not isinstance(node, ast.Await): + continue + host = self._function_name_holding_ast_subtree(tree, node) + if host and host not in async_methods: + async_methods.append(host) return async_methods diff --git a/src/specfact_cli/analyzers/graph_analyzer.py b/src/specfact_cli/analyzers/graph_analyzer.py index 4846bf15..25cd9ea4 100644 --- a/src/specfact_cli/analyzers/graph_analyzer.py +++ b/src/specfact_cli/analyzers/graph_analyzer.py @@ -186,6 +186,20 @@ def process_imports(file_path: Path) -> list[tuple[str, str]]: finally: executor.shutdown(wait=wait_on_shutdown) + def _add_call_graph_edges_for_module( + self, + graph: StrDiGraph, + module_name: str, + call_graph: dict[str, list[str]], + python_files: list[Path], + ) -> None: + for _caller, callees in call_graph.items(): + for callee in callees: + callee_module = self._resolve_module_from_function(callee, python_files) + if callee_module is None or callee_module not in graph: + continue + graph.add_edge(module_name, callee_module) + def _build_call_graph_edges( self, graph: StrDiGraph, @@ -206,11 +220,7 @@ def _build_call_graph_edges( try: call_graph = future.result() module_name = self._path_to_module_name(file_path) - for _caller, callees in call_graph.items(): - for callee in callees: - callee_module = self._resolve_module_from_function(callee, python_files) - if callee_module and callee_module in graph: - graph.add_edge(module_name, callee_module) + self._add_call_graph_edges_for_module(graph, module_name, call_graph, python_files) except (OSError, RuntimeError): pass completed += 1 diff --git a/src/specfact_cli/generators/contract_generator.py b/src/specfact_cli/generators/contract_generator.py index a80eb53a..94779521 100644 --- a/src/specfact_cli/generators/contract_generator.py +++ b/src/specfact_cli/generators/contract_generator.py @@ -6,6 +6,7 @@ from __future__ import annotations +from dataclasses import dataclass, field from pathlib import Path from typing import Any @@ -17,6 +18,14 @@ from specfact_cli.utils.structure import SpecFactStructure +@dataclass +class _ContractGenAccum: + generated_files: list[Path] = field(default_factory=list) + contracts_per_story: dict[str, int] = field(default_factory=dict) + invariants_per_feature: dict[str, int] = field(default_factory=dict) + errors: list[str] = field(default_factory=list) + + class ContractGenerator: """ Generates contract stubs from SDD HOW sections. @@ -65,27 +74,22 @@ def generate_contracts( contracts_dir = base_path / SpecFactStructure.ROOT / "contracts" contracts_dir.mkdir(parents=True, exist_ok=True) - generated_files: list[Path] = [] - contracts_per_story: dict[str, int] = {} - invariants_per_feature: dict[str, int] = {} - errors: list[str] = [] + accum = _ContractGenAccum() # Map SDD contracts to plan stories/features for feature in plan.features: - self._process_feature_contracts( - sdd, feature, contracts_dir, generated_files, contracts_per_story, invariants_per_feature, errors - ) + self._process_feature_contracts(sdd, feature, contracts_dir, accum) # Fallback: generate bundle-level stub when no feature files were produced - if not generated_files and (sdd.how.contracts or sdd.how.invariants): + if not accum.generated_files and (sdd.how.contracts or sdd.how.invariants): fallback_file = self._generate_bundle_fallback(sdd, contracts_dir) - generated_files.append(fallback_file) + accum.generated_files.append(fallback_file) return { - "generated_files": [str(f) for f in generated_files], - "contracts_per_story": contracts_per_story, - "invariants_per_feature": invariants_per_feature, - "errors": errors, + "generated_files": [str(f) for f in accum.generated_files], + "contracts_per_story": accum.contracts_per_story, + "invariants_per_feature": accum.invariants_per_feature, + "errors": accum.errors, } def _process_feature_contracts( @@ -93,10 +97,7 @@ def _process_feature_contracts( sdd: SDDManifest, feature: Feature, contracts_dir: Path, - generated_files: list[Path], - contracts_per_story: dict[str, int], - invariants_per_feature: dict[str, int], - errors: list[str], + accum: _ContractGenAccum, ) -> None: """ Process contracts and invariants for a single feature, updating accumulators in place. @@ -105,10 +106,7 @@ def _process_feature_contracts( sdd: SDD manifest feature: Feature to process contracts_dir: Output directory for contract files - generated_files: Accumulator list for generated file paths - contracts_per_story: Accumulator mapping story keys to contract counts - invariants_per_feature: Accumulator mapping feature keys to invariant counts - errors: Accumulator list for error messages + accum: Mutable accumulators for generated paths, counts, and errors. """ try: feature_contracts = self._extract_feature_contracts(sdd.how, feature) @@ -118,16 +116,16 @@ def _process_feature_contracts( contract_file = self._generate_feature_contract_file( feature, feature_contracts, feature_invariants, sdd, contracts_dir ) - generated_files.append(contract_file) + accum.generated_files.append(contract_file) for story in feature.stories: story_contracts = self._extract_story_contracts(feature_contracts, story) - contracts_per_story[story.key] = len(story_contracts) + accum.contracts_per_story[story.key] = len(story_contracts) - invariants_per_feature[feature.key] = len(feature_invariants) + accum.invariants_per_feature[feature.key] = len(feature_invariants) except Exception as e: - errors.append(f"Error generating contracts for {feature.key}: {e}") + accum.errors.append(f"Error generating contracts for {feature.key}: {e}") def _generate_bundle_fallback(self, sdd: SDDManifest, contracts_dir: Path) -> Path: """ diff --git a/src/specfact_cli/generators/openapi_extractor.py b/src/specfact_cli/generators/openapi_extractor.py index 9f0ae57f..900a90ba 100644 --- a/src/specfact_cli/generators/openapi_extractor.py +++ b/src/specfact_cli/generators/openapi_extractor.py @@ -12,6 +12,7 @@ import hashlib import os import re +from dataclasses import dataclass from pathlib import Path from threading import Lock from typing import Any, cast @@ -24,6 +25,17 @@ from specfact_cli.models.plan import Feature +@dataclass(slots=True) +class _OpenApiOpSpec: + path: str + method: str + func_node: ast.FunctionDef + path_params: list[dict[str, Any]] | None = None + tags: list[str] | None = None + status_code: int | None = None + security: list[dict[str, list[str]]] | None = None + + def _fastapi_decorator_first_path_str(decorator: ast.Call) -> str | None: if not decorator.args: return None @@ -511,13 +523,15 @@ def _extract_fastapi_function_endpoint( security = self._extract_security_from_decorator(decorator) self._add_operation( openapi_spec, - path, - method, - node, - path_params=path_params, - tags=tags, - status_code=status_code, - security=security, + _OpenApiOpSpec( + path=path, + method=method, + func_node=node, + path_params=path_params, + tags=tags, + status_code=status_code, + security=security, + ), ) @staticmethod @@ -560,7 +574,10 @@ def _extract_flask_function_endpoint( return path, path_params = self._extract_path_parameters(path, flask_format=True) for method in methods: - self._add_operation(openapi_spec, path, method, node, path_params=path_params) + self._add_operation( + openapi_spec, + _OpenApiOpSpec(path=path, method=method, func_node=node, path_params=path_params), + ) def _infer_http_method(self, method_name_lower: str) -> str: """ @@ -623,13 +640,13 @@ def _extract_interface_endpoints(self, node: ast.ClassDef, openapi_spec: dict[st path, path_params = self._extract_path_parameters(method_path) self._add_operation( openapi_spec, - path, - http_method, - method, - path_params=path_params, - tags=[node.name], - status_code=None, - security=None, + _OpenApiOpSpec( + path=path, + method=http_method, + func_node=method, + path_params=path_params, + tags=[node.name], + ), ) def _collect_class_api_methods(self, node: ast.ClassDef) -> list[ast.FunctionDef]: @@ -746,13 +763,13 @@ def _extract_class_method_endpoint( path, path_params = self._extract_path_parameters(method_path) self._add_operation( openapi_spec, - path, - http_method, - method, - path_params=path_params, - tags=[node.name], - status_code=None, - security=None, + _OpenApiOpSpec( + path=path, + method=http_method, + func_node=method, + path_params=path_params, + tags=[node.name], + ), ) def _process_top_level_node_for_endpoints( @@ -1285,36 +1302,22 @@ def _attach_operation_request_body( }, } - def _add_operation( - self, - openapi_spec: dict[str, Any], - path: str, - method: str, - func_node: ast.FunctionDef, - path_params: list[dict[str, Any]] | None = None, - tags: list[str] | None = None, - status_code: int | None = None, - security: list[dict[str, list[str]]] | None = None, - ) -> None: + def _add_operation(self, openapi_spec: dict[str, Any], op: _OpenApiOpSpec) -> None: """ Add operation to OpenAPI spec. Args: openapi_spec: OpenAPI spec dictionary - path: API path - method: HTTP method - func_node: Function AST node - path_params: Path parameters (if any) - tags: Operation tags (if any) + op: Path, method, AST node, and optional OpenAPI operation metadata. """ - openapi_spec["paths"].setdefault(path, {}) - path_param_names = {p["name"] for p in (path_params or [])} - request_body, query_params, response_schema = self._extract_function_parameters(func_node, path_param_names) - default_status = status_code or 200 + openapi_spec["paths"].setdefault(op.path, {}) + path_param_names = {p["name"] for p in (op.path_params or [])} + request_body, query_params, response_schema = self._extract_function_parameters(op.func_node, path_param_names) + default_status = op.status_code or 200 operation: dict[str, Any] = { - "operationId": func_node.name, - "summary": func_node.name.replace("_", " ").title(), - "description": ast.get_docstring(func_node) or "", + "operationId": op.func_node.name, + "summary": op.func_node.name.replace("_", " ").title(), + "description": ast.get_docstring(op.func_node) or "", "responses": { str(default_status): { "description": "Success" if default_status == 200 else f"Status {default_status}", @@ -1326,18 +1329,18 @@ def _add_operation( } }, } - self._merge_standard_error_responses(operation, method) - all_params = list(path_params or []) + self._merge_standard_error_responses(operation, op.method) + all_params = list(op.path_params or []) all_params.extend(query_params) if all_params: operation["parameters"] = all_params - if tags: - operation["tags"] = tags - if security: - operation["security"] = security - self._ensure_bearer_security_scheme(openapi_spec, security) - self._attach_operation_request_body(operation, method, request_body) - openapi_spec["paths"][path][method.lower()] = operation + if op.tags: + operation["tags"] = op.tags + if op.security: + operation["security"] = op.security + self._ensure_bearer_security_scheme(openapi_spec, op.security) + self._attach_operation_request_body(operation, op.method, request_body) + openapi_spec["paths"][op.path][op.method.lower()] = operation @beartype @require(lambda self, contract_path: isinstance(contract_path, Path), "Contract path must be Path") diff --git a/src/specfact_cli/generators/persona_exporter.py b/src/specfact_cli/generators/persona_exporter.py index ba68ba11..83ba040b 100644 --- a/src/specfact_cli/generators/persona_exporter.py +++ b/src/specfact_cli/generators/persona_exporter.py @@ -8,6 +8,7 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass from datetime import UTC, datetime from pathlib import Path from typing import Any @@ -19,6 +20,15 @@ from specfact_cli.models.project import PersonaMapping, ProjectBundle +@dataclass(frozen=True, slots=True) +class _OwnedFeatureFieldSpec: + owns: Sequence[str] + pattern: str + field_name: str + value: Any | None = None + use_getattr: bool = False + + class PersonaExporter: """ Exporter for persona-specific Markdown artifacts. @@ -183,42 +193,45 @@ def _merge_feature_stories_if_owned( def _merge_owned_feature_field( self, - owns: Sequence[str], - pattern: str, feature: Any, feature_dict: dict[str, Any], - field_name: str, - *, - value: Any | None = None, - use_getattr: bool = False, + spec: _OwnedFeatureFieldSpec, ) -> None: from specfact_cli.utils.persona_ownership import match_section_pattern - if not any(match_section_pattern(p, pattern) for p in owns): + if not any(match_section_pattern(p, spec.pattern) for p in spec.owns): return - if use_getattr: - val = getattr(feature, field_name, None) + if spec.use_getattr: + val = getattr(feature, spec.field_name, None) if val: - feature_dict[field_name] = val + feature_dict[spec.field_name] = val return - if value: - feature_dict[field_name] = value + if spec.value: + feature_dict[spec.field_name] = spec.value def _merge_feature_optional_sections( self, feature: Any, persona_mapping: PersonaMapping, feature_dict: dict[str, Any] ) -> None: owns = persona_mapping.owns self._merge_owned_feature_field( - owns, "features.*.outcomes", feature, feature_dict, "outcomes", value=feature.outcomes + feature, + feature_dict, + _OwnedFeatureFieldSpec(owns, "features.*.outcomes", "outcomes", value=feature.outcomes), ) self._merge_owned_feature_field( - owns, "features.*.constraints", feature, feature_dict, "constraints", value=feature.constraints + feature, + feature_dict, + _OwnedFeatureFieldSpec(owns, "features.*.constraints", "constraints", value=feature.constraints), ) self._merge_owned_feature_field( - owns, "features.*.acceptance", feature, feature_dict, "acceptance", value=feature.acceptance + feature, + feature_dict, + _OwnedFeatureFieldSpec(owns, "features.*.acceptance", "acceptance", value=feature.acceptance), ) self._merge_owned_feature_field( - owns, "features.*.implementation", feature, feature_dict, "implementation", use_getattr=True + feature, + feature_dict, + _OwnedFeatureFieldSpec(owns, "features.*.implementation", "implementation", use_getattr=True), ) def _load_bundle_protocols(self, bundle_dir: Path) -> dict[str, Any]: diff --git a/src/specfact_cli/generators/test_to_openapi.py b/src/specfact_cli/generators/test_to_openapi.py index de6e80b2..80fcc0a0 100644 --- a/src/specfact_cli/generators/test_to_openapi.py +++ b/src/specfact_cli/generators/test_to_openapi.py @@ -331,22 +331,26 @@ def _extract_string_arg(self, call: ast.Call, index: int) -> str | None: pass return None + def _coerce_ast_dict_to_plain(self, value: ast.Dict) -> dict[str, Any]: + result: dict[str, Any] = {} + for k, v in zip(value.keys, value.values, strict=True): + if k is None: + continue + key = self._extract_ast_value(k) + val = self._extract_ast_value(v) + if key is not None: + result[str(key)] = val + return result + @beartype def _extract_json_arg(self, call: ast.Call, keyword: str) -> dict[str, Any] | None: """Extract JSON/data argument from function call.""" for keyword_arg in call.keywords: - if keyword_arg.arg == keyword: - value = keyword_arg.value - # Try to extract dict literal - if isinstance(value, ast.Dict): - result: dict[str, Any] = {} - for k, v in zip(value.keys, value.values, strict=True): - if k is not None: - key = self._extract_ast_value(k) - val = self._extract_ast_value(v) - if key is not None: - result[str(key)] = val - return result + if keyword_arg.arg != keyword: + continue + inner = keyword_arg.value + if isinstance(inner, ast.Dict): + return self._coerce_ast_dict_to_plain(inner) return None @beartype @@ -375,6 +379,22 @@ def _extract_ast_value(self, node: ast.AST) -> Any: pass return None + def _examples_from_parsed_test_file(self, tree: ast.AST, func_name: str | None) -> dict[str, dict[str, Any]]: + by_operation: dict[str, dict[str, Any]] = {} + for node in ast.walk(tree): + if not isinstance(node, ast.FunctionDef): + continue + if func_name and node.name != func_name: + continue + if not node.name.startswith("test_"): + continue + example = self._extract_examples_from_test_function(node) + if not example: + continue + operation_id = str(example.get("operation_id", "unknown")) + by_operation.setdefault(operation_id, {}).update(example) + return by_operation + @beartype @require(lambda test_files: isinstance(test_files, list), "Test files must be list") @ensure(lambda result: isinstance(result, dict), "Must return dict") @@ -395,20 +415,8 @@ def _extract_examples_from_ast(self, test_files: list[str]) -> dict[str, Any]: try: tree = ast.parse(test_path.read_text(encoding="utf-8"), filename=str(test_path)) - - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef): - if func_name and node.name != func_name: - continue - if node.name.startswith("test_"): - # Extract examples from test function - example = self._extract_examples_from_test_function(node) - if example: - operation_id = example.get("operation_id", "unknown") - if operation_id not in examples: - examples[operation_id] = {} - examples[operation_id].update(example) - + for op_id, payload in self._examples_from_parsed_test_file(tree, func_name).items(): + examples.setdefault(op_id, {}).update(payload) except Exception: continue diff --git a/src/specfact_cli/models/project.py b/src/specfact_cli/models/project.py index 2f6b3b29..e822ac5b 100644 --- a/src/specfact_cli/models/project.py +++ b/src/specfact_cli/models/project.py @@ -13,6 +13,7 @@ import re from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import dataclass from datetime import UTC, datetime from enum import StrEnum from pathlib import Path @@ -640,14 +641,16 @@ def save_to_directory( feature_indices: list[FeatureIndex | None] = [None] * num_features feature_key_to_save_index = {key: idx for idx, key in enumerate(self.features)} checksums = _run_bundle_parallel_save( - self, - save_tasks, - total_artifacts, - max_workers, - progress_callback, - now, - feature_key_to_save_index, - feature_indices, + _BundleParallelSaveParams( + bundle=self, + save_tasks=save_tasks, + total_artifacts=total_artifacts, + max_workers=max_workers, + progress_callback=progress_callback, + now=now, + feature_key_to_save_index=feature_key_to_save_index, + feature_indices=feature_indices, + ), ) # Update manifest with checksums and feature indices @@ -811,6 +814,18 @@ def get_feature_deltas(self, change_name: str) -> list[FeatureDelta]: return self.change_tracking.feature_deltas.get(change_name, []) +@dataclass(slots=True) +class _BundleParallelSaveParams: + bundle: ProjectBundle + save_tasks: list[tuple[str, Path, dict[str, Any] | Feature]] + total_artifacts: int + max_workers: int + progress_callback: Callable[[int, int, str], None] | None + now: str + feature_key_to_save_index: dict[str, int] + feature_indices: list[FeatureIndex | None] + + def _build_bundle_save_tasks( bundle: ProjectBundle, bundle_dir: Path, @@ -840,27 +855,18 @@ def _build_bundle_save_tasks( return save_tasks -def _run_bundle_parallel_save( - bundle: ProjectBundle, - save_tasks: list[tuple[str, Path, dict[str, Any] | Feature]], - total_artifacts: int, - max_workers: int, - progress_callback: Callable[[int, int, str], None] | None, - now: str, - feature_key_to_save_index: dict[str, int], - feature_indices: list[FeatureIndex | None], -) -> dict[str, str]: +def _run_bundle_parallel_save(params: _BundleParallelSaveParams) -> dict[str, str]: completed_count = 0 checksums: dict[str, str] = {} - if not save_tasks: + if not params.save_tasks: return checksums - executor = ThreadPoolExecutor(max_workers=max_workers) + executor = ThreadPoolExecutor(max_workers=params.max_workers) interrupted = False wait_on_shutdown = os.environ.get("TEST_MODE") != "true" try: future_to_task = { executor.submit(_write_bundle_artifact_disk, name, path, data): (name, path, data) - for name, path, data in save_tasks + for name, path, data in params.save_tasks } try: @@ -870,16 +876,16 @@ def _run_bundle_parallel_save( completed_count += 1 checksums[artifact_name] = checksum - if progress_callback: - progress_callback(completed_count, total_artifacts, artifact_name) + if params.progress_callback: + params.progress_callback(completed_count, params.total_artifacts, artifact_name) _assign_feature_index_from_save( - bundle, + params.bundle, artifact_name, checksum, - now, - feature_key_to_save_index, - feature_indices, + params.now, + params.feature_key_to_save_index, + params.feature_indices, ) except KeyboardInterrupt: interrupted = True diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index aa9347f6..675c2e57 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.24 +version: 0.1.25 commands: - init category: core @@ -17,5 +17,5 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:eec17e6377c9a4a9d0abc4c3da81069d729ff4394b277016f5428d2e798ab1ef - signature: FLs/jr95lBtXCawAzBK08xb0LCPl/veYsSZMhq11KU+11ieyIg8j/GosyCOrpUsTKjb3j3xfZv1MmjERHn7mDg== + checksum: sha256:4b1d6a9399a5417a213f91a9bf0272986ed52ac8cca53f96b9e1899e028f90b4 + signature: CfTDUOZ7PrhgJ1+mFg228b+Pvn6crWYaF3hD+jQznXjbn304ONS1g1CePU4JrjBQmeE1RKdoldeAMTHqJ56JCQ== diff --git a/src/specfact_cli/modules/init/src/first_run_selection.py b/src/specfact_cli/modules/init/src/first_run_selection.py index 7bc7e0e1..a23a3f33 100644 --- a/src/specfact_cli/modules/init/src/first_run_selection.py +++ b/src/specfact_cli/modules/init/src/first_run_selection.py @@ -177,15 +177,18 @@ def _install_one_bundled_module_line(bid: str, module_name: str, deps: _InitBund def _install_marketplace_for_bundle(bid: str, marketplace_id: str, deps: _InitBundleInstallDeps) -> None: from specfact_cli.common import get_bridge_logger + from specfact_cli.registry.module_installer import InstallModuleOptions if deps.emit: deps.console.print(f"[dim] ·[/dim] Installing marketplace module [bold]{marketplace_id}[/bold] …") try: deps.install_module( marketplace_id, - install_root=deps.root, - non_interactive=deps.non_interactive, - trust_non_official=deps.trust_non_official, + InstallModuleOptions( + install_root=deps.root, + non_interactive=deps.non_interactive, + trust_non_official=deps.trust_non_official, + ), ) except Exception as e: logger = get_bridge_logger(__name__) diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml index c0cf77ba..90e3a3b3 100644 --- a/src/specfact_cli/modules/module_registry/module-package.yaml +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -1,5 +1,5 @@ name: module-registry -version: 0.1.17 +version: 0.1.18 commands: - module category: core @@ -17,5 +17,5 @@ publisher: description: 'Manage modules: search, list, show, install, and upgrade.' license: Apache-2.0 integrity: - checksum: sha256:9a16aa56293e9da54f6a6e11a7d596ffe254fab0fe23658966f606dba27abf94 - signature: LuwmQRpXtdH4GeKsT7cjm4TTNwnky2sVwuuFYoEzeh+V8BsBdjm4wm7WTTQMK5+KCy0q8erayaj4FOSiYRJcAQ== + checksum: sha256:913da1a90a94691366c71fc23e91cfc57c38ffb97e4ea739229fc9897cc91131 + signature: lclyeM5FB+AYl+VKScUXBi4+lBC8vSdXE7ki6L/m3C7TrW81x60It50jhcP0+VL3xlPPIPihIQ8KCh2NfWWVBg== diff --git a/src/specfact_cli/modules/module_registry/src/commands.py b/src/specfact_cli/modules/module_registry/src/commands.py index 709ec896..6a1d975e 100644 --- a/src/specfact_cli/modules/module_registry/src/commands.py +++ b/src/specfact_cli/modules/module_registry/src/commands.py @@ -29,6 +29,7 @@ from specfact_cli.registry.module_installer import ( REGISTRY_ID_FILE, USER_MODULES_ROOT, + InstallModuleOptions, get_bundled_module_metadata, install_bundled_module, install_module, @@ -303,13 +304,15 @@ def _install_one(module_id: str, params: _InstallOneParams) -> bool: try: installed_path = install_module( normalized, - version=params.version, - reinstall=params.reinstall, - install_root=params.target_root, - trust_non_official=params.trust_non_official, - non_interactive=is_non_interactive(), - skip_deps=params.skip_deps, - force=params.force, + InstallModuleOptions( + version=params.version, + reinstall=params.reinstall, + install_root=params.target_root, + trust_non_official=params.trust_non_official, + non_interactive=is_non_interactive(), + skip_deps=params.skip_deps, + force=params.force, + ), ) except Exception as exc: console.print(f"[red]Failed installing {normalized}: {exc}[/red]") @@ -1295,7 +1298,7 @@ def _run_one_marketplace_upgrade_target( if not latest_v: with _module_upgrade_status(f"[cyan]Upgrading[/cyan] [bold]{full_id}[/bold] …"): - installed_path = install_module(full_id, reinstall=True) + installed_path = install_module(full_id, InstallModuleOptions(reinstall=True)) accum.upgraded.append((full_id, current_v, _read_installed_module_version(installed_path))) return @@ -1304,7 +1307,7 @@ def _run_one_marketplace_upgrade_target( accum.skipped_major.append(skip_tuple) if should_install: with _module_upgrade_status(f"[cyan]Upgrading[/cyan] [bold]{full_id}[/bold] …"): - installed_path = install_module(full_id, reinstall=True) + installed_path = install_module(full_id, InstallModuleOptions(reinstall=True)) accum.upgraded.append((full_id, current_v, _read_installed_module_version(installed_path))) diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index f52c4f4c..932da97c 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -10,6 +10,7 @@ import sys import tarfile import tempfile +from dataclasses import dataclass from functools import lru_cache from pathlib import Path from typing import Any, cast @@ -37,6 +38,32 @@ USER_MODULES_ROOT = Path.home() / ".specfact" / "modules" + + +@dataclass(slots=True) +class InstallModuleOptions: + """Options for :func:`install_module`.""" + + version: str | None = None + reinstall: bool = False + install_root: Path | None = None + trust_non_official: bool = False + non_interactive: bool = False + skip_deps: bool = False + force: bool = False + + +@dataclass(slots=True) +class _BundleDepsInstallContext: + metadata: dict[str, Any] + metadata_obj: ModulePackageMetadata + target_root: Path + trust_non_official: bool + non_interactive: bool + force: bool + logger: Any + + MARKETPLACE_MODULES_ROOT = Path.home() / ".specfact" / "marketplace-modules" MODULE_DOWNLOAD_CACHE_ROOT = Path.home() / ".specfact" / "downloads" / "cache" _IGNORED_MODULE_DIR_NAMES = {"__pycache__", ".pytest_cache", ".mypy_cache", ".ruff_cache", "logs", "tests"} @@ -774,46 +801,39 @@ def _metadata_obj_from_install_dict(metadata: dict[str, Any], manifest_module_na ) -def _install_bundle_dependencies_for_module( - module_id: str, - metadata: dict[str, Any], - metadata_obj: ModulePackageMetadata, - target_root: Path, - trust_non_official: bool, - non_interactive: bool, - force: bool, - logger: Any, -) -> None: - for dependency_module_id in _extract_bundle_dependencies(metadata): +def _install_bundle_dependencies_for_module(module_id: str, ctx: _BundleDepsInstallContext) -> None: + for dependency_module_id in _extract_bundle_dependencies(ctx.metadata): if dependency_module_id == module_id: continue dependency_name = dependency_module_id.split("/", 1)[1] - dependency_manifest = target_root / dependency_name / "module-package.yaml" + dependency_manifest = ctx.target_root / dependency_name / "module-package.yaml" if dependency_manifest.exists(): dependency_version = _installed_dependency_version(dependency_manifest) - logger.info("Dependency %s already satisfied (version %s)", dependency_module_id, dependency_version) + ctx.logger.info("Dependency %s already satisfied (version %s)", dependency_module_id, dependency_version) continue try: install_module( dependency_module_id, - install_root=target_root, - trust_non_official=trust_non_official, - non_interactive=non_interactive, - skip_deps=False, - force=force, + InstallModuleOptions( + install_root=ctx.target_root, + trust_non_official=ctx.trust_non_official, + non_interactive=ctx.non_interactive, + skip_deps=False, + force=ctx.force, + ), ) except Exception as dep_exc: raise ValueError(f"Dependency install failed for {dependency_module_id}: {dep_exc}") from dep_exc try: all_metas = [e.metadata for e in discover_all_modules()] - all_metas.append(metadata_obj) + all_metas.append(ctx.metadata_obj) resolved = resolve_dependencies(all_metas, allow_unvalidated=True) except DependencyConflictError as dep_err: - if not force: + if not ctx.force: raise ValueError( f"Dependency conflict: {dep_err}. Use --force to bypass or --skip-deps to skip resolution." ) from dep_err - logger.warning("Dependency conflict bypassed by --force: %s", dep_err) + ctx.logger.warning("Dependency conflict bypassed by --force: %s", dep_err) return if not resolved: return @@ -866,18 +886,12 @@ def _atomic_place_verified_module( @ensure(lambda result: cast(Path, result).exists(), "Installed module path must exist") def install_module( module_id: str, - *, - version: str | None = None, - reinstall: bool = False, - install_root: Path | None = None, - trust_non_official: bool = False, - non_interactive: bool = False, - skip_deps: bool = False, - force: bool = False, + options: InstallModuleOptions | None = None, ) -> Path: """Install a marketplace module from tarball into canonical user modules root.""" + o = options or InstallModuleOptions() logger = get_bridge_logger(__name__) - target_root = install_root or USER_MODULES_ROOT + target_root = o.install_root or USER_MODULES_ROOT target_root.mkdir(parents=True, exist_ok=True) _validate_marketplace_namespace_format(module_id) @@ -885,15 +899,15 @@ def install_module( final_path = target_root / module_name manifest_path = final_path / "module-package.yaml" - _check_namespace_collision(module_id, final_path, reinstall) - if manifest_path.exists() and not reinstall: + _check_namespace_collision(module_id, final_path, o.reinstall) + if manifest_path.exists() and not o.reinstall: logger.debug("Module already installed (%s)", module_name) return final_path - if reinstall: + if o.reinstall: _clear_reinstall_download_cache(module_id, logger) - archive_path = _download_archive_with_cache(module_id, version=version) + archive_path = _download_archive_with_cache(module_id, version=o.version) with tempfile.TemporaryDirectory(prefix="specfact-module-install-") as tmp_dir: tmp_dir_path = Path(tmp_dir) @@ -904,20 +918,22 @@ def install_module( extracted_module_dir, metadata = _load_first_extracted_module_manifest(extract_root) manifest_module_name = _validate_install_manifest_constraints( - metadata, module_name, trust_non_official, non_interactive + metadata, module_name, o.trust_non_official, o.non_interactive ) metadata_obj = _metadata_obj_from_install_dict(metadata, manifest_module_name) - if not skip_deps: + if not o.skip_deps: _install_bundle_dependencies_for_module( module_id, - metadata, - metadata_obj, - target_root, - trust_non_official, - non_interactive, - force, - logger, + _BundleDepsInstallContext( + metadata=metadata, + metadata_obj=metadata_obj, + target_root=target_root, + trust_non_official=o.trust_non_official, + non_interactive=o.non_interactive, + force=o.force, + logger=logger, + ), ) _atomic_place_verified_module( diff --git a/src/specfact_cli/sync/bridge_watch.py b/src/specfact_cli/sync/bridge_watch.py index 78081c92..51b2b583 100644 --- a/src/specfact_cli/sync/bridge_watch.py +++ b/src/specfact_cli/sync/bridge_watch.py @@ -27,6 +27,19 @@ _logger = get_bridge_logger(__name__) +def _match_feature_id_from_pattern_parts(pattern_parts: list[str], file_parts: list[str]) -> str | None: + try: + feature_id_index = pattern_parts.index("{feature_id}") + except ValueError: + return None + if feature_id_index >= len(file_parts): + return None + for i in range(feature_id_index): + if i >= len(file_parts) or pattern_parts[i] != file_parts[i]: + return None + return file_parts[feature_id_index] + + class _RunningObserver(Protocol): @require(lambda self, handler, path: isinstance(path, str)) @ensure(lambda result: result is None) @@ -288,29 +301,12 @@ def _extract_feature_id_from_path(self, file_path: Path) -> str | None: for _artifact_key, artifact in self.bridge_config.artifacts.items(): pattern = artifact.path_pattern - # Simple extraction (could be enhanced with regex) - if "{feature_id}" in pattern: - # Extract feature_id from path (e.g., "specs/001-auth/spec.md" -> "001-auth") - # Pattern format: "specs/{feature_id}/spec.md" or "docs/specs/{feature_id}/spec.md" - pattern_parts = pattern.split("/") - - # Find where {feature_id} appears in pattern - try: - feature_id_index = pattern_parts.index("{feature_id}") - # Find corresponding part in file path - # Match pattern parts before {feature_id} to file path - if feature_id_index < len(file_parts): - # Check if preceding parts match - matches = True - for i in range(feature_id_index): - if i < len(file_parts) and pattern_parts[i] != file_parts[i]: - matches = False - break - if matches and feature_id_index < len(file_parts): - return file_parts[feature_id_index] - except ValueError: - # {feature_id} not in pattern - continue + if "{feature_id}" not in pattern: + continue + pattern_parts = pattern.split("/") + feature_id = _match_feature_id_from_pattern_parts(pattern_parts, file_parts) + if feature_id is not None: + return feature_id return None @beartype diff --git a/src/specfact_cli/utils/source_scanner.py b/src/specfact_cli/utils/source_scanner.py index 6f925bf4..4a6a17cb 100644 --- a/src/specfact_cli/utils/source_scanner.py +++ b/src/specfact_cli/utils/source_scanner.py @@ -114,32 +114,30 @@ class SourceArtifactMap: test_mappings: dict[str, list[str]] = field(default_factory=dict) # "test_file.py::test_func" -> [story_keys] -def _resolve_linking_caches( - file_functions_cache: dict[str, list[str]] | None, - file_test_functions_cache: dict[str, list[str]] | None, - file_hashes_cache: dict[str, str] | None, - impl_files_by_stem: dict[str, list[Path]] | None, - test_files_by_stem: dict[str, list[Path]] | None, - impl_stems_by_substring: dict[str, set[str]] | None, - test_stems_by_substring: dict[str, set[str]] | None, -) -> tuple[ - dict[str, list[str]], - dict[str, list[str]], - dict[str, str], - dict[str, list[Path]], - dict[str, list[Path]], - dict[str, set[str]], - dict[str, set[str]], -]: - return ( - file_functions_cache or {}, - file_test_functions_cache or {}, - file_hashes_cache or {}, - impl_files_by_stem or {}, - test_files_by_stem or {}, - impl_stems_by_substring or {}, - test_stems_by_substring or {}, - ) +@dataclass(slots=True) +class _FeatureLinkingContext: + repo_path: Path + impl_files: list[Path] + test_files: list[Path] + file_functions_cache: dict[str, list[str]] + file_test_functions_cache: dict[str, list[str]] + file_hashes_cache: dict[str, str] + impl_files_by_stem: dict[str, list[Path]] + test_files_by_stem: dict[str, list[Path]] + impl_stems_by_substring: dict[str, set[str]] + test_stems_by_substring: dict[str, set[str]] + + +@dataclass(slots=True) +class _ImplTestPathLinkArgs: + feature_key_lower: str + feature_title_words: list[str] + repo_path: Path + file_hashes_cache: dict[str, str] + impl_files_by_stem: dict[str, list[Path]] + test_files_by_stem: dict[str, list[Path]] + impl_stems_by_substring: dict[str, set[str]] + test_stems_by_substring: dict[str, set[str]] class SourceArtifactScanner: @@ -259,55 +257,46 @@ def _register_matched_files( def _link_feature_impl_and_test_paths( self, - feature_key_lower: str, - feature_title_words: list[str], - impl_files_by_stem: dict[str, list[Path]], - test_files_by_stem: dict[str, list[Path]], - impl_stems_by_substring: dict[str, set[str]], - test_stems_by_substring: dict[str, set[str]], - repo_path: Path, source_tracking: SourceTracking, - file_hashes_cache: dict[str, str], + args: _ImplTestPathLinkArgs, ) -> None: matched_impl = self._resolve_matched_paths( - feature_key_lower, feature_title_words, impl_files_by_stem, impl_stems_by_substring, repo_path + args.feature_key_lower, + args.feature_title_words, + args.impl_files_by_stem, + args.impl_stems_by_substring, + args.repo_path, ) self._register_matched_files( - matched_impl, source_tracking.implementation_files, source_tracking, file_hashes_cache, repo_path + matched_impl, + source_tracking.implementation_files, + source_tracking, + args.file_hashes_cache, + args.repo_path, ) matched_test = self._resolve_matched_paths( - feature_key_lower, feature_title_words, test_files_by_stem, test_stems_by_substring, repo_path + args.feature_key_lower, + args.feature_title_words, + args.test_files_by_stem, + args.test_stems_by_substring, + args.repo_path, ) self._register_matched_files( - matched_test, source_tracking.test_files, source_tracking, file_hashes_cache, repo_path + matched_test, + source_tracking.test_files, + source_tracking, + args.file_hashes_cache, + args.repo_path, ) - def _link_feature_to_specs( - self, - feature: Feature, - repo_path: Path, - impl_files: list[Path], - test_files: list[Path], - file_functions_cache: dict[str, list[str]] | None = None, - file_test_functions_cache: dict[str, list[str]] | None = None, - file_hashes_cache: dict[str, str] | None = None, - impl_files_by_stem: dict[str, list[Path]] | None = None, - test_files_by_stem: dict[str, list[Path]] | None = None, - impl_stems_by_substring: dict[str, set[str]] | None = None, - test_stems_by_substring: dict[str, set[str]] | None = None, - ) -> None: + def _link_feature_to_specs(self, feature: Feature, ctx: _FeatureLinkingContext) -> None: """ Link a single feature to matching files (thread-safe helper). Args: feature: Feature to link - repo_path: Repository path - impl_files: Pre-collected implementation files - test_files: Pre-collected test files - file_functions_cache: Pre-computed function mappings cache (file_path -> [functions]) - file_test_functions_cache: Pre-computed test function mappings cache (file_path -> [test_functions]) - file_hashes_cache: Pre-computed file hashes cache (file_path -> hash) + ctx: Pre-computed repository file caches and indexes for linking. """ if feature.source_tracking is None: feature.source_tracking = SourceTracking() @@ -315,46 +304,30 @@ def _link_feature_to_specs( if source_tracking is None: return - ( - file_functions_cache, - file_test_functions_cache, - file_hashes_cache, - impl_files_by_stem, - test_files_by_stem, - impl_stems_by_substring, - test_stems_by_substring, - ) = _resolve_linking_caches( - file_functions_cache, - file_test_functions_cache, - file_hashes_cache, - impl_files_by_stem, - test_files_by_stem, - impl_stems_by_substring, - test_stems_by_substring, - ) - feature_key_lower = feature.key.lower() feature_title_words = [w for w in feature.title.lower().split() if len(w) > 3] self._link_feature_impl_and_test_paths( - feature_key_lower, - feature_title_words, - impl_files_by_stem, - test_files_by_stem, - impl_stems_by_substring, - test_stems_by_substring, - repo_path, source_tracking, - file_hashes_cache, + _ImplTestPathLinkArgs( + feature_key_lower=feature_key_lower, + feature_title_words=feature_title_words, + repo_path=ctx.repo_path, + file_hashes_cache=ctx.file_hashes_cache, + impl_files_by_stem=ctx.impl_files_by_stem, + test_files_by_stem=ctx.test_files_by_stem, + impl_stems_by_substring=ctx.impl_stems_by_substring, + test_stems_by_substring=ctx.test_stems_by_substring, + ), ) for story in feature.stories: self._collect_story_function_mappings( story, - repo_path, + ctx.repo_path, source_tracking, - file_functions_cache, - file_test_functions_cache, + ctx.file_functions_cache, + ctx.file_test_functions_cache, ) # Update sync timestamp @@ -461,19 +434,19 @@ def link_to_specs(self, features: list[Feature], repo_path: Path | None = None) f"[dim]✓ Cached {len(file_functions_cache)} implementation files, {len(file_test_functions_cache)} test files[/dim]" ) - self._run_parallel_feature_linking( - features, - repo_path, - impl_files, - test_files, - file_functions_cache, - file_test_functions_cache, - file_hashes_cache, - impl_files_by_stem, - test_files_by_stem, - impl_stems_by_substring, - test_stems_by_substring, + linking_ctx = _FeatureLinkingContext( + repo_path=repo_path, + impl_files=impl_files, + test_files=test_files, + file_functions_cache=file_functions_cache, + file_test_functions_cache=file_test_functions_cache, + file_hashes_cache=file_hashes_cache, + impl_files_by_stem=impl_files_by_stem, + test_files_by_stem=test_files_by_stem, + impl_stems_by_substring=impl_stems_by_substring, + test_stems_by_substring=test_stems_by_substring, ) + self._run_parallel_feature_linking(features, linking_ctx) def _index_impl_file_for_link_cache( self, @@ -558,16 +531,7 @@ def _index_test_file_for_link_cache( def _run_parallel_feature_linking( self, features: list[Feature], - repo_path: Path, - impl_files: list[Path], - test_files: list[Path], - file_functions_cache: dict[str, list[str]], - file_test_functions_cache: dict[str, list[str]], - file_hashes_cache: dict[str, str], - impl_files_by_stem: dict[str, list[Path]], - test_files_by_stem: dict[str, list[Path]], - impl_stems_by_substring: dict[str, set[str]], - test_stems_by_substring: dict[str, set[str]], + ctx: _FeatureLinkingContext, ) -> None: if os.environ.get("TEST_MODE") == "true": max_workers = max(1, min(2, len(features))) @@ -591,21 +555,7 @@ def _run_parallel_feature_linking( try: future_to_feature = { - executor.submit( - self._link_feature_to_specs, - feature, - repo_path, - impl_files, - test_files, - file_functions_cache, - file_test_functions_cache, - file_hashes_cache, - impl_files_by_stem, - test_files_by_stem, - impl_stems_by_substring, - test_stems_by_substring, - ): feature - for feature in features + executor.submit(self._link_feature_to_specs, feature, ctx): feature for feature in features } interrupted = _drain_feature_link_futures(future_to_feature, progress, task, len(features)) if interrupted: diff --git a/src/specfact_cli/validators/cli_first_validator.py b/src/specfact_cli/validators/cli_first_validator.py index d9b98d8a..1bcc3524 100644 --- a/src/specfact_cli/validators/cli_first_validator.py +++ b/src/specfact_cli/validators/cli_first_validator.py @@ -188,19 +188,21 @@ def detect_direct_manipulation(specfact_dir: Path) -> list[Path]: # Check project bundles projects_dir = specfact_dir / "projects" - if projects_dir.exists(): - for bundle_dir in projects_dir.iterdir(): - if bundle_dir.is_dir(): - # Check bundle manifest - manifest_file = bundle_dir / "bundle.manifest.yaml" - if manifest_file.exists() and not is_cli_generated(manifest_file): - suspicious_files.append(manifest_file) - - # Check feature files - features_dir = bundle_dir / "features" - if features_dir.exists(): - for feature_file in features_dir.glob("*.yaml"): - if not is_cli_generated(feature_file): - suspicious_files.append(feature_file) + if not projects_dir.exists(): + return suspicious_files + + for bundle_dir in projects_dir.iterdir(): + if not bundle_dir.is_dir(): + continue + manifest_file = bundle_dir / "bundle.manifest.yaml" + if manifest_file.exists() and not is_cli_generated(manifest_file): + suspicious_files.append(manifest_file) + + features_dir = bundle_dir / "features" + if not features_dir.exists(): + continue + for feature_file in features_dir.glob("*.yaml"): + if not is_cli_generated(feature_file): + suspicious_files.append(feature_file) return suspicious_files diff --git a/src/specfact_cli/validators/sidecar/frameworks/fastapi.py b/src/specfact_cli/validators/sidecar/frameworks/fastapi.py index 29b8f8aa..45f83385 100644 --- a/src/specfact_cli/validators/sidecar/frameworks/fastapi.py +++ b/src/specfact_cli/validators/sidecar/frameworks/fastapi.py @@ -159,6 +159,21 @@ def _extract_imports(self, tree: ast.AST) -> dict[str, str]: imports[alias_name] = alias.name return imports + def _method_and_path_from_route_decorator(self, decorator: ast.Call) -> tuple[str, str] | None: + func = decorator.func + if isinstance(func, ast.Attribute): + method = func.attr.upper() + elif isinstance(func, ast.Name): + method = func.id.upper() + else: + return None + path = "/" + if decorator.args: + path_arg = self._extract_string_literal(decorator.args[0]) + if path_arg: + path = path_arg + return method, path + @beartype def _extract_route_from_function( self, func_node: ast.FunctionDef, imports: dict[str, str], py_file: Path @@ -168,23 +183,13 @@ def _extract_route_from_function( method = "GET" operation_id = func_node.name - # Check decorators for route information for decorator in func_node.decorator_list: - if isinstance(decorator, ast.Call): - if isinstance(decorator.func, ast.Attribute): - # @app.get(), @app.post(), etc. - method = decorator.func.attr.upper() - if decorator.args: - path_arg = self._extract_string_literal(decorator.args[0]) - if path_arg: - path = path_arg - elif isinstance(decorator.func, ast.Name): - # @get(), @post(), etc. - method = decorator.func.id.upper() - if decorator.args: - path_arg = self._extract_string_literal(decorator.args[0]) - if path_arg: - path = path_arg + if not isinstance(decorator, ast.Call): + continue + mp = self._method_and_path_from_route_decorator(decorator) + if mp is None: + continue + method, path = mp normalized_path, path_params = self._extract_path_parameters(path) diff --git a/tests/e2e/test_bundle_extraction_e2e.py b/tests/e2e/test_bundle_extraction_e2e.py index b937a990..8c4cbb73 100644 --- a/tests/e2e/test_bundle_extraction_e2e.py +++ b/tests/e2e/test_bundle_extraction_e2e.py @@ -104,10 +104,13 @@ def test_publish_install_verify_roundtrip_for_specfact_codebase(monkeypatch, tmp monkeypatch.setattr("specfact_cli.registry.module_installer.assert_module_allowed", lambda *_a, **_k: None) monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_a, **_k: tarball) - from specfact_cli.registry.module_installer import install_module + from specfact_cli.registry.module_installer import InstallModuleOptions, install_module install_root = tmp_path / ".specfact" / "modules" - installed_path = install_module("nold-ai/specfact-codebase", install_root=install_root) + installed_path = install_module( + "nold-ai/specfact-codebase", + InstallModuleOptions(install_root=install_root), + ) assert (installed_path / "module-package.yaml").exists() signature_file = next((registry_dir / "signatures").glob("*.sig")) diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py index 56ba1827..0d123e6f 100644 --- a/tests/unit/registry/test_module_installer.py +++ b/tests/unit/registry/test_module_installer.py @@ -11,7 +11,7 @@ from specfact_cli.models.module_package import IntegrityInfo, ModulePackageMetadata from specfact_cli.registry import module_installer -from specfact_cli.registry.module_installer import install_module, uninstall_module +from specfact_cli.registry.module_installer import InstallModuleOptions, install_module, uninstall_module @pytest.fixture(autouse=True) @@ -66,7 +66,7 @@ def test_install_module_downloads_extracts_and_registers(monkeypatch, tmp_path: monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) install_root = tmp_path / "marketplace-modules" - installed = install_module("specfact/backlog", install_root=install_root) + installed = install_module("specfact/backlog", InstallModuleOptions(install_root=install_root)) assert installed.exists() assert (installed / "module-package.yaml").exists() @@ -78,7 +78,7 @@ def test_install_module_to_default_marketplace_path(monkeypatch, tmp_path: Path) monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) install_root = tmp_path / "marketplace-modules" - installed = install_module("specfact/drift", install_root=install_root) + installed = install_module("specfact/drift", InstallModuleOptions(install_root=install_root)) assert installed == install_root / "drift" @@ -87,8 +87,8 @@ def test_install_module_already_installed_returns_existing(monkeypatch, tmp_path monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) install_root = tmp_path / "marketplace-modules" - first_install = install_module("specfact/sync", install_root=install_root) - second_install = install_module("specfact/sync", install_root=install_root) + first_install = install_module("specfact/sync", InstallModuleOptions(install_root=install_root)) + second_install = install_module("specfact/sync", InstallModuleOptions(install_root=install_root)) assert first_install == second_install @@ -103,8 +103,11 @@ def _download(_module_id: str, version: str | None = None) -> Path: monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", _download) install_root = tmp_path / "marketplace-modules" - install_module("specfact/sync", install_root=install_root, version="0.1.0") - install_module("specfact/sync", install_root=install_root, version="0.2.0", reinstall=True) + install_module("specfact/sync", InstallModuleOptions(install_root=install_root, version="0.1.0")) + install_module( + "specfact/sync", + InstallModuleOptions(install_root=install_root, version="0.2.0", reinstall=True), + ) manifest = (install_root / "sync" / "module-package.yaml").read_text(encoding="utf-8") assert "version: '0.2.0'" in manifest @@ -138,7 +141,10 @@ def test_install_module_logs_satisfied_dependencies_without_warning(monkeypatch, encoding="utf-8", ) - installed = install_module("nold-ai/specfact-backlog", install_root=install_root, reinstall=True) + installed = install_module( + "nold-ai/specfact-backlog", + InstallModuleOptions(install_root=install_root, reinstall=True), + ) assert installed.exists() mock_logger.warning.assert_not_called() @@ -165,7 +171,10 @@ def test_install_module_rejects_archive_path_traversal(monkeypatch, tmp_path: Pa monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) with pytest.raises(ValueError, match="unsafe archive"): - install_module("specfact/policy", install_root=tmp_path / "marketplace-modules") + install_module( + "specfact/policy", + InstallModuleOptions(install_root=tmp_path / "marketplace-modules"), + ) def test_install_module_rejects_invalid_namespace_format(monkeypatch, tmp_path: Path) -> None: @@ -175,7 +184,7 @@ def test_install_module_rejects_invalid_namespace_format(monkeypatch, tmp_path: install_root = tmp_path / "marketplace-modules" for invalid_id in ("NoCap/backlog", "specfact/Backlog", "123/name"): with pytest.raises(ValueError, match=r"namespace/name|Marketplace module id"): - install_module(invalid_id, install_root=install_root) + install_module(invalid_id, InstallModuleOptions(install_root=install_root)) def test_install_module_accepts_valid_namespace_format(monkeypatch, tmp_path: Path) -> None: @@ -183,11 +192,11 @@ def test_install_module_accepts_valid_namespace_format(monkeypatch, tmp_path: Pa tarball = _create_module_tarball(tmp_path, "backlog") monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) install_root = tmp_path / "marketplace-modules" - install_module("specfact/backlog", install_root=install_root) + install_module("specfact/backlog", InstallModuleOptions(install_root=install_root)) assert (install_root / "backlog" / "module-package.yaml").exists() tarball2 = _create_module_tarball(tmp_path, "backlog-pro", module_version="0.1.0") monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball2) - install_module("acme-corp/backlog-pro", install_root=install_root) + install_module("acme-corp/backlog-pro", InstallModuleOptions(install_root=install_root)) assert (install_root / "backlog-pro" / "module-package.yaml").exists() @@ -196,11 +205,11 @@ def test_install_module_namespace_collision_raises(monkeypatch, tmp_path: Path) tarball = _create_module_tarball(tmp_path, "backlog") monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) install_root = tmp_path / "marketplace-modules" - install_module("specfact/backlog", install_root=install_root) + install_module("specfact/backlog", InstallModuleOptions(install_root=install_root)) tarball2 = _create_module_tarball(tmp_path, "backlog", module_version="0.2.0") monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball2) with pytest.raises(ValueError, match=r"namespace collision|conflicts with existing"): - install_module("acme-corp/backlog", install_root=install_root) + install_module("acme-corp/backlog", InstallModuleOptions(install_root=install_root)) def test_uninstall_module_removes_marketplace_module(tmp_path: Path) -> None: @@ -226,7 +235,10 @@ def test_install_module_validates_core_compatibility(monkeypatch, tmp_path: Path monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) with pytest.raises(ValueError, match="requires SpecFact CLI"): - install_module("specfact/policy", install_root=tmp_path / "marketplace-modules") + install_module( + "specfact/policy", + InstallModuleOptions(install_root=tmp_path / "marketplace-modules"), + ) def test_install_module_defaults_to_user_modules_root(monkeypatch, tmp_path: Path) -> None: @@ -250,7 +262,7 @@ def test_install_module_rejects_denylisted_module(monkeypatch, tmp_path: Path) - ) with pytest.raises(ValueError, match="denylisted module"): - install_module("specfact/blocked", install_root=tmp_path / "modules") + install_module("specfact/blocked", InstallModuleOptions(install_root=tmp_path / "modules")) def test_sync_bundled_modules_rejects_denylisted_module(monkeypatch, tmp_path: Path) -> None: diff --git a/tests/unit/validators/test_bundle_dependency_install.py b/tests/unit/validators/test_bundle_dependency_install.py index 2fa5109f..e9902ce2 100644 --- a/tests/unit/validators/test_bundle_dependency_install.py +++ b/tests/unit/validators/test_bundle_dependency_install.py @@ -8,7 +8,7 @@ import pytest -from specfact_cli.registry.module_installer import install_module +from specfact_cli.registry.module_installer import InstallModuleOptions, install_module def _create_module_tarball( @@ -79,7 +79,7 @@ def _download(module_id: str, version: str | None = None) -> Path: monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", _download) install_root = tmp_path / "modules" - install_module("nold-ai/specfact-spec", install_root=install_root) + install_module("nold-ai/specfact-spec", InstallModuleOptions(install_root=install_root)) assert calls[:2] == ["nold-ai/specfact-spec", "nold-ai/specfact-project"] assert (install_root / "specfact-project" / "module-package.yaml").exists() @@ -110,7 +110,7 @@ def _download(module_id: str, version: str | None = None) -> Path: monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", _download) install_root = tmp_path / "modules" - install_module("nold-ai/specfact-govern", install_root=install_root) + install_module("nold-ai/specfact-govern", InstallModuleOptions(install_root=install_root)) assert calls[:2] == ["nold-ai/specfact-govern", "nold-ai/specfact-project"] assert (install_root / "specfact-project" / "module-package.yaml").exists() @@ -144,7 +144,7 @@ def _download(module_id: str, version: str | None = None) -> Path: encoding="utf-8", ) - install_module("nold-ai/specfact-spec", install_root=install_root) + install_module("nold-ai/specfact-spec", InstallModuleOptions(install_root=install_root)) assert calls == ["nold-ai/specfact-spec"] info_messages = " ".join(str(call.args[0]) for call in mock_logger.info.call_args_list) @@ -175,7 +175,7 @@ def _download(module_id: str, version: str | None = None) -> Path: install_root = tmp_path / "modules" with pytest.raises(ValueError, match="Dependency install failed"): - install_module("nold-ai/specfact-spec", install_root=install_root) + install_module("nold-ai/specfact-spec", InstallModuleOptions(install_root=install_root)) assert not (install_root / "specfact-spec").exists() @@ -204,7 +204,7 @@ def test_offline_install_uses_cached_tarball_when_registry_is_unavailable( ) install_root = tmp_path / "modules" - install_module("nold-ai/specfact-spec", install_root=install_root) + install_module("nold-ai/specfact-spec", InstallModuleOptions(install_root=install_root)) assert (install_root / "specfact-project" / "module-package.yaml").exists() assert (install_root / "specfact-spec" / "module-package.yaml").exists() From b6867c695493e2627f8cf9a9e745b77a1ee88224 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 23:43:17 +0200 Subject: [PATCH 11/12] fix(tests): register dynamic check_doc_frontmatter module; align _update_cache tests - Insert check_doc_frontmatter into sys.modules before exec_module so dataclasses can resolve string annotations (fixes Docs Review / agent rules governance fixture). - Call SmartCoverageManager._update_cache with _SmartCacheUpdate after signature refactor (fixes basedpyright reportCallIssue). Made-with: Cursor --- tests/helpers/doc_frontmatter.py | 4 ++++ tests/unit/tools/test_smart_test_coverage.py | 8 ++++---- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/tests/helpers/doc_frontmatter.py b/tests/helpers/doc_frontmatter.py index 357d740c..1b7709f3 100644 --- a/tests/helpers/doc_frontmatter.py +++ b/tests/helpers/doc_frontmatter.py @@ -4,6 +4,7 @@ import datetime import importlib.util +import sys from collections.abc import Callable from pathlib import Path from typing import cast @@ -23,6 +24,9 @@ def load_check_doc_frontmatter_module() -> CheckDocFrontmatterModule: if spec is None or spec.loader is None: raise AssertionError(f"Unable to load module from {script_path}") module = importlib.util.module_from_spec(spec) + # Register before exec_module so dataclasses/string annotations can resolve + # cls.__module__ via sys.modules (matches normal import behavior). + sys.modules[spec.name] = module spec.loader.exec_module(module) dfm = getattr(module, "DocFrontmatter", None) if dfm is not None: diff --git a/tests/unit/tools/test_smart_test_coverage.py b/tests/unit/tools/test_smart_test_coverage.py index f10de220..db521553 100644 --- a/tests/unit/tools/test_smart_test_coverage.py +++ b/tests/unit/tools/test_smart_test_coverage.py @@ -30,7 +30,7 @@ import contextlib -from tools.smart_test_coverage import SmartCoverageManager +from tools.smart_test_coverage import SmartCoverageManager, _SmartCacheUpdate class TestSmartCoverageManager: @@ -354,7 +354,7 @@ def test_update_cache(self): test_count = 150 coverage_percentage = 85.5 - self.manager._update_cache(success, test_count, coverage_percentage) + self.manager._update_cache(_SmartCacheUpdate(success, test_count, coverage_percentage)) assert self.manager.cache["last_full_run"] is not None assert self.manager.cache["coverage_percentage"] == 85.5 @@ -675,7 +675,7 @@ def test_update_cache_with_threshold_error(self): # Try to update with low coverage with pytest.raises(Exception) as exc_info: - self.manager._update_cache(True, 100, 75.0) + self.manager._update_cache(_SmartCacheUpdate(True, 100, 75.0)) assert "Coverage 75.0% is below required threshold" in str(exc_info.value) @@ -937,7 +937,7 @@ def test_update_cache_includes_config_hashes(self): test_count = 150 coverage_percentage = 85.5 - self.manager._update_cache(success, test_count, coverage_percentage) + self.manager._update_cache(_SmartCacheUpdate(success, test_count, coverage_percentage)) assert self.manager.cache["last_full_run"] is not None assert self.manager.cache["coverage_percentage"] == 85.5 From 337a6bdf0d1117b944d6ba47a44800f4f58f8f4a Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Sun, 12 Apr 2026 23:49:52 +0200 Subject: [PATCH 12/12] fix(tests): align install_module mocks with InstallModuleOptions; register verify_bundle script - Monkeypatch/patch fakes now accept (module_id, options=None) matching install_module(module_id, InstallModuleOptions(...)). - Read install_root, trust_non_official, non_interactive, reinstall from InstallModuleOptions in CLI command tests. - Dynamic load of verify-bundle-published registers sys.modules before exec_module (same dataclass annotation issue as check_doc_frontmatter). Made-with: Cursor --- .../modules/init/test_first_run_selection.py | 2 +- .../modules/module_registry/test_commands.py | 47 ++++++++++--------- .../scripts/test_verify_bundle_published.py | 2 + .../test_module_upgrade_improvements.py | 11 +++-- .../test_multi_module_install_uninstall.py | 8 ++-- .../registry/test_profile_presets.py | 4 +- 6 files changed, 39 insertions(+), 35 deletions(-) diff --git a/tests/unit/modules/init/test_first_run_selection.py b/tests/unit/modules/init/test_first_run_selection.py index f2140c19..c53097fa 100644 --- a/tests/unit/modules/init/test_first_run_selection.py +++ b/tests/unit/modules/init/test_first_run_selection.py @@ -391,7 +391,7 @@ def _fake_install(bundle_ids: list[str], install_root: Path, **kwargs: object) - def test_spec_bundle_install_includes_project_dep(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: installed_ids: list[str] = [] - def _record_marketplace(module_id: str, **kwargs: object) -> Path: + def _record_marketplace(module_id: str, options: object | None = None, **_kwargs: object) -> Path: installed_ids.append(module_id) return tmp_path / module_id.split("/")[1] diff --git a/tests/unit/modules/module_registry/test_commands.py b/tests/unit/modules/module_registry/test_commands.py index 3e381631..d236d92e 100644 --- a/tests/unit/modules/module_registry/test_commands.py +++ b/tests/unit/modules/module_registry/test_commands.py @@ -9,7 +9,7 @@ from specfact_cli.models.module_package import ModulePackageMetadata from specfact_cli.modules.module_registry.src.commands import app -from specfact_cli.registry.module_installer import USER_MODULES_ROOT +from specfact_cli.registry.module_installer import USER_MODULES_ROOT, InstallModuleOptions runner = CliRunner() @@ -28,7 +28,7 @@ def test_install_command_integration(monkeypatch, tmp_path: Path) -> None: monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) monkeypatch.setattr( "specfact_cli.modules.module_registry.src.commands.install_module", - lambda module_id, version=None, install_root=None, **_kwargs: tmp_path / module_id.split("/")[-1], + lambda module_id, options=None, **_kwargs: tmp_path / module_id.split("/")[-1], ) result = runner.invoke(app, ["install", "specfact/backlog"]) @@ -41,7 +41,7 @@ def test_install_command_integration(monkeypatch, tmp_path: Path) -> None: def test_install_command_accepts_bare_module_name(monkeypatch, tmp_path: Path) -> None: captured: dict[str, str | None] = {"module_id": None} - def _install(module_id: str, version=None, install_root=None, **_kwargs): + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): captured["module_id"] = module_id return tmp_path / module_id.split("/")[-1] @@ -80,7 +80,7 @@ class _Entry: called = {"install": False} - def _install(module_id: str, version=None, **_kwargs): + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): called["install"] = True return tmp_path / module_id.split("/")[-1] @@ -97,9 +97,10 @@ def _install(module_id: str, version=None, **_kwargs): def test_install_command_project_scope_installs_to_project_modules_root(monkeypatch, tmp_path: Path) -> None: captured: dict[str, object] = {"install_root": None, "module_id": None} - def _install(module_id: str, version=None, install_root=None, **_kwargs): + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): + o = options or InstallModuleOptions() captured["module_id"] = module_id - captured["install_root"] = install_root + captured["install_root"] = o.install_root return tmp_path / "installed" monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) @@ -169,7 +170,7 @@ class _Entry: called = {"marketplace": False} - def _install_marketplace(module_id: str, version=None, install_root=None, **_kwargs): + def _install_marketplace(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): called["marketplace"] = True return tmp_path / module_id.split("/")[-1] @@ -200,7 +201,7 @@ def _bundled(*_args, **_kwargs): called["bundled"] = True return True - def _marketplace(module_id: str, version=None, install_root=None, **_kwargs): + def _marketplace(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): called["marketplace"] = True return tmp_path / module_id.split("/")[-1] @@ -220,10 +221,9 @@ def test_install_command_requires_explicit_trust_for_non_official_in_non_interac monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.discover_all_modules", list) monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.is_non_interactive", lambda: True) - def _install( - module_id: str, version=None, install_root=None, trust_non_official=False, non_interactive=False, **kwargs - ): - if not trust_non_official and non_interactive: + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): + o = options or InstallModuleOptions() + if not o.trust_non_official and o.non_interactive: raise ValueError("requires --trust-non-official") return tmp_path / module_id.split("/")[-1] @@ -245,11 +245,10 @@ def test_install_command_passes_trust_flag_to_marketplace_installer(monkeypatch, monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.is_non_interactive", lambda: True) captured: dict[str, bool | None] = {"trust_non_official": None, "non_interactive": None} - def _install( - module_id: str, version=None, install_root=None, trust_non_official=False, non_interactive=False, **kwargs - ): - captured["trust_non_official"] = trust_non_official - captured["non_interactive"] = non_interactive + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): + o = options or InstallModuleOptions() + captured["trust_non_official"] = o.trust_non_official + captured["non_interactive"] = o.non_interactive return tmp_path / module_id.split("/")[-1] monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install) @@ -1014,8 +1013,9 @@ def test_show_command_fails_for_unknown_module(monkeypatch) -> None: def test_upgrade_command(monkeypatch, tmp_path: Path) -> None: captured: dict[str, bool | None] = {"reinstall": None} - def _install(module_id: str, version=None, reinstall: bool = False): - captured["reinstall"] = reinstall + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): + o = options or InstallModuleOptions() + captured["reinstall"] = o.reinstall return tmp_path / module_id.split("/")[-1] monkeypatch.setattr( @@ -1038,9 +1038,10 @@ def test_upgrade_without_module_name_upgrades_all_marketplace(monkeypatch, tmp_p installed: list[str] = [] reinstall_flags: list[bool] = [] - def _install(module_id: str, version=None, reinstall: bool = False): + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): + o = options or InstallModuleOptions() installed.append(module_id) - reinstall_flags.append(reinstall) + reinstall_flags.append(o.reinstall) return tmp_path / module_id.split("/")[-1] monkeypatch.setattr("specfact_cli.modules.module_registry.src.commands.install_module", _install) @@ -1063,7 +1064,7 @@ def _install(module_id: str, version=None, reinstall: bool = False): def test_upgrade_without_module_name_reports_one_line_per_module_with_versions(monkeypatch, tmp_path: Path) -> None: installed: list[str] = [] - def _install(module_id: str, version=None, reinstall: bool = False): + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): installed.append(module_id) module_dir = tmp_path / module_id.split("/")[-1] module_dir.mkdir(parents=True, exist_ok=True) @@ -1108,7 +1109,7 @@ def test_upgrade_rejects_multi_segment_module_id(monkeypatch, tmp_path: Path) -> """Malformed owner/repo/extra must not resolve via last-segment fallback to a different module.""" installed: list[str] = [] - def _install(module_id: str, version=None, reinstall: bool = False): + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs): installed.append(module_id) return tmp_path / module_id.split("/")[-1] diff --git a/tests/unit/scripts/test_verify_bundle_published.py b/tests/unit/scripts/test_verify_bundle_published.py index 9ac4b528..6aeb7200 100644 --- a/tests/unit/scripts/test_verify_bundle_published.py +++ b/tests/unit/scripts/test_verify_bundle_published.py @@ -4,6 +4,7 @@ import importlib.util import json +import sys from pathlib import Path from typing import Any @@ -18,6 +19,7 @@ def _load_script_module() -> Any: if spec is None or spec.loader is None: raise AssertionError(f"Unable to load script module at {script_path}") module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module spec.loader.exec_module(module) return module diff --git a/tests/unit/specfact_cli/modules/test_module_upgrade_improvements.py b/tests/unit/specfact_cli/modules/test_module_upgrade_improvements.py index d55adc7c..f8836b2c 100644 --- a/tests/unit/specfact_cli/modules/test_module_upgrade_improvements.py +++ b/tests/unit/specfact_cli/modules/test_module_upgrade_improvements.py @@ -19,6 +19,7 @@ _run_marketplace_upgrades, app as module_app, ) +from specfact_cli.registry.module_installer import InstallModuleOptions runner = CliRunner() @@ -43,7 +44,7 @@ def test_run_marketplace_upgrades_skips_reinstall_when_at_latest(tmp_path: Path) install_called = [] - def _fake_install(module_id: str, reinstall: bool = False, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs: object) -> Path: install_called.append(module_id) return tmp_path / "backlog" @@ -86,7 +87,7 @@ def test_run_marketplace_upgrades_mixed_result_shows_sections(tmp_path: Path) -> "nold-ai/specfact-codebase": {"version": "0.44.0", "source": "marketplace", "latest_version": "0.44.0"}, } - def _fake_install(module_id: str, reinstall: bool = False, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs: object) -> Path: if "backlog" in module_id: return tmp_path / "backlog" raise AssertionError(f"Should not install {module_id}") @@ -212,7 +213,7 @@ def test_run_marketplace_upgrades_yes_flag_skips_major_bump_prompt(tmp_path: Pat "nold-ai/specfact-backlog": {"version": "0.41.16", "source": "marketplace", "latest_version": "1.0.0"}, } - def _fake_install(module_id: str, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs: object) -> Path: return tmp_path / "backlog" def _fake_read_version(p: Path) -> str: @@ -242,7 +243,7 @@ def test_upgrade_command_warns_when_registry_unavailable(monkeypatch: pytest.Mon lambda: [{"id": "backlog", "version": "0.2.0", "enabled": True, "source": "marketplace"}], ) - def _install(module_id: str, **kwargs: object) -> Path: + def _install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs: object) -> Path: return tmp_path / "backlog" monkeypatch.setattr( @@ -276,7 +277,7 @@ def test_run_marketplace_upgrades_calls_console_status_when_spinner_enabled( "nold-ai/specfact-backlog": {"version": "0.1.0", "source": "marketplace"}, } - def _fake_install(module_id: str, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: InstallModuleOptions | None = None, **_kwargs: object) -> Path: return tmp_path / "m" with ( diff --git a/tests/unit/specfact_cli/modules/test_multi_module_install_uninstall.py b/tests/unit/specfact_cli/modules/test_multi_module_install_uninstall.py index 23c3eb51..706a2292 100644 --- a/tests/unit/specfact_cli/modules/test_multi_module_install_uninstall.py +++ b/tests/unit/specfact_cli/modules/test_multi_module_install_uninstall.py @@ -58,7 +58,7 @@ def test_module_install_accepts_multiple_ids() -> None: """specfact module install A B must accept two positional arguments.""" installed: list[str] = [] - def _fake_install(module_id: str, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: object | None = None, **_kwargs: object) -> Path: installed.append(module_id) return Path(f"/tmp/{module_id.split('/')[1]}") @@ -112,7 +112,7 @@ def test_module_install_single_still_works() -> None: """Single-module install must still work after multi-install change.""" installed: list[str] = [] - def _fake_install(module_id: str, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: object | None = None, **_kwargs: object) -> Path: installed.append(module_id) return Path(f"/tmp/{module_id.split('/')[1]}") @@ -144,7 +144,7 @@ def test_module_install_multi_aborts_on_first_failure_without_installing_rest() """Multi-install: if module A fails, do not attempt B (avoid partial surprise state).""" installed: list[str] = [] - def _fake_install(module_id: str, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: object | None = None, **_kwargs: object) -> Path: if "codebase" in module_id: raise RuntimeError("mock install failure for first module") installed.append(module_id) @@ -184,7 +184,7 @@ def test_module_install_multi_skips_already_installed_and_continues() -> None: def _fake_skip(scope: str, name: str, root: Path, reinstall: bool, discovered: Any) -> bool: return "codebase" in name # A is already installed - def _fake_install(module_id: str, **kwargs: object) -> Path: + def _fake_install(module_id: str, options: object | None = None, **_kwargs: object) -> Path: installed.append(module_id) return Path(f"/tmp/{module_id.split('/')[1]}") diff --git a/tests/unit/specfact_cli/registry/test_profile_presets.py b/tests/unit/specfact_cli/registry/test_profile_presets.py index 91c8544d..47c4dce7 100644 --- a/tests/unit/specfact_cli/registry/test_profile_presets.py +++ b/tests/unit/specfact_cli/registry/test_profile_presets.py @@ -85,7 +85,7 @@ def test_install_bundles_for_init_calls_marketplace_for_code_review(tmp_path: Pa """install_bundles_for_init must call the marketplace installer for specfact-code-review.""" installed_marketplace_ids: list[str] = [] - def _fake_install_module(module_id: str, **kwargs: object) -> Path: + def _fake_install_module(module_id: str, options: object | None = None, **_kwargs: object) -> Path: installed_marketplace_ids.append(module_id) return tmp_path / module_id.split("/")[1] @@ -114,7 +114,7 @@ def test_install_bundles_for_init_solo_developer_installs_both(tmp_path: Path) - """Solo-developer bundles install via marketplace (slim CLI has no per-command bundled workflow dirs).""" installed_marketplace_ids: list[str] = [] - def _fake_marketplace(module_id: str, **kwargs: object) -> Path: + def _fake_marketplace(module_id: str, options: object | None = None, **_kwargs: object) -> Path: installed_marketplace_ids.append(module_id) return tmp_path / module_id.split("/")[1]