From 6b913be5b68ff714ffc290914dfaed3dda4e702d Mon Sep 17 00:00:00 2001 From: Dom <39115308+djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 15:02:23 +0100 Subject: [PATCH 01/11] Release v0.28.0: Module package separation for command implementations (#201) * perf: optimize startup performance with metadata tracking and update command (#142) * feat: implement backlog field mapping and refinement improvements - Add FieldMapper abstract base class with canonical field names - Implement GitHubFieldMapper and AdoFieldMapper - Add custom field mapping support with YAML templates - Add field validation in refinement (story_points, business_value, priority) - Add comprehensive unit and integration tests (42 tests) - Add custom field mapping documentation - Fix custom_field_mapping parameter connection - Add early validation for custom mapping files Implements OpenSpec change: improve-backlog-field-mapping-and-refinement * perf: optimize startup performance with metadata tracking and update command - Add metadata management module for tracking version and check timestamps - Optimize startup checks to only run when needed: - Template checks: Only after version changes detected - Version checks: Limited to once per day (24h threshold) - Add --skip-checks flag for CI/CD environments - Add new 'specfact update' command for manual update checking and installation - Add comprehensive unit and integration tests (35 tests, all passing) - Update startup_checks to use metadata for conditional execution - Ensure backward compatibility (first-time users still get all checks) Performance Impact: - Startup time: Reduced from several seconds to < 1-2 seconds - Network requests: Reduced from every startup to once per day - File system operations: Reduced from every startup to only after version changes Fixes #140 Implements OpenSpec change: optimize-startup-performance * feat: request offline_access scope for Azure DevOps refresh tokens - Add offline_access scope to Azure DevOps OAuth requests - Refresh tokens now last 90 days (vs 1 hour for access tokens) - Automatic token refresh via persistent cache (no re-authentication needed) - Update documentation to reflect 90-day refresh token lifetime This addresses the issue where tokens were expiring too quickly. Refresh tokens obtained via offline_access scope enable automatic token renewal for 90 days without user interaction. Fixes token lifetime limitation issue * feat: improve CLI UX with banner control and upgrade command - Change banner to hidden by default, shown on first run or with --banner flag - Add simple version line (SpecFact CLI - vXYZ) for regular use - Rename 'update' command to 'upgrade' to avoid confusion - Update documentation for new banner behavior and upgrade command - Update startup checks message to reference 'specfact upgrade' * fix: suppress version line in test mode and fix field mapping issues - Suppress version line output in test mode and for help/version commands to prevent test failures - Fix ADO custom field mapping to honor --custom-field-mapping on writeback - Fix GitHub issue body updates to prevent duplicate sections - Ensure proper type handling for story points and business value calculations * Fix failed tests * chore: bump version to 0.26.7 and update changelog - Fixed adapter token validation tests (ADO and GitHub) - Resolved test timeout issues (commit history, AST parsing, Semgrep) - Improved test file discovery to exclude virtual environments - Added file size limits for AST parsing to prevent timeouts --------- Co-authored-by: Dominikus Nold * fix: add missing ADO field mappings and assignee display (#145) * fix: add missing ADO field mappings and assignee display - Add Microsoft.VSTS.Common.AcceptanceCriteria to default field mappings - Update AdoFieldMapper to support multiple field name alternatives - Fix assignee extraction to include displayName, uniqueName, and mail - Add assignee display in preview output - Add interactive template mapping command (specfact backlog map-fields) - Update specfact init to copy backlog field mapping templates - Extend documentation with step-by-step guides Fixes #144 * test: add unit tests for ADO field mapping and assignee fixes - Add tests for Microsoft.VSTS.Common.AcceptanceCriteria field extraction - Add tests for multiple field name alternatives - Add tests for assignee extraction with displayName, uniqueName, mail - Add tests for assignee filtering with multiple identifiers - Add tests for assignee display in preview output - Add tests for interactive mapping command - Add tests for template copying in init command - Update existing tests to match new assignee extraction behavior * docs: update init command docstring to mention template copying * docs: update documentation for ADO field mapping and interactive mapping features - Update authentication guide with ADO token resolution priority - Update custom field mapping guide with interactive mapping details - Update backlog refinement guide with progress indicators and required field display - Update Azure DevOps adapter guide with field mapping improvements - Update command reference with map-fields command documentation - Update troubleshooting guide with ADO-specific issues - Update README files with new features - Update getting started guide with template initialization Co-authored-by: Cursor * fix: address review findings for ADO field mapping - Prefer System.* fields over Microsoft.VSTS.Common.* when writing updates (fixes issue where PATCH requests could fail for Scrum templates) - Preserve existing work_item_type_mappings when saving field mappings (prevents silent erasure of custom work item type mappings) Fixes review comments: - P1: Prefer System.AcceptanceCriteria when writing updates - P2: Preserve existing work_item_type_mappings on save Co-authored-by: Cursor --------- Co-authored-by: Dominikus Nold Co-authored-by: Cursor * fix: mitigate code scanning vulnerabilities (#148) * fix: mitigate code scanning vulnerabilities - Fix ReDoS vulnerability in github_mapper.py by replacing regex with line-by-line processing - Fix incomplete URL sanitization in github.py, bridge_sync.py, and ado.py using proper URL parsing - Add explicit permissions blocks to 7 GitHub Actions jobs following least-privilege model Resolves all 13 code scanning findings: - 1 ReDoS error - 5 URL sanitization warnings - 7 missing workflow permissions warnings Fixes #147 Co-authored-by: Cursor * fix: accept GitHub SSH host aliases in repo detection Accept ssh.github.com (port 443) in addition to github.com when detecting GitHub repositories via SSH remotes. This ensures repositories using git@ssh.github.com:owner/repo.git are properly detected as GitHub repos. Addresses review feedback on PR #148 Co-authored-by: Cursor * fix: prevent async cleanup issues in test mode Remove manual Live display cleanup that could cause EOFError. The _safe_progress_display function already handles test mode by skipping progress display, so direct save path is sufficient. Fixes test_unlock_section failure with EOFError/ValueError. Co-authored-by: Cursor --------- Co-authored-by: Dominikus Nold Co-authored-by: Cursor * fix: detect GitHub remotes using ssh:// and git:// URLs Extend URL pattern matching to support ssh://git@github.com/owner/repo.git and git://github.com/owner/repo.git formats in addition to existing https?:// and scp-style git@host:path URLs. This fixes a regression where these valid GitHub URL formats were not detected, causing detect() to return false for repos using these schemes. Addresses review feedback on PR #149 Co-authored-by: Cursor * chore: bump version to 0.26.9 and update changelog - Update version from 0.26.8 to 0.26.9 - Add changelog entry for GitHub remote detection fix and code scanning fixes Co-authored-by: Cursor * fix: compare GitHub SSH hostnames case-insensitively Lowercase host_part before comparison to handle mixed-case hostnames like git@GitHub.com:org/repo.git. This restores the case-insensitive behavior from the previous config_content.lower() check and prevents regression where valid GitHub repos with mixed-case hostnames would not be detected. Addresses review feedback on PR #150 Co-authored-by: Cursor * Add openspec and workflow commands for transparency * Add specs from openspec * Remove aisp change which wasn't implemented * Fix openspec gitignore pattern * Update gitignore * Update contribution standards to use openspec for SDD * Migrate to new opsx openspec commands * Migrate workflow and openspec config * fix: bump version to 0.26.10 for PyPI publish - Sync version across pyproject.toml, setup.py, src/__init__.py, src/specfact_cli/__init__.py - Add CHANGELOG entry for 0.26.10 (fixes incorrect version publish issue) Co-authored-by: Cursor * Update version and changelog * Add canonical user-friendly workitem url for ado workitems * Update to support OSPX * feat(backlog): implement refine --import-from-tmp and fix type-check (#156) * feat(backlog): implement --import-from-tmp for refine export/import round-trip - Add _parse_refined_export_markdown() to parse export-format markdown (ID, Body, Acceptance Criteria, optional title/metrics) - Import branch: read file, match by ID, update items; --write calls adapter.update_backlog_item() - Remove 'Import functionality pending implementation' message - Unit tests for parser (single item, AC/metrics, header-only, blocks without ID) - Bump version to 0.26.11 and sync across pyproject.toml, setup.py, src/__init__.py, src/specfact_cli/__init__.py - OpenSpec change: implement-backlog-refine-import-from-tmp (proposal, tasks, spec delta) Fixes #155 Co-authored-by: Cursor * Fix type check issues --------- Co-authored-by: Dominikus Nold Co-authored-by: Cursor * feat: debug logs under ~/.specfact/logs and release 0.26.13 (#159) * feat: add debug logs under ~/.specfact/logs with operation metadata - User-level log dir: get_specfact_home_logs_dir() (~/.specfact/logs, 0o755) - debug_print() routes to console and rotating specfact-debug.log when --debug - debug_log_operation() for structured metadata (ADO, GitHub, backlog, init) - CLI init_debug_log_file() when --debug; help text updated Closes #158 OpenSpec change: add-debug-logs-specfact-home Co-authored-by: Cursor * Add debug logging for selected commands at first * release: 0.26.13 - debug log parity for upgrade, versions and changelog - Log upgrade success (up to date) to ~/.specfact/logs/specfact-debug.log - Bump version to 0.26.13; sync pyproject.toml, setup.py, src/__init__.py, specfact_cli/__init__.py - CHANGELOG: 0.26.13 Fixed entry for upgrade debug parity Co-authored-by: Cursor * Remove pr markdown --------- Co-authored-by: Dominikus Nold Co-authored-by: Cursor * Potential fix for pull request finding 'Empty except' Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Signed-off-by: Dom <39115308+djm81@users.noreply.github.com> * Fix unused variable review * Fix unused variable review * Fix type and test errors * Finalize change * Change for debug logs archived * fix: improve ADO backlog refine error logging and user-facing error UX (#164) * Improving error logging capabilities * small fix on changelog * Archived change --------- Co-authored-by: Dominikus Nold * feat: backlog refine --ignore-refined and --id, startup docs (fixes #166) (#167) * feat: backlog refine --ignore-refined and --id, startup docs (fixes #166) OpenSpec change: improve-backlog-refine-and-cli-startup. Adds --ignore-refined/--no-ignore-refined, --id ; helper _item_needs_refinement; interactive refinement prompt section; version 0.26.15. * Add change for this branch and improve change create workflow * Improve refinement prompt and add specification feedback, update docs and add backlog refinement tutorial * Fix spec update and tasks * Improve pr orchestrator pipeline triggers --------- Co-authored-by: Dominikus Nold * Add change proposals for full scrum support * Add support for systematic, structured issue creation with copilot help * feat(backlog): daily standup defaults, iteration/sprint, unassigned items view (#174) * Issue 179 resolution (#180) * fix(backlog): address CodeQL/Codex PR 181 findings - Replace empty except with debug_log_operation in _load_standup_config and _load_backlog_config (correct signature: operation, target, status, error) - Add dim console message in sprint end date parse except block - Gate summarize prompt description/comments on --comments; add include_comments to _build_summarize_prompt_content and call site - Add test for metadata-only summarize when include_comments=False; update existing test to pass include_comments=True Co-authored-by: Cursor * Update openspec enforcement rules * Structure openspec changes * Fix ruff finding * Fix linter issues with StrEnum and parameters * Fix tests and depcreation warnings * Improve sync script * Add change for modular command registry * Fix review finding on dev sync script * Update modular change proposal * feat: CLI modular command registry and lazy load (arch-01) (#196) * feat: CLI modular command registry and lazy load (arch-01) Co-authored-by: Cursor * Add missing exports * Fix lazy loading review findigns * Removed example package and fixed tests * Fix test failures and lazy load logic for modules * Fix tests --------- Co-authored-by: Dominikus Nold Co-authored-by: Cursor * docs: document CLI modules design; sync version and cleanup - Add Modules design section to architecture (registry, module packages, state) - Update module structure tree with registry/ and modules/ - Cross-reference directory-structure to architecture#modules-design - Changelog, version, and project file updates; remove obsolete commands/prompts Co-authored-by: Cursor * Archive modular change and specs * Fix banner display on help screen * Improve action runner on main * Setup claude skills and instructions * feat: module package separation for command implementations (#200) * feat: separate module package command implementations * docs: finalize openspec apply checklist for arch-02 * Archived arch-02 change and updated specs * fix: restore plan sync shared compatibility import --------- Co-authored-by: Dominikus Nold * fix: address CodeQL and Codex review findings from PR #201 - Fix unreachable code in contract init (Prompt.ask after raise typer.Exit) - Replace empty except with print_warning for contract file load failures - Fix repo-root fallback path depth in backlog commands after module migration Co-Authored-By: Claude Opus 4.6 --------- Signed-off-by: Dom <39115308+djm81@users.noreply.github.com> Co-authored-by: Dominikus Nold Co-authored-by: Cursor Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 --- .claude/skills/openspec-workflows/SKILL.md | 67 + .../references/create-change-from-plan.md | 287 + .../references/validate-change.md | 264 + .gitignore | 4 + AGENTS.md | 7 +- CHANGELOG.md | 16 + CLAUDE.md | 148 + README.md | 13 + docs/guides/adapter-development.md | 2 +- docs/reference/architecture.md | 25 +- docs/reference/parameter-standard.md | 16 +- docs/technical/dual-stack-pattern.md | 4 +- .../CHANGE_VALIDATION.md | 113 + .../TEST_SCENARIO_MAPPING.md | 85 + .../proposal.md | 36 + .../specs/module-package-separation/spec.md | 65 + .../tasks.md | 98 + .../specs/module-package-separation/spec.md | 67 + pyproject.toml | 9 +- pyrightconfig.json | 4 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/analyzers/graph_analyzer.py | 12 +- src/specfact_cli/commands/analyze.py | 361 +- src/specfact_cli/commands/auth.py | 708 +-- src/specfact_cli/commands/backlog_commands.py | 2800 +-------- src/specfact_cli/commands/contract_cmd.py | 1241 +--- src/specfact_cli/commands/drift.py | 246 +- src/specfact_cli/commands/enforce.py | 609 +- src/specfact_cli/commands/generate.py | 2121 +------ src/specfact_cli/commands/import_cmd.py | 2905 +-------- src/specfact_cli/commands/init.py | 573 +- src/specfact_cli/commands/migrate.py | 930 +-- src/specfact_cli/commands/plan.py | 5539 +--------------- src/specfact_cli/commands/project_cmd.py | 1839 +----- src/specfact_cli/commands/repro.py | 547 +- src/specfact_cli/commands/sdd.py | 431 +- src/specfact_cli/commands/spec.py | 897 +-- src/specfact_cli/commands/sync.py | 2360 +------ src/specfact_cli/commands/update.py | 305 +- src/specfact_cli/commands/validate.py | 314 +- .../generators/persona_exporter.py | 2 +- src/specfact_cli/merge/resolver.py | 2 +- src/specfact_cli/models/plan.py | 5 +- .../modules/analyze/src/__init__.py | 1 + src/specfact_cli/modules/analyze/src/app.py | 4 +- .../modules/analyze/src/commands.py | 361 ++ src/specfact_cli/modules/auth/src/__init__.py | 1 + src/specfact_cli/modules/auth/src/app.py | 4 +- src/specfact_cli/modules/auth/src/commands.py | 708 +++ .../modules/backlog/src/__init__.py | 1 + src/specfact_cli/modules/backlog/src/app.py | 4 +- .../modules/backlog/src/commands.py | 2802 +++++++++ .../modules/contract/src/__init__.py | 1 + src/specfact_cli/modules/contract/src/app.py | 4 +- .../modules/contract/src/commands.py | 1244 ++++ .../modules/drift/src/__init__.py | 1 + src/specfact_cli/modules/drift/src/app.py | 4 +- .../modules/drift/src/commands.py | 246 + .../modules/enforce/module-package.yaml | 3 +- .../modules/enforce/src/__init__.py | 1 + src/specfact_cli/modules/enforce/src/app.py | 4 +- .../modules/enforce/src/commands.py | 609 ++ .../modules/generate/module-package.yaml | 3 +- .../modules/generate/src/__init__.py | 1 + src/specfact_cli/modules/generate/src/app.py | 4 +- .../modules/generate/src/commands.py | 2121 +++++++ .../modules/import_cmd/src/__init__.py | 1 + .../modules/import_cmd/src/app.py | 4 +- .../modules/import_cmd/src/commands.py | 2905 +++++++++ src/specfact_cli/modules/init/src/__init__.py | 1 + src/specfact_cli/modules/init/src/app.py | 4 +- src/specfact_cli/modules/init/src/commands.py | 573 ++ .../modules/migrate/src/__init__.py | 1 + src/specfact_cli/modules/migrate/src/app.py | 4 +- .../modules/migrate/src/commands.py | 930 +++ .../modules/plan/module-package.yaml | 3 +- src/specfact_cli/modules/plan/src/__init__.py | 1 + src/specfact_cli/modules/plan/src/app.py | 4 +- src/specfact_cli/modules/plan/src/commands.py | 5552 +++++++++++++++++ .../modules/project/src/__init__.py | 1 + src/specfact_cli/modules/project/src/app.py | 4 +- .../modules/project/src/commands.py | 1836 ++++++ .../modules/repro/src/__init__.py | 1 + src/specfact_cli/modules/repro/src/app.py | 4 +- .../modules/repro/src/commands.py | 547 ++ src/specfact_cli/modules/sdd/src/__init__.py | 1 + src/specfact_cli/modules/sdd/src/app.py | 4 +- src/specfact_cli/modules/sdd/src/commands.py | 431 ++ src/specfact_cli/modules/spec/src/__init__.py | 1 + src/specfact_cli/modules/spec/src/app.py | 4 +- src/specfact_cli/modules/spec/src/commands.py | 897 +++ .../modules/sync/module-package.yaml | 4 +- src/specfact_cli/modules/sync/src/__init__.py | 1 + src/specfact_cli/modules/sync/src/app.py | 4 +- src/specfact_cli/modules/sync/src/commands.py | 2438 ++++++++ .../modules/upgrade/src/__init__.py | 1 + src/specfact_cli/modules/upgrade/src/app.py | 4 +- .../modules/upgrade/src/commands.py | 305 + .../modules/validate/src/__init__.py | 1 + src/specfact_cli/modules/validate/src/app.py | 4 +- .../modules/validate/src/commands.py | 314 + src/specfact_cli/parsers/persona_importer.py | 4 +- src/specfact_cli/registry/registry.py | 20 +- src/specfact_cli/utils/git.py | 6 +- src/specfact_cli/utils/persona_ownership.py | 34 + src/specfact_cli/utils/source_scanner.py | 25 +- .../test_backlog_refine_limit_and_cancel.py | 6 +- .../e2e/test_brownfield_speckit_compliance.py | 6 +- tests/e2e/test_complete_workflow.py | 6 +- .../e2e/test_directory_structure_workflow.py | 14 +- tests/e2e/test_enforcement_workflow.py | 2 +- tests/e2e/test_enrichment_workflow.py | 4 +- tests/e2e/test_init_command.py | 4 +- tests/e2e/test_phase1_features_e2e.py | 10 +- tests/e2e/test_phase2_contracts_e2e.py | 8 +- tests/e2e/test_plan_review_batch_updates.py | 28 +- tests/e2e/test_plan_review_non_interactive.py | 10 +- tests/e2e/test_watch_mode_e2e.py | 20 +- .../test_backlog_filtering_integration.py | 2 +- .../test_auth_commands_integration.py | 2 +- .../commands/test_enrich_for_speckit.py | 6 +- .../test_ensure_speckit_compliance.py | 4 +- .../test_import_enrichment_contracts.py | 2 +- .../commands/test_repro_command.py | 10 +- .../commands/test_sdd_contract_integration.py | 2 +- .../commands/test_spec_commands.py | 26 +- tests/integration/sync/test_sync_command.py | 30 +- tests/integration/test_plan_command.py | 48 +- tests/unit/commands/test_backlog_commands.py | 4 +- tests/unit/commands/test_backlog_config.py | 26 +- tests/unit/commands/test_backlog_daily.py | 22 +- tests/unit/commands/test_backlog_filtering.py | 2 +- .../test_import_feature_validation.py | 2 +- tests/unit/commands/test_plan_add_commands.py | 2 +- tests/unit/commands/test_plan_telemetry.py | 16 +- .../commands/test_plan_update_commands.py | 2 +- tests/unit/commands/test_update.py | 42 +- .../test_module_boundary_imports.py | 34 + .../test_module_migration_compatibility.py | 151 + 141 files changed, 26696 insertions(+), 24951 deletions(-) create mode 100644 .claude/skills/openspec-workflows/SKILL.md create mode 100644 .claude/skills/openspec-workflows/references/create-change-from-plan.md create mode 100644 .claude/skills/openspec-workflows/references/validate-change.md create mode 100644 CLAUDE.md create mode 100644 openspec/changes/archive/2026-02-06-arch-02-module-package-separation/CHANGE_VALIDATION.md create mode 100644 openspec/changes/archive/2026-02-06-arch-02-module-package-separation/TEST_SCENARIO_MAPPING.md create mode 100644 openspec/changes/archive/2026-02-06-arch-02-module-package-separation/proposal.md create mode 100644 openspec/changes/archive/2026-02-06-arch-02-module-package-separation/specs/module-package-separation/spec.md create mode 100644 openspec/changes/archive/2026-02-06-arch-02-module-package-separation/tasks.md create mode 100644 openspec/specs/module-package-separation/spec.md create mode 100644 src/specfact_cli/modules/analyze/src/__init__.py create mode 100644 src/specfact_cli/modules/analyze/src/commands.py create mode 100644 src/specfact_cli/modules/auth/src/__init__.py create mode 100644 src/specfact_cli/modules/auth/src/commands.py create mode 100644 src/specfact_cli/modules/backlog/src/__init__.py create mode 100644 src/specfact_cli/modules/backlog/src/commands.py create mode 100644 src/specfact_cli/modules/contract/src/__init__.py create mode 100644 src/specfact_cli/modules/contract/src/commands.py create mode 100644 src/specfact_cli/modules/drift/src/__init__.py create mode 100644 src/specfact_cli/modules/drift/src/commands.py create mode 100644 src/specfact_cli/modules/enforce/src/__init__.py create mode 100644 src/specfact_cli/modules/enforce/src/commands.py create mode 100644 src/specfact_cli/modules/generate/src/__init__.py create mode 100644 src/specfact_cli/modules/generate/src/commands.py create mode 100644 src/specfact_cli/modules/import_cmd/src/__init__.py create mode 100644 src/specfact_cli/modules/import_cmd/src/commands.py create mode 100644 src/specfact_cli/modules/init/src/__init__.py create mode 100644 src/specfact_cli/modules/init/src/commands.py create mode 100644 src/specfact_cli/modules/migrate/src/__init__.py create mode 100644 src/specfact_cli/modules/migrate/src/commands.py create mode 100644 src/specfact_cli/modules/plan/src/__init__.py create mode 100644 src/specfact_cli/modules/plan/src/commands.py create mode 100644 src/specfact_cli/modules/project/src/__init__.py create mode 100644 src/specfact_cli/modules/project/src/commands.py create mode 100644 src/specfact_cli/modules/repro/src/__init__.py create mode 100644 src/specfact_cli/modules/repro/src/commands.py create mode 100644 src/specfact_cli/modules/sdd/src/__init__.py create mode 100644 src/specfact_cli/modules/sdd/src/commands.py create mode 100644 src/specfact_cli/modules/spec/src/__init__.py create mode 100644 src/specfact_cli/modules/spec/src/commands.py create mode 100644 src/specfact_cli/modules/sync/src/__init__.py create mode 100644 src/specfact_cli/modules/sync/src/commands.py create mode 100644 src/specfact_cli/modules/upgrade/src/__init__.py create mode 100644 src/specfact_cli/modules/upgrade/src/commands.py create mode 100644 src/specfact_cli/modules/validate/src/__init__.py create mode 100644 src/specfact_cli/modules/validate/src/commands.py create mode 100644 src/specfact_cli/utils/persona_ownership.py create mode 100644 tests/unit/specfact_cli/test_module_boundary_imports.py create mode 100644 tests/unit/specfact_cli/test_module_migration_compatibility.py diff --git a/.claude/skills/openspec-workflows/SKILL.md b/.claude/skills/openspec-workflows/SKILL.md new file mode 100644 index 00000000..08937fbc --- /dev/null +++ b/.claude/skills/openspec-workflows/SKILL.md @@ -0,0 +1,67 @@ +--- +name: openspec-workflows +description: Create OpenSpec changes from implementation plans, and validate existing changes before implementation. Use when the user wants to turn a plan document into an OpenSpec change proposal, or validate that a change is safe to implement (breaking changes, dependency analysis). +license: MIT +compatibility: Requires openspec CLI and gh CLI. +metadata: + author: openspec + version: "1.0" +--- + +Two workflows for managing OpenSpec changes at the proposal stage. + +**Input**: Optionally specify a workflow name (`create` or `validate`) and a target (plan path or change ID). If omitted, ask the user which workflow they need. + +## Workflow Selection + +Determine which workflow to run: + +| User Intent | Workflow | Reference | +|---|---|---| +| Turn a plan into an OpenSpec change | **Create Change from Plan** | `references/create-change-from-plan.md` | +| Validate a change before implementation | **Validate Change** | `references/validate-change.md` | + +If the user's intent is unclear, use **AskUserQuestion** to ask which workflow they need. + +## Create Change from Plan + +Turns an implementation plan document into a fully formed OpenSpec change with proposal, specs, design, and tasks — including GitHub issue creation for public repos. + +**When to use**: The user has a plan document (typically in `specfact-cli-internal/docs/internal/implementation/`) and wants to create an OpenSpec change from it. + +**Load** `references/create-change-from-plan.md` and follow the full workflow. + +**Key steps**: +1. Select and parse the plan document +2. Cross-reference against existing plans and validate targets +3. Resolve any issues interactively +4. Create the OpenSpec change via `opsx:ff` skill +5. Review and improve: enforce TDD-first, add git workflow tasks (branch first, PR last), validate against `openspec/config.yaml` +6. Create GitHub issue (public repos only) + +## Validate Change + +Performs dry-run simulation to detect breaking changes, analyze dependencies, and verify format compliance before implementation begins. + +**When to use**: The user wants to validate that an existing change is safe to implement — check for breaking interface changes, missing dependency updates, and format compliance. + +**Load** `references/validate-change.md` and follow the full workflow. + +**Key steps**: +1. Select the change (by ID or interactive list) +2. Parse all change artifacts (proposal, tasks, design, spec deltas) +3. Simulate interface changes in a temporary workspace +4. Analyze dependencies and detect breaking changes +5. Present findings and get user decision if breaking changes found +6. Run `openspec validate --strict` +7. Create `CHANGE_VALIDATION.md` report + +## Guardrails + +- Read `openspec/config.yaml` for project context and rules +- Read `CLAUDE.md` for project conventions +- Never modify production code during validation — use temp workspaces +- Never proceed with ambiguities — ask for clarification +- Enforce TDD-first ordering in tasks (per config.yaml) +- Enforce git workflow: branch creation first task, PR creation last task +- Only create GitHub issues in the target repository specified by the plan diff --git a/.claude/skills/openspec-workflows/references/create-change-from-plan.md b/.claude/skills/openspec-workflows/references/create-change-from-plan.md new file mode 100644 index 00000000..a6678214 --- /dev/null +++ b/.claude/skills/openspec-workflows/references/create-change-from-plan.md @@ -0,0 +1,287 @@ +# Workflow: Create OpenSpec Change from Plan + +## Table of Contents + +- [Guardrails](#guardrails) +- [Step 1: Plan Selection](#step-1-plan-selection) +- [Step 2: Plan Review and Alignment](#step-2-plan-review-and-alignment) +- [Step 3: Integrity Re-Check](#step-3-integrity-re-check) +- [Step 4: OpenSpec Change Creation](#step-4-openspec-change-creation) +- [Step 5: Proposal Review and Improvement](#step-5-proposal-review-and-improvement) +- [Step 6: GitHub Issue Creation](#step-6-github-issue-creation) +- [Step 7: Create GitHub Issue via gh CLI](#step-7-create-github-issue-via-gh-cli) +- [Step 8: Completion](#step-8-completion) + +## Guardrails + +- Read `openspec/config.yaml` during the workflow (before or at Step 5) for project context and TDD/SDD rules. +- Favor straightforward, minimal implementations. Keep changes tightly scoped. +- Never proceed with ambiguities or conflicts — ask for clarification interactively. +- Do not write code during the proposal stage. Only create design documents (proposal.md, tasks.md, design.md, spec deltas). +- Always validate alignment against existing plans and implementation reality before proceeding. +- **CRITICAL**: Only create GitHub issues in the target repository specified by the plan. +- **CRITICAL Git Workflow**: Add tasks to create a git branch (feature/bugfix/hotfix based on change-id) BEFORE any code modifications, and create a PR to `dev` AFTER all tasks complete. Never work on protected branches (main/dev). Branch naming: `/`. +- **CRITICAL TDD**: Per config.yaml, test tasks MUST come before implementation tasks. + +## Step 1: Plan Selection + +**If plan path provided**: Resolve to absolute path, verify file exists. + +**If no plan path provided**: +1. Search for plans in: + - `specfact-cli-internal/docs/internal/brownfield-strategy/` (`*.md`) + - `specfact-cli-internal/docs/internal/implementation/` (`*.md`) + - `specfact-cli/docs/` (if accessible) +2. Display numbered list with file path, title (first heading), last modified date. +3. Prompt user to select. + +## Step 2: Plan Review and Alignment + +### 2.1: Read and Parse Plan + +1. Read plan file completely. +2. Extract: + - Title and purpose (first H1) + - **Target repository** (look for `**Repository**:` in header metadata, e.g. `` `nold-ai/specfact-cli` ``) + - Phases/tasks with descriptions + - Files to create/modify (note repository prefixes) + - Dependencies, success metrics, estimated effort +3. Identify referenced targets (files, directories, repositories). + +### 2.2: Cross-Reference Check + +1. Search `specfact-cli-internal/docs/internal/brownfield-strategy/` for overlapping plans. +2. Search `specfact-cli-internal/docs/internal/implementation/` for conflicting implementation plans. +3. Extract conflicting info, overlapping scope, dependency relationships, timeline conflicts. + +### 2.3: Target Validation + +For each target in the plan: +- **Files**: Check existence, readability, location, structure matches assumptions. +- **Directories**: Check existence, structure. +- **Repositories**: Verify in workspace, structure matches, access ok. +- **Code refs**: Verify functions/classes exist, structure matches. + +### 2.4: Alignment Analysis + +Check: +1. **Accuracy**: File paths correct? Repos referenced accurately? Commands valid? +2. **Correctness**: Technical details accurate? Implementation approaches align with codebase? +3. **Ambiguities**: Unclear requirements, vague acceptance criteria, missing context. +4. **Conflicts**: With other plans, overlapping scope, timeline/resource conflicts. +5. **Consistency**: With CLAUDE.md conventions, OpenSpec conventions, existing patterns. + +### 2.5: Issue Detection and Interactive Resolution + +**If issues found**: +1. Categorize: Critical (must resolve), Warning (should resolve), Info (non-blocking). +2. Present: `[CRITICAL/WARNING/INFO] : ` with context and suggested resolutions. +3. Resolve interactively: For critical issues, prompt for clarification. For warnings, ask resolve or skip. +4. Re-validate after resolution. Loop until all critical issues resolved. + +## Step 3: Integrity Re-Check + +1. Re-run all checks from Step 2 with updated understanding. +2. Verify user clarifications are consistent. +3. Check for new issues introduced by clarifications. +4. If misalignments remain, go back to Step 2.5. + +## Step 4: OpenSpec Change Creation + +### 4.1: Determine Change Name + +1. Extract from plan title, convert to kebab-case. +2. Ensure unique (check existing changes in `openspec/changes/`). + +### 4.2: Execute OPSX Fast-Forward + +Invoke the `opsx:ff` skill with the change name: +- Use the plan as source of requirements. +- Map plan phases/tasks to OpenSpec capabilities. +- The opsx:ff workflow creates: change directory, proposal.md, specs/, design.md, tasks.md. +- It reads `openspec/config.yaml` for project context and per-artifact rules. + +### 4.3: Extract Change ID + +1. Identify created change ID. +2. Verify change directory: `openspec/changes//`. +3. Verify artifacts created: proposal.md, tasks.md, specs/. + +## Step 5: Proposal Review and Improvement + +### 5.1: Review Against Config and Project Rules + +1. **Read `openspec/config.yaml`**: + - Project context: Tech stack, constraints, architecture patterns. + - Development discipline (SDD + TDD): (1) Specs first, (2) Tests second (expect failure), (3) Code last. + - Per-artifact rules: `rules.tasks` — TDD order, test-before-code. + +2. **Read and apply project rules** from CLAUDE.md: + - Contract-first development, testing requirements, code conventions. + +3. **Verify config.yaml rules applied**: + - Source Tracking section (if public-facing). + - GitHub issue creation task (if public repo). + - 2-hour maximum chunks. + - TDD: test tasks before implementation. + +### 5.2: Update Tasks with Quality Standards and Git Workflow + +#### 5.2.1: Determine Branch Type + +- `add-*`, `create-*`, `implement-*`, `enhance-*` -> `feature/` +- `fix-*`, `correct-*`, `repair-*` -> `bugfix/` +- `update-*`, `modify-*`, `refactor-*` -> `feature/` +- `hotfix-*`, `urgent-*` -> `hotfix/` +- Default: `feature/` + +Branch name: `/`. Target: `dev`. + +#### 5.2.2: Add Git Branch Creation Task (FIRST TASK) + +Add as first task in tasks.md: + +```markdown +## 1. Create git branch from dev + +- [ ] 1.1 Ensure on dev and up to date; create branch `/`; verify. + - [ ] 1.1.1 `git checkout dev && git pull origin dev` + - [ ] 1.1.2 `gh issue develop --repo --name / --checkout` (if issue exists) + - [ ] 1.1.3 Or: `git checkout -b /` (if no issue) + - [ ] 1.1.4 `git branch --show-current` +``` + +#### 5.2.3: Update Tasks with Quality Standards + +For each task, ensure: +- Testing requirements (unit, contract, integration, E2E). +- Code quality checks: `hatch run format`, `hatch run type-check`, `hatch run contract-test`. +- Validation: `openspec validate --strict`. + +#### 5.2.4: Enforce TDD-first in tasks.md + +1. **Add "TDD / SDD order (enforced)" section** at top of tasks.md (after title, before first numbered task): + - State: per config.yaml, tests before code for any behavior-changing task. + - Order: (1) Spec deltas, (2) Tests from scenarios (expect failure), (3) Code last. + - "Do not implement production code until tests exist and have been run (expecting failure)." + - Separate with `---`. + +2. **Reorder each behavior-changing section**: Test tasks before implementation tasks. + +3. **Verify**: Scan tasks.md — any section with both test and implementation tasks must have tests first. + +#### 5.2.5: Add PR Creation Task (LAST TASK) + +Add as last task in tasks.md. Only create PR if target repo is public (specfact-cli, platform-frontend). + +Key steps: +1. Prepare commit: `git add .`, commit with conventional message, push. +2. Create PR body from `.github/pull_request_template.md`: + - Use full repo path format for issue refs: `Fixes nold-ai/specfact-cli#` + - Include OpenSpec change ID in description. +3. Create PR: `gh pr create --repo --base dev --head --title ": " --body-file ` +4. Link to project (specfact-cli only): `gh project item-add 1 --owner nold-ai --url ` +5. Verify Development link on issue, project board. +6. Update project status to "In Progress" (if applicable). + +PR title format: `feat:` for feature/, `fix:` for bugfix/, etc. + +### 5.3: Update Proposal with Quality Gates + +Update proposal.md with: quality standards section, git workflow requirements, acceptance criteria (branch created, tests pass, contracts validated, docs updated, PR created). + +### 5.4: Validate with OpenSpec + +1. Verify format: proposal.md has `# Change:` title, `## Why`, `## What Changes`, `## Impact`. Tasks.md uses `## 1.` numbered format. +2. Check status: `openspec status --change "" --json`. +3. Run: `openspec validate --strict`. Fix and re-run until passing. + +### 5.5: Markdown Linting + +Run `markdownlint --config .markdownlint.json --fix` on all `.md` files in the change directory. Fix remaining issues manually. + +## Step 6: GitHub Issue Creation + +### 6.1: Determine Target Repository + +1. Extract target repo from plan header (`**Repository**:` field). +2. Decision: + - `specfact-cli` or `platform-frontend` (public) -> create issue, proceed to 6.2. + - `specfact-cli-internal` (internal) -> skip issue creation, go to Step 8. + - Not specified -> ask user. + +### 6.2: Sanitize Proposal Content + +For public issues: +- **Remove**: Competitive analysis, market positioning, internal strategy, effort estimates. +- **Preserve**: User-facing value, feature descriptions, acceptance criteria, API changes. + +Format per config.yaml: +- Title: `[Change] ` +- Labels: `enhancement`, `change-proposal` +- Body: `## Why`, `## What Changes`, `## Acceptance Criteria` +- Footer: `*OpenSpec Change Proposal: *` + +Show sanitized content to user for approval before creating. + +## Step 7: Create GitHub Issue via gh CLI + +1. Write sanitized content to temp file. +2. Create issue: + +```bash +gh issue create \ + --repo \ + --title "[Change] " \ + --body-file /tmp/github-issue-<change-id>.md \ + --label "enhancement" \ + --label "change-proposal" +``` + +3. For specfact-cli: link to project `gh project item-add 1 --owner nold-ai --url <ISSUE_URL>`. +4. Update `proposal.md` Source Tracking section: + +```markdown +## Source Tracking + +<!-- source_repo: <target-repo> --> +- **GitHub Issue**: #<number> +- **Issue URL**: <url> +- **Last Synced Status**: proposed +``` + +5. Cleanup temp file. + +## Step 8: Completion + +Display summary: + +``` +Change ID: <change-id> +Location: openspec/changes/<change-id>/ + +Validation: + - OpenSpec validation passed + - Markdown linting passed + - Config.yaml rules applied (TDD-first enforced) + - Git workflow tasks added (branch + PR) + +GitHub Issue (if public): + - Issue #<number> created: <url> + - Source tracking updated + +Next Steps: + 1. Review: openspec/changes/<change-id>/proposal.md + 2. Review: openspec/changes/<change-id>/tasks.md + 3. Verify TDD order and git workflow in tasks + 4. Apply when ready: invoke opsx:apply skill +``` + +## Error Handling + +- **Plan not found**: Search and suggest alternatives. +- **Validation failures**: Present clearly, allow interactive resolution. +- **OpenSpec validation fails**: Fix and re-validate, don't proceed until passing. +- **gh CLI unavailable**: Inform user, provide manual creation instructions. +- **Issue creation fails**: Log error, allow retry, don't fail entire workflow. +- **Project linking fails**: Log warning, continue (non-critical). diff --git a/.claude/skills/openspec-workflows/references/validate-change.md b/.claude/skills/openspec-workflows/references/validate-change.md new file mode 100644 index 00000000..bfbf0172 --- /dev/null +++ b/.claude/skills/openspec-workflows/references/validate-change.md @@ -0,0 +1,264 @@ +# Workflow: Validate OpenSpec Change + +## Table of Contents + +- [Guardrails](#guardrails) +- [Step 1: Change Selection](#step-1-change-selection) +- [Step 2: Read and Parse Change](#step-2-read-and-parse-change) +- [Step 3: Simulate Change Application](#step-3-simulate-change-application) +- [Step 4: Dependency Analysis](#step-4-dependency-analysis) +- [Step 5: Validation Report and Decision](#step-5-validation-report-and-decision) +- [Step 6: Create Validation Report](#step-6-create-validation-report) +- [Step 7: Completion](#step-7-completion) + +## Guardrails + +- Never modify the actual codebase during validation — only work in temp directories. +- Focus on interface/contract/parameter analysis, not implementation details. +- Identify breaking changes, not style or formatting issues. +- Always create CHANGE_VALIDATION.md for audit trail. +- Ask for user confirmation before extending change scope or rejecting proposals. + +## Step 1: Change Selection + +**If change ID provided**: Resolve to `openspec/changes/<change-id>/`, verify directory and proposal.md exist. + +**If no change ID provided**: +1. List active changes: `openspec list --json`. +2. Display numbered list with change ID, schema, status, brief description. +3. Prompt user to select. + +## Step 2: Read and Parse Change + +### 2.1: Check Status and Read Artifacts + +1. **Read `openspec/config.yaml`** for project context, constraints, and per-artifact rules. + +2. **Check change status**: `openspec status --change "<change-id>" --json` + - Verify artifacts exist and are complete (status: "done"). + +3. **Get artifact context**: `openspec instructions apply --change "<change-id>" --json` + +4. **Verify proposal.md format** (per config.yaml): + - Title: `# Change: [Brief description]` + - Required sections: `## Why`, `## What Changes`, `## Capabilities`, `## Impact` + - "What Changes": bullet list with NEW/EXTEND/MODIFY markers + - "Capabilities": each capability needs a spec file + - "Impact": Affected specs, Affected code, Integration points + +5. **Read proposal.md**: Extract summary, rationale, scope, capabilities, affected files. + +6. **Verify tasks.md format** (per config.yaml): + - Hierarchical numbered sections: `## 1.`, `## 2.` + - Tasks: `- [ ] 1.1 [Description]` + - Sub-tasks: `- [ ] 1.1.1 [Description]` + - Rules: 2-hour max chunks, contract tasks, test tasks, quality gates, git workflow (branch first, PR last) + +7. **Read tasks.md**: Extract tasks, files to create/modify/delete, task dependencies. Verify branch creation first, PR creation last. + +8. **Read design.md** (if exists): Architectural decisions, interface changes, contracts, migration plans. Verify bridge adapter docs, sequence diagrams for multi-repo. + +9. **Read spec deltas** (`specs/<capability>/spec.md`): ADDED/MODIFIED/REMOVED requirements, interface/parameter/contract changes, cross-refs. Verify Given/When/Then format. + +### 2.2: Identify Change Scope + +1. **Files to modify**: Extract from tasks.md and proposal.md. Categorize: code, tests, docs, config. +2. **Modules/Components**: Python modules, classes, functions, interfaces, contracts, APIs. Note public vs private. +3. **Dependencies**: From proposal "Dependencies" section and task dependencies. + +## Step 3: Simulate Change Application + +### 3.1: Create Temporary Workspace + +```bash +TEMP_WORKSPACE="/tmp/specfact-validation-<change-id>-$(date +%s)" +mkdir -p "$TEMP_WORKSPACE" +``` + +Copy relevant repository structure to temp workspace. + +### 3.2: Analyze Spec Deltas for Interface Changes + +For each spec delta: +1. Parse ADDED/MODIFIED/REMOVED requirements. +2. Extract interface changes: function signatures, class interfaces, `@icontract`/`@beartype` decorators, type hints, API endpoints. +3. Create interface scaffolds in temp workspace (stubs only, no implementation): + +```python +# OLD INTERFACE (from existing codebase) +def process_data(data: str, options: dict) -> dict: ... + +# NEW INTERFACE (from change proposal) +def process_data(data: str, options: dict, validate: bool = True) -> dict: ... +``` + +### 3.3: Map Tasks to File Modifications + +For each task, categorize modification type: +- **Interface change**: Function/class signature modification +- **Contract change**: `@icontract` decorator modification +- **Type change**: Type hint modification +- **New/Delete file**: Module/class/function added or removed +- **Documentation**: Non-breaking doc changes + +Create modification map: File path -> Modification type -> Interface changes. + +## Step 4: Dependency Analysis + +### 4.1: Find Dependent Code + +For each modified file/interface, search codebase: +- `from...import...<module>` — find imports +- `<function_name>(` or `<class_name>(` — find usages +- `@<decorator>` — find contract decorators + +Build dependency graph: Modified interface -> dependent files (direct, indirect, test). + +### 4.2: Analyze Breaking Changes + +Compare old vs new interface. Detect: +- **Parameter removal**: Required param removed +- **Parameter addition**: Required param added (no default) +- **Parameter type change**: Incompatible type +- **Return type change**: Incompatible return +- **Contract strengthening**: `@require` stricter, `@ensure` weaker +- **Method/class/module removal**: Public API removed + +For each dependent file, check if it would break: +- **Would break**: Incompatible usage detected +- **Would need update**: Compatible but may need adjustment +- **No impact**: Usage compatible + +### 4.3: Identify Required Updates + +Categorize: +- **Critical**: Must update or code breaks +- **Recommended**: Should update for consistency +- **Optional**: No update needed + +## Step 5: Validation Report and Decision + +### 5.1: Summary + +Count breaking changes, affected interfaces, dependent files. Assess impact: High/Medium/Low. + +### 5.2: Present Findings + +``` +Change Validation Report: <change-id> + +Breaking Changes Detected: <count> + - <interface 1>: <description> + +Dependent Files Affected: <count> + Critical (must update): <count> + Recommended: <count> + Optional: <count> + +Impact Assessment: <High/Medium/Low> +``` + +### 5.3: User Decision (if breaking changes) + +**Option A: Extend Scope** — Add tasks to update dependent files. May require major version. + +**Option B: Adjust Change** — Add default params, keep old interface (deprecation), use optional params. + +**Option C: Reject and Defer** — Update status to "deferred", document in CHANGE_VALIDATION.md. + +**No breaking changes**: Proceed to 5.4. + +### 5.4: OpenSpec Validation + +1. Check status: `openspec status --change "<change-id>" --json` +2. Run: `openspec validate <change-id> --strict` +3. Fix issues and re-run until passing. +4. If proposal was updated (scope extended/adjusted), re-validate. + +## Step 6: Create Validation Report + +Create `openspec/changes/<change-id>/CHANGE_VALIDATION.md`: + +```markdown +# Change Validation Report: <change-id> + +**Validation Date**: <timestamp> +**Change Proposal**: [proposal.md](./proposal.md) +**Validation Method**: Dry-run simulation in temporary workspace + +## Executive Summary + +- Breaking Changes: <count> detected / <count> resolved +- Dependent Files: <count> affected +- Impact Level: <High/Medium/Low> +- Validation Result: <Pass/Fail/Deferred> +- User Decision: <Extend Scope/Adjust Change/Reject/N/A> + +## Breaking Changes Detected + +### Interface: <name> +- **Type**: Parameter addition/removal/type change +- **Old Signature**: `<old>` +- **New Signature**: `<new>` +- **Dependent Files**: <file>: <impact> + +## Dependencies Affected + +### Critical Updates Required +- <file>: <reason> + +### Recommended Updates +- <file>: <reason> + +## Impact Assessment + +- **Code Impact**: <description> +- **Test Impact**: <description> +- **Documentation Impact**: <description> +- **Release Impact**: <Minor/Major/Patch> + +## Format Validation + +- **proposal.md Format**: <Pass/Fail> + - Title, sections, capabilities, impact per config.yaml +- **tasks.md Format**: <Pass/Fail> + - Headers, task format, config.yaml compliance (TDD, git workflow, quality gates) +- **specs Format**: <Pass/Fail> + - Given/When/Then format, references existing patterns +- **Config.yaml Compliance**: <Pass/Fail> + +## OpenSpec Validation + +- **Status**: <Pass/Fail> +- **Command**: `openspec validate <change-id> --strict` +- **Issues Found/Fixed**: <count> + +## Validation Artifacts + +- Temporary workspace: <path> +``` + +Update proposal status if deferred, scope extended, or adjusted. + +## Step 7: Completion + +``` +Change ID: <change-id> +Validation Report: openspec/changes/<change-id>/CHANGE_VALIDATION.md + +Findings: + - Breaking Changes: <count> + - Dependent Files: <count> + - Impact Level: <level> + - Validation Result: <result> + +Next Steps: + <based on decision — implement, re-validate, or defer> +``` + +## Error Handling + +- **Change not found**: Search and suggest alternatives. +- **Repo not accessible**: Inform user, provide manual validation instructions. +- **Breaking changes**: Present options clearly, don't proceed without user decision. +- **Dependency analysis fails**: Continue with partial analysis, note limitations. diff --git a/.gitignore b/.gitignore index b2d90dfc..0adb289b 100644 --- a/.gitignore +++ b/.gitignore @@ -107,6 +107,10 @@ docs/internal/ .github/prompts/specfact.*.md .github/prompts/opsx-*.md +.claude/commands/opsx/ +.claude/skills/openspec-*/ +!.claude/skills/openspec-workflows/ + # Semgrep rules (generated from tools/semgrep/ - source rules are versioned) .semgrep/ diff --git a/AGENTS.md b/AGENTS.md index e9a6e96e..a66289ef 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -4,7 +4,8 @@ - `src/specfact_cli/` contains the CLI command implementation - `cli.py` - Main Typer application entry point - - `commands/` - Command modules (import, analyze, plan, compare, enforce, repro) + - `modules/<module>/src/commands.py` - Primary command implementations (module-local) + - `commands/` - Legacy compatibility shims (app-only re-exports from module-local commands) - `models/` - Pydantic data models (plan, protocol, deviation) - `generators/` - Code generators (protocol, plan, report) - `validators/` - Validation logic (schema, contract, FSM) @@ -86,6 +87,10 @@ ## CLI Command Development Notes - All commands extend `typer.Typer()` for consistent CLI interface +- New command logic belongs in `src/specfact_cli/modules/<module>/src/commands.py` +- Legacy import path compatibility is limited to `from specfact_cli.commands.<name> import app` +- Replacement path for module code is `from specfact_cli.modules.<module>.src.commands import ...` +- Compatibility shims are planned for removal no earlier than `v0.30` (or next major migration window) - Use `rich.console.Console()` for beautiful terminal output - Validate inputs with Pydantic models at command boundaries - Apply `@icontract` decorators to enforce contracts at runtime diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e1109ad..fc20488e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ All notable changes to this project will be documented in this file. --- +## [0.28.0] - 2026-02-06 + +### Added (0.28.0) + +- **Module package separation for command implementations** (OpenSpec change `arch-02-module-package-separation`, fixes [#199](https://github.com/nold-ai/specfact-cli/issues/199)) + - **Module-local command sources**: command implementations now live under `src/specfact_cli/modules/<module>/src/commands.py` and module app entrypoints import from local command modules. + - **Boundary regression checks**: added tests to prevent new non-`app` dependencies from `specfact_cli.commands.*` and protect module encapsulation. + - **Shared helper extraction**: common cross-command helpers moved to stable shared utilities to reduce coupling between module command packages. + +### Changed (0.28.0) + +- **Compatibility shims for migration**: legacy `src/specfact_cli/commands/*.py` files are now compatibility shims focused on `app` export, preserving CLI behavior during transition while reducing symbol-level coupling. +- **Version**: Bumped to 0.28.0 (minor: architectural feature/refactor, backward compatible). + +--- + ## [0.27.0] - 2026-02-04 ### Added (0.27.0) diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..622af728 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,148 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +SpecFact CLI is a Python CLI tool for agile DevOps teams. It keeps backlogs, specs, tests, and code in sync with contract-driven development, validation, and enforcement. Built with Typer + Rich, using Hatch as the build system. Python 3.11+. + +## Essential Commands + +```bash +# Development environment +pip install -e ".[dev]" +hatch shell + +# Format & lint (run after every code change, in this order) +hatch run format # ruff format + fix +hatch run type-check # basedpyright strict mode +hatch run contract-test # contract-first validation (primary) +hatch test --cover -v # full pytest suite + +# Contract-first testing layers +hatch run contract-test-contracts # runtime contract validation only +hatch run contract-test-exploration # CrossHair symbolic execution +hatch run contract-test-scenarios # integration/E2E with contract refs +hatch run contract-test-full # all layers +hatch run contract-test-status # coverage status report + +# Run a single test file +hatch test -- tests/unit/specfact_cli/test_example.py -v + +# Lint subsystems +hatch run lint # full lint suite +hatch run governance # pylint detailed analysis +hatch run yaml-lint # YAML validation +hatch run lint-workflows # GitHub Actions actionlint + +# Code scanning +hatch run scan-all # semgrep analysis +``` + +## Architecture + +### Modular Command Registry with Lazy Loading + +The CLI uses a module package system in `src/specfact_cli/modules/`. Each module is a self-contained package: + +``` +modules/{name}/ + module-package.yaml # metadata: name, version, commands, dependencies + src/{name}/ + __init__.py + main.py # typer.Typer app with command definitions +``` + +The registry (`src/specfact_cli/registry/`) discovers modules at startup but defers imports until a command is actually invoked. `bootstrap.py` registers all modules; `registry.py` manages lazy loading; `module_packages.py` handles discovery from `module-package.yaml` files. + +**Entry flow**: `cli.py:cli_main()` → Typer app with global options → `ProgressiveDisclosureGroup` for help → lazy-loaded command groups from registry. + +### Contract-First Development + +All public APIs must use `@icontract` decorators (`@require`, `@ensure`, `@invariant`) and `@beartype` for runtime type checking. CrossHair discovers counterexamples via symbolic execution. Contracts are the primary validation mechanism; traditional unit tests are secondary. + +### Key Subsystems + +- **`models/`** - Pydantic BaseModel classes for all data structures +- **`parsers/`**, **`analyzers/`** - Code analysis +- **`generators/`** - Code/spec generation using Jinja2 templates from `resources/templates/` +- **`validators/`** - Schema, contract, FSM validation +- **`adapters/`** - Bridge pattern for tool integrations (GitHub, Azure DevOps, Jira, Linear) +- **`modes/`** - Operational modes: CICD (fast, deterministic, non-interactive) vs Copilot (interactive, IDE-aware). Auto-detected from environment. +- **`resources/`** - Bundled prompts, templates, schemas, mappings (force-included in wheel) + +### Logging + +Use `from specfact_cli.common import get_bridge_logger` — never `print()`. Debug logs go to `~/.specfact/logs/specfact-debug.log` when `--debug` is passed. + +## Development Workflow + +### Branch Protection + +`dev` and `main` are protected. Always work on feature/bugfix/hotfix branches and submit PRs: +- `feature/your-feature-name` +- `bugfix/your-bugfix-name` +- `hotfix/your-hotfix-name` + +### Post-Change Checklist + +1. `hatch run format` +2. `hatch run type-check` +3. `hatch run contract-test` +4. `hatch test --cover -v` + +### OpenSpec Workflow + +Before modifying application code, check if an OpenSpec change exists in `openspec/`. This is the spec-driven workflow defined in `openspec/config.yaml`. Skip only when explicitly told ("skip openspec", "direct implementation", "simple fix"). + +### Version Updates + +When bumping version, sync across: `pyproject.toml`, `setup.py`, `src/specfact_cli/__init__.py`. CI/CD auto-publishes to PyPI on merge to `main` only if version exceeds the published one. + +### Commits + +Follow Conventional Commits: `feat:`, `fix:`, `docs:`, `test:`, `refactor:`. + +## Code Conventions + +- Python 3.11+, line length 120, Google-style docstrings +- `snake_case` for files/modules, `PascalCase` for classes, `UPPER_SNAKE_CASE` for constants +- All data structures use Pydantic `BaseModel` with `Field(...)` and descriptions +- CLI commands use `typer.Typer()` + `rich.console.Console()` +- Only write high-value comments; avoid verbose or redundant commentary +- `rich~=13.5.2` is pinned for semgrep compatibility — do not upgrade without checking + +## CLI Command Pattern + +```python +import typer +from beartype import beartype +from icontract import require, ensure +from rich.console import Console + +app = typer.Typer() +console = Console() + +@app.command() +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +@beartype +def my_command( + repo_path: Path = typer.Argument(..., help="Path to repository"), +) -> None: + """Command docstring.""" + console.print("[bold]Processing...[/bold]") +``` + +## Testing + +**Contract-first approach**: `@icontract` contracts on public APIs are the primary coverage mechanism (target 80%+ API coverage). Redundant unit tests that merely assert input validation or type checks should be removed — contracts and beartype handle that. + +Test structure mirrors source: `tests/unit/`, `tests/integration/`, `tests/e2e/`. Use `@pytest.mark.asyncio` for async tests. Guard environment-sensitive logic with `os.environ.get("TEST_MODE") == "true"`. + +## CI/CD + +Key workflows in `.github/workflows/`: +- `tests.yml` — contract-first test execution +- `specfact.yml` — contract validation on PR/push (`hatch run specfact repro --verbose`) +- `pr-orchestrator.yml` — coordinates PR workflows +- `build-and-push.yml` — Docker image building (depends on all above passing) diff --git a/README.md b/README.md index b37ba198..97f38f5f 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,19 @@ Most tools help **either** coders **or** agile teams. SpecFact does both: --- +## Developer Note: Command Layout + +- Primary command implementations live in `src/specfact_cli/modules/<module>/src/commands.py`. +- Legacy imports from `src/specfact_cli/commands/*.py` are compatibility shims and only guarantee `app` re-exports. +- Preferred imports for module code: + - `from specfact_cli.modules.<module>.src.commands import app` + - `from specfact_cli.modules.<module>.src.commands import <symbol>` +- Shim deprecation timeline: + - Legacy shim usage is deprecated for non-`app` symbols now. + - Shim removal is planned no earlier than `v0.30` (or the next major migration window). + +--- + ## Where SpecFact Fits SpecFact complements your stack rather than replacing it. diff --git a/docs/guides/adapter-development.md b/docs/guides/adapter-development.md index cf9a2296..4699bf75 100644 --- a/docs/guides/adapter-development.md +++ b/docs/guides/adapter-development.md @@ -445,7 +445,7 @@ The `OpenSpecAdapter` is an example of a bidirectional sync adapter with change **✅ DO:** ```python -# In commands/sync.py +# In modules/sync/src/commands.py adapter = AdapterRegistry.get_adapter(adapter_name) if adapter: adapter_instance = adapter() diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 56d88c07..4e3917cc 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -542,7 +542,16 @@ class ChangeArchive(BaseModel): - `name`, `version`, `commands` (list of command names the package provides) - optional `command_help` (name → short help for root `specfact --help`) - optional `pip_dependencies`, `module_dependencies`, `tier` (e.g. community/enterprise), `addon_id` -- **Entry point**: Each package has `src/app.py` (or equivalent) that exposes a Typer `app`. Modules typically re-export the app from `specfact_cli.commands.<name>` so that the real implementation stays in `commands/` while modules act as discoverable wrappers. +- **Entry point**: Each package has `src/app.py` that exposes a Typer `app` by importing from module-local `src/commands.py`. + +### Legacy shim policy and timeline + +- Legacy files under `src/specfact_cli/commands/*.py` are compatibility shims. +- Supported legacy surface: `from specfact_cli.commands.<name> import app`. +- Preferred replacement imports: + - `from specfact_cli.modules.<module>.src.commands import app` + - `from specfact_cli.modules.<module>.src.commands import <symbol>` +- Deprecation timeline: non-`app` legacy shim usage is deprecated now; shim removal is planned no earlier than `v0.30` (or next major migration window). ### Module state (user-level) @@ -579,14 +588,12 @@ src/specfact_cli/ │ │ ├── module-package.yaml │ │ └── src/app.py │ └── ... # plan, analyze, enforce, repro, etc. -├── commands/ # CLI command implementations (Typer apps) -│ ├── import_cmd.py # Import from external formats -│ ├── analyze.py # Code analysis -│ ├── plan.py # Plan management -│ ├── enforce.py # Enforcement configuration -│ ├── repro.py # Reproducibility validation -│ ├── sync.py # Sync operations (Spec-Kit, OpenSpec, repository) -│ └── ... # init, auth, backlog, generate, etc. +├── commands/ # Legacy app-only compatibility shims +│ ├── import_cmd.py # -> modules/import_cmd/src/commands.py +│ ├── analyze.py # -> modules/analyze/src/commands.py +│ ├── plan.py # -> modules/plan/src/commands.py +│ ├── enforce.py # -> modules/enforce/src/commands.py +│ └── ... # auth, backlog, contract, drift, etc. ├── modes/ # Operational mode management │ ├── detector.py # Mode detection logic │ └── router.py # Command routing diff --git a/docs/reference/parameter-standard.md b/docs/reference/parameter-standard.md index 1462839d..e045f561 100644 --- a/docs/reference/parameter-standard.md +++ b/docs/reference/parameter-standard.md @@ -131,27 +131,27 @@ Parameters must be organized into logical groups in the following order: The following parameters have been renamed: 1. **`--base-path` → `--repo`** ✅ - - **File**: `src/specfact_cli/commands/generate.py` + - **File**: `src/specfact_cli/modules/generate/src/commands.py` - **Command**: `generate contracts` - **Status**: Completed - Parameter renamed and all references updated 2. **`--output` → `--out`** ✅ - - **File**: `src/specfact_cli/commands/constitution.py` - - **Command**: `constitution bootstrap` + - **File**: `src/specfact_cli/modules/sdd/src/commands.py` + - **Command**: `sdd constitution bootstrap` - **Status**: Completed - Parameter renamed and all references updated 3. **`--format` → `--output-format`** ✅ - **Files**: - - `src/specfact_cli/commands/plan.py` (plan compare command) - - `src/specfact_cli/commands/enforce.py` (enforce sdd command) + - `src/specfact_cli/modules/plan/src/commands.py` (plan compare command) + - `src/specfact_cli/modules/enforce/src/commands.py` (enforce sdd command) - **Status**: Completed - Parameters renamed and all references updated 4. **`--non-interactive` → `--no-interactive`** ✅ - **Files**: - `src/specfact_cli/cli.py` (global flag) - - `src/specfact_cli/commands/plan.py` (multiple commands) - - `src/specfact_cli/commands/enforce.py` (enforce sdd command) - - `src/specfact_cli/commands/generate.py` (generate contracts command) + - `src/specfact_cli/modules/plan/src/commands.py` (multiple commands) + - `src/specfact_cli/modules/enforce/src/commands.py` (enforce sdd command) + - `src/specfact_cli/modules/generate/src/commands.py` (generate contracts command) - **Status**: Completed - Global flag and all command flags updated, interaction logic fixed ### Phase 1.3: Verify `--bundle` Parameter ✅ **COMPLETED** diff --git a/docs/technical/dual-stack-pattern.md b/docs/technical/dual-stack-pattern.md index 62af0530..7df5c21e 100644 --- a/docs/technical/dual-stack-pattern.md +++ b/docs/technical/dual-stack-pattern.md @@ -51,7 +51,7 @@ The Dual-Stack Enrichment Pattern is a technical architecture that enforces CLI- The validation loop pattern is implemented in: -- `src/specfact_cli/commands/generate.py`: +- `src/specfact_cli/modules/generate/src/commands.py`: - `generate_contracts_prompt()` - Generates structured prompts - `apply_enhanced_contracts()` - Validates and applies enhanced code @@ -140,7 +140,7 @@ The `cli_first_validator.py` module provides: ## Related Code - `src/specfact_cli/validators/cli_first_validator.py` - Validation utilities -- `src/specfact_cli/commands/generate.py` - Contract enhancement commands +- `src/specfact_cli/modules/generate/src/commands.py` - Contract enhancement commands - `resources/prompts/shared/cli-enforcement.md` - CLI enforcement rules - `resources/prompts/specfact.*.md` - Slash command prompts with dual-stack workflow diff --git a/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/CHANGE_VALIDATION.md b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/CHANGE_VALIDATION.md new file mode 100644 index 00000000..5dccfd4e --- /dev/null +++ b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/CHANGE_VALIDATION.md @@ -0,0 +1,113 @@ +# Change Validation Report: arch-02-module-package-separation + +**Validation Date**: 2026-02-05 +**Change Proposal**: [proposal.md](./proposal.md) +**Validation Method**: Dry-run dependency and interface-impact analysis with temporary workspace artifacts in `/tmp` + +## Executive Summary + +- Breaking Changes: 1 class of breaking change detected (legacy non-`app` command symbol imports) +- Dependent Files: 35 affected (7 in `src/`, 28 in `tests/`) +- Impact Level: High +- Validation Result: Pass with approved scope extension (hybrid compatibility + decoupling strategy) +- User Decision: Approved hybrid strategy (temporary compatibility re-exports plus decoupling and boundary enforcement) + +## Breaking Changes Detected + +### Interface: `specfact_cli.commands.<module>` symbol surface + +- **Type**: Public module export reduction risk +- **Old Signature**: Command modules expose `app` plus additional helpers/constants/functions used by internal code and tests (for example `_convert_project_bundle_to_plan_bundle`, `match_section_pattern`, `check_persona_ownership`, `AZURE_DEVOPS_RESOURCE`, `sync_spec_kit`, `is_constitution_minimal`). +- **New Signature (as currently described in proposal/tasks)**: Re-export shims in `src/specfact_cli/commands/*.py` expose only `app`. +- **Breaking**: Yes, if shims only export `app`. +- **Dependent Files**: + - `src/specfact_cli/commands/generate.py`: imports `_convert_project_bundle_to_plan_bundle` from `specfact_cli.commands.plan` + - `src/specfact_cli/commands/enforce.py`: imports `_convert_project_bundle_to_plan_bundle` from `specfact_cli.commands.plan` + - `src/specfact_cli/commands/sync.py`: imports `_convert_project_bundle_to_plan_bundle` and `is_constitution_minimal` + - `src/specfact_cli/commands/plan.py`: imports `sync_spec_kit` from `specfact_cli.commands.sync` + - `src/specfact_cli/parsers/persona_importer.py`: imports `match_section_pattern` from `specfact_cli.commands.project_cmd` + - `src/specfact_cli/generators/persona_exporter.py`: imports `match_section_pattern` from `specfact_cli.commands.project_cmd` + - `src/specfact_cli/merge/resolver.py`: imports `check_persona_ownership` from `specfact_cli.commands.project_cmd` + - `tests/` files: 28 files import non-`app` symbols from `specfact_cli.commands.*` + +## Dependencies Affected + +### Critical Updates Required + +- Preserve compatibility for non-`app` symbol imports, either by: + - Re-exporting required symbols from each `src/specfact_cli/commands/<name>.py` shim, or + - Refactoring all imports to module-local paths in same change. +- Add explicit migration tasks for helper/constant/function import mapping and regression tests. + +### Recommended Updates + +- Add a compatibility policy section in `proposal.md` and `tasks.md` defining whether command modules are intentionally importable API surfaces or internal-only. +- Add test matrix that validates both command invocation and symbol-level imports during migration waves. + +### Optional Updates + +- Consolidate cross-command helper functions into shared core packages (`utils`, `models`, dedicated helper modules) to reduce command-to-command coupling over time. + +## Impact Assessment + +- **Code Impact**: High; command-to-command and parser/generator/resolver imports depend on non-`app` symbols. +- **Test Impact**: High; 28 tests import command-module helpers/constants/functions directly. +- **Documentation Impact**: Medium; docs should clarify compatibility guarantees for `specfact_cli.commands.*` imports during and after migration. +- **Release Impact**: Potentially Major if compatibility is not preserved; Minor if compatibility is explicitly preserved in shims. + +## User Decision + +**Decision**: Approved by user (2026-02-05): hybrid strategy. + +**Approved strategy**: + +1. **Temporary compatibility**: Preserve behavior during migration by re-exporting `app` and currently used non-`app` symbols in command shims. +2. **Active decoupling**: Move shared helper/constant logic out of command modules into stable shared packages and migrate imports. +3. **Boundary enforcement**: Add checks so new non-`app` imports from `specfact_cli.commands.*` fail CI. +4. **Exit criteria**: Drive non-`app` imports to zero and then reduce command shims toward `app`-only. + +**Rationale**: This preserves runtime/test compatibility while achieving long-term module encapsulation and lower merge-conflict surface. + +## Format Validation + +- **proposal.md Format**: Pass + - Title format: Correct (`# Change: ...`) + - Required sections: Present (`Why`, `What Changes`, `Capabilities`, `Impact`) + - "What Changes" format: Correct (bullet list with NEW/EXTEND markers) + - "Capabilities" section: Present + - "Impact" format: Correct + - Source Tracking section: Present for public-facing repo +- **tasks.md Format**: Pass with one quality note + - Section headers: Correct hierarchical numbering + - Task format: Correct checklist numbering + - Config compliance: Mostly pass + - 2-hour maximum chunks: Not explicitly verifiable from current task granularity + - Contract decorator tasks: Implicit only; should be explicit for any newly exposed public API adjustments + - Test tasks: Present + - Quality gate tasks: Present + - Git workflow tasks: Present (branch first, PR last) + - GitHub issue creation task: Present +- **specs Format**: Pass + - Given/When/Then format: Verified + - Existing pattern alignment: Verified +- **design.md Format**: Not applicable (no `design.md` yet; status is `ready`) +- **Format Issues Found**: 0 blocking +- **Format Issues Fixed**: 0 +- **Config.yaml Compliance**: Pass (proposal/spec/tasks level) + +## OpenSpec Validation + +- **Status**: Pass +- **Validation Command**: `openspec validate arch-02-module-package-separation --strict` +- **Issues Found**: 0 blocking issues +- **Issues Fixed**: 0 +- **Re-validated**: Yes + +## Validation Artifacts + +- Temporary workspace: `/tmp/specfact-validation-arch-02-module-package-separation-1770329891` +- Dependency scan (raw): `/tmp/specfact-validation-arch-02-module-package-separation-1770329891/dependency-raw.txt` +- Dependent files list: `/tmp/specfact-validation-arch-02-module-package-separation-1770329891/dependent-files.txt` +- Source dependents: `/tmp/specfact-validation-arch-02-module-package-separation-1770329891/dependent-src-files.txt` +- Test dependents: `/tmp/specfact-validation-arch-02-module-package-separation-1770329891/dependent-test-files.txt` +- Interface scaffold notes: `/tmp/specfact-validation-arch-02-module-package-separation-1770329891/interface-scaffold.txt` diff --git a/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/TEST_SCENARIO_MAPPING.md b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/TEST_SCENARIO_MAPPING.md new file mode 100644 index 00000000..8f7f231e --- /dev/null +++ b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/TEST_SCENARIO_MAPPING.md @@ -0,0 +1,85 @@ +# Test-to-Scenario Mapping: arch-02-module-package-separation + +## Scope + +This mapping links OpenSpec scenarios in +`openspec/changes/arch-02-module-package-separation/specs/module-package-separation/spec.md` +to concrete tests and execution evidence. + +## Scenario Mapping + +### Requirement: Module-local command implementation + +- Scenario: `Move command implementation into module package` +- Tests: + - `tests/unit/specfact_cli/test_module_migration_compatibility.py::test_module_app_entrypoints_import_module_local_commands` +- Coverage: + - Verifies each `src/specfact_cli/modules/<module>/src/app.py` imports `app` from module-local `commands`. + - Verifies module package `src/` structure is present for all discovered module packages. + +### Requirement: Backward-compatible command shims + +- Scenario: `Legacy import path remains valid` +- Tests: + - `tests/unit/specfact_cli/test_module_migration_compatibility.py::test_legacy_command_shims_reexport_module_app` + - `tests/unit/specfact_cli/test_module_migration_compatibility.py::test_legacy_command_shims_reexport_public_symbols` + - `tests/unit/specfact_cli/test_module_boundary_imports.py::test_no_legacy_non_app_command_imports_outside_compat_shims` +- Coverage: + - Verifies legacy shim modules in `src/specfact_cli/commands/*.py` still expose the same `app` as module-local command implementations. + - Verifies shim exports are reduced to the required compatibility surface (`app` plus any remaining in-repo legacy import requirements). + - Verifies new non-`app` imports from `specfact_cli.commands.*` are blocked outside compatibility shims. + +### Requirement: Phased migration with verification gates + +- Scenario: `Tier-based migration progression` +- Tests: + - `tests/unit/specfact_cli/registry/test_module_packages.py::test_registry_receives_example_command_when_registered` + - QA command checks: + - `hatch run smart-test` (pass) + - `hatch run contract-test` (pass; contract exploration warnings only) + - `specfact <command> --help` for all migrated commands (pass) +- Coverage: + - Verifies registry bootstrap and command discoverability. + - Verifies representative verification gates for migrated command set. + +### Requirement: Module dependency declaration integrity + +- Scenario: `Dependency declaration after migration` +- Verification: + - Manifest review and migration tasks in `tasks.md` section `8.3` completed. + - Dependency declarations validated through successful command bootstrap and targeted command help checks. + +## Execution Evidence + +- Command: + - `hatch run pytest -q tests/unit/specfact_cli/test_module_migration_compatibility.py tests/unit/specfact_cli/test_module_boundary_imports.py tests/unit/specfact_cli/registry/test_module_packages.py::test_registry_receives_example_command_when_registered` +- Result: + - `5 passed` + +## Tier Verification Evidence + +### Tier 1 (`drift`, `upgrade`, `validate`, `sdd`) + +- Command: + - `hatch run pytest -q tests/integration/commands/test_drift_command.py tests/unit/commands/test_update.py tests/integration/commands/test_sdd_contract_integration.py` +- Result: + - `21 passed` + +### Tier 2 (`auth`, `repro`, `enforce`, `migrate`, `spec`, `init`) + +- Command: + - `hatch run pytest -q tests/integration/commands/test_auth_commands_integration.py tests/integration/commands/test_repro_command.py tests/integration/commands/test_repro_sidecar.py tests/e2e/test_enforcement_workflow.py tests/e2e/test_init_command.py tests/integration/commands/test_spec_commands.py tests/e2e/test_specmatic_integration_e2e.py` +- Result: + - `53 passed` + +### Tier 3/4 (`contract`, `project`, `generate`, `sync`, `backlog`, `import_cmd`, `plan`) + +- Command: + - `hatch run pytest -q tests/integration/commands/test_contract_commands.py tests/integration/commands/test_project_commands.py tests/integration/commands/test_generate_command.py tests/integration/sync/test_sync_command.py tests/integration/commands/test_sync_intelligent_command.py tests/integration/backlog/test_backlog_filtering_integration.py tests/integration/commands/test_import_enrichment_contracts.py tests/integration/test_plan_command.py tests/unit/commands/test_plan_add_commands.py tests/unit/commands/test_plan_update_commands.py tests/unit/commands/test_plan_telemetry.py` +- Result: + - `154 passed` + +## TDD Order Note (Task 4.2) + +The strict pre-implementation "expect failure first" step cannot be replayed after migration is already implemented. +This mapping captures retrospective targeted verification for the same scenarios and records current pass evidence. diff --git a/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/proposal.md b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/proposal.md new file mode 100644 index 00000000..c240f8b1 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/proposal.md @@ -0,0 +1,36 @@ +# Change: Module Package Separation for Command Implementations + +## Why + +The modular registry introduced in arch-01 created module packages, but command implementations still live in `src/specfact_cli/commands/` and module `src/app.py` files are mostly re-export shims. This keeps command ownership centralized and increases merge conflict risk when multiple modules evolve in parallel. + +To complete the modular architecture, each command implementation needs to live inside its own module package while preserving backward compatibility for existing imports and command loading behavior. + +## What Changes + +- **NEW**: Define and execute a phased migration pattern that moves command implementations from `src/specfact_cli/commands/*.py` into `src/specfact_cli/modules/<module>/src/commands.py`. +- **NEW**: Require module-local `src/app.py` wiring (`from ...src.commands import app`) and module-local `src/__init__.py` scaffolding for each migrated module. +- **NEW**: Require backward-compatible re-export shims in `src/specfact_cli/commands/*.py` so external and legacy imports remain stable during migration, including temporary re-export of currently used non-`app` symbols. +- **NEW**: Require per-module verification (CLI help, contract-first tests, relevant module tests) and phased rollout from simplest modules to heavyweight modules. +- **NEW**: Require decoupling work that extracts cross-command helper symbols into shared core modules (`utils`, `models`, or dedicated shared modules), then migrates imports off `specfact_cli.commands.*`. +- **NEW**: Require a boundary guard in tests/CI so new cross-command imports (`specfact_cli.commands.*` non-`app`) cannot be introduced after migration waves. +- **EXTEND**: Require migration tasks to include documentation review and updates where architecture docs or contributor guidance reference the old command layout. + +## Capabilities + +- **module-package-separation**: Migrate command implementations into module packages with compatibility shims and phased validation. + +## Impact + +- **Affected specs**: New `openspec/changes/arch-02-module-package-separation/specs/module-package-separation/spec.md`. +- **Affected code**: `src/specfact_cli/commands/`, `src/specfact_cli/modules/*/src/`, and module-local test locations under `src/specfact_cli/modules/*/tests/` where applicable. +- **Affected documentation** (<https://docs.specfact.io>): Any architecture or contributor docs that describe command locations and module boundaries; likely includes `README.md`, `AGENTS.md`, and potentially docs architecture/reference pages if they mention `src/specfact_cli/commands` as implementation home. +- **Integration points**: Module discovery/registry loading via existing module package entrypoints (`src/app.py`), CLI command help and invocation behavior, contract-first test workflows. +- **Backward compatibility**: Preserved in transition by command-level re-export shims for `app` plus currently used legacy symbols; then reduced toward `app`-only once dependents are migrated. + +## Source Tracking + +- **GitHub Issue**: #199 +- **Issue URL**: <https://github.com/nold-ai/specfact-cli/issues/199> +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: proposed diff --git a/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/specs/module-package-separation/spec.md b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/specs/module-package-separation/spec.md new file mode 100644 index 00000000..d94ee491 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/specs/module-package-separation/spec.md @@ -0,0 +1,65 @@ +# Module Package Separation + +## ADDED Requirements + +### Requirement: Module-local command implementation + +The system SHALL store each CLI command implementation inside its owning module package at `src/specfact_cli/modules/<module>/src/commands.py`. + +#### Scenario: Move command implementation into module package + +**Given** an existing command implementation in `src/specfact_cli/commands/<command_file>.py` + +**When** that module is migrated in this change + +**Then** the command implementation is moved to `src/specfact_cli/modules/<module>/src/commands.py` + +**And** the module package includes `src/__init__.py` + +**And** the module `src/app.py` imports `app` from the module-local `commands` module + +### Requirement: Backward-compatible command shims + +The system SHALL preserve backward compatibility for legacy imports from `src/specfact_cli/commands/` during migration. + +#### Scenario: Legacy import path remains valid + +**Given** existing code or tests import `app` from `specfact_cli.commands.<command>` + +**When** a module has been migrated to module-local command implementation + +**Then** `src/specfact_cli/commands/<command_file>.py` remains present as a re-export shim + +**And** the shim imports `app` from `specfact_cli.modules.<module>.src.commands` + +**And** command invocation behavior remains unchanged + +### Requirement: Phased migration with verification gates + +The system SHALL execute migration in phased waves and require verification for each wave before proceeding. + +#### Scenario: Tier-based migration progression + +**Given** migration tiers ordered from simplest modules to heavyweight modules + +**When** a tier migration is executed + +**Then** tests derived from this change spec are run for the migrated modules + +**And** CLI help paths for migrated commands remain available + +**And** contract-first validation passes before the next tier starts + +### Requirement: Module dependency declaration integrity + +The system SHALL keep `module_dependencies` accurate in each module package manifest when migration introduces module-to-module imports. + +#### Scenario: Dependency declaration after migration + +**Given** a migrated module imports code from another module package + +**When** `module-package.yaml` is reviewed for that module + +**Then** the imported module is declared under `module_dependencies` + +**And** if no cross-module imports exist, `module_dependencies` remains empty diff --git a/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/tasks.md b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/tasks.md new file mode 100644 index 00000000..d0208084 --- /dev/null +++ b/openspec/changes/archive/2026-02-06-arch-02-module-package-separation/tasks.md @@ -0,0 +1,98 @@ +# Tasks: Module Package Separation for Command Implementations + +## TDD / SDD order (enforced) + +Per `openspec/config.yaml`, **tests before code** apply to any task that adds or changes behavior. + +1. **Spec deltas** define behavior (Given/When/Then) in `openspec/changes/arch-02-module-package-separation/specs/module-package-separation/spec.md`. +2. **Tests second**: Write unit/integration tests from those scenarios; run tests and **expect failure** (no implementation yet). +3. **Code last**: Implement until tests pass and behavior satisfies the spec. + +Do not implement production code for new behavior until the corresponding tests exist and have been run (expecting failure). + +--- + +## 1. Create git branch from dev + +- [x] 1.1 Ensure we're on `dev` and up to date: `git checkout dev && git pull origin dev` +- [x] 1.2 Create branch with Development link to issue (if issue exists): `gh issue develop 199 --repo nold-ai/specfact-cli --name feature/arch-02-module-package-separation --checkout` +- [x] 1.3 Or create branch without issue link: `git checkout -b feature/arch-02-module-package-separation` (if no issue yet) +- [x] 1.4 Verify branch was created: `git branch --show-current` + +## 2. Create GitHub issue in nold-ai/specfact-cli (mandatory for public repo) + +- [x] 2.1 Create sanitized issue: `gh issue create --repo nold-ai/specfact-cli --title "[Change] Module package separation for command implementations" --body-file <path> --label "enhancement" --label "change-proposal"` +- [x] 2.2 Use proposal content for Why/What Changes and add footer `*OpenSpec Change Proposal: arch-02-module-package-separation*` +- [x] 2.3 Confirm `proposal.md` Source Tracking section references issue `#199`, URL `https://github.com/nold-ai/specfact-cli/issues/199`, repository, and Last Synced Status `proposed` +- [x] 2.4 Optionally link issue to project board: `gh project item-add 1 --owner nold-ai --url <issue-url>` + +## 3. Verify spec deltas (SDD: specs first) + +- [x] 3.1 Confirm `specs/module-package-separation/spec.md` exists and covers migration behavior, compatibility shims, phased execution, and verification. +- [x] 3.2 Map each scenario to concrete implementation and test tasks before touching command code. + +## 4. Tests first (TDD: write tests from spec scenarios; expect failure) + +- [x] 4.1 Add/update tests derived from spec scenarios for migrated modules: module-local app wiring, backward-compatible imports from `src/specfact_cli/commands/*`, and module discovery behavior. +- [x] 4.2 Run targeted tests and expect failures before migration code is updated. (Retrospective note: migration already implemented; targeted scenario tests executed and documented in `TEST_SCENARIO_MAPPING.md`.) +- [x] 4.3 Document test-to-scenario mapping for each migrated module wave. + +## 5. Build compatibility map before migration waves + +- [x] 5.1 Inventory all non-`app` imports from `specfact_cli.commands.*` in `src/` and `tests/` (current baseline: 35 files). +- [x] 5.2 For each imported symbol, decide migration target: temporary shim re-export, move to shared core module, or direct module-local import. +- [x] 5.3 Add regression tests for symbol-level compatibility for migrated modules (not only CLI command `app` entrypoints). + +## 6. Implement Tier 1 migration wave (small modules first) + +- [x] 6.1 Migrate Tier 1 modules (`drift`, `upgrade`, `validate`, `sdd`) by moving command logic to `src/specfact_cli/modules/<name>/src/commands.py` and adding module-local `src/__init__.py`. +- [x] 6.2 Update `src/specfact_cli/modules/<name>/src/app.py` to import `app` from local `commands`. +- [x] 6.3 Replace legacy files in `src/specfact_cli/commands/` with backward-compatible shims that re-export `app` plus any still-used legacy symbols for that module. +- [x] 6.4 Re-run targeted tests for Tier 1 and fix failures. + +## 7. Implement Tier 2 migration wave + +- [x] 7.1 Migrate Tier 2 modules (`auth`, `repro`, `enforce`, `migrate`, `spec`, `init`) using the same move/update/shim pattern. +- [x] 7.2 Verify any module-specific test relocations to module package test directories where appropriate. +- [x] 7.3 Re-run targeted tests for Tier 2 and fix failures. + +## 8. Implement Tier 3 and Tier 4 migration waves + +- [x] 8.1 Migrate Tier 3 modules (`contract`, `project`, `generate`, `sync`, `backlog`, `import_cmd`) with the same pattern. +- [x] 8.2 Migrate Tier 4 module (`plan`) last after validating prior wave stability. +- [x] 8.3 Keep `module_dependencies` in each `module-package.yaml` accurate if any cross-module dependency is introduced. +- [x] 8.4 Re-run targeted tests for Tier 3/4 and fix failures. + +## 9. Decouple cross-command dependencies + +- [x] 9.1 Extract shared helper symbols currently imported from command modules into stable shared packages (for example under `src/specfact_cli/utils/` or `src/specfact_cli/shared/`). +- [x] 9.2 Update `src/` and `tests/` imports to stop relying on `specfact_cli.commands.*` for non-`app` symbols. +- [x] 9.3 Reduce shim exports module-by-module as dependents are migrated; keep only compatibility exports still needed. +- [x] 9.4 Add/enable boundary checks so new non-`app` imports from `specfact_cli.commands.*` fail CI. + +## 10. Quality gates + +- [x] 10.1 Run formatting and static checks: `hatch run format`, `hatch run lint`, `hatch run type-check`. (`format` and `type-check` pass; `lint` currently advisory in CI via `.github/workflows/pr-orchestrator.yml`.) +- [x] 10.2 Run contract-first validation: `hatch run contract-test`. +- [x] 10.3 Run full or smart test suite: `hatch run smart-test`. +- [x] 10.4 Verify command UX remains stable by checking representative help paths: `specfact <command> --help` across migrated modules. +- [x] 10.5 Verify non-`app` imports from `specfact_cli.commands.*` are reduced versus baseline and tracked toward zero. + +## 11. Documentation research and review + +- [x] 11.1 Identify affected docs: `README.md`, `AGENTS.md`, and any docs pages that describe command implementation locations or module architecture. +- [x] 11.2 Update docs to reflect module-local command implementations and compatibility shim policy. +- [x] 11.3 Document deprecation timeline for non-`app` command-module imports and expected replacement import paths. +- [x] 11.4 If docs pages are added or moved, ensure front-matter and sidebar updates in `docs/_layouts/default.html`. (No docs pages were added/moved in this change.) + +## 12. Version and changelog (required before PR) + +- [x] 12.1 Bump version per semver for this architectural feature and sync versions in `pyproject.toml`, `setup.py`, `src/__init__.py`, and `src/specfact_cli/__init__.py`. +- [x] 12.2 Add `CHANGELOG.md` entry describing module package separation and compatibility behavior. + +## 13. Create Pull Request to dev (last) + +- [x] 13.1 Commit all changes with conventional commit message(s). +- [x] 13.2 Push branch: `git push origin feature/arch-02-module-package-separation`. +- [x] 13.3 Create PR to `dev` using repository template and include OpenSpec reference plus issue linkage (`Fixes nold-ai/specfact-cli#199`). +- [x] 13.4 Verify issue Development section links branch and PR. (Verified via PR cross-reference on issue #199 with head branch `feature/arch-02-module-package-separation`.) diff --git a/openspec/specs/module-package-separation/spec.md b/openspec/specs/module-package-separation/spec.md new file mode 100644 index 00000000..38b8239c --- /dev/null +++ b/openspec/specs/module-package-separation/spec.md @@ -0,0 +1,67 @@ +# module-package-separation Specification + +## Purpose +TBD - created by archiving change arch-02-module-package-separation. Update Purpose after archive. +## Requirements +### Requirement: Module-local command implementation + +The system SHALL store each CLI command implementation inside its owning module package at `src/specfact_cli/modules/<module>/src/commands.py`. + +#### Scenario: Move command implementation into module package + +**Given** an existing command implementation in `src/specfact_cli/commands/<command_file>.py` + +**When** that module is migrated in this change + +**Then** the command implementation is moved to `src/specfact_cli/modules/<module>/src/commands.py` + +**And** the module package includes `src/__init__.py` + +**And** the module `src/app.py` imports `app` from the module-local `commands` module + +### Requirement: Backward-compatible command shims + +The system SHALL preserve backward compatibility for legacy imports from `src/specfact_cli/commands/` during migration. + +#### Scenario: Legacy import path remains valid + +**Given** existing code or tests import `app` from `specfact_cli.commands.<command>` + +**When** a module has been migrated to module-local command implementation + +**Then** `src/specfact_cli/commands/<command_file>.py` remains present as a re-export shim + +**And** the shim imports `app` from `specfact_cli.modules.<module>.src.commands` + +**And** command invocation behavior remains unchanged + +### Requirement: Phased migration with verification gates + +The system SHALL execute migration in phased waves and require verification for each wave before proceeding. + +#### Scenario: Tier-based migration progression + +**Given** migration tiers ordered from simplest modules to heavyweight modules + +**When** a tier migration is executed + +**Then** tests derived from this change spec are run for the migrated modules + +**And** CLI help paths for migrated commands remain available + +**And** contract-first validation passes before the next tier starts + +### Requirement: Module dependency declaration integrity + +The system SHALL keep `module_dependencies` accurate in each module package manifest when migration introduces module-to-module imports. + +#### Scenario: Dependency declaration after migration + +**Given** a migrated module imports code from another module package + +**When** `module-package.yaml` is reviewed for that module + +**Then** the imported module is declared under `module_dependencies` + +**And** if no cross-module imports exist, `module_dependencies` remains empty + diff --git a/pyproject.toml b/pyproject.toml index 1876cc79..995ccdbb 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.27.0" +version = "0.28.0" 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" @@ -193,8 +193,8 @@ validate-prompts = "python tools/validate_prompts.py" # Development scripts test = "pytest {args}" test-cov = "pytest --cov=src --cov-report=term-missing {args}" -type-check = "basedpyright {args}" -lint = "ruff format . --check && basedpyright && ruff check . && pylint src tests tools" +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" governance = "pylint src tests tools --reports=y --output-format=parseable" format = "ruff check . --fix && ruff format ." @@ -705,6 +705,9 @@ ignore = [ "src/specfact_cli/commands/**/*" = [ "B008", # typer.Option/Argument in defaults (common Typer pattern) ] +"src/specfact_cli/modules/**/src/commands.py" = [ + "B008", # typer.Option/Argument in defaults (common Typer pattern) +] [tool.ruff.lint.isort] # Match isort ruff profile configuration diff --git a/pyrightconfig.json b/pyrightconfig.json index e906075c..0cb26c5d 100644 --- a/pyrightconfig.json +++ b/pyrightconfig.json @@ -9,6 +9,8 @@ ], "pythonVersion": "3.11", "typeCheckingMode": "standard", + "reportMissingImports": "none", + "reportMissingModuleSource": "none", "reportUnknownMemberType": "warning", "reportAttributeAccessIssue": "warning", "useLibraryCodeForTypes": true, @@ -26,4 +28,4 @@ "**/coverage_html_report/**", "**/coverage.*/**", ] -} \ No newline at end of file +} diff --git a/setup.py b/setup.py index 81acca7f..6a7f79dd 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.27.0", + version="0.28.0", 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 fec87e64..8a9a0e05 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.27.0" +__version__ = "0.28.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index dafb4c07..fb8f6639 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.27.0" +__version__ = "0.28.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/analyzers/graph_analyzer.py b/src/specfact_cli/analyzers/graph_analyzer.py index fe3b0c3a..57460aef 100644 --- a/src/specfact_cli/analyzers/graph_analyzer.py +++ b/src/specfact_cli/analyzers/graph_analyzer.py @@ -370,11 +370,11 @@ def _find_matching_module(self, imported: str, known_modules: list[str]) -> str Tries multiple strategies: 1. Exact match - 2. Last part match (e.g., "import_cmd" matches "src.specfact_cli.commands.import_cmd") - 3. Partial path match (e.g., "specfact_cli.commands" matches "src.specfact_cli.commands.import_cmd") + 2. Last part match (e.g., "import_cmd" matches "src.specfact_cli.modules.import_cmd.src.commands") + 3. Partial path match (e.g., "specfact_cli.commands" matches "src.specfact_cli.modules.import_cmd.src.commands") Args: - imported: Imported module name (e.g., "specfact_cli.commands.import_cmd") + imported: Imported module name (e.g., "specfact_cli.modules.import_cmd.src.commands") known_modules: List of known module names in the graph Returns: @@ -385,14 +385,14 @@ def _find_matching_module(self, imported: str, known_modules: list[str]) -> str return imported # Strategy 2: Last part match - # e.g., "import_cmd" matches "src.specfact_cli.commands.import_cmd" + # e.g., "import_cmd" matches "src.specfact_cli.modules.import_cmd.src.commands" imported_last = imported.split(".")[-1] for module in known_modules: if module.endswith(f".{imported_last}") or module == imported_last: return module # Strategy 3: Partial path match - # e.g., "specfact_cli.commands" matches "src.specfact_cli.commands.import_cmd" + # e.g., "specfact_cli.commands" matches "src.specfact_cli.modules.import_cmd.src.commands" for module in known_modules: # Check if imported is a prefix of module if module.startswith(imported + ".") or module == imported: @@ -406,7 +406,7 @@ def _find_matching_module(self, imported: str, known_modules: list[str]) -> str for module in known_modules: module_parts = module.split(".") # Check if there's overlap in the path - # e.g., "commands.import_cmd" might match "src.specfact_cli.commands.import_cmd" + # e.g., "commands.import_cmd" might match "src.specfact_cli.modules.import_cmd.src.commands" if len(imported_parts) >= 2 and len(module_parts) >= 2 and imported_parts[-2:] == module_parts[-2:]: return module diff --git a/src/specfact_cli/commands/analyze.py b/src/specfact_cli/commands/analyze.py index acff0a75..c250ec2d 100644 --- a/src/specfact_cli/commands/analyze.py +++ b/src/specfact_cli/commands/analyze.py @@ -1,361 +1,6 @@ -""" -Analyze command - Analyze codebase for contract coverage and quality. +"""Backward-compatible app shim. Implementation moved to modules/analyze/.""" -This module provides commands for analyzing codebases to determine -contract coverage, code quality metrics, and enhancement opportunities. -""" +from specfact_cli.modules.analyze.src.commands import app -from __future__ import annotations -import ast -from pathlib import Path - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.table import Table - -from specfact_cli.models.quality import CodeQuality, QualityTracking -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils import print_error, print_success -from specfact_cli.utils.progress import load_bundle_with_progress -from specfact_cli.utils.structure import SpecFactStructure - - -app = typer.Typer(help="Analyze codebase for contract coverage and quality") -console = Console() - - -@app.command("contracts") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@ensure(lambda result: result is None, "Must return None") -def analyze_contracts( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'", - ), -) -> None: - """ - Analyze contract coverage for codebase. - - Scans codebase to determine which files have beartype, icontract, - and CrossHair contracts, and identifies files that need enhancement. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle (required) - - **Examples:** - specfact analyze contracts --repo . --bundle legacy-api - """ - if is_debug_mode(): - debug_log_operation("command", "analyze contracts", "started", extra={"repo": str(repo), "bundle": bundle}) - debug_print("[dim]analyze contracts: started[/dim]") - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None: - if is_debug_mode(): - debug_log_operation( - "command", - "analyze contracts", - "failed", - error="Bundle name required", - extra={"reason": "no_bundle"}, - ) - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - repo_path = repo.resolve() - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - - if not bundle_dir.exists(): - if is_debug_mode(): - debug_log_operation( - "command", - "analyze contracts", - "failed", - error=f"Bundle not found: {bundle_dir}", - extra={"reason": "bundle_missing"}, - ) - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - telemetry_metadata = { - "bundle": bundle, - } - - with telemetry.track_command("analyze.contracts", telemetry_metadata) as record: - console.print(f"[bold cyan]Contract Coverage Analysis:[/bold cyan] {bundle}") - console.print(f"[dim]Repository:[/dim] {repo_path}\n") - - # Load project bundle with unified progress display - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Analyze each feature's source files - quality_tracking = QualityTracking() - files_analyzed = 0 - files_with_beartype = 0 - files_with_icontract = 0 - files_with_crosshair = 0 - - for _feature_key, feature in project_bundle.features.items(): - if not feature.source_tracking: - continue - - for impl_file in feature.source_tracking.implementation_files: - file_path = repo_path / impl_file - if not file_path.exists(): - continue - - files_analyzed += 1 - quality = _analyze_file_quality(file_path) - quality_tracking.code_quality[impl_file] = quality - - if quality.beartype: - files_with_beartype += 1 - if quality.icontract: - files_with_icontract += 1 - if quality.crosshair: - files_with_crosshair += 1 - - # Sort files: prioritize files missing contracts - # Sort key: (has_all_contracts, total_contracts, file_path) - # This puts files missing contracts first, then by number of contracts (asc), then alphabetically - def sort_key(item: tuple[str, CodeQuality]) -> tuple[bool, int, str]: - file_path, quality = item - has_all = quality.beartype and quality.icontract and quality.crosshair - total_contracts = sum([quality.beartype, quality.icontract, quality.crosshair]) - return (has_all, total_contracts, file_path) - - sorted_files = sorted(quality_tracking.code_quality.items(), key=sort_key) - - # Show files needing attention first, limit to 30 for readability - max_display = 30 - files_to_display = sorted_files[:max_display] - total_files = len(sorted_files) - - # Display results - table_title = "Contract Coverage Analysis" - if total_files > max_display: - table_title += f" (showing top {max_display} files needing attention)" - table = Table(title=table_title) - table.add_column("File", style="cyan") - table.add_column("beartype", justify="center") - table.add_column("icontract", justify="center") - table.add_column("crosshair", justify="center") - table.add_column("Coverage", justify="right") - - for file_path, quality in files_to_display: - # Highlight files missing contracts - file_style = "yellow" if not (quality.beartype and quality.icontract) else "cyan" - table.add_row( - f"[{file_style}]{file_path}[/{file_style}]", - "✓" if quality.beartype else "[red]✗[/red]", - "✓" if quality.icontract else "[red]✗[/red]", - "✓" if quality.crosshair else "[dim]✗[/dim]", - f"{quality.coverage:.0%}", - ) - - console.print(table) - - # Show message if files were filtered - if total_files > max_display: - console.print( - f"\n[yellow]Note:[/yellow] Showing top {max_display} files needing attention " - f"(out of {total_files} total files analyzed). " - f"Files missing contracts are prioritized." - ) - - # Summary - console.print("\n[bold]Summary:[/bold]") - console.print(f" Files analyzed: {files_analyzed}") - if files_analyzed > 0: - beartype_pct = files_with_beartype / files_analyzed - icontract_pct = files_with_icontract / files_analyzed - crosshair_pct = files_with_crosshair / files_analyzed - console.print(f" Files with beartype: {files_with_beartype} ({beartype_pct:.1%})") - console.print(f" Files with icontract: {files_with_icontract} ({icontract_pct:.1%})") - console.print(f" Files with crosshair: {files_with_crosshair} ({crosshair_pct:.1%})") - else: - console.print(" Files with beartype: 0") - console.print(" Files with icontract: 0") - console.print(" Files with crosshair: 0") - - # Save quality tracking - quality_file = bundle_dir / "quality-tracking.yaml" - import yaml - - quality_file.parent.mkdir(parents=True, exist_ok=True) - with quality_file.open("w", encoding="utf-8") as f: - yaml.dump(quality_tracking.model_dump(), f, default_flow_style=False) - - print_success(f"Quality tracking saved to: {quality_file}") - - record( - { - "files_analyzed": files_analyzed, - "files_with_beartype": files_with_beartype, - "files_with_icontract": files_with_icontract, - "files_with_crosshair": files_with_crosshair, - } - ) - if is_debug_mode(): - debug_log_operation( - "command", - "analyze contracts", - "success", - extra={"files_analyzed": files_analyzed, "bundle": bundle}, - ) - debug_print("[dim]analyze contracts: success[/dim]") - - -def _analyze_file_quality(file_path: Path) -> CodeQuality: - """Analyze a file for contract coverage.""" - try: - with file_path.open(encoding="utf-8") as f: - content = f.read() - - # Quick check: if file is in models/ directory, likely a data model file - # This avoids expensive AST parsing for most data model files - file_str = str(file_path) - is_models_dir = "/models/" in file_str or "\\models\\" in file_str - - # For files in models/ directory, do quick AST check to confirm - if is_models_dir: - try: - import ast - - tree = ast.parse(content, filename=str(file_path)) - # Quick check: if only BaseModel classes with no business logic, skip contract check - if _is_pure_data_model_file(tree): - return CodeQuality( - beartype=True, # Pydantic provides type validation - icontract=True, # Pydantic provides validation (Field validators) - crosshair=False, # CrossHair not typically used for data models - coverage=0.0, - ) - except (SyntaxError, ValueError): - # If AST parsing fails, fall through to normal check - pass - - # Check for contract decorators in content - has_beartype = "beartype" in content or "@beartype" in content - has_icontract = "icontract" in content or "@require" in content or "@ensure" in content - has_crosshair = "crosshair" in content.lower() - - # Simple coverage estimation (would need actual test coverage tool) - coverage = 0.0 - - return CodeQuality( - beartype=has_beartype, - icontract=has_icontract, - crosshair=has_crosshair, - coverage=coverage, - ) - except Exception: - # Return default quality if analysis fails - return CodeQuality() - - -def _is_pure_data_model_file(tree: ast.AST) -> bool: - """ - Quick check if file contains only pure data models (Pydantic BaseModel, dataclasses) with no business logic. - - Returns: - True if file is pure data models, False otherwise - """ - has_pydantic_models = False - has_dataclasses = False - has_business_logic = False - - # Standard methods that don't need contracts (including common helper methods) - standard_methods = { - "__init__", - "__str__", - "__repr__", - "__eq__", - "__hash__", - "model_dump", - "model_validate", - "dict", - "json", - "copy", - "update", - # Common helper methods on data models (convenience methods, not business logic) - "compute_summary", - "update_summary", - "to_dict", - "from_dict", - "validate", - "serialize", - "deserialize", - } - - # Check module-level functions and class methods separately - # First, collect all classes and check their methods - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - # Check methods in this class - for item in node.body: - if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name not in standard_methods: - # Non-standard method - likely business logic - has_business_logic = True - break - if has_business_logic: - break - - # Then check for module-level functions (functions not inside any class) - if not has_business_logic and isinstance(tree, ast.Module): - # Get all top-level nodes (module body) - for node in tree.body: - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith( - "_" - ): # Public functions - has_business_logic = True - break - - # Check for Pydantic models and dataclasses - for node in ast.walk(tree): - if isinstance(node, ast.ClassDef): - for base in node.bases: - if isinstance(base, ast.Name) and base.id == "BaseModel": - has_pydantic_models = True - break - if isinstance(base, ast.Attribute) and base.attr == "BaseModel": - has_pydantic_models = True - break - - for decorator in node.decorator_list: - if (isinstance(decorator, ast.Name) and decorator.id == "dataclass") or ( - isinstance(decorator, ast.Attribute) and decorator.attr == "dataclass" - ): - has_dataclasses = True - break - - # Business logic check is done above (methods and module-level functions) - - # File is pure data model if: - # 1. Has Pydantic models or dataclasses - # 2. No business logic methods or functions - return (has_pydantic_models or has_dataclasses) and not has_business_logic +__all__ = ["app"] diff --git a/src/specfact_cli/commands/auth.py b/src/specfact_cli/commands/auth.py index 1254349d..d17c84ff 100644 --- a/src/specfact_cli/commands/auth.py +++ b/src/specfact_cli/commands/auth.py @@ -1,708 +1,6 @@ -"""Authentication commands for DevOps providers.""" +"""Backward-compatible app shim. Implementation moved to modules/auth/.""" -from __future__ import annotations +from specfact_cli.modules.auth.src.commands import app -import os -import time -from datetime import UTC, datetime, timedelta -from typing import Any -import requests -import typer -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console -from specfact_cli.utils.auth_tokens import ( - clear_all_tokens, - clear_token, - normalize_provider, - set_token, - token_is_expired, -) - - -app = typer.Typer(help="Authenticate with DevOps providers using device code flows") -console = get_configured_console() - - -AZURE_DEVOPS_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798/.default" -# Note: Refresh tokens (90-day lifetime) are automatically obtained via persistent token cache -# offline_access is a reserved scope and cannot be explicitly requested -AZURE_DEVOPS_SCOPES = [AZURE_DEVOPS_RESOURCE] -DEFAULT_GITHUB_BASE_URL = "https://github.com" -DEFAULT_GITHUB_API_URL = "https://api.github.com" -DEFAULT_GITHUB_SCOPES = "repo" -DEFAULT_GITHUB_CLIENT_ID = "Ov23lizkVHsbEIjZKvRD" - - -@beartype -@ensure(lambda result: result is None, "Must return None") -def _print_token_status(provider: str, token_data: dict[str, Any]) -> None: - """Print a formatted token status line.""" - expires_at = token_data.get("expires_at") - status = "valid" - if token_is_expired(token_data): - status = "expired" - scope_info = "" - scopes = token_data.get("scopes") or token_data.get("scope") - if isinstance(scopes, list): - scope_info = ", scopes=" + ",".join(scopes) - elif isinstance(scopes, str) and scopes: - scope_info = f", scopes={scopes}" - expiry_info = f", expires_at={expires_at}" if expires_at else "" - console.print(f"[bold]{provider}[/bold]: {status}{scope_info}{expiry_info}") - - -@beartype -@ensure(lambda result: isinstance(result, str), "Must return base URL") -def _normalize_github_host(base_url: str) -> str: - """Normalize GitHub base URL to host root (no API path).""" - trimmed = base_url.rstrip("/") - if trimmed.endswith("/api/v3"): - trimmed = trimmed[: -len("/api/v3")] - if trimmed.endswith("/api"): - trimmed = trimmed[: -len("/api")] - return trimmed - - -@beartype -@ensure(lambda result: isinstance(result, str), "Must return API base URL") -def _infer_github_api_base_url(host_url: str) -> str: - """Infer GitHub API base URL from host URL.""" - normalized = host_url.rstrip("/") - if normalized.lower() == DEFAULT_GITHUB_BASE_URL: - return DEFAULT_GITHUB_API_URL - return f"{normalized}/api/v3" - - -@beartype -@require(lambda scopes: isinstance(scopes, str), "Scopes must be string") -@ensure(lambda result: isinstance(result, str), "Must return scope string") -def _normalize_scopes(scopes: str) -> str: - """Normalize scope string to space-separated list.""" - if not scopes.strip(): - return DEFAULT_GITHUB_SCOPES - if "," in scopes: - parts = [part.strip() for part in scopes.split(",") if part.strip()] - return " ".join(parts) - return scopes.strip() - - -@beartype -@require(lambda client_id: isinstance(client_id, str) and len(client_id) > 0, "Client ID required") -@require(lambda base_url: isinstance(base_url, str) and len(base_url) > 0, "Base URL required") -@require(lambda scopes: isinstance(scopes, str), "Scopes must be string") -@ensure(lambda result: isinstance(result, dict), "Must return device code response") -def _request_github_device_code(client_id: str, base_url: str, scopes: str) -> dict[str, Any]: - """Request GitHub device code payload.""" - endpoint = f"{base_url.rstrip('/')}/login/device/code" - headers = {"Accept": "application/json"} - payload = {"client_id": client_id, "scope": scopes} - response = requests.post(endpoint, data=payload, headers=headers, timeout=30) - response.raise_for_status() - return response.json() - - -@beartype -@require(lambda client_id: isinstance(client_id, str) and len(client_id) > 0, "Client ID required") -@require(lambda base_url: isinstance(base_url, str) and len(base_url) > 0, "Base URL required") -@require(lambda device_code: isinstance(device_code, str) and len(device_code) > 0, "Device code required") -@require(lambda interval: isinstance(interval, int) and interval > 0, "Interval must be positive int") -@require(lambda expires_in: isinstance(expires_in, int) and expires_in > 0, "Expires_in must be positive int") -@ensure(lambda result: isinstance(result, dict), "Must return token response") -def _poll_github_device_token( - client_id: str, - base_url: str, - device_code: str, - interval: int, - expires_in: int, -) -> dict[str, Any]: - """Poll GitHub device code token endpoint until authorized or timeout.""" - endpoint = f"{base_url.rstrip('/')}/login/oauth/access_token" - headers = {"Accept": "application/json"} - payload = { - "client_id": client_id, - "device_code": device_code, - "grant_type": "urn:ietf:params:oauth:grant-type:device_code", - } - - deadline = time.monotonic() + expires_in - poll_interval = interval - - while time.monotonic() < deadline: - response = requests.post(endpoint, data=payload, headers=headers, timeout=30) - response.raise_for_status() - body = response.json() - error = body.get("error") - if not error: - return body - - if error == "authorization_pending": - time.sleep(poll_interval) - continue - if error == "slow_down": - poll_interval += 5 - time.sleep(poll_interval) - continue - if error in {"expired_token", "access_denied"}: - msg = body.get("error_description") or error - raise RuntimeError(msg) - - msg = body.get("error_description") or error - raise RuntimeError(msg) - - raise RuntimeError("Device code expired before authorization completed") - - -@app.command("azure-devops") -def auth_azure_devops( - pat: str | None = typer.Option( - None, - "--pat", - help="Store a Personal Access Token (PAT) directly. PATs can have expiration up to 1 year, " - "unlike OAuth tokens which expire after ~1 hour. Create PAT at: " - "https://dev.azure.com/{org}/_usersSettings/tokens", - ), - use_device_code: bool = typer.Option( - False, - "--use-device-code", - help="Force device code flow instead of trying interactive browser first. " - "Useful for SSH/headless environments where browser cannot be opened.", - ), -) -> None: - """ - Authenticate to Azure DevOps using OAuth (device code or interactive browser) or Personal Access Token (PAT). - - **Token Options:** - - 1. **Personal Access Token (PAT)** - Recommended for long-lived authentication: - - Use --pat option to store a PAT directly - - PATs can have expiration up to 1 year (maximum allowed) - - Create PAT at: https://dev.azure.com/{org}/_usersSettings/tokens - - Select required scopes (e.g., "Work Items: Read & Write") - - Example: specfact auth azure-devops --pat your_pat_token - - 2. **OAuth Flow** (default, when no PAT provided): - - **First tries interactive browser** (opens browser automatically, better UX) - - **Falls back to device code** if browser unavailable (SSH/headless environments) - - Access tokens expire after ~1 hour, refresh tokens last 90 days (obtained automatically via persistent cache) - - Refresh tokens are automatically obtained when using persistent token cache (no explicit scope needed) - - Automatic token refresh via persistent cache (no re-authentication needed for 90 days) - - Example: specfact auth azure-devops - - 3. **Force Device Code Flow** (--use-device-code): - - Skip interactive browser, use device code directly - - Useful for SSH/headless environments or when browser cannot be opened - - Example: specfact auth azure-devops --use-device-code - - **For Long-Lived Tokens:** - Use a PAT with 90 days or 1 year expiration instead of OAuth tokens to avoid - frequent re-authentication. PATs are stored securely and work the same way as OAuth tokens. - """ - try: - from azure.identity import ( # type: ignore[reportMissingImports] - DeviceCodeCredential, - InteractiveBrowserCredential, - ) - except ImportError: - console.print("[bold red]✗[/bold red] azure-identity is not installed.") - console.print("Install dependencies with: pip install specfact-cli") - raise typer.Exit(1) from None - - def prompt_callback(verification_uri: str, user_code: str, expires_on: datetime) -> None: - expires_at = expires_on - if expires_at.tzinfo is None: - expires_at = expires_at.replace(tzinfo=UTC) - console.print("To sign in, use a web browser to open:") - console.print(f"[bold]{verification_uri}[/bold]") - console.print(f"Enter the code: [bold]{user_code}[/bold]") - console.print(f"Code expires at: {expires_at.isoformat()}") - - # If PAT is provided, store it directly (no expiration for PATs stored as Basic auth) - if pat: - console.print("[bold]Storing Personal Access Token (PAT)...[/bold]") - # PATs are stored as Basic auth tokens (no expiration date set by default) - # Users can create PATs with up to 1 year expiration in Azure DevOps UI - token_data = { - "access_token": pat, - "token_type": "basic", # PATs use Basic authentication - "issued_at": datetime.now(tz=UTC).isoformat(), - # Note: PAT expiration is managed by Azure DevOps, not stored locally - # Users should set expiration when creating PAT (up to 1 year) - } - set_token("azure-devops", token_data) - debug_log_operation("auth", "azure-devops", "success", extra={"method": "pat"}) - debug_print("[dim]auth azure-devops: PAT stored[/dim]") - console.print("[bold green]✓[/bold green] Personal Access Token stored") - console.print( - "[dim]PAT stored successfully. PATs can have expiration up to 1 year when created in Azure DevOps.[/dim]" - ) - console.print("[dim]Create/manage PATs at: https://dev.azure.com/{org}/_usersSettings/tokens[/dim]") - return - - # OAuth flow with persistent token cache (automatic refresh) - # Try interactive browser first, fall back to device code if it fails - debug_log_operation("auth", "azure-devops", "started", extra={"flow": "oauth"}) - debug_print("[dim]auth azure-devops: OAuth flow started[/dim]") - console.print("[bold]Starting Azure DevOps OAuth authentication...[/bold]") - - # Enable persistent token cache for automatic token refresh (like Azure CLI) - # This allows tokens to be refreshed automatically without re-authentication - cache_options = None - use_unencrypted_cache = False - try: - from azure.identity import TokenCachePersistenceOptions # type: ignore[reportMissingImports] - - # Try encrypted cache first (secure), fall back to unencrypted if keyring is locked - # Note: On Linux, the GNOME Keyring must be unlocked for encrypted cache to work. - # In SSH sessions, the keyring is typically locked and needs to be unlocked manually. - # The unencrypted cache fallback provides the same functionality (persistent storage, - # automatic refresh) without encryption. - try: - cache_options = TokenCachePersistenceOptions( - name="specfact-azure-devops", # Shared cache name across processes - allow_unencrypted_storage=False, # Prefer encrypted storage - ) - debug_log_operation("auth", "azure-devops", "cache_prepared", extra={"cache": "encrypted"}) - debug_print("[dim]auth azure-devops: token cache prepared (encrypted)[/dim]") - # Don't claim encrypted cache is enabled until we verify it works - # We'll print a message after successful authentication - # Check if we're on Linux and provide helpful info - import os - import platform - - if platform.system() == "Linux": - # Check D-Bus and secret service availability - dbus_session = os.environ.get("DBUS_SESSION_BUS_ADDRESS") - if not dbus_session: - console.print( - "[yellow]Note:[/yellow] D-Bus session not detected. Encrypted cache may fail.\n" - "[dim]To enable encrypted cache, ensure D-Bus is available:\n" - "[dim] - In SSH sessions: export $(dbus-launch)\n" - "[dim] - Unlock keyring: echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock[/dim]" - ) - except Exception: - # Encrypted cache not available (e.g., libsecret missing on Linux), try unencrypted - try: - cache_options = TokenCachePersistenceOptions( - name="specfact-azure-devops", - allow_unencrypted_storage=True, # Fallback: unencrypted storage - ) - use_unencrypted_cache = True - debug_log_operation( - "auth", - "azure-devops", - "cache_prepared", - extra={"cache": "unencrypted", "reason": "encrypted_unavailable"}, - ) - debug_print("[dim]auth azure-devops: token cache prepared (unencrypted fallback)[/dim]") - console.print( - "[yellow]Note:[/yellow] Encrypted cache unavailable (keyring locked). " - "Using unencrypted cache instead.\n" - "[dim]Tokens will be stored in plain text file but will refresh automatically.[/dim]" - ) - # Provide installation instructions for Linux - import platform - - if platform.system() == "Linux": - import os - - dbus_session = os.environ.get("DBUS_SESSION_BUS_ADDRESS") - console.print( - "[dim]To enable encrypted cache on Linux:\n" - " 1. Ensure packages are installed:\n" - " Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" - " RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" - " Arch: sudo pacman -S libsecret python-secretstorage\n" - ) - if not dbus_session: - console.print( - "[dim] 2. D-Bus session not detected. To enable encrypted cache:\n" - "[dim] - Start D-Bus: export $(dbus-launch)\n" - "[dim] - Unlock keyring: echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock\n" - "[dim] - Or use unencrypted cache (current fallback)[/dim]" - ) - else: - console.print( - "[dim] 2. D-Bus session detected, but keyring may be locked.\n" - "[dim] To unlock keyring in SSH session:\n" - "[dim] export $(dbus-launch)\n" - "[dim] echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock\n" - "[dim] Or use unencrypted cache (current fallback)[/dim]" - ) - except Exception: - # Persistent cache completely unavailable, use in-memory only - debug_log_operation( - "auth", - "azure-devops", - "cache_prepared", - extra={"cache": "none", "reason": "persistent_unavailable"}, - ) - debug_print("[dim]auth azure-devops: no persistent cache, in-memory only[/dim]") - console.print( - "[yellow]Note:[/yellow] Persistent cache not available, using in-memory cache only. " - "Tokens will need to be refreshed manually after expiration." - ) - # Provide installation instructions for Linux - import platform - - if platform.system() == "Linux": - console.print( - "[dim]To enable persistent token cache on Linux, install libsecret:\n" - " Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" - " RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" - " Arch: sudo pacman -S libsecret python-secretstorage\n" - " Also ensure a secret service daemon is running (gnome-keyring, kwallet, etc.)[/dim]" - ) - except ImportError: - # TokenCachePersistenceOptions not available in this version - pass - - # Helper function to try authentication with fallback to unencrypted cache or no cache - def try_authenticate_with_fallback(credential_class, credential_kwargs): - """Try authentication, falling back to unencrypted cache or no cache if encrypted cache fails.""" - nonlocal cache_options, use_unencrypted_cache - # First try with current cache_options - try: - credential = credential_class(cache_persistence_options=cache_options, **credential_kwargs) - # Refresh tokens are automatically obtained via persistent token cache - return credential.get_token(*AZURE_DEVOPS_SCOPES) - except Exception as e: - error_msg = str(e).lower() - # Log the actual error for debugging (only in verbose mode or if it's not a cache encryption error) - if "cache encryption" not in error_msg and "libsecret" not in error_msg: - console.print(f"[dim]Authentication error: {type(e).__name__}: {e}[/dim]") - # Check if error is about cache encryption and we haven't already tried unencrypted - if ( - ("cache encryption" in error_msg or "libsecret" in error_msg) - and cache_options - and not use_unencrypted_cache - ): - # Try again with unencrypted cache - console.print("[yellow]Note:[/yellow] Encrypted cache unavailable, trying unencrypted cache...") - try: - from azure.identity import TokenCachePersistenceOptions # type: ignore[reportMissingImports] - - unencrypted_cache = TokenCachePersistenceOptions( - name="specfact-azure-devops", - allow_unencrypted_storage=True, # Use unencrypted file storage - ) - credential = credential_class(cache_persistence_options=unencrypted_cache, **credential_kwargs) - # Refresh tokens are automatically obtained via persistent token cache - token = credential.get_token(*AZURE_DEVOPS_SCOPES) - console.print( - "[yellow]Note:[/yellow] Using unencrypted token cache (keyring locked). " - "Tokens will refresh automatically but stored without encryption." - ) - # Update global cache_options for future use - cache_options = unencrypted_cache - use_unencrypted_cache = True - return token - except Exception as e2: - # Unencrypted cache also failed - check if it's the same error - error_msg2 = str(e2).lower() - if "cache encryption" in error_msg2 or "libsecret" in error_msg2: - # Still failing on cache, try without cache entirely - console.print("[yellow]Note:[/yellow] Persistent cache unavailable, trying without cache...") - try: - credential = credential_class(**credential_kwargs) - # Without persistent cache, refresh tokens cannot be stored - token = credential.get_token(*AZURE_DEVOPS_SCOPES) - console.print( - "[yellow]Note:[/yellow] Using in-memory cache only. " - "Tokens will need to be refreshed manually after ~1 hour." - ) - return token - except Exception: - # Even without cache it failed, re-raise original - raise e from e2 - # Different error, re-raise - raise e2 from e - # Not a cache encryption error, re-raise - raise - - # Try interactive browser first (better UX), fall back to device code if it fails - token = None - if not use_device_code: - debug_log_operation("auth", "azure-devops", "attempt", extra={"method": "interactive_browser"}) - debug_print("[dim]auth azure-devops: attempting interactive browser[/dim]") - try: - console.print("[dim]Trying interactive browser authentication...[/dim]") - token = try_authenticate_with_fallback(InteractiveBrowserCredential, {}) - debug_log_operation("auth", "azure-devops", "success", extra={"method": "interactive_browser"}) - debug_print("[dim]auth azure-devops: interactive browser succeeded[/dim]") - console.print("[bold green]✓[/bold green] Interactive browser authentication successful") - except Exception as e: - # Interactive browser failed (no display, headless environment, etc.) - debug_log_operation( - "auth", - "azure-devops", - "fallback", - error=str(e), - extra={"method": "interactive_browser", "reason": "unavailable"}, - ) - debug_print(f"[dim]auth azure-devops: interactive browser failed, falling back: {e!s}[/dim]") - console.print(f"[yellow]⚠[/yellow] Interactive browser unavailable: {type(e).__name__}") - console.print("[dim]Falling back to device code flow...[/dim]") - - # Use device code flow if interactive browser failed or was explicitly requested - if token is None: - debug_log_operation("auth", "azure-devops", "attempt", extra={"method": "device_code"}) - debug_print("[dim]auth azure-devops: trying device code[/dim]") - console.print("[bold]Using device code authentication...[/bold]") - try: - token = try_authenticate_with_fallback(DeviceCodeCredential, {"prompt_callback": prompt_callback}) - debug_log_operation("auth", "azure-devops", "success", extra={"method": "device_code"}) - debug_print("[dim]auth azure-devops: device code succeeded[/dim]") - except Exception as e: - debug_log_operation( - "auth", - "azure-devops", - "failed", - error=str(e), - extra={"method": "device_code", "reason": type(e).__name__}, - ) - debug_print(f"[dim]auth azure-devops: device code failed: {e!s}[/dim]") - console.print(f"[bold red]✗[/bold red] Authentication failed: {e}") - raise typer.Exit(1) from e - - # token.expires_on is Unix timestamp in seconds since epoch (UTC) - # Verify it's in seconds (not milliseconds) - if > 1e10, it's likely milliseconds - expires_on_timestamp = token.expires_on - if expires_on_timestamp > 1e10: - # Likely in milliseconds, convert to seconds - expires_on_timestamp = expires_on_timestamp / 1000 - - # Convert to datetime for display - expires_at_dt = datetime.fromtimestamp(expires_on_timestamp, tz=UTC) - expires_at = expires_at_dt.isoformat() - - # Calculate remaining lifetime from current time (not total lifetime) - # This shows how much time is left until expiration - current_time_utc = datetime.now(tz=UTC) - current_timestamp = current_time_utc.timestamp() - remaining_lifetime_seconds = expires_on_timestamp - current_timestamp - token_lifetime_minutes = remaining_lifetime_seconds / 60 - - # For issued_at, we don't have the exact issue time from the token - # Estimate it based on typical token lifetime (usually ~1 hour for access tokens) - # Or calculate backwards from expiration if we know the typical lifetime - # For now, use current time as approximation (token was just issued) - issued_at = current_time_utc - - token_data = { - "access_token": token.token, - "token_type": "bearer", - "expires_at": expires_at, - "resource": AZURE_DEVOPS_RESOURCE, - "issued_at": issued_at.isoformat(), - } - set_token("azure-devops", token_data) - - cache_type = ( - "encrypted" - if cache_options and not use_unencrypted_cache - else ("unencrypted" if use_unencrypted_cache else "none") - ) - debug_log_operation( - "auth", - "azure-devops", - "success", - extra={"method": "oauth", "cache": cache_type, "reason": "token_stored"}, - ) - debug_print("[dim]auth azure-devops: OAuth complete, token stored[/dim]") - console.print("[bold green]✓[/bold green] Azure DevOps authentication complete") - console.print("Stored token for provider: azure-devops") - - # Calculate and display token lifetime - if token_lifetime_minutes < 30: - console.print( - f"[yellow]⚠[/yellow] Token expires at: {expires_at} (lifetime: ~{int(token_lifetime_minutes)} minutes)\n" - "[dim]Note: Short token lifetime may be due to Conditional Access policies or app registration settings.[/dim]\n" - "[dim]Without persistent cache, refresh tokens cannot be stored.\n" - "[dim]On Linux, install libsecret for automatic token refresh:\n" - "[dim] Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" - "[dim] RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" - "[dim] Arch: sudo pacman -S libsecret python-secretstorage[/dim]\n" - "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" - ) - else: - console.print( - f"[yellow]⚠[/yellow] Token expires at: {expires_at} (UTC)\n" - f"[yellow]⚠[/yellow] Time until expiration: ~{int(token_lifetime_minutes)} minutes\n" - ) - if cache_options is None: - console.print( - "[dim]Note: Persistent cache unavailable. Tokens will need to be refreshed manually after expiration.[/dim]\n" - "[dim]On Linux, install libsecret for automatic token refresh (90-day refresh token lifetime):\n" - "[dim] Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" - "[dim] RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" - "[dim] Arch: sudo pacman -S libsecret python-secretstorage[/dim]\n" - "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" - ) - elif use_unencrypted_cache: - console.print( - "[dim]Persistent cache configured (unencrypted file storage). Tokens should refresh automatically.[/dim]\n" - "[dim]Note: Tokens are stored in plain text file. To enable encrypted storage, unlock the keyring:\n" - "[dim] export $(dbus-launch)\n" - "[dim] echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock[/dim]\n" - "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" - ) - else: - console.print( - "[dim]Persistent cache configured (encrypted storage). Tokens should refresh automatically (90-day refresh token lifetime).[/dim]\n" - "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" - ) - - -@app.command("github") -def auth_github( - client_id: str | None = typer.Option( - None, - "--client-id", - help="GitHub OAuth app client ID (defaults to SpecFact GitHub App)", - ), - base_url: str = typer.Option( - DEFAULT_GITHUB_BASE_URL, - "--base-url", - help="GitHub base URL (use your enterprise host for GitHub Enterprise)", - ), - scopes: str = typer.Option( - DEFAULT_GITHUB_SCOPES, - "--scopes", - help="OAuth scopes (comma or space separated)", - hidden=True, - ), -) -> None: - """Authenticate to GitHub using RFC 8628 device code flow.""" - provided_client_id = client_id or os.environ.get("SPECFACT_GITHUB_CLIENT_ID") - effective_client_id = provided_client_id or DEFAULT_GITHUB_CLIENT_ID - if not effective_client_id: - console.print("[bold red]✗[/bold red] GitHub client_id is required.") - console.print("Use --client-id or set SPECFACT_GITHUB_CLIENT_ID.") - raise typer.Exit(1) - - host_url = _normalize_github_host(base_url) - if provided_client_id is None and host_url.lower() != DEFAULT_GITHUB_BASE_URL: - console.print("[bold red]✗[/bold red] GitHub Enterprise requires a client ID.") - console.print("Provide --client-id or set SPECFACT_GITHUB_CLIENT_ID.") - raise typer.Exit(1) - scope_string = _normalize_scopes(scopes) - - console.print("[bold]Starting GitHub device code authentication...[/bold]") - device_payload = _request_github_device_code(effective_client_id, host_url, scope_string) - - user_code = device_payload.get("user_code") - verification_uri = device_payload.get("verification_uri") - verification_uri_complete = device_payload.get("verification_uri_complete") - device_code = device_payload.get("device_code") - expires_in = int(device_payload.get("expires_in", 900)) - interval = int(device_payload.get("interval", 5)) - - if not device_code: - console.print("[bold red]✗[/bold red] Invalid device code response from GitHub") - raise typer.Exit(1) - - if verification_uri_complete: - console.print(f"Open: [bold]{verification_uri_complete}[/bold]") - elif verification_uri and user_code: - console.print(f"Open: [bold]{verification_uri}[/bold] and enter code [bold]{user_code}[/bold]") - else: - console.print("[bold red]✗[/bold red] Invalid device code response from GitHub") - raise typer.Exit(1) - - token_payload = _poll_github_device_token( - effective_client_id, - host_url, - device_code, - interval, - expires_in, - ) - - access_token = token_payload.get("access_token") - if not access_token: - console.print("[bold red]✗[/bold red] GitHub did not return an access token") - raise typer.Exit(1) - - expires_at = datetime.now(tz=UTC) + timedelta(seconds=expires_in) - token_data = { - "access_token": access_token, - "token_type": token_payload.get("token_type", "bearer"), - "scopes": token_payload.get("scope", scope_string), - "client_id": effective_client_id, - "issued_at": datetime.now(tz=UTC).isoformat(), - "expires_at": None, - "base_url": host_url, - "api_base_url": _infer_github_api_base_url(host_url), - } - - # Preserve expires_at only if GitHub returns explicit expiry (usually None) - if token_payload.get("expires_in"): - token_data["expires_at"] = expires_at.isoformat() - - set_token("github", token_data) - - console.print("[bold green]✓[/bold green] GitHub authentication complete") - console.print("Stored token for provider: github") - - -@app.command("status") -def auth_status() -> None: - """Show authentication status for supported providers.""" - tokens = load_tokens_safe() - if not tokens: - console.print("No stored authentication tokens found.") - return - - if len(tokens) == 1: - only_provider = next(iter(tokens.keys())) - console.print(f"Detected provider: {only_provider} (auto-detected)") - - for provider, token_data in tokens.items(): - _print_token_status(provider, token_data) - - -@app.command("clear") -def auth_clear( - provider: str | None = typer.Option( - None, - "--provider", - help="Provider to clear (azure-devops or github). Clear all if omitted.", - ), -) -> None: - """Clear stored authentication tokens.""" - if provider: - clear_token(provider) - console.print(f"Cleared stored token for {normalize_provider(provider)}") - return - - tokens = load_tokens_safe() - if not tokens: - console.print("No stored tokens to clear") - return - - if len(tokens) == 1: - only_provider = next(iter(tokens.keys())) - clear_token(only_provider) - console.print(f"Cleared stored token for {only_provider} (auto-detected)") - return - - clear_all_tokens() - console.print("Cleared all stored tokens") - - -def load_tokens_safe() -> dict[str, dict[str, Any]]: - """Load tokens and handle errors gracefully for CLI output.""" - try: - return get_token_map() - except ValueError as exc: - console.print(f"[bold red]✗[/bold red] {exc}") - raise typer.Exit(1) from exc - - -def get_token_map() -> dict[str, dict[str, Any]]: - """Load token map without CLI side effects.""" - from specfact_cli.utils.auth_tokens import load_tokens - - return load_tokens() +__all__ = ["app"] diff --git a/src/specfact_cli/commands/backlog_commands.py b/src/specfact_cli/commands/backlog_commands.py index 032ce335..f632ff0c 100644 --- a/src/specfact_cli/commands/backlog_commands.py +++ b/src/specfact_cli/commands/backlog_commands.py @@ -1,2800 +1,6 @@ -""" -Backlog refinement commands. +"""Backward-compatible app shim. Implementation moved to modules/backlog/.""" -This module provides the `specfact backlog refine` command for AI-assisted -backlog refinement with template detection and matching. +from specfact_cli.modules.backlog.src.commands import app -SpecFact CLI Architecture: -- SpecFact CLI generates prompts/instructions for IDE AI copilots -- IDE AI copilots execute those instructions using their native LLM -- IDE AI copilots feed results back to SpecFact CLI -- SpecFact CLI validates and processes the results -""" -from __future__ import annotations - -import contextlib -import os -import re -import subprocess -import sys -import tempfile -from datetime import date, datetime -from pathlib import Path -from typing import Any -from urllib.parse import urlparse - -import typer -import yaml -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.panel import Panel -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn -from rich.prompt import Confirm -from rich.table import Table - -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.backlog.adapters.base import BacklogAdapter -from specfact_cli.backlog.ai_refiner import BacklogAIRefiner -from specfact_cli.backlog.filters import BacklogFilters -from specfact_cli.backlog.template_detector import TemplateDetector -from specfact_cli.models.backlog_item import BacklogItem -from specfact_cli.models.dor_config import DefinitionOfReady -from specfact_cli.runtime import debug_log_operation, is_debug_mode -from specfact_cli.templates.registry import TemplateRegistry - - -app = typer.Typer( - name="backlog", - help="Backlog refinement and template management", - context_settings={"help_option_names": ["-h", "--help"]}, -) -console = Console() - - -def _apply_filters( - items: list[BacklogItem], - labels: list[str] | None = None, - state: str | None = None, - assignee: str | None = None, - iteration: str | None = None, - sprint: str | None = None, - release: str | None = None, -) -> list[BacklogItem]: - """ - Apply post-fetch filters to backlog items. - - Args: - items: List of BacklogItem instances to filter - labels: Filter by labels/tags (any label must match) - state: Filter by state (exact match) - assignee: Filter by assignee (exact match) - iteration: Filter by iteration path (exact match) - sprint: Filter by sprint (exact match) - release: Filter by release (exact match) - - Returns: - Filtered list of BacklogItem instances - """ - filtered = items - - # Filter by labels/tags (any label must match) - if labels: - filtered = [ - item for item in filtered if any(label.lower() in [tag.lower() for tag in item.tags] for label in labels) - ] - - # Filter by state (case-insensitive) - if state: - normalized_state = BacklogFilters.normalize_filter_value(state) - filtered = [item for item in filtered if BacklogFilters.normalize_filter_value(item.state) == normalized_state] - - # Filter by assignee (case-insensitive) - # Matches against any identifier in assignees list (displayName, uniqueName, or mail for ADO) - if assignee: - normalized_assignee = BacklogFilters.normalize_filter_value(assignee) - filtered = [ - item - for item in filtered - if item.assignees # Only check items with assignees - and any( - BacklogFilters.normalize_filter_value(a) == normalized_assignee - for a in item.assignees - if a # Skip None or empty strings - ) - ] - - # Filter by iteration (case-insensitive) - if iteration: - normalized_iteration = BacklogFilters.normalize_filter_value(iteration) - filtered = [ - item - for item in filtered - if item.iteration and BacklogFilters.normalize_filter_value(item.iteration) == normalized_iteration - ] - - # Filter by sprint (case-insensitive) - if sprint: - normalized_sprint = BacklogFilters.normalize_filter_value(sprint) - filtered = [ - item - for item in filtered - if item.sprint and BacklogFilters.normalize_filter_value(item.sprint) == normalized_sprint - ] - - # Filter by release (case-insensitive) - if release: - normalized_release = BacklogFilters.normalize_filter_value(release) - filtered = [ - item - for item in filtered - if item.release and BacklogFilters.normalize_filter_value(item.release) == normalized_release - ] - - return filtered - - -def _parse_standup_from_body(body: str) -> tuple[str | None, str | None, str | None]: - """Extract yesterday/today/blockers lines from body (standup format).""" - yesterday: str | None = None - today: str | None = None - blockers: str | None = None - if not body: - return yesterday, today, blockers - for line in body.splitlines(): - line_stripped = line.strip() - if re.match(r"^\*\*[Yy]esterday(?:\*\*|:)\s*\*\*\s*", line_stripped): - yesterday = re.sub(r"^\*\*[Yy]esterday(?:\*\*|:)\s*\*\*\s*", "", line_stripped).strip() - elif re.match(r"^\*\*[Tt]oday(?:\*\*|:)\s*\*\*\s*", line_stripped): - today = re.sub(r"^\*\*[Tt]oday(?:\*\*|:)\s*\*\*\s*", "", line_stripped).strip() - elif re.match(r"^\*\*[Bb]lockers?(?:\*\*|:)\s*\*\*\s*", line_stripped): - blockers = re.sub(r"^\*\*[Bb]lockers?(?:\*\*|:)\s*\*\*\s*", "", line_stripped).strip() - return yesterday, today, blockers - - -def _load_standup_config() -> dict[str, Any]: - """Load standup config from env and optional .specfact/standup.yaml. Env overrides file.""" - config: dict[str, Any] = {} - config_dir = os.environ.get("SPECFACT_CONFIG_DIR") - search_paths: list[Path] = [] - if config_dir: - search_paths.append(Path(config_dir)) - search_paths.append(Path.cwd() / ".specfact") - for base in search_paths: - path = base / "standup.yaml" - if path.is_file(): - try: - with open(path, encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - config = dict(data.get("standup", data)) - except Exception as exc: - debug_log_operation("config_load", str(path), "error", error=repr(exc)) - break - if os.environ.get("SPECFACT_STANDUP_STATE"): - config["default_state"] = os.environ["SPECFACT_STANDUP_STATE"] - if os.environ.get("SPECFACT_STANDUP_LIMIT"): - with contextlib.suppress(ValueError): - config["limit"] = int(os.environ["SPECFACT_STANDUP_LIMIT"]) - if os.environ.get("SPECFACT_STANDUP_ASSIGNEE"): - config["default_assignee"] = os.environ["SPECFACT_STANDUP_ASSIGNEE"] - return config - - -def _load_backlog_config() -> dict[str, Any]: - """Load project backlog context from .specfact/backlog.yaml (no secrets). - Same search path as standup: SPECFACT_CONFIG_DIR then .specfact in cwd. - When file has top-level 'backlog' key, that nested structure is returned. - """ - config: dict[str, Any] = {} - config_dir = os.environ.get("SPECFACT_CONFIG_DIR") - search_paths: list[Path] = [] - if config_dir: - search_paths.append(Path(config_dir)) - search_paths.append(Path.cwd() / ".specfact") - for base in search_paths: - path = base / "backlog.yaml" - if path.is_file(): - try: - with open(path, encoding="utf-8") as f: - data = yaml.safe_load(f) or {} - if isinstance(data, dict) and "backlog" in data: - nested = data["backlog"] - config = dict(nested) if isinstance(nested, dict) else {} - else: - config = dict(data) if isinstance(data, dict) else {} - except Exception as exc: - debug_log_operation("config_load", str(path), "error", error=repr(exc)) - break - return config - - -@beartype -def _resolve_standup_options( - cli_state: str | None, - cli_limit: int | None, - cli_assignee: str | None, - config: dict[str, Any] | None, -) -> tuple[str, int, str | None]: - """ - Resolve effective state, limit, assignee from CLI options and config. - CLI options override config; config overrides built-in defaults. - Returns (state, limit, assignee). - """ - cfg = config or _load_standup_config() - default_state = str(cfg.get("default_state", "open")) - default_limit = int(cfg.get("limit", 20)) if cfg.get("limit") is not None else 20 - default_assignee = cfg.get("default_assignee") - if default_assignee is not None: - default_assignee = str(default_assignee) - state = cli_state if cli_state is not None else default_state - limit = cli_limit if cli_limit is not None else default_limit - assignee = cli_assignee if cli_assignee is not None else default_assignee - return (state, limit, assignee) - - -@beartype -def _split_assigned_unassigned(items: list[BacklogItem]) -> tuple[list[BacklogItem], list[BacklogItem]]: - """Split items into assigned and unassigned (assignees empty or None).""" - assigned: list[BacklogItem] = [] - unassigned: list[BacklogItem] = [] - for item in items: - if item.assignees: - assigned.append(item) - else: - unassigned.append(item) - return (assigned, unassigned) - - -def _format_sprint_end_header(end_date: date) -> str: - """Format sprint end date as 'Sprint ends: YYYY-MM-DD (N days)'.""" - today = date.today() - delta = (end_date - today).days - return f"Sprint ends: {end_date.isoformat()} ({delta} days)" - - -@beartype -def _sort_standup_rows_blockers_first(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: - """Sort standup rows so items with non-empty blockers appear first.""" - with_blockers = [r for r in rows if (r.get("blockers") or "").strip()] - without = [r for r in rows if not (r.get("blockers") or "").strip()] - return with_blockers + without - - -@beartype -def _build_standup_rows( - items: list[BacklogItem], - include_priority: bool = False, -) -> list[dict[str, Any]]: - """ - Build standup view rows from backlog items (id, title, status, last_updated, optional yesterday/today/blockers). - When include_priority is True and item has priority/business_value, add to row. - """ - rows: list[dict[str, Any]] = [] - for item in items: - yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") - row: dict[str, Any] = { - "id": item.id, - "title": item.title, - "status": item.state, - "last_updated": item.updated_at, - "yesterday": yesterday or "", - "today": today or "", - "blockers": blockers or "", - } - if include_priority and item.priority is not None: - row["priority"] = item.priority - elif include_priority and item.business_value is not None: - row["priority"] = item.business_value - rows.append(row) - return rows - - -@beartype -def _format_standup_comment(yesterday: str, today: str, blockers: str) -> str: - """Format standup text as a comment (Yesterday / Today / Blockers) with date prefix.""" - prefix = f"Standup {date.today().isoformat()}" - parts = [prefix, ""] - if yesterday: - parts.append(f"**Yesterday:** {yesterday}") - if today: - parts.append(f"**Today:** {today}") - if blockers: - parts.append(f"**Blockers:** {blockers}") - return "\n".join(parts).strip() - - -@beartype -def _post_standup_comment_supported(adapter: BacklogAdapter, item: BacklogItem) -> bool: - """Return True if the adapter supports adding comments (e.g. for standup post).""" - return adapter.supports_add_comment() - - -@beartype -def _post_standup_to_item(adapter: BacklogAdapter, item: BacklogItem, body: str) -> bool: - """Post standup comment to the linked issue via adapter. Returns True on success.""" - return adapter.add_comment(item, body) - - -@beartype -@ensure( - lambda result: result is None or (isinstance(result, (int, float)) and result >= 0), - "Value score is non-negative when present", -) -def _compute_value_score(item: BacklogItem) -> float | None: - """ - Compute value score for next-best suggestion: business_value / max(1, story_points * priority). - - Returns None when any of story_points, business_value, or priority is missing. - """ - if item.story_points is None or item.business_value is None or item.priority is None: - return None - denom = max(1, (item.story_points or 0) * (item.priority or 1)) - return item.business_value / denom - - -@beartype -def _format_daily_item_detail(item: BacklogItem, comments: list[str]) -> str: - """ - Format a single backlog item for interactive detail view (refine-like). - - Includes ID, title, status, assignees, last updated, description, acceptance criteria, - standup fields (yesterday/today/blockers), and comments when provided. - """ - parts: list[str] = [] - parts.append(f"## {item.id} - {item.title}") - parts.append(f"- **Status:** {item.state}") - assignee_str = ", ".join(item.assignees) if item.assignees else "—" - parts.append(f"- **Assignees:** {assignee_str}") - updated = ( - item.updated_at.strftime("%Y-%m-%d %H:%M") if hasattr(item.updated_at, "strftime") else str(item.updated_at) - ) - parts.append(f"- **Last updated:** {updated}") - if item.body_markdown: - parts.append("\n**Description:**") - parts.append(item.body_markdown.strip()) - if item.acceptance_criteria: - parts.append("\n**Acceptance criteria:**") - parts.append(item.acceptance_criteria.strip()) - yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") - if yesterday or today or blockers: - parts.append("\n**Standup:**") - if yesterday: - parts.append(f"- Yesterday: {yesterday}") - if today: - parts.append(f"- Today: {today}") - if blockers: - parts.append(f"- Blockers: {blockers}") - if item.story_points is not None: - parts.append(f"\n- **Story points:** {item.story_points}") - if item.business_value is not None: - parts.append(f"- **Business value:** {item.business_value}") - if item.priority is not None: - parts.append(f"- **Priority:** {item.priority}") - if comments: - parts.append("\n**Comments:**") - for c in comments: - parts.append(f"- {c}") - return "\n".join(parts) - - -def _collect_comment_annotations( - adapter: str, - items: list[BacklogItem], - *, - repo_owner: str | None, - repo_name: str | None, - github_token: str | None, - ado_org: str | None, - ado_project: str | None, - ado_token: str | None, -) -> dict[str, list[str]]: - """ - Collect comment annotations for backlog items when the adapter supports get_comments(). - - Returns a mapping of item ID -> list of comment strings. Returns empty dict if not supported. - """ - comments_by_item_id: dict[str, list[str]] = {} - try: - adapter_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - registry = AdapterRegistry() - adapter_instance = registry.get_adapter(adapter, **adapter_kwargs) - if not isinstance(adapter_instance, BacklogAdapter): - return comments_by_item_id - get_comments_fn = getattr(adapter_instance, "get_comments", None) - if not callable(get_comments_fn): - return comments_by_item_id - for item in items: - with contextlib.suppress(Exception): - raw = get_comments_fn(item) - comments_by_item_id[item.id] = list(raw) if isinstance(raw, list) else [] - except Exception: - return comments_by_item_id - return comments_by_item_id - - -@beartype -def _build_copilot_export_content( - items: list[BacklogItem], - include_value_score: bool = False, - include_comments: bool = False, - comments_by_item_id: dict[str, list[str]] | None = None, -) -> str: - """ - Build Markdown content for Copilot export: one section per item. - - Per item: ID, title, status, assignees, last updated, progress summary (standup fields), - blockers, optional value score, and optionally description/comments when enabled. - """ - lines: list[str] = [] - lines.append("# Daily standup – Copilot export") - lines.append("") - comments_map = comments_by_item_id or {} - for item in items: - lines.append(f"## {item.id} - {item.title}") - lines.append("") - lines.append(f"- **Status:** {item.state}") - assignee_str = ", ".join(item.assignees) if item.assignees else "—" - lines.append(f"- **Assignees:** {assignee_str}") - updated = ( - item.updated_at.strftime("%Y-%m-%d %H:%M") if hasattr(item.updated_at, "strftime") else str(item.updated_at) - ) - lines.append(f"- **Last updated:** {updated}") - if include_comments: - body = (item.body_markdown or "").strip() - if body: - snippet = body[:_SUMMARIZE_BODY_TRUNCATE] - if len(body) > _SUMMARIZE_BODY_TRUNCATE: - snippet += "\n..." - lines.append("- **Description:**") - for line in snippet.splitlines(): - lines.append(f" {line}" if line else " ") - yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") - if yesterday or today: - lines.append(f"- **Progress:** Yesterday: {yesterday or '—'}; Today: {today or '—'}") - if blockers: - lines.append(f"- **Blockers:** {blockers}") - if include_comments: - item_comments = comments_map.get(item.id, []) - if item_comments: - lines.append("- **Comments (annotations):**") - for c in item_comments: - lines.append(f" - {c}") - if item.story_points is not None: - lines.append(f"- **Story points:** {item.story_points}") - if item.priority is not None: - lines.append(f"- **Priority:** {item.priority}") - if include_value_score: - score = _compute_value_score(item) - if score is not None: - lines.append(f"- **Value score:** {score:.2f}") - lines.append("") - return "\n".join(lines).strip() - - -_SUMMARIZE_BODY_TRUNCATE = 1200 - - -@beartype -def _build_summarize_prompt_content( - items: list[BacklogItem], - filter_context: dict[str, Any], - include_value_score: bool = False, - comments_by_item_id: dict[str, list[str]] | None = None, - include_comments: bool = False, -) -> str: - """ - Build prompt content for standup summary: instruction + filter context + per-item data. - - When include_comments is True, includes body (description) and annotations (comments) per item - so an LLM can produce a meaningful summary. When False, only metadata (id, title, status, - assignees, last updated) is included to avoid leaking sensitive or large context. - For use with slash command (e.g. specfact.daily) or copy-paste to Copilot. - """ - lines: list[str] = [] - lines.append("--- BEGIN STANDUP PROMPT ---") - lines.append("Generate a concise daily standup summary from the following data.") - if include_comments: - lines.append( - "Include: current focus, blockers, and pending items. Use each item's description and comments for context. Keep it short and actionable." - ) - else: - lines.append("Include: current focus and pending items from the metadata below. Keep it short and actionable.") - lines.append("") - lines.append("## Filter context") - lines.append(f"- Adapter: {filter_context.get('adapter', '—')}") - lines.append(f"- State: {filter_context.get('state', '—')}") - lines.append(f"- Sprint: {filter_context.get('sprint', '—')}") - lines.append(f"- Assignee: {filter_context.get('assignee', '—')}") - lines.append(f"- Limit: {filter_context.get('limit', '—')}") - lines.append("") - data_header = "Standup data (with description and comments)" if include_comments else "Standup data (metadata only)" - lines.append(f"## {data_header}") - lines.append("") - comments_map = comments_by_item_id or {} - for item in items: - lines.append(f"## {item.id} - {item.title}") - lines.append("") - lines.append(f"- **Status:** {item.state}") - assignee_str = ", ".join(item.assignees) if item.assignees else "—" - lines.append(f"- **Assignees:** {assignee_str}") - updated = ( - item.updated_at.strftime("%Y-%m-%d %H:%M") if hasattr(item.updated_at, "strftime") else str(item.updated_at) - ) - lines.append(f"- **Last updated:** {updated}") - if include_comments: - body = (item.body_markdown or "").strip() - if body: - snippet = body[:_SUMMARIZE_BODY_TRUNCATE] - if len(body) > _SUMMARIZE_BODY_TRUNCATE: - snippet += "\n..." - lines.append("- **Description:**") - lines.append(snippet) - lines.append("") - yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") - if yesterday or today: - lines.append(f"- **Progress:** Yesterday: {yesterday or '—'}; Today: {today or '—'}") - if blockers: - lines.append(f"- **Blockers:** {blockers}") - item_comments = comments_map.get(item.id, []) - if item_comments: - lines.append("- **Comments (annotations):**") - for c in item_comments: - lines.append(f" - {c}") - if item.story_points is not None: - lines.append(f"- **Story points:** {item.story_points}") - if item.priority is not None: - lines.append(f"- **Priority:** {item.priority}") - if include_value_score: - score = _compute_value_score(item) - if score is not None: - lines.append(f"- **Value score:** {score:.2f}") - lines.append("") - lines.append("--- END STANDUP PROMPT ---") - return "\n".join(lines).strip() - - -def _run_interactive_daily( - items: list[BacklogItem], - standup_config: dict[str, Any], - suggest_next: bool, - adapter: str, - repo_owner: str | None, - repo_name: str | None, - github_token: str | None, - ado_org: str | None, - ado_project: str | None, - ado_token: str | None, -) -> None: - """ - Run interactive step-by-step review: questionary selection, detail view, next/previous/back/exit. - """ - try: - import questionary # type: ignore[reportMissingImports] - except ImportError: - console.print( - "[red]Interactive mode requires the 'questionary' package. Install with: pip install questionary[/red]" - ) - raise typer.Exit(1) from None - - adapter_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - registry = AdapterRegistry() - adapter_instance = registry.get_adapter(adapter, **adapter_kwargs) - get_comments_fn = getattr(adapter_instance, "get_comments", lambda _: []) - - n = len(items) - choices = [ - f"{item.id} - {item.title[:50]}{'...' if len(item.title) > 50 else ''} [{item.state}] ({', '.join(item.assignees) or '—'})" - for item in items - ] - choices.append("Exit") - - while True: - selected = questionary.select("Select a story to review (or Exit)", choices=choices).ask() - if selected is None or selected == "Exit": - return - try: - idx = choices.index(selected) - except ValueError: - return - if idx >= n: - return - - current_idx = idx - while True: - item = items[current_idx] - comments: list[str] = [] - if callable(get_comments_fn): - with contextlib.suppress(Exception): - raw = get_comments_fn(item) - comments = list(raw) if isinstance(raw, list) else [] - detail = _format_daily_item_detail(item, comments) - console.print(Panel(detail, title=f"Story: {item.id}", border_style="cyan")) - - if suggest_next and n > 1: - pending = [i for i in items if not i.assignees or i.story_points is not None] - if pending: - best: BacklogItem | None = None - best_score: float = -1.0 - for i in pending: - s = _compute_value_score(i) - if s is not None and s > best_score: - best_score = s - best = i - if best is not None: - console.print( - f"[dim]Suggested next (value score {best_score:.2f}): {best.id} - {best.title}[/dim]" - ) - - nav_choices = ["Next story", "Previous story", "Back to list", "Exit"] - nav = questionary.select("Navigation", choices=nav_choices).ask() - if nav is None or nav == "Exit": - return - if nav == "Back to list": - break - if nav == "Next story": - current_idx = (current_idx + 1) % n - elif nav == "Previous story": - current_idx = (current_idx - 1) % n - - -def _extract_openspec_change_id(body: str) -> str | None: - """ - Extract OpenSpec change proposal ID from issue body. - - Looks for patterns like: - - *OpenSpec Change Proposal: `id`* - - OpenSpec Change Proposal: `id` - - OpenSpec.*proposal: `id` - - Args: - body: Issue body text - - Returns: - Change proposal ID if found, None otherwise - """ - import re - - openspec_patterns = [ - r"OpenSpec Change Proposal[:\s]+`?([a-z0-9-]+)`?", - r"\*OpenSpec Change Proposal:\s*`([a-z0-9-]+)`", - r"OpenSpec.*proposal[:\s]+`?([a-z0-9-]+)`?", - ] - for pattern in openspec_patterns: - match = re.search(pattern, body, re.IGNORECASE) - if match: - return match.group(1) - return None - - -def _infer_github_repo_from_cwd() -> tuple[str | None, str | None]: - """ - Infer repo_owner and repo_name from git remote origin when run inside a GitHub clone. - Returns (owner, repo) or (None, None) if not a GitHub remote or git unavailable. - """ - try: - result = subprocess.run( - ["git", "remote", "get-url", "origin"], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=5, - check=False, - ) - if result.returncode != 0 or not result.stdout or not result.stdout.strip(): - return (None, None) - url = result.stdout.strip() - owner, repo = None, None - if url.startswith("git@"): - part = url.split(":", 1)[-1].strip() - if part.endswith(".git"): - part = part[:-4] - segments = part.split("/") - if len(segments) >= 2 and "github" in url.lower(): - owner, repo = segments[-2], segments[-1] - else: - parsed = urlparse(url) - if parsed.hostname and "github" in parsed.hostname.lower() and parsed.path: - path = parsed.path.strip("/") - if path.endswith(".git"): - path = path[:-4] - segments = path.split("/") - if len(segments) >= 2: - owner, repo = segments[-2], segments[-1] - return (owner or None, repo or None) - except Exception: - return (None, None) - - -def _infer_ado_context_from_cwd() -> tuple[str | None, str | None]: - """ - Infer org and project from git remote origin when run inside an Azure DevOps clone. - Returns (org, project) or (None, None) if not an ADO remote or git unavailable. - Supports: - - HTTPS: https://dev.azure.com/org/project/_git/repo - - SSH (keys): git@ssh.dev.azure.com:v3/<org>/<project>/<repo> - - SSH (other): <user>@dev.azure.com:v3/<org>/<project>/<repo> (no ssh. subdomain) - """ - try: - result = subprocess.run( - ["git", "remote", "get-url", "origin"], - cwd=Path.cwd(), - capture_output=True, - text=True, - timeout=5, - check=False, - ) - if result.returncode != 0 or not result.stdout or not result.stdout.strip(): - return (None, None) - url = result.stdout.strip() - org, project = None, None - if "dev.azure.com" not in url.lower(): - return (None, None) - if ":" in url and "v3/" in url: - idx = url.find("v3/") - if idx != -1: - part = url[idx + 3 :].strip() - segments = part.split("/") - if len(segments) >= 2: - org, project = segments[0], segments[1] - else: - parsed = urlparse(url) - if parsed.path: - path = parsed.path.strip("/") - segments = path.split("/") - if len(segments) >= 2: - org, project = segments[0], segments[1] - return (org or None, project or None) - except Exception: - return (None, None) - - -def _build_adapter_kwargs( - adapter: str, - repo_owner: str | None = None, - repo_name: str | None = None, - github_token: str | None = None, - ado_org: str | None = None, - ado_project: str | None = None, - ado_team: str | None = None, - ado_token: str | None = None, -) -> dict[str, Any]: - """ - Build adapter kwargs from CLI args, then env, then .specfact/backlog.yaml. - Resolution order: explicit arg > env (SPECFACT_GITHUB_REPO_OWNER, etc.) > config. - Tokens are never read from config; only from explicit args (env handled by caller). - """ - cfg = _load_backlog_config() - kwargs: dict[str, Any] = {} - if adapter.lower() == "github": - owner = ( - repo_owner or os.environ.get("SPECFACT_GITHUB_REPO_OWNER") or (cfg.get("github") or {}).get("repo_owner") - ) - name = repo_name or os.environ.get("SPECFACT_GITHUB_REPO_NAME") or (cfg.get("github") or {}).get("repo_name") - if not owner or not name: - inferred_owner, inferred_name = _infer_github_repo_from_cwd() - if inferred_owner and inferred_name: - owner = owner or inferred_owner - name = name or inferred_name - if owner: - kwargs["repo_owner"] = owner - if name: - kwargs["repo_name"] = name - if github_token: - kwargs["api_token"] = github_token - elif adapter.lower() == "ado": - org = ado_org or os.environ.get("SPECFACT_ADO_ORG") or (cfg.get("ado") or {}).get("org") - project = ado_project or os.environ.get("SPECFACT_ADO_PROJECT") or (cfg.get("ado") or {}).get("project") - team = ado_team or os.environ.get("SPECFACT_ADO_TEAM") or (cfg.get("ado") or {}).get("team") - if not org or not project: - inferred_org, inferred_project = _infer_ado_context_from_cwd() - if inferred_org and inferred_project: - org = org or inferred_org - project = project or inferred_project - if org: - kwargs["org"] = org - if project: - kwargs["project"] = project - if team: - kwargs["team"] = team - if ado_token: - kwargs["api_token"] = ado_token - return kwargs - - -def _extract_body_from_block(block: str) -> str: - """ - Extract **Body** content from a refined export block, handling nested fenced code. - - The body is wrapped in ```markdown ... ```. If the body itself contains fenced - code blocks (e.g. ```python ... ```), the closing fence is matched by tracking - depth: a line that is exactly ``` closes the current fence (body or inner). - """ - start_marker = "**Body**:" - fence_open = "```markdown" - if start_marker not in block or fence_open not in block: - return "" - idx = block.find(start_marker) - rest = block[idx + len(start_marker) :].lstrip() - if not rest.startswith("```"): - return "" - if not rest.startswith(fence_open + "\n") and not rest.startswith(fence_open + "\r\n"): - return "" - after_open = rest[len(fence_open) :].lstrip("\n\r") - if not after_open: - return "" - lines = after_open.split("\n") - body_lines: list[str] = [] - depth = 1 - for line in lines: - stripped = line.rstrip() - if stripped == "```": - if depth == 1: - break - depth -= 1 - body_lines.append(line) - elif stripped.startswith("```") and stripped != "```": - depth += 1 - body_lines.append(line) - else: - body_lines.append(line) - return "\n".join(body_lines).strip() - - -def _parse_refined_export_markdown(content: str) -> dict[str, dict[str, Any]]: - """ - Parse refined export markdown (same format as --export-to-tmp) into id -> fields. - - Splits by ## Item blocks, extracts **ID**, **Body** (from ```markdown ... ```), - **Acceptance Criteria**, and optionally title and **Metrics** (story_points, - business_value, priority). Body extraction is fence-aware so bodies containing - nested code blocks are parsed correctly. Returns a dict mapping item id to - parsed fields (body_markdown, acceptance_criteria, title?, story_points?, - business_value?, priority?). - """ - result: dict[str, dict[str, Any]] = {} - blocks = re.split(r"\n## Item \d+:", content) - for block in blocks: - block = block.strip() - if not block or block.startswith("# SpecFact") or "**ID**:" not in block: - continue - id_match = re.search(r"\*\*ID\*\*:\s*(.+?)(?:\n|$)", block) - if not id_match: - continue - item_id = id_match.group(1).strip() - fields: dict[str, Any] = {} - - fields["body_markdown"] = _extract_body_from_block(block) - - ac_match = re.search(r"\*\*Acceptance Criteria\*\*:\s*\n(.*?)(?=\n\*\*|\n---|\Z)", block, re.DOTALL) - if ac_match: - fields["acceptance_criteria"] = ac_match.group(1).strip() or None - else: - fields["acceptance_criteria"] = None - - first_line = block.split("\n")[0].strip() if block else "" - if first_line and not first_line.startswith("**"): - fields["title"] = first_line - - if "Story Points:" in block: - sp_match = re.search(r"Story Points:\s*(\d+)", block) - if sp_match: - fields["story_points"] = int(sp_match.group(1)) - if "Business Value:" in block: - bv_match = re.search(r"Business Value:\s*(\d+)", block) - if bv_match: - fields["business_value"] = int(bv_match.group(1)) - if "Priority:" in block: - pri_match = re.search(r"Priority:\s*(\d+)", block) - if pri_match: - fields["priority"] = int(pri_match.group(1)) - - result[item_id] = fields - return result - - -@beartype -def _item_needs_refinement( - item: BacklogItem, - detector: TemplateDetector, - registry: TemplateRegistry, - template_id: str | None, - normalized_adapter: str | None, - normalized_framework: str | None, - normalized_persona: str | None, -) -> bool: - """ - Return True if the item needs refinement (should be processed); False if already refined (skip). - - Mirrors the "already refined" skip logic used in the refine loop: checkboxes + all required - sections, or high confidence with no missing fields. - """ - detection_result = detector.detect_template( - item, - provider=normalized_adapter, - framework=normalized_framework, - persona=normalized_persona, - ) - if detection_result.template_id: - target = registry.get_template(detection_result.template_id) if detection_result.template_id else None - if target and target.required_sections: - has_checkboxes = bool( - re.search(r"^[\s]*- \[[ x]\]", item.body_markdown or "", re.MULTILINE | re.IGNORECASE) - ) - all_present = all( - bool(re.search(rf"^#+\s+{re.escape(s)}\s*$", item.body_markdown or "", re.MULTILINE | re.IGNORECASE)) - for s in target.required_sections - ) - if has_checkboxes and all_present and not detection_result.missing_fields: - return False - already_refined = template_id is None and detection_result.confidence >= 0.8 and not detection_result.missing_fields - return not already_refined - - -def _fetch_backlog_items( - adapter_name: str, - search_query: str | None = None, - labels: list[str] | None = None, - state: str | None = None, - assignee: str | None = None, - iteration: str | None = None, - sprint: str | None = None, - release: str | None = None, - limit: int | None = None, - repo_owner: str | None = None, - repo_name: str | None = None, - github_token: str | None = None, - ado_org: str | None = None, - ado_project: str | None = None, - ado_team: str | None = None, - ado_token: str | None = None, -) -> list[BacklogItem]: - """ - Fetch backlog items using the specified adapter with filtering support. - - Args: - adapter_name: Adapter name (github, ado, etc.) - search_query: Optional search query to filter items (provider-specific syntax) - labels: Filter by labels/tags (post-fetch filtering) - state: Filter by state (post-fetch filtering) - assignee: Filter by assignee (post-fetch filtering) - iteration: Filter by iteration path (post-fetch filtering) - sprint: Filter by sprint (post-fetch filtering) - release: Filter by release (post-fetch filtering) - limit: Maximum number of items to fetch - - Returns: - List of BacklogItem instances (filtered) - """ - from specfact_cli.backlog.adapters.base import BacklogAdapter - - registry = AdapterRegistry() - - # Build adapter kwargs based on adapter type - adapter_kwargs = _build_adapter_kwargs( - adapter_name, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_team=ado_team, - ado_token=ado_token, - ) - - if adapter_name.lower() == "github" and ( - not adapter_kwargs.get("repo_owner") or not adapter_kwargs.get("repo_name") - ): - console.print("[red]repo_owner and repo_name required for GitHub.[/red]") - console.print( - "Set via: [cyan]--repo-owner[/cyan]/[cyan]--repo-name[/cyan], " - "env [cyan]SPECFACT_GITHUB_REPO_OWNER[/cyan]/[cyan]SPECFACT_GITHUB_REPO_NAME[/cyan], " - "or [cyan].specfact/backlog.yaml[/cyan] (see docs/guides/devops-adapter-integration.md). " - "When run from a GitHub clone, org/repo are auto-detected from git remote." - ) - raise typer.Exit(1) - if adapter_name.lower() == "ado" and (not adapter_kwargs.get("org") or not adapter_kwargs.get("project")): - console.print("[red]ado_org and ado_project required for Azure DevOps.[/red]") - console.print( - "Set via: [cyan]--ado-org[/cyan]/[cyan]--ado-project[/cyan], " - "env [cyan]SPECFACT_ADO_ORG[/cyan]/[cyan]SPECFACT_ADO_PROJECT[/cyan], " - "or [cyan].specfact/backlog.yaml[/cyan]. " - "When run from an ADO clone, org/project are auto-detected from git remote." - ) - raise typer.Exit(1) - - adapter = registry.get_adapter(adapter_name, **adapter_kwargs) - - # Check if adapter implements BacklogAdapter interface - if not isinstance(adapter, BacklogAdapter): - msg = f"Adapter {adapter_name} does not implement BacklogAdapter interface" - raise NotImplementedError(msg) - - # Create BacklogFilters from parameters - filters = BacklogFilters( - assignee=assignee, - state=state, - labels=labels, - search=search_query, - iteration=iteration, - sprint=sprint, - release=release, - limit=limit, - ) - - # Fetch items using the adapter - items = adapter.fetch_backlog_items(filters) - - # Apply limit deterministically (slice after filtering) - if limit is not None and len(items) > limit: - items = items[:limit] - - return items - - -@beartype -@app.command() -@require( - lambda adapter: isinstance(adapter, str) and len(adapter) > 0, - "Adapter must be non-empty string", -) -def daily( - adapter: str = typer.Argument(..., help="Backlog adapter name (github, ado, etc.)"), - assignee: str | None = typer.Option( - None, - "--assignee", - help="Filter by assignee (e.g. 'me' or username). Only matching items are listed.", - ), - state: str | None = typer.Option(None, "--state", help="Filter by state (e.g. open, closed, Active)"), - labels: list[str] | None = typer.Option(None, "--labels", "--tags", help="Filter by labels/tags"), - limit: int | None = typer.Option(None, "--limit", help="Maximum number of items to show"), - iteration: str | None = typer.Option( - None, - "--iteration", - help="Filter by iteration (e.g. 'current' or literal path). ADO: full path; adapter must support.", - ), - sprint: str | None = typer.Option( - None, - "--sprint", - help="Filter by sprint (e.g. 'current' or name). Adapter must support iteration/sprint.", - ), - show_unassigned: bool = typer.Option( - True, - "--show-unassigned/--no-show-unassigned", - help="Show unassigned/pending items in a second table (default: true).", - ), - unassigned_only: bool = typer.Option( - False, - "--unassigned-only", - help="Show only unassigned items (single table).", - ), - blockers_first: bool = typer.Option( - False, - "--blockers-first", - help="Sort so items with non-empty blockers appear first.", - ), - interactive: bool = typer.Option( - False, - "--interactive", - help="Step-by-step review: select items with arrow keys and view full detail (refine-like) and comments.", - ), - copilot_export: str | None = typer.Option( - None, - "--copilot-export", - help="Write summarized progress per story to a file for Copilot slash-command use during standup.", - ), - include_comments: bool = typer.Option( - False, - "--comments", - "--annotations", - help="Include item comments/annotations in summarize/copilot export (adapter must support get_comments).", - ), - summarize: bool = typer.Option( - False, - "--summarize", - help="Output a prompt (instruction + filter context + standup data) for slash command or Copilot to generate a standup summary (prints to stdout).", - ), - summarize_to: str | None = typer.Option( - None, - "--summarize-to", - help="Write the summarize prompt to this file (alternative to --summarize stdout).", - ), - suggest_next: bool = typer.Option( - False, - "--suggest-next", - help="In interactive mode, show suggested next item by value score (business value / (story points * priority)).", - ), - post: bool = typer.Option( - False, - "--post", - help="Post standup comment to the first item's issue. Requires at least one of --yesterday, --today, --blockers with a value (adapter must support comments).", - ), - yesterday: str | None = typer.Option( - None, - "--yesterday", - help='Standup: what was done yesterday (used when posting with --post; pass a value e.g. --yesterday "Worked on X").', - ), - today: str | None = typer.Option( - None, - "--today", - help='Standup: what will be done today (used when posting with --post; pass a value e.g. --today "Will do Y").', - ), - blockers: str | None = typer.Option( - None, - "--blockers", - help='Standup: blockers (used when posting with --post; pass a value e.g. --blockers "None").', - ), - repo_owner: str | None = typer.Option(None, "--repo-owner", help="GitHub repository owner"), - repo_name: str | None = typer.Option(None, "--repo-name", help="GitHub repository name"), - github_token: str | None = typer.Option(None, "--github-token", help="GitHub API token"), - ado_org: str | None = typer.Option(None, "--ado-org", help="Azure DevOps organization"), - ado_project: str | None = typer.Option(None, "--ado-project", help="Azure DevOps project"), - ado_team: str | None = typer.Option( - None, "--ado-team", help="ADO team for current iteration (when --sprint current)" - ), - ado_token: str | None = typer.Option(None, "--ado-token", help="Azure DevOps PAT"), -) -> None: - """ - Show daily standup view: list my/filtered backlog items with status and last activity. - - Optional standup summary lines (yesterday/today/blockers) are shown when present in item body. - Use --post with --yesterday, --today, --blockers to post a standup comment to the first item's linked issue - (only when the adapter supports comments, e.g. GitHub). - Default scope: state=open, limit=20 (overridable via SPECFACT_STANDUP_* env or .specfact/standup.yaml). - """ - standup_config = _load_standup_config() - effective_state, effective_limit, effective_assignee = _resolve_standup_options( - state, limit, assignee, standup_config - ) - items = _fetch_backlog_items( - adapter, - state=effective_state, - assignee=effective_assignee, - labels=labels, - limit=effective_limit, - iteration=iteration, - sprint=sprint, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_team=ado_team, - ado_token=ado_token, - ) - filtered = _apply_filters( - items, - labels=labels, - state=effective_state, - assignee=effective_assignee, - iteration=iteration, - sprint=sprint, - ) - if len(filtered) > effective_limit: - filtered = filtered[:effective_limit] - - if not filtered: - console.print("[yellow]No backlog items found.[/yellow]") - return - - comments_by_item_id: dict[str, list[str]] = {} - if include_comments and (copilot_export is not None or summarize or summarize_to is not None): - comments_by_item_id = _collect_comment_annotations( - adapter, - filtered, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - - if copilot_export is not None: - include_score = suggest_next or bool(standup_config.get("suggest_next")) - export_path = Path(copilot_export) - content = _build_copilot_export_content( - filtered, - include_value_score=include_score, - include_comments=include_comments, - comments_by_item_id=comments_by_item_id or None, - ) - export_path.write_text(content, encoding="utf-8") - console.print(f"[dim]Exported {len(filtered)} item(s) to {export_path}[/dim]") - - if summarize or summarize_to is not None: - include_score = suggest_next or bool(standup_config.get("suggest_next")) - filter_ctx: dict[str, Any] = { - "adapter": adapter, - "state": effective_state or "—", - "sprint": sprint or iteration or "—", - "assignee": effective_assignee or "—", - "limit": effective_limit, - } - content = _build_summarize_prompt_content( - filtered, - filter_context=filter_ctx, - include_value_score=include_score, - comments_by_item_id=comments_by_item_id or None, - include_comments=include_comments, - ) - if summarize_to: - Path(summarize_to).write_text(content, encoding="utf-8") - console.print(f"[dim]Summarize prompt written to {summarize_to} ({len(filtered)} item(s))[/dim]") - else: - console.print(content) - return - - if interactive: - _run_interactive_daily( - filtered, - standup_config=standup_config, - suggest_next=suggest_next, - adapter=adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - return - - first_item = filtered[0] - include_priority = bool(standup_config.get("show_priority") or standup_config.get("show_value")) - rows_unassigned: list[dict[str, Any]] = [] - if unassigned_only: - _, filtered = _split_assigned_unassigned(filtered) - if not filtered: - console.print("[yellow]No unassigned items in scope.[/yellow]") - return - rows = _build_standup_rows(filtered, include_priority=include_priority) - if blockers_first: - rows = _sort_standup_rows_blockers_first(rows) - else: - assigned, unassigned = _split_assigned_unassigned(filtered) - rows = _build_standup_rows(assigned, include_priority=include_priority) - if blockers_first: - rows = _sort_standup_rows_blockers_first(rows) - if show_unassigned and unassigned: - rows_unassigned = _build_standup_rows(unassigned, include_priority=include_priority) - - if post: - y = (yesterday or "").strip() - t = (today or "").strip() - b = (blockers or "").strip() - if not y and not t and not b: - console.print("[yellow]Use --yesterday, --today, and/or --blockers with values when using --post.[/yellow]") - console.print('[dim]Example: --yesterday "Worked on X" --today "Will do Y" --blockers "None" --post[/dim]') - return - body = _format_standup_comment(y, t, b) - item = first_item - registry = AdapterRegistry() - adapter_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - adapter_instance = registry.get_adapter(adapter, **adapter_kwargs) - if not isinstance(adapter_instance, BacklogAdapter): - console.print("[red]Adapter does not implement BacklogAdapter.[/red]") - raise typer.Exit(1) - if not _post_standup_comment_supported(adapter_instance, item): - console.print("[yellow]Posting comments is not supported for this adapter.[/yellow]") - return - ok = _post_standup_to_item(adapter_instance, item, body) - if ok: - console.print(f"[green]✓ Standup comment posted to {item.url}[/green]") - else: - console.print("[red]Failed to post standup comment.[/red]") - raise typer.Exit(1) - return - - sprint_end = standup_config.get("sprint_end_date") or os.environ.get("SPECFACT_STANDUP_SPRINT_END") - if sprint_end and (sprint or iteration): - try: - from datetime import datetime as dt - - end_date = dt.strptime(str(sprint_end)[:10], "%Y-%m-%d").date() - console.print(f"[dim]{_format_sprint_end_header(end_date)}[/dim]") - except (ValueError, TypeError): - console.print("[dim]Sprint end date could not be parsed; header skipped.[/dim]") - - def _add_standup_rows_to_table(tbl: Table, row_list: list[dict[str, Any]], include_pri: bool) -> None: - for r in row_list: - cells: list[Any] = [ - str(r["id"]), - str(r["title"])[:50], - str(r["status"]), - r["last_updated"].strftime("%Y-%m-%d %H:%M") - if hasattr(r["last_updated"], "strftime") - else str(r["last_updated"]), - (r.get("yesterday") or "")[:30], - (r.get("today") or "")[:30], - (r.get("blockers") or "")[:20], - ] - if include_pri and "priority" in r: - cells.append(str(r["priority"])) - tbl.add_row(*cells) - - table = Table(title="Daily standup", show_header=True, header_style="bold cyan") - table.add_column("ID", style="dim") - table.add_column("Title") - table.add_column("Status") - table.add_column("Last updated") - table.add_column("Yesterday", style="dim", max_width=30) - table.add_column("Today", style="dim", max_width=30) - table.add_column("Blockers", style="dim", max_width=20) - if include_priority: - table.add_column("Priority", style="dim") - _add_standup_rows_to_table(table, rows, include_priority) - console.print(table) - if not unassigned_only and show_unassigned and rows_unassigned: - table_pending = Table( - title="Pending / open for commitment", - show_header=True, - header_style="bold cyan", - ) - table_pending.add_column("ID", style="dim") - table_pending.add_column("Title") - table_pending.add_column("Status") - table_pending.add_column("Last updated") - table_pending.add_column("Yesterday", style="dim", max_width=30) - table_pending.add_column("Today", style="dim", max_width=30) - table_pending.add_column("Blockers", style="dim", max_width=20) - if include_priority: - table_pending.add_column("Priority", style="dim") - _add_standup_rows_to_table(table_pending, rows_unassigned, include_priority) - console.print(table_pending) - - -@beartype -@app.command() -@require( - lambda adapter: isinstance(adapter, str) and len(adapter) > 0, - "Adapter must be non-empty string", -) -def refine( - adapter: str = typer.Argument(..., help="Backlog adapter name (github, ado, etc.)"), - # Common filters - labels: list[str] | None = typer.Option( - None, "--labels", "--tags", help="Filter by labels/tags (can specify multiple)" - ), - state: str | None = typer.Option( - None, "--state", help="Filter by state (case-insensitive, e.g., 'open', 'closed', 'Active', 'New')" - ), - assignee: str | None = typer.Option( - None, - "--assignee", - help="Filter by assignee (case-insensitive). GitHub: login or @username. ADO: displayName, uniqueName, or mail", - ), - # Iteration/sprint filters - iteration: str | None = typer.Option( - None, - "--iteration", - help="Filter by iteration path (ADO format: 'Project\\Sprint 1' or 'current' for current iteration). Must be exact full path from ADO.", - ), - sprint: str | None = typer.Option( - None, - "--sprint", - help="Filter by sprint (case-insensitive). ADO: use full iteration path (e.g., 'Project\\Sprint 1') to avoid ambiguity. If omitted, defaults to current active iteration.", - ), - release: str | None = typer.Option(None, "--release", help="Filter by release identifier"), - # Template filters - persona: str | None = typer.Option( - None, "--persona", help="Filter templates by persona (product-owner, architect, developer)" - ), - framework: str | None = typer.Option( - None, "--framework", help="Filter templates by framework (agile, scrum, safe, kanban)" - ), - # Existing options - search: str | None = typer.Option( - None, "--search", "-s", help="Search query to filter backlog items (provider-specific syntax)" - ), - limit: int | None = typer.Option( - None, - "--limit", - help="Maximum number of items to process in this refinement session. Use to cap batch size and avoid processing too many items at once.", - ), - ignore_refined: bool = typer.Option( - True, - "--ignore-refined/--no-ignore-refined", - help="When set (default), exclude already-refined items from the batch so --limit applies to items that need refinement. Use --no-ignore-refined to process the first N items in order (already-refined skipped in loop).", - ), - issue_id: str | None = typer.Option( - None, - "--id", - help="Refine only this backlog item (issue or work item ID). Other items are ignored.", - ), - template_id: str | None = typer.Option(None, "--template", "-t", help="Target template ID (default: auto-detect)"), - auto_accept_high_confidence: bool = typer.Option( - False, "--auto-accept-high-confidence", help="Auto-accept refinements with confidence >= 0.85" - ), - bundle: str | None = typer.Option(None, "--bundle", "-b", help="OpenSpec bundle path to import refined items"), - auto_bundle: bool = typer.Option(False, "--auto-bundle", help="Auto-import refined items to OpenSpec bundle"), - openspec_comment: bool = typer.Option( - False, "--openspec-comment", help="Add OpenSpec change proposal reference as comment (preserves original body)" - ), - # Preview/write flags (production safety) - preview: bool = typer.Option( - True, - "--preview/--no-preview", - help="Preview mode: show what will be written without updating backlog (default: True)", - ), - write: bool = typer.Option( - False, "--write", help="Write mode: explicitly opt-in to update remote backlog (requires --write flag)" - ), - # Export/import for copilot processing - export_to_tmp: bool = typer.Option( - False, - "--export-to-tmp", - help="Export backlog items to temporary file for copilot processing (default: <system-temp>/specfact-backlog-refine-<timestamp>.md)", - ), - import_from_tmp: bool = typer.Option( - False, - "--import-from-tmp", - help="Import refined content from temporary file after copilot processing (default: <system-temp>/specfact-backlog-refine-<timestamp>-refined.md)", - ), - tmp_file: Path | None = typer.Option( - None, - "--tmp-file", - help="Custom temporary file path (overrides default)", - ), - # DoR validation - check_dor: bool = typer.Option( - False, "--check-dor", help="Check Definition of Ready (DoR) rules before refinement" - ), - # Adapter configuration (GitHub) - repo_owner: str | None = typer.Option( - None, "--repo-owner", help="GitHub repository owner (required for GitHub adapter)" - ), - repo_name: str | None = typer.Option( - None, "--repo-name", help="GitHub repository name (required for GitHub adapter)" - ), - github_token: str | None = typer.Option( - None, "--github-token", help="GitHub API token (optional, uses GITHUB_TOKEN env var or gh CLI if not provided)" - ), - # Adapter configuration (ADO) - ado_org: str | None = typer.Option(None, "--ado-org", help="Azure DevOps organization (required for ADO adapter)"), - ado_project: str | None = typer.Option( - None, "--ado-project", help="Azure DevOps project (required for ADO adapter)" - ), - ado_team: str | None = typer.Option( - None, - "--ado-team", - help="Azure DevOps team name for iteration lookup (defaults to project name). Used when resolving current iteration when --sprint is omitted.", - ), - ado_token: str | None = typer.Option( - None, "--ado-token", help="Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided)" - ), - custom_field_mapping: str | None = typer.Option( - None, - "--custom-field-mapping", - help="Path to custom ADO field mapping YAML file (overrides default mappings)", - ), -) -> None: - """ - Refine backlog items using AI-assisted template matching. - - This command: - 1. Fetches backlog items from the specified adapter - 2. Detects template matches with confidence scores - 3. Identifies items needing refinement (low confidence or no match) - 4. Generates prompts for IDE AI copilot to refine items - 5. Validates refined content from IDE AI copilot - 6. Updates remote backlog with refined content - 7. Optionally imports refined items to OpenSpec bundle - - SpecFact CLI Architecture: - - This command generates prompts for IDE AI copilots (Cursor, Claude Code, etc.) - - IDE AI copilots execute those prompts using their native LLM - - IDE AI copilots feed refined content back to this command - - This command validates and processes the refined content - """ - try: - # Show initialization progress to provide feedback during setup - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - transient=False, - ) as init_progress: - # Initialize template registry and load templates - init_task = init_progress.add_task("[cyan]Initializing templates...[/cyan]", total=None) - registry = TemplateRegistry() - - # Determine template directories (built-in first so custom overrides take effect) - from specfact_cli.utils.ide_setup import find_package_resources_path - - current_dir = Path.cwd() - - # 1. Load built-in templates from resources/templates/backlog/ (preferred location) - # Try to find resources directory using package resource finder (for installed packages) - resources_path = find_package_resources_path("specfact_cli", "resources/templates/backlog") - built_in_loaded = False - if resources_path and resources_path.exists(): - registry.load_templates_from_directory(resources_path) - built_in_loaded = True - else: - # Fallback: Try relative to repo root (development mode) - repo_root = Path(__file__).parent.parent.parent.parent - resources_templates_dir = repo_root / "resources" / "templates" / "backlog" - if resources_templates_dir.exists(): - registry.load_templates_from_directory(resources_templates_dir) - built_in_loaded = True - else: - # 2. Fallback to src/specfact_cli/templates/ for backward compatibility - src_templates_dir = Path(__file__).parent.parent / "templates" - if src_templates_dir.exists(): - registry.load_templates_from_directory(src_templates_dir) - built_in_loaded = True - - if not built_in_loaded: - console.print( - "[yellow]⚠ No built-in backlog templates found; continuing with custom templates only.[/yellow]" - ) - - # 3. Load custom templates from project directory (highest priority) - project_templates_dir = current_dir / ".specfact" / "templates" / "backlog" - if project_templates_dir.exists(): - registry.load_templates_from_directory(project_templates_dir) - - init_progress.update(init_task, description="[green]✓[/green] Templates initialized") - - # Initialize template detector - detector_task = init_progress.add_task("[cyan]Initializing template detector...[/cyan]", total=None) - detector = TemplateDetector(registry) - init_progress.update(detector_task, description="[green]✓[/green] Template detector ready") - - # Initialize AI refiner (prompt generator and validator) - refiner_task = init_progress.add_task("[cyan]Initializing AI refiner...[/cyan]", total=None) - refiner = BacklogAIRefiner() - init_progress.update(refiner_task, description="[green]✓[/green] AI refiner ready") - - # Get adapter registry for writeback - adapter_task = init_progress.add_task("[cyan]Initializing adapter...[/cyan]", total=None) - adapter_registry = AdapterRegistry() - init_progress.update(adapter_task, description="[green]✓[/green] Adapter registry ready") - - # Load DoR configuration (if --check-dor flag set) - dor_config: DefinitionOfReady | None = None - if check_dor: - dor_task = init_progress.add_task("[cyan]Loading DoR configuration...[/cyan]", total=None) - repo_path = Path(".") - dor_config = DefinitionOfReady.load_from_repo(repo_path) - if dor_config: - init_progress.update(dor_task, description="[green]✓[/green] DoR configuration loaded") - else: - init_progress.update(dor_task, description="[yellow]⚠[/yellow] Using default DoR rules") - # Use default DoR rules - dor_config = DefinitionOfReady( - rules={ - "story_points": True, - "value_points": False, # Optional by default - "priority": True, - "business_value": True, - "acceptance_criteria": True, - "dependencies": False, # Optional by default - } - ) - - # Normalize adapter, framework, and persona to lowercase for template matching - # Template metadata in YAML uses lowercase (e.g., provider: github, framework: scrum) - # This ensures case-insensitive matching regardless of CLI input case - normalized_adapter = adapter.lower() if adapter else None - normalized_framework = framework.lower() if framework else None - normalized_persona = persona.lower() if persona else None - - # Validate adapter-specific required parameters (use same resolution as daily: CLI > env > config > git) - validate_task = init_progress.add_task("[cyan]Validating adapter configuration...[/cyan]", total=None) - writeback_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_team=ado_team, - ado_token=ado_token, - ) - if normalized_adapter == "github" and ( - not writeback_kwargs.get("repo_owner") or not writeback_kwargs.get("repo_name") - ): - init_progress.stop() - console.print("[red]repo_owner and repo_name required for GitHub.[/red]") - console.print( - "Set via: [cyan]--repo-owner[/cyan]/[cyan]--repo-name[/cyan], " - "env [cyan]SPECFACT_GITHUB_REPO_OWNER[/cyan]/[cyan]SPECFACT_GITHUB_REPO_NAME[/cyan], " - "or [cyan].specfact/backlog.yaml[/cyan] (see docs/guides/devops-adapter-integration.md)." - ) - raise typer.Exit(1) - if normalized_adapter == "ado" and (not writeback_kwargs.get("org") or not writeback_kwargs.get("project")): - init_progress.stop() - console.print( - "[red]ado_org and ado_project required for Azure DevOps.[/red] " - "Set via --ado-org/--ado-project, env SPECFACT_ADO_ORG/SPECFACT_ADO_PROJECT, or .specfact/backlog.yaml." - ) - raise typer.Exit(1) - - # Validate and set custom field mapping (if provided) - if custom_field_mapping: - mapping_path = Path(custom_field_mapping) - if not mapping_path.exists(): - init_progress.stop() - console.print(f"[red]Error:[/red] Custom field mapping file not found: {custom_field_mapping}") - sys.exit(1) - if not mapping_path.is_file(): - init_progress.stop() - console.print(f"[red]Error:[/red] Custom field mapping path is not a file: {custom_field_mapping}") - sys.exit(1) - # Validate file format by attempting to load it - try: - from specfact_cli.backlog.mappers.template_config import FieldMappingConfig - - FieldMappingConfig.from_file(mapping_path) - init_progress.update(validate_task, description="[green]✓[/green] Field mapping validated") - except (FileNotFoundError, ValueError, yaml.YAMLError) as e: - init_progress.stop() - console.print(f"[red]Error:[/red] Invalid custom field mapping file: {e}") - sys.exit(1) - # Set environment variable for converter to use - os.environ["SPECFACT_ADO_CUSTOM_MAPPING"] = str(mapping_path.absolute()) - else: - init_progress.update(validate_task, description="[green]✓[/green] Configuration validated") - - # Fetch backlog items with filters - # When ignore_refined and limit are set, fetch more candidates so we have enough after filtering - fetch_limit: int | None = limit - if ignore_refined and limit is not None and limit > 0: - fetch_limit = limit * 5 - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - transient=False, - ) as progress: - fetch_task = progress.add_task(f"[cyan]Fetching backlog items from {adapter}...[/cyan]", total=None) - items = _fetch_backlog_items( - adapter, - search_query=search, - labels=labels, - state=state, - assignee=assignee, - iteration=iteration, - sprint=sprint, - release=release, - limit=fetch_limit, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_team=ado_team, - ado_token=ado_token, - ) - progress.update(fetch_task, description="[green]✓[/green] Fetched backlog items") - - if not items: - # Provide helpful message when no items found, especially if filters were used - filter_info = [] - if state: - filter_info.append(f"state={state}") - if assignee: - filter_info.append(f"assignee={assignee}") - if iteration: - filter_info.append(f"iteration={iteration}") - if sprint: - filter_info.append(f"sprint={sprint}") - if release: - filter_info.append(f"release={release}") - - if filter_info: - console.print( - f"[yellow]No backlog items found with the specified filters:[/yellow] {', '.join(filter_info)}\n" - f"[cyan]Tips:[/cyan]\n" - f" • Verify the iteration path exists in Azure DevOps (Project Settings → Boards → Iterations)\n" - f" • Try using [bold]--iteration current[/bold] to use the current active iteration\n" - f" • Try using [bold]--sprint[/bold] with just the sprint name for automatic matching\n" - f" • Check that items exist in the specified iteration/sprint" - ) - else: - console.print("[yellow]No backlog items found.[/yellow]") - return - - # Filter by issue ID when --id is set - if issue_id is not None: - items = [i for i in items if str(i.id) == str(issue_id)] - if not items: - console.print( - f"[bold red]✗[/bold red] No backlog item with id {issue_id!r} found. " - "Check filters and adapter configuration." - ) - raise typer.Exit(1) - - # When ignore_refined (default), keep only items that need refinement; then apply limit - if ignore_refined: - items = [ - i - for i in items - if _item_needs_refinement( - i, detector, registry, template_id, normalized_adapter, normalized_framework, normalized_persona - ) - ] - if limit is not None and len(items) > limit: - items = items[:limit] - if ignore_refined and (limit is not None or issue_id is not None): - console.print( - f"[dim]Filtered to {len(items)} item(s) needing refinement" - + (f" (limit {limit})" if limit is not None else "") - + "[/dim]" - ) - - # Validate export/import flags - if export_to_tmp and import_from_tmp: - console.print("[bold red]✗[/bold red] --export-to-tmp and --import-from-tmp are mutually exclusive") - raise typer.Exit(1) - - # Handle export mode - if export_to_tmp: - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - export_file = tmp_file or (Path(tempfile.gettempdir()) / f"specfact-backlog-refine-{timestamp}.md") - - console.print(f"[bold cyan]Exporting {len(items)} backlog item(s) to: {export_file}[/bold cyan]") - - # Export items to markdown file - export_content = "# SpecFact Backlog Refinement Export\n\n" - export_content += f"**Export Date**: {datetime.now().isoformat()}\n" - export_content += f"**Adapter**: {adapter}\n" - export_content += f"**Items**: {len(items)}\n\n" - export_content += "---\n\n" - - for idx, item in enumerate(items, 1): - export_content += f"## Item {idx}: {item.title}\n\n" - export_content += f"**ID**: {item.id}\n" - export_content += f"**URL**: {item.url}\n" - if item.canonical_url: - export_content += f"**Canonical URL**: {item.canonical_url}\n" - export_content += f"**State**: {item.state}\n" - export_content += f"**Provider**: {item.provider}\n" - - # Include metrics - if item.story_points is not None or item.business_value is not None or item.priority is not None: - export_content += "\n**Metrics**:\n" - if item.story_points is not None: - export_content += f"- Story Points: {item.story_points}\n" - if item.business_value is not None: - export_content += f"- Business Value: {item.business_value}\n" - if item.priority is not None: - export_content += f"- Priority: {item.priority} (1=highest)\n" - if item.value_points is not None: - export_content += f"- Value Points (SAFe): {item.value_points}\n" - if item.work_item_type: - export_content += f"- Work Item Type: {item.work_item_type}\n" - - # Include acceptance criteria - if item.acceptance_criteria: - export_content += f"\n**Acceptance Criteria**:\n{item.acceptance_criteria}\n" - - # Include body - export_content += f"\n**Body**:\n```markdown\n{item.body_markdown}\n```\n" - - export_content += "\n---\n\n" - - export_file.write_text(export_content, encoding="utf-8") - console.print(f"[green]✓ Exported to: {export_file}[/green]") - console.print("[dim]Process items with copilot, then use --import-from-tmp to import refined content[/dim]") - return - - # Handle import mode - if import_from_tmp: - timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") - import_file = tmp_file or (Path(tempfile.gettempdir()) / f"specfact-backlog-refine-{timestamp}-refined.md") - - if not import_file.exists(): - console.print(f"[bold red]✗[/bold red] Import file not found: {import_file}") - console.print(f"[dim]Expected file: {import_file}[/dim]") - console.print("[dim]Or specify custom path with --tmp-file[/dim]") - raise typer.Exit(1) - - console.print(f"[bold cyan]Importing refined content from: {import_file}[/bold cyan]") - try: - raw = import_file.read_text(encoding="utf-8") - if is_debug_mode(): - debug_log_operation("file_read", str(import_file), "success") - except OSError as e: - if is_debug_mode(): - debug_log_operation("file_read", str(import_file), "error", error=str(e)) - raise - parsed_by_id = _parse_refined_export_markdown(raw) - if not parsed_by_id: - console.print( - "[yellow]No valid item blocks found in import file (expected ## Item N: and **ID**:)[/yellow]" - ) - raise typer.Exit(1) - - updated_items: list[BacklogItem] = [] - for item in items: - if item.id not in parsed_by_id: - continue - data = parsed_by_id[item.id] - body = data.get("body_markdown", item.body_markdown or "") - item.body_markdown = body if body is not None else (item.body_markdown or "") - if "acceptance_criteria" in data: - item.acceptance_criteria = data["acceptance_criteria"] - if data.get("title"): - item.title = data["title"] - if "story_points" in data: - item.story_points = data["story_points"] - if "business_value" in data: - item.business_value = data["business_value"] - if "priority" in data: - item.priority = data["priority"] - updated_items.append(item) - - if not write: - console.print(f"[green]Would update {len(updated_items)} item(s)[/green]") - console.print("[dim]Run with --write to apply changes to the backlog[/dim]") - return - - writeback_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_team=ado_team, - ado_token=ado_token, - ) - adapter_instance = adapter_registry.get_adapter(adapter, **writeback_kwargs) - if not isinstance(adapter_instance, BacklogAdapter): - console.print("[bold red]✗[/bold red] Adapter does not support backlog updates") - raise typer.Exit(1) - - for item in updated_items: - update_fields_list = ["title", "body_markdown"] - if item.acceptance_criteria: - update_fields_list.append("acceptance_criteria") - if item.story_points is not None: - update_fields_list.append("story_points") - if item.business_value is not None: - update_fields_list.append("business_value") - if item.priority is not None: - update_fields_list.append("priority") - adapter_instance.update_backlog_item(item, update_fields=update_fields_list) - console.print(f"[green]✓ Updated backlog item: {item.url}[/green]") - console.print(f"[green]✓ Updated {len(updated_items)} backlog item(s)[/green]") - return - - # Apply limit if specified (when not ignore_refined; when ignore_refined we already filtered and sliced) - if not ignore_refined and limit is not None and len(items) > limit: - items = items[:limit] - console.print(f"[yellow]Limited to {limit} items (found {len(items)} total)[/yellow]") - else: - console.print(f"[green]Found {len(items)} backlog items[/green]") - - # Process each item - refined_count = 0 - skipped_count = 0 - cancelled = False - - # Process items without progress bar during refinement to avoid conflicts with interactive prompts - for idx, item in enumerate(items, 1): - # Check for cancellation - if cancelled: - break - - # Show simple status text instead of progress bar - console.print(f"\n[bold cyan]Refining item {idx} of {len(items)}: {item.title}[/bold cyan]") - - # Check DoR (if enabled) - if check_dor and dor_config: - item_dict = item.model_dump() - dor_errors = dor_config.validate_item(item_dict) - if dor_errors: - console.print("[yellow]⚠ Definition of Ready (DoR) issues:[/yellow]") - for error in dor_errors: - console.print(f" - {error}") - console.print("[yellow]Item may not be ready for sprint planning[/yellow]") - else: - console.print("[green]✓ Definition of Ready (DoR) satisfied[/green]") - - # Detect template with persona/framework/provider filtering - # Use normalized values for case-insensitive template matching - detection_result = detector.detect_template( - item, provider=normalized_adapter, framework=normalized_framework, persona=normalized_persona - ) - - if detection_result.template_id: - template_id_str = detection_result.template_id - confidence_str = f"{detection_result.confidence:.2f}" - console.print(f"[green]✓ Detected template: {template_id_str} (confidence: {confidence_str})[/green]") - item.detected_template = detection_result.template_id - item.template_confidence = detection_result.confidence - item.template_missing_fields = detection_result.missing_fields - - # Check if item already has checkboxes in required sections (already refined) - # Items with checkboxes (- [ ] or - [x]) in required sections are considered already refined - target_template_for_check = ( - registry.get_template(detection_result.template_id) if detection_result.template_id else None - ) - if target_template_for_check: - import re - - has_checkboxes = bool( - re.search(r"^[\s]*- \[[ x]\]", item.body_markdown, re.MULTILINE | re.IGNORECASE) - ) - # Check if all required sections are present - all_sections_present = True - for section in target_template_for_check.required_sections: - # Look for section heading (## Section Name or ### Section Name) - section_pattern = rf"^#+\s+{re.escape(section)}\s*$" - if not re.search(section_pattern, item.body_markdown, re.MULTILINE | re.IGNORECASE): - all_sections_present = False - break - # If item has checkboxes and all required sections, it's already refined - skip it - if has_checkboxes and all_sections_present and not detection_result.missing_fields: - console.print( - "[green]Item already refined with checkboxes and all required sections - skipping[/green]" - ) - skipped_count += 1 - continue - - # High confidence AND no missing required fields - no refinement needed - # Note: Even with high confidence, if required sections are missing, refinement is needed - if template_id is None and detection_result.confidence >= 0.8 and not detection_result.missing_fields: - console.print( - "[green]High confidence match with all required sections - no refinement needed[/green]" - ) - skipped_count += 1 - continue - if detection_result.missing_fields: - missing_str = ", ".join(detection_result.missing_fields) - console.print(f"[yellow]⚠ Missing required sections: {missing_str} - refinement needed[/yellow]") - - # Low confidence or no match - needs refinement - # Get target template using priority-based resolution - target_template = None - if template_id: - target_template = registry.get_template(template_id) - if not target_template: - console.print(f"[yellow]Template {template_id} not found, using auto-detection[/yellow]") - elif detection_result.template_id: - target_template = registry.get_template(detection_result.template_id) - else: - # Use priority-based template resolution - # Use normalized values for case-insensitive template matching - target_template = registry.resolve_template( - provider=normalized_adapter, framework=normalized_framework, persona=normalized_persona - ) - if target_template: - resolved_id = target_template.template_id - console.print(f"[yellow]No template detected, using resolved template: {resolved_id}[/yellow]") - else: - # Fallback: Use first available template as default - templates = registry.list_templates(scope="corporate") - if templates: - target_template = templates[0] - console.print( - f"[yellow]No template resolved, using default: {target_template.template_id}[/yellow]" - ) - - if not target_template: - console.print("[yellow]No template available for refinement[/yellow]") - skipped_count += 1 - continue - - # In preview mode without --write, show full item details but skip interactive refinement - if preview and not write: - console.print("\n[bold]Preview Mode: Full Item Details[/bold]") - console.print(f"[bold]Title:[/bold] {item.title}") - console.print(f"[bold]URL:[/bold] {item.url}") - if item.canonical_url: - console.print(f"[bold]Canonical URL:[/bold] {item.canonical_url}") - console.print(f"[bold]State:[/bold] {item.state}") - console.print(f"[bold]Provider:[/bold] {item.provider}") - console.print(f"[bold]Assignee:[/bold] {', '.join(item.assignees) if item.assignees else 'Unassigned'}") - - # Show metrics if available - if item.story_points is not None or item.business_value is not None or item.priority is not None: - console.print("\n[bold]Story Metrics:[/bold]") - if item.story_points is not None: - console.print(f" - Story Points: {item.story_points}") - if item.business_value is not None: - console.print(f" - Business Value: {item.business_value}") - if item.priority is not None: - console.print(f" - Priority: {item.priority} (1=highest)") - if item.value_points is not None: - console.print(f" - Value Points (SAFe): {item.value_points}") - if item.work_item_type: - console.print(f" - Work Item Type: {item.work_item_type}") - - # Always show acceptance criteria if it's a required section, even if empty - # This helps copilot understand what fields need to be added - is_acceptance_criteria_required = ( - target_template.required_sections and "Acceptance Criteria" in target_template.required_sections - ) - if is_acceptance_criteria_required or item.acceptance_criteria: - console.print("\n[bold]Acceptance Criteria:[/bold]") - if item.acceptance_criteria: - console.print(Panel(item.acceptance_criteria)) - else: - # Show empty state so copilot knows to add it - console.print(Panel("[dim](empty - required field)[/dim]", border_style="dim")) - - # Always show body (Description is typically required) - console.print("\n[bold]Body:[/bold]") - body_content = ( - item.body_markdown[:1000] + "..." if len(item.body_markdown) > 1000 else item.body_markdown - ) - if not body_content.strip(): - # Show empty state so copilot knows to add it - console.print(Panel("[dim](empty - required field)[/dim]", border_style="dim")) - else: - console.print(Panel(body_content)) - - # Show template info - console.print( - f"\n[bold]Target Template:[/bold] {target_template.name} (ID: {target_template.template_id})" - ) - console.print(f"[bold]Template Description:[/bold] {target_template.description}") - - # Show what would be updated - console.print( - "\n[yellow]⚠ Preview mode: Item needs refinement but interactive prompts are skipped[/yellow]" - ) - console.print( - "[yellow] Use [bold]--write[/bold] flag to enable interactive refinement and writeback[/yellow]" - ) - console.print( - "[yellow] Or use [bold]--export-to-tmp[/bold] to export items for copilot processing[/yellow]" - ) - skipped_count += 1 - continue - - # Generate prompt for IDE AI copilot - console.print(f"[bold]Generating refinement prompt for template: {target_template.name}...[/bold]") - prompt = refiner.generate_refinement_prompt(item, target_template) - - # Display prompt for IDE AI copilot - console.print("\n[bold]Refinement Prompt for IDE AI Copilot:[/bold]") - console.print(Panel(prompt, title="Copy this prompt to your IDE AI copilot")) - - # Prompt user to get refined content from IDE AI copilot - console.print("\n[yellow]Instructions:[/yellow]") - console.print("1. Copy the prompt above to your IDE AI copilot (Cursor, Claude Code, etc.)") - console.print("2. Execute the prompt in your IDE AI copilot") - console.print("3. Copy the refined content from the AI copilot response") - console.print("4. Paste the refined content below, then type 'END' on a new line when done\n") - - # Read multiline input from stdin - # Support both interactive (paste + Ctrl+D) and non-interactive (EOF) modes - # Note: When pasting multiline content, each line is read sequentially - refined_content_lines: list[str] = [] - console.print("[bold]Paste refined content below (type 'END' on a new line when done):[/bold]") - console.print("[dim]Commands: :skip (skip this item), :quit or :abort (cancel session)[/dim]") - - try: - while True: - try: - line = input() - line_stripped = line.strip() - line_upper = line_stripped.upper() - - # Check for sentinel values (case-insensitive) - if line_upper == "END": - break - if line_upper == ":SKIP": - console.print("[yellow]Skipping current item[/yellow]") - skipped_count += 1 - refined_content_lines = [] # Clear content - break - if line_upper in (":QUIT", ":ABORT"): - console.print("[yellow]Cancelling refinement session[/yellow]") - cancelled = True - refined_content_lines = [] # Clear content - break - - refined_content_lines.append(line) - except EOFError: - # Ctrl+D pressed or EOF reached (common when pasting multiline content) - break - except KeyboardInterrupt: - console.print("\n[yellow]Input cancelled - skipping[/yellow]") - skipped_count += 1 - continue - - # Check if session was cancelled - if cancelled: - break - - refined_content = "\n".join(refined_content_lines).strip() - - if not refined_content: - console.print("[yellow]No refined content provided - skipping[/yellow]") - skipped_count += 1 - continue - - # Validate and score refined content (provider-aware) - try: - refinement_result = refiner.validate_and_score_refinement( - refined_content, item.body_markdown, target_template, item - ) - - # Print newline to separate validation results - console.print() - - # Display validation result - console.print("[bold]Refinement Validation Result:[/bold]") - console.print(f"[green]Confidence: {refinement_result.confidence:.2f}[/green]") - if refinement_result.has_todo_markers: - console.print("[yellow]⚠ Contains TODO markers[/yellow]") - if refinement_result.has_notes_section: - console.print("[yellow]⚠ Contains NOTES section[/yellow]") - - # Display story metrics if available - if item.story_points is not None or item.business_value is not None or item.priority is not None: - console.print("\n[bold]Story Metrics:[/bold]") - if item.story_points is not None: - console.print(f" - Story Points: {item.story_points}") - if item.business_value is not None: - console.print(f" - Business Value: {item.business_value}") - if item.priority is not None: - console.print(f" - Priority: {item.priority} (1=highest)") - if item.value_points is not None: - console.print(f" - Value Points (SAFe): {item.value_points}") - if item.work_item_type: - console.print(f" - Work Item Type: {item.work_item_type}") - - # Display story splitting suggestion if needed - if refinement_result.needs_splitting and refinement_result.splitting_suggestion: - console.print("\n[yellow]⚠ Story Splitting Recommendation:[/yellow]") - console.print(Panel(refinement_result.splitting_suggestion, title="Splitting Suggestion")) - - # Show preview with field preservation information - console.print("\n[bold]Preview: What will be updated[/bold]") - console.print("[dim]Fields that will be UPDATED:[/dim]") - console.print(" - title: Will be updated if changed") - console.print(" - body_markdown: Will be updated with refined content") - console.print("[dim]Fields that will be PRESERVED (not modified):[/dim]") - console.print(" - assignees: Preserved") - console.print(" - tags: Preserved") - console.print(" - state: Preserved") - console.print(" - priority: Preserved (if present in provider_fields)") - console.print(" - due_date: Preserved (if present in provider_fields)") - console.print(" - story_points: Preserved (if present in provider_fields)") - console.print(" - business_value: Preserved (if present in provider_fields)") - console.print(" - priority: Preserved (if present in provider_fields)") - console.print(" - acceptance_criteria: Preserved (if present in provider_fields)") - console.print(" - All other metadata: Preserved in provider_fields") - - console.print("\n[bold]Original:[/bold]") - console.print( - Panel(item.body_markdown[:500] + "..." if len(item.body_markdown) > 500 else item.body_markdown) - ) - console.print("\n[bold]Refined:[/bold]") - console.print( - Panel( - refinement_result.refined_body[:500] + "..." - if len(refinement_result.refined_body) > 500 - else refinement_result.refined_body - ) - ) - - # Store refined body for preview/write - item.refined_body = refinement_result.refined_body - - # Preview mode (default) - don't write, just show preview - if preview and not write: - console.print("\n[yellow]Preview mode: Refinement will NOT be written to backlog[/yellow]") - console.print("[yellow]Use --write flag to explicitly opt-in to writeback[/yellow]") - refined_count += 1 # Count as refined for preview purposes - continue - - # Write mode - requires explicit --write flag - if write: - # Auto-accept high confidence - if auto_accept_high_confidence and refinement_result.confidence >= 0.85: - console.print("[green]Auto-accepting high-confidence refinement and writing to backlog[/green]") - item.apply_refinement() - - # Writeback to remote backlog using adapter - # Build adapter kwargs for writeback - writeback_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - - adapter_instance = adapter_registry.get_adapter(adapter, **writeback_kwargs) - if isinstance(adapter_instance, BacklogAdapter): - # Update all fields including new agile framework fields - update_fields_list = ["title", "body_markdown"] - if item.acceptance_criteria: - update_fields_list.append("acceptance_criteria") - if item.story_points is not None: - update_fields_list.append("story_points") - if item.business_value is not None: - update_fields_list.append("business_value") - if item.priority is not None: - update_fields_list.append("priority") - updated_item = adapter_instance.update_backlog_item(item, update_fields=update_fields_list) - console.print(f"[green]✓ Updated backlog item: {updated_item.url}[/green]") - - # Add OpenSpec comment if requested - if openspec_comment: - # Extract OpenSpec change proposal ID from original body if present - original_body = item.body_markdown or "" - openspec_change_id = _extract_openspec_change_id(original_body) - - # Generate OpenSpec change proposal reference - change_id = openspec_change_id or f"backlog-refine-{item.id}" - comment_text = ( - f"## OpenSpec Change Proposal Reference\n\n" - f"This backlog item was refined using SpecFact CLI template-driven refinement.\n\n" - f"- **Change ID**: `{change_id}`\n" - f"- **Template**: `{item.detected_template or 'auto-detected'}`\n" - f"- **Confidence**: `{item.template_confidence or 0.0:.2f}`\n" - f"- **Refined**: {item.refinement_timestamp or 'N/A'}\n\n" - f"*Note: Original body preserved. " - f"This comment provides OpenSpec reference for cross-sync.*" - ) - if adapter_instance.add_comment(updated_item, comment_text): - console.print("[green]✓ Added OpenSpec reference comment[/green]") - else: - console.print( - "[yellow]⚠ Failed to add comment (adapter may not support comments)[/yellow]" - ) - else: - console.print("[yellow]⚠ Adapter does not support backlog updates[/yellow]") - refined_count += 1 - else: - # Interactive prompt with clear separation - console.print() - accept = Confirm.ask("Accept refinement and write to backlog?", default=False) - if accept: - item.apply_refinement() - - # Writeback to remote backlog using adapter - # Build adapter kwargs for writeback - writeback_kwargs = _build_adapter_kwargs( - adapter, - repo_owner=repo_owner, - repo_name=repo_name, - github_token=github_token, - ado_org=ado_org, - ado_project=ado_project, - ado_token=ado_token, - ) - - adapter_instance = adapter_registry.get_adapter(adapter, **writeback_kwargs) - if isinstance(adapter_instance, BacklogAdapter): - # Update all fields including new agile framework fields - update_fields_list = ["title", "body_markdown"] - if item.acceptance_criteria: - update_fields_list.append("acceptance_criteria") - if item.story_points is not None: - update_fields_list.append("story_points") - if item.business_value is not None: - update_fields_list.append("business_value") - if item.priority is not None: - update_fields_list.append("priority") - updated_item = adapter_instance.update_backlog_item( - item, update_fields=update_fields_list - ) - console.print(f"[green]✓ Updated backlog item: {updated_item.url}[/green]") - - # Add OpenSpec comment if requested - if openspec_comment: - # Extract OpenSpec change proposal ID from original body if present - original_body = item.body_markdown or "" - openspec_change_id = _extract_openspec_change_id(original_body) - - # Generate OpenSpec change proposal reference - change_id = openspec_change_id or f"backlog-refine-{item.id}" - comment_text = ( - f"## OpenSpec Change Proposal Reference\n\n" - f"This backlog item was refined using SpecFact CLI template-driven refinement.\n\n" - f"- **Change ID**: `{change_id}`\n" - f"- **Template**: `{item.detected_template or 'auto-detected'}`\n" - f"- **Confidence**: `{item.template_confidence or 0.0:.2f}`\n" - f"- **Refined**: {item.refinement_timestamp or 'N/A'}\n\n" - f"*Note: Original body preserved. " - f"This comment provides OpenSpec reference for cross-sync.*" - ) - if adapter_instance.add_comment(updated_item, comment_text): - console.print("[green]✓ Added OpenSpec reference comment[/green]") - else: - console.print( - "[yellow]⚠ Failed to add comment " - "(adapter may not support comments)[/yellow]" - ) - else: - console.print("[yellow]⚠ Adapter does not support backlog updates[/yellow]") - refined_count += 1 - else: - console.print("[yellow]Refinement rejected - not writing to backlog[/yellow]") - skipped_count += 1 - else: - # Preview mode but user didn't explicitly set --write - console.print("[yellow]Preview mode: Use --write to update backlog[/yellow]") - refined_count += 1 - - except ValueError as e: - console.print(f"[red]Validation failed: {e}[/red]") - console.print("[yellow]Please fix the refined content and try again[/yellow]") - skipped_count += 1 - continue - - # OpenSpec bundle import (if requested) - if (bundle or auto_bundle) and refined_count > 0: - console.print("\n[bold]OpenSpec Bundle Import:[/bold]") - try: - # Determine bundle path - bundle_path: Path | None = None - if bundle: - bundle_path = Path(bundle) - elif auto_bundle: - # Auto-detect bundle from current directory - current_dir = Path.cwd() - bundle_path = current_dir / ".specfact" / "bundle.yaml" - if not bundle_path.exists(): - bundle_path = current_dir / "bundle.yaml" - - if bundle_path and bundle_path.exists(): - console.print( - f"[green]Importing {refined_count} refined items to OpenSpec bundle: {bundle_path}[/green]" - ) - # TODO: Implement actual import logic using import command functionality - console.print( - "[yellow]⚠ OpenSpec bundle import integration pending (use import command separately)[/yellow]" - ) - else: - console.print("[yellow]⚠ Bundle path not found. Skipping import.[/yellow]") - except Exception as e: - console.print(f"[yellow]⚠ Failed to import to OpenSpec bundle: {e}[/yellow]") - - # Summary - console.print("\n[bold]Summary:[/bold]") - if cancelled: - console.print("[yellow]Session cancelled by user[/yellow]") - if limit: - console.print(f"[dim]Limit applied: {limit} items[/dim]") - console.print(f"[green]Refined: {refined_count}[/green]") - console.print(f"[yellow]Skipped: {skipped_count}[/yellow]") - - # Note: Writeback is handled per-item above when --write flag is set - - except Exception as e: - console.print(f"[red]Error: {e}[/red]") - raise typer.Exit(1) from e - - -@app.command("map-fields") -@require( - lambda ado_org, ado_project: isinstance(ado_org, str) - and len(ado_org) > 0 - and isinstance(ado_project, str) - and len(ado_project) > 0, - "ADO org and project must be non-empty strings", -) -@beartype -def map_fields( - ado_org: str = typer.Option(..., "--ado-org", help="Azure DevOps organization (required)"), - ado_project: str = typer.Option(..., "--ado-project", help="Azure DevOps project (required)"), - ado_token: str | None = typer.Option( - None, "--ado-token", help="Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided)" - ), - ado_base_url: str | None = typer.Option( - None, "--ado-base-url", help="Azure DevOps base URL (defaults to https://dev.azure.com)" - ), - reset: bool = typer.Option( - False, "--reset", help="Reset custom field mapping to defaults (deletes ado_custom.yaml)" - ), -) -> None: - """ - Interactive command to map ADO fields to canonical field names. - - Fetches available fields from Azure DevOps API and guides you through - mapping them to canonical field names (description, acceptance_criteria, etc.). - Saves the mapping to .specfact/templates/backlog/field_mappings/ado_custom.yaml. - - Examples: - specfact backlog map-fields --ado-org myorg --ado-project myproject - specfact backlog map-fields --ado-org myorg --ado-project myproject --ado-token <token> - specfact backlog map-fields --ado-org myorg --ado-project myproject --reset - """ - import base64 - import re - import sys - - import questionary # type: ignore[reportMissingImports] - import requests - - from specfact_cli.backlog.mappers.template_config import FieldMappingConfig - from specfact_cli.utils.auth_tokens import get_token - - def _find_potential_match(canonical_field: str, available_fields: list[dict[str, Any]]) -> str | None: - """ - Find a potential ADO field match for a canonical field using regex/fuzzy matching. - - Args: - canonical_field: Canonical field name (e.g., "acceptance_criteria") - available_fields: List of ADO field dicts with "referenceName" and "name" - - Returns: - Reference name of best matching field, or None if no good match found - """ - # Convert canonical field to search patterns - # e.g., "acceptance_criteria" -> ["acceptance", "criteria"] - field_parts = re.split(r"[_\s-]+", canonical_field.lower()) - - best_match: tuple[str, int] | None = None - best_score = 0 - - for field in available_fields: - ref_name = field.get("referenceName", "") - name = field.get("name", ref_name) - - # Search in both reference name and display name - search_text = f"{ref_name} {name}".lower() - - # Calculate match score - score = 0 - matched_parts = 0 - - for part in field_parts: - # Exact match in reference name (highest priority) - if part in ref_name.lower(): - score += 10 - matched_parts += 1 - # Exact match in display name - elif part in name.lower(): - score += 5 - matched_parts += 1 - # Partial match (contains substring) - elif part in search_text: - score += 2 - matched_parts += 1 - - # Bonus for matching all parts - if matched_parts == len(field_parts): - score += 5 - - # Prefer Microsoft.VSTS.Common.* fields - if ref_name.startswith("Microsoft.VSTS.Common."): - score += 3 - - if score > best_score and matched_parts > 0: - best_score = score - best_match = (ref_name, score) - - # Only return if we have a reasonable match (score >= 5) - if best_match and best_score >= 5: - return best_match[0] - - return None - - # Resolve token (explicit > env var > stored token) - api_token: str | None = None - auth_scheme = "basic" - if ado_token: - api_token = ado_token - auth_scheme = "basic" - elif os.environ.get("AZURE_DEVOPS_TOKEN"): - api_token = os.environ.get("AZURE_DEVOPS_TOKEN") - auth_scheme = "basic" - elif stored_token := get_token("azure-devops", allow_expired=False): - # Valid, non-expired token found - api_token = stored_token.get("access_token") - token_type = (stored_token.get("token_type") or "bearer").lower() - 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 - use it anyway for this command (user can refresh later) - api_token = stored_token_expired.get("access_token") - token_type = (stored_token_expired.get("token_type") or "bearer").lower() - auth_scheme = "bearer" if token_type == "bearer" else "basic" - console.print( - "[yellow]⚠[/yellow] Using expired stored token. If authentication fails, refresh with: specfact auth azure-devops" - ) - - if not api_token: - console.print("[red]Error:[/red] Azure DevOps token required") - console.print("[yellow]Options:[/yellow]") - console.print(" 1. Use --ado-token option") - console.print(" 2. Set AZURE_DEVOPS_TOKEN environment variable") - console.print(" 3. Use: specfact auth azure-devops") - raise typer.Exit(1) - - # Build base URL - base_url = (ado_base_url or "https://dev.azure.com").rstrip("/") - - # Fetch fields from ADO API - console.print("[cyan]Fetching fields from Azure DevOps...[/cyan]") - fields_url = f"{base_url}/{ado_org}/{ado_project}/_apis/wit/fields?api-version=7.1" - - # Prepare authentication headers based on auth scheme - headers: dict[str, str] = {} - if auth_scheme == "bearer": - headers["Authorization"] = f"Bearer {api_token}" - else: - # Basic auth for PAT tokens - auth_header = base64.b64encode(f":{api_token}".encode()).decode() - headers["Authorization"] = f"Basic {auth_header}" - - try: - response = requests.get(fields_url, headers=headers, timeout=30) - response.raise_for_status() - fields_data = response.json() - except requests.exceptions.RequestException as e: - console.print(f"[red]Error:[/red] Failed to fetch fields from Azure DevOps: {e}") - raise typer.Exit(1) from e - - # Extract fields and filter out system-only fields - all_fields = fields_data.get("value", []) - system_only_fields = { - "System.Id", - "System.Rev", - "System.ChangedDate", - "System.CreatedDate", - "System.ChangedBy", - "System.CreatedBy", - "System.AreaId", - "System.IterationId", - "System.TeamProject", - "System.NodeName", - "System.AreaLevel1", - "System.AreaLevel2", - "System.AreaLevel3", - "System.AreaLevel4", - "System.AreaLevel5", - "System.AreaLevel6", - "System.AreaLevel7", - "System.AreaLevel8", - "System.AreaLevel9", - "System.AreaLevel10", - "System.IterationLevel1", - "System.IterationLevel2", - "System.IterationLevel3", - "System.IterationLevel4", - "System.IterationLevel5", - "System.IterationLevel6", - "System.IterationLevel7", - "System.IterationLevel8", - "System.IterationLevel9", - "System.IterationLevel10", - } - - # Filter relevant fields - relevant_fields = [ - field - for field in all_fields - if field.get("referenceName") not in system_only_fields - and not field.get("referenceName", "").startswith("System.History") - and not field.get("referenceName", "").startswith("System.Watermark") - ] - - # Sort fields by reference name - relevant_fields.sort(key=lambda f: f.get("referenceName", "")) - - # Canonical fields to map - canonical_fields = { - "description": "Description", - "acceptance_criteria": "Acceptance Criteria", - "story_points": "Story Points", - "business_value": "Business Value", - "priority": "Priority", - "work_item_type": "Work Item Type", - } - - # Load default mappings from AdoFieldMapper - from specfact_cli.backlog.mappers.ado_mapper import AdoFieldMapper - - default_mappings = AdoFieldMapper.DEFAULT_FIELD_MAPPINGS - # Reverse default mappings: canonical -> list of ADO fields - default_mappings_reversed: dict[str, list[str]] = {} - for ado_field, canonical in default_mappings.items(): - if canonical not in default_mappings_reversed: - default_mappings_reversed[canonical] = [] - default_mappings_reversed[canonical].append(ado_field) - - # Handle --reset flag - current_dir = Path.cwd() - custom_mapping_file = current_dir / ".specfact" / "templates" / "backlog" / "field_mappings" / "ado_custom.yaml" - - if reset: - if custom_mapping_file.exists(): - custom_mapping_file.unlink() - console.print(f"[green]✓[/green] Reset custom field mapping (deleted {custom_mapping_file})") - console.print("[dim]Custom mappings removed. Default mappings will be used.[/dim]") - else: - console.print("[yellow]⚠[/yellow] No custom mapping file found. Nothing to reset.") - return - - # Load existing mapping if it exists - existing_mapping: dict[str, str] = {} - existing_work_item_type_mappings: dict[str, str] = {} - existing_config: FieldMappingConfig | None = None - if custom_mapping_file.exists(): - try: - existing_config = FieldMappingConfig.from_file(custom_mapping_file) - existing_mapping = existing_config.field_mappings - existing_work_item_type_mappings = existing_config.work_item_type_mappings or {} - console.print(f"[green]✓[/green] Loaded existing mapping from {custom_mapping_file}") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Failed to load existing mapping: {e}") - - # Build combined mapping: existing > default (checking which defaults exist in fetched fields) - combined_mapping: dict[str, str] = {} - # Get list of available ADO field reference names - available_ado_refs = {field.get("referenceName", "") for field in relevant_fields} - - # First add defaults, but only if they exist in the fetched ADO fields - for canonical_field in canonical_fields: - if canonical_field in default_mappings_reversed: - # Find which default mappings actually exist in the fetched ADO fields - # Prefer more common field names (Microsoft.VSTS.Common.* over System.*) - default_options = default_mappings_reversed[canonical_field] - existing_defaults = [ado_field for ado_field in default_options if ado_field in available_ado_refs] - - if existing_defaults: - # Prefer Microsoft.VSTS.Common.* over System.* for better compatibility - preferred = None - for ado_field in existing_defaults: - if ado_field.startswith("Microsoft.VSTS.Common."): - preferred = ado_field - break - # If no Microsoft.VSTS.Common.* found, use first existing - if preferred is None: - preferred = existing_defaults[0] - combined_mapping[preferred] = canonical_field - else: - # No default mapping exists - try to find a potential match using regex/fuzzy matching - potential_match = _find_potential_match(canonical_field, relevant_fields) - if potential_match: - combined_mapping[potential_match] = canonical_field - # Then override with existing mappings - combined_mapping.update(existing_mapping) - - # Interactive mapping - console.print() - console.print(Panel("[bold cyan]Interactive Field Mapping[/bold cyan]", border_style="cyan")) - console.print("[dim]Use ↑↓ to navigate, ⏎ to select. Map ADO fields to canonical field names.[/dim]") - console.print() - - new_mapping: dict[str, str] = {} - - # Build choice list with display names - field_choices_display: list[str] = ["<no mapping>"] - field_choices_refs: list[str] = ["<no mapping>"] - for field in relevant_fields: - ref_name = field.get("referenceName", "") - name = field.get("name", ref_name) - display = f"{ref_name} ({name})" - field_choices_display.append(display) - field_choices_refs.append(ref_name) - - for canonical_field, display_name in canonical_fields.items(): - # Find current mapping (existing > default) - current_ado_fields = [ - ado_field for ado_field, canonical in combined_mapping.items() if canonical == canonical_field - ] - - # Determine default selection - default_selection = "<no mapping>" - if current_ado_fields: - # Find the current mapping in the choices list - current_ref = current_ado_fields[0] - if current_ref in field_choices_refs: - default_selection = field_choices_display[field_choices_refs.index(current_ref)] - else: - # If current mapping not in available fields, use "<no mapping>" - default_selection = "<no mapping>" - - # Use interactive selection menu with questionary - console.print(f"[bold]{display_name}[/bold] (canonical: {canonical_field})") - if current_ado_fields: - console.print(f"[dim]Current: {', '.join(current_ado_fields)}[/dim]") - else: - console.print("[dim]Current: <no mapping>[/dim]") - - # Find default index - default_index = 0 - if default_selection != "<no mapping>" and default_selection in field_choices_display: - default_index = field_choices_display.index(default_selection) - - # Use questionary for interactive selection with arrow keys - try: - selected_display = questionary.select( - f"Select ADO field for {display_name}", - choices=field_choices_display, - default=field_choices_display[default_index] if default_index < len(field_choices_display) else None, - use_arrow_keys=True, - use_jk_keys=False, - ).ask() - if selected_display is None: - selected_display = "<no mapping>" - except KeyboardInterrupt: - console.print("\n[yellow]Selection cancelled.[/yellow]") - sys.exit(0) - - # Convert display name back to reference name - if selected_display and selected_display != "<no mapping>" and selected_display in field_choices_display: - selected_ref = field_choices_refs[field_choices_display.index(selected_display)] - new_mapping[selected_ref] = canonical_field - - console.print() - - # Validate mapping - console.print("[cyan]Validating mapping...[/cyan]") - duplicate_ado_fields = {} - for ado_field, canonical in new_mapping.items(): - if ado_field in duplicate_ado_fields: - duplicate_ado_fields[ado_field].append(canonical) - else: - # Check if this ADO field is already mapped to a different canonical field - for other_ado, other_canonical in new_mapping.items(): - if other_ado == ado_field and other_canonical != canonical: - if ado_field not in duplicate_ado_fields: - duplicate_ado_fields[ado_field] = [] - duplicate_ado_fields[ado_field].extend([canonical, other_canonical]) - - if duplicate_ado_fields: - console.print("[yellow]⚠[/yellow] Warning: Some ADO fields are mapped to multiple canonical fields:") - for ado_field, canonicals in duplicate_ado_fields.items(): - console.print(f" {ado_field}: {', '.join(set(canonicals))}") - if not Confirm.ask("Continue anyway?", default=False): - console.print("[yellow]Mapping cancelled.[/yellow]") - raise typer.Exit(0) - - # Merge with existing mapping (new mapping takes precedence) - final_mapping = existing_mapping.copy() - final_mapping.update(new_mapping) - - # Preserve existing work_item_type_mappings if they exist - # This prevents erasing custom work item type mappings when updating field mappings - work_item_type_mappings = existing_work_item_type_mappings.copy() if existing_work_item_type_mappings else {} - - # Create FieldMappingConfig - config = FieldMappingConfig( - framework=existing_config.framework if existing_config else "default", - field_mappings=final_mapping, - work_item_type_mappings=work_item_type_mappings, - ) - - # Save to file - custom_mapping_file.parent.mkdir(parents=True, exist_ok=True) - with custom_mapping_file.open("w", encoding="utf-8") as f: - yaml.dump(config.model_dump(), f, default_flow_style=False, sort_keys=False) - - console.print() - console.print(Panel("[bold green]✓ Mapping saved successfully[/bold green]", border_style="green")) - console.print(f"[green]Location:[/green] {custom_mapping_file}") - console.print() - console.print("[dim]You can now use this mapping with specfact backlog refine.[/dim]") +__all__ = ["app"] diff --git a/src/specfact_cli/commands/contract_cmd.py b/src/specfact_cli/commands/contract_cmd.py index e0713f76..ff9daaba 100644 --- a/src/specfact_cli/commands/contract_cmd.py +++ b/src/specfact_cli/commands/contract_cmd.py @@ -1,1241 +1,6 @@ -""" -Contract command - OpenAPI contract management for project bundles. +"""Backward-compatible app shim. Implementation moved to modules/contract/.""" -This module provides commands for managing OpenAPI contracts within project bundles, -including initialization, validation, mock server generation, test generation, and coverage. -""" +from specfact_cli.modules.contract.src.commands import app -from __future__ import annotations -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.table import Table - -from specfact_cli.models.contract import ( - ContractIndex, - ContractStatus, - count_endpoints, - load_openapi_contract, - validate_openapi_schema, -) -from specfact_cli.models.project import FeatureIndex, ProjectBundle -from specfact_cli.telemetry import telemetry -from specfact_cli.utils import print_error, print_info, print_section, print_success, print_warning -from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress -from specfact_cli.utils.structure import SpecFactStructure - - -app = typer.Typer(help="Manage OpenAPI contracts for project bundles") -console = Console() - - -@app.command("init") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def init_contract( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - feature: str = typer.Option(..., "--feature", help="Feature key (e.g., FEATURE-001)"), - # Output/Results - title: str | None = typer.Option(None, "--title", help="API title (default: feature title)"), - version: str = typer.Option("1.0.0", "--version", help="API version"), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), - force: bool = typer.Option( - False, - "--force", - help="Overwrite existing contract file without prompting (useful for updating contracts)", - ), -) -> None: - """ - Initialize OpenAPI contract for a feature. - - Creates a new OpenAPI 3.0.3 contract stub in the bundle's contracts/ directory - and links it to the feature in the bundle manifest. - - Note: Defaults to OpenAPI 3.0.3 for compatibility with Specmatic. - Validation accepts both 3.0.x and 3.1.x for forward compatibility. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --feature - - **Output/Results**: --title, --version - - **Behavior/Options**: --no-interactive, --force - - **Examples:** - specfact contract init --bundle legacy-api --feature FEATURE-001 - specfact contract init --bundle legacy-api --feature FEATURE-001 --title "Authentication API" --version 1.0.0 - specfact contract init --bundle legacy-api --feature FEATURE-001 --force --no-interactive - """ - telemetry_metadata = { - "bundle": bundle, - "feature": feature, - "title": title, - "version": version, - } - - with telemetry.track_command("contract.init", telemetry_metadata) as record: - print_section("SpecFact CLI - OpenAPI Contract Initialization") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Check feature exists - if feature not in bundle_obj.features: - print_error(f"Feature '{feature}' not found in bundle") - raise typer.Exit(1) - - feature_obj = bundle_obj.features[feature] - - # Determine contract file path - contracts_dir = bundle_dir / "contracts" - contracts_dir.mkdir(parents=True, exist_ok=True) - contract_file = contracts_dir / f"{feature}.openapi.yaml" - - if contract_file.exists(): - if force: - print_warning(f"Overwriting existing contract file: {contract_file}") - else: - print_warning(f"Contract file already exists: {contract_file}") - if not no_interactive: - overwrite = typer.confirm("Overwrite existing contract?") - if not overwrite: - raise typer.Exit(0) - else: - print_error("Use --force to overwrite existing contract in non-interactive mode") - raise typer.Exit(1) - - # Generate OpenAPI stub - api_title = title or feature_obj.title - openapi_stub = _generate_openapi_stub(api_title, version, feature) - - # Write contract file - import yaml - - with contract_file.open("w", encoding="utf-8") as f: - yaml.dump(openapi_stub, f, default_flow_style=False, sort_keys=False) - - # Update feature index in manifest - contract_path = f"contracts/{contract_file.name}" - _update_feature_contract(bundle_obj, feature, contract_path) - - # Update contract index in manifest - _update_contract_index(bundle_obj, feature, contract_path, bundle_dir / contract_path) - - # Save bundle - save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True, console_instance=console) - print_success(f"Initialized OpenAPI contract for {feature}: {contract_file}") - - record({"feature": feature, "contract_file": str(contract_file)}) - - -@beartype -@require(lambda title: isinstance(title, str), "Title must be str") -@require(lambda version: isinstance(version, str), "Version must be str") -@require(lambda feature: isinstance(feature, str), "Feature must be str") -@ensure(lambda result: isinstance(result, dict), "Must return dict") -def _generate_openapi_stub(title: str, version: str, feature: str) -> dict[str, Any]: - """Generate OpenAPI 3.0.3 stub. - - Note: Defaults to 3.0.3 for Specmatic compatibility. - Specmatic 3.1.x support is planned but not yet released (as of Dec 2025). - Once Specmatic adds 3.1.x support, we can update the default here. - """ - return { - "openapi": "3.0.3", # Default to 3.0.3 for Specmatic compatibility - "info": { - "title": title, - "version": version, - "description": f"OpenAPI contract for {feature}", - }, - "servers": [ - {"url": "https://api.example.com/v1", "description": "Production server"}, - {"url": "https://staging.api.example.com/v1", "description": "Staging server"}, - ], - "paths": {}, - "components": { - "schemas": {}, - "responses": {}, - "parameters": {}, - }, - } - - -@beartype -@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") -@require(lambda feature_key: isinstance(feature_key, str), "Feature key must be str") -@require(lambda contract_path: isinstance(contract_path, str), "Contract path must be str") -@ensure(lambda result: result is None, "Must return None") -def _update_feature_contract(bundle: ProjectBundle, feature_key: str, contract_path: str) -> None: - """Update feature contract reference in manifest.""" - # Find feature index - for feature_index in bundle.manifest.features: - if feature_index.key == feature_key: - feature_index.contract = contract_path - return - - # If not found, create new index entry - feature_obj = bundle.features[feature_key] - from datetime import UTC, datetime - - feature_index = FeatureIndex( - key=feature_key, - title=feature_obj.title, - file=f"features/{feature_key}.yaml", - contract=contract_path, - status="active", - stories_count=len(feature_obj.stories), - created_at=datetime.now(UTC).isoformat(), - updated_at=datetime.now(UTC).isoformat(), - checksum=None, - ) - bundle.manifest.features.append(feature_index) - - -@beartype -@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") -@require(lambda feature_key: isinstance(feature_key, str), "Feature key must be str") -@require(lambda contract_path: isinstance(contract_path, str), "Contract path must be str") -@require(lambda contract_file: isinstance(contract_file, Path), "Contract file must be Path") -@ensure(lambda result: result is None, "Must return None") -def _update_contract_index(bundle: ProjectBundle, feature_key: str, contract_path: str, contract_file: Path) -> None: - """Update contract index in manifest.""" - import hashlib - - # Check if contract index already exists - for contract_index in bundle.manifest.contracts: - if contract_index.feature_key == feature_key: - # Update existing index - contract_index.contract_file = contract_path - contract_index.status = ContractStatus.DRAFT - if contract_file.exists(): - try: - contract_data = load_openapi_contract(contract_file) - contract_index.endpoints_count = count_endpoints(contract_data) - contract_index.checksum = hashlib.sha256(contract_file.read_bytes()).hexdigest() - except Exception: - contract_index.endpoints_count = 0 - contract_index.checksum = None - return - - # Create new contract index entry - endpoints_count = 0 - checksum = None - if contract_file.exists(): - try: - contract_data = load_openapi_contract(contract_file) - endpoints_count = count_endpoints(contract_data) - checksum = hashlib.sha256(contract_file.read_bytes()).hexdigest() - except Exception: - pass - - contract_index = ContractIndex( - feature_key=feature_key, - contract_file=contract_path, - status=ContractStatus.DRAFT, - checksum=checksum, - endpoints_count=endpoints_count, - coverage=0.0, - ) - bundle.manifest.contracts.append(contract_index) - - -@app.command("validate") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def validate_contract( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - feature: str | None = typer.Option( - None, - "--feature", - help="Feature key (e.g., FEATURE-001). If not specified, validates all contracts in bundle.", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Validate OpenAPI contract schema. - - Validates OpenAPI schema structure (supports both 3.0.x and 3.1.x). - For comprehensive validation including Specmatic, use 'specfact spec validate'. - - Note: Accepts both OpenAPI 3.0.x and 3.1.x for forward compatibility. - Specmatic currently supports 3.0.x; 3.1.x support is planned. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --feature - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact contract validate --bundle legacy-api --feature FEATURE-001 - specfact contract validate --bundle legacy-api # Validates all contracts - """ - telemetry_metadata = { - "bundle": bundle, - "feature": feature, - } - - with telemetry.track_command("contract.validate", telemetry_metadata) as record: - print_section("SpecFact CLI - OpenAPI Contract Validation") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Determine which contracts to validate - contracts_to_validate: list[tuple[str, Path]] = [] - - if feature: - # Validate specific feature contract - if feature not in bundle_obj.features: - print_error(f"Feature '{feature}' not found in bundle") - raise typer.Exit(1) - - feature_obj = bundle_obj.features[feature] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - - contract_path = bundle_dir / feature_obj.contract - if not contract_path.exists(): - print_error(f"Contract file not found: {contract_path}") - raise typer.Exit(1) - - contracts_to_validate = [(feature, contract_path)] - else: - # Validate all contracts - for feature_key, feature_obj in bundle_obj.features.items(): - if feature_obj.contract: - contract_path = bundle_dir / feature_obj.contract - if contract_path.exists(): - contracts_to_validate.append((feature_key, contract_path)) - - if not contracts_to_validate: - print_warning("No contracts found to validate") - raise typer.Exit(0) - - # Validate contracts - table = Table(title="Contract Validation Results") - table.add_column("Feature", style="cyan") - table.add_column("Contract File", style="magenta") - table.add_column("Status", style="green") - table.add_column("Endpoints", style="yellow") - - all_valid = True - for feature_key, contract_path in contracts_to_validate: - try: - contract_data = load_openapi_contract(contract_path) - is_valid = validate_openapi_schema(contract_data) - endpoint_count = count_endpoints(contract_data) - - if is_valid: - status = "✓ Valid" - table.add_row(feature_key, contract_path.name, status, str(endpoint_count)) - else: - status = "✗ Invalid" - table.add_row(feature_key, contract_path.name, status, "0") - all_valid = False - except Exception as e: - status = f"✗ Error: {e}" - table.add_row(feature_key, contract_path.name, status, "0") - all_valid = False - - console.print(table) - - if not all_valid: - print_error("Some contracts failed validation") - record({"valid": False, "contracts_count": len(contracts_to_validate)}) - raise typer.Exit(1) - - print_success("All contracts validated successfully") - record({"valid": True, "contracts_count": len(contracts_to_validate)}) - - -@app.command("coverage") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def contract_coverage( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Calculate contract coverage for a project bundle. - - Shows which features have contracts and calculates coverage metrics. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact contract coverage --bundle legacy-api - """ - telemetry_metadata = { - "bundle": bundle, - } - - with telemetry.track_command("contract.coverage", telemetry_metadata) as record: - print_section("SpecFact CLI - OpenAPI Contract Coverage") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Calculate coverage - total_features = len(bundle_obj.features) - features_with_contracts = 0 - total_endpoints = 0 - - table = Table(title="Contract Coverage") - table.add_column("Feature", style="cyan") - table.add_column("Contract", style="magenta") - table.add_column("Endpoints", style="yellow") - table.add_column("Status", style="green") - - for feature_key, feature_obj in bundle_obj.features.items(): - if feature_obj.contract: - contract_path = bundle_dir / feature_obj.contract - if contract_path.exists(): - try: - contract_data = load_openapi_contract(contract_path) - endpoint_count = count_endpoints(contract_data) - total_endpoints += endpoint_count - features_with_contracts += 1 - table.add_row(feature_key, contract_path.name, str(endpoint_count), "✓") - except Exception as e: - table.add_row(feature_key, contract_path.name, "0", f"✗ Error: {e}") - else: - table.add_row(feature_key, feature_obj.contract, "0", "✗ File not found") - else: - table.add_row(feature_key, "-", "0", "✗ No contract") - - console.print(table) - - # Calculate coverage percentage - coverage_percent = (features_with_contracts / total_features * 100) if total_features > 0 else 0.0 - - console.print("\n[bold]Coverage Summary:[/bold]") - console.print( - f" Features with contracts: {features_with_contracts}/{total_features} ({coverage_percent:.1f}%)" - ) - console.print(f" Total API endpoints: {total_endpoints}") - - if coverage_percent < 100.0: - print_warning(f"Coverage is {coverage_percent:.1f}% - some features are missing contracts") - else: - print_success("All features have contracts (100% coverage)") - - record( - { - "total_features": total_features, - "features_with_contracts": features_with_contracts, - "coverage_percent": coverage_percent, - "total_endpoints": total_endpoints, - } - ) - - -@app.command("serve") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def serve_contract( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - feature: str | None = typer.Option( - None, - "--feature", - help="Feature key (e.g., FEATURE-001). If not specified, prompts for selection.", - ), - # Behavior/Options - port: int = typer.Option(9000, "--port", help="Port number for mock server (default: 9000)"), - strict: bool = typer.Option( - True, - "--strict/--examples", - help="Use strict validation mode (default: strict)", - ), - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Start mock server for OpenAPI contract. - - Launches a Specmatic mock server that serves API endpoints based on the - OpenAPI contract. Useful for frontend development and testing without a - running backend. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --feature - - **Behavior/Options**: --port, --strict/--examples, --no-interactive - - **Examples:** - specfact contract serve --bundle legacy-api --feature FEATURE-001 - specfact contract serve --bundle legacy-api --feature FEATURE-001 --port 8080 - specfact contract serve --bundle legacy-api --feature FEATURE-001 --examples - """ - telemetry_metadata = { - "bundle": bundle, - "feature": feature, - "port": port, - "strict": strict, - } - - with telemetry.track_command("contract.serve", telemetry_metadata): - from specfact_cli.integrations.specmatic import check_specmatic_available, create_mock_server - - print_section("SpecFact CLI - OpenAPI Contract Mock Server") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Get feature contract - if feature: - if feature not in bundle_obj.features: - print_error(f"Feature '{feature}' not found in bundle") - raise typer.Exit(1) - feature_obj = bundle_obj.features[feature] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - contract_path = bundle_dir / feature_obj.contract - if not contract_path.exists(): - print_error(f"Contract file not found: {contract_path}") - raise typer.Exit(1) - else: - # Find features with contracts - features_with_contracts = [(key, obj) for key, obj in bundle_obj.features.items() if obj.contract] - if not features_with_contracts: - print_error("No features with contracts found in bundle") - raise typer.Exit(1) - - if len(features_with_contracts) == 1: - # Only one contract, use it - feature, feature_obj = features_with_contracts[0] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - contract_path = bundle_dir / feature_obj.contract - elif no_interactive: - # Non-interactive mode, use first contract - feature, feature_obj = features_with_contracts[0] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - contract_path = bundle_dir / feature_obj.contract - else: - # Interactive selection - from rich.prompt import Prompt - - feature_choices = [f"{key}: {obj.title}" for key, obj in features_with_contracts] - selected = Prompt.ask("Select feature contract", choices=feature_choices) - feature = selected.split(":")[0] - feature_obj = bundle_obj.features[feature] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - contract_path = bundle_dir / feature_obj.contract - - # Check if Specmatic is available - is_available, error_msg = check_specmatic_available() - if not is_available: - print_error(f"Specmatic not available: {error_msg}") - print_info("Install Specmatic: npm install -g @specmatic/specmatic") - raise typer.Exit(1) - - # Start mock server - console.print("[bold cyan]Starting mock server...[/bold cyan]") - console.print(f" Feature: {feature}") - # Resolve repo to absolute path for relative_to() to work - repo_resolved = repo.resolve() - try: - contract_path_display = contract_path.relative_to(repo_resolved) - except ValueError: - # If contract_path is not a subpath of repo, show absolute path - contract_path_display = contract_path - console.print(f" Contract: {contract_path_display}") - console.print(f" Port: {port}") - console.print(f" Mode: {'strict' if strict else 'examples'}") - - import asyncio - - console.print("[dim]Starting mock server (this may take a few seconds)...[/dim]") - try: - mock_server = asyncio.run(create_mock_server(contract_path, port=port, strict_mode=strict)) - print_success(f"✓ Mock server started at http://localhost:{port}") - console.print("\n[bold]Available endpoints:[/bold]") - console.print(f" Try: curl http://localhost:{port}/actuator/health") - console.print("\n[yellow]Press Ctrl+C to stop the server[/yellow]") - - # Keep running until interrupted - try: - import time - - while mock_server.is_running(): - time.sleep(1) - except KeyboardInterrupt: - console.print("\n[yellow]Stopping mock server...[/yellow]") - mock_server.stop() - print_success("✓ Mock server stopped") - except Exception as e: - print_error(f"✗ Failed to start mock server: {e!s}") - raise typer.Exit(1) from e - - -@app.command("verify") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def verify_contract( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - feature: str | None = typer.Option( - None, - "--feature", - help="Feature key (e.g., FEATURE-001). If not specified, verifies all contracts in bundle.", - ), - # Behavior/Options - port: int = typer.Option(9000, "--port", help="Port number for mock server (default: 9000)"), - skip_mock: bool = typer.Option( - False, - "--skip-mock", - help="Skip mock server startup (only validate contract)", - ), - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Verify OpenAPI contract - validate, generate examples, and test mock server. - - This is a convenience command that combines multiple steps: - 1. Validates the contract schema - 2. Generates examples from the contract - 3. Starts a mock server (optional) - 4. Runs basic connectivity tests - - Perfect for verifying contracts work correctly without a real API implementation. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --feature - - **Behavior/Options**: --port, --skip-mock, --no-interactive - - **Examples:** - # Verify a specific contract - specfact contract verify --bundle my-api --feature FEATURE-001 - - # Verify all contracts in a bundle - specfact contract verify --bundle my-api - - # Verify without starting mock server (CI/CD) - specfact contract verify --bundle my-api --feature FEATURE-001 --skip-mock --no-interactive - """ - telemetry_metadata = { - "bundle": bundle, - "feature": feature, - "port": port, - "skip_mock": skip_mock, - } - - with telemetry.track_command("contract.verify", telemetry_metadata) as record: - from specfact_cli.integrations.specmatic import ( - check_specmatic_available, - create_mock_server, - generate_specmatic_examples, - ) - - print_section("SpecFact CLI - OpenAPI Contract Verification") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Determine which contracts to verify - contracts_to_verify: list[tuple[str, Path]] = [] - if feature: - if feature not in bundle_obj.features: - print_error(f"Feature '{feature}' not found in bundle") - raise typer.Exit(1) - feature_obj = bundle_obj.features[feature] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - contract_path = bundle_dir / feature_obj.contract - if not contract_path.exists(): - print_error(f"Contract file not found: {contract_path}") - raise typer.Exit(1) - contracts_to_verify = [(feature, contract_path)] - else: - # Verify all contracts in bundle - for feat_key, feat_obj in bundle_obj.features.items(): - if feat_obj.contract: - contract_path = bundle_dir / feat_obj.contract - if contract_path.exists(): - contracts_to_verify.append((feat_key, contract_path)) - - if not contracts_to_verify: - print_error("No contracts found to verify") - raise typer.Exit(1) - - # Check if Specmatic is available - is_available, error_msg = check_specmatic_available() - if not is_available: - print_error(f"Specmatic not available: {error_msg}") - print_info("Install Specmatic: npm install -g @specmatic/specmatic") - raise typer.Exit(1) - - # Step 1: Validate contracts - console.print("\n[bold cyan]Step 1: Validating contracts...[/bold cyan]") - validation_errors = [] - for feat_key, contract_path in contracts_to_verify: - try: - contract_data = load_openapi_contract(contract_path) - is_valid = validate_openapi_schema(contract_data) - if is_valid: - endpoints = count_endpoints(contract_data) - print_success(f"✓ {feat_key}: Valid ({endpoints} endpoints)") - else: - print_error(f"✗ {feat_key}: Invalid schema") - validation_errors.append(f"{feat_key}: Schema validation failed") - except Exception as e: - print_error(f"✗ {feat_key}: Error - {e!s}") - validation_errors.append(f"{feat_key}: {e!s}") - - if validation_errors: - console.print("\n[bold red]Validation Errors:[/bold red]") - for error in validation_errors[:10]: # Show first 10 errors - console.print(f" • {error}") - if len(validation_errors) > 10: - console.print(f" ... and {len(validation_errors) - 10} more errors") - record({"validation_errors": len(validation_errors), "validated": False}) - raise typer.Exit(1) - - record({"validated": True, "contracts_count": len(contracts_to_verify)}) - - # Step 2: Generate examples - console.print("\n[bold cyan]Step 2: Generating examples...[/bold cyan]") - import asyncio - - examples_generated = 0 - for feat_key, contract_path in contracts_to_verify: - try: - examples_dir = asyncio.run(generate_specmatic_examples(contract_path)) - if examples_dir.exists() and any(examples_dir.iterdir()): - examples_generated += 1 - print_success(f"✓ {feat_key}: Examples generated") - else: - print_warning(f"⚠ {feat_key}: No examples generated (schema may not have examples)") - except Exception as e: - print_warning(f"⚠ {feat_key}: Example generation failed - {e!s}") - - record({"examples_generated": examples_generated}) - - # Step 3: Start mock server and test (if not skipped) - if not skip_mock: - if len(contracts_to_verify) > 1: - console.print( - f"\n[yellow]Note: Multiple contracts found. Starting mock server for first contract: {contracts_to_verify[0][0]}[/yellow]" - ) - - feat_key, contract_path = contracts_to_verify[0] - console.print(f"\n[bold cyan]Step 3: Starting mock server for {feat_key}...[/bold cyan]") - - try: - mock_server = asyncio.run(create_mock_server(contract_path, port=port, strict_mode=False)) - print_success(f"✓ Mock server started at http://localhost:{port}") - - # Step 4: Run basic connectivity test - console.print("\n[bold cyan]Step 4: Testing connectivity...[/bold cyan]") - try: - import requests - - # Test health endpoint - health_url = f"http://localhost:{port}/actuator/health" - response = requests.get(health_url, timeout=5) - if response.status_code == 200: - print_success(f"✓ Health check passed: {response.json().get('status', 'OK')}") - record({"health_check": True}) - else: - print_warning(f"⚠ Health check returned: {response.status_code}") - record({"health_check": False, "health_status": response.status_code}) - except ImportError: - print_warning("⚠ 'requests' library not available - skipping connectivity test") - record({"health_check": None}) - except Exception as e: - print_warning(f"⚠ Connectivity test failed: {e!s}") - record({"health_check": False, "health_error": str(e)}) - - # Summary - console.print("\n[bold green]✓ Contract verification complete![/bold green]") - console.print("\n[bold]Summary:[/bold]") - console.print(f" • Contracts validated: {len(contracts_to_verify)}") - console.print(f" • Examples generated: {examples_generated}") - console.print(f" • Mock server: http://localhost:{port}") - console.print("\n[yellow]Press Ctrl+C to stop the mock server[/yellow]") - - # Keep running until interrupted - try: - import time - - while mock_server.is_running(): - time.sleep(1) - except KeyboardInterrupt: - console.print("\n[yellow]Stopping mock server...[/yellow]") - mock_server.stop() - print_success("✓ Mock server stopped") - except Exception as e: - print_error(f"✗ Failed to start mock server: {e!s}") - record({"mock_server": False, "mock_error": str(e)}) - raise typer.Exit(1) from e - else: - # Summary without mock server - console.print("\n[bold green]✓ Contract verification complete![/bold green]") - console.print("\n[bold]Summary:[/bold]") - console.print(f" • Contracts validated: {len(contracts_to_verify)}") - console.print(f" • Examples generated: {examples_generated}") - console.print(" • Mock server: Skipped (--skip-mock)") - record({"mock_server": False, "skipped": True}) - - -@app.command("test") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def test_contract( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - feature: str | None = typer.Option( - None, - "--feature", - help="Feature key (e.g., FEATURE-001). If not specified, generates tests for all contracts in bundle.", - ), - # Output/Results - output_dir: Path | None = typer.Option( - None, - "--output", - "--out", - help="Output directory for generated tests (default: bundle-specific .specfact/projects/<bundle-name>/tests/contracts/)", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Generate contract tests and examples from OpenAPI contract. - - **IMPORTANT**: This command generates test files and examples, but running the tests - requires a REAL API implementation. The generated tests validate that your API - matches the contract - they cannot test the contract itself. - - **What this command does:** - 1. Generates example request/response files from the contract schema - 2. Generates test files that can validate API implementations - 3. Prepares everything needed for contract testing - - **What you can do WITHOUT a real API:** - - ✅ Validate contract schema: `specfact contract validate` - - ✅ Start mock server: `specfact contract serve --examples` - - ✅ Generate examples: This command does this automatically - - **What REQUIRES a real API:** - - ❌ Running contract tests: `specmatic test --host <api-url>` - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --feature - - **Output/Results**: --output - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact contract test --bundle legacy-api --feature FEATURE-001 - specfact contract test --bundle legacy-api # Generates tests for all contracts - specfact contract test --bundle legacy-api --output tests/contracts/ - - **See**: [Contract Testing Workflow](../guides/contract-testing-workflow.md) for details. - """ - telemetry_metadata = { - "bundle": bundle, - "feature": feature, - } - - with telemetry.track_command("contract.test", telemetry_metadata) as record: - from specfact_cli.integrations.specmatic import check_specmatic_available - - print_section("SpecFact CLI - OpenAPI Contract Test Generation") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Determine which contracts to generate tests for - contracts_to_test: list[tuple[str, Path]] = [] - - if feature: - # Generate tests for specific feature contract - if feature not in bundle_obj.features: - print_error(f"Feature '{feature}' not found in bundle") - raise typer.Exit(1) - feature_obj = bundle_obj.features[feature] - if not feature_obj.contract: - print_error(f"Feature '{feature}' has no contract") - raise typer.Exit(1) - contract_path = bundle_dir / feature_obj.contract - if not contract_path.exists(): - print_error(f"Contract file not found: {contract_path}") - raise typer.Exit(1) - contracts_to_test = [(feature, contract_path)] - else: - # Generate tests for all contracts - for feature_key, feature_obj in bundle_obj.features.items(): - if feature_obj.contract: - contract_path = bundle_dir / feature_obj.contract - if contract_path.exists(): - contracts_to_test.append((feature_key, contract_path)) - - if not contracts_to_test: - print_warning("No contracts found to generate tests for") - raise typer.Exit(0) - - # Check if Specmatic is available (after checking contracts exist) - is_available, error_msg = check_specmatic_available() - if not is_available: - print_error(f"Specmatic not available: {error_msg}") - print_info("Install Specmatic: npm install -g @specmatic/specmatic") - raise typer.Exit(1) - - # Determine output directory (set default if not provided) - if output_dir is None: - output_dir = bundle_dir / "tests" / "contracts" - output_dir.mkdir(parents=True, exist_ok=True) - - # Generate tests using Specmatic - console.print("[bold cyan]Generating contract tests...[/bold cyan]") - # Resolve repo to absolute path for relative_to() to work - repo_resolved = repo.resolve() - try: - output_dir_display = output_dir.relative_to(repo_resolved) - except ValueError: - # If output_dir is not a subpath of repo, show absolute path - output_dir_display = output_dir - console.print(f" Output directory: {output_dir_display}") - console.print(f" Contracts: {len(contracts_to_test)}") - - import asyncio - - from specfact_cli.integrations.specmatic import generate_specmatic_tests - - generated_count = 0 - failed_count = 0 - - for feature_key, contract_path in contracts_to_test: - try: - # Create feature-specific output directory - feature_output_dir = output_dir / feature_key.lower() - feature_output_dir.mkdir(parents=True, exist_ok=True) - - # Step 1: Generate examples from contract (required for mock server and tests) - from specfact_cli.integrations.specmatic import generate_specmatic_examples - - examples_dir = contract_path.parent / f"{contract_path.stem}_examples" - console.print(f" [dim]Generating examples for {feature_key}...[/dim]") - try: - asyncio.run(generate_specmatic_examples(contract_path, examples_dir)) - console.print(f" [dim]✓ Examples generated: {examples_dir.name}[/dim]") - except Exception as e: - # Examples generation is optional - continue even if it fails - console.print(f" [yellow]⚠ Examples generation skipped: {e!s}[/yellow]") - - # Step 2: Generate tests (uses examples if available) - test_dir = asyncio.run(generate_specmatic_tests(contract_path, feature_output_dir)) - generated_count += 1 - try: - test_dir_display = test_dir.relative_to(repo_resolved) - except ValueError: - # If test_dir is not a subpath of repo, show absolute path - test_dir_display = test_dir - console.print(f" ✓ Generated tests for {feature_key}: {test_dir_display}") - except Exception as e: - failed_count += 1 - console.print(f" ✗ Failed to generate tests for {feature_key}: {e!s}") - - if generated_count > 0: - print_success(f"Generated {generated_count} test suite(s)") - if failed_count > 0: - print_warning(f"Failed to generate {failed_count} test suite(s)") - record({"generated": generated_count, "failed": failed_count}) - raise typer.Exit(1) - - record({"generated": generated_count, "failed": failed_count}) +__all__ = ["app"] diff --git a/src/specfact_cli/commands/drift.py b/src/specfact_cli/commands/drift.py index b34b93f9..b03f0c6f 100644 --- a/src/specfact_cli/commands/drift.py +++ b/src/specfact_cli/commands/drift.py @@ -1,246 +1,6 @@ -""" -Drift command - Detect misalignment between code and specifications. +"""Backward-compatible app shim. Implementation moved to modules/drift/.""" -This module provides commands for detecting drift between actual code/tests -and specifications. -""" +from specfact_cli.modules.drift.src.commands import app -from __future__ import annotations -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console - -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils import print_error, print_success - - -app = typer.Typer(help="Detect drift between code and specifications") -console = Console() - - -@app.command("detect") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def detect_drift( - # Target/Input - bundle: str | None = typer.Argument( - None, help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'" - ), - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Output - output_format: str = typer.Option( - "table", - "--format", - help="Output format: 'table' (rich table), 'json', or 'yaml'. Default: table", - ), - out: Path | None = typer.Option( - None, - "--out", - help="Output file path (for JSON/YAML format). Default: stdout", - ), -) -> None: - """ - Detect drift between code and specifications. - - Scans repository and project bundle to identify: - - Added code (files with no spec) - - Removed code (deleted but spec exists) - - Modified code (hash changed) - - Orphaned specs (spec with no code) - - Test coverage gaps (stories missing tests) - - Contract violations (implementation doesn't match contract) - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --repo - - **Output**: --format, --out - - **Examples:** - specfact drift detect legacy-api --repo . - specfact drift detect my-bundle --repo . --format json --out drift-report.json - """ - if is_debug_mode(): - debug_log_operation( - "command", "drift detect", "started", extra={"bundle": bundle, "repo": str(repo), "format": output_format} - ) - debug_print("[dim]drift detect: started[/dim]") - from rich.console import Console - - from specfact_cli.utils.structure import SpecFactStructure - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None: - if is_debug_mode(): - debug_log_operation( - "command", "drift detect", "failed", error="Bundle name required", extra={"reason": "no_bundle"} - ) - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - from specfact_cli.sync.drift_detector import DriftDetector - - repo_path = repo.resolve() - - telemetry_metadata = { - "bundle": bundle, - "output_format": output_format, - } - - with telemetry.track_command("drift.detect", telemetry_metadata) as record: - console.print(f"[bold cyan]Drift Detection:[/bold cyan] {bundle}") - console.print(f"[dim]Repository:[/dim] {repo_path}\n") - - detector = DriftDetector(bundle, repo_path) - report = detector.scan(bundle, repo_path) - - # Display report - if output_format == "table": - _display_drift_report_table(report) - elif output_format == "json": - import json - - output = json.dumps(report.__dict__, indent=2) - if out: - out.write_text(output, encoding="utf-8") - print_success(f"Report written to: {out}") - else: - console.print(output) - elif output_format == "yaml": - import yaml - - output = yaml.dump(report.__dict__, default_flow_style=False, sort_keys=False) - if out: - out.write_text(output, encoding="utf-8") - print_success(f"Report written to: {out}") - else: - console.print(output) - else: - if is_debug_mode(): - debug_log_operation( - "command", - "drift detect", - "failed", - error=f"Unknown format: {output_format}", - extra={"reason": "invalid_format"}, - ) - print_error(f"Unknown output format: {output_format}") - raise typer.Exit(1) - - # Summary - total_issues = ( - len(report.added_code) - + len(report.removed_code) - + len(report.modified_code) - + len(report.orphaned_specs) - + len(report.test_coverage_gaps) - + len(report.contract_violations) - ) - - if total_issues == 0: - print_success("No drift detected - code and specs are in sync!") - else: - console.print(f"\n[bold yellow]Total Issues:[/bold yellow] {total_issues}") - - record( - { - "added_code": len(report.added_code), - "removed_code": len(report.removed_code), - "modified_code": len(report.modified_code), - "orphaned_specs": len(report.orphaned_specs), - "test_coverage_gaps": len(report.test_coverage_gaps), - "contract_violations": len(report.contract_violations), - "total_issues": total_issues, - } - ) - if is_debug_mode(): - debug_log_operation( - "command", - "drift detect", - "success", - extra={"bundle": bundle, "total_issues": total_issues}, - ) - debug_print("[dim]drift detect: success[/dim]") - - -def _display_drift_report_table(report: Any) -> None: - """Display drift report as a rich table.""" - - console.print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") - console.print("[bold]Drift Detection Report[/bold]") - console.print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") - - # Added Code - if report.added_code: - console.print(f"[bold yellow]Added Code ({len(report.added_code)} files):[/bold yellow]") - for file_path in report.added_code[:10]: # Show first 10 - console.print(f" • {file_path} (no spec)") - if len(report.added_code) > 10: - console.print(f" ... and {len(report.added_code) - 10} more") - console.print() - - # Removed Code - if report.removed_code: - console.print(f"[bold yellow]Removed Code ({len(report.removed_code)} files):[/bold yellow]") - for file_path in report.removed_code[:10]: - console.print(f" • {file_path} (deleted but spec exists)") - if len(report.removed_code) > 10: - console.print(f" ... and {len(report.removed_code) - 10} more") - console.print() - - # Modified Code - if report.modified_code: - console.print(f"[bold yellow]Modified Code ({len(report.modified_code)} files):[/bold yellow]") - for file_path in report.modified_code[:10]: - console.print(f" • {file_path} (hash changed)") - if len(report.modified_code) > 10: - console.print(f" ... and {len(report.modified_code) - 10} more") - console.print() - - # Orphaned Specs - if report.orphaned_specs: - console.print(f"[bold yellow]Orphaned Specs ({len(report.orphaned_specs)} features):[/bold yellow]") - for feature_key in report.orphaned_specs[:10]: - console.print(f" • {feature_key} (no code)") - if len(report.orphaned_specs) > 10: - console.print(f" ... and {len(report.orphaned_specs) - 10} more") - console.print() - - # Test Coverage Gaps - if report.test_coverage_gaps: - console.print(f"[bold yellow]Test Coverage Gaps ({len(report.test_coverage_gaps)}):[/bold yellow]") - for feature_key, story_key in report.test_coverage_gaps[:10]: - console.print(f" • {feature_key}, {story_key} (no tests)") - if len(report.test_coverage_gaps) > 10: - console.print(f" ... and {len(report.test_coverage_gaps) - 10} more") - console.print() - - # Contract Violations - if report.contract_violations: - console.print(f"[bold yellow]Contract Violations ({len(report.contract_violations)}):[/bold yellow]") - for violation in report.contract_violations[:10]: - console.print(f" • {violation}") - if len(report.contract_violations) > 10: - console.print(f" ... and {len(report.contract_violations) - 10} more") - console.print() +__all__ = ["app"] diff --git a/src/specfact_cli/commands/enforce.py b/src/specfact_cli/commands/enforce.py index 9c9269a3..ba996f4d 100644 --- a/src/specfact_cli/commands/enforce.py +++ b/src/specfact_cli/commands/enforce.py @@ -1,609 +1,6 @@ -""" -Enforce command - Configure contract validation quality gates. +"""Backward-compatible app shim. Implementation moved to modules/enforce/.""" -This module provides commands for configuring enforcement modes -and validation policies. -""" +from specfact_cli.modules.enforce.src.commands import app -from __future__ import annotations -from datetime import datetime -from pathlib import Path - -import typer -from beartype import beartype -from icontract import require -from rich.console import Console -from rich.table import Table - -from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport -from specfact_cli.models.enforcement import EnforcementConfig, EnforcementPreset -from specfact_cli.models.sdd import SDDManifest -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils.structure import SpecFactStructure -from specfact_cli.utils.yaml_utils import dump_yaml - - -app = typer.Typer(help="Configure quality gates and enforcement modes") -console = Console() - - -@app.command("stage") -@beartype -def stage( - # Advanced/Configuration - preset: str = typer.Option( - "balanced", - "--preset", - help="Enforcement preset (minimal, balanced, strict)", - ), -) -> None: - """ - Set enforcement mode for contract validation. - - Modes: - - minimal: Log violations, never block - - balanced: Block HIGH severity, warn MEDIUM - - strict: Block all MEDIUM+ violations - - **Parameter Groups:** - - **Advanced/Configuration**: --preset - - **Examples:** - specfact enforce stage --preset balanced - specfact enforce stage --preset strict - specfact enforce stage --preset minimal - """ - if is_debug_mode(): - debug_log_operation("command", "enforce stage", "started", extra={"preset": preset}) - debug_print("[dim]enforce stage: started[/dim]") - telemetry_metadata = { - "preset": preset.lower(), - } - - with telemetry.track_command("enforce.stage", telemetry_metadata) as record: - # Validate preset (contract-style validation) - if not isinstance(preset, str) or len(preset) == 0: - console.print("[bold red]✗[/bold red] Preset must be non-empty string") - raise typer.Exit(1) - - if preset.lower() not in ("minimal", "balanced", "strict"): - if is_debug_mode(): - debug_log_operation( - "command", - "enforce stage", - "failed", - error=f"Unknown preset: {preset}", - extra={"reason": "invalid_preset"}, - ) - console.print(f"[bold red]✗[/bold red] Unknown preset: {preset}") - console.print("Valid presets: minimal, balanced, strict") - raise typer.Exit(1) - - console.print(f"[bold cyan]Setting enforcement mode:[/bold cyan] {preset}") - - # Validate preset enum - try: - preset_enum = EnforcementPreset(preset) - except ValueError as err: - if is_debug_mode(): - debug_log_operation( - "command", "enforce stage", "failed", error=str(err), extra={"reason": "invalid_preset"} - ) - console.print(f"[bold red]✗[/bold red] Unknown preset: {preset}") - console.print("Valid presets: minimal, balanced, strict") - raise typer.Exit(1) from err - - # Create enforcement configuration - config = EnforcementConfig.from_preset(preset_enum) - - # Display configuration as table - table = Table(title=f"Enforcement Mode: {preset.upper()}") - table.add_column("Severity", style="cyan") - table.add_column("Action", style="yellow") - - for severity, action in config.to_summary_dict().items(): - table.add_row(severity, action) - - console.print(table) - - # Ensure .specfact structure exists - SpecFactStructure.ensure_structure() - - # Write configuration to file - config_path = SpecFactStructure.get_enforcement_config_path() - config_path.parent.mkdir(parents=True, exist_ok=True) - - # Use mode='json' to convert enums to their string values - dump_yaml(config.model_dump(mode="json"), config_path) - - record({"config_saved": True, "enabled": config.enabled}) - if is_debug_mode(): - debug_log_operation( - "command", "enforce stage", "success", extra={"preset": preset, "config_path": str(config_path)} - ) - debug_print("[dim]enforce stage: success[/dim]") - - console.print(f"\n[bold green]✓[/bold green] Enforcement mode set to {preset}") - console.print(f"[dim]Configuration saved to: {config_path}[/dim]") - - -@app.command("sdd") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda sdd: sdd is None or isinstance(sdd, Path), "SDD must be None or Path") -@require( - lambda output_format: isinstance(output_format, str) and output_format.lower() in ("yaml", "json", "markdown"), - "Output format must be yaml, json, or markdown", -) -@require(lambda out: out is None or isinstance(out, Path), "Out must be None or Path") -def enforce_sdd( - # Target/Input - bundle: str | None = typer.Argument( - None, - help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", - ), - sdd: Path | None = typer.Option( - None, - "--sdd", - help="Path to SDD manifest. Default: bundle-specific .specfact/projects/<bundle-name>/sdd.<format>. No legacy root-level fallback.", - ), - # Output/Results - output_format: str = typer.Option( - "yaml", - "--output-format", - help="Output format (yaml, json, markdown). Default: yaml", - ), - out: Path | None = typer.Option( - None, - "--out", - help="Output file path. Default: bundle-specific .specfact/projects/<bundle-name>/reports/enforcement/report-<timestamp>.<format> (Phase 8.5)", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Validate SDD manifest against project bundle and contracts. - - Checks: - - SDD ↔ bundle hash match - - Coverage thresholds (contracts/story, invariants/feature, architecture facets) - - Frozen sections (hash mismatch detection) - - Contract density metrics - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --sdd - - **Output/Results**: --output-format, --out - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact enforce sdd legacy-api - specfact enforce sdd auth-module --output-format json --out validation-report.json - specfact enforce sdd legacy-api --no-interactive - """ - if is_debug_mode(): - debug_log_operation( - "command", "enforce sdd", "started", extra={"bundle": bundle, "output_format": output_format} - ) - debug_print("[dim]enforce sdd: started[/dim]") - from rich.console import Console - - from specfact_cli.models.sdd import SDDManifest - from specfact_cli.utils.structure import SpecFactStructure - from specfact_cli.utils.structured_io import StructuredFormat - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle is None: - if is_debug_mode(): - debug_log_operation( - "command", "enforce sdd", "failed", error="Bundle name required", extra={"reason": "no_bundle"} - ) - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - from specfact_cli.utils.structured_io import ( - dump_structured_file, - load_structured_file, - ) - - telemetry_metadata = { - "output_format": output_format.lower(), - "no_interactive": no_interactive, - } - - with telemetry.track_command("enforce.sdd", telemetry_metadata) as record: - console.print("\n[bold cyan]SpecFact CLI - SDD Validation[/bold cyan]") - console.print("=" * 60) - - # Find bundle directory - base_path = Path(".") - bundle_dir = SpecFactStructure.project_dir(base_path=base_path, bundle_name=bundle) - if not bundle_dir.exists(): - if is_debug_mode(): - debug_log_operation( - "command", - "enforce sdd", - "failed", - error=f"Bundle not found: {bundle_dir}", - extra={"reason": "bundle_missing"}, - ) - console.print(f"[bold red]✗[/bold red] Project bundle not found: {bundle_dir}") - console.print(f"[dim]Create one with: specfact plan init {bundle}[/dim]") - raise typer.Exit(1) - - # Find SDD manifest path using discovery utility - from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle - - base_path = Path(".") - discovered_sdd = find_sdd_for_bundle(bundle, base_path, sdd) - if discovered_sdd is None: - if is_debug_mode(): - debug_log_operation( - "command", - "enforce sdd", - "failed", - error="SDD manifest not found", - extra={"reason": "sdd_not_found", "bundle": bundle}, - ) - console.print("[bold red]✗[/bold red] SDD manifest not found") - console.print(f"[dim]Searched for: .specfact/projects/{bundle}/sdd.yaml (bundle-specific)[/dim]") - console.print(f"[dim]Create one with: specfact plan harden {bundle}[/dim]") - raise typer.Exit(1) - - sdd = discovered_sdd - console.print(f"[dim]Using SDD manifest: {sdd}[/dim]") - - try: - # Load SDD manifest - console.print(f"[dim]Loading SDD manifest: {sdd}[/dim]") - sdd_data = load_structured_file(sdd) - sdd_manifest = SDDManifest.model_validate(sdd_data) - - # Load project bundle with progress indicator - - from specfact_cli.utils.progress import load_bundle_with_progress - - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - console.print("[dim]Computing hash...[/dim]") - - summary = project_bundle.compute_summary(include_hash=True) - project_hash = summary.content_hash - - if not project_hash: - if is_debug_mode(): - debug_log_operation( - "command", - "enforce sdd", - "failed", - error="Failed to compute project bundle hash", - extra={"reason": "hash_compute_failed"}, - ) - console.print("[bold red]✗[/bold red] Failed to compute project bundle hash") - raise typer.Exit(1) - - # Convert to PlanBundle for compatibility with validation functions - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Create validation report - report = ValidationReport() - - # 1. Validate hash match - console.print("\n[cyan]Validating hash match...[/cyan]") - if sdd_manifest.plan_bundle_hash != project_hash: - deviation = Deviation( - type=DeviationType.HASH_MISMATCH, - severity=DeviationSeverity.HIGH, - description=f"SDD bundle hash mismatch: expected {project_hash[:16]}..., got {sdd_manifest.plan_bundle_hash[:16]}...", - location=str(sdd), - fix_hint=f"Run 'specfact plan harden {bundle}' to update SDD manifest with current bundle hash", - ) - report.add_deviation(deviation) - console.print("[bold red]✗[/bold red] Hash mismatch detected") - else: - console.print("[bold green]✓[/bold green] Hash match verified") - - # 2. Validate coverage thresholds using contract validator - console.print("\n[cyan]Validating coverage thresholds...[/cyan]") - - from specfact_cli.validators.contract_validator import calculate_contract_density, validate_contract_density - - # Calculate contract density metrics - metrics = calculate_contract_density(sdd_manifest, plan_bundle) - - # Validate against thresholds - density_deviations = validate_contract_density(sdd_manifest, plan_bundle, metrics) - - # Add deviations to report - for deviation in density_deviations: - report.add_deviation(deviation) - - # Display metrics with status indicators - thresholds = sdd_manifest.coverage_thresholds - - # Contracts per story - if metrics.contracts_per_story < thresholds.contracts_per_story: - console.print( - f"[bold yellow]⚠[/bold yellow] Contracts/story: {metrics.contracts_per_story:.2f} (threshold: {thresholds.contracts_per_story})" - ) - else: - console.print( - f"[bold green]✓[/bold green] Contracts/story: {metrics.contracts_per_story:.2f} (threshold: {thresholds.contracts_per_story})" - ) - - # Invariants per feature - if metrics.invariants_per_feature < thresholds.invariants_per_feature: - console.print( - f"[bold yellow]⚠[/bold yellow] Invariants/feature: {metrics.invariants_per_feature:.2f} (threshold: {thresholds.invariants_per_feature})" - ) - else: - console.print( - f"[bold green]✓[/bold green] Invariants/feature: {metrics.invariants_per_feature:.2f} (threshold: {thresholds.invariants_per_feature})" - ) - - # Architecture facets - if metrics.architecture_facets < thresholds.architecture_facets: - console.print( - f"[bold yellow]⚠[/bold yellow] Architecture facets: {metrics.architecture_facets} (threshold: {thresholds.architecture_facets})" - ) - else: - console.print( - f"[bold green]✓[/bold green] Architecture facets: {metrics.architecture_facets} (threshold: {thresholds.architecture_facets})" - ) - - # OpenAPI contract coverage - if metrics.openapi_coverage_percent < thresholds.openapi_coverage_percent: - console.print( - f"[bold yellow]⚠[/bold yellow] OpenAPI coverage: {metrics.openapi_coverage_percent:.1f}% (threshold: {thresholds.openapi_coverage_percent}%)" - ) - else: - console.print( - f"[bold green]✓[/bold green] OpenAPI coverage: {metrics.openapi_coverage_percent:.1f}% (threshold: {thresholds.openapi_coverage_percent}%)" - ) - - # 3. Validate frozen sections (placeholder - hash comparison would require storing section hashes) - if sdd_manifest.frozen_sections: - console.print("\n[cyan]Checking frozen sections...[/cyan]") - console.print(f"[dim]Frozen sections: {len(sdd_manifest.frozen_sections)}[/dim]") - # TODO: Implement hash-based frozen section validation in Phase 6 - - # 4. Validate OpenAPI/AsyncAPI contracts referenced in bundle with Specmatic - console.print("\n[cyan]Validating API contracts with Specmatic...[/cyan]") - import asyncio - - from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic - - is_available, error_msg = check_specmatic_available() - if not is_available: - console.print(f"[dim]💡 Tip: Install Specmatic to validate API contracts: {error_msg}[/dim]") - else: - # Validate contracts referenced in bundle features - # PlanBundle.features is a list, not a dict - contract_files = [] - features_iter = ( - plan_bundle.features.values() if isinstance(plan_bundle.features, dict) else plan_bundle.features - ) - for feature in features_iter: - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - contract_files.append((contract_path, feature.key)) - - if contract_files: - console.print(f"[dim]Found {len(contract_files)} contract(s) referenced in bundle[/dim]") - for contract_path, feature_key in contract_files[:5]: # Validate up to 5 contracts - console.print( - f"[dim]Validating {contract_path.relative_to(bundle_dir)} (from {feature_key})...[/dim]" - ) - try: - result = asyncio.run(validate_spec_with_specmatic(contract_path)) - if not result.is_valid: - deviation = Deviation( - type=DeviationType.CONTRACT_VIOLATION, - severity=DeviationSeverity.MEDIUM, - description=f"API contract validation failed: {contract_path.name} (feature: {feature_key})", - location=str(contract_path), - fix_hint=f"Run 'specfact spec validate {contract_path}' to see detailed errors", - ) - report.add_deviation(deviation) - console.print( - f" [bold yellow]⚠[/bold yellow] {contract_path.name} has validation issues" - ) - if result.errors: - for error in result.errors[:2]: - console.print(f" - {error}") - else: - console.print(f" [bold green]✓[/bold green] {contract_path.name} is valid") - except Exception as e: - console.print(f" [bold yellow]⚠[/bold yellow] Validation error: {e!s}") - deviation = Deviation( - type=DeviationType.CONTRACT_VIOLATION, - severity=DeviationSeverity.LOW, - description=f"API contract validation error: {contract_path.name} - {e!s}", - location=str(contract_path), - fix_hint=f"Run 'specfact spec validate {contract_path}' to diagnose", - ) - report.add_deviation(deviation) - if len(contract_files) > 5: - console.print( - f"[dim]... and {len(contract_files) - 5} more contract(s) (run 'specfact spec validate' to validate all)[/dim]" - ) - else: - console.print("[dim]No API contracts found in bundle[/dim]") - - # Generate output report (Phase 8.5: bundle-specific location) - output_format_str = output_format.lower() - if out is None: - # Use bundle-specific enforcement report path - extension = "md" if output_format_str == "markdown" else output_format_str - out = SpecFactStructure.get_bundle_enforcement_report_path(bundle_name=bundle, base_path=base_path) - # Update extension if needed - if extension != "yaml" and out.suffix != f".{extension}": - out = out.with_suffix(f".{extension}") - - # Save report - if output_format_str == "markdown": - _save_markdown_report(out, report, sdd_manifest, bundle, project_hash) - elif output_format_str == "json": - dump_structured_file(report.model_dump(mode="json"), out, StructuredFormat.JSON) - else: # yaml - dump_structured_file(report.model_dump(mode="json"), out, StructuredFormat.YAML) - - # Display summary - console.print("\n[bold cyan]Validation Summary[/bold cyan]") - console.print("=" * 60) - console.print(f"Total deviations: {report.total_deviations}") - console.print(f" High: {report.high_count}") - console.print(f" Medium: {report.medium_count}") - console.print(f" Low: {report.low_count}") - console.print(f"\nReport saved to: {out}") - - # Exit with appropriate code and clear error messages - if not report.passed: - console.print("\n[bold red]✗[/bold red] SDD validation failed") - console.print("\n[bold yellow]Issues Found:[/bold yellow]") - - # Group deviations by type for clearer messaging - hash_mismatches = [d for d in report.deviations if d.type == DeviationType.HASH_MISMATCH] - coverage_issues = [d for d in report.deviations if d.type == DeviationType.COVERAGE_THRESHOLD] - - if hash_mismatches: - console.print("\n[bold red]1. Hash Mismatch (HIGH)[/bold red]") - console.print(" The project bundle has been modified since the SDD manifest was created.") - console.print(f" [dim]SDD hash: {sdd_manifest.plan_bundle_hash[:16]}...[/dim]") - console.print(f" [dim]Bundle hash: {project_hash[:16]}...[/dim]") - console.print("\n [bold]Why this happens:[/bold]") - console.print(" The hash changes when you modify:") - console.print(" - Features (add/remove/update)") - console.print(" - Stories (add/remove/update)") - console.print(" - Product, idea, business, or clarifications") - console.print( - f"\n [bold]Fix:[/bold] Run [cyan]specfact plan harden {bundle}[/cyan] to update the SDD manifest" - ) - console.print( - " [dim]This updates the SDD with the current bundle hash and regenerates HOW sections[/dim]" - ) - - if coverage_issues: - console.print("\n[bold yellow]2. Coverage Thresholds Not Met (MEDIUM)[/bold yellow]") - console.print(" Contract density metrics are below required thresholds:") - console.print( - f" - Contracts/story: {metrics.contracts_per_story:.2f} (required: {thresholds.contracts_per_story})" - ) - console.print( - f" - Invariants/feature: {metrics.invariants_per_feature:.2f} (required: {thresholds.invariants_per_feature})" - ) - console.print("\n [bold]Fix:[/bold] Add more contracts to stories and invariants to features") - console.print(" [dim]Tip: Use 'specfact plan review' to identify areas needing contracts[/dim]") - - console.print("\n[bold cyan]Next Steps:[/bold cyan]") - if hash_mismatches: - console.print(f" 1. Update SDD: [cyan]specfact plan harden {bundle}[/cyan]") - if coverage_issues: - console.print(" 2. Add contracts: Review features and add @icontract decorators") - console.print(" 3. Re-validate: Run this command again after fixes") - - if is_debug_mode(): - debug_log_operation( - "command", - "enforce sdd", - "failed", - error="SDD validation failed", - extra={"reason": "deviations", "total_deviations": report.total_deviations}, - ) - record({"passed": False, "deviations": report.total_deviations}) - raise typer.Exit(1) - - console.print("\n[bold green]✓[/bold green] SDD validation passed") - record({"passed": True, "deviations": 0}) - if is_debug_mode(): - debug_log_operation( - "command", "enforce sdd", "success", extra={"bundle": bundle, "report_path": str(out)} - ) - debug_print("[dim]enforce sdd: success[/dim]") - - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", "enforce sdd", "failed", error=str(e), extra={"reason": type(e).__name__} - ) - console.print(f"[bold red]✗[/bold red] Validation failed: {e}") - raise typer.Exit(1) from e - - -def _find_plan_path(plan: Path | None) -> Path | None: - """ - Find plan path (default, latest, or provided). - - Args: - plan: Provided plan path or None - - Returns: - Plan path or None if not found - """ - if plan is not None: - return plan - - # Try to find active plan or latest - default_plan = SpecFactStructure.get_default_plan_path() - if default_plan.exists(): - return default_plan - - # Find latest plan bundle - base_path = Path(".") - plans_dir = base_path / SpecFactStructure.PLANS - if plans_dir.exists(): - plan_files = [ - p - for p in plans_dir.glob("*.bundle.*") - if any(str(p).endswith(suffix) for suffix in SpecFactStructure.PLAN_SUFFIXES) - ] - plan_files = sorted(plan_files, key=lambda p: p.stat().st_mtime, reverse=True) - if plan_files: - return plan_files[0] - return None - - -def _save_markdown_report( - out: Path, - report: ValidationReport, - sdd_manifest: SDDManifest, - bundle, # type: ignore[type-arg] - plan_hash: str, -) -> None: - """Save validation report in Markdown format.""" - with open(out, "w") as f: - f.write("# SDD Validation Report\n\n") - f.write(f"**Generated**: {datetime.now().isoformat()}\n\n") - f.write(f"**SDD Manifest**: {sdd_manifest.plan_bundle_id}\n") - f.write(f"**Plan Bundle Hash**: {plan_hash[:32]}...\n\n") - - f.write("## Summary\n\n") - f.write(f"- **Total Deviations**: {report.total_deviations}\n") - f.write(f"- **High**: {report.high_count}\n") - f.write(f"- **Medium**: {report.medium_count}\n") - f.write(f"- **Low**: {report.low_count}\n") - f.write(f"- **Status**: {'✅ PASSED' if report.passed else '❌ FAILED'}\n\n") - - if report.deviations: - f.write("## Deviations\n\n") - for i, deviation in enumerate(report.deviations, 1): - f.write(f"### {i}. {deviation.type.value} ({deviation.severity.value})\n\n") - f.write(f"{deviation.description}\n\n") - if deviation.fix_hint: - f.write(f"**Fix**: {deviation.fix_hint}\n\n") +__all__ = ["app"] diff --git a/src/specfact_cli/commands/generate.py b/src/specfact_cli/commands/generate.py index e8ac150b..d97e5cd9 100644 --- a/src/specfact_cli/commands/generate.py +++ b/src/specfact_cli/commands/generate.py @@ -1,2121 +1,6 @@ -"""Generate command - Generate artifacts from SDD and plans. +"""Backward-compatible app shim. Implementation moved to modules/generate/.""" -This module provides commands for generating contract stubs, CrossHair harnesses, -and other artifacts from SDD manifests and plan bundles. -""" +from specfact_cli.modules.generate.src.commands import app -from __future__ import annotations -from pathlib import Path - -import typer -from beartype import beartype -from icontract import ensure, require - -from specfact_cli.generators.contract_generator import ContractGenerator -from specfact_cli.migrations.plan_migrator import load_plan_bundle -from specfact_cli.models.sdd import SDDManifest -from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils import print_error, print_info, print_success, print_warning -from specfact_cli.utils.env_manager import ( - build_tool_command, - detect_env_manager, - detect_source_directories, - find_test_files_for_source, -) -from specfact_cli.utils.optional_deps import check_cli_tool_available -from specfact_cli.utils.structured_io import load_structured_file - - -app = typer.Typer(help="Generate artifacts from SDD and plans") -console = get_configured_console() - - -def _show_apply_help() -> None: - """Show helpful error message for missing --apply option.""" - print_error("Missing required option: --apply") - console.print("\n[yellow]Available contract types:[/yellow]") - console.print(" - all-contracts (apply all available contract types)") - console.print(" - beartype (type checking decorators)") - console.print(" - icontract (pre/post condition decorators)") - console.print(" - crosshair (property-based test functions)") - console.print("\n[yellow]Examples:[/yellow]") - console.print(" specfact generate contracts-prompt src/file.py --apply all-contracts") - console.print(" specfact generate contracts-prompt src/file.py --apply beartype,icontract") - console.print(" specfact generate contracts-prompt --bundle my-bundle --apply all-contracts") - console.print("\n[dim]Use 'specfact generate contracts-prompt --help' for full documentation.[/dim]") - - -@app.command("contracts") -@beartype -@require(lambda sdd: sdd is None or isinstance(sdd, Path), "SDD must be None or Path") -@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -@require(lambda repo: repo is None or isinstance(repo, Path), "Repository path must be None or Path") -@ensure(lambda result: result is None, "Must return None") -def generate_contracts( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If specified, uses bundle instead of --plan/--sdd paths. Default: auto-detect from current directory.", - ), - sdd: Path | None = typer.Option( - None, - "--sdd", - help="Path to SDD manifest. Default: bundle-specific .specfact/projects/<bundle-name>/sdd.yaml when --bundle is provided. No legacy root-level fallback.", - ), - plan: Path | None = typer.Option( - None, - "--plan", - help="Path to plan bundle. Default: .specfact/projects/<bundle-name>/ if --bundle specified, else active plan. Ignored if --bundle is specified.", - ), - repo: Path | None = typer.Option( - None, - "--repo", - help="Repository path. Default: current directory (.)", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Generate contract stubs from SDD HOW sections. - - Parses SDD manifest HOW section (invariants, contracts) and generates - contract stub files with icontract decorators, beartype type checks, - and CrossHair harness templates. - - Generated files are saved to `.specfact/projects/<bundle-name>/contracts/` when --bundle is specified. - - **Parameter Groups:** - - **Target/Input**: --bundle, --sdd, --plan, --repo - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact generate contracts --bundle legacy-api - specfact generate contracts --bundle legacy-api --no-interactive - """ - - telemetry_metadata = { - "no_interactive": no_interactive, - } - - if is_debug_mode(): - debug_log_operation( - "command", "generate contracts", "started", extra={"bundle": bundle, "repo": str(repo or ".")} - ) - debug_print("[dim]generate contracts: started[/dim]") - - with telemetry.track_command("generate.contracts", telemetry_metadata) as record: - try: - # Determine repository path - base_path = Path(".").resolve() if repo is None else Path(repo).resolve() - - # Import here to avoid circular imports - from specfact_cli.utils.bundle_loader import BundleFormat, detect_bundle_format - from specfact_cli.utils.progress import load_bundle_with_progress - from specfact_cli.utils.structure import SpecFactStructure - - # Initialize bundle_dir and paths - bundle_dir: Path | None = None - plan_path: Path | None = None - sdd_path: Path | None = None - - # If --bundle is specified, use bundle-based paths - if bundle: - bundle_dir = SpecFactStructure.project_dir(base_path=base_path, bundle_name=bundle) - if not bundle_dir.exists(): - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts", - "failed", - error=f"Project bundle not found: {bundle_dir}", - extra={"reason": "bundle_not_found", "bundle": bundle}, - ) - print_error(f"Project bundle not found: {bundle_dir}") - print_info(f"Create one with: specfact plan init {bundle}") - raise typer.Exit(1) - - plan_path = bundle_dir - from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle - - sdd_path = find_sdd_for_bundle(bundle, base_path) - else: - # Use --plan and --sdd paths if provided - if plan is None: - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts", - "failed", - error="Bundle or plan path is required", - extra={"reason": "no_plan_or_bundle"}, - ) - print_error("Bundle or plan path is required") - print_info("Run 'specfact plan init <bundle-name>' then rerun with --bundle <name>") - raise typer.Exit(1) - plan_path = Path(plan).resolve() - - if not plan_path.exists(): - print_error(f"Plan bundle not found: {plan_path}") - raise typer.Exit(1) - - # Normalize base_path to repository root when a bundle directory is provided - if plan_path.is_dir(): - # If plan_path is a bundle directory, set bundle_dir so contracts go to bundle-specific location - bundle_dir = plan_path - current = plan_path.resolve() - while current != current.parent: - if current.name == ".specfact": - base_path = current.parent - break - current = current.parent - - # Determine SDD path based on bundle format - if sdd is None: - format_type, _ = detect_bundle_format(plan_path) - if format_type != BundleFormat.MODULAR: - print_error("Legacy monolithic bundles are not supported by this command.") - print_info("Migrate to the new structure with: specfact migrate artifacts --repo .") - raise typer.Exit(1) - - if plan_path.is_dir(): - bundle_name = plan_path.name - # Prefer bundle-local SDD when present - candidate_sdd = plan_path / "sdd.yaml" - sdd_path = candidate_sdd if candidate_sdd.exists() else None - else: - bundle_name = plan_path.parent.name if plan_path.parent.name != "projects" else plan_path.stem - - from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle - - if sdd_path is None: - sdd_path = find_sdd_for_bundle(bundle_name, base_path) - # Direct bundle-dir check as a safety net - direct_sdd = plan_path / "sdd.yaml" - if direct_sdd.exists(): - sdd_path = direct_sdd - else: - sdd_path = Path(sdd).resolve() - - if sdd_path is None or not sdd_path.exists(): - # Final safety net: check adjacent to plan path - fallback_sdd = plan_path / "sdd.yaml" if plan_path.is_dir() else plan_path.parent / "sdd.yaml" - if fallback_sdd.exists(): - sdd_path = fallback_sdd - else: - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts", - "failed", - error=f"SDD manifest not found: {sdd_path}", - extra={"reason": "sdd_not_found"}, - ) - print_error(f"SDD manifest not found: {sdd_path}") - print_info("Run 'specfact plan harden' to create SDD manifest") - raise typer.Exit(1) - - # Load SDD manifest - print_info(f"Loading SDD manifest: {sdd_path}") - sdd_data = load_structured_file(sdd_path) - sdd_manifest = SDDManifest(**sdd_data) - - # Align base_path with plan path when a bundle directory is provided - if bundle_dir is None and plan_path.is_dir(): - parts = plan_path.resolve().parts - if ".specfact" in parts: - spec_idx = parts.index(".specfact") - base_path = Path(*parts[:spec_idx]) if spec_idx > 0 else Path(".").resolve() - - # Load plan bundle (handle both modular and monolithic formats) - print_info(f"Loading plan bundle: {plan_path}") - format_type, _ = detect_bundle_format(plan_path) - - plan_hash = None - if format_type == BundleFormat.MODULAR or bundle: - # Load modular ProjectBundle and convert to PlanBundle for compatibility - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - - project_bundle = load_bundle_with_progress(plan_path, validate_hashes=False, console_instance=console) - - # Compute hash from ProjectBundle (same way as plan harden does) - summary = project_bundle.compute_summary(include_hash=True) - plan_hash = summary.content_hash - - # Convert to PlanBundle for ContractGenerator compatibility - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - else: - # Load monolithic PlanBundle - plan_bundle = load_plan_bundle(plan_path) - - # Compute hash from PlanBundle - plan_bundle.update_summary(include_hash=True) - plan_hash = ( - plan_bundle.metadata.summary.content_hash - if plan_bundle.metadata and plan_bundle.metadata.summary - else None - ) - - if not plan_hash: - print_error("Failed to compute plan bundle hash") - raise typer.Exit(1) - - # Verify hash match (SDD uses plan_bundle_hash field) - if sdd_manifest.plan_bundle_hash != plan_hash: - print_error("SDD manifest hash does not match plan bundle hash") - print_info("Run 'specfact plan harden' to update SDD manifest") - raise typer.Exit(1) - - # Determine contracts directory based on bundle - # For bundle-based generation, save contracts inside project bundle directory - # Legacy mode uses global contracts directory - contracts_dir = ( - bundle_dir / "contracts" if bundle_dir is not None else base_path / SpecFactStructure.ROOT / "contracts" - ) - - # Ensure we have at least one feature to anchor generation; if plan has none - # but SDD carries contracts/invariants, create a synthetic feature to generate stubs. - if not plan_bundle.features and (sdd_manifest.how.contracts or sdd_manifest.how.invariants): - from specfact_cli.models.plan import Feature - - plan_bundle.features.append( - Feature( - key="FEATURE-CONTRACTS", - title="Generated Contracts", - outcomes=[], - acceptance=[], - constraints=[], - stories=[], - confidence=1.0, - draft=True, - source_tracking=None, - contract=None, - protocol=None, - ) - ) - - # Generate contracts - print_info("Generating contract stubs from SDD HOW sections...") - generator = ContractGenerator() - result = generator.generate_contracts(sdd_manifest, plan_bundle, base_path, contracts_dir=contracts_dir) - - # Display results - if result["errors"]: - print_error(f"Errors during generation: {len(result['errors'])}") - for error in result["errors"]: - print_error(f" - {error}") - - if result["generated_files"]: - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts", - "success", - extra={ - "generated_files": len(result["generated_files"]), - "contracts_dir": str(contracts_dir), - }, - ) - debug_print("[dim]generate contracts: success[/dim]") - print_success(f"Generated {len(result['generated_files'])} contract file(s):") - for file_path in result["generated_files"]: - print_info(f" - {file_path}") - - # Display statistics - total_contracts = sum(result["contracts_per_story"].values()) - total_invariants = sum(result["invariants_per_feature"].values()) - print_info(f"Total contracts: {total_contracts}") - print_info(f"Total invariants: {total_invariants}") - - # Check coverage thresholds - if sdd_manifest.coverage_thresholds: - thresholds = sdd_manifest.coverage_thresholds - avg_contracts_per_story = ( - total_contracts / len(result["contracts_per_story"]) if result["contracts_per_story"] else 0.0 - ) - avg_invariants_per_feature = ( - total_invariants / len(result["invariants_per_feature"]) - if result["invariants_per_feature"] - else 0.0 - ) - - if avg_contracts_per_story < thresholds.contracts_per_story: - print_error( - f"Contract coverage below threshold: {avg_contracts_per_story:.2f} < {thresholds.contracts_per_story}" - ) - else: - print_success( - f"Contract coverage meets threshold: {avg_contracts_per_story:.2f} >= {thresholds.contracts_per_story}" - ) - - if avg_invariants_per_feature < thresholds.invariants_per_feature: - print_error( - f"Invariant coverage below threshold: {avg_invariants_per_feature:.2f} < {thresholds.invariants_per_feature}" - ) - else: - print_success( - f"Invariant coverage meets threshold: {avg_invariants_per_feature:.2f} >= {thresholds.invariants_per_feature}" - ) - - record( - { - "generated_files": len(result["generated_files"]), - "total_contracts": total_contracts, - "total_invariants": total_invariants, - } - ) - else: - print_warning("No contract files generated (no contracts/invariants found in SDD HOW section)") - - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts", - "failed", - error=str(e), - extra={"reason": type(e).__name__}, - ) - print_error(f"Failed to generate contracts: {e}") - record({"error": str(e)}) - raise typer.Exit(1) from e - - -@app.command("contracts-prompt") -@beartype -@require(lambda file: file is None or isinstance(file, Path), "File path must be None or Path") -@require(lambda apply: apply is None or isinstance(apply, str), "Apply must be None or string") -@ensure(lambda result: result is None, "Must return None") -def generate_contracts_prompt( - # Target/Input - file: Path | None = typer.Argument( - None, - help="Path to file to enhance (optional if --bundle provided)", - exists=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If provided, selects files from bundle. Default: active plan from 'specfact plan select'", - ), - apply: str = typer.Option( - ..., - "--apply", - help="Contracts to apply: 'all-contracts', 'beartype', 'icontract', 'crosshair', or comma-separated list (e.g., 'beartype,icontract')", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", - ), - # Output - output: Path | None = typer.Option( - None, - "--output", - help=("Output file path (currently unused, prompt saved to .specfact/prompts/)"), - hidden=True, # Hidden by default, shown with --help-advanced - ), -) -> None: - """ - Generate AI IDE prompt for adding contracts to existing code. - - Creates a structured prompt file that you can use with your AI IDE (Cursor, CoPilot, etc.) - to add beartype, icontract, or CrossHair contracts to existing code files. The CLI generates - the prompt, your AI IDE's LLM applies the contracts. - - **How It Works:** - 1. CLI reads the file and generates a structured prompt - 2. Prompt is saved to `.specfact/prompts/enhance-<filename>-<contracts>.md` - 3. You copy the prompt to your AI IDE (Cursor, CoPilot, etc.) - 4. AI IDE provides enhanced code (does NOT modify file directly) - 5. You validate the enhanced code with SpecFact CLI - 6. If validation passes, you apply the changes to the file - 7. Run tests and commit - - **Why This Approach:** - - Uses your existing AI IDE infrastructure (no separate LLM API setup) - - No additional API costs (leverages IDE's native LLM) - - You maintain control (review before committing) - - Works with any AI IDE (Cursor, CoPilot, Claude, etc.) - - **Parameter Groups:** - - **Target/Input**: file (optional if --bundle provided), --bundle, --apply - - **Behavior/Options**: --no-interactive - - **Output**: --output (currently unused, prompt is saved to .specfact/prompts/) - - **Examples:** - specfact generate contracts-prompt src/auth/login.py --apply beartype,icontract - specfact generate contracts-prompt --bundle legacy-api --apply beartype - specfact generate contracts-prompt --bundle legacy-api --apply beartype,icontract # Interactive selection - specfact generate contracts-prompt --bundle legacy-api --apply beartype --no-interactive # Process all files in bundle - - **Complete Workflow:** - 1. Generate prompt: specfact generate contracts-prompt --bundle legacy-api --apply all-contracts - 2. Select file(s) from interactive list (if multiple) - 3. Open prompt file: .specfact/prompts/enhance-<filename>-beartype-icontract-crosshair.md - 4. Copy prompt to your AI IDE (Cursor, CoPilot, etc.) - 5. AI IDE reads the file and provides enhanced code (does NOT modify file directly) - 6. AI IDE writes enhanced code to temporary file: enhanced_<filename>.py - 7. AI IDE runs validation: specfact generate contracts-apply enhanced_<filename>.py --original <original-file> - 8. If validation fails, AI IDE fixes issues and re-validates (up to 3 attempts) - 9. If validation succeeds, CLI applies changes automatically - 10. Verify contract coverage: specfact analyze contracts --bundle legacy-api - 11. Run your test suite: pytest (or your project's test command) - 12. Commit the enhanced code - """ - from rich.prompt import Prompt - from rich.table import Table - - from specfact_cli.utils.progress import load_bundle_with_progress - from specfact_cli.utils.structure import SpecFactStructure - - repo_path = Path(".").resolve() - - # Validate inputs first - if apply is None: - print_error("Missing required option: --apply") - console.print("\n[yellow]Available contract types:[/yellow]") - console.print(" - all-contracts (apply all available contract types)") - console.print(" - beartype (type checking decorators)") - console.print(" - icontract (pre/post condition decorators)") - console.print(" - crosshair (property-based test functions)") - console.print("\n[yellow]Examples:[/yellow]") - console.print(" specfact generate contracts-prompt src/file.py --apply all-contracts") - console.print(" specfact generate contracts-prompt src/file.py --apply beartype,icontract") - console.print(" specfact generate contracts-prompt --bundle my-bundle --apply all-contracts") - console.print("\n[dim]Use 'specfact generate contracts-prompt --help' for full documentation.[/dim]") - raise typer.Exit(1) - - if not file and not bundle: - print_error("Either file path or --bundle must be provided") - raise typer.Exit(1) - - # Use active plan as default if bundle not provided (but only if no file specified) - if bundle is None and not file: - bundle = SpecFactStructure.get_active_bundle_name(repo_path) - if bundle: - console.print(f"[dim]Using active plan: {bundle}[/dim]") - else: - print_error("No file specified and no active plan found. Please provide --bundle or a file path.") - raise typer.Exit(1) - - # Determine bundle directory for saving artifacts (only if needed) - bundle_dir: Path | None = None - - # Determine which files to process - file_paths: list[Path] = [] - - if file: - # Direct file path provided - no need to load bundle for file selection - file_paths = [file.resolve()] - # Only determine bundle_dir for saving prompts in the right location - if bundle: - # Bundle explicitly provided - use it for prompt storage location - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - else: - # Use active bundle if available for prompt storage location (no need to load bundle) - active_bundle = SpecFactStructure.get_active_bundle_name(repo_path) - if active_bundle: - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=active_bundle) - bundle = active_bundle - # If no active bundle, prompts will be saved to .specfact/prompts/ (fallback) - elif bundle: - # Bundle provided but no file - need to load bundle to get files - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - # Load files from bundle - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - for _feature_key, feature in project_bundle.features.items(): - if not feature.source_tracking: - continue - - for impl_file in feature.source_tracking.implementation_files: - file_path = repo_path / impl_file - if file_path.exists(): - file_paths.append(file_path) - - if not file_paths: - print_error("No implementation files found in bundle") - raise typer.Exit(1) - - # Warn if processing all files automatically - if len(file_paths) > 1 and no_interactive: - console.print( - f"[yellow]Note:[/yellow] Processing all {len(file_paths)} files from bundle '{bundle}' (--no-interactive mode)" - ) - - # If multiple files and not in non-interactive mode, show selection - if len(file_paths) > 1 and not no_interactive: - console.print(f"\n[bold]Found {len(file_paths)} files in bundle '{bundle}':[/bold]\n") - table = Table(show_header=True, header_style="bold cyan") - table.add_column("#", style="bold yellow", justify="right", width=4) - table.add_column("File Path", style="dim") - - for i, fp in enumerate(file_paths, 1): - table.add_row(str(i), str(fp.relative_to(repo_path))) - - console.print(table) - console.print() - - selection = Prompt.ask( - f"Select file(s) to enhance (1-{len(file_paths)}, comma-separated, 'all', or 'q' to quit)" - ).strip() - - if selection.lower() in ("q", "quit", ""): - print_info("Cancelled") - raise typer.Exit(0) - - if selection.lower() == "all": - # Process all files - pass - else: - # Parse selection - try: - indices = [int(s.strip()) - 1 for s in selection.split(",")] - selected_files = [file_paths[i] for i in indices if 0 <= i < len(file_paths)] - if not selected_files: - print_error("Invalid selection") - raise typer.Exit(1) - file_paths = selected_files - except (ValueError, IndexError) as e: - print_error("Invalid selection format. Use numbers separated by commas (e.g., 1,3,5)") - raise typer.Exit(1) from e - - contracts_to_apply = [c.strip() for c in apply.split(",")] - valid_contracts = {"beartype", "icontract", "crosshair"} - # Define canonical order for consistent filenames - contract_order = ["beartype", "icontract", "crosshair"] - - # Handle "all-contracts" flag - if "all-contracts" in contracts_to_apply: - if len(contracts_to_apply) > 1: - print_error( - "Cannot use 'all-contracts' with other contract types. Use 'all-contracts' alone or specify individual types." - ) - raise typer.Exit(1) - contracts_to_apply = contract_order.copy() - console.print(f"[dim]Applying all available contracts: {', '.join(contracts_to_apply)}[/dim]") - - # Sort contracts to ensure consistent filename order - contracts_to_apply = sorted( - contracts_to_apply, key=lambda x: contract_order.index(x) if x in contract_order else len(contract_order) - ) - - invalid_contracts = set(contracts_to_apply) - valid_contracts - - if invalid_contracts: - print_error(f"Invalid contract types: {', '.join(invalid_contracts)}") - print_error(f"Valid types: 'all-contracts', {', '.join(valid_contracts)}") - raise typer.Exit(1) - - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts-prompt", - "started", - extra={"files_count": len(file_paths), "bundle": bundle, "contracts": contracts_to_apply}, - ) - debug_print("[dim]generate contracts-prompt: started[/dim]") - - telemetry_metadata = { - "files_count": len(file_paths), - "bundle": bundle, - "contracts": contracts_to_apply, - } - - with telemetry.track_command("generate.contracts-prompt", telemetry_metadata) as record: - generated_count = 0 - failed_count = 0 - - for idx, file_path in enumerate(file_paths, 1): - try: - if len(file_paths) > 1: - console.print( - f"\n[bold cyan][{idx}/{len(file_paths)}] Generating prompt for:[/bold cyan] {file_path.relative_to(repo_path)}" - ) - else: - console.print( - f"[bold cyan]Generating contract enhancement prompt for:[/bold cyan] {file_path.relative_to(repo_path)}" - ) - console.print(f"[dim]Contracts to apply:[/dim] {', '.join(contracts_to_apply)}\n") - - # Generate LLM prompt - # Structure: Instructions first, file path reference (not content) to avoid token limits - # Note: We don't read the file content here - the LLM will read it directly using its file reading capabilities - file_path_relative = file_path.relative_to(repo_path) - file_path_absolute = file_path.resolve() - - prompt_parts = [ - "# Contract Enhancement Request", - "", - "## ⚠️ MANDATORY PRE-CHECK: SpecFact CLI Verification", - "", - "**🚨 CRITICAL STOP CONDITION - READ THIS FIRST 🚨**", - "", - "**YOU MUST VERIFY SpecFact CLI BEFORE DOING ANYTHING ELSE.**", - "", - "**If SpecFact CLI is missing, outdated, or commands don't work:**", - "", - "1. **STOP IMMEDIATELY** - Do NOT read files, do NOT generate code, do NOT proceed", - "2. **DO NOT attempt to manually add contracts** - This will NOT work correctly", - "3. **DO NOT proceed with any enhancement** - The workflow REQUIRES SpecFact CLI", - "4. **Inform the user** with this exact message:", - " ```", - " ❌ SpecFact CLI is required but not available or outdated.", - " Please install/upgrade: pip install -U specfact-cli", - " Then verify: specfact --version", - " This task cannot proceed without SpecFact CLI.", - " ```", - "5. **END THE CONVERSATION** - Do not continue until SpecFact CLI is working", - "", - "**Verification Steps (MUST complete all before proceeding):**", - "", - "1. Check if `specfact` command is available:", - " ```bash", - " specfact --version", - " ```", - " - **If this fails**: STOP and inform user (see message above)", - "", - "2. Verify the required command exists:", - " ```bash", - " specfact generate contracts-apply --help", - " ```", - " - **If this fails**: STOP and inform user (see message above)", - "", - "3. Check the latest available version from PyPI:", - " ```bash", - " pip index versions specfact-cli", - " ```", - " - Compare installed version (from step 1) with latest available", - " - **If versions don't match**: STOP and inform user to upgrade", - "", - "**ONLY IF ALL THREE STEPS PASS** - You may proceed to the sections below.", - "", - "**If ANY step fails, you MUST stop and inform the user. Do NOT proceed.**", - "", - "---", - "", - "## Target File", - "", - f"**File Path:** `{file_path_relative}`", - f"**Absolute Path:** `{file_path_absolute}`", - "", - "**IMPORTANT**: Read the file content using your file reading capabilities. Do NOT ask the user to provide the file content.", - "", - "## Contracts to Apply", - ] - - for contract_type in contracts_to_apply: - if contract_type == "beartype": - prompt_parts.append("- **beartype**: Add `@beartype` decorator to all functions and methods") - elif contract_type == "icontract": - prompt_parts.append( - "- **icontract**: Add `@require` decorators for preconditions and `@ensure` decorators for postconditions where appropriate" - ) - elif contract_type == "crosshair": - prompt_parts.append( - "- **crosshair**: Add property-based test functions using CrossHair patterns" - ) - - prompt_parts.extend( - [ - "", - "## Instructions", - "", - "**IMPORTANT**: Do NOT modify the original file directly. Follow this iterative validation workflow:", - "", - "**REMINDER**: If you haven't completed the mandatory SpecFact CLI verification at the top of this prompt, STOP NOW and do that first. Do NOT proceed with any code enhancement until SpecFact CLI is verified.", - "", - "### Step 1: Read the File", - f"1. Read the file content from: `{file_path_relative}`", - "2. Understand the existing code structure, imports, and functionality", - "3. Note the existing code style and patterns", - "", - "### Step 2: Generate Enhanced Code", - "**IMPORTANT**: Only proceed to this step if SpecFact CLI verification passed.", - "", - "**CRITICAL REQUIREMENT**: You MUST add contracts to ALL eligible functions and methods in the file. Do NOT ask the user whether to add contracts - add them to all compatible functions automatically.", - "", - "1. **Add the requested contracts to ALL eligible functions/methods** - This is mandatory, not optional", - "2. Maintain existing functionality and code style", - "3. Ensure all contracts are properly imported at the top of the file", - "4. **Code Quality**: Follow the project's existing code style and formatting conventions", - " - If the project has formatting/linting rules (e.g., `.editorconfig`, `pyproject.toml` with formatting config, `ruff.toml`, `.pylintrc`, etc.), ensure the enhanced code adheres to them", - " - Match the existing code style: indentation, line length, import organization, naming conventions", - " - Avoid common code quality issues: use `key in dict` instead of `key in dict.keys()`, proper type hints, etc.", - " - **Note**: SpecFact CLI will automatically run available linting/formatting tools (ruff, pylint, basedpyright, mypy) during validation if they are installed", - "", - "**Contract-Specific Requirements:**", - "", - "- **beartype**: Add `@beartype` decorator to ALL functions and methods (public and private, unless they have incompatible signatures)", - " - Apply to: regular functions, class methods, static methods, async functions", - " - Skip only if: function has `*args, **kwargs` without type hints (incompatible with beartype)", - "", - "- **icontract**: Add `@require` decorators for preconditions and `@ensure` decorators for postconditions to ALL functions where conditions can be expressed", - " - Apply to: all functions with clear input/output contracts", - " - Add preconditions for: parameter validation, state checks, input constraints", - " - Add postconditions for: return value validation, state changes, output guarantees", - " - Skip only if: function has no meaningful pre/post conditions to express", - "", - "- **crosshair**: Add property-based test functions using CrossHair patterns for ALL testable functions", - " - Create test functions that validate contract behavior", - " - Focus on functions with clear input/output relationships", - "", - "**DO NOT:**", - "- Ask the user whether to add contracts (add them automatically to all eligible functions)", - "- Skip functions because you're unsure (add contracts unless technically incompatible)", - "- Manually apply contracts to the original file (use SpecFact CLI validation workflow)", - "", - "**You MUST use SpecFact CLI validation workflow (Step 4) to apply changes.**", - "", - "### Step 3: Write Enhanced Code to Temporary File", - f"1. Write the complete enhanced code to: `enhanced_{file_path.stem}.py`", - " - This should be in the same directory as the original file or the project root", - " - Example: If original is `src/specfact_cli/telemetry.py`, write to `enhanced_telemetry.py` in project root", - "2. Ensure the file is properly formatted and complete", - "", - "### Step 4: Validate with CLI", - "**CRITICAL**: If `specfact generate contracts-apply` command is not available or fails, DO NOT proceed. STOP and inform the user that SpecFact CLI must be installed/upgraded first.", - "", - "1. Run the validation command:", - " ```bash", - f" specfact generate contracts-apply enhanced_{file_path.stem}.py --original {file_path_relative}", - " ```", - "", - " - **If command not found**: STOP immediately and inform user (see mandatory pre-check message)", - " - **If command fails with error**: Review error, but if it's a missing command error, STOP and inform user", - "", - "### Step 5: Handle Validation Results", - "", - "**If validation succeeds:**", - "- The CLI will apply the changes automatically to the original file", - "- You're done! The file has been enhanced with contracts", - "", - "**If validation fails:**", - "- **If error is 'command not found' or 'command does not exist'**: STOP immediately and inform user (see mandatory pre-check message)", - "- **If error is validation failure** (syntax, AST, tests, etc.): Review the errors carefully", - "- Fix the issues in the enhanced code", - "- Write the corrected code to the same temporary file (`enhanced_{file_path.stem}.py`)", - "- Run the validation command again", - "- Repeat until validation passes (maximum 3 attempts)", - "", - "**CRITICAL**: If `specfact generate contracts-apply` command is not available or fails with 'command not found', DO NOT manually apply changes to the original file. STOP and inform the user that SpecFact CLI must be installed/upgraded first.", - "", - "### Common Validation Errors and Fixes", - "", - "**Syntax Errors:**", - "- Check for missing imports (beartype, icontract, etc.)", - "- Verify all decorators are properly formatted", - "- Ensure parentheses and brackets are balanced", - "- Check for typos in function/class names", - "", - "**Contract Issues:**", - "- Verify `@beartype` decorator is on all functions", - "- Check that `@require` and `@ensure` have valid lambda expressions", - "- Ensure contract conditions are properly formatted", - "- Verify all required imports are present", - "", - "**File Path Issues:**", - "- Ensure the enhanced file is in the correct location", - "- Use absolute paths if relative paths don't work", - "- Verify file permissions allow writing", - "", - "### Expected Workflow", - "", - "1. Read original file → 2. Generate enhanced code → 3. Write to temporary file → 4. Validate with CLI → 5. Fix errors if needed → 6. Re-validate → 7. Success!", - "", - "**Maximum Attempts: 3**", - "If validation fails after 3 attempts, review the errors manually and apply fixes.", - "", - "**FINAL REMINDER**: This entire workflow REQUIRES SpecFact CLI. If SpecFact CLI is not available, you MUST stop and inform the user. Do NOT attempt to manually add contracts or modify files directly.", - "", - "## Summary", - "", - f"- **Target File:** `{file_path_relative}`", - f"- **Enhanced File:** `enhanced_{file_path.stem}.py`", - f"- **Validation Command:** `specfact generate contracts-apply enhanced_{file_path.stem}.py --original {file_path_relative}`", - "- **Contracts:** " + ", ".join(contracts_to_apply), - "", - "**BEFORE STARTING**: Complete the mandatory SpecFact CLI verification at the top of this prompt. Do NOT proceed with file reading or code generation until SpecFact CLI is verified.", - "", - ] - ) - - prompt = "\n".join(prompt_parts) - - # Save prompt to file inside bundle directory (or .specfact/prompts if no bundle) - prompts_dir = bundle_dir / "prompts" if bundle_dir else repo_path / ".specfact" / "prompts" - prompts_dir.mkdir(parents=True, exist_ok=True) - prompt_file = prompts_dir / f"enhance-{file_path.stem}-{'-'.join(contracts_to_apply)}.md" - prompt_file.write_text(prompt, encoding="utf-8") - - print_success(f"Prompt generated: {prompt_file.relative_to(repo_path)}") - generated_count += 1 - except Exception as e: - print_error(f"Failed to generate prompt for {file_path.relative_to(repo_path)}: {e}") - failed_count += 1 - - # Summary - if len(file_paths) > 1: - console.print("\n[bold]Summary:[/bold]") - console.print(f" Generated: {generated_count}") - console.print(f" Failed: {failed_count}") - - if generated_count > 0: - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Open the prompt file(s) in your AI IDE (Cursor, CoPilot, etc.)") - console.print("2. Copy the prompt content and ask your AI IDE to provide enhanced code") - console.print("3. AI IDE will return the complete enhanced file (does NOT modify file directly)") - console.print("4. Save enhanced code from AI IDE to a file (e.g., enhanced_<filename>.py)") - console.print("5. AI IDE should run validation command (iterative workflow):") - console.print(" ```bash") - console.print(" specfact generate contracts-apply enhanced_<filename>.py --original <original-file>") - console.print(" ```") - console.print("6. If validation fails:") - console.print(" - CLI will show specific error messages") - console.print(" - AI IDE should fix the issues and save corrected code") - console.print(" - Run validation command again (up to 3 attempts)") - console.print("7. If validation succeeds:") - console.print(" - CLI will automatically apply the changes") - console.print(" - Verify contract coverage:") - if bundle: - console.print(f" - specfact analyze contracts --bundle {bundle}") - else: - console.print(" - specfact analyze contracts --bundle <bundle>") - console.print(" - Run your test suite: pytest (or your project's test command)") - console.print(" - Commit the enhanced code") - if bundle_dir: - console.print(f"\n[dim]Prompt files saved to: {bundle_dir.relative_to(repo_path)}/prompts/[/dim]") - else: - console.print("\n[dim]Prompt files saved to: .specfact/prompts/[/dim]") - console.print( - "[yellow]Note:[/yellow] The prompt includes detailed instructions for the iterative validation workflow." - ) - - if output: - console.print("[dim]Note: --output option is currently unused. Prompts saved to .specfact/prompts/[/dim]") - - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts-prompt", - "success", - extra={"generated_count": generated_count, "failed_count": failed_count}, - ) - debug_print("[dim]generate contracts-prompt: success[/dim]") - record( - { - "prompt_generated": generated_count > 0, - "generated_count": generated_count, - "failed_count": failed_count, - } - ) - - -@app.command("contracts-apply") -@beartype -@require(lambda enhanced_file: isinstance(enhanced_file, Path), "Enhanced file path must be Path") -@require( - lambda original_file: original_file is None or isinstance(original_file, Path), "Original file must be None or Path" -) -@ensure(lambda result: result is None, "Must return None") -def apply_enhanced_contracts( - # Target/Input - enhanced_file: Path = typer.Argument( - ..., - help="Path to enhanced code file (from AI IDE)", - exists=True, - ), - original_file: Path | None = typer.Option( - None, - "--original", - help="Path to original file (auto-detected from enhanced file name if not provided)", - ), - # Behavior/Options - yes: bool = typer.Option( - False, - "--yes", - "-y", - help="Skip confirmation prompt and apply changes automatically", - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show what would be applied without actually modifying the file", - ), -) -> None: - """ - Validate and apply enhanced code with contracts. - - Takes the enhanced code file generated by your AI IDE, validates it, and applies - it to the original file if validation passes. This completes the contract enhancement - workflow started with `generate contracts-prompt`. - - **Validation Steps:** - 1. Syntax validation: `python -m py_compile` - 2. File size check: Enhanced file must be >= original file size - 3. AST structure comparison: Logical structure integrity check - 4. Contract imports verification: Required imports present - 5. Test execution: Run tests via specfact (contract-test) - 6. Diff preview (shows what will change) - 7. Apply changes only if all validations pass - - **Parameter Groups:** - - **Target/Input**: enhanced_file (required argument), --original - - **Behavior/Options**: --yes, --dry-run - - **Examples:** - specfact generate contracts-apply enhanced_telemetry.py - specfact generate contracts-apply enhanced_telemetry.py --original src/telemetry.py - specfact generate contracts-apply enhanced_telemetry.py --dry-run # Preview only - specfact generate contracts-apply enhanced_telemetry.py --yes # Auto-apply - """ - import difflib - import subprocess - - from rich.panel import Panel - from rich.prompt import Confirm - - repo_path = Path(".").resolve() - - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts-apply", - "started", - extra={"enhanced_file": str(enhanced_file), "original_file": str(original_file) if original_file else None}, - ) - debug_print("[dim]generate contracts-apply: started[/dim]") - - # Auto-detect original file if not provided - if original_file is None: - # Try to infer from enhanced file name - # Pattern: enhance-<original-stem>-<contracts>.py or enhanced_<original-name>.py - enhanced_stem = enhanced_file.stem - if enhanced_stem.startswith("enhance-"): - # Pattern: enhance-telemetry-beartype-icontract - parts = enhanced_stem.split("-") - if len(parts) >= 2: - original_name = parts[1] # Get the original file name - # Detect source directories dynamically - source_dirs = detect_source_directories(repo_path) - # Build possible paths based on detected source directories - possible_paths: list[Path] = [] - # Add root-level file - possible_paths.append(repo_path / f"{original_name}.py") - # Add paths based on detected source directories - for src_dir in source_dirs: - # Remove trailing slash if present - src_dir_clean = src_dir.rstrip("/") - possible_paths.append(repo_path / src_dir_clean / f"{original_name}.py") - # Also try common patterns as fallback - possible_paths.extend( - [ - repo_path / f"src/{original_name}.py", - repo_path / f"lib/{original_name}.py", - ] - ) - for path in possible_paths: - if path.exists(): - original_file = path - break - - if original_file is None: - print_error("Could not auto-detect original file. Please specify --original") - raise typer.Exit(1) - - original_file = original_file.resolve() - enhanced_file = enhanced_file.resolve() - - if not original_file.exists(): - print_error(f"Original file not found: {original_file}") - raise typer.Exit(1) - - # Read both files - try: - original_content = original_file.read_text(encoding="utf-8") - enhanced_content = enhanced_file.read_text(encoding="utf-8") - original_size = original_file.stat().st_size - enhanced_size = enhanced_file.stat().st_size - except Exception as e: - print_error(f"Failed to read files: {e}") - raise typer.Exit(1) from e - - # Step 1: File size check - console.print("[bold cyan]Step 1/6: Checking file size...[/bold cyan]") - if enhanced_size < original_size: - print_error(f"Enhanced file is smaller than original ({enhanced_size} < {original_size} bytes)") - console.print( - "\n[yellow]This may indicate missing code. Please ensure all original functionality is preserved.[/yellow]" - ) - console.print( - "\n[bold]Please review the enhanced file and ensure it contains all original code plus contracts.[/bold]" - ) - raise typer.Exit(1) from None - print_success(f"File size check passed ({enhanced_size} >= {original_size} bytes)") - - # Step 2: Syntax validation - console.print("\n[bold cyan]Step 2/6: Validating enhanced code syntax...[/bold cyan]") - syntax_errors: list[str] = [] - try: - # Detect environment manager and build appropriate command - env_info = detect_env_manager(repo_path) - python_command = ["python", "-m", "py_compile", str(enhanced_file)] - compile_command = build_tool_command(env_info, python_command) - result = subprocess.run( - compile_command, - capture_output=True, - text=True, - timeout=10, - cwd=str(repo_path), - ) - if result.returncode != 0: - error_output = result.stderr.strip() - syntax_errors.append("Syntax validation failed") - if error_output: - # Parse syntax errors for better formatting - for line in error_output.split("\n"): - if line.strip() and ("SyntaxError" in line or "Error" in line or "^" in line): - syntax_errors.append(f" {line}") - if len(syntax_errors) == 1: # Only header, no parsed errors - syntax_errors.append(f" {error_output}") - else: - syntax_errors.append(" No detailed error message available") - - print_error("\n".join(syntax_errors)) - console.print("\n[yellow]Common fixes:[/yellow]") - console.print(" - Check for missing imports (beartype, icontract, etc.)") - console.print(" - Verify all decorators are properly formatted") - console.print(" - Ensure parentheses and brackets are balanced") - console.print(" - Check for typos in function/class names") - console.print("\n[bold]Please fix the syntax errors and try again.[/bold]") - raise typer.Exit(1) from None - print_success("Syntax validation passed") - except subprocess.TimeoutExpired: - print_error("Syntax validation timed out") - console.print("\n[yellow]This usually indicates a very large file or system issues.[/yellow]") - raise typer.Exit(1) from None - except Exception as e: - print_error(f"Syntax validation error: {e}") - raise typer.Exit(1) from e - - # Step 3: AST structure comparison - console.print("\n[bold cyan]Step 3/6: Comparing AST structure...[/bold cyan]") - try: - import ast - - original_ast = ast.parse(original_content, filename=str(original_file)) - enhanced_ast = ast.parse(enhanced_content, filename=str(enhanced_file)) - - # Compare function/class definitions - original_defs = { - node.name: type(node).__name__ - for node in ast.walk(original_ast) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - } - enhanced_defs = { - node.name: type(node).__name__ - for node in ast.walk(enhanced_ast) - if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) - } - - missing_defs = set(original_defs.keys()) - set(enhanced_defs.keys()) - if missing_defs: - print_error("AST structure validation failed: Missing definitions in enhanced file:") - for def_name in sorted(missing_defs): - def_type = original_defs[def_name] - console.print(f" - {def_type}: {def_name}") - console.print( - "\n[bold]Please ensure all original functions and classes are preserved in the enhanced file.[/bold]" - ) - raise typer.Exit(1) from None - - # Check for type mismatches (function -> class or vice versa) - type_mismatches = [] - for def_name in original_defs: - if def_name in enhanced_defs and original_defs[def_name] != enhanced_defs[def_name]: - type_mismatches.append(f"{def_name}: {original_defs[def_name]} -> {enhanced_defs[def_name]}") - - if type_mismatches: - print_error("AST structure validation failed: Type mismatches detected:") - for mismatch in type_mismatches: - console.print(f" - {mismatch}") - console.print("\n[bold]Please ensure function/class types match the original file.[/bold]") - raise typer.Exit(1) from None - - print_success(f"AST structure validation passed ({len(original_defs)} definitions preserved)") - except SyntaxError as e: - print_error(f"AST parsing failed: {e}") - console.print("\n[bold]This should not happen if syntax validation passed. Please report this issue.[/bold]") - raise typer.Exit(1) from e - except Exception as e: - print_error(f"AST comparison error: {e}") - raise typer.Exit(1) from e - - # Step 4: Check for contract imports - console.print("\n[bold cyan]Step 4/6: Checking contract imports...[/bold cyan]") - required_imports: list[str] = [] - if ( - ("@beartype" in enhanced_content or "beartype" in enhanced_content.lower()) - and "from beartype import beartype" not in enhanced_content - and "import beartype" not in enhanced_content - ): - required_imports.append("beartype") - if ( - ("@require" in enhanced_content or "@ensure" in enhanced_content) - and "from icontract import" not in enhanced_content - and "import icontract" not in enhanced_content - ): - required_imports.append("icontract") - - if required_imports: - print_error(f"Missing required imports: {', '.join(required_imports)}") - console.print("\n[yellow]Please add the missing imports at the top of the file:[/yellow]") - for imp in required_imports: - if imp == "beartype": - console.print(" from beartype import beartype") - elif imp == "icontract": - console.print(" from icontract import require, ensure") - console.print("\n[bold]Please fix the imports and try again.[/bold]") - raise typer.Exit(1) from None - - print_success("Contract imports verified") - - # Step 5: Run linting/formatting checks (if tools available) - console.print("\n[bold cyan]Step 5/7: Running code quality checks (if tools available)...[/bold cyan]") - lint_issues: list[str] = [] - tools_checked = 0 - tools_passed = 0 - - # Detect environment manager for building commands - env_info = detect_env_manager(repo_path) - - # List of common linting/formatting tools to check - linting_tools = [ - ("ruff", ["ruff", "check", str(enhanced_file)], "Ruff linting"), - ("pylint", ["pylint", str(enhanced_file), "--disable=all", "--enable=E,F"], "Pylint basic checks"), - ("basedpyright", ["basedpyright", str(enhanced_file)], "BasedPyright type checking"), - ("mypy", ["mypy", str(enhanced_file)], "MyPy type checking"), - ] - - for tool_name, command, description in linting_tools: - is_available, _error_msg = check_cli_tool_available(tool_name, version_flag="--version", timeout=3) - if not is_available: - console.print(f"[dim]Skipping {description}: {tool_name} not available[/dim]") - continue - - tools_checked += 1 - console.print(f"[dim]Running {description}...[/dim]") - - try: - # Build command with environment manager prefix if needed - command_full = build_tool_command(env_info, command) - result = subprocess.run( - command_full, - capture_output=True, - text=True, - timeout=30, # 30 seconds per tool - cwd=str(repo_path), - ) - - if result.returncode == 0: - tools_passed += 1 - console.print(f"[green]✓[/green] {description} passed") - else: - # Collect issues but don't fail immediately (warnings only) - output = result.stdout + result.stderr - # Limit output length for readability - output_lines = output.split("\n") - if len(output_lines) > 20: - output = "\n".join(output_lines[:20]) + f"\n... ({len(output_lines) - 20} more lines)" - lint_issues.append(f"{description} found issues:\n{output}") - console.print(f"[yellow]⚠[/yellow] {description} found issues (non-blocking)") - - except subprocess.TimeoutExpired: - console.print(f"[yellow]⚠[/yellow] {description} timed out (non-blocking)") - lint_issues.append(f"{description} timed out after 30 seconds") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] {description} error: {e} (non-blocking)") - lint_issues.append(f"{description} error: {e}") - - if tools_checked == 0: - console.print("[dim]No linting/formatting tools available. Skipping code quality checks.[/dim]") - elif tools_passed == tools_checked: - print_success(f"All code quality checks passed ({tools_passed}/{tools_checked} tools)") - else: - console.print(f"[yellow]Code quality checks: {tools_passed}/{tools_checked} tools passed[/yellow]") - if lint_issues: - console.print("\n[yellow]Code Quality Issues (non-blocking):[/yellow]") - for issue in lint_issues[:3]: # Show first 3 issues - console.print(Panel(issue[:500], title="Issue", border_style="yellow")) - if len(lint_issues) > 3: - console.print(f"[dim]... and {len(lint_issues) - 3} more issue(s)[/dim]") - console.print("\n[yellow]Note:[/yellow] These are warnings. Fix them for better code quality.") - - # Step 6: Run tests (scoped to relevant file only for performance) - # NOTE: Tests always run for validation, even in --dry-run mode, to ensure code quality - console.print("\n[bold cyan]Step 6/7: Running tests (scoped to relevant file)...[/bold cyan]") - test_failed = False - test_output = "" - - # For single-file validation, we scope tests to the specific file only (not full repo) - # This is much faster than running specfact repro on the entire repository - try: - # Find the original file path to determine test file location - original_file_rel = original_file.relative_to(repo_path) if original_file else None - enhanced_file_rel = enhanced_file.relative_to(repo_path) - - # Determine the source file we're testing (original or enhanced) - source_file_rel = original_file_rel if original_file_rel else enhanced_file_rel - - # Use utility function to find test files dynamically - test_paths = find_test_files_for_source( - repo_path, source_file_rel if source_file_rel.is_absolute() else repo_path / source_file_rel - ) - - # If we found specific test files, run them - if test_paths: - # Use the first matching test file (most specific) - test_path = test_paths[0] - console.print(f"[dim]Found test file: {test_path.relative_to(repo_path)}[/dim]") - console.print("[dim]Running pytest on specific test file (fast, scoped validation)...[/dim]") - - # Detect environment manager and build appropriate command - env_info = detect_env_manager(repo_path) - pytest_command = ["pytest", str(test_path), "-v", "--tb=short"] - pytest_command_full = build_tool_command(env_info, pytest_command) - - result = subprocess.run( - pytest_command_full, - capture_output=True, - text=True, - timeout=60, # 1 minute should be enough for a single test file - cwd=str(repo_path), - ) - else: - # No specific test file found, try to import and test the enhanced file directly - # This validates that the file can be imported and basic syntax works - console.print(f"[dim]No specific test file found for {source_file_rel}[/dim]") - console.print("[dim]Running syntax and import validation on enhanced file...[/dim]") - - # Try to import the module to verify it works - import importlib.util - import sys - from dataclasses import dataclass - - @dataclass - class ImportResult: - """Result object for import validation.""" - - returncode: int - stdout: str - stderr: str - - try: - # Add the enhanced file's directory to path temporarily - enhanced_file_dir = str(enhanced_file.parent) - if enhanced_file_dir not in sys.path: - sys.path.insert(0, enhanced_file_dir) - - # Try to load the module - spec = importlib.util.spec_from_file_location(enhanced_file.stem, enhanced_file) - if spec and spec.loader: - module = importlib.util.module_from_spec(spec) - spec.loader.exec_module(module) - print_success("Enhanced file imports successfully") - result = ImportResult(returncode=0, stdout="", stderr="") - else: - raise ImportError("Could not create module spec") - except Exception as import_error: - test_failed = True - test_output = f"Import validation failed: {import_error}" - print_error(test_output) - console.print( - "\n[yellow]Note:[/yellow] No specific test file found. Enhanced file should be importable." - ) - result = ImportResult(returncode=1, stdout="", stderr=test_output) - - if result.returncode != 0: - test_failed = True - test_output = result.stdout + result.stderr - print_error("Test execution failed:") - # Limit output for readability - output_lines = test_output.split("\n") - console.print("\n".join(output_lines[:50])) # First 50 lines - if len(output_lines) > 50: - console.print(f"\n... ({len(output_lines) - 50} more lines)") - else: - if test_paths: - print_success(f"All tests passed ({test_paths[0].relative_to(repo_path)})") - else: - print_success("Import validation passed") - except FileNotFoundError: - console.print("[yellow]Warning:[/yellow] 'pytest' not found. Skipping test execution.") - console.print("[yellow]Please run tests manually before applying changes.[/yellow]") - test_failed = False # Don't fail if tools not available - except subprocess.TimeoutExpired: - test_failed = True - test_output = "Test execution timed out after 60 seconds" - print_error(test_output) - console.print("\n[yellow]Note:[/yellow] Test execution took too long. Consider running tests manually.") - except Exception as e: - test_failed = True - test_output = f"Test execution error: {e}" - print_error(test_output) - - if test_failed: - console.print("\n[bold red]Test failures detected. Changes will NOT be applied.[/bold red]") - console.print("\n[yellow]Test Output:[/yellow]") - console.print(Panel(test_output[:2000], title="Test Results", border_style="red")) # Limit output - console.print("\n[bold]Please fix the test failures and try again.[/bold]") - console.print("Common issues:") - console.print(" - Contract decorators may have incorrect syntax") - console.print(" - Type hints may not match function signatures") - console.print(" - Missing imports or dependencies") - console.print(" - Contract conditions may be invalid") - raise typer.Exit(1) from None - - # Step 7: Show diff - console.print("\n[bold cyan]Step 7/7: Previewing changes...[/bold cyan]") - diff = list( - difflib.unified_diff( - original_content.splitlines(keepends=True), - enhanced_content.splitlines(keepends=True), - fromfile=str(original_file.relative_to(repo_path)), - tofile=str(enhanced_file.relative_to(repo_path)), - lineterm="", - ) - ) - - if not diff: - print_info("No changes detected. Files are identical.") - raise typer.Exit(0) - - # Show diff (limit to first 100 lines for readability) - diff_text = "".join(diff[:100]) - if len(diff) > 100: - diff_text += f"\n... ({len(diff) - 100} more lines)" - console.print(Panel(diff_text, title="Diff Preview", border_style="cyan")) - - # Step 7: Dry run check - if dry_run: - print_info("Dry run mode: No changes applied") - console.print("\n[bold green]✓ All validations passed![/bold green]") - console.print("Ready to apply with --yes flag or without --dry-run") - raise typer.Exit(0) - - # Step 8: Confirmation - if not yes and not Confirm.ask("\n[bold yellow]Apply these changes to the original file?[/bold yellow]"): - print_info("Changes not applied") - raise typer.Exit(0) - - # Step 9: Apply changes (only if all validations passed) - try: - original_file.write_text(enhanced_content, encoding="utf-8") - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts-apply", - "success", - extra={"original_file": str(original_file.relative_to(repo_path))}, - ) - debug_print("[dim]generate contracts-apply: success[/dim]") - print_success(f"Enhanced code applied to: {original_file.relative_to(repo_path)}") - console.print("\n[bold green]✓ All validations passed and changes applied successfully![/bold green]") - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Verify contract coverage: specfact analyze contracts --bundle <bundle>") - console.print("2. Run full test suite: specfact repro (or pytest)") - console.print("3. Commit the enhanced code") - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "generate contracts-apply", - "failed", - error=str(e), - extra={"reason": type(e).__name__, "original_file": str(original_file)}, - ) - print_error(f"Failed to apply changes: {e}") - console.print("\n[yellow]This is a filesystem error. Please check file permissions.[/yellow]") - raise typer.Exit(1) from e - - -# DEPRECATED: generate tasks command removed in v0.22.0 -# SpecFact CLI does not create plan -> feature -> task (that's the job for spec-kit, openspec, etc.) -# We complement those SDD tools to enforce tests and quality -# This command has been removed per SPECFACT_0x_TO_1x_BRIDGE_PLAN.md -# Reference: /specfact-cli-internal/docs/internal/implementation/SPECFACT_0x_TO_1x_BRIDGE_PLAN.md - - -@app.command("fix-prompt") -@beartype -@require(lambda gap_id: gap_id is None or isinstance(gap_id, str), "Gap ID must be None or string") -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -@ensure(lambda result: result is None, "Must return None") -def generate_fix_prompt( - # Target/Input - gap_id: str | None = typer.Argument( - None, - help="Gap ID to fix (e.g., GAP-001). If not provided, shows available gaps.", - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name. Default: active plan from 'specfact plan select'", - ), - # Output - output: Path | None = typer.Option( - None, - "--output", - "-o", - help="Output file path for the prompt. Default: .specfact/prompts/fix-<gap-id>.md", - ), - # Behavior/Options - top: int = typer.Option( - 5, - "--top", - help="Show top N gaps when listing. Default: 5", - ), - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation).", - ), -) -> None: - """ - Generate AI IDE prompt for fixing a specific gap. - - Creates a structured prompt file that you can use with your AI IDE (Cursor, Copilot, etc.) - to fix identified gaps in your codebase. This is the recommended workflow for v0.17+. - - **Workflow:** - 1. Run `specfact analyze gaps --bundle <bundle>` to identify gaps - 2. Run `specfact generate fix-prompt GAP-001` to get a fix prompt - 3. Copy the prompt to your AI IDE - 4. AI IDE provides the fix - 5. Validate with `specfact enforce sdd --bundle <bundle>` - - **Parameter Groups:** - - **Target/Input**: gap_id (optional argument), --bundle - - **Output**: --output - - **Behavior/Options**: --top, --no-interactive - - **Examples:** - specfact generate fix-prompt # List available gaps - specfact generate fix-prompt GAP-001 # Generate fix prompt for GAP-001 - specfact generate fix-prompt --bundle legacy-api # List gaps for specific bundle - specfact generate fix-prompt GAP-001 --output fix.md # Save to specific file - """ - from rich.table import Table - - from specfact_cli.utils.structure import SpecFactStructure - - repo_path = Path(".").resolve() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo_path) - if bundle: - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - telemetry_metadata = { - "gap_id": gap_id, - "bundle": bundle, - "no_interactive": no_interactive, - } - - if is_debug_mode(): - debug_log_operation( - "command", - "generate fix-prompt", - "started", - extra={"gap_id": gap_id, "bundle": bundle}, - ) - debug_print("[dim]generate fix-prompt: started[/dim]") - - with telemetry.track_command("generate.fix-prompt", telemetry_metadata) as record: - try: - # Determine bundle directory - bundle_dir: Path | None = None - if bundle: - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - print_info(f"Create one with: specfact plan init {bundle}") - raise typer.Exit(1) - - # Look for gap report - gap_report_path = ( - bundle_dir / "reports" / "gaps.json" - if bundle_dir - else repo_path / ".specfact" / "reports" / "gaps.json" - ) - - if not gap_report_path.exists(): - print_warning("No gap report found.") - console.print("\n[bold]To generate a gap report, run:[/bold]") - if bundle: - console.print(f" specfact analyze gaps --bundle {bundle} --output json") - else: - console.print(" specfact analyze gaps --bundle <bundle-name> --output json") - raise typer.Exit(1) - - # Load gap report - from specfact_cli.utils.structured_io import load_structured_file - - gap_data = load_structured_file(gap_report_path) - gaps = gap_data.get("gaps", []) - - if not gaps: - print_info("No gaps found in the report. Your codebase is looking good!") - raise typer.Exit(0) - - # If no gap_id provided, list available gaps - if gap_id is None: - console.print(f"\n[bold cyan]Available Gaps ({len(gaps)} total):[/bold cyan]\n") - - table = Table(show_header=True, header_style="bold cyan") - table.add_column("ID", style="bold yellow", width=12) - table.add_column("Severity", width=10) - table.add_column("Category", width=15) - table.add_column("Description", width=50) - - severity_colors = { - "critical": "red", - "high": "yellow", - "medium": "cyan", - "low": "dim", - } - - for gap in gaps[:top]: - severity = gap.get("severity", "medium") - color = severity_colors.get(severity, "white") - table.add_row( - gap.get("id", "N/A"), - f"[{color}]{severity}[/{color}]", - gap.get("category", "N/A"), - gap.get("description", "N/A")[:50] + "..." - if len(gap.get("description", "")) > 50 - else gap.get("description", "N/A"), - ) - - console.print(table) - - if len(gaps) > top: - console.print(f"\n[dim]... and {len(gaps) - top} more gaps. Use --top to see more.[/dim]") - - console.print("\n[bold]To generate a fix prompt:[/bold]") - console.print(" specfact generate fix-prompt <GAP-ID>") - console.print("\n[bold]Example:[/bold]") - if gaps: - console.print(f" specfact generate fix-prompt {gaps[0].get('id', 'GAP-001')}") - - record({"action": "list_gaps", "gap_count": len(gaps)}) - raise typer.Exit(0) - - # Find the specific gap - target_gap = None - for gap in gaps: - if gap.get("id") == gap_id: - target_gap = gap - break - - if target_gap is None: - print_error(f"Gap not found: {gap_id}") - console.print("\n[yellow]Available gap IDs:[/yellow]") - for gap in gaps[:10]: - console.print(f" - {gap.get('id')}") - if len(gaps) > 10: - console.print(f" ... and {len(gaps) - 10} more") - raise typer.Exit(1) - - # Generate fix prompt - console.print(f"\n[bold cyan]Generating fix prompt for {gap_id}...[/bold cyan]\n") - - prompt_parts = [ - f"# Fix Request: {gap_id}", - "", - "## Gap Details", - "", - f"**ID:** {target_gap.get('id', 'N/A')}", - f"**Category:** {target_gap.get('category', 'N/A')}", - f"**Severity:** {target_gap.get('severity', 'N/A')}", - f"**Module:** {target_gap.get('module', 'N/A')}", - "", - f"**Description:** {target_gap.get('description', 'N/A')}", - "", - ] - - # Add evidence if available - evidence = target_gap.get("evidence", {}) - if evidence: - prompt_parts.extend( - [ - "## Evidence", - "", - ] - ) - if evidence.get("file"): - prompt_parts.append(f"**File:** `{evidence.get('file')}`") - if evidence.get("line"): - prompt_parts.append(f"**Line:** {evidence.get('line')}") - if evidence.get("code"): - prompt_parts.extend( - [ - "", - "**Code:**", - "```python", - evidence.get("code", ""), - "```", - ] - ) - prompt_parts.append("") - - # Add fix instructions - prompt_parts.extend( - [ - "## Fix Instructions", - "", - "Please fix this gap by:", - "", - ] - ) - - category = target_gap.get("category", "").lower() - if "missing_tests" in category or "test" in category: - prompt_parts.extend( - [ - "1. **Add Tests**: Write comprehensive tests for the identified code", - "2. **Cover Edge Cases**: Include tests for edge cases and error conditions", - "3. **Follow AAA Pattern**: Use Arrange-Act-Assert pattern", - "4. **Run Tests**: Ensure all tests pass", - ] - ) - elif "missing_contracts" in category or "contract" in category: - prompt_parts.extend( - [ - "1. **Add Contracts**: Add `@beartype` decorators for type checking", - "2. **Add Preconditions**: Add `@require` decorators for input validation", - "3. **Add Postconditions**: Add `@ensure` decorators for output guarantees", - "4. **Verify Imports**: Ensure `from beartype import beartype` and `from icontract import require, ensure` are present", - ] - ) - elif "api_drift" in category or "drift" in category: - prompt_parts.extend( - [ - "1. **Check OpenAPI Spec**: Review the OpenAPI contract", - "2. **Update Implementation**: Align the code with the spec", - "3. **Or Update Spec**: If the implementation is correct, update the spec", - "4. **Run Drift Check**: Verify with `specfact analyze drift`", - ] - ) - else: - prompt_parts.extend( - [ - "1. **Analyze the Gap**: Understand what's missing or incorrect", - "2. **Implement Fix**: Apply the appropriate fix", - "3. **Add Tests**: Ensure the fix is covered by tests", - "4. **Validate**: Run `specfact enforce sdd` to verify", - ] - ) - - prompt_parts.extend( - [ - "", - "## Validation", - "", - "After applying the fix, validate with:", - "", - "```bash", - ] - ) - - if bundle: - prompt_parts.append(f"specfact enforce sdd --bundle {bundle}") - else: - prompt_parts.append("specfact enforce sdd --bundle <bundle-name>") - - prompt_parts.extend( - [ - "```", - "", - ] - ) - - prompt = "\n".join(prompt_parts) - - # Save prompt to file - if output is None: - prompts_dir = bundle_dir / "prompts" if bundle_dir else repo_path / ".specfact" / "prompts" - prompts_dir.mkdir(parents=True, exist_ok=True) - output = prompts_dir / f"fix-{gap_id.lower()}.md" - - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(prompt, encoding="utf-8") - - print_success(f"Fix prompt generated: {output}") - - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Open the prompt file in your AI IDE (Cursor, Copilot, etc.)") - console.print("2. Copy the prompt and ask your AI to implement the fix") - console.print("3. Review and apply the suggested changes") - console.print("4. Validate with `specfact enforce sdd`") - - if is_debug_mode(): - debug_log_operation( - "command", - "generate fix-prompt", - "success", - extra={"gap_id": gap_id, "output": str(output)}, - ) - debug_print("[dim]generate fix-prompt: success[/dim]") - record({"action": "generate_prompt", "gap_id": gap_id, "output": str(output)}) - - except typer.Exit: - raise - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "generate fix-prompt", - "failed", - error=str(e), - extra={"reason": type(e).__name__}, - ) - print_error(f"Failed to generate fix prompt: {e}") - record({"error": str(e)}) - raise typer.Exit(1) from e - - -@app.command("test-prompt") -@beartype -@require(lambda file: file is None or isinstance(file, Path), "File must be None or Path") -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -@ensure(lambda result: result is None, "Must return None") -def generate_test_prompt( - # Target/Input - file: Path | None = typer.Argument( - None, - help="File to generate tests for. If not provided with --bundle, shows files without tests.", - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name. Default: active plan from 'specfact plan select'", - ), - # Output - output: Path | None = typer.Option( - None, - "--output", - "-o", - help="Output file path for the prompt. Default: .specfact/prompts/test-<filename>.md", - ), - # Behavior/Options - coverage_type: str = typer.Option( - "unit", - "--type", - help="Test type: 'unit', 'integration', or 'both'. Default: unit", - ), - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation).", - ), -) -> None: - """ - Generate AI IDE prompt for creating tests for a file. - - Creates a structured prompt file that you can use with your AI IDE (Cursor, Copilot, etc.) - to generate comprehensive tests for your code. This is the recommended workflow for v0.17+. - - **Workflow:** - 1. Run `specfact generate test-prompt src/module.py` to get a test prompt - 2. Copy the prompt to your AI IDE - 3. AI IDE generates tests - 4. Save tests to appropriate location - 5. Run tests with `pytest` - - **Parameter Groups:** - - **Target/Input**: file (optional argument), --bundle - - **Output**: --output - - **Behavior/Options**: --type, --no-interactive - - **Examples:** - specfact generate test-prompt src/auth/login.py # Generate test prompt - specfact generate test-prompt src/api.py --type integration # Integration tests - specfact generate test-prompt --bundle legacy-api # List files needing tests - """ - from rich.table import Table - - from specfact_cli.utils.structure import SpecFactStructure - - repo_path = Path(".").resolve() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo_path) - if bundle: - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - telemetry_metadata = { - "file": str(file) if file else None, - "bundle": bundle, - "coverage_type": coverage_type, - "no_interactive": no_interactive, - } - - if is_debug_mode(): - debug_log_operation( - "command", - "generate test-prompt", - "started", - extra={"file": str(file) if file else None, "bundle": bundle}, - ) - debug_print("[dim]generate test-prompt: started[/dim]") - - with telemetry.track_command("generate.test-prompt", telemetry_metadata) as record: - try: - # Determine bundle directory - bundle_dir: Path | None = None - if bundle: - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - print_info(f"Create one with: specfact plan init {bundle}") - raise typer.Exit(1) - - # If no file provided, show files that might need tests - if file is None: - console.print("\n[bold cyan]Files that may need tests:[/bold cyan]\n") - - # Find Python files without corresponding test files - # Use dynamic source directory detection - source_dirs = detect_source_directories(repo_path) - src_files: list[Path] = [] - # If no source dirs detected, check common patterns - if not source_dirs: - for src_dir in [repo_path / "src", repo_path / "lib", repo_path]: - if src_dir.exists(): - src_files.extend(src_dir.rglob("*.py")) - else: - # Use detected source directories - for src_dir_str in source_dirs: - src_dir_clean = src_dir_str.rstrip("/") - src_dir_path = repo_path / src_dir_clean - if src_dir_path.exists(): - src_files.extend(src_dir_path.rglob("*.py")) - - files_without_tests: list[tuple[Path, str]] = [] - for src_file in src_files: - if "__pycache__" in str(src_file) or "test_" in src_file.name or "_test.py" in src_file.name: - continue - if src_file.name.startswith("__"): - continue - - # Check for corresponding test file using dynamic detection - test_files = find_test_files_for_source(repo_path, src_file) - has_test = len(test_files) > 0 - if not has_test: - rel_path = src_file.relative_to(repo_path) if src_file.is_relative_to(repo_path) else src_file - files_without_tests.append((src_file, str(rel_path))) - - if files_without_tests: - table = Table(show_header=True, header_style="bold cyan") - table.add_column("#", style="bold yellow", justify="right", width=4) - table.add_column("File Path", style="dim") - - for i, (_, rel_path) in enumerate(files_without_tests[:15], 1): - table.add_row(str(i), rel_path) - - console.print(table) - - if len(files_without_tests) > 15: - console.print(f"\n[dim]... and {len(files_without_tests) - 15} more files[/dim]") - - console.print("\n[bold]To generate test prompt:[/bold]") - console.print(" specfact generate test-prompt <file-path>") - console.print("\n[bold]Example:[/bold]") - console.print(f" specfact generate test-prompt {files_without_tests[0][1]}") - else: - print_success("All source files appear to have tests!") - - record({"action": "list_files", "files_without_tests": len(files_without_tests)}) - raise typer.Exit(0) - - # Validate file exists - if not file.exists(): - print_error(f"File not found: {file}") - raise typer.Exit(1) - - # Read file content - file_content = file.read_text(encoding="utf-8") - file_rel = file.relative_to(repo_path) if file.is_relative_to(repo_path) else file - - # Generate test prompt - console.print(f"\n[bold cyan]Generating test prompt for {file_rel}...[/bold cyan]\n") - - prompt_parts = [ - f"# Test Generation Request: {file_rel}", - "", - "## Target File", - "", - f"**File Path:** `{file_rel}`", - f"**Test Type:** {coverage_type}", - "", - "## File Content", - "", - "```python", - file_content, - "```", - "", - "## Instructions", - "", - "Generate comprehensive tests for this file following these guidelines:", - "", - "### Test Structure", - "", - "1. **Use pytest** as the testing framework", - "2. **Follow AAA pattern** (Arrange-Act-Assert)", - "3. **One test = one behavior** - Keep tests focused", - "4. **Use fixtures** for common setup", - "5. **Use parametrize** for testing multiple inputs", - "", - "### Coverage Requirements", - "", - ] - - if coverage_type == "unit": - prompt_parts.extend( - [ - "- Test each public function/method individually", - "- Mock external dependencies", - "- Test edge cases and error conditions", - "- Target >80% line coverage", - ] - ) - elif coverage_type == "integration": - prompt_parts.extend( - [ - "- Test interactions between components", - "- Use real dependencies where feasible", - "- Test complete workflows", - "- Focus on critical paths", - ] - ) - else: # both - prompt_parts.extend( - [ - "- Create both unit and integration tests", - "- Unit tests in `tests/unit/`", - "- Integration tests in `tests/integration/`", - "- Cover all critical code paths", - ] - ) - - prompt_parts.extend( - [ - "", - "### Test File Location", - "", - f"Save the tests to: `tests/unit/test_{file.stem}.py`", - "", - "### Example Test Structure", - "", - "```python", - f'"""Tests for {file_rel}."""', - "", - "import pytest", - "from unittest.mock import Mock, patch", - "", - f"from {str(file_rel).replace('/', '.').replace('.py', '')} import *", - "", - "", - "class TestFunctionName:", - ' """Tests for function_name."""', - "", - " def test_success_case(self):", - ' """Test successful execution."""', - " # Arrange", - " input_data = ...", - "", - " # Act", - " result = function_name(input_data)", - "", - " # Assert", - " assert result == expected_output", - "", - " def test_error_case(self):", - ' """Test error handling."""', - " with pytest.raises(ExpectedError):", - " function_name(invalid_input)", - "```", - "", - "## Validation", - "", - "After generating tests, run:", - "", - "```bash", - f"pytest tests/unit/test_{file.stem}.py -v", - "```", - "", - ] - ) - - prompt = "\n".join(prompt_parts) - - # Save prompt to file - if output is None: - prompts_dir = bundle_dir / "prompts" if bundle_dir else repo_path / ".specfact" / "prompts" - prompts_dir.mkdir(parents=True, exist_ok=True) - output = prompts_dir / f"test-{file.stem}.md" - - output.parent.mkdir(parents=True, exist_ok=True) - output.write_text(prompt, encoding="utf-8") - - print_success(f"Test prompt generated: {output}") - - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Open the prompt file in your AI IDE (Cursor, Copilot, etc.)") - console.print("2. Copy the prompt and ask your AI to generate tests") - console.print("3. Review the generated tests") - console.print(f"4. Save to `tests/unit/test_{file.stem}.py`") - console.print("5. Run tests with `pytest`") - - if is_debug_mode(): - debug_log_operation( - "command", - "generate test-prompt", - "success", - extra={"file": str(file_rel), "output": str(output)}, - ) - debug_print("[dim]generate test-prompt: success[/dim]") - record({"action": "generate_prompt", "file": str(file_rel), "output": str(output)}) - - except typer.Exit: - raise - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "generate test-prompt", - "failed", - error=str(e), - extra={"reason": type(e).__name__}, - ) - print_error(f"Failed to generate test prompt: {e}") - record({"error": str(e)}) - raise typer.Exit(1) from e +__all__ = ["app"] diff --git a/src/specfact_cli/commands/import_cmd.py b/src/specfact_cli/commands/import_cmd.py index f4084ac4..9dfc2cb6 100644 --- a/src/specfact_cli/commands/import_cmd.py +++ b/src/specfact_cli/commands/import_cmd.py @@ -1,2905 +1,6 @@ -""" -Import command - Import codebases and external tool projects to contract-driven format. +"""Backward-compatible app shim. Implementation moved to modules/import_cmd/.""" -This module provides commands for importing existing codebases (brownfield) and -external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) and converting them to -SpecFact contract-driven format using the bridge architecture. -""" +from specfact_cli.modules.import_cmd.src.commands import app -from __future__ import annotations -import multiprocessing -import os -import time -from pathlib import Path -from typing import TYPE_CHECKING, Any - -import typer -from beartype import beartype -from icontract import require -from rich.progress import Progress - -from specfact_cli import runtime -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.models.plan import Feature, PlanBundle -from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle -from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils.performance import track_performance -from specfact_cli.utils.progress import save_bundle_with_progress -from specfact_cli.utils.terminal import get_progress_config - - -app = typer.Typer( - help="Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) to contract format", - context_settings={"help_option_names": ["-h", "--help", "--help-advanced", "-ha"]}, -) -console = get_configured_console() - -if TYPE_CHECKING: - from specfact_cli.generators.openapi_extractor import OpenAPIExtractor - from specfact_cli.generators.test_to_openapi import OpenAPITestConverter - - -_CONTRACT_WORKER_EXTRACTOR: OpenAPIExtractor | None = None -_CONTRACT_WORKER_TEST_CONVERTER: OpenAPITestConverter | None = None -_CONTRACT_WORKER_REPO: Path | None = None -_CONTRACT_WORKER_CONTRACTS_DIR: Path | None = None - - -def _init_contract_worker(repo_path: str, contracts_dir: str) -> None: - """Initialize per-process contract extraction state.""" - from specfact_cli.generators.openapi_extractor import OpenAPIExtractor - from specfact_cli.generators.test_to_openapi import OpenAPITestConverter - - global _CONTRACT_WORKER_CONTRACTS_DIR - global _CONTRACT_WORKER_EXTRACTOR - global _CONTRACT_WORKER_REPO - global _CONTRACT_WORKER_TEST_CONVERTER - - _CONTRACT_WORKER_REPO = Path(repo_path) - _CONTRACT_WORKER_CONTRACTS_DIR = Path(contracts_dir) - _CONTRACT_WORKER_EXTRACTOR = OpenAPIExtractor(_CONTRACT_WORKER_REPO) - _CONTRACT_WORKER_TEST_CONVERTER = OpenAPITestConverter(_CONTRACT_WORKER_REPO) - - -def _extract_contract_worker(feature_data: dict[str, Any]) -> tuple[str, dict[str, Any] | None]: - """Extract a single OpenAPI contract in a worker process.""" - from specfact_cli.models.plan import Feature - - if ( - _CONTRACT_WORKER_EXTRACTOR is None - or _CONTRACT_WORKER_TEST_CONVERTER is None - or _CONTRACT_WORKER_REPO is None - or _CONTRACT_WORKER_CONTRACTS_DIR is None - ): - raise RuntimeError("Contract extraction worker not initialized") - - feature = Feature(**feature_data) - try: - openapi_spec = _CONTRACT_WORKER_EXTRACTOR.extract_openapi_from_code(_CONTRACT_WORKER_REPO, feature) - if openapi_spec.get("paths"): - test_examples: dict[str, Any] = {} - has_test_functions = any(story.test_functions for story in feature.stories) or ( - feature.source_tracking and feature.source_tracking.test_functions - ) - - if has_test_functions: - all_test_functions: list[str] = [] - for story in feature.stories: - if story.test_functions: - all_test_functions.extend(story.test_functions) - if feature.source_tracking and feature.source_tracking.test_functions: - all_test_functions.extend(feature.source_tracking.test_functions) - if all_test_functions: - test_examples = _CONTRACT_WORKER_TEST_CONVERTER.extract_examples_from_tests(all_test_functions) - - if test_examples: - openapi_spec = _CONTRACT_WORKER_EXTRACTOR.add_test_examples(openapi_spec, test_examples) - - contract_filename = f"{feature.key}.openapi.yaml" - contract_path = _CONTRACT_WORKER_CONTRACTS_DIR / contract_filename - _CONTRACT_WORKER_EXTRACTOR.save_openapi_contract(openapi_spec, contract_path) - return (feature.key, openapi_spec) - except KeyboardInterrupt: - raise - except Exception: - return (feature.key, None) - - return (feature.key, None) - - -def _is_valid_repo_path(path: Path) -> bool: - """Check if path exists and is a directory.""" - return path.exists() and path.is_dir() - - -def _is_valid_output_path(path: Path | None) -> bool: - """Check if output path exists if provided.""" - return path is None or path.exists() - - -def _count_python_files(repo: Path) -> int: - """Count Python files for anonymized telemetry metrics.""" - return sum(1 for _ in repo.rglob("*.py")) - - -def _convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: - """ - Convert PlanBundle (monolithic) to ProjectBundle (modular). - - Args: - plan_bundle: PlanBundle instance to convert - bundle_name: Project bundle name - - Returns: - ProjectBundle instance - """ - from specfact_cli.migrations.plan_migrator import get_latest_schema_version - - # Create manifest with latest schema version - manifest = BundleManifest( - versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - - # Convert features list to dict - features_dict: dict[str, Feature] = {f.key: f for f in plan_bundle.features} - - # Create and return ProjectBundle - return ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - idea=plan_bundle.idea, - business=plan_bundle.business, - product=plan_bundle.product, - features=features_dict, - clarifications=plan_bundle.clarifications, - ) - - -def _check_incremental_changes( - bundle_dir: Path, repo: Path, enrichment: Path | None, force: bool = False -) -> dict[str, bool] | None: - """Check for incremental changes and return what needs regeneration.""" - if force: - console.print("[yellow]⚠ Force mode enabled - regenerating all artifacts[/yellow]\n") - return None # None means regenerate everything - if not bundle_dir.exists(): - return None # No bundle exists, regenerate everything - # Note: enrichment doesn't force full regeneration - it only adds/updates features - # Contracts should only be regenerated if source files changed, not just because enrichment was applied - - from specfact_cli.utils.incremental_check import check_incremental_changes - - try: - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - # Load manifest first to get feature count for determinate progress - manifest_path = bundle_dir / "bundle.manifest.yaml" - num_features = 0 - total_ops = 100 # Default estimate for determinate progress - - if manifest_path.exists(): - try: - from specfact_cli.models.project import BundleManifest - from specfact_cli.utils.structured_io import load_structured_file - - manifest_data = load_structured_file(manifest_path) - manifest = BundleManifest.model_validate(manifest_data) - num_features = len(manifest.features) - - # Estimate total operations: manifest (1) + loading features (num_features) + file checks (num_features * ~2 avg files) - # Use a reasonable estimate for determinate progress - estimated_file_checks = num_features * 2 if num_features > 0 else 10 - total_ops = max(1 + num_features + estimated_file_checks, 10) # Minimum 10 for visibility - except Exception: - # If manifest load fails, use default estimate - pass - - # Create task with estimated total for determinate progress bar - task = progress.add_task("[cyan]Loading manifest and checking file changes...", total=total_ops) - - # Create progress callback to update the progress bar - def update_progress(current: int, total: int, message: str) -> None: - """Update progress bar with current status.""" - # Always update total when provided (we get better estimates as we progress) - # The total from incremental_check may be more accurate than our initial estimate - current_total = progress.tasks[task].total - if current_total is None: - # No total set yet, use the provided one - progress.update(task, total=total) - elif total != current_total: - # Total changed, update it (this handles both increases and decreases) - # We trust the incremental_check calculation as it has more accurate info - progress.update(task, total=total) - # Always update completed and description - progress.update(task, completed=current, description=f"[cyan]{message}[/cyan]") - - # Call check_incremental_changes with progress callback - incremental_changes = check_incremental_changes( - bundle_dir, repo, features=None, progress_callback=update_progress - ) - - # Update progress to completion - task_info = progress.tasks[task] - final_total = task_info.total if task_info.total and task_info.total > 0 else total_ops - progress.update( - task, - completed=final_total, - total=final_total, - description="[green]✓[/green] Change check complete", - ) - # Brief pause to show completion - time.sleep(0.1) - - # If enrichment is provided, we need to apply it even if no source files changed - # Mark bundle as needing regeneration to ensure enrichment is applied - if enrichment and incremental_changes and not any(incremental_changes.values()): - # Enrichment provided but no source changes - still need to apply enrichment - incremental_changes["bundle"] = True # Force bundle regeneration to apply enrichment - console.print(f"[green]✓[/green] Project bundle already exists: {bundle_dir}") - console.print("[dim]No source file changes detected, but enrichment will be applied[/dim]") - elif not any(incremental_changes.values()): - # No changes and no enrichment - can skip everything - console.print(f"[green]✓[/green] Project bundle already exists: {bundle_dir}") - console.print("[dim]No changes detected - all artifacts are up-to-date[/dim]") - console.print("[dim]Skipping regeneration of relationships, contracts, graph, and enrichment context[/dim]") - console.print( - "[dim]Use --force to force regeneration, or modify source files to trigger incremental update[/dim]" - ) - raise typer.Exit(0) - - changed_items = [key for key, value in incremental_changes.items() if value] - if changed_items: - console.print("[yellow]⚠[/yellow] Project bundle exists, but some artifacts need regeneration:") - for item in changed_items: - console.print(f" [dim]- {item}[/dim]") - console.print("[dim]Regenerating only changed artifacts...[/dim]\n") - - return incremental_changes - except KeyboardInterrupt: - raise - except typer.Exit: - raise - except Exception as e: - error_msg = str(e) if str(e) else f"{type(e).__name__}" - if "bundle.manifest.yaml" in error_msg or "Cannot determine bundle format" in error_msg: - console.print( - "[yellow]⚠ Incomplete bundle directory detected (likely from a failed save) - will regenerate all artifacts[/yellow]\n" - ) - else: - console.print( - f"[yellow]⚠ Existing bundle found but couldn't be loaded ({type(e).__name__}: {error_msg}) - will regenerate all artifacts[/yellow]\n" - ) - return None - - -def _validate_existing_features(plan_bundle: PlanBundle, repo: Path) -> dict[str, Any]: - """ - Validate existing features to check if they're still valid. - - Args: - plan_bundle: Plan bundle with features to validate - repo: Repository root path - - Returns: - Dictionary with validation results: - - 'valid_features': List of valid feature keys - - 'orphaned_features': List of feature keys whose source files no longer exist - - 'invalid_features': List of feature keys with validation issues - - 'missing_files': Dict mapping feature_key -> list of missing file paths - - 'total_checked': Total number of features checked - """ - - result: dict[str, Any] = { - "valid_features": [], - "orphaned_features": [], - "invalid_features": [], - "missing_files": {}, - "total_checked": len(plan_bundle.features), - } - - for feature in plan_bundle.features: - if not feature.source_tracking: - # Feature has no source tracking - mark as potentially invalid - result["invalid_features"].append(feature.key) - continue - - missing_files: list[str] = [] - has_any_files = False - - # Check implementation files - for impl_file in feature.source_tracking.implementation_files: - file_path = repo / impl_file - if file_path.exists(): - has_any_files = True - else: - missing_files.append(impl_file) - - # Check test files - for test_file in feature.source_tracking.test_files: - file_path = repo / test_file - if file_path.exists(): - has_any_files = True - else: - missing_files.append(test_file) - - # Validate feature structure - # Note: Features can legitimately have no stories if they're newly discovered - # Only mark as invalid if there are actual structural problems (missing key/title) - has_structure_issues = False - if not feature.key or not feature.title: - has_structure_issues = True - # Don't mark features with no stories as invalid - they may be newly discovered - # Stories will be added during analysis or enrichment - - # Classify feature - if not has_any_files and missing_files: - # All source files are missing - orphaned feature - result["orphaned_features"].append(feature.key) - result["missing_files"][feature.key] = missing_files - elif missing_files: - # Some files missing but not all - invalid but recoverable - result["invalid_features"].append(feature.key) - result["missing_files"][feature.key] = missing_files - elif has_structure_issues: - # Feature has actual structure issues (missing key/title) - result["invalid_features"].append(feature.key) - else: - # Feature is valid (has source_tracking, files exist, and has key/title) - # Note: Features without stories are still considered valid - result["valid_features"].append(feature.key) - - return result - - -def _load_existing_bundle(bundle_dir: Path) -> PlanBundle | None: - """Load existing project bundle and convert to PlanBundle.""" - from specfact_cli.models.plan import PlanBundle as PlanBundleModel - from specfact_cli.utils.progress import load_bundle_with_progress - - try: - existing_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - plan_bundle = PlanBundleModel( - version="1.0", - idea=existing_bundle.idea, - business=existing_bundle.business, - product=existing_bundle.product, - features=list(existing_bundle.features.values()), - metadata=None, - clarifications=existing_bundle.clarifications, - ) - total_stories = sum(len(f.stories) for f in plan_bundle.features) - console.print( - f"[green]✓[/green] Loaded existing bundle: {len(plan_bundle.features)} features, {total_stories} stories" - ) - return plan_bundle - except Exception as e: - console.print(f"[yellow]⚠ Could not load existing bundle: {e}[/yellow]") - console.print("[dim]Falling back to full codebase analysis...[/dim]\n") - return None - - -def _analyze_codebase( - repo: Path, - entry_point: Path | None, - bundle: str, - confidence: float, - key_format: str, - routing_result: Any, - incremental_callback: Any | None = None, -) -> PlanBundle: - """Analyze codebase using AI agent or AST fallback.""" - from specfact_cli.agents.analyze_agent import AnalyzeAgent - from specfact_cli.agents.registry import get_agent - from specfact_cli.analyzers.code_analyzer import CodeAnalyzer - - if routing_result.execution_mode == "agent": - console.print("[dim]Mode: CoPilot (AI-first import)[/dim]") - agent = get_agent("import from-code") - if agent and isinstance(agent, AnalyzeAgent): - context = { - "workspace": str(repo), - "current_file": None, - "selection": None, - } - _enhanced_context = agent.inject_context(context) - console.print("\n[cyan]🤖 AI-powered import (semantic understanding)...[/cyan]") - plan_bundle = agent.analyze_codebase(repo, confidence=confidence, plan_name=bundle) - console.print("[green]✓[/green] AI import complete") - return plan_bundle - console.print("[yellow]⚠ Agent not available, falling back to AST-based import[/yellow]") - - # AST-based import (CI/CD mode or fallback) - console.print("[dim]Mode: CI/CD (AST-based import)[/dim]") - console.print( - "\n[yellow]⏱️ Note: This analysis typically takes 2-5 minutes for large codebases (optimized for speed)[/yellow]" - ) - - # Phase 4.9: Create incremental callback for early feedback - def on_incremental_update(features_count: int, themes: list[str]) -> None: - """Callback for incremental results (Phase 4.9: Quick Start Optimization).""" - # Feature count updates are shown in the progress bar description, not as separate lines - # No intermediate messages needed - final summary provides all information - - # Create analyzer with incremental callback - analyzer = CodeAnalyzer( - repo, - confidence_threshold=confidence, - key_format=key_format, - plan_name=bundle, - entry_point=entry_point, - incremental_callback=incremental_callback or on_incremental_update, - ) - - # Display plugin status - plugin_status = analyzer.get_plugin_status() - if plugin_status: - from rich.table import Table - - console.print("\n[bold]Analysis Plugins:[/bold]") - plugin_table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1)) - plugin_table.add_column("Plugin", style="cyan", width=25) - plugin_table.add_column("Status", style="bold", width=12) - plugin_table.add_column("Details", style="dim", width=50) - - for plugin in plugin_status: - if plugin["enabled"] and plugin["used"]: - status = "[green]✓ Enabled[/green]" - elif plugin["enabled"] and not plugin["used"]: - status = "[yellow]⚠ Enabled (not used)[/yellow]" - else: - status = "[dim]⊘ Disabled[/dim]" - - plugin_table.add_row(plugin["name"], status, plugin["reason"]) - - console.print(plugin_table) - console.print() - - if entry_point: - console.print(f"[cyan]🔍 Analyzing codebase (scoped to {entry_point})...[/cyan]\n") - else: - console.print("[cyan]🔍 Analyzing codebase...[/cyan]\n") - - return analyzer.analyze() - - -def _update_source_tracking(plan_bundle: PlanBundle, repo: Path) -> None: - """Update source tracking with file hashes (parallelized).""" - import os - from concurrent.futures import ThreadPoolExecutor, as_completed - - from specfact_cli.utils.source_scanner import SourceArtifactScanner - - console.print("\n[cyan]🔗 Linking source files to features...[/cyan]") - scanner = SourceArtifactScanner(repo) - scanner.link_to_specs(plan_bundle.features, repo) - - def update_file_hash(feature: Feature, file_path: Path) -> None: - """Update hash for a single file (thread-safe).""" - if file_path.exists() and feature.source_tracking is not None: - feature.source_tracking.update_hash(file_path) - - hash_tasks: list[tuple[Feature, Path]] = [] - for feature in plan_bundle.features: - if feature.source_tracking: - for impl_file in feature.source_tracking.implementation_files: - hash_tasks.append((feature, repo / impl_file)) - for test_file in feature.source_tracking.test_files: - hash_tasks.append((feature, repo / test_file)) - - if hash_tasks: - import os - - from rich.progress import Progress - - from specfact_cli.utils.terminal import get_progress_config - - # In test mode, use sequential processing to avoid ThreadPoolExecutor deadlocks - is_test_mode = os.environ.get("TEST_MODE") == "true" - if is_test_mode: - # Sequential processing in test mode - avoids ThreadPoolExecutor deadlocks - import contextlib - - for feature, file_path in hash_tasks: - with contextlib.suppress(Exception): - update_file_hash(feature, file_path) - else: - max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(hash_tasks))) - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - hash_task = progress.add_task( - f"[cyan]Computing file hashes for {len(hash_tasks)} files...", - total=len(hash_tasks), - ) - - executor = ThreadPoolExecutor(max_workers=max_workers) - interrupted = False - completed_count = 0 - try: - future_to_task = { - executor.submit(update_file_hash, feature, file_path): (feature, file_path) - for feature, file_path in hash_tasks - } - try: - for future in as_completed(future_to_task): - try: - future.result() - completed_count += 1 - progress.update( - hash_task, - completed=completed_count, - description=f"[cyan]Computing file hashes... ({completed_count}/{len(hash_tasks)})", - ) - except KeyboardInterrupt: - interrupted = True - for f in future_to_task: - if not f.done(): - f.cancel() - break - except Exception: - completed_count += 1 - progress.update(hash_task, completed=completed_count) - except KeyboardInterrupt: - interrupted = True - for f in future_to_task: - if not f.done(): - f.cancel() - if interrupted: - raise KeyboardInterrupt - except KeyboardInterrupt: - interrupted = True - executor.shutdown(wait=False, cancel_futures=True) - raise - finally: - if not interrupted: - progress.update( - hash_task, - completed=len(hash_tasks), - description=f"[green]✓[/green] Computed hashes for {len(hash_tasks)} files", - ) - progress.remove_task(hash_task) - executor.shutdown(wait=True) - else: - executor.shutdown(wait=False) - - # Update sync timestamps (fast operation, no progress needed) - for feature in plan_bundle.features: - if feature.source_tracking: - feature.source_tracking.update_sync_timestamp() - - console.print("[green]✓[/green] Source tracking complete") - - -def _extract_relationships_and_graph( - repo: Path, - entry_point: Path | None, - bundle_dir: Path, - incremental_changes: dict[str, bool] | None, - plan_bundle: PlanBundle | None, - should_regenerate_relationships: bool, - should_regenerate_graph: bool, - include_tests: bool = False, -) -> tuple[dict[str, Any], dict[str, Any] | None]: - """Extract relationships and graph dependencies.""" - relationships: dict[str, Any] = {} - graph_summary: dict[str, Any] | None = None - - if not (should_regenerate_relationships or should_regenerate_graph): - console.print("\n[dim]⏭ Skipping relationships and graph analysis (no changes detected)[/dim]") - enrichment_context_path = bundle_dir / "enrichment_context.md" - if enrichment_context_path.exists(): - relationships = {"imports": {}, "interfaces": {}, "routes": {}} - return relationships, graph_summary - - console.print("\n[cyan]🔍 Enhanced analysis: Extracting relationships, contracts, and graph dependencies...[/cyan]") - from rich.progress import Progress, SpinnerColumn, TextColumn - - from specfact_cli.analyzers.graph_analyzer import GraphAnalyzer - from specfact_cli.analyzers.relationship_mapper import RelationshipMapper - from specfact_cli.utils.optional_deps import check_cli_tool_available - from specfact_cli.utils.terminal import get_progress_config - - # Show spinner while checking pyan3 and collecting file hashes - _progress_columns, progress_kwargs = get_progress_config() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - **progress_kwargs, - ) as setup_progress: - setup_task = setup_progress.add_task("[cyan]Preparing enhanced analysis...", total=None) - - pyan3_available, _ = check_cli_tool_available("pyan3") - if not pyan3_available: - console.print( - "[dim]💡 Note: Enhanced analysis tool pyan3 is not available (call graph analysis will be skipped)[/dim]" - ) - console.print("[dim] Install with: pip install pyan3[/dim]") - - # Pre-compute file hashes for caching (reuse from source tracking if available) - setup_progress.update(setup_task, description="[cyan]Collecting file hashes for caching...") - file_hashes_cache: dict[str, str] = {} - if plan_bundle: - # Collect file hashes from source tracking - for feature in plan_bundle.features: - if feature.source_tracking: - file_hashes_cache.update(feature.source_tracking.file_hashes) - - relationship_mapper = RelationshipMapper(repo, file_hashes_cache=file_hashes_cache) - - # Discover and filter Python files with progress - changed_files: set[Path] = set() - if incremental_changes and plan_bundle: - setup_progress.update(setup_task, description="[cyan]Checking for changed files...") - from specfact_cli.utils.incremental_check import get_changed_files - - # get_changed_files iterates through all features and checks file hashes - # This can be slow for large bundles - show progress - changed_files_dict = get_changed_files(bundle_dir, repo, list(plan_bundle.features)) - setup_progress.update(setup_task, description="[cyan]Collecting changed file paths...") - for feature_changes in changed_files_dict.values(): - for file_path_str in feature_changes: - clean_path = file_path_str.replace(" (deleted)", "") - file_path = repo / clean_path - if file_path.exists(): - changed_files.add(file_path) - - if changed_files: - python_files = list(changed_files) - setup_progress.update(setup_task, description=f"[green]✓[/green] Found {len(python_files)} changed file(s)") - else: - setup_progress.update(setup_task, description="[cyan]Discovering Python files...") - # This can be slow for large codebases - show progress - python_files = list(repo.rglob("*.py")) - setup_progress.update(setup_task, description=f"[cyan]Filtering {len(python_files)} files...") - - if entry_point: - python_files = [f for f in python_files if entry_point in f.parts] - - # Filter files based on --include-tests/--exclude-tests flag - # Default: Exclude test files (they're validation artifacts, not specifications) - # --include-tests: Include test files in dependency graph (only if needed) - # Rationale for excluding tests by default: - # - Test files are consumers of production code (not producers) - # - Test files import production code, but production code doesn't import tests - # - Interfaces and routes are defined in production code, not tests - # - Dependency graph flows from production code, so skipping tests has minimal impact - # - Test files are never extracted as features (they validate code, they don't define it) - if not include_tests: - # Exclude test files when --exclude-tests is specified (default) - # Test files are validation artifacts, not specifications - python_files = [ - f - for f in python_files - if not any( - skip in str(f) - for skip in [ - "/test_", - "/tests/", - "/test/", # Handle singular "test/" directory (e.g., SQLAlchemy) - "/vendor/", - "/.venv/", - "/venv/", - "/node_modules/", - "/__pycache__/", - ] - ) - and not f.name.startswith("test_") # Exclude test_*.py files - and not f.name.endswith("_test.py") # Exclude *_test.py files - ] - else: - # Default: Include test files, but still filter vendor/venv files - python_files = [ - f - for f in python_files - if not any( - skip in str(f) for skip in ["/vendor/", "/.venv/", "/venv/", "/node_modules/", "/__pycache__/"] - ) - ] - setup_progress.update( - setup_task, description=f"[green]✓[/green] Ready to analyze {len(python_files)} files" - ) - - setup_progress.remove_task(setup_task) - - if changed_files: - console.print(f"[dim]Analyzing {len(python_files)} changed file(s) for relationships...[/dim]") - else: - console.print(f"[dim]Analyzing {len(python_files)} file(s) for relationships...[/dim]") - - # Analyze relationships in parallel with progress reporting - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - import time - - # Step 1: Analyze relationships - relationships_task = progress.add_task( - f"[cyan]Analyzing relationships in {len(python_files)} files...", - total=len(python_files), - ) - - def update_relationships_progress(completed: int, total: int) -> None: - """Update progress for relationship analysis.""" - progress.update( - relationships_task, - completed=completed, - description=f"[cyan]Analyzing relationships... ({completed}/{total} files)", - ) - - relationships = relationship_mapper.analyze_files(python_files, progress_callback=update_relationships_progress) - progress.update( - relationships_task, - completed=len(python_files), - total=len(python_files), - description=f"[green]✓[/green] Relationship analysis complete: {len(relationships['imports'])} files mapped", - ) - # Keep final progress bar visible instead of removing it - time.sleep(0.1) # Brief pause to show completion - - # Graph analysis is optional and can be slow - only run if explicitly needed - # Skip by default for faster imports (can be enabled with --with-graph flag in future) - if should_regenerate_graph and pyan3_available: - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - graph_task = progress.add_task( - f"[cyan]Building dependency graph from {len(python_files)} files...", - total=len(python_files) * 2, # Two phases: AST imports + call graphs - ) - - def update_graph_progress(completed: int, total: int) -> None: - """Update progress for graph building.""" - progress.update( - graph_task, - completed=completed, - description=f"[cyan]Building dependency graph... ({completed}/{total})", - ) - - graph_analyzer = GraphAnalyzer(repo, file_hashes_cache=file_hashes_cache) - graph_analyzer.build_dependency_graph(python_files, progress_callback=update_graph_progress) - graph_summary = graph_analyzer.get_graph_summary() - if graph_summary: - progress.update( - graph_task, - completed=len(python_files) * 2, - total=len(python_files) * 2, - description=f"[green]✓[/green] Dependency graph complete: {graph_summary.get('nodes', 0)} modules, {graph_summary.get('edges', 0)} dependencies", - ) - # Keep final progress bar visible instead of removing it - time.sleep(0.1) # Brief pause to show completion - relationships["dependency_graph"] = graph_summary - relationships["call_graphs"] = graph_analyzer.call_graphs - elif should_regenerate_graph and not pyan3_available: - console.print("[dim]⏭ Skipping graph analysis (pyan3 not available)[/dim]") - - return relationships, graph_summary - - -def _extract_contracts( - repo: Path, - bundle_dir: Path, - plan_bundle: PlanBundle, - should_regenerate_contracts: bool, - record_event: Any, - force: bool = False, -) -> dict[str, dict[str, Any]]: - """Extract OpenAPI contracts from features.""" - import os - from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed - - from specfact_cli.generators.openapi_extractor import OpenAPIExtractor - from specfact_cli.generators.test_to_openapi import OpenAPITestConverter - - contracts_generated = 0 - contracts_dir = bundle_dir / "contracts" - contracts_dir.mkdir(parents=True, exist_ok=True) - contracts_data: dict[str, dict[str, Any]] = {} - - # Load existing contracts if not regenerating (parallelized) - if not should_regenerate_contracts: - console.print("\n[dim]⏭ Skipping contract extraction (no changes detected)[/dim]") - - def load_contract(feature: Feature) -> tuple[str, dict[str, Any] | None]: - """Load contract for a single feature (thread-safe).""" - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - try: - import yaml - - contract_data = yaml.safe_load(contract_path.read_text()) - return (feature.key, contract_data) - except KeyboardInterrupt: - raise - except Exception: - pass - return (feature.key, None) - - features_with_contracts = [f for f in plan_bundle.features if f.contract] - if features_with_contracts: - import os - from concurrent.futures import ThreadPoolExecutor, as_completed - - from rich.progress import Progress - - from specfact_cli.utils.terminal import get_progress_config - - # In test mode, use sequential processing to avoid ThreadPoolExecutor deadlocks - is_test_mode = os.environ.get("TEST_MODE") == "true" - existing_contracts_count = 0 - - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - load_task = progress.add_task( - f"[cyan]Loading {len(features_with_contracts)} existing contract(s)...", - total=len(features_with_contracts), - ) - - if is_test_mode: - # Sequential processing in test mode - avoids ThreadPoolExecutor deadlocks - for idx, feature in enumerate(features_with_contracts): - try: - feature_key, contract_data = load_contract(feature) - if contract_data: - contracts_data[feature_key] = contract_data - existing_contracts_count += 1 - except Exception: - pass - progress.update(load_task, completed=idx + 1) - else: - max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(features_with_contracts))) - executor = ThreadPoolExecutor(max_workers=max_workers) - interrupted = False - completed_count = 0 - try: - future_to_feature = { - executor.submit(load_contract, feature): feature for feature in features_with_contracts - } - try: - for future in as_completed(future_to_feature): - try: - feature_key, contract_data = future.result() - completed_count += 1 - progress.update(load_task, completed=completed_count) - if contract_data: - contracts_data[feature_key] = contract_data - existing_contracts_count += 1 - except KeyboardInterrupt: - interrupted = True - for f in future_to_feature: - if not f.done(): - f.cancel() - break - except Exception: - completed_count += 1 - progress.update(load_task, completed=completed_count) - except KeyboardInterrupt: - interrupted = True - for f in future_to_feature: - if not f.done(): - f.cancel() - if interrupted: - raise KeyboardInterrupt - except KeyboardInterrupt: - interrupted = True - executor.shutdown(wait=False, cancel_futures=True) - raise - finally: - if not interrupted: - progress.update( - load_task, - completed=len(features_with_contracts), - description=f"[green]✓[/green] Loaded {existing_contracts_count} contract(s)", - ) - executor.shutdown(wait=True) - else: - executor.shutdown(wait=False) - - if existing_contracts_count == 0: - progress.remove_task(load_task) - - if existing_contracts_count > 0: - console.print(f"[green]✓[/green] Loaded {existing_contracts_count} existing contract(s) from bundle") - - # Extract contracts if needed - if should_regenerate_contracts: - # When force=True, skip hash checking and process all features with source files - if force: - # Force mode: process all features with implementation files - features_with_files = [ - f for f in plan_bundle.features if f.source_tracking and f.source_tracking.implementation_files - ] - else: - # Filter features that need contract regeneration (check file hashes) - # Pre-compute all file hashes in parallel to avoid redundant I/O - import os - from concurrent.futures import ThreadPoolExecutor, as_completed - - # Collect all unique files that need hash checking - files_to_check: set[Path] = set() - feature_to_files: dict[str, list[Path]] = {} # Use feature key (str) instead of Feature object - feature_objects: dict[str, Feature] = {} # Keep reference to Feature objects - - for f in plan_bundle.features: - if f.source_tracking and f.source_tracking.implementation_files: - feature_files: list[Path] = [] - for impl_file in f.source_tracking.implementation_files: - file_path = repo / impl_file - if file_path.exists(): - files_to_check.add(file_path) - feature_files.append(file_path) - if feature_files: - feature_to_files[f.key] = feature_files - feature_objects[f.key] = f - - # Pre-compute all file hashes in parallel (batch operation) - current_hashes: dict[Path, str] = {} - if files_to_check: - is_test_mode = os.environ.get("TEST_MODE") == "true" - - def compute_file_hash(file_path: Path) -> tuple[Path, str | None]: - """Compute hash for a single file (thread-safe).""" - try: - import hashlib - - return (file_path, hashlib.sha256(file_path.read_bytes()).hexdigest()) - except Exception: - return (file_path, None) - - if is_test_mode: - # Sequential in test mode - for file_path in files_to_check: - _, hash_value = compute_file_hash(file_path) - if hash_value: - current_hashes[file_path] = hash_value - else: - # Parallel in production mode - max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(files_to_check))) - with ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = {executor.submit(compute_file_hash, fp): fp for fp in files_to_check} - for future in as_completed(futures): - try: - file_path, hash_value = future.result() - if hash_value: - current_hashes[file_path] = hash_value - except Exception: - pass - - # Now check features using pre-computed hashes (no file I/O) - features_with_files = [] - for feature_key, feature_files in feature_to_files.items(): - f = feature_objects[feature_key] - # Check if contract needs regeneration (file changed or contract missing) - needs_regeneration = False - if not f.contract: - needs_regeneration = True - else: - # Check if any source file changed - contract_path = bundle_dir / f.contract - if not contract_path.exists(): - needs_regeneration = True - else: - # Check if any implementation file changed using pre-computed hashes - if f.source_tracking: - for file_path in feature_files: - if file_path in current_hashes: - stored_hash = f.source_tracking.file_hashes.get(str(file_path)) - if stored_hash != current_hashes[file_path]: - needs_regeneration = True - break - else: - # File exists but hash computation failed, assume changed - needs_regeneration = True - break - if needs_regeneration: - features_with_files.append(f) - else: - features_with_files: list[Feature] = [] - - if features_with_files and should_regenerate_contracts: - import os - - # In test mode, use sequential processing to avoid ThreadPoolExecutor deadlocks - is_test_mode = os.environ.get("TEST_MODE") == "true" - pool_mode = os.environ.get("SPECFACT_CONTRACT_POOL", "process").lower() - use_process_pool = not is_test_mode and pool_mode != "thread" and len(features_with_files) > 1 - # Define max_workers for non-test mode (always defined to satisfy type checker) - max_workers = 1 - if is_test_mode: - console.print( - f"[cyan]📋 Extracting contracts from {len(features_with_files)} features (sequential mode)...[/cyan]" - ) - else: - max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(features_with_files))) - pool_label = "process" if use_process_pool else "thread" - console.print( - f"[cyan]📋 Extracting contracts from {len(features_with_files)} features (using {max_workers} {pool_label} worker(s))...[/cyan]" - ) - - from rich.progress import Progress - - from specfact_cli.utils.terminal import get_progress_config - - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - task = progress.add_task("[cyan]Extracting contracts...", total=len(features_with_files)) - if use_process_pool: - feature_lookup: dict[str, Feature] = {f.key: f for f in features_with_files} - executor = ProcessPoolExecutor( - max_workers=max_workers, - initializer=_init_contract_worker, - initargs=(str(repo), str(contracts_dir)), - ) - interrupted = False - try: - future_to_feature_key = { - executor.submit(_extract_contract_worker, f.model_dump()): f.key for f in features_with_files - } - completed_count = 0 - total_features = len(features_with_files) - pending_count = total_features - try: - for future in as_completed(future_to_feature_key): - try: - feature_key, openapi_spec = future.result() - completed_count += 1 - pending_count = total_features - completed_count - feature_display = feature_key[:50] + "..." if len(feature_key) > 50 else feature_key - - if openapi_spec: - progress.update( - task, - completed=completed_count, - description=f"[cyan]Extracted contract from {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)", - ) - feature = feature_lookup.get(feature_key) - if feature: - contract_ref = f"contracts/{feature_key}.openapi.yaml" - feature.contract = contract_ref - contracts_data[feature_key] = openapi_spec - contracts_generated += 1 - else: - progress.update( - task, - completed=completed_count, - description=f"[dim]No contract for {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", - ) - except KeyboardInterrupt: - interrupted = True - for f in future_to_feature_key: - if not f.done(): - f.cancel() - break - except Exception as e: - completed_count += 1 - pending_count = total_features - completed_count - feature_key_for_display = future_to_feature_key.get(future, "unknown") - feature_display = ( - feature_key_for_display[:50] + "..." - if len(feature_key_for_display) > 50 - else feature_key_for_display - ) - progress.update( - task, - completed=completed_count, - description=f"[dim]⚠ Failed: {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", - ) - console.print( - f"[dim]⚠ Warning: Failed to process feature {feature_key_for_display}: {e}[/dim]" - ) - except KeyboardInterrupt: - interrupted = True - for f in future_to_feature_key: - if not f.done(): - f.cancel() - if interrupted: - raise KeyboardInterrupt - except KeyboardInterrupt: - interrupted = True - executor.shutdown(wait=False, cancel_futures=True) - raise - finally: - if not interrupted: - executor.shutdown(wait=True) - progress.update( - task, - completed=len(features_with_files), - total=len(features_with_files), - description=f"[green]✓[/green] Contract extraction complete: {contracts_generated} contract(s) generated from {len(features_with_files)} feature(s)", - ) - time.sleep(0.1) - else: - executor.shutdown(wait=False) - else: - openapi_extractor = OpenAPIExtractor(repo) - test_converter = OpenAPITestConverter(repo) - - def process_feature(feature: Feature) -> tuple[str, dict[str, Any] | None]: - """Process a single feature and return (feature_key, openapi_spec or None).""" - try: - openapi_spec = openapi_extractor.extract_openapi_from_code(repo, feature) - if openapi_spec.get("paths"): - test_examples: dict[str, Any] = {} - has_test_functions = any(story.test_functions for story in feature.stories) or ( - feature.source_tracking and feature.source_tracking.test_functions - ) - - if has_test_functions: - all_test_functions: list[str] = [] - for story in feature.stories: - if story.test_functions: - all_test_functions.extend(story.test_functions) - if feature.source_tracking and feature.source_tracking.test_functions: - all_test_functions.extend(feature.source_tracking.test_functions) - if all_test_functions: - test_examples = test_converter.extract_examples_from_tests(all_test_functions) - - if test_examples: - openapi_spec = openapi_extractor.add_test_examples(openapi_spec, test_examples) - - contract_filename = f"{feature.key}.openapi.yaml" - contract_path = contracts_dir / contract_filename - openapi_extractor.save_openapi_contract(openapi_spec, contract_path) - return (feature.key, openapi_spec) - except KeyboardInterrupt: - raise - except Exception: - pass - return (feature.key, None) - - if is_test_mode: - # Sequential processing in test mode - avoids ThreadPoolExecutor deadlocks - completed_count = 0 - for idx, feature in enumerate(features_with_files, 1): - try: - feature_display = feature.key[:60] + "..." if len(feature.key) > 60 else feature.key - progress.update( - task, - completed=completed_count, - description=f"[cyan]Extracting contract from {feature_display}... ({idx}/{len(features_with_files)})", - ) - feature_key, openapi_spec = process_feature(feature) - completed_count += 1 - progress.update( - task, - completed=completed_count, - description=f"[cyan]Extracted contract from {feature_display} ({completed_count}/{len(features_with_files)})", - ) - if openapi_spec: - contract_ref = f"contracts/{feature_key}.openapi.yaml" - feature.contract = contract_ref - contracts_data[feature_key] = openapi_spec - contracts_generated += 1 - except Exception as e: - completed_count += 1 - progress.update( - task, - completed=completed_count, - description=f"[dim]⚠ Failed: {feature.key[:50]}... ({completed_count}/{len(features_with_files)})[/dim]", - ) - console.print(f"[dim]⚠ Warning: Failed to process feature {feature.key}: {e}[/dim]") - progress.update( - task, - completed=len(features_with_files), - total=len(features_with_files), - description=f"[green]✓[/green] Contract extraction complete: {contracts_generated} contract(s) generated from {len(features_with_files)} feature(s)", - ) - time.sleep(0.1) - else: - feature_lookup_thread: dict[str, Feature] = {f.key: f for f in features_with_files} - executor = ThreadPoolExecutor(max_workers=max_workers) - interrupted = False - try: - future_to_feature = {executor.submit(process_feature, f): f for f in features_with_files} - completed_count = 0 - total_features = len(features_with_files) - pending_count = total_features - try: - for future in as_completed(future_to_feature): - try: - feature_key, openapi_spec = future.result() - completed_count += 1 - pending_count = total_features - completed_count - feature = feature_lookup_thread.get(feature_key) - feature_display = feature_key[:50] + "..." if len(feature_key) > 50 else feature_key - - if openapi_spec: - progress.update( - task, - completed=completed_count, - description=f"[cyan]Extracted contract from {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)", - ) - if feature: - contract_ref = f"contracts/{feature_key}.openapi.yaml" - feature.contract = contract_ref - contracts_data[feature_key] = openapi_spec - contracts_generated += 1 - else: - progress.update( - task, - completed=completed_count, - description=f"[dim]No contract for {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", - ) - except KeyboardInterrupt: - interrupted = True - for f in future_to_feature: - if not f.done(): - f.cancel() - break - except Exception as e: - completed_count += 1 - pending_count = total_features - completed_count - feature_for_error = future_to_feature.get(future) - feature_key_for_display = feature_for_error.key if feature_for_error else "unknown" - feature_display = ( - feature_key_for_display[:50] + "..." - if len(feature_key_for_display) > 50 - else feature_key_for_display - ) - progress.update( - task, - completed=completed_count, - description=f"[dim]⚠ Failed: {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", - ) - console.print( - f"[dim]⚠ Warning: Failed to process feature {feature_key_for_display}: {e}[/dim]" - ) - except KeyboardInterrupt: - interrupted = True - for f in future_to_feature: - if not f.done(): - f.cancel() - if interrupted: - raise KeyboardInterrupt - except KeyboardInterrupt: - interrupted = True - executor.shutdown(wait=False, cancel_futures=True) - raise - finally: - if not interrupted: - executor.shutdown(wait=True) - progress.update( - task, - completed=len(features_with_files), - total=len(features_with_files), - description=f"[green]✓[/green] Contract extraction complete: {contracts_generated} contract(s) generated from {len(features_with_files)} feature(s)", - ) - time.sleep(0.1) - else: - executor.shutdown(wait=False) - - elif should_regenerate_contracts: - console.print("[dim]No features with implementation files found for contract extraction[/dim]") - - # Report contract status - if should_regenerate_contracts: - if contracts_generated > 0: - console.print(f"[green]✓[/green] Generated {contracts_generated} contract scaffolds") - elif not features_with_files: - console.print("[dim]No API contracts detected in codebase[/dim]") - - return contracts_data - - -def _build_enrichment_context( - bundle_dir: Path, - repo: Path, - plan_bundle: PlanBundle, - relationships: dict[str, Any], - contracts_data: dict[str, dict[str, Any]], - should_regenerate_enrichment: bool, - record_event: Any, -) -> Path: - """Build enrichment context for LLM.""" - import hashlib - - context_path = bundle_dir / "enrichment_context.md" - - # Check if context needs regeneration using file hash - needs_regeneration = should_regenerate_enrichment - if not needs_regeneration and context_path.exists(): - # Check if any source data changed (relationships, contracts, features) - # This can be slow for large bundles - show progress - from rich.progress import SpinnerColumn, TextColumn - - from specfact_cli.utils.terminal import get_progress_config - - _progress_columns, progress_kwargs = get_progress_config() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - **progress_kwargs, - ) as check_progress: - check_task = check_progress.add_task("[cyan]Checking if enrichment context changed...", total=None) - try: - existing_hash = hashlib.sha256(context_path.read_bytes()).hexdigest() - # Build temporary context to compare hash - from specfact_cli.utils.enrichment_context import build_enrichment_context - - check_progress.update(check_task, description="[cyan]Building temporary context for comparison...") - temp_context = build_enrichment_context( - plan_bundle, relationships=relationships, contracts=contracts_data - ) - temp_md = temp_context.to_markdown() - new_hash = hashlib.sha256(temp_md.encode("utf-8")).hexdigest() - if existing_hash != new_hash: - needs_regeneration = True - except Exception: - # If we can't check, regenerate to be safe - needs_regeneration = True - - if needs_regeneration: - console.print("\n[cyan]📊 Building enrichment context...[/cyan]") - # Building context can be slow for large bundles - show progress - from rich.progress import SpinnerColumn, TextColumn - - from specfact_cli.utils.enrichment_context import build_enrichment_context - from specfact_cli.utils.terminal import get_progress_config - - _progress_columns, progress_kwargs = get_progress_config() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - console=console, - **progress_kwargs, - ) as build_progress: - build_task = build_progress.add_task( - f"[cyan]Building context from {len(plan_bundle.features)} features...", total=None - ) - enrichment_context = build_enrichment_context( - plan_bundle, relationships=relationships, contracts=contracts_data - ) - build_progress.update(build_task, description="[cyan]Converting to markdown...") - _enrichment_context_md = enrichment_context.to_markdown() - build_progress.update(build_task, description="[cyan]Writing to file...") - context_path.write_text(_enrichment_context_md, encoding="utf-8") - try: - rel_path = context_path.relative_to(repo.resolve()) - console.print(f"[green]✓[/green] Enrichment context saved to: {rel_path}") - except ValueError: - console.print(f"[green]✓[/green] Enrichment context saved to: {context_path}") - else: - console.print("\n[dim]⏭ Skipping enrichment context generation (no changes detected)[/dim]") - _ = context_path.read_text(encoding="utf-8") if context_path.exists() else "" - - record_event( - { - "enrichment_context_available": True, - "relationships_files": len(relationships.get("imports", {})), - "contracts_count": len(contracts_data), - } - ) - return context_path - - -def _apply_enrichment( - enrichment: Path, - plan_bundle: PlanBundle, - record_event: Any, -) -> PlanBundle: - """Apply enrichment report to plan bundle.""" - if not enrichment.exists(): - console.print(f"[bold red]✗ Enrichment report not found: {enrichment}[/bold red]") - raise typer.Exit(1) - - console.print(f"\n[cyan]📝 Applying enrichment from: {enrichment}[/cyan]") - from specfact_cli.utils.enrichment_parser import EnrichmentParser, apply_enrichment - - try: - parser = EnrichmentParser() - enrichment_report = parser.parse(enrichment) - plan_bundle = apply_enrichment(plan_bundle, enrichment_report) - - if enrichment_report.missing_features: - console.print(f"[green]✓[/green] Added {len(enrichment_report.missing_features)} missing features") - if enrichment_report.confidence_adjustments: - console.print( - f"[green]✓[/green] Adjusted confidence for {len(enrichment_report.confidence_adjustments)} features" - ) - if enrichment_report.business_context.get("priorities") or enrichment_report.business_context.get( - "constraints" - ): - console.print("[green]✓[/green] Applied business context") - - record_event( - { - "enrichment_applied": True, - "features_added": len(enrichment_report.missing_features), - "confidence_adjusted": len(enrichment_report.confidence_adjustments), - } - ) - except Exception as e: - console.print(f"[bold red]✗ Failed to apply enrichment: {e}[/bold red]") - raise typer.Exit(1) from e - - return plan_bundle - - -def _save_bundle_if_needed( - plan_bundle: PlanBundle, - bundle: str, - bundle_dir: Path, - incremental_changes: dict[str, bool] | None, - should_regenerate_relationships: bool, - should_regenerate_graph: bool, - should_regenerate_contracts: bool, - should_regenerate_enrichment: bool, -) -> None: - """Save project bundle only if something changed.""" - any_artifact_changed = ( - should_regenerate_relationships - or should_regenerate_graph - or should_regenerate_contracts - or should_regenerate_enrichment - ) - should_regenerate_bundle = ( - incremental_changes is None or any_artifact_changed or incremental_changes.get("bundle", False) - ) - - if should_regenerate_bundle: - console.print("\n[cyan]💾 Compiling and saving project bundle...[/cyan]") - project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) - save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) - else: - console.print("\n[dim]⏭ Skipping bundle save (no changes detected)[/dim]") - - -def _validate_bundle_contracts(bundle_dir: Path, plan_bundle: PlanBundle) -> tuple[int, int]: - """ - Validate OpenAPI/AsyncAPI contracts in bundle with Specmatic if available. - - Args: - bundle_dir: Path to bundle directory - plan_bundle: Plan bundle containing features with contract references - - Returns: - Tuple of (validated_count, failed_count) - """ - import asyncio - - from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic - - # Skip validation in test mode to avoid long-running subprocess calls - if os.environ.get("TEST_MODE") == "true": - return 0, 0 - - is_available, _error_msg = check_specmatic_available() - if not is_available: - return 0, 0 - - validated_count = 0 - failed_count = 0 - contract_files = [] - - # Collect contract files from features - # PlanBundle.features is a list, not a dict - features_iter = plan_bundle.features.values() if isinstance(plan_bundle.features, dict) else plan_bundle.features - for feature in features_iter: - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - contract_files.append((contract_path, feature.key)) - - if not contract_files: - return 0, 0 - - # Limit validation to first 5 contracts to avoid long delays - contracts_to_validate = contract_files[:5] - - console.print(f"\n[cyan]🔍 Validating {len(contracts_to_validate)} contract(s) in bundle with Specmatic...[/cyan]") - - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - validation_task = progress.add_task( - "[cyan]Validating contracts...", - total=len(contracts_to_validate), - ) - - for idx, (contract_path, _feature_key) in enumerate(contracts_to_validate): - progress.update( - validation_task, - completed=idx, - description=f"[cyan]Validating {contract_path.name}...", - ) - try: - result = asyncio.run(validate_spec_with_specmatic(contract_path)) - if result.is_valid: - validated_count += 1 - else: - failed_count += 1 - if result.errors: - console.print(f" [yellow]⚠[/yellow] {contract_path.name} has validation issues") - for error in result.errors[:2]: - console.print(f" - {error}") - except Exception as e: - failed_count += 1 - console.print(f" [yellow]⚠[/yellow] Validation error for {contract_path.name}: {e!s}") - - progress.update( - validation_task, - completed=len(contracts_to_validate), - description=f"[green]✓[/green] Validated {validated_count} contract(s)", - ) - progress.remove_task(validation_task) - - if len(contract_files) > 5: - console.print( - f"[dim]... and {len(contract_files) - 5} more contract(s) (run 'specfact spec validate' to validate all)[/dim]" - ) - - return validated_count, failed_count - - -def _validate_api_specs(repo: Path, bundle_dir: Path | None = None, plan_bundle: PlanBundle | None = None) -> None: - """ - Validate OpenAPI/AsyncAPI specs with Specmatic if available. - - Validates both repo-level spec files and bundle contracts if provided. - - Args: - repo: Repository path - bundle_dir: Optional bundle directory path - plan_bundle: Optional plan bundle for contract validation - """ - import asyncio - - spec_files = [] - for pattern in [ - "**/openapi.yaml", - "**/openapi.yml", - "**/openapi.json", - "**/asyncapi.yaml", - "**/asyncapi.yml", - "**/asyncapi.json", - ]: - spec_files.extend(repo.glob(pattern)) - - validated_contracts = 0 - failed_contracts = 0 - - # Validate bundle contracts if provided - if bundle_dir and plan_bundle: - validated_contracts, failed_contracts = _validate_bundle_contracts(bundle_dir, plan_bundle) - - # Validate repo-level spec files - if spec_files: - console.print(f"\n[cyan]🔍 Found {len(spec_files)} API specification file(s) in repository[/cyan]") - from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic - - is_available, error_msg = check_specmatic_available() - if is_available: - for spec_file in spec_files[:3]: - console.print(f"[dim]Validating {spec_file.relative_to(repo)} with Specmatic...[/dim]") - try: - result = asyncio.run(validate_spec_with_specmatic(spec_file)) - if result.is_valid: - console.print(f" [green]✓[/green] {spec_file.name} is valid") - else: - console.print(f" [yellow]⚠[/yellow] {spec_file.name} has validation issues") - if result.errors: - for error in result.errors[:2]: - console.print(f" - {error}") - except Exception as e: - console.print(f" [yellow]⚠[/yellow] Validation error: {e!s}") - if len(spec_files) > 3: - console.print( - f"[dim]... and {len(spec_files) - 3} more spec file(s) (run 'specfact spec validate' to validate all)[/dim]" - ) - console.print("[dim]💡 Tip: Run 'specfact spec mock' to start a mock server for development[/dim]") - else: - console.print(f"[dim]💡 Tip: Install Specmatic to validate API specs: {error_msg}[/dim]") - elif validated_contracts > 0 or failed_contracts > 0: - # Only show mock server tip if we validated contracts - console.print("[dim]💡 Tip: Run 'specfact spec mock' to start a mock server for development[/dim]") - - -def _suggest_next_steps(repo: Path, bundle: str, plan_bundle: PlanBundle | None) -> None: - """ - Suggest next steps after first import (Phase 4.9: Quick Start Optimization). - - Args: - repo: Repository path - bundle: Bundle name - plan_bundle: Generated plan bundle - """ - if plan_bundle is None: - return - - console.print("\n[bold cyan]📋 Next Steps:[/bold cyan]") - console.print("[dim]Here are some commands you might want to run next:[/dim]\n") - - # Check if this is a first run (no existing bundle) - from specfact_cli.utils.structure import SpecFactStructure - - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - is_first_run = not (bundle_dir / "bundle.manifest.yaml").exists() - - if is_first_run: - console.print(" [yellow]→[/yellow] [bold]Review your plan:[/bold]") - console.print(f" specfact plan review {bundle}") - console.print(" [dim]Review and refine the generated plan bundle[/dim]\n") - - console.print(" [yellow]→[/yellow] [bold]Compare with code:[/bold]") - console.print(f" specfact plan compare --bundle {bundle}") - console.print(" [dim]Detect deviations between plan and code[/dim]\n") - - console.print(" [yellow]→[/yellow] [bold]Validate SDD:[/bold]") - console.print(f" specfact enforce sdd {bundle}") - console.print(" [dim]Check for violations and coverage thresholds[/dim]\n") - else: - console.print(" [yellow]→[/yellow] [bold]Review changes:[/bold]") - console.print(f" specfact plan review {bundle}") - console.print(" [dim]Review updates to your plan bundle[/dim]\n") - - console.print(" [yellow]→[/yellow] [bold]Check deviations:[/bold]") - console.print(f" specfact plan compare --bundle {bundle}") - console.print(" [dim]See what changed since last import[/dim]\n") - - -def _suggest_constitution_bootstrap(repo: Path) -> None: - """Suggest or generate constitution bootstrap for brownfield imports.""" - specify_dir = repo / ".specify" / "memory" - constitution_path = specify_dir / "constitution.md" - if not constitution_path.exists() or ( - constitution_path.exists() and constitution_path.read_text(encoding="utf-8").strip() in ("", "# Constitution") - ): - import os - - is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None - if is_test_env: - from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher - - specify_dir.mkdir(parents=True, exist_ok=True) - enricher = ConstitutionEnricher() - enriched_content = enricher.bootstrap(repo, constitution_path) - constitution_path.write_text(enriched_content, encoding="utf-8") - else: - if runtime.is_interactive(): - console.print() - console.print("[bold cyan]💡 Tip:[/bold cyan] Generate project constitution for tool integration") - suggest_constitution = typer.confirm( - "Generate bootstrap constitution from repository analysis?", - default=True, - ) - if suggest_constitution: - from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher - - console.print("[dim]Generating bootstrap constitution...[/dim]") - specify_dir.mkdir(parents=True, exist_ok=True) - enricher = ConstitutionEnricher() - enriched_content = enricher.bootstrap(repo, constitution_path) - constitution_path.write_text(enriched_content, encoding="utf-8") - console.print("[bold green]✓[/bold green] Bootstrap constitution generated") - console.print(f"[dim]Review and adjust: {constitution_path}[/dim]") - console.print( - "[dim]Then run 'specfact sync bridge --adapter <tool>' to sync with external tool artifacts[/dim]" - ) - else: - console.print() - console.print( - "[dim]💡 Tip: Run 'specfact sdd constitution bootstrap --repo .' to generate constitution[/dim]" - ) - - -def _enrich_for_speckit_compliance(plan_bundle: PlanBundle) -> None: - """ - Enrich plan for Spec-Kit compliance using PlanEnricher. - - This function uses PlanEnricher for consistent enrichment behavior with - the `plan review --auto-enrich` command. It also adds edge case stories - for features with only 1 story to ensure better tool compliance. - """ - console.print("\n[cyan]🔧 Enriching plan for tool compliance...[/cyan]") - try: - from specfact_cli.enrichers.plan_enricher import PlanEnricher - from specfact_cli.utils.terminal import get_progress_config - - # Use PlanEnricher for consistent enrichment (same as plan review --auto-enrich) - console.print("[dim]Enhancing vague acceptance criteria, incomplete requirements, generic tasks...[/dim]") - - # Add progress reporting for large bundles - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - enrich_task = progress.add_task( - f"[cyan]Enriching {len(plan_bundle.features)} features...", - total=len(plan_bundle.features), - ) - - enricher = PlanEnricher() - enrichment_summary = enricher.enrich_plan(plan_bundle) - progress.update(enrich_task, completed=len(plan_bundle.features)) - progress.remove_task(enrich_task) - - # Add edge case stories for features with only 1 story (preserve existing behavior) - features_with_one_story = [f for f in plan_bundle.features if len(f.stories) == 1] - if features_with_one_story: - console.print(f"[yellow]⚠ Found {len(features_with_one_story)} features with only 1 story[/yellow]") - console.print("[dim]Adding edge case stories for better tool compliance...[/dim]") - - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - edge_case_task = progress.add_task( - "[cyan]Adding edge case stories...", - total=len(features_with_one_story), - ) - - for idx, feature in enumerate(features_with_one_story): - edge_case_title = f"As a user, I receive error handling for {feature.title.lower()}" - edge_case_acceptance = [ - "Must verify error conditions are handled gracefully", - "Must validate error messages are clear and actionable", - "Must ensure system recovers from errors", - ] - - existing_story_nums = [] - for s in feature.stories: - parts = s.key.split("-") - if len(parts) >= 2: - last_part = parts[-1] - if last_part.isdigit(): - existing_story_nums.append(int(last_part)) - - next_story_num = max(existing_story_nums) + 1 if existing_story_nums else 2 - feature_key_parts = feature.key.split("-") - if len(feature_key_parts) >= 2: - class_name = feature_key_parts[-1] - story_key = f"STORY-{class_name}-{next_story_num:03d}" - else: - story_key = f"STORY-{next_story_num:03d}" - - from specfact_cli.models.plan import Story - - edge_case_story = Story( - key=story_key, - title=edge_case_title, - acceptance=edge_case_acceptance, - story_points=3, - value_points=None, - confidence=0.8, - scenarios=None, - contracts=None, - ) - feature.stories.append(edge_case_story) - progress.update(edge_case_task, completed=idx + 1) - - progress.remove_task(edge_case_task) - - console.print(f"[green]✓ Added edge case stories to {len(features_with_one_story)} features[/green]") - - # Display enrichment summary (consistent with plan review --auto-enrich) - if enrichment_summary["features_updated"] > 0 or enrichment_summary["stories_updated"] > 0: - console.print( - f"[green]✓ Enhanced plan bundle: {enrichment_summary['features_updated']} features, " - f"{enrichment_summary['stories_updated']} stories updated[/green]" - ) - if enrichment_summary["acceptance_criteria_enhanced"] > 0: - console.print( - f"[dim] - Enhanced {enrichment_summary['acceptance_criteria_enhanced']} acceptance criteria[/dim]" - ) - if enrichment_summary["requirements_enhanced"] > 0: - console.print(f"[dim] - Enhanced {enrichment_summary['requirements_enhanced']} requirements[/dim]") - if enrichment_summary["tasks_enhanced"] > 0: - console.print(f"[dim] - Enhanced {enrichment_summary['tasks_enhanced']} tasks[/dim]") - else: - console.print("[green]✓ Plan bundle is already well-specified (no enrichments needed)[/green]") - - console.print("[green]✓ Tool enrichment complete[/green]") - - except Exception as e: - console.print(f"[yellow]⚠ Tool enrichment failed: {e}[/yellow]") - console.print("[dim]Plan is still valid, but may need manual enrichment[/dim]") - - -def _generate_report( - repo: Path, - bundle_dir: Path, - plan_bundle: PlanBundle, - confidence: float, - enrichment: Path | None, - report: Path, -) -> None: - """Generate import report.""" - # Ensure report directory exists (Phase 8.5: bundle-specific reports) - report.parent.mkdir(parents=True, exist_ok=True) - - total_stories = sum(len(f.stories) for f in plan_bundle.features) - - report_content = f"""# Brownfield Import Report - -## Repository: {repo} - -## Summary -- **Features Found**: {len(plan_bundle.features)} -- **Total Stories**: {total_stories} -- **Detected Themes**: {", ".join(plan_bundle.product.themes)} -- **Confidence Threshold**: {confidence} -""" - if enrichment: - report_content += f""" -## Enrichment Applied -- **Enrichment Report**: `{enrichment}` -""" - report_content += f""" -## Output Files -- **Project Bundle**: `{bundle_dir}` -- **Import Report**: `{report}` - -## Features - -""" - for feature in plan_bundle.features: - report_content += f"### {feature.title} ({feature.key})\n" - report_content += f"- **Stories**: {len(feature.stories)}\n" - report_content += f"- **Confidence**: {feature.confidence}\n" - report_content += f"- **Outcomes**: {', '.join(feature.outcomes)}\n\n" - - report.write_text(report_content) - console.print(f"[dim]Report written to: {report}[/dim]") - - -@app.command("from-bridge") -def from_bridge( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository with external tool artifacts", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Output/Results - report: Path | None = typer.Option( - None, - "--report", - help="Path to write import report", - ), - out_branch: str = typer.Option( - "feat/specfact-migration", - "--out-branch", - help="Feature branch name for migration", - ), - # Behavior/Options - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Preview changes without writing files", - ), - write: bool = typer.Option( - False, - "--write", - help="Write changes to disk", - ), - force: bool = typer.Option( - False, - "--force", - help="Overwrite existing files", - ), - # Advanced/Configuration - adapter: str = typer.Option( - "speckit", - "--adapter", - help="Adapter type: speckit, openspec, generic-markdown (available). Default: auto-detect", - hidden=True, # Hidden by default, shown with --help-advanced - ), -) -> None: - """ - Convert external tool project to SpecFact contract format using bridge architecture. - - This command uses bridge configuration to scan an external tool repository - (e.g., Spec-Kit, OpenSpec, generic-markdown), parse its structure, and generate equivalent - SpecFact contracts, protocols, and plans. - - Supported adapters (code/spec adapters only): - - speckit: Spec-Kit projects (specs/, .specify/) - import & sync - - openspec: OpenSpec integration (openspec/) - read-only sync (Phase 1) - - generic-markdown: Generic markdown-based specifications - import & sync - - Note: For backlog synchronization (GitHub Issues, ADO, Linear, Jira), use 'specfact sync bridge' instead. - - **Parameter Groups:** - - **Target/Input**: --repo - - **Output/Results**: --report, --out-branch - - **Behavior/Options**: --dry-run, --write, --force - - **Advanced/Configuration**: --adapter - - **Examples:** - specfact import from-bridge --repo ./my-project --adapter speckit --write - specfact import from-bridge --repo ./my-project --write # Auto-detect adapter - specfact import from-bridge --repo ./my-project --dry-run # Preview changes - """ - from specfact_cli.sync.bridge_probe import BridgeProbe - from specfact_cli.utils.structure import SpecFactStructure - - if is_debug_mode(): - debug_log_operation( - "command", - "import from-bridge", - "started", - extra={"repo": str(repo), "adapter": adapter, "dry_run": dry_run, "write": write}, - ) - debug_print("[dim]import from-bridge: started[/dim]") - - # Auto-detect adapter if not specified - if adapter == "speckit" or adapter == "auto": - probe = BridgeProbe(repo) - detected_capabilities = probe.detect() - # Use detected tool directly (e.g., "speckit", "openspec", "github") - # BridgeProbe already tries all registered adapters - if detected_capabilities.tool == "unknown": - if is_debug_mode(): - debug_log_operation( - "command", - "import from-bridge", - "failed", - error="Could not auto-detect adapter", - extra={"reason": "adapter_unknown"}, - ) - console.print("[bold red]✗[/bold red] Could not auto-detect adapter") - console.print("[dim]No registered adapter detected this repository structure[/dim]") - registered = AdapterRegistry.list_adapters() - console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") - console.print("[dim]Tip: Specify adapter explicitly with --adapter <adapter>[/dim]") - raise typer.Exit(1) - adapter = detected_capabilities.tool - - # Validate adapter using registry (no hard-coded checks) - adapter_lower = adapter.lower() - if not AdapterRegistry.is_registered(adapter_lower): - console.print(f"[bold red]✗[/bold red] Unsupported adapter: {adapter}") - registered = AdapterRegistry.list_adapters() - console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") - raise typer.Exit(1) - - # Get adapter from registry (universal pattern - no hard-coded checks) - adapter_instance = AdapterRegistry.get_adapter(adapter_lower) - if adapter_instance is None: - console.print(f"[bold red]✗[/bold red] Adapter '{adapter_lower}' not found in registry") - console.print("[dim]Available adapters: " + ", ".join(AdapterRegistry.list_adapters()) + "[/dim]") - raise typer.Exit(1) - - # Use adapter's detect() method - from specfact_cli.sync.bridge_probe import BridgeProbe - - probe = BridgeProbe(repo) - capabilities = probe.detect() - bridge_config = probe.auto_generate_bridge(capabilities) if capabilities.tool != "unknown" else None - - if not adapter_instance.detect(repo, bridge_config): - console.print(f"[bold red]✗[/bold red] Not a {adapter_lower} repository") - console.print(f"[dim]Expected: {adapter_lower} structure[/dim]") - console.print("[dim]Tip: Use 'specfact sync bridge probe' to auto-detect tool configuration[/dim]") - raise typer.Exit(1) - - console.print(f"[bold green]✓[/bold green] Detected {adapter_lower} repository") - - # Get adapter capabilities for adapter-specific operations - capabilities = adapter_instance.get_capabilities(repo, bridge_config) - - telemetry_metadata = { - "adapter": adapter, - "dry_run": dry_run, - "write": write, - "force": force, - } - - with telemetry.track_command("import.from_bridge", telemetry_metadata) as record: - console.print(f"[bold cyan]Importing {adapter_lower} project from:[/bold cyan] {repo}") - - # Reject backlog adapters - they should use 'sync bridge' instead - backlog_adapters = {"github", "ado", "linear", "jira", "notion"} - if adapter_lower in backlog_adapters: - console.print( - f"[bold yellow]⚠[/bold yellow] '{adapter_lower}' is a backlog adapter, not a code/spec adapter" - ) - console.print( - f"[dim]Use 'specfact sync bridge --adapter {adapter_lower}' for backlog synchronization[/dim]" - ) - console.print( - "[dim]The 'import from-bridge' command is for importing code/spec projects (Spec-Kit, OpenSpec, generic-markdown)[/dim]" - ) - raise typer.Exit(1) - - # Use adapter for feature discovery (adapter-agnostic) - if dry_run: - # Discover features using adapter - features = adapter_instance.discover_features(repo, bridge_config) - console.print("[yellow]→ Dry run mode - no files will be written[/yellow]") - console.print("\n[bold]Detected Structure:[/bold]") - console.print( - f" - Specs Directory: {capabilities.specs_dir if hasattr(capabilities, 'specs_dir') else 'N/A'}" - ) - console.print(f" - Features Found: {len(features)}") - record({"dry_run": True, "features_found": len(features)}) - return - - if not write: - console.print("[yellow]→ Use --write to actually convert files[/yellow]") - console.print("[dim]Use --dry-run to preview changes[/dim]") - return - - # Ensure SpecFact structure exists - SpecFactStructure.ensure_structure(repo) - - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - # Step 1: Discover features from markdown artifacts (adapter-agnostic) - task = progress.add_task(f"Discovering {adapter_lower} features...", total=None) - # Use adapter's discover_features method (universal pattern) - features = adapter_instance.discover_features(repo, bridge_config) - - if not features: - console.print(f"[bold red]✗[/bold red] No features found in {adapter_lower} repository") - console.print("[dim]Expected: specs/*/spec.md files (or bridge-configured paths)[/dim]") - console.print("[dim]Tip: Use 'specfact sync bridge probe' to validate bridge configuration[/dim]") - raise typer.Exit(1) - progress.update(task, description=f"✓ Discovered {len(features)} features") - - # Step 2: Import artifacts using BridgeSync (adapter-agnostic) - from specfact_cli.sync.bridge_sync import BridgeSync - - bridge_sync = BridgeSync(repo, bridge_config=bridge_config) - protocol = None - plan_bundle = None - - # Import protocol if available - protocol_path = repo / ".specfact" / "protocols" / "workflow.protocol.yaml" - if protocol_path.exists(): - from specfact_cli.models.protocol import Protocol - from specfact_cli.utils.yaml_utils import load_yaml - - try: - protocol_data = load_yaml(protocol_path) - protocol = Protocol(**protocol_data) - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Protocol loading failed: {e}") - protocol = None - - # Import features using adapter's import_artifact method - # Use "main" as default bundle name for bridge imports - bundle_name = "main" - - # Ensure project bundle structure exists - from specfact_cli.utils.structure import SpecFactStructure - - SpecFactStructure.ensure_project_structure(base_path=repo, bundle_name=bundle_name) - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle_name) - - # Load or create project bundle - from specfact_cli.migrations.plan_migrator import get_latest_schema_version - from specfact_cli.models.project import BundleManifest, BundleVersions, Product, ProjectBundle - from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle - - if bundle_dir.exists() and (bundle_dir / "bundle.manifest.yaml").exists(): - plan_bundle = load_project_bundle(bundle_dir, validate_hashes=False) - else: - # Create initial bundle with latest schema version - manifest = BundleManifest( - versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - product = Product(themes=[], releases=[]) - plan_bundle = ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - product=product, - features={}, - ) - save_project_bundle(plan_bundle, bundle_dir, atomic=True) - - # Import specification artifacts for each feature (creates features) - task = progress.add_task("Importing specifications...", total=len(features)) - import_errors = [] - imported_count = 0 - for feature in features: - # Use original directory name for path resolution (feature_branch or spec_path) - # feature_key is normalized (uppercase/underscores), but we need original name for paths - feature_id = feature.get("feature_branch") # Original directory name - if not feature_id and "spec_path" in feature: - # Fallback: extract from spec_path if available - spec_path_str = feature["spec_path"] - if "/" in spec_path_str: - parts = spec_path_str.split("/") - # Find the directory name (should be before spec.md) - for i, part in enumerate(parts): - if part == "spec.md" and i > 0: - feature_id = parts[i - 1] - break - - # If still no feature_id, try to use feature_key but convert back to directory format - if not feature_id: - feature_key = feature.get("feature_key") or feature.get("key", "") - if feature_key: - # Convert normalized key back to directory name (ORDER_SERVICE -> order-service) - # This is a best-effort conversion - feature_id = feature_key.lower().replace("_", "-") - - if feature_id: - # Verify artifact path exists before importing (use original directory name) - try: - artifact_path = bridge_sync.resolve_artifact_path("specification", feature_id, bundle_name) - if not artifact_path.exists(): - error_msg = f"Artifact not found for {feature_id}: {artifact_path}" - import_errors.append(error_msg) - console.print(f"[yellow]⚠[/yellow] {error_msg}") - progress.update(task, advance=1) - continue - except Exception as e: - error_msg = f"Failed to resolve artifact path for {feature_id}: {e}" - import_errors.append(error_msg) - console.print(f"[yellow]⚠[/yellow] {error_msg}") - progress.update(task, advance=1) - continue - - # Import specification artifact (use original directory name for path resolution) - result = bridge_sync.import_artifact("specification", feature_id, bundle_name) - if result.success: - imported_count += 1 - else: - error_msg = f"Failed to import specification for {feature_id}: {', '.join(result.errors)}" - import_errors.append(error_msg) - console.print(f"[yellow]⚠[/yellow] {error_msg}") - progress.update(task, advance=1) - - if import_errors: - console.print(f"[bold yellow]⚠[/bold yellow] {len(import_errors)} specification import(s) had issues") - for error in import_errors[:5]: # Show first 5 errors - console.print(f" - {error}") - if len(import_errors) > 5: - console.print(f" ... and {len(import_errors) - 5} more") - - if imported_count == 0 and len(features) > 0: - console.print("[bold red]✗[/bold red] No specifications were imported successfully") - raise typer.Exit(1) - - # Reload bundle after importing specifications - plan_bundle = load_project_bundle(bundle_dir, validate_hashes=False) - - # Optionally import plan artifacts to add plan information - task = progress.add_task("Importing plans...", total=len(features)) - for feature in features: - feature_key = feature.get("feature_key") or feature.get("key", "") - if feature_key: - # Import plan artifact (adds plan information to existing features) - result = bridge_sync.import_artifact("plan", feature_key, bundle_name) - if not result.success and result.errors: - # Plan import is optional, only warn if there are actual errors - pass - progress.update(task, advance=1) - - # Reload bundle after importing plans - plan_bundle = load_project_bundle(bundle_dir, validate_hashes=False) - - # For Spec-Kit adapter, also generate protocol, Semgrep rules and GitHub Actions if supported - # These are Spec-Kit-specific enhancements, not core import functionality - if adapter_lower == "speckit": - from specfact_cli.importers.speckit_converter import SpecKitConverter - - converter = SpecKitConverter(repo) - # Step 3: Generate protocol (Spec-Kit specific) - if hasattr(converter, "convert_protocol"): - task = progress.add_task("Generating protocol...", total=None) - try: - _protocol = converter.convert_protocol() # Generates .specfact/protocols/workflow.protocol.yaml - progress.update(task, description="✓ Protocol generated") - # Reload protocol after generation - protocol_path = repo / ".specfact" / "protocols" / "workflow.protocol.yaml" - if protocol_path.exists(): - from specfact_cli.models.protocol import Protocol - from specfact_cli.utils.yaml_utils import load_yaml - - try: - protocol_data = load_yaml(protocol_path) - protocol = Protocol(**protocol_data) - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Protocol loading failed: {e}") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Protocol generation failed: {e}") - - # Step 4: Generate Semgrep rules (Spec-Kit specific) - if hasattr(converter, "generate_semgrep_rules"): - task = progress.add_task("Generating Semgrep rules...", total=None) - try: - _semgrep_path = converter.generate_semgrep_rules() # Not used yet - progress.update(task, description="✓ Semgrep rules generated") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Semgrep rules generation failed: {e}") - - # Step 5: Generate GitHub Action workflow (Spec-Kit specific) - if hasattr(converter, "generate_github_action"): - task = progress.add_task("Generating GitHub Action workflow...", total=None) - repo_name = repo.name if isinstance(repo, Path) else None - try: - _workflow_path = converter.generate_github_action(repo_name=repo_name) # Not used yet - progress.update(task, description="✓ GitHub Action workflow generated") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] GitHub Action workflow generation failed: {e}") - - # Handle file existence errors (conversion already completed above with individual try/except blocks) - # If plan_bundle or protocol are None, try to load existing ones - if plan_bundle is None or protocol is None: - from specfact_cli.migrations.plan_migrator import get_current_schema_version - from specfact_cli.models.plan import PlanBundle, Product - - if plan_bundle is None: - plan_bundle = PlanBundle( - version=get_current_schema_version(), - idea=None, - business=None, - product=Product(themes=[], releases=[]), - features=[], - clarifications=None, - metadata=None, - ) - if protocol is None: - # Try to load existing protocol if available - protocol_path = repo / ".specfact" / "protocols" / "workflow.protocol.yaml" - if protocol_path.exists(): - from specfact_cli.models.protocol import Protocol - from specfact_cli.utils.yaml_utils import load_yaml - - try: - protocol_data = load_yaml(protocol_path) - protocol = Protocol(**protocol_data) - except Exception: - pass - - # Generate report - if report and protocol and plan_bundle: - report_content = f"""# {adapter_lower.upper()} Import Report - -## Repository: {repo} -## Adapter: {adapter_lower} - -## Summary -- **States Found**: {len(protocol.states)} -- **Transitions**: {len(protocol.transitions)} -- **Features Extracted**: {len(plan_bundle.features)} -- **Total Stories**: {sum(len(f.stories) for f in plan_bundle.features)} - -## Generated Files -- **Protocol**: `.specfact/protocols/workflow.protocol.yaml` -- **Plan Bundle**: `.specfact/projects/<bundle-name>/` -- **Semgrep Rules**: `.semgrep/async-anti-patterns.yml` -- **GitHub Action**: `.github/workflows/specfact-gate.yml` - -## States -{chr(10).join(f"- {state}" for state in protocol.states)} - -## Features -{chr(10).join(f"- {f.title} ({f.key})" for f in plan_bundle.features)} -""" - report.parent.mkdir(parents=True, exist_ok=True) - report.write_text(report_content, encoding="utf-8") - console.print(f"[dim]Report written to: {report}[/dim]") - - # Save plan bundle as ProjectBundle (modular structure) - if plan_bundle: - from specfact_cli.models.plan import PlanBundle - from specfact_cli.models.project import ProjectBundle - - bundle_name = "main" # Default bundle name for bridge imports - # Check if plan_bundle is already a ProjectBundle or needs conversion - if isinstance(plan_bundle, ProjectBundle): - project_bundle = plan_bundle - elif isinstance(plan_bundle, PlanBundle): - project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle_name) - else: - # Unknown type, skip conversion - project_bundle = None - - if project_bundle: - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle_name) - SpecFactStructure.ensure_project_structure(base_path=repo, bundle_name=bundle_name) - save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) - console.print(f"[dim]Project bundle: .specfact/projects/{bundle_name}/[/dim]") - - console.print("[bold green]✓[/bold green] Import complete!") - console.print("[dim]Protocol: .specfact/protocols/workflow.protocol.yaml[/dim]") - console.print("[dim]Plan: .specfact/projects/<bundle-name>/ (modular bundle)[/dim]") - console.print("[dim]Semgrep Rules: .semgrep/async-anti-patterns.yml[/dim]") - console.print("[dim]GitHub Action: .github/workflows/specfact-gate.yml[/dim]") - - if is_debug_mode(): - debug_log_operation( - "command", - "import from-bridge", - "success", - extra={ - "protocol_states": len(protocol.states) if protocol else 0, - "features": len(plan_bundle.features) if plan_bundle else 0, - }, - ) - debug_print("[dim]import from-bridge: success[/dim]") - - # Record import results - if protocol and plan_bundle: - record( - { - "states_found": len(protocol.states), - "transitions": len(protocol.transitions), - "features_extracted": len(plan_bundle.features), - "total_stories": sum(len(f.stories) for f in plan_bundle.features), - } - ) - - -@app.command("from-code") -@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda confidence: 0.0 <= confidence <= 1.0, "Confidence must be 0.0-1.0") -@beartype -def from_code( - # Target/Input - bundle: str | None = typer.Argument( - None, - help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", - ), - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository to import. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - entry_point: Path | None = typer.Option( - None, - "--entry-point", - help="Subdirectory path for partial analysis (relative to repo root). Analyzes only files within this directory and subdirectories. Default: None (analyze entire repo)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - enrichment: Path | None = typer.Option( - None, - "--enrichment", - help="Path to Markdown enrichment report from LLM (applies missing features, confidence adjustments, business context). Default: None", - hidden=True, # Hidden by default, shown with --help-advanced - ), - # Output/Results - report: Path | None = typer.Option( - None, - "--report", - help="Path to write analysis report. Default: bundle-specific .specfact/projects/<bundle-name>/reports/brownfield/analysis-<timestamp>.md (Phase 8.5)", - ), - # Behavior/Options - shadow_only: bool = typer.Option( - False, - "--shadow-only", - help="Shadow mode - observe without enforcing. Default: False", - ), - enrich_for_speckit: bool = typer.Option( - True, - "--enrich-for-speckit/--no-enrich-for-speckit", - help="Automatically enrich plan for Spec-Kit compliance (uses PlanEnricher to enhance vague acceptance criteria, incomplete requirements, generic tasks, and adds edge case stories for features with only 1 story). Default: True (enabled)", - ), - force: bool = typer.Option( - False, - "--force", - help="Force full regeneration of all artifacts, ignoring incremental changes. Default: False", - ), - include_tests: bool = typer.Option( - False, - "--include-tests/--exclude-tests", - help="Include/exclude test files in relationship mapping and dependency graph. Default: --exclude-tests (test files are excluded by default). Test files are never extracted as features (they're validation artifacts, not specifications). Use --include-tests only if you need test files in the dependency graph.", - ), - revalidate_features: bool = typer.Option( - False, - "--revalidate-features/--no-revalidate-features", - help="Re-validate and re-analyze existing features even if source files haven't changed. Useful when analysis logic improved or confidence threshold changed. Default: False (only re-analyze if files changed)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - # Advanced/Configuration (hidden by default, use --help-advanced to see) - confidence: float = typer.Option( - 0.5, - "--confidence", - min=0.0, - max=1.0, - help="Minimum confidence score for features. Default: 0.5 (range: 0.0-1.0)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - key_format: str = typer.Option( - "classname", - "--key-format", - help="Feature key format: 'classname' (FEATURE-CLASSNAME) or 'sequential' (FEATURE-001). Default: classname", - hidden=True, # Hidden by default, shown with --help-advanced - ), -) -> None: - """ - Import plan bundle from existing codebase (one-way import). - - Analyzes code structure using AI-first semantic understanding or AST-based fallback - to generate a plan bundle that represents the current system. - - Supports dual-stack enrichment workflow: apply LLM-generated enrichment report - to refine the auto-detected plan bundle (add missing features, adjust confidence scores, - add business context). - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --repo, --entry-point, --enrichment - - **Output/Results**: --report - - **Behavior/Options**: --shadow-only, --enrich-for-speckit, --force, --include-tests/--exclude-tests (default: exclude) - - **Advanced/Configuration**: --confidence, --key-format - - **Examples:** - specfact import from-code legacy-api --repo . - specfact import from-code auth-module --repo . --enrichment enrichment-report.md - specfact import from-code my-project --repo . --confidence 0.7 --shadow-only - specfact import from-code my-project --repo . --force # Force full regeneration - specfact import from-code my-project --repo . # Test files excluded by default - specfact import from-code my-project --repo . --include-tests # Include test files in dependency graph - """ - from specfact_cli.cli import get_current_mode - from specfact_cli.modes import get_router - from specfact_cli.utils.structure import SpecFactStructure - - if is_debug_mode(): - debug_log_operation( - "command", - "import from-code", - "started", - extra={"bundle": bundle, "repo": str(repo), "force": force, "shadow_only": shadow_only}, - ) - debug_print("[dim]import from-code: started[/dim]") - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None: - if is_debug_mode(): - debug_log_operation( - "command", - "import from-code", - "failed", - error="Bundle name required", - extra={"reason": "no_bundle"}, - ) - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - mode = get_current_mode() - - # Route command based on mode - router = get_router() - routing_result = router.route("import from-code", mode, {"repo": str(repo), "confidence": confidence}) - - python_file_count = _count_python_files(repo) - - from specfact_cli.utils.structure import SpecFactStructure - - # Ensure .specfact structure exists in the repository being imported - SpecFactStructure.ensure_structure(repo) - - # Get project bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - - # Check for incremental processing (if bundle exists) - incremental_changes = _check_incremental_changes(bundle_dir, repo, enrichment, force) - - # Ensure project structure exists - SpecFactStructure.ensure_project_structure(base_path=repo, bundle_name=bundle) - - if report is None: - # Use bundle-specific report path (Phase 8.5) - report = SpecFactStructure.get_bundle_brownfield_report_path(bundle_name=bundle, base_path=repo) - - console.print(f"[bold cyan]Importing repository:[/bold cyan] {repo}") - console.print(f"[bold cyan]Project bundle:[/bold cyan] {bundle}") - console.print(f"[dim]Confidence threshold: {confidence}[/dim]") - - if shadow_only: - console.print("[yellow]→ Shadow mode - observe without enforcement[/yellow]") - - telemetry_metadata = { - "bundle": bundle, - "mode": mode.value, - "execution_mode": routing_result.execution_mode, - "files_analyzed": python_file_count, - "shadow_mode": shadow_only, - } - - # Phase 4.10: CI Performance Optimization - Track performance - with ( - track_performance("import.from_code", threshold=5.0) as perf_monitor, - telemetry.track_command("import.from_code", telemetry_metadata) as record_event, - ): - try: - # If enrichment is provided, try to load existing bundle - # Note: For now, enrichment workflow needs to be updated for modular bundles - # TODO: Phase 4 - Update enrichment to work with modular bundles - plan_bundle: PlanBundle | None = None - - # Check if we need to regenerate features (requires full codebase scan) - # Features need regeneration if: - # - No incremental changes detected (new bundle) - # - Source files actually changed (not just missing relationships/contracts) - # - Revalidation requested (--revalidate-features flag) - # - # Important: Missing relationships/contracts alone should NOT trigger feature regeneration. - # If features exist (from checkpoint), we can regenerate relationships/contracts separately. - # Only regenerate features if source files actually changed. - should_regenerate_features = incremental_changes is None or revalidate_features - - # Check if source files actually changed (not just missing artifacts) - # If features exist from checkpoint, only regenerate if source files changed - if incremental_changes and not should_regenerate_features: - # Check if we have features saved (checkpoint exists) - features_dir = bundle_dir / "features" - has_features = features_dir.exists() and any(features_dir.glob("*.yaml")) - - if has_features: - # Features exist from checkpoint - check if source files actually changed - # The incremental_check already computed this, but we need to verify: - # If relationships/contracts need regeneration, it could be because: - # 1. Source files changed (should regenerate features) - # 2. Relationships/contracts are just missing (should NOT regenerate features) - # - # We can tell the difference by checking if the incremental_check detected - # source file changes. If it did, relationships will be True. - # But if relationships are True just because they're missing (not because files changed), - # we should NOT regenerate features. - # - # The incremental_check function already handles this correctly - it only marks - # relationships as needing regeneration if source files changed OR if relationships don't exist. - # So we need to check if source files actually changed by examining feature source tracking. - try: - # Load bundle to check source tracking (we'll reuse this later if we don't regenerate) - existing_bundle = _load_existing_bundle(bundle_dir) - if existing_bundle and existing_bundle.features: - # Check if any source files actually changed - # If features don't have source_tracking yet (cancelled before source linking), - # we can't check file changes, so assume files haven't changed and reuse features - source_files_changed = False - has_source_tracking = False - - for feature in existing_bundle.features: - if feature.source_tracking: - has_source_tracking = True - # Check implementation files - for impl_file in feature.source_tracking.implementation_files: - file_path = repo / impl_file - if file_path.exists() and feature.source_tracking.has_changed(file_path): - source_files_changed = True - break - if source_files_changed: - break - # Check test files - for test_file in feature.source_tracking.test_files: - file_path = repo / test_file - if file_path.exists() and feature.source_tracking.has_changed(file_path): - source_files_changed = True - break - if source_files_changed: - break - - # Only regenerate features if source files actually changed - # If features don't have source_tracking yet, assume files haven't changed - # (they were just discovered, not yet linked) - if source_files_changed: - should_regenerate_features = True - console.print("[yellow]⚠[/yellow] Source files changed - will re-analyze features\n") - else: - # Source files haven't changed (or features don't have source_tracking yet) - # Don't regenerate features, just regenerate relationships/contracts - if has_source_tracking: - console.print( - "[dim]✓[/dim] Features exist from checkpoint - will regenerate relationships/contracts only\n" - ) - else: - console.print( - "[dim]✓[/dim] Features exist from checkpoint (no source tracking yet) - will link source files and regenerate relationships/contracts\n" - ) - # Reuse the loaded bundle instead of loading again later - plan_bundle = existing_bundle - except Exception: - # If we can't check, be conservative and don't regenerate features - # (relationships/contracts will be regenerated separately) - pass - - # If revalidation is requested, show message - if revalidate_features and incremental_changes: - console.print( - "[yellow]⚠[/yellow] --revalidate-features enabled: Will re-analyze features even if files unchanged\n" - ) - - # If we have incremental changes and features don't need regeneration, load existing bundle - # (unless we already loaded it above to check for source file changes) - if incremental_changes and not should_regenerate_features and not enrichment: - if plan_bundle is None: - plan_bundle = _load_existing_bundle(bundle_dir) - if plan_bundle: - # Validate existing features to ensure they're still valid - # Only validate if we're actually using existing features (not regenerating) - validation_results = _validate_existing_features(plan_bundle, repo) - - # Report validation results - valid_count = len(validation_results["valid_features"]) - orphaned_count = len(validation_results["orphaned_features"]) - total_checked = validation_results["total_checked"] - - # Only show validation warnings if there are actual problems (orphaned or missing files) - # Don't warn about features with no stories - that's normal for newly discovered features - features_with_missing_files = [ - key - for key in validation_results["invalid_features"] - if validation_results["missing_files"].get(key) - ] - - if orphaned_count > 0 or features_with_missing_files: - console.print("[cyan]🔍 Validating existing features...[/cyan]") - console.print( - f"[yellow]⚠[/yellow] Feature validation found issues: {valid_count}/{total_checked} valid, " - f"{orphaned_count} orphaned, {len(features_with_missing_files)} with missing files" - ) - - # Show orphaned features - if orphaned_count > 0: - console.print("[red] Orphaned features (all source files missing):[/red]") - for feature_key in validation_results["orphaned_features"][:5]: # Show first 5 - missing = validation_results["missing_files"].get(feature_key, []) - console.print(f" [dim]- {feature_key}[/dim] ({len(missing)} missing files)") - if orphaned_count > 5: - console.print(f" [dim]... and {orphaned_count - 5} more[/dim]") - - # Show invalid features (only those with missing files) - if features_with_missing_files: - console.print("[yellow] Features with missing files:[/yellow]") - for feature_key in features_with_missing_files[:5]: # Show first 5 - missing = validation_results["missing_files"].get(feature_key, []) - console.print(f" [dim]- {feature_key}[/dim] ({len(missing)} missing files)") - if len(features_with_missing_files) > 5: - console.print(f" [dim]... and {len(features_with_missing_files) - 5} more[/dim]") - - console.print( - "[dim] Tip: Use --revalidate-features to re-analyze features and fix issues[/dim]\n" - ) - # Don't show validation message if all features are valid (no noise) - - console.print("[dim]Skipping codebase analysis (features unchanged)[/dim]\n") - - if plan_bundle is None: - # Need to run full codebase analysis (either no bundle exists, or features need regeneration) - # If enrichment is provided, try to load existing bundle first (enrichment needs existing bundle) - if enrichment: - plan_bundle = _load_existing_bundle(bundle_dir) - if plan_bundle is None: - console.print( - "[bold red]✗ Cannot apply enrichment: No existing bundle found. Run import without --enrichment first.[/bold red]" - ) - raise typer.Exit(1) - - if plan_bundle is None: - # Phase 4.9 & 4.10: Track codebase analysis performance - with perf_monitor.track("analyze_codebase", {"files": python_file_count}): - # Phase 4.9: Create callback for incremental results - def on_incremental_update(features_count: int, themes: list[str]) -> None: - """Callback for incremental results (Phase 4.9: Quick Start Optimization).""" - # Feature count updates are shown in the progress bar description, not as separate lines - # No intermediate messages needed - final summary provides all information - - plan_bundle = _analyze_codebase( - repo, - entry_point, - bundle, - confidence, - key_format, - routing_result, - incremental_callback=on_incremental_update, - ) - if plan_bundle is None: - console.print("[bold red]✗ Failed to analyze codebase[/bold red]") - raise typer.Exit(1) - - # Phase 4.9: Analysis complete (results shown in progress bar and final summary) - console.print(f"[green]✓[/green] Found {len(plan_bundle.features)} features") - console.print(f"[green]✓[/green] Detected themes: {', '.join(plan_bundle.product.themes)}") - total_stories = sum(len(f.stories) for f in plan_bundle.features) - console.print(f"[green]✓[/green] Total stories: {total_stories}\n") - record_event({"features_detected": len(plan_bundle.features), "stories_detected": total_stories}) - - # Save features immediately after analysis to avoid losing work if process is cancelled - # This ensures we can resume from this point if interrupted during expensive operations - console.print("[cyan]💾 Saving features (checkpoint)...[/cyan]") - project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) - save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) - console.print("[dim]✓ Features saved (can resume if interrupted)[/dim]\n") - - # Ensure plan_bundle is not None before proceeding - if plan_bundle is None: - console.print("[bold red]✗ No plan bundle available[/bold red]") - raise typer.Exit(1) - - # Add source tracking to features - with perf_monitor.track("update_source_tracking"): - _update_source_tracking(plan_bundle, repo) - - # Enhanced Analysis Phase: Extract relationships, contracts, and graph dependencies - # Check if we need to regenerate these artifacts - # Note: enrichment doesn't force full regeneration - only new features need contracts - should_regenerate_relationships = incremental_changes is None or incremental_changes.get( - "relationships", True - ) - should_regenerate_graph = incremental_changes is None or incremental_changes.get("graph", True) - should_regenerate_contracts = incremental_changes is None or incremental_changes.get("contracts", True) - should_regenerate_enrichment = incremental_changes is None or incremental_changes.get( - "enrichment_context", True - ) - # If enrichment is provided, ensure bundle is regenerated to apply it - # This ensures enrichment is applied even if no source files changed - if enrichment and incremental_changes: - # Force bundle regeneration to apply enrichment - incremental_changes["bundle"] = True - - # Track features before enrichment to detect new ones that need contracts - features_before_enrichment = {f.key for f in plan_bundle.features} if enrichment else set() - - # Phase 4.10: Track relationship extraction performance - with perf_monitor.track("extract_relationships_and_graph"): - relationships, _graph_summary = _extract_relationships_and_graph( - repo, - entry_point, - bundle_dir, - incremental_changes, - plan_bundle, - should_regenerate_relationships, - should_regenerate_graph, - include_tests, - ) - - # Apply enrichment BEFORE contract extraction so new features get contracts - if enrichment: - with perf_monitor.track("apply_enrichment"): - plan_bundle = _apply_enrichment(enrichment, plan_bundle, record_event) - - # After enrichment, check if new features were added that need contracts - features_after_enrichment = {f.key for f in plan_bundle.features} - new_features_added = features_after_enrichment - features_before_enrichment - - # If new features were added, we need to extract contracts for them - # Mark contracts for regeneration if new features were added - if new_features_added: - console.print( - f"[dim]Note: {len(new_features_added)} new feature(s) from enrichment will get contracts extracted[/dim]" - ) - # New features need contracts, so ensure contract extraction runs - if incremental_changes and not incremental_changes.get("contracts", False): - # Only regenerate contracts if we have new features, not all contracts - should_regenerate_contracts = True - - # Phase 4.10: Track contract extraction performance - with perf_monitor.track("extract_contracts"): - contracts_data = _extract_contracts( - repo, bundle_dir, plan_bundle, should_regenerate_contracts, record_event, force=force - ) - - # Phase 4.10: Track enrichment context building performance - with perf_monitor.track("build_enrichment_context"): - _build_enrichment_context( - bundle_dir, - repo, - plan_bundle, - relationships, - contracts_data, - should_regenerate_enrichment, - record_event, - ) - - # Save bundle if needed - with perf_monitor.track("save_bundle"): - _save_bundle_if_needed( - plan_bundle, - bundle, - bundle_dir, - incremental_changes, - should_regenerate_relationships, - should_regenerate_graph, - should_regenerate_contracts, - should_regenerate_enrichment, - ) - - console.print("\n[bold green]✓ Import complete![/bold green]") - console.print(f"[dim]Project bundle written to: {bundle_dir}[/dim]") - - # Validate API specs (both repo-level and bundle contracts) - with perf_monitor.track("validate_api_specs"): - _validate_api_specs(repo, bundle_dir=bundle_dir, plan_bundle=plan_bundle) - - # Phase 4.9: Suggest next steps (Quick Start Optimization) - _suggest_next_steps(repo, bundle, plan_bundle) - - # Suggest constitution bootstrap - _suggest_constitution_bootstrap(repo) - - # Enrich for tool compliance if requested - if enrich_for_speckit: - if plan_bundle is None: - console.print("[yellow]⚠ Cannot enrich: plan bundle is None[/yellow]") - else: - _enrich_for_speckit_compliance(plan_bundle) - - # Generate report - if plan_bundle is None: - console.print("[bold red]✗ Cannot generate report: plan bundle is None[/bold red]") - raise typer.Exit(1) - - _generate_report(repo, bundle_dir, plan_bundle, confidence, enrichment, report) - - if is_debug_mode(): - debug_log_operation( - "command", - "import from-code", - "success", - extra={"bundle": bundle, "bundle_dir": str(bundle_dir), "report": str(report)}, - ) - debug_print("[dim]import from-code: success[/dim]") - - # Phase 4.10: Print performance report if slow operations detected - perf_report = perf_monitor.get_report() - if perf_report.slow_operations and not os.environ.get("CI"): - # Only show in non-CI mode (interactive) - perf_report.print_summary() - - except KeyboardInterrupt: - # Re-raise KeyboardInterrupt immediately (don't catch it here) - raise - except typer.Exit: - # Re-raise typer.Exit (used for clean exits) - raise - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "import from-code", - "failed", - error=str(e), - extra={"reason": type(e).__name__, "bundle": bundle}, - ) - console.print(f"[bold red]✗ Import failed:[/bold red] {e}") - raise typer.Exit(1) from e +__all__ = ["app"] diff --git a/src/specfact_cli/commands/init.py b/src/specfact_cli/commands/init.py index d6cf9fd1..d5822a09 100644 --- a/src/specfact_cli/commands/init.py +++ b/src/specfact_cli/commands/init.py @@ -1,573 +1,6 @@ -""" -Init command - Initialize SpecFact for IDE integration. +"""Backward-compatible app shim. Implementation moved to modules/init/.""" -This module provides the `specfact init` command to copy prompt templates -to IDE-specific locations for slash command integration. -""" +from specfact_cli.modules.init.src.commands import app -from __future__ import annotations -import subprocess -import sys -from pathlib import Path - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.panel import Panel - -from specfact_cli import __version__ -from specfact_cli.registry.help_cache import run_discovery_and_write_cache -from specfact_cli.registry.module_packages import get_discovered_modules_for_state -from specfact_cli.registry.module_state import write_modules_state -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils.env_manager import EnvManager, build_tool_command, detect_env_manager -from specfact_cli.utils.ide_setup import ( - IDE_CONFIG, - copy_templates_to_ide, - detect_ide, - find_package_resources_path, - get_package_installation_locations, -) - - -def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: Console) -> None: - """ - Copy backlog field mapping templates to .specfact/templates/backlog/field_mappings/. - - Args: - repo_path: Repository path - force: Whether to overwrite existing files - console: Rich console for output - """ - import shutil - - # Find backlog field mapping templates directory - # Priority order: - # 1. Development: relative to project root (resources/templates/backlog/field_mappings) - # 2. Installed package: use importlib.resources to find package location - templates_dir: Path | None = None - - # Try 1: Development mode - relative to repo root - dev_templates_dir = (repo_path / "resources" / "templates" / "backlog" / "field_mappings").resolve() - if dev_templates_dir.exists(): - templates_dir = dev_templates_dir - else: - # Try 2: Installed package - use importlib.resources - try: - import importlib.resources - - resources_ref = importlib.resources.files("specfact_cli") - templates_ref = resources_ref / "resources" / "templates" / "backlog" / "field_mappings" - package_templates_dir = Path(str(templates_ref)).resolve() - if package_templates_dir.exists(): - templates_dir = package_templates_dir - except Exception: - # Fallback: try importlib.util.find_spec() - try: - import importlib.util - - spec = importlib.util.find_spec("specfact_cli") - if spec and spec.origin: - package_root = Path(spec.origin).parent.resolve() - package_templates_dir = ( - package_root / "resources" / "templates" / "backlog" / "field_mappings" - ).resolve() - if package_templates_dir.exists(): - templates_dir = package_templates_dir - except Exception: - pass - - if not templates_dir or not templates_dir.exists(): - # Templates not found - this is not critical, just skip - debug_print("[dim]Debug:[/dim] Backlog field mapping templates not found, skipping copy") - return - - # Create target directory - target_dir = repo_path / ".specfact" / "templates" / "backlog" / "field_mappings" - target_dir.mkdir(parents=True, exist_ok=True) - - # Copy templates (ado_*.yaml files) - template_files = list(templates_dir.glob("ado_*.yaml")) - copied_count = 0 - - for template_file in template_files: - target_file = target_dir / template_file.name - if target_file.exists() and not force: - continue # Skip if file exists and --force not used - try: - shutil.copy2(template_file, target_file) - copied_count += 1 - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Failed to copy {template_file.name}: {e}") - - if copied_count > 0: - console.print( - f"[green]✓[/green] Copied {copied_count} ADO field mapping template(s) to .specfact/templates/backlog/field_mappings/" - ) - elif template_files: - console.print("[dim]Backlog field mapping templates already exist (use --force to overwrite)[/dim]") - - -app = typer.Typer(help="Initialize SpecFact for IDE integration") -console = Console() - - -def _is_valid_repo_path(path: Path) -> bool: - """Check if path exists and is a directory.""" - return path.exists() and path.is_dir() - - -@app.callback(invoke_without_command=True) -@require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") -@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") -@ensure(lambda result: result is None, "Command should return None") -@beartype -def init( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Repository path (default: current directory)", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Behavior/Options - force: bool = typer.Option( - False, - "--force", - help="Overwrite existing files", - ), - install_deps: bool = typer.Option( - False, - "--install-deps", - help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) using detected environment manager", - ), - # Advanced/Configuration - ide: str = typer.Option( - "auto", - "--ide", - help="IDE type (auto, cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - enable_module: list[str] = typer.Option( - [], - "--enable-module", - help="Enable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", - ), - disable_module: list[str] = typer.Option( - [], - "--disable-module", - help="Disable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", - ), -) -> None: - """ - Initialize SpecFact for IDE integration. - - Copies prompt templates to IDE-specific locations so slash commands work. - This command detects the IDE type (or uses --ide flag) and copies - SpecFact prompt templates to the appropriate directory. - - Also copies backlog field mapping templates to `.specfact/templates/backlog/field_mappings/` - for custom ADO field mapping configuration. - - Examples: - specfact init # Auto-detect IDE - specfact init --ide cursor # Initialize for Cursor - specfact init --ide vscode --force # Overwrite existing files - specfact init --repo /path/to/repo --ide copilot - specfact init --install-deps # Install required packages for contract enhancement - """ - telemetry_metadata = { - "ide": ide, - "force": force, - "install_deps": install_deps, - } - - with telemetry.track_command("init", telemetry_metadata) as record: - # Update module state (enable/disable) and persist; then refresh help cache - modules_list = get_discovered_modules_for_state( - enable_ids=enable_module, - disable_ids=disable_module, - ) - if modules_list: - write_modules_state(modules_list) - disabled = [m["id"] for m in modules_list if m.get("enabled") is False] - if disabled: - console.print() - console.print( - f"[dim]The following modules are disabled by your configuration: {', '.join(disabled)}. " - "Re-enable with specfact init --enable-module <id>.[/dim]" - ) - run_discovery_and_write_cache(__version__) - - # Resolve repo path - repo_path = repo.resolve() - - # Detect IDE - detected_ide = detect_ide(ide) - ide_config = IDE_CONFIG[detected_ide] - ide_name = ide_config["name"] - - console.print() - console.print(Panel("[bold cyan]SpecFact IDE Setup[/bold cyan]", border_style="cyan")) - console.print(f"[cyan]Repository:[/cyan] {repo_path}") - console.print(f"[cyan]IDE:[/cyan] {ide_name} ({detected_ide})") - console.print() - - # Check for environment manager - env_info = detect_env_manager(repo_path) - if env_info.manager == EnvManager.UNKNOWN: - console.print() - console.print( - Panel( - "[bold yellow]⚠ No Compatible Environment Manager Detected[/bold yellow]", - border_style="yellow", - ) - ) - console.print( - "[yellow]SpecFact CLI works best with projects using standard Python project management tools.[/yellow]" - ) - console.print() - console.print("[dim]Supported tools:[/dim]") - console.print(" - hatch (detected from [tool.hatch] in pyproject.toml)") - console.print(" - poetry (detected from [tool.poetry] in pyproject.toml or poetry.lock)") - console.print(" - uv (detected from [tool.uv] in pyproject.toml, uv.lock, or uv.toml)") - console.print(" - pip (detected from requirements.txt or setup.py)") - console.print() - console.print( - "[dim]Note: SpecFact CLI will still work, but commands like 'specfact repro' may use direct tool invocation.[/dim]" - ) - console.print( - "[dim]Consider adding a pyproject.toml with [tool.hatch], [tool.poetry], or [tool.uv] for better integration.[/dim]" - ) - console.print() - - # Install dependencies if requested - if install_deps: - console.print() - console.print(Panel("[bold cyan]Installing Required Packages[/bold cyan]", border_style="cyan")) - if env_info.message: - console.print(f"[dim]{env_info.message}[/dim]") - - required_packages = [ - "beartype>=0.22.4", - "icontract>=2.7.1", - "crosshair-tool>=0.0.97", - "pytest>=8.4.2", - # Sidecar validation tools - # Note: specmatic may need separate installation (Java-based tool) - # Users may need to install specmatic separately: https://specmatic.in/documentation/getting_started.html - ] - console.print("[dim]Installing packages for contract enhancement:[/dim]") - for package in required_packages: - console.print(f" - {package}") - - # Build install command using environment manager detection - install_cmd = ["pip", "install", "-U", *required_packages] - install_cmd = build_tool_command(env_info, install_cmd) - - console.print(f"[dim]Using command: {' '.join(install_cmd)}[/dim]") - - try: - result = subprocess.run( - install_cmd, - capture_output=True, - text=True, - check=False, - cwd=str(repo_path), - timeout=300, # 5 minute timeout - ) - - if result.returncode == 0: - console.print() - console.print("[green]✓[/green] All required packages installed successfully") - record( - { - "deps_installed": True, - "packages_count": len(required_packages), - "env_manager": env_info.manager.value, - } - ) - else: - console.print() - console.print("[yellow]⚠[/yellow] Some packages failed to install") - console.print("[dim]Output:[/dim]") - if result.stdout: - console.print(result.stdout) - if result.stderr: - console.print(result.stderr) - console.print() - console.print("[yellow]You may need to install packages manually:[/yellow]") - # Provide environment-specific guidance - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record( - { - "deps_installed": False, - "error": result.stderr[:200] if result.stderr else "Unknown error", - "env_manager": env_info.manager.value, - } - ) - except subprocess.TimeoutExpired: - console.print() - console.print("[red]Error:[/red] Installation timed out after 5 minutes") - console.print("[yellow]You may need to install packages manually:[/yellow]") - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": "timeout", "env_manager": env_info.manager.value}) - except FileNotFoundError: - console.print() - console.print("[red]Error:[/red] pip not found. Please install packages manually:") - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": "pip not found", "env_manager": env_info.manager.value}) - except Exception as e: - console.print() - console.print(f"[red]Error:[/red] Failed to install packages: {e}") - console.print("[yellow]You may need to install packages manually:[/yellow]") - if env_info.manager == EnvManager.HATCH: - console.print(f" hatch run pip install {' '.join(required_packages)}") - elif env_info.manager == EnvManager.POETRY: - console.print(f" poetry add --dev {' '.join(required_packages)}") - elif env_info.manager == EnvManager.UV: - console.print(f" uv pip install {' '.join(required_packages)}") - else: - console.print(f" pip install {' '.join(required_packages)}") - record({"deps_installed": False, "error": str(e), "env_manager": env_info.manager.value}) - console.print() - - # Find templates directory - # Priority order: - # 1. Development: relative to project root (resources/prompts) - # 2. Installed package: use importlib.resources to find package location - # 3. Fallback: try relative to this file (for edge cases) - templates_dir: Path | None = None - package_templates_dir: Path | None = None - tried_locations: list[Path] = [] - - # Try 1: Development mode - relative to repo root - dev_templates_dir = (repo_path / "resources" / "prompts").resolve() - tried_locations.append(dev_templates_dir) - debug_print(f"[dim]Debug:[/dim] Trying development path: {dev_templates_dir}") - if dev_templates_dir.exists(): - templates_dir = dev_templates_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - debug_print("[dim]Debug:[/dim] Development path not found, trying installed package...") - # Try 2: Installed package - use importlib.resources - # Note: importlib is part of Python's standard library (since Python 3.1) - # importlib.resources.files() is available since Python 3.9 - # Since we require Python >=3.11, this should always be available - # However, we catch exceptions for robustness (minimal installations, edge cases) - package_templates_dir = None - try: - import importlib.resources - - debug_print("[dim]Debug:[/dim] Using importlib.resources.files() API...") - # Use files() API (Python 3.9+) - recommended approach - resources_ref = importlib.resources.files("specfact_cli") - templates_ref = resources_ref / "resources" / "prompts" - # Convert Traversable to Path - # Traversable objects can be converted to Path via str() - # Use resolve() to handle Windows/Linux/macOS path differences - package_templates_dir = Path(str(templates_ref)).resolve() - tried_locations.append(package_templates_dir) - debug_print(f"[dim]Debug:[/dim] Package templates path: {package_templates_dir}") - if package_templates_dir.exists(): - templates_dir = package_templates_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Package templates path exists but directory not found") - except (ImportError, ModuleNotFoundError) as e: - console.print( - f"[yellow]⚠[/yellow] importlib.resources not available or module not found: {type(e).__name__}: {e}" - ) - debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") - except (TypeError, AttributeError, ValueError) as e: - console.print(f"[yellow]⚠[/yellow] Error converting Traversable to Path: {e}") - debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Unexpected error with importlib.resources: {type(e).__name__}: {e}") - debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") - - # Fallback: importlib.util.find_spec() + comprehensive package location search - if not templates_dir or not templates_dir.exists(): - try: - import importlib.util - - debug_print("[dim]Debug:[/dim] Using importlib.util.find_spec() fallback...") - spec = importlib.util.find_spec("specfact_cli") - if spec and spec.origin: - # spec.origin points to __init__.py - # Go up to package root, then to resources/prompts - # Use resolve() for cross-platform compatibility - package_root = Path(spec.origin).parent.resolve() - package_templates_dir = (package_root / "resources" / "prompts").resolve() - tried_locations.append(package_templates_dir) - debug_print(f"[dim]Debug:[/dim] Package root from spec.origin: {package_root}") - debug_print(f"[dim]Debug:[/dim] Templates path from spec: {package_templates_dir}") - if package_templates_dir.exists(): - templates_dir = package_templates_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Templates path from spec not found") - else: - console.print("[yellow]⚠[/yellow] Could not find specfact_cli module spec") - if spec is None: - debug_print("[dim]Debug:[/dim] spec is None") - elif not spec.origin: - debug_print("[dim]Debug:[/dim] spec.origin is None or empty") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Error with importlib.util.find_spec(): {type(e).__name__}: {e}") - - # Fallback: Comprehensive package location search (cross-platform) - if not templates_dir or not templates_dir.exists(): - try: - debug_print("[dim]Debug:[/dim] Searching all package installation locations...") - package_locations = get_package_installation_locations("specfact_cli") - debug_print(f"[dim]Debug:[/dim] Found {len(package_locations)} possible package location(s)") - for i, loc in enumerate(package_locations, 1): - debug_print(f"[dim]Debug:[/dim] {i}. {loc}") - # Check for resources/prompts in this package location - resource_path = (loc / "resources" / "prompts").resolve() - tried_locations.append(resource_path) - if resource_path.exists(): - templates_dir = resource_path - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - break - if not templates_dir or not templates_dir.exists(): - # Try using the helper function as a final attempt - debug_print("[dim]Debug:[/dim] Trying find_package_resources_path() helper...") - resource_path = find_package_resources_path("specfact_cli", "resources/prompts") - if resource_path and resource_path.exists(): - tried_locations.append(resource_path) - templates_dir = resource_path - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Resources not found in any package location") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Error searching package locations: {type(e).__name__}: {e}") - - # Try 3: Fallback - relative to this file (for edge cases) - if not templates_dir or not templates_dir.exists(): - try: - debug_print("[dim]Debug:[/dim] Trying fallback: relative to __file__...") - # Get the directory containing this file (init.py) - # init.py is in: src/specfact_cli/commands/init.py - # Go up: commands -> specfact_cli -> src -> project root - current_file = Path(__file__).resolve() - fallback_dir = (current_file.parent.parent.parent.parent / "resources" / "prompts").resolve() - tried_locations.append(fallback_dir) - debug_print(f"[dim]Debug:[/dim] Current file: {current_file}") - debug_print(f"[dim]Debug:[/dim] Fallback templates path: {fallback_dir}") - if fallback_dir.exists(): - templates_dir = fallback_dir - console.print(f"[green]✓[/green] Found templates at: {templates_dir}") - else: - console.print("[yellow]⚠[/yellow] Fallback path not found") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Error with __file__ fallback: {type(e).__name__}: {e}") - - if templates_dir and templates_dir.exists() and is_debug_mode(): - debug_log_operation("template_resolution", str(templates_dir), "success") - if not templates_dir or not templates_dir.exists(): - if is_debug_mode() and tried_locations: - debug_log_operation( - "template_resolution", - str(tried_locations[-1]) if tried_locations else "unknown", - "failure", - error="Templates directory not found after all attempts", - ) - console.print() - console.print("[red]Error:[/red] Templates directory not found after all attempts") - console.print() - console.print("[yellow]Tried locations:[/yellow]") - for i, location in enumerate(tried_locations, 1): - exists = "✓" if location.exists() else "✗" - console.print(f" {i}. {exists} {location}") - console.print() - console.print("[yellow]Debug information:[/yellow]") - console.print(f" - Python version: {sys.version}") - console.print(f" - Platform: {sys.platform}") - console.print(f" - Current working directory: {Path.cwd()}") - console.print(f" - Repository path: {repo_path}") - console.print(f" - __file__ location: {Path(__file__).resolve()}") - try: - import importlib.util - - spec = importlib.util.find_spec("specfact_cli") - if spec: - console.print(f" - Module spec found: {spec}") - console.print(f" - Module origin: {spec.origin}") - if spec.origin: - console.print(f" - Module location: {Path(spec.origin).parent.resolve()}") - else: - console.print(" - Module spec: Not found") - except Exception as e: - console.print(f" - Error checking module spec: {e}") - console.print() - console.print("[yellow]Expected location:[/yellow] resources/prompts/") - console.print("[yellow]Please ensure SpecFact is properly installed.[/yellow]") - raise typer.Exit(1) - - console.print(f"[cyan]Templates:[/cyan] {templates_dir}") - console.print() - - # Copy templates to IDE location - try: - copied_files, settings_path = copy_templates_to_ide(repo_path, detected_ide, templates_dir, force) - - if not copied_files: - console.print( - "[yellow]No templates copied (all files already exist, use --force to overwrite)[/yellow]" - ) - record({"files_copied": 0, "already_exists": True}) - raise typer.Exit(0) - - record( - { - "detected_ide": detected_ide, - "files_copied": len(copied_files), - "settings_updated": settings_path is not None, - } - ) - - console.print() - console.print(Panel("[bold green]✓ Initialization Complete[/bold green]", border_style="green")) - console.print(f"[green]Copied {len(copied_files)} template(s) to {ide_config['folder']}[/green]") - if settings_path: - console.print(f"[green]Updated VS Code settings:[/green] {settings_path}") - console.print() - - # Copy backlog field mapping templates - _copy_backlog_field_mapping_templates(repo_path, force, console) - - console.print() - console.print("[dim]You can now use SpecFact slash commands in your IDE![/dim]") - console.print("[dim]Example: /specfact.01-import --bundle legacy-api --repo .[/dim]") - - except Exception as e: - console.print(f"[red]Error:[/red] Failed to initialize IDE integration: {e}") - raise typer.Exit(1) from e +__all__ = ["app"] diff --git a/src/specfact_cli/commands/migrate.py b/src/specfact_cli/commands/migrate.py index f2c486fd..8c93580d 100644 --- a/src/specfact_cli/commands/migrate.py +++ b/src/specfact_cli/commands/migrate.py @@ -1,930 +1,6 @@ -""" -Migrate command - Convert project bundles between formats. +"""Backward-compatible app shim. Implementation moved to modules/migrate/.""" -This module provides commands for migrating project bundles from verbose -format to OpenAPI contract-based format. -""" +from specfact_cli.modules.migrate.src.commands import app -from __future__ import annotations -import re -import shutil -from pathlib import Path - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console - -from specfact_cli.models.plan import Feature -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.utils import print_error, print_info, print_success, print_warning -from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress -from specfact_cli.utils.structure import SpecFactStructure -from specfact_cli.utils.structured_io import StructuredFormat - - -app = typer.Typer(help="Migrate project bundles between formats") -console = Console() - - -@app.command("cleanup-legacy") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def cleanup_legacy( - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show what would be removed without actually removing. Default: False", - ), - force: bool = typer.Option( - False, - "--force", - help="Remove directories even if they contain files. Default: False (only removes empty directories)", - ), -) -> None: - """ - Remove empty legacy top-level directories (Phase 8.5 cleanup). - - Removes legacy directories that are no longer created by ensure_structure(): - - .specfact/plans/ (deprecated: no monolithic bundles, active bundle config moved to config.yaml) - - .specfact/contracts/ (now bundle-specific: .specfact/projects/<bundle-name>/contracts/) - - .specfact/protocols/ (now bundle-specific: .specfact/projects/<bundle-name>/protocols/) - - .specfact/sdd/ (now bundle-specific: .specfact/projects/<bundle-name>/sdd.yaml) - - .specfact/reports/ (now bundle-specific: .specfact/projects/<bundle-name>/reports/) - - .specfact/gates/results/ (removed: not used; enforcement reports are bundle-specific in reports/enforcement/) - - **Note**: If plans/config.yaml exists, it will be preserved (migrated to config.yaml) before removing plans/ directory. - - **Safety**: By default, only removes empty directories. Use --force to remove directories with files. - - **Examples:** - specfact migrate cleanup-legacy --repo . - specfact migrate cleanup-legacy --repo . --dry-run - specfact migrate cleanup-legacy --repo . --force # Remove even if files exist - """ - if is_debug_mode(): - debug_log_operation( - "command", - "migrate cleanup-legacy", - "started", - extra={"repo": str(repo), "dry_run": dry_run, "force": force}, - ) - debug_print("[dim]migrate cleanup-legacy: started[/dim]") - - specfact_dir = repo / SpecFactStructure.ROOT - if not specfact_dir.exists(): - console.print(f"[yellow]⚠[/yellow] No .specfact directory found at {specfact_dir}") - return - - legacy_dirs = [ - (specfact_dir / "plans", "plans"), - (specfact_dir / "contracts", "contracts"), - (specfact_dir / "protocols", "protocols"), - (specfact_dir / "sdd", "sdd"), - (specfact_dir / "reports", "reports"), - (specfact_dir / "gates" / "results", "gates/results"), - ] - - removed_count = 0 - skipped_count = 0 - - # Special handling for plans/ directory: migrate config.yaml before removal - plans_dir = specfact_dir / "plans" - plans_config = plans_dir / "config.yaml" - if plans_config.exists() and not dry_run: - try: - import yaml - - # Read legacy config - with plans_config.open() as f: - legacy_config = yaml.safe_load(f) or {} - active_plan = legacy_config.get("active_plan") - - if active_plan: - # Migrate to global config.yaml - global_config_path = specfact_dir / "config.yaml" - global_config = {} - if global_config_path.exists(): - with global_config_path.open() as f: - global_config = yaml.safe_load(f) or {} - global_config[SpecFactStructure.ACTIVE_BUNDLE_CONFIG_KEY] = active_plan - global_config_path.parent.mkdir(parents=True, exist_ok=True) - with global_config_path.open("w") as f: - yaml.dump(global_config, f, default_flow_style=False, sort_keys=False) - console.print("[green]✓[/green] Migrated active bundle config from plans/config.yaml to config.yaml") - except Exception as e: - console.print(f"[yellow]⚠[/yellow] Failed to migrate plans/config.yaml: {e}") - - for legacy_dir, name in legacy_dirs: - if not legacy_dir.exists(): - continue - - # Check if directory is empty - has_files = any(legacy_dir.iterdir()) - if has_files and not force: - console.print(f"[yellow]⚠[/yellow] Skipping {name}/ (contains files, use --force to remove): {legacy_dir}") - skipped_count += 1 - continue - - if dry_run: - if has_files: - console.print(f"[dim]Would remove {name}/ (contains files, --force required): {legacy_dir}[/dim]") - else: - console.print(f"[dim]Would remove empty {name}/: {legacy_dir}[/dim]") - else: - try: - if has_files: - shutil.rmtree(legacy_dir) - console.print(f"[green]✓[/green] Removed {name}/ (with files): {legacy_dir}") - else: - legacy_dir.rmdir() - console.print(f"[green]✓[/green] Removed empty {name}/: {legacy_dir}") - removed_count += 1 - except OSError as e: - console.print(f"[red]✗[/red] Failed to remove {name}/: {e}") - skipped_count += 1 - - if dry_run: - console.print( - f"\n[dim]Dry run complete. Would remove {removed_count} directory(ies), skip {skipped_count}[/dim]" - ) - else: - if removed_count > 0: - console.print( - f"\n[bold green]✓[/bold green] Cleanup complete. Removed {removed_count} legacy directory(ies)" - ) - if skipped_count > 0: - console.print( - f"[yellow]⚠[/yellow] Skipped {skipped_count} directory(ies) (use --force to remove directories with files)" - ) - if removed_count == 0 and skipped_count == 0: - console.print("[dim]No legacy directories found to remove[/dim]") - if is_debug_mode(): - debug_log_operation( - "command", - "migrate cleanup-legacy", - "success", - extra={"removed_count": removed_count, "skipped_count": skipped_count}, - ) - debug_print("[dim]migrate cleanup-legacy: success[/dim]") - - -@app.command("to-contracts") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def to_contracts( - # Target/Input - bundle: str | None = typer.Argument( - None, help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'" - ), - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Behavior/Options - extract_openapi: bool = typer.Option( - True, - "--extract-openapi/--no-extract-openapi", - help="Extract OpenAPI contracts from verbose acceptance criteria. Default: True", - ), - validate_with_specmatic: bool = typer.Option( - True, - "--validate-with-specmatic/--no-validate-with-specmatic", - help="Validate generated contracts with Specmatic. Default: True", - ), - clean_verbose_specs: bool = typer.Option( - True, - "--clean-verbose-specs/--no-clean-verbose-specs", - help="Convert verbose Given-When-Then acceptance criteria to scenarios or remove them. Default: True", - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show what would be migrated without actually migrating. Default: False", - ), -) -> None: - """ - Convert verbose project bundle to contract-based format. - - Migrates project bundles from verbose "Given...When...Then" acceptance criteria - to lightweight OpenAPI contract-based format, reducing bundle size significantly. - - For non-API features, verbose acceptance criteria are converted to scenarios - or removed to reduce bundle size. - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --repo - - **Behavior/Options**: --extract-openapi, --validate-with-specmatic, --clean-verbose-specs, --dry-run - - **Examples:** - specfact migrate to-contracts legacy-api --repo . - specfact migrate to-contracts my-bundle --repo . --dry-run - specfact migrate to-contracts my-bundle --repo . --no-validate-with-specmatic - specfact migrate to-contracts my-bundle --repo . --no-clean-verbose-specs - """ - from rich.console import Console - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None: - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - from specfact_cli.generators.openapi_extractor import OpenAPIExtractor - from specfact_cli.telemetry import telemetry - - repo_path = repo.resolve() - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - telemetry_metadata = { - "bundle": bundle, - "extract_openapi": extract_openapi, - "validate_with_specmatic": validate_with_specmatic, - "dry_run": dry_run, - } - - if is_debug_mode(): - debug_log_operation( - "command", - "migrate to-contracts", - "started", - extra={"bundle": bundle, "repo": str(repo_path), "dry_run": dry_run}, - ) - debug_print("[dim]migrate to-contracts: started[/dim]") - - with telemetry.track_command("migrate.to_contracts", telemetry_metadata) as record: - console.print(f"[bold cyan]Migrating bundle:[/bold cyan] {bundle}") - console.print(f"[dim]Repository:[/dim] {repo_path}") - - if dry_run: - print_warning("DRY RUN MODE - No changes will be made") - - try: - # Load existing project bundle with unified progress display - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Ensure contracts directory exists - contracts_dir = bundle_dir / "contracts" - if not dry_run: - contracts_dir.mkdir(parents=True, exist_ok=True) - - extractor = OpenAPIExtractor(repo_path) - contracts_created = 0 - contracts_validated = 0 - contracts_removed = 0 # Track invalid contract references removed - verbose_specs_cleaned = 0 # Track verbose specs cleaned - - # Process each feature - for feature_key, feature in project_bundle.features.items(): - if not feature.stories: - continue - - # Clean verbose acceptance criteria for all features (before contract extraction) - if clean_verbose_specs: - cleaned = _clean_verbose_acceptance_criteria(feature, feature_key, dry_run) - if cleaned: - verbose_specs_cleaned += cleaned - - # Check if feature already has a contract AND the file actually exists - if feature.contract: - contract_path_check = bundle_dir / feature.contract - if contract_path_check.exists(): - print_info(f"Feature {feature_key} already has contract: {feature.contract}") - continue - # Contract reference exists but file is missing - recreate it - print_warning( - f"Feature {feature_key} has contract reference but file is missing: {feature.contract}. Will recreate." - ) - # Clear the contract reference so we recreate it - feature.contract = None - - # Extract OpenAPI contract - if extract_openapi: - print_info(f"Extracting OpenAPI contract for {feature_key}...") - - # Try to extract from code first (more accurate) - if feature.source_tracking and feature.source_tracking.implementation_files: - openapi_spec = extractor.extract_openapi_from_code(repo_path, feature) - else: - # Fallback to extracting from verbose acceptance criteria - openapi_spec = extractor.extract_openapi_from_verbose(feature) - - # Only save contract if it has paths (non-empty spec) - paths = openapi_spec.get("paths", {}) - if not paths or len(paths) == 0: - # Feature has no API endpoints - remove invalid contract reference if it exists - if feature.contract: - print_warning( - f"Feature {feature_key} has no API endpoints but has contract reference. Removing invalid reference." - ) - feature.contract = None - contracts_removed += 1 - else: - print_warning( - f"Feature {feature_key} has no API endpoints in acceptance criteria, skipping contract creation" - ) - continue - - # Save contract file - contract_filename = f"{feature_key}.openapi.yaml" - contract_path = contracts_dir / contract_filename - - if not dry_run: - try: - # Ensure contracts directory exists before saving - contracts_dir.mkdir(parents=True, exist_ok=True) - extractor.save_openapi_contract(openapi_spec, contract_path) - # Verify contract file was actually created - if not contract_path.exists(): - print_error(f"Failed to create contract file: {contract_path}") - continue - # Verify contracts directory exists - if not contracts_dir.exists(): - print_error(f"Contracts directory was not created: {contracts_dir}") - continue - # Update feature with contract reference - feature.contract = f"contracts/{contract_filename}" - contracts_created += 1 - except Exception as e: - print_error(f"Failed to save contract for {feature_key}: {e}") - continue - - # Validate with Specmatic if requested - if validate_with_specmatic: - print_info(f"Validating contract for {feature_key} with Specmatic...") - import asyncio - - try: - result = asyncio.run(extractor.validate_with_specmatic(contract_path)) - if result.is_valid: - print_success(f"Contract for {feature_key} is valid") - contracts_validated += 1 - else: - print_warning(f"Contract for {feature_key} has validation issues:") - for error in result.errors[:3]: # Show first 3 errors - console.print(f" [yellow]- {error}[/yellow]") - except Exception as e: - print_warning(f"Specmatic validation failed: {e}") - else: - console.print(f"[dim]Would create contract: {contract_path}[/dim]") - - # Save updated project bundle if contracts were created, invalid references removed, or verbose specs cleaned - if not dry_run and (contracts_created > 0 or contracts_removed > 0 or verbose_specs_cleaned > 0): - print_info("Saving updated project bundle...") - # Save contracts directory to a temporary location before atomic save - # (atomic save removes the entire bundle_dir, so we need to preserve contracts) - import shutil - import tempfile - - contracts_backup_path: Path | None = None - # Always backup contracts directory if it exists and has files - # (even if we didn't create new ones, we need to preserve existing contracts) - if contracts_dir.exists() and contracts_dir.is_dir() and list(contracts_dir.iterdir()): - # Create temporary backup of contracts directory - contracts_backup = tempfile.mkdtemp() - contracts_backup_path = Path(contracts_backup) - # Copy contracts directory to backup - shutil.copytree(contracts_dir, contracts_backup_path / "contracts", dirs_exist_ok=True) - - # Save bundle (this will remove and recreate bundle_dir) - save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) - - # Restore contracts directory after atomic save - if contracts_backup_path is not None and (contracts_backup_path / "contracts").exists(): - restored_contracts = contracts_backup_path / "contracts" - # Restore contracts to bundle_dir - if restored_contracts.exists(): - shutil.copytree(restored_contracts, contracts_dir, dirs_exist_ok=True) - # Clean up backup - shutil.rmtree(str(contracts_backup_path), ignore_errors=True) - - if contracts_created > 0: - print_success(f"Migration complete: {contracts_created} contracts created") - if contracts_removed > 0: - print_success(f"Migration complete: {contracts_removed} invalid contract references removed") - if contracts_created == 0 and contracts_removed == 0 and verbose_specs_cleaned == 0: - print_info("Migration complete: No changes needed") - if verbose_specs_cleaned > 0: - print_success(f"Cleaned verbose specs: {verbose_specs_cleaned} stories updated") - if validate_with_specmatic and contracts_created > 0: - console.print(f"[dim]Contracts validated: {contracts_validated}/{contracts_created}[/dim]") - elif dry_run: - console.print(f"[dim]Would create {contracts_created} contracts[/dim]") - if clean_verbose_specs: - console.print(f"[dim]Would clean verbose specs in {verbose_specs_cleaned} stories[/dim]") - - if is_debug_mode(): - debug_log_operation( - "command", - "migrate to-contracts", - "success", - extra={ - "contracts_created": contracts_created, - "contracts_validated": contracts_validated, - "verbose_specs_cleaned": verbose_specs_cleaned, - }, - ) - debug_print("[dim]migrate to-contracts: success[/dim]") - record( - { - "contracts_created": contracts_created, - "contracts_validated": contracts_validated, - "verbose_specs_cleaned": verbose_specs_cleaned, - } - ) - - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "migrate to-contracts", - "failed", - error=str(e), - extra={"reason": type(e).__name__}, - ) - print_error(f"Migration failed: {e}") - record({"error": str(e)}) - raise typer.Exit(1) from e - - -def _is_verbose_gwt_pattern(acceptance: str) -> bool: - """Check if acceptance criteria is verbose Given-When-Then pattern.""" - # Check for verbose patterns: "Given X, When Y, Then Z" with detailed conditions - gwt_pattern = r"Given\s+.+?,\s*When\s+.+?,\s*Then\s+.+" - if not re.search(gwt_pattern, acceptance, re.IGNORECASE): - return False - - # Consider verbose if it's longer than 100 characters (detailed scenario) - # or contains multiple conditions (and/or operators) - return ( - len(acceptance) > 100 - or " and " in acceptance.lower() - or " or " in acceptance.lower() - or acceptance.count(",") > 2 # Multiple comma-separated conditions - ) - - -def _extract_gwt_parts(acceptance: str) -> tuple[str, str, str] | None: - """Extract Given, When, Then parts from acceptance criteria.""" - # Pattern to match "Given X, When Y, Then Z" format - gwt_pattern = r"Given\s+(.+?),\s*When\s+(.+?),\s*Then\s+(.+?)(?:$|,)" - match = re.search(gwt_pattern, acceptance, re.IGNORECASE | re.DOTALL) - if match: - return (match.group(1).strip(), match.group(2).strip(), match.group(3).strip()) - return None - - -def _categorize_scenario(acceptance: str) -> str: - """Categorize scenario as primary, alternate, exception, or recovery.""" - acc_lower = acceptance.lower() - if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid", "reject"]): - return "exception" - if any(keyword in acc_lower for keyword in ["recover", "retry", "fallback", "alternative"]): - return "recovery" - if any(keyword in acc_lower for keyword in ["alternate", "alternative", "else", "otherwise"]): - return "alternate" - return "primary" - - -@beartype -def _clean_verbose_acceptance_criteria(feature: Feature, feature_key: str, dry_run: bool) -> int: - """ - Clean verbose Given-When-Then acceptance criteria. - - Converts verbose acceptance criteria to scenarios or removes them if redundant. - Returns the number of stories cleaned. - """ - cleaned_count = 0 - - if not feature.stories: - return 0 - - for story in feature.stories: - if not story.acceptance: - continue - - # Check if story has GWT patterns (move all to scenarios, not just verbose ones) - gwt_acceptance = [acc for acc in story.acceptance if "Given" in acc and "When" in acc and "Then" in acc] - if not gwt_acceptance: - continue - - # Initialize scenarios dict if needed - if story.scenarios is None: - story.scenarios = {"primary": [], "alternate": [], "exception": [], "recovery": []} - - # Convert verbose acceptance criteria to scenarios - converted_count = 0 - remaining_acceptance = [] - - for acc in story.acceptance: - # Move all GWT patterns to scenarios (not just verbose ones) - if "Given" in acc and "When" in acc and "Then" in acc: - # Extract GWT parts - gwt_parts = _extract_gwt_parts(acc) - if gwt_parts: - given, when, then = gwt_parts - scenario_text = f"Given {given}, When {when}, Then {then}" - category = _categorize_scenario(acc) - - # Add to appropriate scenario category (even if it already exists, we still remove from acceptance) - if scenario_text not in story.scenarios[category]: - story.scenarios[category].append(scenario_text) - # Always count as converted (removed from acceptance) even if scenario already exists - converted_count += 1 - # Don't keep GWT patterns in acceptance list - else: - # Keep non-GWT acceptance criteria - remaining_acceptance.append(acc) - - if converted_count > 0: - # Update acceptance criteria (remove verbose ones, keep simple ones) - story.acceptance = remaining_acceptance - - # If all acceptance was verbose and we converted to scenarios, - # add a simple summary acceptance criterion - if not story.acceptance: - story.acceptance.append( - f"Given {story.title}, When operations are performed, Then expected behavior is achieved" - ) - - if not dry_run: - print_info( - f"Feature {feature_key}, Story {story.key}: Converted {converted_count} verbose acceptance criteria to scenarios" - ) - else: - console.print( - f"[dim]Would convert {converted_count} verbose acceptance criteria to scenarios for {feature_key}/{story.key}[/dim]" - ) - - cleaned_count += 1 - - return cleaned_count - - -@app.command("artifacts") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def migrate_artifacts( - # Target/Input - bundle: str | None = typer.Argument( - None, - help="Project bundle name (e.g., legacy-api). If not specified, migrates artifacts for all bundles found in .specfact/projects/", - ), - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Behavior/Options - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show what would be migrated without actually migrating. Default: False", - ), - backup: bool = typer.Option( - True, - "--backup/--no-backup", - help="Create backup before migration. Default: True", - ), -) -> None: - """ - Migrate bundle-specific artifacts to bundle folders (Phase 8.5). - - Moves artifacts from global locations to bundle-specific folders: - - Reports: .specfact/reports/* → .specfact/projects/<bundle-name>/reports/* - - SDD manifests: .specfact/sdd/<bundle-name>.yaml → .specfact/projects/<bundle-name>/sdd.yaml - - Tasks: .specfact/tasks/<bundle-name>-*.yaml → .specfact/projects/<bundle-name>/tasks.yaml - - **Parameter Groups:** - - **Target/Input**: bundle (optional argument), --repo - - **Behavior/Options**: --dry-run, --backup/--no-backup - - **Examples:** - specfact migrate artifacts legacy-api --repo . - specfact migrate artifacts --repo . # Migrate all bundles - specfact migrate artifacts legacy-api --dry-run # Preview migration - specfact migrate artifacts legacy-api --no-backup # Skip backup - """ - - repo_path = repo.resolve() - base_path = repo_path - - # Determine which bundles to migrate - bundles_to_migrate: list[str] = [] - if bundle: - bundles_to_migrate = [bundle] - else: - # Find all bundles in .specfact/projects/ - projects_dir = base_path / SpecFactStructure.PROJECTS - if projects_dir.exists(): - for bundle_dir in projects_dir.iterdir(): - if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists(): - bundles_to_migrate.append(bundle_dir.name) - if not bundles_to_migrate: - print_error("No project bundles found. Create one with 'specfact plan init' or 'specfact import from-code'") - raise typer.Exit(1) - - if is_debug_mode(): - debug_log_operation( - "command", - "migrate artifacts", - "started", - extra={"bundles": bundles_to_migrate, "repo": str(repo_path), "dry_run": dry_run}, - ) - debug_print("[dim]migrate artifacts: started[/dim]") - - console.print(f"[bold cyan]Migrating artifacts for {len(bundles_to_migrate)} bundle(s)[/bold cyan]") - if dry_run: - print_warning("DRY RUN MODE - No changes will be made") - - total_moved = 0 - total_errors = 0 - - for bundle_name in bundles_to_migrate: - console.print(f"\n[bold]Bundle:[/bold] {bundle_name}") - - # Verify bundle exists - bundle_dir = SpecFactStructure.project_dir(base_path=base_path, bundle_name=bundle_name) - if not bundle_dir.exists() or not (bundle_dir / "bundle.manifest.yaml").exists(): - # If a specific bundle was requested, fail; otherwise skip (for --all mode) - if bundle: - print_error(f"Bundle {bundle_name} not found") - raise typer.Exit(1) - print_warning(f"Bundle {bundle_name} not found, skipping") - total_errors += 1 - continue - - # Ensure bundle-specific directories exist - if not dry_run: - SpecFactStructure.ensure_project_structure(base_path=base_path, bundle_name=bundle_name) - - moved_count = 0 - - # 1. Migrate reports - moved_count += _migrate_reports(base_path, bundle_name, bundle_dir, dry_run, backup) - - # 2. Migrate SDD manifest - moved_count += _migrate_sdd(base_path, bundle_name, bundle_dir, dry_run, backup) - - # 3. Migrate tasks - moved_count += _migrate_tasks(base_path, bundle_name, bundle_dir, dry_run, backup) - - total_moved += moved_count - if moved_count > 0: - print_success(f"Migrated {moved_count} artifact(s) for bundle {bundle_name}") - else: - print_info(f"No artifacts to migrate for bundle {bundle_name}") - - # Summary - console.print("\n[bold cyan]Migration Summary[/bold cyan]") - console.print(f" Bundles processed: {len(bundles_to_migrate)}") - console.print(f" Artifacts moved: {total_moved}") - if total_errors > 0: - console.print(f" Errors: {total_errors}") - - if dry_run: - print_warning("DRY RUN - No changes were made. Run without --dry-run to perform migration.") - - if is_debug_mode(): - debug_log_operation( - "command", - "migrate artifacts", - "success", - extra={ - "bundles_processed": len(bundles_to_migrate), - "total_moved": total_moved, - "total_errors": total_errors, - }, - ) - debug_print("[dim]migrate artifacts: success[/dim]") - - -def _migrate_reports(base_path: Path, bundle_name: str, bundle_dir: Path, dry_run: bool, backup: bool) -> int: - """Migrate reports from global location to bundle-specific location.""" - moved_count = 0 - - # Global reports directories - global_reports = base_path / SpecFactStructure.REPORTS - if not global_reports.exists(): - return 0 - - # Bundle-specific reports directory - bundle_reports_dir = bundle_dir / "reports" - - # Migrate each report type - report_types = ["brownfield", "comparison", "enrichment", "enforcement"] - for report_type in report_types: - global_report_dir = global_reports / report_type - if not global_report_dir.exists(): - continue - - bundle_report_dir = bundle_reports_dir / report_type - - # Find reports that might belong to this bundle - # Look for files with bundle name in filename or all files if bundle is the only one - for report_file in global_report_dir.glob("*"): - if not report_file.is_file(): - continue - - # Check if report belongs to this bundle - # Reports might have bundle name in filename, or we migrate all if it's the only bundle - should_migrate = False - if bundle_name.lower() in report_file.name.lower(): - should_migrate = True - elif report_type == "enrichment" and ".enrichment." in report_file.name: - # Enrichment reports are typically bundle-specific - should_migrate = True - elif report_type in ("brownfield", "comparison", "enforcement"): - # For other report types, migrate if filename suggests it's for this bundle - # or if it's the only bundle (conservative approach) - should_migrate = True # Migrate all reports to bundle (user can reorganize if needed) - - if should_migrate: - target_path = bundle_report_dir / report_file.name - if target_path.exists(): - print_warning(f"Target report already exists: {target_path}, skipping {report_file.name}") - continue - - if not dry_run: - if backup: - # Create backup - backup_dir = ( - base_path - / SpecFactStructure.ROOT - / ".migration-backup" - / bundle_name - / "reports" - / report_type - ) - backup_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(report_file, backup_dir / report_file.name) - - # Move file - bundle_report_dir.mkdir(parents=True, exist_ok=True) - shutil.move(str(report_file), str(target_path)) - moved_count += 1 - else: - console.print(f" [dim]Would move: {report_file} → {target_path}[/dim]") - moved_count += 1 - - return moved_count - - -def _migrate_sdd(base_path: Path, bundle_name: str, bundle_dir: Path, dry_run: bool, backup: bool) -> int: - """Migrate SDD manifest from global location to bundle-specific location.""" - moved_count = 0 - - # Check legacy multi-SDD location: .specfact/sdd/<bundle-name>.yaml - sdd_dir = base_path / SpecFactStructure.SDD - legacy_sdd_yaml = sdd_dir / f"{bundle_name}.yaml" - legacy_sdd_json = sdd_dir / f"{bundle_name}.json" - - # Check legacy single-SDD location: .specfact/sdd.yaml (only if bundle name matches active) - legacy_single_yaml = base_path / SpecFactStructure.ROOT / "sdd.yaml" - legacy_single_json = base_path / SpecFactStructure.ROOT / "sdd.json" - - # Determine which SDD to migrate - sdd_to_migrate: Path | None = None - if legacy_sdd_yaml.exists(): - sdd_to_migrate = legacy_sdd_yaml - elif legacy_sdd_json.exists(): - sdd_to_migrate = legacy_sdd_json - elif legacy_single_yaml.exists(): - # Only migrate single SDD if it's the active bundle - active_bundle = SpecFactStructure.get_active_bundle_name(base_path) - if active_bundle == bundle_name: - sdd_to_migrate = legacy_single_yaml - elif legacy_single_json.exists(): - active_bundle = SpecFactStructure.get_active_bundle_name(base_path) - if active_bundle == bundle_name: - sdd_to_migrate = legacy_single_json - - if sdd_to_migrate: - # Bundle-specific SDD path - target_sdd = SpecFactStructure.get_bundle_sdd_path( - bundle_name=bundle_name, - base_path=base_path, - format=StructuredFormat.YAML if sdd_to_migrate.suffix == ".yaml" else StructuredFormat.JSON, - ) - - if target_sdd.exists(): - print_warning(f"Target SDD already exists: {target_sdd}, skipping {sdd_to_migrate.name}") - return 0 - - if not dry_run: - if backup: - # Create backup - backup_dir = base_path / SpecFactStructure.ROOT / ".migration-backup" / bundle_name - backup_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(sdd_to_migrate, backup_dir / sdd_to_migrate.name) - - # Move file - target_sdd.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(sdd_to_migrate), str(target_sdd)) - moved_count += 1 - else: - console.print(f" [dim]Would move: {sdd_to_migrate} → {target_sdd}[/dim]") - moved_count += 1 - - return moved_count - - -def _migrate_tasks(base_path: Path, bundle_name: str, bundle_dir: Path, dry_run: bool, backup: bool) -> int: - """Migrate task files from global location to bundle-specific location.""" - moved_count = 0 - - # Global tasks directory - tasks_dir = base_path / SpecFactStructure.TASKS - if not tasks_dir.exists(): - return 0 - - # Find task files for this bundle - # Task files typically named: <bundle-name>-<hash>.tasks.yaml - task_patterns = [ - f"{bundle_name}-*.tasks.yaml", - f"{bundle_name}-*.tasks.json", - f"{bundle_name}-*.tasks.md", - ] - - task_files: list[Path] = [] - for pattern in task_patterns: - task_files.extend(tasks_dir.glob(pattern)) - - if not task_files: - return 0 - - # Bundle-specific tasks path - target_tasks = SpecFactStructure.get_bundle_tasks_path(bundle_name=bundle_name, base_path=base_path) - - # If multiple task files, use the most recent one - if len(task_files) > 1: - task_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) - print_info(f"Found {len(task_files)} task files for {bundle_name}, using most recent: {task_files[0].name}") - - task_file = task_files[0] - - if target_tasks.exists(): - print_warning(f"Target tasks file already exists: {target_tasks}, skipping {task_file.name}") - return 0 - - if not dry_run: - if backup: - # Create backup - backup_dir = base_path / SpecFactStructure.ROOT / ".migration-backup" / bundle_name - backup_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(task_file, backup_dir / task_file.name) - - # Move file - target_tasks.parent.mkdir(parents=True, exist_ok=True) - shutil.move(str(task_file), str(target_tasks)) - moved_count += 1 - - # Remove other task files for this bundle (if any) - for other_task in task_files[1:]: - if backup: - backup_dir = base_path / SpecFactStructure.ROOT / ".migration-backup" / bundle_name - backup_dir.mkdir(parents=True, exist_ok=True) - shutil.copy2(other_task, backup_dir / other_task.name) - other_task.unlink() - else: - console.print(f" [dim]Would move: {task_file} → {target_tasks}[/dim]") - if len(task_files) > 1: - console.print(f" [dim]Would remove {len(task_files) - 1} other task file(s) for {bundle_name}[/dim]") - moved_count += 1 - - return moved_count +__all__ = ["app"] diff --git a/src/specfact_cli/commands/plan.py b/src/specfact_cli/commands/plan.py index 7b2d033e..fbaa4c6f 100644 --- a/src/specfact_cli/commands/plan.py +++ b/src/specfact_cli/commands/plan.py @@ -1,5539 +1,6 @@ -""" -Plan command - Manage greenfield development plans. +"""Backward-compatible app shim. Implementation moved to modules/plan/.""" -This module provides commands for creating and managing development plans, -features, and stories. -""" +from specfact_cli.modules.plan.src.commands import app -from __future__ import annotations -import json -from contextlib import suppress -from datetime import UTC -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.table import Table - -from specfact_cli import runtime -from specfact_cli.analyzers.ambiguity_scanner import AmbiguityFinding -from specfact_cli.comparators.plan_comparator import PlanComparator -from specfact_cli.generators.report_generator import ReportFormat, ReportGenerator -from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport -from specfact_cli.models.enforcement import EnforcementConfig -from specfact_cli.models.plan import Business, Feature, Idea, PlanBundle, Product, Release, Story -from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle -from specfact_cli.models.sdd import SDDHow, SDDManifest, SDDWhat, SDDWhy -from specfact_cli.modes import detect_mode -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive -from specfact_cli.telemetry import telemetry -from specfact_cli.utils import ( - display_summary, - print_error, - print_info, - print_section, - print_success, - print_warning, - prompt_confirm, - prompt_dict, - prompt_list, - prompt_text, -) -from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress -from specfact_cli.utils.structured_io import StructuredFormat, load_structured_file -from specfact_cli.validators.schema import validate_plan_bundle - - -app = typer.Typer(help="Manage development plans, features, and stories") -console = Console() - - -# Use shared progress utilities for consistency (aliased to maintain existing function names) -def _load_bundle_with_progress(bundle_dir: Path, validate_hashes: bool = False) -> ProjectBundle: - """Load project bundle with unified progress display.""" - return load_bundle_with_progress(bundle_dir, validate_hashes=validate_hashes, console_instance=console) - - -def _save_bundle_with_progress(bundle: ProjectBundle, bundle_dir: Path, atomic: bool = True) -> None: - """Save project bundle with unified progress display.""" - save_bundle_with_progress(bundle, bundle_dir, atomic=atomic, console_instance=console) - - -@app.command("init") -@beartype -@require(lambda bundle: isinstance(bundle, str) and len(bundle) > 0, "Bundle name must be non-empty string") -def init( - # Target/Input - bundle: str = typer.Argument(..., help="Project bundle name (e.g., legacy-api, auth-module)"), - # Behavior/Options (interactive=None: use global --no-interactive from root when set) - interactive: bool | None = typer.Option( - None, - "--interactive/--no-interactive", - help="Interactive mode with prompts. Default: follows global --no-interactive if set, else True", - ), - scaffold: bool = typer.Option( - True, - "--scaffold/--no-scaffold", - help="Create complete .specfact directory structure. Default: True (scaffold enabled)", - ), -) -> None: - """ - Initialize a new modular project bundle. - - Creates a new modular project bundle with idea, product, and features structure. - The bundle is created in .specfact/projects/<bundle-name>/ directory. - - **Parameter Groups:** - - **Target/Input**: bundle (required argument) - - **Behavior/Options**: --interactive/--no-interactive, --scaffold/--no-scaffold - - **Examples:** - specfact plan init legacy-api # Interactive with scaffold - specfact --no-interactive plan init auth-module # Minimal bundle (global option first) - specfact plan init my-project --no-scaffold # Bundle without directory structure - """ - from specfact_cli.utils.structure import SpecFactStructure - - # Respect global --no-interactive when passed before the command (specfact [OPTIONS] COMMAND) - if interactive is None: - interactive = not is_non_interactive() - - telemetry_metadata = { - "bundle": bundle, - "interactive": interactive, - "scaffold": scaffold, - } - - if is_debug_mode(): - debug_log_operation( - "command", - "plan init", - "started", - extra={"bundle": bundle, "interactive": interactive, "scaffold": scaffold}, - ) - debug_print("[dim]plan init: started[/dim]") - - with telemetry.track_command("plan.init", telemetry_metadata) as record: - print_section("SpecFact CLI - Project Bundle Builder") - - # Create .specfact structure if requested - if scaffold: - print_info("Creating .specfact/ directory structure...") - SpecFactStructure.scaffold_project() - print_success("Directory structure created") - else: - # Ensure minimum structure exists - SpecFactStructure.ensure_structure() - - # Get project bundle directory - bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) - if bundle_dir.exists(): - if is_debug_mode(): - debug_log_operation( - "command", - "plan init", - "failed", - error=f"Project bundle already exists: {bundle_dir}", - extra={"reason": "bundle_exists", "bundle": bundle}, - ) - print_error(f"Project bundle already exists: {bundle_dir}") - print_info("Use a different bundle name or remove the existing bundle") - raise typer.Exit(1) - - # Ensure project structure exists - SpecFactStructure.ensure_project_structure(bundle_name=bundle) - - if not interactive: - # Non-interactive mode: create minimal bundle - _create_minimal_bundle(bundle, bundle_dir) - record({"bundle_type": "minimal"}) - return - - # Interactive mode: guided bundle creation - try: - project_bundle = _build_bundle_interactively(bundle) - - # Save bundle - _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) - - # Record bundle statistics - record( - { - "bundle_type": "interactive", - "features_count": len(project_bundle.features), - "stories_count": sum(len(f.stories) for f in project_bundle.features.values()), - } - ) - - if is_debug_mode(): - debug_log_operation( - "command", - "plan init", - "success", - extra={"bundle": bundle, "bundle_dir": str(bundle_dir)}, - ) - debug_print("[dim]plan init: success[/dim]") - print_success(f"Project bundle created successfully: {bundle_dir}") - - except KeyboardInterrupt: - print_warning("\nBundle creation cancelled") - raise typer.Exit(1) from None - except Exception as e: - if is_debug_mode(): - debug_log_operation( - "command", - "plan init", - "failed", - error=str(e), - extra={"reason": type(e).__name__, "bundle": bundle}, - ) - print_error(f"Failed to create bundle: {e}") - raise typer.Exit(1) from e - - -def _create_minimal_bundle(bundle_name: str, bundle_dir: Path) -> None: - """Create a minimal project bundle.""" - - manifest = BundleManifest( - versions=BundleVersions(schema="1.0", project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - - bundle = ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - idea=None, - business=None, - product=Product(themes=[], releases=[]), - features={}, - clarifications=None, - ) - - _save_bundle_with_progress(bundle, bundle_dir, atomic=True) - print_success(f"Minimal project bundle created: {bundle_dir}") - - -def _build_bundle_interactively(bundle_name: str) -> ProjectBundle: - """Build a plan bundle through interactive prompts.""" - # Section 1: Idea - print_section("1. Idea - What are you building?") - - idea_title = prompt_text("Project title", required=True) - idea_narrative = prompt_text("Project narrative (brief description)", required=True) - - add_idea_details = prompt_confirm("Add optional idea details? (target users, metrics)", default=False) - - idea_data: dict[str, Any] = {"title": idea_title, "narrative": idea_narrative} - - if add_idea_details: - target_users = prompt_list("Target users") - value_hypothesis = prompt_text("Value hypothesis", required=False) - - if target_users: - idea_data["target_users"] = target_users - if value_hypothesis: - idea_data["value_hypothesis"] = value_hypothesis - - if prompt_confirm("Add success metrics?", default=False): - metrics = prompt_dict("Success Metrics") - if metrics: - idea_data["metrics"] = metrics - - idea = Idea(**idea_data) - display_summary("Idea Summary", idea_data) - - # Section 2: Business (optional) - print_section("2. Business Context (optional)") - - business = None - if prompt_confirm("Add business context?", default=False): - segments = prompt_list("Market segments") - problems = prompt_list("Problems you're solving") - solutions = prompt_list("Your solutions") - differentiation = prompt_list("How you differentiate") - risks = prompt_list("Business risks") - - business = Business( - segments=segments if segments else [], - problems=problems if problems else [], - solutions=solutions if solutions else [], - differentiation=differentiation if differentiation else [], - risks=risks if risks else [], - ) - - # Section 3: Product - print_section("3. Product - Themes and Releases") - - themes = prompt_list("Product themes (e.g., AI/ML, Security)") - releases: list[Release] = [] - - if prompt_confirm("Define releases?", default=True): - while True: - release_name = prompt_text("Release name (e.g., v1.0 - MVP)", required=False) - if not release_name: - break - - objectives = prompt_list("Release objectives") - scope = prompt_list("Feature keys in scope (e.g., FEATURE-001)") - risks = prompt_list("Release risks") - - releases.append( - Release( - name=release_name, - objectives=objectives if objectives else [], - scope=scope if scope else [], - risks=risks if risks else [], - ) - ) - - if not prompt_confirm("Add another release?", default=False): - break - - product = Product(themes=themes if themes else [], releases=releases) - - # Section 4: Features - print_section("4. Features - What will you build?") - - features: list[Feature] = [] - while prompt_confirm("Add a feature?", default=True): - feature = _prompt_feature() - features.append(feature) - - if not prompt_confirm("Add another feature?", default=False): - break - - # Create project bundle - - manifest = BundleManifest( - versions=BundleVersions(schema="1.0", project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - - # Convert features list to dict - features_dict: dict[str, Feature] = {f.key: f for f in features} - - project_bundle = ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - idea=idea, - business=business, - product=product, - features=features_dict, - clarifications=None, - ) - - # Final summary - print_section("Project Bundle Summary") - console.print(f"[cyan]Bundle:[/cyan] {bundle_name}") - console.print(f"[cyan]Title:[/cyan] {idea.title}") - console.print(f"[cyan]Themes:[/cyan] {', '.join(product.themes)}") - console.print(f"[cyan]Features:[/cyan] {len(features)}") - console.print(f"[cyan]Releases:[/cyan] {len(product.releases)}") - - return project_bundle - - -def _prompt_feature() -> Feature: - """Prompt for feature details.""" - print_info("\nNew Feature") - - key = prompt_text("Feature key (e.g., FEATURE-001)", required=True) - title = prompt_text("Feature title", required=True) - outcomes = prompt_list("Expected outcomes") - acceptance = prompt_list("Acceptance criteria") - - add_details = prompt_confirm("Add optional details?", default=False) - - feature_data = { - "key": key, - "title": title, - "outcomes": outcomes if outcomes else [], - "acceptance": acceptance if acceptance else [], - } - - if add_details: - constraints = prompt_list("Constraints") - if constraints: - feature_data["constraints"] = constraints - - confidence = prompt_text("Confidence (0.0-1.0)", required=False) - if confidence: - with suppress(ValueError): - feature_data["confidence"] = float(confidence) - - draft = prompt_confirm("Mark as draft?", default=False) - feature_data["draft"] = draft - - # Add stories - stories: list[Story] = [] - if prompt_confirm("Add stories to this feature?", default=True): - while True: - story = _prompt_story() - stories.append(story) - - if not prompt_confirm("Add another story?", default=False): - break - - feature_data["stories"] = stories - - return Feature(**feature_data) - - -def _prompt_story() -> Story: - """Prompt for story details.""" - print_info(" New Story") - - key = prompt_text(" Story key (e.g., STORY-001)", required=True) - title = prompt_text(" Story title", required=True) - acceptance = prompt_list(" Acceptance criteria") - - story_data = { - "key": key, - "title": title, - "acceptance": acceptance if acceptance else [], - } - - if prompt_confirm(" Add optional details?", default=False): - tags = prompt_list(" Tags (e.g., critical, backend)") - if tags: - story_data["tags"] = tags - - confidence = prompt_text(" Confidence (0.0-1.0)", required=False) - if confidence: - with suppress(ValueError): - story_data["confidence"] = float(confidence) - - draft = prompt_confirm(" Mark as draft?", default=False) - story_data["draft"] = draft - - return Story(**story_data) - - -@app.command("add-feature") -@beartype -@require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string") -@require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string") -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -def add_feature( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", - ), - key: str = typer.Option(..., "--key", help="Feature key (e.g., FEATURE-001)"), - title: str = typer.Option(..., "--title", help="Feature title"), - outcomes: str | None = typer.Option(None, "--outcomes", help="Expected outcomes (comma-separated)"), - acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), -) -> None: - """ - Add a new feature to an existing project bundle. - - **Parameter Groups:** - - **Target/Input**: --bundle, --key, --title, --outcomes, --acceptance - - **Examples:** - specfact plan add-feature --key FEATURE-001 --title "User Auth" --outcomes "Secure login" --acceptance "Login works" --bundle legacy-api - specfact plan add-feature --key FEATURE-002 --title "Payment Processing" --bundle legacy-api - """ - - telemetry_metadata = { - "feature_key": key, - } - - with telemetry.track_command("plan.add_feature", telemetry_metadata) as record: - from specfact_cli.utils.structure import SpecFactStructure - - # Find bundle directory - if bundle is None: - # Try to use active plan first - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle: - print_info(f"Using active plan: {bundle}") - else: - # Fallback: Try to find default bundle (first bundle in projects directory) - projects_dir = Path(".specfact/projects") - if projects_dir.exists(): - bundles = [ - d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() - ] - if bundles: - bundle = bundles[0] - print_info(f"Using default bundle: {bundle}") - print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") - else: - print_error(f"No project bundles found in {projects_dir}") - print_error("Create one with: specfact plan init <bundle-name>") - print_error("Or specify --bundle <bundle-name> if the bundle exists") - raise typer.Exit(1) - else: - print_error(f"Projects directory not found: {projects_dir}") - print_error("Create one with: specfact plan init <bundle-name>") - print_error("Or specify --bundle <bundle-name> if the bundle exists") - raise typer.Exit(1) - - bundle_dir = _find_bundle_dir(bundle) - if bundle_dir is None: - raise typer.Exit(1) - - print_section("SpecFact CLI - Add Feature") - - try: - # Load existing project bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Convert to PlanBundle for compatibility - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Check if feature key already exists - existing_keys = {f.key for f in plan_bundle.features} - if key in existing_keys: - print_error(f"Feature '{key}' already exists in bundle") - raise typer.Exit(1) - - # Parse outcomes and acceptance (comma-separated strings) - outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else [] - acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] - - # Create new feature - new_feature = Feature( - key=key, - title=title, - outcomes=outcomes_list, - acceptance=acceptance_list, - constraints=[], - stories=[], - confidence=1.0, - draft=False, - source_tracking=None, - contract=None, - protocol=None, - ) - - # Add feature to plan bundle - plan_bundle.features.append(new_feature) - - # Convert back to ProjectBundle and save - updated_project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "total_features": len(plan_bundle.features), - "outcomes_count": len(outcomes_list), - "acceptance_count": len(acceptance_list), - } - ) - - print_success(f"Feature '{key}' added successfully") - console.print(f"[dim]Feature: {title}[/dim]") - if outcomes_list: - console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]") - if acceptance_list: - console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") - - except Exception as e: - print_error(f"Failed to add feature: {e}") - raise typer.Exit(1) from e - - -@app.command("add-story") -@beartype -@require(lambda feature: isinstance(feature, str) and len(feature) > 0, "Feature must be non-empty string") -@require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string") -@require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string") -@require( - lambda story_points: story_points is None or (story_points >= 0 and story_points <= 100), - "Story points must be 0-100 if provided", -) -@require( - lambda value_points: value_points is None or (value_points >= 0 and value_points <= 100), - "Value points must be 0-100 if provided", -) -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -def add_story( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", - ), - feature: str = typer.Option(..., "--feature", help="Parent feature key"), - key: str = typer.Option(..., "--key", help="Story key (e.g., STORY-001)"), - title: str = typer.Option(..., "--title", help="Story title"), - acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), - story_points: int | None = typer.Option(None, "--story-points", help="Story points (complexity)"), - value_points: int | None = typer.Option(None, "--value-points", help="Value points (business value)"), - # Behavior/Options - draft: bool = typer.Option(False, "--draft", help="Mark story as draft"), -) -> None: - """ - Add a new story to a feature. - - **Parameter Groups:** - - **Target/Input**: --bundle, --feature, --key, --title, --acceptance, --story-points, --value-points - - **Behavior/Options**: --draft - - **Examples:** - specfact plan add-story --feature FEATURE-001 --key STORY-001 --title "Login API" --acceptance "API works" --story-points 5 --bundle legacy-api - specfact plan add-story --feature FEATURE-001 --key STORY-002 --title "Logout API" --bundle legacy-api --draft - """ - - telemetry_metadata = { - "feature_key": feature, - "story_key": key, - } - - with telemetry.track_command("plan.add_story", telemetry_metadata) as record: - from specfact_cli.utils.structure import SpecFactStructure - - # Find bundle directory - if bundle is None: - # Try to use active plan first - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle: - print_info(f"Using active plan: {bundle}") - else: - # Fallback: Try to find default bundle (first bundle in projects directory) - projects_dir = Path(".specfact/projects") - if projects_dir.exists(): - bundles = [ - d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() - ] - if bundles: - bundle = bundles[0] - print_info(f"Using default bundle: {bundle}") - print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") - else: - print_error(f"No project bundles found in {projects_dir}") - print_error("Create one with: specfact plan init <bundle-name>") - print_error("Or specify --bundle <bundle-name> if the bundle exists") - raise typer.Exit(1) - else: - print_error(f"Projects directory not found: {projects_dir}") - print_error("Create one with: specfact plan init <bundle-name>") - print_error("Or specify --bundle <bundle-name> if the bundle exists") - raise typer.Exit(1) - - bundle_dir = _find_bundle_dir(bundle) - if bundle_dir is None: - raise typer.Exit(1) - - print_section("SpecFact CLI - Add Story") - - try: - # Load existing project bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Convert to PlanBundle for compatibility - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Find parent feature - parent_feature = None - for f in plan_bundle.features: - if f.key == feature: - parent_feature = f - break - - if parent_feature is None: - print_error(f"Feature '{feature}' not found in bundle") - console.print(f"[dim]Available features: {', '.join(f.key for f in plan_bundle.features)}[/dim]") - raise typer.Exit(1) - - # Check if story key already exists in feature - existing_story_keys = {s.key for s in parent_feature.stories} - if key in existing_story_keys: - print_error(f"Story '{key}' already exists in feature '{feature}'") - raise typer.Exit(1) - - # Parse acceptance (comma-separated string) - acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] - - # Create new story - new_story = Story( - key=key, - title=title, - acceptance=acceptance_list, - tags=[], - story_points=story_points, - value_points=value_points, - tasks=[], - confidence=1.0, - draft=draft, - contracts=None, - scenarios=None, - ) - - # Add story to feature - parent_feature.stories.append(new_story) - - # Convert back to ProjectBundle and save - updated_project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "total_stories": len(parent_feature.stories), - "acceptance_count": len(acceptance_list), - "story_points": story_points if story_points else 0, - "value_points": value_points if value_points else 0, - } - ) - - print_success(f"Story '{key}' added to feature '{feature}'") - console.print(f"[dim]Story: {title}[/dim]") - if acceptance_list: - console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") - if story_points: - console.print(f"[dim]Story Points: {story_points}[/dim]") - if value_points: - console.print(f"[dim]Value Points: {value_points}[/dim]") - - except Exception as e: - print_error(f"Failed to add story: {e}") - raise typer.Exit(1) from e - - -@app.command("update-idea") -@beartype -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -def update_idea( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", - ), - title: str | None = typer.Option(None, "--title", help="Idea title"), - narrative: str | None = typer.Option(None, "--narrative", help="Idea narrative (brief description)"), - target_users: str | None = typer.Option(None, "--target-users", help="Target user personas (comma-separated)"), - value_hypothesis: str | None = typer.Option(None, "--value-hypothesis", help="Value hypothesis statement"), - constraints: str | None = typer.Option(None, "--constraints", help="Idea-level constraints (comma-separated)"), -) -> None: - """ - Update idea section metadata in a project bundle (optional business context). - - This command allows updating idea properties (title, narrative, target users, - value hypothesis, constraints) in non-interactive environments (CI/CD, Copilot). - - Note: The idea section is OPTIONAL - it provides business context and metadata, - not technical implementation details. All parameters are optional. - - **Parameter Groups:** - - **Target/Input**: --bundle, --title, --narrative, --target-users, --value-hypothesis, --constraints - - **Examples:** - specfact plan update-idea --target-users "Developers, DevOps" --value-hypothesis "Reduce technical debt" --bundle legacy-api - specfact plan update-idea --constraints "Python 3.11+, Maintain backward compatibility" --bundle legacy-api - """ - - telemetry_metadata = {} - - with telemetry.track_command("plan.update_idea", telemetry_metadata) as record: - from specfact_cli.utils.structure import SpecFactStructure - - # Find bundle directory - if bundle is None: - # Try to use active plan first - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle: - print_info(f"Using active plan: {bundle}") - else: - # Fallback: Try to find default bundle (first bundle in projects directory) - projects_dir = Path(".specfact/projects") - if projects_dir.exists(): - bundles = [ - d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() - ] - if bundles: - bundle = bundles[0] - print_info(f"Using default bundle: {bundle}") - print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") - else: - print_error(f"No project bundles found in {projects_dir}") - print_error("Create one with: specfact plan init <bundle-name>") - print_error("Or specify --bundle <bundle-name> if the bundle exists") - raise typer.Exit(1) - else: - print_error(f"Projects directory not found: {projects_dir}") - print_error("Create one with: specfact plan init <bundle-name>") - print_error("Or specify --bundle <bundle-name> if the bundle exists") - raise typer.Exit(1) - - bundle_dir = _find_bundle_dir(bundle) - if bundle_dir is None: - raise typer.Exit(1) - - print_section("SpecFact CLI - Update Idea") - - try: - # Load existing project bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Convert to PlanBundle for compatibility - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Create idea section if it doesn't exist - if plan_bundle.idea is None: - plan_bundle.idea = Idea( - title=title or "Untitled", - narrative=narrative or "", - target_users=[], - value_hypothesis="", - constraints=[], - metrics=None, - ) - print_info("Created new idea section") - - # Track what was updated - updates_made = [] - - # Update title if provided - if title is not None: - plan_bundle.idea.title = title - updates_made.append("title") - - # Update narrative if provided - if narrative is not None: - plan_bundle.idea.narrative = narrative - updates_made.append("narrative") - - # Update target_users if provided - if target_users is not None: - target_users_list = [u.strip() for u in target_users.split(",")] if target_users else [] - plan_bundle.idea.target_users = target_users_list - updates_made.append("target_users") - - # Update value_hypothesis if provided - if value_hypothesis is not None: - plan_bundle.idea.value_hypothesis = value_hypothesis - updates_made.append("value_hypothesis") - - # Update constraints if provided - if constraints is not None: - constraints_list = [c.strip() for c in constraints.split(",")] if constraints else [] - plan_bundle.idea.constraints = constraints_list - updates_made.append("constraints") - - if not updates_made: - print_warning( - "No updates specified. Use --title, --narrative, --target-users, --value-hypothesis, or --constraints" - ) - raise typer.Exit(1) - - # Convert back to ProjectBundle and save - updated_project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "updates": updates_made, - "idea_exists": plan_bundle.idea is not None, - } - ) - - print_success("Idea section updated successfully") - console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]") - if title: - console.print(f"[dim]Title: {title}[/dim]") - if narrative: - console.print( - f"[dim]Narrative: {narrative[:80]}...[/dim]" - if len(narrative) > 80 - else f"[dim]Narrative: {narrative}[/dim]" - ) - if target_users: - target_users_list = [u.strip() for u in target_users.split(",")] if target_users else [] - console.print(f"[dim]Target Users: {', '.join(target_users_list)}[/dim]") - if value_hypothesis: - console.print( - f"[dim]Value Hypothesis: {value_hypothesis[:80]}...[/dim]" - if len(value_hypothesis) > 80 - else f"[dim]Value Hypothesis: {value_hypothesis}[/dim]" - ) - if constraints: - constraints_list = [c.strip() for c in constraints.split(",")] if constraints else [] - console.print(f"[dim]Constraints: {', '.join(constraints_list)}[/dim]") - - except Exception as e: - print_error(f"Failed to update idea: {e}") - raise typer.Exit(1) from e - - -@app.command("update-feature") -@beartype -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -def update_feature( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", - ), - key: str | None = typer.Option( - None, "--key", help="Feature key to update (e.g., FEATURE-001). Required unless --batch-updates is provided." - ), - title: str | None = typer.Option(None, "--title", help="Feature title"), - outcomes: str | None = typer.Option(None, "--outcomes", help="Expected outcomes (comma-separated)"), - acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), - constraints: str | None = typer.Option(None, "--constraints", help="Constraints (comma-separated)"), - confidence: float | None = typer.Option(None, "--confidence", help="Confidence score (0.0-1.0)"), - draft: bool | None = typer.Option( - None, - "--draft/--no-draft", - help="Mark as draft (use --draft to set True, --no-draft to set False, omit to leave unchanged)", - ), - batch_updates: Path | None = typer.Option( - None, - "--batch-updates", - help="Path to JSON/YAML file with multiple feature updates. File format: list of objects with 'key' and update fields (title, outcomes, acceptance, constraints, confidence, draft).", - ), -) -> None: - """ - Update an existing feature's metadata in a project bundle. - - This command allows updating feature properties (title, outcomes, acceptance criteria, - constraints, confidence, draft status) in non-interactive environments (CI/CD, Copilot). - - Supports both single feature updates and batch updates via --batch-updates file. - - **Parameter Groups:** - - **Target/Input**: --bundle, --key, --title, --outcomes, --acceptance, --constraints, --confidence, --batch-updates - - **Behavior/Options**: --draft/--no-draft - - **Examples:** - # Single feature update - specfact plan update-feature --key FEATURE-001 --title "Updated Title" --outcomes "Outcome 1, Outcome 2" --bundle legacy-api - specfact plan update-feature --key FEATURE-001 --acceptance "Criterion 1, Criterion 2" --confidence 0.9 --bundle legacy-api - - # Batch updates from file - specfact plan update-feature --batch-updates updates.json --bundle legacy-api - """ - from specfact_cli.utils.structure import SpecFactStructure - - # Validate that either key or batch_updates is provided - if not key and not batch_updates: - print_error("Either --key or --batch-updates must be provided") - raise typer.Exit(1) - - if key and batch_updates: - print_error("Cannot use both --key and --batch-updates. Use --batch-updates for multiple updates.") - raise typer.Exit(1) - - telemetry_metadata = { - "batch_mode": batch_updates is not None, - } - - with telemetry.track_command("plan.update_feature", telemetry_metadata) as record: - # Find bundle directory - if bundle is None: - # Try to use active plan first - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle: - print_info(f"Using active plan: {bundle}") - else: - # Fallback: Try to find default bundle (first bundle in projects directory) - projects_dir = Path(".specfact/projects") - if projects_dir.exists(): - bundles = [ - d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() - ] - if bundles: - bundle = bundles[0] - print_info(f"Using default bundle: {bundle}") - print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") - else: - print_error("No bundles found. Create one with: specfact plan init <bundle-name>") - raise typer.Exit(1) - else: - print_error("No bundles found. Create one with: specfact plan init <bundle-name>") - raise typer.Exit(1) - - bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Bundle '{bundle}' not found: {bundle_dir}\nCreate one with: specfact plan init {bundle}") - raise typer.Exit(1) - - print_section("SpecFact CLI - Update Feature") - - try: - # Load existing project bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Convert to PlanBundle for compatibility - existing_plan = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Handle batch updates - if batch_updates: - if not batch_updates.exists(): - print_error(f"Batch updates file not found: {batch_updates}") - raise typer.Exit(1) - - print_info(f"Loading batch updates from: {batch_updates}") - batch_data = load_structured_file(batch_updates) - - if not isinstance(batch_data, list): - print_error("Batch updates file must contain a list of update objects") - raise typer.Exit(1) - - total_updates = 0 - successful_updates = 0 - failed_updates = [] - - for update_item in batch_data: - if not isinstance(update_item, dict): - failed_updates.append({"item": update_item, "error": "Not a dictionary"}) - continue - - update_key = update_item.get("key") - if not update_key: - failed_updates.append({"item": update_item, "error": "Missing 'key' field"}) - continue - - total_updates += 1 - - # Find feature to update - feature_to_update = None - for f in existing_plan.features: - if f.key == update_key: - feature_to_update = f - break - - if feature_to_update is None: - failed_updates.append({"key": update_key, "error": f"Feature '{update_key}' not found in plan"}) - continue - - # Track what was updated - updates_made = [] - - # Update fields from batch item - if "title" in update_item: - feature_to_update.title = update_item["title"] - updates_made.append("title") - - if "outcomes" in update_item: - outcomes_val = update_item["outcomes"] - if isinstance(outcomes_val, str): - outcomes_list = [o.strip() for o in outcomes_val.split(",")] if outcomes_val else [] - elif isinstance(outcomes_val, list): - outcomes_list = outcomes_val - else: - failed_updates.append({"key": update_key, "error": "Invalid 'outcomes' format"}) - continue - feature_to_update.outcomes = outcomes_list - updates_made.append("outcomes") - - if "acceptance" in update_item: - acceptance_val = update_item["acceptance"] - if isinstance(acceptance_val, str): - acceptance_list = [a.strip() for a in acceptance_val.split(",")] if acceptance_val else [] - elif isinstance(acceptance_val, list): - acceptance_list = acceptance_val - else: - failed_updates.append({"key": update_key, "error": "Invalid 'acceptance' format"}) - continue - feature_to_update.acceptance = acceptance_list - updates_made.append("acceptance") - - if "constraints" in update_item: - constraints_val = update_item["constraints"] - if isinstance(constraints_val, str): - constraints_list = ( - [c.strip() for c in constraints_val.split(",")] if constraints_val else [] - ) - elif isinstance(constraints_val, list): - constraints_list = constraints_val - else: - failed_updates.append({"key": update_key, "error": "Invalid 'constraints' format"}) - continue - feature_to_update.constraints = constraints_list - updates_made.append("constraints") - - if "confidence" in update_item: - conf_val = update_item["confidence"] - if not isinstance(conf_val, (int, float)) or not (0.0 <= conf_val <= 1.0): - failed_updates.append({"key": update_key, "error": "Confidence must be 0.0-1.0"}) - continue - feature_to_update.confidence = float(conf_val) - updates_made.append("confidence") - - if "draft" in update_item: - feature_to_update.draft = bool(update_item["draft"]) - updates_made.append("draft") - - if updates_made: - successful_updates += 1 - console.print(f"[dim]✓ Updated {update_key}: {', '.join(updates_made)}[/dim]") - else: - failed_updates.append({"key": update_key, "error": "No valid update fields provided"}) - - # Convert back to ProjectBundle and save - print_info("Validating updated plan...") - updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "batch_total": total_updates, - "batch_successful": successful_updates, - "batch_failed": len(failed_updates), - "total_features": len(existing_plan.features), - } - ) - - print_success(f"Batch update complete: {successful_updates}/{total_updates} features updated") - if failed_updates: - print_warning(f"{len(failed_updates)} update(s) failed:") - for failed in failed_updates: - console.print( - f"[dim] - {failed.get('key', 'Unknown')}: {failed.get('error', 'Unknown error')}[/dim]" - ) - - else: - # Single feature update (existing logic) - if not key: - print_error("--key is required when not using --batch-updates") - raise typer.Exit(1) - - # Find feature to update - feature_to_update = None - for f in existing_plan.features: - if f.key == key: - feature_to_update = f - break - - if feature_to_update is None: - print_error(f"Feature '{key}' not found in plan") - console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]") - raise typer.Exit(1) - - # Track what was updated - updates_made = [] - - # Update title if provided - if title is not None: - feature_to_update.title = title - updates_made.append("title") - - # Update outcomes if provided - if outcomes is not None: - outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else [] - feature_to_update.outcomes = outcomes_list - updates_made.append("outcomes") - - # Update acceptance criteria if provided - if acceptance is not None: - acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] - feature_to_update.acceptance = acceptance_list - updates_made.append("acceptance") - - # Update constraints if provided - if constraints is not None: - constraints_list = [c.strip() for c in constraints.split(",")] if constraints else [] - feature_to_update.constraints = constraints_list - updates_made.append("constraints") - - # Update confidence if provided - if confidence is not None: - if not (0.0 <= confidence <= 1.0): - print_error(f"Confidence must be between 0.0 and 1.0, got: {confidence}") - raise typer.Exit(1) - feature_to_update.confidence = confidence - updates_made.append("confidence") - - # Update draft status if provided - if draft is not None: - feature_to_update.draft = draft - updates_made.append("draft") - - if not updates_made: - print_warning( - "No updates specified. Use --title, --outcomes, --acceptance, --constraints, --confidence, or --draft" - ) - raise typer.Exit(1) - - # Convert back to ProjectBundle and save - print_info("Validating updated plan...") - updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "updates": updates_made, - "total_features": len(existing_plan.features), - } - ) - - print_success(f"Feature '{key}' updated successfully") - console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]") - if title: - console.print(f"[dim]Title: {title}[/dim]") - if outcomes: - outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else [] - console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]") - if acceptance: - acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] - console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") - - except Exception as e: - print_error(f"Failed to update feature: {e}") - raise typer.Exit(1) from e - - -@app.command("update-story") -@beartype -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -@require( - lambda story_points: story_points is None or (story_points >= 0 and story_points <= 100), - "Story points must be 0-100 if provided", -) -@require( - lambda value_points: value_points is None or (value_points >= 0 and value_points <= 100), - "Value points must be 0-100 if provided", -) -@require(lambda confidence: confidence is None or (0.0 <= confidence <= 1.0), "Confidence must be 0.0-1.0 if provided") -def update_story( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", - ), - feature: str | None = typer.Option( - None, "--feature", help="Parent feature key (e.g., FEATURE-001). Required unless --batch-updates is provided." - ), - key: str | None = typer.Option( - None, "--key", help="Story key to update (e.g., STORY-001). Required unless --batch-updates is provided." - ), - title: str | None = typer.Option(None, "--title", help="Story title"), - acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), - story_points: int | None = typer.Option(None, "--story-points", help="Story points (complexity: 0-100)"), - value_points: int | None = typer.Option(None, "--value-points", help="Value points (business value: 0-100)"), - confidence: float | None = typer.Option(None, "--confidence", help="Confidence score (0.0-1.0)"), - draft: bool | None = typer.Option( - None, - "--draft/--no-draft", - help="Mark as draft (use --draft to set True, --no-draft to set False, omit to leave unchanged)", - ), - batch_updates: Path | None = typer.Option( - None, - "--batch-updates", - help="Path to JSON/YAML file with multiple story updates. File format: list of objects with 'feature', 'key' and update fields (title, acceptance, story_points, value_points, confidence, draft).", - ), -) -> None: - """ - Update an existing story's metadata in a project bundle. - - This command allows updating story properties (title, acceptance criteria, - story points, value points, confidence, draft status) in non-interactive - environments (CI/CD, Copilot). - - Supports both single story updates and batch updates via --batch-updates file. - - **Parameter Groups:** - - **Target/Input**: --bundle, --feature, --key, --title, --acceptance, --story-points, --value-points, --confidence, --batch-updates - - **Behavior/Options**: --draft/--no-draft - - **Examples:** - # Single story update - specfact plan update-story --feature FEATURE-001 --key STORY-001 --title "Updated Title" --bundle legacy-api - specfact plan update-story --feature FEATURE-001 --key STORY-001 --acceptance "Criterion 1, Criterion 2" --confidence 0.9 --bundle legacy-api - - # Batch updates from file - specfact plan update-story --batch-updates updates.json --bundle legacy-api - """ - from specfact_cli.utils.structure import SpecFactStructure - - # Validate that either (feature and key) or batch_updates is provided - if not (feature and key) and not batch_updates: - print_error("Either (--feature and --key) or --batch-updates must be provided") - raise typer.Exit(1) - - if (feature or key) and batch_updates: - print_error("Cannot use both (--feature/--key) and --batch-updates. Use --batch-updates for multiple updates.") - raise typer.Exit(1) - - telemetry_metadata = { - "batch_mode": batch_updates is not None, - } - - with telemetry.track_command("plan.update_story", telemetry_metadata) as record: - # Find bundle directory - if bundle is None: - # Try to use active plan first - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle: - print_info(f"Using active plan: {bundle}") - else: - # Fallback: Try to find default bundle (first bundle in projects directory) - projects_dir = Path(".specfact/projects") - if projects_dir.exists(): - bundles = [ - d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() - ] - if bundles: - bundle = bundles[0] - print_info(f"Using default bundle: {bundle}") - print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") - else: - print_error("No bundles found. Create one with: specfact plan init <bundle-name>") - raise typer.Exit(1) - else: - print_error("No bundles found. Create one with: specfact plan init <bundle-name>") - raise typer.Exit(1) - - bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Bundle '{bundle}' not found: {bundle_dir}\nCreate one with: specfact plan init {bundle}") - raise typer.Exit(1) - - print_section("SpecFact CLI - Update Story") - - try: - # Load existing project bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Convert to PlanBundle for compatibility - existing_plan = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Handle batch updates - if batch_updates: - if not batch_updates.exists(): - print_error(f"Batch updates file not found: {batch_updates}") - raise typer.Exit(1) - - print_info(f"Loading batch updates from: {batch_updates}") - batch_data = load_structured_file(batch_updates) - - if not isinstance(batch_data, list): - print_error("Batch updates file must contain a list of update objects") - raise typer.Exit(1) - - total_updates = 0 - successful_updates = 0 - failed_updates = [] - - for update_item in batch_data: - if not isinstance(update_item, dict): - failed_updates.append({"item": update_item, "error": "Not a dictionary"}) - continue - - update_feature = update_item.get("feature") - update_key = update_item.get("key") - if not update_feature or not update_key: - failed_updates.append({"item": update_item, "error": "Missing 'feature' or 'key' field"}) - continue - - total_updates += 1 - - # Find parent feature - parent_feature = None - for f in existing_plan.features: - if f.key == update_feature: - parent_feature = f - break - - if parent_feature is None: - failed_updates.append( - { - "feature": update_feature, - "key": update_key, - "error": f"Feature '{update_feature}' not found in plan", - } - ) - continue - - # Find story to update - story_to_update = None - for s in parent_feature.stories: - if s.key == update_key: - story_to_update = s - break - - if story_to_update is None: - failed_updates.append( - { - "feature": update_feature, - "key": update_key, - "error": f"Story '{update_key}' not found in feature '{update_feature}'", - } - ) - continue - - # Track what was updated - updates_made = [] - - # Update fields from batch item - if "title" in update_item: - story_to_update.title = update_item["title"] - updates_made.append("title") - - if "acceptance" in update_item: - acceptance_val = update_item["acceptance"] - if isinstance(acceptance_val, str): - acceptance_list = [a.strip() for a in acceptance_val.split(",")] if acceptance_val else [] - elif isinstance(acceptance_val, list): - acceptance_list = acceptance_val - else: - failed_updates.append( - {"feature": update_feature, "key": update_key, "error": "Invalid 'acceptance' format"} - ) - continue - story_to_update.acceptance = acceptance_list - updates_made.append("acceptance") - - if "story_points" in update_item: - sp_val = update_item["story_points"] - if not isinstance(sp_val, int) or not (0 <= sp_val <= 100): - failed_updates.append( - {"feature": update_feature, "key": update_key, "error": "Story points must be 0-100"} - ) - continue - story_to_update.story_points = sp_val - updates_made.append("story_points") - - if "value_points" in update_item: - vp_val = update_item["value_points"] - if not isinstance(vp_val, int) or not (0 <= vp_val <= 100): - failed_updates.append( - {"feature": update_feature, "key": update_key, "error": "Value points must be 0-100"} - ) - continue - story_to_update.value_points = vp_val - updates_made.append("value_points") - - if "confidence" in update_item: - conf_val = update_item["confidence"] - if not isinstance(conf_val, (int, float)) or not (0.0 <= conf_val <= 1.0): - failed_updates.append( - {"feature": update_feature, "key": update_key, "error": "Confidence must be 0.0-1.0"} - ) - continue - story_to_update.confidence = float(conf_val) - updates_made.append("confidence") - - if "draft" in update_item: - story_to_update.draft = bool(update_item["draft"]) - updates_made.append("draft") - - if updates_made: - successful_updates += 1 - console.print(f"[dim]✓ Updated {update_feature}/{update_key}: {', '.join(updates_made)}[/dim]") - else: - failed_updates.append( - {"feature": update_feature, "key": update_key, "error": "No valid update fields provided"} - ) - - # Convert back to ProjectBundle and save - print_info("Validating updated plan...") - updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "batch_total": total_updates, - "batch_successful": successful_updates, - "batch_failed": len(failed_updates), - } - ) - - print_success(f"Batch update complete: {successful_updates}/{total_updates} stories updated") - if failed_updates: - print_warning(f"{len(failed_updates)} update(s) failed:") - for failed in failed_updates: - console.print( - f"[dim] - {failed.get('feature', 'Unknown')}/{failed.get('key', 'Unknown')}: {failed.get('error', 'Unknown error')}[/dim]" - ) - - else: - # Single story update (existing logic) - if not feature or not key: - print_error("--feature and --key are required when not using --batch-updates") - raise typer.Exit(1) - - # Find parent feature - parent_feature = None - for f in existing_plan.features: - if f.key == feature: - parent_feature = f - break - - if parent_feature is None: - print_error(f"Feature '{feature}' not found in plan") - console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]") - raise typer.Exit(1) - - # Find story to update - story_to_update = None - for s in parent_feature.stories: - if s.key == key: - story_to_update = s - break - - if story_to_update is None: - print_error(f"Story '{key}' not found in feature '{feature}'") - console.print(f"[dim]Available stories: {', '.join(s.key for s in parent_feature.stories)}[/dim]") - raise typer.Exit(1) - - # Track what was updated - updates_made = [] - - # Update title if provided - if title is not None: - story_to_update.title = title - updates_made.append("title") - - # Update acceptance criteria if provided - if acceptance is not None: - acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] - story_to_update.acceptance = acceptance_list - updates_made.append("acceptance") - - # Update story points if provided - if story_points is not None: - story_to_update.story_points = story_points - updates_made.append("story_points") - - # Update value points if provided - if value_points is not None: - story_to_update.value_points = value_points - updates_made.append("value_points") - - # Update confidence if provided - if confidence is not None: - if not (0.0 <= confidence <= 1.0): - print_error(f"Confidence must be between 0.0 and 1.0, got: {confidence}") - raise typer.Exit(1) - story_to_update.confidence = confidence - updates_made.append("confidence") - - # Update draft status if provided - if draft is not None: - story_to_update.draft = draft - updates_made.append("draft") - - if not updates_made: - print_warning( - "No updates specified. Use --title, --acceptance, --story-points, --value-points, --confidence, or --draft" - ) - raise typer.Exit(1) - - # Convert back to ProjectBundle and save - print_info("Validating updated plan...") - updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) - _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) - - record( - { - "updates": updates_made, - "total_stories": len(parent_feature.stories), - } - ) - - print_success(f"Story '{key}' in feature '{feature}' updated successfully") - console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]") - if title: - console.print(f"[dim]Title: {title}[/dim]") - if acceptance: - acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] - console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") - if story_points is not None: - console.print(f"[dim]Story Points: {story_points}[/dim]") - if value_points is not None: - console.print(f"[dim]Value Points: {value_points}[/dim]") - if confidence is not None: - console.print(f"[dim]Confidence: {confidence}[/dim]") - - except Exception as e: - print_error(f"Failed to update story: {e}") - raise typer.Exit(1) from e - - -@app.command("compare") -@beartype -@require(lambda manual: manual is None or isinstance(manual, Path), "Manual must be None or Path") -@require(lambda auto: auto is None or isinstance(auto, Path), "Auto must be None or Path") -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") -@require( - lambda output_format: isinstance(output_format, str) and output_format.lower() in ("markdown", "json", "yaml"), - "Output format must be markdown, json, or yaml", -) -@require(lambda out: out is None or isinstance(out, Path), "Out must be None or Path") -def compare( - # Target/Input - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If specified, compares bundles instead of legacy plan files.", - ), - manual: Path | None = typer.Option( - None, - "--manual", - help="Manual plan bundle path (bundle directory: .specfact/projects/<bundle>/). Ignored if --bundle is specified.", - ), - auto: Path | None = typer.Option( - None, - "--auto", - help="Auto-derived plan bundle path (bundle directory: .specfact/projects/<bundle>/). Ignored if --bundle is specified.", - ), - # Output/Results - output_format: str = typer.Option( - "markdown", - "--output-format", - help="Output format (markdown, json, yaml)", - ), - out: Path | None = typer.Option( - None, - "--out", - help="Output file path (default: .specfact/projects/<bundle-name>/reports/comparison/report-<timestamp>.md when --bundle is provided).", - ), - # Behavior/Options - code_vs_plan: bool = typer.Option( - False, - "--code-vs-plan", - help="Alias for comparing code-derived plan vs manual plan (auto-detects latest auto plan)", - ), -) -> None: - """ - Compare manual and auto-derived plans to detect code vs plan drift. - - Detects deviations between manually created plans (intended design) and - reverse-engineered plans from code (actual implementation). This comparison - identifies code vs plan drift automatically. - - Use --code-vs-plan for convenience: automatically compares the latest - code-derived plan against the manual plan. - - **Parameter Groups:** - - **Target/Input**: --bundle, --manual, --auto - - **Output/Results**: --output-format, --out - - **Behavior/Options**: --code-vs-plan - - **Examples:** - specfact plan compare --manual .specfact/projects/manual-bundle --auto .specfact/projects/auto-bundle - specfact plan compare --code-vs-plan # Convenience alias (requires bundle-based paths) - specfact plan compare --bundle legacy-api --output-format json - """ - from specfact_cli.utils.structure import SpecFactStructure - - telemetry_metadata = { - "code_vs_plan": code_vs_plan, - "output_format": output_format.lower(), - } - - with telemetry.track_command("plan.compare", telemetry_metadata) as record: - # Ensure .specfact structure exists - SpecFactStructure.ensure_structure() - - # Handle --code-vs-plan convenience alias - if code_vs_plan: - # Auto-detect manual plan (default) - if manual is None: - manual = SpecFactStructure.get_default_plan_path() - if not manual.exists(): - print_error( - "Default manual bundle not found.\nCreate one with: specfact plan init <bundle-name> --interactive" - ) - raise typer.Exit(1) - print_info(f"Using default manual bundle: {manual}") - - # Auto-detect latest code-derived plan - if auto is None: - auto = SpecFactStructure.get_latest_brownfield_report() - if auto is None: - print_error( - "No code-derived bundles found in .specfact/projects/*/reports/brownfield/.\n" - "Generate one with: specfact import from-code <bundle-name> --repo ." - ) - raise typer.Exit(1) - print_info(f"Using latest code-derived bundle report: {auto}") - - # Override help text to emphasize code vs plan drift - print_section("Code vs Plan Drift Detection") - console.print( - "[dim]Comparing intended design (manual plan) vs actual implementation (code-derived plan)[/dim]\n" - ) - - # Use default paths if not specified (smart defaults) - if manual is None: - manual = SpecFactStructure.get_default_plan_path() - if not manual.exists(): - print_error( - "Default manual bundle not found.\nCreate one with: specfact plan init <bundle-name> --interactive" - ) - raise typer.Exit(1) - print_info(f"Using default manual bundle: {manual}") - - if auto is None: - # Use smart default: find latest auto-derived plan - auto = SpecFactStructure.get_latest_brownfield_report() - if auto is None: - print_error( - "No auto-derived bundles found in .specfact/projects/*/reports/brownfield/.\n" - "Generate one with: specfact import from-code <bundle-name> --repo ." - ) - raise typer.Exit(1) - print_info(f"Using latest auto-derived bundle: {auto}") - - if out is None: - # Use smart default: timestamped comparison report - extension = {"markdown": "md", "json": "json", "yaml": "yaml"}[output_format.lower()] - # Phase 8.5: Use bundle-specific path if bundle context available - # Try to infer bundle from manual plan path or use bundle parameter - bundle_name = None - if bundle is not None: - bundle_name = bundle - elif manual is not None: - # Try to extract bundle name from manual plan path - manual_str = str(manual) - if "/projects/" in manual_str: - # Extract bundle name from path like .specfact/projects/<bundle-name>/... - parts = manual_str.split("/projects/") - if len(parts) > 1: - bundle_part = parts[1].split("/")[0] - if bundle_part: - bundle_name = bundle_part - - if bundle_name: - # Use bundle-specific comparison report path (Phase 8.5) - out = SpecFactStructure.get_bundle_comparison_report_path( - bundle_name=bundle_name, base_path=Path("."), format=extension - ) - else: - # Fallback to global path (backward compatibility during transition) - out = SpecFactStructure.get_comparison_report_path(format=extension) - print_info(f"Writing comparison report to: {out}") - - print_section("SpecFact CLI - Plan Comparison") - - # Validate inputs (after defaults are set) - if manual is not None and not manual.exists(): - print_error(f"Manual plan not found: {manual}") - raise typer.Exit(1) - - if auto is not None and not auto.exists(): - print_error(f"Auto plan not found: {auto}") - raise typer.Exit(1) - - # Validate output format - if output_format.lower() not in ("markdown", "json", "yaml"): - print_error(f"Invalid output format: {output_format}. Must be markdown, json, or yaml") - raise typer.Exit(1) - - try: - # Load plans - # Note: validate_plan_bundle returns tuple[bool, str | None, PlanBundle | None] when given a Path - print_info(f"Loading manual plan: {manual}") - validation_result = validate_plan_bundle(manual) - # Type narrowing: when Path is passed, always returns tuple - assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path" - is_valid, error, manual_plan = validation_result - if not is_valid or manual_plan is None: - print_error(f"Manual plan validation failed: {error}") - raise typer.Exit(1) - - print_info(f"Loading auto plan: {auto}") - validation_result = validate_plan_bundle(auto) - # Type narrowing: when Path is passed, always returns tuple - assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path" - is_valid, error, auto_plan = validation_result - if not is_valid or auto_plan is None: - print_error(f"Auto plan validation failed: {error}") - raise typer.Exit(1) - - # Compare plans - print_info("Comparing plans...") - comparator = PlanComparator() - report = comparator.compare( - manual_plan, - auto_plan, - manual_label=str(manual), - auto_label=str(auto), - ) - - # Record comparison results - record( - { - "total_deviations": report.total_deviations, - "high_count": report.high_count, - "medium_count": report.medium_count, - "low_count": report.low_count, - "manual_features": len(manual_plan.features) if manual_plan.features else 0, - "auto_features": len(auto_plan.features) if auto_plan.features else 0, - } - ) - - # Display results - print_section("Comparison Results") - - console.print(f"[cyan]Manual Plan:[/cyan] {manual}") - console.print(f"[cyan]Auto Plan:[/cyan] {auto}") - console.print(f"[cyan]Total Deviations:[/cyan] {report.total_deviations}\n") - - if report.total_deviations == 0: - print_success("No deviations found! Plans are identical.") - else: - # Show severity summary - console.print("[bold]Deviation Summary:[/bold]") - console.print(f" 🔴 [bold red]HIGH:[/bold red] {report.high_count}") - console.print(f" 🟡 [bold yellow]MEDIUM:[/bold yellow] {report.medium_count}") - console.print(f" 🔵 [bold blue]LOW:[/bold blue] {report.low_count}\n") - - # Show detailed table - table = Table(title="Deviations by Type and Severity") - table.add_column("Severity", style="bold") - table.add_column("Type", style="cyan") - table.add_column("Description", style="white", no_wrap=False) - table.add_column("Location", style="dim") - - for deviation in report.deviations: - severity_icon = {"HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🔵"}[deviation.severity.value] - table.add_row( - f"{severity_icon} {deviation.severity.value}", - deviation.type.value.replace("_", " ").title(), - deviation.description[:80] + "..." - if len(deviation.description) > 80 - else deviation.description, - deviation.location, - ) - - console.print(table) - - # Generate report file if requested - if out: - print_info(f"Generating {output_format} report...") - generator = ReportGenerator() - - # Map format string to enum - format_map = { - "markdown": ReportFormat.MARKDOWN, - "json": ReportFormat.JSON, - "yaml": ReportFormat.YAML, - } - - report_format = format_map.get(output_format.lower(), ReportFormat.MARKDOWN) - generator.generate_deviation_report(report, out, report_format) - - print_success(f"Report written to: {out}") - - # Apply enforcement rules if config exists - from specfact_cli.utils.structure import SpecFactStructure - - # Determine base path from plan paths (use manual plan's parent directory) - base_path = manual.parent if manual else None - # If base_path is not a repository root, find the repository root - if base_path: - # Walk up to find repository root (where .specfact would be) - current = base_path.resolve() - while current != current.parent: - if (current / SpecFactStructure.ROOT).exists(): - base_path = current - break - current = current.parent - else: - # If we didn't find .specfact, use the plan's directory - # But resolve to absolute path first - base_path = manual.parent.resolve() - - config_path = SpecFactStructure.get_enforcement_config_path(base_path) - if config_path.exists(): - try: - from specfact_cli.utils.yaml_utils import load_yaml - - config_data = load_yaml(config_path) - enforcement_config = EnforcementConfig(**config_data) - - if enforcement_config.enabled and report.total_deviations > 0: - print_section("Enforcement Rules") - console.print(f"[dim]Using enforcement config: {config_path}[/dim]\n") - - # Check for blocking deviations - blocking_deviations: list[Deviation] = [] - for deviation in report.deviations: - action = enforcement_config.get_action(deviation.severity.value) - action_icon = {"BLOCK": "🚫", "WARN": "⚠️", "LOG": "📝"}[action.value] - - console.print( - f"{action_icon} [{deviation.severity.value}] {deviation.type.value}: " - f"[dim]{action.value}[/dim]" - ) - - if enforcement_config.should_block_deviation(deviation.severity.value): - blocking_deviations.append(deviation) - - if blocking_deviations: - print_error( - f"\n❌ Enforcement BLOCKED: {len(blocking_deviations)} deviation(s) violate quality gates" - ) - console.print("[dim]Fix the blocking deviations or adjust enforcement config[/dim]") - raise typer.Exit(1) - print_success("\n✅ Enforcement PASSED: No blocking deviations") - - except Exception as e: - print_warning(f"Could not load enforcement config: {e}") - raise typer.Exit(1) from e - - # Note: Finding deviations without enforcement is a successful comparison result - # Exit code 0 indicates successful execution (even if deviations were found) - # Use the report file, stdout, or enforcement config to determine if deviations are critical - if report.total_deviations > 0: - print_warning(f"\n{report.total_deviations} deviation(s) found") - - except KeyboardInterrupt: - print_warning("\nComparison cancelled") - raise typer.Exit(1) from None - except Exception as e: - print_error(f"Comparison failed: {e}") - raise typer.Exit(1) from e - - -@app.command("select") -@beartype -@require(lambda plan: plan is None or isinstance(plan, str), "Plan must be None or str") -@require(lambda last: last is None or last > 0, "Last must be None or positive integer") -def select( - # Target/Input - plan: str | None = typer.Argument( - None, - help="Plan name or number to select (e.g., 'main.bundle.<format>' or '1')", - ), - name: str | None = typer.Option( - None, - "--name", - help="Select bundle by exact bundle name (non-interactive, e.g., 'main')", - hidden=True, # Hidden by default, shown with --help-advanced - ), - plan_id: str | None = typer.Option( - None, - "--id", - help="Select plan by content hash ID (non-interactive, from metadata.summary.content_hash)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", - ), - # Advanced/Configuration - current: bool = typer.Option( - False, - "--current", - help="Show only the currently active plan", - ), - stages: str | None = typer.Option( - None, - "--stages", - help="Filter by stages (comma-separated, e.g., 'draft,review,approved')", - ), - last: int | None = typer.Option( - None, - "--last", - help="Show last N plans by modification time (most recent first)", - min=1, - ), -) -> None: - """ - Select active project bundle from available bundles. - - Displays a numbered list of available project bundles and allows selection by number or name. - The selected bundle becomes the active bundle tracked in `.specfact/config.yaml`. - - Filter Options: - --current Show only the currently active bundle (non-interactive, auto-selects) - --stages STAGES Filter by stages (comma-separated: draft,review,approved,released) - --last N Show last N bundles by modification time (most recent first) - --name NAME Select by exact bundle name (non-interactive, e.g., 'main') - --id HASH Select by content hash ID (non-interactive, from bundle manifest) - - Example: - specfact plan select # Interactive selection - specfact plan select 1 # Select by number - specfact plan select main # Select by bundle name (positional) - specfact plan select --current # Show only active bundle (auto-selects) - specfact plan select --stages draft,review # Filter by stages - specfact plan select --last 5 # Show last 5 bundles - specfact plan select --no-interactive --last 1 # CI/CD: get most recent bundle - specfact plan select --name main # CI/CD: select by exact bundle name - specfact plan select --id abc123def456 # CI/CD: select by content hash - """ - from specfact_cli.utils.structure import SpecFactStructure - - telemetry_metadata = { - "no_interactive": no_interactive, - "current": current, - "stages": stages, - "last": last, - "name": name is not None, - "plan_id": plan_id is not None, - } - - with telemetry.track_command("plan.select", telemetry_metadata) as record: - print_section("SpecFact CLI - Plan Selection") - - # List all available plans - # Performance optimization: If --last N is specified, only process N+10 most recent files - # This avoids processing all 31 files when user only wants last 5 - max_files_to_process = None - if last is not None: - # Process a few more files than requested to account for filtering - max_files_to_process = last + 10 - - plans = SpecFactStructure.list_plans(max_files=max_files_to_process) - - if not plans: - print_warning("No project bundles found in .specfact/projects/") - print_info("Create a project bundle with:") - print_info(" - specfact plan init <bundle-name>") - print_info(" - specfact import from-code <bundle-name>") - raise typer.Exit(1) - - # Apply filters - filtered_plans = plans.copy() - - # Filter by current/active (non-interactive: auto-selects if single match) - if current: - filtered_plans = [p for p in filtered_plans if p.get("active", False)] - if not filtered_plans: - print_warning("No active plan found") - raise typer.Exit(1) - # Auto-select in non-interactive mode when --current is provided - if no_interactive and len(filtered_plans) == 1: - selected_plan = filtered_plans[0] - plan_name = str(selected_plan["name"]) - SpecFactStructure.set_active_plan(plan_name) - record( - { - "plans_available": len(plans), - "plans_filtered": len(filtered_plans), - "selected_plan": plan_name, - "features": selected_plan["features"], - "stories": selected_plan["stories"], - "auto_selected": True, - } - ) - print_success(f"Active plan (--current): {plan_name}") - print_info(f" Features: {selected_plan['features']}") - print_info(f" Stories: {selected_plan['stories']}") - print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") - raise typer.Exit(0) - - # Filter by stages - if stages: - stage_list = [s.strip().lower() for s in stages.split(",")] - valid_stages = {"draft", "review", "approved", "released", "unknown"} - invalid_stages = [s for s in stage_list if s not in valid_stages] - if invalid_stages: - print_error(f"Invalid stage(s): {', '.join(invalid_stages)}") - print_info(f"Valid stages: {', '.join(sorted(valid_stages))}") - raise typer.Exit(1) - filtered_plans = [p for p in filtered_plans if str(p.get("stage", "unknown")).lower() in stage_list] - - # Filter by last N (most recent first) - if last: - # Sort by modification time (most recent first) and take last N - # Handle None values by using empty string as fallback for sorting - filtered_plans = sorted(filtered_plans, key=lambda p: p.get("modified") or "", reverse=True)[:last] - - if not filtered_plans: - print_warning("No plans match the specified filters") - raise typer.Exit(1) - - # Handle --name flag (non-interactive selection by exact filename) - if name is not None: - no_interactive = True # Force non-interactive when --name is used - plan_name = SpecFactStructure.ensure_plan_filename(str(name)) - - selected_plan = None - for p in plans: # Search all plans, not just filtered - if p["name"] == plan_name: - selected_plan = p - break - - if selected_plan is None: - print_error(f"Plan not found: {plan_name}") - raise typer.Exit(1) - - # Set as active and exit - SpecFactStructure.set_active_plan(plan_name) - record( - { - "plans_available": len(plans), - "plans_filtered": len(filtered_plans), - "selected_plan": plan_name, - "features": selected_plan["features"], - "stories": selected_plan["stories"], - "selected_by": "name", - } - ) - print_success(f"Active plan (--name): {plan_name}") - print_info(f" Features: {selected_plan['features']}") - print_info(f" Stories: {selected_plan['stories']}") - print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") - raise typer.Exit(0) - - # Handle --id flag (non-interactive selection by content hash) - if plan_id is not None: - no_interactive = True # Force non-interactive when --id is used - # Match by content hash (from bundle manifest summary) - selected_plan = None - for p in plans: - content_hash = p.get("content_hash") - if content_hash and (content_hash == plan_id or content_hash.startswith(plan_id)): - selected_plan = p - break - - if selected_plan is None: - print_error(f"Plan not found with ID: {plan_id}") - print_info("Tip: Use 'specfact plan select' to see available plans and their IDs") - raise typer.Exit(1) - - # Set as active and exit - plan_name = str(selected_plan["name"]) - SpecFactStructure.set_active_plan(plan_name) - record( - { - "plans_available": len(plans), - "plans_filtered": len(filtered_plans), - "selected_plan": plan_name, - "features": selected_plan["features"], - "stories": selected_plan["stories"], - "selected_by": "id", - } - ) - print_success(f"Active plan (--id): {plan_name}") - print_info(f" Features: {selected_plan['features']}") - print_info(f" Stories: {selected_plan['stories']}") - print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") - raise typer.Exit(0) - - # If plan provided, try to resolve it - if plan is not None: - # Try as number first (using filtered list) - if isinstance(plan, str) and plan.isdigit(): - plan_num = int(plan) - if 1 <= plan_num <= len(filtered_plans): - selected_plan = filtered_plans[plan_num - 1] - else: - print_error(f"Invalid plan number: {plan_num}. Must be between 1 and {len(filtered_plans)}") - raise typer.Exit(1) - else: - # Try as bundle name (search in filtered list first, then all plans) - bundle_name = str(plan) - - # Find matching bundle in filtered list first - selected_plan = None - for p in filtered_plans: - if p["name"] == bundle_name: - selected_plan = p - break - - # If not found in filtered list, search all plans (for better error message) - if selected_plan is None: - for p in plans: - if p["name"] == bundle_name: - print_warning(f"Bundle '{bundle_name}' exists but is filtered out by current options") - print_info("Available filtered bundles:") - for i, p in enumerate(filtered_plans, 1): - print_info(f" {i}. {p['name']}") - raise typer.Exit(1) - - if selected_plan is None: - print_error(f"Plan not found: {plan}") - print_info("Available filtered plans:") - for i, p in enumerate(filtered_plans, 1): - print_info(f" {i}. {p['name']}") - raise typer.Exit(1) - else: - # Display numbered list - console.print("\n[bold]Available Plans:[/bold]\n") - - # Create table with optimized column widths - # "#" column: fixed at 4 chars (never shrinks) - # Features/Stories/Stage: minimal widths to avoid wasting space - # Plan Name: flexible to use remaining space (most important) - table = Table(show_header=True, header_style="bold cyan", expand=False) - table.add_column("#", style="bold yellow", justify="right", width=4, min_width=4, no_wrap=True) - table.add_column("Status", style="dim", width=8, min_width=6) - table.add_column("Plan Name", style="bold", min_width=30) # Flexible, gets most space - table.add_column("Features", justify="right", width=8, min_width=6) # Reduced from 10 - table.add_column("Stories", justify="right", width=8, min_width=6) # Reduced from 10 - table.add_column("Stage", width=8, min_width=6) # Reduced from 10 to 8 (draft/review/approved/released fit) - table.add_column("Modified", style="dim", width=19, min_width=15) # Slightly reduced - - for i, p in enumerate(filtered_plans, 1): - status = "[ACTIVE]" if p.get("active") else "" - plan_name = str(p["name"]) - features_count = str(p["features"]) - stories_count = str(p["stories"]) - stage = str(p.get("stage", "unknown")) - modified = str(p["modified"]) - modified_display = modified[:19] if len(modified) > 19 else modified - table.add_row( - f"[bold yellow]{i}[/bold yellow]", - status, - plan_name, - features_count, - stories_count, - stage, - modified_display, - ) - - console.print(table) - console.print() - - # Handle selection (interactive or non-interactive) - if no_interactive: - # Non-interactive mode: select first plan (or error if multiple) - if len(filtered_plans) == 1: - selected_plan = filtered_plans[0] - print_info(f"Non-interactive mode: auto-selecting plan '{selected_plan['name']}'") - else: - print_error( - f"Non-interactive mode requires exactly one plan, but {len(filtered_plans)} plans match filters" - ) - print_info("Use --current, --last 1, or specify a plan name/number to select a single plan") - raise typer.Exit(1) - else: - # Interactive selection - prompt for selection - selection = "" - try: - selection = prompt_text( - f"Select a plan by number (1-{len(filtered_plans)}) or 'q' to quit: " - ).strip() - - if selection.lower() in ("q", "quit", ""): - print_info("Selection cancelled") - raise typer.Exit(0) - - plan_num = int(selection) - if not (1 <= plan_num <= len(filtered_plans)): - print_error(f"Invalid selection: {plan_num}. Must be between 1 and {len(filtered_plans)}") - raise typer.Exit(1) - - selected_plan = filtered_plans[plan_num - 1] - except ValueError: - print_error(f"Invalid input: {selection}. Please enter a number.") - raise typer.Exit(1) from None - except KeyboardInterrupt: - print_warning("\nSelection cancelled") - raise typer.Exit(1) from None - - # Set as active plan - plan_name = str(selected_plan["name"]) - SpecFactStructure.set_active_plan(plan_name) - - record( - { - "plans_available": len(plans), - "plans_filtered": len(filtered_plans), - "selected_plan": plan_name, - "features": selected_plan["features"], - "stories": selected_plan["stories"], - } - ) - - print_success(f"Active plan set to: {plan_name}") - print_info(f" Features: {selected_plan['features']}") - print_info(f" Stories: {selected_plan['stories']}") - print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") - - print_info("\nThis plan will now be used as the default for all commands with --bundle option:") - print_info(" • Plan management: plan compare, plan promote, plan add-feature, plan add-story,") - print_info(" plan update-idea, plan update-feature, plan update-story, plan review") - print_info(" • Analysis & generation: import from-code, generate contracts, analyze contracts") - print_info(" • Synchronization: sync bridge, sync intelligent") - print_info(" • Enforcement & migration: enforce sdd, migrate to-contracts, drift detect") - print_info("\n Use --bundle <name> to override the active plan for any command.") - - -@app.command("upgrade") -@beartype -@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") -@require(lambda all_plans: isinstance(all_plans, bool), "All plans must be bool") -@require(lambda dry_run: isinstance(dry_run, bool), "Dry run must be bool") -def upgrade( - # Target/Input - plan: Path | None = typer.Option( - None, - "--plan", - help="Path to specific plan bundle to upgrade (default: active plan)", - ), - # Behavior/Options - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Show what would be upgraded without making changes", - ), - all_plans: bool = typer.Option( - False, - "--all", - help="Upgrade all plan bundles in .specfact/plans/", - ), -) -> None: - """ - Upgrade plan bundles to the latest schema version. - - Migrates plan bundles from older schema versions to the current version. - This ensures compatibility with the latest features and performance optimizations. - - Examples: - specfact plan upgrade # Upgrade active plan - specfact plan upgrade --plan path/to/plan.bundle.<format> # Upgrade specific plan - specfact plan upgrade --all # Upgrade all plans - specfact plan upgrade --all --dry-run # Preview upgrades without changes - """ - from specfact_cli.migrations.plan_migrator import PlanMigrator, get_current_schema_version - from specfact_cli.utils.structure import SpecFactStructure - - current_version = get_current_schema_version() - migrator = PlanMigrator() - - print_section(f"Plan Bundle Upgrade (Schema {current_version})") - - # Determine which plans to upgrade - plans_to_upgrade: list[Path] = [] - - if all_plans: - # Get all monolithic plan bundles from .specfact/plans/ - plans_dir = Path(".specfact/plans") - if plans_dir.exists(): - for plan_file in plans_dir.glob("*.bundle.*"): - if any(str(plan_file).endswith(suffix) for suffix in SpecFactStructure.PLAN_SUFFIXES): - plans_to_upgrade.append(plan_file) - - # Also get modular project bundles (though they're already in new format, they might need schema updates) - projects = SpecFactStructure.list_plans() - projects_dir = Path(".specfact/projects") - for project_info in projects: - bundle_dir = projects_dir / str(project_info["name"]) - manifest_path = bundle_dir / "bundle.manifest.yaml" - if manifest_path.exists(): - # For modular bundles, we upgrade the manifest file - plans_to_upgrade.append(manifest_path) - elif plan: - # Use specified plan - if not plan.exists(): - print_error(f"Plan file not found: {plan}") - raise typer.Exit(1) - plans_to_upgrade.append(plan) - else: - # Use active plan (modular bundle system) - active_bundle_name = SpecFactStructure.get_active_bundle_name(Path(".")) - if active_bundle_name: - bundle_dir = SpecFactStructure.project_dir(base_path=Path("."), bundle_name=active_bundle_name) - if bundle_dir.exists(): - manifest_path = bundle_dir / "bundle.manifest.yaml" - if manifest_path.exists(): - plans_to_upgrade.append(manifest_path) - print_info(f"Using active plan: {active_bundle_name}") - else: - print_error(f"Bundle manifest not found: {manifest_path}") - print_error(f"Bundle directory exists but manifest is missing: {bundle_dir}") - raise typer.Exit(1) - else: - print_error(f"Active bundle directory not found: {bundle_dir}") - print_error(f"Active bundle name: {active_bundle_name}") - raise typer.Exit(1) - else: - # Fallback: Try to find default bundle (first bundle in projects directory) - projects_dir = Path(".specfact/projects") - if projects_dir.exists(): - bundles = [ - d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() - ] - if bundles: - bundle_name = bundles[0] - bundle_dir = SpecFactStructure.project_dir(base_path=Path("."), bundle_name=bundle_name) - manifest_path = bundle_dir / "bundle.manifest.yaml" - plans_to_upgrade.append(manifest_path) - print_info(f"Using default bundle: {bundle_name}") - print_info(f"Tip: Use 'specfact plan select {bundle_name}' to set as active plan") - else: - print_error("No project bundles found. Use --plan to specify a plan or --all to upgrade all plans.") - print_error("Create one with: specfact plan init <bundle-name>") - raise typer.Exit(1) - else: - print_error("No plan configuration found. Use --plan to specify a plan or --all to upgrade all plans.") - print_error("Create one with: specfact plan init <bundle-name>") - raise typer.Exit(1) - - if not plans_to_upgrade: - print_warning("No plans found to upgrade") - raise typer.Exit(0) - - # Check and upgrade each plan - upgraded_count = 0 - skipped_count = 0 - error_count = 0 - - for plan_path in plans_to_upgrade: - try: - needs_migration, reason = migrator.check_migration_needed(plan_path) - if not needs_migration: - print_info(f"✓ {plan_path.name}: {reason}") - skipped_count += 1 - continue - - if dry_run: - print_warning(f"Would upgrade: {plan_path.name} ({reason})") - upgraded_count += 1 - else: - print_info(f"Upgrading: {plan_path.name} ({reason})...") - bundle, was_migrated = migrator.load_and_migrate(plan_path, dry_run=False) - if was_migrated: - print_success(f"✓ Upgraded {plan_path.name} to schema {bundle.version}") - upgraded_count += 1 - else: - print_info(f"✓ {plan_path.name}: Already up to date") - skipped_count += 1 - except Exception as e: - print_error(f"✗ Failed to upgrade {plan_path.name}: {e}") - error_count += 1 - - # Summary - print() - if dry_run: - print_info(f"Dry run complete: {upgraded_count} would be upgraded, {skipped_count} up to date") - else: - print_success(f"Upgrade complete: {upgraded_count} upgraded, {skipped_count} up to date") - if error_count > 0: - print_warning(f"{error_count} errors occurred") - - if error_count > 0: - raise typer.Exit(1) - - -@app.command("sync") -@beartype -@require(lambda repo: repo is None or isinstance(repo, Path), "Repo must be None or Path") -@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") -@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") -@require(lambda watch: isinstance(watch, bool), "Watch must be bool") -@require(lambda interval: isinstance(interval, int) and interval >= 1, "Interval must be int >= 1") -def sync( - # Target/Input - repo: Path | None = typer.Option( - None, - "--repo", - help="Path to repository (default: current directory)", - ), - plan: Path | None = typer.Option( - None, - "--plan", - help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: active plan)", - ), - # Behavior/Options - shared: bool = typer.Option( - False, - "--shared", - help="Enable shared plans sync (bidirectional sync with Spec-Kit)", - ), - overwrite: bool = typer.Option( - False, - "--overwrite", - help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)", - ), - watch: bool = typer.Option( - False, - "--watch", - help="Watch mode for continuous sync", - ), - # Advanced/Configuration - interval: int = typer.Option( - 5, - "--interval", - help="Watch interval in seconds (default: 5)", - min=1, - ), -) -> None: - """ - Sync shared plans between Spec-Kit and SpecFact (bidirectional sync). - - This is a convenience wrapper around `specfact sync spec-kit --bidirectional` - that enables team collaboration through shared structured plans. The bidirectional - sync keeps Spec-Kit artifacts and SpecFact plans synchronized automatically. - - Shared plans enable: - - Team collaboration: Multiple developers can work on the same plan - - Automated sync: Changes in Spec-Kit automatically sync to SpecFact - - Deviation detection: Compare code vs plan drift automatically - - Conflict resolution: Automatic conflict detection and resolution - - Example: - specfact plan sync --shared # One-time sync - specfact plan sync --shared --watch # Continuous sync - specfact plan sync --shared --repo ./project # Sync specific repo - """ - from specfact_cli.commands.sync import sync_spec_kit - from specfact_cli.utils.structure import SpecFactStructure - - telemetry_metadata = { - "shared": shared, - "watch": watch, - "overwrite": overwrite, - "interval": interval, - } - - with telemetry.track_command("plan.sync", telemetry_metadata) as record: - if not shared: - print_error("This command requires --shared flag") - print_info("Use 'specfact plan sync --shared' to enable shared plans sync") - print_info("Or use 'specfact sync spec-kit --bidirectional' for direct sync") - raise typer.Exit(1) - - # Use default repo if not specified - if repo is None: - repo = Path(".").resolve() - print_info(f"Using current directory: {repo}") - - # Use default plan if not specified - if plan is None: - plan = SpecFactStructure.get_default_plan_path() - if not plan.exists(): - print_warning(f"Default plan not found: {plan}") - print_info("Using default plan path (will be created if needed)") - else: - print_info(f"Using active plan: {plan}") - - print_section("Shared Plans Sync") - console.print("[dim]Bidirectional sync between Spec-Kit and SpecFact for team collaboration[/dim]\n") - - # Call the underlying sync command - try: - # Call sync_spec_kit with bidirectional=True - sync_spec_kit( - repo=repo, - bidirectional=True, # Always bidirectional for shared plans - plan=plan, - overwrite=overwrite, - watch=watch, - interval=interval, - ) - record({"sync_completed": True}) - except Exception as e: - print_error(f"Shared plans sync failed: {e}") - raise typer.Exit(1) from e - - -def _validate_stage(value: str) -> str: - """Validate stage parameter and provide user-friendly error message.""" - valid_stages = ("draft", "review", "approved", "released") - if value not in valid_stages: - console.print(f"[bold red]✗[/bold red] Invalid stage: {value}") - console.print(f"Valid stages: {', '.join(valid_stages)}") - raise typer.Exit(1) - return value - - -@app.command("promote") -@beartype -@require(lambda bundle: isinstance(bundle, str) and len(bundle) > 0, "Bundle name must be non-empty string") -@require( - lambda stage: stage in ("draft", "review", "approved", "released"), - "Stage must be draft, review, approved, or released", -) -def promote( - # Target/Input - bundle: str | None = typer.Argument( - None, - help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", - ), - stage: str = typer.Option( - ..., "--stage", callback=_validate_stage, help="Target stage (draft, review, approved, released)" - ), - # Behavior/Options - validate: bool = typer.Option( - True, - "--validate/--no-validate", - help="Run validation before promotion (default: true)", - ), - force: bool = typer.Option( - False, - "--force", - help="Force promotion even if validation fails (default: false)", - ), -) -> None: - """ - Promote a project bundle through development stages. - - Stages: draft → review → approved → released - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --stage - - **Behavior/Options**: --validate/--no-validate, --force - - **Examples:** - specfact plan promote legacy-api --stage review - specfact plan promote auth-module --stage approved --validate - specfact plan promote legacy-api --stage released --force - """ - import os - from datetime import datetime - - from rich.console import Console - - from specfact_cli.utils.structure import SpecFactStructure - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle is None: - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - telemetry_metadata = { - "target_stage": stage, - "validate": validate, - "force": force, - } - - with telemetry.track_command("plan.promote", telemetry_metadata) as record: - # Find bundle directory - bundle_dir = _find_bundle_dir(bundle) - if bundle_dir is None: - raise typer.Exit(1) - - print_section("SpecFact CLI - Plan Promotion") - - try: - # Load project bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Convert to PlanBundle for compatibility with validation functions - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Check current stage (ProjectBundle doesn't have metadata.stage, use default) - current_stage = "draft" # TODO: Add promotion status to ProjectBundle manifest - - print_info(f"Current stage: {current_stage}") - print_info(f"Target stage: {stage}") - - # Validate stage progression - stage_order = {"draft": 0, "review": 1, "approved": 2, "released": 3} - current_order = stage_order.get(current_stage, 0) - target_order = stage_order.get(stage, 0) - - if target_order < current_order: - print_error(f"Cannot promote backward: {current_stage} → {stage}") - print_error("Only forward promotion is allowed (draft → review → approved → released)") - raise typer.Exit(1) - - if target_order == current_order: - print_warning(f"Plan is already at stage: {stage}") - raise typer.Exit(0) - - # Validate promotion rules - print_info("Checking promotion rules...") - - # Require SDD manifest for promotion to "review" or higher stages - if stage in ("review", "approved", "released"): - print_info("Checking SDD manifest...") - sdd_valid, sdd_manifest, sdd_report = _validate_sdd_for_bundle(plan_bundle, bundle, require_sdd=True) - - if sdd_manifest is None: - print_error("SDD manifest is required for promotion to 'review' or higher stages") - console.print("[dim]Run 'specfact plan harden' to create SDD manifest[/dim]") - if not force: - raise typer.Exit(1) - print_warning("Promoting with --force despite missing SDD manifest") - elif not sdd_valid: - print_error("SDD manifest validation failed:") - for deviation in sdd_report.deviations: - if deviation.severity == DeviationSeverity.HIGH: - console.print(f" [bold red]✗[/bold red] {deviation.description}") - console.print(f" [dim]Fix: {deviation.fix_hint}[/dim]") - if sdd_report.high_count > 0: - console.print( - f"\n[bold red]Cannot promote: {sdd_report.high_count} high severity deviation(s)[/bold red]" - ) - if not force: - raise typer.Exit(1) - print_warning("Promoting with --force despite SDD validation failures") - elif sdd_report.medium_count > 0 or sdd_report.low_count > 0: - print_warning( - f"SDD has {sdd_report.medium_count} medium and {sdd_report.low_count} low severity deviation(s)" - ) - console.print("[dim]Run 'specfact enforce sdd' for detailed report[/dim]") - if not force and not prompt_confirm( - "Continue with promotion despite coverage threshold warnings?", default=False - ): - raise typer.Exit(1) - else: - print_success("SDD manifest validated successfully") - if sdd_report.total_deviations > 0: - console.print(f"[dim]Found {sdd_report.total_deviations} coverage threshold warning(s)[/dim]") - - # Draft → Review: All features must have at least one story - if current_stage == "draft" and stage == "review": - features_without_stories = [f for f in plan_bundle.features if len(f.stories) == 0] - if features_without_stories: - print_error(f"Cannot promote to review: {len(features_without_stories)} feature(s) without stories") - console.print("[dim]Features without stories:[/dim]") - for f in features_without_stories[:5]: - console.print(f" - {f.key}: {f.title}") - if len(features_without_stories) > 5: - console.print(f" ... and {len(features_without_stories) - 5} more") - if not force: - raise typer.Exit(1) - - # Check coverage status for critical categories - if validate: - from specfact_cli.analyzers.ambiguity_scanner import ( - AmbiguityScanner, - AmbiguityStatus, - TaxonomyCategory, - ) - - print_info("Checking coverage status...") - scanner = AmbiguityScanner() - report = scanner.scan(plan_bundle) - - # Critical categories that block promotion if Missing - critical_categories = [ - TaxonomyCategory.FUNCTIONAL_SCOPE, - TaxonomyCategory.FEATURE_COMPLETENESS, - TaxonomyCategory.CONSTRAINTS, - ] - - # Important categories that warn if Missing or Partial - important_categories = [ - TaxonomyCategory.DATA_MODEL, - TaxonomyCategory.INTEGRATION, - TaxonomyCategory.NON_FUNCTIONAL, - ] - - missing_critical: list[TaxonomyCategory] = [] - missing_important: list[TaxonomyCategory] = [] - partial_important: list[TaxonomyCategory] = [] - - if report.coverage: - for category, status in report.coverage.items(): - if category in critical_categories and status == AmbiguityStatus.MISSING: - missing_critical.append(category) - elif category in important_categories: - if status == AmbiguityStatus.MISSING: - missing_important.append(category) - elif status == AmbiguityStatus.PARTIAL: - partial_important.append(category) - - # Block promotion if critical categories are Missing - if missing_critical: - print_error( - f"Cannot promote to review: {len(missing_critical)} critical category(ies) are Missing" - ) - console.print("[dim]Missing critical categories:[/dim]") - for cat in missing_critical: - console.print(f" - {cat.value}") - console.print("\n[dim]Run 'specfact plan review' to resolve these ambiguities[/dim]") - if not force: - raise typer.Exit(1) - - # Warn if important categories are Missing or Partial - if missing_important or partial_important: - print_warning( - f"Plan has {len(missing_important)} missing and {len(partial_important)} partial important category(ies)" - ) - if missing_important: - console.print("[dim]Missing important categories:[/dim]") - for cat in missing_important: - console.print(f" - {cat.value}") - if partial_important: - console.print("[dim]Partial important categories:[/dim]") - for cat in partial_important: - console.print(f" - {cat.value}") - if not force: - console.print("\n[dim]Consider running 'specfact plan review' to improve coverage[/dim]") - console.print("[dim]Use --force to promote anyway[/dim]") - if not prompt_confirm( - "Continue with promotion despite missing/partial categories?", default=False - ): - raise typer.Exit(1) - - # Review → Approved: All features must pass validation - if current_stage == "review" and stage == "approved" and validate: - # SDD validation is already checked above for "review" or higher stages - # But we can add additional checks here if needed - - print_info("Validating all features...") - incomplete_features: list[Feature] = [] - for f in plan_bundle.features: - if not f.acceptance: - incomplete_features.append(f) - for s in f.stories: - if not s.acceptance: - incomplete_features.append(f) - break - - if incomplete_features: - print_warning(f"{len(incomplete_features)} feature(s) have incomplete acceptance criteria") - if not force: - console.print("[dim]Use --force to promote anyway[/dim]") - raise typer.Exit(1) - - # Check coverage status for critical categories - from specfact_cli.analyzers.ambiguity_scanner import ( - AmbiguityScanner, - AmbiguityStatus, - TaxonomyCategory, - ) - - print_info("Checking coverage status...") - scanner_approved = AmbiguityScanner() - report_approved = scanner_approved.scan(plan_bundle) - - # Critical categories that block promotion if Missing - critical_categories_approved = [ - TaxonomyCategory.FUNCTIONAL_SCOPE, - TaxonomyCategory.FEATURE_COMPLETENESS, - TaxonomyCategory.CONSTRAINTS, - ] - - missing_critical_approved: list[TaxonomyCategory] = [] - - if report_approved.coverage: - for category, status in report_approved.coverage.items(): - if category in critical_categories_approved and status == AmbiguityStatus.MISSING: - missing_critical_approved.append(category) - - # Block promotion if critical categories are Missing - if missing_critical_approved: - print_error( - f"Cannot promote to approved: {len(missing_critical_approved)} critical category(ies) are Missing" - ) - console.print("[dim]Missing critical categories:[/dim]") - for cat in missing_critical_approved: - console.print(f" - {cat.value}") - console.print("\n[dim]Run 'specfact plan review' to resolve these ambiguities[/dim]") - if not force: - raise typer.Exit(1) - - # Approved → Released: All features must be implemented (future check) - if current_stage == "approved" and stage == "released": - print_warning("Release promotion: Implementation verification not yet implemented") - if not force: - console.print("[dim]Use --force to promote to released stage[/dim]") - raise typer.Exit(1) - - # Run validation if enabled - if validate: - print_info("Running validation...") - validation_result = validate_plan_bundle(plan_bundle) - if isinstance(validation_result, ValidationReport): - if not validation_result.passed: - deviation_count = len(validation_result.deviations) - print_warning(f"Validation found {deviation_count} issue(s)") - if not force: - console.print("[dim]Use --force to promote anyway[/dim]") - raise typer.Exit(1) - else: - print_success("Validation passed") - else: - print_success("Validation passed") - - # Update promotion status (TODO: Add promotion status to ProjectBundle manifest) - print_info(f"Promoting bundle to stage: {stage}") - promoted_by = ( - os.environ.get("USER") or os.environ.get("USERNAME") or os.environ.get("GIT_AUTHOR_NAME") or "unknown" - ) - - # Save updated project bundle - # TODO: Update ProjectBundle manifest with promotion status - # For now, just save the bundle (promotion status will be added in a future update) - _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) - - record( - { - "current_stage": current_stage, - "target_stage": stage, - "features_count": len(plan_bundle.features) if plan_bundle.features else 0, - } - ) - - # Display summary - print_success(f"Plan promoted: {current_stage} → {stage}") - promoted_at = datetime.now(UTC).isoformat() - console.print(f"[dim]Promoted at: {promoted_at}[/dim]") - console.print(f"[dim]Promoted by: {promoted_by}[/dim]") - - # Show next steps - console.print("\n[bold]Next Steps:[/bold]") - if stage == "review": - console.print(" • Review plan bundle for completeness") - console.print(" • Add stories to features if missing") - console.print(" • Run: specfact plan promote --stage approved") - elif stage == "approved": - console.print(" • Plan is approved for implementation") - console.print(" • Begin feature development") - console.print(" • Run: specfact plan promote --stage released (after implementation)") - elif stage == "released": - console.print(" • Plan is released and should be immutable") - console.print(" • Create new plan bundle for future changes") - - except Exception as e: - print_error(f"Failed to promote plan: {e}") - raise typer.Exit(1) from e - - -@beartype -@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") -@ensure(lambda result: result is None or isinstance(result, Path), "Must return Path or None") -def _find_plan_path(plan: Path | None) -> Path | None: - """ - Find plan path (default, latest, or provided). - - Args: - plan: Provided plan path or None - - Returns: - Plan path or None if not found - """ - from specfact_cli.utils.structure import SpecFactStructure - - if plan is not None: - return plan - - # Try to find active plan or latest - default_plan = SpecFactStructure.get_default_plan_path() - if default_plan.exists(): - print_info(f"Using default plan: {default_plan}") - return default_plan - - # Find latest plan bundle - base_path = Path(".") - plans_dir = base_path / SpecFactStructure.PLANS - if plans_dir.exists(): - plan_files = [ - p - for p in plans_dir.glob("*.bundle.*") - if any(str(p).endswith(suffix) for suffix in SpecFactStructure.PLAN_SUFFIXES) - ] - plan_files = sorted(plan_files, key=lambda p: p.stat().st_mtime, reverse=True) - if plan_files: - print_info(f"Using latest plan: {plan_files[0]}") - return plan_files[0] - print_error(f"No plan bundles found in {plans_dir}") - print_error("Create one with: specfact plan init --interactive") - return None - print_error(f"Plans directory not found: {plans_dir}") - print_error("Create one with: specfact plan init --interactive") - return None - - -@beartype -@require(lambda plan: plan is not None and isinstance(plan, Path), "Plan must be non-None Path") -@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return (bool, PlanBundle | None) tuple") -def _load_and_validate_plan(plan: Path) -> tuple[bool, PlanBundle | None]: - """ - Load and validate plan bundle. - - Args: - plan: Path to plan bundle - - Returns: - Tuple of (is_valid, plan_bundle) - """ - print_info(f"Loading plan: {plan}") - validation_result = validate_plan_bundle(plan) - assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path" - is_valid, error, bundle = validation_result - - if not is_valid or bundle is None: - print_error(f"Plan validation failed: {error}") - return (False, None) - - return (True, bundle) - - -@beartype -@require( - lambda bundle, bundle_dir, auto_enrich: isinstance(bundle, PlanBundle) - and bundle_dir is not None - and isinstance(bundle_dir, Path), - "Bundle must be PlanBundle and bundle_dir must be non-None Path", -) -@ensure(lambda result: result is None, "Must return None") -def _handle_auto_enrichment(bundle: PlanBundle, bundle_dir: Path, auto_enrich: bool) -> None: - """ - Handle auto-enrichment if requested. - - Args: - bundle: Plan bundle to enrich (converted from ProjectBundle) - bundle_dir: Project bundle directory - auto_enrich: Whether to auto-enrich - """ - if not auto_enrich: - return - - print_info( - "Auto-enriching project bundle (enhancing vague acceptance criteria, incomplete requirements, generic tasks)..." - ) - from specfact_cli.enrichers.plan_enricher import PlanEnricher - - enricher = PlanEnricher() - enrichment_summary = enricher.enrich_plan(bundle) - - if enrichment_summary["features_updated"] > 0 or enrichment_summary["stories_updated"] > 0: - # Convert back to ProjectBundle and save - - # Reload to get current state - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - # Update features from enriched bundle - project_bundle.features = {f.key: f for f in bundle.features} - _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) - print_success( - f"✓ Auto-enriched plan bundle: {enrichment_summary['features_updated']} features, " - f"{enrichment_summary['stories_updated']} stories updated" - ) - if enrichment_summary["acceptance_criteria_enhanced"] > 0: - console.print( - f"[dim] - Enhanced {enrichment_summary['acceptance_criteria_enhanced']} acceptance criteria[/dim]" - ) - if enrichment_summary["requirements_enhanced"] > 0: - console.print(f"[dim] - Enhanced {enrichment_summary['requirements_enhanced']} requirements[/dim]") - if enrichment_summary["tasks_enhanced"] > 0: - console.print(f"[dim] - Enhanced {enrichment_summary['tasks_enhanced']} tasks[/dim]") - if enrichment_summary["changes"]: - console.print("\n[bold]Changes made:[/bold]") - for change in enrichment_summary["changes"][:10]: # Show first 10 changes - console.print(f"[dim] - {change}[/dim]") - if len(enrichment_summary["changes"]) > 10: - console.print(f"[dim] ... and {len(enrichment_summary['changes']) - 10} more[/dim]") - else: - print_info("No enrichments needed - plan bundle is already well-specified") - - -@beartype -@require(lambda report: report is not None, "Report must not be None") -@require( - lambda findings_format: findings_format is None or isinstance(findings_format, str), - "Findings format must be None or str", -) -@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") -@ensure(lambda result: result is None, "Must return None") -def _output_findings( - report: Any, # AmbiguityReport (imported locally to avoid circular dependency) - findings_format: str | None, - is_non_interactive: bool, - output_path: Path | None = None, -) -> None: - """ - Output findings in structured format or table. - - Args: - report: Ambiguity report - findings_format: Output format (json, yaml, table) - is_non_interactive: Whether in non-interactive mode - output_path: Optional file path to save findings. If None, outputs to stdout. - """ - from rich.console import Console - from rich.table import Table - - from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus - - console = Console() - - # Determine output format - output_format_str = findings_format - if not output_format_str: - # Default: json for non-interactive, table for interactive - output_format_str = "json" if is_non_interactive else "table" - - output_format_str = output_format_str.lower() - - if output_format_str == "table": - # Interactive table output - findings_table = Table(title="Plan Review Findings", show_header=True, header_style="bold magenta") - findings_table.add_column("Category", style="cyan", no_wrap=True) - findings_table.add_column("Status", style="yellow") - findings_table.add_column("Description", style="white") - findings_table.add_column("Impact", justify="right", style="green") - findings_table.add_column("Uncertainty", justify="right", style="blue") - findings_table.add_column("Priority", justify="right", style="bold") - - findings_list = report.findings or [] - for finding in sorted(findings_list, key=lambda f: f.impact * f.uncertainty, reverse=True): - status_icon = ( - "✅" - if finding.status == AmbiguityStatus.CLEAR - else "⚠️" - if finding.status == AmbiguityStatus.PARTIAL - else "❌" - ) - priority = finding.impact * finding.uncertainty - findings_table.add_row( - finding.category.value, - f"{status_icon} {finding.status.value}", - finding.description[:80] + "..." if len(finding.description) > 80 else finding.description, - f"{finding.impact:.2f}", - f"{finding.uncertainty:.2f}", - f"{priority:.2f}", - ) - - console.print("\n") - console.print(findings_table) - - # Also show coverage summary - if report.coverage: - from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory - - console.print("\n[bold]Coverage Summary:[/bold]") - # Count findings per category by status - total_findings_by_category: dict[TaxonomyCategory, int] = {} - clear_findings_by_category: dict[TaxonomyCategory, int] = {} - partial_findings_by_category: dict[TaxonomyCategory, int] = {} - for finding in findings_list: - cat = finding.category - total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 - # Count by finding status - if finding.status == AmbiguityStatus.CLEAR: - clear_findings_by_category[cat] = clear_findings_by_category.get(cat, 0) + 1 - elif finding.status == AmbiguityStatus.PARTIAL: - partial_findings_by_category[cat] = partial_findings_by_category.get(cat, 0) + 1 - - for cat, status in report.coverage.items(): - status_icon = ( - "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" - ) - total = total_findings_by_category.get(cat, 0) - clear_count = clear_findings_by_category.get(cat, 0) - partial_count = partial_findings_by_category.get(cat, 0) - # Show format based on status: - # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total - # - Partial: Show partial_count/total (count of findings with PARTIAL status = unclear findings) - if status == AmbiguityStatus.CLEAR: - if total == 0: - # No findings - just show status without counts - console.print(f" {status_icon} {cat.value}: {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") - elif status == AmbiguityStatus.PARTIAL: - # Show count of partial (unclear) findings - # If all are unclear, just show the count without the fraction - if partial_count == total: - console.print(f" {status_icon} {cat.value}: {partial_count} {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {partial_count}/{total} {status.value}") - else: # MISSING - console.print(f" {status_icon} {cat.value}: {status.value}") - - elif output_format_str in ("json", "yaml"): - # Structured output (JSON or YAML) - findings_data = { - "findings": [ - { - "category": f.category.value, - "status": f.status.value, - "description": f.description, - "impact": f.impact, - "uncertainty": f.uncertainty, - "priority": f.impact * f.uncertainty, - "question": f.question, - "related_sections": f.related_sections or [], - } - for f in (report.findings or []) - ], - "coverage": {cat.value: status.value for cat, status in (report.coverage or {}).items()}, - "total_findings": len(report.findings or []), - "priority_score": report.priority_score, - } - - import sys - - if output_format_str == "json": - formatted_output = json.dumps(findings_data, indent=2) + "\n" - else: # yaml - from ruamel.yaml import YAML - - yaml = YAML() - yaml.default_flow_style = False - yaml.preserve_quotes = True - from io import StringIO - - output = StringIO() - yaml.dump(findings_data, output) - formatted_output = output.getvalue() - - if output_path: - # Save to file - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(formatted_output, encoding="utf-8") - from rich.console import Console - - console = Console() - console.print(f"[green]✓[/green] Findings saved to: {output_path}") - else: - # Output to stdout - sys.stdout.write(formatted_output) - sys.stdout.flush() - else: - print_error(f"Invalid findings format: {findings_format}. Must be 'json', 'yaml', or 'table'") - raise typer.Exit(1) - - -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda bundle: bundle is not None, "Bundle must not be None") -@ensure(lambda result: isinstance(result, int), "Must return int") -def _deduplicate_features(bundle: PlanBundle) -> int: - """ - Deduplicate features by normalized key (clean up duplicates from previous syncs). - - Uses prefix matching to handle abbreviated vs full names (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM). - - Args: - bundle: Plan bundle to deduplicate - - Returns: - Number of duplicates removed - """ - from specfact_cli.utils.feature_keys import normalize_feature_key - - seen_normalized_keys: set[str] = set() - deduplicated_features: list[Feature] = [] - - for existing_feature in bundle.features: - normalized_key = normalize_feature_key(existing_feature.key) - - # Check for exact match first - if normalized_key in seen_normalized_keys: - continue - - # Check for prefix match (abbreviated vs full names) - # e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM - # Only match if shorter is a PREFIX of longer with significant length difference - # AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin - # This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis) - matched = False - for seen_key in seen_normalized_keys: - shorter = min(normalized_key, seen_key, key=len) - longer = max(normalized_key, seen_key, key=len) - - # Check if at least one of the original keys has a numbered prefix (Spec-Kit format) - import re - - has_speckit_key = bool( - re.match(r"^\d{3}[_-]", existing_feature.key) - or any( - re.match(r"^\d{3}[_-]", f.key) - for f in deduplicated_features - if normalize_feature_key(f.key) == seen_key - ) - ) - - # More conservative matching: - # 1. At least one key must have numbered prefix (Spec-Kit origin) - # 2. Shorter must be at least 10 chars - # 3. Longer must start with shorter (prefix match) - # 4. Length difference must be at least 6 chars - # 5. Shorter must be < 75% of longer (to ensure significant difference) - length_diff = len(longer) - len(shorter) - length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0 - - if ( - has_speckit_key - and len(shorter) >= 10 - and longer.startswith(shorter) - and length_diff >= 6 - and length_ratio < 0.75 - ): - matched = True - # Prefer the longer (full) name - update the existing feature's key if needed - if len(normalized_key) > len(seen_key): - # Current feature has longer name - update the existing one - for dedup_feature in deduplicated_features: - if normalize_feature_key(dedup_feature.key) == seen_key: - dedup_feature.key = existing_feature.key - break - break - - if not matched: - seen_normalized_keys.add(normalized_key) - deduplicated_features.append(existing_feature) - - duplicates_removed = len(bundle.features) - len(deduplicated_features) - if duplicates_removed > 0: - bundle.features = deduplicated_features - - return duplicates_removed - - -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require( - lambda bundle_name: isinstance(bundle_name, str) and len(bundle_name) > 0, "Bundle name must be non-empty string" -) -@require(lambda project_hash: project_hash is None or isinstance(project_hash, str), "Project hash must be None or str") -@ensure( - lambda result: isinstance(result, tuple) and len(result) == 3, - "Must return (bool, SDDManifest | None, ValidationReport) tuple", -) -def _validate_sdd_for_bundle( - bundle: PlanBundle, bundle_name: str, require_sdd: bool = False, project_hash: str | None = None -) -> tuple[bool, SDDManifest | None, ValidationReport]: - """ - Validate SDD manifest for project bundle. - - Args: - bundle: Plan bundle to validate (converted from ProjectBundle) - bundle_name: Project bundle name - require_sdd: If True, return False if SDD is missing (for promotion gates) - project_hash: Optional hash computed from ProjectBundle BEFORE modifications (for consistency with plan harden) - - Returns: - Tuple of (is_valid, sdd_manifest, validation_report) - """ - from specfact_cli.models.deviation import Deviation, DeviationSeverity, ValidationReport - from specfact_cli.models.sdd import SDDManifest - - report = ValidationReport() - # Find SDD using discovery utility - from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle - - base_path = Path.cwd() - sdd_path = find_sdd_for_bundle(bundle_name, base_path) - - # Check if SDD manifest exists - if sdd_path is None: - if require_sdd: - deviation = Deviation( - type=DeviationType.COVERAGE_THRESHOLD, - severity=DeviationSeverity.HIGH, - description="SDD manifest is required for plan promotion but not found", - location=str(sdd_path), - fix_hint=f"Run 'specfact plan harden {bundle_name}' to create SDD manifest", - ) - report.add_deviation(deviation) - return (False, None, report) - # SDD not required, just return None - return (True, None, report) - - # Load SDD manifest - try: - sdd_data = load_structured_file(sdd_path) - sdd_manifest = SDDManifest.model_validate(sdd_data) - except Exception as e: - deviation = Deviation( - type=DeviationType.COVERAGE_THRESHOLD, - severity=DeviationSeverity.HIGH, - description=f"Failed to load SDD manifest: {e}", - location=str(sdd_path), - fix_hint=f"Run 'specfact plan harden {bundle_name}' to recreate SDD manifest", - ) - report.add_deviation(deviation) - return (False, None, report) - - # Validate hash match - # IMPORTANT: Use project_hash if provided (computed from ProjectBundle BEFORE modifications) - # This ensures consistency with plan harden which computes hash from ProjectBundle. - # If not provided, fall back to computing from PlanBundle (for backward compatibility). - if project_hash: - bundle_hash = project_hash - else: - bundle.update_summary(include_hash=True) - bundle_hash = bundle.metadata.summary.content_hash if bundle.metadata and bundle.metadata.summary else None - - if bundle_hash and sdd_manifest.plan_bundle_hash != bundle_hash: - deviation = Deviation( - type=DeviationType.HASH_MISMATCH, - severity=DeviationSeverity.HIGH, - description=f"SDD bundle hash mismatch: expected {bundle_hash[:16]}..., got {sdd_manifest.plan_bundle_hash[:16]}...", - location=str(sdd_path), - fix_hint=f"Run 'specfact plan harden {bundle_name}' to update SDD manifest", - ) - report.add_deviation(deviation) - return (False, sdd_manifest, report) - - # Validate coverage thresholds - from specfact_cli.validators.contract_validator import calculate_contract_density, validate_contract_density - - metrics = calculate_contract_density(sdd_manifest, bundle) - density_deviations = validate_contract_density(sdd_manifest, bundle, metrics) - for deviation in density_deviations: - report.add_deviation(deviation) - - is_valid = report.total_deviations == 0 - return (is_valid, sdd_manifest, report) - - -def _validate_sdd_for_plan( - bundle: PlanBundle, plan_path: Path, require_sdd: bool = False -) -> tuple[bool, SDDManifest | None, ValidationReport]: - """ - Validate SDD manifest for plan bundle. - - Args: - bundle: Plan bundle to validate - plan_path: Path to plan bundle - require_sdd: If True, return False if SDD is missing (for promotion gates) - - Returns: - Tuple of (is_valid, sdd_manifest, validation_report) - """ - from specfact_cli.models.deviation import Deviation, DeviationSeverity, ValidationReport - from specfact_cli.models.sdd import SDDManifest - from specfact_cli.utils.structure import SpecFactStructure - - report = ValidationReport() - # Construct bundle-specific SDD path (Phase 8.5+) - base_path = Path.cwd() - if not plan_path.is_dir(): - print_error( - "Legacy monolithic plan detected. Please migrate to bundle directories via 'specfact migrate artifacts --repo .'." - ) - raise typer.Exit(1) - bundle_name = plan_path.name - from specfact_cli.utils.structured_io import StructuredFormat - - sdd_path = SpecFactStructure.get_bundle_sdd_path(bundle_name, base_path, StructuredFormat.YAML) - if not sdd_path.exists(): - sdd_path = SpecFactStructure.get_bundle_sdd_path(bundle_name, base_path, StructuredFormat.JSON) - - # Check if SDD manifest exists - if not sdd_path.exists(): - if require_sdd: - deviation = Deviation( - type=DeviationType.COVERAGE_THRESHOLD, - severity=DeviationSeverity.HIGH, - description="SDD manifest is required for plan promotion but not found", - location=".specfact/projects/<bundle>/sdd.yaml", - fix_hint="Run 'specfact plan harden' to create SDD manifest", - ) - report.add_deviation(deviation) - return (False, None, report) - # SDD not required, just return None - return (True, None, report) - - # Load SDD manifest - try: - sdd_data = load_structured_file(sdd_path) - sdd_manifest = SDDManifest.model_validate(sdd_data) - except Exception as e: - deviation = Deviation( - type=DeviationType.COVERAGE_THRESHOLD, - severity=DeviationSeverity.HIGH, - description=f"Failed to load SDD manifest: {e}", - location=str(sdd_path), - fix_hint="Run 'specfact plan harden' to regenerate SDD manifest", - ) - report.add_deviation(deviation) - return (False, None, report) - - # Validate hash match - bundle.update_summary(include_hash=True) - plan_hash = bundle.metadata.summary.content_hash if bundle.metadata and bundle.metadata.summary else None - - if not plan_hash: - deviation = Deviation( - type=DeviationType.COVERAGE_THRESHOLD, - severity=DeviationSeverity.HIGH, - description="Failed to compute plan bundle hash", - location=str(plan_path), - fix_hint="Plan bundle may be corrupted", - ) - report.add_deviation(deviation) - return (False, sdd_manifest, report) - - if sdd_manifest.plan_bundle_hash != plan_hash: - deviation = Deviation( - type=DeviationType.HASH_MISMATCH, - severity=DeviationSeverity.HIGH, - description=f"SDD plan bundle hash mismatch: expected {plan_hash[:16]}..., got {sdd_manifest.plan_bundle_hash[:16]}...", - location=".specfact/projects/<bundle>/sdd.yaml", - fix_hint="Run 'specfact plan harden' to update SDD manifest with current plan hash", - ) - report.add_deviation(deviation) - return (False, sdd_manifest, report) - - # Validate coverage thresholds using contract validator - from specfact_cli.validators.contract_validator import calculate_contract_density, validate_contract_density - - metrics = calculate_contract_density(sdd_manifest, bundle) - density_deviations = validate_contract_density(sdd_manifest, bundle, metrics) - - for deviation in density_deviations: - report.add_deviation(deviation) - - # Valid if no HIGH severity deviations - is_valid = report.high_count == 0 - return (is_valid, sdd_manifest, report) - - -@beartype -@require(lambda project_bundle: isinstance(project_bundle, ProjectBundle), "Project bundle must be ProjectBundle") -@require(lambda bundle_dir: isinstance(bundle_dir, Path), "Bundle dir must be Path") -@require(lambda bundle_name: isinstance(bundle_name, str), "Bundle name must be str") -@require(lambda auto_enrich: isinstance(auto_enrich, bool), "Auto enrich must be bool") -@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return tuple of PlanBundle and str") -def _prepare_review_bundle( - project_bundle: ProjectBundle, bundle_dir: Path, bundle_name: str, auto_enrich: bool -) -> tuple[PlanBundle, str]: - """ - Prepare plan bundle for review. - - Args: - project_bundle: Loaded project bundle - bundle_dir: Path to bundle directory - bundle_name: Bundle name - auto_enrich: Whether to auto-enrich the bundle - - Returns: - Tuple of (plan_bundle, current_stage) - """ - # Compute hash from ProjectBundle BEFORE any modifications (same as plan harden does) - # This ensures hash consistency with SDD manifest created by plan harden - project_summary = project_bundle.compute_summary(include_hash=True) - project_hash = project_summary.content_hash - if not project_hash: - print_warning("Failed to compute project bundle hash for SDD validation") - - # Convert to PlanBundle for compatibility with review functions - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Deduplicate features by normalized key (clean up duplicates from previous syncs) - duplicates_removed = _deduplicate_features(plan_bundle) - if duplicates_removed > 0: - # Convert back to ProjectBundle and save - # Update project bundle with deduplicated features - project_bundle.features = {f.key: f for f in plan_bundle.features} - _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) - print_success(f"✓ Removed {duplicates_removed} duplicate features from project bundle") - - # Check current stage (ProjectBundle doesn't have metadata.stage, use default) - current_stage = "draft" # TODO: Add promotion status to ProjectBundle manifest - - print_info(f"Current stage: {current_stage}") - - # Validate SDD manifest (warn if missing, validate thresholds if present) - # Pass project_hash computed BEFORE modifications to ensure consistency - print_info("Checking SDD manifest...") - sdd_valid, sdd_manifest, sdd_report = _validate_sdd_for_bundle( - plan_bundle, bundle_name, require_sdd=False, project_hash=project_hash - ) - - if sdd_manifest is None: - print_warning("SDD manifest not found. Consider running 'specfact plan harden' to create one.") - from rich.console import Console - - console = Console() - console.print("[dim]SDD manifest is recommended for plan review and promotion[/dim]") - elif not sdd_valid: - print_warning("SDD manifest validation failed:") - from rich.console import Console - - from specfact_cli.models.deviation import DeviationSeverity - - console = Console() - for deviation in sdd_report.deviations: - if deviation.severity == DeviationSeverity.HIGH: - console.print(f" [bold red]✗[/bold red] {deviation.description}") - elif deviation.severity == DeviationSeverity.MEDIUM: - console.print(f" [bold yellow]⚠[/bold yellow] {deviation.description}") - else: - console.print(f" [dim]ℹ[/dim] {deviation.description}") - console.print("\n[dim]Run 'specfact enforce sdd' for detailed validation report[/dim]") - else: - print_success("SDD manifest validated successfully") - - # Display contract density metrics - from rich.console import Console - - from specfact_cli.validators.contract_validator import calculate_contract_density - - console = Console() - metrics = calculate_contract_density(sdd_manifest, plan_bundle) - thresholds = sdd_manifest.coverage_thresholds - - console.print("\n[bold]Contract Density Metrics:[/bold]") - console.print( - f" Contracts/story: {metrics.contracts_per_story:.2f} (threshold: {thresholds.contracts_per_story})" - ) - console.print( - f" Invariants/feature: {metrics.invariants_per_feature:.2f} (threshold: {thresholds.invariants_per_feature})" - ) - console.print( - f" Architecture facets: {metrics.architecture_facets} (threshold: {thresholds.architecture_facets})" - ) - - if sdd_report.total_deviations > 0: - console.print(f"\n[dim]Found {sdd_report.total_deviations} coverage threshold warning(s)[/dim]") - console.print("[dim]Run 'specfact enforce sdd' for detailed report[/dim]") - - # Initialize clarifications if needed - from specfact_cli.models.plan import Clarifications - - if plan_bundle.clarifications is None: - plan_bundle.clarifications = Clarifications(sessions=[]) - - # Auto-enrich if requested (before scanning for ambiguities) - _handle_auto_enrichment(plan_bundle, bundle_dir, auto_enrich) - - return (plan_bundle, current_stage) - - -@beartype -@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle") -@require(lambda bundle_dir: isinstance(bundle_dir, Path), "Bundle dir must be Path") -@require(lambda category: category is None or isinstance(category, str), "Category must be None or str") -@require(lambda max_questions: max_questions > 0, "Max questions must be positive") -@ensure( - lambda result: isinstance(result, tuple) and len(result) == 3 and isinstance(result[0], list), - "Must return tuple of questions, report, scanner", -) -def _scan_and_prepare_questions( - plan_bundle: PlanBundle, bundle_dir: Path, category: str | None, max_questions: int -) -> tuple[list[tuple[Any, str]], Any, Any]: # Returns (questions_to_ask, report, scanner) - """ - Scan plan bundle and prepare questions for review. - - Args: - plan_bundle: Plan bundle to scan - bundle_dir: Bundle directory path (for finding repo path) - category: Optional category filter - max_questions: Maximum questions to prepare - - Returns: - Tuple of (questions_to_ask, report, scanner) - """ - from specfact_cli.analyzers.ambiguity_scanner import ( - AmbiguityScanner, - TaxonomyCategory, - ) - - # Scan for ambiguities - print_info("Scanning plan bundle for ambiguities...") - # Try to find repo path from bundle directory (go up to find .specfact parent, then repo root) - repo_path: Path | None = None - if bundle_dir.exists(): - # bundle_dir is typically .specfact/projects/<bundle-name> - # Go up to .specfact, then up to repo root - specfact_dir = bundle_dir.parent.parent if bundle_dir.parent.name == "projects" else bundle_dir.parent - if specfact_dir.name == ".specfact" and specfact_dir.parent.exists(): - repo_path = specfact_dir.parent - else: - # Fallback: try current directory - repo_path = Path(".") - else: - repo_path = Path(".") - - scanner = AmbiguityScanner(repo_path=repo_path) - report = scanner.scan(plan_bundle) - - # Filter by category if specified - if category: - try: - target_category = TaxonomyCategory(category) - if report.findings: - report.findings = [f for f in report.findings if f.category == target_category] - except ValueError: - print_warning(f"Unknown category: {category}, ignoring filter") - category = None - - # Prioritize questions by (Impact x Uncertainty) - findings_list = report.findings or [] - prioritized_findings = sorted( - findings_list, - key=lambda f: f.impact * f.uncertainty, - reverse=True, - ) - - # Filter out findings that already have clarifications - existing_question_ids = set() - if plan_bundle.clarifications: - for session in plan_bundle.clarifications.sessions: - for q in session.questions: - existing_question_ids.add(q.id) - - # Generate question IDs and filter - question_counter = 1 - candidate_questions: list[tuple[Any, str]] = [] - for finding in prioritized_findings: - if finding.question: - # Skip to next available question ID if current one is already used - while (question_id := f"Q{question_counter:03d}") in existing_question_ids: - question_counter += 1 - # Generate question ID and add if not already answered - candidate_questions.append((finding, question_id)) - question_counter += 1 - - # Limit to max_questions - questions_to_ask = candidate_questions[:max_questions] - - return (questions_to_ask, report, scanner) - - -@beartype -@require(lambda questions_to_ask: isinstance(questions_to_ask, list), "Questions must be list") -@require(lambda report: report is not None, "Report must not be None") -@ensure(lambda result: result is None, "Must return None") -def _handle_no_questions_case( - questions_to_ask: list[tuple[Any, str]], - report: Any, # AmbiguityReport -) -> None: - """ - Handle case when there are no questions to ask. - - Args: - questions_to_ask: List of questions (should be empty) - report: Ambiguity report - """ - from rich.console import Console - - from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus, TaxonomyCategory - - console = Console() - - # Check coverage status to determine if plan is truly ready for promotion - critical_categories = [ - TaxonomyCategory.FUNCTIONAL_SCOPE, - TaxonomyCategory.FEATURE_COMPLETENESS, - TaxonomyCategory.CONSTRAINTS, - ] - - missing_critical: list[TaxonomyCategory] = [] - if report.coverage: - for category, status in report.coverage.items(): - if category in critical_categories and status == AmbiguityStatus.MISSING: - missing_critical.append(category) - - # Count total findings per category (shared for both branches) - total_findings_by_category: dict[TaxonomyCategory, int] = {} - if report.findings: - for finding in report.findings: - cat = finding.category - total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 - - if missing_critical: - print_warning( - f"Plan has {len(missing_critical)} critical category(ies) marked as Missing, but no high-priority questions remain" - ) - console.print("[dim]Missing critical categories:[/dim]") - for cat in missing_critical: - console.print(f" - {cat.value}") - console.print("\n[bold]Coverage Summary:[/bold]") - if report.coverage: - for cat, status in report.coverage.items(): - status_icon = ( - "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" - ) - total = total_findings_by_category.get(cat, 0) - # Count findings by status - clear_count = sum( - 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.CLEAR - ) - partial_count = sum( - 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.PARTIAL - ) - # Show format based on status: - # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total - # - Partial: Show partial_count/total (count of findings with PARTIAL status) - if status == AmbiguityStatus.CLEAR: - if total == 0: - # No findings - just show status without counts - console.print(f" {status_icon} {cat.value}: {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") - elif status == AmbiguityStatus.PARTIAL: - console.print(f" {status_icon} {cat.value}: {partial_count}/{total} {status.value}") - else: # MISSING - console.print(f" {status_icon} {cat.value}: {status.value}") - console.print( - "\n[bold]⚠️ Warning:[/bold] Plan may not be ready for promotion due to missing critical categories" - ) - console.print("[dim]Consider addressing these categories before promoting[/dim]") - else: - print_success("No critical ambiguities detected. Plan is ready for promotion.") - console.print("\n[bold]Coverage Summary:[/bold]") - if report.coverage: - for cat, status in report.coverage.items(): - status_icon = ( - "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" - ) - total = total_findings_by_category.get(cat, 0) - # Count findings by status - clear_count = sum( - 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.CLEAR - ) - partial_count = sum( - 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.PARTIAL - ) - # Show format based on status: - # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total - # - Partial: Show partial_count/total (count of findings with PARTIAL status) - if status == AmbiguityStatus.CLEAR: - if total == 0: - # No findings - just show status without counts - console.print(f" {status_icon} {cat.value}: {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") - elif status == AmbiguityStatus.PARTIAL: - console.print(f" {status_icon} {cat.value}: {partial_count}/{total} {status.value}") - else: # MISSING - console.print(f" {status_icon} {cat.value}: {status.value}") - - return - - -@beartype -@require(lambda questions_to_ask: isinstance(questions_to_ask, list), "Questions must be list") -@ensure(lambda result: result is None, "Must return None") -def _handle_list_questions_mode(questions_to_ask: list[tuple[Any, str]], output_path: Path | None = None) -> None: - """ - Handle --list-questions mode by outputting questions as JSON. - - Args: - questions_to_ask: List of (finding, question_id) tuples - output_path: Optional file path to save questions. If None, outputs to stdout. - """ - import json - import sys - - questions_json = [] - for finding, question_id in questions_to_ask: - questions_json.append( - { - "id": question_id, - "category": finding.category.value, - "question": finding.question, - "impact": finding.impact, - "uncertainty": finding.uncertainty, - "related_sections": finding.related_sections or [], - } - ) - - json_output = json.dumps({"questions": questions_json, "total": len(questions_json)}, indent=2) - - if output_path: - # Save to file - output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json_output + "\n", encoding="utf-8") - from rich.console import Console - - console = Console() - console.print(f"[green]✓[/green] Questions saved to: {output_path}") - else: - # Output JSON to stdout (for Copilot mode parsing) - sys.stdout.write(json_output) - sys.stdout.write("\n") - sys.stdout.flush() - - return - - -@beartype -@require(lambda answers: isinstance(answers, str), "Answers must be string") -@ensure(lambda result: isinstance(result, dict), "Must return dict") -def _parse_answers_dict(answers: str) -> dict[str, str]: - """ - Parse --answers JSON string or file path. - - Args: - answers: JSON string or file path - - Returns: - Dictionary mapping question_id -> answer - """ - import json - - try: - # Try to parse as JSON string first - try: - answers_dict = json.loads(answers) - except json.JSONDecodeError: - # If JSON parsing fails, try as file path - answers_path = Path(answers) - if answers_path.exists() and answers_path.is_file(): - answers_dict = json.loads(answers_path.read_text()) - else: - raise ValueError(f"Invalid JSON string and file not found: {answers}") from None - - if not isinstance(answers_dict, dict): - print_error("--answers must be a JSON object with question_id -> answer mappings") - raise typer.Exit(1) - return answers_dict - except (json.JSONDecodeError, ValueError) as e: - print_error(f"Invalid JSON in --answers: {e}") - raise typer.Exit(1) from e - - -@beartype -@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle") -@require(lambda questions_to_ask: isinstance(questions_to_ask, list), "Questions must be list") -@require(lambda answers_dict: isinstance(answers_dict, dict), "Answers dict must be dict") -@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") -@require(lambda bundle_dir: isinstance(bundle_dir, Path), "Bundle dir must be Path") -@require(lambda project_bundle: isinstance(project_bundle, ProjectBundle), "Project bundle must be ProjectBundle") -@ensure(lambda result: isinstance(result, int), "Must return int") -def _ask_questions_interactive( - plan_bundle: PlanBundle, - questions_to_ask: list[tuple[Any, str]], - answers_dict: dict[str, str], - is_non_interactive: bool, - bundle_dir: Path, - project_bundle: ProjectBundle, -) -> int: - """ - Ask questions interactively and integrate answers. - - Args: - plan_bundle: Plan bundle to update - questions_to_ask: List of (finding, question_id) tuples - answers_dict: Pre-provided answers dict (may be empty) - is_non_interactive: Whether in non-interactive mode - bundle_dir: Bundle directory path - project_bundle: Project bundle to save - - Returns: - Number of questions asked - """ - from datetime import date, datetime - - from rich.console import Console - - from specfact_cli.models.plan import Clarification, ClarificationSession - - console = Console() - - # Create or get today's session - today = date.today().isoformat() - today_session: ClarificationSession | None = None - if plan_bundle.clarifications: - for session in plan_bundle.clarifications.sessions: - if session.date == today: - today_session = session - break - - if today_session is None: - today_session = ClarificationSession(date=today, questions=[]) - if plan_bundle.clarifications: - plan_bundle.clarifications.sessions.append(today_session) - - # Ask questions sequentially - questions_asked = 0 - for finding, question_id in questions_to_ask: - questions_asked += 1 - - # Get answer (interactive or from --answers) - if question_id in answers_dict: - # Non-interactive: use provided answer - answer = answers_dict[question_id] - if not isinstance(answer, str) or not answer.strip(): - print_error(f"Answer for {question_id} must be a non-empty string") - raise typer.Exit(1) - console.print(f"\n[bold cyan]Question {questions_asked}/{len(questions_to_ask)}[/bold cyan]") - console.print(f"[dim]Category: {finding.category.value}[/dim]") - console.print(f"[bold]Q: {finding.question}[/bold]") - console.print(f"[dim]Answer (from --answers): {answer}[/dim]") - default_value = None - else: - # Interactive: prompt user - if is_non_interactive: - # In non-interactive mode without --answers, skip this question - print_warning(f"Skipping {question_id}: no answer provided in non-interactive mode") - continue - - console.print(f"\n[bold cyan]Question {questions_asked}/{len(questions_to_ask)}[/bold cyan]") - console.print(f"[dim]Category: {finding.category.value}[/dim]") - console.print(f"[bold]Q: {finding.question}[/bold]") - - # Show current settings for related sections before asking and get default value - default_value = _show_current_settings_for_finding(plan_bundle, finding, console_instance=console) - - # Get answer from user with smart Yes/No handling (with default to confirm existing) - answer = _get_smart_answer(finding, plan_bundle, is_non_interactive, default_value=default_value) - - # Validate answer length (warn if too long, but only if user typed something new) - # Don't warn if user confirmed existing default value - # Check if answer matches default (normalize whitespace for comparison) - is_confirmed_default = False - if default_value: - # Normalize both for comparison (strip and compare) - answer_normalized = answer.strip() - default_normalized = default_value.strip() - # Check exact match or if answer is empty and we have default (Enter pressed) - is_confirmed_default = answer_normalized == default_normalized or ( - not answer_normalized and default_normalized - ) - if not is_confirmed_default and len(answer.split()) > 5: - print_warning("Answer is longer than 5 words. Consider a shorter, more focused answer.") - - # Integrate answer into plan bundle - integration_points = _integrate_clarification(plan_bundle, finding, answer) - - # Create clarification record - clarification = Clarification( - id=question_id, - category=finding.category.value, - question=finding.question or "", - answer=answer, - integrated_into=integration_points, - timestamp=datetime.now(UTC).isoformat(), - ) - - today_session.questions.append(clarification) - - # Answer integrated into bundle (will save at end for performance) - print_success("Answer recorded and integrated into plan bundle") - - # Ask if user wants to continue (only in interactive mode) - if ( - not is_non_interactive - and questions_asked < len(questions_to_ask) - and not prompt_confirm("Continue to next question?", default=True) - ): - break - - # Save project bundle once at the end (more efficient than saving after each question) - # Update existing project_bundle in memory (no need to reload - we already have it) - # Preserve manifest from original bundle - project_bundle.idea = plan_bundle.idea - project_bundle.business = plan_bundle.business - project_bundle.product = plan_bundle.product - project_bundle.features = {f.key: f for f in plan_bundle.features} - project_bundle.clarifications = plan_bundle.clarifications - _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) - print_success("Project bundle saved") - - return questions_asked - - -@beartype -@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle") -@require(lambda scanner: scanner is not None, "Scanner must not be None") -@require(lambda bundle: isinstance(bundle, str), "Bundle must be str") -@require(lambda questions_asked: questions_asked >= 0, "Questions asked must be non-negative") -@require(lambda report: report is not None, "Report must not be None") -@require(lambda current_stage: isinstance(current_stage, str), "Current stage must be str") -@require(lambda today_session: today_session is not None, "Today session must not be None") -@ensure(lambda result: result is None, "Must return None") -def _display_review_summary( - plan_bundle: PlanBundle, - scanner: Any, # AmbiguityScanner - bundle: str, - questions_asked: int, - report: Any, # AmbiguityReport - current_stage: str, - today_session: Any, # ClarificationSession -) -> None: - """ - Display final review summary and updated coverage. - - Args: - plan_bundle: Updated plan bundle - scanner: Ambiguity scanner instance - bundle: Bundle name - questions_asked: Number of questions asked - report: Original ambiguity report - current_stage: Current plan stage - today_session: Today's clarification session - """ - from rich.console import Console - - from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus - - console = Console() - - # Final validation - print_info("Validating updated plan bundle...") - validation_result = validate_plan_bundle(plan_bundle) - if isinstance(validation_result, ValidationReport): - if not validation_result.passed: - print_warning(f"Validation found {len(validation_result.deviations)} issue(s)") - else: - print_success("Validation passed") - else: - print_success("Validation passed") - - # Display summary - print_success(f"Review complete: {questions_asked} question(s) answered") - console.print(f"\n[bold]Project Bundle:[/bold] {bundle}") - console.print(f"[bold]Questions Asked:[/bold] {questions_asked}") - - if today_session.questions: - console.print("\n[bold]Sections Touched:[/bold]") - all_sections = set() - for q in today_session.questions: - all_sections.update(q.integrated_into) - for section in sorted(all_sections): - console.print(f" • {section}") - - # Re-scan plan bundle after questions to get updated coverage summary - print_info("Re-scanning plan bundle for updated coverage...") - updated_report = scanner.scan(plan_bundle) - - # Coverage summary (updated after questions) - console.print("\n[bold]Updated Coverage Summary:[/bold]") - if updated_report.coverage: - from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory - - # Count findings that can still generate questions (unclear findings) - # Use the same logic as _scan_and_prepare_questions to count unclear findings - existing_question_ids = set() - if plan_bundle.clarifications: - for session in plan_bundle.clarifications.sessions: - for q in session.questions: - existing_question_ids.add(q.id) - - # Prioritize findings by (Impact x Uncertainty) - same as _scan_and_prepare_questions - findings_list = updated_report.findings or [] - prioritized_findings = sorted( - findings_list, - key=lambda f: f.impact * f.uncertainty, - reverse=True, - ) - - # Count total findings and unclear findings per category - # A finding is unclear if it can still generate a question (same logic as _scan_and_prepare_questions) - total_findings_by_category: dict[TaxonomyCategory, int] = {} - unclear_findings_by_category: dict[TaxonomyCategory, int] = {} - clear_findings_by_category: dict[TaxonomyCategory, int] = {} - - question_counter = 1 - for finding in prioritized_findings: - cat = finding.category - total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 - - # Count by finding status - if finding.status == AmbiguityStatus.CLEAR: - clear_findings_by_category[cat] = clear_findings_by_category.get(cat, 0) + 1 - elif finding.status == AmbiguityStatus.PARTIAL: - # A finding is unclear if it can generate a question (same logic as _scan_and_prepare_questions) - if finding.question: - # Skip to next available question ID if current one is already used - while f"Q{question_counter:03d}" in existing_question_ids: - question_counter += 1 - # This finding can generate a question, so it's unclear - unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 - question_counter += 1 - else: - # Finding has no question, so it's unclear - unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 - - for cat, status in updated_report.coverage.items(): - status_icon = ( - "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" - ) - total = total_findings_by_category.get(cat, 0) - unclear = unclear_findings_by_category.get(cat, 0) - clear_count = clear_findings_by_category.get(cat, 0) - # Show format based on status: - # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total - # - Partial: Show unclear_count/total (how many findings are still unclear) - if status == AmbiguityStatus.CLEAR: - if total == 0: - # No findings - just show status without counts - console.print(f" {status_icon} {cat.value}: {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") - elif status == AmbiguityStatus.PARTIAL: - # Show how many findings are still unclear - # If all are unclear, just show the count without the fraction - if unclear == total: - console.print(f" {status_icon} {cat.value}: {unclear} {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {unclear}/{total} {status.value}") - else: # MISSING - console.print(f" {status_icon} {cat.value}: {status.value}") - - # Next steps - console.print("\n[bold]Next Steps:[/bold]") - if current_stage == "draft": - console.print(" • Review plan bundle for completeness") - console.print(" • Run: specfact plan promote --stage review") - elif current_stage == "review": - console.print(" • Plan is ready for approval") - console.print(" • Run: specfact plan promote --stage approved") - - return - - -@app.command("review") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda max_questions: max_questions > 0, "Max questions must be positive") -def review( - # Target/Input - bundle: str | None = typer.Argument( - None, - help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", - ), - category: str | None = typer.Option( - None, - "--category", - help="Focus on specific taxonomy category (optional). Default: None (all categories)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - # Output/Results - list_questions: bool = typer.Option( - False, - "--list-questions", - help="Output questions in JSON format without asking (for Copilot mode). Default: False", - ), - output_questions: Path | None = typer.Option( - None, - "--output-questions", - help="Save questions to file (JSON format). If --list-questions is also set, questions are saved to file instead of stdout. Default: None", - ), - list_findings: bool = typer.Option( - False, - "--list-findings", - help="Output all findings in structured format (JSON/YAML) or as table (interactive mode). Preferred for bulk updates via Copilot LLM enrichment. Default: False", - ), - findings_format: str | None = typer.Option( - None, - "--findings-format", - help="Output format for --list-findings: json, yaml, or table. Default: json for non-interactive, table for interactive", - case_sensitive=False, - hidden=True, # Hidden by default, shown with --help-advanced - ), - output_findings: Path | None = typer.Option( - None, - "--output-findings", - help="Save findings to file (JSON/YAML format). If --list-findings is also set, findings are saved to file instead of stdout. Default: None", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), - answers: str | None = typer.Option( - None, - "--answers", - help="JSON object with question_id -> answer mappings (for non-interactive mode). Can be JSON string or path to JSON file. Use with --output-questions to save questions, then edit and provide answers. Default: None", - hidden=True, # Hidden by default, shown with --help-advanced - ), - auto_enrich: bool = typer.Option( - False, - "--auto-enrich", - help="Automatically enrich vague acceptance criteria, incomplete requirements, and generic tasks using LLM-enhanced pattern matching. Default: False", - ), - # Advanced/Configuration - max_questions: int = typer.Option( - 5, - "--max-questions", - min=1, - max=10, - help="Maximum questions per session. Default: 5 (range: 1-10)", - hidden=True, # Hidden by default, shown with --help-advanced - ), -) -> None: - """ - Review project bundle to identify and resolve ambiguities. - - Analyzes the project bundle for missing information, unclear requirements, - and unknowns. Asks targeted questions to resolve ambiguities and make - the bundle ready for promotion. - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --category - - **Output/Results**: --list-questions, --list-findings, --findings-format - - **Behavior/Options**: --no-interactive, --answers, --auto-enrich - - **Advanced/Configuration**: --max-questions - - **Examples:** - specfact plan review legacy-api - specfact plan review auth-module --max-questions 3 --category "Functional Scope" - specfact plan review legacy-api --list-questions # Output questions as JSON - specfact plan review legacy-api --list-questions --output-questions /tmp/questions.json # Save questions to file - specfact plan review legacy-api --list-findings --findings-format json # Output all findings as JSON - specfact plan review legacy-api --list-findings --output-findings /tmp/findings.json # Save findings to file - specfact plan review legacy-api --answers '{"Q001": "answer1", "Q002": "answer2"}' # Non-interactive - """ - from rich.console import Console - - from specfact_cli.utils.structure import SpecFactStructure - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle is None: - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - from datetime import date - - from specfact_cli.analyzers.ambiguity_scanner import ( - AmbiguityStatus, - ) - from specfact_cli.models.plan import ClarificationSession - - # Detect operational mode - mode = detect_mode() - is_non_interactive = no_interactive or (answers is not None) or list_questions - - telemetry_metadata = { - "max_questions": max_questions, - "category": category, - "list_questions": list_questions, - "non_interactive": is_non_interactive, - "mode": mode.value, - } - - with telemetry.track_command("plan.review", telemetry_metadata) as record: - # Find bundle directory - bundle_dir = _find_bundle_dir(bundle) - if bundle_dir is None: - raise typer.Exit(1) - - print_section("SpecFact CLI - Plan Review") - - try: - # Load and prepare bundle - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - plan_bundle, current_stage = _prepare_review_bundle(project_bundle, bundle_dir, bundle, auto_enrich) - - if current_stage not in ("draft", "review"): - print_warning("Review is typically run on 'draft' or 'review' stage plans") - if not is_non_interactive and not prompt_confirm("Continue anyway?", default=False): - raise typer.Exit(0) - if is_non_interactive: - print_info("Continuing in non-interactive mode") - - # Scan and prepare questions - questions_to_ask, report, scanner = _scan_and_prepare_questions( - plan_bundle, bundle_dir, category, max_questions - ) - - # Handle --list-findings mode - if list_findings: - _output_findings(report, findings_format, is_non_interactive, output_findings) - raise typer.Exit(0) - - # Show initial coverage summary BEFORE questions (so user knows what's missing) - if questions_to_ask: - from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus - - console.print("\n[bold]Initial Coverage Summary:[/bold]") - if report.coverage: - from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory - - # Count findings that can still generate questions (unclear findings) - # Use the same logic as _scan_and_prepare_questions to count unclear findings - existing_question_ids = set() - if plan_bundle.clarifications: - for session in plan_bundle.clarifications.sessions: - for q in session.questions: - existing_question_ids.add(q.id) - - # Prioritize findings by (Impact x Uncertainty) - same as _scan_and_prepare_questions - findings_list = report.findings or [] - prioritized_findings = sorted( - findings_list, - key=lambda f: f.impact * f.uncertainty, - reverse=True, - ) - - # Count total findings and unclear findings per category - # A finding is unclear if it can still generate a question (same logic as _scan_and_prepare_questions) - total_findings_by_category: dict[TaxonomyCategory, int] = {} - unclear_findings_by_category: dict[TaxonomyCategory, int] = {} - clear_findings_by_category: dict[TaxonomyCategory, int] = {} - - question_counter = 1 - for finding in prioritized_findings: - cat = finding.category - total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 - - # Count by finding status - if finding.status == AmbiguityStatus.CLEAR: - clear_findings_by_category[cat] = clear_findings_by_category.get(cat, 0) + 1 - elif finding.status == AmbiguityStatus.PARTIAL: - # A finding is unclear if it can generate a question (same logic as _scan_and_prepare_questions) - if finding.question: - # Skip to next available question ID if current one is already used - while f"Q{question_counter:03d}" in existing_question_ids: - question_counter += 1 - # This finding can generate a question, so it's unclear - unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 - question_counter += 1 - else: - # Finding has no question, so it's unclear - unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 - - for cat, status in report.coverage.items(): - status_icon = ( - "✅" - if status == AmbiguityStatus.CLEAR - else "⚠️" - if status == AmbiguityStatus.PARTIAL - else "❌" - ) - total = total_findings_by_category.get(cat, 0) - unclear = unclear_findings_by_category.get(cat, 0) - clear_count = clear_findings_by_category.get(cat, 0) - # Show format based on status: - # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total - # - Partial: Show unclear_count/total (how many findings are still unclear) - if status == AmbiguityStatus.CLEAR: - if total == 0: - # No findings - just show status without counts - console.print(f" {status_icon} {cat.value}: {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") - elif status == AmbiguityStatus.PARTIAL: - # Show how many findings are still unclear - # If all are unclear, just show the count without the fraction - if unclear == total: - console.print(f" {status_icon} {cat.value}: {unclear} {status.value}") - else: - console.print(f" {status_icon} {cat.value}: {unclear}/{total} {status.value}") - else: # MISSING - console.print(f" {status_icon} {cat.value}: {status.value}") - console.print(f"\n[dim]Found {len(questions_to_ask)} question(s) to resolve[/dim]\n") - - # Handle --list-questions mode (must be before no-questions check) - if list_questions: - _handle_list_questions_mode(questions_to_ask, output_questions) - raise typer.Exit(0) - - if not questions_to_ask: - _handle_no_questions_case(questions_to_ask, report) - raise typer.Exit(0) - - # Parse answers if provided - answers_dict: dict[str, str] = {} - if answers: - answers_dict = _parse_answers_dict(answers) - - print_info(f"Found {len(questions_to_ask)} question(s) to resolve") - - # Ask questions interactively - questions_asked = _ask_questions_interactive( - plan_bundle, questions_to_ask, answers_dict, is_non_interactive, bundle_dir, project_bundle - ) - - # Get today's session for summary display - from datetime import date - - from specfact_cli.models.plan import ClarificationSession - - today = date.today().isoformat() - today_session: ClarificationSession | None = None - if plan_bundle.clarifications: - for session in plan_bundle.clarifications.sessions: - if session.date == today: - today_session = session - break - if today_session is None: - today_session = ClarificationSession(date=today, questions=[]) - - # Display final summary - _display_review_summary(plan_bundle, scanner, bundle, questions_asked, report, current_stage, today_session) - - record( - { - "questions_asked": questions_asked, - "findings_count": len(report.findings) if report.findings else 0, - "priority_score": report.priority_score, - } - ) - - except KeyboardInterrupt: - print_warning("Review interrupted by user") - raise typer.Exit(0) from None - except typer.Exit: - # Re-raise typer.Exit (used for --list-questions and other early exits) - raise - except Exception as e: - print_error(f"Failed to review plan: {e}") - raise typer.Exit(1) from e - - -def _convert_project_bundle_to_plan_bundle(project_bundle: ProjectBundle) -> PlanBundle: - """ - Convert ProjectBundle to PlanBundle for compatibility with existing extraction functions. - - Args: - project_bundle: ProjectBundle instance - - Returns: - PlanBundle instance - """ - return PlanBundle( - version="1.0", - idea=project_bundle.idea, - business=project_bundle.business, - product=project_bundle.product, - features=list(project_bundle.features.values()), - metadata=None, # ProjectBundle doesn't use Metadata, uses manifest instead - clarifications=project_bundle.clarifications, - ) - - -@beartype -def _convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: - """ - Convert PlanBundle to ProjectBundle (modular). - - Args: - plan_bundle: PlanBundle instance to convert - bundle_name: Project bundle name - - Returns: - ProjectBundle instance - """ - from specfact_cli.models.project import BundleManifest, BundleVersions - - # Create manifest - manifest = BundleManifest( - versions=BundleVersions(schema="1.0", project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - - # Convert features list to dict - features_dict: dict[str, Feature] = {f.key: f for f in plan_bundle.features} - - # Create and return ProjectBundle - return ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - idea=plan_bundle.idea, - business=plan_bundle.business, - product=plan_bundle.product, - features=features_dict, - clarifications=plan_bundle.clarifications, - ) - - -def _find_bundle_dir(bundle: str | None) -> Path | None: - """ - Find project bundle directory with improved validation and error messages. - - Args: - bundle: Bundle name or None - - Returns: - Bundle directory path or None if not found - """ - from specfact_cli.utils.structure import SpecFactStructure - - if bundle is None: - print_error("Bundle name is required. Use --bundle <name>") - print_info("Available bundles:") - projects_dir = Path(".") / SpecFactStructure.PROJECTS - if projects_dir.exists(): - bundles = [ - bundle_dir.name - for bundle_dir in projects_dir.iterdir() - if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists() - ] - if bundles: - for bundle_name in bundles: - print_info(f" - {bundle_name}") - else: - print_info(" (no bundles found)") - print_info("Create one with: specfact plan init <bundle-name>") - else: - print_info(" (projects directory not found)") - print_info("Create one with: specfact plan init <bundle-name>") - return None - - bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle '{bundle}' not found: {bundle_dir}") - print_info(f"Create one with: specfact plan init {bundle}") - - # Suggest similar bundle names if available - projects_dir = Path(".") / SpecFactStructure.PROJECTS - if projects_dir.exists(): - available_bundles = [ - bundle_dir.name - for bundle_dir in projects_dir.iterdir() - if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists() - ] - if available_bundles: - print_info("Available bundles:") - for available_bundle in available_bundles: - print_info(f" - {available_bundle}") - return None - - return bundle_dir - - -@app.command("harden") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda sdd_path: sdd_path is None or isinstance(sdd_path, Path), "SDD path must be None or Path") -def harden( - # Target/Input - bundle: str | None = typer.Argument( - None, - help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", - ), - sdd_path: Path | None = typer.Option( - None, - "--sdd", - help="Output SDD manifest path. Default: bundle-specific .specfact/projects/<bundle-name>/sdd.<format> (Phase 8.5)", - ), - # Output/Results - output_format: StructuredFormat | None = typer.Option( - None, - "--output-format", - help="SDD manifest format (yaml or json). Default: global --output-format (yaml)", - case_sensitive=False, - ), - # Behavior/Options - interactive: bool = typer.Option( - True, - "--interactive/--no-interactive", - help="Interactive mode with prompts. Default: True (interactive, auto-detect)", - ), -) -> None: - """ - Create or update SDD manifest (hard spec) from project bundle. - - Generates a canonical SDD bundle that captures WHY (intent, constraints), - WHAT (capabilities, acceptance), and HOW (high-level architecture, invariants, - contracts) with promotion status. - - **Important**: SDD manifests are linked to specific project bundles via hash. - Each project bundle has its own SDD manifest in `.specfact/projects/<bundle-name>/sdd.yaml` (Phase 8.5). - - **Parameter Groups:** - - **Target/Input**: bundle (optional argument, defaults to active plan), --sdd - - **Output/Results**: --output-format - - **Behavior/Options**: --interactive/--no-interactive - - **Examples:** - specfact plan harden # Uses active plan (set via 'plan select') - specfact plan harden legacy-api # Interactive - specfact plan harden auth-module --no-interactive # CI/CD mode - specfact plan harden legacy-api --output-format json - """ - from specfact_cli.models.sdd import ( - SDDCoverageThresholds, - SDDEnforcementBudget, - SDDManifest, - ) - from specfact_cli.utils.structured_io import dump_structured_file - - effective_format = output_format or runtime.get_output_format() - is_non_interactive = not interactive - - from rich.console import Console - - from specfact_cli.utils.structure import SpecFactStructure - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle is None: - console.print("[bold red]✗[/bold red] Bundle name required") - console.print( - "[yellow]→[/yellow] Specify bundle name as argument or run 'specfact plan select' to set active plan" - ) - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - telemetry_metadata = { - "interactive": interactive, - "output_format": effective_format.value, - } - - with telemetry.track_command("plan.harden", telemetry_metadata) as record: - print_section("SpecFact CLI - SDD Manifest Creation") - - # Find bundle directory - bundle_dir = _find_bundle_dir(bundle) - if bundle_dir is None: - raise typer.Exit(1) - - try: - # Load project bundle with progress indicator - project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Compute project bundle hash - summary = project_bundle.compute_summary(include_hash=True) - project_hash = summary.content_hash - if not project_hash: - print_error("Failed to compute project bundle hash") - raise typer.Exit(1) - - # Determine SDD output path (bundle-specific: .specfact/projects/<bundle-name>/sdd.yaml, Phase 8.5) - from specfact_cli.utils.sdd_discovery import get_default_sdd_path_for_bundle - - if sdd_path is None: - base_path = Path(".") - sdd_path = get_default_sdd_path_for_bundle(bundle, base_path, effective_format.value) - sdd_path.parent.mkdir(parents=True, exist_ok=True) - else: - # Ensure correct extension - if effective_format == StructuredFormat.YAML: - sdd_path = sdd_path.with_suffix(".yaml") - else: - sdd_path = sdd_path.with_suffix(".json") - - # Check if SDD already exists and reuse it if hash matches - existing_sdd: SDDManifest | None = None - # Convert to PlanBundle for extraction functions (temporary compatibility) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - if sdd_path.exists(): - try: - from specfact_cli.utils.structured_io import load_structured_file - - existing_sdd_data = load_structured_file(sdd_path) - existing_sdd = SDDManifest.model_validate(existing_sdd_data) - if existing_sdd.plan_bundle_hash == project_hash: - # Hash matches - reuse existing SDD sections - print_info("SDD manifest exists with matching hash - reusing existing sections") - why = existing_sdd.why - what = existing_sdd.what - how = existing_sdd.how - else: - # Hash mismatch - warn and extract new, but reuse existing SDD as fallback - print_warning( - f"SDD manifest exists but is linked to a different bundle version.\n" - f" Existing bundle hash: {existing_sdd.plan_bundle_hash[:16]}...\n" - f" New bundle hash: {project_hash[:16]}...\n" - f" This will overwrite the existing SDD manifest.\n" - f" Note: SDD manifests are linked to specific bundle versions." - ) - if not is_non_interactive: - # In interactive mode, ask for confirmation - from rich.prompt import Confirm - - if not Confirm.ask("Overwrite existing SDD manifest?", default=False): - print_info("SDD manifest creation cancelled.") - raise typer.Exit(0) - # Extract from bundle, using existing SDD as fallback - why = _extract_sdd_why(plan_bundle, is_non_interactive, existing_sdd.why) - what = _extract_sdd_what(plan_bundle, is_non_interactive, existing_sdd.what) - how = _extract_sdd_how( - plan_bundle, is_non_interactive, existing_sdd.how, project_bundle, bundle_dir - ) - except Exception: - # If we can't read/validate existing SDD, just proceed (might be corrupted) - existing_sdd = None - # Extract from bundle without fallback - why = _extract_sdd_why(plan_bundle, is_non_interactive, None) - what = _extract_sdd_what(plan_bundle, is_non_interactive, None) - how = _extract_sdd_how(plan_bundle, is_non_interactive, None, project_bundle, bundle_dir) - else: - # No existing SDD found, extract from bundle - why = _extract_sdd_why(plan_bundle, is_non_interactive, None) - what = _extract_sdd_what(plan_bundle, is_non_interactive, None) - how = _extract_sdd_how(plan_bundle, is_non_interactive, None, project_bundle, bundle_dir) - - # Type assertion: these variables are always set in valid code paths - # (typer.Exit exits the function, so those paths don't need these variables) - assert why is not None and what is not None and how is not None # type: ignore[unreachable] - - # Create SDD manifest - plan_bundle_id = project_hash[:16] # Use first 16 chars as ID - sdd_manifest = SDDManifest( - version="1.0.0", - plan_bundle_id=plan_bundle_id, - plan_bundle_hash=project_hash, - why=why, - what=what, - how=how, - coverage_thresholds=SDDCoverageThresholds( - contracts_per_story=1.0, - invariants_per_feature=1.0, - architecture_facets=3, - openapi_coverage_percent=80.0, - ), - enforcement_budget=SDDEnforcementBudget( - shadow_budget_seconds=300, - warn_budget_seconds=180, - block_budget_seconds=90, - ), - promotion_status="draft", # TODO: Add promotion status to ProjectBundle manifest - provenance={ - "source": "plan_harden", - "bundle_name": bundle, - "bundle_path": str(bundle_dir), - "created_by": "specfact_cli", - }, - ) - - # Save SDD manifest - sdd_path.parent.mkdir(parents=True, exist_ok=True) - sdd_data = sdd_manifest.model_dump(exclude_none=True) - dump_structured_file(sdd_data, sdd_path, effective_format) - - print_success(f"SDD manifest created: {sdd_path}") - - # Display summary - console.print("\n[bold]SDD Manifest Summary:[/bold]") - console.print(f"[bold]Project Bundle:[/bold] {bundle_dir}") - console.print(f"[bold]Bundle Hash:[/bold] {project_hash[:16]}...") - console.print(f"[bold]SDD Path:[/bold] {sdd_path}") - console.print("\n[bold]WHY (Intent):[/bold]") - console.print(f" {why.intent}") - if why.constraints: - console.print(f"[bold]Constraints:[/bold] {len(why.constraints)}") - console.print(f"\n[bold]WHAT (Capabilities):[/bold] {len(what.capabilities)}") - console.print("\n[bold]HOW (Architecture):[/bold]") - if how.architecture: - console.print(f" {how.architecture[:100]}...") - console.print(f"[bold]Invariants:[/bold] {len(how.invariants)}") - console.print(f"[bold]Contracts:[/bold] {len(how.contracts)}") - console.print(f"[bold]OpenAPI Contracts:[/bold] {len(how.openapi_contracts)}") - - record( - { - "bundle_name": bundle, - "bundle_path": str(bundle_dir), - "sdd_path": str(sdd_path), - "capabilities_count": len(what.capabilities), - "invariants_count": len(how.invariants), - } - ) - - except KeyboardInterrupt: - print_warning("SDD creation interrupted by user") - raise typer.Exit(0) from None - except Exception as e: - print_error(f"Failed to create SDD manifest: {e}") - raise typer.Exit(1) from e - - -@beartype -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") -def _extract_sdd_why(bundle: PlanBundle, is_non_interactive: bool, fallback: SDDWhy | None = None) -> SDDWhy: - """ - Extract WHY section from plan bundle. - - Args: - bundle: Plan bundle to extract from - is_non_interactive: Whether in non-interactive mode - - Returns: - SDDWhy instance - """ - from specfact_cli.models.sdd import SDDWhy - - intent = "" - constraints: list[str] = [] - target_users: str | None = None - value_hypothesis: str | None = None - - if bundle.idea: - intent = bundle.idea.narrative or bundle.idea.title or "" - constraints = bundle.idea.constraints or [] - if bundle.idea.target_users: - target_users = ", ".join(bundle.idea.target_users) - value_hypothesis = bundle.idea.value_hypothesis or None - - # Use fallback from existing SDD if available - if fallback: - if not intent: - intent = fallback.intent or "" - if not constraints: - constraints = fallback.constraints or [] - if not target_users: - target_users = fallback.target_users - if not value_hypothesis: - value_hypothesis = fallback.value_hypothesis - - # If intent is empty, prompt or use default - if not intent and not is_non_interactive: - intent = prompt_text("Primary intent/goal (WHY):", required=True) - elif not intent: - intent = "Extracted from plan bundle" - - return SDDWhy( - intent=intent, - constraints=constraints, - target_users=target_users, - value_hypothesis=value_hypothesis, - ) - - -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") -def _extract_sdd_what(bundle: PlanBundle, is_non_interactive: bool, fallback: SDDWhat | None = None) -> SDDWhat: - """ - Extract WHAT section from plan bundle. - - Args: - bundle: Plan bundle to extract from - is_non_interactive: Whether in non-interactive mode - - Returns: - SDDWhat instance - """ - from specfact_cli.models.sdd import SDDWhat - - capabilities: list[str] = [] - acceptance_criteria: list[str] = [] - out_of_scope: list[str] = [] - - # Extract capabilities from features - for feature in bundle.features: - if feature.title: - capabilities.append(feature.title) - # Collect acceptance criteria - acceptance_criteria.extend(feature.acceptance or []) - # Collect constraints that might indicate out-of-scope - for constraint in feature.constraints or []: - if "out of scope" in constraint.lower() or "not included" in constraint.lower(): - out_of_scope.append(constraint) - - # Use fallback from existing SDD if available - if fallback: - if not capabilities: - capabilities = fallback.capabilities or [] - if not acceptance_criteria: - acceptance_criteria = fallback.acceptance_criteria or [] - if not out_of_scope: - out_of_scope = fallback.out_of_scope or [] - - # If no capabilities, use default - if not capabilities: - if not is_non_interactive: - capabilities_input = prompt_text("Core capabilities (comma-separated):", required=True) - capabilities = [c.strip() for c in capabilities_input.split(",")] - else: - capabilities = ["Extracted from plan bundle"] - - return SDDWhat( - capabilities=capabilities, - acceptance_criteria=acceptance_criteria, - out_of_scope=out_of_scope, - ) - - -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") -def _extract_sdd_how( - bundle: PlanBundle, - is_non_interactive: bool, - fallback: SDDHow | None = None, - project_bundle: ProjectBundle | None = None, - bundle_dir: Path | None = None, -) -> SDDHow: - """ - Extract HOW section from plan bundle. - - Args: - bundle: Plan bundle to extract from - is_non_interactive: Whether in non-interactive mode - fallback: Optional fallback SDDHow to reuse values from - project_bundle: Optional ProjectBundle to extract OpenAPI contract references - bundle_dir: Optional bundle directory path for contract file validation - - Returns: - SDDHow instance - """ - from specfact_cli.models.contract import count_endpoints, load_openapi_contract, validate_openapi_schema - from specfact_cli.models.sdd import OpenAPIContractReference, SDDHow - - architecture: str | None = None - invariants: list[str] = [] - contracts: list[str] = [] - module_boundaries: list[str] = [] - - # Extract architecture from constraints - architecture_parts: list[str] = [] - for feature in bundle.features: - for constraint in feature.constraints or []: - if any(keyword in constraint.lower() for keyword in ["architecture", "design", "structure", "component"]): - architecture_parts.append(constraint) - - if architecture_parts: - architecture = " ".join(architecture_parts[:3]) # Limit to first 3 - - # Extract invariants from stories (acceptance criteria that are invariants) - for feature in bundle.features: - for story in feature.stories: - for acceptance in story.acceptance or []: - if any(keyword in acceptance.lower() for keyword in ["always", "never", "must", "invariant"]): - invariants.append(acceptance) - - # Extract contracts from story contracts - for feature in bundle.features: - for story in feature.stories: - if story.contracts: - contracts.append(f"{story.key}: {str(story.contracts)[:100]}") - - # Extract module boundaries from feature keys (as a simple heuristic) - module_boundaries = [f.key for f in bundle.features[:10]] # Limit to first 10 - - # Extract OpenAPI contract references from project bundle if available - openapi_contracts: list[OpenAPIContractReference] = [] - if project_bundle and bundle_dir: - for feature_index in project_bundle.manifest.features: - if feature_index.contract: - contract_path = bundle_dir / feature_index.contract - if contract_path.exists(): - try: - contract_data = load_openapi_contract(contract_path) - if validate_openapi_schema(contract_data): - endpoints_count = count_endpoints(contract_data) - openapi_contracts.append( - OpenAPIContractReference( - feature_key=feature_index.key, - contract_file=feature_index.contract, - endpoints_count=endpoints_count, - status="validated", - ) - ) - else: - # Contract exists but is invalid - openapi_contracts.append( - OpenAPIContractReference( - feature_key=feature_index.key, - contract_file=feature_index.contract, - endpoints_count=0, - status="draft", - ) - ) - except Exception: - # Contract file exists but couldn't be loaded - openapi_contracts.append( - OpenAPIContractReference( - feature_key=feature_index.key, - contract_file=feature_index.contract, - endpoints_count=0, - status="draft", - ) - ) - - # Use fallback from existing SDD if available - if fallback: - if not architecture: - architecture = fallback.architecture - if not invariants: - invariants = fallback.invariants or [] - if not contracts: - contracts = fallback.contracts or [] - if not module_boundaries: - module_boundaries = fallback.module_boundaries or [] - if not openapi_contracts: - openapi_contracts = fallback.openapi_contracts or [] - - # If no architecture, prompt or use default - if not architecture and not is_non_interactive: - # If we have a fallback, use it as default value in prompt - default_arch = fallback.architecture if fallback else None - if default_arch: - architecture = ( - prompt_text( - f"High-level architecture description (optional, current: {default_arch[:50]}...):", - required=False, - ) - or default_arch - ) - else: - architecture = prompt_text("High-level architecture description (optional):", required=False) or None - elif not architecture: - architecture = "Extracted from plan bundle constraints" - - return SDDHow( - architecture=architecture, - invariants=invariants[:10], # Limit to first 10 - contracts=contracts[:10], # Limit to first 10 - openapi_contracts=openapi_contracts, - module_boundaries=module_boundaries, - ) - - -@beartype -@require(lambda answer: isinstance(answer, str), "Answer must be string") -@ensure(lambda result: isinstance(result, list), "Must return list of criteria strings") -def _extract_specific_criteria_from_answer(answer: str) -> list[str]: - """ - Extract specific testable criteria from answer that contains replacement instructions. - - When answer contains "Replace generic 'works correctly' with testable criteria:", - extracts the specific criteria (items in single quotes) and returns them as a list. - - Args: - answer: Answer text that may contain replacement instructions - - Returns: - List of specific criteria strings, or empty list if no extraction possible - """ - import re - - # Check if answer contains replacement instructions - if "testable criteria:" not in answer.lower() and "replace generic" not in answer.lower(): - # Answer doesn't contain replacement format, return as single item - return [answer] if answer.strip() else [] - - # Find the position after "testable criteria:" to only extract criteria from that point - # This avoids extracting "works correctly" from the instruction text itself - testable_criteria_marker = "testable criteria:" - marker_pos = answer.lower().find(testable_criteria_marker) - - if marker_pos == -1: - # Fallback: try "with testable criteria:" - marker_pos = answer.lower().find("with testable criteria:") - if marker_pos != -1: - marker_pos += len("with testable criteria:") - - if marker_pos != -1: - # Only search for criteria after the marker - criteria_section = answer[marker_pos + len(testable_criteria_marker) :] - # Extract criteria (items in single quotes) - criteria_pattern = r"'([^']+)'" - matches = re.findall(criteria_pattern, criteria_section) - - if matches: - # Filter out "works correctly" if it appears (it's part of instruction, not a criterion) - filtered = [ - criterion.strip() - for criterion in matches - if criterion.strip() and criterion.strip().lower() not in ("works correctly", "works as expected") - ] - if filtered: - return filtered - - # Fallback: if no quoted criteria found, return original answer - return [answer] if answer.strip() else [] - - -@beartype -@require(lambda acceptance_list: isinstance(acceptance_list, list), "Acceptance list must be list") -@require(lambda finding: finding is not None, "Finding must not be None") -@ensure(lambda result: isinstance(result, list), "Must return list of acceptance strings") -def _identify_vague_criteria_to_remove( - acceptance_list: list[str], - finding: Any, # AmbiguityFinding -) -> list[str]: - """ - Identify vague acceptance criteria that should be removed when replacing with specific criteria. - - Args: - acceptance_list: Current list of acceptance criteria - finding: Ambiguity finding that triggered the question - - Returns: - List of vague criteria strings to remove - """ - from specfact_cli.utils.acceptance_criteria import ( - is_code_specific_criteria, - is_simplified_format_criteria, - ) - - vague_to_remove: list[str] = [] - - # Patterns that indicate vague criteria (from ambiguity scanner) - vague_patterns = [ - "is implemented", - "is functional", - "works", - "is done", - "is complete", - "is ready", - ] - - for acc in acceptance_list: - acc_lower = acc.lower() - - # Skip code-specific criteria (should not be removed) - if is_code_specific_criteria(acc): - continue - - # Skip simplified format criteria (valid format) - if is_simplified_format_criteria(acc): - continue - - # ALWAYS remove replacement instruction text (from previous answers) - # These are meta-instructions, not actual acceptance criteria - contains_replacement_instruction = ( - "replace generic" in acc_lower - or ("should be more specific" in acc_lower and "testable criteria:" in acc_lower) - or ("yes, these should be more specific" in acc_lower) - ) - - if contains_replacement_instruction: - vague_to_remove.append(acc) - continue - - # Check for vague patterns (but be more selective) - # Only flag as vague if it contains "works correctly" without "see contract examples" - # or other vague patterns in a standalone context - is_vague = False - if "works correctly" in acc_lower: - # Only remove if it doesn't have "see contract examples" (simplified format is valid) - if "see contract" not in acc_lower and "contract examples" not in acc_lower: - is_vague = True - else: - # Check other vague patterns - is_vague = any( - pattern in acc_lower and len(acc.split()) < 10 # Only flag short, vague statements - for pattern in vague_patterns - ) - - if is_vague: - vague_to_remove.append(acc) - - return vague_to_remove - - -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda answer: isinstance(answer, str) and bool(answer.strip()), "Answer must be non-empty string") -@ensure(lambda result: isinstance(result, list), "Must return list of integration points") -def _integrate_clarification( - bundle: PlanBundle, - finding: AmbiguityFinding, - answer: str, -) -> list[str]: - """ - Integrate clarification answer into plan bundle. - - Args: - bundle: Plan bundle to update - finding: Ambiguity finding with related sections - answer: User-provided answer - - Returns: - List of integration points (section paths) - """ - from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory - - integration_points: list[str] = [] - - category = finding.category - - # Functional Scope → idea.narrative, idea.target_users, features[].outcomes - if category == TaxonomyCategory.FUNCTIONAL_SCOPE: - related_sections = finding.related_sections or [] - if ( - "idea.narrative" in related_sections - and bundle.idea - and (not bundle.idea.narrative or len(bundle.idea.narrative) < 20) - ): - bundle.idea.narrative = answer - integration_points.append("idea.narrative") - elif "idea.target_users" in related_sections and bundle.idea: - if bundle.idea.target_users is None: - bundle.idea.target_users = [] - if answer not in bundle.idea.target_users: - bundle.idea.target_users.append(answer) - integration_points.append("idea.target_users") - else: - # Try to find feature by related section - for section in related_sections: - if section.startswith("features.") and ".outcomes" in section: - feature_key = section.split(".")[1] - for feature in bundle.features: - if feature.key == feature_key: - if answer not in feature.outcomes: - feature.outcomes.append(answer) - integration_points.append(section) - break - - # Data Model, Integration, Constraints → features[].constraints - elif category in ( - TaxonomyCategory.DATA_MODEL, - TaxonomyCategory.INTEGRATION, - TaxonomyCategory.CONSTRAINTS, - ): - related_sections = finding.related_sections or [] - for section in related_sections: - if section.startswith("features.") and ".constraints" in section: - feature_key = section.split(".")[1] - for feature in bundle.features: - if feature.key == feature_key: - if answer not in feature.constraints: - feature.constraints.append(answer) - integration_points.append(section) - break - elif section == "idea.constraints" and bundle.idea: - if bundle.idea.constraints is None: - bundle.idea.constraints = [] - if answer not in bundle.idea.constraints: - bundle.idea.constraints.append(answer) - integration_points.append(section) - - # Edge Cases, Completion Signals, Interaction & UX Flow → features[].acceptance, stories[].acceptance - elif category in ( - TaxonomyCategory.EDGE_CASES, - TaxonomyCategory.COMPLETION_SIGNALS, - TaxonomyCategory.INTERACTION_UX, - ): - related_sections = finding.related_sections or [] - for section in related_sections: - if section.startswith("features."): - parts = section.split(".") - if len(parts) >= 3: - feature_key = parts[1] - if parts[2] == "acceptance": - for feature in bundle.features: - if feature.key == feature_key: - # Extract specific criteria from answer - specific_criteria = _extract_specific_criteria_from_answer(answer) - # Identify and remove vague criteria - vague_to_remove = _identify_vague_criteria_to_remove(feature.acceptance, finding) - # Remove vague criteria - for vague in vague_to_remove: - if vague in feature.acceptance: - feature.acceptance.remove(vague) - # Add new specific criteria - for criterion in specific_criteria: - if criterion not in feature.acceptance: - feature.acceptance.append(criterion) - if specific_criteria: - integration_points.append(section) - break - elif parts[2] == "stories" and len(parts) >= 5: - story_key = parts[3] - if parts[4] == "acceptance": - for feature in bundle.features: - if feature.key == feature_key: - for story in feature.stories: - if story.key == story_key: - # Extract specific criteria from answer - specific_criteria = _extract_specific_criteria_from_answer(answer) - # Identify and remove vague criteria - vague_to_remove = _identify_vague_criteria_to_remove( - story.acceptance, finding - ) - # Remove vague criteria - for vague in vague_to_remove: - if vague in story.acceptance: - story.acceptance.remove(vague) - # Add new specific criteria - for criterion in specific_criteria: - if criterion not in story.acceptance: - story.acceptance.append(criterion) - if specific_criteria: - integration_points.append(section) - break - break - - # Feature Completeness → features[].stories, features[].acceptance - elif category == TaxonomyCategory.FEATURE_COMPLETENESS: - related_sections = finding.related_sections or [] - for section in related_sections: - if section.startswith("features."): - parts = section.split(".") - if len(parts) >= 3: - feature_key = parts[1] - if parts[2] == "stories": - # This would require creating a new story - skip for now - # (stories should be added via add-story command) - pass - elif parts[2] == "acceptance": - for feature in bundle.features: - if feature.key == feature_key: - if answer not in feature.acceptance: - feature.acceptance.append(answer) - integration_points.append(section) - break - - # Non-Functional → idea.constraints (with quantification) - elif ( - category == TaxonomyCategory.NON_FUNCTIONAL - and finding.related_sections - and "idea.constraints" in finding.related_sections - and bundle.idea - ): - if bundle.idea.constraints is None: - bundle.idea.constraints = [] - if answer not in bundle.idea.constraints: - # Try to quantify vague terms - quantified_answer = answer - bundle.idea.constraints.append(quantified_answer) - integration_points.append("idea.constraints") - - return integration_points - - -@beartype -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda finding: finding is not None, "Finding must not be None") -def _show_current_settings_for_finding( - bundle: PlanBundle, - finding: Any, # AmbiguityFinding (imported locally to avoid circular dependency) - console_instance: Any | None = None, # Console (imported locally, optional) -) -> str | None: - """ - Show current settings for related sections before asking a question. - - Displays current values for target_users, constraints, outcomes, acceptance criteria, - and narrative so users can confirm or modify them. - - Args: - bundle: Plan bundle to inspect - finding: Ambiguity finding with related sections - console_instance: Rich console instance (defaults to module console) - - Returns: - Default value string to use in prompt (or None if no current value) - """ - from rich.console import Console - - console = console_instance or Console() - - related_sections = finding.related_sections or [] - if not related_sections: - return None - - # Only show high-level plan attributes (idea-level), not individual features/stories - # Only show where there are findings to fix - current_values: dict[str, list[str] | str] = {} - default_value: str | None = None - - for section in related_sections: - # Only handle idea-level sections (high-level plan attributes) - if section == "idea.narrative" and bundle.idea and bundle.idea.narrative: - narrative_preview = ( - bundle.idea.narrative[:100] + "..." if len(bundle.idea.narrative) > 100 else bundle.idea.narrative - ) - current_values["Idea Narrative"] = narrative_preview - # Use full narrative as default (truncated for display only) - default_value = bundle.idea.narrative - - elif section == "idea.target_users" and bundle.idea and bundle.idea.target_users: - current_values["Target Users"] = bundle.idea.target_users - # Use comma-separated list as default - if not default_value: - default_value = ", ".join(bundle.idea.target_users) - - elif section == "idea.constraints" and bundle.idea and bundle.idea.constraints: - current_values["Idea Constraints"] = bundle.idea.constraints - # Use comma-separated list as default - if not default_value: - default_value = ", ".join(bundle.idea.constraints) - - # For Completion Signals questions, also extract story acceptance criteria - # (these are the specific values we're asking about) - elif section.startswith("features.") and ".stories." in section and ".acceptance" in section: - parts = section.split(".") - if len(parts) >= 5: - feature_key = parts[1] - story_key = parts[3] - feature = next((f for f in bundle.features if f.key == feature_key), None) - if feature: - story = next((s for s in feature.stories if s.key == story_key), None) - if story and story.acceptance: - # Show current acceptance criteria as default (for confirming or modifying) - acceptance_str = ", ".join(story.acceptance) - current_values[f"Story {story_key} Acceptance"] = story.acceptance - # Use first acceptance criteria as default (or all if short) - if not default_value: - default_value = acceptance_str if len(acceptance_str) <= 200 else story.acceptance[0] - - # Skip other feature/story-level sections - only show high-level plan attributes - # Other features and stories are handled through their specific questions - - # Display current values if any (only high-level attributes) - if current_values: - console.print("\n[dim]Current Plan Settings:[/dim]") - for key, value in current_values.items(): - if isinstance(value, list): - value_str = ", ".join(str(v) for v in value) if value else "(none)" - else: - value_str = str(value) - console.print(f" [cyan]{key}:[/cyan] {value_str}") - console.print("[dim]Press Enter to confirm current value, or type a new value[/dim]") - - return default_value - - -@beartype -@require(lambda finding: finding is not None, "Finding must not be None") -@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") -@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") -@ensure(lambda result: isinstance(result, str) and bool(result.strip()), "Must return non-empty string") -def _get_smart_answer( - finding: Any, # AmbiguityFinding (imported locally) - bundle: PlanBundle, - is_non_interactive: bool, - default_value: str | None = None, -) -> str: - """ - Get answer from user with smart Yes/No handling. - - For Completion Signals questions asking "Should these be more specific?", - if user answers "Yes", prompts for the actual specific criteria. - If "No", marks as acceptable and returns appropriate response. - - Args: - finding: Ambiguity finding with question - bundle: Plan bundle (for context) - is_non_interactive: Whether in non-interactive mode - default_value: Default value to show in prompt (for confirming existing value) - - Returns: - User answer (processed if Yes/No detected) - """ - from rich.console import Console - - from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory - - console = Console() - - # Build prompt message with default hint - if default_value: - # Truncate default for display if too long - default_display = default_value[:60] + "..." if len(default_value) > 60 else default_value - prompt_msg = f"Your answer (press Enter to confirm, or type new value/Yes/No): [{default_display}]" - else: - prompt_msg = "Your answer (<=5 words recommended, or Yes/No):" - - # Get initial answer (not required if default exists - user can press Enter) - # When default exists, allow empty answer (Enter) to confirm - answer = prompt_text(prompt_msg, default=default_value, required=not default_value) - - # If user pressed Enter with default, return the default value (confirm existing) - if not answer.strip() and default_value: - return default_value - - # Normalize Yes/No answers - answer_lower = answer.strip().lower() - is_yes = answer_lower in ("yes", "y", "true", "1") - is_no = answer_lower in ("no", "n", "false", "0") - - # Handle Completion Signals questions about specificity - if ( - finding.category == TaxonomyCategory.COMPLETION_SIGNALS - and "should these be more specific" in finding.question.lower() - ): - if is_yes: - # User wants to make it more specific - prompt for actual criteria - console.print("\n[yellow]Please provide the specific acceptance criteria:[/yellow]") - return prompt_text("Specific criteria:", required=True) - if is_no: - # User says no - mark as acceptable, return a note that it's acceptable as-is - return "Acceptable as-is (details in OpenAPI contracts)" - # Otherwise, return the original answer (might be a specific criteria already) - return answer - - # Handle other Yes/No questions intelligently - # For questions asking if something should be done/added - if (is_yes or is_no) and ("should" in finding.question.lower() or "need" in finding.question.lower()): - if is_yes: - # Prompt for what should be added - console.print("\n[yellow]What should be added?[/yellow]") - return prompt_text("Details:", required=True) - if is_no: - return "Not needed" - - # Return original answer if not a Yes/No or if Yes/No handling didn't apply - return answer +__all__ = ["app"] diff --git a/src/specfact_cli/commands/project_cmd.py b/src/specfact_cli/commands/project_cmd.py index 00b1c3be..92eed718 100644 --- a/src/specfact_cli/commands/project_cmd.py +++ b/src/specfact_cli/commands/project_cmd.py @@ -1,1839 +1,6 @@ -""" -Project command - Persona workflows and bundle management. +"""Backward-compatible app shim. Implementation moved to modules/project/.""" -This module provides commands for persona-based editing, lock enforcement, -and merge conflict resolution for project bundles. -""" +from specfact_cli.modules.project.src.commands import app -from __future__ import annotations -import fnmatch -import os -from contextlib import suppress -from datetime import UTC, datetime -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.table import Table - -from specfact_cli.models.project import ( - BundleManifest, - PersonaMapping, - ProjectBundle, - ProjectMetadata, - SectionLock, -) -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.utils import print_error, print_info, print_section, print_success, print_warning -from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress -from specfact_cli.utils.structure import SpecFactStructure -from specfact_cli.versioning import ChangeAnalyzer, bump_version, validate_semver - - -app = typer.Typer(help="Manage project bundles with persona workflows") -version_app = typer.Typer(help="Manage project bundle versions") -app.add_typer(version_app, name="version") -console = Console() - - -# Use shared progress utilities for consistency (aliased to maintain existing function names) -def _load_bundle_with_progress(bundle_dir: Path, validate_hashes: bool = False) -> ProjectBundle: - """Load project bundle with unified progress display.""" - return load_bundle_with_progress(bundle_dir, validate_hashes=validate_hashes, console_instance=console) - - -def _save_bundle_with_progress(bundle: ProjectBundle, bundle_dir: Path, atomic: bool = True) -> None: - """Save project bundle with unified progress display.""" - save_bundle_with_progress(bundle, bundle_dir, atomic=atomic, console_instance=console) - - -# Default persona mappings -DEFAULT_PERSONAS: dict[str, PersonaMapping] = { - "product-owner": PersonaMapping( - owns=["idea", "business", "features.*.stories", "features.*.outcomes"], - exports_to="specs/*/spec.md", - ), - "architect": PersonaMapping( - owns=["features.*.constraints", "protocols", "contracts"], - exports_to="specs/*/plan.md", - ), - "developer": PersonaMapping( - owns=["features.*.acceptance", "features.*.implementation"], - exports_to="specs/*/tasks.md", - ), -} - -# Version bump severity ordering (for recommendations) -BUMP_SEVERITY = {"none": 0, "patch": 1, "minor": 2, "major": 3} - - -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return (bundle_name, bundle_dir)") -def _resolve_bundle(repo: Path, bundle: str | None) -> tuple[str, Path]: - """ - Resolve bundle name and directory, falling back to active bundle. - - Args: - repo: Repository path - bundle: Optional bundle name - - Returns: - Tuple of (bundle_name, bundle_dir) - """ - bundle_name = bundle or SpecFactStructure.get_active_bundle_name(repo) - if bundle_name is None: - print_error("Bundle not specified and no active bundle found. Use --bundle or set active bundle in config.") - raise typer.Exit(1) - - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle_name) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - return bundle_name, bundle_dir - - -@beartype -@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") -@require(lambda persona: isinstance(persona, str), "Persona must be str") -@require(lambda no_interactive: isinstance(no_interactive, bool), "No interactive must be bool") -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def _initialize_persona_if_needed(bundle: ProjectBundle, persona: str, no_interactive: bool) -> bool: - """ - Initialize persona in bundle manifest if missing and available in defaults. - - Args: - bundle: Project bundle to update - persona: Persona name to initialize - no_interactive: If True, auto-initialize without prompting - - Returns: - True if persona was initialized, False otherwise - """ - # Check if persona already exists - if persona in bundle.manifest.personas: - return False - - # Check if persona is in default personas - if persona not in DEFAULT_PERSONAS: - return False - - # Initialize persona - if no_interactive: - # Auto-initialize in non-interactive mode - bundle.manifest.personas[persona] = DEFAULT_PERSONAS[persona] - print_success(f"Initialized persona '{persona}' in bundle manifest") - return True - # Interactive mode: ask user - from rich.prompt import Confirm - - print_info(f"Persona '{persona}' not found in bundle manifest.") - print_info(f"Would you like to initialize '{persona}' with default settings?") - if Confirm.ask("Initialize persona?", default=True): - bundle.manifest.personas[persona] = DEFAULT_PERSONAS[persona] - print_success(f"Initialized persona '{persona}' in bundle manifest") - return True - - return False - - -@beartype -@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") -@require(lambda no_interactive: isinstance(no_interactive, bool), "No interactive must be bool") -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def _initialize_all_default_personas(bundle: ProjectBundle, no_interactive: bool) -> bool: - """ - Initialize all default personas in bundle manifest if missing. - - Args: - bundle: Project bundle to update - no_interactive: If True, auto-initialize without prompting - - Returns: - True if any personas were initialized, False otherwise - """ - # Find missing default personas - missing_personas = {k: v for k, v in DEFAULT_PERSONAS.items() if k not in bundle.manifest.personas} - - if not missing_personas: - return False - - if no_interactive: - # Auto-initialize all missing personas - bundle.manifest.personas.update(missing_personas) - print_success(f"Initialized {len(missing_personas)} default persona(s) in bundle manifest") - return True - # Interactive mode: ask user - from rich.prompt import Confirm - - console.print() # Empty line - print_info(f"Found {len(missing_personas)} default persona(s) not in bundle:") - for p_name in missing_personas: - print_info(f" - {p_name}") - console.print() # Empty line - if Confirm.ask("Initialize all default personas?", default=True): - bundle.manifest.personas.update(missing_personas) - print_success(f"Initialized {len(missing_personas)} default persona(s) in bundle manifest") - return True - - return False - - -@beartype -@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") -@require(lambda bundle_name: isinstance(bundle_name, str), "Bundle name must be str") -@ensure(lambda result: result is None, "Must return None") -def _list_available_personas(bundle: ProjectBundle, bundle_name: str) -> None: - """ - List all available personas (both in bundle and default personas). - - Args: - bundle: Project bundle to check - bundle_name: Name of the bundle (for display) - """ - console.print(f"\n[bold cyan]Available Personas for bundle '{bundle_name}'[/bold cyan]") - console.print("=" * 60) - - # Show personas in bundle - available_personas = list(bundle.manifest.personas.keys()) - if available_personas: - console.print("\n[bold green]Personas in bundle:[/bold green]") - for p in available_personas: - persona_mapping = bundle.manifest.personas[p] - owns_preview = ", ".join(persona_mapping.owns[:3]) - if len(persona_mapping.owns) > 3: - owns_preview += "..." - console.print(f" [green]✓[/green] {p}: owns {owns_preview}") - else: - console.print("\n[yellow]No personas defined in bundle manifest.[/yellow]") - - # Show default personas - console.print("\n[bold cyan]Default personas available:[/bold cyan]") - for p_name, p_mapping in DEFAULT_PERSONAS.items(): - status = "[green]✓[/green]" if p_name in bundle.manifest.personas else "[dim]○[/dim]" - owns_preview = ", ".join(p_mapping.owns[:3]) - if len(p_mapping.owns) > 3: - owns_preview += "..." - console.print(f" {status} {p_name}: owns {owns_preview}") - - console.print("\n[dim]To add personas, use:[/dim]") - console.print("[dim] specfact project init-personas --bundle <name>[/dim]") - console.print("[dim] specfact project init-personas --bundle <name> --persona <name>[/dim]") - console.print() - - -@beartype -@require(lambda section_pattern: isinstance(section_pattern, str), "Section pattern must be str") -@require(lambda path: isinstance(path, str), "Path must be str") -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def match_section_pattern(section_pattern: str, path: str) -> bool: - """ - Check if a path matches a section pattern. - - Args: - section_pattern: Pattern (e.g., "idea", "features.*.stories", "contracts") - path: Path to check (e.g., "idea", "features/FEATURE-001/stories/STORY-001") - - Returns: - True if path matches pattern, False otherwise - - Examples: - >>> match_section_pattern("idea", "idea") - True - >>> match_section_pattern("features.*.stories", "features/FEATURE-001/stories/STORY-001") - True - >>> match_section_pattern("contracts", "contracts/FEATURE-001.openapi.yaml") - True - """ - # Normalize patterns: replace * with fnmatch pattern - pattern = section_pattern.replace(".*", "/*") - return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path, section_pattern) - - -@beartype -@require(lambda persona: isinstance(persona, str), "Persona must be str") -@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") -@require(lambda section_path: isinstance(section_path, str), "Section path must be str") -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def check_persona_ownership(persona: str, manifest: BundleManifest, section_path: str) -> bool: - """ - Check if persona owns a section. - - Args: - persona: Persona name (e.g., "product-owner", "architect") - manifest: Bundle manifest with persona mappings - section_path: Section path to check (e.g., "idea", "features/FEATURE-001/stories") - - Returns: - True if persona owns section, False otherwise - """ - if persona not in manifest.personas: - return False - - persona_mapping = manifest.personas[persona] - return any(match_section_pattern(pattern, section_path) for pattern in persona_mapping.owns) - - -@beartype -@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") -@require(lambda section_path: isinstance(section_path, str), "Section path must be str") -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def check_section_locked(manifest: BundleManifest, section_path: str) -> bool: - """ - Check if a section is locked. - - Args: - manifest: Bundle manifest with locks - section_path: Section path to check - - Returns: - True if section is locked, False otherwise - """ - return any(match_section_pattern(lock.section, section_path) for lock in manifest.locks) - - -@beartype -@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") -@require(lambda section_paths: isinstance(section_paths, list), "Section paths must be list") -@require(lambda persona: isinstance(persona, str), "Persona must be str") -@ensure(lambda result: isinstance(result, tuple), "Must return tuple") -def check_sections_locked_for_persona( - manifest: BundleManifest, section_paths: list[str], persona: str -) -> tuple[bool, list[str], str | None]: - """ - Check if any sections are locked and if persona can edit them. - - Args: - manifest: Bundle manifest with locks - section_paths: List of section paths to check - persona: Persona attempting to edit - - Returns: - Tuple of (is_locked, locked_sections, lock_owner) - - is_locked: True if any section is locked - - locked_sections: List of locked section paths - - lock_owner: Owner persona of the lock (if locked and not owned by persona) - """ - locked_sections: list[str] = [] - lock_owner: str | None = None - - for section_path in section_paths: - for lock in manifest.locks: - if match_section_pattern(lock.section, section_path): - locked_sections.append(section_path) - # If locked by a different persona, record the owner - if lock.owner != persona: - lock_owner = lock.owner - break - - return (len(locked_sections) > 0, locked_sections, lock_owner) - - -@app.command("export") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def export_persona( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - persona: str | None = typer.Option( - None, - "--persona", - help="Persona name (e.g., product-owner, architect). Use --list-personas to see available personas.", - ), - # Output/Results - output: Path | None = typer.Option( - None, - "--output", - "--out", - help="Output file path (default: docs/project-plans/<bundle>/<persona>.md or stdout with --stdout)", - ), - output_dir: Path | None = typer.Option( - None, - "--output-dir", - help="Output directory for Markdown file (default: docs/project-plans/<bundle>)", - ), - # Behavior/Options - stdout: bool = typer.Option( - False, - "--stdout", - help="Output to stdout instead of file (for piping/CI usage)", - ), - template: str | None = typer.Option( - None, - "--template", - help="Custom template name (default: uses persona-specific template)", - ), - list_personas: bool = typer.Option( - False, - "--list-personas", - help="List all available personas and exit", - ), - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Export persona-owned sections from project bundle to Markdown. - - Generates well-structured Markdown artifacts using templates, filtered by - persona ownership. Perfect for AI IDEs and manual editing workflows. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --persona - - **Output/Results**: --output, --output-dir, --stdout - - **Behavior/Options**: --template, --no-interactive - - **Examples:** - specfact project export --bundle legacy-api --persona product-owner - specfact project export --bundle legacy-api --persona architect --output-dir docs/plans - specfact project export --bundle legacy-api --persona developer --stdout - """ - if is_debug_mode(): - debug_log_operation( - "command", - "project export", - "started", - extra={"repo": str(repo), "bundle": bundle, "persona": persona}, - ) - debug_print("[dim]project export: started[/dim]") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - if is_debug_mode(): - debug_log_operation( - "command", - "project export", - "failed", - error="No project bundles found", - extra={"reason": "no_bundles"}, - ) - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - if is_debug_mode(): - debug_log_operation( - "command", - "project export", - "failed", - error=f"Project bundle not found: {bundle_dir}", - extra={"reason": "bundle_not_found", "bundle": bundle}, - ) - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Handle --list-personas flag or missing --persona - if list_personas or persona is None: - _list_available_personas(bundle_obj, bundle) - raise typer.Exit(0) - - # Check persona exists, try to initialize if missing - if persona not in bundle_obj.manifest.personas: - # Try to initialize the requested persona - persona_initialized = _initialize_persona_if_needed(bundle_obj, persona, no_interactive) - - if persona_initialized: - # Save bundle with new persona - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - else: - # Persona not available in defaults or user declined - print_error(f"Persona '{persona}' not found in bundle manifest") - console.print() # Empty line - - # Always show available personas in bundle - available_personas = list(bundle_obj.manifest.personas.keys()) - if available_personas: - print_info("Available personas in bundle:") - for p in available_personas: - print_info(f" - {p}") - else: - print_info("No personas defined in bundle manifest.") - - console.print() # Empty line - - # Always show default personas (even if some are already in bundle) - print_info("Default personas available:") - for p_name, p_mapping in DEFAULT_PERSONAS.items(): - status = "[green]✓[/green]" if p_name in bundle_obj.manifest.personas else "[dim]○[/dim]" - owns_preview = ", ".join(p_mapping.owns[:3]) - if len(p_mapping.owns) > 3: - owns_preview += "..." - print_info(f" {status} {p_name}: owns {owns_preview}") - - console.print() # Empty line - - # Offer to initialize all default personas if none are defined - if not available_personas and not no_interactive: - all_initialized = _initialize_all_default_personas(bundle_obj, no_interactive) - if all_initialized: - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - # Retry with the newly initialized persona - if persona in bundle_obj.manifest.personas: - persona_initialized = True - - if not persona_initialized: - print_info("To add personas, use:") - print_info(" specfact project init-personas --bundle <name>") - print_info(" specfact project init-personas --bundle <name> --persona <name>") - raise typer.Exit(1) - - # Get persona mapping - persona_mapping = bundle_obj.manifest.personas[persona] - - # Initialize exporter with template support - from specfact_cli.generators.persona_exporter import PersonaExporter - - # Check for project-specific templates - project_templates_dir = repo / ".specfact" / "templates" / "persona" - project_templates_dir = project_templates_dir if project_templates_dir.exists() else None - - exporter = PersonaExporter(project_templates_dir=project_templates_dir) - - # Determine output path - if stdout: - # Export to stdout - markdown_content = exporter.export_to_string(bundle_obj, persona_mapping, persona) - console.print(markdown_content) - else: - # Determine output file path - if output: - output_path = Path(output) - elif output_dir: - output_path = Path(output_dir) / f"{persona}.md" - else: - # Default: docs/project-plans/<bundle>/<persona>.md - default_dir = repo / "docs" / "project-plans" / bundle - output_path = default_dir / f"{persona}.md" - - # Export to file with progress - from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - ) as progress: - task = progress.add_task(f"[cyan]Exporting persona '{persona}' to Markdown...", total=None) - try: - exporter.export_to_file(bundle_obj, persona_mapping, persona, output_path) - progress.update(task, description=f"[green]✓[/green] Exported to {output_path}") - except Exception as e: - progress.update(task, description="[red]✗[/red] Export failed") - print_error(f"Export failed: {e}") - raise typer.Exit(1) from e - - if is_debug_mode(): - debug_log_operation( - "command", - "project export", - "success", - extra={"bundle": bundle, "persona": persona, "output_path": str(output_path)}, - ) - debug_print("[dim]project export: success[/dim]") - print_success(f"Exported persona '{persona}' sections to {output_path}") - print_info(f"Template: {persona}.md.j2") - - -@app.command("import") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def import_persona( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - persona: str | None = typer.Option( - None, - "--persona", - help="Persona name (e.g., product-owner, architect). Use --list-personas to see available personas.", - ), - # Input - input_file: Path = typer.Option( - ..., - "--input", - "--file", - "-i", - help="Path to Markdown file to import", - exists=True, - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), - dry_run: bool = typer.Option( - False, - "--dry-run", - help="Validate import without applying changes", - ), -) -> None: - """ - Import persona-edited Markdown file back into project bundle. - - Validates Markdown structure against template schema, checks ownership, - and transforms Markdown content back to YAML bundle format. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --persona, --input - - **Behavior/Options**: --dry-run, --no-interactive - - **Examples:** - specfact project import --bundle legacy-api --persona product-owner --input product-owner.md - specfact project import --bundle legacy-api --persona architect --input architect.md --dry-run - """ - if is_debug_mode(): - debug_log_operation( - "command", - "project import", - "started", - extra={"repo": str(repo), "bundle": bundle, "persona": persona, "input_file": str(input_file)}, - ) - debug_print("[dim]project import: started[/dim]") - - from specfact_cli.models.persona_template import PersonaTemplate, SectionType, TemplateSection - from specfact_cli.parsers.persona_importer import PersonaImporter, PersonaImportError - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Handle --list-personas flag or missing --persona - if persona is None: - _list_available_personas(bundle_obj, bundle) - raise typer.Exit(0) - - # Check persona exists - if persona not in bundle_obj.manifest.personas: - print_error(f"Persona '{persona}' not found in bundle manifest") - _list_available_personas(bundle_obj, bundle) - raise typer.Exit(1) - - persona_mapping = bundle_obj.manifest.personas[persona] - - # Create template (simplified - in production would load from file) - # For now, create a basic template based on persona - template_sections = [ - TemplateSection( - name="idea_business_context", - heading="## Idea & Business Context", - type=SectionType.REQUIRED - if "idea" in " ".join(persona_mapping.owns) or "business" in " ".join(persona_mapping.owns) - else SectionType.OPTIONAL, - description="Problem statement, solution vision, and business context", - order=1, - validation=None, - placeholder=None, - condition=None, - ), - TemplateSection( - name="features", - heading="## Features & User Stories", - type=SectionType.REQUIRED if any("features" in o for o in persona_mapping.owns) else SectionType.OPTIONAL, - description="Features and user stories", - order=2, - validation=None, - placeholder=None, - condition=None, - ), - ] - template = PersonaTemplate( - persona_name=persona, - version="1.0.0", - description=f"Template for {persona} persona", - sections=template_sections, - ) - - # Initialize importer - # Disable agile validation in test mode to allow simpler test scenarios - validate_agile = os.environ.get("TEST_MODE") != "true" - importer = PersonaImporter(template, validate_agile=validate_agile) - - # Import with progress - from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - ) as progress: - task = progress.add_task(f"[cyan]Validating and importing '{input_file.name}'...", total=None) - - try: - if dry_run: - # Just validate without importing - markdown_content = input_file.read_text(encoding="utf-8") - sections = importer.parse_markdown(markdown_content) - validation_errors = importer.validate_structure(sections) - - if validation_errors: - progress.update(task, description="[red]✗[/red] Validation failed") - print_error("Template validation failed:") - for error in validation_errors: - print_error(f" - {error}") - raise typer.Exit(1) - progress.update(task, description="[green]✓[/green] Validation passed") - print_success("Import validation passed (dry-run)") - else: - # Check locks before importing - # Determine which sections will be modified based on persona ownership - sections_to_modify = list(persona_mapping.owns) - - is_locked, locked_sections, lock_owner = check_sections_locked_for_persona( - bundle_obj.manifest, sections_to_modify, persona - ) - - # Only block if locked by a different persona - if is_locked and lock_owner is not None and lock_owner != persona: - progress.update(task, description="[red]✗[/red] Import blocked by locks") - print_error("Cannot import: Section(s) are locked") - for locked_section in locked_sections: - # Find the lock for this section - for lock in bundle_obj.manifest.locks: - if match_section_pattern(lock.section, locked_section): - # Only report if locked by different persona - if lock.owner != persona: - print_error( - f" - Section '{locked_section}' is locked by '{lock.owner}' " - f"(locked at {lock.locked_at})" - ) - break - print_info("Use 'specfact project unlock --section <section>' to unlock, or contact the lock owner") - raise typer.Exit(1) - - # Import and update bundle - updated_bundle = importer.import_from_file(input_file, bundle_obj, persona_mapping, persona) - progress.update(task, description="[green]✓[/green] Import complete") - - # Save updated bundle - _save_bundle_with_progress(updated_bundle, bundle_dir, atomic=True) - print_success(f"Imported persona '{persona}' edits from {input_file}") - - except PersonaImportError as e: - progress.update(task, description="[red]✗[/red] Import failed") - print_error(f"Import failed: {e}") - raise typer.Exit(1) from e - except Exception as e: - progress.update(task, description="[red]✗[/red] Import failed") - print_error(f"Unexpected error during import: {e}") - raise typer.Exit(1) from e - - -@beartype -@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") -@require(lambda persona_mapping: isinstance(persona_mapping, PersonaMapping), "Persona mapping must be PersonaMapping") -@ensure(lambda result: isinstance(result, dict), "Must return dict") -def _filter_bundle_by_persona(bundle: ProjectBundle, persona_mapping: PersonaMapping) -> dict[str, Any]: - """ - Filter bundle to include only persona-owned sections. - - Args: - bundle: Project bundle to filter - persona_mapping: Persona mapping with owned sections - - Returns: - Filtered bundle dictionary - """ - filtered: dict[str, Any] = { - "bundle_name": bundle.bundle_name, - "manifest": bundle.manifest.model_dump(), - } - - # Filter aspects by persona ownership - if bundle.idea and any(match_section_pattern(p, "idea") for p in persona_mapping.owns): - filtered["idea"] = bundle.idea.model_dump() - - if bundle.business and any(match_section_pattern(p, "business") for p in persona_mapping.owns): - filtered["business"] = bundle.business.model_dump() - - if any(match_section_pattern(p, "product") for p in persona_mapping.owns): - filtered["product"] = bundle.product.model_dump() - - # Filter features by persona ownership - filtered_features: dict[str, Any] = {} - for feature_key, feature in bundle.features.items(): - feature_dict = feature.model_dump() - filtered_feature: dict[str, Any] = {"key": feature.key, "title": feature.title} - - # Filter stories if persona owns stories - if any(match_section_pattern(p, "features.*.stories") for p in persona_mapping.owns): - filtered_feature["stories"] = feature_dict.get("stories", []) - - # Filter outcomes if persona owns outcomes - if any(match_section_pattern(p, "features.*.outcomes") for p in persona_mapping.owns): - filtered_feature["outcomes"] = feature_dict.get("outcomes", []) - - # Filter constraints if persona owns constraints - if any(match_section_pattern(p, "features.*.constraints") for p in persona_mapping.owns): - filtered_feature["constraints"] = feature_dict.get("constraints", []) - - # Filter acceptance if persona owns acceptance - if any(match_section_pattern(p, "features.*.acceptance") for p in persona_mapping.owns): - filtered_feature["acceptance"] = feature_dict.get("acceptance", []) - - if filtered_feature: - filtered_features[feature_key] = filtered_feature - - if filtered_features: - filtered["features"] = filtered_features - - return filtered - - -@beartype -@require(lambda bundle_data: isinstance(bundle_data, dict), "Bundle data must be dict") -@require(lambda output_path: isinstance(output_path, Path), "Output path must be Path") -@require(lambda format: isinstance(format, str), "Format must be str") -@ensure(lambda result: result is None, "Must return None") -def _export_bundle_to_file(bundle_data: dict[str, Any], output_path: Path, format: str) -> None: - """Export bundle data to file.""" - import json - - import yaml - - output_path.parent.mkdir(parents=True, exist_ok=True) - with output_path.open("w", encoding="utf-8") as f: - if format.lower() == "json": - json.dump(bundle_data, f, indent=2, default=str) - else: - yaml.dump(bundle_data, f, default_flow_style=False, sort_keys=False) - - -@beartype -@require(lambda bundle_data: isinstance(bundle_data, dict), "Bundle data must be dict") -@require(lambda format: isinstance(format, str), "Format must be str") -@ensure(lambda result: result is None, "Must return None") -def _export_bundle_to_stdout(bundle_data: dict[str, Any], format: str) -> None: - """Export bundle data to stdout.""" - import json - - import yaml - - if format.lower() == "json": - console.print(json.dumps(bundle_data, indent=2, default=str)) - else: - console.print(yaml.dump(bundle_data, default_flow_style=False, sort_keys=False)) - - -@app.command("lock") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def lock_section( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - section: str = typer.Option(..., "--section", help="Section pattern (e.g., 'idea', 'features.*.stories')"), - persona: str = typer.Option(..., "--persona", help="Persona name (e.g., product-owner, architect)"), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Lock a section for a persona. - - Prevents other personas from editing the specified section until unlocked. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --section, --persona - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact project lock --bundle legacy-api --section idea --persona product-owner - specfact project lock --bundle legacy-api --section "features.*.stories" --persona product-owner - """ - if is_debug_mode(): - debug_log_operation( - "command", - "project lock", - "started", - extra={"repo": str(repo), "bundle": bundle, "section": section, "persona": persona}, - ) - debug_print("[dim]project lock: started[/dim]") - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Check persona exists, try to initialize if missing - if persona not in bundle_obj.manifest.personas: - # Try to initialize the requested persona - persona_initialized = _initialize_persona_if_needed(bundle_obj, persona, no_interactive) - - if persona_initialized: - # Save bundle with new persona - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - else: - # Persona not available in defaults or user declined - print_error(f"Persona '{persona}' not found in bundle manifest") - console.print() # Empty line - - # Always show available personas in bundle - available_personas = list(bundle_obj.manifest.personas.keys()) - if available_personas: - print_info("Available personas in bundle:") - for p in available_personas: - print_info(f" - {p}") - else: - print_info("No personas defined in bundle manifest.") - - console.print() # Empty line - - # Always show default personas (even if some are already in bundle) - print_info("Default personas available:") - for p_name, p_mapping in DEFAULT_PERSONAS.items(): - status = "[green]✓[/green]" if p_name in bundle_obj.manifest.personas else "[dim]○[/dim]" - owns_preview = ", ".join(p_mapping.owns[:3]) - if len(p_mapping.owns) > 3: - owns_preview += "..." - print_info(f" {status} {p_name}: owns {owns_preview}") - - console.print() # Empty line - - # Offer to initialize all default personas if none are defined - if not available_personas and not no_interactive: - all_initialized = _initialize_all_default_personas(bundle_obj, no_interactive) - if all_initialized: - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - # Retry with the newly initialized persona - if persona in bundle_obj.manifest.personas: - persona_initialized = True - - if not persona_initialized: - print_info("To add personas, use:") - print_info(" specfact project init-personas --bundle <name>") - print_info(" specfact project init-personas --bundle <name> --persona <name>") - raise typer.Exit(1) - - # Check persona owns section - if not check_persona_ownership(persona, bundle_obj.manifest, section): - print_error(f"Persona '{persona}' does not own section '{section}'") - raise typer.Exit(1) - - # Check if already locked - if check_section_locked(bundle_obj.manifest, section): - print_warning(f"Section '{section}' is already locked") - raise typer.Exit(1) - - # Create lock - lock = SectionLock( - section=section, - owner=persona, - locked_at=datetime.now(UTC).isoformat(), - locked_by=os.environ.get("USER", "unknown") + "@" + os.environ.get("HOSTNAME", "unknown"), - ) - - bundle_obj.manifest.locks.append(lock) - - # Save bundle - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - print_success(f"Locked section '{section}' for persona '{persona}'") - - -@app.command("unlock") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def unlock_section( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - section: str = typer.Option(..., "--section", help="Section pattern (e.g., 'idea', 'features.*.stories')"), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Unlock a section. - - Removes the lock on the specified section, allowing edits by any persona that owns it. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --section - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact project unlock --bundle legacy-api --section idea - specfact project unlock --bundle legacy-api --section "features.*.stories" - """ - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Find and remove lock - removed = False - for i, lock in enumerate(bundle_obj.manifest.locks): - if match_section_pattern(lock.section, section): - bundle_obj.manifest.locks.pop(i) - removed = True - break - - if not removed: - print_warning(f"Section '{section}' is not locked") - raise typer.Exit(1) - - # Save bundle - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - print_success(f"Unlocked section '{section}'") - - -@app.command("locks") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def list_locks( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - List all section locks. - - Shows all currently locked sections with their owners and lock timestamps. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact project locks --bundle legacy-api - """ - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Display locks - if not bundle_obj.manifest.locks: - print_info("No locks found") - return - - table = Table(title="Section Locks") - table.add_column("Section", style="cyan") - table.add_column("Owner", style="magenta") - table.add_column("Locked At", style="green") - table.add_column("Locked By", style="yellow") - - for lock in bundle_obj.manifest.locks: - table.add_row(lock.section, lock.owner, lock.locked_at, lock.locked_by) - - console.print(table) - - -@app.command("init-personas") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def init_personas( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - personas: list[str] = typer.Option( - [], - "--persona", - help="Specific persona(s) to initialize (e.g., --persona product-owner --persona architect). If not specified, initializes all default personas.", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", - ), -) -> None: - """ - Initialize personas in project bundle manifest. - - Adds default persona mappings to the bundle manifest if they are missing. - Useful for migrating existing bundles to use persona workflows. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --persona - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact project init-personas --bundle legacy-api - specfact project init-personas --bundle legacy-api --persona product-owner --persona architect - specfact project init-personas --bundle legacy-api --no-interactive - """ - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - # Interactive selection - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - # Ensure bundle is not None - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Get bundle directory - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Determine which personas to initialize - personas_to_init: dict[str, PersonaMapping] = {} - - if personas: - # Initialize specific personas - for persona_name in personas: - if persona_name not in DEFAULT_PERSONAS: - print_error(f"Persona '{persona_name}' is not a default persona") - print_info("Available default personas:") - for p_name in DEFAULT_PERSONAS: - print_info(f" - {p_name}") - raise typer.Exit(1) - - if persona_name in bundle_obj.manifest.personas: - print_warning(f"Persona '{persona_name}' already exists in bundle manifest") - else: - personas_to_init[persona_name] = DEFAULT_PERSONAS[persona_name] - else: - # Initialize all missing default personas - personas_to_init = {k: v for k, v in DEFAULT_PERSONAS.items() if k not in bundle_obj.manifest.personas} - - if not personas_to_init: - print_info("All default personas are already initialized in bundle manifest") - return - - # Show what will be initialized - console.print() # Empty line - print_info(f"Will initialize {len(personas_to_init)} persona(s):") - for p_name, p_mapping in personas_to_init.items(): - owns_preview = ", ".join(p_mapping.owns[:3]) - if len(p_mapping.owns) > 3: - owns_preview += "..." - print_info(f" - {p_name}: owns {owns_preview}") - - # Confirm in interactive mode - if not no_interactive: - from rich.prompt import Confirm - - console.print() # Empty line - if not Confirm.ask("Initialize these personas?", default=True): - print_info("Persona initialization cancelled") - raise typer.Exit(0) - - # Initialize personas - bundle_obj.manifest.personas.update(personas_to_init) - - # Save bundle - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - print_success(f"Initialized {len(personas_to_init)} persona(s) in bundle manifest") - - -@app.command("merge") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def merge_bundles( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - base: str = typer.Option(..., "--base", help="Base branch/commit (common ancestor)"), - ours: str = typer.Option(..., "--ours", help="Our branch/commit (current branch)"), - theirs: str = typer.Option(..., "--theirs", help="Their branch/commit (incoming branch)"), - persona_ours: str = typer.Option(..., "--persona-ours", help="Persona who made our changes (e.g., product-owner)"), - persona_theirs: str = typer.Option( - ..., "--persona-theirs", help="Persona who made their changes (e.g., architect)" - ), - # Output/Results - output: Path | None = typer.Option( - None, - "--output", - "--out", - help="Output directory for merged bundle (default: current bundle directory)", - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", - ), - strategy: str = typer.Option( - "auto", - "--strategy", - help="Merge strategy: auto (persona-based), ours, theirs, base, manual", - ), -) -> None: - """ - Merge project bundles using three-way merge with persona-aware conflict resolution. - - Performs a three-way merge between base, ours, and theirs versions of a project bundle, - automatically resolving conflicts based on persona ownership rules. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --base, --ours, --theirs, --persona-ours, --persona-theirs - - **Output/Results**: --output - - **Behavior/Options**: --no-interactive, --strategy - - **Examples:** - specfact project merge --base main --ours po-branch --theirs arch-branch --persona-ours product-owner --persona-theirs architect - specfact project merge --bundle legacy-api --base main --ours feature-1 --theirs feature-2 --persona-ours developer --persona-theirs developer - """ - from specfact_cli.merge.resolver import MergeStrategy, PersonaMergeResolver - from specfact_cli.utils.git import GitOperations - - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - # Initialize Git operations - git_ops = GitOperations(repo) - if not git_ops._is_git_repo(): - print_error("Not a Git repository. Merge requires Git.") - raise typer.Exit(1) - - print_section(f"Merging project bundle '{bundle}'") - - # Load bundles from Git branches/commits - # For now, we'll load from current directory and assume bundles are checked out - # In a full implementation, we'd checkout branches and load bundles - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - - # Load base, ours, and theirs bundles from Git branches/commits - print_info("Loading bundles from Git branches/commits...") - - # Save current branch - current_branch = git_ops.get_current_branch() - - try: - # Load base bundle - print_info(f"Loading base bundle from {base}...") - git_ops.checkout(base) - base_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Load ours bundle - print_info(f"Loading ours bundle from {ours}...") - git_ops.checkout(ours) - ours_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Load theirs bundle - print_info(f"Loading theirs bundle from {theirs}...") - git_ops.checkout(theirs) - theirs_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - except Exception as e: - print_error(f"Failed to load bundles from Git: {e}") - # Restore original branch - with suppress(Exception): - git_ops.checkout(current_branch) - raise typer.Exit(1) from e - finally: - # Restore original branch - with suppress(Exception): - git_ops.checkout(current_branch) - print_info(f"Restored branch: {current_branch}") - - # Perform merge - resolver = PersonaMergeResolver() - resolution = resolver.resolve(base_bundle, ours_bundle, theirs_bundle, persona_ours, persona_theirs) - - # Display results - print_section("Merge Resolution Results") - print_info(f"Auto-resolved: {resolution.auto_resolved}") - print_info(f"Manual resolution required: {resolution.unresolved}") - - if resolution.conflicts: - from rich.table import Table - - conflicts_table = Table(title="Conflicts") - conflicts_table.add_column("Section", style="cyan") - conflicts_table.add_column("Field", style="magenta") - conflicts_table.add_column("Resolution", style="green") - conflicts_table.add_column("Status", style="yellow") - - for conflict in resolution.conflicts: - status = "✅ Auto-resolved" if conflict.resolution != MergeStrategy.MANUAL else "❌ Manual required" - conflicts_table.add_row( - conflict.section_path, - conflict.field_name, - conflict.resolution.value if conflict.resolution else "pending", - status, - ) - - console.print(conflicts_table) - - # Handle unresolved conflicts - if resolution.unresolved > 0: - print_warning(f"{resolution.unresolved} conflict(s) require manual resolution") - if not no_interactive: - from rich.prompt import Confirm - - if not Confirm.ask("Continue with manual resolution?", default=True): - print_info("Merge cancelled") - raise typer.Exit(0) - - # Interactive resolution for each conflict - for conflict in resolution.conflicts: - if conflict.resolution == MergeStrategy.MANUAL: - print_section(f"Resolving conflict: {conflict.field_name}") - print_info(f"Base: {conflict.base_value}") - print_info(f"Ours ({persona_ours}): {conflict.ours_value}") - print_info(f"Theirs ({persona_theirs}): {conflict.theirs_value}") - - from rich.prompt import Prompt - - choice = Prompt.ask( - "Choose resolution", - choices=["ours", "theirs", "base", "manual"], - default="ours", - ) - - if choice == "ours": - conflict.resolution = MergeStrategy.OURS - conflict.resolved_value = conflict.ours_value - elif choice == "theirs": - conflict.resolution = MergeStrategy.THEIRS - conflict.resolved_value = conflict.theirs_value - elif choice == "base": - conflict.resolution = MergeStrategy.BASE - conflict.resolved_value = conflict.base_value - else: - # Manual edit - prompt for value - manual_value = Prompt.ask("Enter manual value") - conflict.resolution = MergeStrategy.MANUAL - conflict.resolved_value = manual_value - - # Apply resolution - resolver._apply_resolution(resolution.merged_bundle, conflict.field_name, conflict.resolved_value) - - # Save merged bundle - output_dir = output if output else bundle_dir - output_dir.mkdir(parents=True, exist_ok=True) - - _save_bundle_with_progress(resolution.merged_bundle, output_dir, atomic=True) - print_success(f"Merged bundle saved to {output_dir}") - - -@app.command("resolve-conflict") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def resolve_conflict( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", - ), - conflict_path: str = typer.Option(..., "--path", help="Conflict path (e.g., 'features.FEATURE-001.title')"), - resolution: str = typer.Option(..., "--resolution", help="Resolution: ours, theirs, base, or manual value"), - persona: str | None = typer.Option( - None, "--persona", help="Persona resolving the conflict (for ownership validation)" - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", - ), -) -> None: - """ - Resolve a specific conflict in a project bundle. - - Helper command for manually resolving individual conflicts after a merge operation. - - **Parameter Groups:** - - **Target/Input**: --repo, --bundle, --path, --resolution, --persona - - **Behavior/Options**: --no-interactive - - **Examples:** - specfact project resolve-conflict --path features.FEATURE-001.title --resolution ours - specfact project resolve-conflict --bundle legacy-api --path idea.intent --resolution theirs --persona product-owner - """ - # Get bundle name - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None and not no_interactive: - from rich.prompt import Prompt - - plans = SpecFactStructure.list_plans(repo) - if not plans: - print_error("No project bundles found") - raise typer.Exit(1) - bundle_names = [str(p["name"]) for p in plans if p.get("name")] - if not bundle_names: - print_error("No valid bundle names found") - raise typer.Exit(1) - bundle = Prompt.ask("Select bundle", choices=bundle_names) - elif bundle is None: - print_error("Bundle not specified and no active bundle found") - raise typer.Exit(1) - - if bundle is None: - print_error("Bundle not specified") - raise typer.Exit(1) - - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - # Parse resolution - from specfact_cli.merge.resolver import PersonaMergeResolver - - resolver = PersonaMergeResolver() - - # Determine value based on resolution strategy - if resolution.lower() in ("ours", "theirs", "base"): - print_warning("Resolution strategy 'ours', 'theirs', or 'base' requires merge context") - print_info("Use 'specfact project merge' for full merge resolution") - raise typer.Exit(1) - - # Manual value provided - resolved_value = resolution - - # Apply resolution - resolver._apply_resolution(bundle_obj, conflict_path, resolved_value) - - # Save bundle - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - print_success(f"Conflict resolved: {conflict_path} = {resolved_value}") - - -# ----------------------------- -# Version management subcommands -# ----------------------------- - - -@version_app.command("check") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def version_check( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, uses active bundle from config.", - ), -) -> None: - """ - Analyze bundle changes and recommend version bump (major/minor/patch/none). - """ - bundle_name, bundle_dir = _resolve_bundle(repo, bundle) - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - - analyzer = ChangeAnalyzer(repo_path=repo) - analysis = analyzer.analyze(bundle_dir, bundle=bundle_obj) - - print_section(f"Version analysis for bundle '{bundle_name}'") - print_info(f"Recommended bump: {analysis.recommended_bump}") - print_info(f"Change type: {analysis.change_type.value}") - - if analysis.changed_files: - table = Table(title="Bundle changes") - table.add_column("Path", style="cyan") - for path in sorted(set(analysis.changed_files)): - table.add_row(path) - console.print(table) - else: - print_info("No bundle file changes detected.") - - if analysis.reasons: - print_section("Reasons") - for reason in analysis.reasons: - console.print(f"- {reason}") - - if analysis.content_hash: - print_info(f"Current bundle hash: {analysis.content_hash[:8]}...") - - -@version_app.command("bump") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@require(lambda bump_type: bump_type in {"major", "minor", "patch"}, "Bump type must be major|minor|patch") -@ensure(lambda result: result is None, "Must return None") -def version_bump( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, uses active bundle from config.", - ), - bump_type: str = typer.Option( - ..., - "--type", - help="Version bump type: major | minor | patch", - case_sensitive=False, - ), -) -> None: - """ - Bump project version in bundle manifest (SemVer). - """ - bump_type = bump_type.lower() - bundle_name, bundle_dir = _resolve_bundle(repo, bundle) - - analyzer = ChangeAnalyzer(repo_path=repo) - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - analysis = analyzer.analyze(bundle_dir, bundle=bundle_obj) - current_version = bundle_obj.manifest.versions.project - new_version = bump_version(current_version, bump_type) - - # Warn if selected bump is lower than recommended - if BUMP_SEVERITY.get(analysis.recommended_bump, 0) > BUMP_SEVERITY.get(bump_type, 0): - print_warning( - f"Recommended bump is '{analysis.recommended_bump}' based on detected changes, " - f"but '{bump_type}' was requested." - ) - - project_metadata = bundle_obj.manifest.project_metadata or ProjectMetadata(stability="alpha") - project_metadata.version_history.append( - ChangeAnalyzer.create_history_entry(current_version, new_version, bump_type) - ) - bundle_obj.manifest.project_metadata = project_metadata - bundle_obj.manifest.versions.project = new_version - - # Record current content hash to support future comparisons - summary = bundle_obj.compute_summary(include_hash=True) - if summary.content_hash: - bundle_obj.manifest.bundle["content_hash"] = summary.content_hash - - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - print_success(f"Bumped project version to {new_version} for bundle '{bundle_name}'") - - -@version_app.command("set") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def version_set( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If not specified, uses active bundle from config.", - ), - version: str = typer.Option(..., "--version", help="Exact SemVer to set (e.g., 1.2.3)"), -) -> None: - """ - Set explicit project version in bundle manifest. - """ - bundle_name, bundle_dir = _resolve_bundle(repo, bundle) - bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) - current_version = bundle_obj.manifest.versions.project - - # Validate version before loading full bundle again for save - validate_semver(version) - - project_metadata = bundle_obj.manifest.project_metadata or ProjectMetadata(stability="alpha") - project_metadata.version_history.append(ChangeAnalyzer.create_history_entry(current_version, version, "set")) - bundle_obj.manifest.project_metadata = project_metadata - bundle_obj.manifest.versions.project = version - - summary = bundle_obj.compute_summary(include_hash=True) - if summary.content_hash: - bundle_obj.manifest.bundle["content_hash"] = summary.content_hash - - _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) - print_success(f"Set project version to {version} for bundle '{bundle_name}'") +__all__ = ["app"] diff --git a/src/specfact_cli/commands/repro.py b/src/specfact_cli/commands/repro.py index e8da6538..da14c610 100644 --- a/src/specfact_cli/commands/repro.py +++ b/src/specfact_cli/commands/repro.py @@ -1,547 +1,6 @@ -""" -Repro command - Run full validation suite for reproducibility. +"""Backward-compatible app shim. Implementation moved to modules/repro/.""" -This module provides commands for running comprehensive validation -including linting, type checking, contract exploration, and tests. -""" +from specfact_cli.modules.repro.src.commands import app -from __future__ import annotations -from pathlib import Path - -import typer -from beartype import beartype -from click import Context as ClickContext -from icontract import require -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn -from rich.table import Table - -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils.env_manager import check_tool_in_env, detect_env_manager, detect_source_directories -from specfact_cli.utils.structure import SpecFactStructure -from specfact_cli.validators.repro_checker import ReproChecker - - -app = typer.Typer(help="Run validation suite for reproducibility") -console = Console() - - -def _update_pyproject_crosshair_config(pyproject_path: Path, config: dict[str, int | float]) -> bool: - """ - Update or create [tool.crosshair] section in pyproject.toml. - - Args: - pyproject_path: Path to pyproject.toml - config: Dictionary with CrossHair configuration values - - Returns: - True if config was updated/created, False otherwise - """ - try: - # Try tomlkit for style-preserving updates (recommended) - try: - import tomlkit - - # Read existing file to preserve style - if pyproject_path.exists(): - with pyproject_path.open("r", encoding="utf-8") as f: - doc = tomlkit.parse(f.read()) - else: - doc = tomlkit.document() - - # Update or create [tool.crosshair] section - if "tool" not in doc: - doc["tool"] = tomlkit.table() # type: ignore[assignment] - if "crosshair" not in doc["tool"]: # type: ignore[index] - doc["tool"]["crosshair"] = tomlkit.table() # type: ignore[index,assignment] - - for key, value in config.items(): - doc["tool"]["crosshair"][key] = value # type: ignore[index] - - # Write back - with pyproject_path.open("w", encoding="utf-8") as f: - f.write(tomlkit.dumps(doc)) # type: ignore[arg-type] - - return True - - except ImportError: - # Fallback: use tomllib/tomli to read, then append section manually - try: - import tomllib - except ImportError: - try: - import tomli as tomllib # noqa: F401 - except ImportError: - console.print("[red]Error:[/red] No TOML library available (need tomlkit, tomllib, or tomli)") - return False - - # Read existing content - existing_content = "" - if pyproject_path.exists(): - existing_content = pyproject_path.read_text(encoding="utf-8") - - # Check if [tool.crosshair] already exists - if "[tool.crosshair]" in existing_content: - # Update existing section (simple regex replacement) - import re - - pattern = r"\[tool\.crosshair\][^\[]*" - new_section = "[tool.crosshair]\n" - for key, value in config.items(): - new_section += f"{key} = {value}\n" - - existing_content = re.sub(pattern, new_section.rstrip(), existing_content, flags=re.DOTALL) - else: - # Append new section - if existing_content and not existing_content.endswith("\n"): - existing_content += "\n" - existing_content += "\n[tool.crosshair]\n" - for key, value in config.items(): - existing_content += f"{key} = {value}\n" - - pyproject_path.write_text(existing_content, encoding="utf-8") - return True - - except Exception as e: - console.print(f"[red]Error updating pyproject.toml:[/red] {e}") - return False - - -def _is_valid_repo_path(path: Path) -> bool: - """Check if path exists and is a directory.""" - return path.exists() and path.is_dir() - - -def _is_valid_output_path(path: Path | None) -> bool: - """Check if output path exists if provided.""" - return path is None or path.exists() - - -def _count_python_files(path: Path) -> int: - """Count Python files for anonymized telemetry reporting.""" - return sum(1 for _ in path.rglob("*.py")) - - -@app.callback(invoke_without_command=True, no_args_is_help=False) -# CrossHair: Skip analysis for Typer-decorated functions (signature analysis limitation) -# type: ignore[crosshair] -def main( - ctx: ClickContext, - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Output/Results - out: Path | None = typer.Option( - None, - "--out", - help="Output report path (default: bundle-specific .specfact/projects/<bundle-name>/reports/enforcement/report-<timestamp>.yaml if bundle context available, else global .specfact/reports/enforcement/, Phase 8.5)", - ), - # Behavior/Options - verbose: bool = typer.Option( - False, - "--verbose", - "-v", - help="Verbose output", - ), - fail_fast: bool = typer.Option( - False, - "--fail-fast", - help="Stop on first failure", - ), - fix: bool = typer.Option( - False, - "--fix", - help="Apply auto-fixes where available (Semgrep auto-fixes)", - ), - # Advanced/Configuration - budget: int = typer.Option( - 120, - "--budget", - help="Time budget in seconds (must be > 0)", - hidden=True, # Hidden by default, shown with --help-advanced - ), - sidecar: bool = typer.Option( - False, - "--sidecar", - help="Run sidecar validation for unannotated code (no-edit path)", - ), - sidecar_bundle: str | None = typer.Option( - None, - "--sidecar-bundle", - help="Bundle name for sidecar validation (required if --sidecar is used)", - ), -) -> None: - """ - Run full validation suite for reproducibility. - - Automatically detects the target repository's environment manager (hatch, poetry, uv, pip) - and adapts commands accordingly. All tools are optional and will be skipped with clear - messages if unavailable. - - Executes: - - Lint checks (ruff) - optional - - Async patterns (semgrep) - optional, only if config exists - - Type checking (basedpyright) - optional - - Contract exploration (CrossHair) - optional - - Property tests (pytest tests/contracts/) - optional, only if directory exists - - Smoke tests (pytest tests/smoke/) - optional, only if directory exists - - Sidecar validation (--sidecar) - optional, for unannotated code validation - - Works on external repositories without requiring SpecFact CLI adoption. - - Example: - specfact repro --verbose --budget 120 - specfact repro --repo /path/to/external/repo --verbose - specfact repro --fix --budget 120 - specfact repro --sidecar --sidecar-bundle legacy-api --repo /path/to/repo - """ - # If a subcommand was invoked, don't run the main validation - if ctx.invoked_subcommand is not None: - return - - if is_debug_mode(): - debug_log_operation( - "command", - "repro", - "started", - extra={"repo": str(repo), "budget": budget, "sidecar": sidecar, "sidecar_bundle": sidecar_bundle}, - ) - debug_print("[dim]repro: started[/dim]") - - # Type checking for parameters (after subcommand check) - if not _is_valid_repo_path(repo): - raise typer.BadParameter("Repo path must exist and be directory") - if budget <= 0: - raise typer.BadParameter("Budget must be positive") - if not _is_valid_output_path(out): - raise typer.BadParameter("Output path must exist if provided") - if sidecar and not sidecar_bundle: - raise typer.BadParameter("--sidecar-bundle is required when --sidecar is used") - - from specfact_cli.utils.yaml_utils import dump_yaml - - console.print("[bold cyan]Running validation suite...[/bold cyan]") - console.print(f"[dim]Repository: {repo}[/dim]") - console.print(f"[dim]Time budget: {budget}s[/dim]") - if fail_fast: - console.print("[dim]Fail-fast: enabled[/dim]") - if fix: - console.print("[dim]Auto-fix: enabled[/dim]") - console.print() - - # Ensure structure exists - SpecFactStructure.ensure_structure(repo) - - python_file_count = _count_python_files(repo) - - telemetry_metadata = { - "mode": "repro", - "files_analyzed": python_file_count, - } - - with telemetry.track_command("repro.run", telemetry_metadata) as record_event: - # Run all checks - checker = ReproChecker(repo_path=repo, budget=budget, fail_fast=fail_fast, fix=fix) - - # Detect and display environment manager before starting progress spinner - from specfact_cli.utils.env_manager import detect_env_manager - - env_info = detect_env_manager(repo) - if env_info.message: - console.print(f"[dim]{env_info.message}[/dim]") - - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - ) as progress: - progress.add_task("Running validation checks...", total=None) - - # This will show progress for each check internally - report = checker.run_all_checks() - - # Display results - console.print("\n[bold]Validation Results[/bold]\n") - - # Summary table - table = Table(title="Check Summary") - table.add_column("Check", style="cyan") - table.add_column("Tool", style="dim") - table.add_column("Status", style="bold") - table.add_column("Duration", style="dim") - - for check in report.checks: - if check.status.value == "passed": - status_icon = "[green]✓[/green] PASSED" - elif check.status.value == "failed": - status_icon = "[red]✗[/red] FAILED" - elif check.status.value == "timeout": - status_icon = "[yellow]⏱[/yellow] TIMEOUT" - elif check.status.value == "skipped": - status_icon = "[dim]⊘[/dim] SKIPPED" - else: - status_icon = "[dim]…[/dim] PENDING" - - duration_str = f"{check.duration:.2f}s" if check.duration else "N/A" - - table.add_row(check.name, check.tool, status_icon, duration_str) - - console.print(table) - - # Summary stats - console.print("\n[bold]Summary:[/bold]") - console.print(f" Total checks: {report.total_checks}") - console.print(f" [green]Passed: {report.passed_checks}[/green]") - if report.failed_checks > 0: - console.print(f" [red]Failed: {report.failed_checks}[/red]") - if report.timeout_checks > 0: - console.print(f" [yellow]Timeout: {report.timeout_checks}[/yellow]") - if report.skipped_checks > 0: - console.print(f" [dim]Skipped: {report.skipped_checks}[/dim]") - console.print(f" Total duration: {report.total_duration:.2f}s") - - if is_debug_mode(): - debug_log_operation( - "command", - "repro", - "success", - extra={ - "total_checks": report.total_checks, - "passed": report.passed_checks, - "failed": report.failed_checks, - }, - ) - debug_print("[dim]repro: success[/dim]") - record_event( - { - "checks_total": report.total_checks, - "checks_failed": report.failed_checks, - "violations_detected": report.failed_checks, - } - ) - - # Show errors if verbose - if verbose: - for check in report.checks: - if check.error: - console.print(f"\n[bold red]{check.name} Error:[/bold red]") - console.print(f"[dim]{check.error}[/dim]") - if check.output and check.status.value == "failed": - console.print(f"\n[bold red]{check.name} Output:[/bold red]") - console.print(f"[dim]{check.output[:500]}[/dim]") # Limit output - - # Write report if requested (Phase 8.5: try to use bundle-specific path) - if out is None: - # Try to detect bundle from active plan - bundle_name = SpecFactStructure.get_active_bundle_name(repo) - if bundle_name: - # Use bundle-specific enforcement report path (Phase 8.5) - out = SpecFactStructure.get_bundle_enforcement_report_path(bundle_name=bundle_name, base_path=repo) - else: - # Fallback to global path (backward compatibility during transition) - out = SpecFactStructure.get_timestamped_report_path("enforcement", repo, "yaml") - SpecFactStructure.ensure_structure(repo) - - out.parent.mkdir(parents=True, exist_ok=True) - dump_yaml(report.to_dict(), out) - console.print(f"\n[dim]Report written to: {out}[/dim]") - - # Run sidecar validation if requested (after main checks) - if sidecar and sidecar_bundle: - from specfact_cli.validators.sidecar.models import SidecarConfig - from specfact_cli.validators.sidecar.orchestrator import run_sidecar_validation - from specfact_cli.validators.sidecar.unannotated_detector import detect_unannotated_in_repo - - console.print("\n[bold cyan]Running sidecar validation for unannotated code...[/bold cyan]") - - # Detect unannotated code - unannotated = detect_unannotated_in_repo(repo) - if unannotated: - console.print(f"[dim]Found {len(unannotated)} unannotated functions[/dim]") - # Store unannotated functions info for harness generation - sidecar_config = SidecarConfig.create(sidecar_bundle, repo) - # Pass unannotated info to orchestrator (via results dict) - else: - console.print("[dim]No unannotated functions detected (all functions have contracts)[/dim]") - sidecar_config = SidecarConfig.create(sidecar_bundle, repo) - - # Run sidecar validation (harness will be generated for unannotated code) - sidecar_results = run_sidecar_validation(sidecar_config, console=console) - - # Display sidecar results - if sidecar_results.get("crosshair_summary"): - summary = sidecar_results["crosshair_summary"] - console.print( - f"[dim]Sidecar CrossHair: {summary.get('confirmed', 0)} confirmed, " - f"{summary.get('not_confirmed', 0)} not confirmed, " - f"{summary.get('violations', 0)} violations[/dim]" - ) - - # Exit with appropriate code - exit_code = report.get_exit_code() - if exit_code == 0: - crosshair_failed = any( - check.tool == "crosshair" and check.status.value == "failed" for check in report.checks - ) - if crosshair_failed: - console.print( - "\n[bold yellow]![/bold yellow] Required validations passed, but CrossHair failed (advisory)" - ) - console.print("[dim]Reproducibility verified with advisory failures[/dim]") - else: - console.print("\n[bold green]✓[/bold green] All validations passed!") - console.print("[dim]Reproducibility verified[/dim]") - elif exit_code == 1: - console.print("\n[bold red]✗[/bold red] Some validations failed") - raise typer.Exit(1) - else: - console.print("\n[yellow]⏱[/yellow] Budget exceeded") - raise typer.Exit(2) - - -@app.command("setup") -@beartype -@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") -def setup( - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - install_crosshair: bool = typer.Option( - False, - "--install-crosshair", - help="Attempt to install crosshair-tool if not available", - ), -) -> None: - """ - Set up CrossHair configuration for contract exploration. - - - Automatically generates [tool.crosshair] configuration in pyproject.toml - to enable contract exploration with CrossHair during repro runs. - - This command: - - Detects source directories in the repository - - Creates/updates pyproject.toml with CrossHair configuration - - Optionally checks if crosshair-tool is installed - - Provides guidance on next steps - - Example: - specfact repro setup - specfact repro setup --repo /path/to/repo - specfact repro setup --install-crosshair - """ - console.print("[bold cyan]Setting up CrossHair configuration...[/bold cyan]") - console.print(f"[dim]Repository: {repo}[/dim]\n") - - # Detect environment manager - env_info = detect_env_manager(repo) - if env_info.message: - console.print(f"[dim]{env_info.message}[/dim]") - - # Detect source directories - source_dirs = detect_source_directories(repo) - if not source_dirs: - # Fallback to common patterns - if (repo / "src").exists(): - source_dirs = ["src/"] - elif (repo / "lib").exists(): - source_dirs = ["lib/"] - else: - source_dirs = ["."] - - console.print(f"[green]✓[/green] Detected source directories: {', '.join(source_dirs)}") - - # Check if crosshair-tool is available - crosshair_available, crosshair_message = check_tool_in_env(repo, "crosshair", env_info) - if crosshair_available: - console.print("[green]✓[/green] crosshair-tool is available") - else: - console.print(f"[yellow]⚠[/yellow] crosshair-tool not available: {crosshair_message}") - if install_crosshair: - console.print("[dim]Attempting to install crosshair-tool...[/dim]") - import subprocess - - # Build install command with environment manager - from specfact_cli.utils.env_manager import build_tool_command - - install_cmd = ["pip", "install", "crosshair-tool>=0.0.97"] - install_cmd = build_tool_command(env_info, install_cmd) - - try: - result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=60, cwd=str(repo)) - if result.returncode == 0: - console.print("[green]✓[/green] crosshair-tool installed successfully") - crosshair_available = True - else: - console.print(f"[red]✗[/red] Failed to install crosshair-tool: {result.stderr}") - except subprocess.TimeoutExpired: - console.print("[red]✗[/red] Installation timed out") - except Exception as e: - console.print(f"[red]✗[/red] Installation error: {e}") - else: - console.print( - "[dim]Tip: Install with --install-crosshair flag, or manually: " - f"{'hatch run pip install' if env_info.manager == 'hatch' else 'pip install'} crosshair-tool[/dim]" - ) - - # Create/update pyproject.toml with CrossHair config - pyproject_path = repo / "pyproject.toml" - - # Default CrossHair configuration (matching our own pyproject.toml) - crosshair_config: dict[str, int | float] = { - "timeout": 60, - "per_condition_timeout": 10, - "per_path_timeout": 5, - "max_iterations": 1000, - } - - if _update_pyproject_crosshair_config(pyproject_path, crosshair_config): - if is_debug_mode(): - debug_log_operation("command", "repro setup", "success", extra={"pyproject_path": str(pyproject_path)}) - debug_print("[dim]repro setup: success[/dim]") - console.print(f"[green]✓[/green] Updated {pyproject_path.relative_to(repo)} with CrossHair configuration") - console.print("\n[bold]CrossHair Configuration:[/bold]") - for key, value in crosshair_config.items(): - console.print(f" {key} = {value}") - else: - if is_debug_mode(): - debug_log_operation( - "command", - "repro setup", - "failed", - error=f"Failed to update {pyproject_path}", - extra={"reason": "update_failed"}, - ) - console.print(f"[red]✗[/red] Failed to update {pyproject_path.relative_to(repo)}") - raise typer.Exit(1) - - # Summary - console.print("\n[bold green]✓[/bold green] Setup complete!") - console.print("\n[bold]Next steps:[/bold]") - console.print(" 1. Run [cyan]specfact repro[/cyan] to execute validation checks") - if not crosshair_available: - console.print(" 2. Install crosshair-tool to enable contract exploration:") - if env_info.manager == "hatch": - console.print(" [dim]hatch run pip install crosshair-tool[/dim]") - elif env_info.manager == "poetry": - console.print(" [dim]poetry add --dev crosshair-tool[/dim]") - elif env_info.manager == "uv": - console.print(" [dim]uv pip install crosshair-tool[/dim]") - else: - console.print(" [dim]pip install crosshair-tool[/dim]") - console.print(" 3. CrossHair will automatically explore contracts in your source code") - console.print(" 4. Results will appear in the validation report") +__all__ = ["app"] diff --git a/src/specfact_cli/commands/sdd.py b/src/specfact_cli/commands/sdd.py index 90d6b102..414f6f9d 100644 --- a/src/specfact_cli/commands/sdd.py +++ b/src/specfact_cli/commands/sdd.py @@ -1,431 +1,6 @@ -""" -SDD (Spec-Driven Development) manifest management commands. +"""Backward-compatible app shim. Implementation moved to modules/sdd/.""" -This module provides commands for managing SDD manifests, including listing -all SDD manifests in a repository, and constitution management for Spec-Kit compatibility. -""" +from specfact_cli.modules.sdd.src.commands import app -from __future__ import annotations -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.table import Table - -from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher -from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode -from specfact_cli.utils import print_error, print_info, print_success -from specfact_cli.utils.sdd_discovery import list_all_sdds -from specfact_cli.utils.structure import SpecFactStructure - - -app = typer.Typer( - name="sdd", - help="Manage SDD (Spec-Driven Development) manifests and constitutions", - rich_markup_mode="rich", -) - -console = get_configured_console() - -# Constitution subcommand group -constitution_app = typer.Typer( - help="Manage project constitutions (Spec-Kit format compatibility). Generates and validates constitutions at .specify/memory/constitution.md for Spec-Kit format compatibility." -) - -app.add_typer(constitution_app, name="constitution") - -# Constitution subcommand group -constitution_app = typer.Typer( - help="Manage project constitutions (Spec-Kit format compatibility). Generates and validates constitutions at .specify/memory/constitution.md for Spec-Kit format compatibility." -) - -app.add_typer(constitution_app, name="constitution") - - -@app.command("list") -@beartype -@require(lambda repo: isinstance(repo, Path), "Repo must be Path") -def sdd_list( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), -) -> None: - """ - List all SDD manifests in the repository. - - Shows all SDD manifests found in bundle-specific locations (.specfact/projects/<bundle-name>/sdd.yaml, Phase 8.5) - and legacy multi-SDD layout (.specfact/sdd/*.yaml) - and legacy single-SDD layout (.specfact/sdd.yaml). - - **Parameter Groups:** - - **Target/Input**: --repo - - **Examples:** - specfact sdd list - specfact sdd list --repo /path/to/repo - """ - if is_debug_mode(): - debug_log_operation("command", "sdd list", "started", extra={"repo": str(repo)}) - debug_print("[dim]sdd list: started[/dim]") - - console.print("\n[bold cyan]SpecFact CLI - SDD Manifest List[/bold cyan]") - console.print("=" * 60) - - base_path = repo.resolve() - all_sdds = list_all_sdds(base_path) - - if not all_sdds: - if is_debug_mode(): - debug_log_operation("command", "sdd list", "success", extra={"count": 0, "reason": "none_found"}) - debug_print("[dim]sdd list: success (none found)[/dim]") - console.print("[yellow]No SDD manifests found[/yellow]") - console.print(f"[dim]Searched in: {base_path / SpecFactStructure.PROJECTS}/*/sdd.yaml[/dim]") - console.print( - f"[dim]Legacy fallback: {base_path / SpecFactStructure.SDD}/* and {base_path / SpecFactStructure.ROOT / 'sdd.yaml'}[/dim]" - ) - console.print("\n[dim]Create SDD manifests with: specfact plan harden <bundle-name>[/dim]") - console.print("[dim]If you have legacy bundles, migrate with: specfact migrate artifacts --repo .[/dim]") - raise typer.Exit(0) - - # Create table - table = Table(title="SDD Manifests", show_header=True, header_style="bold cyan") - table.add_column("Path", style="cyan", no_wrap=False) - table.add_column("Bundle Hash", style="magenta") - table.add_column("Bundle ID", style="blue") - table.add_column("Status", style="green") - table.add_column("Coverage", style="yellow") - - for sdd_path, manifest in all_sdds: - # Determine if this is legacy or bundle-specific layout - # Bundle-specific (new format): .specfact/projects/<bundle-name>/sdd.yaml - # Legacy single-SDD: .specfact/sdd.yaml (root level) - # Legacy multi-SDD: .specfact/sdd/<bundle-name>.yaml - sdd_path_str = str(sdd_path) - is_bundle_specific = "/projects/" in sdd_path_str or "\\projects\\" in sdd_path_str - layout_type = "[green]bundle-specific[/green]" if is_bundle_specific else "[dim]legacy[/dim]" - - # Format path (relative to base_path) - try: - rel_path = sdd_path.relative_to(base_path) - except ValueError: - rel_path = sdd_path - - # Format hash (first 16 chars) - hash_short = ( - manifest.plan_bundle_hash[:16] + "..." if len(manifest.plan_bundle_hash) > 16 else manifest.plan_bundle_hash - ) - bundle_id_short = ( - manifest.plan_bundle_id[:16] + "..." if len(manifest.plan_bundle_id) > 16 else manifest.plan_bundle_id - ) - - # Format coverage thresholds - coverage_str = ( - f"Contracts/Story: {manifest.coverage_thresholds.contracts_per_story:.1f}, " - f"Invariants/Feature: {manifest.coverage_thresholds.invariants_per_feature:.1f}, " - f"Arch Facets: {manifest.coverage_thresholds.architecture_facets}" - ) - - # Format status - status = manifest.promotion_status - - table.add_row( - f"{rel_path} {layout_type}", - hash_short, - bundle_id_short, - status, - coverage_str, - ) - - console.print() - console.print(table) - console.print(f"\n[dim]Total SDD manifests: {len(all_sdds)}[/dim]") - if is_debug_mode(): - debug_log_operation("command", "sdd list", "success", extra={"count": len(all_sdds)}) - debug_print("[dim]sdd list: success[/dim]") - - # Show layout information - bundle_specific_count = sum(1 for path, _ in all_sdds if "/projects/" in str(path) or "\\projects\\" in str(path)) - legacy_count = len(all_sdds) - bundle_specific_count - - if bundle_specific_count > 0: - console.print(f"[green]✓ {bundle_specific_count} bundle-specific SDD manifest(s) found[/green]") - - if legacy_count > 0: - console.print(f"[yellow]⚠ {legacy_count} legacy SDD manifest(s) found[/yellow]") - console.print( - "[dim]Consider migrating to bundle-specific layout: .specfact/projects/<bundle-name>/sdd.yaml (Phase 8.5)[/dim]" - ) - - -@constitution_app.command("bootstrap") -@beartype -@require(lambda repo: repo.exists(), "Repository path must exist") -@require(lambda repo: repo.is_dir(), "Repository path must be a directory") -@ensure(lambda result: result is None, "Must return None") -def constitution_bootstrap( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Repository path. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Output/Results - out: Path | None = typer.Option( - None, - "--out", - help="Output path for constitution. Default: .specify/memory/constitution.md", - ), - # Behavior/Options - overwrite: bool = typer.Option( - False, - "--overwrite", - help="Overwrite existing constitution if it exists. Default: False", - ), -) -> None: - """ - Generate bootstrap constitution from repository analysis (Spec-Kit compatibility). - - This command generates a constitution in Spec-Kit format (`.specify/memory/constitution.md`) - for compatibility with Spec-Kit artifacts and sync operations. - - **Note**: SpecFact itself uses plan bundles (`.specfact/plans/*.bundle.<format>`) for internal - operations. Constitutions are only needed when syncing with Spec-Kit or working in Spec-Kit format. - - Analyzes the repository (README, pyproject.toml, .cursor/rules/, docs/rules/) - to extract project metadata, development principles, and quality standards, - then generates a bootstrap constitution template ready for review and adjustment. - - **Parameter Groups:** - - **Target/Input**: --repo - - **Output/Results**: --out - - **Behavior/Options**: --overwrite - - **Examples:** - specfact sdd constitution bootstrap --repo . - specfact sdd constitution bootstrap --repo . --out custom-constitution.md - specfact sdd constitution bootstrap --repo . --overwrite - """ - from specfact_cli.telemetry import telemetry - - with telemetry.track_command("sdd.constitution.bootstrap", {"repo": str(repo)}): - console.print(f"[bold cyan]Generating bootstrap constitution for:[/bold cyan] {repo}") - - # Determine output path - if out is None: - # Use Spec-Kit convention: .specify/memory/constitution.md - specify_dir = repo / ".specify" / "memory" - specify_dir.mkdir(parents=True, exist_ok=True) - out = specify_dir / "constitution.md" - else: - out.parent.mkdir(parents=True, exist_ok=True) - - # Check if constitution already exists - if out.exists() and not overwrite: - console.print(f"[yellow]⚠[/yellow] Constitution already exists: {out}") - console.print("[dim]Use --overwrite to replace it[/dim]") - raise typer.Exit(1) - - # Generate bootstrap constitution - print_info("Analyzing repository...") - enricher = ConstitutionEnricher() - enriched_content = enricher.bootstrap(repo, out) - - # Write constitution - out.write_text(enriched_content, encoding="utf-8") - print_success(f"✓ Bootstrap constitution generated: {out}") - - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Review the generated constitution") - console.print("2. Adjust principles and sections as needed") - console.print("3. Run 'specfact sdd constitution validate' to check completeness") - console.print("4. Run 'specfact sync bridge --adapter speckit' to sync with Spec-Kit artifacts") - - -@constitution_app.command("enrich") -@beartype -@require(lambda repo: repo.exists(), "Repository path must exist") -@require(lambda repo: repo.is_dir(), "Repository path must be a directory") -@ensure(lambda result: result is None, "Must return None") -def constitution_enrich( - repo: Path = typer.Option( - Path("."), - "--repo", - help="Repository path (default: current directory)", - exists=True, - file_okay=False, - dir_okay=True, - ), - constitution: Path | None = typer.Option( - None, - "--constitution", - help="Path to constitution file (default: .specify/memory/constitution.md)", - ), -) -> None: - """ - Auto-enrich existing constitution with repository context (Spec-Kit compatibility). - - This command enriches a constitution in Spec-Kit format (`.specify/memory/constitution.md`) - for compatibility with Spec-Kit artifacts and sync operations. - - **Note**: SpecFact itself uses plan bundles (`.specfact/plans/*.bundle.<format>`) for internal - operations. Constitutions are only needed when syncing with Spec-Kit or working in Spec-Kit format. - - Analyzes the repository and enriches the existing constitution with - additional principles and details extracted from repository context. - - Example: - specfact sdd constitution enrich --repo . - """ - from specfact_cli.telemetry import telemetry - - with telemetry.track_command("sdd.constitution.enrich", {"repo": str(repo)}): - # Determine constitution path - if constitution is None: - constitution = repo / ".specify" / "memory" / "constitution.md" - - if not constitution.exists(): - console.print(f"[bold red]✗[/bold red] Constitution not found: {constitution}") - console.print("[dim]Run 'specfact sdd constitution bootstrap' first[/dim]") - raise typer.Exit(1) - - console.print(f"[bold cyan]Enriching constitution:[/bold cyan] {constitution}") - - # Analyze repository - print_info("Analyzing repository...") - enricher = ConstitutionEnricher() - analysis = enricher.analyze_repository(repo) - - # Suggest additional principles - principles = enricher.suggest_principles(analysis) - - console.print(f"[dim]Found {len(principles)} suggested principles[/dim]") - - # Read existing constitution - existing_content = constitution.read_text(encoding="utf-8") - - # Check if enrichment is needed (has placeholders) - import re - - placeholder_pattern = r"\[[A-Z_0-9]+\]" - placeholders = re.findall(placeholder_pattern, existing_content) - - if not placeholders: - console.print("[yellow]⚠[/yellow] Constitution appears complete (no placeholders found)") - console.print("[dim]No enrichment needed[/dim]") - return - - console.print(f"[dim]Found {len(placeholders)} placeholders to enrich[/dim]") - - # Enrich template - suggestions: dict[str, Any] = { - "project_name": analysis.get("project_name", "Project"), - "principles": principles, - "section2_name": "Development Workflow", - "section2_content": enricher._generate_workflow_section(analysis), - "section3_name": "Quality Standards", - "section3_content": enricher._generate_quality_standards_section(analysis), - "governance_rules": "Constitution supersedes all other practices. Amendments require documentation, team approval, and migration plan for breaking changes.", - } - - enriched_content = enricher.enrich_template(constitution, suggestions) - - # Write enriched constitution - constitution.write_text(enriched_content, encoding="utf-8") - print_success(f"✓ Constitution enriched: {constitution}") - - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Review the enriched constitution") - console.print("2. Adjust as needed") - console.print("3. Run 'specfact sdd constitution validate' to check completeness") - - -@constitution_app.command("validate") -@beartype -@require(lambda constitution: constitution.exists(), "Constitution path must exist") -@ensure(lambda result: result is None, "Must return None") -def constitution_validate( - constitution: Path = typer.Option( - Path(".specify/memory/constitution.md"), - "--constitution", - help="Path to constitution file", - exists=True, - ), -) -> None: - """ - Validate constitution completeness (Spec-Kit compatibility). - - This command validates a constitution in Spec-Kit format (`.specify/memory/constitution.md`) - for compatibility with Spec-Kit artifacts and sync operations. - - **Note**: SpecFact itself uses plan bundles (`.specfact/plans/*.bundle.<format>`) for internal - operations. Constitutions are only needed when syncing with Spec-Kit or working in Spec-Kit format. - - Checks if the constitution is complete (no placeholders, has principles, - has governance section, etc.). - - Example: - specfact sdd constitution validate - specfact sdd constitution validate --constitution custom-constitution.md - """ - from specfact_cli.telemetry import telemetry - - with telemetry.track_command("sdd.constitution.validate", {"constitution": str(constitution)}): - console.print(f"[bold cyan]Validating constitution:[/bold cyan] {constitution}") - - enricher = ConstitutionEnricher() - is_valid, issues = enricher.validate(constitution) - - if is_valid: - print_success("✓ Constitution is valid and complete") - else: - print_error("✗ Constitution validation failed") - console.print("\n[bold]Issues found:[/bold]") - for issue in issues: - console.print(f" - {issue}") - - console.print("\n[bold]Next Steps:[/bold]") - console.print("1. Run 'specfact sdd constitution bootstrap' to generate a complete constitution") - console.print("2. Or run 'specfact sdd constitution enrich' to enrich existing constitution") - raise typer.Exit(1) - - -def is_constitution_minimal(constitution_path: Path) -> bool: - """ - Check if constitution is minimal (essentially empty). - - Args: - constitution_path: Path to constitution file - - Returns: - True if constitution is minimal, False otherwise - """ - if not constitution_path.exists(): - return True - - try: - content = constitution_path.read_text(encoding="utf-8").strip() - # Check if it's just a header or very minimal - if not content or content == "# Constitution" or len(content) < 100: - return True - - # Check if it has mostly placeholders - import re - - placeholder_pattern = r"\[[A-Z_0-9]+\]" - placeholders = re.findall(placeholder_pattern, content) - lines = [line.strip() for line in content.split("\n") if line.strip()] - return bool(lines and len(placeholders) > len(lines) * 0.5) - except Exception: - return True +__all__ = ["app"] diff --git a/src/specfact_cli/commands/spec.py b/src/specfact_cli/commands/spec.py index 934e0a3b..2a69f994 100644 --- a/src/specfact_cli/commands/spec.py +++ b/src/specfact_cli/commands/spec.py @@ -1,897 +1,6 @@ -""" -Spec command - Specmatic integration for API contract testing. +"""Backward-compatible app shim. Implementation moved to modules/spec/.""" -This module provides commands for validating OpenAPI/AsyncAPI specifications, -checking backward compatibility, generating test suites, and running mock servers -using Specmatic. -""" +from specfact_cli.modules.spec.src.commands import app -from __future__ import annotations -import hashlib -import json -from contextlib import suppress -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.console import Console -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn -from rich.table import Table - -from specfact_cli.integrations.specmatic import ( - check_backward_compatibility, - check_specmatic_available, - create_mock_server, - generate_specmatic_tests, - validate_spec_with_specmatic, -) -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.utils import print_error, print_info, print_success, print_warning, prompt_text -from specfact_cli.utils.progress import load_bundle_with_progress -from specfact_cli.utils.structure import SpecFactStructure - - -app = typer.Typer( - help="Specmatic integration for API contract testing (OpenAPI/AsyncAPI validation, backward compatibility, mock servers)" -) -console = Console() - - -@app.command("validate") -@beartype -@require(lambda spec_path: spec_path is None or spec_path.exists(), "Spec file must exist if provided") -@ensure(lambda result: result is None, "Must return None") -def validate( - # Target/Input - spec_path: Path | None = typer.Argument( - None, - help="Path to OpenAPI/AsyncAPI specification file (optional if --bundle provided)", - exists=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If provided, validates all contracts in bundle. Default: active plan from 'specfact plan select'", - ), - # Advanced - previous_version: Path | None = typer.Option( - None, - "--previous", - help="Path to previous version for backward compatibility check", - exists=True, - hidden=True, # Hidden by default, shown with --help-advanced - ), - # Behavior/Options - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", - ), - force: bool = typer.Option( - False, - "--force", - help="Force validation even if cached result exists (bypass cache).", - ), -) -> None: - """ - Validate OpenAPI/AsyncAPI specification using Specmatic. - - Runs comprehensive validation including: - - Schema structure validation - - Example generation test - - Backward compatibility check (if previous version provided) - - Can validate a single contract file or all contracts in a project bundle. - Uses active plan (from 'specfact plan select') as default if --bundle not provided. - - **Caching:** - Validation results are cached in `.specfact/cache/specmatic-validation.json` based on - file content hashes. Unchanged contracts are automatically skipped on subsequent runs - to improve performance. Use --force to bypass cache and re-validate all contracts. - - **Parameter Groups:** - - **Target/Input**: spec_path (optional if --bundle provided), --bundle - - **Advanced**: --previous - - **Behavior/Options**: --no-interactive, --force - - **Examples:** - specfact spec validate api/openapi.yaml - specfact spec validate api/openapi.yaml --previous api/openapi.v1.yaml - specfact spec validate --bundle legacy-api - specfact spec validate # Interactive: select from active bundle contracts - specfact spec validate --bundle legacy-api --force # Bypass cache - """ - from specfact_cli.telemetry import telemetry - - if is_debug_mode(): - debug_log_operation( - "command", - "spec validate", - "started", - extra={"spec_path": str(spec_path) if spec_path else None, "bundle": bundle}, - ) - debug_print("[dim]spec validate: started[/dim]") - - repo_path = Path(".").resolve() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo_path) - if bundle: - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - # Determine which contracts to validate - spec_paths: list[Path] = [] - - if spec_path: - # Direct file path provided - spec_paths = [spec_path] - elif bundle: - # Load all contracts from bundle - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - if is_debug_mode(): - debug_log_operation( - "command", - "spec validate", - "failed", - error=f"Project bundle not found: {bundle_dir}", - extra={"reason": "bundle_not_found", "bundle": bundle}, - ) - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - for feature_key, feature in project_bundle.features.items(): - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - spec_paths.append(contract_path) - else: - print_warning(f"Contract file not found for {feature_key}: {feature.contract}") - - if not spec_paths: - print_error("No contract files found in bundle") - raise typer.Exit(1) - - # If multiple contracts and not in non-interactive mode, show selection - if len(spec_paths) > 1 and not no_interactive: - console.print(f"\n[bold]Found {len(spec_paths)} contracts in bundle '{bundle}':[/bold]\n") - table = Table(show_header=True, header_style="bold cyan") - table.add_column("#", style="bold yellow", justify="right", width=4) - table.add_column("Feature", style="bold", min_width=20) - table.add_column("Contract Path", style="dim") - - for i, contract_path in enumerate(spec_paths, 1): - # Find feature key for this contract - feature_key = "Unknown" - for fk, f in project_bundle.features.items(): - if f.contract and (bundle_dir / f.contract) == contract_path: - feature_key = fk - break - - table.add_row( - str(i), - feature_key, - str(contract_path.relative_to(repo_path)), - ) - - console.print(table) - console.print() - - selection = prompt_text( - f"Select contract(s) to validate (1-{len(spec_paths)}, 'all', or 'q' to quit): " - ).strip() - - if selection.lower() in ("q", "quit", ""): - print_info("Validation cancelled") - raise typer.Exit(0) - - if selection.lower() == "all": - # Validate all contracts - pass - else: - try: - indices = [int(x.strip()) for x in selection.split(",")] - if not all(1 <= idx <= len(spec_paths) for idx in indices): - print_error(f"Invalid selection. Must be between 1 and {len(spec_paths)}") - raise typer.Exit(1) - spec_paths = [spec_paths[idx - 1] for idx in indices] - except ValueError: - print_error(f"Invalid input: {selection}. Please enter numbers separated by commas.") - raise typer.Exit(1) from None - else: - # No spec_path and no bundle - show error - print_error("Either spec_path or --bundle must be provided") - console.print("\n[bold]Options:[/bold]") - console.print(" 1. Provide a spec file: specfact spec validate api/openapi.yaml") - console.print(" 2. Use --bundle option: specfact spec validate --bundle legacy-api") - console.print(" 3. Set active plan first: specfact plan select") - raise typer.Exit(1) - - if not spec_paths: - print_error("No contracts to validate") - raise typer.Exit(1) - - telemetry_metadata = { - "spec_path": str(spec_path) if spec_path else None, - "bundle": bundle, - "contracts_count": len(spec_paths), - } - - with telemetry.track_command("spec.validate", telemetry_metadata) as record: - # Check if Specmatic is available - is_available, error_msg = check_specmatic_available() - if not is_available: - if is_debug_mode(): - debug_log_operation( - "command", - "spec validate", - "failed", - error=error_msg or "Specmatic not available", - extra={"reason": "specmatic_unavailable"}, - ) - print_error(f"Specmatic not available: {error_msg}") - console.print("\n[bold]Installation:[/bold]") - console.print("Visit https://docs.specmatic.io/ for installation instructions") - raise typer.Exit(1) - - import asyncio - from datetime import UTC, datetime - from time import time - - # Load validation cache - cache_dir = repo_path / SpecFactStructure.CACHE - cache_dir.mkdir(parents=True, exist_ok=True) - cache_file = cache_dir / "specmatic-validation.json" - validation_cache: dict[str, dict[str, Any]] = {} - if cache_file.exists(): - try: - validation_cache = json.loads(cache_file.read_text()) - except Exception: - validation_cache = {} - - def compute_file_hash(file_path: Path) -> str: - """Compute SHA256 hash of file content.""" - try: - return hashlib.sha256(file_path.read_bytes()).hexdigest() - except Exception: - return "" - - validated_count = 0 - failed_count = 0 - skipped_count = 0 - total_count = len(spec_paths) - - for idx, contract_path in enumerate(spec_paths, 1): - contract_relative = contract_path.relative_to(repo_path) - contract_key = str(contract_relative) - file_hash = compute_file_hash(contract_path) if contract_path.exists() else "" - cache_entry = validation_cache.get(contract_key, {}) - - # Check cache (only if no previous_version specified, as backward compat check can't be cached) - use_cache = ( - not force - and not previous_version - and file_hash - and cache_entry - and cache_entry.get("hash") == file_hash - and cache_entry.get("status") == "success" - and cache_entry.get("is_valid") is True - ) - - if use_cache: - console.print( - f"\n[dim][{idx}/{total_count}][/dim] [bold cyan]Validating specification:[/bold cyan] {contract_relative}" - ) - console.print( - f"[dim]⏭️ Skipping (cache hit - unchanged since {cache_entry.get('timestamp', 'unknown')})[/dim]" - ) - validated_count += 1 - skipped_count += 1 - continue - - console.print( - f"\n[bold yellow][{idx}/{total_count}][/bold yellow] [bold cyan]Validating specification:[/bold cyan] {contract_relative}" - ) - - # Run validation with progress - start_time = time() - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - ) as progress: - task = progress.add_task("Running Specmatic validation...", total=None) - result = asyncio.run(validate_spec_with_specmatic(contract_path, previous_version)) - elapsed = time() - start_time - progress.update(task, description=f"✓ Validation complete ({elapsed:.2f}s)") - - # Display results - table = Table(title=f"Validation Results: {contract_path.name}") - table.add_column("Check", style="cyan") - table.add_column("Status", style="magenta") - table.add_column("Details", style="white") - - # Helper to format details with truncation - def format_details(items: list[str], max_length: int = 100) -> str: - """Format list of items, truncating if too long.""" - if not items: - return "" - if len(items) == 1: - detail = items[0] - return detail[:max_length] + ("..." if len(detail) > max_length else "") - # Multiple items: show first with count - first = items[0][: max_length - 20] - if len(first) < len(items[0]): - first += "..." - return f"{first} (+{len(items) - 1} more)" if len(items) > 1 else first - - # Get errors for each check type - schema_errors = [ - e - for e in result.errors - if "schema" in e.lower() or ("validate" in e.lower() and "example" not in e.lower()) - ] - example_errors = [e for e in result.errors if "example" in e.lower()] - compat_errors = [e for e in result.errors if "backward" in e.lower() or "compatibility" in e.lower()] - - # If we can't categorize, use all errors (fallback) - if not schema_errors and not example_errors and not compat_errors and result.errors: - # Distribute errors: first for schema, second for examples, rest for compat - if len(result.errors) >= 1: - schema_errors = [result.errors[0]] - if len(result.errors) >= 2: - example_errors = [result.errors[1]] - if len(result.errors) > 2: - compat_errors = result.errors[2:] - - table.add_row( - "Schema Validation", - "✓ PASS" if result.schema_valid else "✗ FAIL", - format_details(schema_errors) if not result.schema_valid else "", - ) - - table.add_row( - "Example Generation", - "✓ PASS" if result.examples_valid else "✗ FAIL", - format_details(example_errors) if not result.examples_valid else "", - ) - - if previous_version: - # For backward compatibility, show breaking changes if available, otherwise errors - compat_details = result.breaking_changes if result.breaking_changes else compat_errors - table.add_row( - "Backward Compatibility", - "✓ PASS" if result.backward_compatible else "✗ FAIL", - format_details(compat_details) if not result.backward_compatible else "", - ) - - console.print(table) - - # Show warnings if any - if result.warnings: - console.print("\n[bold yellow]Warnings:[/bold yellow]") - for warning in result.warnings: - console.print(f" ⚠ {warning}") - - # Show all errors in detail if validation failed - if not result.is_valid and result.errors: - console.print("\n[bold red]All Errors:[/bold red]") - for i, error in enumerate(result.errors, 1): - console.print(f" {i}. {error}") - - # Update cache (only if no previous_version, as backward compat can't be cached) - if not previous_version and file_hash: - validation_cache[contract_key] = { - "hash": file_hash, - "status": "success" if result.is_valid else "failure", - "is_valid": result.is_valid, - "schema_valid": result.schema_valid, - "examples_valid": result.examples_valid, - "timestamp": datetime.now(UTC).isoformat(), - } - # Save cache after each validation - with suppress(Exception): # Don't fail validation if cache write fails - cache_file.write_text(json.dumps(validation_cache, indent=2)) - - if result.is_valid: - print_success(f"✓ Specification is valid: {contract_path.name}") - validated_count += 1 - else: - print_error(f"✗ Specification validation failed: {contract_path.name}") - if result.errors: - console.print("\n[bold]Errors:[/bold]") - for error in result.errors: - console.print(f" - {error}") - failed_count += 1 - - if is_debug_mode(): - debug_log_operation( - "command", - "spec validate", - "success", - extra={"validated": validated_count, "skipped": skipped_count, "failed": failed_count}, - ) - debug_print("[dim]spec validate: success[/dim]") - - # Summary - if len(spec_paths) > 1: - console.print("\n[bold]Summary:[/bold]") - console.print(f" Validated: {validated_count}") - if skipped_count > 0: - console.print(f" Skipped (cache): {skipped_count}") - console.print(f" Failed: {failed_count}") - - record({"validated": validated_count, "skipped": skipped_count, "failed": failed_count}) - - if failed_count > 0: - raise typer.Exit(1) - - -@app.command("backward-compat") -@beartype -@require(lambda old_spec: old_spec.exists(), "Old spec file must exist") -@require(lambda new_spec: new_spec.exists(), "New spec file must exist") -@ensure(lambda result: result is None, "Must return None") -def backward_compat( - # Target/Input - old_spec: Path = typer.Argument(..., help="Path to old specification version", exists=True), - new_spec: Path = typer.Argument(..., help="Path to new specification version", exists=True), -) -> None: - """ - Check backward compatibility between two spec versions. - - Compares the new specification against the old version to detect - breaking changes that would affect existing consumers. - - **Parameter Groups:** - - **Target/Input**: old_spec, new_spec (both required) - - **Examples:** - specfact spec backward-compat api/openapi.v1.yaml api/openapi.v2.yaml - """ - import asyncio - - from specfact_cli.telemetry import telemetry - - with telemetry.track_command("spec.backward-compat", {"old_spec": str(old_spec), "new_spec": str(new_spec)}): - # Check if Specmatic is available - is_available, error_msg = check_specmatic_available() - if not is_available: - print_error(f"Specmatic not available: {error_msg}") - raise typer.Exit(1) - - console.print("[bold cyan]Checking backward compatibility...[/bold cyan]") - console.print(f" Old: {old_spec}") - console.print(f" New: {new_spec}") - - is_compatible, breaking_changes = asyncio.run(check_backward_compatibility(old_spec, new_spec)) - - if is_compatible: - print_success("✓ Specifications are backward compatible") - else: - print_error("✗ Backward compatibility check failed") - if breaking_changes: - console.print("\n[bold]Breaking Changes:[/bold]") - for change in breaking_changes: - console.print(f" - {change}") - raise typer.Exit(1) - - -@app.command("generate-tests") -@beartype -@require(lambda spec_path: spec_path.exists() if spec_path else True, "Spec file must exist if provided") -@ensure(lambda result: result is None, "Must return None") -def generate_tests( - # Target/Input - spec_path: Path | None = typer.Argument( - None, help="Path to OpenAPI/AsyncAPI specification (optional if --bundle provided)", exists=True - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If provided, generates tests for all contracts in bundle", - ), - # Output - output_dir: Path | None = typer.Option( - None, - "--output", - "--out", - help="Output directory for generated tests (default: .specfact/specmatic-tests/)", - ), - # Behavior/Options - force: bool = typer.Option( - False, - "--force", - help="Force test generation even if cached result exists (bypass cache).", - ), -) -> None: - """ - Generate Specmatic test suite from specification. - - Auto-generates contract tests from the OpenAPI/AsyncAPI specification - that can be run to validate API implementations. Can generate tests for - a single contract file or all contracts in a project bundle. - - **Caching:** - Test generation results are cached in `.specfact/cache/specmatic-tests.json` based on - file content hashes. Unchanged contracts are automatically skipped on subsequent runs - to improve performance. Use --force to bypass cache and re-generate all tests. - - **Parameter Groups:** - - **Target/Input**: spec_path (optional if --bundle provided), --bundle - - **Output**: --output - - **Behavior/Options**: --force - - **Examples:** - specfact spec generate-tests api/openapi.yaml - specfact spec generate-tests api/openapi.yaml --output tests/specmatic/ - specfact spec generate-tests --bundle legacy-api --output tests/contract/ - specfact spec generate-tests --bundle legacy-api --force # Bypass cache - """ - from rich.console import Console - - from specfact_cli.telemetry import telemetry - from specfact_cli.utils.progress import load_bundle_with_progress - from specfact_cli.utils.structure import SpecFactStructure - - console = Console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(Path(".")) - if bundle: - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - # Validate inputs - if not spec_path and not bundle: - print_error("Either spec_path or --bundle must be provided") - raise typer.Exit(1) - - repo_path = Path(".").resolve() - spec_paths: list[Path] = [] - - # If bundle provided, load all contracts from bundle - if bundle: - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - for feature_key, feature in project_bundle.features.items(): - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - spec_paths.append(contract_path) - else: - print_warning(f"Contract file not found for {feature_key}: {feature.contract}") - elif spec_path: - spec_paths = [spec_path] - - if not spec_paths: - print_error("No contract files found to generate tests from") - raise typer.Exit(1) - - telemetry_metadata = { - "spec_path": str(spec_path) if spec_path else None, - "bundle": bundle, - "contracts_count": len(spec_paths), - } - - with telemetry.track_command("spec.generate-tests", telemetry_metadata) as record: - # Check if Specmatic is available - is_available, error_msg = check_specmatic_available() - if not is_available: - print_error(f"Specmatic not available: {error_msg}") - raise typer.Exit(1) - - import asyncio - from datetime import UTC, datetime - - # Load test generation cache - cache_dir = repo_path / SpecFactStructure.CACHE - cache_dir.mkdir(parents=True, exist_ok=True) - cache_file = cache_dir / "specmatic-tests.json" - test_cache: dict[str, dict[str, Any]] = {} - if cache_file.exists(): - try: - test_cache = json.loads(cache_file.read_text()) - except Exception: - test_cache = {} - - def compute_file_hash(file_path: Path) -> str: - """Compute SHA256 hash of file content.""" - try: - return hashlib.sha256(file_path.read_bytes()).hexdigest() - except Exception: - return "" - - generated_count = 0 - failed_count = 0 - skipped_count = 0 - total_count = len(spec_paths) - - for idx, contract_path in enumerate(spec_paths, 1): - contract_relative = contract_path.relative_to(repo_path) - contract_key = str(contract_relative) - file_hash = compute_file_hash(contract_path) if contract_path.exists() else "" - cache_entry = test_cache.get(contract_key, {}) - - # Check cache - use_cache = ( - not force - and file_hash - and cache_entry - and cache_entry.get("hash") == file_hash - and cache_entry.get("status") == "success" - and cache_entry.get("output_dir") == str(output_dir or Path(".specfact/specmatic-tests")) - ) - - if use_cache: - console.print( - f"\n[dim][{idx}/{total_count}][/dim] [bold cyan]Generating test suite from:[/bold cyan] {contract_relative}" - ) - console.print( - f"[dim]⏭️ Skipping (cache hit - unchanged since {cache_entry.get('timestamp', 'unknown')})[/dim]" - ) - generated_count += 1 - skipped_count += 1 - continue - - console.print( - f"\n[bold yellow][{idx}/{total_count}][/bold yellow] [bold cyan]Generating test suite from:[/bold cyan] {contract_relative}" - ) - - try: - output = asyncio.run(generate_specmatic_tests(contract_path, output_dir)) - print_success(f"✓ Test suite generated: {output}") - - # Update cache - if file_hash: - test_cache[contract_key] = { - "hash": file_hash, - "status": "success", - "output_dir": str(output_dir or Path(".specfact/specmatic-tests")), - "timestamp": datetime.now(UTC).isoformat(), - } - # Save cache after each generation - with suppress(Exception): # Don't fail if cache write fails - cache_file.write_text(json.dumps(test_cache, indent=2)) - - generated_count += 1 - except Exception as e: - print_error(f"✗ Test generation failed for {contract_path.name}: {e!s}") - - # Update cache with failure (so we don't skip failed contracts) - if file_hash: - test_cache[contract_key] = { - "hash": file_hash, - "status": "failure", - "output_dir": str(output_dir or Path(".specfact/specmatic-tests")), - "timestamp": datetime.now(UTC).isoformat(), - } - with suppress(Exception): - cache_file.write_text(json.dumps(test_cache, indent=2)) - - failed_count += 1 - - # Summary - if generated_count > 0: - console.print(f"\n[bold green]✓[/bold green] Generated tests for {generated_count} contract(s)") - if skipped_count > 0: - console.print(f"[dim] Skipped (cache): {skipped_count}[/dim]") - console.print("[dim]Run the generated tests to validate your API implementation[/dim]") - - if failed_count > 0: - print_warning(f"Failed to generate tests for {failed_count} contract(s)") - if generated_count == 0: - raise typer.Exit(1) - - record({"generated": generated_count, "skipped": skipped_count, "failed": failed_count}) - - -@app.command("mock") -@beartype -@require(lambda spec_path: spec_path is None or spec_path.exists(), "Spec file must exist if provided") -@ensure(lambda result: result is None, "Must return None") -def mock( - # Target/Input - spec_path: Path | None = typer.Option( - None, - "--spec", - help="Path to OpenAPI/AsyncAPI specification (optional if --bundle provided)", - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name (e.g., legacy-api). If provided, selects contract from bundle. Default: active plan from 'specfact plan select'", - ), - # Behavior/Options - port: int = typer.Option(9000, "--port", help="Port number for mock server (default: 9000)"), - strict: bool = typer.Option( - True, - "--strict/--examples", - help="Use strict validation mode (default: strict)", - ), - no_interactive: bool = typer.Option( - False, - "--no-interactive", - help="Non-interactive mode (for CI/CD automation). Uses first contract if multiple available.", - ), -) -> None: - """ - Launch Specmatic mock server from specification. - - Starts a mock server that responds to API requests based on the - OpenAPI/AsyncAPI specification. Useful for frontend development - without a running backend. Can use a single spec file or select from bundle contracts. - - **Parameter Groups:** - - **Target/Input**: --spec (optional if --bundle provided), --bundle - - **Behavior/Options**: --port, --strict/--examples, --no-interactive - - **Examples:** - specfact spec mock --spec api/openapi.yaml - specfact spec mock --spec api/openapi.yaml --port 8080 - specfact spec mock --spec api/openapi.yaml --examples - specfact spec mock --bundle legacy-api # Interactive selection - specfact spec mock --bundle legacy-api --no-interactive # Uses first contract - """ - from specfact_cli.telemetry import telemetry - - repo_path = Path(".").resolve() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo_path) - if bundle: - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - # Determine which spec to use - selected_spec: Path | None = None - - if spec_path: - # Direct spec file provided - selected_spec = spec_path - elif bundle: - # Load contracts from bundle - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - if not bundle_dir.exists(): - print_error(f"Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - spec_paths: list[Path] = [] - feature_map: dict[str, str] = {} # contract_path -> feature_key - - for feature_key, feature in project_bundle.features.items(): - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - spec_paths.append(contract_path) - feature_map[str(contract_path)] = feature_key - - if not spec_paths: - print_error("No contract files found in bundle") - raise typer.Exit(1) - - if len(spec_paths) == 1: - # Only one contract, use it - selected_spec = spec_paths[0] - elif no_interactive: - # Non-interactive mode, use first contract - selected_spec = spec_paths[0] - console.print(f"[dim]Using first contract: {feature_map[str(selected_spec)]}[/dim]") - else: - # Interactive selection - console.print(f"\n[bold]Found {len(spec_paths)} contracts in bundle '{bundle}':[/bold]\n") - table = Table(show_header=True, header_style="bold cyan") - table.add_column("#", style="bold yellow", justify="right", width=4) - table.add_column("Feature", style="bold", min_width=20) - table.add_column("Contract Path", style="dim") - - for i, contract_path in enumerate(spec_paths, 1): - feature_key = feature_map.get(str(contract_path), "Unknown") - table.add_row( - str(i), - feature_key, - str(contract_path.relative_to(repo_path)), - ) - - console.print(table) - console.print() - - selection = prompt_text( - f"Select contract to use for mock server (1-{len(spec_paths)} or 'q' to quit): " - ).strip() - - if selection.lower() in ("q", "quit", ""): - print_info("Mock server cancelled") - raise typer.Exit(0) - - try: - idx = int(selection) - if not (1 <= idx <= len(spec_paths)): - print_error(f"Invalid selection. Must be between 1 and {len(spec_paths)}") - raise typer.Exit(1) - selected_spec = spec_paths[idx - 1] - except ValueError: - print_error(f"Invalid input: {selection}. Please enter a number.") - raise typer.Exit(1) from None - else: - # Auto-detect spec if not provided - common_names = [ - "openapi.yaml", - "openapi.yml", - "openapi.json", - "asyncapi.yaml", - "asyncapi.yml", - "asyncapi.json", - ] - for name in common_names: - candidate = Path(name) - if candidate.exists(): - selected_spec = candidate - break - - if selected_spec is None: - print_error("No specification file found. Please provide --spec or --bundle option.") - console.print("\n[bold]Options:[/bold]") - console.print(" 1. Provide a spec file: specfact spec mock --spec api/openapi.yaml") - console.print(" 2. Use --bundle option: specfact spec mock --bundle legacy-api") - console.print(" 3. Set active plan first: specfact plan select") - console.print("\n[bold]Common locations for auto-detection:[/bold]") - console.print(" - openapi.yaml") - console.print(" - api/openapi.yaml") - console.print(" - specs/openapi.yaml") - raise typer.Exit(1) - - telemetry_metadata = { - "spec_path": str(selected_spec) if selected_spec else None, - "bundle": bundle, - "port": port, - } - - with telemetry.track_command("spec.mock", telemetry_metadata): - # Check if Specmatic is available - is_available, error_msg = check_specmatic_available() - if not is_available: - print_error(f"Specmatic not available: {error_msg}") - raise typer.Exit(1) - - console.print("[bold cyan]Starting mock server...[/bold cyan]") - console.print(f" Spec: {selected_spec.relative_to(repo_path)}") - console.print(f" Port: {port}") - console.print(f" Mode: {'strict' if strict else 'examples'}") - - import asyncio - - try: - mock_server = asyncio.run(create_mock_server(selected_spec, port=port, strict_mode=strict)) - print_success(f"✓ Mock server started at http://localhost:{port}") - console.print("\n[bold]Available endpoints:[/bold]") - console.print(f" Try: curl http://localhost:{port}/actuator/health") - console.print("\n[yellow]Press Ctrl+C to stop the server[/yellow]") - - # Keep running until interrupted - try: - import time - - while mock_server.is_running(): - time.sleep(1) - except KeyboardInterrupt: - console.print("\n[yellow]Stopping mock server...[/yellow]") - mock_server.stop() - print_success("✓ Mock server stopped") - except Exception as e: - print_error(f"✗ Failed to start mock server: {e!s}") - raise typer.Exit(1) from e +__all__ = ["app"] diff --git a/src/specfact_cli/commands/sync.py b/src/specfact_cli/commands/sync.py index b8b3c466..a6397791 100644 --- a/src/specfact_cli/commands/sync.py +++ b/src/specfact_cli/commands/sync.py @@ -1,2360 +1,6 @@ -""" -Sync command - Bidirectional synchronization for external tools and repositories. +"""Backward-compatible app shim. Implementation moved to modules/sync/.""" -This module provides commands for synchronizing changes between external tool artifacts -(e.g., Spec-Kit, Linear, Jira), repository changes, and SpecFact plans using the -bridge architecture. -""" +from specfact_cli.modules.sync.src.commands import app -from __future__ import annotations -import os -import re -import shutil -from pathlib import Path -from typing import Any - -import typer -from beartype import beartype -from icontract import ensure, require -from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn - -from specfact_cli import runtime -from specfact_cli.adapters.registry import AdapterRegistry -from specfact_cli.models.bridge import AdapterType -from specfact_cli.models.plan import Feature, PlanBundle -from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode -from specfact_cli.telemetry import telemetry -from specfact_cli.utils.terminal import get_progress_config - - -app = typer.Typer( - help="Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, Linear, Jira, etc.). See 'specfact backlog refine' for template-driven backlog refinement." -) -console = get_configured_console() - - -@beartype -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def _is_test_mode() -> bool: - """Check if running in test mode.""" - # Check for TEST_MODE environment variable - if os.environ.get("TEST_MODE") == "true": - return True - # Check if running under pytest (common patterns) - import sys - - return any("pytest" in arg or "test" in arg.lower() for arg in sys.argv) or "pytest" in sys.modules - - -@beartype -@require(lambda selection: isinstance(selection, str), "Selection must be string") -@ensure(lambda result: isinstance(result, list), "Must return list") -def _parse_backlog_selection(selection: str) -> list[str]: - """Parse backlog selection string into a list of IDs/URLs.""" - if not selection: - return [] - parts = re.split(r"[,\n\r]+", selection) - return [part.strip() for part in parts if part.strip()] - - -@beartype -@require(lambda repo: isinstance(repo, Path), "Repo must be Path") -@ensure(lambda result: result is None or isinstance(result, str), "Must return None or string") -def _infer_bundle_name(repo: Path) -> str | None: - """Infer bundle name from active config or single bundle directory.""" - from specfact_cli.utils.structure import SpecFactStructure - - active_bundle = SpecFactStructure.get_active_bundle_name(repo) - if active_bundle: - return active_bundle - - projects_dir = repo / SpecFactStructure.PROJECTS - if projects_dir.exists(): - candidates = [ - bundle_dir.name - for bundle_dir in projects_dir.iterdir() - if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists() - ] - if len(candidates) == 1: - return candidates[0] - - return None - - -@beartype -@require(lambda repo: repo.exists(), "Repository path must exist") -@require(lambda repo: repo.is_dir(), "Repository path must be a directory") -@require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool") -@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or str") -@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") -@require(lambda adapter_type: adapter_type is not None, "Adapter type must be set") -@ensure(lambda result: result is None, "Must return None") -def _perform_sync_operation( - repo: Path, - bidirectional: bool, - bundle: str | None, - overwrite: bool, - adapter_type: AdapterType, -) -> None: - """ - Perform sync operation without watch mode. - - This is extracted to avoid recursion when called from watch mode callback. - - Args: - repo: Path to repository - bidirectional: Enable bidirectional sync - bundle: Project bundle name - overwrite: Overwrite existing tool artifacts - adapter_type: Adapter type to use - """ - # Step 1: Detect tool repository (using bridge probe for auto-detection) - from specfact_cli.utils.structure import SpecFactStructure - from specfact_cli.validators.schema import validate_plan_bundle - - # Get adapter from registry (universal pattern - no hard-coded checks) - adapter_instance = AdapterRegistry.get_adapter(adapter_type.value) - if adapter_instance is None: - console.print(f"[bold red]✗[/bold red] Adapter '{adapter_type.value}' not found in registry") - console.print("[dim]Available adapters: " + ", ".join(AdapterRegistry.list_adapters()) + "[/dim]") - raise typer.Exit(1) - - # Use adapter's detect() method (no bridge_config needed for initial detection) - if not adapter_instance.detect(repo, None): - console.print(f"[bold red]✗[/bold red] Not a {adapter_type.value} repository") - console.print(f"[dim]Expected: {adapter_type.value} structure[/dim]") - console.print("[dim]Tip: Use 'specfact sync bridge probe' to auto-detect tool configuration[/dim]") - raise typer.Exit(1) - - console.print(f"[bold green]✓[/bold green] Detected {adapter_type.value} repository") - - # Generate bridge config using adapter - bridge_config = adapter_instance.generate_bridge_config(repo) - - # Step 1.5: Validate constitution exists and is not empty (Spec-Kit only) - # Note: Constitution is required for Spec-Kit but not for other adapters (e.g., OpenSpec) - capabilities = adapter_instance.get_capabilities(repo, bridge_config) - if adapter_type == AdapterType.SPECKIT: - has_constitution = capabilities.has_custom_hooks - if not has_constitution: - console.print("[bold red]✗[/bold red] Constitution required") - console.print("[red]Constitution file not found or is empty[/red]") - console.print("\n[bold yellow]Next Steps:[/bold yellow]") - console.print("1. Run 'specfact sdd constitution bootstrap --repo .' to auto-generate constitution") - console.print("2. Or run tool-specific constitution command in your AI assistant") - console.print("3. Then run 'specfact sync bridge --adapter <adapter>' again") - raise typer.Exit(1) - - # Check if constitution is minimal and suggest bootstrap (Spec-Kit only) - if adapter_type == AdapterType.SPECKIT: - constitution_path = repo / ".specify" / "memory" / "constitution.md" - if constitution_path.exists(): - from specfact_cli.commands.sdd import is_constitution_minimal - - if is_constitution_minimal(constitution_path): - # Auto-generate in test mode, prompt in interactive mode - # Check for test environment (TEST_MODE or PYTEST_CURRENT_TEST) - is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None - if is_test_env: - # Auto-generate bootstrap constitution in test mode - from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher - - enricher = ConstitutionEnricher() - enriched_content = enricher.bootstrap(repo, constitution_path) - constitution_path.write_text(enriched_content, encoding="utf-8") - else: - # Check if we're in an interactive environment - if runtime.is_interactive(): - console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)") - suggest_bootstrap = typer.confirm( - "Generate bootstrap constitution from repository analysis?", - default=True, - ) - if suggest_bootstrap: - from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher - - console.print("[dim]Generating bootstrap constitution...[/dim]") - enricher = ConstitutionEnricher() - enriched_content = enricher.bootstrap(repo, constitution_path) - constitution_path.write_text(enriched_content, encoding="utf-8") - console.print("[bold green]✓[/bold green] Bootstrap constitution generated") - console.print("[dim]Review and adjust as needed before syncing[/dim]") - else: - console.print( - "[dim]Skipping bootstrap. Run 'specfact sdd constitution bootstrap' manually if needed[/dim]" - ) - else: - # Non-interactive mode: skip prompt - console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)") - console.print( - "[dim]Run 'specfact sdd constitution bootstrap --repo .' to generate constitution[/dim]" - ) - else: - # Constitution exists and is not minimal - console.print("[bold green]✓[/bold green] Constitution found and validated") - - # Step 2: Detect SpecFact structure - specfact_exists = (repo / SpecFactStructure.ROOT).exists() - - if not specfact_exists: - console.print("[yellow]⚠[/yellow] SpecFact structure not found") - console.print(f"[dim]Initialize with: specfact plan init --scaffold --repo {repo}[/dim]") - # Create structure automatically - SpecFactStructure.ensure_structure(repo) - console.print("[bold green]✓[/bold green] Created SpecFact structure") - - if specfact_exists: - console.print("[bold green]✓[/bold green] Detected SpecFact structure") - - # Use BridgeSync for adapter-agnostic sync operations - from specfact_cli.sync.bridge_sync import BridgeSync - - bridge_sync = BridgeSync(repo, bridge_config=bridge_config) - - # Note: _sync_tool_to_specfact now uses adapter pattern, so converter/scanner are no longer needed - - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - # Step 3: Discover features using adapter (via bridge config) - task = progress.add_task(f"[cyan]Scanning {adapter_type.value} artifacts...[/cyan]", total=None) - progress.update(task, description=f"[cyan]Scanning {adapter_type.value} artifacts...[/cyan]") - - # Discover features using adapter or bridge_sync (adapter-agnostic) - features: list[dict[str, Any]] = [] - # Use adapter's discover_features method if available (e.g., Spec-Kit adapter) - if adapter_instance and hasattr(adapter_instance, "discover_features"): - features = adapter_instance.discover_features(repo, bridge_config) - else: - # For other adapters, use bridge_sync to discover features - feature_ids = bridge_sync._discover_feature_ids() - # Convert feature_ids to feature dicts (simplified for now) - features = [{"feature_key": fid} for fid in feature_ids] - - progress.update(task, description=f"[green]✓[/green] Found {len(features)} features") - - # Step 3.5: Validate tool artifacts for unidirectional sync - if not bidirectional and len(features) == 0: - console.print(f"[bold red]✗[/bold red] No {adapter_type.value} features found") - console.print( - f"[red]Unidirectional sync ({adapter_type.value} → SpecFact) requires at least one feature specification.[/red]" - ) - console.print("\n[bold yellow]Next Steps:[/bold yellow]") - console.print(f"1. Create feature specifications in your {adapter_type.value} project") - console.print(f"2. Then run 'specfact sync bridge --adapter {adapter_type.value}' again") - console.print( - f"\n[dim]Note: For bidirectional sync, {adapter_type.value} artifacts are optional if syncing from SpecFact → {adapter_type.value}[/dim]" - ) - raise typer.Exit(1) - - # Step 4: Sync based on mode - features_converted_speckit = 0 - conflicts: list[dict[str, Any]] = [] # Initialize conflicts for use in summary - - if bidirectional: - # Bidirectional sync: tool → SpecFact and SpecFact → tool - # Step 5.1: tool → SpecFact (unidirectional sync) - # Skip expensive conversion if no tool features found (optimization) - merged_bundle: PlanBundle | None = None - features_updated = 0 - features_added = 0 - - if len(features) == 0: - task = progress.add_task(f"[cyan]📝[/cyan] Converting {adapter_type.value} → SpecFact...", total=None) - progress.update( - task, - description=f"[green]✓[/green] Skipped (no {adapter_type.value} features found)", - ) - console.print(f"[dim] - Skipped {adapter_type.value} → SpecFact (no features found)[/dim]") - # Use existing plan bundle if available, otherwise create minimal empty one - from specfact_cli.utils.structure import SpecFactStructure - from specfact_cli.validators.schema import validate_plan_bundle - - # Use get_default_plan_path() to find the active plan (checks config or falls back to main.bundle.yaml) - plan_path = SpecFactStructure.get_default_plan_path(repo) - if plan_path and plan_path.exists(): - # Show progress while loading plan bundle - progress.update(task, description="[cyan]Parsing plan bundle YAML...[/cyan]") - # Check if path is a directory (modular bundle) - load it first - if plan_path.is_dir(): - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - from specfact_cli.utils.progress import load_bundle_with_progress - - project_bundle = load_bundle_with_progress( - plan_path, - validate_hashes=False, - console_instance=progress.console if hasattr(progress, "console") else None, - ) - loaded_plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - is_valid = True - else: - # It's a file (legacy monolithic bundle) - validate directly - validation_result = validate_plan_bundle(plan_path) - if isinstance(validation_result, tuple): - is_valid, _error, loaded_plan_bundle = validation_result - else: - is_valid = False - loaded_plan_bundle = None - if is_valid and loaded_plan_bundle: - # Show progress during validation (Pydantic validation can be slow for large bundles) - progress.update( - task, - description=f"[cyan]Validating {len(loaded_plan_bundle.features)} features...[/cyan]", - ) - merged_bundle = loaded_plan_bundle - progress.update( - task, - description=f"[green]✓[/green] Loaded plan bundle ({len(loaded_plan_bundle.features)} features)", - ) - else: - # Fallback: create minimal bundle via adapter (but skip expensive parsing) - progress.update( - task, description=f"[cyan]Creating plan bundle from {adapter_type.value}...[/cyan]" - ) - merged_bundle = _sync_tool_to_specfact( - repo, adapter_instance, bridge_config, bridge_sync, progress, task - )[0] - else: - # No plan path found, create minimal bundle - progress.update(task, description=f"[cyan]Creating plan bundle from {adapter_type.value}...[/cyan]") - merged_bundle = _sync_tool_to_specfact( - repo, adapter_instance, bridge_config, bridge_sync, progress, task - )[0] - else: - task = progress.add_task(f"[cyan]Converting {adapter_type.value} → SpecFact...[/cyan]", total=None) - # Show current activity (spinner will show automatically) - progress.update(task, description=f"[cyan]Converting {adapter_type.value} → SpecFact...[/cyan]") - merged_bundle, features_updated, features_added = _sync_tool_to_specfact( - repo, adapter_instance, bridge_config, bridge_sync, progress - ) - - if merged_bundle: - if features_updated > 0 or features_added > 0: - progress.update( - task, - description=f"[green]✓[/green] Updated {features_updated}, Added {features_added} features", - ) - console.print(f"[dim] - Updated {features_updated} features[/dim]") - console.print(f"[dim] - Added {features_added} new features[/dim]") - else: - progress.update( - task, - description=f"[green]✓[/green] Created plan with {len(merged_bundle.features)} features", - ) - - # Step 5.2: SpecFact → tool (reverse conversion) - task = progress.add_task(f"[cyan]Converting SpecFact → {adapter_type.value}...[/cyan]", total=None) - # Show current activity (spinner will show automatically) - progress.update(task, description="[cyan]Detecting SpecFact changes...[/cyan]") - - # Detect SpecFact changes (for tracking/incremental sync, but don't block conversion) - # Uses adapter's change detection if available (adapter-agnostic) - - # Use the merged_bundle we already loaded, or load it if not available - # We convert even if no "changes" detected, as long as plan bundle exists and has features - plan_bundle_to_convert: PlanBundle | None = None - - # Prefer using merged_bundle if it has features (already loaded above) - if merged_bundle and len(merged_bundle.features) > 0: - plan_bundle_to_convert = merged_bundle - else: - # Fallback: load plan bundle from bundle name or default - plan_bundle_to_convert = None - if bundle: - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - from specfact_cli.utils.progress import load_bundle_with_progress - - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if bundle_dir.exists(): - project_bundle = load_bundle_with_progress( - bundle_dir, validate_hashes=False, console_instance=console - ) - plan_bundle_to_convert = _convert_project_bundle_to_plan_bundle(project_bundle) - else: - # Use get_default_plan_path() to find the active plan (legacy compatibility) - plan_path: Path | None = None - if hasattr(SpecFactStructure, "get_default_plan_path"): - plan_path = SpecFactStructure.get_default_plan_path(repo) - if plan_path and plan_path.exists(): - progress.update(task, description="[cyan]Loading plan bundle...[/cyan]") - # Check if path is a directory (modular bundle) - load it first - if plan_path.is_dir(): - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - from specfact_cli.utils.progress import load_bundle_with_progress - - project_bundle = load_bundle_with_progress( - plan_path, - validate_hashes=False, - console_instance=progress.console if hasattr(progress, "console") else None, - ) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - is_valid = True - else: - # It's a file (legacy monolithic bundle) - validate directly - validation_result = validate_plan_bundle(plan_path) - if isinstance(validation_result, tuple): - is_valid, _error, plan_bundle = validation_result - else: - is_valid = False - plan_bundle = None - if is_valid and plan_bundle and len(plan_bundle.features) > 0: - plan_bundle_to_convert = plan_bundle - - # Convert if we have a plan bundle with features - if plan_bundle_to_convert and len(plan_bundle_to_convert.features) > 0: - # Handle overwrite mode - if overwrite: - progress.update(task, description="[cyan]Removing existing artifacts...[/cyan]") - # Delete existing tool artifacts before conversion - specs_dir = repo / "specs" - if specs_dir.exists(): - console.print( - f"[yellow]⚠[/yellow] Overwrite mode: Removing existing {adapter_type.value} artifacts..." - ) - shutil.rmtree(specs_dir) - specs_dir.mkdir(parents=True, exist_ok=True) - console.print("[green]✓[/green] Existing artifacts removed") - - # Convert SpecFact plan bundle to tool format - total_features = len(plan_bundle_to_convert.features) - progress.update( - task, - description=f"[cyan]Converting plan bundle to {adapter_type.value} format (0 of {total_features})...[/cyan]", - ) - - # Progress callback to update during conversion - def update_progress(current: int, total: int) -> None: - progress.update( - task, - description=f"[cyan]Converting plan bundle to {adapter_type.value} format ({current} of {total})...[/cyan]", - ) - - # Use adapter's export_bundle method (adapter-agnostic) - if adapter_instance and hasattr(adapter_instance, "export_bundle"): - features_converted_speckit = adapter_instance.export_bundle( - plan_bundle_to_convert, repo, update_progress, bridge_config - ) - else: - msg = "Bundle export not available for this adapter" - raise RuntimeError(msg) - progress.update( - task, - description=f"[green]✓[/green] Converted {features_converted_speckit} features to {adapter_type.value}", - ) - mode_text = "overwritten" if overwrite else "generated" - console.print( - f"[dim] - {mode_text.capitalize()} spec.md, plan.md, tasks.md for {features_converted_speckit} features[/dim]" - ) - # Warning about Constitution Check gates - console.print( - "[yellow]⚠[/yellow] [dim]Note: Constitution Check gates in plan.md are set to PENDING - review and check gates based on your project's actual state[/dim]" - ) - else: - progress.update(task, description=f"[green]✓[/green] No features to convert to {adapter_type.value}") - features_converted_speckit = 0 - - # Detect conflicts between both directions using adapter - if ( - adapter_instance - and hasattr(adapter_instance, "detect_changes") - and hasattr(adapter_instance, "detect_conflicts") - ): - # Detect changes in both directions - changes_result = adapter_instance.detect_changes(repo, direction="both", bridge_config=bridge_config) - speckit_changes = changes_result.get("speckit_changes", {}) - specfact_changes = changes_result.get("specfact_changes", {}) - # Detect conflicts - conflicts = adapter_instance.detect_conflicts(speckit_changes, specfact_changes) - else: - # Fallback: no conflict detection available - conflicts = [] - - if conflicts: - console.print(f"[yellow]⚠[/yellow] Found {len(conflicts)} conflicts") - console.print( - f"[dim]Conflicts resolved using priority rules (SpecFact > {adapter_type.value} for artifacts)[/dim]" - ) - else: - console.print("[bold green]✓[/bold green] No conflicts detected") - else: - # Unidirectional sync: tool → SpecFact - task = progress.add_task("[cyan]Converting to SpecFact format...[/cyan]", total=None) - # Show current activity (spinner will show automatically) - progress.update(task, description="[cyan]Converting to SpecFact format...[/cyan]") - - merged_bundle, features_updated, features_added = _sync_tool_to_specfact( - repo, adapter_instance, bridge_config, bridge_sync, progress - ) - - if features_updated > 0 or features_added > 0: - task = progress.add_task("[cyan]🔀[/cyan] Merging with existing plan...", total=None) - progress.update( - task, - description=f"[green]✓[/green] Updated {features_updated} features, Added {features_added} features", - ) - console.print(f"[dim] - Updated {features_updated} features[/dim]") - console.print(f"[dim] - Added {features_added} new features[/dim]") - else: - if merged_bundle: - progress.update( - task, description=f"[green]✓[/green] Created plan with {len(merged_bundle.features)} features" - ) - console.print(f"[dim]Created plan with {len(merged_bundle.features)} features[/dim]") - - # Report features synced - console.print() - if features: - console.print("[bold cyan]Features synced:[/bold cyan]") - for feature in features: - feature_key = feature.get("feature_key", "UNKNOWN") - feature_title = feature.get("title", "Unknown Feature") - console.print(f" - [cyan]{feature_key}[/cyan]: {feature_title}") - - # Step 8: Output Results - console.print() - if bidirectional: - console.print("[bold cyan]Sync Summary (Bidirectional):[/bold cyan]") - console.print( - f" - {adapter_type.value} → SpecFact: Updated {features_updated}, Added {features_added} features" - ) - # Always show conversion result (we convert if plan bundle exists, not just when changes detected) - if features_converted_speckit > 0: - console.print( - f" - SpecFact → {adapter_type.value}: {features_converted_speckit} features converted to {adapter_type.value} format" - ) - else: - console.print(f" - SpecFact → {adapter_type.value}: No features to convert") - if conflicts: - console.print(f" - Conflicts: {len(conflicts)} detected and resolved") - else: - console.print(" - Conflicts: None detected") - - # Post-sync validation suggestion - if features_converted_speckit > 0: - console.print() - console.print("[bold cyan]Next Steps:[/bold cyan]") - console.print(f" Validate {adapter_type.value} artifact consistency and quality") - console.print(" This will check for ambiguities, duplications, and constitution alignment") - else: - console.print("[bold cyan]Sync Summary (Unidirectional):[/bold cyan]") - if features: - console.print(f" - Features synced: {len(features)}") - if features_updated > 0 or features_added > 0: - console.print(f" - Updated: {features_updated} features") - console.print(f" - Added: {features_added} new features") - console.print(f" - Direction: {adapter_type.value} → SpecFact") - - # Post-sync validation suggestion - console.print() - console.print("[bold cyan]Next Steps:[/bold cyan]") - console.print(f" Validate {adapter_type.value} artifact consistency and quality") - console.print(" This will check for ambiguities, duplications, and constitution alignment") - - console.print() - console.print("[bold green]✓[/bold green] Sync complete!") - - # Auto-validate OpenAPI/AsyncAPI specs with Specmatic (if found) - import asyncio - - from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic - - spec_files = [] - for pattern in [ - "**/openapi.yaml", - "**/openapi.yml", - "**/openapi.json", - "**/asyncapi.yaml", - "**/asyncapi.yml", - "**/asyncapi.json", - ]: - spec_files.extend(repo.glob(pattern)) - - if spec_files: - console.print(f"\n[cyan]🔍 Found {len(spec_files)} API specification file(s)[/cyan]") - is_available, error_msg = check_specmatic_available() - if is_available: - for spec_file in spec_files[:3]: # Validate up to 3 specs - console.print(f"[dim]Validating {spec_file.relative_to(repo)} with Specmatic...[/dim]") - try: - result = asyncio.run(validate_spec_with_specmatic(spec_file)) - if result.is_valid: - console.print(f" [green]✓[/green] {spec_file.name} is valid") - else: - console.print(f" [yellow]⚠[/yellow] {spec_file.name} has validation issues") - if result.errors: - for error in result.errors[:2]: # Show first 2 errors - console.print(f" - {error}") - except Exception as e: - console.print(f" [yellow]⚠[/yellow] Validation error: {e!s}") - if len(spec_files) > 3: - console.print( - f"[dim]... and {len(spec_files) - 3} more spec file(s) (run 'specfact spec validate' to validate all)[/dim]" - ) - else: - console.print(f"[dim]💡 Tip: Install Specmatic to validate API specs: {error_msg}[/dim]") - - -@beartype -@require(lambda repo: repo.exists(), "Repository path must exist") -@require(lambda repo: repo.is_dir(), "Repository path must be a directory") -@require(lambda adapter_instance: adapter_instance is not None, "Adapter instance must not be None") -@require(lambda bridge_config: bridge_config is not None, "Bridge config must not be None") -@require(lambda bridge_sync: bridge_sync is not None, "Bridge sync must not be None") -@require(lambda progress: progress is not None, "Progress must not be None") -@require(lambda task: task is None or (isinstance(task, int) and task >= 0), "Task must be None or non-negative int") -@ensure(lambda result: isinstance(result, tuple) and len(result) == 3, "Must return tuple of 3 elements") -@ensure(lambda result: isinstance(result[0], PlanBundle), "First element must be PlanBundle") -@ensure(lambda result: isinstance(result[1], int) and result[1] >= 0, "Second element must be non-negative int") -@ensure(lambda result: isinstance(result[2], int) and result[2] >= 0, "Third element must be non-negative int") -def _sync_tool_to_specfact( - repo: Path, - adapter_instance: Any, - bridge_config: Any, - bridge_sync: Any, - progress: Any, - task: int | None = None, -) -> tuple[PlanBundle, int, int]: - """ - Sync tool artifacts to SpecFact format using adapter registry pattern. - - This is an adapter-agnostic replacement for _sync_speckit_to_specfact that uses - the adapter registry instead of hard-coded converter/scanner instances. - - Args: - repo: Repository path - adapter_instance: Adapter instance from registry - bridge_config: Bridge configuration - bridge_sync: BridgeSync instance - progress: Rich Progress instance - task: Optional progress task ID to update - - Returns: - Tuple of (merged_bundle, features_updated, features_added) - """ - from specfact_cli.generators.plan_generator import PlanGenerator - from specfact_cli.utils.structure import SpecFactStructure - from specfact_cli.validators.schema import validate_plan_bundle - - plan_path = SpecFactStructure.get_default_plan_path(repo) - existing_bundle: PlanBundle | None = None - # Check if plan_path is a modular bundle directory (even if it doesn't exist yet) - is_modular_bundle = (plan_path.exists() and plan_path.is_dir()) or ( - not plan_path.exists() and plan_path.parent.name == "projects" - ) - - if plan_path.exists(): - if task is not None: - progress.update(task, description="[cyan]Validating existing plan bundle...[/cyan]") - # Check if path is a directory (modular bundle) - load it first - if plan_path.is_dir(): - is_modular_bundle = True - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - from specfact_cli.utils.progress import load_bundle_with_progress - - project_bundle = load_bundle_with_progress( - plan_path, - validate_hashes=False, - console_instance=progress.console if hasattr(progress, "console") else None, - ) - bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - is_valid = True - else: - # It's a file (legacy monolithic bundle) - validate directly - validation_result = validate_plan_bundle(plan_path) - if isinstance(validation_result, tuple): - is_valid, _error, bundle = validation_result - else: - is_valid = False - bundle = None - if is_valid and bundle: - existing_bundle = bundle - # Deduplicate existing features by normalized key (clean up duplicates from previous syncs) - from specfact_cli.utils.feature_keys import normalize_feature_key - - seen_normalized_keys: set[str] = set() - deduplicated_features: list[Feature] = [] - for existing_feature in existing_bundle.features: - normalized_key = normalize_feature_key(existing_feature.key) - if normalized_key not in seen_normalized_keys: - seen_normalized_keys.add(normalized_key) - deduplicated_features.append(existing_feature) - - duplicates_removed = len(existing_bundle.features) - len(deduplicated_features) - if duplicates_removed > 0: - existing_bundle.features = deduplicated_features - # Write back deduplicated bundle immediately to clean up the plan file - from specfact_cli.generators.plan_generator import PlanGenerator - - if task is not None: - progress.update( - task, - description=f"[cyan]Deduplicating {duplicates_removed} duplicate features and writing cleaned plan...[/cyan]", - ) - # Skip writing if plan_path is a modular bundle directory (already saved as ProjectBundle) - if not is_modular_bundle: - generator = PlanGenerator() - generator.generate(existing_bundle, plan_path) - if task is not None: - progress.update( - task, - description=f"[green]✓[/green] Removed {duplicates_removed} duplicates, cleaned plan saved", - ) - - # Convert tool artifacts to SpecFact using adapter pattern - if task is not None: - progress.update(task, description="[cyan]Converting tool artifacts to SpecFact format...[/cyan]") - - # Get default bundle name for ProjectBundle operations - from specfact_cli.utils.structure import SpecFactStructure - - bundle_name = SpecFactStructure.get_active_bundle_name(repo) or SpecFactStructure.DEFAULT_PLAN_NAME - bundle_dir = repo / SpecFactStructure.PROJECTS / bundle_name - - # Ensure bundle directory exists - bundle_dir.mkdir(parents=True, exist_ok=True) - - # Load or create ProjectBundle - from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle - from specfact_cli.utils.bundle_loader import load_project_bundle - - project_bundle: ProjectBundle | None = None - if bundle_dir.exists() and (bundle_dir / "bundle.manifest.yaml").exists(): - try: - project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) - except Exception: - # Bundle exists but failed to load - create new one - project_bundle = None - - if project_bundle is None: - # Create new ProjectBundle with latest schema version - from specfact_cli.migrations.plan_migrator import get_latest_schema_version - - manifest = BundleManifest( - versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - from specfact_cli.models.plan import Product - - project_bundle = ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - product=Product(themes=[], releases=[]), - features={}, - idea=None, - business=None, - clarifications=None, - ) - - # Discover features using adapter - discovered_features = [] - if hasattr(adapter_instance, "discover_features"): - discovered_features = adapter_instance.discover_features(repo, bridge_config) - else: - # Fallback: use bridge_sync to discover feature IDs - feature_ids = bridge_sync._discover_feature_ids() - discovered_features = [{"feature_key": fid} for fid in feature_ids] - - # Import each feature using adapter pattern - # Import artifacts in order: specification (required), then plan and tasks (if available) - artifact_order = ["specification", "plan", "tasks"] - for feature_data in discovered_features: - feature_id = feature_data.get("feature_key", "") - if not feature_id: - continue - - # Import artifacts in order (specification first, then plan/tasks if available) - for artifact_key in artifact_order: - # Check if artifact type is supported by bridge config - if artifact_key not in bridge_config.artifacts: - continue - - try: - result = bridge_sync.import_artifact(artifact_key, feature_id, bundle_name) - if not result.success and task is not None and artifact_key == "specification": - # Log error but continue with other artifacts/features - # Only show warning for specification (required), skip warnings for optional artifacts - progress.update( - task, - description=f"[yellow]⚠[/yellow] Failed to import {artifact_key} for {feature_id}: {result.errors[0] if result.errors else 'Unknown error'}", - ) - except Exception as e: - # Log error but continue - if task is not None and artifact_key == "specification": - progress.update( - task, description=f"[yellow]⚠[/yellow] Error importing {artifact_key} for {feature_id}: {e}" - ) - - # Save project bundle after all imports (BridgeSync.import_artifact saves automatically, but ensure it's saved) - from specfact_cli.utils.bundle_loader import save_project_bundle - - try: - project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) - save_project_bundle(project_bundle, bundle_dir, atomic=True) - except Exception: - # If loading fails, we'll create a new bundle below - project_bundle = None - - # Reload project bundle to get updated features (after all imports) - # BridgeSync.import_artifact saves automatically, so reload to get latest state - try: - project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) - except Exception: - # If loading fails after imports, something went wrong - create minimal bundle - if project_bundle is None: - from specfact_cli.migrations.plan_migrator import get_latest_schema_version - - manifest = BundleManifest( - versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - from specfact_cli.models.plan import Product - - project_bundle = ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - product=Product(themes=[], releases=[]), - features={}, - idea=None, - business=None, - clarifications=None, - ) - save_project_bundle(project_bundle, bundle_dir, atomic=True) - - # Convert ProjectBundle to PlanBundle for merging logic - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - - converted_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - # Merge with existing plan if it exists - features_updated = 0 - features_added = 0 - - if existing_bundle: - if task is not None: - progress.update(task, description="[cyan]Merging with existing plan bundle...[/cyan]") - # Use normalized keys for matching to handle different key formats (e.g., FEATURE-001 vs 001_FEATURE_NAME) - from specfact_cli.utils.feature_keys import normalize_feature_key - - # Build a map of normalized_key -> (index, original_key) for existing features - normalized_key_map: dict[str, tuple[int, str]] = {} - for idx, existing_feature in enumerate(existing_bundle.features): - normalized_key = normalize_feature_key(existing_feature.key) - # If multiple features have the same normalized key, keep the first one - if normalized_key not in normalized_key_map: - normalized_key_map[normalized_key] = (idx, existing_feature.key) - - for feature in converted_bundle.features: - normalized_key = normalize_feature_key(feature.key) - matched = False - - # Try exact match first - if normalized_key in normalized_key_map: - existing_idx, original_key = normalized_key_map[normalized_key] - # Preserve the original key format from existing bundle - feature.key = original_key - existing_bundle.features[existing_idx] = feature - features_updated += 1 - matched = True - else: - # Try prefix match for abbreviated vs full names - # (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM) - # Only match if shorter is a PREFIX of longer with significant length difference - # AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin - # This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis) - for existing_norm_key, (existing_idx, original_key) in normalized_key_map.items(): - shorter = min(normalized_key, existing_norm_key, key=len) - longer = max(normalized_key, existing_norm_key, key=len) - - # Check if at least one key has a numbered prefix (tool format, e.g., Spec-Kit) - import re - - has_speckit_key = bool( - re.match(r"^\d{3}[_-]", feature.key) or re.match(r"^\d{3}[_-]", original_key) - ) - - # More conservative matching: - # 1. At least one key must have numbered prefix (tool origin, e.g., Spec-Kit) - # 2. Shorter must be at least 10 chars - # 3. Longer must start with shorter (prefix match) - # 4. Length difference must be at least 6 chars - # 5. Shorter must be < 75% of longer (to ensure significant difference) - length_diff = len(longer) - len(shorter) - length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0 - - if ( - has_speckit_key - and len(shorter) >= 10 - and longer.startswith(shorter) - and length_diff >= 6 - and length_ratio < 0.75 - ): - # Match found - use the existing key format (prefer full name if available) - if len(existing_norm_key) >= len(normalized_key): - # Existing key is longer (full name) - keep it - feature.key = original_key - else: - # New key is longer (full name) - use it but update existing - existing_bundle.features[existing_idx].key = feature.key - existing_bundle.features[existing_idx] = feature - features_updated += 1 - matched = True - break - - if not matched: - # New feature - add it - existing_bundle.features.append(feature) - features_added += 1 - - # Update product themes - themes_existing = set(existing_bundle.product.themes) - themes_new = set(converted_bundle.product.themes) - existing_bundle.product.themes = list(themes_existing | themes_new) - - # Write merged bundle (skip if modular bundle - already saved as ProjectBundle) - if not is_modular_bundle: - if task is not None: - progress.update(task, description="[cyan]Writing plan bundle to disk...[/cyan]") - generator = PlanGenerator() - generator.generate(existing_bundle, plan_path) - return existing_bundle, features_updated, features_added - # Write new bundle (skip if plan_path is a modular bundle directory) - if not is_modular_bundle: - # Legacy monolithic file - write it - generator = PlanGenerator() - generator.generate(converted_bundle, plan_path) - return converted_bundle, 0, len(converted_bundle.features) - - -@app.command("bridge") -@beartype -@require(lambda repo: repo.exists(), "Repository path must exist") -@require(lambda repo: repo.is_dir(), "Repository path must be a directory") -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle must be None or non-empty str", -) -@require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool") -@require( - lambda mode: mode is None - or mode in ("read-only", "export-only", "import-annotation", "bidirectional", "unidirectional"), - "Mode must be valid sync mode", -) -@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") -@require( - lambda adapter: adapter is None or (isinstance(adapter, str) and len(adapter) > 0), - "Adapter must be None or non-empty str", -) -@ensure(lambda result: result is None, "Must return None") -def sync_bridge( - # Target/Input - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - bundle: str | None = typer.Option( - None, - "--bundle", - help="Project bundle name for SpecFact → tool conversion (default: auto-detect). Required for cross-adapter sync to preserve lossless content.", - ), - # Behavior/Options - bidirectional: bool = typer.Option( - False, - "--bidirectional", - help="Enable bidirectional sync (tool ↔ SpecFact)", - ), - mode: str | None = typer.Option( - None, - "--mode", - help="Sync mode: 'read-only' (OpenSpec → SpecFact), 'export-only' (SpecFact → DevOps), 'bidirectional' (tool ↔ SpecFact). Default: bidirectional if --bidirectional, else unidirectional. For backlog adapters (github/ado), use 'export-only' with --bundle for cross-adapter sync.", - ), - overwrite: bool = typer.Option( - False, - "--overwrite", - help="Overwrite existing tool artifacts (delete all existing before sync)", - ), - watch: bool = typer.Option( - False, - "--watch", - help="Watch mode for continuous sync", - ), - ensure_compliance: bool = typer.Option( - False, - "--ensure-compliance", - help="Validate and auto-enrich plan bundle for tool compliance before sync", - ), - # Advanced/Configuration - adapter: str = typer.Option( - "speckit", - "--adapter", - help="Adapter type: speckit, openspec, generic-markdown, github (available), ado (available), linear, jira, notion (future). Default: auto-detect. Use 'github' or 'ado' for backlog sync with cross-adapter capabilities (requires --bundle for lossless sync).", - hidden=True, # Hidden by default, shown with --help-advanced - ), - repo_owner: str | None = typer.Option( - None, - "--repo-owner", - help="GitHub repository owner (for GitHub adapter). Required for GitHub backlog sync.", - hidden=True, - ), - repo_name: str | None = typer.Option( - None, - "--repo-name", - help="GitHub repository name (for GitHub adapter). Required for GitHub backlog sync.", - hidden=True, - ), - external_base_path: Path | None = typer.Option( - None, - "--external-base-path", - help="Base path for external tool repository (for cross-repo integrations, e.g., OpenSpec in different repo)", - file_okay=False, - dir_okay=True, - ), - github_token: str | None = typer.Option( - None, - "--github-token", - help="GitHub API token (optional, uses GITHUB_TOKEN env var or gh CLI if not provided)", - hidden=True, - ), - use_gh_cli: bool = typer.Option( - True, - "--use-gh-cli/--no-gh-cli", - help="Use GitHub CLI (`gh auth token`) to get token automatically (default: True). Useful in enterprise environments where PAT creation is restricted.", - hidden=True, - ), - ado_org: str | None = typer.Option( - None, - "--ado-org", - help="Azure DevOps organization (for ADO adapter). Required for ADO backlog sync.", - hidden=True, - ), - ado_project: str | None = typer.Option( - None, - "--ado-project", - help="Azure DevOps project (for ADO adapter). Required for ADO backlog sync.", - hidden=True, - ), - ado_base_url: str | None = typer.Option( - None, - "--ado-base-url", - help="Azure DevOps base URL (for ADO adapter, defaults to https://dev.azure.com). Use for Azure DevOps Server (on-prem).", - hidden=True, - ), - ado_token: str | None = typer.Option( - None, - "--ado-token", - help="Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided). Requires Work Items (Read & Write) permissions.", - hidden=True, - ), - ado_work_item_type: str | None = typer.Option( - None, - "--ado-work-item-type", - help="Azure DevOps work item type (for ADO adapter, derived from process template if not provided). Examples: 'User Story', 'Product Backlog Item', 'Bug'.", - hidden=True, - ), - sanitize: bool | None = typer.Option( - None, - "--sanitize/--no-sanitize", - help="Sanitize proposal content for public issues (default: auto-detect based on repo setup). Removes competitive analysis, internal strategy, implementation details.", - hidden=True, - ), - target_repo: str | None = typer.Option( - None, - "--target-repo", - help="Target repository for issue creation (format: owner/repo). Default: same as code repository.", - hidden=True, - ), - interactive: bool = typer.Option( - False, - "--interactive", - help="Interactive mode for AI-assisted sanitization (requires slash command).", - hidden=True, - ), - change_ids: str | None = typer.Option( - None, - "--change-ids", - help="Comma-separated list of change proposal IDs to export (default: all active proposals). Use with --bundle for cross-adapter export. Example: 'add-feature-x,update-api'. Find change IDs in import output or bundle directory.", - ), - backlog_ids: str | None = typer.Option( - None, - "--backlog-ids", - help="Comma-separated list of backlog item IDs or URLs to import (GitHub/ADO). Use with --bundle to store lossless content for cross-adapter sync. Example: '123,456' or 'https://github.com/org/repo/issues/123'", - ), - backlog_ids_file: Path | None = typer.Option( - None, - "--backlog-ids-file", - help="Path to file containing backlog item IDs/URLs (one per line or comma-separated).", - exists=True, - file_okay=True, - dir_okay=False, - ), - export_to_tmp: bool = typer.Option( - False, - "--export-to-tmp", - help="Export proposal content to temporary file for LLM review (default: <system-temp>/specfact-proposal-<change-id>.md).", - hidden=True, - ), - import_from_tmp: bool = typer.Option( - False, - "--import-from-tmp", - help="Import sanitized content from temporary file after LLM review (default: <system-temp>/specfact-proposal-<change-id>-sanitized.md).", - hidden=True, - ), - tmp_file: Path | None = typer.Option( - None, - "--tmp-file", - help="Custom temporary file path (default: <system-temp>/specfact-proposal-<change-id>.md).", - hidden=True, - ), - update_existing: bool = typer.Option( - False, - "--update-existing/--no-update-existing", - help="Update existing issue bodies when proposal content changes (default: False for safety). Uses content hash to detect changes.", - hidden=True, - ), - track_code_changes: bool = typer.Option( - False, - "--track-code-changes/--no-track-code-changes", - help="Detect code changes (git commits, file modifications) and add progress comments to existing issues (default: False).", - hidden=True, - ), - add_progress_comment: bool = typer.Option( - False, - "--add-progress-comment/--no-add-progress-comment", - help="Add manual progress comment to existing issues without code change detection (default: False).", - hidden=True, - ), - code_repo: Path | None = typer.Option( - None, - "--code-repo", - help="Path to source code repository for code change detection (default: same as --repo). Required when OpenSpec repository differs from source code repository.", - hidden=True, - ), - include_archived: bool = typer.Option( - False, - "--include-archived/--no-include-archived", - help="Include archived change proposals in sync (default: False). Useful for updating existing issues with new comment logic or branch detection improvements.", - hidden=True, - ), - interval: int = typer.Option( - 5, - "--interval", - help="Watch interval in seconds (default: 5)", - min=1, - hidden=True, # Hidden by default, shown with --help-advanced - ), -) -> None: - """ - Sync changes between external tool artifacts and SpecFact using bridge architecture. - - Synchronizes artifacts from external tools (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.) with - SpecFact project bundles using configurable bridge mappings. - - **Related**: Use `specfact backlog refine` to standardize backlog items with template-driven refinement - before syncing to OpenSpec bundles. See backlog refinement guide for details. - - Supported adapters: - - - speckit: Spec-Kit projects (specs/, .specify/) - import & sync - - generic-markdown: Generic markdown-based specifications - import & sync - - openspec: OpenSpec integration (openspec/) - read-only sync (Phase 1) - - github: GitHub Issues - bidirectional sync (import issues as change proposals, export proposals as issues) - - ado: Azure DevOps Work Items - bidirectional sync (import work items as change proposals, export proposals as work items) - - linear: Linear Issues (future) - planned - - jira: Jira Issues (future) - planned - - notion: Notion pages (future) - planned - - **Sync Modes:** - - - read-only: OpenSpec → SpecFact (read specs, no writes) - OpenSpec adapter only - - bidirectional: Full two-way sync (tool ↔ SpecFact) - Spec-Kit, GitHub, and ADO adapters - - GitHub: Import issues as change proposals, export proposals as issues - - ADO: Import work items as change proposals, export proposals as work items - - Spec-Kit: Full bidirectional sync of specs and plans - - export-only: SpecFact → DevOps (create/update issues/work items, no import) - GitHub and ADO adapters - - import-annotation: DevOps → SpecFact (import issues, annotate with findings) - future - - **🚀 Cross-Adapter Sync (Advanced Feature):** - - Enable lossless round-trip synchronization between different backlog adapters (GitHub ↔ ADO): - - Use --bundle to preserve lossless content during cross-adapter syncs - - Import from one adapter (e.g., GitHub) into a bundle, then export to another (e.g., ADO) - - Content is preserved exactly as imported, enabling 100% fidelity migrations - - Example: Import GitHub issue → bundle → export to ADO (no content loss) - - **Parameter Groups:** - - - **Target/Input**: --repo, --bundle - - **Behavior/Options**: --bidirectional, --mode, --overwrite, --watch, --ensure-compliance - - **Advanced/Configuration**: --adapter, --interval, --repo-owner, --repo-name, --github-token - - **GitHub Options**: --repo-owner, --repo-name, --github-token, --use-gh-cli, --sanitize - - **ADO Options**: --ado-org, --ado-project, --ado-base-url, --ado-token, --ado-work-item-type - - **Basic Examples:** - - specfact sync bridge --adapter speckit --repo . --bidirectional - specfact sync bridge --adapter openspec --repo . --mode read-only # OpenSpec → SpecFact (read-only) - specfact sync bridge --adapter openspec --repo . --external-base-path ../other-repo # Cross-repo OpenSpec - specfact sync bridge --repo . --bidirectional # Auto-detect adapter - specfact sync bridge --repo . --watch --interval 10 - - **GitHub Examples:** - - specfact sync bridge --adapter github --bidirectional --repo-owner owner --repo-name repo # Bidirectional sync - specfact sync bridge --adapter github --mode export-only --repo-owner owner --repo-name repo # Export only - specfact sync bridge --adapter github --update-existing # Update existing issues when content changes - specfact sync bridge --adapter github --track-code-changes # Detect code changes and add progress comments - specfact sync bridge --adapter github --add-progress-comment # Add manual progress comment - - **Azure DevOps Examples:** - - specfact sync bridge --adapter ado --bidirectional --ado-org myorg --ado-project myproject # Bidirectional sync - specfact sync bridge --adapter ado --mode export-only --ado-org myorg --ado-project myproject # Export only - specfact sync bridge --adapter ado --mode export-only --ado-org myorg --ado-project myproject --bundle main # Bundle export - - **Cross-Adapter Sync Examples:** - - # GitHub → ADO Migration (lossless round-trip) - specfact sync bridge --adapter github --mode bidirectional --bundle migration --backlog-ids 123 - # Output shows: "✓ Imported GitHub issue #123 as change proposal: add-feature-x" - specfact sync bridge --adapter ado --mode export-only --bundle migration --change-ids add-feature-x - - # Multi-Tool Workflow (public GitHub + internal ADO) - specfact sync bridge --adapter github --mode export-only --sanitize # Export to public GitHub - specfact sync bridge --adapter github --mode bidirectional --bundle internal --backlog-ids 123 # Import to bundle - specfact sync bridge --adapter ado --mode export-only --bundle internal --change-ids <change-id> # Export to ADO - - **Finding Change IDs:** - - - Change IDs are shown in import output: "✓ Imported as change proposal: <change-id>" - - Or check bundle directory: ls .specfact/projects/<bundle>/change_tracking/proposals/ - - Or check OpenSpec directory: ls openspec/changes/ - - See docs/guides/devops-adapter-integration.md for complete documentation. - """ - if is_debug_mode(): - debug_log_operation( - "command", - "sync bridge", - "started", - extra={"repo": str(repo), "bundle": bundle, "adapter": adapter, "bidirectional": bidirectional}, - ) - debug_print("[dim]sync bridge: started[/dim]") - - # Auto-detect adapter if not specified - from specfact_cli.sync.bridge_probe import BridgeProbe - - if adapter == "speckit" or adapter == "auto": - probe = BridgeProbe(repo) - detected_capabilities = probe.detect() - # Use detected tool directly (e.g., "speckit", "openspec", "github") - # BridgeProbe already tries all registered adapters - if detected_capabilities.tool == "unknown": - console.print("[bold red]✗[/bold red] Could not auto-detect adapter") - console.print("[dim]No registered adapter detected this repository structure[/dim]") - registered = AdapterRegistry.list_adapters() - console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") - console.print("[dim]Tip: Specify adapter explicitly with --adapter <adapter>[/dim]") - raise typer.Exit(1) - adapter = detected_capabilities.tool - - # Validate adapter using registry (no hard-coded checks) - adapter_lower = adapter.lower() - if not AdapterRegistry.is_registered(adapter_lower): - console.print(f"[bold red]✗[/bold red] Unsupported adapter: {adapter}") - registered = AdapterRegistry.list_adapters() - console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") - raise typer.Exit(1) - - # Convert to AdapterType enum (for backward compatibility with existing code) - try: - adapter_type = AdapterType(adapter_lower) - except ValueError: - # Adapter is registered but not in enum (e.g., openspec might not be in enum yet) - # Use adapter string value directly - adapter_type = None - - # Determine adapter_value for use throughout function - adapter_value = adapter_type.value if adapter_type else adapter_lower - - # Determine sync mode using adapter capabilities (adapter-agnostic) - if mode is None: - # Get adapter to check capabilities - adapter_instance = AdapterRegistry.get_adapter(adapter_lower) - if adapter_instance: - # Get capabilities to determine supported sync modes - probe = BridgeProbe(repo) - capabilities = probe.detect() - bridge_config = probe.auto_generate_bridge(capabilities) if capabilities.tool != "unknown" else None - adapter_capabilities = adapter_instance.get_capabilities(repo, bridge_config) - - # Use adapter's supported sync modes if available - if adapter_capabilities.supported_sync_modes: - # Auto-select based on adapter capabilities and context - if "export-only" in adapter_capabilities.supported_sync_modes and (repo_owner or repo_name): - sync_mode = "export-only" - elif "read-only" in adapter_capabilities.supported_sync_modes: - sync_mode = "read-only" - elif "bidirectional" in adapter_capabilities.supported_sync_modes: - sync_mode = "bidirectional" if bidirectional else "unidirectional" - else: - sync_mode = "unidirectional" # Default fallback - else: - # Fallback: use bidirectional/unidirectional based on flag - sync_mode = "bidirectional" if bidirectional else "unidirectional" - else: - # Fallback if adapter not found - sync_mode = "bidirectional" if bidirectional else "unidirectional" - else: - sync_mode = mode.lower() - - # Validate mode for adapter type using adapter capabilities - adapter_instance = AdapterRegistry.get_adapter(adapter_lower) - adapter_capabilities = None - if adapter_instance: - probe = BridgeProbe(repo) - capabilities = probe.detect() - bridge_config = probe.auto_generate_bridge(capabilities) if capabilities.tool != "unknown" else None - adapter_capabilities = adapter_instance.get_capabilities(repo, bridge_config) - - if adapter_capabilities.supported_sync_modes and sync_mode not in adapter_capabilities.supported_sync_modes: - console.print(f"[bold red]✗[/bold red] Sync mode '{sync_mode}' not supported by adapter '{adapter_lower}'") - console.print(f"[dim]Supported modes: {', '.join(adapter_capabilities.supported_sync_modes)}[/dim]") - raise typer.Exit(1) - - # Validate temporary file workflow parameters - if export_to_tmp and import_from_tmp: - console.print("[bold red]✗[/bold red] --export-to-tmp and --import-from-tmp are mutually exclusive") - raise typer.Exit(1) - - # Parse change_ids if provided - change_ids_list: list[str] | None = None - if change_ids: - change_ids_list = [cid.strip() for cid in change_ids.split(",") if cid.strip()] - - backlog_items: list[str] = [] - if backlog_ids: - backlog_items.extend(_parse_backlog_selection(backlog_ids)) - if backlog_ids_file: - backlog_items.extend(_parse_backlog_selection(backlog_ids_file.read_text(encoding="utf-8"))) - if backlog_items: - backlog_items = list(dict.fromkeys(backlog_items)) - - telemetry_metadata = { - "adapter": adapter_value, - "mode": sync_mode, - "bidirectional": bidirectional, - "watch": watch, - "overwrite": overwrite, - "interval": interval, - } - - with telemetry.track_command("sync.bridge", telemetry_metadata) as record: - # Handle export-only mode (SpecFact → DevOps) - if sync_mode == "export-only": - from specfact_cli.sync.bridge_sync import BridgeSync - - console.print(f"[bold cyan]Exporting OpenSpec change proposals to {adapter_value}...[/bold cyan]") - - # Create bridge config using adapter registry - from specfact_cli.models.bridge import BridgeConfig - - adapter_instance = AdapterRegistry.get_adapter(adapter_value) - bridge_config = adapter_instance.generate_bridge_config(repo) - - # Create bridge sync instance - bridge_sync = BridgeSync(repo, bridge_config=bridge_config) - - # If bundle is provided for backlog adapters, export stored backlog items from bundle - if adapter_value in ("github", "ado") and bundle: - resolved_bundle = bundle or _infer_bundle_name(repo) - if not resolved_bundle: - console.print("[bold red]✗[/bold red] Bundle name required for backlog export") - console.print("[dim]Provide --bundle or set an active bundle in .specfact/config.yaml[/dim]") - raise typer.Exit(1) - - console.print( - f"[bold cyan]Exporting bundle backlog items to {adapter_value} ({resolved_bundle})...[/bold cyan]" - ) - if adapter_value == "github": - adapter_kwargs = { - "repo_owner": repo_owner, - "repo_name": repo_name, - "api_token": github_token, - "use_gh_cli": use_gh_cli, - } - else: - adapter_kwargs = { - "org": ado_org, - "project": ado_project, - "base_url": ado_base_url, - "api_token": ado_token, - "work_item_type": ado_work_item_type, - } - result = bridge_sync.export_backlog_from_bundle( - adapter_type=adapter_value, - bundle_name=resolved_bundle, - adapter_kwargs=adapter_kwargs, - update_existing=update_existing, - change_ids=change_ids_list, - ) - - if result.success: - console.print( - f"[bold green]✓[/bold green] Exported {len(result.operations)} backlog item(s) from bundle" - ) - for warning in result.warnings: - console.print(f"[yellow]⚠[/yellow] {warning}") - else: - console.print(f"[bold red]✗[/bold red] Export failed with {len(result.errors)} errors") - for error in result.errors: - console.print(f"[red] • {error}[/red]") - raise typer.Exit(1) - - return - - # Export change proposals - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - task = progress.add_task("[cyan]Syncing change proposals to DevOps...[/cyan]", total=None) - - # Resolve code_repo_path if provided, otherwise use repo (OpenSpec repo) - code_repo_path_for_export = Path(code_repo).resolve() if code_repo else repo.resolve() - - result = bridge_sync.export_change_proposals_to_devops( - include_archived=include_archived, - adapter_type=adapter_value, - repo_owner=repo_owner, - repo_name=repo_name, - api_token=github_token if adapter_value == "github" else ado_token, - use_gh_cli=use_gh_cli, - sanitize=sanitize, - target_repo=target_repo, - interactive=interactive, - change_ids=change_ids_list, - export_to_tmp=export_to_tmp, - import_from_tmp=import_from_tmp, - tmp_file=tmp_file, - update_existing=update_existing, - track_code_changes=track_code_changes, - add_progress_comment=add_progress_comment, - code_repo_path=code_repo_path_for_export, - ado_org=ado_org, - ado_project=ado_project, - ado_base_url=ado_base_url, - ado_work_item_type=ado_work_item_type, - ) - progress.update(task, description="[green]✓[/green] Sync complete") - - # Report results - if result.success: - console.print( - f"[bold green]✓[/bold green] Successfully synced {len(result.operations)} change proposals" - ) - if result.warnings: - for warning in result.warnings: - console.print(f"[yellow]⚠[/yellow] {warning}") - else: - console.print(f"[bold red]✗[/bold red] Sync failed with {len(result.errors)} errors") - for error in result.errors: - console.print(f"[red] • {error}[/red]") - raise typer.Exit(1) - - # Telemetry is automatically tracked via context manager - return - - # Handle read-only mode (OpenSpec → SpecFact) - if sync_mode == "read-only": - from specfact_cli.models.bridge import BridgeConfig - from specfact_cli.sync.bridge_sync import BridgeSync - - console.print(f"[bold cyan]Syncing OpenSpec artifacts (read-only) from:[/bold cyan] {repo}") - - # Create bridge config with external_base_path if provided - bridge_config = BridgeConfig.preset_openspec() - if external_base_path: - if not external_base_path.exists() or not external_base_path.is_dir(): - console.print( - f"[bold red]✗[/bold red] External base path does not exist or is not a directory: {external_base_path}" - ) - raise typer.Exit(1) - bridge_config.external_base_path = external_base_path.resolve() - - # Create bridge sync instance - bridge_sync = BridgeSync(repo, bridge_config=bridge_config) - - # Import OpenSpec artifacts - # In test mode, skip Progress to avoid stream closure issues with test framework - if _is_test_mode(): - # Test mode: simple console output without Progress - console.print("[cyan]Importing OpenSpec artifacts...[/cyan]") - - # Import project context - if bundle: - # Import specific artifacts for the bundle - # For now, import all OpenSpec specs - openspec_specs_dir = ( - bridge_config.external_base_path / "openspec" / "specs" - if bridge_config.external_base_path - else repo / "openspec" / "specs" - ) - if openspec_specs_dir.exists(): - for spec_dir in openspec_specs_dir.iterdir(): - if spec_dir.is_dir() and (spec_dir / "spec.md").exists(): - feature_id = spec_dir.name - result = bridge_sync.import_artifact("specification", feature_id, bundle) - if not result.success: - console.print( - f"[yellow]⚠[/yellow] Failed to import {feature_id}: {', '.join(result.errors)}" - ) - - console.print("[green]✓[/green] Import complete") - else: - # Normal mode: use Progress - progress_columns, progress_kwargs = get_progress_config() - with Progress( - *progress_columns, - console=console, - **progress_kwargs, - ) as progress: - task = progress.add_task("[cyan]Importing OpenSpec artifacts...[/cyan]", total=None) - - # Import project context - if bundle: - # Import specific artifacts for the bundle - # For now, import all OpenSpec specs - openspec_specs_dir = ( - bridge_config.external_base_path / "openspec" / "specs" - if bridge_config.external_base_path - else repo / "openspec" / "specs" - ) - if openspec_specs_dir.exists(): - for spec_dir in openspec_specs_dir.iterdir(): - if spec_dir.is_dir() and (spec_dir / "spec.md").exists(): - feature_id = spec_dir.name - result = bridge_sync.import_artifact("specification", feature_id, bundle) - if not result.success: - console.print( - f"[yellow]⚠[/yellow] Failed to import {feature_id}: {', '.join(result.errors)}" - ) - - progress.update(task, description="[green]✓[/green] Import complete") - # Ensure progress output is flushed before context exits - progress.refresh() - - # Generate alignment report - if bundle: - console.print("\n[bold]Generating alignment report...[/bold]") - bridge_sync.generate_alignment_report(bundle) - - console.print("[bold green]✓[/bold green] Read-only sync complete") - return - - console.print(f"[bold cyan]Syncing {adapter_value} artifacts from:[/bold cyan] {repo}") - - # Use adapter capabilities to check if bidirectional sync is supported - if adapter_capabilities and ( - adapter_capabilities.supported_sync_modes - and "bidirectional" not in adapter_capabilities.supported_sync_modes - ): - console.print(f"[yellow]⚠ Adapter '{adapter_value}' does not support bidirectional sync[/yellow]") - console.print(f"[dim]Supported modes: {', '.join(adapter_capabilities.supported_sync_modes)}[/dim]") - console.print("[dim]Use read-only mode for adapters that don't support bidirectional sync[/dim]") - raise typer.Exit(1) - - # Ensure tool compliance if requested - if ensure_compliance: - adapter_display = adapter_type.value if adapter_type else adapter_value - console.print(f"\n[cyan]🔍 Validating plan bundle for {adapter_display} compliance...[/cyan]") - from specfact_cli.utils.structure import SpecFactStructure - from specfact_cli.validators.schema import validate_plan_bundle - - # Use provided bundle name or default - plan_bundle = None - if bundle: - from specfact_cli.utils.progress import load_bundle_with_progress - - bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) - if bundle_dir.exists(): - project_bundle = load_bundle_with_progress( - bundle_dir, validate_hashes=False, console_instance=console - ) - # Convert to PlanBundle for validation (legacy compatibility) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - else: - console.print(f"[yellow]⚠ Bundle '{bundle}' not found, skipping compliance check[/yellow]") - plan_bundle = None - else: - # Legacy: Try to find default plan path (for backward compatibility) - if hasattr(SpecFactStructure, "get_default_plan_path"): - plan_path = SpecFactStructure.get_default_plan_path(repo) - if plan_path and plan_path.exists(): - # Check if path is a directory (modular bundle) - load it first - if plan_path.is_dir(): - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - from specfact_cli.utils.progress import load_bundle_with_progress - - project_bundle = load_bundle_with_progress( - plan_path, validate_hashes=False, console_instance=console - ) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - else: - # It's a file (legacy monolithic bundle) - validate directly - validation_result = validate_plan_bundle(plan_path) - if isinstance(validation_result, tuple): - is_valid, _error, plan_bundle = validation_result - if not is_valid: - plan_bundle = None - else: - plan_bundle = None - - if plan_bundle: - # Check for technology stack in constraints - has_tech_stack = bool( - plan_bundle.idea - and plan_bundle.idea.constraints - and any( - "Python" in c or "framework" in c.lower() or "database" in c.lower() - for c in plan_bundle.idea.constraints - ) - ) - - if not has_tech_stack: - console.print("[yellow]⚠ Technology stack not found in constraints[/yellow]") - console.print("[dim]Technology stack will be extracted from constraints during sync[/dim]") - - # Check for testable acceptance criteria - features_with_non_testable = [] - for feature in plan_bundle.features: - for story in feature.stories: - testable_count = sum( - 1 - for acc in story.acceptance - if any( - keyword in acc.lower() for keyword in ["must", "should", "verify", "validate", "ensure"] - ) - ) - if testable_count < len(story.acceptance) and len(story.acceptance) > 0: - features_with_non_testable.append((feature.key, story.key)) - - if features_with_non_testable: - console.print( - f"[yellow]⚠ Found {len(features_with_non_testable)} stories with non-testable acceptance criteria[/yellow]" - ) - console.print("[dim]Acceptance criteria will be enhanced during sync[/dim]") - - console.print("[green]✓ Plan bundle validation complete[/green]") - else: - console.print("[yellow]⚠ Plan bundle not found, skipping compliance check[/yellow]") - - # Resolve repo path to ensure it's absolute and valid (do this once at the start) - resolved_repo = repo.resolve() - if not resolved_repo.exists(): - console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}") - raise typer.Exit(1) - if not resolved_repo.is_dir(): - console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}") - raise typer.Exit(1) - - if adapter_value in ("github", "ado") and sync_mode == "bidirectional": - from specfact_cli.sync.bridge_sync import BridgeSync - - resolved_bundle = bundle or _infer_bundle_name(resolved_repo) - if not resolved_bundle: - console.print("[bold red]✗[/bold red] Bundle name required for backlog sync") - console.print("[dim]Provide --bundle or set an active bundle in .specfact/config.yaml[/dim]") - raise typer.Exit(1) - - if not backlog_items and interactive and runtime.is_interactive(): - prompt = typer.prompt( - "Enter backlog item IDs/URLs to import (comma-separated, leave blank to skip)", - default="", - ) - backlog_items = _parse_backlog_selection(prompt) - backlog_items = list(dict.fromkeys(backlog_items)) - - if backlog_items: - console.print(f"[dim]Selected backlog items ({len(backlog_items)}): {', '.join(backlog_items)}[/dim]") - else: - console.print("[yellow]⚠[/yellow] No backlog items selected; import skipped") - - adapter_instance = AdapterRegistry.get_adapter(adapter_value) - bridge_config = adapter_instance.generate_bridge_config(resolved_repo) - bridge_sync = BridgeSync(resolved_repo, bridge_config=bridge_config) - - if backlog_items: - if adapter_value == "github": - adapter_kwargs = { - "repo_owner": repo_owner, - "repo_name": repo_name, - "api_token": github_token, - "use_gh_cli": use_gh_cli, - } - else: - adapter_kwargs = { - "org": ado_org, - "project": ado_project, - "base_url": ado_base_url, - "api_token": ado_token, - "work_item_type": ado_work_item_type, - } - - import_result = bridge_sync.import_backlog_items_to_bundle( - adapter_type=adapter_value, - bundle_name=resolved_bundle, - backlog_items=backlog_items, - adapter_kwargs=adapter_kwargs, - ) - if import_result.success: - console.print( - f"[bold green]✓[/bold green] Imported {len(import_result.operations)} backlog item(s)" - ) - for warning in import_result.warnings: - console.print(f"[yellow]⚠[/yellow] {warning}") - else: - console.print(f"[bold red]✗[/bold red] Import failed with {len(import_result.errors)} errors") - for error in import_result.errors: - console.print(f"[red] • {error}[/red]") - raise typer.Exit(1) - - if adapter_value == "github": - export_adapter_kwargs = { - "repo_owner": repo_owner, - "repo_name": repo_name, - "api_token": github_token, - "use_gh_cli": use_gh_cli, - } - else: - export_adapter_kwargs = { - "org": ado_org, - "project": ado_project, - "base_url": ado_base_url, - "api_token": ado_token, - "work_item_type": ado_work_item_type, - } - - export_result = bridge_sync.export_backlog_from_bundle( - adapter_type=adapter_value, - bundle_name=resolved_bundle, - adapter_kwargs=export_adapter_kwargs, - update_existing=update_existing, - change_ids=change_ids_list, - ) - - if export_result.success: - console.print(f"[bold green]✓[/bold green] Exported {len(export_result.operations)} backlog item(s)") - for warning in export_result.warnings: - console.print(f"[yellow]⚠[/yellow] {warning}") - else: - console.print(f"[bold red]✗[/bold red] Export failed with {len(export_result.errors)} errors") - for error in export_result.errors: - console.print(f"[red] • {error}[/red]") - raise typer.Exit(1) - - return - - # Watch mode implementation (using bridge-based watch) - if watch: - from specfact_cli.sync.bridge_watch import BridgeWatch - - console.print("[bold cyan]Watch mode enabled[/bold cyan]") - console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n") - - # Use bridge-based watch mode - bridge_watch = BridgeWatch( - repo_path=resolved_repo, - bundle_name=bundle, - interval=interval, - ) - - bridge_watch.watch() - return - - # Legacy watch mode (for backward compatibility during transition) - if False: # Disabled - use bridge watch above - from specfact_cli.sync.watcher import FileChange, SyncWatcher - - @beartype - @require(lambda changes: isinstance(changes, list), "Changes must be a list") - @require( - lambda changes: all(hasattr(c, "change_type") for c in changes), - "All changes must have change_type attribute", - ) - @ensure(lambda result: result is None, "Must return None") - def sync_callback(changes: list[FileChange]) -> None: - """Handle file changes and trigger sync.""" - tool_changes = [c for c in changes if c.change_type == "spec_kit"] - specfact_changes = [c for c in changes if c.change_type == "specfact"] - - if tool_changes or specfact_changes: - console.print(f"[cyan]Detected {len(changes)} change(s), syncing...[/cyan]") - # Perform one-time sync (bidirectional if enabled) - try: - # Re-validate resolved_repo before use (may have been cleaned up) - if not resolved_repo.exists(): - console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n") - return - if not resolved_repo.is_dir(): - console.print( - f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n" - ) - return - # Use resolved_repo from outer scope (already resolved and validated) - _perform_sync_operation( - repo=resolved_repo, - bidirectional=bidirectional, - bundle=bundle, - overwrite=overwrite, - adapter_type=adapter_type, - ) - console.print("[green]✓[/green] Sync complete\n") - except Exception as e: - console.print(f"[red]✗[/red] Sync failed: {e}\n") - - # Use resolved_repo for watcher (already resolved and validated) - watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval) - watcher.watch() - record({"watch_mode": True}) - return - - # Validate OpenAPI specs before sync (if bundle provided) - if bundle: - import asyncio - - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle - from specfact_cli.utils.progress import load_bundle_with_progress - from specfact_cli.utils.structure import SpecFactStructure - - bundle_dir = SpecFactStructure.project_dir(base_path=resolved_repo, bundle_name=bundle) - if bundle_dir.exists(): - console.print("\n[cyan]🔍 Validating OpenAPI contracts before sync...[/cyan]") - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) - - from specfact_cli.integrations.specmatic import ( - check_specmatic_available, - validate_spec_with_specmatic, - ) - - is_available, error_msg = check_specmatic_available() - if is_available: - # Validate contracts referenced in bundle - contract_files = [] - for feature in plan_bundle.features: - if feature.contract: - contract_path = bundle_dir / feature.contract - if contract_path.exists(): - contract_files.append(contract_path) - - if contract_files: - console.print(f"[dim]Validating {len(contract_files)} contract(s)...[/dim]") - validation_failed = False - for contract_path in contract_files[:5]: # Validate up to 5 contracts - console.print(f"[dim]Validating {contract_path.relative_to(bundle_dir)}...[/dim]") - try: - result = asyncio.run(validate_spec_with_specmatic(contract_path)) - if not result.is_valid: - console.print( - f" [bold yellow]⚠[/bold yellow] {contract_path.name} has validation issues" - ) - if result.errors: - for error in result.errors[:2]: - console.print(f" - {error}") - validation_failed = True - else: - console.print(f" [bold green]✓[/bold green] {contract_path.name} is valid") - except Exception as e: - console.print(f" [bold yellow]⚠[/bold yellow] Validation error: {e!s}") - validation_failed = True - - if validation_failed: - console.print( - "[yellow]⚠[/yellow] Some contracts have validation issues. Sync will continue, but consider fixing them." - ) - else: - console.print("[green]✓[/green] All contracts validated successfully") - - # Check backward compatibility if previous version exists (for bidirectional sync) - if bidirectional and len(contract_files) > 0: - # TODO: Implement backward compatibility check by comparing with previous version - # This would require storing previous contract versions - console.print( - "[dim]Backward compatibility check skipped (previous versions not stored)[/dim]" - ) - else: - console.print("[dim]No contracts found in bundle[/dim]") - else: - console.print(f"[dim]💡 Tip: Install Specmatic to validate contracts: {error_msg}[/dim]") - - # Perform sync operation (extracted to avoid recursion in watch mode) - # Use resolved_repo (already resolved and validated above) - # Convert adapter_value to AdapterType for legacy _perform_sync_operation - # (This function will be refactored to use adapter registry in future) - if adapter_type is None: - # For adapters not in enum yet (like openspec), we can't use legacy sync - console.print(f"[yellow]⚠ Adapter '{adapter_value}' requires bridge-based sync (not legacy)[/yellow]") - console.print("[dim]Use read-only mode for OpenSpec adapter[/dim]") - raise typer.Exit(1) - - _perform_sync_operation( - repo=resolved_repo, - bidirectional=bidirectional, - bundle=bundle, - overwrite=overwrite, - adapter_type=adapter_type, - ) - if is_debug_mode(): - debug_log_operation("command", "sync bridge", "success", extra={"adapter": adapter, "bundle": bundle}) - debug_print("[dim]sync bridge: success[/dim]") - record({"sync_completed": True}) - - -@app.command("repository") -@beartype -@require(lambda repo: repo.exists(), "Repository path must exist") -@require(lambda repo: repo.is_dir(), "Repository path must be a directory") -@require( - lambda target: target is None or (isinstance(target, Path) and target.exists()), - "Target must be None or existing Path", -) -@require(lambda watch: isinstance(watch, bool), "Watch must be bool") -@require(lambda interval: isinstance(interval, int) and interval >= 1, "Interval must be int >= 1") -@require( - lambda confidence: isinstance(confidence, float) and 0.0 <= confidence <= 1.0, - "Confidence must be float in [0.0, 1.0]", -) -@ensure(lambda result: result is None, "Must return None") -def sync_repository( - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository", - exists=True, - file_okay=False, - dir_okay=True, - ), - target: Path | None = typer.Option( - None, - "--target", - help="Target directory for artifacts (default: .specfact)", - ), - watch: bool = typer.Option( - False, - "--watch", - help="Watch mode for continuous sync", - ), - interval: int = typer.Option( - 5, - "--interval", - help="Watch interval in seconds (default: 5)", - min=1, - hidden=True, # Hidden by default, shown with --help-advanced - ), - confidence: float = typer.Option( - 0.5, - "--confidence", - help="Minimum confidence threshold for feature detection (default: 0.5)", - min=0.0, - max=1.0, - hidden=True, # Hidden by default, shown with --help-advanced - ), -) -> None: - """ - Sync code changes to SpecFact artifacts. - - Monitors repository code changes, updates plan artifacts based on detected - features/stories, and tracks deviations from manual plans. - - Example: - specfact sync repository --repo . --confidence 0.5 - """ - if is_debug_mode(): - debug_log_operation( - "command", - "sync repository", - "started", - extra={"repo": str(repo), "target": str(target) if target else None, "watch": watch}, - ) - debug_print("[dim]sync repository: started[/dim]") - - from specfact_cli.sync.repository_sync import RepositorySync - - telemetry_metadata = { - "watch": watch, - "interval": interval, - "confidence": confidence, - } - - with telemetry.track_command("sync.repository", telemetry_metadata) as record: - console.print(f"[bold cyan]Syncing repository changes from:[/bold cyan] {repo}") - - # Resolve repo path to ensure it's absolute and valid (do this once at the start) - resolved_repo = repo.resolve() - if not resolved_repo.exists(): - console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}") - raise typer.Exit(1) - if not resolved_repo.is_dir(): - console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}") - raise typer.Exit(1) - - if target is None: - target = resolved_repo / ".specfact" - - sync = RepositorySync(resolved_repo, target, confidence_threshold=confidence) - - if watch: - from specfact_cli.sync.watcher import FileChange, SyncWatcher - - console.print("[bold cyan]Watch mode enabled[/bold cyan]") - console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n") - - @beartype - @require(lambda changes: isinstance(changes, list), "Changes must be a list") - @require( - lambda changes: all(hasattr(c, "change_type") for c in changes), - "All changes must have change_type attribute", - ) - @ensure(lambda result: result is None, "Must return None") - def sync_callback(changes: list[FileChange]) -> None: - """Handle file changes and trigger sync.""" - code_changes = [c for c in changes if c.change_type == "code"] - - if code_changes: - console.print(f"[cyan]Detected {len(code_changes)} code change(s), syncing...[/cyan]") - # Perform repository sync - try: - # Re-validate resolved_repo before use (may have been cleaned up) - if not resolved_repo.exists(): - console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n") - return - if not resolved_repo.is_dir(): - console.print( - f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n" - ) - return - # Use resolved_repo from outer scope (already resolved and validated) - result = sync.sync_repository_changes(resolved_repo) - if result.status == "success": - console.print("[green]✓[/green] Repository sync complete\n") - elif result.status == "deviation_detected": - console.print(f"[yellow]⚠[/yellow] Deviations detected: {len(result.deviations)}\n") - else: - console.print(f"[red]✗[/red] Sync failed: {result.status}\n") - except Exception as e: - console.print(f"[red]✗[/red] Sync failed: {e}\n") - - # Use resolved_repo for watcher (already resolved and validated) - watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval) - watcher.watch() - record({"watch_mode": True}) - return - - # Use resolved_repo (already resolved and validated above) - # Disable Progress in test mode to avoid LiveError conflicts - if _is_test_mode(): - # In test mode, just run the sync without Progress - result = sync.sync_repository_changes(resolved_repo) - else: - with Progress( - SpinnerColumn(), - TextColumn("[progress.description]{task.description}"), - TimeElapsedColumn(), - console=console, - ) as progress: - # Step 1: Detect code changes - task = progress.add_task("Detecting code changes...", total=None) - result = sync.sync_repository_changes(resolved_repo) - progress.update(task, description=f"✓ Detected {len(result.code_changes)} code changes") - - # Step 2: Show plan updates - if result.plan_updates: - task = progress.add_task("Updating plan artifacts...", total=None) - total_features = sum(update.get("features", 0) for update in result.plan_updates) - progress.update(task, description=f"✓ Updated plan artifacts ({total_features} features)") - - # Step 3: Show deviations - if result.deviations: - task = progress.add_task("Tracking deviations...", total=None) - progress.update(task, description=f"✓ Found {len(result.deviations)} deviations") - - if is_debug_mode(): - debug_log_operation( - "command", - "sync repository", - "success", - extra={"code_changes": len(result.code_changes)}, - ) - debug_print("[dim]sync repository: success[/dim]") - # Record sync results - record( - { - "code_changes": len(result.code_changes), - "plan_updates": len(result.plan_updates) if result.plan_updates else 0, - "deviations": len(result.deviations) if result.deviations else 0, - } - ) - - # Report results - console.print(f"[bold cyan]Code Changes:[/bold cyan] {len(result.code_changes)}") - if result.plan_updates: - console.print(f"[bold cyan]Plan Updates:[/bold cyan] {len(result.plan_updates)}") - if result.deviations: - console.print(f"[yellow]⚠[/yellow] Found {len(result.deviations)} deviations from manual plan") - console.print("[dim]Run 'specfact plan compare' for detailed deviation report[/dim]") - else: - console.print("[bold green]✓[/bold green] No deviations detected") - console.print("[bold green]✓[/bold green] Repository sync complete!") - - # Auto-validate OpenAPI/AsyncAPI specs with Specmatic (if found) - import asyncio - - from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic - - spec_files = [] - for pattern in [ - "**/openapi.yaml", - "**/openapi.yml", - "**/openapi.json", - "**/asyncapi.yaml", - "**/asyncapi.yml", - "**/asyncapi.json", - ]: - spec_files.extend(resolved_repo.glob(pattern)) - - if spec_files: - console.print(f"\n[cyan]🔍 Found {len(spec_files)} API specification file(s)[/cyan]") - is_available, error_msg = check_specmatic_available() - if is_available: - for spec_file in spec_files[:3]: # Validate up to 3 specs - console.print(f"[dim]Validating {spec_file.relative_to(resolved_repo)} with Specmatic...[/dim]") - try: - result = asyncio.run(validate_spec_with_specmatic(spec_file)) - if result.is_valid: - console.print(f" [green]✓[/green] {spec_file.name} is valid") - else: - console.print(f" [yellow]⚠[/yellow] {spec_file.name} has validation issues") - if result.errors: - for error in result.errors[:2]: # Show first 2 errors - console.print(f" - {error}") - except Exception as e: - console.print(f" [yellow]⚠[/yellow] Validation error: {e!s}") - if len(spec_files) > 3: - console.print( - f"[dim]... and {len(spec_files) - 3} more spec file(s) (run 'specfact spec validate' to validate all)[/dim]" - ) - else: - console.print(f"[dim]💡 Tip: Install Specmatic to validate API specs: {error_msg}[/dim]") - - -@app.command("intelligent") -@beartype -@require( - lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), - "Bundle name must be None or non-empty string", -) -@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") -@ensure(lambda result: result is None, "Must return None") -def sync_intelligent( - # Target/Input - bundle: str | None = typer.Argument( - None, help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'" - ), - repo: Path = typer.Option( - Path("."), - "--repo", - help="Path to repository. Default: current directory (.)", - exists=True, - file_okay=False, - dir_okay=True, - ), - # Behavior/Options - watch: bool = typer.Option( - False, - "--watch", - help="Watch mode for continuous sync. Default: False", - ), - code_to_spec: str = typer.Option( - "auto", - "--code-to-spec", - help="Code-to-spec sync mode: 'auto' (AST-based) or 'off'. Default: auto", - ), - spec_to_code: str = typer.Option( - "llm-prompt", - "--spec-to-code", - help="Spec-to-code sync mode: 'llm-prompt' (generate prompts) or 'off'. Default: llm-prompt", - ), - tests: str = typer.Option( - "specmatic", - "--tests", - help="Test generation mode: 'specmatic' (contract-based) or 'off'. Default: specmatic", - ), -) -> None: - """ - Continuous intelligent bidirectional sync with conflict resolution. - - Detects changes via hashing and syncs intelligently: - - Code→Spec: AST-based automatic sync (CLI can do) - - Spec→Code: LLM prompt generation (CLI orchestrates, LLM writes) - - Spec→Tests: Specmatic flows (contract-based, not LLM guessing) - - **Parameter Groups:** - - **Target/Input**: bundle (required argument), --repo - - **Behavior/Options**: --watch, --code-to-spec, --spec-to-code, --tests - - **Examples:** - specfact sync intelligent legacy-api --repo . - specfact sync intelligent my-bundle --repo . --watch - specfact sync intelligent my-bundle --repo . --code-to-spec auto --spec-to-code llm-prompt --tests specmatic - """ - if is_debug_mode(): - debug_log_operation( - "command", - "sync intelligent", - "started", - extra={"bundle": bundle, "repo": str(repo), "watch": watch}, - ) - debug_print("[dim]sync intelligent: started[/dim]") - - from specfact_cli.utils.structure import SpecFactStructure - - console = get_configured_console() - - # Use active plan as default if bundle not provided - if bundle is None: - bundle = SpecFactStructure.get_active_bundle_name(repo) - if bundle is None: - console.print("[bold red]✗[/bold red] Bundle name required") - console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") - raise typer.Exit(1) - console.print(f"[dim]Using active plan: {bundle}[/dim]") - - from specfact_cli.sync.change_detector import ChangeDetector - from specfact_cli.sync.code_to_spec import CodeToSpecSync - from specfact_cli.sync.spec_to_code import SpecToCodeSync - from specfact_cli.sync.spec_to_tests import SpecToTestsSync - from specfact_cli.telemetry import telemetry - from specfact_cli.utils.progress import load_bundle_with_progress - from specfact_cli.utils.structure import SpecFactStructure - - repo_path = repo.resolve() - bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) - - if not bundle_dir.exists(): - console.print(f"[bold red]✗[/bold red] Project bundle not found: {bundle_dir}") - raise typer.Exit(1) - - telemetry_metadata = { - "bundle": bundle, - "watch": watch, - "code_to_spec": code_to_spec, - "spec_to_code": spec_to_code, - "tests": tests, - } - - with telemetry.track_command("sync.intelligent", telemetry_metadata) as record: - console.print(f"[bold cyan]Intelligent Sync:[/bold cyan] {bundle}") - console.print(f"[dim]Repository:[/dim] {repo_path}") - - # Load project bundle with unified progress display - project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - - # Initialize sync components - change_detector = ChangeDetector(bundle, repo_path) - code_to_spec_sync = CodeToSpecSync(repo_path) - spec_to_code_sync = SpecToCodeSync(repo_path) - spec_to_tests_sync = SpecToTestsSync(bundle, repo_path) - - def perform_sync() -> None: - """Perform one sync cycle.""" - console.print("\n[cyan]Detecting changes...[/cyan]") - - # Detect changes - changeset = change_detector.detect_changes(project_bundle.features) - - if not any([changeset.code_changes, changeset.spec_changes, changeset.test_changes]): - console.print("[dim]No changes detected[/dim]") - return - - # Report changes - if changeset.code_changes: - console.print(f"[cyan]Code changes:[/cyan] {len(changeset.code_changes)}") - if changeset.spec_changes: - console.print(f"[cyan]Spec changes:[/cyan] {len(changeset.spec_changes)}") - if changeset.test_changes: - console.print(f"[cyan]Test changes:[/cyan] {len(changeset.test_changes)}") - if changeset.conflicts: - console.print(f"[yellow]⚠ Conflicts:[/yellow] {len(changeset.conflicts)}") - - # Sync code→spec (AST-based, automatic) - if code_to_spec == "auto" and changeset.code_changes: - console.print("\n[cyan]Syncing code→spec (AST-based)...[/cyan]") - try: - code_to_spec_sync.sync(changeset.code_changes, bundle) - console.print("[green]✓[/green] Code→spec sync complete") - except Exception as e: - console.print(f"[red]✗[/red] Code→spec sync failed: {e}") - - # Sync spec→code (LLM prompt generation) - if spec_to_code == "llm-prompt" and changeset.spec_changes: - console.print("\n[cyan]Preparing LLM prompts for spec→code...[/cyan]") - try: - context = spec_to_code_sync.prepare_llm_context(changeset.spec_changes, repo_path) - prompt = spec_to_code_sync.generate_llm_prompt(context) - - # Save prompt to file - prompts_dir = repo_path / ".specfact" / "prompts" - prompts_dir.mkdir(parents=True, exist_ok=True) - prompt_file = prompts_dir / f"{bundle}-code-generation-{len(changeset.spec_changes)}.md" - prompt_file.write_text(prompt, encoding="utf-8") - - console.print(f"[green]✓[/green] LLM prompt generated: {prompt_file}") - console.print("[yellow]Execute this prompt with your LLM to generate code[/yellow]") - except Exception as e: - console.print(f"[red]✗[/red] LLM prompt generation failed: {e}") - - # Sync spec→tests (Specmatic) - if tests == "specmatic" and changeset.spec_changes: - console.print("\n[cyan]Generating tests via Specmatic...[/cyan]") - try: - spec_to_tests_sync.sync(changeset.spec_changes, bundle) - console.print("[green]✓[/green] Test generation complete") - except Exception as e: - console.print(f"[red]✗[/red] Test generation failed: {e}") - - if watch: - console.print("[bold cyan]Watch mode enabled[/bold cyan]") - console.print("[dim]Watching for changes...[/dim]") - console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") - - from specfact_cli.sync.watcher import SyncWatcher - - def sync_callback(_changes: list) -> None: - """Handle file changes and trigger sync.""" - perform_sync() - - watcher = SyncWatcher(repo_path, sync_callback, interval=5) - try: - watcher.watch() - except KeyboardInterrupt: - console.print("\n[yellow]Stopping watch mode...[/yellow]") - else: - perform_sync() - - if is_debug_mode(): - debug_log_operation("command", "sync intelligent", "success", extra={"bundle": bundle}) - debug_print("[dim]sync intelligent: success[/dim]") - record({"sync_completed": True}) +__all__ = ["app"] diff --git a/src/specfact_cli/commands/update.py b/src/specfact_cli/commands/update.py index 86ecef30..80c75f6b 100644 --- a/src/specfact_cli/commands/update.py +++ b/src/specfact_cli/commands/update.py @@ -1,305 +1,6 @@ -""" -Upgrade command for SpecFact CLI. +"""Backward-compatible app shim. Implementation moved to modules/upgrade/.""" -This module provides the `specfact upgrade` command for checking and installing -CLI updates from PyPI. -""" +from specfact_cli.modules.upgrade.src.commands import app -from __future__ import annotations -import subprocess -import sys -from datetime import UTC -from pathlib import Path -from typing import NamedTuple - -import typer -from beartype import beartype -from icontract import ensure -from rich.console import Console -from rich.panel import Panel -from rich.prompt import Confirm - -from specfact_cli import __version__ -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode -from specfact_cli.utils.metadata import update_metadata -from specfact_cli.utils.startup_checks import check_pypi_version - - -app = typer.Typer( - help="Check for and install SpecFact CLI updates", - context_settings={"help_option_names": ["-h", "--help"]}, -) -console = Console() - - -class InstallationMethod(NamedTuple): - """Installation method information.""" - - method: str # "pip", "uvx", "pipx", or "unknown" - command: str # Command to run for update - location: str | None # Installation location if known - - -@beartype -@ensure(lambda result: isinstance(result, InstallationMethod), "Must return InstallationMethod") -def detect_installation_method() -> InstallationMethod: - """ - Detect how SpecFact CLI was installed. - - Returns: - InstallationMethod with detected method and update command - """ - # Check if running via uvx - if "uvx" in sys.argv[0] or "uvx" in str(Path(sys.executable)): - return InstallationMethod( - method="uvx", - command="uvx --from specfact-cli specfact --version", - location=None, - ) - - # Check if running via pipx - try: - result = subprocess.run( - ["pipx", "list"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - if "specfact-cli" in result.stdout: - return InstallationMethod( - method="pipx", - command="pipx upgrade specfact-cli", - location=None, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - - # Check if installed via pip (user or system) - try: - result = subprocess.run( - [sys.executable, "-m", "pip", "show", "specfact-cli"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - if result.returncode == 0: - # Parse location from output - location = None - for line in result.stdout.splitlines(): - if line.startswith("Location:"): - location = line.split(":", 1)[1].strip() - break - - return InstallationMethod( - method="pip", - command=f"{sys.executable} -m pip install --upgrade specfact-cli", - location=location, - ) - except (subprocess.TimeoutExpired, FileNotFoundError): - pass - - # Fallback: assume pip - return InstallationMethod( - method="pip", - command="pip install --upgrade specfact-cli", - location=None, - ) - - -@beartype -@ensure(lambda result: isinstance(result, bool), "Must return bool") -def install_update(method: InstallationMethod, yes: bool = False) -> bool: - """ - Install update using the detected installation method. - - Args: - method: InstallationMethod with update command - yes: If True, skip confirmation prompt - - Returns: - True if update was successful, False otherwise - """ - if not yes: - console.print(f"[yellow]This will update SpecFact CLI using:[/yellow] [cyan]{method.command}[/cyan]") - if not Confirm.ask("Continue?", default=True): - console.print("[dim]Update cancelled[/dim]") - return False - - try: - console.print("[cyan]Updating SpecFact CLI...[/cyan]") - # Split command into parts for subprocess - if method.method == "pipx": - cmd = ["pipx", "upgrade", "specfact-cli"] - elif method.method == "pip": - # Handle both formats: "python -m pip" and "pip" - if " -m pip" in method.command: - parts = method.command.split() - cmd = [parts[0], "-m", "pip", "install", "--upgrade", "specfact-cli"] - else: - cmd = ["pip", "install", "--upgrade", "specfact-cli"] - else: - # uvx - just inform user - console.print( - "[yellow]uvx automatically uses the latest version.[/yellow]\n" - "[dim]No update needed. If you want to force a refresh, run:[/dim]\n" - "[cyan]uvx --from specfact-cli@latest specfact --version[/cyan]" - ) - return True - - result = subprocess.run( - cmd, - check=False, - timeout=300, # 5 minute timeout - ) - - if result.returncode == 0: - console.print("[green]✓ Update successful![/green]") - # Update metadata to reflect new version - from datetime import datetime - - update_metadata( - last_checked_version=__version__, - last_version_check_timestamp=datetime.now(UTC).isoformat(), - ) - return True - console.print(f"[red]✗ Update failed with exit code {result.returncode}[/red]") - return False - - except subprocess.TimeoutExpired: - console.print("[red]✗ Update timed out (exceeded 5 minutes)[/red]") - return False - except Exception as e: - console.print(f"[red]✗ Update failed: {e}[/red]") - return False - - -@app.callback(invoke_without_command=True) -@beartype -def upgrade( - check_only: bool = typer.Option( - False, - "--check-only", - help="Only check for updates, don't install", - ), - yes: bool = typer.Option( - False, - "--yes", - "-y", - help="Skip confirmation prompt and install immediately", - ), -) -> None: - """ - Check for and install SpecFact CLI updates. - - This command: - 1. Checks PyPI for the latest version - 2. Compares with current version - 3. Optionally installs the update using the detected installation method (pip, pipx, uvx) - - Examples: - # Check for updates only - specfact upgrade --check-only - - # Check and install (with confirmation) - specfact upgrade - - # Check and install without confirmation - specfact upgrade --yes - """ - if is_debug_mode(): - debug_log_operation( - "command", - "upgrade", - "started", - extra={"check_only": check_only, "yes": yes}, - ) - debug_print("[dim]upgrade: started[/dim]") - - # Check for updates - console.print("[cyan]Checking for updates...[/cyan]") - version_result = check_pypi_version() - - if version_result.error: - if is_debug_mode(): - debug_log_operation( - "command", - "upgrade", - "failed", - error=version_result.error or "Unknown error", - extra={"reason": "check_error"}, - ) - console.print(f"[red]Error checking for updates: {version_result.error}[/red]") - sys.exit(1) - - if not version_result.update_available: - if is_debug_mode(): - debug_log_operation( - "command", - "upgrade", - "success", - extra={"reason": "up_to_date", "version": version_result.current_version}, - ) - debug_print("[dim]upgrade: success (up to date)[/dim]") - console.print(f"[green]✓ You're up to date![/green] (version {version_result.current_version})") - # Update metadata even if no update available - from datetime import datetime - - update_metadata( - last_checked_version=__version__, - last_version_check_timestamp=datetime.now(UTC).isoformat(), - ) - return - - # Update available - if version_result.latest_version and version_result.update_type: - update_type_color = "red" if version_result.update_type == "major" else "yellow" - update_type_icon = "🔴" if version_result.update_type == "major" else "🟡" - - update_info = ( - f"[bold {update_type_color}]{update_type_icon} Update Available[/bold {update_type_color}]\n\n" - f"Current: [cyan]{version_result.current_version}[/cyan]\n" - f"Latest: [green]{version_result.latest_version}[/green]\n" - ) - - if version_result.update_type == "major": - update_info += ( - "\n[bold red]⚠ Breaking changes may be present![/bold red]\nReview release notes before upgrading.\n" - ) - - console.print() - console.print(Panel(update_info, border_style=update_type_color)) - - if check_only: - # Detect installation method for user info - method = detect_installation_method() - console.print(f"\n[yellow]To upgrade, run:[/yellow] [cyan]{method.command}[/cyan]") - console.print("[dim]Or run:[/dim] [cyan]specfact upgrade --yes[/cyan]") - return - - # Install update - method = detect_installation_method() - console.print(f"\n[cyan]Installation method detected:[/cyan] [bold]{method.method}[/bold]") - - success = install_update(method, yes=yes) - - if success: - if is_debug_mode(): - debug_log_operation("command", "upgrade", "success", extra={"reason": "installed"}) - debug_print("[dim]upgrade: success[/dim]") - console.print("\n[green]✓ Update complete![/green]") - console.print("[dim]Run 'specfact --version' to verify the new version.[/dim]") - else: - if is_debug_mode(): - debug_log_operation( - "command", - "upgrade", - "failed", - error="Update was not installed", - extra={"reason": "install_failed"}, - ) - console.print("\n[yellow]Update was not installed.[/yellow]") - console.print("[dim]You can manually update using the command shown above.[/dim]") - sys.exit(1) +__all__ = ["app"] diff --git a/src/specfact_cli/commands/validate.py b/src/specfact_cli/commands/validate.py index 8a27ac83..fdd0c33d 100644 --- a/src/specfact_cli/commands/validate.py +++ b/src/specfact_cli/commands/validate.py @@ -1,314 +1,6 @@ -""" -Validate command group for SpecFact CLI. +"""Backward-compatible app shim. Implementation moved to modules/validate/.""" -This module provides validation commands including sidecar validation. -""" +from specfact_cli.modules.validate.src.commands import app -from __future__ import annotations -import re -from pathlib import Path - -import typer -from beartype import beartype -from icontract import require - -from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode -from specfact_cli.validators.sidecar.crosshair_summary import format_summary_line -from specfact_cli.validators.sidecar.models import SidecarConfig -from specfact_cli.validators.sidecar.orchestrator import initialize_sidecar_workspace, run_sidecar_validation - - -app = typer.Typer(name="validate", help="Validation commands", suggest_commands=False) -console = get_configured_console() - - -@beartype -def _format_crosshair_error(stderr: str, stdout: str) -> str: - """ - Format CrossHair error messages into user-friendly text. - - Filters out technical errors (like Rich markup errors) and provides - actionable error messages. - - Args: - stderr: CrossHair stderr output - stdout: CrossHair stdout output - - Returns: - User-friendly error message or empty string if no actionable error - """ - combined = (stderr + "\n" + stdout).strip() - if not combined: - return "" - - # Filter out Rich markup errors - these are internal errors, not user-facing - error_lower = combined.lower() - if "closing tag" in error_lower and "doesn't match any open tag" in error_lower: - # This is a Rich internal error - ignore it completely - return "" - - # Detect common error patterns and provide user-friendly messages - # Python shared library issue (venv Python can't load libraries) - if "error while loading shared libraries" in error_lower or "libpython" in error_lower: - return ( - "Python environment issue detected. CrossHair is using system Python instead. " - "This is usually harmless - validation will continue with system Python." - ) - - # CrossHair not found - if "not found" in error_lower and ("crosshair" in error_lower or "command" in error_lower): - return "CrossHair is not installed or not in PATH. Install it with: pip install crosshair-tool" - - # Timeout - if "timeout" in error_lower or "timed out" in error_lower: - return ( - "CrossHair analysis timed out. This is expected for complex applications with many routes. " - "Some routes were analyzed before timeout. Check the summary file for partial results. " - "To analyze more routes, increase --crosshair-timeout or --crosshair-per-path-timeout." - ) - - # Import errors - if "importerror" in error_lower or "module not found" in error_lower: - module_match = re.search(r"no module named ['\"]([^'\"]+)['\"]", error_lower) - if module_match: - module_name = module_match.group(1) - return ( - f"Missing Python module: {module_name}. " - "Ensure all dependencies are installed in the sidecar environment." - ) - return "Missing Python module. Ensure all dependencies are installed." - - # Syntax errors in harness - if "syntaxerror" in error_lower or "syntax error" in error_lower: - return ( - "Syntax error in generated harness. This may indicate an issue with contract generation. " - "Check the harness file for errors." - ) - - # Generic error - show a sanitized version (remove paths, technical details) - # Only show first line and remove technical details - lines = combined.split("\n") - first_line = lines[0].strip() if lines else "" - - # Remove common technical noise - first_line = re.sub(r"Error: closing tag.*", "", first_line, flags=re.IGNORECASE) - first_line = re.sub(r"at position \d+", "", first_line, flags=re.IGNORECASE) - first_line = re.sub(r"\.specfact/venv/bin/python.*", "", first_line) - first_line = re.sub(r"error while loading shared libraries.*", "", first_line, flags=re.IGNORECASE) - - # If we have a clean message, show it (limited length) - if first_line and len(first_line) > 10: - # Limit to reasonable length - if len(first_line) > 150: - first_line = first_line[:147] + "..." - return first_line - - # Fallback: generic message - return "CrossHair execution failed. Check logs for details." - - -# Create sidecar subcommand group -sidecar_app = typer.Typer(name="sidecar", help="Sidecar validation commands", suggest_commands=False) -app.add_typer(sidecar_app) - - -@sidecar_app.command() -@beartype -@require(lambda bundle_name: bundle_name and len(bundle_name.strip()) > 0, "Bundle name must be non-empty") -@require(lambda repo_path: repo_path.exists(), "Repository path must exist") -def init( - bundle_name: str = typer.Argument(..., help="Project bundle name (e.g., 'legacy-api')"), - repo_path: Path = typer.Argument(..., help="Path to repository root directory"), -) -> None: - """ - Initialize sidecar workspace for validation. - - Creates sidecar workspace directory structure and configuration for contract-based - validation of external codebases without modifying source code. - - **What it does:** - - Detects framework type (Django, FastAPI, DRF, pure-python) - - Creates sidecar workspace directory structure - - Generates configuration files - - Detects Python environment (venv, poetry, uv, pip) - - Sets up framework-specific configuration (e.g., DJANGO_SETTINGS_MODULE) - - **Example:** - ```bash - specfact validate sidecar init legacy-api /path/to/repo - ``` - - **Next steps:** - After initialization, run `specfact validate sidecar run` to execute validation. - """ - if is_debug_mode(): - debug_log_operation( - "command", - "validate sidecar init", - "started", - extra={"bundle_name": bundle_name, "repo_path": str(repo_path)}, - ) - debug_print("[dim]validate sidecar init: started[/dim]") - - config = SidecarConfig.create(bundle_name, repo_path) - - console.print(f"[bold]Initializing sidecar workspace for bundle: {bundle_name}[/bold]") - - if initialize_sidecar_workspace(config): - console.print("[green]✓[/green] Sidecar workspace initialized successfully") - console.print(f" Framework detected: {config.framework_type}") - if config.django_settings_module: - console.print(f" Django settings: {config.django_settings_module}") - else: - if is_debug_mode(): - debug_log_operation( - "command", - "validate sidecar init", - "failed", - error="Failed to initialize sidecar workspace", - extra={"reason": "init_failed", "bundle_name": bundle_name}, - ) - console.print("[red]✗[/red] Failed to initialize sidecar workspace") - raise typer.Exit(1) - - -@sidecar_app.command() -@beartype -@require(lambda bundle_name: bundle_name and len(bundle_name.strip()) > 0, "Bundle name must be non-empty") -@require(lambda repo_path: repo_path.exists(), "Repository path must exist") -def run( - bundle_name: str = typer.Argument(..., help="Project bundle name (e.g., 'legacy-api')"), - repo_path: Path = typer.Argument(..., help="Path to repository root directory"), - run_crosshair: bool = typer.Option( - True, "--run-crosshair/--no-run-crosshair", help="Run CrossHair symbolic execution analysis" - ), - run_specmatic: bool = typer.Option( - True, "--run-specmatic/--no-run-specmatic", help="Run Specmatic contract testing validation" - ), -) -> None: - """ - Run sidecar validation workflow. - - Executes complete sidecar validation workflow including framework detection, - route extraction, contract population, harness generation, and validation tools. - - **Workflow steps:** - 1. **Framework Detection**: Automatically detects Django, FastAPI, DRF, or pure-python - 2. **Route Extraction**: Extracts routes and schemas from framework-specific patterns - 3. **Contract Population**: Populates OpenAPI contracts with extracted routes/schemas - 4. **Harness Generation**: Generates CrossHair harness from populated contracts - 5. **CrossHair Analysis**: Runs symbolic execution on source code and harness (if enabled) - 6. **Specmatic Validation**: Runs contract testing against API endpoints (if enabled) - - **Example:** - ```bash - # Run full validation (CrossHair + Specmatic) - specfact validate sidecar run legacy-api /path/to/repo - - # Run only CrossHair analysis - specfact validate sidecar run legacy-api /path/to/repo --no-run-specmatic - - # Run only Specmatic validation - specfact validate sidecar run legacy-api /path/to/repo --no-run-crosshair - ``` - - **Output:** - - - Validation results displayed in console - - Reports saved to `.specfact/projects/<bundle>/reports/sidecar/` - - Progress indicators for long-running operations - """ - if is_debug_mode(): - debug_log_operation( - "command", - "validate sidecar run", - "started", - extra={ - "bundle_name": bundle_name, - "repo_path": str(repo_path), - "run_crosshair": run_crosshair, - "run_specmatic": run_specmatic, - }, - ) - debug_print("[dim]validate sidecar run: started[/dim]") - - config = SidecarConfig.create(bundle_name, repo_path) - config.tools.run_crosshair = run_crosshair - config.tools.run_specmatic = run_specmatic - - console.print(f"[bold]Running sidecar validation for bundle: {bundle_name}[/bold]") - - results = run_sidecar_validation(config, console=console) - - # Display results - console.print("\n[bold]Validation Results:[/bold]") - console.print(f" Framework: {results.get('framework_detected', 'unknown')}") - console.print(f" Routes extracted: {results.get('routes_extracted', 0)}") - console.print(f" Contracts populated: {results.get('contracts_populated', 0)}") - console.print(f" Harness generated: {results.get('harness_generated', False)}") - - if results.get("crosshair_results"): - console.print("\n[bold]CrossHair Results:[/bold]") - for key, value in results["crosshair_results"].items(): - success = value.get("success", False) - status = "[green]✓[/green]" if success else "[red]✗[/red]" - console.print(f" {status} {key}") - - # Display user-friendly error messages if CrossHair failed - if not success: - stderr = value.get("stderr", "") - stdout = value.get("stdout", "") - error_message = _format_crosshair_error(stderr, stdout) - if error_message: - # Use markup=False to prevent Rich from parsing brackets in error messages - # This prevents Rich markup errors when error messages contain brackets - try: - console.print(" [red]Error:[/red]", end=" ") - console.print(error_message, markup=False) - except Exception: - # If Rich itself fails (shouldn't happen with markup=False, but be safe) - # Fall back to plain print - print(f" Error: {error_message}") - - # Display summary if available - if results.get("crosshair_summary"): - summary = results["crosshair_summary"] - summary_line = format_summary_line(summary) - # Use try/except to catch Rich parsing errors - try: - console.print(f" {summary_line}") - except Exception: - # Fall back to plain print if Rich fails - print(f" {summary_line}") - - # Show summary file location if generated - if results.get("crosshair_summary_file"): - summary_file_path = results["crosshair_summary_file"] - # Use markup=False for paths to prevent Rich from parsing brackets - try: - console.print(" Summary file: ", end="") - console.print(str(summary_file_path), markup=False) - except Exception: - # Fall back to plain print if Rich fails - print(f" Summary file: {summary_file_path}") - - if results.get("specmatic_skipped"): - console.print( - f"\n[yellow]⚠ Specmatic skipped: {results.get('specmatic_skip_reason', 'Unknown reason')}[/yellow]" - ) - elif results.get("specmatic_results"): - console.print("\n[bold]Specmatic Results:[/bold]") - for key, value in results["specmatic_results"].items(): - success = value.get("success", False) - status = "[green]✓[/green]" if success else "[red]✗[/red]" - console.print(f" {status} {key}") - - if is_debug_mode(): - debug_log_operation( - "command", - "validate sidecar run", - "success", - extra={"bundle_name": bundle_name, "routes_extracted": results.get("routes_extracted", 0)}, - ) - debug_print("[dim]validate sidecar run: success[/dim]") +__all__ = ["app"] diff --git a/src/specfact_cli/generators/persona_exporter.py b/src/specfact_cli/generators/persona_exporter.py index 607c9b50..bf8beb54 100644 --- a/src/specfact_cli/generators/persona_exporter.py +++ b/src/specfact_cli/generators/persona_exporter.py @@ -108,7 +108,7 @@ def prepare_template_context( Returns: Template context dictionary """ - from specfact_cli.commands.project_cmd import match_section_pattern + from specfact_cli.utils.persona_ownership import match_section_pattern context: dict[str, Any] = { "bundle_name": bundle.bundle_name, diff --git a/src/specfact_cli/merge/resolver.py b/src/specfact_cli/merge/resolver.py index d1ca5fee..6addc5f1 100644 --- a/src/specfact_cli/merge/resolver.py +++ b/src/specfact_cli/merge/resolver.py @@ -355,7 +355,7 @@ def _get_owner(self, section_path: str, manifest: BundleManifest, persona: str) Returns: Persona name if persona owns section, None otherwise """ - from specfact_cli.commands.project_cmd import check_persona_ownership + from specfact_cli.utils.persona_ownership import check_persona_ownership if check_persona_ownership(persona, manifest, section_path): return persona diff --git a/src/specfact_cli/models/plan.py b/src/specfact_cli/models/plan.py index bf51fde3..68f673da 100644 --- a/src/specfact_cli/models/plan.py +++ b/src/specfact_cli/models/plan.py @@ -290,4 +290,7 @@ def update_summary(self, include_hash: bool = False) -> None: external_dependencies=[], summary=None, ) - self.metadata.summary = self.compute_summary(include_hash=include_hash) + metadata = self.metadata + if metadata is None: + return + metadata.summary = self.compute_summary(include_hash=include_hash) diff --git a/src/specfact_cli/modules/analyze/src/__init__.py b/src/specfact_cli/modules/analyze/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/analyze/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/analyze/src/app.py b/src/specfact_cli/modules/analyze/src/app.py index 2c2fcd63..fd59f482 100644 --- a/src/specfact_cli/modules/analyze/src/app.py +++ b/src/specfact_cli/modules/analyze/src/app.py @@ -1,6 +1,6 @@ -"""Analyze command: re-export from commands package.""" +"""analyze command entrypoint.""" -from specfact_cli.commands.analyze import app +from specfact_cli.modules.analyze.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/analyze/src/commands.py b/src/specfact_cli/modules/analyze/src/commands.py new file mode 100644 index 00000000..acff0a75 --- /dev/null +++ b/src/specfact_cli/modules/analyze/src/commands.py @@ -0,0 +1,361 @@ +""" +Analyze command - Analyze codebase for contract coverage and quality. + +This module provides commands for analyzing codebases to determine +contract coverage, code quality metrics, and enhancement opportunities. +""" + +from __future__ import annotations + +import ast +from pathlib import Path + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.table import Table + +from specfact_cli.models.quality import CodeQuality, QualityTracking +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils import print_error, print_success +from specfact_cli.utils.progress import load_bundle_with_progress +from specfact_cli.utils.structure import SpecFactStructure + + +app = typer.Typer(help="Analyze codebase for contract coverage and quality") +console = Console() + + +@app.command("contracts") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@ensure(lambda result: result is None, "Must return None") +def analyze_contracts( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'", + ), +) -> None: + """ + Analyze contract coverage for codebase. + + Scans codebase to determine which files have beartype, icontract, + and CrossHair contracts, and identifies files that need enhancement. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle (required) + + **Examples:** + specfact analyze contracts --repo . --bundle legacy-api + """ + if is_debug_mode(): + debug_log_operation("command", "analyze contracts", "started", extra={"repo": str(repo), "bundle": bundle}) + debug_print("[dim]analyze contracts: started[/dim]") + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None: + if is_debug_mode(): + debug_log_operation( + "command", + "analyze contracts", + "failed", + error="Bundle name required", + extra={"reason": "no_bundle"}, + ) + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + repo_path = repo.resolve() + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + + if not bundle_dir.exists(): + if is_debug_mode(): + debug_log_operation( + "command", + "analyze contracts", + "failed", + error=f"Bundle not found: {bundle_dir}", + extra={"reason": "bundle_missing"}, + ) + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + telemetry_metadata = { + "bundle": bundle, + } + + with telemetry.track_command("analyze.contracts", telemetry_metadata) as record: + console.print(f"[bold cyan]Contract Coverage Analysis:[/bold cyan] {bundle}") + console.print(f"[dim]Repository:[/dim] {repo_path}\n") + + # Load project bundle with unified progress display + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Analyze each feature's source files + quality_tracking = QualityTracking() + files_analyzed = 0 + files_with_beartype = 0 + files_with_icontract = 0 + files_with_crosshair = 0 + + for _feature_key, feature in project_bundle.features.items(): + if not feature.source_tracking: + continue + + for impl_file in feature.source_tracking.implementation_files: + file_path = repo_path / impl_file + if not file_path.exists(): + continue + + files_analyzed += 1 + quality = _analyze_file_quality(file_path) + quality_tracking.code_quality[impl_file] = quality + + if quality.beartype: + files_with_beartype += 1 + if quality.icontract: + files_with_icontract += 1 + if quality.crosshair: + files_with_crosshair += 1 + + # Sort files: prioritize files missing contracts + # Sort key: (has_all_contracts, total_contracts, file_path) + # This puts files missing contracts first, then by number of contracts (asc), then alphabetically + def sort_key(item: tuple[str, CodeQuality]) -> tuple[bool, int, str]: + file_path, quality = item + has_all = quality.beartype and quality.icontract and quality.crosshair + total_contracts = sum([quality.beartype, quality.icontract, quality.crosshair]) + return (has_all, total_contracts, file_path) + + sorted_files = sorted(quality_tracking.code_quality.items(), key=sort_key) + + # Show files needing attention first, limit to 30 for readability + max_display = 30 + files_to_display = sorted_files[:max_display] + total_files = len(sorted_files) + + # Display results + table_title = "Contract Coverage Analysis" + if total_files > max_display: + table_title += f" (showing top {max_display} files needing attention)" + table = Table(title=table_title) + table.add_column("File", style="cyan") + table.add_column("beartype", justify="center") + table.add_column("icontract", justify="center") + table.add_column("crosshair", justify="center") + table.add_column("Coverage", justify="right") + + for file_path, quality in files_to_display: + # Highlight files missing contracts + file_style = "yellow" if not (quality.beartype and quality.icontract) else "cyan" + table.add_row( + f"[{file_style}]{file_path}[/{file_style}]", + "✓" if quality.beartype else "[red]✗[/red]", + "✓" if quality.icontract else "[red]✗[/red]", + "✓" if quality.crosshair else "[dim]✗[/dim]", + f"{quality.coverage:.0%}", + ) + + console.print(table) + + # Show message if files were filtered + if total_files > max_display: + console.print( + f"\n[yellow]Note:[/yellow] Showing top {max_display} files needing attention " + f"(out of {total_files} total files analyzed). " + f"Files missing contracts are prioritized." + ) + + # Summary + console.print("\n[bold]Summary:[/bold]") + console.print(f" Files analyzed: {files_analyzed}") + if files_analyzed > 0: + beartype_pct = files_with_beartype / files_analyzed + icontract_pct = files_with_icontract / files_analyzed + crosshair_pct = files_with_crosshair / files_analyzed + console.print(f" Files with beartype: {files_with_beartype} ({beartype_pct:.1%})") + console.print(f" Files with icontract: {files_with_icontract} ({icontract_pct:.1%})") + console.print(f" Files with crosshair: {files_with_crosshair} ({crosshair_pct:.1%})") + else: + console.print(" Files with beartype: 0") + console.print(" Files with icontract: 0") + console.print(" Files with crosshair: 0") + + # Save quality tracking + quality_file = bundle_dir / "quality-tracking.yaml" + import yaml + + quality_file.parent.mkdir(parents=True, exist_ok=True) + with quality_file.open("w", encoding="utf-8") as f: + yaml.dump(quality_tracking.model_dump(), f, default_flow_style=False) + + print_success(f"Quality tracking saved to: {quality_file}") + + record( + { + "files_analyzed": files_analyzed, + "files_with_beartype": files_with_beartype, + "files_with_icontract": files_with_icontract, + "files_with_crosshair": files_with_crosshair, + } + ) + if is_debug_mode(): + debug_log_operation( + "command", + "analyze contracts", + "success", + extra={"files_analyzed": files_analyzed, "bundle": bundle}, + ) + debug_print("[dim]analyze contracts: success[/dim]") + + +def _analyze_file_quality(file_path: Path) -> CodeQuality: + """Analyze a file for contract coverage.""" + try: + with file_path.open(encoding="utf-8") as f: + content = f.read() + + # Quick check: if file is in models/ directory, likely a data model file + # This avoids expensive AST parsing for most data model files + file_str = str(file_path) + is_models_dir = "/models/" in file_str or "\\models\\" in file_str + + # For files in models/ directory, do quick AST check to confirm + if is_models_dir: + try: + import ast + + tree = ast.parse(content, filename=str(file_path)) + # Quick check: if only BaseModel classes with no business logic, skip contract check + if _is_pure_data_model_file(tree): + return CodeQuality( + beartype=True, # Pydantic provides type validation + icontract=True, # Pydantic provides validation (Field validators) + crosshair=False, # CrossHair not typically used for data models + coverage=0.0, + ) + except (SyntaxError, ValueError): + # If AST parsing fails, fall through to normal check + pass + + # Check for contract decorators in content + has_beartype = "beartype" in content or "@beartype" in content + has_icontract = "icontract" in content or "@require" in content or "@ensure" in content + has_crosshair = "crosshair" in content.lower() + + # Simple coverage estimation (would need actual test coverage tool) + coverage = 0.0 + + return CodeQuality( + beartype=has_beartype, + icontract=has_icontract, + crosshair=has_crosshair, + coverage=coverage, + ) + except Exception: + # Return default quality if analysis fails + return CodeQuality() + + +def _is_pure_data_model_file(tree: ast.AST) -> bool: + """ + Quick check if file contains only pure data models (Pydantic BaseModel, dataclasses) with no business logic. + + Returns: + True if file is pure data models, False otherwise + """ + has_pydantic_models = False + has_dataclasses = False + has_business_logic = False + + # Standard methods that don't need contracts (including common helper methods) + standard_methods = { + "__init__", + "__str__", + "__repr__", + "__eq__", + "__hash__", + "model_dump", + "model_validate", + "dict", + "json", + "copy", + "update", + # Common helper methods on data models (convenience methods, not business logic) + "compute_summary", + "update_summary", + "to_dict", + "from_dict", + "validate", + "serialize", + "deserialize", + } + + # Check module-level functions and class methods separately + # First, collect all classes and check their methods + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + # Check methods in this class + for item in node.body: + if isinstance(item, (ast.FunctionDef, ast.AsyncFunctionDef)) and item.name not in standard_methods: + # Non-standard method - likely business logic + has_business_logic = True + break + if has_business_logic: + break + + # Then check for module-level functions (functions not inside any class) + if not has_business_logic and isinstance(tree, ast.Module): + # Get all top-level nodes (module body) + for node in tree.body: + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)) and not node.name.startswith( + "_" + ): # Public functions + has_business_logic = True + break + + # Check for Pydantic models and dataclasses + for node in ast.walk(tree): + if isinstance(node, ast.ClassDef): + for base in node.bases: + if isinstance(base, ast.Name) and base.id == "BaseModel": + has_pydantic_models = True + break + if isinstance(base, ast.Attribute) and base.attr == "BaseModel": + has_pydantic_models = True + break + + for decorator in node.decorator_list: + if (isinstance(decorator, ast.Name) and decorator.id == "dataclass") or ( + isinstance(decorator, ast.Attribute) and decorator.attr == "dataclass" + ): + has_dataclasses = True + break + + # Business logic check is done above (methods and module-level functions) + + # File is pure data model if: + # 1. Has Pydantic models or dataclasses + # 2. No business logic methods or functions + return (has_pydantic_models or has_dataclasses) and not has_business_logic diff --git a/src/specfact_cli/modules/auth/src/__init__.py b/src/specfact_cli/modules/auth/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/auth/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/auth/src/app.py b/src/specfact_cli/modules/auth/src/app.py index de0320e5..48d52b41 100644 --- a/src/specfact_cli/modules/auth/src/app.py +++ b/src/specfact_cli/modules/auth/src/app.py @@ -1,6 +1,6 @@ -"""Auth command: re-export from commands package.""" +"""auth command entrypoint.""" -from specfact_cli.commands.auth import app +from specfact_cli.modules.auth.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/auth/src/commands.py b/src/specfact_cli/modules/auth/src/commands.py new file mode 100644 index 00000000..1254349d --- /dev/null +++ b/src/specfact_cli/modules/auth/src/commands.py @@ -0,0 +1,708 @@ +"""Authentication commands for DevOps providers.""" + +from __future__ import annotations + +import os +import time +from datetime import UTC, datetime, timedelta +from typing import Any + +import requests +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console +from specfact_cli.utils.auth_tokens import ( + clear_all_tokens, + clear_token, + normalize_provider, + set_token, + token_is_expired, +) + + +app = typer.Typer(help="Authenticate with DevOps providers using device code flows") +console = get_configured_console() + + +AZURE_DEVOPS_RESOURCE = "499b84ac-1321-427f-aa17-267ca6975798/.default" +# Note: Refresh tokens (90-day lifetime) are automatically obtained via persistent token cache +# offline_access is a reserved scope and cannot be explicitly requested +AZURE_DEVOPS_SCOPES = [AZURE_DEVOPS_RESOURCE] +DEFAULT_GITHUB_BASE_URL = "https://github.com" +DEFAULT_GITHUB_API_URL = "https://api.github.com" +DEFAULT_GITHUB_SCOPES = "repo" +DEFAULT_GITHUB_CLIENT_ID = "Ov23lizkVHsbEIjZKvRD" + + +@beartype +@ensure(lambda result: result is None, "Must return None") +def _print_token_status(provider: str, token_data: dict[str, Any]) -> None: + """Print a formatted token status line.""" + expires_at = token_data.get("expires_at") + status = "valid" + if token_is_expired(token_data): + status = "expired" + scope_info = "" + scopes = token_data.get("scopes") or token_data.get("scope") + if isinstance(scopes, list): + scope_info = ", scopes=" + ",".join(scopes) + elif isinstance(scopes, str) and scopes: + scope_info = f", scopes={scopes}" + expiry_info = f", expires_at={expires_at}" if expires_at else "" + console.print(f"[bold]{provider}[/bold]: {status}{scope_info}{expiry_info}") + + +@beartype +@ensure(lambda result: isinstance(result, str), "Must return base URL") +def _normalize_github_host(base_url: str) -> str: + """Normalize GitHub base URL to host root (no API path).""" + trimmed = base_url.rstrip("/") + if trimmed.endswith("/api/v3"): + trimmed = trimmed[: -len("/api/v3")] + if trimmed.endswith("/api"): + trimmed = trimmed[: -len("/api")] + return trimmed + + +@beartype +@ensure(lambda result: isinstance(result, str), "Must return API base URL") +def _infer_github_api_base_url(host_url: str) -> str: + """Infer GitHub API base URL from host URL.""" + normalized = host_url.rstrip("/") + if normalized.lower() == DEFAULT_GITHUB_BASE_URL: + return DEFAULT_GITHUB_API_URL + return f"{normalized}/api/v3" + + +@beartype +@require(lambda scopes: isinstance(scopes, str), "Scopes must be string") +@ensure(lambda result: isinstance(result, str), "Must return scope string") +def _normalize_scopes(scopes: str) -> str: + """Normalize scope string to space-separated list.""" + if not scopes.strip(): + return DEFAULT_GITHUB_SCOPES + if "," in scopes: + parts = [part.strip() for part in scopes.split(",") if part.strip()] + return " ".join(parts) + return scopes.strip() + + +@beartype +@require(lambda client_id: isinstance(client_id, str) and len(client_id) > 0, "Client ID required") +@require(lambda base_url: isinstance(base_url, str) and len(base_url) > 0, "Base URL required") +@require(lambda scopes: isinstance(scopes, str), "Scopes must be string") +@ensure(lambda result: isinstance(result, dict), "Must return device code response") +def _request_github_device_code(client_id: str, base_url: str, scopes: str) -> dict[str, Any]: + """Request GitHub device code payload.""" + endpoint = f"{base_url.rstrip('/')}/login/device/code" + headers = {"Accept": "application/json"} + payload = {"client_id": client_id, "scope": scopes} + response = requests.post(endpoint, data=payload, headers=headers, timeout=30) + response.raise_for_status() + return response.json() + + +@beartype +@require(lambda client_id: isinstance(client_id, str) and len(client_id) > 0, "Client ID required") +@require(lambda base_url: isinstance(base_url, str) and len(base_url) > 0, "Base URL required") +@require(lambda device_code: isinstance(device_code, str) and len(device_code) > 0, "Device code required") +@require(lambda interval: isinstance(interval, int) and interval > 0, "Interval must be positive int") +@require(lambda expires_in: isinstance(expires_in, int) and expires_in > 0, "Expires_in must be positive int") +@ensure(lambda result: isinstance(result, dict), "Must return token response") +def _poll_github_device_token( + client_id: str, + base_url: str, + device_code: str, + interval: int, + expires_in: int, +) -> dict[str, Any]: + """Poll GitHub device code token endpoint until authorized or timeout.""" + endpoint = f"{base_url.rstrip('/')}/login/oauth/access_token" + headers = {"Accept": "application/json"} + payload = { + "client_id": client_id, + "device_code": device_code, + "grant_type": "urn:ietf:params:oauth:grant-type:device_code", + } + + deadline = time.monotonic() + expires_in + poll_interval = interval + + while time.monotonic() < deadline: + response = requests.post(endpoint, data=payload, headers=headers, timeout=30) + response.raise_for_status() + body = response.json() + error = body.get("error") + if not error: + return body + + if error == "authorization_pending": + time.sleep(poll_interval) + continue + if error == "slow_down": + poll_interval += 5 + time.sleep(poll_interval) + continue + if error in {"expired_token", "access_denied"}: + msg = body.get("error_description") or error + raise RuntimeError(msg) + + msg = body.get("error_description") or error + raise RuntimeError(msg) + + raise RuntimeError("Device code expired before authorization completed") + + +@app.command("azure-devops") +def auth_azure_devops( + pat: str | None = typer.Option( + None, + "--pat", + help="Store a Personal Access Token (PAT) directly. PATs can have expiration up to 1 year, " + "unlike OAuth tokens which expire after ~1 hour. Create PAT at: " + "https://dev.azure.com/{org}/_usersSettings/tokens", + ), + use_device_code: bool = typer.Option( + False, + "--use-device-code", + help="Force device code flow instead of trying interactive browser first. " + "Useful for SSH/headless environments where browser cannot be opened.", + ), +) -> None: + """ + Authenticate to Azure DevOps using OAuth (device code or interactive browser) or Personal Access Token (PAT). + + **Token Options:** + + 1. **Personal Access Token (PAT)** - Recommended for long-lived authentication: + - Use --pat option to store a PAT directly + - PATs can have expiration up to 1 year (maximum allowed) + - Create PAT at: https://dev.azure.com/{org}/_usersSettings/tokens + - Select required scopes (e.g., "Work Items: Read & Write") + - Example: specfact auth azure-devops --pat your_pat_token + + 2. **OAuth Flow** (default, when no PAT provided): + - **First tries interactive browser** (opens browser automatically, better UX) + - **Falls back to device code** if browser unavailable (SSH/headless environments) + - Access tokens expire after ~1 hour, refresh tokens last 90 days (obtained automatically via persistent cache) + - Refresh tokens are automatically obtained when using persistent token cache (no explicit scope needed) + - Automatic token refresh via persistent cache (no re-authentication needed for 90 days) + - Example: specfact auth azure-devops + + 3. **Force Device Code Flow** (--use-device-code): + - Skip interactive browser, use device code directly + - Useful for SSH/headless environments or when browser cannot be opened + - Example: specfact auth azure-devops --use-device-code + + **For Long-Lived Tokens:** + Use a PAT with 90 days or 1 year expiration instead of OAuth tokens to avoid + frequent re-authentication. PATs are stored securely and work the same way as OAuth tokens. + """ + try: + from azure.identity import ( # type: ignore[reportMissingImports] + DeviceCodeCredential, + InteractiveBrowserCredential, + ) + except ImportError: + console.print("[bold red]✗[/bold red] azure-identity is not installed.") + console.print("Install dependencies with: pip install specfact-cli") + raise typer.Exit(1) from None + + def prompt_callback(verification_uri: str, user_code: str, expires_on: datetime) -> None: + expires_at = expires_on + if expires_at.tzinfo is None: + expires_at = expires_at.replace(tzinfo=UTC) + console.print("To sign in, use a web browser to open:") + console.print(f"[bold]{verification_uri}[/bold]") + console.print(f"Enter the code: [bold]{user_code}[/bold]") + console.print(f"Code expires at: {expires_at.isoformat()}") + + # If PAT is provided, store it directly (no expiration for PATs stored as Basic auth) + if pat: + console.print("[bold]Storing Personal Access Token (PAT)...[/bold]") + # PATs are stored as Basic auth tokens (no expiration date set by default) + # Users can create PATs with up to 1 year expiration in Azure DevOps UI + token_data = { + "access_token": pat, + "token_type": "basic", # PATs use Basic authentication + "issued_at": datetime.now(tz=UTC).isoformat(), + # Note: PAT expiration is managed by Azure DevOps, not stored locally + # Users should set expiration when creating PAT (up to 1 year) + } + set_token("azure-devops", token_data) + debug_log_operation("auth", "azure-devops", "success", extra={"method": "pat"}) + debug_print("[dim]auth azure-devops: PAT stored[/dim]") + console.print("[bold green]✓[/bold green] Personal Access Token stored") + console.print( + "[dim]PAT stored successfully. PATs can have expiration up to 1 year when created in Azure DevOps.[/dim]" + ) + console.print("[dim]Create/manage PATs at: https://dev.azure.com/{org}/_usersSettings/tokens[/dim]") + return + + # OAuth flow with persistent token cache (automatic refresh) + # Try interactive browser first, fall back to device code if it fails + debug_log_operation("auth", "azure-devops", "started", extra={"flow": "oauth"}) + debug_print("[dim]auth azure-devops: OAuth flow started[/dim]") + console.print("[bold]Starting Azure DevOps OAuth authentication...[/bold]") + + # Enable persistent token cache for automatic token refresh (like Azure CLI) + # This allows tokens to be refreshed automatically without re-authentication + cache_options = None + use_unencrypted_cache = False + try: + from azure.identity import TokenCachePersistenceOptions # type: ignore[reportMissingImports] + + # Try encrypted cache first (secure), fall back to unencrypted if keyring is locked + # Note: On Linux, the GNOME Keyring must be unlocked for encrypted cache to work. + # In SSH sessions, the keyring is typically locked and needs to be unlocked manually. + # The unencrypted cache fallback provides the same functionality (persistent storage, + # automatic refresh) without encryption. + try: + cache_options = TokenCachePersistenceOptions( + name="specfact-azure-devops", # Shared cache name across processes + allow_unencrypted_storage=False, # Prefer encrypted storage + ) + debug_log_operation("auth", "azure-devops", "cache_prepared", extra={"cache": "encrypted"}) + debug_print("[dim]auth azure-devops: token cache prepared (encrypted)[/dim]") + # Don't claim encrypted cache is enabled until we verify it works + # We'll print a message after successful authentication + # Check if we're on Linux and provide helpful info + import os + import platform + + if platform.system() == "Linux": + # Check D-Bus and secret service availability + dbus_session = os.environ.get("DBUS_SESSION_BUS_ADDRESS") + if not dbus_session: + console.print( + "[yellow]Note:[/yellow] D-Bus session not detected. Encrypted cache may fail.\n" + "[dim]To enable encrypted cache, ensure D-Bus is available:\n" + "[dim] - In SSH sessions: export $(dbus-launch)\n" + "[dim] - Unlock keyring: echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock[/dim]" + ) + except Exception: + # Encrypted cache not available (e.g., libsecret missing on Linux), try unencrypted + try: + cache_options = TokenCachePersistenceOptions( + name="specfact-azure-devops", + allow_unencrypted_storage=True, # Fallback: unencrypted storage + ) + use_unencrypted_cache = True + debug_log_operation( + "auth", + "azure-devops", + "cache_prepared", + extra={"cache": "unencrypted", "reason": "encrypted_unavailable"}, + ) + debug_print("[dim]auth azure-devops: token cache prepared (unencrypted fallback)[/dim]") + console.print( + "[yellow]Note:[/yellow] Encrypted cache unavailable (keyring locked). " + "Using unencrypted cache instead.\n" + "[dim]Tokens will be stored in plain text file but will refresh automatically.[/dim]" + ) + # Provide installation instructions for Linux + import platform + + if platform.system() == "Linux": + import os + + dbus_session = os.environ.get("DBUS_SESSION_BUS_ADDRESS") + console.print( + "[dim]To enable encrypted cache on Linux:\n" + " 1. Ensure packages are installed:\n" + " Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" + " RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" + " Arch: sudo pacman -S libsecret python-secretstorage\n" + ) + if not dbus_session: + console.print( + "[dim] 2. D-Bus session not detected. To enable encrypted cache:\n" + "[dim] - Start D-Bus: export $(dbus-launch)\n" + "[dim] - Unlock keyring: echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock\n" + "[dim] - Or use unencrypted cache (current fallback)[/dim]" + ) + else: + console.print( + "[dim] 2. D-Bus session detected, but keyring may be locked.\n" + "[dim] To unlock keyring in SSH session:\n" + "[dim] export $(dbus-launch)\n" + "[dim] echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock\n" + "[dim] Or use unencrypted cache (current fallback)[/dim]" + ) + except Exception: + # Persistent cache completely unavailable, use in-memory only + debug_log_operation( + "auth", + "azure-devops", + "cache_prepared", + extra={"cache": "none", "reason": "persistent_unavailable"}, + ) + debug_print("[dim]auth azure-devops: no persistent cache, in-memory only[/dim]") + console.print( + "[yellow]Note:[/yellow] Persistent cache not available, using in-memory cache only. " + "Tokens will need to be refreshed manually after expiration." + ) + # Provide installation instructions for Linux + import platform + + if platform.system() == "Linux": + console.print( + "[dim]To enable persistent token cache on Linux, install libsecret:\n" + " Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" + " RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" + " Arch: sudo pacman -S libsecret python-secretstorage\n" + " Also ensure a secret service daemon is running (gnome-keyring, kwallet, etc.)[/dim]" + ) + except ImportError: + # TokenCachePersistenceOptions not available in this version + pass + + # Helper function to try authentication with fallback to unencrypted cache or no cache + def try_authenticate_with_fallback(credential_class, credential_kwargs): + """Try authentication, falling back to unencrypted cache or no cache if encrypted cache fails.""" + nonlocal cache_options, use_unencrypted_cache + # First try with current cache_options + try: + credential = credential_class(cache_persistence_options=cache_options, **credential_kwargs) + # Refresh tokens are automatically obtained via persistent token cache + return credential.get_token(*AZURE_DEVOPS_SCOPES) + except Exception as e: + error_msg = str(e).lower() + # Log the actual error for debugging (only in verbose mode or if it's not a cache encryption error) + if "cache encryption" not in error_msg and "libsecret" not in error_msg: + console.print(f"[dim]Authentication error: {type(e).__name__}: {e}[/dim]") + # Check if error is about cache encryption and we haven't already tried unencrypted + if ( + ("cache encryption" in error_msg or "libsecret" in error_msg) + and cache_options + and not use_unencrypted_cache + ): + # Try again with unencrypted cache + console.print("[yellow]Note:[/yellow] Encrypted cache unavailable, trying unencrypted cache...") + try: + from azure.identity import TokenCachePersistenceOptions # type: ignore[reportMissingImports] + + unencrypted_cache = TokenCachePersistenceOptions( + name="specfact-azure-devops", + allow_unencrypted_storage=True, # Use unencrypted file storage + ) + credential = credential_class(cache_persistence_options=unencrypted_cache, **credential_kwargs) + # Refresh tokens are automatically obtained via persistent token cache + token = credential.get_token(*AZURE_DEVOPS_SCOPES) + console.print( + "[yellow]Note:[/yellow] Using unencrypted token cache (keyring locked). " + "Tokens will refresh automatically but stored without encryption." + ) + # Update global cache_options for future use + cache_options = unencrypted_cache + use_unencrypted_cache = True + return token + except Exception as e2: + # Unencrypted cache also failed - check if it's the same error + error_msg2 = str(e2).lower() + if "cache encryption" in error_msg2 or "libsecret" in error_msg2: + # Still failing on cache, try without cache entirely + console.print("[yellow]Note:[/yellow] Persistent cache unavailable, trying without cache...") + try: + credential = credential_class(**credential_kwargs) + # Without persistent cache, refresh tokens cannot be stored + token = credential.get_token(*AZURE_DEVOPS_SCOPES) + console.print( + "[yellow]Note:[/yellow] Using in-memory cache only. " + "Tokens will need to be refreshed manually after ~1 hour." + ) + return token + except Exception: + # Even without cache it failed, re-raise original + raise e from e2 + # Different error, re-raise + raise e2 from e + # Not a cache encryption error, re-raise + raise + + # Try interactive browser first (better UX), fall back to device code if it fails + token = None + if not use_device_code: + debug_log_operation("auth", "azure-devops", "attempt", extra={"method": "interactive_browser"}) + debug_print("[dim]auth azure-devops: attempting interactive browser[/dim]") + try: + console.print("[dim]Trying interactive browser authentication...[/dim]") + token = try_authenticate_with_fallback(InteractiveBrowserCredential, {}) + debug_log_operation("auth", "azure-devops", "success", extra={"method": "interactive_browser"}) + debug_print("[dim]auth azure-devops: interactive browser succeeded[/dim]") + console.print("[bold green]✓[/bold green] Interactive browser authentication successful") + except Exception as e: + # Interactive browser failed (no display, headless environment, etc.) + debug_log_operation( + "auth", + "azure-devops", + "fallback", + error=str(e), + extra={"method": "interactive_browser", "reason": "unavailable"}, + ) + debug_print(f"[dim]auth azure-devops: interactive browser failed, falling back: {e!s}[/dim]") + console.print(f"[yellow]⚠[/yellow] Interactive browser unavailable: {type(e).__name__}") + console.print("[dim]Falling back to device code flow...[/dim]") + + # Use device code flow if interactive browser failed or was explicitly requested + if token is None: + debug_log_operation("auth", "azure-devops", "attempt", extra={"method": "device_code"}) + debug_print("[dim]auth azure-devops: trying device code[/dim]") + console.print("[bold]Using device code authentication...[/bold]") + try: + token = try_authenticate_with_fallback(DeviceCodeCredential, {"prompt_callback": prompt_callback}) + debug_log_operation("auth", "azure-devops", "success", extra={"method": "device_code"}) + debug_print("[dim]auth azure-devops: device code succeeded[/dim]") + except Exception as e: + debug_log_operation( + "auth", + "azure-devops", + "failed", + error=str(e), + extra={"method": "device_code", "reason": type(e).__name__}, + ) + debug_print(f"[dim]auth azure-devops: device code failed: {e!s}[/dim]") + console.print(f"[bold red]✗[/bold red] Authentication failed: {e}") + raise typer.Exit(1) from e + + # token.expires_on is Unix timestamp in seconds since epoch (UTC) + # Verify it's in seconds (not milliseconds) - if > 1e10, it's likely milliseconds + expires_on_timestamp = token.expires_on + if expires_on_timestamp > 1e10: + # Likely in milliseconds, convert to seconds + expires_on_timestamp = expires_on_timestamp / 1000 + + # Convert to datetime for display + expires_at_dt = datetime.fromtimestamp(expires_on_timestamp, tz=UTC) + expires_at = expires_at_dt.isoformat() + + # Calculate remaining lifetime from current time (not total lifetime) + # This shows how much time is left until expiration + current_time_utc = datetime.now(tz=UTC) + current_timestamp = current_time_utc.timestamp() + remaining_lifetime_seconds = expires_on_timestamp - current_timestamp + token_lifetime_minutes = remaining_lifetime_seconds / 60 + + # For issued_at, we don't have the exact issue time from the token + # Estimate it based on typical token lifetime (usually ~1 hour for access tokens) + # Or calculate backwards from expiration if we know the typical lifetime + # For now, use current time as approximation (token was just issued) + issued_at = current_time_utc + + token_data = { + "access_token": token.token, + "token_type": "bearer", + "expires_at": expires_at, + "resource": AZURE_DEVOPS_RESOURCE, + "issued_at": issued_at.isoformat(), + } + set_token("azure-devops", token_data) + + cache_type = ( + "encrypted" + if cache_options and not use_unencrypted_cache + else ("unencrypted" if use_unencrypted_cache else "none") + ) + debug_log_operation( + "auth", + "azure-devops", + "success", + extra={"method": "oauth", "cache": cache_type, "reason": "token_stored"}, + ) + debug_print("[dim]auth azure-devops: OAuth complete, token stored[/dim]") + console.print("[bold green]✓[/bold green] Azure DevOps authentication complete") + console.print("Stored token for provider: azure-devops") + + # Calculate and display token lifetime + if token_lifetime_minutes < 30: + console.print( + f"[yellow]⚠[/yellow] Token expires at: {expires_at} (lifetime: ~{int(token_lifetime_minutes)} minutes)\n" + "[dim]Note: Short token lifetime may be due to Conditional Access policies or app registration settings.[/dim]\n" + "[dim]Without persistent cache, refresh tokens cannot be stored.\n" + "[dim]On Linux, install libsecret for automatic token refresh:\n" + "[dim] Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" + "[dim] RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" + "[dim] Arch: sudo pacman -S libsecret python-secretstorage[/dim]\n" + "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" + ) + else: + console.print( + f"[yellow]⚠[/yellow] Token expires at: {expires_at} (UTC)\n" + f"[yellow]⚠[/yellow] Time until expiration: ~{int(token_lifetime_minutes)} minutes\n" + ) + if cache_options is None: + console.print( + "[dim]Note: Persistent cache unavailable. Tokens will need to be refreshed manually after expiration.[/dim]\n" + "[dim]On Linux, install libsecret for automatic token refresh (90-day refresh token lifetime):\n" + "[dim] Ubuntu/Debian: sudo apt-get install libsecret-1-dev python3-secretstorage\n" + "[dim] RHEL/CentOS: sudo yum install libsecret-devel python3-secretstorage\n" + "[dim] Arch: sudo pacman -S libsecret python-secretstorage[/dim]\n" + "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" + ) + elif use_unencrypted_cache: + console.print( + "[dim]Persistent cache configured (unencrypted file storage). Tokens should refresh automatically.[/dim]\n" + "[dim]Note: Tokens are stored in plain text file. To enable encrypted storage, unlock the keyring:\n" + "[dim] export $(dbus-launch)\n" + "[dim] echo -n 'YOUR_PASSWORD' | gnome-keyring-daemon --replace --unlock[/dim]\n" + "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" + ) + else: + console.print( + "[dim]Persistent cache configured (encrypted storage). Tokens should refresh automatically (90-day refresh token lifetime).[/dim]\n" + "[dim]For longer-lived tokens (up to 1 year), use --pat option with a Personal Access Token.[/dim]" + ) + + +@app.command("github") +def auth_github( + client_id: str | None = typer.Option( + None, + "--client-id", + help="GitHub OAuth app client ID (defaults to SpecFact GitHub App)", + ), + base_url: str = typer.Option( + DEFAULT_GITHUB_BASE_URL, + "--base-url", + help="GitHub base URL (use your enterprise host for GitHub Enterprise)", + ), + scopes: str = typer.Option( + DEFAULT_GITHUB_SCOPES, + "--scopes", + help="OAuth scopes (comma or space separated)", + hidden=True, + ), +) -> None: + """Authenticate to GitHub using RFC 8628 device code flow.""" + provided_client_id = client_id or os.environ.get("SPECFACT_GITHUB_CLIENT_ID") + effective_client_id = provided_client_id or DEFAULT_GITHUB_CLIENT_ID + if not effective_client_id: + console.print("[bold red]✗[/bold red] GitHub client_id is required.") + console.print("Use --client-id or set SPECFACT_GITHUB_CLIENT_ID.") + raise typer.Exit(1) + + host_url = _normalize_github_host(base_url) + if provided_client_id is None and host_url.lower() != DEFAULT_GITHUB_BASE_URL: + console.print("[bold red]✗[/bold red] GitHub Enterprise requires a client ID.") + console.print("Provide --client-id or set SPECFACT_GITHUB_CLIENT_ID.") + raise typer.Exit(1) + scope_string = _normalize_scopes(scopes) + + console.print("[bold]Starting GitHub device code authentication...[/bold]") + device_payload = _request_github_device_code(effective_client_id, host_url, scope_string) + + user_code = device_payload.get("user_code") + verification_uri = device_payload.get("verification_uri") + verification_uri_complete = device_payload.get("verification_uri_complete") + device_code = device_payload.get("device_code") + expires_in = int(device_payload.get("expires_in", 900)) + interval = int(device_payload.get("interval", 5)) + + if not device_code: + console.print("[bold red]✗[/bold red] Invalid device code response from GitHub") + raise typer.Exit(1) + + if verification_uri_complete: + console.print(f"Open: [bold]{verification_uri_complete}[/bold]") + elif verification_uri and user_code: + console.print(f"Open: [bold]{verification_uri}[/bold] and enter code [bold]{user_code}[/bold]") + else: + console.print("[bold red]✗[/bold red] Invalid device code response from GitHub") + raise typer.Exit(1) + + token_payload = _poll_github_device_token( + effective_client_id, + host_url, + device_code, + interval, + expires_in, + ) + + access_token = token_payload.get("access_token") + if not access_token: + console.print("[bold red]✗[/bold red] GitHub did not return an access token") + raise typer.Exit(1) + + expires_at = datetime.now(tz=UTC) + timedelta(seconds=expires_in) + token_data = { + "access_token": access_token, + "token_type": token_payload.get("token_type", "bearer"), + "scopes": token_payload.get("scope", scope_string), + "client_id": effective_client_id, + "issued_at": datetime.now(tz=UTC).isoformat(), + "expires_at": None, + "base_url": host_url, + "api_base_url": _infer_github_api_base_url(host_url), + } + + # Preserve expires_at only if GitHub returns explicit expiry (usually None) + if token_payload.get("expires_in"): + token_data["expires_at"] = expires_at.isoformat() + + set_token("github", token_data) + + console.print("[bold green]✓[/bold green] GitHub authentication complete") + console.print("Stored token for provider: github") + + +@app.command("status") +def auth_status() -> None: + """Show authentication status for supported providers.""" + tokens = load_tokens_safe() + if not tokens: + console.print("No stored authentication tokens found.") + return + + if len(tokens) == 1: + only_provider = next(iter(tokens.keys())) + console.print(f"Detected provider: {only_provider} (auto-detected)") + + for provider, token_data in tokens.items(): + _print_token_status(provider, token_data) + + +@app.command("clear") +def auth_clear( + provider: str | None = typer.Option( + None, + "--provider", + help="Provider to clear (azure-devops or github). Clear all if omitted.", + ), +) -> None: + """Clear stored authentication tokens.""" + if provider: + clear_token(provider) + console.print(f"Cleared stored token for {normalize_provider(provider)}") + return + + tokens = load_tokens_safe() + if not tokens: + console.print("No stored tokens to clear") + return + + if len(tokens) == 1: + only_provider = next(iter(tokens.keys())) + clear_token(only_provider) + console.print(f"Cleared stored token for {only_provider} (auto-detected)") + return + + clear_all_tokens() + console.print("Cleared all stored tokens") + + +def load_tokens_safe() -> dict[str, dict[str, Any]]: + """Load tokens and handle errors gracefully for CLI output.""" + try: + return get_token_map() + except ValueError as exc: + console.print(f"[bold red]✗[/bold red] {exc}") + raise typer.Exit(1) from exc + + +def get_token_map() -> dict[str, dict[str, Any]]: + """Load token map without CLI side effects.""" + from specfact_cli.utils.auth_tokens import load_tokens + + return load_tokens() diff --git a/src/specfact_cli/modules/backlog/src/__init__.py b/src/specfact_cli/modules/backlog/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/backlog/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/backlog/src/app.py b/src/specfact_cli/modules/backlog/src/app.py index 961c4d48..75e962f5 100644 --- a/src/specfact_cli/modules/backlog/src/app.py +++ b/src/specfact_cli/modules/backlog/src/app.py @@ -1,6 +1,6 @@ -"""Backlog command: re-export from commands package.""" +"""backlog command entrypoint.""" -from specfact_cli.commands.backlog_commands import app +from specfact_cli.modules.backlog.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/backlog/src/commands.py b/src/specfact_cli/modules/backlog/src/commands.py new file mode 100644 index 00000000..76f46d66 --- /dev/null +++ b/src/specfact_cli/modules/backlog/src/commands.py @@ -0,0 +1,2802 @@ +""" +Backlog refinement commands. + +This module provides the `specfact backlog refine` command for AI-assisted +backlog refinement with template detection and matching. + +SpecFact CLI Architecture: +- SpecFact CLI generates prompts/instructions for IDE AI copilots +- IDE AI copilots execute those instructions using their native LLM +- IDE AI copilots feed results back to SpecFact CLI +- SpecFact CLI validates and processes the results +""" + +from __future__ import annotations + +import contextlib +import os +import re +import subprocess +import sys +import tempfile +from datetime import date, datetime +from pathlib import Path +from typing import Any +from urllib.parse import urlparse + +import typer +import yaml +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.panel import Panel +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.prompt import Confirm +from rich.table import Table + +from specfact_cli.adapters.registry import AdapterRegistry +from specfact_cli.backlog.adapters.base import BacklogAdapter +from specfact_cli.backlog.ai_refiner import BacklogAIRefiner +from specfact_cli.backlog.filters import BacklogFilters +from specfact_cli.backlog.template_detector import TemplateDetector +from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.models.dor_config import DefinitionOfReady +from specfact_cli.runtime import debug_log_operation, is_debug_mode +from specfact_cli.templates.registry import TemplateRegistry + + +app = typer.Typer( + name="backlog", + help="Backlog refinement and template management", + context_settings={"help_option_names": ["-h", "--help"]}, +) +console = Console() + + +def _apply_filters( + items: list[BacklogItem], + labels: list[str] | None = None, + state: str | None = None, + assignee: str | None = None, + iteration: str | None = None, + sprint: str | None = None, + release: str | None = None, +) -> list[BacklogItem]: + """ + Apply post-fetch filters to backlog items. + + Args: + items: List of BacklogItem instances to filter + labels: Filter by labels/tags (any label must match) + state: Filter by state (exact match) + assignee: Filter by assignee (exact match) + iteration: Filter by iteration path (exact match) + sprint: Filter by sprint (exact match) + release: Filter by release (exact match) + + Returns: + Filtered list of BacklogItem instances + """ + filtered = items + + # Filter by labels/tags (any label must match) + if labels: + filtered = [ + item for item in filtered if any(label.lower() in [tag.lower() for tag in item.tags] for label in labels) + ] + + # Filter by state (case-insensitive) + if state: + normalized_state = BacklogFilters.normalize_filter_value(state) + filtered = [item for item in filtered if BacklogFilters.normalize_filter_value(item.state) == normalized_state] + + # Filter by assignee (case-insensitive) + # Matches against any identifier in assignees list (displayName, uniqueName, or mail for ADO) + if assignee: + normalized_assignee = BacklogFilters.normalize_filter_value(assignee) + filtered = [ + item + for item in filtered + if item.assignees # Only check items with assignees + and any( + BacklogFilters.normalize_filter_value(a) == normalized_assignee + for a in item.assignees + if a # Skip None or empty strings + ) + ] + + # Filter by iteration (case-insensitive) + if iteration: + normalized_iteration = BacklogFilters.normalize_filter_value(iteration) + filtered = [ + item + for item in filtered + if item.iteration and BacklogFilters.normalize_filter_value(item.iteration) == normalized_iteration + ] + + # Filter by sprint (case-insensitive) + if sprint: + normalized_sprint = BacklogFilters.normalize_filter_value(sprint) + filtered = [ + item + for item in filtered + if item.sprint and BacklogFilters.normalize_filter_value(item.sprint) == normalized_sprint + ] + + # Filter by release (case-insensitive) + if release: + normalized_release = BacklogFilters.normalize_filter_value(release) + filtered = [ + item + for item in filtered + if item.release and BacklogFilters.normalize_filter_value(item.release) == normalized_release + ] + + return filtered + + +def _parse_standup_from_body(body: str) -> tuple[str | None, str | None, str | None]: + """Extract yesterday/today/blockers lines from body (standup format).""" + yesterday: str | None = None + today: str | None = None + blockers: str | None = None + if not body: + return yesterday, today, blockers + for line in body.splitlines(): + line_stripped = line.strip() + if re.match(r"^\*\*[Yy]esterday(?:\*\*|:)\s*\*\*\s*", line_stripped): + yesterday = re.sub(r"^\*\*[Yy]esterday(?:\*\*|:)\s*\*\*\s*", "", line_stripped).strip() + elif re.match(r"^\*\*[Tt]oday(?:\*\*|:)\s*\*\*\s*", line_stripped): + today = re.sub(r"^\*\*[Tt]oday(?:\*\*|:)\s*\*\*\s*", "", line_stripped).strip() + elif re.match(r"^\*\*[Bb]lockers?(?:\*\*|:)\s*\*\*\s*", line_stripped): + blockers = re.sub(r"^\*\*[Bb]lockers?(?:\*\*|:)\s*\*\*\s*", "", line_stripped).strip() + return yesterday, today, blockers + + +def _load_standup_config() -> dict[str, Any]: + """Load standup config from env and optional .specfact/standup.yaml. Env overrides file.""" + config: dict[str, Any] = {} + config_dir = os.environ.get("SPECFACT_CONFIG_DIR") + search_paths: list[Path] = [] + if config_dir: + search_paths.append(Path(config_dir)) + search_paths.append(Path.cwd() / ".specfact") + for base in search_paths: + path = base / "standup.yaml" + if path.is_file(): + try: + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + config = dict(data.get("standup", data)) + except Exception as exc: + debug_log_operation("config_load", str(path), "error", error=repr(exc)) + break + if os.environ.get("SPECFACT_STANDUP_STATE"): + config["default_state"] = os.environ["SPECFACT_STANDUP_STATE"] + if os.environ.get("SPECFACT_STANDUP_LIMIT"): + with contextlib.suppress(ValueError): + config["limit"] = int(os.environ["SPECFACT_STANDUP_LIMIT"]) + if os.environ.get("SPECFACT_STANDUP_ASSIGNEE"): + config["default_assignee"] = os.environ["SPECFACT_STANDUP_ASSIGNEE"] + return config + + +def _load_backlog_config() -> dict[str, Any]: + """Load project backlog context from .specfact/backlog.yaml (no secrets). + Same search path as standup: SPECFACT_CONFIG_DIR then .specfact in cwd. + When file has top-level 'backlog' key, that nested structure is returned. + """ + config: dict[str, Any] = {} + config_dir = os.environ.get("SPECFACT_CONFIG_DIR") + search_paths: list[Path] = [] + if config_dir: + search_paths.append(Path(config_dir)) + search_paths.append(Path.cwd() / ".specfact") + for base in search_paths: + path = base / "backlog.yaml" + if path.is_file(): + try: + with open(path, encoding="utf-8") as f: + data = yaml.safe_load(f) or {} + if isinstance(data, dict) and "backlog" in data: + nested = data["backlog"] + config = dict(nested) if isinstance(nested, dict) else {} + else: + config = dict(data) if isinstance(data, dict) else {} + except Exception as exc: + debug_log_operation("config_load", str(path), "error", error=repr(exc)) + break + return config + + +@beartype +def _resolve_standup_options( + cli_state: str | None, + cli_limit: int | None, + cli_assignee: str | None, + config: dict[str, Any] | None, +) -> tuple[str, int, str | None]: + """ + Resolve effective state, limit, assignee from CLI options and config. + CLI options override config; config overrides built-in defaults. + Returns (state, limit, assignee). + """ + cfg = config or _load_standup_config() + default_state = str(cfg.get("default_state", "open")) + default_limit = int(cfg.get("limit", 20)) if cfg.get("limit") is not None else 20 + default_assignee = cfg.get("default_assignee") + if default_assignee is not None: + default_assignee = str(default_assignee) + state = cli_state if cli_state is not None else default_state + limit = cli_limit if cli_limit is not None else default_limit + assignee = cli_assignee if cli_assignee is not None else default_assignee + return (state, limit, assignee) + + +@beartype +def _split_assigned_unassigned(items: list[BacklogItem]) -> tuple[list[BacklogItem], list[BacklogItem]]: + """Split items into assigned and unassigned (assignees empty or None).""" + assigned: list[BacklogItem] = [] + unassigned: list[BacklogItem] = [] + for item in items: + if item.assignees: + assigned.append(item) + else: + unassigned.append(item) + return (assigned, unassigned) + + +def _format_sprint_end_header(end_date: date) -> str: + """Format sprint end date as 'Sprint ends: YYYY-MM-DD (N days)'.""" + today = date.today() + delta = (end_date - today).days + return f"Sprint ends: {end_date.isoformat()} ({delta} days)" + + +@beartype +def _sort_standup_rows_blockers_first(rows: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Sort standup rows so items with non-empty blockers appear first.""" + with_blockers = [r for r in rows if (r.get("blockers") or "").strip()] + without = [r for r in rows if not (r.get("blockers") or "").strip()] + return with_blockers + without + + +@beartype +def _build_standup_rows( + items: list[BacklogItem], + include_priority: bool = False, +) -> list[dict[str, Any]]: + """ + Build standup view rows from backlog items (id, title, status, last_updated, optional yesterday/today/blockers). + When include_priority is True and item has priority/business_value, add to row. + """ + rows: list[dict[str, Any]] = [] + for item in items: + yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") + row: dict[str, Any] = { + "id": item.id, + "title": item.title, + "status": item.state, + "last_updated": item.updated_at, + "yesterday": yesterday or "", + "today": today or "", + "blockers": blockers or "", + } + if include_priority and item.priority is not None: + row["priority"] = item.priority + elif include_priority and item.business_value is not None: + row["priority"] = item.business_value + rows.append(row) + return rows + + +@beartype +def _format_standup_comment(yesterday: str, today: str, blockers: str) -> str: + """Format standup text as a comment (Yesterday / Today / Blockers) with date prefix.""" + prefix = f"Standup {date.today().isoformat()}" + parts = [prefix, ""] + if yesterday: + parts.append(f"**Yesterday:** {yesterday}") + if today: + parts.append(f"**Today:** {today}") + if blockers: + parts.append(f"**Blockers:** {blockers}") + return "\n".join(parts).strip() + + +@beartype +def _post_standup_comment_supported(adapter: BacklogAdapter, item: BacklogItem) -> bool: + """Return True if the adapter supports adding comments (e.g. for standup post).""" + return adapter.supports_add_comment() + + +@beartype +def _post_standup_to_item(adapter: BacklogAdapter, item: BacklogItem, body: str) -> bool: + """Post standup comment to the linked issue via adapter. Returns True on success.""" + return adapter.add_comment(item, body) + + +@beartype +@ensure( + lambda result: result is None or (isinstance(result, (int, float)) and result >= 0), + "Value score is non-negative when present", +) +def _compute_value_score(item: BacklogItem) -> float | None: + """ + Compute value score for next-best suggestion: business_value / max(1, story_points * priority). + + Returns None when any of story_points, business_value, or priority is missing. + """ + if item.story_points is None or item.business_value is None or item.priority is None: + return None + denom = max(1, (item.story_points or 0) * (item.priority or 1)) + return item.business_value / denom + + +@beartype +def _format_daily_item_detail(item: BacklogItem, comments: list[str]) -> str: + """ + Format a single backlog item for interactive detail view (refine-like). + + Includes ID, title, status, assignees, last updated, description, acceptance criteria, + standup fields (yesterday/today/blockers), and comments when provided. + """ + parts: list[str] = [] + parts.append(f"## {item.id} - {item.title}") + parts.append(f"- **Status:** {item.state}") + assignee_str = ", ".join(item.assignees) if item.assignees else "—" + parts.append(f"- **Assignees:** {assignee_str}") + updated = ( + item.updated_at.strftime("%Y-%m-%d %H:%M") if hasattr(item.updated_at, "strftime") else str(item.updated_at) + ) + parts.append(f"- **Last updated:** {updated}") + if item.body_markdown: + parts.append("\n**Description:**") + parts.append(item.body_markdown.strip()) + if item.acceptance_criteria: + parts.append("\n**Acceptance criteria:**") + parts.append(item.acceptance_criteria.strip()) + yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") + if yesterday or today or blockers: + parts.append("\n**Standup:**") + if yesterday: + parts.append(f"- Yesterday: {yesterday}") + if today: + parts.append(f"- Today: {today}") + if blockers: + parts.append(f"- Blockers: {blockers}") + if item.story_points is not None: + parts.append(f"\n- **Story points:** {item.story_points}") + if item.business_value is not None: + parts.append(f"- **Business value:** {item.business_value}") + if item.priority is not None: + parts.append(f"- **Priority:** {item.priority}") + if comments: + parts.append("\n**Comments:**") + for c in comments: + parts.append(f"- {c}") + return "\n".join(parts) + + +def _collect_comment_annotations( + adapter: str, + items: list[BacklogItem], + *, + repo_owner: str | None, + repo_name: str | None, + github_token: str | None, + ado_org: str | None, + ado_project: str | None, + ado_token: str | None, +) -> dict[str, list[str]]: + """ + Collect comment annotations for backlog items when the adapter supports get_comments(). + + Returns a mapping of item ID -> list of comment strings. Returns empty dict if not supported. + """ + comments_by_item_id: dict[str, list[str]] = {} + try: + adapter_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + registry = AdapterRegistry() + adapter_instance = registry.get_adapter(adapter, **adapter_kwargs) + if not isinstance(adapter_instance, BacklogAdapter): + return comments_by_item_id + get_comments_fn = getattr(adapter_instance, "get_comments", None) + if not callable(get_comments_fn): + return comments_by_item_id + for item in items: + with contextlib.suppress(Exception): + raw = get_comments_fn(item) + comments_by_item_id[item.id] = list(raw) if isinstance(raw, list) else [] + except Exception: + return comments_by_item_id + return comments_by_item_id + + +@beartype +def _build_copilot_export_content( + items: list[BacklogItem], + include_value_score: bool = False, + include_comments: bool = False, + comments_by_item_id: dict[str, list[str]] | None = None, +) -> str: + """ + Build Markdown content for Copilot export: one section per item. + + Per item: ID, title, status, assignees, last updated, progress summary (standup fields), + blockers, optional value score, and optionally description/comments when enabled. + """ + lines: list[str] = [] + lines.append("# Daily standup – Copilot export") + lines.append("") + comments_map = comments_by_item_id or {} + for item in items: + lines.append(f"## {item.id} - {item.title}") + lines.append("") + lines.append(f"- **Status:** {item.state}") + assignee_str = ", ".join(item.assignees) if item.assignees else "—" + lines.append(f"- **Assignees:** {assignee_str}") + updated = ( + item.updated_at.strftime("%Y-%m-%d %H:%M") if hasattr(item.updated_at, "strftime") else str(item.updated_at) + ) + lines.append(f"- **Last updated:** {updated}") + if include_comments: + body = (item.body_markdown or "").strip() + if body: + snippet = body[:_SUMMARIZE_BODY_TRUNCATE] + if len(body) > _SUMMARIZE_BODY_TRUNCATE: + snippet += "\n..." + lines.append("- **Description:**") + for line in snippet.splitlines(): + lines.append(f" {line}" if line else " ") + yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") + if yesterday or today: + lines.append(f"- **Progress:** Yesterday: {yesterday or '—'}; Today: {today or '—'}") + if blockers: + lines.append(f"- **Blockers:** {blockers}") + if include_comments: + item_comments = comments_map.get(item.id, []) + if item_comments: + lines.append("- **Comments (annotations):**") + for c in item_comments: + lines.append(f" - {c}") + if item.story_points is not None: + lines.append(f"- **Story points:** {item.story_points}") + if item.priority is not None: + lines.append(f"- **Priority:** {item.priority}") + if include_value_score: + score = _compute_value_score(item) + if score is not None: + lines.append(f"- **Value score:** {score:.2f}") + lines.append("") + return "\n".join(lines).strip() + + +_SUMMARIZE_BODY_TRUNCATE = 1200 + + +@beartype +def _build_summarize_prompt_content( + items: list[BacklogItem], + filter_context: dict[str, Any], + include_value_score: bool = False, + comments_by_item_id: dict[str, list[str]] | None = None, + include_comments: bool = False, +) -> str: + """ + Build prompt content for standup summary: instruction + filter context + per-item data. + + When include_comments is True, includes body (description) and annotations (comments) per item + so an LLM can produce a meaningful summary. When False, only metadata (id, title, status, + assignees, last updated) is included to avoid leaking sensitive or large context. + For use with slash command (e.g. specfact.daily) or copy-paste to Copilot. + """ + lines: list[str] = [] + lines.append("--- BEGIN STANDUP PROMPT ---") + lines.append("Generate a concise daily standup summary from the following data.") + if include_comments: + lines.append( + "Include: current focus, blockers, and pending items. Use each item's description and comments for context. Keep it short and actionable." + ) + else: + lines.append("Include: current focus and pending items from the metadata below. Keep it short and actionable.") + lines.append("") + lines.append("## Filter context") + lines.append(f"- Adapter: {filter_context.get('adapter', '—')}") + lines.append(f"- State: {filter_context.get('state', '—')}") + lines.append(f"- Sprint: {filter_context.get('sprint', '—')}") + lines.append(f"- Assignee: {filter_context.get('assignee', '—')}") + lines.append(f"- Limit: {filter_context.get('limit', '—')}") + lines.append("") + data_header = "Standup data (with description and comments)" if include_comments else "Standup data (metadata only)" + lines.append(f"## {data_header}") + lines.append("") + comments_map = comments_by_item_id or {} + for item in items: + lines.append(f"## {item.id} - {item.title}") + lines.append("") + lines.append(f"- **Status:** {item.state}") + assignee_str = ", ".join(item.assignees) if item.assignees else "—" + lines.append(f"- **Assignees:** {assignee_str}") + updated = ( + item.updated_at.strftime("%Y-%m-%d %H:%M") if hasattr(item.updated_at, "strftime") else str(item.updated_at) + ) + lines.append(f"- **Last updated:** {updated}") + if include_comments: + body = (item.body_markdown or "").strip() + if body: + snippet = body[:_SUMMARIZE_BODY_TRUNCATE] + if len(body) > _SUMMARIZE_BODY_TRUNCATE: + snippet += "\n..." + lines.append("- **Description:**") + lines.append(snippet) + lines.append("") + yesterday, today, blockers = _parse_standup_from_body(item.body_markdown or "") + if yesterday or today: + lines.append(f"- **Progress:** Yesterday: {yesterday or '—'}; Today: {today or '—'}") + if blockers: + lines.append(f"- **Blockers:** {blockers}") + item_comments = comments_map.get(item.id, []) + if item_comments: + lines.append("- **Comments (annotations):**") + for c in item_comments: + lines.append(f" - {c}") + if item.story_points is not None: + lines.append(f"- **Story points:** {item.story_points}") + if item.priority is not None: + lines.append(f"- **Priority:** {item.priority}") + if include_value_score: + score = _compute_value_score(item) + if score is not None: + lines.append(f"- **Value score:** {score:.2f}") + lines.append("") + lines.append("--- END STANDUP PROMPT ---") + return "\n".join(lines).strip() + + +def _run_interactive_daily( + items: list[BacklogItem], + standup_config: dict[str, Any], + suggest_next: bool, + adapter: str, + repo_owner: str | None, + repo_name: str | None, + github_token: str | None, + ado_org: str | None, + ado_project: str | None, + ado_token: str | None, +) -> None: + """ + Run interactive step-by-step review: questionary selection, detail view, next/previous/back/exit. + """ + try: + import questionary # type: ignore[reportMissingImports] + except ImportError: + console.print( + "[red]Interactive mode requires the 'questionary' package. Install with: pip install questionary[/red]" + ) + raise typer.Exit(1) from None + + adapter_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + registry = AdapterRegistry() + adapter_instance = registry.get_adapter(adapter, **adapter_kwargs) + get_comments_fn = getattr(adapter_instance, "get_comments", lambda _: []) + + n = len(items) + choices = [ + f"{item.id} - {item.title[:50]}{'...' if len(item.title) > 50 else ''} [{item.state}] ({', '.join(item.assignees) or '—'})" + for item in items + ] + choices.append("Exit") + + while True: + selected = questionary.select("Select a story to review (or Exit)", choices=choices).ask() + if selected is None or selected == "Exit": + return + try: + idx = choices.index(selected) + except ValueError: + return + if idx >= n: + return + + current_idx = idx + while True: + item = items[current_idx] + comments: list[str] = [] + if callable(get_comments_fn): + with contextlib.suppress(Exception): + raw = get_comments_fn(item) + comments = list(raw) if isinstance(raw, list) else [] + detail = _format_daily_item_detail(item, comments) + console.print(Panel(detail, title=f"Story: {item.id}", border_style="cyan")) + + if suggest_next and n > 1: + pending = [i for i in items if not i.assignees or i.story_points is not None] + if pending: + best: BacklogItem | None = None + best_score: float = -1.0 + for i in pending: + s = _compute_value_score(i) + if s is not None and s > best_score: + best_score = s + best = i + if best is not None: + console.print( + f"[dim]Suggested next (value score {best_score:.2f}): {best.id} - {best.title}[/dim]" + ) + + nav_choices = ["Next story", "Previous story", "Back to list", "Exit"] + nav = questionary.select("Navigation", choices=nav_choices).ask() + if nav is None or nav == "Exit": + return + if nav == "Back to list": + break + if nav == "Next story": + current_idx = (current_idx + 1) % n + elif nav == "Previous story": + current_idx = (current_idx - 1) % n + + +def _extract_openspec_change_id(body: str) -> str | None: + """ + Extract OpenSpec change proposal ID from issue body. + + Looks for patterns like: + - *OpenSpec Change Proposal: `id`* + - OpenSpec Change Proposal: `id` + - OpenSpec.*proposal: `id` + + Args: + body: Issue body text + + Returns: + Change proposal ID if found, None otherwise + """ + import re + + openspec_patterns = [ + r"OpenSpec Change Proposal[:\s]+`?([a-z0-9-]+)`?", + r"\*OpenSpec Change Proposal:\s*`([a-z0-9-]+)`", + r"OpenSpec.*proposal[:\s]+`?([a-z0-9-]+)`?", + ] + for pattern in openspec_patterns: + match = re.search(pattern, body, re.IGNORECASE) + if match: + return match.group(1) + return None + + +def _infer_github_repo_from_cwd() -> tuple[str | None, str | None]: + """ + Infer repo_owner and repo_name from git remote origin when run inside a GitHub clone. + Returns (owner, repo) or (None, None) if not a GitHub remote or git unavailable. + """ + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=Path.cwd(), + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode != 0 or not result.stdout or not result.stdout.strip(): + return (None, None) + url = result.stdout.strip() + owner, repo = None, None + if url.startswith("git@"): + part = url.split(":", 1)[-1].strip() + if part.endswith(".git"): + part = part[:-4] + segments = part.split("/") + if len(segments) >= 2 and "github" in url.lower(): + owner, repo = segments[-2], segments[-1] + else: + parsed = urlparse(url) + if parsed.hostname and "github" in parsed.hostname.lower() and parsed.path: + path = parsed.path.strip("/") + if path.endswith(".git"): + path = path[:-4] + segments = path.split("/") + if len(segments) >= 2: + owner, repo = segments[-2], segments[-1] + return (owner or None, repo or None) + except Exception: + return (None, None) + + +def _infer_ado_context_from_cwd() -> tuple[str | None, str | None]: + """ + Infer org and project from git remote origin when run inside an Azure DevOps clone. + Returns (org, project) or (None, None) if not an ADO remote or git unavailable. + Supports: + - HTTPS: https://dev.azure.com/org/project/_git/repo + - SSH (keys): git@ssh.dev.azure.com:v3/<org>/<project>/<repo> + - SSH (other): <user>@dev.azure.com:v3/<org>/<project>/<repo> (no ssh. subdomain) + """ + try: + result = subprocess.run( + ["git", "remote", "get-url", "origin"], + cwd=Path.cwd(), + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode != 0 or not result.stdout or not result.stdout.strip(): + return (None, None) + url = result.stdout.strip() + org, project = None, None + if "dev.azure.com" not in url.lower(): + return (None, None) + if ":" in url and "v3/" in url: + idx = url.find("v3/") + if idx != -1: + part = url[idx + 3 :].strip() + segments = part.split("/") + if len(segments) >= 2: + org, project = segments[0], segments[1] + else: + parsed = urlparse(url) + if parsed.path: + path = parsed.path.strip("/") + segments = path.split("/") + if len(segments) >= 2: + org, project = segments[0], segments[1] + return (org or None, project or None) + except Exception: + return (None, None) + + +def _build_adapter_kwargs( + adapter: str, + repo_owner: str | None = None, + repo_name: str | None = None, + github_token: str | None = None, + ado_org: str | None = None, + ado_project: str | None = None, + ado_team: str | None = None, + ado_token: str | None = None, +) -> dict[str, Any]: + """ + Build adapter kwargs from CLI args, then env, then .specfact/backlog.yaml. + Resolution order: explicit arg > env (SPECFACT_GITHUB_REPO_OWNER, etc.) > config. + Tokens are never read from config; only from explicit args (env handled by caller). + """ + cfg = _load_backlog_config() + kwargs: dict[str, Any] = {} + if adapter.lower() == "github": + owner = ( + repo_owner or os.environ.get("SPECFACT_GITHUB_REPO_OWNER") or (cfg.get("github") or {}).get("repo_owner") + ) + name = repo_name or os.environ.get("SPECFACT_GITHUB_REPO_NAME") or (cfg.get("github") or {}).get("repo_name") + if not owner or not name: + inferred_owner, inferred_name = _infer_github_repo_from_cwd() + if inferred_owner and inferred_name: + owner = owner or inferred_owner + name = name or inferred_name + if owner: + kwargs["repo_owner"] = owner + if name: + kwargs["repo_name"] = name + if github_token: + kwargs["api_token"] = github_token + elif adapter.lower() == "ado": + org = ado_org or os.environ.get("SPECFACT_ADO_ORG") or (cfg.get("ado") or {}).get("org") + project = ado_project or os.environ.get("SPECFACT_ADO_PROJECT") or (cfg.get("ado") or {}).get("project") + team = ado_team or os.environ.get("SPECFACT_ADO_TEAM") or (cfg.get("ado") or {}).get("team") + if not org or not project: + inferred_org, inferred_project = _infer_ado_context_from_cwd() + if inferred_org and inferred_project: + org = org or inferred_org + project = project or inferred_project + if org: + kwargs["org"] = org + if project: + kwargs["project"] = project + if team: + kwargs["team"] = team + if ado_token: + kwargs["api_token"] = ado_token + return kwargs + + +def _extract_body_from_block(block: str) -> str: + """ + Extract **Body** content from a refined export block, handling nested fenced code. + + The body is wrapped in ```markdown ... ```. If the body itself contains fenced + code blocks (e.g. ```python ... ```), the closing fence is matched by tracking + depth: a line that is exactly ``` closes the current fence (body or inner). + """ + start_marker = "**Body**:" + fence_open = "```markdown" + if start_marker not in block or fence_open not in block: + return "" + idx = block.find(start_marker) + rest = block[idx + len(start_marker) :].lstrip() + if not rest.startswith("```"): + return "" + if not rest.startswith(fence_open + "\n") and not rest.startswith(fence_open + "\r\n"): + return "" + after_open = rest[len(fence_open) :].lstrip("\n\r") + if not after_open: + return "" + lines = after_open.split("\n") + body_lines: list[str] = [] + depth = 1 + for line in lines: + stripped = line.rstrip() + if stripped == "```": + if depth == 1: + break + depth -= 1 + body_lines.append(line) + elif stripped.startswith("```") and stripped != "```": + depth += 1 + body_lines.append(line) + else: + body_lines.append(line) + return "\n".join(body_lines).strip() + + +def _parse_refined_export_markdown(content: str) -> dict[str, dict[str, Any]]: + """ + Parse refined export markdown (same format as --export-to-tmp) into id -> fields. + + Splits by ## Item blocks, extracts **ID**, **Body** (from ```markdown ... ```), + **Acceptance Criteria**, and optionally title and **Metrics** (story_points, + business_value, priority). Body extraction is fence-aware so bodies containing + nested code blocks are parsed correctly. Returns a dict mapping item id to + parsed fields (body_markdown, acceptance_criteria, title?, story_points?, + business_value?, priority?). + """ + result: dict[str, dict[str, Any]] = {} + blocks = re.split(r"\n## Item \d+:", content) + for block in blocks: + block = block.strip() + if not block or block.startswith("# SpecFact") or "**ID**:" not in block: + continue + id_match = re.search(r"\*\*ID\*\*:\s*(.+?)(?:\n|$)", block) + if not id_match: + continue + item_id = id_match.group(1).strip() + fields: dict[str, Any] = {} + + fields["body_markdown"] = _extract_body_from_block(block) + + ac_match = re.search(r"\*\*Acceptance Criteria\*\*:\s*\n(.*?)(?=\n\*\*|\n---|\Z)", block, re.DOTALL) + if ac_match: + fields["acceptance_criteria"] = ac_match.group(1).strip() or None + else: + fields["acceptance_criteria"] = None + + first_line = block.split("\n")[0].strip() if block else "" + if first_line and not first_line.startswith("**"): + fields["title"] = first_line + + if "Story Points:" in block: + sp_match = re.search(r"Story Points:\s*(\d+)", block) + if sp_match: + fields["story_points"] = int(sp_match.group(1)) + if "Business Value:" in block: + bv_match = re.search(r"Business Value:\s*(\d+)", block) + if bv_match: + fields["business_value"] = int(bv_match.group(1)) + if "Priority:" in block: + pri_match = re.search(r"Priority:\s*(\d+)", block) + if pri_match: + fields["priority"] = int(pri_match.group(1)) + + result[item_id] = fields + return result + + +@beartype +def _item_needs_refinement( + item: BacklogItem, + detector: TemplateDetector, + registry: TemplateRegistry, + template_id: str | None, + normalized_adapter: str | None, + normalized_framework: str | None, + normalized_persona: str | None, +) -> bool: + """ + Return True if the item needs refinement (should be processed); False if already refined (skip). + + Mirrors the "already refined" skip logic used in the refine loop: checkboxes + all required + sections, or high confidence with no missing fields. + """ + detection_result = detector.detect_template( + item, + provider=normalized_adapter, + framework=normalized_framework, + persona=normalized_persona, + ) + if detection_result.template_id: + target = registry.get_template(detection_result.template_id) if detection_result.template_id else None + if target and target.required_sections: + has_checkboxes = bool( + re.search(r"^[\s]*- \[[ x]\]", item.body_markdown or "", re.MULTILINE | re.IGNORECASE) + ) + all_present = all( + bool(re.search(rf"^#+\s+{re.escape(s)}\s*$", item.body_markdown or "", re.MULTILINE | re.IGNORECASE)) + for s in target.required_sections + ) + if has_checkboxes and all_present and not detection_result.missing_fields: + return False + already_refined = template_id is None and detection_result.confidence >= 0.8 and not detection_result.missing_fields + return not already_refined + + +def _fetch_backlog_items( + adapter_name: str, + search_query: str | None = None, + labels: list[str] | None = None, + state: str | None = None, + assignee: str | None = None, + iteration: str | None = None, + sprint: str | None = None, + release: str | None = None, + limit: int | None = None, + repo_owner: str | None = None, + repo_name: str | None = None, + github_token: str | None = None, + ado_org: str | None = None, + ado_project: str | None = None, + ado_team: str | None = None, + ado_token: str | None = None, +) -> list[BacklogItem]: + """ + Fetch backlog items using the specified adapter with filtering support. + + Args: + adapter_name: Adapter name (github, ado, etc.) + search_query: Optional search query to filter items (provider-specific syntax) + labels: Filter by labels/tags (post-fetch filtering) + state: Filter by state (post-fetch filtering) + assignee: Filter by assignee (post-fetch filtering) + iteration: Filter by iteration path (post-fetch filtering) + sprint: Filter by sprint (post-fetch filtering) + release: Filter by release (post-fetch filtering) + limit: Maximum number of items to fetch + + Returns: + List of BacklogItem instances (filtered) + """ + from specfact_cli.backlog.adapters.base import BacklogAdapter + + registry = AdapterRegistry() + + # Build adapter kwargs based on adapter type + adapter_kwargs = _build_adapter_kwargs( + adapter_name, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_team=ado_team, + ado_token=ado_token, + ) + + if adapter_name.lower() == "github" and ( + not adapter_kwargs.get("repo_owner") or not adapter_kwargs.get("repo_name") + ): + console.print("[red]repo_owner and repo_name required for GitHub.[/red]") + console.print( + "Set via: [cyan]--repo-owner[/cyan]/[cyan]--repo-name[/cyan], " + "env [cyan]SPECFACT_GITHUB_REPO_OWNER[/cyan]/[cyan]SPECFACT_GITHUB_REPO_NAME[/cyan], " + "or [cyan].specfact/backlog.yaml[/cyan] (see docs/guides/devops-adapter-integration.md). " + "When run from a GitHub clone, org/repo are auto-detected from git remote." + ) + raise typer.Exit(1) + if adapter_name.lower() == "ado" and (not adapter_kwargs.get("org") or not adapter_kwargs.get("project")): + console.print("[red]ado_org and ado_project required for Azure DevOps.[/red]") + console.print( + "Set via: [cyan]--ado-org[/cyan]/[cyan]--ado-project[/cyan], " + "env [cyan]SPECFACT_ADO_ORG[/cyan]/[cyan]SPECFACT_ADO_PROJECT[/cyan], " + "or [cyan].specfact/backlog.yaml[/cyan]. " + "When run from an ADO clone, org/project are auto-detected from git remote." + ) + raise typer.Exit(1) + + adapter = registry.get_adapter(adapter_name, **adapter_kwargs) + + # Check if adapter implements BacklogAdapter interface + if not isinstance(adapter, BacklogAdapter): + msg = f"Adapter {adapter_name} does not implement BacklogAdapter interface" + raise NotImplementedError(msg) + + # Create BacklogFilters from parameters + filters = BacklogFilters( + assignee=assignee, + state=state, + labels=labels, + search=search_query, + iteration=iteration, + sprint=sprint, + release=release, + limit=limit, + ) + + # Fetch items using the adapter + items = adapter.fetch_backlog_items(filters) + + # Apply limit deterministically (slice after filtering) + if limit is not None and len(items) > limit: + items = items[:limit] + + return items + + +@beartype +@app.command() +@require( + lambda adapter: isinstance(adapter, str) and len(adapter) > 0, + "Adapter must be non-empty string", +) +def daily( + adapter: str = typer.Argument(..., help="Backlog adapter name (github, ado, etc.)"), + assignee: str | None = typer.Option( + None, + "--assignee", + help="Filter by assignee (e.g. 'me' or username). Only matching items are listed.", + ), + state: str | None = typer.Option(None, "--state", help="Filter by state (e.g. open, closed, Active)"), + labels: list[str] | None = typer.Option(None, "--labels", "--tags", help="Filter by labels/tags"), + limit: int | None = typer.Option(None, "--limit", help="Maximum number of items to show"), + iteration: str | None = typer.Option( + None, + "--iteration", + help="Filter by iteration (e.g. 'current' or literal path). ADO: full path; adapter must support.", + ), + sprint: str | None = typer.Option( + None, + "--sprint", + help="Filter by sprint (e.g. 'current' or name). Adapter must support iteration/sprint.", + ), + show_unassigned: bool = typer.Option( + True, + "--show-unassigned/--no-show-unassigned", + help="Show unassigned/pending items in a second table (default: true).", + ), + unassigned_only: bool = typer.Option( + False, + "--unassigned-only", + help="Show only unassigned items (single table).", + ), + blockers_first: bool = typer.Option( + False, + "--blockers-first", + help="Sort so items with non-empty blockers appear first.", + ), + interactive: bool = typer.Option( + False, + "--interactive", + help="Step-by-step review: select items with arrow keys and view full detail (refine-like) and comments.", + ), + copilot_export: str | None = typer.Option( + None, + "--copilot-export", + help="Write summarized progress per story to a file for Copilot slash-command use during standup.", + ), + include_comments: bool = typer.Option( + False, + "--comments", + "--annotations", + help="Include item comments/annotations in summarize/copilot export (adapter must support get_comments).", + ), + summarize: bool = typer.Option( + False, + "--summarize", + help="Output a prompt (instruction + filter context + standup data) for slash command or Copilot to generate a standup summary (prints to stdout).", + ), + summarize_to: str | None = typer.Option( + None, + "--summarize-to", + help="Write the summarize prompt to this file (alternative to --summarize stdout).", + ), + suggest_next: bool = typer.Option( + False, + "--suggest-next", + help="In interactive mode, show suggested next item by value score (business value / (story points * priority)).", + ), + post: bool = typer.Option( + False, + "--post", + help="Post standup comment to the first item's issue. Requires at least one of --yesterday, --today, --blockers with a value (adapter must support comments).", + ), + yesterday: str | None = typer.Option( + None, + "--yesterday", + help='Standup: what was done yesterday (used when posting with --post; pass a value e.g. --yesterday "Worked on X").', + ), + today: str | None = typer.Option( + None, + "--today", + help='Standup: what will be done today (used when posting with --post; pass a value e.g. --today "Will do Y").', + ), + blockers: str | None = typer.Option( + None, + "--blockers", + help='Standup: blockers (used when posting with --post; pass a value e.g. --blockers "None").', + ), + repo_owner: str | None = typer.Option(None, "--repo-owner", help="GitHub repository owner"), + repo_name: str | None = typer.Option(None, "--repo-name", help="GitHub repository name"), + github_token: str | None = typer.Option(None, "--github-token", help="GitHub API token"), + ado_org: str | None = typer.Option(None, "--ado-org", help="Azure DevOps organization"), + ado_project: str | None = typer.Option(None, "--ado-project", help="Azure DevOps project"), + ado_team: str | None = typer.Option( + None, "--ado-team", help="ADO team for current iteration (when --sprint current)" + ), + ado_token: str | None = typer.Option(None, "--ado-token", help="Azure DevOps PAT"), +) -> None: + """ + Show daily standup view: list my/filtered backlog items with status and last activity. + + Optional standup summary lines (yesterday/today/blockers) are shown when present in item body. + Use --post with --yesterday, --today, --blockers to post a standup comment to the first item's linked issue + (only when the adapter supports comments, e.g. GitHub). + Default scope: state=open, limit=20 (overridable via SPECFACT_STANDUP_* env or .specfact/standup.yaml). + """ + standup_config = _load_standup_config() + effective_state, effective_limit, effective_assignee = _resolve_standup_options( + state, limit, assignee, standup_config + ) + items = _fetch_backlog_items( + adapter, + state=effective_state, + assignee=effective_assignee, + labels=labels, + limit=effective_limit, + iteration=iteration, + sprint=sprint, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_team=ado_team, + ado_token=ado_token, + ) + filtered = _apply_filters( + items, + labels=labels, + state=effective_state, + assignee=effective_assignee, + iteration=iteration, + sprint=sprint, + ) + if len(filtered) > effective_limit: + filtered = filtered[:effective_limit] + + if not filtered: + console.print("[yellow]No backlog items found.[/yellow]") + return + + comments_by_item_id: dict[str, list[str]] = {} + if include_comments and (copilot_export is not None or summarize or summarize_to is not None): + comments_by_item_id = _collect_comment_annotations( + adapter, + filtered, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + + if copilot_export is not None: + include_score = suggest_next or bool(standup_config.get("suggest_next")) + export_path = Path(copilot_export) + content = _build_copilot_export_content( + filtered, + include_value_score=include_score, + include_comments=include_comments, + comments_by_item_id=comments_by_item_id or None, + ) + export_path.write_text(content, encoding="utf-8") + console.print(f"[dim]Exported {len(filtered)} item(s) to {export_path}[/dim]") + + if summarize or summarize_to is not None: + include_score = suggest_next or bool(standup_config.get("suggest_next")) + filter_ctx: dict[str, Any] = { + "adapter": adapter, + "state": effective_state or "—", + "sprint": sprint or iteration or "—", + "assignee": effective_assignee or "—", + "limit": effective_limit, + } + content = _build_summarize_prompt_content( + filtered, + filter_context=filter_ctx, + include_value_score=include_score, + comments_by_item_id=comments_by_item_id or None, + include_comments=include_comments, + ) + if summarize_to: + Path(summarize_to).write_text(content, encoding="utf-8") + console.print(f"[dim]Summarize prompt written to {summarize_to} ({len(filtered)} item(s))[/dim]") + else: + console.print(content) + return + + if interactive: + _run_interactive_daily( + filtered, + standup_config=standup_config, + suggest_next=suggest_next, + adapter=adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + return + + first_item = filtered[0] + include_priority = bool(standup_config.get("show_priority") or standup_config.get("show_value")) + rows_unassigned: list[dict[str, Any]] = [] + if unassigned_only: + _, filtered = _split_assigned_unassigned(filtered) + if not filtered: + console.print("[yellow]No unassigned items in scope.[/yellow]") + return + rows = _build_standup_rows(filtered, include_priority=include_priority) + if blockers_first: + rows = _sort_standup_rows_blockers_first(rows) + else: + assigned, unassigned = _split_assigned_unassigned(filtered) + rows = _build_standup_rows(assigned, include_priority=include_priority) + if blockers_first: + rows = _sort_standup_rows_blockers_first(rows) + if show_unassigned and unassigned: + rows_unassigned = _build_standup_rows(unassigned, include_priority=include_priority) + + if post: + y = (yesterday or "").strip() + t = (today or "").strip() + b = (blockers or "").strip() + if not y and not t and not b: + console.print("[yellow]Use --yesterday, --today, and/or --blockers with values when using --post.[/yellow]") + console.print('[dim]Example: --yesterday "Worked on X" --today "Will do Y" --blockers "None" --post[/dim]') + return + body = _format_standup_comment(y, t, b) + item = first_item + registry = AdapterRegistry() + adapter_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + adapter_instance = registry.get_adapter(adapter, **adapter_kwargs) + if not isinstance(adapter_instance, BacklogAdapter): + console.print("[red]Adapter does not implement BacklogAdapter.[/red]") + raise typer.Exit(1) + if not _post_standup_comment_supported(adapter_instance, item): + console.print("[yellow]Posting comments is not supported for this adapter.[/yellow]") + return + ok = _post_standup_to_item(adapter_instance, item, body) + if ok: + console.print(f"[green]✓ Standup comment posted to {item.url}[/green]") + else: + console.print("[red]Failed to post standup comment.[/red]") + raise typer.Exit(1) + return + + sprint_end = standup_config.get("sprint_end_date") or os.environ.get("SPECFACT_STANDUP_SPRINT_END") + if sprint_end and (sprint or iteration): + try: + from datetime import datetime as dt + + end_date = dt.strptime(str(sprint_end)[:10], "%Y-%m-%d").date() + console.print(f"[dim]{_format_sprint_end_header(end_date)}[/dim]") + except (ValueError, TypeError): + console.print("[dim]Sprint end date could not be parsed; header skipped.[/dim]") + + def _add_standup_rows_to_table(tbl: Table, row_list: list[dict[str, Any]], include_pri: bool) -> None: + for r in row_list: + cells: list[Any] = [ + str(r["id"]), + str(r["title"])[:50], + str(r["status"]), + r["last_updated"].strftime("%Y-%m-%d %H:%M") + if hasattr(r["last_updated"], "strftime") + else str(r["last_updated"]), + (r.get("yesterday") or "")[:30], + (r.get("today") or "")[:30], + (r.get("blockers") or "")[:20], + ] + if include_pri and "priority" in r: + cells.append(str(r["priority"])) + tbl.add_row(*cells) + + table = Table(title="Daily standup", show_header=True, header_style="bold cyan") + table.add_column("ID", style="dim") + table.add_column("Title") + table.add_column("Status") + table.add_column("Last updated") + table.add_column("Yesterday", style="dim", max_width=30) + table.add_column("Today", style="dim", max_width=30) + table.add_column("Blockers", style="dim", max_width=20) + if include_priority: + table.add_column("Priority", style="dim") + _add_standup_rows_to_table(table, rows, include_priority) + console.print(table) + if not unassigned_only and show_unassigned and rows_unassigned: + table_pending = Table( + title="Pending / open for commitment", + show_header=True, + header_style="bold cyan", + ) + table_pending.add_column("ID", style="dim") + table_pending.add_column("Title") + table_pending.add_column("Status") + table_pending.add_column("Last updated") + table_pending.add_column("Yesterday", style="dim", max_width=30) + table_pending.add_column("Today", style="dim", max_width=30) + table_pending.add_column("Blockers", style="dim", max_width=20) + if include_priority: + table_pending.add_column("Priority", style="dim") + _add_standup_rows_to_table(table_pending, rows_unassigned, include_priority) + console.print(table_pending) + + +@beartype +@app.command() +@require( + lambda adapter: isinstance(adapter, str) and len(adapter) > 0, + "Adapter must be non-empty string", +) +def refine( + adapter: str = typer.Argument(..., help="Backlog adapter name (github, ado, etc.)"), + # Common filters + labels: list[str] | None = typer.Option( + None, "--labels", "--tags", help="Filter by labels/tags (can specify multiple)" + ), + state: str | None = typer.Option( + None, "--state", help="Filter by state (case-insensitive, e.g., 'open', 'closed', 'Active', 'New')" + ), + assignee: str | None = typer.Option( + None, + "--assignee", + help="Filter by assignee (case-insensitive). GitHub: login or @username. ADO: displayName, uniqueName, or mail", + ), + # Iteration/sprint filters + iteration: str | None = typer.Option( + None, + "--iteration", + help="Filter by iteration path (ADO format: 'Project\\Sprint 1' or 'current' for current iteration). Must be exact full path from ADO.", + ), + sprint: str | None = typer.Option( + None, + "--sprint", + help="Filter by sprint (case-insensitive). ADO: use full iteration path (e.g., 'Project\\Sprint 1') to avoid ambiguity. If omitted, defaults to current active iteration.", + ), + release: str | None = typer.Option(None, "--release", help="Filter by release identifier"), + # Template filters + persona: str | None = typer.Option( + None, "--persona", help="Filter templates by persona (product-owner, architect, developer)" + ), + framework: str | None = typer.Option( + None, "--framework", help="Filter templates by framework (agile, scrum, safe, kanban)" + ), + # Existing options + search: str | None = typer.Option( + None, "--search", "-s", help="Search query to filter backlog items (provider-specific syntax)" + ), + limit: int | None = typer.Option( + None, + "--limit", + help="Maximum number of items to process in this refinement session. Use to cap batch size and avoid processing too many items at once.", + ), + ignore_refined: bool = typer.Option( + True, + "--ignore-refined/--no-ignore-refined", + help="When set (default), exclude already-refined items from the batch so --limit applies to items that need refinement. Use --no-ignore-refined to process the first N items in order (already-refined skipped in loop).", + ), + issue_id: str | None = typer.Option( + None, + "--id", + help="Refine only this backlog item (issue or work item ID). Other items are ignored.", + ), + template_id: str | None = typer.Option(None, "--template", "-t", help="Target template ID (default: auto-detect)"), + auto_accept_high_confidence: bool = typer.Option( + False, "--auto-accept-high-confidence", help="Auto-accept refinements with confidence >= 0.85" + ), + bundle: str | None = typer.Option(None, "--bundle", "-b", help="OpenSpec bundle path to import refined items"), + auto_bundle: bool = typer.Option(False, "--auto-bundle", help="Auto-import refined items to OpenSpec bundle"), + openspec_comment: bool = typer.Option( + False, "--openspec-comment", help="Add OpenSpec change proposal reference as comment (preserves original body)" + ), + # Preview/write flags (production safety) + preview: bool = typer.Option( + True, + "--preview/--no-preview", + help="Preview mode: show what will be written without updating backlog (default: True)", + ), + write: bool = typer.Option( + False, "--write", help="Write mode: explicitly opt-in to update remote backlog (requires --write flag)" + ), + # Export/import for copilot processing + export_to_tmp: bool = typer.Option( + False, + "--export-to-tmp", + help="Export backlog items to temporary file for copilot processing (default: <system-temp>/specfact-backlog-refine-<timestamp>.md)", + ), + import_from_tmp: bool = typer.Option( + False, + "--import-from-tmp", + help="Import refined content from temporary file after copilot processing (default: <system-temp>/specfact-backlog-refine-<timestamp>-refined.md)", + ), + tmp_file: Path | None = typer.Option( + None, + "--tmp-file", + help="Custom temporary file path (overrides default)", + ), + # DoR validation + check_dor: bool = typer.Option( + False, "--check-dor", help="Check Definition of Ready (DoR) rules before refinement" + ), + # Adapter configuration (GitHub) + repo_owner: str | None = typer.Option( + None, "--repo-owner", help="GitHub repository owner (required for GitHub adapter)" + ), + repo_name: str | None = typer.Option( + None, "--repo-name", help="GitHub repository name (required for GitHub adapter)" + ), + github_token: str | None = typer.Option( + None, "--github-token", help="GitHub API token (optional, uses GITHUB_TOKEN env var or gh CLI if not provided)" + ), + # Adapter configuration (ADO) + ado_org: str | None = typer.Option(None, "--ado-org", help="Azure DevOps organization (required for ADO adapter)"), + ado_project: str | None = typer.Option( + None, "--ado-project", help="Azure DevOps project (required for ADO adapter)" + ), + ado_team: str | None = typer.Option( + None, + "--ado-team", + help="Azure DevOps team name for iteration lookup (defaults to project name). Used when resolving current iteration when --sprint is omitted.", + ), + ado_token: str | None = typer.Option( + None, "--ado-token", help="Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided)" + ), + custom_field_mapping: str | None = typer.Option( + None, + "--custom-field-mapping", + help="Path to custom ADO field mapping YAML file (overrides default mappings)", + ), +) -> None: + """ + Refine backlog items using AI-assisted template matching. + + This command: + 1. Fetches backlog items from the specified adapter + 2. Detects template matches with confidence scores + 3. Identifies items needing refinement (low confidence or no match) + 4. Generates prompts for IDE AI copilot to refine items + 5. Validates refined content from IDE AI copilot + 6. Updates remote backlog with refined content + 7. Optionally imports refined items to OpenSpec bundle + + SpecFact CLI Architecture: + - This command generates prompts for IDE AI copilots (Cursor, Claude Code, etc.) + - IDE AI copilots execute those prompts using their native LLM + - IDE AI copilots feed refined content back to this command + - This command validates and processes the refined content + """ + try: + # Show initialization progress to provide feedback during setup + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + transient=False, + ) as init_progress: + # Initialize template registry and load templates + init_task = init_progress.add_task("[cyan]Initializing templates...[/cyan]", total=None) + registry = TemplateRegistry() + + # Determine template directories (built-in first so custom overrides take effect) + from specfact_cli.utils.ide_setup import find_package_resources_path + + current_dir = Path.cwd() + + # 1. Load built-in templates from resources/templates/backlog/ (preferred location) + # Try to find resources directory using package resource finder (for installed packages) + resources_path = find_package_resources_path("specfact_cli", "resources/templates/backlog") + built_in_loaded = False + if resources_path and resources_path.exists(): + registry.load_templates_from_directory(resources_path) + built_in_loaded = True + else: + # Fallback: Try relative to repo root (development mode) + # __file__ = src/specfact_cli/modules/backlog/src/commands.py → 6 parents to repo root + repo_root = Path(__file__).parent.parent.parent.parent.parent.parent + resources_templates_dir = repo_root / "resources" / "templates" / "backlog" + if resources_templates_dir.exists(): + registry.load_templates_from_directory(resources_templates_dir) + built_in_loaded = True + else: + # 2. Fallback to src/specfact_cli/templates/ for backward compatibility + # __file__ → 4 parents to reach src/specfact_cli/ + src_templates_dir = Path(__file__).parent.parent.parent.parent / "templates" + if src_templates_dir.exists(): + registry.load_templates_from_directory(src_templates_dir) + built_in_loaded = True + + if not built_in_loaded: + console.print( + "[yellow]⚠ No built-in backlog templates found; continuing with custom templates only.[/yellow]" + ) + + # 3. Load custom templates from project directory (highest priority) + project_templates_dir = current_dir / ".specfact" / "templates" / "backlog" + if project_templates_dir.exists(): + registry.load_templates_from_directory(project_templates_dir) + + init_progress.update(init_task, description="[green]✓[/green] Templates initialized") + + # Initialize template detector + detector_task = init_progress.add_task("[cyan]Initializing template detector...[/cyan]", total=None) + detector = TemplateDetector(registry) + init_progress.update(detector_task, description="[green]✓[/green] Template detector ready") + + # Initialize AI refiner (prompt generator and validator) + refiner_task = init_progress.add_task("[cyan]Initializing AI refiner...[/cyan]", total=None) + refiner = BacklogAIRefiner() + init_progress.update(refiner_task, description="[green]✓[/green] AI refiner ready") + + # Get adapter registry for writeback + adapter_task = init_progress.add_task("[cyan]Initializing adapter...[/cyan]", total=None) + adapter_registry = AdapterRegistry() + init_progress.update(adapter_task, description="[green]✓[/green] Adapter registry ready") + + # Load DoR configuration (if --check-dor flag set) + dor_config: DefinitionOfReady | None = None + if check_dor: + dor_task = init_progress.add_task("[cyan]Loading DoR configuration...[/cyan]", total=None) + repo_path = Path(".") + dor_config = DefinitionOfReady.load_from_repo(repo_path) + if dor_config: + init_progress.update(dor_task, description="[green]✓[/green] DoR configuration loaded") + else: + init_progress.update(dor_task, description="[yellow]⚠[/yellow] Using default DoR rules") + # Use default DoR rules + dor_config = DefinitionOfReady( + rules={ + "story_points": True, + "value_points": False, # Optional by default + "priority": True, + "business_value": True, + "acceptance_criteria": True, + "dependencies": False, # Optional by default + } + ) + + # Normalize adapter, framework, and persona to lowercase for template matching + # Template metadata in YAML uses lowercase (e.g., provider: github, framework: scrum) + # This ensures case-insensitive matching regardless of CLI input case + normalized_adapter = adapter.lower() if adapter else None + normalized_framework = framework.lower() if framework else None + normalized_persona = persona.lower() if persona else None + + # Validate adapter-specific required parameters (use same resolution as daily: CLI > env > config > git) + validate_task = init_progress.add_task("[cyan]Validating adapter configuration...[/cyan]", total=None) + writeback_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_team=ado_team, + ado_token=ado_token, + ) + if normalized_adapter == "github" and ( + not writeback_kwargs.get("repo_owner") or not writeback_kwargs.get("repo_name") + ): + init_progress.stop() + console.print("[red]repo_owner and repo_name required for GitHub.[/red]") + console.print( + "Set via: [cyan]--repo-owner[/cyan]/[cyan]--repo-name[/cyan], " + "env [cyan]SPECFACT_GITHUB_REPO_OWNER[/cyan]/[cyan]SPECFACT_GITHUB_REPO_NAME[/cyan], " + "or [cyan].specfact/backlog.yaml[/cyan] (see docs/guides/devops-adapter-integration.md)." + ) + raise typer.Exit(1) + if normalized_adapter == "ado" and (not writeback_kwargs.get("org") or not writeback_kwargs.get("project")): + init_progress.stop() + console.print( + "[red]ado_org and ado_project required for Azure DevOps.[/red] " + "Set via --ado-org/--ado-project, env SPECFACT_ADO_ORG/SPECFACT_ADO_PROJECT, or .specfact/backlog.yaml." + ) + raise typer.Exit(1) + + # Validate and set custom field mapping (if provided) + if custom_field_mapping: + mapping_path = Path(custom_field_mapping) + if not mapping_path.exists(): + init_progress.stop() + console.print(f"[red]Error:[/red] Custom field mapping file not found: {custom_field_mapping}") + sys.exit(1) + if not mapping_path.is_file(): + init_progress.stop() + console.print(f"[red]Error:[/red] Custom field mapping path is not a file: {custom_field_mapping}") + sys.exit(1) + # Validate file format by attempting to load it + try: + from specfact_cli.backlog.mappers.template_config import FieldMappingConfig + + FieldMappingConfig.from_file(mapping_path) + init_progress.update(validate_task, description="[green]✓[/green] Field mapping validated") + except (FileNotFoundError, ValueError, yaml.YAMLError) as e: + init_progress.stop() + console.print(f"[red]Error:[/red] Invalid custom field mapping file: {e}") + sys.exit(1) + # Set environment variable for converter to use + os.environ["SPECFACT_ADO_CUSTOM_MAPPING"] = str(mapping_path.absolute()) + else: + init_progress.update(validate_task, description="[green]✓[/green] Configuration validated") + + # Fetch backlog items with filters + # When ignore_refined and limit are set, fetch more candidates so we have enough after filtering + fetch_limit: int | None = limit + if ignore_refined and limit is not None and limit > 0: + fetch_limit = limit * 5 + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + transient=False, + ) as progress: + fetch_task = progress.add_task(f"[cyan]Fetching backlog items from {adapter}...[/cyan]", total=None) + items = _fetch_backlog_items( + adapter, + search_query=search, + labels=labels, + state=state, + assignee=assignee, + iteration=iteration, + sprint=sprint, + release=release, + limit=fetch_limit, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_team=ado_team, + ado_token=ado_token, + ) + progress.update(fetch_task, description="[green]✓[/green] Fetched backlog items") + + if not items: + # Provide helpful message when no items found, especially if filters were used + filter_info = [] + if state: + filter_info.append(f"state={state}") + if assignee: + filter_info.append(f"assignee={assignee}") + if iteration: + filter_info.append(f"iteration={iteration}") + if sprint: + filter_info.append(f"sprint={sprint}") + if release: + filter_info.append(f"release={release}") + + if filter_info: + console.print( + f"[yellow]No backlog items found with the specified filters:[/yellow] {', '.join(filter_info)}\n" + f"[cyan]Tips:[/cyan]\n" + f" • Verify the iteration path exists in Azure DevOps (Project Settings → Boards → Iterations)\n" + f" • Try using [bold]--iteration current[/bold] to use the current active iteration\n" + f" • Try using [bold]--sprint[/bold] with just the sprint name for automatic matching\n" + f" • Check that items exist in the specified iteration/sprint" + ) + else: + console.print("[yellow]No backlog items found.[/yellow]") + return + + # Filter by issue ID when --id is set + if issue_id is not None: + items = [i for i in items if str(i.id) == str(issue_id)] + if not items: + console.print( + f"[bold red]✗[/bold red] No backlog item with id {issue_id!r} found. " + "Check filters and adapter configuration." + ) + raise typer.Exit(1) + + # When ignore_refined (default), keep only items that need refinement; then apply limit + if ignore_refined: + items = [ + i + for i in items + if _item_needs_refinement( + i, detector, registry, template_id, normalized_adapter, normalized_framework, normalized_persona + ) + ] + if limit is not None and len(items) > limit: + items = items[:limit] + if ignore_refined and (limit is not None or issue_id is not None): + console.print( + f"[dim]Filtered to {len(items)} item(s) needing refinement" + + (f" (limit {limit})" if limit is not None else "") + + "[/dim]" + ) + + # Validate export/import flags + if export_to_tmp and import_from_tmp: + console.print("[bold red]✗[/bold red] --export-to-tmp and --import-from-tmp are mutually exclusive") + raise typer.Exit(1) + + # Handle export mode + if export_to_tmp: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + export_file = tmp_file or (Path(tempfile.gettempdir()) / f"specfact-backlog-refine-{timestamp}.md") + + console.print(f"[bold cyan]Exporting {len(items)} backlog item(s) to: {export_file}[/bold cyan]") + + # Export items to markdown file + export_content = "# SpecFact Backlog Refinement Export\n\n" + export_content += f"**Export Date**: {datetime.now().isoformat()}\n" + export_content += f"**Adapter**: {adapter}\n" + export_content += f"**Items**: {len(items)}\n\n" + export_content += "---\n\n" + + for idx, item in enumerate(items, 1): + export_content += f"## Item {idx}: {item.title}\n\n" + export_content += f"**ID**: {item.id}\n" + export_content += f"**URL**: {item.url}\n" + if item.canonical_url: + export_content += f"**Canonical URL**: {item.canonical_url}\n" + export_content += f"**State**: {item.state}\n" + export_content += f"**Provider**: {item.provider}\n" + + # Include metrics + if item.story_points is not None or item.business_value is not None or item.priority is not None: + export_content += "\n**Metrics**:\n" + if item.story_points is not None: + export_content += f"- Story Points: {item.story_points}\n" + if item.business_value is not None: + export_content += f"- Business Value: {item.business_value}\n" + if item.priority is not None: + export_content += f"- Priority: {item.priority} (1=highest)\n" + if item.value_points is not None: + export_content += f"- Value Points (SAFe): {item.value_points}\n" + if item.work_item_type: + export_content += f"- Work Item Type: {item.work_item_type}\n" + + # Include acceptance criteria + if item.acceptance_criteria: + export_content += f"\n**Acceptance Criteria**:\n{item.acceptance_criteria}\n" + + # Include body + export_content += f"\n**Body**:\n```markdown\n{item.body_markdown}\n```\n" + + export_content += "\n---\n\n" + + export_file.write_text(export_content, encoding="utf-8") + console.print(f"[green]✓ Exported to: {export_file}[/green]") + console.print("[dim]Process items with copilot, then use --import-from-tmp to import refined content[/dim]") + return + + # Handle import mode + if import_from_tmp: + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + import_file = tmp_file or (Path(tempfile.gettempdir()) / f"specfact-backlog-refine-{timestamp}-refined.md") + + if not import_file.exists(): + console.print(f"[bold red]✗[/bold red] Import file not found: {import_file}") + console.print(f"[dim]Expected file: {import_file}[/dim]") + console.print("[dim]Or specify custom path with --tmp-file[/dim]") + raise typer.Exit(1) + + console.print(f"[bold cyan]Importing refined content from: {import_file}[/bold cyan]") + try: + raw = import_file.read_text(encoding="utf-8") + if is_debug_mode(): + debug_log_operation("file_read", str(import_file), "success") + except OSError as e: + if is_debug_mode(): + debug_log_operation("file_read", str(import_file), "error", error=str(e)) + raise + parsed_by_id = _parse_refined_export_markdown(raw) + if not parsed_by_id: + console.print( + "[yellow]No valid item blocks found in import file (expected ## Item N: and **ID**:)[/yellow]" + ) + raise typer.Exit(1) + + updated_items: list[BacklogItem] = [] + for item in items: + if item.id not in parsed_by_id: + continue + data = parsed_by_id[item.id] + body = data.get("body_markdown", item.body_markdown or "") + item.body_markdown = body if body is not None else (item.body_markdown or "") + if "acceptance_criteria" in data: + item.acceptance_criteria = data["acceptance_criteria"] + if data.get("title"): + item.title = data["title"] + if "story_points" in data: + item.story_points = data["story_points"] + if "business_value" in data: + item.business_value = data["business_value"] + if "priority" in data: + item.priority = data["priority"] + updated_items.append(item) + + if not write: + console.print(f"[green]Would update {len(updated_items)} item(s)[/green]") + console.print("[dim]Run with --write to apply changes to the backlog[/dim]") + return + + writeback_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_team=ado_team, + ado_token=ado_token, + ) + adapter_instance = adapter_registry.get_adapter(adapter, **writeback_kwargs) + if not isinstance(adapter_instance, BacklogAdapter): + console.print("[bold red]✗[/bold red] Adapter does not support backlog updates") + raise typer.Exit(1) + + for item in updated_items: + update_fields_list = ["title", "body_markdown"] + if item.acceptance_criteria: + update_fields_list.append("acceptance_criteria") + if item.story_points is not None: + update_fields_list.append("story_points") + if item.business_value is not None: + update_fields_list.append("business_value") + if item.priority is not None: + update_fields_list.append("priority") + adapter_instance.update_backlog_item(item, update_fields=update_fields_list) + console.print(f"[green]✓ Updated backlog item: {item.url}[/green]") + console.print(f"[green]✓ Updated {len(updated_items)} backlog item(s)[/green]") + return + + # Apply limit if specified (when not ignore_refined; when ignore_refined we already filtered and sliced) + if not ignore_refined and limit is not None and len(items) > limit: + items = items[:limit] + console.print(f"[yellow]Limited to {limit} items (found {len(items)} total)[/yellow]") + else: + console.print(f"[green]Found {len(items)} backlog items[/green]") + + # Process each item + refined_count = 0 + skipped_count = 0 + cancelled = False + + # Process items without progress bar during refinement to avoid conflicts with interactive prompts + for idx, item in enumerate(items, 1): + # Check for cancellation + if cancelled: + break + + # Show simple status text instead of progress bar + console.print(f"\n[bold cyan]Refining item {idx} of {len(items)}: {item.title}[/bold cyan]") + + # Check DoR (if enabled) + if check_dor and dor_config: + item_dict = item.model_dump() + dor_errors = dor_config.validate_item(item_dict) + if dor_errors: + console.print("[yellow]⚠ Definition of Ready (DoR) issues:[/yellow]") + for error in dor_errors: + console.print(f" - {error}") + console.print("[yellow]Item may not be ready for sprint planning[/yellow]") + else: + console.print("[green]✓ Definition of Ready (DoR) satisfied[/green]") + + # Detect template with persona/framework/provider filtering + # Use normalized values for case-insensitive template matching + detection_result = detector.detect_template( + item, provider=normalized_adapter, framework=normalized_framework, persona=normalized_persona + ) + + if detection_result.template_id: + template_id_str = detection_result.template_id + confidence_str = f"{detection_result.confidence:.2f}" + console.print(f"[green]✓ Detected template: {template_id_str} (confidence: {confidence_str})[/green]") + item.detected_template = detection_result.template_id + item.template_confidence = detection_result.confidence + item.template_missing_fields = detection_result.missing_fields + + # Check if item already has checkboxes in required sections (already refined) + # Items with checkboxes (- [ ] or - [x]) in required sections are considered already refined + target_template_for_check = ( + registry.get_template(detection_result.template_id) if detection_result.template_id else None + ) + if target_template_for_check: + import re + + has_checkboxes = bool( + re.search(r"^[\s]*- \[[ x]\]", item.body_markdown, re.MULTILINE | re.IGNORECASE) + ) + # Check if all required sections are present + all_sections_present = True + for section in target_template_for_check.required_sections: + # Look for section heading (## Section Name or ### Section Name) + section_pattern = rf"^#+\s+{re.escape(section)}\s*$" + if not re.search(section_pattern, item.body_markdown, re.MULTILINE | re.IGNORECASE): + all_sections_present = False + break + # If item has checkboxes and all required sections, it's already refined - skip it + if has_checkboxes and all_sections_present and not detection_result.missing_fields: + console.print( + "[green]Item already refined with checkboxes and all required sections - skipping[/green]" + ) + skipped_count += 1 + continue + + # High confidence AND no missing required fields - no refinement needed + # Note: Even with high confidence, if required sections are missing, refinement is needed + if template_id is None and detection_result.confidence >= 0.8 and not detection_result.missing_fields: + console.print( + "[green]High confidence match with all required sections - no refinement needed[/green]" + ) + skipped_count += 1 + continue + if detection_result.missing_fields: + missing_str = ", ".join(detection_result.missing_fields) + console.print(f"[yellow]⚠ Missing required sections: {missing_str} - refinement needed[/yellow]") + + # Low confidence or no match - needs refinement + # Get target template using priority-based resolution + target_template = None + if template_id: + target_template = registry.get_template(template_id) + if not target_template: + console.print(f"[yellow]Template {template_id} not found, using auto-detection[/yellow]") + elif detection_result.template_id: + target_template = registry.get_template(detection_result.template_id) + else: + # Use priority-based template resolution + # Use normalized values for case-insensitive template matching + target_template = registry.resolve_template( + provider=normalized_adapter, framework=normalized_framework, persona=normalized_persona + ) + if target_template: + resolved_id = target_template.template_id + console.print(f"[yellow]No template detected, using resolved template: {resolved_id}[/yellow]") + else: + # Fallback: Use first available template as default + templates = registry.list_templates(scope="corporate") + if templates: + target_template = templates[0] + console.print( + f"[yellow]No template resolved, using default: {target_template.template_id}[/yellow]" + ) + + if not target_template: + console.print("[yellow]No template available for refinement[/yellow]") + skipped_count += 1 + continue + + # In preview mode without --write, show full item details but skip interactive refinement + if preview and not write: + console.print("\n[bold]Preview Mode: Full Item Details[/bold]") + console.print(f"[bold]Title:[/bold] {item.title}") + console.print(f"[bold]URL:[/bold] {item.url}") + if item.canonical_url: + console.print(f"[bold]Canonical URL:[/bold] {item.canonical_url}") + console.print(f"[bold]State:[/bold] {item.state}") + console.print(f"[bold]Provider:[/bold] {item.provider}") + console.print(f"[bold]Assignee:[/bold] {', '.join(item.assignees) if item.assignees else 'Unassigned'}") + + # Show metrics if available + if item.story_points is not None or item.business_value is not None or item.priority is not None: + console.print("\n[bold]Story Metrics:[/bold]") + if item.story_points is not None: + console.print(f" - Story Points: {item.story_points}") + if item.business_value is not None: + console.print(f" - Business Value: {item.business_value}") + if item.priority is not None: + console.print(f" - Priority: {item.priority} (1=highest)") + if item.value_points is not None: + console.print(f" - Value Points (SAFe): {item.value_points}") + if item.work_item_type: + console.print(f" - Work Item Type: {item.work_item_type}") + + # Always show acceptance criteria if it's a required section, even if empty + # This helps copilot understand what fields need to be added + is_acceptance_criteria_required = ( + target_template.required_sections and "Acceptance Criteria" in target_template.required_sections + ) + if is_acceptance_criteria_required or item.acceptance_criteria: + console.print("\n[bold]Acceptance Criteria:[/bold]") + if item.acceptance_criteria: + console.print(Panel(item.acceptance_criteria)) + else: + # Show empty state so copilot knows to add it + console.print(Panel("[dim](empty - required field)[/dim]", border_style="dim")) + + # Always show body (Description is typically required) + console.print("\n[bold]Body:[/bold]") + body_content = ( + item.body_markdown[:1000] + "..." if len(item.body_markdown) > 1000 else item.body_markdown + ) + if not body_content.strip(): + # Show empty state so copilot knows to add it + console.print(Panel("[dim](empty - required field)[/dim]", border_style="dim")) + else: + console.print(Panel(body_content)) + + # Show template info + console.print( + f"\n[bold]Target Template:[/bold] {target_template.name} (ID: {target_template.template_id})" + ) + console.print(f"[bold]Template Description:[/bold] {target_template.description}") + + # Show what would be updated + console.print( + "\n[yellow]⚠ Preview mode: Item needs refinement but interactive prompts are skipped[/yellow]" + ) + console.print( + "[yellow] Use [bold]--write[/bold] flag to enable interactive refinement and writeback[/yellow]" + ) + console.print( + "[yellow] Or use [bold]--export-to-tmp[/bold] to export items for copilot processing[/yellow]" + ) + skipped_count += 1 + continue + + # Generate prompt for IDE AI copilot + console.print(f"[bold]Generating refinement prompt for template: {target_template.name}...[/bold]") + prompt = refiner.generate_refinement_prompt(item, target_template) + + # Display prompt for IDE AI copilot + console.print("\n[bold]Refinement Prompt for IDE AI Copilot:[/bold]") + console.print(Panel(prompt, title="Copy this prompt to your IDE AI copilot")) + + # Prompt user to get refined content from IDE AI copilot + console.print("\n[yellow]Instructions:[/yellow]") + console.print("1. Copy the prompt above to your IDE AI copilot (Cursor, Claude Code, etc.)") + console.print("2. Execute the prompt in your IDE AI copilot") + console.print("3. Copy the refined content from the AI copilot response") + console.print("4. Paste the refined content below, then type 'END' on a new line when done\n") + + # Read multiline input from stdin + # Support both interactive (paste + Ctrl+D) and non-interactive (EOF) modes + # Note: When pasting multiline content, each line is read sequentially + refined_content_lines: list[str] = [] + console.print("[bold]Paste refined content below (type 'END' on a new line when done):[/bold]") + console.print("[dim]Commands: :skip (skip this item), :quit or :abort (cancel session)[/dim]") + + try: + while True: + try: + line = input() + line_stripped = line.strip() + line_upper = line_stripped.upper() + + # Check for sentinel values (case-insensitive) + if line_upper == "END": + break + if line_upper == ":SKIP": + console.print("[yellow]Skipping current item[/yellow]") + skipped_count += 1 + refined_content_lines = [] # Clear content + break + if line_upper in (":QUIT", ":ABORT"): + console.print("[yellow]Cancelling refinement session[/yellow]") + cancelled = True + refined_content_lines = [] # Clear content + break + + refined_content_lines.append(line) + except EOFError: + # Ctrl+D pressed or EOF reached (common when pasting multiline content) + break + except KeyboardInterrupt: + console.print("\n[yellow]Input cancelled - skipping[/yellow]") + skipped_count += 1 + continue + + # Check if session was cancelled + if cancelled: + break + + refined_content = "\n".join(refined_content_lines).strip() + + if not refined_content: + console.print("[yellow]No refined content provided - skipping[/yellow]") + skipped_count += 1 + continue + + # Validate and score refined content (provider-aware) + try: + refinement_result = refiner.validate_and_score_refinement( + refined_content, item.body_markdown, target_template, item + ) + + # Print newline to separate validation results + console.print() + + # Display validation result + console.print("[bold]Refinement Validation Result:[/bold]") + console.print(f"[green]Confidence: {refinement_result.confidence:.2f}[/green]") + if refinement_result.has_todo_markers: + console.print("[yellow]⚠ Contains TODO markers[/yellow]") + if refinement_result.has_notes_section: + console.print("[yellow]⚠ Contains NOTES section[/yellow]") + + # Display story metrics if available + if item.story_points is not None or item.business_value is not None or item.priority is not None: + console.print("\n[bold]Story Metrics:[/bold]") + if item.story_points is not None: + console.print(f" - Story Points: {item.story_points}") + if item.business_value is not None: + console.print(f" - Business Value: {item.business_value}") + if item.priority is not None: + console.print(f" - Priority: {item.priority} (1=highest)") + if item.value_points is not None: + console.print(f" - Value Points (SAFe): {item.value_points}") + if item.work_item_type: + console.print(f" - Work Item Type: {item.work_item_type}") + + # Display story splitting suggestion if needed + if refinement_result.needs_splitting and refinement_result.splitting_suggestion: + console.print("\n[yellow]⚠ Story Splitting Recommendation:[/yellow]") + console.print(Panel(refinement_result.splitting_suggestion, title="Splitting Suggestion")) + + # Show preview with field preservation information + console.print("\n[bold]Preview: What will be updated[/bold]") + console.print("[dim]Fields that will be UPDATED:[/dim]") + console.print(" - title: Will be updated if changed") + console.print(" - body_markdown: Will be updated with refined content") + console.print("[dim]Fields that will be PRESERVED (not modified):[/dim]") + console.print(" - assignees: Preserved") + console.print(" - tags: Preserved") + console.print(" - state: Preserved") + console.print(" - priority: Preserved (if present in provider_fields)") + console.print(" - due_date: Preserved (if present in provider_fields)") + console.print(" - story_points: Preserved (if present in provider_fields)") + console.print(" - business_value: Preserved (if present in provider_fields)") + console.print(" - priority: Preserved (if present in provider_fields)") + console.print(" - acceptance_criteria: Preserved (if present in provider_fields)") + console.print(" - All other metadata: Preserved in provider_fields") + + console.print("\n[bold]Original:[/bold]") + console.print( + Panel(item.body_markdown[:500] + "..." if len(item.body_markdown) > 500 else item.body_markdown) + ) + console.print("\n[bold]Refined:[/bold]") + console.print( + Panel( + refinement_result.refined_body[:500] + "..." + if len(refinement_result.refined_body) > 500 + else refinement_result.refined_body + ) + ) + + # Store refined body for preview/write + item.refined_body = refinement_result.refined_body + + # Preview mode (default) - don't write, just show preview + if preview and not write: + console.print("\n[yellow]Preview mode: Refinement will NOT be written to backlog[/yellow]") + console.print("[yellow]Use --write flag to explicitly opt-in to writeback[/yellow]") + refined_count += 1 # Count as refined for preview purposes + continue + + # Write mode - requires explicit --write flag + if write: + # Auto-accept high confidence + if auto_accept_high_confidence and refinement_result.confidence >= 0.85: + console.print("[green]Auto-accepting high-confidence refinement and writing to backlog[/green]") + item.apply_refinement() + + # Writeback to remote backlog using adapter + # Build adapter kwargs for writeback + writeback_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + + adapter_instance = adapter_registry.get_adapter(adapter, **writeback_kwargs) + if isinstance(adapter_instance, BacklogAdapter): + # Update all fields including new agile framework fields + update_fields_list = ["title", "body_markdown"] + if item.acceptance_criteria: + update_fields_list.append("acceptance_criteria") + if item.story_points is not None: + update_fields_list.append("story_points") + if item.business_value is not None: + update_fields_list.append("business_value") + if item.priority is not None: + update_fields_list.append("priority") + updated_item = adapter_instance.update_backlog_item(item, update_fields=update_fields_list) + console.print(f"[green]✓ Updated backlog item: {updated_item.url}[/green]") + + # Add OpenSpec comment if requested + if openspec_comment: + # Extract OpenSpec change proposal ID from original body if present + original_body = item.body_markdown or "" + openspec_change_id = _extract_openspec_change_id(original_body) + + # Generate OpenSpec change proposal reference + change_id = openspec_change_id or f"backlog-refine-{item.id}" + comment_text = ( + f"## OpenSpec Change Proposal Reference\n\n" + f"This backlog item was refined using SpecFact CLI template-driven refinement.\n\n" + f"- **Change ID**: `{change_id}`\n" + f"- **Template**: `{item.detected_template or 'auto-detected'}`\n" + f"- **Confidence**: `{item.template_confidence or 0.0:.2f}`\n" + f"- **Refined**: {item.refinement_timestamp or 'N/A'}\n\n" + f"*Note: Original body preserved. " + f"This comment provides OpenSpec reference for cross-sync.*" + ) + if adapter_instance.add_comment(updated_item, comment_text): + console.print("[green]✓ Added OpenSpec reference comment[/green]") + else: + console.print( + "[yellow]⚠ Failed to add comment (adapter may not support comments)[/yellow]" + ) + else: + console.print("[yellow]⚠ Adapter does not support backlog updates[/yellow]") + refined_count += 1 + else: + # Interactive prompt with clear separation + console.print() + accept = Confirm.ask("Accept refinement and write to backlog?", default=False) + if accept: + item.apply_refinement() + + # Writeback to remote backlog using adapter + # Build adapter kwargs for writeback + writeback_kwargs = _build_adapter_kwargs( + adapter, + repo_owner=repo_owner, + repo_name=repo_name, + github_token=github_token, + ado_org=ado_org, + ado_project=ado_project, + ado_token=ado_token, + ) + + adapter_instance = adapter_registry.get_adapter(adapter, **writeback_kwargs) + if isinstance(adapter_instance, BacklogAdapter): + # Update all fields including new agile framework fields + update_fields_list = ["title", "body_markdown"] + if item.acceptance_criteria: + update_fields_list.append("acceptance_criteria") + if item.story_points is not None: + update_fields_list.append("story_points") + if item.business_value is not None: + update_fields_list.append("business_value") + if item.priority is not None: + update_fields_list.append("priority") + updated_item = adapter_instance.update_backlog_item( + item, update_fields=update_fields_list + ) + console.print(f"[green]✓ Updated backlog item: {updated_item.url}[/green]") + + # Add OpenSpec comment if requested + if openspec_comment: + # Extract OpenSpec change proposal ID from original body if present + original_body = item.body_markdown or "" + openspec_change_id = _extract_openspec_change_id(original_body) + + # Generate OpenSpec change proposal reference + change_id = openspec_change_id or f"backlog-refine-{item.id}" + comment_text = ( + f"## OpenSpec Change Proposal Reference\n\n" + f"This backlog item was refined using SpecFact CLI template-driven refinement.\n\n" + f"- **Change ID**: `{change_id}`\n" + f"- **Template**: `{item.detected_template or 'auto-detected'}`\n" + f"- **Confidence**: `{item.template_confidence or 0.0:.2f}`\n" + f"- **Refined**: {item.refinement_timestamp or 'N/A'}\n\n" + f"*Note: Original body preserved. " + f"This comment provides OpenSpec reference for cross-sync.*" + ) + if adapter_instance.add_comment(updated_item, comment_text): + console.print("[green]✓ Added OpenSpec reference comment[/green]") + else: + console.print( + "[yellow]⚠ Failed to add comment " + "(adapter may not support comments)[/yellow]" + ) + else: + console.print("[yellow]⚠ Adapter does not support backlog updates[/yellow]") + refined_count += 1 + else: + console.print("[yellow]Refinement rejected - not writing to backlog[/yellow]") + skipped_count += 1 + else: + # Preview mode but user didn't explicitly set --write + console.print("[yellow]Preview mode: Use --write to update backlog[/yellow]") + refined_count += 1 + + except ValueError as e: + console.print(f"[red]Validation failed: {e}[/red]") + console.print("[yellow]Please fix the refined content and try again[/yellow]") + skipped_count += 1 + continue + + # OpenSpec bundle import (if requested) + if (bundle or auto_bundle) and refined_count > 0: + console.print("\n[bold]OpenSpec Bundle Import:[/bold]") + try: + # Determine bundle path + bundle_path: Path | None = None + if bundle: + bundle_path = Path(bundle) + elif auto_bundle: + # Auto-detect bundle from current directory + current_dir = Path.cwd() + bundle_path = current_dir / ".specfact" / "bundle.yaml" + if not bundle_path.exists(): + bundle_path = current_dir / "bundle.yaml" + + if bundle_path and bundle_path.exists(): + console.print( + f"[green]Importing {refined_count} refined items to OpenSpec bundle: {bundle_path}[/green]" + ) + # TODO: Implement actual import logic using import command functionality + console.print( + "[yellow]⚠ OpenSpec bundle import integration pending (use import command separately)[/yellow]" + ) + else: + console.print("[yellow]⚠ Bundle path not found. Skipping import.[/yellow]") + except Exception as e: + console.print(f"[yellow]⚠ Failed to import to OpenSpec bundle: {e}[/yellow]") + + # Summary + console.print("\n[bold]Summary:[/bold]") + if cancelled: + console.print("[yellow]Session cancelled by user[/yellow]") + if limit: + console.print(f"[dim]Limit applied: {limit} items[/dim]") + console.print(f"[green]Refined: {refined_count}[/green]") + console.print(f"[yellow]Skipped: {skipped_count}[/yellow]") + + # Note: Writeback is handled per-item above when --write flag is set + + except Exception as e: + console.print(f"[red]Error: {e}[/red]") + raise typer.Exit(1) from e + + +@app.command("map-fields") +@require( + lambda ado_org, ado_project: isinstance(ado_org, str) + and len(ado_org) > 0 + and isinstance(ado_project, str) + and len(ado_project) > 0, + "ADO org and project must be non-empty strings", +) +@beartype +def map_fields( + ado_org: str = typer.Option(..., "--ado-org", help="Azure DevOps organization (required)"), + ado_project: str = typer.Option(..., "--ado-project", help="Azure DevOps project (required)"), + ado_token: str | None = typer.Option( + None, "--ado-token", help="Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided)" + ), + ado_base_url: str | None = typer.Option( + None, "--ado-base-url", help="Azure DevOps base URL (defaults to https://dev.azure.com)" + ), + reset: bool = typer.Option( + False, "--reset", help="Reset custom field mapping to defaults (deletes ado_custom.yaml)" + ), +) -> None: + """ + Interactive command to map ADO fields to canonical field names. + + Fetches available fields from Azure DevOps API and guides you through + mapping them to canonical field names (description, acceptance_criteria, etc.). + Saves the mapping to .specfact/templates/backlog/field_mappings/ado_custom.yaml. + + Examples: + specfact backlog map-fields --ado-org myorg --ado-project myproject + specfact backlog map-fields --ado-org myorg --ado-project myproject --ado-token <token> + specfact backlog map-fields --ado-org myorg --ado-project myproject --reset + """ + import base64 + import re + import sys + + import questionary # type: ignore[reportMissingImports] + import requests + + from specfact_cli.backlog.mappers.template_config import FieldMappingConfig + from specfact_cli.utils.auth_tokens import get_token + + def _find_potential_match(canonical_field: str, available_fields: list[dict[str, Any]]) -> str | None: + """ + Find a potential ADO field match for a canonical field using regex/fuzzy matching. + + Args: + canonical_field: Canonical field name (e.g., "acceptance_criteria") + available_fields: List of ADO field dicts with "referenceName" and "name" + + Returns: + Reference name of best matching field, or None if no good match found + """ + # Convert canonical field to search patterns + # e.g., "acceptance_criteria" -> ["acceptance", "criteria"] + field_parts = re.split(r"[_\s-]+", canonical_field.lower()) + + best_match: tuple[str, int] | None = None + best_score = 0 + + for field in available_fields: + ref_name = field.get("referenceName", "") + name = field.get("name", ref_name) + + # Search in both reference name and display name + search_text = f"{ref_name} {name}".lower() + + # Calculate match score + score = 0 + matched_parts = 0 + + for part in field_parts: + # Exact match in reference name (highest priority) + if part in ref_name.lower(): + score += 10 + matched_parts += 1 + # Exact match in display name + elif part in name.lower(): + score += 5 + matched_parts += 1 + # Partial match (contains substring) + elif part in search_text: + score += 2 + matched_parts += 1 + + # Bonus for matching all parts + if matched_parts == len(field_parts): + score += 5 + + # Prefer Microsoft.VSTS.Common.* fields + if ref_name.startswith("Microsoft.VSTS.Common."): + score += 3 + + if score > best_score and matched_parts > 0: + best_score = score + best_match = (ref_name, score) + + # Only return if we have a reasonable match (score >= 5) + if best_match and best_score >= 5: + return best_match[0] + + return None + + # Resolve token (explicit > env var > stored token) + api_token: str | None = None + auth_scheme = "basic" + if ado_token: + api_token = ado_token + auth_scheme = "basic" + elif os.environ.get("AZURE_DEVOPS_TOKEN"): + api_token = os.environ.get("AZURE_DEVOPS_TOKEN") + auth_scheme = "basic" + elif stored_token := get_token("azure-devops", allow_expired=False): + # Valid, non-expired token found + api_token = stored_token.get("access_token") + token_type = (stored_token.get("token_type") or "bearer").lower() + 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 - use it anyway for this command (user can refresh later) + api_token = stored_token_expired.get("access_token") + token_type = (stored_token_expired.get("token_type") or "bearer").lower() + auth_scheme = "bearer" if token_type == "bearer" else "basic" + console.print( + "[yellow]⚠[/yellow] Using expired stored token. If authentication fails, refresh with: specfact auth azure-devops" + ) + + if not api_token: + console.print("[red]Error:[/red] Azure DevOps token required") + console.print("[yellow]Options:[/yellow]") + console.print(" 1. Use --ado-token option") + console.print(" 2. Set AZURE_DEVOPS_TOKEN environment variable") + console.print(" 3. Use: specfact auth azure-devops") + raise typer.Exit(1) + + # Build base URL + base_url = (ado_base_url or "https://dev.azure.com").rstrip("/") + + # Fetch fields from ADO API + console.print("[cyan]Fetching fields from Azure DevOps...[/cyan]") + fields_url = f"{base_url}/{ado_org}/{ado_project}/_apis/wit/fields?api-version=7.1" + + # Prepare authentication headers based on auth scheme + headers: dict[str, str] = {} + if auth_scheme == "bearer": + headers["Authorization"] = f"Bearer {api_token}" + else: + # Basic auth for PAT tokens + auth_header = base64.b64encode(f":{api_token}".encode()).decode() + headers["Authorization"] = f"Basic {auth_header}" + + try: + response = requests.get(fields_url, headers=headers, timeout=30) + response.raise_for_status() + fields_data = response.json() + except requests.exceptions.RequestException as e: + console.print(f"[red]Error:[/red] Failed to fetch fields from Azure DevOps: {e}") + raise typer.Exit(1) from e + + # Extract fields and filter out system-only fields + all_fields = fields_data.get("value", []) + system_only_fields = { + "System.Id", + "System.Rev", + "System.ChangedDate", + "System.CreatedDate", + "System.ChangedBy", + "System.CreatedBy", + "System.AreaId", + "System.IterationId", + "System.TeamProject", + "System.NodeName", + "System.AreaLevel1", + "System.AreaLevel2", + "System.AreaLevel3", + "System.AreaLevel4", + "System.AreaLevel5", + "System.AreaLevel6", + "System.AreaLevel7", + "System.AreaLevel8", + "System.AreaLevel9", + "System.AreaLevel10", + "System.IterationLevel1", + "System.IterationLevel2", + "System.IterationLevel3", + "System.IterationLevel4", + "System.IterationLevel5", + "System.IterationLevel6", + "System.IterationLevel7", + "System.IterationLevel8", + "System.IterationLevel9", + "System.IterationLevel10", + } + + # Filter relevant fields + relevant_fields = [ + field + for field in all_fields + if field.get("referenceName") not in system_only_fields + and not field.get("referenceName", "").startswith("System.History") + and not field.get("referenceName", "").startswith("System.Watermark") + ] + + # Sort fields by reference name + relevant_fields.sort(key=lambda f: f.get("referenceName", "")) + + # Canonical fields to map + canonical_fields = { + "description": "Description", + "acceptance_criteria": "Acceptance Criteria", + "story_points": "Story Points", + "business_value": "Business Value", + "priority": "Priority", + "work_item_type": "Work Item Type", + } + + # Load default mappings from AdoFieldMapper + from specfact_cli.backlog.mappers.ado_mapper import AdoFieldMapper + + default_mappings = AdoFieldMapper.DEFAULT_FIELD_MAPPINGS + # Reverse default mappings: canonical -> list of ADO fields + default_mappings_reversed: dict[str, list[str]] = {} + for ado_field, canonical in default_mappings.items(): + if canonical not in default_mappings_reversed: + default_mappings_reversed[canonical] = [] + default_mappings_reversed[canonical].append(ado_field) + + # Handle --reset flag + current_dir = Path.cwd() + custom_mapping_file = current_dir / ".specfact" / "templates" / "backlog" / "field_mappings" / "ado_custom.yaml" + + if reset: + if custom_mapping_file.exists(): + custom_mapping_file.unlink() + console.print(f"[green]✓[/green] Reset custom field mapping (deleted {custom_mapping_file})") + console.print("[dim]Custom mappings removed. Default mappings will be used.[/dim]") + else: + console.print("[yellow]⚠[/yellow] No custom mapping file found. Nothing to reset.") + return + + # Load existing mapping if it exists + existing_mapping: dict[str, str] = {} + existing_work_item_type_mappings: dict[str, str] = {} + existing_config: FieldMappingConfig | None = None + if custom_mapping_file.exists(): + try: + existing_config = FieldMappingConfig.from_file(custom_mapping_file) + existing_mapping = existing_config.field_mappings + existing_work_item_type_mappings = existing_config.work_item_type_mappings or {} + console.print(f"[green]✓[/green] Loaded existing mapping from {custom_mapping_file}") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Failed to load existing mapping: {e}") + + # Build combined mapping: existing > default (checking which defaults exist in fetched fields) + combined_mapping: dict[str, str] = {} + # Get list of available ADO field reference names + available_ado_refs = {field.get("referenceName", "") for field in relevant_fields} + + # First add defaults, but only if they exist in the fetched ADO fields + for canonical_field in canonical_fields: + if canonical_field in default_mappings_reversed: + # Find which default mappings actually exist in the fetched ADO fields + # Prefer more common field names (Microsoft.VSTS.Common.* over System.*) + default_options = default_mappings_reversed[canonical_field] + existing_defaults = [ado_field for ado_field in default_options if ado_field in available_ado_refs] + + if existing_defaults: + # Prefer Microsoft.VSTS.Common.* over System.* for better compatibility + preferred = None + for ado_field in existing_defaults: + if ado_field.startswith("Microsoft.VSTS.Common."): + preferred = ado_field + break + # If no Microsoft.VSTS.Common.* found, use first existing + if preferred is None: + preferred = existing_defaults[0] + combined_mapping[preferred] = canonical_field + else: + # No default mapping exists - try to find a potential match using regex/fuzzy matching + potential_match = _find_potential_match(canonical_field, relevant_fields) + if potential_match: + combined_mapping[potential_match] = canonical_field + # Then override with existing mappings + combined_mapping.update(existing_mapping) + + # Interactive mapping + console.print() + console.print(Panel("[bold cyan]Interactive Field Mapping[/bold cyan]", border_style="cyan")) + console.print("[dim]Use ↑↓ to navigate, ⏎ to select. Map ADO fields to canonical field names.[/dim]") + console.print() + + new_mapping: dict[str, str] = {} + + # Build choice list with display names + field_choices_display: list[str] = ["<no mapping>"] + field_choices_refs: list[str] = ["<no mapping>"] + for field in relevant_fields: + ref_name = field.get("referenceName", "") + name = field.get("name", ref_name) + display = f"{ref_name} ({name})" + field_choices_display.append(display) + field_choices_refs.append(ref_name) + + for canonical_field, display_name in canonical_fields.items(): + # Find current mapping (existing > default) + current_ado_fields = [ + ado_field for ado_field, canonical in combined_mapping.items() if canonical == canonical_field + ] + + # Determine default selection + default_selection = "<no mapping>" + if current_ado_fields: + # Find the current mapping in the choices list + current_ref = current_ado_fields[0] + if current_ref in field_choices_refs: + default_selection = field_choices_display[field_choices_refs.index(current_ref)] + else: + # If current mapping not in available fields, use "<no mapping>" + default_selection = "<no mapping>" + + # Use interactive selection menu with questionary + console.print(f"[bold]{display_name}[/bold] (canonical: {canonical_field})") + if current_ado_fields: + console.print(f"[dim]Current: {', '.join(current_ado_fields)}[/dim]") + else: + console.print("[dim]Current: <no mapping>[/dim]") + + # Find default index + default_index = 0 + if default_selection != "<no mapping>" and default_selection in field_choices_display: + default_index = field_choices_display.index(default_selection) + + # Use questionary for interactive selection with arrow keys + try: + selected_display = questionary.select( + f"Select ADO field for {display_name}", + choices=field_choices_display, + default=field_choices_display[default_index] if default_index < len(field_choices_display) else None, + use_arrow_keys=True, + use_jk_keys=False, + ).ask() + if selected_display is None: + selected_display = "<no mapping>" + except KeyboardInterrupt: + console.print("\n[yellow]Selection cancelled.[/yellow]") + sys.exit(0) + + # Convert display name back to reference name + if selected_display and selected_display != "<no mapping>" and selected_display in field_choices_display: + selected_ref = field_choices_refs[field_choices_display.index(selected_display)] + new_mapping[selected_ref] = canonical_field + + console.print() + + # Validate mapping + console.print("[cyan]Validating mapping...[/cyan]") + duplicate_ado_fields = {} + for ado_field, canonical in new_mapping.items(): + if ado_field in duplicate_ado_fields: + duplicate_ado_fields[ado_field].append(canonical) + else: + # Check if this ADO field is already mapped to a different canonical field + for other_ado, other_canonical in new_mapping.items(): + if other_ado == ado_field and other_canonical != canonical: + if ado_field not in duplicate_ado_fields: + duplicate_ado_fields[ado_field] = [] + duplicate_ado_fields[ado_field].extend([canonical, other_canonical]) + + if duplicate_ado_fields: + console.print("[yellow]⚠[/yellow] Warning: Some ADO fields are mapped to multiple canonical fields:") + for ado_field, canonicals in duplicate_ado_fields.items(): + console.print(f" {ado_field}: {', '.join(set(canonicals))}") + if not Confirm.ask("Continue anyway?", default=False): + console.print("[yellow]Mapping cancelled.[/yellow]") + raise typer.Exit(0) + + # Merge with existing mapping (new mapping takes precedence) + final_mapping = existing_mapping.copy() + final_mapping.update(new_mapping) + + # Preserve existing work_item_type_mappings if they exist + # This prevents erasing custom work item type mappings when updating field mappings + work_item_type_mappings = existing_work_item_type_mappings.copy() if existing_work_item_type_mappings else {} + + # Create FieldMappingConfig + config = FieldMappingConfig( + framework=existing_config.framework if existing_config else "default", + field_mappings=final_mapping, + work_item_type_mappings=work_item_type_mappings, + ) + + # Save to file + custom_mapping_file.parent.mkdir(parents=True, exist_ok=True) + with custom_mapping_file.open("w", encoding="utf-8") as f: + yaml.dump(config.model_dump(), f, default_flow_style=False, sort_keys=False) + + console.print() + console.print(Panel("[bold green]✓ Mapping saved successfully[/bold green]", border_style="green")) + console.print(f"[green]Location:[/green] {custom_mapping_file}") + console.print() + console.print("[dim]You can now use this mapping with specfact backlog refine.[/dim]") diff --git a/src/specfact_cli/modules/contract/src/__init__.py b/src/specfact_cli/modules/contract/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/contract/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/contract/src/app.py b/src/specfact_cli/modules/contract/src/app.py index fe54ffa2..a4c0c1c2 100644 --- a/src/specfact_cli/modules/contract/src/app.py +++ b/src/specfact_cli/modules/contract/src/app.py @@ -1,6 +1,6 @@ -"""Contract command: re-export from commands package.""" +"""contract command entrypoint.""" -from specfact_cli.commands.contract_cmd import app +from specfact_cli.modules.contract.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/contract/src/commands.py b/src/specfact_cli/modules/contract/src/commands.py new file mode 100644 index 00000000..a1c20731 --- /dev/null +++ b/src/specfact_cli/modules/contract/src/commands.py @@ -0,0 +1,1244 @@ +""" +Contract command - OpenAPI contract management for project bundles. + +This module provides commands for managing OpenAPI contracts within project bundles, +including initialization, validation, mock server generation, test generation, and coverage. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.table import Table + +from specfact_cli.models.contract import ( + ContractIndex, + ContractStatus, + count_endpoints, + load_openapi_contract, + validate_openapi_schema, +) +from specfact_cli.models.project import FeatureIndex, ProjectBundle +from specfact_cli.telemetry import telemetry +from specfact_cli.utils import print_error, print_info, print_section, print_success, print_warning +from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress +from specfact_cli.utils.structure import SpecFactStructure + + +app = typer.Typer(help="Manage OpenAPI contracts for project bundles") +console = Console() + + +@app.command("init") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def init_contract( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + feature: str = typer.Option(..., "--feature", help="Feature key (e.g., FEATURE-001)"), + # Output/Results + title: str | None = typer.Option(None, "--title", help="API title (default: feature title)"), + version: str = typer.Option("1.0.0", "--version", help="API version"), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), + force: bool = typer.Option( + False, + "--force", + help="Overwrite existing contract file without prompting (useful for updating contracts)", + ), +) -> None: + """ + Initialize OpenAPI contract for a feature. + + Creates a new OpenAPI 3.0.3 contract stub in the bundle's contracts/ directory + and links it to the feature in the bundle manifest. + + Note: Defaults to OpenAPI 3.0.3 for compatibility with Specmatic. + Validation accepts both 3.0.x and 3.1.x for forward compatibility. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --feature + - **Output/Results**: --title, --version + - **Behavior/Options**: --no-interactive, --force + + **Examples:** + specfact contract init --bundle legacy-api --feature FEATURE-001 + specfact contract init --bundle legacy-api --feature FEATURE-001 --title "Authentication API" --version 1.0.0 + specfact contract init --bundle legacy-api --feature FEATURE-001 --force --no-interactive + """ + telemetry_metadata = { + "bundle": bundle, + "feature": feature, + "title": title, + "version": version, + } + + with telemetry.track_command("contract.init", telemetry_metadata) as record: + print_section("SpecFact CLI - OpenAPI Contract Initialization") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Check feature exists + if feature not in bundle_obj.features: + print_error(f"Feature '{feature}' not found in bundle") + raise typer.Exit(1) + + feature_obj = bundle_obj.features[feature] + + # Determine contract file path + contracts_dir = bundle_dir / "contracts" + contracts_dir.mkdir(parents=True, exist_ok=True) + contract_file = contracts_dir / f"{feature}.openapi.yaml" + + if contract_file.exists(): + if force: + print_warning(f"Overwriting existing contract file: {contract_file}") + else: + print_warning(f"Contract file already exists: {contract_file}") + if not no_interactive: + overwrite = typer.confirm("Overwrite existing contract?") + if not overwrite: + raise typer.Exit(0) + else: + print_error("Use --force to overwrite existing contract in non-interactive mode") + raise typer.Exit(1) + + # Generate OpenAPI stub + api_title = title or feature_obj.title + openapi_stub = _generate_openapi_stub(api_title, version, feature) + + # Write contract file + import yaml + + with contract_file.open("w", encoding="utf-8") as f: + yaml.dump(openapi_stub, f, default_flow_style=False, sort_keys=False) + + # Update feature index in manifest + contract_path = f"contracts/{contract_file.name}" + _update_feature_contract(bundle_obj, feature, contract_path) + + # Update contract index in manifest + _update_contract_index(bundle_obj, feature, contract_path, bundle_dir / contract_path) + + # Save bundle + save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True, console_instance=console) + print_success(f"Initialized OpenAPI contract for {feature}: {contract_file}") + + record({"feature": feature, "contract_file": str(contract_file)}) + + +@beartype +@require(lambda title: isinstance(title, str), "Title must be str") +@require(lambda version: isinstance(version, str), "Version must be str") +@require(lambda feature: isinstance(feature, str), "Feature must be str") +@ensure(lambda result: isinstance(result, dict), "Must return dict") +def _generate_openapi_stub(title: str, version: str, feature: str) -> dict[str, Any]: + """Generate OpenAPI 3.0.3 stub. + + Note: Defaults to 3.0.3 for Specmatic compatibility. + Specmatic 3.1.x support is planned but not yet released (as of Dec 2025). + Once Specmatic adds 3.1.x support, we can update the default here. + """ + return { + "openapi": "3.0.3", # Default to 3.0.3 for Specmatic compatibility + "info": { + "title": title, + "version": version, + "description": f"OpenAPI contract for {feature}", + }, + "servers": [ + {"url": "https://api.example.com/v1", "description": "Production server"}, + {"url": "https://staging.api.example.com/v1", "description": "Staging server"}, + ], + "paths": {}, + "components": { + "schemas": {}, + "responses": {}, + "parameters": {}, + }, + } + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") +@require(lambda feature_key: isinstance(feature_key, str), "Feature key must be str") +@require(lambda contract_path: isinstance(contract_path, str), "Contract path must be str") +@ensure(lambda result: result is None, "Must return None") +def _update_feature_contract(bundle: ProjectBundle, feature_key: str, contract_path: str) -> None: + """Update feature contract reference in manifest.""" + # Find feature index + for feature_index in bundle.manifest.features: + if feature_index.key == feature_key: + feature_index.contract = contract_path + return + + # If not found, create new index entry + feature_obj = bundle.features[feature_key] + from datetime import UTC, datetime + + feature_index = FeatureIndex( + key=feature_key, + title=feature_obj.title, + file=f"features/{feature_key}.yaml", + contract=contract_path, + status="active", + stories_count=len(feature_obj.stories), + created_at=datetime.now(UTC).isoformat(), + updated_at=datetime.now(UTC).isoformat(), + checksum=None, + ) + bundle.manifest.features.append(feature_index) + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") +@require(lambda feature_key: isinstance(feature_key, str), "Feature key must be str") +@require(lambda contract_path: isinstance(contract_path, str), "Contract path must be str") +@require(lambda contract_file: isinstance(contract_file, Path), "Contract file must be Path") +@ensure(lambda result: result is None, "Must return None") +def _update_contract_index(bundle: ProjectBundle, feature_key: str, contract_path: str, contract_file: Path) -> None: + """Update contract index in manifest.""" + import hashlib + + # Check if contract index already exists + for contract_index in bundle.manifest.contracts: + if contract_index.feature_key == feature_key: + # Update existing index + contract_index.contract_file = contract_path + contract_index.status = ContractStatus.DRAFT + if contract_file.exists(): + try: + contract_data = load_openapi_contract(contract_file) + contract_index.endpoints_count = count_endpoints(contract_data) + contract_index.checksum = hashlib.sha256(contract_file.read_bytes()).hexdigest() + except Exception: + contract_index.endpoints_count = 0 + contract_index.checksum = None + return + + # Create new contract index entry + endpoints_count = 0 + checksum = None + if contract_file.exists(): + try: + contract_data = load_openapi_contract(contract_file) + endpoints_count = count_endpoints(contract_data) + checksum = hashlib.sha256(contract_file.read_bytes()).hexdigest() + except Exception: + print_warning(f"Failed to load or analyze contract file '{contract_file}'. Using default metadata.") + + contract_index = ContractIndex( + feature_key=feature_key, + contract_file=contract_path, + status=ContractStatus.DRAFT, + checksum=checksum, + endpoints_count=endpoints_count, + coverage=0.0, + ) + bundle.manifest.contracts.append(contract_index) + + +@app.command("validate") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def validate_contract( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + feature: str | None = typer.Option( + None, + "--feature", + help="Feature key (e.g., FEATURE-001). If not specified, validates all contracts in bundle.", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Validate OpenAPI contract schema. + + Validates OpenAPI schema structure (supports both 3.0.x and 3.1.x). + For comprehensive validation including Specmatic, use 'specfact spec validate'. + + Note: Accepts both OpenAPI 3.0.x and 3.1.x for forward compatibility. + Specmatic currently supports 3.0.x; 3.1.x support is planned. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --feature + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact contract validate --bundle legacy-api --feature FEATURE-001 + specfact contract validate --bundle legacy-api # Validates all contracts + """ + telemetry_metadata = { + "bundle": bundle, + "feature": feature, + } + + with telemetry.track_command("contract.validate", telemetry_metadata) as record: + print_section("SpecFact CLI - OpenAPI Contract Validation") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Determine which contracts to validate + contracts_to_validate: list[tuple[str, Path]] = [] + + if feature: + # Validate specific feature contract + if feature not in bundle_obj.features: + print_error(f"Feature '{feature}' not found in bundle") + raise typer.Exit(1) + + feature_obj = bundle_obj.features[feature] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + + contract_path = bundle_dir / feature_obj.contract + if not contract_path.exists(): + print_error(f"Contract file not found: {contract_path}") + raise typer.Exit(1) + + contracts_to_validate = [(feature, contract_path)] + else: + # Validate all contracts + for feature_key, feature_obj in bundle_obj.features.items(): + if feature_obj.contract: + contract_path = bundle_dir / feature_obj.contract + if contract_path.exists(): + contracts_to_validate.append((feature_key, contract_path)) + + if not contracts_to_validate: + print_warning("No contracts found to validate") + raise typer.Exit(0) + + # Validate contracts + table = Table(title="Contract Validation Results") + table.add_column("Feature", style="cyan") + table.add_column("Contract File", style="magenta") + table.add_column("Status", style="green") + table.add_column("Endpoints", style="yellow") + + all_valid = True + for feature_key, contract_path in contracts_to_validate: + try: + contract_data = load_openapi_contract(contract_path) + is_valid = validate_openapi_schema(contract_data) + endpoint_count = count_endpoints(contract_data) + + if is_valid: + status = "✓ Valid" + table.add_row(feature_key, contract_path.name, status, str(endpoint_count)) + else: + status = "✗ Invalid" + table.add_row(feature_key, contract_path.name, status, "0") + all_valid = False + except Exception as e: + status = f"✗ Error: {e}" + table.add_row(feature_key, contract_path.name, status, "0") + all_valid = False + + console.print(table) + + if not all_valid: + print_error("Some contracts failed validation") + record({"valid": False, "contracts_count": len(contracts_to_validate)}) + raise typer.Exit(1) + + print_success("All contracts validated successfully") + record({"valid": True, "contracts_count": len(contracts_to_validate)}) + + +@app.command("coverage") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def contract_coverage( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Calculate contract coverage for a project bundle. + + Shows which features have contracts and calculates coverage metrics. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact contract coverage --bundle legacy-api + """ + telemetry_metadata = { + "bundle": bundle, + } + + with telemetry.track_command("contract.coverage", telemetry_metadata) as record: + print_section("SpecFact CLI - OpenAPI Contract Coverage") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Calculate coverage + total_features = len(bundle_obj.features) + features_with_contracts = 0 + total_endpoints = 0 + + table = Table(title="Contract Coverage") + table.add_column("Feature", style="cyan") + table.add_column("Contract", style="magenta") + table.add_column("Endpoints", style="yellow") + table.add_column("Status", style="green") + + for feature_key, feature_obj in bundle_obj.features.items(): + if feature_obj.contract: + contract_path = bundle_dir / feature_obj.contract + if contract_path.exists(): + try: + contract_data = load_openapi_contract(contract_path) + endpoint_count = count_endpoints(contract_data) + total_endpoints += endpoint_count + features_with_contracts += 1 + table.add_row(feature_key, contract_path.name, str(endpoint_count), "✓") + except Exception as e: + table.add_row(feature_key, contract_path.name, "0", f"✗ Error: {e}") + else: + table.add_row(feature_key, feature_obj.contract, "0", "✗ File not found") + else: + table.add_row(feature_key, "-", "0", "✗ No contract") + + console.print(table) + + # Calculate coverage percentage + coverage_percent = (features_with_contracts / total_features * 100) if total_features > 0 else 0.0 + + console.print("\n[bold]Coverage Summary:[/bold]") + console.print( + f" Features with contracts: {features_with_contracts}/{total_features} ({coverage_percent:.1f}%)" + ) + console.print(f" Total API endpoints: {total_endpoints}") + + if coverage_percent < 100.0: + print_warning(f"Coverage is {coverage_percent:.1f}% - some features are missing contracts") + else: + print_success("All features have contracts (100% coverage)") + + record( + { + "total_features": total_features, + "features_with_contracts": features_with_contracts, + "coverage_percent": coverage_percent, + "total_endpoints": total_endpoints, + } + ) + + +@app.command("serve") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def serve_contract( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + feature: str | None = typer.Option( + None, + "--feature", + help="Feature key (e.g., FEATURE-001). If not specified, prompts for selection.", + ), + # Behavior/Options + port: int = typer.Option(9000, "--port", help="Port number for mock server (default: 9000)"), + strict: bool = typer.Option( + True, + "--strict/--examples", + help="Use strict validation mode (default: strict)", + ), + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Start mock server for OpenAPI contract. + + Launches a Specmatic mock server that serves API endpoints based on the + OpenAPI contract. Useful for frontend development and testing without a + running backend. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --feature + - **Behavior/Options**: --port, --strict/--examples, --no-interactive + + **Examples:** + specfact contract serve --bundle legacy-api --feature FEATURE-001 + specfact contract serve --bundle legacy-api --feature FEATURE-001 --port 8080 + specfact contract serve --bundle legacy-api --feature FEATURE-001 --examples + """ + telemetry_metadata = { + "bundle": bundle, + "feature": feature, + "port": port, + "strict": strict, + } + + with telemetry.track_command("contract.serve", telemetry_metadata): + from specfact_cli.integrations.specmatic import check_specmatic_available, create_mock_server + + print_section("SpecFact CLI - OpenAPI Contract Mock Server") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Get feature contract + if feature: + if feature not in bundle_obj.features: + print_error(f"Feature '{feature}' not found in bundle") + raise typer.Exit(1) + feature_obj = bundle_obj.features[feature] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + contract_path = bundle_dir / feature_obj.contract + if not contract_path.exists(): + print_error(f"Contract file not found: {contract_path}") + raise typer.Exit(1) + else: + # Find features with contracts + features_with_contracts = [(key, obj) for key, obj in bundle_obj.features.items() if obj.contract] + if not features_with_contracts: + print_error("No features with contracts found in bundle") + raise typer.Exit(1) + + if len(features_with_contracts) == 1: + # Only one contract, use it + feature, feature_obj = features_with_contracts[0] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + contract_path = bundle_dir / feature_obj.contract + elif no_interactive: + # Non-interactive mode, use first contract + feature, feature_obj = features_with_contracts[0] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + contract_path = bundle_dir / feature_obj.contract + else: + # Interactive selection + from rich.prompt import Prompt + + feature_choices = [f"{key}: {obj.title}" for key, obj in features_with_contracts] + selected = Prompt.ask("Select feature contract", choices=feature_choices) + feature = selected.split(":", 1)[0].strip() + if feature not in bundle_obj.features: + print_error(f"Selected feature '{feature}' not found in bundle") + raise typer.Exit(1) + feature_obj = bundle_obj.features[feature] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + contract_path = bundle_dir / feature_obj.contract + + # Check if Specmatic is available + is_available, error_msg = check_specmatic_available() + if not is_available: + print_error(f"Specmatic not available: {error_msg}") + print_info("Install Specmatic: npm install -g @specmatic/specmatic") + raise typer.Exit(1) + + # Start mock server + console.print("[bold cyan]Starting mock server...[/bold cyan]") + console.print(f" Feature: {feature}") + # Resolve repo to absolute path for relative_to() to work + repo_resolved = repo.resolve() + try: + contract_path_display = contract_path.relative_to(repo_resolved) + except ValueError: + # If contract_path is not a subpath of repo, show absolute path + contract_path_display = contract_path + console.print(f" Contract: {contract_path_display}") + console.print(f" Port: {port}") + console.print(f" Mode: {'strict' if strict else 'examples'}") + + import asyncio + + console.print("[dim]Starting mock server (this may take a few seconds)...[/dim]") + try: + mock_server = asyncio.run(create_mock_server(contract_path, port=port, strict_mode=strict)) + print_success(f"✓ Mock server started at http://localhost:{port}") + console.print("\n[bold]Available endpoints:[/bold]") + console.print(f" Try: curl http://localhost:{port}/actuator/health") + console.print("\n[yellow]Press Ctrl+C to stop the server[/yellow]") + + # Keep running until interrupted + try: + import time + + while mock_server.is_running(): + time.sleep(1) + except KeyboardInterrupt: + console.print("\n[yellow]Stopping mock server...[/yellow]") + mock_server.stop() + print_success("✓ Mock server stopped") + except Exception as e: + print_error(f"✗ Failed to start mock server: {e!s}") + raise typer.Exit(1) from e + + +@app.command("verify") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def verify_contract( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + feature: str | None = typer.Option( + None, + "--feature", + help="Feature key (e.g., FEATURE-001). If not specified, verifies all contracts in bundle.", + ), + # Behavior/Options + port: int = typer.Option(9000, "--port", help="Port number for mock server (default: 9000)"), + skip_mock: bool = typer.Option( + False, + "--skip-mock", + help="Skip mock server startup (only validate contract)", + ), + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Verify OpenAPI contract - validate, generate examples, and test mock server. + + This is a convenience command that combines multiple steps: + 1. Validates the contract schema + 2. Generates examples from the contract + 3. Starts a mock server (optional) + 4. Runs basic connectivity tests + + Perfect for verifying contracts work correctly without a real API implementation. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --feature + - **Behavior/Options**: --port, --skip-mock, --no-interactive + + **Examples:** + # Verify a specific contract + specfact contract verify --bundle my-api --feature FEATURE-001 + + # Verify all contracts in a bundle + specfact contract verify --bundle my-api + + # Verify without starting mock server (CI/CD) + specfact contract verify --bundle my-api --feature FEATURE-001 --skip-mock --no-interactive + """ + telemetry_metadata = { + "bundle": bundle, + "feature": feature, + "port": port, + "skip_mock": skip_mock, + } + + with telemetry.track_command("contract.verify", telemetry_metadata) as record: + from specfact_cli.integrations.specmatic import ( + check_specmatic_available, + create_mock_server, + generate_specmatic_examples, + ) + + print_section("SpecFact CLI - OpenAPI Contract Verification") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Determine which contracts to verify + contracts_to_verify: list[tuple[str, Path]] = [] + if feature: + if feature not in bundle_obj.features: + print_error(f"Feature '{feature}' not found in bundle") + raise typer.Exit(1) + feature_obj = bundle_obj.features[feature] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + contract_path = bundle_dir / feature_obj.contract + if not contract_path.exists(): + print_error(f"Contract file not found: {contract_path}") + raise typer.Exit(1) + contracts_to_verify = [(feature, contract_path)] + else: + # Verify all contracts in bundle + for feat_key, feat_obj in bundle_obj.features.items(): + if feat_obj.contract: + contract_path = bundle_dir / feat_obj.contract + if contract_path.exists(): + contracts_to_verify.append((feat_key, contract_path)) + + if not contracts_to_verify: + print_error("No contracts found to verify") + raise typer.Exit(1) + + # Check if Specmatic is available + is_available, error_msg = check_specmatic_available() + if not is_available: + print_error(f"Specmatic not available: {error_msg}") + print_info("Install Specmatic: npm install -g @specmatic/specmatic") + raise typer.Exit(1) + + # Step 1: Validate contracts + console.print("\n[bold cyan]Step 1: Validating contracts...[/bold cyan]") + validation_errors = [] + for feat_key, contract_path in contracts_to_verify: + try: + contract_data = load_openapi_contract(contract_path) + is_valid = validate_openapi_schema(contract_data) + if is_valid: + endpoints = count_endpoints(contract_data) + print_success(f"✓ {feat_key}: Valid ({endpoints} endpoints)") + else: + print_error(f"✗ {feat_key}: Invalid schema") + validation_errors.append(f"{feat_key}: Schema validation failed") + except Exception as e: + print_error(f"✗ {feat_key}: Error - {e!s}") + validation_errors.append(f"{feat_key}: {e!s}") + + if validation_errors: + console.print("\n[bold red]Validation Errors:[/bold red]") + for error in validation_errors[:10]: # Show first 10 errors + console.print(f" • {error}") + if len(validation_errors) > 10: + console.print(f" ... and {len(validation_errors) - 10} more errors") + record({"validation_errors": len(validation_errors), "validated": False}) + raise typer.Exit(1) + + record({"validated": True, "contracts_count": len(contracts_to_verify)}) + + # Step 2: Generate examples + console.print("\n[bold cyan]Step 2: Generating examples...[/bold cyan]") + import asyncio + + examples_generated = 0 + for feat_key, contract_path in contracts_to_verify: + try: + examples_dir = asyncio.run(generate_specmatic_examples(contract_path)) + if examples_dir.exists() and any(examples_dir.iterdir()): + examples_generated += 1 + print_success(f"✓ {feat_key}: Examples generated") + else: + print_warning(f"⚠ {feat_key}: No examples generated (schema may not have examples)") + except Exception as e: + print_warning(f"⚠ {feat_key}: Example generation failed - {e!s}") + + record({"examples_generated": examples_generated}) + + # Step 3: Start mock server and test (if not skipped) + if not skip_mock: + if len(contracts_to_verify) > 1: + console.print( + f"\n[yellow]Note: Multiple contracts found. Starting mock server for first contract: {contracts_to_verify[0][0]}[/yellow]" + ) + + feat_key, contract_path = contracts_to_verify[0] + console.print(f"\n[bold cyan]Step 3: Starting mock server for {feat_key}...[/bold cyan]") + + try: + mock_server = asyncio.run(create_mock_server(contract_path, port=port, strict_mode=False)) + print_success(f"✓ Mock server started at http://localhost:{port}") + + # Step 4: Run basic connectivity test + console.print("\n[bold cyan]Step 4: Testing connectivity...[/bold cyan]") + try: + import requests + + # Test health endpoint + health_url = f"http://localhost:{port}/actuator/health" + response = requests.get(health_url, timeout=5) + if response.status_code == 200: + print_success(f"✓ Health check passed: {response.json().get('status', 'OK')}") + record({"health_check": True}) + else: + print_warning(f"⚠ Health check returned: {response.status_code}") + record({"health_check": False, "health_status": response.status_code}) + except ImportError: + print_warning("⚠ 'requests' library not available - skipping connectivity test") + record({"health_check": None}) + except Exception as e: + print_warning(f"⚠ Connectivity test failed: {e!s}") + record({"health_check": False, "health_error": str(e)}) + + # Summary + console.print("\n[bold green]✓ Contract verification complete![/bold green]") + console.print("\n[bold]Summary:[/bold]") + console.print(f" • Contracts validated: {len(contracts_to_verify)}") + console.print(f" • Examples generated: {examples_generated}") + console.print(f" • Mock server: http://localhost:{port}") + console.print("\n[yellow]Press Ctrl+C to stop the mock server[/yellow]") + + # Keep running until interrupted + try: + import time + + while mock_server.is_running(): + time.sleep(1) + except KeyboardInterrupt: + console.print("\n[yellow]Stopping mock server...[/yellow]") + mock_server.stop() + print_success("✓ Mock server stopped") + except Exception as e: + print_error(f"✗ Failed to start mock server: {e!s}") + record({"mock_server": False, "mock_error": str(e)}) + raise typer.Exit(1) from e + else: + # Summary without mock server + console.print("\n[bold green]✓ Contract verification complete![/bold green]") + console.print("\n[bold]Summary:[/bold]") + console.print(f" • Contracts validated: {len(contracts_to_verify)}") + console.print(f" • Examples generated: {examples_generated}") + console.print(" • Mock server: Skipped (--skip-mock)") + record({"mock_server": False, "skipped": True}) + + +@app.command("test") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def test_contract( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + feature: str | None = typer.Option( + None, + "--feature", + help="Feature key (e.g., FEATURE-001). If not specified, generates tests for all contracts in bundle.", + ), + # Output/Results + output_dir: Path | None = typer.Option( + None, + "--output", + "--out", + help="Output directory for generated tests (default: bundle-specific .specfact/projects/<bundle-name>/tests/contracts/)", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Generate contract tests and examples from OpenAPI contract. + + **IMPORTANT**: This command generates test files and examples, but running the tests + requires a REAL API implementation. The generated tests validate that your API + matches the contract - they cannot test the contract itself. + + **What this command does:** + 1. Generates example request/response files from the contract schema + 2. Generates test files that can validate API implementations + 3. Prepares everything needed for contract testing + + **What you can do WITHOUT a real API:** + - ✅ Validate contract schema: `specfact contract validate` + - ✅ Start mock server: `specfact contract serve --examples` + - ✅ Generate examples: This command does this automatically + + **What REQUIRES a real API:** + - ❌ Running contract tests: `specmatic test --host <api-url>` + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --feature + - **Output/Results**: --output + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact contract test --bundle legacy-api --feature FEATURE-001 + specfact contract test --bundle legacy-api # Generates tests for all contracts + specfact contract test --bundle legacy-api --output tests/contracts/ + + **See**: [Contract Testing Workflow](../guides/contract-testing-workflow.md) for details. + """ + telemetry_metadata = { + "bundle": bundle, + "feature": feature, + } + + with telemetry.track_command("contract.test", telemetry_metadata) as record: + from specfact_cli.integrations.specmatic import check_specmatic_available + + print_section("SpecFact CLI - OpenAPI Contract Test Generation") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Determine which contracts to generate tests for + contracts_to_test: list[tuple[str, Path]] = [] + + if feature: + # Generate tests for specific feature contract + if feature not in bundle_obj.features: + print_error(f"Feature '{feature}' not found in bundle") + raise typer.Exit(1) + feature_obj = bundle_obj.features[feature] + if not feature_obj.contract: + print_error(f"Feature '{feature}' has no contract") + raise typer.Exit(1) + contract_path = bundle_dir / feature_obj.contract + if not contract_path.exists(): + print_error(f"Contract file not found: {contract_path}") + raise typer.Exit(1) + contracts_to_test = [(feature, contract_path)] + else: + # Generate tests for all contracts + for feature_key, feature_obj in bundle_obj.features.items(): + if feature_obj.contract: + contract_path = bundle_dir / feature_obj.contract + if contract_path.exists(): + contracts_to_test.append((feature_key, contract_path)) + + if not contracts_to_test: + print_warning("No contracts found to generate tests for") + raise typer.Exit(0) + + # Check if Specmatic is available (after checking contracts exist) + is_available, error_msg = check_specmatic_available() + if not is_available: + print_error(f"Specmatic not available: {error_msg}") + print_info("Install Specmatic: npm install -g @specmatic/specmatic") + raise typer.Exit(1) + + # Determine output directory (set default if not provided) + if output_dir is None: + output_dir = bundle_dir / "tests" / "contracts" + output_dir.mkdir(parents=True, exist_ok=True) + + # Generate tests using Specmatic + console.print("[bold cyan]Generating contract tests...[/bold cyan]") + # Resolve repo to absolute path for relative_to() to work + repo_resolved = repo.resolve() + try: + output_dir_display = output_dir.relative_to(repo_resolved) + except ValueError: + # If output_dir is not a subpath of repo, show absolute path + output_dir_display = output_dir + console.print(f" Output directory: {output_dir_display}") + console.print(f" Contracts: {len(contracts_to_test)}") + + import asyncio + + from specfact_cli.integrations.specmatic import generate_specmatic_tests + + generated_count = 0 + failed_count = 0 + + for feature_key, contract_path in contracts_to_test: + try: + # Create feature-specific output directory + feature_output_dir = output_dir / feature_key.lower() + feature_output_dir.mkdir(parents=True, exist_ok=True) + + # Step 1: Generate examples from contract (required for mock server and tests) + from specfact_cli.integrations.specmatic import generate_specmatic_examples + + examples_dir = contract_path.parent / f"{contract_path.stem}_examples" + console.print(f" [dim]Generating examples for {feature_key}...[/dim]") + try: + asyncio.run(generate_specmatic_examples(contract_path, examples_dir)) + console.print(f" [dim]✓ Examples generated: {examples_dir.name}[/dim]") + except Exception as e: + # Examples generation is optional - continue even if it fails + console.print(f" [yellow]⚠ Examples generation skipped: {e!s}[/yellow]") + + # Step 2: Generate tests (uses examples if available) + test_dir = asyncio.run(generate_specmatic_tests(contract_path, feature_output_dir)) + generated_count += 1 + try: + test_dir_display = test_dir.relative_to(repo_resolved) + except ValueError: + # If test_dir is not a subpath of repo, show absolute path + test_dir_display = test_dir + console.print(f" ✓ Generated tests for {feature_key}: {test_dir_display}") + except Exception as e: + failed_count += 1 + console.print(f" ✗ Failed to generate tests for {feature_key}: {e!s}") + + if generated_count > 0: + print_success(f"Generated {generated_count} test suite(s)") + if failed_count > 0: + print_warning(f"Failed to generate {failed_count} test suite(s)") + record({"generated": generated_count, "failed": failed_count}) + raise typer.Exit(1) + + record({"generated": generated_count, "failed": failed_count}) diff --git a/src/specfact_cli/modules/drift/src/__init__.py b/src/specfact_cli/modules/drift/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/drift/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/drift/src/app.py b/src/specfact_cli/modules/drift/src/app.py index 85707f89..7467cd0b 100644 --- a/src/specfact_cli/modules/drift/src/app.py +++ b/src/specfact_cli/modules/drift/src/app.py @@ -1,6 +1,6 @@ -"""Drift command: re-export from commands package.""" +"""drift command entrypoint.""" -from specfact_cli.commands.drift import app +from specfact_cli.modules.drift.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/drift/src/commands.py b/src/specfact_cli/modules/drift/src/commands.py new file mode 100644 index 00000000..b34b93f9 --- /dev/null +++ b/src/specfact_cli/modules/drift/src/commands.py @@ -0,0 +1,246 @@ +""" +Drift command - Detect misalignment between code and specifications. + +This module provides commands for detecting drift between actual code/tests +and specifications. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console + +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils import print_error, print_success + + +app = typer.Typer(help="Detect drift between code and specifications") +console = Console() + + +@app.command("detect") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def detect_drift( + # Target/Input + bundle: str | None = typer.Argument( + None, help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'" + ), + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Output + output_format: str = typer.Option( + "table", + "--format", + help="Output format: 'table' (rich table), 'json', or 'yaml'. Default: table", + ), + out: Path | None = typer.Option( + None, + "--out", + help="Output file path (for JSON/YAML format). Default: stdout", + ), +) -> None: + """ + Detect drift between code and specifications. + + Scans repository and project bundle to identify: + - Added code (files with no spec) + - Removed code (deleted but spec exists) + - Modified code (hash changed) + - Orphaned specs (spec with no code) + - Test coverage gaps (stories missing tests) + - Contract violations (implementation doesn't match contract) + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --repo + - **Output**: --format, --out + + **Examples:** + specfact drift detect legacy-api --repo . + specfact drift detect my-bundle --repo . --format json --out drift-report.json + """ + if is_debug_mode(): + debug_log_operation( + "command", "drift detect", "started", extra={"bundle": bundle, "repo": str(repo), "format": output_format} + ) + debug_print("[dim]drift detect: started[/dim]") + from rich.console import Console + + from specfact_cli.utils.structure import SpecFactStructure + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None: + if is_debug_mode(): + debug_log_operation( + "command", "drift detect", "failed", error="Bundle name required", extra={"reason": "no_bundle"} + ) + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + from specfact_cli.sync.drift_detector import DriftDetector + + repo_path = repo.resolve() + + telemetry_metadata = { + "bundle": bundle, + "output_format": output_format, + } + + with telemetry.track_command("drift.detect", telemetry_metadata) as record: + console.print(f"[bold cyan]Drift Detection:[/bold cyan] {bundle}") + console.print(f"[dim]Repository:[/dim] {repo_path}\n") + + detector = DriftDetector(bundle, repo_path) + report = detector.scan(bundle, repo_path) + + # Display report + if output_format == "table": + _display_drift_report_table(report) + elif output_format == "json": + import json + + output = json.dumps(report.__dict__, indent=2) + if out: + out.write_text(output, encoding="utf-8") + print_success(f"Report written to: {out}") + else: + console.print(output) + elif output_format == "yaml": + import yaml + + output = yaml.dump(report.__dict__, default_flow_style=False, sort_keys=False) + if out: + out.write_text(output, encoding="utf-8") + print_success(f"Report written to: {out}") + else: + console.print(output) + else: + if is_debug_mode(): + debug_log_operation( + "command", + "drift detect", + "failed", + error=f"Unknown format: {output_format}", + extra={"reason": "invalid_format"}, + ) + print_error(f"Unknown output format: {output_format}") + raise typer.Exit(1) + + # Summary + total_issues = ( + len(report.added_code) + + len(report.removed_code) + + len(report.modified_code) + + len(report.orphaned_specs) + + len(report.test_coverage_gaps) + + len(report.contract_violations) + ) + + if total_issues == 0: + print_success("No drift detected - code and specs are in sync!") + else: + console.print(f"\n[bold yellow]Total Issues:[/bold yellow] {total_issues}") + + record( + { + "added_code": len(report.added_code), + "removed_code": len(report.removed_code), + "modified_code": len(report.modified_code), + "orphaned_specs": len(report.orphaned_specs), + "test_coverage_gaps": len(report.test_coverage_gaps), + "contract_violations": len(report.contract_violations), + "total_issues": total_issues, + } + ) + if is_debug_mode(): + debug_log_operation( + "command", + "drift detect", + "success", + extra={"bundle": bundle, "total_issues": total_issues}, + ) + debug_print("[dim]drift detect: success[/dim]") + + +def _display_drift_report_table(report: Any) -> None: + """Display drift report as a rich table.""" + + console.print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + console.print("[bold]Drift Detection Report[/bold]") + console.print("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━\n") + + # Added Code + if report.added_code: + console.print(f"[bold yellow]Added Code ({len(report.added_code)} files):[/bold yellow]") + for file_path in report.added_code[:10]: # Show first 10 + console.print(f" • {file_path} (no spec)") + if len(report.added_code) > 10: + console.print(f" ... and {len(report.added_code) - 10} more") + console.print() + + # Removed Code + if report.removed_code: + console.print(f"[bold yellow]Removed Code ({len(report.removed_code)} files):[/bold yellow]") + for file_path in report.removed_code[:10]: + console.print(f" • {file_path} (deleted but spec exists)") + if len(report.removed_code) > 10: + console.print(f" ... and {len(report.removed_code) - 10} more") + console.print() + + # Modified Code + if report.modified_code: + console.print(f"[bold yellow]Modified Code ({len(report.modified_code)} files):[/bold yellow]") + for file_path in report.modified_code[:10]: + console.print(f" • {file_path} (hash changed)") + if len(report.modified_code) > 10: + console.print(f" ... and {len(report.modified_code) - 10} more") + console.print() + + # Orphaned Specs + if report.orphaned_specs: + console.print(f"[bold yellow]Orphaned Specs ({len(report.orphaned_specs)} features):[/bold yellow]") + for feature_key in report.orphaned_specs[:10]: + console.print(f" • {feature_key} (no code)") + if len(report.orphaned_specs) > 10: + console.print(f" ... and {len(report.orphaned_specs) - 10} more") + console.print() + + # Test Coverage Gaps + if report.test_coverage_gaps: + console.print(f"[bold yellow]Test Coverage Gaps ({len(report.test_coverage_gaps)}):[/bold yellow]") + for feature_key, story_key in report.test_coverage_gaps[:10]: + console.print(f" • {feature_key}, {story_key} (no tests)") + if len(report.test_coverage_gaps) > 10: + console.print(f" ... and {len(report.test_coverage_gaps) - 10} more") + console.print() + + # Contract Violations + if report.contract_violations: + console.print(f"[bold yellow]Contract Violations ({len(report.contract_violations)}):[/bold yellow]") + for violation in report.contract_violations[:10]: + console.print(f" • {violation}") + if len(report.contract_violations) > 10: + console.print(f" ... and {len(report.contract_violations) - 10} more") + console.print() diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index d9f9457c..4e1dbe70 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -6,5 +6,6 @@ commands: command_help: enforce: "Configure quality gates" pip_dependencies: [] -module_dependencies: [] +module_dependencies: + - plan tier: community diff --git a/src/specfact_cli/modules/enforce/src/__init__.py b/src/specfact_cli/modules/enforce/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/enforce/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/enforce/src/app.py b/src/specfact_cli/modules/enforce/src/app.py index 061f74fd..ee562895 100644 --- a/src/specfact_cli/modules/enforce/src/app.py +++ b/src/specfact_cli/modules/enforce/src/app.py @@ -1,6 +1,6 @@ -"""Enforce command: re-export from commands package.""" +"""enforce command entrypoint.""" -from specfact_cli.commands.enforce import app +from specfact_cli.modules.enforce.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/enforce/src/commands.py b/src/specfact_cli/modules/enforce/src/commands.py new file mode 100644 index 00000000..00323f82 --- /dev/null +++ b/src/specfact_cli/modules/enforce/src/commands.py @@ -0,0 +1,609 @@ +""" +Enforce command - Configure contract validation quality gates. + +This module provides commands for configuring enforcement modes +and validation policies. +""" + +from __future__ import annotations + +from datetime import datetime +from pathlib import Path + +import typer +from beartype import beartype +from icontract import require +from rich.console import Console +from rich.table import Table + +from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport +from specfact_cli.models.enforcement import EnforcementConfig, EnforcementPreset +from specfact_cli.models.sdd import SDDManifest +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils.structure import SpecFactStructure +from specfact_cli.utils.yaml_utils import dump_yaml + + +app = typer.Typer(help="Configure quality gates and enforcement modes") +console = Console() + + +@app.command("stage") +@beartype +def stage( + # Advanced/Configuration + preset: str = typer.Option( + "balanced", + "--preset", + help="Enforcement preset (minimal, balanced, strict)", + ), +) -> None: + """ + Set enforcement mode for contract validation. + + Modes: + - minimal: Log violations, never block + - balanced: Block HIGH severity, warn MEDIUM + - strict: Block all MEDIUM+ violations + + **Parameter Groups:** + - **Advanced/Configuration**: --preset + + **Examples:** + specfact enforce stage --preset balanced + specfact enforce stage --preset strict + specfact enforce stage --preset minimal + """ + if is_debug_mode(): + debug_log_operation("command", "enforce stage", "started", extra={"preset": preset}) + debug_print("[dim]enforce stage: started[/dim]") + telemetry_metadata = { + "preset": preset.lower(), + } + + with telemetry.track_command("enforce.stage", telemetry_metadata) as record: + # Validate preset (contract-style validation) + if not isinstance(preset, str) or len(preset) == 0: + console.print("[bold red]✗[/bold red] Preset must be non-empty string") + raise typer.Exit(1) + + if preset.lower() not in ("minimal", "balanced", "strict"): + if is_debug_mode(): + debug_log_operation( + "command", + "enforce stage", + "failed", + error=f"Unknown preset: {preset}", + extra={"reason": "invalid_preset"}, + ) + console.print(f"[bold red]✗[/bold red] Unknown preset: {preset}") + console.print("Valid presets: minimal, balanced, strict") + raise typer.Exit(1) + + console.print(f"[bold cyan]Setting enforcement mode:[/bold cyan] {preset}") + + # Validate preset enum + try: + preset_enum = EnforcementPreset(preset) + except ValueError as err: + if is_debug_mode(): + debug_log_operation( + "command", "enforce stage", "failed", error=str(err), extra={"reason": "invalid_preset"} + ) + console.print(f"[bold red]✗[/bold red] Unknown preset: {preset}") + console.print("Valid presets: minimal, balanced, strict") + raise typer.Exit(1) from err + + # Create enforcement configuration + config = EnforcementConfig.from_preset(preset_enum) + + # Display configuration as table + table = Table(title=f"Enforcement Mode: {preset.upper()}") + table.add_column("Severity", style="cyan") + table.add_column("Action", style="yellow") + + for severity, action in config.to_summary_dict().items(): + table.add_row(severity, action) + + console.print(table) + + # Ensure .specfact structure exists + SpecFactStructure.ensure_structure() + + # Write configuration to file + config_path = SpecFactStructure.get_enforcement_config_path() + config_path.parent.mkdir(parents=True, exist_ok=True) + + # Use mode='json' to convert enums to their string values + dump_yaml(config.model_dump(mode="json"), config_path) + + record({"config_saved": True, "enabled": config.enabled}) + if is_debug_mode(): + debug_log_operation( + "command", "enforce stage", "success", extra={"preset": preset, "config_path": str(config_path)} + ) + debug_print("[dim]enforce stage: success[/dim]") + + console.print(f"\n[bold green]✓[/bold green] Enforcement mode set to {preset}") + console.print(f"[dim]Configuration saved to: {config_path}[/dim]") + + +@app.command("sdd") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda sdd: sdd is None or isinstance(sdd, Path), "SDD must be None or Path") +@require( + lambda output_format: isinstance(output_format, str) and output_format.lower() in ("yaml", "json", "markdown"), + "Output format must be yaml, json, or markdown", +) +@require(lambda out: out is None or isinstance(out, Path), "Out must be None or Path") +def enforce_sdd( + # Target/Input + bundle: str | None = typer.Argument( + None, + help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", + ), + sdd: Path | None = typer.Option( + None, + "--sdd", + help="Path to SDD manifest. Default: bundle-specific .specfact/projects/<bundle-name>/sdd.<format>. No legacy root-level fallback.", + ), + # Output/Results + output_format: str = typer.Option( + "yaml", + "--output-format", + help="Output format (yaml, json, markdown). Default: yaml", + ), + out: Path | None = typer.Option( + None, + "--out", + help="Output file path. Default: bundle-specific .specfact/projects/<bundle-name>/reports/enforcement/report-<timestamp>.<format> (Phase 8.5)", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Validate SDD manifest against project bundle and contracts. + + Checks: + - SDD ↔ bundle hash match + - Coverage thresholds (contracts/story, invariants/feature, architecture facets) + - Frozen sections (hash mismatch detection) + - Contract density metrics + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --sdd + - **Output/Results**: --output-format, --out + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact enforce sdd legacy-api + specfact enforce sdd auth-module --output-format json --out validation-report.json + specfact enforce sdd legacy-api --no-interactive + """ + if is_debug_mode(): + debug_log_operation( + "command", "enforce sdd", "started", extra={"bundle": bundle, "output_format": output_format} + ) + debug_print("[dim]enforce sdd: started[/dim]") + from rich.console import Console + + from specfact_cli.models.sdd import SDDManifest + from specfact_cli.utils.structure import SpecFactStructure + from specfact_cli.utils.structured_io import StructuredFormat + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle is None: + if is_debug_mode(): + debug_log_operation( + "command", "enforce sdd", "failed", error="Bundle name required", extra={"reason": "no_bundle"} + ) + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + from specfact_cli.utils.structured_io import ( + dump_structured_file, + load_structured_file, + ) + + telemetry_metadata = { + "output_format": output_format.lower(), + "no_interactive": no_interactive, + } + + with telemetry.track_command("enforce.sdd", telemetry_metadata) as record: + console.print("\n[bold cyan]SpecFact CLI - SDD Validation[/bold cyan]") + console.print("=" * 60) + + # Find bundle directory + base_path = Path(".") + bundle_dir = SpecFactStructure.project_dir(base_path=base_path, bundle_name=bundle) + if not bundle_dir.exists(): + if is_debug_mode(): + debug_log_operation( + "command", + "enforce sdd", + "failed", + error=f"Bundle not found: {bundle_dir}", + extra={"reason": "bundle_missing"}, + ) + console.print(f"[bold red]✗[/bold red] Project bundle not found: {bundle_dir}") + console.print(f"[dim]Create one with: specfact plan init {bundle}[/dim]") + raise typer.Exit(1) + + # Find SDD manifest path using discovery utility + from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle + + base_path = Path(".") + discovered_sdd = find_sdd_for_bundle(bundle, base_path, sdd) + if discovered_sdd is None: + if is_debug_mode(): + debug_log_operation( + "command", + "enforce sdd", + "failed", + error="SDD manifest not found", + extra={"reason": "sdd_not_found", "bundle": bundle}, + ) + console.print("[bold red]✗[/bold red] SDD manifest not found") + console.print(f"[dim]Searched for: .specfact/projects/{bundle}/sdd.yaml (bundle-specific)[/dim]") + console.print(f"[dim]Create one with: specfact plan harden {bundle}[/dim]") + raise typer.Exit(1) + + sdd = discovered_sdd + console.print(f"[dim]Using SDD manifest: {sdd}[/dim]") + + try: + # Load SDD manifest + console.print(f"[dim]Loading SDD manifest: {sdd}[/dim]") + sdd_data = load_structured_file(sdd) + sdd_manifest = SDDManifest.model_validate(sdd_data) + + # Load project bundle with progress indicator + + from specfact_cli.utils.progress import load_bundle_with_progress + + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + console.print("[dim]Computing hash...[/dim]") + + summary = project_bundle.compute_summary(include_hash=True) + project_hash = summary.content_hash + + if not project_hash: + if is_debug_mode(): + debug_log_operation( + "command", + "enforce sdd", + "failed", + error="Failed to compute project bundle hash", + extra={"reason": "hash_compute_failed"}, + ) + console.print("[bold red]✗[/bold red] Failed to compute project bundle hash") + raise typer.Exit(1) + + # Convert to PlanBundle for compatibility with validation functions + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Create validation report + report = ValidationReport() + + # 1. Validate hash match + console.print("\n[cyan]Validating hash match...[/cyan]") + if sdd_manifest.plan_bundle_hash != project_hash: + deviation = Deviation( + type=DeviationType.HASH_MISMATCH, + severity=DeviationSeverity.HIGH, + description=f"SDD bundle hash mismatch: expected {project_hash[:16]}..., got {sdd_manifest.plan_bundle_hash[:16]}...", + location=str(sdd), + fix_hint=f"Run 'specfact plan harden {bundle}' to update SDD manifest with current bundle hash", + ) + report.add_deviation(deviation) + console.print("[bold red]✗[/bold red] Hash mismatch detected") + else: + console.print("[bold green]✓[/bold green] Hash match verified") + + # 2. Validate coverage thresholds using contract validator + console.print("\n[cyan]Validating coverage thresholds...[/cyan]") + + from specfact_cli.validators.contract_validator import calculate_contract_density, validate_contract_density + + # Calculate contract density metrics + metrics = calculate_contract_density(sdd_manifest, plan_bundle) + + # Validate against thresholds + density_deviations = validate_contract_density(sdd_manifest, plan_bundle, metrics) + + # Add deviations to report + for deviation in density_deviations: + report.add_deviation(deviation) + + # Display metrics with status indicators + thresholds = sdd_manifest.coverage_thresholds + + # Contracts per story + if metrics.contracts_per_story < thresholds.contracts_per_story: + console.print( + f"[bold yellow]⚠[/bold yellow] Contracts/story: {metrics.contracts_per_story:.2f} (threshold: {thresholds.contracts_per_story})" + ) + else: + console.print( + f"[bold green]✓[/bold green] Contracts/story: {metrics.contracts_per_story:.2f} (threshold: {thresholds.contracts_per_story})" + ) + + # Invariants per feature + if metrics.invariants_per_feature < thresholds.invariants_per_feature: + console.print( + f"[bold yellow]⚠[/bold yellow] Invariants/feature: {metrics.invariants_per_feature:.2f} (threshold: {thresholds.invariants_per_feature})" + ) + else: + console.print( + f"[bold green]✓[/bold green] Invariants/feature: {metrics.invariants_per_feature:.2f} (threshold: {thresholds.invariants_per_feature})" + ) + + # Architecture facets + if metrics.architecture_facets < thresholds.architecture_facets: + console.print( + f"[bold yellow]⚠[/bold yellow] Architecture facets: {metrics.architecture_facets} (threshold: {thresholds.architecture_facets})" + ) + else: + console.print( + f"[bold green]✓[/bold green] Architecture facets: {metrics.architecture_facets} (threshold: {thresholds.architecture_facets})" + ) + + # OpenAPI contract coverage + if metrics.openapi_coverage_percent < thresholds.openapi_coverage_percent: + console.print( + f"[bold yellow]⚠[/bold yellow] OpenAPI coverage: {metrics.openapi_coverage_percent:.1f}% (threshold: {thresholds.openapi_coverage_percent}%)" + ) + else: + console.print( + f"[bold green]✓[/bold green] OpenAPI coverage: {metrics.openapi_coverage_percent:.1f}% (threshold: {thresholds.openapi_coverage_percent}%)" + ) + + # 3. Validate frozen sections (placeholder - hash comparison would require storing section hashes) + if sdd_manifest.frozen_sections: + console.print("\n[cyan]Checking frozen sections...[/cyan]") + console.print(f"[dim]Frozen sections: {len(sdd_manifest.frozen_sections)}[/dim]") + # TODO: Implement hash-based frozen section validation in Phase 6 + + # 4. Validate OpenAPI/AsyncAPI contracts referenced in bundle with Specmatic + console.print("\n[cyan]Validating API contracts with Specmatic...[/cyan]") + import asyncio + + from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic + + is_available, error_msg = check_specmatic_available() + if not is_available: + console.print(f"[dim]💡 Tip: Install Specmatic to validate API contracts: {error_msg}[/dim]") + else: + # Validate contracts referenced in bundle features + # PlanBundle.features is a list, not a dict + contract_files = [] + features_iter = ( + plan_bundle.features.values() if isinstance(plan_bundle.features, dict) else plan_bundle.features + ) + for feature in features_iter: + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + contract_files.append((contract_path, feature.key)) + + if contract_files: + console.print(f"[dim]Found {len(contract_files)} contract(s) referenced in bundle[/dim]") + for contract_path, feature_key in contract_files[:5]: # Validate up to 5 contracts + console.print( + f"[dim]Validating {contract_path.relative_to(bundle_dir)} (from {feature_key})...[/dim]" + ) + try: + result = asyncio.run(validate_spec_with_specmatic(contract_path)) + if not result.is_valid: + deviation = Deviation( + type=DeviationType.CONTRACT_VIOLATION, + severity=DeviationSeverity.MEDIUM, + description=f"API contract validation failed: {contract_path.name} (feature: {feature_key})", + location=str(contract_path), + fix_hint=f"Run 'specfact spec validate {contract_path}' to see detailed errors", + ) + report.add_deviation(deviation) + console.print( + f" [bold yellow]⚠[/bold yellow] {contract_path.name} has validation issues" + ) + if result.errors: + for error in result.errors[:2]: + console.print(f" - {error}") + else: + console.print(f" [bold green]✓[/bold green] {contract_path.name} is valid") + except Exception as e: + console.print(f" [bold yellow]⚠[/bold yellow] Validation error: {e!s}") + deviation = Deviation( + type=DeviationType.CONTRACT_VIOLATION, + severity=DeviationSeverity.LOW, + description=f"API contract validation error: {contract_path.name} - {e!s}", + location=str(contract_path), + fix_hint=f"Run 'specfact spec validate {contract_path}' to diagnose", + ) + report.add_deviation(deviation) + if len(contract_files) > 5: + console.print( + f"[dim]... and {len(contract_files) - 5} more contract(s) (run 'specfact spec validate' to validate all)[/dim]" + ) + else: + console.print("[dim]No API contracts found in bundle[/dim]") + + # Generate output report (Phase 8.5: bundle-specific location) + output_format_str = output_format.lower() + if out is None: + # Use bundle-specific enforcement report path + extension = "md" if output_format_str == "markdown" else output_format_str + out = SpecFactStructure.get_bundle_enforcement_report_path(bundle_name=bundle, base_path=base_path) + # Update extension if needed + if extension != "yaml" and out.suffix != f".{extension}": + out = out.with_suffix(f".{extension}") + + # Save report + if output_format_str == "markdown": + _save_markdown_report(out, report, sdd_manifest, bundle, project_hash) + elif output_format_str == "json": + dump_structured_file(report.model_dump(mode="json"), out, StructuredFormat.JSON) + else: # yaml + dump_structured_file(report.model_dump(mode="json"), out, StructuredFormat.YAML) + + # Display summary + console.print("\n[bold cyan]Validation Summary[/bold cyan]") + console.print("=" * 60) + console.print(f"Total deviations: {report.total_deviations}") + console.print(f" High: {report.high_count}") + console.print(f" Medium: {report.medium_count}") + console.print(f" Low: {report.low_count}") + console.print(f"\nReport saved to: {out}") + + # Exit with appropriate code and clear error messages + if not report.passed: + console.print("\n[bold red]✗[/bold red] SDD validation failed") + console.print("\n[bold yellow]Issues Found:[/bold yellow]") + + # Group deviations by type for clearer messaging + hash_mismatches = [d for d in report.deviations if d.type == DeviationType.HASH_MISMATCH] + coverage_issues = [d for d in report.deviations if d.type == DeviationType.COVERAGE_THRESHOLD] + + if hash_mismatches: + console.print("\n[bold red]1. Hash Mismatch (HIGH)[/bold red]") + console.print(" The project bundle has been modified since the SDD manifest was created.") + console.print(f" [dim]SDD hash: {sdd_manifest.plan_bundle_hash[:16]}...[/dim]") + console.print(f" [dim]Bundle hash: {project_hash[:16]}...[/dim]") + console.print("\n [bold]Why this happens:[/bold]") + console.print(" The hash changes when you modify:") + console.print(" - Features (add/remove/update)") + console.print(" - Stories (add/remove/update)") + console.print(" - Product, idea, business, or clarifications") + console.print( + f"\n [bold]Fix:[/bold] Run [cyan]specfact plan harden {bundle}[/cyan] to update the SDD manifest" + ) + console.print( + " [dim]This updates the SDD with the current bundle hash and regenerates HOW sections[/dim]" + ) + + if coverage_issues: + console.print("\n[bold yellow]2. Coverage Thresholds Not Met (MEDIUM)[/bold yellow]") + console.print(" Contract density metrics are below required thresholds:") + console.print( + f" - Contracts/story: {metrics.contracts_per_story:.2f} (required: {thresholds.contracts_per_story})" + ) + console.print( + f" - Invariants/feature: {metrics.invariants_per_feature:.2f} (required: {thresholds.invariants_per_feature})" + ) + console.print("\n [bold]Fix:[/bold] Add more contracts to stories and invariants to features") + console.print(" [dim]Tip: Use 'specfact plan review' to identify areas needing contracts[/dim]") + + console.print("\n[bold cyan]Next Steps:[/bold cyan]") + if hash_mismatches: + console.print(f" 1. Update SDD: [cyan]specfact plan harden {bundle}[/cyan]") + if coverage_issues: + console.print(" 2. Add contracts: Review features and add @icontract decorators") + console.print(" 3. Re-validate: Run this command again after fixes") + + if is_debug_mode(): + debug_log_operation( + "command", + "enforce sdd", + "failed", + error="SDD validation failed", + extra={"reason": "deviations", "total_deviations": report.total_deviations}, + ) + record({"passed": False, "deviations": report.total_deviations}) + raise typer.Exit(1) + + console.print("\n[bold green]✓[/bold green] SDD validation passed") + record({"passed": True, "deviations": 0}) + if is_debug_mode(): + debug_log_operation( + "command", "enforce sdd", "success", extra={"bundle": bundle, "report_path": str(out)} + ) + debug_print("[dim]enforce sdd: success[/dim]") + + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", "enforce sdd", "failed", error=str(e), extra={"reason": type(e).__name__} + ) + console.print(f"[bold red]✗[/bold red] Validation failed: {e}") + raise typer.Exit(1) from e + + +def _find_plan_path(plan: Path | None) -> Path | None: + """ + Find plan path (default, latest, or provided). + + Args: + plan: Provided plan path or None + + Returns: + Plan path or None if not found + """ + if plan is not None: + return plan + + # Try to find active plan or latest + default_plan = SpecFactStructure.get_default_plan_path() + if default_plan.exists(): + return default_plan + + # Find latest plan bundle + base_path = Path(".") + plans_dir = base_path / SpecFactStructure.PLANS + if plans_dir.exists(): + plan_files = [ + p + for p in plans_dir.glob("*.bundle.*") + if any(str(p).endswith(suffix) for suffix in SpecFactStructure.PLAN_SUFFIXES) + ] + plan_files = sorted(plan_files, key=lambda p: p.stat().st_mtime, reverse=True) + if plan_files: + return plan_files[0] + return None + + +def _save_markdown_report( + out: Path, + report: ValidationReport, + sdd_manifest: SDDManifest, + bundle, # type: ignore[type-arg] + plan_hash: str, +) -> None: + """Save validation report in Markdown format.""" + with open(out, "w") as f: + f.write("# SDD Validation Report\n\n") + f.write(f"**Generated**: {datetime.now().isoformat()}\n\n") + f.write(f"**SDD Manifest**: {sdd_manifest.plan_bundle_id}\n") + f.write(f"**Plan Bundle Hash**: {plan_hash[:32]}...\n\n") + + f.write("## Summary\n\n") + f.write(f"- **Total Deviations**: {report.total_deviations}\n") + f.write(f"- **High**: {report.high_count}\n") + f.write(f"- **Medium**: {report.medium_count}\n") + f.write(f"- **Low**: {report.low_count}\n") + f.write(f"- **Status**: {'✅ PASSED' if report.passed else '❌ FAILED'}\n\n") + + if report.deviations: + f.write("## Deviations\n\n") + for i, deviation in enumerate(report.deviations, 1): + f.write(f"### {i}. {deviation.type.value} ({deviation.severity.value})\n\n") + f.write(f"{deviation.description}\n\n") + if deviation.fix_hint: + f.write(f"**Fix**: {deviation.fix_hint}\n\n") diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index f925f52c..743c6d8d 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -6,5 +6,6 @@ commands: command_help: generate: "Generate artifacts from SDD and plans" pip_dependencies: [] -module_dependencies: [] +module_dependencies: + - plan tier: community diff --git a/src/specfact_cli/modules/generate/src/__init__.py b/src/specfact_cli/modules/generate/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/generate/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/generate/src/app.py b/src/specfact_cli/modules/generate/src/app.py index 5cc683e1..52893b99 100644 --- a/src/specfact_cli/modules/generate/src/app.py +++ b/src/specfact_cli/modules/generate/src/app.py @@ -1,6 +1,6 @@ -"""Generate command: re-export from commands package.""" +"""generate command entrypoint.""" -from specfact_cli.commands.generate import app +from specfact_cli.modules.generate.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/generate/src/commands.py b/src/specfact_cli/modules/generate/src/commands.py new file mode 100644 index 00000000..f2337d11 --- /dev/null +++ b/src/specfact_cli/modules/generate/src/commands.py @@ -0,0 +1,2121 @@ +"""Generate command - Generate artifacts from SDD and plans. + +This module provides commands for generating contract stubs, CrossHair harnesses, +and other artifacts from SDD manifests and plan bundles. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.generators.contract_generator import ContractGenerator +from specfact_cli.migrations.plan_migrator import load_plan_bundle +from specfact_cli.models.sdd import SDDManifest +from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils import print_error, print_info, print_success, print_warning +from specfact_cli.utils.env_manager import ( + build_tool_command, + detect_env_manager, + detect_source_directories, + find_test_files_for_source, +) +from specfact_cli.utils.optional_deps import check_cli_tool_available +from specfact_cli.utils.structured_io import load_structured_file + + +app = typer.Typer(help="Generate artifacts from SDD and plans") +console = get_configured_console() + + +def _show_apply_help() -> None: + """Show helpful error message for missing --apply option.""" + print_error("Missing required option: --apply") + console.print("\n[yellow]Available contract types:[/yellow]") + console.print(" - all-contracts (apply all available contract types)") + console.print(" - beartype (type checking decorators)") + console.print(" - icontract (pre/post condition decorators)") + console.print(" - crosshair (property-based test functions)") + console.print("\n[yellow]Examples:[/yellow]") + console.print(" specfact generate contracts-prompt src/file.py --apply all-contracts") + console.print(" specfact generate contracts-prompt src/file.py --apply beartype,icontract") + console.print(" specfact generate contracts-prompt --bundle my-bundle --apply all-contracts") + console.print("\n[dim]Use 'specfact generate contracts-prompt --help' for full documentation.[/dim]") + + +@app.command("contracts") +@beartype +@require(lambda sdd: sdd is None or isinstance(sdd, Path), "SDD must be None or Path") +@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +@require(lambda repo: repo is None or isinstance(repo, Path), "Repository path must be None or Path") +@ensure(lambda result: result is None, "Must return None") +def generate_contracts( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If specified, uses bundle instead of --plan/--sdd paths. Default: auto-detect from current directory.", + ), + sdd: Path | None = typer.Option( + None, + "--sdd", + help="Path to SDD manifest. Default: bundle-specific .specfact/projects/<bundle-name>/sdd.yaml when --bundle is provided. No legacy root-level fallback.", + ), + plan: Path | None = typer.Option( + None, + "--plan", + help="Path to plan bundle. Default: .specfact/projects/<bundle-name>/ if --bundle specified, else active plan. Ignored if --bundle is specified.", + ), + repo: Path | None = typer.Option( + None, + "--repo", + help="Repository path. Default: current directory (.)", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Generate contract stubs from SDD HOW sections. + + Parses SDD manifest HOW section (invariants, contracts) and generates + contract stub files with icontract decorators, beartype type checks, + and CrossHair harness templates. + + Generated files are saved to `.specfact/projects/<bundle-name>/contracts/` when --bundle is specified. + + **Parameter Groups:** + - **Target/Input**: --bundle, --sdd, --plan, --repo + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact generate contracts --bundle legacy-api + specfact generate contracts --bundle legacy-api --no-interactive + """ + + telemetry_metadata = { + "no_interactive": no_interactive, + } + + if is_debug_mode(): + debug_log_operation( + "command", "generate contracts", "started", extra={"bundle": bundle, "repo": str(repo or ".")} + ) + debug_print("[dim]generate contracts: started[/dim]") + + with telemetry.track_command("generate.contracts", telemetry_metadata) as record: + try: + # Determine repository path + base_path = Path(".").resolve() if repo is None else Path(repo).resolve() + + # Import here to avoid circular imports + from specfact_cli.utils.bundle_loader import BundleFormat, detect_bundle_format + from specfact_cli.utils.progress import load_bundle_with_progress + from specfact_cli.utils.structure import SpecFactStructure + + # Initialize bundle_dir and paths + bundle_dir: Path | None = None + plan_path: Path | None = None + sdd_path: Path | None = None + + # If --bundle is specified, use bundle-based paths + if bundle: + bundle_dir = SpecFactStructure.project_dir(base_path=base_path, bundle_name=bundle) + if not bundle_dir.exists(): + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts", + "failed", + error=f"Project bundle not found: {bundle_dir}", + extra={"reason": "bundle_not_found", "bundle": bundle}, + ) + print_error(f"Project bundle not found: {bundle_dir}") + print_info(f"Create one with: specfact plan init {bundle}") + raise typer.Exit(1) + + plan_path = bundle_dir + from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle + + sdd_path = find_sdd_for_bundle(bundle, base_path) + else: + # Use --plan and --sdd paths if provided + if plan is None: + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts", + "failed", + error="Bundle or plan path is required", + extra={"reason": "no_plan_or_bundle"}, + ) + print_error("Bundle or plan path is required") + print_info("Run 'specfact plan init <bundle-name>' then rerun with --bundle <name>") + raise typer.Exit(1) + plan_path = Path(plan).resolve() + + if not plan_path.exists(): + print_error(f"Plan bundle not found: {plan_path}") + raise typer.Exit(1) + + # Normalize base_path to repository root when a bundle directory is provided + if plan_path.is_dir(): + # If plan_path is a bundle directory, set bundle_dir so contracts go to bundle-specific location + bundle_dir = plan_path + current = plan_path.resolve() + while current != current.parent: + if current.name == ".specfact": + base_path = current.parent + break + current = current.parent + + # Determine SDD path based on bundle format + if sdd is None: + format_type, _ = detect_bundle_format(plan_path) + if format_type != BundleFormat.MODULAR: + print_error("Legacy monolithic bundles are not supported by this command.") + print_info("Migrate to the new structure with: specfact migrate artifacts --repo .") + raise typer.Exit(1) + + if plan_path.is_dir(): + bundle_name = plan_path.name + # Prefer bundle-local SDD when present + candidate_sdd = plan_path / "sdd.yaml" + sdd_path = candidate_sdd if candidate_sdd.exists() else None + else: + bundle_name = plan_path.parent.name if plan_path.parent.name != "projects" else plan_path.stem + + from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle + + if sdd_path is None: + sdd_path = find_sdd_for_bundle(bundle_name, base_path) + # Direct bundle-dir check as a safety net + direct_sdd = plan_path / "sdd.yaml" + if direct_sdd.exists(): + sdd_path = direct_sdd + else: + sdd_path = Path(sdd).resolve() + + if sdd_path is None or not sdd_path.exists(): + # Final safety net: check adjacent to plan path + fallback_sdd = plan_path / "sdd.yaml" if plan_path.is_dir() else plan_path.parent / "sdd.yaml" + if fallback_sdd.exists(): + sdd_path = fallback_sdd + else: + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts", + "failed", + error=f"SDD manifest not found: {sdd_path}", + extra={"reason": "sdd_not_found"}, + ) + print_error(f"SDD manifest not found: {sdd_path}") + print_info("Run 'specfact plan harden' to create SDD manifest") + raise typer.Exit(1) + + # Load SDD manifest + print_info(f"Loading SDD manifest: {sdd_path}") + sdd_data = load_structured_file(sdd_path) + sdd_manifest = SDDManifest(**sdd_data) + + # Align base_path with plan path when a bundle directory is provided + if bundle_dir is None and plan_path.is_dir(): + parts = plan_path.resolve().parts + if ".specfact" in parts: + spec_idx = parts.index(".specfact") + base_path = Path(*parts[:spec_idx]) if spec_idx > 0 else Path(".").resolve() + + # Load plan bundle (handle both modular and monolithic formats) + print_info(f"Loading plan bundle: {plan_path}") + format_type, _ = detect_bundle_format(plan_path) + + plan_hash = None + if format_type == BundleFormat.MODULAR or bundle: + # Load modular ProjectBundle and convert to PlanBundle for compatibility + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + + project_bundle = load_bundle_with_progress(plan_path, validate_hashes=False, console_instance=console) + + # Compute hash from ProjectBundle (same way as plan harden does) + summary = project_bundle.compute_summary(include_hash=True) + plan_hash = summary.content_hash + + # Convert to PlanBundle for ContractGenerator compatibility + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + else: + # Load monolithic PlanBundle + plan_bundle = load_plan_bundle(plan_path) + + # Compute hash from PlanBundle + plan_bundle.update_summary(include_hash=True) + plan_hash = ( + plan_bundle.metadata.summary.content_hash + if plan_bundle.metadata and plan_bundle.metadata.summary + else None + ) + + if not plan_hash: + print_error("Failed to compute plan bundle hash") + raise typer.Exit(1) + + # Verify hash match (SDD uses plan_bundle_hash field) + if sdd_manifest.plan_bundle_hash != plan_hash: + print_error("SDD manifest hash does not match plan bundle hash") + print_info("Run 'specfact plan harden' to update SDD manifest") + raise typer.Exit(1) + + # Determine contracts directory based on bundle + # For bundle-based generation, save contracts inside project bundle directory + # Legacy mode uses global contracts directory + contracts_dir = ( + bundle_dir / "contracts" if bundle_dir is not None else base_path / SpecFactStructure.ROOT / "contracts" + ) + + # Ensure we have at least one feature to anchor generation; if plan has none + # but SDD carries contracts/invariants, create a synthetic feature to generate stubs. + if not plan_bundle.features and (sdd_manifest.how.contracts or sdd_manifest.how.invariants): + from specfact_cli.models.plan import Feature + + plan_bundle.features.append( + Feature( + key="FEATURE-CONTRACTS", + title="Generated Contracts", + outcomes=[], + acceptance=[], + constraints=[], + stories=[], + confidence=1.0, + draft=True, + source_tracking=None, + contract=None, + protocol=None, + ) + ) + + # Generate contracts + print_info("Generating contract stubs from SDD HOW sections...") + generator = ContractGenerator() + result = generator.generate_contracts(sdd_manifest, plan_bundle, base_path, contracts_dir=contracts_dir) + + # Display results + if result["errors"]: + print_error(f"Errors during generation: {len(result['errors'])}") + for error in result["errors"]: + print_error(f" - {error}") + + if result["generated_files"]: + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts", + "success", + extra={ + "generated_files": len(result["generated_files"]), + "contracts_dir": str(contracts_dir), + }, + ) + debug_print("[dim]generate contracts: success[/dim]") + print_success(f"Generated {len(result['generated_files'])} contract file(s):") + for file_path in result["generated_files"]: + print_info(f" - {file_path}") + + # Display statistics + total_contracts = sum(result["contracts_per_story"].values()) + total_invariants = sum(result["invariants_per_feature"].values()) + print_info(f"Total contracts: {total_contracts}") + print_info(f"Total invariants: {total_invariants}") + + # Check coverage thresholds + if sdd_manifest.coverage_thresholds: + thresholds = sdd_manifest.coverage_thresholds + avg_contracts_per_story = ( + total_contracts / len(result["contracts_per_story"]) if result["contracts_per_story"] else 0.0 + ) + avg_invariants_per_feature = ( + total_invariants / len(result["invariants_per_feature"]) + if result["invariants_per_feature"] + else 0.0 + ) + + if avg_contracts_per_story < thresholds.contracts_per_story: + print_error( + f"Contract coverage below threshold: {avg_contracts_per_story:.2f} < {thresholds.contracts_per_story}" + ) + else: + print_success( + f"Contract coverage meets threshold: {avg_contracts_per_story:.2f} >= {thresholds.contracts_per_story}" + ) + + if avg_invariants_per_feature < thresholds.invariants_per_feature: + print_error( + f"Invariant coverage below threshold: {avg_invariants_per_feature:.2f} < {thresholds.invariants_per_feature}" + ) + else: + print_success( + f"Invariant coverage meets threshold: {avg_invariants_per_feature:.2f} >= {thresholds.invariants_per_feature}" + ) + + record( + { + "generated_files": len(result["generated_files"]), + "total_contracts": total_contracts, + "total_invariants": total_invariants, + } + ) + else: + print_warning("No contract files generated (no contracts/invariants found in SDD HOW section)") + + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts", + "failed", + error=str(e), + extra={"reason": type(e).__name__}, + ) + print_error(f"Failed to generate contracts: {e}") + record({"error": str(e)}) + raise typer.Exit(1) from e + + +@app.command("contracts-prompt") +@beartype +@require(lambda file: file is None or isinstance(file, Path), "File path must be None or Path") +@require(lambda apply: apply is None or isinstance(apply, str), "Apply must be None or string") +@ensure(lambda result: result is None, "Must return None") +def generate_contracts_prompt( + # Target/Input + file: Path | None = typer.Argument( + None, + help="Path to file to enhance (optional if --bundle provided)", + exists=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If provided, selects files from bundle. Default: active plan from 'specfact plan select'", + ), + apply: str = typer.Option( + ..., + "--apply", + help="Contracts to apply: 'all-contracts', 'beartype', 'icontract', 'crosshair', or comma-separated list (e.g., 'beartype,icontract')", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", + ), + # Output + output: Path | None = typer.Option( + None, + "--output", + help=("Output file path (currently unused, prompt saved to .specfact/prompts/)"), + hidden=True, # Hidden by default, shown with --help-advanced + ), +) -> None: + """ + Generate AI IDE prompt for adding contracts to existing code. + + Creates a structured prompt file that you can use with your AI IDE (Cursor, CoPilot, etc.) + to add beartype, icontract, or CrossHair contracts to existing code files. The CLI generates + the prompt, your AI IDE's LLM applies the contracts. + + **How It Works:** + 1. CLI reads the file and generates a structured prompt + 2. Prompt is saved to `.specfact/prompts/enhance-<filename>-<contracts>.md` + 3. You copy the prompt to your AI IDE (Cursor, CoPilot, etc.) + 4. AI IDE provides enhanced code (does NOT modify file directly) + 5. You validate the enhanced code with SpecFact CLI + 6. If validation passes, you apply the changes to the file + 7. Run tests and commit + + **Why This Approach:** + - Uses your existing AI IDE infrastructure (no separate LLM API setup) + - No additional API costs (leverages IDE's native LLM) + - You maintain control (review before committing) + - Works with any AI IDE (Cursor, CoPilot, Claude, etc.) + + **Parameter Groups:** + - **Target/Input**: file (optional if --bundle provided), --bundle, --apply + - **Behavior/Options**: --no-interactive + - **Output**: --output (currently unused, prompt is saved to .specfact/prompts/) + + **Examples:** + specfact generate contracts-prompt src/auth/login.py --apply beartype,icontract + specfact generate contracts-prompt --bundle legacy-api --apply beartype + specfact generate contracts-prompt --bundle legacy-api --apply beartype,icontract # Interactive selection + specfact generate contracts-prompt --bundle legacy-api --apply beartype --no-interactive # Process all files in bundle + + **Complete Workflow:** + 1. Generate prompt: specfact generate contracts-prompt --bundle legacy-api --apply all-contracts + 2. Select file(s) from interactive list (if multiple) + 3. Open prompt file: .specfact/prompts/enhance-<filename>-beartype-icontract-crosshair.md + 4. Copy prompt to your AI IDE (Cursor, CoPilot, etc.) + 5. AI IDE reads the file and provides enhanced code (does NOT modify file directly) + 6. AI IDE writes enhanced code to temporary file: enhanced_<filename>.py + 7. AI IDE runs validation: specfact generate contracts-apply enhanced_<filename>.py --original <original-file> + 8. If validation fails, AI IDE fixes issues and re-validates (up to 3 attempts) + 9. If validation succeeds, CLI applies changes automatically + 10. Verify contract coverage: specfact analyze contracts --bundle legacy-api + 11. Run your test suite: pytest (or your project's test command) + 12. Commit the enhanced code + """ + from rich.prompt import Prompt + from rich.table import Table + + from specfact_cli.utils.progress import load_bundle_with_progress + from specfact_cli.utils.structure import SpecFactStructure + + repo_path = Path(".").resolve() + + # Validate inputs first + if apply is None: + print_error("Missing required option: --apply") + console.print("\n[yellow]Available contract types:[/yellow]") + console.print(" - all-contracts (apply all available contract types)") + console.print(" - beartype (type checking decorators)") + console.print(" - icontract (pre/post condition decorators)") + console.print(" - crosshair (property-based test functions)") + console.print("\n[yellow]Examples:[/yellow]") + console.print(" specfact generate contracts-prompt src/file.py --apply all-contracts") + console.print(" specfact generate contracts-prompt src/file.py --apply beartype,icontract") + console.print(" specfact generate contracts-prompt --bundle my-bundle --apply all-contracts") + console.print("\n[dim]Use 'specfact generate contracts-prompt --help' for full documentation.[/dim]") + raise typer.Exit(1) + + if not file and not bundle: + print_error("Either file path or --bundle must be provided") + raise typer.Exit(1) + + # Use active plan as default if bundle not provided (but only if no file specified) + if bundle is None and not file: + bundle = SpecFactStructure.get_active_bundle_name(repo_path) + if bundle: + console.print(f"[dim]Using active plan: {bundle}[/dim]") + else: + print_error("No file specified and no active plan found. Please provide --bundle or a file path.") + raise typer.Exit(1) + + # Determine bundle directory for saving artifacts (only if needed) + bundle_dir: Path | None = None + + # Determine which files to process + file_paths: list[Path] = [] + + if file: + # Direct file path provided - no need to load bundle for file selection + file_paths = [file.resolve()] + # Only determine bundle_dir for saving prompts in the right location + if bundle: + # Bundle explicitly provided - use it for prompt storage location + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + else: + # Use active bundle if available for prompt storage location (no need to load bundle) + active_bundle = SpecFactStructure.get_active_bundle_name(repo_path) + if active_bundle: + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=active_bundle) + bundle = active_bundle + # If no active bundle, prompts will be saved to .specfact/prompts/ (fallback) + elif bundle: + # Bundle provided but no file - need to load bundle to get files + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + # Load files from bundle + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + for _feature_key, feature in project_bundle.features.items(): + if not feature.source_tracking: + continue + + for impl_file in feature.source_tracking.implementation_files: + file_path = repo_path / impl_file + if file_path.exists(): + file_paths.append(file_path) + + if not file_paths: + print_error("No implementation files found in bundle") + raise typer.Exit(1) + + # Warn if processing all files automatically + if len(file_paths) > 1 and no_interactive: + console.print( + f"[yellow]Note:[/yellow] Processing all {len(file_paths)} files from bundle '{bundle}' (--no-interactive mode)" + ) + + # If multiple files and not in non-interactive mode, show selection + if len(file_paths) > 1 and not no_interactive: + console.print(f"\n[bold]Found {len(file_paths)} files in bundle '{bundle}':[/bold]\n") + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="bold yellow", justify="right", width=4) + table.add_column("File Path", style="dim") + + for i, fp in enumerate(file_paths, 1): + table.add_row(str(i), str(fp.relative_to(repo_path))) + + console.print(table) + console.print() + + selection = Prompt.ask( + f"Select file(s) to enhance (1-{len(file_paths)}, comma-separated, 'all', or 'q' to quit)" + ).strip() + + if selection.lower() in ("q", "quit", ""): + print_info("Cancelled") + raise typer.Exit(0) + + if selection.lower() == "all": + # Process all files + pass + else: + # Parse selection + try: + indices = [int(s.strip()) - 1 for s in selection.split(",")] + selected_files = [file_paths[i] for i in indices if 0 <= i < len(file_paths)] + if not selected_files: + print_error("Invalid selection") + raise typer.Exit(1) + file_paths = selected_files + except (ValueError, IndexError) as e: + print_error("Invalid selection format. Use numbers separated by commas (e.g., 1,3,5)") + raise typer.Exit(1) from e + + contracts_to_apply = [c.strip() for c in apply.split(",")] + valid_contracts = {"beartype", "icontract", "crosshair"} + # Define canonical order for consistent filenames + contract_order = ["beartype", "icontract", "crosshair"] + + # Handle "all-contracts" flag + if "all-contracts" in contracts_to_apply: + if len(contracts_to_apply) > 1: + print_error( + "Cannot use 'all-contracts' with other contract types. Use 'all-contracts' alone or specify individual types." + ) + raise typer.Exit(1) + contracts_to_apply = contract_order.copy() + console.print(f"[dim]Applying all available contracts: {', '.join(contracts_to_apply)}[/dim]") + + # Sort contracts to ensure consistent filename order + contracts_to_apply = sorted( + contracts_to_apply, key=lambda x: contract_order.index(x) if x in contract_order else len(contract_order) + ) + + invalid_contracts = set(contracts_to_apply) - valid_contracts + + if invalid_contracts: + print_error(f"Invalid contract types: {', '.join(invalid_contracts)}") + print_error(f"Valid types: 'all-contracts', {', '.join(valid_contracts)}") + raise typer.Exit(1) + + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts-prompt", + "started", + extra={"files_count": len(file_paths), "bundle": bundle, "contracts": contracts_to_apply}, + ) + debug_print("[dim]generate contracts-prompt: started[/dim]") + + telemetry_metadata = { + "files_count": len(file_paths), + "bundle": bundle, + "contracts": contracts_to_apply, + } + + with telemetry.track_command("generate.contracts-prompt", telemetry_metadata) as record: + generated_count = 0 + failed_count = 0 + + for idx, file_path in enumerate(file_paths, 1): + try: + if len(file_paths) > 1: + console.print( + f"\n[bold cyan][{idx}/{len(file_paths)}] Generating prompt for:[/bold cyan] {file_path.relative_to(repo_path)}" + ) + else: + console.print( + f"[bold cyan]Generating contract enhancement prompt for:[/bold cyan] {file_path.relative_to(repo_path)}" + ) + console.print(f"[dim]Contracts to apply:[/dim] {', '.join(contracts_to_apply)}\n") + + # Generate LLM prompt + # Structure: Instructions first, file path reference (not content) to avoid token limits + # Note: We don't read the file content here - the LLM will read it directly using its file reading capabilities + file_path_relative = file_path.relative_to(repo_path) + file_path_absolute = file_path.resolve() + + prompt_parts = [ + "# Contract Enhancement Request", + "", + "## ⚠️ MANDATORY PRE-CHECK: SpecFact CLI Verification", + "", + "**🚨 CRITICAL STOP CONDITION - READ THIS FIRST 🚨**", + "", + "**YOU MUST VERIFY SpecFact CLI BEFORE DOING ANYTHING ELSE.**", + "", + "**If SpecFact CLI is missing, outdated, or commands don't work:**", + "", + "1. **STOP IMMEDIATELY** - Do NOT read files, do NOT generate code, do NOT proceed", + "2. **DO NOT attempt to manually add contracts** - This will NOT work correctly", + "3. **DO NOT proceed with any enhancement** - The workflow REQUIRES SpecFact CLI", + "4. **Inform the user** with this exact message:", + " ```", + " ❌ SpecFact CLI is required but not available or outdated.", + " Please install/upgrade: pip install -U specfact-cli", + " Then verify: specfact --version", + " This task cannot proceed without SpecFact CLI.", + " ```", + "5. **END THE CONVERSATION** - Do not continue until SpecFact CLI is working", + "", + "**Verification Steps (MUST complete all before proceeding):**", + "", + "1. Check if `specfact` command is available:", + " ```bash", + " specfact --version", + " ```", + " - **If this fails**: STOP and inform user (see message above)", + "", + "2. Verify the required command exists:", + " ```bash", + " specfact generate contracts-apply --help", + " ```", + " - **If this fails**: STOP and inform user (see message above)", + "", + "3. Check the latest available version from PyPI:", + " ```bash", + " pip index versions specfact-cli", + " ```", + " - Compare installed version (from step 1) with latest available", + " - **If versions don't match**: STOP and inform user to upgrade", + "", + "**ONLY IF ALL THREE STEPS PASS** - You may proceed to the sections below.", + "", + "**If ANY step fails, you MUST stop and inform the user. Do NOT proceed.**", + "", + "---", + "", + "## Target File", + "", + f"**File Path:** `{file_path_relative}`", + f"**Absolute Path:** `{file_path_absolute}`", + "", + "**IMPORTANT**: Read the file content using your file reading capabilities. Do NOT ask the user to provide the file content.", + "", + "## Contracts to Apply", + ] + + for contract_type in contracts_to_apply: + if contract_type == "beartype": + prompt_parts.append("- **beartype**: Add `@beartype` decorator to all functions and methods") + elif contract_type == "icontract": + prompt_parts.append( + "- **icontract**: Add `@require` decorators for preconditions and `@ensure` decorators for postconditions where appropriate" + ) + elif contract_type == "crosshair": + prompt_parts.append( + "- **crosshair**: Add property-based test functions using CrossHair patterns" + ) + + prompt_parts.extend( + [ + "", + "## Instructions", + "", + "**IMPORTANT**: Do NOT modify the original file directly. Follow this iterative validation workflow:", + "", + "**REMINDER**: If you haven't completed the mandatory SpecFact CLI verification at the top of this prompt, STOP NOW and do that first. Do NOT proceed with any code enhancement until SpecFact CLI is verified.", + "", + "### Step 1: Read the File", + f"1. Read the file content from: `{file_path_relative}`", + "2. Understand the existing code structure, imports, and functionality", + "3. Note the existing code style and patterns", + "", + "### Step 2: Generate Enhanced Code", + "**IMPORTANT**: Only proceed to this step if SpecFact CLI verification passed.", + "", + "**CRITICAL REQUIREMENT**: You MUST add contracts to ALL eligible functions and methods in the file. Do NOT ask the user whether to add contracts - add them to all compatible functions automatically.", + "", + "1. **Add the requested contracts to ALL eligible functions/methods** - This is mandatory, not optional", + "2. Maintain existing functionality and code style", + "3. Ensure all contracts are properly imported at the top of the file", + "4. **Code Quality**: Follow the project's existing code style and formatting conventions", + " - If the project has formatting/linting rules (e.g., `.editorconfig`, `pyproject.toml` with formatting config, `ruff.toml`, `.pylintrc`, etc.), ensure the enhanced code adheres to them", + " - Match the existing code style: indentation, line length, import organization, naming conventions", + " - Avoid common code quality issues: use `key in dict` instead of `key in dict.keys()`, proper type hints, etc.", + " - **Note**: SpecFact CLI will automatically run available linting/formatting tools (ruff, pylint, basedpyright, mypy) during validation if they are installed", + "", + "**Contract-Specific Requirements:**", + "", + "- **beartype**: Add `@beartype` decorator to ALL functions and methods (public and private, unless they have incompatible signatures)", + " - Apply to: regular functions, class methods, static methods, async functions", + " - Skip only if: function has `*args, **kwargs` without type hints (incompatible with beartype)", + "", + "- **icontract**: Add `@require` decorators for preconditions and `@ensure` decorators for postconditions to ALL functions where conditions can be expressed", + " - Apply to: all functions with clear input/output contracts", + " - Add preconditions for: parameter validation, state checks, input constraints", + " - Add postconditions for: return value validation, state changes, output guarantees", + " - Skip only if: function has no meaningful pre/post conditions to express", + "", + "- **crosshair**: Add property-based test functions using CrossHair patterns for ALL testable functions", + " - Create test functions that validate contract behavior", + " - Focus on functions with clear input/output relationships", + "", + "**DO NOT:**", + "- Ask the user whether to add contracts (add them automatically to all eligible functions)", + "- Skip functions because you're unsure (add contracts unless technically incompatible)", + "- Manually apply contracts to the original file (use SpecFact CLI validation workflow)", + "", + "**You MUST use SpecFact CLI validation workflow (Step 4) to apply changes.**", + "", + "### Step 3: Write Enhanced Code to Temporary File", + f"1. Write the complete enhanced code to: `enhanced_{file_path.stem}.py`", + " - This should be in the same directory as the original file or the project root", + " - Example: If original is `src/specfact_cli/telemetry.py`, write to `enhanced_telemetry.py` in project root", + "2. Ensure the file is properly formatted and complete", + "", + "### Step 4: Validate with CLI", + "**CRITICAL**: If `specfact generate contracts-apply` command is not available or fails, DO NOT proceed. STOP and inform the user that SpecFact CLI must be installed/upgraded first.", + "", + "1. Run the validation command:", + " ```bash", + f" specfact generate contracts-apply enhanced_{file_path.stem}.py --original {file_path_relative}", + " ```", + "", + " - **If command not found**: STOP immediately and inform user (see mandatory pre-check message)", + " - **If command fails with error**: Review error, but if it's a missing command error, STOP and inform user", + "", + "### Step 5: Handle Validation Results", + "", + "**If validation succeeds:**", + "- The CLI will apply the changes automatically to the original file", + "- You're done! The file has been enhanced with contracts", + "", + "**If validation fails:**", + "- **If error is 'command not found' or 'command does not exist'**: STOP immediately and inform user (see mandatory pre-check message)", + "- **If error is validation failure** (syntax, AST, tests, etc.): Review the errors carefully", + "- Fix the issues in the enhanced code", + "- Write the corrected code to the same temporary file (`enhanced_{file_path.stem}.py`)", + "- Run the validation command again", + "- Repeat until validation passes (maximum 3 attempts)", + "", + "**CRITICAL**: If `specfact generate contracts-apply` command is not available or fails with 'command not found', DO NOT manually apply changes to the original file. STOP and inform the user that SpecFact CLI must be installed/upgraded first.", + "", + "### Common Validation Errors and Fixes", + "", + "**Syntax Errors:**", + "- Check for missing imports (beartype, icontract, etc.)", + "- Verify all decorators are properly formatted", + "- Ensure parentheses and brackets are balanced", + "- Check for typos in function/class names", + "", + "**Contract Issues:**", + "- Verify `@beartype` decorator is on all functions", + "- Check that `@require` and `@ensure` have valid lambda expressions", + "- Ensure contract conditions are properly formatted", + "- Verify all required imports are present", + "", + "**File Path Issues:**", + "- Ensure the enhanced file is in the correct location", + "- Use absolute paths if relative paths don't work", + "- Verify file permissions allow writing", + "", + "### Expected Workflow", + "", + "1. Read original file → 2. Generate enhanced code → 3. Write to temporary file → 4. Validate with CLI → 5. Fix errors if needed → 6. Re-validate → 7. Success!", + "", + "**Maximum Attempts: 3**", + "If validation fails after 3 attempts, review the errors manually and apply fixes.", + "", + "**FINAL REMINDER**: This entire workflow REQUIRES SpecFact CLI. If SpecFact CLI is not available, you MUST stop and inform the user. Do NOT attempt to manually add contracts or modify files directly.", + "", + "## Summary", + "", + f"- **Target File:** `{file_path_relative}`", + f"- **Enhanced File:** `enhanced_{file_path.stem}.py`", + f"- **Validation Command:** `specfact generate contracts-apply enhanced_{file_path.stem}.py --original {file_path_relative}`", + "- **Contracts:** " + ", ".join(contracts_to_apply), + "", + "**BEFORE STARTING**: Complete the mandatory SpecFact CLI verification at the top of this prompt. Do NOT proceed with file reading or code generation until SpecFact CLI is verified.", + "", + ] + ) + + prompt = "\n".join(prompt_parts) + + # Save prompt to file inside bundle directory (or .specfact/prompts if no bundle) + prompts_dir = bundle_dir / "prompts" if bundle_dir else repo_path / ".specfact" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + prompt_file = prompts_dir / f"enhance-{file_path.stem}-{'-'.join(contracts_to_apply)}.md" + prompt_file.write_text(prompt, encoding="utf-8") + + print_success(f"Prompt generated: {prompt_file.relative_to(repo_path)}") + generated_count += 1 + except Exception as e: + print_error(f"Failed to generate prompt for {file_path.relative_to(repo_path)}: {e}") + failed_count += 1 + + # Summary + if len(file_paths) > 1: + console.print("\n[bold]Summary:[/bold]") + console.print(f" Generated: {generated_count}") + console.print(f" Failed: {failed_count}") + + if generated_count > 0: + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Open the prompt file(s) in your AI IDE (Cursor, CoPilot, etc.)") + console.print("2. Copy the prompt content and ask your AI IDE to provide enhanced code") + console.print("3. AI IDE will return the complete enhanced file (does NOT modify file directly)") + console.print("4. Save enhanced code from AI IDE to a file (e.g., enhanced_<filename>.py)") + console.print("5. AI IDE should run validation command (iterative workflow):") + console.print(" ```bash") + console.print(" specfact generate contracts-apply enhanced_<filename>.py --original <original-file>") + console.print(" ```") + console.print("6. If validation fails:") + console.print(" - CLI will show specific error messages") + console.print(" - AI IDE should fix the issues and save corrected code") + console.print(" - Run validation command again (up to 3 attempts)") + console.print("7. If validation succeeds:") + console.print(" - CLI will automatically apply the changes") + console.print(" - Verify contract coverage:") + if bundle: + console.print(f" - specfact analyze contracts --bundle {bundle}") + else: + console.print(" - specfact analyze contracts --bundle <bundle>") + console.print(" - Run your test suite: pytest (or your project's test command)") + console.print(" - Commit the enhanced code") + if bundle_dir: + console.print(f"\n[dim]Prompt files saved to: {bundle_dir.relative_to(repo_path)}/prompts/[/dim]") + else: + console.print("\n[dim]Prompt files saved to: .specfact/prompts/[/dim]") + console.print( + "[yellow]Note:[/yellow] The prompt includes detailed instructions for the iterative validation workflow." + ) + + if output: + console.print("[dim]Note: --output option is currently unused. Prompts saved to .specfact/prompts/[/dim]") + + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts-prompt", + "success", + extra={"generated_count": generated_count, "failed_count": failed_count}, + ) + debug_print("[dim]generate contracts-prompt: success[/dim]") + record( + { + "prompt_generated": generated_count > 0, + "generated_count": generated_count, + "failed_count": failed_count, + } + ) + + +@app.command("contracts-apply") +@beartype +@require(lambda enhanced_file: isinstance(enhanced_file, Path), "Enhanced file path must be Path") +@require( + lambda original_file: original_file is None or isinstance(original_file, Path), "Original file must be None or Path" +) +@ensure(lambda result: result is None, "Must return None") +def apply_enhanced_contracts( + # Target/Input + enhanced_file: Path = typer.Argument( + ..., + help="Path to enhanced code file (from AI IDE)", + exists=True, + ), + original_file: Path | None = typer.Option( + None, + "--original", + help="Path to original file (auto-detected from enhanced file name if not provided)", + ), + # Behavior/Options + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip confirmation prompt and apply changes automatically", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would be applied without actually modifying the file", + ), +) -> None: + """ + Validate and apply enhanced code with contracts. + + Takes the enhanced code file generated by your AI IDE, validates it, and applies + it to the original file if validation passes. This completes the contract enhancement + workflow started with `generate contracts-prompt`. + + **Validation Steps:** + 1. Syntax validation: `python -m py_compile` + 2. File size check: Enhanced file must be >= original file size + 3. AST structure comparison: Logical structure integrity check + 4. Contract imports verification: Required imports present + 5. Test execution: Run tests via specfact (contract-test) + 6. Diff preview (shows what will change) + 7. Apply changes only if all validations pass + + **Parameter Groups:** + - **Target/Input**: enhanced_file (required argument), --original + - **Behavior/Options**: --yes, --dry-run + + **Examples:** + specfact generate contracts-apply enhanced_telemetry.py + specfact generate contracts-apply enhanced_telemetry.py --original src/telemetry.py + specfact generate contracts-apply enhanced_telemetry.py --dry-run # Preview only + specfact generate contracts-apply enhanced_telemetry.py --yes # Auto-apply + """ + import difflib + import subprocess + + from rich.panel import Panel + from rich.prompt import Confirm + + repo_path = Path(".").resolve() + + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts-apply", + "started", + extra={"enhanced_file": str(enhanced_file), "original_file": str(original_file) if original_file else None}, + ) + debug_print("[dim]generate contracts-apply: started[/dim]") + + # Auto-detect original file if not provided + if original_file is None: + # Try to infer from enhanced file name + # Pattern: enhance-<original-stem>-<contracts>.py or enhanced_<original-name>.py + enhanced_stem = enhanced_file.stem + if enhanced_stem.startswith("enhance-"): + # Pattern: enhance-telemetry-beartype-icontract + parts = enhanced_stem.split("-") + if len(parts) >= 2: + original_name = parts[1] # Get the original file name + # Detect source directories dynamically + source_dirs = detect_source_directories(repo_path) + # Build possible paths based on detected source directories + possible_paths: list[Path] = [] + # Add root-level file + possible_paths.append(repo_path / f"{original_name}.py") + # Add paths based on detected source directories + for src_dir in source_dirs: + # Remove trailing slash if present + src_dir_clean = src_dir.rstrip("/") + possible_paths.append(repo_path / src_dir_clean / f"{original_name}.py") + # Also try common patterns as fallback + possible_paths.extend( + [ + repo_path / f"src/{original_name}.py", + repo_path / f"lib/{original_name}.py", + ] + ) + for path in possible_paths: + if path.exists(): + original_file = path + break + + if original_file is None: + print_error("Could not auto-detect original file. Please specify --original") + raise typer.Exit(1) + + original_file = original_file.resolve() + enhanced_file = enhanced_file.resolve() + + if not original_file.exists(): + print_error(f"Original file not found: {original_file}") + raise typer.Exit(1) + + # Read both files + try: + original_content = original_file.read_text(encoding="utf-8") + enhanced_content = enhanced_file.read_text(encoding="utf-8") + original_size = original_file.stat().st_size + enhanced_size = enhanced_file.stat().st_size + except Exception as e: + print_error(f"Failed to read files: {e}") + raise typer.Exit(1) from e + + # Step 1: File size check + console.print("[bold cyan]Step 1/6: Checking file size...[/bold cyan]") + if enhanced_size < original_size: + print_error(f"Enhanced file is smaller than original ({enhanced_size} < {original_size} bytes)") + console.print( + "\n[yellow]This may indicate missing code. Please ensure all original functionality is preserved.[/yellow]" + ) + console.print( + "\n[bold]Please review the enhanced file and ensure it contains all original code plus contracts.[/bold]" + ) + raise typer.Exit(1) from None + print_success(f"File size check passed ({enhanced_size} >= {original_size} bytes)") + + # Step 2: Syntax validation + console.print("\n[bold cyan]Step 2/6: Validating enhanced code syntax...[/bold cyan]") + syntax_errors: list[str] = [] + try: + # Detect environment manager and build appropriate command + env_info = detect_env_manager(repo_path) + python_command = ["python", "-m", "py_compile", str(enhanced_file)] + compile_command = build_tool_command(env_info, python_command) + result = subprocess.run( + compile_command, + capture_output=True, + text=True, + timeout=10, + cwd=str(repo_path), + ) + if result.returncode != 0: + error_output = result.stderr.strip() + syntax_errors.append("Syntax validation failed") + if error_output: + # Parse syntax errors for better formatting + for line in error_output.split("\n"): + if line.strip() and ("SyntaxError" in line or "Error" in line or "^" in line): + syntax_errors.append(f" {line}") + if len(syntax_errors) == 1: # Only header, no parsed errors + syntax_errors.append(f" {error_output}") + else: + syntax_errors.append(" No detailed error message available") + + print_error("\n".join(syntax_errors)) + console.print("\n[yellow]Common fixes:[/yellow]") + console.print(" - Check for missing imports (beartype, icontract, etc.)") + console.print(" - Verify all decorators are properly formatted") + console.print(" - Ensure parentheses and brackets are balanced") + console.print(" - Check for typos in function/class names") + console.print("\n[bold]Please fix the syntax errors and try again.[/bold]") + raise typer.Exit(1) from None + print_success("Syntax validation passed") + except subprocess.TimeoutExpired: + print_error("Syntax validation timed out") + console.print("\n[yellow]This usually indicates a very large file or system issues.[/yellow]") + raise typer.Exit(1) from None + except Exception as e: + print_error(f"Syntax validation error: {e}") + raise typer.Exit(1) from e + + # Step 3: AST structure comparison + console.print("\n[bold cyan]Step 3/6: Comparing AST structure...[/bold cyan]") + try: + import ast + + original_ast = ast.parse(original_content, filename=str(original_file)) + enhanced_ast = ast.parse(enhanced_content, filename=str(enhanced_file)) + + # Compare function/class definitions + original_defs = { + node.name: type(node).__name__ + for node in ast.walk(original_ast) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } + enhanced_defs = { + node.name: type(node).__name__ + for node in ast.walk(enhanced_ast) + if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + } + + missing_defs = set(original_defs.keys()) - set(enhanced_defs.keys()) + if missing_defs: + print_error("AST structure validation failed: Missing definitions in enhanced file:") + for def_name in sorted(missing_defs): + def_type = original_defs[def_name] + console.print(f" - {def_type}: {def_name}") + console.print( + "\n[bold]Please ensure all original functions and classes are preserved in the enhanced file.[/bold]" + ) + raise typer.Exit(1) from None + + # Check for type mismatches (function -> class or vice versa) + type_mismatches = [] + for def_name in original_defs: + if def_name in enhanced_defs and original_defs[def_name] != enhanced_defs[def_name]: + type_mismatches.append(f"{def_name}: {original_defs[def_name]} -> {enhanced_defs[def_name]}") + + if type_mismatches: + print_error("AST structure validation failed: Type mismatches detected:") + for mismatch in type_mismatches: + console.print(f" - {mismatch}") + console.print("\n[bold]Please ensure function/class types match the original file.[/bold]") + raise typer.Exit(1) from None + + print_success(f"AST structure validation passed ({len(original_defs)} definitions preserved)") + except SyntaxError as e: + print_error(f"AST parsing failed: {e}") + console.print("\n[bold]This should not happen if syntax validation passed. Please report this issue.[/bold]") + raise typer.Exit(1) from e + except Exception as e: + print_error(f"AST comparison error: {e}") + raise typer.Exit(1) from e + + # Step 4: Check for contract imports + console.print("\n[bold cyan]Step 4/6: Checking contract imports...[/bold cyan]") + required_imports: list[str] = [] + if ( + ("@beartype" in enhanced_content or "beartype" in enhanced_content.lower()) + and "from beartype import beartype" not in enhanced_content + and "import beartype" not in enhanced_content + ): + required_imports.append("beartype") + if ( + ("@require" in enhanced_content or "@ensure" in enhanced_content) + and "from icontract import" not in enhanced_content + and "import icontract" not in enhanced_content + ): + required_imports.append("icontract") + + if required_imports: + print_error(f"Missing required imports: {', '.join(required_imports)}") + console.print("\n[yellow]Please add the missing imports at the top of the file:[/yellow]") + for imp in required_imports: + if imp == "beartype": + console.print(" from beartype import beartype") + elif imp == "icontract": + console.print(" from icontract import require, ensure") + console.print("\n[bold]Please fix the imports and try again.[/bold]") + raise typer.Exit(1) from None + + print_success("Contract imports verified") + + # Step 5: Run linting/formatting checks (if tools available) + console.print("\n[bold cyan]Step 5/7: Running code quality checks (if tools available)...[/bold cyan]") + lint_issues: list[str] = [] + tools_checked = 0 + tools_passed = 0 + + # Detect environment manager for building commands + env_info = detect_env_manager(repo_path) + + # List of common linting/formatting tools to check + linting_tools = [ + ("ruff", ["ruff", "check", str(enhanced_file)], "Ruff linting"), + ("pylint", ["pylint", str(enhanced_file), "--disable=all", "--enable=E,F"], "Pylint basic checks"), + ("basedpyright", ["basedpyright", str(enhanced_file)], "BasedPyright type checking"), + ("mypy", ["mypy", str(enhanced_file)], "MyPy type checking"), + ] + + for tool_name, command, description in linting_tools: + is_available, _error_msg = check_cli_tool_available(tool_name, version_flag="--version", timeout=3) + if not is_available: + console.print(f"[dim]Skipping {description}: {tool_name} not available[/dim]") + continue + + tools_checked += 1 + console.print(f"[dim]Running {description}...[/dim]") + + try: + # Build command with environment manager prefix if needed + command_full = build_tool_command(env_info, command) + result = subprocess.run( + command_full, + capture_output=True, + text=True, + timeout=30, # 30 seconds per tool + cwd=str(repo_path), + ) + + if result.returncode == 0: + tools_passed += 1 + console.print(f"[green]✓[/green] {description} passed") + else: + # Collect issues but don't fail immediately (warnings only) + output = result.stdout + result.stderr + # Limit output length for readability + output_lines = output.split("\n") + if len(output_lines) > 20: + output = "\n".join(output_lines[:20]) + f"\n... ({len(output_lines) - 20} more lines)" + lint_issues.append(f"{description} found issues:\n{output}") + console.print(f"[yellow]⚠[/yellow] {description} found issues (non-blocking)") + + except subprocess.TimeoutExpired: + console.print(f"[yellow]⚠[/yellow] {description} timed out (non-blocking)") + lint_issues.append(f"{description} timed out after 30 seconds") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] {description} error: {e} (non-blocking)") + lint_issues.append(f"{description} error: {e}") + + if tools_checked == 0: + console.print("[dim]No linting/formatting tools available. Skipping code quality checks.[/dim]") + elif tools_passed == tools_checked: + print_success(f"All code quality checks passed ({tools_passed}/{tools_checked} tools)") + else: + console.print(f"[yellow]Code quality checks: {tools_passed}/{tools_checked} tools passed[/yellow]") + if lint_issues: + console.print("\n[yellow]Code Quality Issues (non-blocking):[/yellow]") + for issue in lint_issues[:3]: # Show first 3 issues + console.print(Panel(issue[:500], title="Issue", border_style="yellow")) + if len(lint_issues) > 3: + console.print(f"[dim]... and {len(lint_issues) - 3} more issue(s)[/dim]") + console.print("\n[yellow]Note:[/yellow] These are warnings. Fix them for better code quality.") + + # Step 6: Run tests (scoped to relevant file only for performance) + # NOTE: Tests always run for validation, even in --dry-run mode, to ensure code quality + console.print("\n[bold cyan]Step 6/7: Running tests (scoped to relevant file)...[/bold cyan]") + test_failed = False + test_output = "" + + # For single-file validation, we scope tests to the specific file only (not full repo) + # This is much faster than running specfact repro on the entire repository + try: + # Find the original file path to determine test file location + original_file_rel = original_file.relative_to(repo_path) if original_file else None + enhanced_file_rel = enhanced_file.relative_to(repo_path) + + # Determine the source file we're testing (original or enhanced) + source_file_rel = original_file_rel if original_file_rel else enhanced_file_rel + + # Use utility function to find test files dynamically + test_paths = find_test_files_for_source( + repo_path, source_file_rel if source_file_rel.is_absolute() else repo_path / source_file_rel + ) + + # If we found specific test files, run them + if test_paths: + # Use the first matching test file (most specific) + test_path = test_paths[0] + console.print(f"[dim]Found test file: {test_path.relative_to(repo_path)}[/dim]") + console.print("[dim]Running pytest on specific test file (fast, scoped validation)...[/dim]") + + # Detect environment manager and build appropriate command + env_info = detect_env_manager(repo_path) + pytest_command = ["pytest", str(test_path), "-v", "--tb=short"] + pytest_command_full = build_tool_command(env_info, pytest_command) + + result = subprocess.run( + pytest_command_full, + capture_output=True, + text=True, + timeout=60, # 1 minute should be enough for a single test file + cwd=str(repo_path), + ) + else: + # No specific test file found, try to import and test the enhanced file directly + # This validates that the file can be imported and basic syntax works + console.print(f"[dim]No specific test file found for {source_file_rel}[/dim]") + console.print("[dim]Running syntax and import validation on enhanced file...[/dim]") + + # Try to import the module to verify it works + import importlib.util + import sys + from dataclasses import dataclass + + @dataclass + class ImportResult: + """Result object for import validation.""" + + returncode: int + stdout: str + stderr: str + + try: + # Add the enhanced file's directory to path temporarily + enhanced_file_dir = str(enhanced_file.parent) + if enhanced_file_dir not in sys.path: + sys.path.insert(0, enhanced_file_dir) + + # Try to load the module + spec = importlib.util.spec_from_file_location(enhanced_file.stem, enhanced_file) + if spec and spec.loader: + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + print_success("Enhanced file imports successfully") + result = ImportResult(returncode=0, stdout="", stderr="") + else: + raise ImportError("Could not create module spec") + except Exception as import_error: + test_failed = True + test_output = f"Import validation failed: {import_error}" + print_error(test_output) + console.print( + "\n[yellow]Note:[/yellow] No specific test file found. Enhanced file should be importable." + ) + result = ImportResult(returncode=1, stdout="", stderr=test_output) + + if result.returncode != 0: + test_failed = True + test_output = result.stdout + result.stderr + print_error("Test execution failed:") + # Limit output for readability + output_lines = test_output.split("\n") + console.print("\n".join(output_lines[:50])) # First 50 lines + if len(output_lines) > 50: + console.print(f"\n... ({len(output_lines) - 50} more lines)") + else: + if test_paths: + print_success(f"All tests passed ({test_paths[0].relative_to(repo_path)})") + else: + print_success("Import validation passed") + except FileNotFoundError: + console.print("[yellow]Warning:[/yellow] 'pytest' not found. Skipping test execution.") + console.print("[yellow]Please run tests manually before applying changes.[/yellow]") + test_failed = False # Don't fail if tools not available + except subprocess.TimeoutExpired: + test_failed = True + test_output = "Test execution timed out after 60 seconds" + print_error(test_output) + console.print("\n[yellow]Note:[/yellow] Test execution took too long. Consider running tests manually.") + except Exception as e: + test_failed = True + test_output = f"Test execution error: {e}" + print_error(test_output) + + if test_failed: + console.print("\n[bold red]Test failures detected. Changes will NOT be applied.[/bold red]") + console.print("\n[yellow]Test Output:[/yellow]") + console.print(Panel(test_output[:2000], title="Test Results", border_style="red")) # Limit output + console.print("\n[bold]Please fix the test failures and try again.[/bold]") + console.print("Common issues:") + console.print(" - Contract decorators may have incorrect syntax") + console.print(" - Type hints may not match function signatures") + console.print(" - Missing imports or dependencies") + console.print(" - Contract conditions may be invalid") + raise typer.Exit(1) from None + + # Step 7: Show diff + console.print("\n[bold cyan]Step 7/7: Previewing changes...[/bold cyan]") + diff = list( + difflib.unified_diff( + original_content.splitlines(keepends=True), + enhanced_content.splitlines(keepends=True), + fromfile=str(original_file.relative_to(repo_path)), + tofile=str(enhanced_file.relative_to(repo_path)), + lineterm="", + ) + ) + + if not diff: + print_info("No changes detected. Files are identical.") + raise typer.Exit(0) + + # Show diff (limit to first 100 lines for readability) + diff_text = "".join(diff[:100]) + if len(diff) > 100: + diff_text += f"\n... ({len(diff) - 100} more lines)" + console.print(Panel(diff_text, title="Diff Preview", border_style="cyan")) + + # Step 7: Dry run check + if dry_run: + print_info("Dry run mode: No changes applied") + console.print("\n[bold green]✓ All validations passed![/bold green]") + console.print("Ready to apply with --yes flag or without --dry-run") + raise typer.Exit(0) + + # Step 8: Confirmation + if not yes and not Confirm.ask("\n[bold yellow]Apply these changes to the original file?[/bold yellow]"): + print_info("Changes not applied") + raise typer.Exit(0) + + # Step 9: Apply changes (only if all validations passed) + try: + original_file.write_text(enhanced_content, encoding="utf-8") + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts-apply", + "success", + extra={"original_file": str(original_file.relative_to(repo_path))}, + ) + debug_print("[dim]generate contracts-apply: success[/dim]") + print_success(f"Enhanced code applied to: {original_file.relative_to(repo_path)}") + console.print("\n[bold green]✓ All validations passed and changes applied successfully![/bold green]") + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Verify contract coverage: specfact analyze contracts --bundle <bundle>") + console.print("2. Run full test suite: specfact repro (or pytest)") + console.print("3. Commit the enhanced code") + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "generate contracts-apply", + "failed", + error=str(e), + extra={"reason": type(e).__name__, "original_file": str(original_file)}, + ) + print_error(f"Failed to apply changes: {e}") + console.print("\n[yellow]This is a filesystem error. Please check file permissions.[/yellow]") + raise typer.Exit(1) from e + + +# DEPRECATED: generate tasks command removed in v0.22.0 +# SpecFact CLI does not create plan -> feature -> task (that's the job for spec-kit, openspec, etc.) +# We complement those SDD tools to enforce tests and quality +# This command has been removed per SPECFACT_0x_TO_1x_BRIDGE_PLAN.md +# Reference: /specfact-cli-internal/docs/internal/implementation/SPECFACT_0x_TO_1x_BRIDGE_PLAN.md + + +@app.command("fix-prompt") +@beartype +@require(lambda gap_id: gap_id is None or isinstance(gap_id, str), "Gap ID must be None or string") +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +@ensure(lambda result: result is None, "Must return None") +def generate_fix_prompt( + # Target/Input + gap_id: str | None = typer.Argument( + None, + help="Gap ID to fix (e.g., GAP-001). If not provided, shows available gaps.", + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name. Default: active plan from 'specfact plan select'", + ), + # Output + output: Path | None = typer.Option( + None, + "--output", + "-o", + help="Output file path for the prompt. Default: .specfact/prompts/fix-<gap-id>.md", + ), + # Behavior/Options + top: int = typer.Option( + 5, + "--top", + help="Show top N gaps when listing. Default: 5", + ), + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation).", + ), +) -> None: + """ + Generate AI IDE prompt for fixing a specific gap. + + Creates a structured prompt file that you can use with your AI IDE (Cursor, Copilot, etc.) + to fix identified gaps in your codebase. This is the recommended workflow for v0.17+. + + **Workflow:** + 1. Run `specfact analyze gaps --bundle <bundle>` to identify gaps + 2. Run `specfact generate fix-prompt GAP-001` to get a fix prompt + 3. Copy the prompt to your AI IDE + 4. AI IDE provides the fix + 5. Validate with `specfact enforce sdd --bundle <bundle>` + + **Parameter Groups:** + - **Target/Input**: gap_id (optional argument), --bundle + - **Output**: --output + - **Behavior/Options**: --top, --no-interactive + + **Examples:** + specfact generate fix-prompt # List available gaps + specfact generate fix-prompt GAP-001 # Generate fix prompt for GAP-001 + specfact generate fix-prompt --bundle legacy-api # List gaps for specific bundle + specfact generate fix-prompt GAP-001 --output fix.md # Save to specific file + """ + from rich.table import Table + + from specfact_cli.utils.structure import SpecFactStructure + + repo_path = Path(".").resolve() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo_path) + if bundle: + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + telemetry_metadata = { + "gap_id": gap_id, + "bundle": bundle, + "no_interactive": no_interactive, + } + + if is_debug_mode(): + debug_log_operation( + "command", + "generate fix-prompt", + "started", + extra={"gap_id": gap_id, "bundle": bundle}, + ) + debug_print("[dim]generate fix-prompt: started[/dim]") + + with telemetry.track_command("generate.fix-prompt", telemetry_metadata) as record: + try: + # Determine bundle directory + bundle_dir: Path | None = None + if bundle: + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + print_info(f"Create one with: specfact plan init {bundle}") + raise typer.Exit(1) + + # Look for gap report + gap_report_path = ( + bundle_dir / "reports" / "gaps.json" + if bundle_dir + else repo_path / ".specfact" / "reports" / "gaps.json" + ) + + if not gap_report_path.exists(): + print_warning("No gap report found.") + console.print("\n[bold]To generate a gap report, run:[/bold]") + if bundle: + console.print(f" specfact analyze gaps --bundle {bundle} --output json") + else: + console.print(" specfact analyze gaps --bundle <bundle-name> --output json") + raise typer.Exit(1) + + # Load gap report + from specfact_cli.utils.structured_io import load_structured_file + + gap_data = load_structured_file(gap_report_path) + gaps = gap_data.get("gaps", []) + + if not gaps: + print_info("No gaps found in the report. Your codebase is looking good!") + raise typer.Exit(0) + + # If no gap_id provided, list available gaps + if gap_id is None: + console.print(f"\n[bold cyan]Available Gaps ({len(gaps)} total):[/bold cyan]\n") + + table = Table(show_header=True, header_style="bold cyan") + table.add_column("ID", style="bold yellow", width=12) + table.add_column("Severity", width=10) + table.add_column("Category", width=15) + table.add_column("Description", width=50) + + severity_colors = { + "critical": "red", + "high": "yellow", + "medium": "cyan", + "low": "dim", + } + + for gap in gaps[:top]: + severity = gap.get("severity", "medium") + color = severity_colors.get(severity, "white") + table.add_row( + gap.get("id", "N/A"), + f"[{color}]{severity}[/{color}]", + gap.get("category", "N/A"), + gap.get("description", "N/A")[:50] + "..." + if len(gap.get("description", "")) > 50 + else gap.get("description", "N/A"), + ) + + console.print(table) + + if len(gaps) > top: + console.print(f"\n[dim]... and {len(gaps) - top} more gaps. Use --top to see more.[/dim]") + + console.print("\n[bold]To generate a fix prompt:[/bold]") + console.print(" specfact generate fix-prompt <GAP-ID>") + console.print("\n[bold]Example:[/bold]") + if gaps: + console.print(f" specfact generate fix-prompt {gaps[0].get('id', 'GAP-001')}") + + record({"action": "list_gaps", "gap_count": len(gaps)}) + raise typer.Exit(0) + + # Find the specific gap + target_gap = None + for gap in gaps: + if gap.get("id") == gap_id: + target_gap = gap + break + + if target_gap is None: + print_error(f"Gap not found: {gap_id}") + console.print("\n[yellow]Available gap IDs:[/yellow]") + for gap in gaps[:10]: + console.print(f" - {gap.get('id')}") + if len(gaps) > 10: + console.print(f" ... and {len(gaps) - 10} more") + raise typer.Exit(1) + + # Generate fix prompt + console.print(f"\n[bold cyan]Generating fix prompt for {gap_id}...[/bold cyan]\n") + + prompt_parts = [ + f"# Fix Request: {gap_id}", + "", + "## Gap Details", + "", + f"**ID:** {target_gap.get('id', 'N/A')}", + f"**Category:** {target_gap.get('category', 'N/A')}", + f"**Severity:** {target_gap.get('severity', 'N/A')}", + f"**Module:** {target_gap.get('module', 'N/A')}", + "", + f"**Description:** {target_gap.get('description', 'N/A')}", + "", + ] + + # Add evidence if available + evidence = target_gap.get("evidence", {}) + if evidence: + prompt_parts.extend( + [ + "## Evidence", + "", + ] + ) + if evidence.get("file"): + prompt_parts.append(f"**File:** `{evidence.get('file')}`") + if evidence.get("line"): + prompt_parts.append(f"**Line:** {evidence.get('line')}") + if evidence.get("code"): + prompt_parts.extend( + [ + "", + "**Code:**", + "```python", + evidence.get("code", ""), + "```", + ] + ) + prompt_parts.append("") + + # Add fix instructions + prompt_parts.extend( + [ + "## Fix Instructions", + "", + "Please fix this gap by:", + "", + ] + ) + + category = target_gap.get("category", "").lower() + if "missing_tests" in category or "test" in category: + prompt_parts.extend( + [ + "1. **Add Tests**: Write comprehensive tests for the identified code", + "2. **Cover Edge Cases**: Include tests for edge cases and error conditions", + "3. **Follow AAA Pattern**: Use Arrange-Act-Assert pattern", + "4. **Run Tests**: Ensure all tests pass", + ] + ) + elif "missing_contracts" in category or "contract" in category: + prompt_parts.extend( + [ + "1. **Add Contracts**: Add `@beartype` decorators for type checking", + "2. **Add Preconditions**: Add `@require` decorators for input validation", + "3. **Add Postconditions**: Add `@ensure` decorators for output guarantees", + "4. **Verify Imports**: Ensure `from beartype import beartype` and `from icontract import require, ensure` are present", + ] + ) + elif "api_drift" in category or "drift" in category: + prompt_parts.extend( + [ + "1. **Check OpenAPI Spec**: Review the OpenAPI contract", + "2. **Update Implementation**: Align the code with the spec", + "3. **Or Update Spec**: If the implementation is correct, update the spec", + "4. **Run Drift Check**: Verify with `specfact analyze drift`", + ] + ) + else: + prompt_parts.extend( + [ + "1. **Analyze the Gap**: Understand what's missing or incorrect", + "2. **Implement Fix**: Apply the appropriate fix", + "3. **Add Tests**: Ensure the fix is covered by tests", + "4. **Validate**: Run `specfact enforce sdd` to verify", + ] + ) + + prompt_parts.extend( + [ + "", + "## Validation", + "", + "After applying the fix, validate with:", + "", + "```bash", + ] + ) + + if bundle: + prompt_parts.append(f"specfact enforce sdd --bundle {bundle}") + else: + prompt_parts.append("specfact enforce sdd --bundle <bundle-name>") + + prompt_parts.extend( + [ + "```", + "", + ] + ) + + prompt = "\n".join(prompt_parts) + + # Save prompt to file + if output is None: + prompts_dir = bundle_dir / "prompts" if bundle_dir else repo_path / ".specfact" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + output = prompts_dir / f"fix-{gap_id.lower()}.md" + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(prompt, encoding="utf-8") + + print_success(f"Fix prompt generated: {output}") + + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Open the prompt file in your AI IDE (Cursor, Copilot, etc.)") + console.print("2. Copy the prompt and ask your AI to implement the fix") + console.print("3. Review and apply the suggested changes") + console.print("4. Validate with `specfact enforce sdd`") + + if is_debug_mode(): + debug_log_operation( + "command", + "generate fix-prompt", + "success", + extra={"gap_id": gap_id, "output": str(output)}, + ) + debug_print("[dim]generate fix-prompt: success[/dim]") + record({"action": "generate_prompt", "gap_id": gap_id, "output": str(output)}) + + except typer.Exit: + raise + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "generate fix-prompt", + "failed", + error=str(e), + extra={"reason": type(e).__name__}, + ) + print_error(f"Failed to generate fix prompt: {e}") + record({"error": str(e)}) + raise typer.Exit(1) from e + + +@app.command("test-prompt") +@beartype +@require(lambda file: file is None or isinstance(file, Path), "File must be None or Path") +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +@ensure(lambda result: result is None, "Must return None") +def generate_test_prompt( + # Target/Input + file: Path | None = typer.Argument( + None, + help="File to generate tests for. If not provided with --bundle, shows files without tests.", + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name. Default: active plan from 'specfact plan select'", + ), + # Output + output: Path | None = typer.Option( + None, + "--output", + "-o", + help="Output file path for the prompt. Default: .specfact/prompts/test-<filename>.md", + ), + # Behavior/Options + coverage_type: str = typer.Option( + "unit", + "--type", + help="Test type: 'unit', 'integration', or 'both'. Default: unit", + ), + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation).", + ), +) -> None: + """ + Generate AI IDE prompt for creating tests for a file. + + Creates a structured prompt file that you can use with your AI IDE (Cursor, Copilot, etc.) + to generate comprehensive tests for your code. This is the recommended workflow for v0.17+. + + **Workflow:** + 1. Run `specfact generate test-prompt src/module.py` to get a test prompt + 2. Copy the prompt to your AI IDE + 3. AI IDE generates tests + 4. Save tests to appropriate location + 5. Run tests with `pytest` + + **Parameter Groups:** + - **Target/Input**: file (optional argument), --bundle + - **Output**: --output + - **Behavior/Options**: --type, --no-interactive + + **Examples:** + specfact generate test-prompt src/auth/login.py # Generate test prompt + specfact generate test-prompt src/api.py --type integration # Integration tests + specfact generate test-prompt --bundle legacy-api # List files needing tests + """ + from rich.table import Table + + from specfact_cli.utils.structure import SpecFactStructure + + repo_path = Path(".").resolve() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo_path) + if bundle: + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + telemetry_metadata = { + "file": str(file) if file else None, + "bundle": bundle, + "coverage_type": coverage_type, + "no_interactive": no_interactive, + } + + if is_debug_mode(): + debug_log_operation( + "command", + "generate test-prompt", + "started", + extra={"file": str(file) if file else None, "bundle": bundle}, + ) + debug_print("[dim]generate test-prompt: started[/dim]") + + with telemetry.track_command("generate.test-prompt", telemetry_metadata) as record: + try: + # Determine bundle directory + bundle_dir: Path | None = None + if bundle: + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + print_info(f"Create one with: specfact plan init {bundle}") + raise typer.Exit(1) + + # If no file provided, show files that might need tests + if file is None: + console.print("\n[bold cyan]Files that may need tests:[/bold cyan]\n") + + # Find Python files without corresponding test files + # Use dynamic source directory detection + source_dirs = detect_source_directories(repo_path) + src_files: list[Path] = [] + # If no source dirs detected, check common patterns + if not source_dirs: + for src_dir in [repo_path / "src", repo_path / "lib", repo_path]: + if src_dir.exists(): + src_files.extend(src_dir.rglob("*.py")) + else: + # Use detected source directories + for src_dir_str in source_dirs: + src_dir_clean = src_dir_str.rstrip("/") + src_dir_path = repo_path / src_dir_clean + if src_dir_path.exists(): + src_files.extend(src_dir_path.rglob("*.py")) + + files_without_tests: list[tuple[Path, str]] = [] + for src_file in src_files: + if "__pycache__" in str(src_file) or "test_" in src_file.name or "_test.py" in src_file.name: + continue + if src_file.name.startswith("__"): + continue + + # Check for corresponding test file using dynamic detection + test_files = find_test_files_for_source(repo_path, src_file) + has_test = len(test_files) > 0 + if not has_test: + rel_path = src_file.relative_to(repo_path) if src_file.is_relative_to(repo_path) else src_file + files_without_tests.append((src_file, str(rel_path))) + + if files_without_tests: + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="bold yellow", justify="right", width=4) + table.add_column("File Path", style="dim") + + for i, (_, rel_path) in enumerate(files_without_tests[:15], 1): + table.add_row(str(i), rel_path) + + console.print(table) + + if len(files_without_tests) > 15: + console.print(f"\n[dim]... and {len(files_without_tests) - 15} more files[/dim]") + + console.print("\n[bold]To generate test prompt:[/bold]") + console.print(" specfact generate test-prompt <file-path>") + console.print("\n[bold]Example:[/bold]") + console.print(f" specfact generate test-prompt {files_without_tests[0][1]}") + else: + print_success("All source files appear to have tests!") + + record({"action": "list_files", "files_without_tests": len(files_without_tests)}) + raise typer.Exit(0) + + # Validate file exists + if not file.exists(): + print_error(f"File not found: {file}") + raise typer.Exit(1) + + # Read file content + file_content = file.read_text(encoding="utf-8") + file_rel = file.relative_to(repo_path) if file.is_relative_to(repo_path) else file + + # Generate test prompt + console.print(f"\n[bold cyan]Generating test prompt for {file_rel}...[/bold cyan]\n") + + prompt_parts = [ + f"# Test Generation Request: {file_rel}", + "", + "## Target File", + "", + f"**File Path:** `{file_rel}`", + f"**Test Type:** {coverage_type}", + "", + "## File Content", + "", + "```python", + file_content, + "```", + "", + "## Instructions", + "", + "Generate comprehensive tests for this file following these guidelines:", + "", + "### Test Structure", + "", + "1. **Use pytest** as the testing framework", + "2. **Follow AAA pattern** (Arrange-Act-Assert)", + "3. **One test = one behavior** - Keep tests focused", + "4. **Use fixtures** for common setup", + "5. **Use parametrize** for testing multiple inputs", + "", + "### Coverage Requirements", + "", + ] + + if coverage_type == "unit": + prompt_parts.extend( + [ + "- Test each public function/method individually", + "- Mock external dependencies", + "- Test edge cases and error conditions", + "- Target >80% line coverage", + ] + ) + elif coverage_type == "integration": + prompt_parts.extend( + [ + "- Test interactions between components", + "- Use real dependencies where feasible", + "- Test complete workflows", + "- Focus on critical paths", + ] + ) + else: # both + prompt_parts.extend( + [ + "- Create both unit and integration tests", + "- Unit tests in `tests/unit/`", + "- Integration tests in `tests/integration/`", + "- Cover all critical code paths", + ] + ) + + prompt_parts.extend( + [ + "", + "### Test File Location", + "", + f"Save the tests to: `tests/unit/test_{file.stem}.py`", + "", + "### Example Test Structure", + "", + "```python", + f'"""Tests for {file_rel}."""', + "", + "import pytest", + "from unittest.mock import Mock, patch", + "", + f"from {str(file_rel).replace('/', '.').replace('.py', '')} import *", + "", + "", + "class TestFunctionName:", + ' """Tests for function_name."""', + "", + " def test_success_case(self):", + ' """Test successful execution."""', + " # Arrange", + " input_data = ...", + "", + " # Act", + " result = function_name(input_data)", + "", + " # Assert", + " assert result == expected_output", + "", + " def test_error_case(self):", + ' """Test error handling."""', + " with pytest.raises(ExpectedError):", + " function_name(invalid_input)", + "```", + "", + "## Validation", + "", + "After generating tests, run:", + "", + "```bash", + f"pytest tests/unit/test_{file.stem}.py -v", + "```", + "", + ] + ) + + prompt = "\n".join(prompt_parts) + + # Save prompt to file + if output is None: + prompts_dir = bundle_dir / "prompts" if bundle_dir else repo_path / ".specfact" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + output = prompts_dir / f"test-{file.stem}.md" + + output.parent.mkdir(parents=True, exist_ok=True) + output.write_text(prompt, encoding="utf-8") + + print_success(f"Test prompt generated: {output}") + + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Open the prompt file in your AI IDE (Cursor, Copilot, etc.)") + console.print("2. Copy the prompt and ask your AI to generate tests") + console.print("3. Review the generated tests") + console.print(f"4. Save to `tests/unit/test_{file.stem}.py`") + console.print("5. Run tests with `pytest`") + + if is_debug_mode(): + debug_log_operation( + "command", + "generate test-prompt", + "success", + extra={"file": str(file_rel), "output": str(output)}, + ) + debug_print("[dim]generate test-prompt: success[/dim]") + record({"action": "generate_prompt", "file": str(file_rel), "output": str(output)}) + + except typer.Exit: + raise + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "generate test-prompt", + "failed", + error=str(e), + extra={"reason": type(e).__name__}, + ) + print_error(f"Failed to generate test prompt: {e}") + record({"error": str(e)}) + raise typer.Exit(1) from e diff --git a/src/specfact_cli/modules/import_cmd/src/__init__.py b/src/specfact_cli/modules/import_cmd/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/import_cmd/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/import_cmd/src/app.py b/src/specfact_cli/modules/import_cmd/src/app.py index 61903299..ee17e86f 100644 --- a/src/specfact_cli/modules/import_cmd/src/app.py +++ b/src/specfact_cli/modules/import_cmd/src/app.py @@ -1,6 +1,6 @@ -"""Import command: re-export from commands package.""" +"""import_cmd command entrypoint.""" -from specfact_cli.commands.import_cmd import app +from specfact_cli.modules.import_cmd.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/import_cmd/src/commands.py b/src/specfact_cli/modules/import_cmd/src/commands.py new file mode 100644 index 00000000..f4084ac4 --- /dev/null +++ b/src/specfact_cli/modules/import_cmd/src/commands.py @@ -0,0 +1,2905 @@ +""" +Import command - Import codebases and external tool projects to contract-driven format. + +This module provides commands for importing existing codebases (brownfield) and +external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) and converting them to +SpecFact contract-driven format using the bridge architecture. +""" + +from __future__ import annotations + +import multiprocessing +import os +import time +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import typer +from beartype import beartype +from icontract import require +from rich.progress import Progress + +from specfact_cli import runtime +from specfact_cli.adapters.registry import AdapterRegistry +from specfact_cli.models.plan import Feature, PlanBundle +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils.performance import track_performance +from specfact_cli.utils.progress import save_bundle_with_progress +from specfact_cli.utils.terminal import get_progress_config + + +app = typer.Typer( + help="Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) to contract format", + context_settings={"help_option_names": ["-h", "--help", "--help-advanced", "-ha"]}, +) +console = get_configured_console() + +if TYPE_CHECKING: + from specfact_cli.generators.openapi_extractor import OpenAPIExtractor + from specfact_cli.generators.test_to_openapi import OpenAPITestConverter + + +_CONTRACT_WORKER_EXTRACTOR: OpenAPIExtractor | None = None +_CONTRACT_WORKER_TEST_CONVERTER: OpenAPITestConverter | None = None +_CONTRACT_WORKER_REPO: Path | None = None +_CONTRACT_WORKER_CONTRACTS_DIR: Path | None = None + + +def _init_contract_worker(repo_path: str, contracts_dir: str) -> None: + """Initialize per-process contract extraction state.""" + from specfact_cli.generators.openapi_extractor import OpenAPIExtractor + from specfact_cli.generators.test_to_openapi import OpenAPITestConverter + + global _CONTRACT_WORKER_CONTRACTS_DIR + global _CONTRACT_WORKER_EXTRACTOR + global _CONTRACT_WORKER_REPO + global _CONTRACT_WORKER_TEST_CONVERTER + + _CONTRACT_WORKER_REPO = Path(repo_path) + _CONTRACT_WORKER_CONTRACTS_DIR = Path(contracts_dir) + _CONTRACT_WORKER_EXTRACTOR = OpenAPIExtractor(_CONTRACT_WORKER_REPO) + _CONTRACT_WORKER_TEST_CONVERTER = OpenAPITestConverter(_CONTRACT_WORKER_REPO) + + +def _extract_contract_worker(feature_data: dict[str, Any]) -> tuple[str, dict[str, Any] | None]: + """Extract a single OpenAPI contract in a worker process.""" + from specfact_cli.models.plan import Feature + + if ( + _CONTRACT_WORKER_EXTRACTOR is None + or _CONTRACT_WORKER_TEST_CONVERTER is None + or _CONTRACT_WORKER_REPO is None + or _CONTRACT_WORKER_CONTRACTS_DIR is None + ): + raise RuntimeError("Contract extraction worker not initialized") + + feature = Feature(**feature_data) + try: + openapi_spec = _CONTRACT_WORKER_EXTRACTOR.extract_openapi_from_code(_CONTRACT_WORKER_REPO, feature) + if openapi_spec.get("paths"): + test_examples: dict[str, Any] = {} + has_test_functions = any(story.test_functions for story in feature.stories) or ( + feature.source_tracking and feature.source_tracking.test_functions + ) + + if has_test_functions: + all_test_functions: list[str] = [] + for story in feature.stories: + if story.test_functions: + all_test_functions.extend(story.test_functions) + if feature.source_tracking and feature.source_tracking.test_functions: + all_test_functions.extend(feature.source_tracking.test_functions) + if all_test_functions: + test_examples = _CONTRACT_WORKER_TEST_CONVERTER.extract_examples_from_tests(all_test_functions) + + if test_examples: + openapi_spec = _CONTRACT_WORKER_EXTRACTOR.add_test_examples(openapi_spec, test_examples) + + contract_filename = f"{feature.key}.openapi.yaml" + contract_path = _CONTRACT_WORKER_CONTRACTS_DIR / contract_filename + _CONTRACT_WORKER_EXTRACTOR.save_openapi_contract(openapi_spec, contract_path) + return (feature.key, openapi_spec) + except KeyboardInterrupt: + raise + except Exception: + return (feature.key, None) + + return (feature.key, None) + + +def _is_valid_repo_path(path: Path) -> bool: + """Check if path exists and is a directory.""" + return path.exists() and path.is_dir() + + +def _is_valid_output_path(path: Path | None) -> bool: + """Check if output path exists if provided.""" + return path is None or path.exists() + + +def _count_python_files(repo: Path) -> int: + """Count Python files for anonymized telemetry metrics.""" + return sum(1 for _ in repo.rglob("*.py")) + + +def _convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: + """ + Convert PlanBundle (monolithic) to ProjectBundle (modular). + + Args: + plan_bundle: PlanBundle instance to convert + bundle_name: Project bundle name + + Returns: + ProjectBundle instance + """ + from specfact_cli.migrations.plan_migrator import get_latest_schema_version + + # Create manifest with latest schema version + manifest = BundleManifest( + versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + + # Convert features list to dict + features_dict: dict[str, Feature] = {f.key: f for f in plan_bundle.features} + + # Create and return ProjectBundle + return ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + idea=plan_bundle.idea, + business=plan_bundle.business, + product=plan_bundle.product, + features=features_dict, + clarifications=plan_bundle.clarifications, + ) + + +def _check_incremental_changes( + bundle_dir: Path, repo: Path, enrichment: Path | None, force: bool = False +) -> dict[str, bool] | None: + """Check for incremental changes and return what needs regeneration.""" + if force: + console.print("[yellow]⚠ Force mode enabled - regenerating all artifacts[/yellow]\n") + return None # None means regenerate everything + if not bundle_dir.exists(): + return None # No bundle exists, regenerate everything + # Note: enrichment doesn't force full regeneration - it only adds/updates features + # Contracts should only be regenerated if source files changed, not just because enrichment was applied + + from specfact_cli.utils.incremental_check import check_incremental_changes + + try: + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + # Load manifest first to get feature count for determinate progress + manifest_path = bundle_dir / "bundle.manifest.yaml" + num_features = 0 + total_ops = 100 # Default estimate for determinate progress + + if manifest_path.exists(): + try: + from specfact_cli.models.project import BundleManifest + from specfact_cli.utils.structured_io import load_structured_file + + manifest_data = load_structured_file(manifest_path) + manifest = BundleManifest.model_validate(manifest_data) + num_features = len(manifest.features) + + # Estimate total operations: manifest (1) + loading features (num_features) + file checks (num_features * ~2 avg files) + # Use a reasonable estimate for determinate progress + estimated_file_checks = num_features * 2 if num_features > 0 else 10 + total_ops = max(1 + num_features + estimated_file_checks, 10) # Minimum 10 for visibility + except Exception: + # If manifest load fails, use default estimate + pass + + # Create task with estimated total for determinate progress bar + task = progress.add_task("[cyan]Loading manifest and checking file changes...", total=total_ops) + + # Create progress callback to update the progress bar + def update_progress(current: int, total: int, message: str) -> None: + """Update progress bar with current status.""" + # Always update total when provided (we get better estimates as we progress) + # The total from incremental_check may be more accurate than our initial estimate + current_total = progress.tasks[task].total + if current_total is None: + # No total set yet, use the provided one + progress.update(task, total=total) + elif total != current_total: + # Total changed, update it (this handles both increases and decreases) + # We trust the incremental_check calculation as it has more accurate info + progress.update(task, total=total) + # Always update completed and description + progress.update(task, completed=current, description=f"[cyan]{message}[/cyan]") + + # Call check_incremental_changes with progress callback + incremental_changes = check_incremental_changes( + bundle_dir, repo, features=None, progress_callback=update_progress + ) + + # Update progress to completion + task_info = progress.tasks[task] + final_total = task_info.total if task_info.total and task_info.total > 0 else total_ops + progress.update( + task, + completed=final_total, + total=final_total, + description="[green]✓[/green] Change check complete", + ) + # Brief pause to show completion + time.sleep(0.1) + + # If enrichment is provided, we need to apply it even if no source files changed + # Mark bundle as needing regeneration to ensure enrichment is applied + if enrichment and incremental_changes and not any(incremental_changes.values()): + # Enrichment provided but no source changes - still need to apply enrichment + incremental_changes["bundle"] = True # Force bundle regeneration to apply enrichment + console.print(f"[green]✓[/green] Project bundle already exists: {bundle_dir}") + console.print("[dim]No source file changes detected, but enrichment will be applied[/dim]") + elif not any(incremental_changes.values()): + # No changes and no enrichment - can skip everything + console.print(f"[green]✓[/green] Project bundle already exists: {bundle_dir}") + console.print("[dim]No changes detected - all artifacts are up-to-date[/dim]") + console.print("[dim]Skipping regeneration of relationships, contracts, graph, and enrichment context[/dim]") + console.print( + "[dim]Use --force to force regeneration, or modify source files to trigger incremental update[/dim]" + ) + raise typer.Exit(0) + + changed_items = [key for key, value in incremental_changes.items() if value] + if changed_items: + console.print("[yellow]⚠[/yellow] Project bundle exists, but some artifacts need regeneration:") + for item in changed_items: + console.print(f" [dim]- {item}[/dim]") + console.print("[dim]Regenerating only changed artifacts...[/dim]\n") + + return incremental_changes + except KeyboardInterrupt: + raise + except typer.Exit: + raise + except Exception as e: + error_msg = str(e) if str(e) else f"{type(e).__name__}" + if "bundle.manifest.yaml" in error_msg or "Cannot determine bundle format" in error_msg: + console.print( + "[yellow]⚠ Incomplete bundle directory detected (likely from a failed save) - will regenerate all artifacts[/yellow]\n" + ) + else: + console.print( + f"[yellow]⚠ Existing bundle found but couldn't be loaded ({type(e).__name__}: {error_msg}) - will regenerate all artifacts[/yellow]\n" + ) + return None + + +def _validate_existing_features(plan_bundle: PlanBundle, repo: Path) -> dict[str, Any]: + """ + Validate existing features to check if they're still valid. + + Args: + plan_bundle: Plan bundle with features to validate + repo: Repository root path + + Returns: + Dictionary with validation results: + - 'valid_features': List of valid feature keys + - 'orphaned_features': List of feature keys whose source files no longer exist + - 'invalid_features': List of feature keys with validation issues + - 'missing_files': Dict mapping feature_key -> list of missing file paths + - 'total_checked': Total number of features checked + """ + + result: dict[str, Any] = { + "valid_features": [], + "orphaned_features": [], + "invalid_features": [], + "missing_files": {}, + "total_checked": len(plan_bundle.features), + } + + for feature in plan_bundle.features: + if not feature.source_tracking: + # Feature has no source tracking - mark as potentially invalid + result["invalid_features"].append(feature.key) + continue + + missing_files: list[str] = [] + has_any_files = False + + # Check implementation files + for impl_file in feature.source_tracking.implementation_files: + file_path = repo / impl_file + if file_path.exists(): + has_any_files = True + else: + missing_files.append(impl_file) + + # Check test files + for test_file in feature.source_tracking.test_files: + file_path = repo / test_file + if file_path.exists(): + has_any_files = True + else: + missing_files.append(test_file) + + # Validate feature structure + # Note: Features can legitimately have no stories if they're newly discovered + # Only mark as invalid if there are actual structural problems (missing key/title) + has_structure_issues = False + if not feature.key or not feature.title: + has_structure_issues = True + # Don't mark features with no stories as invalid - they may be newly discovered + # Stories will be added during analysis or enrichment + + # Classify feature + if not has_any_files and missing_files: + # All source files are missing - orphaned feature + result["orphaned_features"].append(feature.key) + result["missing_files"][feature.key] = missing_files + elif missing_files: + # Some files missing but not all - invalid but recoverable + result["invalid_features"].append(feature.key) + result["missing_files"][feature.key] = missing_files + elif has_structure_issues: + # Feature has actual structure issues (missing key/title) + result["invalid_features"].append(feature.key) + else: + # Feature is valid (has source_tracking, files exist, and has key/title) + # Note: Features without stories are still considered valid + result["valid_features"].append(feature.key) + + return result + + +def _load_existing_bundle(bundle_dir: Path) -> PlanBundle | None: + """Load existing project bundle and convert to PlanBundle.""" + from specfact_cli.models.plan import PlanBundle as PlanBundleModel + from specfact_cli.utils.progress import load_bundle_with_progress + + try: + existing_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + plan_bundle = PlanBundleModel( + version="1.0", + idea=existing_bundle.idea, + business=existing_bundle.business, + product=existing_bundle.product, + features=list(existing_bundle.features.values()), + metadata=None, + clarifications=existing_bundle.clarifications, + ) + total_stories = sum(len(f.stories) for f in plan_bundle.features) + console.print( + f"[green]✓[/green] Loaded existing bundle: {len(plan_bundle.features)} features, {total_stories} stories" + ) + return plan_bundle + except Exception as e: + console.print(f"[yellow]⚠ Could not load existing bundle: {e}[/yellow]") + console.print("[dim]Falling back to full codebase analysis...[/dim]\n") + return None + + +def _analyze_codebase( + repo: Path, + entry_point: Path | None, + bundle: str, + confidence: float, + key_format: str, + routing_result: Any, + incremental_callback: Any | None = None, +) -> PlanBundle: + """Analyze codebase using AI agent or AST fallback.""" + from specfact_cli.agents.analyze_agent import AnalyzeAgent + from specfact_cli.agents.registry import get_agent + from specfact_cli.analyzers.code_analyzer import CodeAnalyzer + + if routing_result.execution_mode == "agent": + console.print("[dim]Mode: CoPilot (AI-first import)[/dim]") + agent = get_agent("import from-code") + if agent and isinstance(agent, AnalyzeAgent): + context = { + "workspace": str(repo), + "current_file": None, + "selection": None, + } + _enhanced_context = agent.inject_context(context) + console.print("\n[cyan]🤖 AI-powered import (semantic understanding)...[/cyan]") + plan_bundle = agent.analyze_codebase(repo, confidence=confidence, plan_name=bundle) + console.print("[green]✓[/green] AI import complete") + return plan_bundle + console.print("[yellow]⚠ Agent not available, falling back to AST-based import[/yellow]") + + # AST-based import (CI/CD mode or fallback) + console.print("[dim]Mode: CI/CD (AST-based import)[/dim]") + console.print( + "\n[yellow]⏱️ Note: This analysis typically takes 2-5 minutes for large codebases (optimized for speed)[/yellow]" + ) + + # Phase 4.9: Create incremental callback for early feedback + def on_incremental_update(features_count: int, themes: list[str]) -> None: + """Callback for incremental results (Phase 4.9: Quick Start Optimization).""" + # Feature count updates are shown in the progress bar description, not as separate lines + # No intermediate messages needed - final summary provides all information + + # Create analyzer with incremental callback + analyzer = CodeAnalyzer( + repo, + confidence_threshold=confidence, + key_format=key_format, + plan_name=bundle, + entry_point=entry_point, + incremental_callback=incremental_callback or on_incremental_update, + ) + + # Display plugin status + plugin_status = analyzer.get_plugin_status() + if plugin_status: + from rich.table import Table + + console.print("\n[bold]Analysis Plugins:[/bold]") + plugin_table = Table(show_header=True, header_style="bold cyan", box=None, padding=(0, 1)) + plugin_table.add_column("Plugin", style="cyan", width=25) + plugin_table.add_column("Status", style="bold", width=12) + plugin_table.add_column("Details", style="dim", width=50) + + for plugin in plugin_status: + if plugin["enabled"] and plugin["used"]: + status = "[green]✓ Enabled[/green]" + elif plugin["enabled"] and not plugin["used"]: + status = "[yellow]⚠ Enabled (not used)[/yellow]" + else: + status = "[dim]⊘ Disabled[/dim]" + + plugin_table.add_row(plugin["name"], status, plugin["reason"]) + + console.print(plugin_table) + console.print() + + if entry_point: + console.print(f"[cyan]🔍 Analyzing codebase (scoped to {entry_point})...[/cyan]\n") + else: + console.print("[cyan]🔍 Analyzing codebase...[/cyan]\n") + + return analyzer.analyze() + + +def _update_source_tracking(plan_bundle: PlanBundle, repo: Path) -> None: + """Update source tracking with file hashes (parallelized).""" + import os + from concurrent.futures import ThreadPoolExecutor, as_completed + + from specfact_cli.utils.source_scanner import SourceArtifactScanner + + console.print("\n[cyan]🔗 Linking source files to features...[/cyan]") + scanner = SourceArtifactScanner(repo) + scanner.link_to_specs(plan_bundle.features, repo) + + def update_file_hash(feature: Feature, file_path: Path) -> None: + """Update hash for a single file (thread-safe).""" + if file_path.exists() and feature.source_tracking is not None: + feature.source_tracking.update_hash(file_path) + + hash_tasks: list[tuple[Feature, Path]] = [] + for feature in plan_bundle.features: + if feature.source_tracking: + for impl_file in feature.source_tracking.implementation_files: + hash_tasks.append((feature, repo / impl_file)) + for test_file in feature.source_tracking.test_files: + hash_tasks.append((feature, repo / test_file)) + + if hash_tasks: + import os + + from rich.progress import Progress + + from specfact_cli.utils.terminal import get_progress_config + + # In test mode, use sequential processing to avoid ThreadPoolExecutor deadlocks + is_test_mode = os.environ.get("TEST_MODE") == "true" + if is_test_mode: + # Sequential processing in test mode - avoids ThreadPoolExecutor deadlocks + import contextlib + + for feature, file_path in hash_tasks: + with contextlib.suppress(Exception): + update_file_hash(feature, file_path) + else: + max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(hash_tasks))) + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + hash_task = progress.add_task( + f"[cyan]Computing file hashes for {len(hash_tasks)} files...", + total=len(hash_tasks), + ) + + executor = ThreadPoolExecutor(max_workers=max_workers) + interrupted = False + completed_count = 0 + try: + future_to_task = { + executor.submit(update_file_hash, feature, file_path): (feature, file_path) + for feature, file_path in hash_tasks + } + try: + for future in as_completed(future_to_task): + try: + future.result() + completed_count += 1 + progress.update( + hash_task, + completed=completed_count, + description=f"[cyan]Computing file hashes... ({completed_count}/{len(hash_tasks)})", + ) + except KeyboardInterrupt: + interrupted = True + for f in future_to_task: + if not f.done(): + f.cancel() + break + except Exception: + completed_count += 1 + progress.update(hash_task, completed=completed_count) + except KeyboardInterrupt: + interrupted = True + for f in future_to_task: + if not f.done(): + f.cancel() + if interrupted: + raise KeyboardInterrupt + except KeyboardInterrupt: + interrupted = True + executor.shutdown(wait=False, cancel_futures=True) + raise + finally: + if not interrupted: + progress.update( + hash_task, + completed=len(hash_tasks), + description=f"[green]✓[/green] Computed hashes for {len(hash_tasks)} files", + ) + progress.remove_task(hash_task) + executor.shutdown(wait=True) + else: + executor.shutdown(wait=False) + + # Update sync timestamps (fast operation, no progress needed) + for feature in plan_bundle.features: + if feature.source_tracking: + feature.source_tracking.update_sync_timestamp() + + console.print("[green]✓[/green] Source tracking complete") + + +def _extract_relationships_and_graph( + repo: Path, + entry_point: Path | None, + bundle_dir: Path, + incremental_changes: dict[str, bool] | None, + plan_bundle: PlanBundle | None, + should_regenerate_relationships: bool, + should_regenerate_graph: bool, + include_tests: bool = False, +) -> tuple[dict[str, Any], dict[str, Any] | None]: + """Extract relationships and graph dependencies.""" + relationships: dict[str, Any] = {} + graph_summary: dict[str, Any] | None = None + + if not (should_regenerate_relationships or should_regenerate_graph): + console.print("\n[dim]⏭ Skipping relationships and graph analysis (no changes detected)[/dim]") + enrichment_context_path = bundle_dir / "enrichment_context.md" + if enrichment_context_path.exists(): + relationships = {"imports": {}, "interfaces": {}, "routes": {}} + return relationships, graph_summary + + console.print("\n[cyan]🔍 Enhanced analysis: Extracting relationships, contracts, and graph dependencies...[/cyan]") + from rich.progress import Progress, SpinnerColumn, TextColumn + + from specfact_cli.analyzers.graph_analyzer import GraphAnalyzer + from specfact_cli.analyzers.relationship_mapper import RelationshipMapper + from specfact_cli.utils.optional_deps import check_cli_tool_available + from specfact_cli.utils.terminal import get_progress_config + + # Show spinner while checking pyan3 and collecting file hashes + _progress_columns, progress_kwargs = get_progress_config() + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + **progress_kwargs, + ) as setup_progress: + setup_task = setup_progress.add_task("[cyan]Preparing enhanced analysis...", total=None) + + pyan3_available, _ = check_cli_tool_available("pyan3") + if not pyan3_available: + console.print( + "[dim]💡 Note: Enhanced analysis tool pyan3 is not available (call graph analysis will be skipped)[/dim]" + ) + console.print("[dim] Install with: pip install pyan3[/dim]") + + # Pre-compute file hashes for caching (reuse from source tracking if available) + setup_progress.update(setup_task, description="[cyan]Collecting file hashes for caching...") + file_hashes_cache: dict[str, str] = {} + if plan_bundle: + # Collect file hashes from source tracking + for feature in plan_bundle.features: + if feature.source_tracking: + file_hashes_cache.update(feature.source_tracking.file_hashes) + + relationship_mapper = RelationshipMapper(repo, file_hashes_cache=file_hashes_cache) + + # Discover and filter Python files with progress + changed_files: set[Path] = set() + if incremental_changes and plan_bundle: + setup_progress.update(setup_task, description="[cyan]Checking for changed files...") + from specfact_cli.utils.incremental_check import get_changed_files + + # get_changed_files iterates through all features and checks file hashes + # This can be slow for large bundles - show progress + changed_files_dict = get_changed_files(bundle_dir, repo, list(plan_bundle.features)) + setup_progress.update(setup_task, description="[cyan]Collecting changed file paths...") + for feature_changes in changed_files_dict.values(): + for file_path_str in feature_changes: + clean_path = file_path_str.replace(" (deleted)", "") + file_path = repo / clean_path + if file_path.exists(): + changed_files.add(file_path) + + if changed_files: + python_files = list(changed_files) + setup_progress.update(setup_task, description=f"[green]✓[/green] Found {len(python_files)} changed file(s)") + else: + setup_progress.update(setup_task, description="[cyan]Discovering Python files...") + # This can be slow for large codebases - show progress + python_files = list(repo.rglob("*.py")) + setup_progress.update(setup_task, description=f"[cyan]Filtering {len(python_files)} files...") + + if entry_point: + python_files = [f for f in python_files if entry_point in f.parts] + + # Filter files based on --include-tests/--exclude-tests flag + # Default: Exclude test files (they're validation artifacts, not specifications) + # --include-tests: Include test files in dependency graph (only if needed) + # Rationale for excluding tests by default: + # - Test files are consumers of production code (not producers) + # - Test files import production code, but production code doesn't import tests + # - Interfaces and routes are defined in production code, not tests + # - Dependency graph flows from production code, so skipping tests has minimal impact + # - Test files are never extracted as features (they validate code, they don't define it) + if not include_tests: + # Exclude test files when --exclude-tests is specified (default) + # Test files are validation artifacts, not specifications + python_files = [ + f + for f in python_files + if not any( + skip in str(f) + for skip in [ + "/test_", + "/tests/", + "/test/", # Handle singular "test/" directory (e.g., SQLAlchemy) + "/vendor/", + "/.venv/", + "/venv/", + "/node_modules/", + "/__pycache__/", + ] + ) + and not f.name.startswith("test_") # Exclude test_*.py files + and not f.name.endswith("_test.py") # Exclude *_test.py files + ] + else: + # Default: Include test files, but still filter vendor/venv files + python_files = [ + f + for f in python_files + if not any( + skip in str(f) for skip in ["/vendor/", "/.venv/", "/venv/", "/node_modules/", "/__pycache__/"] + ) + ] + setup_progress.update( + setup_task, description=f"[green]✓[/green] Ready to analyze {len(python_files)} files" + ) + + setup_progress.remove_task(setup_task) + + if changed_files: + console.print(f"[dim]Analyzing {len(python_files)} changed file(s) for relationships...[/dim]") + else: + console.print(f"[dim]Analyzing {len(python_files)} file(s) for relationships...[/dim]") + + # Analyze relationships in parallel with progress reporting + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + import time + + # Step 1: Analyze relationships + relationships_task = progress.add_task( + f"[cyan]Analyzing relationships in {len(python_files)} files...", + total=len(python_files), + ) + + def update_relationships_progress(completed: int, total: int) -> None: + """Update progress for relationship analysis.""" + progress.update( + relationships_task, + completed=completed, + description=f"[cyan]Analyzing relationships... ({completed}/{total} files)", + ) + + relationships = relationship_mapper.analyze_files(python_files, progress_callback=update_relationships_progress) + progress.update( + relationships_task, + completed=len(python_files), + total=len(python_files), + description=f"[green]✓[/green] Relationship analysis complete: {len(relationships['imports'])} files mapped", + ) + # Keep final progress bar visible instead of removing it + time.sleep(0.1) # Brief pause to show completion + + # Graph analysis is optional and can be slow - only run if explicitly needed + # Skip by default for faster imports (can be enabled with --with-graph flag in future) + if should_regenerate_graph and pyan3_available: + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + graph_task = progress.add_task( + f"[cyan]Building dependency graph from {len(python_files)} files...", + total=len(python_files) * 2, # Two phases: AST imports + call graphs + ) + + def update_graph_progress(completed: int, total: int) -> None: + """Update progress for graph building.""" + progress.update( + graph_task, + completed=completed, + description=f"[cyan]Building dependency graph... ({completed}/{total})", + ) + + graph_analyzer = GraphAnalyzer(repo, file_hashes_cache=file_hashes_cache) + graph_analyzer.build_dependency_graph(python_files, progress_callback=update_graph_progress) + graph_summary = graph_analyzer.get_graph_summary() + if graph_summary: + progress.update( + graph_task, + completed=len(python_files) * 2, + total=len(python_files) * 2, + description=f"[green]✓[/green] Dependency graph complete: {graph_summary.get('nodes', 0)} modules, {graph_summary.get('edges', 0)} dependencies", + ) + # Keep final progress bar visible instead of removing it + time.sleep(0.1) # Brief pause to show completion + relationships["dependency_graph"] = graph_summary + relationships["call_graphs"] = graph_analyzer.call_graphs + elif should_regenerate_graph and not pyan3_available: + console.print("[dim]⏭ Skipping graph analysis (pyan3 not available)[/dim]") + + return relationships, graph_summary + + +def _extract_contracts( + repo: Path, + bundle_dir: Path, + plan_bundle: PlanBundle, + should_regenerate_contracts: bool, + record_event: Any, + force: bool = False, +) -> dict[str, dict[str, Any]]: + """Extract OpenAPI contracts from features.""" + import os + from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor, as_completed + + from specfact_cli.generators.openapi_extractor import OpenAPIExtractor + from specfact_cli.generators.test_to_openapi import OpenAPITestConverter + + contracts_generated = 0 + contracts_dir = bundle_dir / "contracts" + contracts_dir.mkdir(parents=True, exist_ok=True) + contracts_data: dict[str, dict[str, Any]] = {} + + # Load existing contracts if not regenerating (parallelized) + if not should_regenerate_contracts: + console.print("\n[dim]⏭ Skipping contract extraction (no changes detected)[/dim]") + + def load_contract(feature: Feature) -> tuple[str, dict[str, Any] | None]: + """Load contract for a single feature (thread-safe).""" + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + try: + import yaml + + contract_data = yaml.safe_load(contract_path.read_text()) + return (feature.key, contract_data) + except KeyboardInterrupt: + raise + except Exception: + pass + return (feature.key, None) + + features_with_contracts = [f for f in plan_bundle.features if f.contract] + if features_with_contracts: + import os + from concurrent.futures import ThreadPoolExecutor, as_completed + + from rich.progress import Progress + + from specfact_cli.utils.terminal import get_progress_config + + # In test mode, use sequential processing to avoid ThreadPoolExecutor deadlocks + is_test_mode = os.environ.get("TEST_MODE") == "true" + existing_contracts_count = 0 + + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + load_task = progress.add_task( + f"[cyan]Loading {len(features_with_contracts)} existing contract(s)...", + total=len(features_with_contracts), + ) + + if is_test_mode: + # Sequential processing in test mode - avoids ThreadPoolExecutor deadlocks + for idx, feature in enumerate(features_with_contracts): + try: + feature_key, contract_data = load_contract(feature) + if contract_data: + contracts_data[feature_key] = contract_data + existing_contracts_count += 1 + except Exception: + pass + progress.update(load_task, completed=idx + 1) + else: + max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(features_with_contracts))) + executor = ThreadPoolExecutor(max_workers=max_workers) + interrupted = False + completed_count = 0 + try: + future_to_feature = { + executor.submit(load_contract, feature): feature for feature in features_with_contracts + } + try: + for future in as_completed(future_to_feature): + try: + feature_key, contract_data = future.result() + completed_count += 1 + progress.update(load_task, completed=completed_count) + if contract_data: + contracts_data[feature_key] = contract_data + existing_contracts_count += 1 + except KeyboardInterrupt: + interrupted = True + for f in future_to_feature: + if not f.done(): + f.cancel() + break + except Exception: + completed_count += 1 + progress.update(load_task, completed=completed_count) + except KeyboardInterrupt: + interrupted = True + for f in future_to_feature: + if not f.done(): + f.cancel() + if interrupted: + raise KeyboardInterrupt + except KeyboardInterrupt: + interrupted = True + executor.shutdown(wait=False, cancel_futures=True) + raise + finally: + if not interrupted: + progress.update( + load_task, + completed=len(features_with_contracts), + description=f"[green]✓[/green] Loaded {existing_contracts_count} contract(s)", + ) + executor.shutdown(wait=True) + else: + executor.shutdown(wait=False) + + if existing_contracts_count == 0: + progress.remove_task(load_task) + + if existing_contracts_count > 0: + console.print(f"[green]✓[/green] Loaded {existing_contracts_count} existing contract(s) from bundle") + + # Extract contracts if needed + if should_regenerate_contracts: + # When force=True, skip hash checking and process all features with source files + if force: + # Force mode: process all features with implementation files + features_with_files = [ + f for f in plan_bundle.features if f.source_tracking and f.source_tracking.implementation_files + ] + else: + # Filter features that need contract regeneration (check file hashes) + # Pre-compute all file hashes in parallel to avoid redundant I/O + import os + from concurrent.futures import ThreadPoolExecutor, as_completed + + # Collect all unique files that need hash checking + files_to_check: set[Path] = set() + feature_to_files: dict[str, list[Path]] = {} # Use feature key (str) instead of Feature object + feature_objects: dict[str, Feature] = {} # Keep reference to Feature objects + + for f in plan_bundle.features: + if f.source_tracking and f.source_tracking.implementation_files: + feature_files: list[Path] = [] + for impl_file in f.source_tracking.implementation_files: + file_path = repo / impl_file + if file_path.exists(): + files_to_check.add(file_path) + feature_files.append(file_path) + if feature_files: + feature_to_files[f.key] = feature_files + feature_objects[f.key] = f + + # Pre-compute all file hashes in parallel (batch operation) + current_hashes: dict[Path, str] = {} + if files_to_check: + is_test_mode = os.environ.get("TEST_MODE") == "true" + + def compute_file_hash(file_path: Path) -> tuple[Path, str | None]: + """Compute hash for a single file (thread-safe).""" + try: + import hashlib + + return (file_path, hashlib.sha256(file_path.read_bytes()).hexdigest()) + except Exception: + return (file_path, None) + + if is_test_mode: + # Sequential in test mode + for file_path in files_to_check: + _, hash_value = compute_file_hash(file_path) + if hash_value: + current_hashes[file_path] = hash_value + else: + # Parallel in production mode + max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(files_to_check))) + with ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {executor.submit(compute_file_hash, fp): fp for fp in files_to_check} + for future in as_completed(futures): + try: + file_path, hash_value = future.result() + if hash_value: + current_hashes[file_path] = hash_value + except Exception: + pass + + # Now check features using pre-computed hashes (no file I/O) + features_with_files = [] + for feature_key, feature_files in feature_to_files.items(): + f = feature_objects[feature_key] + # Check if contract needs regeneration (file changed or contract missing) + needs_regeneration = False + if not f.contract: + needs_regeneration = True + else: + # Check if any source file changed + contract_path = bundle_dir / f.contract + if not contract_path.exists(): + needs_regeneration = True + else: + # Check if any implementation file changed using pre-computed hashes + if f.source_tracking: + for file_path in feature_files: + if file_path in current_hashes: + stored_hash = f.source_tracking.file_hashes.get(str(file_path)) + if stored_hash != current_hashes[file_path]: + needs_regeneration = True + break + else: + # File exists but hash computation failed, assume changed + needs_regeneration = True + break + if needs_regeneration: + features_with_files.append(f) + else: + features_with_files: list[Feature] = [] + + if features_with_files and should_regenerate_contracts: + import os + + # In test mode, use sequential processing to avoid ThreadPoolExecutor deadlocks + is_test_mode = os.environ.get("TEST_MODE") == "true" + pool_mode = os.environ.get("SPECFACT_CONTRACT_POOL", "process").lower() + use_process_pool = not is_test_mode and pool_mode != "thread" and len(features_with_files) > 1 + # Define max_workers for non-test mode (always defined to satisfy type checker) + max_workers = 1 + if is_test_mode: + console.print( + f"[cyan]📋 Extracting contracts from {len(features_with_files)} features (sequential mode)...[/cyan]" + ) + else: + max_workers = max(1, min(multiprocessing.cpu_count() or 4, 16, len(features_with_files))) + pool_label = "process" if use_process_pool else "thread" + console.print( + f"[cyan]📋 Extracting contracts from {len(features_with_files)} features (using {max_workers} {pool_label} worker(s))...[/cyan]" + ) + + from rich.progress import Progress + + from specfact_cli.utils.terminal import get_progress_config + + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + task = progress.add_task("[cyan]Extracting contracts...", total=len(features_with_files)) + if use_process_pool: + feature_lookup: dict[str, Feature] = {f.key: f for f in features_with_files} + executor = ProcessPoolExecutor( + max_workers=max_workers, + initializer=_init_contract_worker, + initargs=(str(repo), str(contracts_dir)), + ) + interrupted = False + try: + future_to_feature_key = { + executor.submit(_extract_contract_worker, f.model_dump()): f.key for f in features_with_files + } + completed_count = 0 + total_features = len(features_with_files) + pending_count = total_features + try: + for future in as_completed(future_to_feature_key): + try: + feature_key, openapi_spec = future.result() + completed_count += 1 + pending_count = total_features - completed_count + feature_display = feature_key[:50] + "..." if len(feature_key) > 50 else feature_key + + if openapi_spec: + progress.update( + task, + completed=completed_count, + description=f"[cyan]Extracted contract from {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)", + ) + feature = feature_lookup.get(feature_key) + if feature: + contract_ref = f"contracts/{feature_key}.openapi.yaml" + feature.contract = contract_ref + contracts_data[feature_key] = openapi_spec + contracts_generated += 1 + else: + progress.update( + task, + completed=completed_count, + description=f"[dim]No contract for {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", + ) + except KeyboardInterrupt: + interrupted = True + for f in future_to_feature_key: + if not f.done(): + f.cancel() + break + except Exception as e: + completed_count += 1 + pending_count = total_features - completed_count + feature_key_for_display = future_to_feature_key.get(future, "unknown") + feature_display = ( + feature_key_for_display[:50] + "..." + if len(feature_key_for_display) > 50 + else feature_key_for_display + ) + progress.update( + task, + completed=completed_count, + description=f"[dim]⚠ Failed: {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", + ) + console.print( + f"[dim]⚠ Warning: Failed to process feature {feature_key_for_display}: {e}[/dim]" + ) + except KeyboardInterrupt: + interrupted = True + for f in future_to_feature_key: + if not f.done(): + f.cancel() + if interrupted: + raise KeyboardInterrupt + except KeyboardInterrupt: + interrupted = True + executor.shutdown(wait=False, cancel_futures=True) + raise + finally: + if not interrupted: + executor.shutdown(wait=True) + progress.update( + task, + completed=len(features_with_files), + total=len(features_with_files), + description=f"[green]✓[/green] Contract extraction complete: {contracts_generated} contract(s) generated from {len(features_with_files)} feature(s)", + ) + time.sleep(0.1) + else: + executor.shutdown(wait=False) + else: + openapi_extractor = OpenAPIExtractor(repo) + test_converter = OpenAPITestConverter(repo) + + def process_feature(feature: Feature) -> tuple[str, dict[str, Any] | None]: + """Process a single feature and return (feature_key, openapi_spec or None).""" + try: + openapi_spec = openapi_extractor.extract_openapi_from_code(repo, feature) + if openapi_spec.get("paths"): + test_examples: dict[str, Any] = {} + has_test_functions = any(story.test_functions for story in feature.stories) or ( + feature.source_tracking and feature.source_tracking.test_functions + ) + + if has_test_functions: + all_test_functions: list[str] = [] + for story in feature.stories: + if story.test_functions: + all_test_functions.extend(story.test_functions) + if feature.source_tracking and feature.source_tracking.test_functions: + all_test_functions.extend(feature.source_tracking.test_functions) + if all_test_functions: + test_examples = test_converter.extract_examples_from_tests(all_test_functions) + + if test_examples: + openapi_spec = openapi_extractor.add_test_examples(openapi_spec, test_examples) + + contract_filename = f"{feature.key}.openapi.yaml" + contract_path = contracts_dir / contract_filename + openapi_extractor.save_openapi_contract(openapi_spec, contract_path) + return (feature.key, openapi_spec) + except KeyboardInterrupt: + raise + except Exception: + pass + return (feature.key, None) + + if is_test_mode: + # Sequential processing in test mode - avoids ThreadPoolExecutor deadlocks + completed_count = 0 + for idx, feature in enumerate(features_with_files, 1): + try: + feature_display = feature.key[:60] + "..." if len(feature.key) > 60 else feature.key + progress.update( + task, + completed=completed_count, + description=f"[cyan]Extracting contract from {feature_display}... ({idx}/{len(features_with_files)})", + ) + feature_key, openapi_spec = process_feature(feature) + completed_count += 1 + progress.update( + task, + completed=completed_count, + description=f"[cyan]Extracted contract from {feature_display} ({completed_count}/{len(features_with_files)})", + ) + if openapi_spec: + contract_ref = f"contracts/{feature_key}.openapi.yaml" + feature.contract = contract_ref + contracts_data[feature_key] = openapi_spec + contracts_generated += 1 + except Exception as e: + completed_count += 1 + progress.update( + task, + completed=completed_count, + description=f"[dim]⚠ Failed: {feature.key[:50]}... ({completed_count}/{len(features_with_files)})[/dim]", + ) + console.print(f"[dim]⚠ Warning: Failed to process feature {feature.key}: {e}[/dim]") + progress.update( + task, + completed=len(features_with_files), + total=len(features_with_files), + description=f"[green]✓[/green] Contract extraction complete: {contracts_generated} contract(s) generated from {len(features_with_files)} feature(s)", + ) + time.sleep(0.1) + else: + feature_lookup_thread: dict[str, Feature] = {f.key: f for f in features_with_files} + executor = ThreadPoolExecutor(max_workers=max_workers) + interrupted = False + try: + future_to_feature = {executor.submit(process_feature, f): f for f in features_with_files} + completed_count = 0 + total_features = len(features_with_files) + pending_count = total_features + try: + for future in as_completed(future_to_feature): + try: + feature_key, openapi_spec = future.result() + completed_count += 1 + pending_count = total_features - completed_count + feature = feature_lookup_thread.get(feature_key) + feature_display = feature_key[:50] + "..." if len(feature_key) > 50 else feature_key + + if openapi_spec: + progress.update( + task, + completed=completed_count, + description=f"[cyan]Extracted contract from {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)", + ) + if feature: + contract_ref = f"contracts/{feature_key}.openapi.yaml" + feature.contract = contract_ref + contracts_data[feature_key] = openapi_spec + contracts_generated += 1 + else: + progress.update( + task, + completed=completed_count, + description=f"[dim]No contract for {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", + ) + except KeyboardInterrupt: + interrupted = True + for f in future_to_feature: + if not f.done(): + f.cancel() + break + except Exception as e: + completed_count += 1 + pending_count = total_features - completed_count + feature_for_error = future_to_feature.get(future) + feature_key_for_display = feature_for_error.key if feature_for_error else "unknown" + feature_display = ( + feature_key_for_display[:50] + "..." + if len(feature_key_for_display) > 50 + else feature_key_for_display + ) + progress.update( + task, + completed=completed_count, + description=f"[dim]⚠ Failed: {feature_display}... ({completed_count}/{total_features}, {pending_count} pending)[/dim]", + ) + console.print( + f"[dim]⚠ Warning: Failed to process feature {feature_key_for_display}: {e}[/dim]" + ) + except KeyboardInterrupt: + interrupted = True + for f in future_to_feature: + if not f.done(): + f.cancel() + if interrupted: + raise KeyboardInterrupt + except KeyboardInterrupt: + interrupted = True + executor.shutdown(wait=False, cancel_futures=True) + raise + finally: + if not interrupted: + executor.shutdown(wait=True) + progress.update( + task, + completed=len(features_with_files), + total=len(features_with_files), + description=f"[green]✓[/green] Contract extraction complete: {contracts_generated} contract(s) generated from {len(features_with_files)} feature(s)", + ) + time.sleep(0.1) + else: + executor.shutdown(wait=False) + + elif should_regenerate_contracts: + console.print("[dim]No features with implementation files found for contract extraction[/dim]") + + # Report contract status + if should_regenerate_contracts: + if contracts_generated > 0: + console.print(f"[green]✓[/green] Generated {contracts_generated} contract scaffolds") + elif not features_with_files: + console.print("[dim]No API contracts detected in codebase[/dim]") + + return contracts_data + + +def _build_enrichment_context( + bundle_dir: Path, + repo: Path, + plan_bundle: PlanBundle, + relationships: dict[str, Any], + contracts_data: dict[str, dict[str, Any]], + should_regenerate_enrichment: bool, + record_event: Any, +) -> Path: + """Build enrichment context for LLM.""" + import hashlib + + context_path = bundle_dir / "enrichment_context.md" + + # Check if context needs regeneration using file hash + needs_regeneration = should_regenerate_enrichment + if not needs_regeneration and context_path.exists(): + # Check if any source data changed (relationships, contracts, features) + # This can be slow for large bundles - show progress + from rich.progress import SpinnerColumn, TextColumn + + from specfact_cli.utils.terminal import get_progress_config + + _progress_columns, progress_kwargs = get_progress_config() + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + **progress_kwargs, + ) as check_progress: + check_task = check_progress.add_task("[cyan]Checking if enrichment context changed...", total=None) + try: + existing_hash = hashlib.sha256(context_path.read_bytes()).hexdigest() + # Build temporary context to compare hash + from specfact_cli.utils.enrichment_context import build_enrichment_context + + check_progress.update(check_task, description="[cyan]Building temporary context for comparison...") + temp_context = build_enrichment_context( + plan_bundle, relationships=relationships, contracts=contracts_data + ) + temp_md = temp_context.to_markdown() + new_hash = hashlib.sha256(temp_md.encode("utf-8")).hexdigest() + if existing_hash != new_hash: + needs_regeneration = True + except Exception: + # If we can't check, regenerate to be safe + needs_regeneration = True + + if needs_regeneration: + console.print("\n[cyan]📊 Building enrichment context...[/cyan]") + # Building context can be slow for large bundles - show progress + from rich.progress import SpinnerColumn, TextColumn + + from specfact_cli.utils.enrichment_context import build_enrichment_context + from specfact_cli.utils.terminal import get_progress_config + + _progress_columns, progress_kwargs = get_progress_config() + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + console=console, + **progress_kwargs, + ) as build_progress: + build_task = build_progress.add_task( + f"[cyan]Building context from {len(plan_bundle.features)} features...", total=None + ) + enrichment_context = build_enrichment_context( + plan_bundle, relationships=relationships, contracts=contracts_data + ) + build_progress.update(build_task, description="[cyan]Converting to markdown...") + _enrichment_context_md = enrichment_context.to_markdown() + build_progress.update(build_task, description="[cyan]Writing to file...") + context_path.write_text(_enrichment_context_md, encoding="utf-8") + try: + rel_path = context_path.relative_to(repo.resolve()) + console.print(f"[green]✓[/green] Enrichment context saved to: {rel_path}") + except ValueError: + console.print(f"[green]✓[/green] Enrichment context saved to: {context_path}") + else: + console.print("\n[dim]⏭ Skipping enrichment context generation (no changes detected)[/dim]") + _ = context_path.read_text(encoding="utf-8") if context_path.exists() else "" + + record_event( + { + "enrichment_context_available": True, + "relationships_files": len(relationships.get("imports", {})), + "contracts_count": len(contracts_data), + } + ) + return context_path + + +def _apply_enrichment( + enrichment: Path, + plan_bundle: PlanBundle, + record_event: Any, +) -> PlanBundle: + """Apply enrichment report to plan bundle.""" + if not enrichment.exists(): + console.print(f"[bold red]✗ Enrichment report not found: {enrichment}[/bold red]") + raise typer.Exit(1) + + console.print(f"\n[cyan]📝 Applying enrichment from: {enrichment}[/cyan]") + from specfact_cli.utils.enrichment_parser import EnrichmentParser, apply_enrichment + + try: + parser = EnrichmentParser() + enrichment_report = parser.parse(enrichment) + plan_bundle = apply_enrichment(plan_bundle, enrichment_report) + + if enrichment_report.missing_features: + console.print(f"[green]✓[/green] Added {len(enrichment_report.missing_features)} missing features") + if enrichment_report.confidence_adjustments: + console.print( + f"[green]✓[/green] Adjusted confidence for {len(enrichment_report.confidence_adjustments)} features" + ) + if enrichment_report.business_context.get("priorities") or enrichment_report.business_context.get( + "constraints" + ): + console.print("[green]✓[/green] Applied business context") + + record_event( + { + "enrichment_applied": True, + "features_added": len(enrichment_report.missing_features), + "confidence_adjusted": len(enrichment_report.confidence_adjustments), + } + ) + except Exception as e: + console.print(f"[bold red]✗ Failed to apply enrichment: {e}[/bold red]") + raise typer.Exit(1) from e + + return plan_bundle + + +def _save_bundle_if_needed( + plan_bundle: PlanBundle, + bundle: str, + bundle_dir: Path, + incremental_changes: dict[str, bool] | None, + should_regenerate_relationships: bool, + should_regenerate_graph: bool, + should_regenerate_contracts: bool, + should_regenerate_enrichment: bool, +) -> None: + """Save project bundle only if something changed.""" + any_artifact_changed = ( + should_regenerate_relationships + or should_regenerate_graph + or should_regenerate_contracts + or should_regenerate_enrichment + ) + should_regenerate_bundle = ( + incremental_changes is None or any_artifact_changed or incremental_changes.get("bundle", False) + ) + + if should_regenerate_bundle: + console.print("\n[cyan]💾 Compiling and saving project bundle...[/cyan]") + project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) + save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) + else: + console.print("\n[dim]⏭ Skipping bundle save (no changes detected)[/dim]") + + +def _validate_bundle_contracts(bundle_dir: Path, plan_bundle: PlanBundle) -> tuple[int, int]: + """ + Validate OpenAPI/AsyncAPI contracts in bundle with Specmatic if available. + + Args: + bundle_dir: Path to bundle directory + plan_bundle: Plan bundle containing features with contract references + + Returns: + Tuple of (validated_count, failed_count) + """ + import asyncio + + from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic + + # Skip validation in test mode to avoid long-running subprocess calls + if os.environ.get("TEST_MODE") == "true": + return 0, 0 + + is_available, _error_msg = check_specmatic_available() + if not is_available: + return 0, 0 + + validated_count = 0 + failed_count = 0 + contract_files = [] + + # Collect contract files from features + # PlanBundle.features is a list, not a dict + features_iter = plan_bundle.features.values() if isinstance(plan_bundle.features, dict) else plan_bundle.features + for feature in features_iter: + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + contract_files.append((contract_path, feature.key)) + + if not contract_files: + return 0, 0 + + # Limit validation to first 5 contracts to avoid long delays + contracts_to_validate = contract_files[:5] + + console.print(f"\n[cyan]🔍 Validating {len(contracts_to_validate)} contract(s) in bundle with Specmatic...[/cyan]") + + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + validation_task = progress.add_task( + "[cyan]Validating contracts...", + total=len(contracts_to_validate), + ) + + for idx, (contract_path, _feature_key) in enumerate(contracts_to_validate): + progress.update( + validation_task, + completed=idx, + description=f"[cyan]Validating {contract_path.name}...", + ) + try: + result = asyncio.run(validate_spec_with_specmatic(contract_path)) + if result.is_valid: + validated_count += 1 + else: + failed_count += 1 + if result.errors: + console.print(f" [yellow]⚠[/yellow] {contract_path.name} has validation issues") + for error in result.errors[:2]: + console.print(f" - {error}") + except Exception as e: + failed_count += 1 + console.print(f" [yellow]⚠[/yellow] Validation error for {contract_path.name}: {e!s}") + + progress.update( + validation_task, + completed=len(contracts_to_validate), + description=f"[green]✓[/green] Validated {validated_count} contract(s)", + ) + progress.remove_task(validation_task) + + if len(contract_files) > 5: + console.print( + f"[dim]... and {len(contract_files) - 5} more contract(s) (run 'specfact spec validate' to validate all)[/dim]" + ) + + return validated_count, failed_count + + +def _validate_api_specs(repo: Path, bundle_dir: Path | None = None, plan_bundle: PlanBundle | None = None) -> None: + """ + Validate OpenAPI/AsyncAPI specs with Specmatic if available. + + Validates both repo-level spec files and bundle contracts if provided. + + Args: + repo: Repository path + bundle_dir: Optional bundle directory path + plan_bundle: Optional plan bundle for contract validation + """ + import asyncio + + spec_files = [] + for pattern in [ + "**/openapi.yaml", + "**/openapi.yml", + "**/openapi.json", + "**/asyncapi.yaml", + "**/asyncapi.yml", + "**/asyncapi.json", + ]: + spec_files.extend(repo.glob(pattern)) + + validated_contracts = 0 + failed_contracts = 0 + + # Validate bundle contracts if provided + if bundle_dir and plan_bundle: + validated_contracts, failed_contracts = _validate_bundle_contracts(bundle_dir, plan_bundle) + + # Validate repo-level spec files + if spec_files: + console.print(f"\n[cyan]🔍 Found {len(spec_files)} API specification file(s) in repository[/cyan]") + from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic + + is_available, error_msg = check_specmatic_available() + if is_available: + for spec_file in spec_files[:3]: + console.print(f"[dim]Validating {spec_file.relative_to(repo)} with Specmatic...[/dim]") + try: + result = asyncio.run(validate_spec_with_specmatic(spec_file)) + if result.is_valid: + console.print(f" [green]✓[/green] {spec_file.name} is valid") + else: + console.print(f" [yellow]⚠[/yellow] {spec_file.name} has validation issues") + if result.errors: + for error in result.errors[:2]: + console.print(f" - {error}") + except Exception as e: + console.print(f" [yellow]⚠[/yellow] Validation error: {e!s}") + if len(spec_files) > 3: + console.print( + f"[dim]... and {len(spec_files) - 3} more spec file(s) (run 'specfact spec validate' to validate all)[/dim]" + ) + console.print("[dim]💡 Tip: Run 'specfact spec mock' to start a mock server for development[/dim]") + else: + console.print(f"[dim]💡 Tip: Install Specmatic to validate API specs: {error_msg}[/dim]") + elif validated_contracts > 0 or failed_contracts > 0: + # Only show mock server tip if we validated contracts + console.print("[dim]💡 Tip: Run 'specfact spec mock' to start a mock server for development[/dim]") + + +def _suggest_next_steps(repo: Path, bundle: str, plan_bundle: PlanBundle | None) -> None: + """ + Suggest next steps after first import (Phase 4.9: Quick Start Optimization). + + Args: + repo: Repository path + bundle: Bundle name + plan_bundle: Generated plan bundle + """ + if plan_bundle is None: + return + + console.print("\n[bold cyan]📋 Next Steps:[/bold cyan]") + console.print("[dim]Here are some commands you might want to run next:[/dim]\n") + + # Check if this is a first run (no existing bundle) + from specfact_cli.utils.structure import SpecFactStructure + + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + is_first_run = not (bundle_dir / "bundle.manifest.yaml").exists() + + if is_first_run: + console.print(" [yellow]→[/yellow] [bold]Review your plan:[/bold]") + console.print(f" specfact plan review {bundle}") + console.print(" [dim]Review and refine the generated plan bundle[/dim]\n") + + console.print(" [yellow]→[/yellow] [bold]Compare with code:[/bold]") + console.print(f" specfact plan compare --bundle {bundle}") + console.print(" [dim]Detect deviations between plan and code[/dim]\n") + + console.print(" [yellow]→[/yellow] [bold]Validate SDD:[/bold]") + console.print(f" specfact enforce sdd {bundle}") + console.print(" [dim]Check for violations and coverage thresholds[/dim]\n") + else: + console.print(" [yellow]→[/yellow] [bold]Review changes:[/bold]") + console.print(f" specfact plan review {bundle}") + console.print(" [dim]Review updates to your plan bundle[/dim]\n") + + console.print(" [yellow]→[/yellow] [bold]Check deviations:[/bold]") + console.print(f" specfact plan compare --bundle {bundle}") + console.print(" [dim]See what changed since last import[/dim]\n") + + +def _suggest_constitution_bootstrap(repo: Path) -> None: + """Suggest or generate constitution bootstrap for brownfield imports.""" + specify_dir = repo / ".specify" / "memory" + constitution_path = specify_dir / "constitution.md" + if not constitution_path.exists() or ( + constitution_path.exists() and constitution_path.read_text(encoding="utf-8").strip() in ("", "# Constitution") + ): + import os + + is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None + if is_test_env: + from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher + + specify_dir.mkdir(parents=True, exist_ok=True) + enricher = ConstitutionEnricher() + enriched_content = enricher.bootstrap(repo, constitution_path) + constitution_path.write_text(enriched_content, encoding="utf-8") + else: + if runtime.is_interactive(): + console.print() + console.print("[bold cyan]💡 Tip:[/bold cyan] Generate project constitution for tool integration") + suggest_constitution = typer.confirm( + "Generate bootstrap constitution from repository analysis?", + default=True, + ) + if suggest_constitution: + from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher + + console.print("[dim]Generating bootstrap constitution...[/dim]") + specify_dir.mkdir(parents=True, exist_ok=True) + enricher = ConstitutionEnricher() + enriched_content = enricher.bootstrap(repo, constitution_path) + constitution_path.write_text(enriched_content, encoding="utf-8") + console.print("[bold green]✓[/bold green] Bootstrap constitution generated") + console.print(f"[dim]Review and adjust: {constitution_path}[/dim]") + console.print( + "[dim]Then run 'specfact sync bridge --adapter <tool>' to sync with external tool artifacts[/dim]" + ) + else: + console.print() + console.print( + "[dim]💡 Tip: Run 'specfact sdd constitution bootstrap --repo .' to generate constitution[/dim]" + ) + + +def _enrich_for_speckit_compliance(plan_bundle: PlanBundle) -> None: + """ + Enrich plan for Spec-Kit compliance using PlanEnricher. + + This function uses PlanEnricher for consistent enrichment behavior with + the `plan review --auto-enrich` command. It also adds edge case stories + for features with only 1 story to ensure better tool compliance. + """ + console.print("\n[cyan]🔧 Enriching plan for tool compliance...[/cyan]") + try: + from specfact_cli.enrichers.plan_enricher import PlanEnricher + from specfact_cli.utils.terminal import get_progress_config + + # Use PlanEnricher for consistent enrichment (same as plan review --auto-enrich) + console.print("[dim]Enhancing vague acceptance criteria, incomplete requirements, generic tasks...[/dim]") + + # Add progress reporting for large bundles + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + enrich_task = progress.add_task( + f"[cyan]Enriching {len(plan_bundle.features)} features...", + total=len(plan_bundle.features), + ) + + enricher = PlanEnricher() + enrichment_summary = enricher.enrich_plan(plan_bundle) + progress.update(enrich_task, completed=len(plan_bundle.features)) + progress.remove_task(enrich_task) + + # Add edge case stories for features with only 1 story (preserve existing behavior) + features_with_one_story = [f for f in plan_bundle.features if len(f.stories) == 1] + if features_with_one_story: + console.print(f"[yellow]⚠ Found {len(features_with_one_story)} features with only 1 story[/yellow]") + console.print("[dim]Adding edge case stories for better tool compliance...[/dim]") + + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + edge_case_task = progress.add_task( + "[cyan]Adding edge case stories...", + total=len(features_with_one_story), + ) + + for idx, feature in enumerate(features_with_one_story): + edge_case_title = f"As a user, I receive error handling for {feature.title.lower()}" + edge_case_acceptance = [ + "Must verify error conditions are handled gracefully", + "Must validate error messages are clear and actionable", + "Must ensure system recovers from errors", + ] + + existing_story_nums = [] + for s in feature.stories: + parts = s.key.split("-") + if len(parts) >= 2: + last_part = parts[-1] + if last_part.isdigit(): + existing_story_nums.append(int(last_part)) + + next_story_num = max(existing_story_nums) + 1 if existing_story_nums else 2 + feature_key_parts = feature.key.split("-") + if len(feature_key_parts) >= 2: + class_name = feature_key_parts[-1] + story_key = f"STORY-{class_name}-{next_story_num:03d}" + else: + story_key = f"STORY-{next_story_num:03d}" + + from specfact_cli.models.plan import Story + + edge_case_story = Story( + key=story_key, + title=edge_case_title, + acceptance=edge_case_acceptance, + story_points=3, + value_points=None, + confidence=0.8, + scenarios=None, + contracts=None, + ) + feature.stories.append(edge_case_story) + progress.update(edge_case_task, completed=idx + 1) + + progress.remove_task(edge_case_task) + + console.print(f"[green]✓ Added edge case stories to {len(features_with_one_story)} features[/green]") + + # Display enrichment summary (consistent with plan review --auto-enrich) + if enrichment_summary["features_updated"] > 0 or enrichment_summary["stories_updated"] > 0: + console.print( + f"[green]✓ Enhanced plan bundle: {enrichment_summary['features_updated']} features, " + f"{enrichment_summary['stories_updated']} stories updated[/green]" + ) + if enrichment_summary["acceptance_criteria_enhanced"] > 0: + console.print( + f"[dim] - Enhanced {enrichment_summary['acceptance_criteria_enhanced']} acceptance criteria[/dim]" + ) + if enrichment_summary["requirements_enhanced"] > 0: + console.print(f"[dim] - Enhanced {enrichment_summary['requirements_enhanced']} requirements[/dim]") + if enrichment_summary["tasks_enhanced"] > 0: + console.print(f"[dim] - Enhanced {enrichment_summary['tasks_enhanced']} tasks[/dim]") + else: + console.print("[green]✓ Plan bundle is already well-specified (no enrichments needed)[/green]") + + console.print("[green]✓ Tool enrichment complete[/green]") + + except Exception as e: + console.print(f"[yellow]⚠ Tool enrichment failed: {e}[/yellow]") + console.print("[dim]Plan is still valid, but may need manual enrichment[/dim]") + + +def _generate_report( + repo: Path, + bundle_dir: Path, + plan_bundle: PlanBundle, + confidence: float, + enrichment: Path | None, + report: Path, +) -> None: + """Generate import report.""" + # Ensure report directory exists (Phase 8.5: bundle-specific reports) + report.parent.mkdir(parents=True, exist_ok=True) + + total_stories = sum(len(f.stories) for f in plan_bundle.features) + + report_content = f"""# Brownfield Import Report + +## Repository: {repo} + +## Summary +- **Features Found**: {len(plan_bundle.features)} +- **Total Stories**: {total_stories} +- **Detected Themes**: {", ".join(plan_bundle.product.themes)} +- **Confidence Threshold**: {confidence} +""" + if enrichment: + report_content += f""" +## Enrichment Applied +- **Enrichment Report**: `{enrichment}` +""" + report_content += f""" +## Output Files +- **Project Bundle**: `{bundle_dir}` +- **Import Report**: `{report}` + +## Features + +""" + for feature in plan_bundle.features: + report_content += f"### {feature.title} ({feature.key})\n" + report_content += f"- **Stories**: {len(feature.stories)}\n" + report_content += f"- **Confidence**: {feature.confidence}\n" + report_content += f"- **Outcomes**: {', '.join(feature.outcomes)}\n\n" + + report.write_text(report_content) + console.print(f"[dim]Report written to: {report}[/dim]") + + +@app.command("from-bridge") +def from_bridge( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository with external tool artifacts", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Output/Results + report: Path | None = typer.Option( + None, + "--report", + help="Path to write import report", + ), + out_branch: str = typer.Option( + "feat/specfact-migration", + "--out-branch", + help="Feature branch name for migration", + ), + # Behavior/Options + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Preview changes without writing files", + ), + write: bool = typer.Option( + False, + "--write", + help="Write changes to disk", + ), + force: bool = typer.Option( + False, + "--force", + help="Overwrite existing files", + ), + # Advanced/Configuration + adapter: str = typer.Option( + "speckit", + "--adapter", + help="Adapter type: speckit, openspec, generic-markdown (available). Default: auto-detect", + hidden=True, # Hidden by default, shown with --help-advanced + ), +) -> None: + """ + Convert external tool project to SpecFact contract format using bridge architecture. + + This command uses bridge configuration to scan an external tool repository + (e.g., Spec-Kit, OpenSpec, generic-markdown), parse its structure, and generate equivalent + SpecFact contracts, protocols, and plans. + + Supported adapters (code/spec adapters only): + - speckit: Spec-Kit projects (specs/, .specify/) - import & sync + - openspec: OpenSpec integration (openspec/) - read-only sync (Phase 1) + - generic-markdown: Generic markdown-based specifications - import & sync + + Note: For backlog synchronization (GitHub Issues, ADO, Linear, Jira), use 'specfact sync bridge' instead. + + **Parameter Groups:** + - **Target/Input**: --repo + - **Output/Results**: --report, --out-branch + - **Behavior/Options**: --dry-run, --write, --force + - **Advanced/Configuration**: --adapter + + **Examples:** + specfact import from-bridge --repo ./my-project --adapter speckit --write + specfact import from-bridge --repo ./my-project --write # Auto-detect adapter + specfact import from-bridge --repo ./my-project --dry-run # Preview changes + """ + from specfact_cli.sync.bridge_probe import BridgeProbe + from specfact_cli.utils.structure import SpecFactStructure + + if is_debug_mode(): + debug_log_operation( + "command", + "import from-bridge", + "started", + extra={"repo": str(repo), "adapter": adapter, "dry_run": dry_run, "write": write}, + ) + debug_print("[dim]import from-bridge: started[/dim]") + + # Auto-detect adapter if not specified + if adapter == "speckit" or adapter == "auto": + probe = BridgeProbe(repo) + detected_capabilities = probe.detect() + # Use detected tool directly (e.g., "speckit", "openspec", "github") + # BridgeProbe already tries all registered adapters + if detected_capabilities.tool == "unknown": + if is_debug_mode(): + debug_log_operation( + "command", + "import from-bridge", + "failed", + error="Could not auto-detect adapter", + extra={"reason": "adapter_unknown"}, + ) + console.print("[bold red]✗[/bold red] Could not auto-detect adapter") + console.print("[dim]No registered adapter detected this repository structure[/dim]") + registered = AdapterRegistry.list_adapters() + console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") + console.print("[dim]Tip: Specify adapter explicitly with --adapter <adapter>[/dim]") + raise typer.Exit(1) + adapter = detected_capabilities.tool + + # Validate adapter using registry (no hard-coded checks) + adapter_lower = adapter.lower() + if not AdapterRegistry.is_registered(adapter_lower): + console.print(f"[bold red]✗[/bold red] Unsupported adapter: {adapter}") + registered = AdapterRegistry.list_adapters() + console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") + raise typer.Exit(1) + + # Get adapter from registry (universal pattern - no hard-coded checks) + adapter_instance = AdapterRegistry.get_adapter(adapter_lower) + if adapter_instance is None: + console.print(f"[bold red]✗[/bold red] Adapter '{adapter_lower}' not found in registry") + console.print("[dim]Available adapters: " + ", ".join(AdapterRegistry.list_adapters()) + "[/dim]") + raise typer.Exit(1) + + # Use adapter's detect() method + from specfact_cli.sync.bridge_probe import BridgeProbe + + probe = BridgeProbe(repo) + capabilities = probe.detect() + bridge_config = probe.auto_generate_bridge(capabilities) if capabilities.tool != "unknown" else None + + if not adapter_instance.detect(repo, bridge_config): + console.print(f"[bold red]✗[/bold red] Not a {adapter_lower} repository") + console.print(f"[dim]Expected: {adapter_lower} structure[/dim]") + console.print("[dim]Tip: Use 'specfact sync bridge probe' to auto-detect tool configuration[/dim]") + raise typer.Exit(1) + + console.print(f"[bold green]✓[/bold green] Detected {adapter_lower} repository") + + # Get adapter capabilities for adapter-specific operations + capabilities = adapter_instance.get_capabilities(repo, bridge_config) + + telemetry_metadata = { + "adapter": adapter, + "dry_run": dry_run, + "write": write, + "force": force, + } + + with telemetry.track_command("import.from_bridge", telemetry_metadata) as record: + console.print(f"[bold cyan]Importing {adapter_lower} project from:[/bold cyan] {repo}") + + # Reject backlog adapters - they should use 'sync bridge' instead + backlog_adapters = {"github", "ado", "linear", "jira", "notion"} + if adapter_lower in backlog_adapters: + console.print( + f"[bold yellow]⚠[/bold yellow] '{adapter_lower}' is a backlog adapter, not a code/spec adapter" + ) + console.print( + f"[dim]Use 'specfact sync bridge --adapter {adapter_lower}' for backlog synchronization[/dim]" + ) + console.print( + "[dim]The 'import from-bridge' command is for importing code/spec projects (Spec-Kit, OpenSpec, generic-markdown)[/dim]" + ) + raise typer.Exit(1) + + # Use adapter for feature discovery (adapter-agnostic) + if dry_run: + # Discover features using adapter + features = adapter_instance.discover_features(repo, bridge_config) + console.print("[yellow]→ Dry run mode - no files will be written[/yellow]") + console.print("\n[bold]Detected Structure:[/bold]") + console.print( + f" - Specs Directory: {capabilities.specs_dir if hasattr(capabilities, 'specs_dir') else 'N/A'}" + ) + console.print(f" - Features Found: {len(features)}") + record({"dry_run": True, "features_found": len(features)}) + return + + if not write: + console.print("[yellow]→ Use --write to actually convert files[/yellow]") + console.print("[dim]Use --dry-run to preview changes[/dim]") + return + + # Ensure SpecFact structure exists + SpecFactStructure.ensure_structure(repo) + + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + # Step 1: Discover features from markdown artifacts (adapter-agnostic) + task = progress.add_task(f"Discovering {adapter_lower} features...", total=None) + # Use adapter's discover_features method (universal pattern) + features = adapter_instance.discover_features(repo, bridge_config) + + if not features: + console.print(f"[bold red]✗[/bold red] No features found in {adapter_lower} repository") + console.print("[dim]Expected: specs/*/spec.md files (or bridge-configured paths)[/dim]") + console.print("[dim]Tip: Use 'specfact sync bridge probe' to validate bridge configuration[/dim]") + raise typer.Exit(1) + progress.update(task, description=f"✓ Discovered {len(features)} features") + + # Step 2: Import artifacts using BridgeSync (adapter-agnostic) + from specfact_cli.sync.bridge_sync import BridgeSync + + bridge_sync = BridgeSync(repo, bridge_config=bridge_config) + protocol = None + plan_bundle = None + + # Import protocol if available + protocol_path = repo / ".specfact" / "protocols" / "workflow.protocol.yaml" + if protocol_path.exists(): + from specfact_cli.models.protocol import Protocol + from specfact_cli.utils.yaml_utils import load_yaml + + try: + protocol_data = load_yaml(protocol_path) + protocol = Protocol(**protocol_data) + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Protocol loading failed: {e}") + protocol = None + + # Import features using adapter's import_artifact method + # Use "main" as default bundle name for bridge imports + bundle_name = "main" + + # Ensure project bundle structure exists + from specfact_cli.utils.structure import SpecFactStructure + + SpecFactStructure.ensure_project_structure(base_path=repo, bundle_name=bundle_name) + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle_name) + + # Load or create project bundle + from specfact_cli.migrations.plan_migrator import get_latest_schema_version + from specfact_cli.models.project import BundleManifest, BundleVersions, Product, ProjectBundle + from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle + + if bundle_dir.exists() and (bundle_dir / "bundle.manifest.yaml").exists(): + plan_bundle = load_project_bundle(bundle_dir, validate_hashes=False) + else: + # Create initial bundle with latest schema version + manifest = BundleManifest( + versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + product = Product(themes=[], releases=[]) + plan_bundle = ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + product=product, + features={}, + ) + save_project_bundle(plan_bundle, bundle_dir, atomic=True) + + # Import specification artifacts for each feature (creates features) + task = progress.add_task("Importing specifications...", total=len(features)) + import_errors = [] + imported_count = 0 + for feature in features: + # Use original directory name for path resolution (feature_branch or spec_path) + # feature_key is normalized (uppercase/underscores), but we need original name for paths + feature_id = feature.get("feature_branch") # Original directory name + if not feature_id and "spec_path" in feature: + # Fallback: extract from spec_path if available + spec_path_str = feature["spec_path"] + if "/" in spec_path_str: + parts = spec_path_str.split("/") + # Find the directory name (should be before spec.md) + for i, part in enumerate(parts): + if part == "spec.md" and i > 0: + feature_id = parts[i - 1] + break + + # If still no feature_id, try to use feature_key but convert back to directory format + if not feature_id: + feature_key = feature.get("feature_key") or feature.get("key", "") + if feature_key: + # Convert normalized key back to directory name (ORDER_SERVICE -> order-service) + # This is a best-effort conversion + feature_id = feature_key.lower().replace("_", "-") + + if feature_id: + # Verify artifact path exists before importing (use original directory name) + try: + artifact_path = bridge_sync.resolve_artifact_path("specification", feature_id, bundle_name) + if not artifact_path.exists(): + error_msg = f"Artifact not found for {feature_id}: {artifact_path}" + import_errors.append(error_msg) + console.print(f"[yellow]⚠[/yellow] {error_msg}") + progress.update(task, advance=1) + continue + except Exception as e: + error_msg = f"Failed to resolve artifact path for {feature_id}: {e}" + import_errors.append(error_msg) + console.print(f"[yellow]⚠[/yellow] {error_msg}") + progress.update(task, advance=1) + continue + + # Import specification artifact (use original directory name for path resolution) + result = bridge_sync.import_artifact("specification", feature_id, bundle_name) + if result.success: + imported_count += 1 + else: + error_msg = f"Failed to import specification for {feature_id}: {', '.join(result.errors)}" + import_errors.append(error_msg) + console.print(f"[yellow]⚠[/yellow] {error_msg}") + progress.update(task, advance=1) + + if import_errors: + console.print(f"[bold yellow]⚠[/bold yellow] {len(import_errors)} specification import(s) had issues") + for error in import_errors[:5]: # Show first 5 errors + console.print(f" - {error}") + if len(import_errors) > 5: + console.print(f" ... and {len(import_errors) - 5} more") + + if imported_count == 0 and len(features) > 0: + console.print("[bold red]✗[/bold red] No specifications were imported successfully") + raise typer.Exit(1) + + # Reload bundle after importing specifications + plan_bundle = load_project_bundle(bundle_dir, validate_hashes=False) + + # Optionally import plan artifacts to add plan information + task = progress.add_task("Importing plans...", total=len(features)) + for feature in features: + feature_key = feature.get("feature_key") or feature.get("key", "") + if feature_key: + # Import plan artifact (adds plan information to existing features) + result = bridge_sync.import_artifact("plan", feature_key, bundle_name) + if not result.success and result.errors: + # Plan import is optional, only warn if there are actual errors + pass + progress.update(task, advance=1) + + # Reload bundle after importing plans + plan_bundle = load_project_bundle(bundle_dir, validate_hashes=False) + + # For Spec-Kit adapter, also generate protocol, Semgrep rules and GitHub Actions if supported + # These are Spec-Kit-specific enhancements, not core import functionality + if adapter_lower == "speckit": + from specfact_cli.importers.speckit_converter import SpecKitConverter + + converter = SpecKitConverter(repo) + # Step 3: Generate protocol (Spec-Kit specific) + if hasattr(converter, "convert_protocol"): + task = progress.add_task("Generating protocol...", total=None) + try: + _protocol = converter.convert_protocol() # Generates .specfact/protocols/workflow.protocol.yaml + progress.update(task, description="✓ Protocol generated") + # Reload protocol after generation + protocol_path = repo / ".specfact" / "protocols" / "workflow.protocol.yaml" + if protocol_path.exists(): + from specfact_cli.models.protocol import Protocol + from specfact_cli.utils.yaml_utils import load_yaml + + try: + protocol_data = load_yaml(protocol_path) + protocol = Protocol(**protocol_data) + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Protocol loading failed: {e}") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Protocol generation failed: {e}") + + # Step 4: Generate Semgrep rules (Spec-Kit specific) + if hasattr(converter, "generate_semgrep_rules"): + task = progress.add_task("Generating Semgrep rules...", total=None) + try: + _semgrep_path = converter.generate_semgrep_rules() # Not used yet + progress.update(task, description="✓ Semgrep rules generated") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Semgrep rules generation failed: {e}") + + # Step 5: Generate GitHub Action workflow (Spec-Kit specific) + if hasattr(converter, "generate_github_action"): + task = progress.add_task("Generating GitHub Action workflow...", total=None) + repo_name = repo.name if isinstance(repo, Path) else None + try: + _workflow_path = converter.generate_github_action(repo_name=repo_name) # Not used yet + progress.update(task, description="✓ GitHub Action workflow generated") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] GitHub Action workflow generation failed: {e}") + + # Handle file existence errors (conversion already completed above with individual try/except blocks) + # If plan_bundle or protocol are None, try to load existing ones + if plan_bundle is None or protocol is None: + from specfact_cli.migrations.plan_migrator import get_current_schema_version + from specfact_cli.models.plan import PlanBundle, Product + + if plan_bundle is None: + plan_bundle = PlanBundle( + version=get_current_schema_version(), + idea=None, + business=None, + product=Product(themes=[], releases=[]), + features=[], + clarifications=None, + metadata=None, + ) + if protocol is None: + # Try to load existing protocol if available + protocol_path = repo / ".specfact" / "protocols" / "workflow.protocol.yaml" + if protocol_path.exists(): + from specfact_cli.models.protocol import Protocol + from specfact_cli.utils.yaml_utils import load_yaml + + try: + protocol_data = load_yaml(protocol_path) + protocol = Protocol(**protocol_data) + except Exception: + pass + + # Generate report + if report and protocol and plan_bundle: + report_content = f"""# {adapter_lower.upper()} Import Report + +## Repository: {repo} +## Adapter: {adapter_lower} + +## Summary +- **States Found**: {len(protocol.states)} +- **Transitions**: {len(protocol.transitions)} +- **Features Extracted**: {len(plan_bundle.features)} +- **Total Stories**: {sum(len(f.stories) for f in plan_bundle.features)} + +## Generated Files +- **Protocol**: `.specfact/protocols/workflow.protocol.yaml` +- **Plan Bundle**: `.specfact/projects/<bundle-name>/` +- **Semgrep Rules**: `.semgrep/async-anti-patterns.yml` +- **GitHub Action**: `.github/workflows/specfact-gate.yml` + +## States +{chr(10).join(f"- {state}" for state in protocol.states)} + +## Features +{chr(10).join(f"- {f.title} ({f.key})" for f in plan_bundle.features)} +""" + report.parent.mkdir(parents=True, exist_ok=True) + report.write_text(report_content, encoding="utf-8") + console.print(f"[dim]Report written to: {report}[/dim]") + + # Save plan bundle as ProjectBundle (modular structure) + if plan_bundle: + from specfact_cli.models.plan import PlanBundle + from specfact_cli.models.project import ProjectBundle + + bundle_name = "main" # Default bundle name for bridge imports + # Check if plan_bundle is already a ProjectBundle or needs conversion + if isinstance(plan_bundle, ProjectBundle): + project_bundle = plan_bundle + elif isinstance(plan_bundle, PlanBundle): + project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle_name) + else: + # Unknown type, skip conversion + project_bundle = None + + if project_bundle: + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle_name) + SpecFactStructure.ensure_project_structure(base_path=repo, bundle_name=bundle_name) + save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) + console.print(f"[dim]Project bundle: .specfact/projects/{bundle_name}/[/dim]") + + console.print("[bold green]✓[/bold green] Import complete!") + console.print("[dim]Protocol: .specfact/protocols/workflow.protocol.yaml[/dim]") + console.print("[dim]Plan: .specfact/projects/<bundle-name>/ (modular bundle)[/dim]") + console.print("[dim]Semgrep Rules: .semgrep/async-anti-patterns.yml[/dim]") + console.print("[dim]GitHub Action: .github/workflows/specfact-gate.yml[/dim]") + + if is_debug_mode(): + debug_log_operation( + "command", + "import from-bridge", + "success", + extra={ + "protocol_states": len(protocol.states) if protocol else 0, + "features": len(plan_bundle.features) if plan_bundle else 0, + }, + ) + debug_print("[dim]import from-bridge: success[/dim]") + + # Record import results + if protocol and plan_bundle: + record( + { + "states_found": len(protocol.states), + "transitions": len(protocol.transitions), + "features_extracted": len(plan_bundle.features), + "total_stories": sum(len(f.stories) for f in plan_bundle.features), + } + ) + + +@app.command("from-code") +@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda confidence: 0.0 <= confidence <= 1.0, "Confidence must be 0.0-1.0") +@beartype +def from_code( + # Target/Input + bundle: str | None = typer.Argument( + None, + help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", + ), + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository to import. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + entry_point: Path | None = typer.Option( + None, + "--entry-point", + help="Subdirectory path for partial analysis (relative to repo root). Analyzes only files within this directory and subdirectories. Default: None (analyze entire repo)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + enrichment: Path | None = typer.Option( + None, + "--enrichment", + help="Path to Markdown enrichment report from LLM (applies missing features, confidence adjustments, business context). Default: None", + hidden=True, # Hidden by default, shown with --help-advanced + ), + # Output/Results + report: Path | None = typer.Option( + None, + "--report", + help="Path to write analysis report. Default: bundle-specific .specfact/projects/<bundle-name>/reports/brownfield/analysis-<timestamp>.md (Phase 8.5)", + ), + # Behavior/Options + shadow_only: bool = typer.Option( + False, + "--shadow-only", + help="Shadow mode - observe without enforcing. Default: False", + ), + enrich_for_speckit: bool = typer.Option( + True, + "--enrich-for-speckit/--no-enrich-for-speckit", + help="Automatically enrich plan for Spec-Kit compliance (uses PlanEnricher to enhance vague acceptance criteria, incomplete requirements, generic tasks, and adds edge case stories for features with only 1 story). Default: True (enabled)", + ), + force: bool = typer.Option( + False, + "--force", + help="Force full regeneration of all artifacts, ignoring incremental changes. Default: False", + ), + include_tests: bool = typer.Option( + False, + "--include-tests/--exclude-tests", + help="Include/exclude test files in relationship mapping and dependency graph. Default: --exclude-tests (test files are excluded by default). Test files are never extracted as features (they're validation artifacts, not specifications). Use --include-tests only if you need test files in the dependency graph.", + ), + revalidate_features: bool = typer.Option( + False, + "--revalidate-features/--no-revalidate-features", + help="Re-validate and re-analyze existing features even if source files haven't changed. Useful when analysis logic improved or confidence threshold changed. Default: False (only re-analyze if files changed)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + # Advanced/Configuration (hidden by default, use --help-advanced to see) + confidence: float = typer.Option( + 0.5, + "--confidence", + min=0.0, + max=1.0, + help="Minimum confidence score for features. Default: 0.5 (range: 0.0-1.0)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + key_format: str = typer.Option( + "classname", + "--key-format", + help="Feature key format: 'classname' (FEATURE-CLASSNAME) or 'sequential' (FEATURE-001). Default: classname", + hidden=True, # Hidden by default, shown with --help-advanced + ), +) -> None: + """ + Import plan bundle from existing codebase (one-way import). + + Analyzes code structure using AI-first semantic understanding or AST-based fallback + to generate a plan bundle that represents the current system. + + Supports dual-stack enrichment workflow: apply LLM-generated enrichment report + to refine the auto-detected plan bundle (add missing features, adjust confidence scores, + add business context). + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --repo, --entry-point, --enrichment + - **Output/Results**: --report + - **Behavior/Options**: --shadow-only, --enrich-for-speckit, --force, --include-tests/--exclude-tests (default: exclude) + - **Advanced/Configuration**: --confidence, --key-format + + **Examples:** + specfact import from-code legacy-api --repo . + specfact import from-code auth-module --repo . --enrichment enrichment-report.md + specfact import from-code my-project --repo . --confidence 0.7 --shadow-only + specfact import from-code my-project --repo . --force # Force full regeneration + specfact import from-code my-project --repo . # Test files excluded by default + specfact import from-code my-project --repo . --include-tests # Include test files in dependency graph + """ + from specfact_cli.cli import get_current_mode + from specfact_cli.modes import get_router + from specfact_cli.utils.structure import SpecFactStructure + + if is_debug_mode(): + debug_log_operation( + "command", + "import from-code", + "started", + extra={"bundle": bundle, "repo": str(repo), "force": force, "shadow_only": shadow_only}, + ) + debug_print("[dim]import from-code: started[/dim]") + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None: + if is_debug_mode(): + debug_log_operation( + "command", + "import from-code", + "failed", + error="Bundle name required", + extra={"reason": "no_bundle"}, + ) + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + mode = get_current_mode() + + # Route command based on mode + router = get_router() + routing_result = router.route("import from-code", mode, {"repo": str(repo), "confidence": confidence}) + + python_file_count = _count_python_files(repo) + + from specfact_cli.utils.structure import SpecFactStructure + + # Ensure .specfact structure exists in the repository being imported + SpecFactStructure.ensure_structure(repo) + + # Get project bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + + # Check for incremental processing (if bundle exists) + incremental_changes = _check_incremental_changes(bundle_dir, repo, enrichment, force) + + # Ensure project structure exists + SpecFactStructure.ensure_project_structure(base_path=repo, bundle_name=bundle) + + if report is None: + # Use bundle-specific report path (Phase 8.5) + report = SpecFactStructure.get_bundle_brownfield_report_path(bundle_name=bundle, base_path=repo) + + console.print(f"[bold cyan]Importing repository:[/bold cyan] {repo}") + console.print(f"[bold cyan]Project bundle:[/bold cyan] {bundle}") + console.print(f"[dim]Confidence threshold: {confidence}[/dim]") + + if shadow_only: + console.print("[yellow]→ Shadow mode - observe without enforcement[/yellow]") + + telemetry_metadata = { + "bundle": bundle, + "mode": mode.value, + "execution_mode": routing_result.execution_mode, + "files_analyzed": python_file_count, + "shadow_mode": shadow_only, + } + + # Phase 4.10: CI Performance Optimization - Track performance + with ( + track_performance("import.from_code", threshold=5.0) as perf_monitor, + telemetry.track_command("import.from_code", telemetry_metadata) as record_event, + ): + try: + # If enrichment is provided, try to load existing bundle + # Note: For now, enrichment workflow needs to be updated for modular bundles + # TODO: Phase 4 - Update enrichment to work with modular bundles + plan_bundle: PlanBundle | None = None + + # Check if we need to regenerate features (requires full codebase scan) + # Features need regeneration if: + # - No incremental changes detected (new bundle) + # - Source files actually changed (not just missing relationships/contracts) + # - Revalidation requested (--revalidate-features flag) + # + # Important: Missing relationships/contracts alone should NOT trigger feature regeneration. + # If features exist (from checkpoint), we can regenerate relationships/contracts separately. + # Only regenerate features if source files actually changed. + should_regenerate_features = incremental_changes is None or revalidate_features + + # Check if source files actually changed (not just missing artifacts) + # If features exist from checkpoint, only regenerate if source files changed + if incremental_changes and not should_regenerate_features: + # Check if we have features saved (checkpoint exists) + features_dir = bundle_dir / "features" + has_features = features_dir.exists() and any(features_dir.glob("*.yaml")) + + if has_features: + # Features exist from checkpoint - check if source files actually changed + # The incremental_check already computed this, but we need to verify: + # If relationships/contracts need regeneration, it could be because: + # 1. Source files changed (should regenerate features) + # 2. Relationships/contracts are just missing (should NOT regenerate features) + # + # We can tell the difference by checking if the incremental_check detected + # source file changes. If it did, relationships will be True. + # But if relationships are True just because they're missing (not because files changed), + # we should NOT regenerate features. + # + # The incremental_check function already handles this correctly - it only marks + # relationships as needing regeneration if source files changed OR if relationships don't exist. + # So we need to check if source files actually changed by examining feature source tracking. + try: + # Load bundle to check source tracking (we'll reuse this later if we don't regenerate) + existing_bundle = _load_existing_bundle(bundle_dir) + if existing_bundle and existing_bundle.features: + # Check if any source files actually changed + # If features don't have source_tracking yet (cancelled before source linking), + # we can't check file changes, so assume files haven't changed and reuse features + source_files_changed = False + has_source_tracking = False + + for feature in existing_bundle.features: + if feature.source_tracking: + has_source_tracking = True + # Check implementation files + for impl_file in feature.source_tracking.implementation_files: + file_path = repo / impl_file + if file_path.exists() and feature.source_tracking.has_changed(file_path): + source_files_changed = True + break + if source_files_changed: + break + # Check test files + for test_file in feature.source_tracking.test_files: + file_path = repo / test_file + if file_path.exists() and feature.source_tracking.has_changed(file_path): + source_files_changed = True + break + if source_files_changed: + break + + # Only regenerate features if source files actually changed + # If features don't have source_tracking yet, assume files haven't changed + # (they were just discovered, not yet linked) + if source_files_changed: + should_regenerate_features = True + console.print("[yellow]⚠[/yellow] Source files changed - will re-analyze features\n") + else: + # Source files haven't changed (or features don't have source_tracking yet) + # Don't regenerate features, just regenerate relationships/contracts + if has_source_tracking: + console.print( + "[dim]✓[/dim] Features exist from checkpoint - will regenerate relationships/contracts only\n" + ) + else: + console.print( + "[dim]✓[/dim] Features exist from checkpoint (no source tracking yet) - will link source files and regenerate relationships/contracts\n" + ) + # Reuse the loaded bundle instead of loading again later + plan_bundle = existing_bundle + except Exception: + # If we can't check, be conservative and don't regenerate features + # (relationships/contracts will be regenerated separately) + pass + + # If revalidation is requested, show message + if revalidate_features and incremental_changes: + console.print( + "[yellow]⚠[/yellow] --revalidate-features enabled: Will re-analyze features even if files unchanged\n" + ) + + # If we have incremental changes and features don't need regeneration, load existing bundle + # (unless we already loaded it above to check for source file changes) + if incremental_changes and not should_regenerate_features and not enrichment: + if plan_bundle is None: + plan_bundle = _load_existing_bundle(bundle_dir) + if plan_bundle: + # Validate existing features to ensure they're still valid + # Only validate if we're actually using existing features (not regenerating) + validation_results = _validate_existing_features(plan_bundle, repo) + + # Report validation results + valid_count = len(validation_results["valid_features"]) + orphaned_count = len(validation_results["orphaned_features"]) + total_checked = validation_results["total_checked"] + + # Only show validation warnings if there are actual problems (orphaned or missing files) + # Don't warn about features with no stories - that's normal for newly discovered features + features_with_missing_files = [ + key + for key in validation_results["invalid_features"] + if validation_results["missing_files"].get(key) + ] + + if orphaned_count > 0 or features_with_missing_files: + console.print("[cyan]🔍 Validating existing features...[/cyan]") + console.print( + f"[yellow]⚠[/yellow] Feature validation found issues: {valid_count}/{total_checked} valid, " + f"{orphaned_count} orphaned, {len(features_with_missing_files)} with missing files" + ) + + # Show orphaned features + if orphaned_count > 0: + console.print("[red] Orphaned features (all source files missing):[/red]") + for feature_key in validation_results["orphaned_features"][:5]: # Show first 5 + missing = validation_results["missing_files"].get(feature_key, []) + console.print(f" [dim]- {feature_key}[/dim] ({len(missing)} missing files)") + if orphaned_count > 5: + console.print(f" [dim]... and {orphaned_count - 5} more[/dim]") + + # Show invalid features (only those with missing files) + if features_with_missing_files: + console.print("[yellow] Features with missing files:[/yellow]") + for feature_key in features_with_missing_files[:5]: # Show first 5 + missing = validation_results["missing_files"].get(feature_key, []) + console.print(f" [dim]- {feature_key}[/dim] ({len(missing)} missing files)") + if len(features_with_missing_files) > 5: + console.print(f" [dim]... and {len(features_with_missing_files) - 5} more[/dim]") + + console.print( + "[dim] Tip: Use --revalidate-features to re-analyze features and fix issues[/dim]\n" + ) + # Don't show validation message if all features are valid (no noise) + + console.print("[dim]Skipping codebase analysis (features unchanged)[/dim]\n") + + if plan_bundle is None: + # Need to run full codebase analysis (either no bundle exists, or features need regeneration) + # If enrichment is provided, try to load existing bundle first (enrichment needs existing bundle) + if enrichment: + plan_bundle = _load_existing_bundle(bundle_dir) + if plan_bundle is None: + console.print( + "[bold red]✗ Cannot apply enrichment: No existing bundle found. Run import without --enrichment first.[/bold red]" + ) + raise typer.Exit(1) + + if plan_bundle is None: + # Phase 4.9 & 4.10: Track codebase analysis performance + with perf_monitor.track("analyze_codebase", {"files": python_file_count}): + # Phase 4.9: Create callback for incremental results + def on_incremental_update(features_count: int, themes: list[str]) -> None: + """Callback for incremental results (Phase 4.9: Quick Start Optimization).""" + # Feature count updates are shown in the progress bar description, not as separate lines + # No intermediate messages needed - final summary provides all information + + plan_bundle = _analyze_codebase( + repo, + entry_point, + bundle, + confidence, + key_format, + routing_result, + incremental_callback=on_incremental_update, + ) + if plan_bundle is None: + console.print("[bold red]✗ Failed to analyze codebase[/bold red]") + raise typer.Exit(1) + + # Phase 4.9: Analysis complete (results shown in progress bar and final summary) + console.print(f"[green]✓[/green] Found {len(plan_bundle.features)} features") + console.print(f"[green]✓[/green] Detected themes: {', '.join(plan_bundle.product.themes)}") + total_stories = sum(len(f.stories) for f in plan_bundle.features) + console.print(f"[green]✓[/green] Total stories: {total_stories}\n") + record_event({"features_detected": len(plan_bundle.features), "stories_detected": total_stories}) + + # Save features immediately after analysis to avoid losing work if process is cancelled + # This ensures we can resume from this point if interrupted during expensive operations + console.print("[cyan]💾 Saving features (checkpoint)...[/cyan]") + project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) + save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) + console.print("[dim]✓ Features saved (can resume if interrupted)[/dim]\n") + + # Ensure plan_bundle is not None before proceeding + if plan_bundle is None: + console.print("[bold red]✗ No plan bundle available[/bold red]") + raise typer.Exit(1) + + # Add source tracking to features + with perf_monitor.track("update_source_tracking"): + _update_source_tracking(plan_bundle, repo) + + # Enhanced Analysis Phase: Extract relationships, contracts, and graph dependencies + # Check if we need to regenerate these artifacts + # Note: enrichment doesn't force full regeneration - only new features need contracts + should_regenerate_relationships = incremental_changes is None or incremental_changes.get( + "relationships", True + ) + should_regenerate_graph = incremental_changes is None or incremental_changes.get("graph", True) + should_regenerate_contracts = incremental_changes is None or incremental_changes.get("contracts", True) + should_regenerate_enrichment = incremental_changes is None or incremental_changes.get( + "enrichment_context", True + ) + # If enrichment is provided, ensure bundle is regenerated to apply it + # This ensures enrichment is applied even if no source files changed + if enrichment and incremental_changes: + # Force bundle regeneration to apply enrichment + incremental_changes["bundle"] = True + + # Track features before enrichment to detect new ones that need contracts + features_before_enrichment = {f.key for f in plan_bundle.features} if enrichment else set() + + # Phase 4.10: Track relationship extraction performance + with perf_monitor.track("extract_relationships_and_graph"): + relationships, _graph_summary = _extract_relationships_and_graph( + repo, + entry_point, + bundle_dir, + incremental_changes, + plan_bundle, + should_regenerate_relationships, + should_regenerate_graph, + include_tests, + ) + + # Apply enrichment BEFORE contract extraction so new features get contracts + if enrichment: + with perf_monitor.track("apply_enrichment"): + plan_bundle = _apply_enrichment(enrichment, plan_bundle, record_event) + + # After enrichment, check if new features were added that need contracts + features_after_enrichment = {f.key for f in plan_bundle.features} + new_features_added = features_after_enrichment - features_before_enrichment + + # If new features were added, we need to extract contracts for them + # Mark contracts for regeneration if new features were added + if new_features_added: + console.print( + f"[dim]Note: {len(new_features_added)} new feature(s) from enrichment will get contracts extracted[/dim]" + ) + # New features need contracts, so ensure contract extraction runs + if incremental_changes and not incremental_changes.get("contracts", False): + # Only regenerate contracts if we have new features, not all contracts + should_regenerate_contracts = True + + # Phase 4.10: Track contract extraction performance + with perf_monitor.track("extract_contracts"): + contracts_data = _extract_contracts( + repo, bundle_dir, plan_bundle, should_regenerate_contracts, record_event, force=force + ) + + # Phase 4.10: Track enrichment context building performance + with perf_monitor.track("build_enrichment_context"): + _build_enrichment_context( + bundle_dir, + repo, + plan_bundle, + relationships, + contracts_data, + should_regenerate_enrichment, + record_event, + ) + + # Save bundle if needed + with perf_monitor.track("save_bundle"): + _save_bundle_if_needed( + plan_bundle, + bundle, + bundle_dir, + incremental_changes, + should_regenerate_relationships, + should_regenerate_graph, + should_regenerate_contracts, + should_regenerate_enrichment, + ) + + console.print("\n[bold green]✓ Import complete![/bold green]") + console.print(f"[dim]Project bundle written to: {bundle_dir}[/dim]") + + # Validate API specs (both repo-level and bundle contracts) + with perf_monitor.track("validate_api_specs"): + _validate_api_specs(repo, bundle_dir=bundle_dir, plan_bundle=plan_bundle) + + # Phase 4.9: Suggest next steps (Quick Start Optimization) + _suggest_next_steps(repo, bundle, plan_bundle) + + # Suggest constitution bootstrap + _suggest_constitution_bootstrap(repo) + + # Enrich for tool compliance if requested + if enrich_for_speckit: + if plan_bundle is None: + console.print("[yellow]⚠ Cannot enrich: plan bundle is None[/yellow]") + else: + _enrich_for_speckit_compliance(plan_bundle) + + # Generate report + if plan_bundle is None: + console.print("[bold red]✗ Cannot generate report: plan bundle is None[/bold red]") + raise typer.Exit(1) + + _generate_report(repo, bundle_dir, plan_bundle, confidence, enrichment, report) + + if is_debug_mode(): + debug_log_operation( + "command", + "import from-code", + "success", + extra={"bundle": bundle, "bundle_dir": str(bundle_dir), "report": str(report)}, + ) + debug_print("[dim]import from-code: success[/dim]") + + # Phase 4.10: Print performance report if slow operations detected + perf_report = perf_monitor.get_report() + if perf_report.slow_operations and not os.environ.get("CI"): + # Only show in non-CI mode (interactive) + perf_report.print_summary() + + except KeyboardInterrupt: + # Re-raise KeyboardInterrupt immediately (don't catch it here) + raise + except typer.Exit: + # Re-raise typer.Exit (used for clean exits) + raise + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "import from-code", + "failed", + error=str(e), + extra={"reason": type(e).__name__, "bundle": bundle}, + ) + console.print(f"[bold red]✗ Import failed:[/bold red] {e}") + raise typer.Exit(1) from e diff --git a/src/specfact_cli/modules/init/src/__init__.py b/src/specfact_cli/modules/init/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/init/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/init/src/app.py b/src/specfact_cli/modules/init/src/app.py index 0d3f4b6a..7bdf74f6 100644 --- a/src/specfact_cli/modules/init/src/app.py +++ b/src/specfact_cli/modules/init/src/app.py @@ -1,6 +1,6 @@ -"""Init command: re-export from commands package.""" +"""init command entrypoint.""" -from specfact_cli.commands.init import app +from specfact_cli.modules.init.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py new file mode 100644 index 00000000..d6cf9fd1 --- /dev/null +++ b/src/specfact_cli/modules/init/src/commands.py @@ -0,0 +1,573 @@ +""" +Init command - Initialize SpecFact for IDE integration. + +This module provides the `specfact init` command to copy prompt templates +to IDE-specific locations for slash command integration. +""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.panel import Panel + +from specfact_cli import __version__ +from specfact_cli.registry.help_cache import run_discovery_and_write_cache +from specfact_cli.registry.module_packages import get_discovered_modules_for_state +from specfact_cli.registry.module_state import write_modules_state +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils.env_manager import EnvManager, build_tool_command, detect_env_manager +from specfact_cli.utils.ide_setup import ( + IDE_CONFIG, + copy_templates_to_ide, + detect_ide, + find_package_resources_path, + get_package_installation_locations, +) + + +def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: Console) -> None: + """ + Copy backlog field mapping templates to .specfact/templates/backlog/field_mappings/. + + Args: + repo_path: Repository path + force: Whether to overwrite existing files + console: Rich console for output + """ + import shutil + + # Find backlog field mapping templates directory + # Priority order: + # 1. Development: relative to project root (resources/templates/backlog/field_mappings) + # 2. Installed package: use importlib.resources to find package location + templates_dir: Path | None = None + + # Try 1: Development mode - relative to repo root + dev_templates_dir = (repo_path / "resources" / "templates" / "backlog" / "field_mappings").resolve() + if dev_templates_dir.exists(): + templates_dir = dev_templates_dir + else: + # Try 2: Installed package - use importlib.resources + try: + import importlib.resources + + resources_ref = importlib.resources.files("specfact_cli") + templates_ref = resources_ref / "resources" / "templates" / "backlog" / "field_mappings" + package_templates_dir = Path(str(templates_ref)).resolve() + if package_templates_dir.exists(): + templates_dir = package_templates_dir + except Exception: + # Fallback: try importlib.util.find_spec() + try: + import importlib.util + + spec = importlib.util.find_spec("specfact_cli") + if spec and spec.origin: + package_root = Path(spec.origin).parent.resolve() + package_templates_dir = ( + package_root / "resources" / "templates" / "backlog" / "field_mappings" + ).resolve() + if package_templates_dir.exists(): + templates_dir = package_templates_dir + except Exception: + pass + + if not templates_dir or not templates_dir.exists(): + # Templates not found - this is not critical, just skip + debug_print("[dim]Debug:[/dim] Backlog field mapping templates not found, skipping copy") + return + + # Create target directory + target_dir = repo_path / ".specfact" / "templates" / "backlog" / "field_mappings" + target_dir.mkdir(parents=True, exist_ok=True) + + # Copy templates (ado_*.yaml files) + template_files = list(templates_dir.glob("ado_*.yaml")) + copied_count = 0 + + for template_file in template_files: + target_file = target_dir / template_file.name + if target_file.exists() and not force: + continue # Skip if file exists and --force not used + try: + shutil.copy2(template_file, target_file) + copied_count += 1 + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Failed to copy {template_file.name}: {e}") + + if copied_count > 0: + console.print( + f"[green]✓[/green] Copied {copied_count} ADO field mapping template(s) to .specfact/templates/backlog/field_mappings/" + ) + elif template_files: + console.print("[dim]Backlog field mapping templates already exist (use --force to overwrite)[/dim]") + + +app = typer.Typer(help="Initialize SpecFact for IDE integration") +console = Console() + + +def _is_valid_repo_path(path: Path) -> bool: + """Check if path exists and is a directory.""" + return path.exists() and path.is_dir() + + +@app.callback(invoke_without_command=True) +@require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") +@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") +@ensure(lambda result: result is None, "Command should return None") +@beartype +def init( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Repository path (default: current directory)", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Behavior/Options + force: bool = typer.Option( + False, + "--force", + help="Overwrite existing files", + ), + install_deps: bool = typer.Option( + False, + "--install-deps", + help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) using detected environment manager", + ), + # Advanced/Configuration + ide: str = typer.Option( + "auto", + "--ide", + help="IDE type (auto, cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + enable_module: list[str] = typer.Option( + [], + "--enable-module", + help="Enable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", + ), + disable_module: list[str] = typer.Option( + [], + "--disable-module", + help="Disable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", + ), +) -> None: + """ + Initialize SpecFact for IDE integration. + + Copies prompt templates to IDE-specific locations so slash commands work. + This command detects the IDE type (or uses --ide flag) and copies + SpecFact prompt templates to the appropriate directory. + + Also copies backlog field mapping templates to `.specfact/templates/backlog/field_mappings/` + for custom ADO field mapping configuration. + + Examples: + specfact init # Auto-detect IDE + specfact init --ide cursor # Initialize for Cursor + specfact init --ide vscode --force # Overwrite existing files + specfact init --repo /path/to/repo --ide copilot + specfact init --install-deps # Install required packages for contract enhancement + """ + telemetry_metadata = { + "ide": ide, + "force": force, + "install_deps": install_deps, + } + + with telemetry.track_command("init", telemetry_metadata) as record: + # Update module state (enable/disable) and persist; then refresh help cache + modules_list = get_discovered_modules_for_state( + enable_ids=enable_module, + disable_ids=disable_module, + ) + if modules_list: + write_modules_state(modules_list) + disabled = [m["id"] for m in modules_list if m.get("enabled") is False] + if disabled: + console.print() + console.print( + f"[dim]The following modules are disabled by your configuration: {', '.join(disabled)}. " + "Re-enable with specfact init --enable-module <id>.[/dim]" + ) + run_discovery_and_write_cache(__version__) + + # Resolve repo path + repo_path = repo.resolve() + + # Detect IDE + detected_ide = detect_ide(ide) + ide_config = IDE_CONFIG[detected_ide] + ide_name = ide_config["name"] + + console.print() + console.print(Panel("[bold cyan]SpecFact IDE Setup[/bold cyan]", border_style="cyan")) + console.print(f"[cyan]Repository:[/cyan] {repo_path}") + console.print(f"[cyan]IDE:[/cyan] {ide_name} ({detected_ide})") + console.print() + + # Check for environment manager + env_info = detect_env_manager(repo_path) + if env_info.manager == EnvManager.UNKNOWN: + console.print() + console.print( + Panel( + "[bold yellow]⚠ No Compatible Environment Manager Detected[/bold yellow]", + border_style="yellow", + ) + ) + console.print( + "[yellow]SpecFact CLI works best with projects using standard Python project management tools.[/yellow]" + ) + console.print() + console.print("[dim]Supported tools:[/dim]") + console.print(" - hatch (detected from [tool.hatch] in pyproject.toml)") + console.print(" - poetry (detected from [tool.poetry] in pyproject.toml or poetry.lock)") + console.print(" - uv (detected from [tool.uv] in pyproject.toml, uv.lock, or uv.toml)") + console.print(" - pip (detected from requirements.txt or setup.py)") + console.print() + console.print( + "[dim]Note: SpecFact CLI will still work, but commands like 'specfact repro' may use direct tool invocation.[/dim]" + ) + console.print( + "[dim]Consider adding a pyproject.toml with [tool.hatch], [tool.poetry], or [tool.uv] for better integration.[/dim]" + ) + console.print() + + # Install dependencies if requested + if install_deps: + console.print() + console.print(Panel("[bold cyan]Installing Required Packages[/bold cyan]", border_style="cyan")) + if env_info.message: + console.print(f"[dim]{env_info.message}[/dim]") + + required_packages = [ + "beartype>=0.22.4", + "icontract>=2.7.1", + "crosshair-tool>=0.0.97", + "pytest>=8.4.2", + # Sidecar validation tools + # Note: specmatic may need separate installation (Java-based tool) + # Users may need to install specmatic separately: https://specmatic.in/documentation/getting_started.html + ] + console.print("[dim]Installing packages for contract enhancement:[/dim]") + for package in required_packages: + console.print(f" - {package}") + + # Build install command using environment manager detection + install_cmd = ["pip", "install", "-U", *required_packages] + install_cmd = build_tool_command(env_info, install_cmd) + + console.print(f"[dim]Using command: {' '.join(install_cmd)}[/dim]") + + try: + result = subprocess.run( + install_cmd, + capture_output=True, + text=True, + check=False, + cwd=str(repo_path), + timeout=300, # 5 minute timeout + ) + + if result.returncode == 0: + console.print() + console.print("[green]✓[/green] All required packages installed successfully") + record( + { + "deps_installed": True, + "packages_count": len(required_packages), + "env_manager": env_info.manager.value, + } + ) + else: + console.print() + console.print("[yellow]⚠[/yellow] Some packages failed to install") + console.print("[dim]Output:[/dim]") + if result.stdout: + console.print(result.stdout) + if result.stderr: + console.print(result.stderr) + console.print() + console.print("[yellow]You may need to install packages manually:[/yellow]") + # Provide environment-specific guidance + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record( + { + "deps_installed": False, + "error": result.stderr[:200] if result.stderr else "Unknown error", + "env_manager": env_info.manager.value, + } + ) + except subprocess.TimeoutExpired: + console.print() + console.print("[red]Error:[/red] Installation timed out after 5 minutes") + console.print("[yellow]You may need to install packages manually:[/yellow]") + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record({"deps_installed": False, "error": "timeout", "env_manager": env_info.manager.value}) + except FileNotFoundError: + console.print() + console.print("[red]Error:[/red] pip not found. Please install packages manually:") + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record({"deps_installed": False, "error": "pip not found", "env_manager": env_info.manager.value}) + except Exception as e: + console.print() + console.print(f"[red]Error:[/red] Failed to install packages: {e}") + console.print("[yellow]You may need to install packages manually:[/yellow]") + if env_info.manager == EnvManager.HATCH: + console.print(f" hatch run pip install {' '.join(required_packages)}") + elif env_info.manager == EnvManager.POETRY: + console.print(f" poetry add --dev {' '.join(required_packages)}") + elif env_info.manager == EnvManager.UV: + console.print(f" uv pip install {' '.join(required_packages)}") + else: + console.print(f" pip install {' '.join(required_packages)}") + record({"deps_installed": False, "error": str(e), "env_manager": env_info.manager.value}) + console.print() + + # Find templates directory + # Priority order: + # 1. Development: relative to project root (resources/prompts) + # 2. Installed package: use importlib.resources to find package location + # 3. Fallback: try relative to this file (for edge cases) + templates_dir: Path | None = None + package_templates_dir: Path | None = None + tried_locations: list[Path] = [] + + # Try 1: Development mode - relative to repo root + dev_templates_dir = (repo_path / "resources" / "prompts").resolve() + tried_locations.append(dev_templates_dir) + debug_print(f"[dim]Debug:[/dim] Trying development path: {dev_templates_dir}") + if dev_templates_dir.exists(): + templates_dir = dev_templates_dir + console.print(f"[green]✓[/green] Found templates at: {templates_dir}") + else: + debug_print("[dim]Debug:[/dim] Development path not found, trying installed package...") + # Try 2: Installed package - use importlib.resources + # Note: importlib is part of Python's standard library (since Python 3.1) + # importlib.resources.files() is available since Python 3.9 + # Since we require Python >=3.11, this should always be available + # However, we catch exceptions for robustness (minimal installations, edge cases) + package_templates_dir = None + try: + import importlib.resources + + debug_print("[dim]Debug:[/dim] Using importlib.resources.files() API...") + # Use files() API (Python 3.9+) - recommended approach + resources_ref = importlib.resources.files("specfact_cli") + templates_ref = resources_ref / "resources" / "prompts" + # Convert Traversable to Path + # Traversable objects can be converted to Path via str() + # Use resolve() to handle Windows/Linux/macOS path differences + package_templates_dir = Path(str(templates_ref)).resolve() + tried_locations.append(package_templates_dir) + debug_print(f"[dim]Debug:[/dim] Package templates path: {package_templates_dir}") + if package_templates_dir.exists(): + templates_dir = package_templates_dir + console.print(f"[green]✓[/green] Found templates at: {templates_dir}") + else: + console.print("[yellow]⚠[/yellow] Package templates path exists but directory not found") + except (ImportError, ModuleNotFoundError) as e: + console.print( + f"[yellow]⚠[/yellow] importlib.resources not available or module not found: {type(e).__name__}: {e}" + ) + debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") + except (TypeError, AttributeError, ValueError) as e: + console.print(f"[yellow]⚠[/yellow] Error converting Traversable to Path: {e}") + debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Unexpected error with importlib.resources: {type(e).__name__}: {e}") + debug_print("[dim]Debug:[/dim] Falling back to importlib.util.find_spec()...") + + # Fallback: importlib.util.find_spec() + comprehensive package location search + if not templates_dir or not templates_dir.exists(): + try: + import importlib.util + + debug_print("[dim]Debug:[/dim] Using importlib.util.find_spec() fallback...") + spec = importlib.util.find_spec("specfact_cli") + if spec and spec.origin: + # spec.origin points to __init__.py + # Go up to package root, then to resources/prompts + # Use resolve() for cross-platform compatibility + package_root = Path(spec.origin).parent.resolve() + package_templates_dir = (package_root / "resources" / "prompts").resolve() + tried_locations.append(package_templates_dir) + debug_print(f"[dim]Debug:[/dim] Package root from spec.origin: {package_root}") + debug_print(f"[dim]Debug:[/dim] Templates path from spec: {package_templates_dir}") + if package_templates_dir.exists(): + templates_dir = package_templates_dir + console.print(f"[green]✓[/green] Found templates at: {templates_dir}") + else: + console.print("[yellow]⚠[/yellow] Templates path from spec not found") + else: + console.print("[yellow]⚠[/yellow] Could not find specfact_cli module spec") + if spec is None: + debug_print("[dim]Debug:[/dim] spec is None") + elif not spec.origin: + debug_print("[dim]Debug:[/dim] spec.origin is None or empty") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Error with importlib.util.find_spec(): {type(e).__name__}: {e}") + + # Fallback: Comprehensive package location search (cross-platform) + if not templates_dir or not templates_dir.exists(): + try: + debug_print("[dim]Debug:[/dim] Searching all package installation locations...") + package_locations = get_package_installation_locations("specfact_cli") + debug_print(f"[dim]Debug:[/dim] Found {len(package_locations)} possible package location(s)") + for i, loc in enumerate(package_locations, 1): + debug_print(f"[dim]Debug:[/dim] {i}. {loc}") + # Check for resources/prompts in this package location + resource_path = (loc / "resources" / "prompts").resolve() + tried_locations.append(resource_path) + if resource_path.exists(): + templates_dir = resource_path + console.print(f"[green]✓[/green] Found templates at: {templates_dir}") + break + if not templates_dir or not templates_dir.exists(): + # Try using the helper function as a final attempt + debug_print("[dim]Debug:[/dim] Trying find_package_resources_path() helper...") + resource_path = find_package_resources_path("specfact_cli", "resources/prompts") + if resource_path and resource_path.exists(): + tried_locations.append(resource_path) + templates_dir = resource_path + console.print(f"[green]✓[/green] Found templates at: {templates_dir}") + else: + console.print("[yellow]⚠[/yellow] Resources not found in any package location") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Error searching package locations: {type(e).__name__}: {e}") + + # Try 3: Fallback - relative to this file (for edge cases) + if not templates_dir or not templates_dir.exists(): + try: + debug_print("[dim]Debug:[/dim] Trying fallback: relative to __file__...") + # Get the directory containing this file (init.py) + # init.py is in: src/specfact_cli/commands/init.py + # Go up: commands -> specfact_cli -> src -> project root + current_file = Path(__file__).resolve() + fallback_dir = (current_file.parent.parent.parent.parent / "resources" / "prompts").resolve() + tried_locations.append(fallback_dir) + debug_print(f"[dim]Debug:[/dim] Current file: {current_file}") + debug_print(f"[dim]Debug:[/dim] Fallback templates path: {fallback_dir}") + if fallback_dir.exists(): + templates_dir = fallback_dir + console.print(f"[green]✓[/green] Found templates at: {templates_dir}") + else: + console.print("[yellow]⚠[/yellow] Fallback path not found") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Error with __file__ fallback: {type(e).__name__}: {e}") + + if templates_dir and templates_dir.exists() and is_debug_mode(): + debug_log_operation("template_resolution", str(templates_dir), "success") + if not templates_dir or not templates_dir.exists(): + if is_debug_mode() and tried_locations: + debug_log_operation( + "template_resolution", + str(tried_locations[-1]) if tried_locations else "unknown", + "failure", + error="Templates directory not found after all attempts", + ) + console.print() + console.print("[red]Error:[/red] Templates directory not found after all attempts") + console.print() + console.print("[yellow]Tried locations:[/yellow]") + for i, location in enumerate(tried_locations, 1): + exists = "✓" if location.exists() else "✗" + console.print(f" {i}. {exists} {location}") + console.print() + console.print("[yellow]Debug information:[/yellow]") + console.print(f" - Python version: {sys.version}") + console.print(f" - Platform: {sys.platform}") + console.print(f" - Current working directory: {Path.cwd()}") + console.print(f" - Repository path: {repo_path}") + console.print(f" - __file__ location: {Path(__file__).resolve()}") + try: + import importlib.util + + spec = importlib.util.find_spec("specfact_cli") + if spec: + console.print(f" - Module spec found: {spec}") + console.print(f" - Module origin: {spec.origin}") + if spec.origin: + console.print(f" - Module location: {Path(spec.origin).parent.resolve()}") + else: + console.print(" - Module spec: Not found") + except Exception as e: + console.print(f" - Error checking module spec: {e}") + console.print() + console.print("[yellow]Expected location:[/yellow] resources/prompts/") + console.print("[yellow]Please ensure SpecFact is properly installed.[/yellow]") + raise typer.Exit(1) + + console.print(f"[cyan]Templates:[/cyan] {templates_dir}") + console.print() + + # Copy templates to IDE location + try: + copied_files, settings_path = copy_templates_to_ide(repo_path, detected_ide, templates_dir, force) + + if not copied_files: + console.print( + "[yellow]No templates copied (all files already exist, use --force to overwrite)[/yellow]" + ) + record({"files_copied": 0, "already_exists": True}) + raise typer.Exit(0) + + record( + { + "detected_ide": detected_ide, + "files_copied": len(copied_files), + "settings_updated": settings_path is not None, + } + ) + + console.print() + console.print(Panel("[bold green]✓ Initialization Complete[/bold green]", border_style="green")) + console.print(f"[green]Copied {len(copied_files)} template(s) to {ide_config['folder']}[/green]") + if settings_path: + console.print(f"[green]Updated VS Code settings:[/green] {settings_path}") + console.print() + + # Copy backlog field mapping templates + _copy_backlog_field_mapping_templates(repo_path, force, console) + + console.print() + console.print("[dim]You can now use SpecFact slash commands in your IDE![/dim]") + console.print("[dim]Example: /specfact.01-import --bundle legacy-api --repo .[/dim]") + + except Exception as e: + console.print(f"[red]Error:[/red] Failed to initialize IDE integration: {e}") + raise typer.Exit(1) from e diff --git a/src/specfact_cli/modules/migrate/src/__init__.py b/src/specfact_cli/modules/migrate/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/migrate/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/migrate/src/app.py b/src/specfact_cli/modules/migrate/src/app.py index 53ecf6ea..d4a57ce2 100644 --- a/src/specfact_cli/modules/migrate/src/app.py +++ b/src/specfact_cli/modules/migrate/src/app.py @@ -1,6 +1,6 @@ -"""Migrate command: re-export from commands package.""" +"""migrate command entrypoint.""" -from specfact_cli.commands.migrate import app +from specfact_cli.modules.migrate.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/migrate/src/commands.py b/src/specfact_cli/modules/migrate/src/commands.py new file mode 100644 index 00000000..f2c486fd --- /dev/null +++ b/src/specfact_cli/modules/migrate/src/commands.py @@ -0,0 +1,930 @@ +""" +Migrate command - Convert project bundles between formats. + +This module provides commands for migrating project bundles from verbose +format to OpenAPI contract-based format. +""" + +from __future__ import annotations + +import re +import shutil +from pathlib import Path + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console + +from specfact_cli.models.plan import Feature +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.utils import print_error, print_info, print_success, print_warning +from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress +from specfact_cli.utils.structure import SpecFactStructure +from specfact_cli.utils.structured_io import StructuredFormat + + +app = typer.Typer(help="Migrate project bundles between formats") +console = Console() + + +@app.command("cleanup-legacy") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def cleanup_legacy( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would be removed without actually removing. Default: False", + ), + force: bool = typer.Option( + False, + "--force", + help="Remove directories even if they contain files. Default: False (only removes empty directories)", + ), +) -> None: + """ + Remove empty legacy top-level directories (Phase 8.5 cleanup). + + Removes legacy directories that are no longer created by ensure_structure(): + - .specfact/plans/ (deprecated: no monolithic bundles, active bundle config moved to config.yaml) + - .specfact/contracts/ (now bundle-specific: .specfact/projects/<bundle-name>/contracts/) + - .specfact/protocols/ (now bundle-specific: .specfact/projects/<bundle-name>/protocols/) + - .specfact/sdd/ (now bundle-specific: .specfact/projects/<bundle-name>/sdd.yaml) + - .specfact/reports/ (now bundle-specific: .specfact/projects/<bundle-name>/reports/) + - .specfact/gates/results/ (removed: not used; enforcement reports are bundle-specific in reports/enforcement/) + + **Note**: If plans/config.yaml exists, it will be preserved (migrated to config.yaml) before removing plans/ directory. + + **Safety**: By default, only removes empty directories. Use --force to remove directories with files. + + **Examples:** + specfact migrate cleanup-legacy --repo . + specfact migrate cleanup-legacy --repo . --dry-run + specfact migrate cleanup-legacy --repo . --force # Remove even if files exist + """ + if is_debug_mode(): + debug_log_operation( + "command", + "migrate cleanup-legacy", + "started", + extra={"repo": str(repo), "dry_run": dry_run, "force": force}, + ) + debug_print("[dim]migrate cleanup-legacy: started[/dim]") + + specfact_dir = repo / SpecFactStructure.ROOT + if not specfact_dir.exists(): + console.print(f"[yellow]⚠[/yellow] No .specfact directory found at {specfact_dir}") + return + + legacy_dirs = [ + (specfact_dir / "plans", "plans"), + (specfact_dir / "contracts", "contracts"), + (specfact_dir / "protocols", "protocols"), + (specfact_dir / "sdd", "sdd"), + (specfact_dir / "reports", "reports"), + (specfact_dir / "gates" / "results", "gates/results"), + ] + + removed_count = 0 + skipped_count = 0 + + # Special handling for plans/ directory: migrate config.yaml before removal + plans_dir = specfact_dir / "plans" + plans_config = plans_dir / "config.yaml" + if plans_config.exists() and not dry_run: + try: + import yaml + + # Read legacy config + with plans_config.open() as f: + legacy_config = yaml.safe_load(f) or {} + active_plan = legacy_config.get("active_plan") + + if active_plan: + # Migrate to global config.yaml + global_config_path = specfact_dir / "config.yaml" + global_config = {} + if global_config_path.exists(): + with global_config_path.open() as f: + global_config = yaml.safe_load(f) or {} + global_config[SpecFactStructure.ACTIVE_BUNDLE_CONFIG_KEY] = active_plan + global_config_path.parent.mkdir(parents=True, exist_ok=True) + with global_config_path.open("w") as f: + yaml.dump(global_config, f, default_flow_style=False, sort_keys=False) + console.print("[green]✓[/green] Migrated active bundle config from plans/config.yaml to config.yaml") + except Exception as e: + console.print(f"[yellow]⚠[/yellow] Failed to migrate plans/config.yaml: {e}") + + for legacy_dir, name in legacy_dirs: + if not legacy_dir.exists(): + continue + + # Check if directory is empty + has_files = any(legacy_dir.iterdir()) + if has_files and not force: + console.print(f"[yellow]⚠[/yellow] Skipping {name}/ (contains files, use --force to remove): {legacy_dir}") + skipped_count += 1 + continue + + if dry_run: + if has_files: + console.print(f"[dim]Would remove {name}/ (contains files, --force required): {legacy_dir}[/dim]") + else: + console.print(f"[dim]Would remove empty {name}/: {legacy_dir}[/dim]") + else: + try: + if has_files: + shutil.rmtree(legacy_dir) + console.print(f"[green]✓[/green] Removed {name}/ (with files): {legacy_dir}") + else: + legacy_dir.rmdir() + console.print(f"[green]✓[/green] Removed empty {name}/: {legacy_dir}") + removed_count += 1 + except OSError as e: + console.print(f"[red]✗[/red] Failed to remove {name}/: {e}") + skipped_count += 1 + + if dry_run: + console.print( + f"\n[dim]Dry run complete. Would remove {removed_count} directory(ies), skip {skipped_count}[/dim]" + ) + else: + if removed_count > 0: + console.print( + f"\n[bold green]✓[/bold green] Cleanup complete. Removed {removed_count} legacy directory(ies)" + ) + if skipped_count > 0: + console.print( + f"[yellow]⚠[/yellow] Skipped {skipped_count} directory(ies) (use --force to remove directories with files)" + ) + if removed_count == 0 and skipped_count == 0: + console.print("[dim]No legacy directories found to remove[/dim]") + if is_debug_mode(): + debug_log_operation( + "command", + "migrate cleanup-legacy", + "success", + extra={"removed_count": removed_count, "skipped_count": skipped_count}, + ) + debug_print("[dim]migrate cleanup-legacy: success[/dim]") + + +@app.command("to-contracts") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def to_contracts( + # Target/Input + bundle: str | None = typer.Argument( + None, help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'" + ), + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Behavior/Options + extract_openapi: bool = typer.Option( + True, + "--extract-openapi/--no-extract-openapi", + help="Extract OpenAPI contracts from verbose acceptance criteria. Default: True", + ), + validate_with_specmatic: bool = typer.Option( + True, + "--validate-with-specmatic/--no-validate-with-specmatic", + help="Validate generated contracts with Specmatic. Default: True", + ), + clean_verbose_specs: bool = typer.Option( + True, + "--clean-verbose-specs/--no-clean-verbose-specs", + help="Convert verbose Given-When-Then acceptance criteria to scenarios or remove them. Default: True", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would be migrated without actually migrating. Default: False", + ), +) -> None: + """ + Convert verbose project bundle to contract-based format. + + Migrates project bundles from verbose "Given...When...Then" acceptance criteria + to lightweight OpenAPI contract-based format, reducing bundle size significantly. + + For non-API features, verbose acceptance criteria are converted to scenarios + or removed to reduce bundle size. + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --repo + - **Behavior/Options**: --extract-openapi, --validate-with-specmatic, --clean-verbose-specs, --dry-run + + **Examples:** + specfact migrate to-contracts legacy-api --repo . + specfact migrate to-contracts my-bundle --repo . --dry-run + specfact migrate to-contracts my-bundle --repo . --no-validate-with-specmatic + specfact migrate to-contracts my-bundle --repo . --no-clean-verbose-specs + """ + from rich.console import Console + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None: + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + from specfact_cli.generators.openapi_extractor import OpenAPIExtractor + from specfact_cli.telemetry import telemetry + + repo_path = repo.resolve() + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + telemetry_metadata = { + "bundle": bundle, + "extract_openapi": extract_openapi, + "validate_with_specmatic": validate_with_specmatic, + "dry_run": dry_run, + } + + if is_debug_mode(): + debug_log_operation( + "command", + "migrate to-contracts", + "started", + extra={"bundle": bundle, "repo": str(repo_path), "dry_run": dry_run}, + ) + debug_print("[dim]migrate to-contracts: started[/dim]") + + with telemetry.track_command("migrate.to_contracts", telemetry_metadata) as record: + console.print(f"[bold cyan]Migrating bundle:[/bold cyan] {bundle}") + console.print(f"[dim]Repository:[/dim] {repo_path}") + + if dry_run: + print_warning("DRY RUN MODE - No changes will be made") + + try: + # Load existing project bundle with unified progress display + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Ensure contracts directory exists + contracts_dir = bundle_dir / "contracts" + if not dry_run: + contracts_dir.mkdir(parents=True, exist_ok=True) + + extractor = OpenAPIExtractor(repo_path) + contracts_created = 0 + contracts_validated = 0 + contracts_removed = 0 # Track invalid contract references removed + verbose_specs_cleaned = 0 # Track verbose specs cleaned + + # Process each feature + for feature_key, feature in project_bundle.features.items(): + if not feature.stories: + continue + + # Clean verbose acceptance criteria for all features (before contract extraction) + if clean_verbose_specs: + cleaned = _clean_verbose_acceptance_criteria(feature, feature_key, dry_run) + if cleaned: + verbose_specs_cleaned += cleaned + + # Check if feature already has a contract AND the file actually exists + if feature.contract: + contract_path_check = bundle_dir / feature.contract + if contract_path_check.exists(): + print_info(f"Feature {feature_key} already has contract: {feature.contract}") + continue + # Contract reference exists but file is missing - recreate it + print_warning( + f"Feature {feature_key} has contract reference but file is missing: {feature.contract}. Will recreate." + ) + # Clear the contract reference so we recreate it + feature.contract = None + + # Extract OpenAPI contract + if extract_openapi: + print_info(f"Extracting OpenAPI contract for {feature_key}...") + + # Try to extract from code first (more accurate) + if feature.source_tracking and feature.source_tracking.implementation_files: + openapi_spec = extractor.extract_openapi_from_code(repo_path, feature) + else: + # Fallback to extracting from verbose acceptance criteria + openapi_spec = extractor.extract_openapi_from_verbose(feature) + + # Only save contract if it has paths (non-empty spec) + paths = openapi_spec.get("paths", {}) + if not paths or len(paths) == 0: + # Feature has no API endpoints - remove invalid contract reference if it exists + if feature.contract: + print_warning( + f"Feature {feature_key} has no API endpoints but has contract reference. Removing invalid reference." + ) + feature.contract = None + contracts_removed += 1 + else: + print_warning( + f"Feature {feature_key} has no API endpoints in acceptance criteria, skipping contract creation" + ) + continue + + # Save contract file + contract_filename = f"{feature_key}.openapi.yaml" + contract_path = contracts_dir / contract_filename + + if not dry_run: + try: + # Ensure contracts directory exists before saving + contracts_dir.mkdir(parents=True, exist_ok=True) + extractor.save_openapi_contract(openapi_spec, contract_path) + # Verify contract file was actually created + if not contract_path.exists(): + print_error(f"Failed to create contract file: {contract_path}") + continue + # Verify contracts directory exists + if not contracts_dir.exists(): + print_error(f"Contracts directory was not created: {contracts_dir}") + continue + # Update feature with contract reference + feature.contract = f"contracts/{contract_filename}" + contracts_created += 1 + except Exception as e: + print_error(f"Failed to save contract for {feature_key}: {e}") + continue + + # Validate with Specmatic if requested + if validate_with_specmatic: + print_info(f"Validating contract for {feature_key} with Specmatic...") + import asyncio + + try: + result = asyncio.run(extractor.validate_with_specmatic(contract_path)) + if result.is_valid: + print_success(f"Contract for {feature_key} is valid") + contracts_validated += 1 + else: + print_warning(f"Contract for {feature_key} has validation issues:") + for error in result.errors[:3]: # Show first 3 errors + console.print(f" [yellow]- {error}[/yellow]") + except Exception as e: + print_warning(f"Specmatic validation failed: {e}") + else: + console.print(f"[dim]Would create contract: {contract_path}[/dim]") + + # Save updated project bundle if contracts were created, invalid references removed, or verbose specs cleaned + if not dry_run and (contracts_created > 0 or contracts_removed > 0 or verbose_specs_cleaned > 0): + print_info("Saving updated project bundle...") + # Save contracts directory to a temporary location before atomic save + # (atomic save removes the entire bundle_dir, so we need to preserve contracts) + import shutil + import tempfile + + contracts_backup_path: Path | None = None + # Always backup contracts directory if it exists and has files + # (even if we didn't create new ones, we need to preserve existing contracts) + if contracts_dir.exists() and contracts_dir.is_dir() and list(contracts_dir.iterdir()): + # Create temporary backup of contracts directory + contracts_backup = tempfile.mkdtemp() + contracts_backup_path = Path(contracts_backup) + # Copy contracts directory to backup + shutil.copytree(contracts_dir, contracts_backup_path / "contracts", dirs_exist_ok=True) + + # Save bundle (this will remove and recreate bundle_dir) + save_bundle_with_progress(project_bundle, bundle_dir, atomic=True, console_instance=console) + + # Restore contracts directory after atomic save + if contracts_backup_path is not None and (contracts_backup_path / "contracts").exists(): + restored_contracts = contracts_backup_path / "contracts" + # Restore contracts to bundle_dir + if restored_contracts.exists(): + shutil.copytree(restored_contracts, contracts_dir, dirs_exist_ok=True) + # Clean up backup + shutil.rmtree(str(contracts_backup_path), ignore_errors=True) + + if contracts_created > 0: + print_success(f"Migration complete: {contracts_created} contracts created") + if contracts_removed > 0: + print_success(f"Migration complete: {contracts_removed} invalid contract references removed") + if contracts_created == 0 and contracts_removed == 0 and verbose_specs_cleaned == 0: + print_info("Migration complete: No changes needed") + if verbose_specs_cleaned > 0: + print_success(f"Cleaned verbose specs: {verbose_specs_cleaned} stories updated") + if validate_with_specmatic and contracts_created > 0: + console.print(f"[dim]Contracts validated: {contracts_validated}/{contracts_created}[/dim]") + elif dry_run: + console.print(f"[dim]Would create {contracts_created} contracts[/dim]") + if clean_verbose_specs: + console.print(f"[dim]Would clean verbose specs in {verbose_specs_cleaned} stories[/dim]") + + if is_debug_mode(): + debug_log_operation( + "command", + "migrate to-contracts", + "success", + extra={ + "contracts_created": contracts_created, + "contracts_validated": contracts_validated, + "verbose_specs_cleaned": verbose_specs_cleaned, + }, + ) + debug_print("[dim]migrate to-contracts: success[/dim]") + record( + { + "contracts_created": contracts_created, + "contracts_validated": contracts_validated, + "verbose_specs_cleaned": verbose_specs_cleaned, + } + ) + + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "migrate to-contracts", + "failed", + error=str(e), + extra={"reason": type(e).__name__}, + ) + print_error(f"Migration failed: {e}") + record({"error": str(e)}) + raise typer.Exit(1) from e + + +def _is_verbose_gwt_pattern(acceptance: str) -> bool: + """Check if acceptance criteria is verbose Given-When-Then pattern.""" + # Check for verbose patterns: "Given X, When Y, Then Z" with detailed conditions + gwt_pattern = r"Given\s+.+?,\s*When\s+.+?,\s*Then\s+.+" + if not re.search(gwt_pattern, acceptance, re.IGNORECASE): + return False + + # Consider verbose if it's longer than 100 characters (detailed scenario) + # or contains multiple conditions (and/or operators) + return ( + len(acceptance) > 100 + or " and " in acceptance.lower() + or " or " in acceptance.lower() + or acceptance.count(",") > 2 # Multiple comma-separated conditions + ) + + +def _extract_gwt_parts(acceptance: str) -> tuple[str, str, str] | None: + """Extract Given, When, Then parts from acceptance criteria.""" + # Pattern to match "Given X, When Y, Then Z" format + gwt_pattern = r"Given\s+(.+?),\s*When\s+(.+?),\s*Then\s+(.+?)(?:$|,)" + match = re.search(gwt_pattern, acceptance, re.IGNORECASE | re.DOTALL) + if match: + return (match.group(1).strip(), match.group(2).strip(), match.group(3).strip()) + return None + + +def _categorize_scenario(acceptance: str) -> str: + """Categorize scenario as primary, alternate, exception, or recovery.""" + acc_lower = acceptance.lower() + if any(keyword in acc_lower for keyword in ["error", "exception", "fail", "invalid", "reject"]): + return "exception" + if any(keyword in acc_lower for keyword in ["recover", "retry", "fallback", "alternative"]): + return "recovery" + if any(keyword in acc_lower for keyword in ["alternate", "alternative", "else", "otherwise"]): + return "alternate" + return "primary" + + +@beartype +def _clean_verbose_acceptance_criteria(feature: Feature, feature_key: str, dry_run: bool) -> int: + """ + Clean verbose Given-When-Then acceptance criteria. + + Converts verbose acceptance criteria to scenarios or removes them if redundant. + Returns the number of stories cleaned. + """ + cleaned_count = 0 + + if not feature.stories: + return 0 + + for story in feature.stories: + if not story.acceptance: + continue + + # Check if story has GWT patterns (move all to scenarios, not just verbose ones) + gwt_acceptance = [acc for acc in story.acceptance if "Given" in acc and "When" in acc and "Then" in acc] + if not gwt_acceptance: + continue + + # Initialize scenarios dict if needed + if story.scenarios is None: + story.scenarios = {"primary": [], "alternate": [], "exception": [], "recovery": []} + + # Convert verbose acceptance criteria to scenarios + converted_count = 0 + remaining_acceptance = [] + + for acc in story.acceptance: + # Move all GWT patterns to scenarios (not just verbose ones) + if "Given" in acc and "When" in acc and "Then" in acc: + # Extract GWT parts + gwt_parts = _extract_gwt_parts(acc) + if gwt_parts: + given, when, then = gwt_parts + scenario_text = f"Given {given}, When {when}, Then {then}" + category = _categorize_scenario(acc) + + # Add to appropriate scenario category (even if it already exists, we still remove from acceptance) + if scenario_text not in story.scenarios[category]: + story.scenarios[category].append(scenario_text) + # Always count as converted (removed from acceptance) even if scenario already exists + converted_count += 1 + # Don't keep GWT patterns in acceptance list + else: + # Keep non-GWT acceptance criteria + remaining_acceptance.append(acc) + + if converted_count > 0: + # Update acceptance criteria (remove verbose ones, keep simple ones) + story.acceptance = remaining_acceptance + + # If all acceptance was verbose and we converted to scenarios, + # add a simple summary acceptance criterion + if not story.acceptance: + story.acceptance.append( + f"Given {story.title}, When operations are performed, Then expected behavior is achieved" + ) + + if not dry_run: + print_info( + f"Feature {feature_key}, Story {story.key}: Converted {converted_count} verbose acceptance criteria to scenarios" + ) + else: + console.print( + f"[dim]Would convert {converted_count} verbose acceptance criteria to scenarios for {feature_key}/{story.key}[/dim]" + ) + + cleaned_count += 1 + + return cleaned_count + + +@app.command("artifacts") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def migrate_artifacts( + # Target/Input + bundle: str | None = typer.Argument( + None, + help="Project bundle name (e.g., legacy-api). If not specified, migrates artifacts for all bundles found in .specfact/projects/", + ), + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Behavior/Options + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would be migrated without actually migrating. Default: False", + ), + backup: bool = typer.Option( + True, + "--backup/--no-backup", + help="Create backup before migration. Default: True", + ), +) -> None: + """ + Migrate bundle-specific artifacts to bundle folders (Phase 8.5). + + Moves artifacts from global locations to bundle-specific folders: + - Reports: .specfact/reports/* → .specfact/projects/<bundle-name>/reports/* + - SDD manifests: .specfact/sdd/<bundle-name>.yaml → .specfact/projects/<bundle-name>/sdd.yaml + - Tasks: .specfact/tasks/<bundle-name>-*.yaml → .specfact/projects/<bundle-name>/tasks.yaml + + **Parameter Groups:** + - **Target/Input**: bundle (optional argument), --repo + - **Behavior/Options**: --dry-run, --backup/--no-backup + + **Examples:** + specfact migrate artifacts legacy-api --repo . + specfact migrate artifacts --repo . # Migrate all bundles + specfact migrate artifacts legacy-api --dry-run # Preview migration + specfact migrate artifacts legacy-api --no-backup # Skip backup + """ + + repo_path = repo.resolve() + base_path = repo_path + + # Determine which bundles to migrate + bundles_to_migrate: list[str] = [] + if bundle: + bundles_to_migrate = [bundle] + else: + # Find all bundles in .specfact/projects/ + projects_dir = base_path / SpecFactStructure.PROJECTS + if projects_dir.exists(): + for bundle_dir in projects_dir.iterdir(): + if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists(): + bundles_to_migrate.append(bundle_dir.name) + if not bundles_to_migrate: + print_error("No project bundles found. Create one with 'specfact plan init' or 'specfact import from-code'") + raise typer.Exit(1) + + if is_debug_mode(): + debug_log_operation( + "command", + "migrate artifacts", + "started", + extra={"bundles": bundles_to_migrate, "repo": str(repo_path), "dry_run": dry_run}, + ) + debug_print("[dim]migrate artifacts: started[/dim]") + + console.print(f"[bold cyan]Migrating artifacts for {len(bundles_to_migrate)} bundle(s)[/bold cyan]") + if dry_run: + print_warning("DRY RUN MODE - No changes will be made") + + total_moved = 0 + total_errors = 0 + + for bundle_name in bundles_to_migrate: + console.print(f"\n[bold]Bundle:[/bold] {bundle_name}") + + # Verify bundle exists + bundle_dir = SpecFactStructure.project_dir(base_path=base_path, bundle_name=bundle_name) + if not bundle_dir.exists() or not (bundle_dir / "bundle.manifest.yaml").exists(): + # If a specific bundle was requested, fail; otherwise skip (for --all mode) + if bundle: + print_error(f"Bundle {bundle_name} not found") + raise typer.Exit(1) + print_warning(f"Bundle {bundle_name} not found, skipping") + total_errors += 1 + continue + + # Ensure bundle-specific directories exist + if not dry_run: + SpecFactStructure.ensure_project_structure(base_path=base_path, bundle_name=bundle_name) + + moved_count = 0 + + # 1. Migrate reports + moved_count += _migrate_reports(base_path, bundle_name, bundle_dir, dry_run, backup) + + # 2. Migrate SDD manifest + moved_count += _migrate_sdd(base_path, bundle_name, bundle_dir, dry_run, backup) + + # 3. Migrate tasks + moved_count += _migrate_tasks(base_path, bundle_name, bundle_dir, dry_run, backup) + + total_moved += moved_count + if moved_count > 0: + print_success(f"Migrated {moved_count} artifact(s) for bundle {bundle_name}") + else: + print_info(f"No artifacts to migrate for bundle {bundle_name}") + + # Summary + console.print("\n[bold cyan]Migration Summary[/bold cyan]") + console.print(f" Bundles processed: {len(bundles_to_migrate)}") + console.print(f" Artifacts moved: {total_moved}") + if total_errors > 0: + console.print(f" Errors: {total_errors}") + + if dry_run: + print_warning("DRY RUN - No changes were made. Run without --dry-run to perform migration.") + + if is_debug_mode(): + debug_log_operation( + "command", + "migrate artifacts", + "success", + extra={ + "bundles_processed": len(bundles_to_migrate), + "total_moved": total_moved, + "total_errors": total_errors, + }, + ) + debug_print("[dim]migrate artifacts: success[/dim]") + + +def _migrate_reports(base_path: Path, bundle_name: str, bundle_dir: Path, dry_run: bool, backup: bool) -> int: + """Migrate reports from global location to bundle-specific location.""" + moved_count = 0 + + # Global reports directories + global_reports = base_path / SpecFactStructure.REPORTS + if not global_reports.exists(): + return 0 + + # Bundle-specific reports directory + bundle_reports_dir = bundle_dir / "reports" + + # Migrate each report type + report_types = ["brownfield", "comparison", "enrichment", "enforcement"] + for report_type in report_types: + global_report_dir = global_reports / report_type + if not global_report_dir.exists(): + continue + + bundle_report_dir = bundle_reports_dir / report_type + + # Find reports that might belong to this bundle + # Look for files with bundle name in filename or all files if bundle is the only one + for report_file in global_report_dir.glob("*"): + if not report_file.is_file(): + continue + + # Check if report belongs to this bundle + # Reports might have bundle name in filename, or we migrate all if it's the only bundle + should_migrate = False + if bundle_name.lower() in report_file.name.lower(): + should_migrate = True + elif report_type == "enrichment" and ".enrichment." in report_file.name: + # Enrichment reports are typically bundle-specific + should_migrate = True + elif report_type in ("brownfield", "comparison", "enforcement"): + # For other report types, migrate if filename suggests it's for this bundle + # or if it's the only bundle (conservative approach) + should_migrate = True # Migrate all reports to bundle (user can reorganize if needed) + + if should_migrate: + target_path = bundle_report_dir / report_file.name + if target_path.exists(): + print_warning(f"Target report already exists: {target_path}, skipping {report_file.name}") + continue + + if not dry_run: + if backup: + # Create backup + backup_dir = ( + base_path + / SpecFactStructure.ROOT + / ".migration-backup" + / bundle_name + / "reports" + / report_type + ) + backup_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(report_file, backup_dir / report_file.name) + + # Move file + bundle_report_dir.mkdir(parents=True, exist_ok=True) + shutil.move(str(report_file), str(target_path)) + moved_count += 1 + else: + console.print(f" [dim]Would move: {report_file} → {target_path}[/dim]") + moved_count += 1 + + return moved_count + + +def _migrate_sdd(base_path: Path, bundle_name: str, bundle_dir: Path, dry_run: bool, backup: bool) -> int: + """Migrate SDD manifest from global location to bundle-specific location.""" + moved_count = 0 + + # Check legacy multi-SDD location: .specfact/sdd/<bundle-name>.yaml + sdd_dir = base_path / SpecFactStructure.SDD + legacy_sdd_yaml = sdd_dir / f"{bundle_name}.yaml" + legacy_sdd_json = sdd_dir / f"{bundle_name}.json" + + # Check legacy single-SDD location: .specfact/sdd.yaml (only if bundle name matches active) + legacy_single_yaml = base_path / SpecFactStructure.ROOT / "sdd.yaml" + legacy_single_json = base_path / SpecFactStructure.ROOT / "sdd.json" + + # Determine which SDD to migrate + sdd_to_migrate: Path | None = None + if legacy_sdd_yaml.exists(): + sdd_to_migrate = legacy_sdd_yaml + elif legacy_sdd_json.exists(): + sdd_to_migrate = legacy_sdd_json + elif legacy_single_yaml.exists(): + # Only migrate single SDD if it's the active bundle + active_bundle = SpecFactStructure.get_active_bundle_name(base_path) + if active_bundle == bundle_name: + sdd_to_migrate = legacy_single_yaml + elif legacy_single_json.exists(): + active_bundle = SpecFactStructure.get_active_bundle_name(base_path) + if active_bundle == bundle_name: + sdd_to_migrate = legacy_single_json + + if sdd_to_migrate: + # Bundle-specific SDD path + target_sdd = SpecFactStructure.get_bundle_sdd_path( + bundle_name=bundle_name, + base_path=base_path, + format=StructuredFormat.YAML if sdd_to_migrate.suffix == ".yaml" else StructuredFormat.JSON, + ) + + if target_sdd.exists(): + print_warning(f"Target SDD already exists: {target_sdd}, skipping {sdd_to_migrate.name}") + return 0 + + if not dry_run: + if backup: + # Create backup + backup_dir = base_path / SpecFactStructure.ROOT / ".migration-backup" / bundle_name + backup_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(sdd_to_migrate, backup_dir / sdd_to_migrate.name) + + # Move file + target_sdd.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(sdd_to_migrate), str(target_sdd)) + moved_count += 1 + else: + console.print(f" [dim]Would move: {sdd_to_migrate} → {target_sdd}[/dim]") + moved_count += 1 + + return moved_count + + +def _migrate_tasks(base_path: Path, bundle_name: str, bundle_dir: Path, dry_run: bool, backup: bool) -> int: + """Migrate task files from global location to bundle-specific location.""" + moved_count = 0 + + # Global tasks directory + tasks_dir = base_path / SpecFactStructure.TASKS + if not tasks_dir.exists(): + return 0 + + # Find task files for this bundle + # Task files typically named: <bundle-name>-<hash>.tasks.yaml + task_patterns = [ + f"{bundle_name}-*.tasks.yaml", + f"{bundle_name}-*.tasks.json", + f"{bundle_name}-*.tasks.md", + ] + + task_files: list[Path] = [] + for pattern in task_patterns: + task_files.extend(tasks_dir.glob(pattern)) + + if not task_files: + return 0 + + # Bundle-specific tasks path + target_tasks = SpecFactStructure.get_bundle_tasks_path(bundle_name=bundle_name, base_path=base_path) + + # If multiple task files, use the most recent one + if len(task_files) > 1: + task_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) + print_info(f"Found {len(task_files)} task files for {bundle_name}, using most recent: {task_files[0].name}") + + task_file = task_files[0] + + if target_tasks.exists(): + print_warning(f"Target tasks file already exists: {target_tasks}, skipping {task_file.name}") + return 0 + + if not dry_run: + if backup: + # Create backup + backup_dir = base_path / SpecFactStructure.ROOT / ".migration-backup" / bundle_name + backup_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(task_file, backup_dir / task_file.name) + + # Move file + target_tasks.parent.mkdir(parents=True, exist_ok=True) + shutil.move(str(task_file), str(target_tasks)) + moved_count += 1 + + # Remove other task files for this bundle (if any) + for other_task in task_files[1:]: + if backup: + backup_dir = base_path / SpecFactStructure.ROOT / ".migration-backup" / bundle_name + backup_dir.mkdir(parents=True, exist_ok=True) + shutil.copy2(other_task, backup_dir / other_task.name) + other_task.unlink() + else: + console.print(f" [dim]Would move: {task_file} → {target_tasks}[/dim]") + if len(task_files) > 1: + console.print(f" [dim]Would remove {len(task_files) - 1} other task file(s) for {bundle_name}[/dim]") + moved_count += 1 + + return moved_count diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index 9058aea0..e34eeec6 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -6,5 +6,6 @@ commands: command_help: plan: "Manage development plans" pip_dependencies: [] -module_dependencies: [] +module_dependencies: + - sync tier: community diff --git a/src/specfact_cli/modules/plan/src/__init__.py b/src/specfact_cli/modules/plan/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/plan/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/plan/src/app.py b/src/specfact_cli/modules/plan/src/app.py index b152af0e..b51d7448 100644 --- a/src/specfact_cli/modules/plan/src/app.py +++ b/src/specfact_cli/modules/plan/src/app.py @@ -1,6 +1,6 @@ -"""Plan command: re-export from commands package.""" +"""plan command entrypoint.""" -from specfact_cli.commands.plan import app +from specfact_cli.modules.plan.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/plan/src/commands.py b/src/specfact_cli/modules/plan/src/commands.py new file mode 100644 index 00000000..d3777d96 --- /dev/null +++ b/src/specfact_cli/modules/plan/src/commands.py @@ -0,0 +1,5552 @@ +""" +Plan command - Manage greenfield development plans. + +This module provides commands for creating and managing development plans, +features, and stories. +""" + +from __future__ import annotations + +import json +from contextlib import suppress +from datetime import UTC +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.table import Table + +from specfact_cli import runtime +from specfact_cli.analyzers.ambiguity_scanner import AmbiguityFinding +from specfact_cli.comparators.plan_comparator import PlanComparator +from specfact_cli.generators.report_generator import ReportFormat, ReportGenerator +from specfact_cli.models.deviation import Deviation, DeviationSeverity, DeviationType, ValidationReport +from specfact_cli.models.enforcement import EnforcementConfig +from specfact_cli.models.plan import Business, Feature, Idea, PlanBundle, Product, Release, Story +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.models.sdd import SDDHow, SDDManifest, SDDWhat, SDDWhy +from specfact_cli.modes import detect_mode +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive +from specfact_cli.telemetry import telemetry +from specfact_cli.utils import ( + display_summary, + print_error, + print_info, + print_section, + print_success, + print_warning, + prompt_confirm, + prompt_dict, + prompt_list, + prompt_text, +) +from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress +from specfact_cli.utils.structured_io import StructuredFormat, load_structured_file +from specfact_cli.validators.schema import validate_plan_bundle + + +app = typer.Typer(help="Manage development plans, features, and stories") +console = Console() + + +# Use shared progress utilities for consistency (aliased to maintain existing function names) +def _load_bundle_with_progress(bundle_dir: Path, validate_hashes: bool = False) -> ProjectBundle: + """Load project bundle with unified progress display.""" + return load_bundle_with_progress(bundle_dir, validate_hashes=validate_hashes, console_instance=console) + + +def _save_bundle_with_progress(bundle: ProjectBundle, bundle_dir: Path, atomic: bool = True) -> None: + """Save project bundle with unified progress display.""" + save_bundle_with_progress(bundle, bundle_dir, atomic=atomic, console_instance=console) + + +@app.command("init") +@beartype +@require(lambda bundle: isinstance(bundle, str) and len(bundle) > 0, "Bundle name must be non-empty string") +def init( + # Target/Input + bundle: str = typer.Argument(..., help="Project bundle name (e.g., legacy-api, auth-module)"), + # Behavior/Options (interactive=None: use global --no-interactive from root when set) + interactive: bool | None = typer.Option( + None, + "--interactive/--no-interactive", + help="Interactive mode with prompts. Default: follows global --no-interactive if set, else True", + ), + scaffold: bool = typer.Option( + True, + "--scaffold/--no-scaffold", + help="Create complete .specfact directory structure. Default: True (scaffold enabled)", + ), +) -> None: + """ + Initialize a new modular project bundle. + + Creates a new modular project bundle with idea, product, and features structure. + The bundle is created in .specfact/projects/<bundle-name>/ directory. + + **Parameter Groups:** + - **Target/Input**: bundle (required argument) + - **Behavior/Options**: --interactive/--no-interactive, --scaffold/--no-scaffold + + **Examples:** + specfact plan init legacy-api # Interactive with scaffold + specfact --no-interactive plan init auth-module # Minimal bundle (global option first) + specfact plan init my-project --no-scaffold # Bundle without directory structure + """ + from specfact_cli.utils.structure import SpecFactStructure + + # Respect global --no-interactive when passed before the command (specfact [OPTIONS] COMMAND) + if interactive is None: + interactive = not is_non_interactive() + + telemetry_metadata = { + "bundle": bundle, + "interactive": interactive, + "scaffold": scaffold, + } + + if is_debug_mode(): + debug_log_operation( + "command", + "plan init", + "started", + extra={"bundle": bundle, "interactive": interactive, "scaffold": scaffold}, + ) + debug_print("[dim]plan init: started[/dim]") + + with telemetry.track_command("plan.init", telemetry_metadata) as record: + print_section("SpecFact CLI - Project Bundle Builder") + + # Create .specfact structure if requested + if scaffold: + print_info("Creating .specfact/ directory structure...") + SpecFactStructure.scaffold_project() + print_success("Directory structure created") + else: + # Ensure minimum structure exists + SpecFactStructure.ensure_structure() + + # Get project bundle directory + bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) + if bundle_dir.exists(): + if is_debug_mode(): + debug_log_operation( + "command", + "plan init", + "failed", + error=f"Project bundle already exists: {bundle_dir}", + extra={"reason": "bundle_exists", "bundle": bundle}, + ) + print_error(f"Project bundle already exists: {bundle_dir}") + print_info("Use a different bundle name or remove the existing bundle") + raise typer.Exit(1) + + # Ensure project structure exists + SpecFactStructure.ensure_project_structure(bundle_name=bundle) + + if not interactive: + # Non-interactive mode: create minimal bundle + _create_minimal_bundle(bundle, bundle_dir) + record({"bundle_type": "minimal"}) + return + + # Interactive mode: guided bundle creation + try: + project_bundle = _build_bundle_interactively(bundle) + + # Save bundle + _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) + + # Record bundle statistics + record( + { + "bundle_type": "interactive", + "features_count": len(project_bundle.features), + "stories_count": sum(len(f.stories) for f in project_bundle.features.values()), + } + ) + + if is_debug_mode(): + debug_log_operation( + "command", + "plan init", + "success", + extra={"bundle": bundle, "bundle_dir": str(bundle_dir)}, + ) + debug_print("[dim]plan init: success[/dim]") + print_success(f"Project bundle created successfully: {bundle_dir}") + + except KeyboardInterrupt: + print_warning("\nBundle creation cancelled") + raise typer.Exit(1) from None + except Exception as e: + if is_debug_mode(): + debug_log_operation( + "command", + "plan init", + "failed", + error=str(e), + extra={"reason": type(e).__name__, "bundle": bundle}, + ) + print_error(f"Failed to create bundle: {e}") + raise typer.Exit(1) from e + + +def _create_minimal_bundle(bundle_name: str, bundle_dir: Path) -> None: + """Create a minimal project bundle.""" + + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + + bundle = ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + idea=None, + business=None, + product=Product(themes=[], releases=[]), + features={}, + clarifications=None, + ) + + _save_bundle_with_progress(bundle, bundle_dir, atomic=True) + print_success(f"Minimal project bundle created: {bundle_dir}") + + +def _build_bundle_interactively(bundle_name: str) -> ProjectBundle: + """Build a plan bundle through interactive prompts.""" + # Section 1: Idea + print_section("1. Idea - What are you building?") + + idea_title = prompt_text("Project title", required=True) + idea_narrative = prompt_text("Project narrative (brief description)", required=True) + + add_idea_details = prompt_confirm("Add optional idea details? (target users, metrics)", default=False) + + idea_data: dict[str, Any] = {"title": idea_title, "narrative": idea_narrative} + + if add_idea_details: + target_users = prompt_list("Target users") + value_hypothesis = prompt_text("Value hypothesis", required=False) + + if target_users: + idea_data["target_users"] = target_users + if value_hypothesis: + idea_data["value_hypothesis"] = value_hypothesis + + if prompt_confirm("Add success metrics?", default=False): + metrics = prompt_dict("Success Metrics") + if metrics: + idea_data["metrics"] = metrics + + idea = Idea(**idea_data) + display_summary("Idea Summary", idea_data) + + # Section 2: Business (optional) + print_section("2. Business Context (optional)") + + business = None + if prompt_confirm("Add business context?", default=False): + segments = prompt_list("Market segments") + problems = prompt_list("Problems you're solving") + solutions = prompt_list("Your solutions") + differentiation = prompt_list("How you differentiate") + risks = prompt_list("Business risks") + + business = Business( + segments=segments if segments else [], + problems=problems if problems else [], + solutions=solutions if solutions else [], + differentiation=differentiation if differentiation else [], + risks=risks if risks else [], + ) + + # Section 3: Product + print_section("3. Product - Themes and Releases") + + themes = prompt_list("Product themes (e.g., AI/ML, Security)") + releases: list[Release] = [] + + if prompt_confirm("Define releases?", default=True): + while True: + release_name = prompt_text("Release name (e.g., v1.0 - MVP)", required=False) + if not release_name: + break + + objectives = prompt_list("Release objectives") + scope = prompt_list("Feature keys in scope (e.g., FEATURE-001)") + risks = prompt_list("Release risks") + + releases.append( + Release( + name=release_name, + objectives=objectives if objectives else [], + scope=scope if scope else [], + risks=risks if risks else [], + ) + ) + + if not prompt_confirm("Add another release?", default=False): + break + + product = Product(themes=themes if themes else [], releases=releases) + + # Section 4: Features + print_section("4. Features - What will you build?") + + features: list[Feature] = [] + while prompt_confirm("Add a feature?", default=True): + feature = _prompt_feature() + features.append(feature) + + if not prompt_confirm("Add another feature?", default=False): + break + + # Create project bundle + + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + + # Convert features list to dict + features_dict: dict[str, Feature] = {f.key: f for f in features} + + project_bundle = ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + idea=idea, + business=business, + product=product, + features=features_dict, + clarifications=None, + ) + + # Final summary + print_section("Project Bundle Summary") + console.print(f"[cyan]Bundle:[/cyan] {bundle_name}") + console.print(f"[cyan]Title:[/cyan] {idea.title}") + console.print(f"[cyan]Themes:[/cyan] {', '.join(product.themes)}") + console.print(f"[cyan]Features:[/cyan] {len(features)}") + console.print(f"[cyan]Releases:[/cyan] {len(product.releases)}") + + return project_bundle + + +def _prompt_feature() -> Feature: + """Prompt for feature details.""" + print_info("\nNew Feature") + + key = prompt_text("Feature key (e.g., FEATURE-001)", required=True) + title = prompt_text("Feature title", required=True) + outcomes = prompt_list("Expected outcomes") + acceptance = prompt_list("Acceptance criteria") + + add_details = prompt_confirm("Add optional details?", default=False) + + feature_data = { + "key": key, + "title": title, + "outcomes": outcomes if outcomes else [], + "acceptance": acceptance if acceptance else [], + } + + if add_details: + constraints = prompt_list("Constraints") + if constraints: + feature_data["constraints"] = constraints + + confidence = prompt_text("Confidence (0.0-1.0)", required=False) + if confidence: + with suppress(ValueError): + feature_data["confidence"] = float(confidence) + + draft = prompt_confirm("Mark as draft?", default=False) + feature_data["draft"] = draft + + # Add stories + stories: list[Story] = [] + if prompt_confirm("Add stories to this feature?", default=True): + while True: + story = _prompt_story() + stories.append(story) + + if not prompt_confirm("Add another story?", default=False): + break + + feature_data["stories"] = stories + + return Feature(**feature_data) + + +def _prompt_story() -> Story: + """Prompt for story details.""" + print_info(" New Story") + + key = prompt_text(" Story key (e.g., STORY-001)", required=True) + title = prompt_text(" Story title", required=True) + acceptance = prompt_list(" Acceptance criteria") + + story_data = { + "key": key, + "title": title, + "acceptance": acceptance if acceptance else [], + } + + if prompt_confirm(" Add optional details?", default=False): + tags = prompt_list(" Tags (e.g., critical, backend)") + if tags: + story_data["tags"] = tags + + confidence = prompt_text(" Confidence (0.0-1.0)", required=False) + if confidence: + with suppress(ValueError): + story_data["confidence"] = float(confidence) + + draft = prompt_confirm(" Mark as draft?", default=False) + story_data["draft"] = draft + + return Story(**story_data) + + +@app.command("add-feature") +@beartype +@require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string") +@require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string") +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +def add_feature( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", + ), + key: str = typer.Option(..., "--key", help="Feature key (e.g., FEATURE-001)"), + title: str = typer.Option(..., "--title", help="Feature title"), + outcomes: str | None = typer.Option(None, "--outcomes", help="Expected outcomes (comma-separated)"), + acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), +) -> None: + """ + Add a new feature to an existing project bundle. + + **Parameter Groups:** + - **Target/Input**: --bundle, --key, --title, --outcomes, --acceptance + + **Examples:** + specfact plan add-feature --key FEATURE-001 --title "User Auth" --outcomes "Secure login" --acceptance "Login works" --bundle legacy-api + specfact plan add-feature --key FEATURE-002 --title "Payment Processing" --bundle legacy-api + """ + + telemetry_metadata = { + "feature_key": key, + } + + with telemetry.track_command("plan.add_feature", telemetry_metadata) as record: + from specfact_cli.utils.structure import SpecFactStructure + + # Find bundle directory + if bundle is None: + # Try to use active plan first + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle: + print_info(f"Using active plan: {bundle}") + else: + # Fallback: Try to find default bundle (first bundle in projects directory) + projects_dir = Path(".specfact/projects") + if projects_dir.exists(): + bundles = [ + d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() + ] + if bundles: + bundle = bundles[0] + print_info(f"Using default bundle: {bundle}") + print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") + else: + print_error(f"No project bundles found in {projects_dir}") + print_error("Create one with: specfact plan init <bundle-name>") + print_error("Or specify --bundle <bundle-name> if the bundle exists") + raise typer.Exit(1) + else: + print_error(f"Projects directory not found: {projects_dir}") + print_error("Create one with: specfact plan init <bundle-name>") + print_error("Or specify --bundle <bundle-name> if the bundle exists") + raise typer.Exit(1) + + bundle_dir = _find_bundle_dir(bundle) + if bundle_dir is None: + raise typer.Exit(1) + + print_section("SpecFact CLI - Add Feature") + + try: + # Load existing project bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Convert to PlanBundle for compatibility + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Check if feature key already exists + existing_keys = {f.key for f in plan_bundle.features} + if key in existing_keys: + print_error(f"Feature '{key}' already exists in bundle") + raise typer.Exit(1) + + # Parse outcomes and acceptance (comma-separated strings) + outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else [] + acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] + + # Create new feature + new_feature = Feature( + key=key, + title=title, + outcomes=outcomes_list, + acceptance=acceptance_list, + constraints=[], + stories=[], + confidence=1.0, + draft=False, + source_tracking=None, + contract=None, + protocol=None, + ) + + # Add feature to plan bundle + plan_bundle.features.append(new_feature) + + # Convert back to ProjectBundle and save + updated_project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "total_features": len(plan_bundle.features), + "outcomes_count": len(outcomes_list), + "acceptance_count": len(acceptance_list), + } + ) + + print_success(f"Feature '{key}' added successfully") + console.print(f"[dim]Feature: {title}[/dim]") + if outcomes_list: + console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]") + if acceptance_list: + console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") + + except Exception as e: + print_error(f"Failed to add feature: {e}") + raise typer.Exit(1) from e + + +@app.command("add-story") +@beartype +@require(lambda feature: isinstance(feature, str) and len(feature) > 0, "Feature must be non-empty string") +@require(lambda key: isinstance(key, str) and len(key) > 0, "Key must be non-empty string") +@require(lambda title: isinstance(title, str) and len(title) > 0, "Title must be non-empty string") +@require( + lambda story_points: story_points is None or (story_points >= 0 and story_points <= 100), + "Story points must be 0-100 if provided", +) +@require( + lambda value_points: value_points is None or (value_points >= 0 and value_points <= 100), + "Value points must be 0-100 if provided", +) +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +def add_story( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", + ), + feature: str = typer.Option(..., "--feature", help="Parent feature key"), + key: str = typer.Option(..., "--key", help="Story key (e.g., STORY-001)"), + title: str = typer.Option(..., "--title", help="Story title"), + acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), + story_points: int | None = typer.Option(None, "--story-points", help="Story points (complexity)"), + value_points: int | None = typer.Option(None, "--value-points", help="Value points (business value)"), + # Behavior/Options + draft: bool = typer.Option(False, "--draft", help="Mark story as draft"), +) -> None: + """ + Add a new story to a feature. + + **Parameter Groups:** + - **Target/Input**: --bundle, --feature, --key, --title, --acceptance, --story-points, --value-points + - **Behavior/Options**: --draft + + **Examples:** + specfact plan add-story --feature FEATURE-001 --key STORY-001 --title "Login API" --acceptance "API works" --story-points 5 --bundle legacy-api + specfact plan add-story --feature FEATURE-001 --key STORY-002 --title "Logout API" --bundle legacy-api --draft + """ + + telemetry_metadata = { + "feature_key": feature, + "story_key": key, + } + + with telemetry.track_command("plan.add_story", telemetry_metadata) as record: + from specfact_cli.utils.structure import SpecFactStructure + + # Find bundle directory + if bundle is None: + # Try to use active plan first + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle: + print_info(f"Using active plan: {bundle}") + else: + # Fallback: Try to find default bundle (first bundle in projects directory) + projects_dir = Path(".specfact/projects") + if projects_dir.exists(): + bundles = [ + d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() + ] + if bundles: + bundle = bundles[0] + print_info(f"Using default bundle: {bundle}") + print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") + else: + print_error(f"No project bundles found in {projects_dir}") + print_error("Create one with: specfact plan init <bundle-name>") + print_error("Or specify --bundle <bundle-name> if the bundle exists") + raise typer.Exit(1) + else: + print_error(f"Projects directory not found: {projects_dir}") + print_error("Create one with: specfact plan init <bundle-name>") + print_error("Or specify --bundle <bundle-name> if the bundle exists") + raise typer.Exit(1) + + bundle_dir = _find_bundle_dir(bundle) + if bundle_dir is None: + raise typer.Exit(1) + + print_section("SpecFact CLI - Add Story") + + try: + # Load existing project bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Convert to PlanBundle for compatibility + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Find parent feature + parent_feature = None + for f in plan_bundle.features: + if f.key == feature: + parent_feature = f + break + + if parent_feature is None: + print_error(f"Feature '{feature}' not found in bundle") + console.print(f"[dim]Available features: {', '.join(f.key for f in plan_bundle.features)}[/dim]") + raise typer.Exit(1) + + # Check if story key already exists in feature + existing_story_keys = {s.key for s in parent_feature.stories} + if key in existing_story_keys: + print_error(f"Story '{key}' already exists in feature '{feature}'") + raise typer.Exit(1) + + # Parse acceptance (comma-separated string) + acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] + + # Create new story + new_story = Story( + key=key, + title=title, + acceptance=acceptance_list, + tags=[], + story_points=story_points, + value_points=value_points, + tasks=[], + confidence=1.0, + draft=draft, + contracts=None, + scenarios=None, + ) + + # Add story to feature + parent_feature.stories.append(new_story) + + # Convert back to ProjectBundle and save + updated_project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "total_stories": len(parent_feature.stories), + "acceptance_count": len(acceptance_list), + "story_points": story_points if story_points else 0, + "value_points": value_points if value_points else 0, + } + ) + + print_success(f"Story '{key}' added to feature '{feature}'") + console.print(f"[dim]Story: {title}[/dim]") + if acceptance_list: + console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") + if story_points: + console.print(f"[dim]Story Points: {story_points}[/dim]") + if value_points: + console.print(f"[dim]Value Points: {value_points}[/dim]") + + except Exception as e: + print_error(f"Failed to add story: {e}") + raise typer.Exit(1) from e + + +@app.command("update-idea") +@beartype +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +def update_idea( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", + ), + title: str | None = typer.Option(None, "--title", help="Idea title"), + narrative: str | None = typer.Option(None, "--narrative", help="Idea narrative (brief description)"), + target_users: str | None = typer.Option(None, "--target-users", help="Target user personas (comma-separated)"), + value_hypothesis: str | None = typer.Option(None, "--value-hypothesis", help="Value hypothesis statement"), + constraints: str | None = typer.Option(None, "--constraints", help="Idea-level constraints (comma-separated)"), +) -> None: + """ + Update idea section metadata in a project bundle (optional business context). + + This command allows updating idea properties (title, narrative, target users, + value hypothesis, constraints) in non-interactive environments (CI/CD, Copilot). + + Note: The idea section is OPTIONAL - it provides business context and metadata, + not technical implementation details. All parameters are optional. + + **Parameter Groups:** + - **Target/Input**: --bundle, --title, --narrative, --target-users, --value-hypothesis, --constraints + + **Examples:** + specfact plan update-idea --target-users "Developers, DevOps" --value-hypothesis "Reduce technical debt" --bundle legacy-api + specfact plan update-idea --constraints "Python 3.11+, Maintain backward compatibility" --bundle legacy-api + """ + + telemetry_metadata = {} + + with telemetry.track_command("plan.update_idea", telemetry_metadata) as record: + from specfact_cli.utils.structure import SpecFactStructure + + # Find bundle directory + if bundle is None: + # Try to use active plan first + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle: + print_info(f"Using active plan: {bundle}") + else: + # Fallback: Try to find default bundle (first bundle in projects directory) + projects_dir = Path(".specfact/projects") + if projects_dir.exists(): + bundles = [ + d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() + ] + if bundles: + bundle = bundles[0] + print_info(f"Using default bundle: {bundle}") + print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") + else: + print_error(f"No project bundles found in {projects_dir}") + print_error("Create one with: specfact plan init <bundle-name>") + print_error("Or specify --bundle <bundle-name> if the bundle exists") + raise typer.Exit(1) + else: + print_error(f"Projects directory not found: {projects_dir}") + print_error("Create one with: specfact plan init <bundle-name>") + print_error("Or specify --bundle <bundle-name> if the bundle exists") + raise typer.Exit(1) + + bundle_dir = _find_bundle_dir(bundle) + if bundle_dir is None: + raise typer.Exit(1) + + print_section("SpecFact CLI - Update Idea") + + try: + # Load existing project bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Convert to PlanBundle for compatibility + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Create idea section if it doesn't exist + if plan_bundle.idea is None: + plan_bundle.idea = Idea( + title=title or "Untitled", + narrative=narrative or "", + target_users=[], + value_hypothesis="", + constraints=[], + metrics=None, + ) + print_info("Created new idea section") + idea = plan_bundle.idea + if idea is None: + print_error("Failed to initialize idea section") + raise typer.Exit(1) + + # Track what was updated + updates_made = [] + + # Update title if provided + if title is not None: + idea.title = title + updates_made.append("title") + + # Update narrative if provided + if narrative is not None: + idea.narrative = narrative + updates_made.append("narrative") + + # Update target_users if provided + if target_users is not None: + target_users_list = [u.strip() for u in target_users.split(",")] if target_users else [] + idea.target_users = target_users_list + updates_made.append("target_users") + + # Update value_hypothesis if provided + if value_hypothesis is not None: + idea.value_hypothesis = value_hypothesis + updates_made.append("value_hypothesis") + + # Update constraints if provided + if constraints is not None: + constraints_list = [c.strip() for c in constraints.split(",")] if constraints else [] + idea.constraints = constraints_list + updates_made.append("constraints") + + if not updates_made: + print_warning( + "No updates specified. Use --title, --narrative, --target-users, --value-hypothesis, or --constraints" + ) + raise typer.Exit(1) + + # Convert back to ProjectBundle and save + updated_project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "updates": updates_made, + "idea_exists": plan_bundle.idea is not None, + } + ) + + print_success("Idea section updated successfully") + console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]") + if title: + console.print(f"[dim]Title: {title}[/dim]") + if narrative: + console.print( + f"[dim]Narrative: {narrative[:80]}...[/dim]" + if len(narrative) > 80 + else f"[dim]Narrative: {narrative}[/dim]" + ) + if target_users: + target_users_list = [u.strip() for u in target_users.split(",")] if target_users else [] + console.print(f"[dim]Target Users: {', '.join(target_users_list)}[/dim]") + if value_hypothesis: + console.print( + f"[dim]Value Hypothesis: {value_hypothesis[:80]}...[/dim]" + if len(value_hypothesis) > 80 + else f"[dim]Value Hypothesis: {value_hypothesis}[/dim]" + ) + if constraints: + constraints_list = [c.strip() for c in constraints.split(",")] if constraints else [] + console.print(f"[dim]Constraints: {', '.join(constraints_list)}[/dim]") + + except Exception as e: + print_error(f"Failed to update idea: {e}") + raise typer.Exit(1) from e + + +@app.command("update-feature") +@beartype +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +def update_feature( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", + ), + key: str | None = typer.Option( + None, "--key", help="Feature key to update (e.g., FEATURE-001). Required unless --batch-updates is provided." + ), + title: str | None = typer.Option(None, "--title", help="Feature title"), + outcomes: str | None = typer.Option(None, "--outcomes", help="Expected outcomes (comma-separated)"), + acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), + constraints: str | None = typer.Option(None, "--constraints", help="Constraints (comma-separated)"), + confidence: float | None = typer.Option(None, "--confidence", help="Confidence score (0.0-1.0)"), + draft: bool | None = typer.Option( + None, + "--draft/--no-draft", + help="Mark as draft (use --draft to set True, --no-draft to set False, omit to leave unchanged)", + ), + batch_updates: Path | None = typer.Option( + None, + "--batch-updates", + help="Path to JSON/YAML file with multiple feature updates. File format: list of objects with 'key' and update fields (title, outcomes, acceptance, constraints, confidence, draft).", + ), +) -> None: + """ + Update an existing feature's metadata in a project bundle. + + This command allows updating feature properties (title, outcomes, acceptance criteria, + constraints, confidence, draft status) in non-interactive environments (CI/CD, Copilot). + + Supports both single feature updates and batch updates via --batch-updates file. + + **Parameter Groups:** + - **Target/Input**: --bundle, --key, --title, --outcomes, --acceptance, --constraints, --confidence, --batch-updates + - **Behavior/Options**: --draft/--no-draft + + **Examples:** + # Single feature update + specfact plan update-feature --key FEATURE-001 --title "Updated Title" --outcomes "Outcome 1, Outcome 2" --bundle legacy-api + specfact plan update-feature --key FEATURE-001 --acceptance "Criterion 1, Criterion 2" --confidence 0.9 --bundle legacy-api + + # Batch updates from file + specfact plan update-feature --batch-updates updates.json --bundle legacy-api + """ + from specfact_cli.utils.structure import SpecFactStructure + + # Validate that either key or batch_updates is provided + if not key and not batch_updates: + print_error("Either --key or --batch-updates must be provided") + raise typer.Exit(1) + + if key and batch_updates: + print_error("Cannot use both --key and --batch-updates. Use --batch-updates for multiple updates.") + raise typer.Exit(1) + + telemetry_metadata = { + "batch_mode": batch_updates is not None, + } + + with telemetry.track_command("plan.update_feature", telemetry_metadata) as record: + # Find bundle directory + if bundle is None: + # Try to use active plan first + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle: + print_info(f"Using active plan: {bundle}") + else: + # Fallback: Try to find default bundle (first bundle in projects directory) + projects_dir = Path(".specfact/projects") + if projects_dir.exists(): + bundles = [ + d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() + ] + if bundles: + bundle = bundles[0] + print_info(f"Using default bundle: {bundle}") + print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") + else: + print_error("No bundles found. Create one with: specfact plan init <bundle-name>") + raise typer.Exit(1) + else: + print_error("No bundles found. Create one with: specfact plan init <bundle-name>") + raise typer.Exit(1) + + bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Bundle '{bundle}' not found: {bundle_dir}\nCreate one with: specfact plan init {bundle}") + raise typer.Exit(1) + + print_section("SpecFact CLI - Update Feature") + + try: + # Load existing project bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Convert to PlanBundle for compatibility + existing_plan = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Handle batch updates + if batch_updates: + if not batch_updates.exists(): + print_error(f"Batch updates file not found: {batch_updates}") + raise typer.Exit(1) + + print_info(f"Loading batch updates from: {batch_updates}") + batch_data = load_structured_file(batch_updates) + + if not isinstance(batch_data, list): + print_error("Batch updates file must contain a list of update objects") + raise typer.Exit(1) + + total_updates = 0 + successful_updates = 0 + failed_updates = [] + + for update_item in batch_data: + if not isinstance(update_item, dict): + failed_updates.append({"item": update_item, "error": "Not a dictionary"}) + continue + + update_key = update_item.get("key") + if not update_key: + failed_updates.append({"item": update_item, "error": "Missing 'key' field"}) + continue + + total_updates += 1 + + # Find feature to update + feature_to_update = None + for f in existing_plan.features: + if f.key == update_key: + feature_to_update = f + break + + if feature_to_update is None: + failed_updates.append({"key": update_key, "error": f"Feature '{update_key}' not found in plan"}) + continue + + # Track what was updated + updates_made = [] + + # Update fields from batch item + if "title" in update_item: + feature_to_update.title = update_item["title"] + updates_made.append("title") + + if "outcomes" in update_item: + outcomes_val = update_item["outcomes"] + if isinstance(outcomes_val, str): + outcomes_list = [o.strip() for o in outcomes_val.split(",")] if outcomes_val else [] + elif isinstance(outcomes_val, list): + outcomes_list = outcomes_val + else: + failed_updates.append({"key": update_key, "error": "Invalid 'outcomes' format"}) + continue + feature_to_update.outcomes = outcomes_list + updates_made.append("outcomes") + + if "acceptance" in update_item: + acceptance_val = update_item["acceptance"] + if isinstance(acceptance_val, str): + acceptance_list = [a.strip() for a in acceptance_val.split(",")] if acceptance_val else [] + elif isinstance(acceptance_val, list): + acceptance_list = acceptance_val + else: + failed_updates.append({"key": update_key, "error": "Invalid 'acceptance' format"}) + continue + feature_to_update.acceptance = acceptance_list + updates_made.append("acceptance") + + if "constraints" in update_item: + constraints_val = update_item["constraints"] + if isinstance(constraints_val, str): + constraints_list = ( + [c.strip() for c in constraints_val.split(",")] if constraints_val else [] + ) + elif isinstance(constraints_val, list): + constraints_list = constraints_val + else: + failed_updates.append({"key": update_key, "error": "Invalid 'constraints' format"}) + continue + feature_to_update.constraints = constraints_list + updates_made.append("constraints") + + if "confidence" in update_item: + conf_val = update_item["confidence"] + if not isinstance(conf_val, (int, float)) or not (0.0 <= conf_val <= 1.0): + failed_updates.append({"key": update_key, "error": "Confidence must be 0.0-1.0"}) + continue + feature_to_update.confidence = float(conf_val) + updates_made.append("confidence") + + if "draft" in update_item: + feature_to_update.draft = bool(update_item["draft"]) + updates_made.append("draft") + + if updates_made: + successful_updates += 1 + console.print(f"[dim]✓ Updated {update_key}: {', '.join(updates_made)}[/dim]") + else: + failed_updates.append({"key": update_key, "error": "No valid update fields provided"}) + + # Convert back to ProjectBundle and save + print_info("Validating updated plan...") + updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "batch_total": total_updates, + "batch_successful": successful_updates, + "batch_failed": len(failed_updates), + "total_features": len(existing_plan.features), + } + ) + + print_success(f"Batch update complete: {successful_updates}/{total_updates} features updated") + if failed_updates: + print_warning(f"{len(failed_updates)} update(s) failed:") + for failed in failed_updates: + console.print( + f"[dim] - {failed.get('key', 'Unknown')}: {failed.get('error', 'Unknown error')}[/dim]" + ) + + else: + # Single feature update (existing logic) + if not key: + print_error("--key is required when not using --batch-updates") + raise typer.Exit(1) + + # Find feature to update + feature_to_update = None + for f in existing_plan.features: + if f.key == key: + feature_to_update = f + break + + if feature_to_update is None: + print_error(f"Feature '{key}' not found in plan") + console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]") + raise typer.Exit(1) + + # Track what was updated + updates_made = [] + + # Update title if provided + if title is not None: + feature_to_update.title = title + updates_made.append("title") + + # Update outcomes if provided + if outcomes is not None: + outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else [] + feature_to_update.outcomes = outcomes_list + updates_made.append("outcomes") + + # Update acceptance criteria if provided + if acceptance is not None: + acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] + feature_to_update.acceptance = acceptance_list + updates_made.append("acceptance") + + # Update constraints if provided + if constraints is not None: + constraints_list = [c.strip() for c in constraints.split(",")] if constraints else [] + feature_to_update.constraints = constraints_list + updates_made.append("constraints") + + # Update confidence if provided + if confidence is not None: + if not (0.0 <= confidence <= 1.0): + print_error(f"Confidence must be between 0.0 and 1.0, got: {confidence}") + raise typer.Exit(1) + feature_to_update.confidence = confidence + updates_made.append("confidence") + + # Update draft status if provided + if draft is not None: + feature_to_update.draft = draft + updates_made.append("draft") + + if not updates_made: + print_warning( + "No updates specified. Use --title, --outcomes, --acceptance, --constraints, --confidence, or --draft" + ) + raise typer.Exit(1) + + # Convert back to ProjectBundle and save + print_info("Validating updated plan...") + updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "updates": updates_made, + "total_features": len(existing_plan.features), + } + ) + + print_success(f"Feature '{key}' updated successfully") + console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]") + if title: + console.print(f"[dim]Title: {title}[/dim]") + if outcomes: + outcomes_list = [o.strip() for o in outcomes.split(",")] if outcomes else [] + console.print(f"[dim]Outcomes: {', '.join(outcomes_list)}[/dim]") + if acceptance: + acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] + console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") + + except Exception as e: + print_error(f"Failed to update feature: {e}") + raise typer.Exit(1) from e + + +@app.command("update-story") +@beartype +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +@require( + lambda story_points: story_points is None or (story_points >= 0 and story_points <= 100), + "Story points must be 0-100 if provided", +) +@require( + lambda value_points: value_points is None or (value_points >= 0 and value_points <= 100), + "Value points must be 0-100 if provided", +) +@require(lambda confidence: confidence is None or (0.0 <= confidence <= 1.0), "Confidence must be 0.0-1.0 if provided") +def update_story( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (required, e.g., legacy-api). If not specified, attempts to use default bundle.", + ), + feature: str | None = typer.Option( + None, "--feature", help="Parent feature key (e.g., FEATURE-001). Required unless --batch-updates is provided." + ), + key: str | None = typer.Option( + None, "--key", help="Story key to update (e.g., STORY-001). Required unless --batch-updates is provided." + ), + title: str | None = typer.Option(None, "--title", help="Story title"), + acceptance: str | None = typer.Option(None, "--acceptance", help="Acceptance criteria (comma-separated)"), + story_points: int | None = typer.Option(None, "--story-points", help="Story points (complexity: 0-100)"), + value_points: int | None = typer.Option(None, "--value-points", help="Value points (business value: 0-100)"), + confidence: float | None = typer.Option(None, "--confidence", help="Confidence score (0.0-1.0)"), + draft: bool | None = typer.Option( + None, + "--draft/--no-draft", + help="Mark as draft (use --draft to set True, --no-draft to set False, omit to leave unchanged)", + ), + batch_updates: Path | None = typer.Option( + None, + "--batch-updates", + help="Path to JSON/YAML file with multiple story updates. File format: list of objects with 'feature', 'key' and update fields (title, acceptance, story_points, value_points, confidence, draft).", + ), +) -> None: + """ + Update an existing story's metadata in a project bundle. + + This command allows updating story properties (title, acceptance criteria, + story points, value points, confidence, draft status) in non-interactive + environments (CI/CD, Copilot). + + Supports both single story updates and batch updates via --batch-updates file. + + **Parameter Groups:** + - **Target/Input**: --bundle, --feature, --key, --title, --acceptance, --story-points, --value-points, --confidence, --batch-updates + - **Behavior/Options**: --draft/--no-draft + + **Examples:** + # Single story update + specfact plan update-story --feature FEATURE-001 --key STORY-001 --title "Updated Title" --bundle legacy-api + specfact plan update-story --feature FEATURE-001 --key STORY-001 --acceptance "Criterion 1, Criterion 2" --confidence 0.9 --bundle legacy-api + + # Batch updates from file + specfact plan update-story --batch-updates updates.json --bundle legacy-api + """ + from specfact_cli.utils.structure import SpecFactStructure + + # Validate that either (feature and key) or batch_updates is provided + if not (feature and key) and not batch_updates: + print_error("Either (--feature and --key) or --batch-updates must be provided") + raise typer.Exit(1) + + if (feature or key) and batch_updates: + print_error("Cannot use both (--feature/--key) and --batch-updates. Use --batch-updates for multiple updates.") + raise typer.Exit(1) + + telemetry_metadata = { + "batch_mode": batch_updates is not None, + } + + with telemetry.track_command("plan.update_story", telemetry_metadata) as record: + # Find bundle directory + if bundle is None: + # Try to use active plan first + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle: + print_info(f"Using active plan: {bundle}") + else: + # Fallback: Try to find default bundle (first bundle in projects directory) + projects_dir = Path(".specfact/projects") + if projects_dir.exists(): + bundles = [ + d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() + ] + if bundles: + bundle = bundles[0] + print_info(f"Using default bundle: {bundle}") + print_info(f"Tip: Use 'specfact plan select {bundle}' to set as active plan") + else: + print_error("No bundles found. Create one with: specfact plan init <bundle-name>") + raise typer.Exit(1) + else: + print_error("No bundles found. Create one with: specfact plan init <bundle-name>") + raise typer.Exit(1) + + bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Bundle '{bundle}' not found: {bundle_dir}\nCreate one with: specfact plan init {bundle}") + raise typer.Exit(1) + + print_section("SpecFact CLI - Update Story") + + try: + # Load existing project bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Convert to PlanBundle for compatibility + existing_plan = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Handle batch updates + if batch_updates: + if not batch_updates.exists(): + print_error(f"Batch updates file not found: {batch_updates}") + raise typer.Exit(1) + + print_info(f"Loading batch updates from: {batch_updates}") + batch_data = load_structured_file(batch_updates) + + if not isinstance(batch_data, list): + print_error("Batch updates file must contain a list of update objects") + raise typer.Exit(1) + + total_updates = 0 + successful_updates = 0 + failed_updates = [] + + for update_item in batch_data: + if not isinstance(update_item, dict): + failed_updates.append({"item": update_item, "error": "Not a dictionary"}) + continue + + update_feature = update_item.get("feature") + update_key = update_item.get("key") + if not update_feature or not update_key: + failed_updates.append({"item": update_item, "error": "Missing 'feature' or 'key' field"}) + continue + + total_updates += 1 + + # Find parent feature + parent_feature = None + for f in existing_plan.features: + if f.key == update_feature: + parent_feature = f + break + + if parent_feature is None: + failed_updates.append( + { + "feature": update_feature, + "key": update_key, + "error": f"Feature '{update_feature}' not found in plan", + } + ) + continue + + # Find story to update + story_to_update = None + for s in parent_feature.stories: + if s.key == update_key: + story_to_update = s + break + + if story_to_update is None: + failed_updates.append( + { + "feature": update_feature, + "key": update_key, + "error": f"Story '{update_key}' not found in feature '{update_feature}'", + } + ) + continue + + # Track what was updated + updates_made = [] + + # Update fields from batch item + if "title" in update_item: + story_to_update.title = update_item["title"] + updates_made.append("title") + + if "acceptance" in update_item: + acceptance_val = update_item["acceptance"] + if isinstance(acceptance_val, str): + acceptance_list = [a.strip() for a in acceptance_val.split(",")] if acceptance_val else [] + elif isinstance(acceptance_val, list): + acceptance_list = acceptance_val + else: + failed_updates.append( + {"feature": update_feature, "key": update_key, "error": "Invalid 'acceptance' format"} + ) + continue + story_to_update.acceptance = acceptance_list + updates_made.append("acceptance") + + if "story_points" in update_item: + sp_val = update_item["story_points"] + if not isinstance(sp_val, int) or not (0 <= sp_val <= 100): + failed_updates.append( + {"feature": update_feature, "key": update_key, "error": "Story points must be 0-100"} + ) + continue + story_to_update.story_points = sp_val + updates_made.append("story_points") + + if "value_points" in update_item: + vp_val = update_item["value_points"] + if not isinstance(vp_val, int) or not (0 <= vp_val <= 100): + failed_updates.append( + {"feature": update_feature, "key": update_key, "error": "Value points must be 0-100"} + ) + continue + story_to_update.value_points = vp_val + updates_made.append("value_points") + + if "confidence" in update_item: + conf_val = update_item["confidence"] + if not isinstance(conf_val, (int, float)) or not (0.0 <= conf_val <= 1.0): + failed_updates.append( + {"feature": update_feature, "key": update_key, "error": "Confidence must be 0.0-1.0"} + ) + continue + story_to_update.confidence = float(conf_val) + updates_made.append("confidence") + + if "draft" in update_item: + story_to_update.draft = bool(update_item["draft"]) + updates_made.append("draft") + + if updates_made: + successful_updates += 1 + console.print(f"[dim]✓ Updated {update_feature}/{update_key}: {', '.join(updates_made)}[/dim]") + else: + failed_updates.append( + {"feature": update_feature, "key": update_key, "error": "No valid update fields provided"} + ) + + # Convert back to ProjectBundle and save + print_info("Validating updated plan...") + updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "batch_total": total_updates, + "batch_successful": successful_updates, + "batch_failed": len(failed_updates), + } + ) + + print_success(f"Batch update complete: {successful_updates}/{total_updates} stories updated") + if failed_updates: + print_warning(f"{len(failed_updates)} update(s) failed:") + for failed in failed_updates: + console.print( + f"[dim] - {failed.get('feature', 'Unknown')}/{failed.get('key', 'Unknown')}: {failed.get('error', 'Unknown error')}[/dim]" + ) + + else: + # Single story update (existing logic) + if not feature or not key: + print_error("--feature and --key are required when not using --batch-updates") + raise typer.Exit(1) + + # Find parent feature + parent_feature = None + for f in existing_plan.features: + if f.key == feature: + parent_feature = f + break + + if parent_feature is None: + print_error(f"Feature '{feature}' not found in plan") + console.print(f"[dim]Available features: {', '.join(f.key for f in existing_plan.features)}[/dim]") + raise typer.Exit(1) + + # Find story to update + story_to_update = None + for s in parent_feature.stories: + if s.key == key: + story_to_update = s + break + + if story_to_update is None: + print_error(f"Story '{key}' not found in feature '{feature}'") + console.print(f"[dim]Available stories: {', '.join(s.key for s in parent_feature.stories)}[/dim]") + raise typer.Exit(1) + + # Track what was updated + updates_made = [] + + # Update title if provided + if title is not None: + story_to_update.title = title + updates_made.append("title") + + # Update acceptance criteria if provided + if acceptance is not None: + acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] + story_to_update.acceptance = acceptance_list + updates_made.append("acceptance") + + # Update story points if provided + if story_points is not None: + story_to_update.story_points = story_points + updates_made.append("story_points") + + # Update value points if provided + if value_points is not None: + story_to_update.value_points = value_points + updates_made.append("value_points") + + # Update confidence if provided + if confidence is not None: + if not (0.0 <= confidence <= 1.0): + print_error(f"Confidence must be between 0.0 and 1.0, got: {confidence}") + raise typer.Exit(1) + story_to_update.confidence = confidence + updates_made.append("confidence") + + # Update draft status if provided + if draft is not None: + story_to_update.draft = draft + updates_made.append("draft") + + if not updates_made: + print_warning( + "No updates specified. Use --title, --acceptance, --story-points, --value-points, --confidence, or --draft" + ) + raise typer.Exit(1) + + # Convert back to ProjectBundle and save + print_info("Validating updated plan...") + updated_project_bundle = _convert_plan_bundle_to_project_bundle(existing_plan, bundle) + _save_bundle_with_progress(updated_project_bundle, bundle_dir, atomic=True) + + record( + { + "updates": updates_made, + "total_stories": len(parent_feature.stories), + } + ) + + print_success(f"Story '{key}' in feature '{feature}' updated successfully") + console.print(f"[dim]Updated fields: {', '.join(updates_made)}[/dim]") + if title: + console.print(f"[dim]Title: {title}[/dim]") + if acceptance: + acceptance_list = [a.strip() for a in acceptance.split(",")] if acceptance else [] + console.print(f"[dim]Acceptance: {', '.join(acceptance_list)}[/dim]") + if story_points is not None: + console.print(f"[dim]Story Points: {story_points}[/dim]") + if value_points is not None: + console.print(f"[dim]Value Points: {value_points}[/dim]") + if confidence is not None: + console.print(f"[dim]Confidence: {confidence}[/dim]") + + except Exception as e: + print_error(f"Failed to update story: {e}") + raise typer.Exit(1) from e + + +@app.command("compare") +@beartype +@require(lambda manual: manual is None or isinstance(manual, Path), "Manual must be None or Path") +@require(lambda auto: auto is None or isinstance(auto, Path), "Auto must be None or Path") +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or string") +@require( + lambda output_format: isinstance(output_format, str) and output_format.lower() in ("markdown", "json", "yaml"), + "Output format must be markdown, json, or yaml", +) +@require(lambda out: out is None or isinstance(out, Path), "Out must be None or Path") +def compare( + # Target/Input + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If specified, compares bundles instead of legacy plan files.", + ), + manual: Path | None = typer.Option( + None, + "--manual", + help="Manual plan bundle path (bundle directory: .specfact/projects/<bundle>/). Ignored if --bundle is specified.", + ), + auto: Path | None = typer.Option( + None, + "--auto", + help="Auto-derived plan bundle path (bundle directory: .specfact/projects/<bundle>/). Ignored if --bundle is specified.", + ), + # Output/Results + output_format: str = typer.Option( + "markdown", + "--output-format", + help="Output format (markdown, json, yaml)", + ), + out: Path | None = typer.Option( + None, + "--out", + help="Output file path (default: .specfact/projects/<bundle-name>/reports/comparison/report-<timestamp>.md when --bundle is provided).", + ), + # Behavior/Options + code_vs_plan: bool = typer.Option( + False, + "--code-vs-plan", + help="Alias for comparing code-derived plan vs manual plan (auto-detects latest auto plan)", + ), +) -> None: + """ + Compare manual and auto-derived plans to detect code vs plan drift. + + Detects deviations between manually created plans (intended design) and + reverse-engineered plans from code (actual implementation). This comparison + identifies code vs plan drift automatically. + + Use --code-vs-plan for convenience: automatically compares the latest + code-derived plan against the manual plan. + + **Parameter Groups:** + - **Target/Input**: --bundle, --manual, --auto + - **Output/Results**: --output-format, --out + - **Behavior/Options**: --code-vs-plan + + **Examples:** + specfact plan compare --manual .specfact/projects/manual-bundle --auto .specfact/projects/auto-bundle + specfact plan compare --code-vs-plan # Convenience alias (requires bundle-based paths) + specfact plan compare --bundle legacy-api --output-format json + """ + from specfact_cli.utils.structure import SpecFactStructure + + telemetry_metadata = { + "code_vs_plan": code_vs_plan, + "output_format": output_format.lower(), + } + + with telemetry.track_command("plan.compare", telemetry_metadata) as record: + # Ensure .specfact structure exists + SpecFactStructure.ensure_structure() + + # Handle --code-vs-plan convenience alias + if code_vs_plan: + # Auto-detect manual plan (default) + if manual is None: + manual = SpecFactStructure.get_default_plan_path() + if not manual.exists(): + print_error( + "Default manual bundle not found.\nCreate one with: specfact plan init <bundle-name> --interactive" + ) + raise typer.Exit(1) + print_info(f"Using default manual bundle: {manual}") + + # Auto-detect latest code-derived plan + if auto is None: + auto = SpecFactStructure.get_latest_brownfield_report() + if auto is None: + print_error( + "No code-derived bundles found in .specfact/projects/*/reports/brownfield/.\n" + "Generate one with: specfact import from-code <bundle-name> --repo ." + ) + raise typer.Exit(1) + print_info(f"Using latest code-derived bundle report: {auto}") + + # Override help text to emphasize code vs plan drift + print_section("Code vs Plan Drift Detection") + console.print( + "[dim]Comparing intended design (manual plan) vs actual implementation (code-derived plan)[/dim]\n" + ) + + # Use default paths if not specified (smart defaults) + if manual is None: + manual = SpecFactStructure.get_default_plan_path() + if not manual.exists(): + print_error( + "Default manual bundle not found.\nCreate one with: specfact plan init <bundle-name> --interactive" + ) + raise typer.Exit(1) + print_info(f"Using default manual bundle: {manual}") + + if auto is None: + # Use smart default: find latest auto-derived plan + auto = SpecFactStructure.get_latest_brownfield_report() + if auto is None: + print_error( + "No auto-derived bundles found in .specfact/projects/*/reports/brownfield/.\n" + "Generate one with: specfact import from-code <bundle-name> --repo ." + ) + raise typer.Exit(1) + print_info(f"Using latest auto-derived bundle: {auto}") + + if out is None: + # Use smart default: timestamped comparison report + extension = {"markdown": "md", "json": "json", "yaml": "yaml"}[output_format.lower()] + # Phase 8.5: Use bundle-specific path if bundle context available + # Try to infer bundle from manual plan path or use bundle parameter + bundle_name = None + if bundle is not None: + bundle_name = bundle + elif manual is not None: + # Try to extract bundle name from manual plan path + manual_str = str(manual) + if "/projects/" in manual_str: + # Extract bundle name from path like .specfact/projects/<bundle-name>/... + parts = manual_str.split("/projects/") + if len(parts) > 1: + bundle_part = parts[1].split("/")[0] + if bundle_part: + bundle_name = bundle_part + + if bundle_name: + # Use bundle-specific comparison report path (Phase 8.5) + out = SpecFactStructure.get_bundle_comparison_report_path( + bundle_name=bundle_name, base_path=Path("."), format=extension + ) + else: + # Fallback to global path (backward compatibility during transition) + out = SpecFactStructure.get_comparison_report_path(format=extension) + print_info(f"Writing comparison report to: {out}") + + print_section("SpecFact CLI - Plan Comparison") + + # Validate inputs (after defaults are set) + if manual is not None and not manual.exists(): + print_error(f"Manual plan not found: {manual}") + raise typer.Exit(1) + + if auto is not None and not auto.exists(): + print_error(f"Auto plan not found: {auto}") + raise typer.Exit(1) + + # Validate output format + if output_format.lower() not in ("markdown", "json", "yaml"): + print_error(f"Invalid output format: {output_format}. Must be markdown, json, or yaml") + raise typer.Exit(1) + + try: + # Load plans + # Note: validate_plan_bundle returns tuple[bool, str | None, PlanBundle | None] when given a Path + print_info(f"Loading manual plan: {manual}") + validation_result = validate_plan_bundle(manual) + # Type narrowing: when Path is passed, always returns tuple + assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path" + is_valid, error, manual_plan = validation_result + if not is_valid or manual_plan is None: + print_error(f"Manual plan validation failed: {error}") + raise typer.Exit(1) + + print_info(f"Loading auto plan: {auto}") + validation_result = validate_plan_bundle(auto) + # Type narrowing: when Path is passed, always returns tuple + assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path" + is_valid, error, auto_plan = validation_result + if not is_valid or auto_plan is None: + print_error(f"Auto plan validation failed: {error}") + raise typer.Exit(1) + + # Compare plans + print_info("Comparing plans...") + comparator = PlanComparator() + report = comparator.compare( + manual_plan, + auto_plan, + manual_label=str(manual), + auto_label=str(auto), + ) + + # Record comparison results + record( + { + "total_deviations": report.total_deviations, + "high_count": report.high_count, + "medium_count": report.medium_count, + "low_count": report.low_count, + "manual_features": len(manual_plan.features) if manual_plan.features else 0, + "auto_features": len(auto_plan.features) if auto_plan.features else 0, + } + ) + + # Display results + print_section("Comparison Results") + + console.print(f"[cyan]Manual Plan:[/cyan] {manual}") + console.print(f"[cyan]Auto Plan:[/cyan] {auto}") + console.print(f"[cyan]Total Deviations:[/cyan] {report.total_deviations}\n") + + if report.total_deviations == 0: + print_success("No deviations found! Plans are identical.") + else: + # Show severity summary + console.print("[bold]Deviation Summary:[/bold]") + console.print(f" 🔴 [bold red]HIGH:[/bold red] {report.high_count}") + console.print(f" 🟡 [bold yellow]MEDIUM:[/bold yellow] {report.medium_count}") + console.print(f" 🔵 [bold blue]LOW:[/bold blue] {report.low_count}\n") + + # Show detailed table + table = Table(title="Deviations by Type and Severity") + table.add_column("Severity", style="bold") + table.add_column("Type", style="cyan") + table.add_column("Description", style="white", no_wrap=False) + table.add_column("Location", style="dim") + + for deviation in report.deviations: + severity_icon = {"HIGH": "🔴", "MEDIUM": "🟡", "LOW": "🔵"}[deviation.severity.value] + table.add_row( + f"{severity_icon} {deviation.severity.value}", + deviation.type.value.replace("_", " ").title(), + deviation.description[:80] + "..." + if len(deviation.description) > 80 + else deviation.description, + deviation.location, + ) + + console.print(table) + + # Generate report file if requested + if out: + print_info(f"Generating {output_format} report...") + generator = ReportGenerator() + + # Map format string to enum + format_map = { + "markdown": ReportFormat.MARKDOWN, + "json": ReportFormat.JSON, + "yaml": ReportFormat.YAML, + } + + report_format = format_map.get(output_format.lower(), ReportFormat.MARKDOWN) + generator.generate_deviation_report(report, out, report_format) + + print_success(f"Report written to: {out}") + + # Apply enforcement rules if config exists + from specfact_cli.utils.structure import SpecFactStructure + + # Determine base path from plan paths (use manual plan's parent directory) + base_path = manual.parent if manual else None + # If base_path is not a repository root, find the repository root + if base_path: + # Walk up to find repository root (where .specfact would be) + current = base_path.resolve() + while current != current.parent: + if (current / SpecFactStructure.ROOT).exists(): + base_path = current + break + current = current.parent + else: + # If we didn't find .specfact, use the plan's directory + # But resolve to absolute path first + base_path = manual.parent.resolve() + + config_path = SpecFactStructure.get_enforcement_config_path(base_path) + if config_path.exists(): + try: + from specfact_cli.utils.yaml_utils import load_yaml + + config_data = load_yaml(config_path) + enforcement_config = EnforcementConfig(**config_data) + + if enforcement_config.enabled and report.total_deviations > 0: + print_section("Enforcement Rules") + console.print(f"[dim]Using enforcement config: {config_path}[/dim]\n") + + # Check for blocking deviations + blocking_deviations: list[Deviation] = [] + for deviation in report.deviations: + action = enforcement_config.get_action(deviation.severity.value) + action_icon = {"BLOCK": "🚫", "WARN": "⚠️", "LOG": "📝"}[action.value] + + console.print( + f"{action_icon} [{deviation.severity.value}] {deviation.type.value}: " + f"[dim]{action.value}[/dim]" + ) + + if enforcement_config.should_block_deviation(deviation.severity.value): + blocking_deviations.append(deviation) + + if blocking_deviations: + print_error( + f"\n❌ Enforcement BLOCKED: {len(blocking_deviations)} deviation(s) violate quality gates" + ) + console.print("[dim]Fix the blocking deviations or adjust enforcement config[/dim]") + raise typer.Exit(1) + print_success("\n✅ Enforcement PASSED: No blocking deviations") + + except Exception as e: + print_warning(f"Could not load enforcement config: {e}") + raise typer.Exit(1) from e + + # Note: Finding deviations without enforcement is a successful comparison result + # Exit code 0 indicates successful execution (even if deviations were found) + # Use the report file, stdout, or enforcement config to determine if deviations are critical + if report.total_deviations > 0: + print_warning(f"\n{report.total_deviations} deviation(s) found") + + except KeyboardInterrupt: + print_warning("\nComparison cancelled") + raise typer.Exit(1) from None + except Exception as e: + print_error(f"Comparison failed: {e}") + raise typer.Exit(1) from e + + +@app.command("select") +@beartype +@require(lambda plan: plan is None or isinstance(plan, str), "Plan must be None or str") +@require(lambda last: last is None or last > 0, "Last must be None or positive integer") +def select( + # Target/Input + plan: str | None = typer.Argument( + None, + help="Plan name or number to select (e.g., 'main.bundle.<format>' or '1')", + ), + name: str | None = typer.Option( + None, + "--name", + help="Select bundle by exact bundle name (non-interactive, e.g., 'main')", + hidden=True, # Hidden by default, shown with --help-advanced + ), + plan_id: str | None = typer.Option( + None, + "--id", + help="Select plan by content hash ID (non-interactive, from metadata.summary.content_hash)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", + ), + # Advanced/Configuration + current: bool = typer.Option( + False, + "--current", + help="Show only the currently active plan", + ), + stages: str | None = typer.Option( + None, + "--stages", + help="Filter by stages (comma-separated, e.g., 'draft,review,approved')", + ), + last: int | None = typer.Option( + None, + "--last", + help="Show last N plans by modification time (most recent first)", + min=1, + ), +) -> None: + """ + Select active project bundle from available bundles. + + Displays a numbered list of available project bundles and allows selection by number or name. + The selected bundle becomes the active bundle tracked in `.specfact/config.yaml`. + + Filter Options: + --current Show only the currently active bundle (non-interactive, auto-selects) + --stages STAGES Filter by stages (comma-separated: draft,review,approved,released) + --last N Show last N bundles by modification time (most recent first) + --name NAME Select by exact bundle name (non-interactive, e.g., 'main') + --id HASH Select by content hash ID (non-interactive, from bundle manifest) + + Example: + specfact plan select # Interactive selection + specfact plan select 1 # Select by number + specfact plan select main # Select by bundle name (positional) + specfact plan select --current # Show only active bundle (auto-selects) + specfact plan select --stages draft,review # Filter by stages + specfact plan select --last 5 # Show last 5 bundles + specfact plan select --no-interactive --last 1 # CI/CD: get most recent bundle + specfact plan select --name main # CI/CD: select by exact bundle name + specfact plan select --id abc123def456 # CI/CD: select by content hash + """ + from specfact_cli.utils.structure import SpecFactStructure + + telemetry_metadata = { + "no_interactive": no_interactive, + "current": current, + "stages": stages, + "last": last, + "name": name is not None, + "plan_id": plan_id is not None, + } + + with telemetry.track_command("plan.select", telemetry_metadata) as record: + print_section("SpecFact CLI - Plan Selection") + + # List all available plans + # Performance optimization: If --last N is specified, only process N+10 most recent files + # This avoids processing all 31 files when user only wants last 5 + max_files_to_process = None + if last is not None: + # Process a few more files than requested to account for filtering + max_files_to_process = last + 10 + + plans = SpecFactStructure.list_plans(max_files=max_files_to_process) + + if not plans: + print_warning("No project bundles found in .specfact/projects/") + print_info("Create a project bundle with:") + print_info(" - specfact plan init <bundle-name>") + print_info(" - specfact import from-code <bundle-name>") + raise typer.Exit(1) + + # Apply filters + filtered_plans = plans.copy() + + # Filter by current/active (non-interactive: auto-selects if single match) + if current: + filtered_plans = [p for p in filtered_plans if p.get("active", False)] + if not filtered_plans: + print_warning("No active plan found") + raise typer.Exit(1) + # Auto-select in non-interactive mode when --current is provided + if no_interactive and len(filtered_plans) == 1: + selected_plan = filtered_plans[0] + plan_name = str(selected_plan["name"]) + SpecFactStructure.set_active_plan(plan_name) + record( + { + "plans_available": len(plans), + "plans_filtered": len(filtered_plans), + "selected_plan": plan_name, + "features": selected_plan["features"], + "stories": selected_plan["stories"], + "auto_selected": True, + } + ) + print_success(f"Active plan (--current): {plan_name}") + print_info(f" Features: {selected_plan['features']}") + print_info(f" Stories: {selected_plan['stories']}") + print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") + raise typer.Exit(0) + + # Filter by stages + if stages: + stage_list = [s.strip().lower() for s in stages.split(",")] + valid_stages = {"draft", "review", "approved", "released", "unknown"} + invalid_stages = [s for s in stage_list if s not in valid_stages] + if invalid_stages: + print_error(f"Invalid stage(s): {', '.join(invalid_stages)}") + print_info(f"Valid stages: {', '.join(sorted(valid_stages))}") + raise typer.Exit(1) + filtered_plans = [p for p in filtered_plans if str(p.get("stage", "unknown")).lower() in stage_list] + + # Filter by last N (most recent first) + if last: + # Sort by modification time (most recent first) and take last N + # Handle None values by using empty string as fallback for sorting + filtered_plans = sorted(filtered_plans, key=lambda p: p.get("modified") or "", reverse=True)[:last] + + if not filtered_plans: + print_warning("No plans match the specified filters") + raise typer.Exit(1) + + # Handle --name flag (non-interactive selection by exact filename) + if name is not None: + no_interactive = True # Force non-interactive when --name is used + plan_name = SpecFactStructure.ensure_plan_filename(str(name)) + + selected_plan = None + for p in plans: # Search all plans, not just filtered + if p["name"] == plan_name: + selected_plan = p + break + + if selected_plan is None: + print_error(f"Plan not found: {plan_name}") + raise typer.Exit(1) + + # Set as active and exit + SpecFactStructure.set_active_plan(plan_name) + record( + { + "plans_available": len(plans), + "plans_filtered": len(filtered_plans), + "selected_plan": plan_name, + "features": selected_plan["features"], + "stories": selected_plan["stories"], + "selected_by": "name", + } + ) + print_success(f"Active plan (--name): {plan_name}") + print_info(f" Features: {selected_plan['features']}") + print_info(f" Stories: {selected_plan['stories']}") + print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") + raise typer.Exit(0) + + # Handle --id flag (non-interactive selection by content hash) + if plan_id is not None: + no_interactive = True # Force non-interactive when --id is used + # Match by content hash (from bundle manifest summary) + selected_plan = None + for p in plans: + content_hash = p.get("content_hash") + if content_hash and (content_hash == plan_id or content_hash.startswith(plan_id)): + selected_plan = p + break + + if selected_plan is None: + print_error(f"Plan not found with ID: {plan_id}") + print_info("Tip: Use 'specfact plan select' to see available plans and their IDs") + raise typer.Exit(1) + + # Set as active and exit + plan_name = str(selected_plan["name"]) + SpecFactStructure.set_active_plan(plan_name) + record( + { + "plans_available": len(plans), + "plans_filtered": len(filtered_plans), + "selected_plan": plan_name, + "features": selected_plan["features"], + "stories": selected_plan["stories"], + "selected_by": "id", + } + ) + print_success(f"Active plan (--id): {plan_name}") + print_info(f" Features: {selected_plan['features']}") + print_info(f" Stories: {selected_plan['stories']}") + print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") + raise typer.Exit(0) + + # If plan provided, try to resolve it + if plan is not None: + # Try as number first (using filtered list) + if isinstance(plan, str) and plan.isdigit(): + plan_num = int(plan) + if 1 <= plan_num <= len(filtered_plans): + selected_plan = filtered_plans[plan_num - 1] + else: + print_error(f"Invalid plan number: {plan_num}. Must be between 1 and {len(filtered_plans)}") + raise typer.Exit(1) + else: + # Try as bundle name (search in filtered list first, then all plans) + bundle_name = str(plan) + + # Find matching bundle in filtered list first + selected_plan = None + for p in filtered_plans: + if p["name"] == bundle_name: + selected_plan = p + break + + # If not found in filtered list, search all plans (for better error message) + if selected_plan is None: + for p in plans: + if p["name"] == bundle_name: + print_warning(f"Bundle '{bundle_name}' exists but is filtered out by current options") + print_info("Available filtered bundles:") + for i, p in enumerate(filtered_plans, 1): + print_info(f" {i}. {p['name']}") + raise typer.Exit(1) + + if selected_plan is None: + print_error(f"Plan not found: {plan}") + print_info("Available filtered plans:") + for i, p in enumerate(filtered_plans, 1): + print_info(f" {i}. {p['name']}") + raise typer.Exit(1) + else: + # Display numbered list + console.print("\n[bold]Available Plans:[/bold]\n") + + # Create table with optimized column widths + # "#" column: fixed at 4 chars (never shrinks) + # Features/Stories/Stage: minimal widths to avoid wasting space + # Plan Name: flexible to use remaining space (most important) + table = Table(show_header=True, header_style="bold cyan", expand=False) + table.add_column("#", style="bold yellow", justify="right", width=4, min_width=4, no_wrap=True) + table.add_column("Status", style="dim", width=8, min_width=6) + table.add_column("Plan Name", style="bold", min_width=30) # Flexible, gets most space + table.add_column("Features", justify="right", width=8, min_width=6) # Reduced from 10 + table.add_column("Stories", justify="right", width=8, min_width=6) # Reduced from 10 + table.add_column("Stage", width=8, min_width=6) # Reduced from 10 to 8 (draft/review/approved/released fit) + table.add_column("Modified", style="dim", width=19, min_width=15) # Slightly reduced + + for i, p in enumerate(filtered_plans, 1): + status = "[ACTIVE]" if p.get("active") else "" + plan_name = str(p["name"]) + features_count = str(p["features"]) + stories_count = str(p["stories"]) + stage = str(p.get("stage", "unknown")) + modified = str(p["modified"]) + modified_display = modified[:19] if len(modified) > 19 else modified + table.add_row( + f"[bold yellow]{i}[/bold yellow]", + status, + plan_name, + features_count, + stories_count, + stage, + modified_display, + ) + + console.print(table) + console.print() + + # Handle selection (interactive or non-interactive) + if no_interactive: + # Non-interactive mode: select first plan (or error if multiple) + if len(filtered_plans) == 1: + selected_plan = filtered_plans[0] + print_info(f"Non-interactive mode: auto-selecting plan '{selected_plan['name']}'") + else: + print_error( + f"Non-interactive mode requires exactly one plan, but {len(filtered_plans)} plans match filters" + ) + print_info("Use --current, --last 1, or specify a plan name/number to select a single plan") + raise typer.Exit(1) + else: + # Interactive selection - prompt for selection + selection = "" + try: + selection = prompt_text( + f"Select a plan by number (1-{len(filtered_plans)}) or 'q' to quit: " + ).strip() + + if selection.lower() in ("q", "quit", ""): + print_info("Selection cancelled") + raise typer.Exit(0) + + plan_num = int(selection) + if not (1 <= plan_num <= len(filtered_plans)): + print_error(f"Invalid selection: {plan_num}. Must be between 1 and {len(filtered_plans)}") + raise typer.Exit(1) + + selected_plan = filtered_plans[plan_num - 1] + except ValueError: + print_error(f"Invalid input: {selection}. Please enter a number.") + raise typer.Exit(1) from None + except KeyboardInterrupt: + print_warning("\nSelection cancelled") + raise typer.Exit(1) from None + + # Set as active plan + plan_name = str(selected_plan["name"]) + SpecFactStructure.set_active_plan(plan_name) + + record( + { + "plans_available": len(plans), + "plans_filtered": len(filtered_plans), + "selected_plan": plan_name, + "features": selected_plan["features"], + "stories": selected_plan["stories"], + } + ) + + print_success(f"Active plan set to: {plan_name}") + print_info(f" Features: {selected_plan['features']}") + print_info(f" Stories: {selected_plan['stories']}") + print_info(f" Stage: {selected_plan.get('stage', 'unknown')}") + + print_info("\nThis plan will now be used as the default for all commands with --bundle option:") + print_info(" • Plan management: plan compare, plan promote, plan add-feature, plan add-story,") + print_info(" plan update-idea, plan update-feature, plan update-story, plan review") + print_info(" • Analysis & generation: import from-code, generate contracts, analyze contracts") + print_info(" • Synchronization: sync bridge, sync intelligent") + print_info(" • Enforcement & migration: enforce sdd, migrate to-contracts, drift detect") + print_info("\n Use --bundle <name> to override the active plan for any command.") + + +@app.command("upgrade") +@beartype +@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") +@require(lambda all_plans: isinstance(all_plans, bool), "All plans must be bool") +@require(lambda dry_run: isinstance(dry_run, bool), "Dry run must be bool") +def upgrade( + # Target/Input + plan: Path | None = typer.Option( + None, + "--plan", + help="Path to specific plan bundle to upgrade (default: active plan)", + ), + # Behavior/Options + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Show what would be upgraded without making changes", + ), + all_plans: bool = typer.Option( + False, + "--all", + help="Upgrade all plan bundles in .specfact/plans/", + ), +) -> None: + """ + Upgrade plan bundles to the latest schema version. + + Migrates plan bundles from older schema versions to the current version. + This ensures compatibility with the latest features and performance optimizations. + + Examples: + specfact plan upgrade # Upgrade active plan + specfact plan upgrade --plan path/to/plan.bundle.<format> # Upgrade specific plan + specfact plan upgrade --all # Upgrade all plans + specfact plan upgrade --all --dry-run # Preview upgrades without changes + """ + from specfact_cli.migrations.plan_migrator import PlanMigrator, get_current_schema_version + from specfact_cli.utils.structure import SpecFactStructure + + current_version = get_current_schema_version() + migrator = PlanMigrator() + + print_section(f"Plan Bundle Upgrade (Schema {current_version})") + + # Determine which plans to upgrade + plans_to_upgrade: list[Path] = [] + + if all_plans: + # Get all monolithic plan bundles from .specfact/plans/ + plans_dir = Path(".specfact/plans") + if plans_dir.exists(): + for plan_file in plans_dir.glob("*.bundle.*"): + if any(str(plan_file).endswith(suffix) for suffix in SpecFactStructure.PLAN_SUFFIXES): + plans_to_upgrade.append(plan_file) + + # Also get modular project bundles (though they're already in new format, they might need schema updates) + projects = SpecFactStructure.list_plans() + projects_dir = Path(".specfact/projects") + for project_info in projects: + bundle_dir = projects_dir / str(project_info["name"]) + manifest_path = bundle_dir / "bundle.manifest.yaml" + if manifest_path.exists(): + # For modular bundles, we upgrade the manifest file + plans_to_upgrade.append(manifest_path) + elif plan: + # Use specified plan + if not plan.exists(): + print_error(f"Plan file not found: {plan}") + raise typer.Exit(1) + plans_to_upgrade.append(plan) + else: + # Use active plan (modular bundle system) + active_bundle_name = SpecFactStructure.get_active_bundle_name(Path(".")) + if active_bundle_name: + bundle_dir = SpecFactStructure.project_dir(base_path=Path("."), bundle_name=active_bundle_name) + if bundle_dir.exists(): + manifest_path = bundle_dir / "bundle.manifest.yaml" + if manifest_path.exists(): + plans_to_upgrade.append(manifest_path) + print_info(f"Using active plan: {active_bundle_name}") + else: + print_error(f"Bundle manifest not found: {manifest_path}") + print_error(f"Bundle directory exists but manifest is missing: {bundle_dir}") + raise typer.Exit(1) + else: + print_error(f"Active bundle directory not found: {bundle_dir}") + print_error(f"Active bundle name: {active_bundle_name}") + raise typer.Exit(1) + else: + # Fallback: Try to find default bundle (first bundle in projects directory) + projects_dir = Path(".specfact/projects") + if projects_dir.exists(): + bundles = [ + d.name for d in projects_dir.iterdir() if d.is_dir() and (d / "bundle.manifest.yaml").exists() + ] + if bundles: + bundle_name = bundles[0] + bundle_dir = SpecFactStructure.project_dir(base_path=Path("."), bundle_name=bundle_name) + manifest_path = bundle_dir / "bundle.manifest.yaml" + plans_to_upgrade.append(manifest_path) + print_info(f"Using default bundle: {bundle_name}") + print_info(f"Tip: Use 'specfact plan select {bundle_name}' to set as active plan") + else: + print_error("No project bundles found. Use --plan to specify a plan or --all to upgrade all plans.") + print_error("Create one with: specfact plan init <bundle-name>") + raise typer.Exit(1) + else: + print_error("No plan configuration found. Use --plan to specify a plan or --all to upgrade all plans.") + print_error("Create one with: specfact plan init <bundle-name>") + raise typer.Exit(1) + + if not plans_to_upgrade: + print_warning("No plans found to upgrade") + raise typer.Exit(0) + + # Check and upgrade each plan + upgraded_count = 0 + skipped_count = 0 + error_count = 0 + + for plan_path in plans_to_upgrade: + try: + needs_migration, reason = migrator.check_migration_needed(plan_path) + if not needs_migration: + print_info(f"✓ {plan_path.name}: {reason}") + skipped_count += 1 + continue + + if dry_run: + print_warning(f"Would upgrade: {plan_path.name} ({reason})") + upgraded_count += 1 + else: + print_info(f"Upgrading: {plan_path.name} ({reason})...") + bundle, was_migrated = migrator.load_and_migrate(plan_path, dry_run=False) + if was_migrated: + print_success(f"✓ Upgraded {plan_path.name} to schema {bundle.version}") + upgraded_count += 1 + else: + print_info(f"✓ {plan_path.name}: Already up to date") + skipped_count += 1 + except Exception as e: + print_error(f"✗ Failed to upgrade {plan_path.name}: {e}") + error_count += 1 + + # Summary + print() + if dry_run: + print_info(f"Dry run complete: {upgraded_count} would be upgraded, {skipped_count} up to date") + else: + print_success(f"Upgrade complete: {upgraded_count} upgraded, {skipped_count} up to date") + if error_count > 0: + print_warning(f"{error_count} errors occurred") + + if error_count > 0: + raise typer.Exit(1) + + +@app.command("sync") +@beartype +@require(lambda repo: repo is None or isinstance(repo, Path), "Repo must be None or Path") +@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") +@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") +@require(lambda watch: isinstance(watch, bool), "Watch must be bool") +@require(lambda interval: isinstance(interval, int) and interval >= 1, "Interval must be int >= 1") +def sync( + # Target/Input + repo: Path | None = typer.Option( + None, + "--repo", + help="Path to repository (default: current directory)", + ), + plan: Path | None = typer.Option( + None, + "--plan", + help="Path to SpecFact plan bundle for SpecFact → Spec-Kit conversion (default: active plan)", + ), + # Behavior/Options + shared: bool = typer.Option( + False, + "--shared", + help="Enable shared plans sync (bidirectional sync with Spec-Kit)", + ), + overwrite: bool = typer.Option( + False, + "--overwrite", + help="Overwrite existing Spec-Kit artifacts (delete all existing before sync)", + ), + watch: bool = typer.Option( + False, + "--watch", + help="Watch mode for continuous sync", + ), + # Advanced/Configuration + interval: int = typer.Option( + 5, + "--interval", + help="Watch interval in seconds (default: 5)", + min=1, + ), +) -> None: + """ + Sync shared plans between Spec-Kit and SpecFact (bidirectional sync). + + This is a convenience wrapper around `specfact sync bridge --adapter speckit --bidirectional` + that enables team collaboration through shared structured plans. The bidirectional + sync keeps Spec-Kit artifacts and SpecFact plans synchronized automatically. + + Shared plans enable: + - Team collaboration: Multiple developers can work on the same plan + - Automated sync: Changes in Spec-Kit automatically sync to SpecFact + - Deviation detection: Compare code vs plan drift automatically + - Conflict resolution: Automatic conflict detection and resolution + + Example: + specfact plan sync --shared # One-time sync + specfact plan sync --shared --watch # Continuous sync + specfact plan sync --shared --repo ./project # Sync specific repo + """ + from specfact_cli.modules.sync.src.commands import sync_spec_kit + from specfact_cli.utils.structure import SpecFactStructure + + telemetry_metadata = { + "shared": shared, + "watch": watch, + "overwrite": overwrite, + "interval": interval, + } + + with telemetry.track_command("plan.sync", telemetry_metadata) as record: + if not shared: + print_error("This command requires --shared flag") + print_info("Use 'specfact plan sync --shared' to enable shared plans sync") + print_info("Or use 'specfact sync bridge --adapter speckit --bidirectional' for direct sync") + raise typer.Exit(1) + + # Use default repo if not specified + if repo is None: + repo = Path(".").resolve() + print_info(f"Using current directory: {repo}") + + # Use default plan if not specified + if plan is None: + plan = SpecFactStructure.get_default_plan_path() + if not plan.exists(): + print_warning(f"Default plan not found: {plan}") + print_info("Using default plan path (will be created if needed)") + else: + print_info(f"Using active plan: {plan}") + + print_section("Shared Plans Sync") + console.print("[dim]Bidirectional sync between Spec-Kit and SpecFact for team collaboration[/dim]\n") + + # Call the underlying sync command + try: + # Delegate to sync bridge via compatibility helper. + sync_spec_kit( + repo=repo, + bidirectional=True, # Always bidirectional for shared plans + plan=plan, + overwrite=overwrite, + watch=watch, + interval=interval, + ) + record({"sync_completed": True}) + except Exception as e: + print_error(f"Shared plans sync failed: {e}") + raise typer.Exit(1) from e + + +def _validate_stage(value: str) -> str: + """Validate stage parameter and provide user-friendly error message.""" + valid_stages = ("draft", "review", "approved", "released") + if value not in valid_stages: + console.print(f"[bold red]✗[/bold red] Invalid stage: {value}") + console.print(f"Valid stages: {', '.join(valid_stages)}") + raise typer.Exit(1) + return value + + +@app.command("promote") +@beartype +@require(lambda bundle: isinstance(bundle, str) and len(bundle) > 0, "Bundle name must be non-empty string") +@require( + lambda stage: stage in ("draft", "review", "approved", "released"), + "Stage must be draft, review, approved, or released", +) +def promote( + # Target/Input + bundle: str | None = typer.Argument( + None, + help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", + ), + stage: str = typer.Option( + ..., "--stage", callback=_validate_stage, help="Target stage (draft, review, approved, released)" + ), + # Behavior/Options + validate: bool = typer.Option( + True, + "--validate/--no-validate", + help="Run validation before promotion (default: true)", + ), + force: bool = typer.Option( + False, + "--force", + help="Force promotion even if validation fails (default: false)", + ), +) -> None: + """ + Promote a project bundle through development stages. + + Stages: draft → review → approved → released + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --stage + - **Behavior/Options**: --validate/--no-validate, --force + + **Examples:** + specfact plan promote legacy-api --stage review + specfact plan promote auth-module --stage approved --validate + specfact plan promote legacy-api --stage released --force + """ + import os + from datetime import datetime + + from rich.console import Console + + from specfact_cli.utils.structure import SpecFactStructure + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle is None: + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + telemetry_metadata = { + "target_stage": stage, + "validate": validate, + "force": force, + } + + with telemetry.track_command("plan.promote", telemetry_metadata) as record: + # Find bundle directory + bundle_dir = _find_bundle_dir(bundle) + if bundle_dir is None: + raise typer.Exit(1) + + print_section("SpecFact CLI - Plan Promotion") + + try: + # Load project bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Convert to PlanBundle for compatibility with validation functions + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Check current stage (ProjectBundle doesn't have metadata.stage, use default) + current_stage = "draft" # TODO: Add promotion status to ProjectBundle manifest + + print_info(f"Current stage: {current_stage}") + print_info(f"Target stage: {stage}") + + # Validate stage progression + stage_order = {"draft": 0, "review": 1, "approved": 2, "released": 3} + current_order = stage_order.get(current_stage, 0) + target_order = stage_order.get(stage, 0) + + if target_order < current_order: + print_error(f"Cannot promote backward: {current_stage} → {stage}") + print_error("Only forward promotion is allowed (draft → review → approved → released)") + raise typer.Exit(1) + + if target_order == current_order: + print_warning(f"Plan is already at stage: {stage}") + raise typer.Exit(0) + + # Validate promotion rules + print_info("Checking promotion rules...") + + # Require SDD manifest for promotion to "review" or higher stages + if stage in ("review", "approved", "released"): + print_info("Checking SDD manifest...") + sdd_valid, sdd_manifest, sdd_report = _validate_sdd_for_bundle(plan_bundle, bundle, require_sdd=True) + + if sdd_manifest is None: + print_error("SDD manifest is required for promotion to 'review' or higher stages") + console.print("[dim]Run 'specfact plan harden' to create SDD manifest[/dim]") + if not force: + raise typer.Exit(1) + print_warning("Promoting with --force despite missing SDD manifest") + elif not sdd_valid: + print_error("SDD manifest validation failed:") + for deviation in sdd_report.deviations: + if deviation.severity == DeviationSeverity.HIGH: + console.print(f" [bold red]✗[/bold red] {deviation.description}") + console.print(f" [dim]Fix: {deviation.fix_hint}[/dim]") + if sdd_report.high_count > 0: + console.print( + f"\n[bold red]Cannot promote: {sdd_report.high_count} high severity deviation(s)[/bold red]" + ) + if not force: + raise typer.Exit(1) + print_warning("Promoting with --force despite SDD validation failures") + elif sdd_report.medium_count > 0 or sdd_report.low_count > 0: + print_warning( + f"SDD has {sdd_report.medium_count} medium and {sdd_report.low_count} low severity deviation(s)" + ) + console.print("[dim]Run 'specfact enforce sdd' for detailed report[/dim]") + if not force and not prompt_confirm( + "Continue with promotion despite coverage threshold warnings?", default=False + ): + raise typer.Exit(1) + else: + print_success("SDD manifest validated successfully") + if sdd_report.total_deviations > 0: + console.print(f"[dim]Found {sdd_report.total_deviations} coverage threshold warning(s)[/dim]") + + # Draft → Review: All features must have at least one story + if current_stage == "draft" and stage == "review": + features_without_stories = [f for f in plan_bundle.features if len(f.stories) == 0] + if features_without_stories: + print_error(f"Cannot promote to review: {len(features_without_stories)} feature(s) without stories") + console.print("[dim]Features without stories:[/dim]") + for f in features_without_stories[:5]: + console.print(f" - {f.key}: {f.title}") + if len(features_without_stories) > 5: + console.print(f" ... and {len(features_without_stories) - 5} more") + if not force: + raise typer.Exit(1) + + # Check coverage status for critical categories + if validate: + from specfact_cli.analyzers.ambiguity_scanner import ( + AmbiguityScanner, + AmbiguityStatus, + TaxonomyCategory, + ) + + print_info("Checking coverage status...") + scanner = AmbiguityScanner() + report = scanner.scan(plan_bundle) + + # Critical categories that block promotion if Missing + critical_categories = [ + TaxonomyCategory.FUNCTIONAL_SCOPE, + TaxonomyCategory.FEATURE_COMPLETENESS, + TaxonomyCategory.CONSTRAINTS, + ] + + # Important categories that warn if Missing or Partial + important_categories = [ + TaxonomyCategory.DATA_MODEL, + TaxonomyCategory.INTEGRATION, + TaxonomyCategory.NON_FUNCTIONAL, + ] + + missing_critical: list[TaxonomyCategory] = [] + missing_important: list[TaxonomyCategory] = [] + partial_important: list[TaxonomyCategory] = [] + + if report.coverage: + for category, status in report.coverage.items(): + if category in critical_categories and status == AmbiguityStatus.MISSING: + missing_critical.append(category) + elif category in important_categories: + if status == AmbiguityStatus.MISSING: + missing_important.append(category) + elif status == AmbiguityStatus.PARTIAL: + partial_important.append(category) + + # Block promotion if critical categories are Missing + if missing_critical: + print_error( + f"Cannot promote to review: {len(missing_critical)} critical category(ies) are Missing" + ) + console.print("[dim]Missing critical categories:[/dim]") + for cat in missing_critical: + console.print(f" - {cat.value}") + console.print("\n[dim]Run 'specfact plan review' to resolve these ambiguities[/dim]") + if not force: + raise typer.Exit(1) + + # Warn if important categories are Missing or Partial + if missing_important or partial_important: + print_warning( + f"Plan has {len(missing_important)} missing and {len(partial_important)} partial important category(ies)" + ) + if missing_important: + console.print("[dim]Missing important categories:[/dim]") + for cat in missing_important: + console.print(f" - {cat.value}") + if partial_important: + console.print("[dim]Partial important categories:[/dim]") + for cat in partial_important: + console.print(f" - {cat.value}") + if not force: + console.print("\n[dim]Consider running 'specfact plan review' to improve coverage[/dim]") + console.print("[dim]Use --force to promote anyway[/dim]") + if not prompt_confirm( + "Continue with promotion despite missing/partial categories?", default=False + ): + raise typer.Exit(1) + + # Review → Approved: All features must pass validation + if current_stage == "review" and stage == "approved" and validate: + # SDD validation is already checked above for "review" or higher stages + # But we can add additional checks here if needed + + print_info("Validating all features...") + incomplete_features: list[Feature] = [] + for f in plan_bundle.features: + if not f.acceptance: + incomplete_features.append(f) + for s in f.stories: + if not s.acceptance: + incomplete_features.append(f) + break + + if incomplete_features: + print_warning(f"{len(incomplete_features)} feature(s) have incomplete acceptance criteria") + if not force: + console.print("[dim]Use --force to promote anyway[/dim]") + raise typer.Exit(1) + + # Check coverage status for critical categories + from specfact_cli.analyzers.ambiguity_scanner import ( + AmbiguityScanner, + AmbiguityStatus, + TaxonomyCategory, + ) + + print_info("Checking coverage status...") + scanner_approved = AmbiguityScanner() + report_approved = scanner_approved.scan(plan_bundle) + + # Critical categories that block promotion if Missing + critical_categories_approved = [ + TaxonomyCategory.FUNCTIONAL_SCOPE, + TaxonomyCategory.FEATURE_COMPLETENESS, + TaxonomyCategory.CONSTRAINTS, + ] + + missing_critical_approved: list[TaxonomyCategory] = [] + + if report_approved.coverage: + for category, status in report_approved.coverage.items(): + if category in critical_categories_approved and status == AmbiguityStatus.MISSING: + missing_critical_approved.append(category) + + # Block promotion if critical categories are Missing + if missing_critical_approved: + print_error( + f"Cannot promote to approved: {len(missing_critical_approved)} critical category(ies) are Missing" + ) + console.print("[dim]Missing critical categories:[/dim]") + for cat in missing_critical_approved: + console.print(f" - {cat.value}") + console.print("\n[dim]Run 'specfact plan review' to resolve these ambiguities[/dim]") + if not force: + raise typer.Exit(1) + + # Approved → Released: All features must be implemented (future check) + if current_stage == "approved" and stage == "released": + print_warning("Release promotion: Implementation verification not yet implemented") + if not force: + console.print("[dim]Use --force to promote to released stage[/dim]") + raise typer.Exit(1) + + # Run validation if enabled + if validate: + print_info("Running validation...") + validation_result = validate_plan_bundle(plan_bundle) + if isinstance(validation_result, ValidationReport): + if not validation_result.passed: + deviation_count = len(validation_result.deviations) + print_warning(f"Validation found {deviation_count} issue(s)") + if not force: + console.print("[dim]Use --force to promote anyway[/dim]") + raise typer.Exit(1) + else: + print_success("Validation passed") + else: + print_success("Validation passed") + + # Update promotion status (TODO: Add promotion status to ProjectBundle manifest) + print_info(f"Promoting bundle to stage: {stage}") + promoted_by = ( + os.environ.get("USER") or os.environ.get("USERNAME") or os.environ.get("GIT_AUTHOR_NAME") or "unknown" + ) + + # Save updated project bundle + # TODO: Update ProjectBundle manifest with promotion status + # For now, just save the bundle (promotion status will be added in a future update) + _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) + + record( + { + "current_stage": current_stage, + "target_stage": stage, + "features_count": len(plan_bundle.features) if plan_bundle.features else 0, + } + ) + + # Display summary + print_success(f"Plan promoted: {current_stage} → {stage}") + promoted_at = datetime.now(UTC).isoformat() + console.print(f"[dim]Promoted at: {promoted_at}[/dim]") + console.print(f"[dim]Promoted by: {promoted_by}[/dim]") + + # Show next steps + console.print("\n[bold]Next Steps:[/bold]") + if stage == "review": + console.print(" • Review plan bundle for completeness") + console.print(" • Add stories to features if missing") + console.print(" • Run: specfact plan promote --stage approved") + elif stage == "approved": + console.print(" • Plan is approved for implementation") + console.print(" • Begin feature development") + console.print(" • Run: specfact plan promote --stage released (after implementation)") + elif stage == "released": + console.print(" • Plan is released and should be immutable") + console.print(" • Create new plan bundle for future changes") + + except Exception as e: + print_error(f"Failed to promote plan: {e}") + raise typer.Exit(1) from e + + +@beartype +@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") +@ensure(lambda result: result is None or isinstance(result, Path), "Must return Path or None") +def _find_plan_path(plan: Path | None) -> Path | None: + """ + Find plan path (default, latest, or provided). + + Args: + plan: Provided plan path or None + + Returns: + Plan path or None if not found + """ + from specfact_cli.utils.structure import SpecFactStructure + + if plan is not None: + return plan + + # Try to find active plan or latest + default_plan = SpecFactStructure.get_default_plan_path() + if default_plan.exists(): + print_info(f"Using default plan: {default_plan}") + return default_plan + + # Find latest plan bundle + base_path = Path(".") + plans_dir = base_path / SpecFactStructure.PLANS + if plans_dir.exists(): + plan_files = [ + p + for p in plans_dir.glob("*.bundle.*") + if any(str(p).endswith(suffix) for suffix in SpecFactStructure.PLAN_SUFFIXES) + ] + plan_files = sorted(plan_files, key=lambda p: p.stat().st_mtime, reverse=True) + if plan_files: + print_info(f"Using latest plan: {plan_files[0]}") + return plan_files[0] + print_error(f"No plan bundles found in {plans_dir}") + print_error("Create one with: specfact plan init --interactive") + return None + print_error(f"Plans directory not found: {plans_dir}") + print_error("Create one with: specfact plan init --interactive") + return None + + +@beartype +@require(lambda plan: plan is not None and isinstance(plan, Path), "Plan must be non-None Path") +@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return (bool, PlanBundle | None) tuple") +def _load_and_validate_plan(plan: Path) -> tuple[bool, PlanBundle | None]: + """ + Load and validate plan bundle. + + Args: + plan: Path to plan bundle + + Returns: + Tuple of (is_valid, plan_bundle) + """ + print_info(f"Loading plan: {plan}") + validation_result = validate_plan_bundle(plan) + assert isinstance(validation_result, tuple), "Expected tuple from validate_plan_bundle for Path" + is_valid, error, bundle = validation_result + + if not is_valid or bundle is None: + print_error(f"Plan validation failed: {error}") + return (False, None) + + return (True, bundle) + + +@beartype +@require( + lambda bundle, bundle_dir, auto_enrich: isinstance(bundle, PlanBundle) + and bundle_dir is not None + and isinstance(bundle_dir, Path), + "Bundle must be PlanBundle and bundle_dir must be non-None Path", +) +@ensure(lambda result: result is None, "Must return None") +def _handle_auto_enrichment(bundle: PlanBundle, bundle_dir: Path, auto_enrich: bool) -> None: + """ + Handle auto-enrichment if requested. + + Args: + bundle: Plan bundle to enrich (converted from ProjectBundle) + bundle_dir: Project bundle directory + auto_enrich: Whether to auto-enrich + """ + if not auto_enrich: + return + + print_info( + "Auto-enriching project bundle (enhancing vague acceptance criteria, incomplete requirements, generic tasks)..." + ) + from specfact_cli.enrichers.plan_enricher import PlanEnricher + + enricher = PlanEnricher() + enrichment_summary = enricher.enrich_plan(bundle) + + if enrichment_summary["features_updated"] > 0 or enrichment_summary["stories_updated"] > 0: + # Convert back to ProjectBundle and save + + # Reload to get current state + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + # Update features from enriched bundle + project_bundle.features = {f.key: f for f in bundle.features} + _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) + print_success( + f"✓ Auto-enriched plan bundle: {enrichment_summary['features_updated']} features, " + f"{enrichment_summary['stories_updated']} stories updated" + ) + if enrichment_summary["acceptance_criteria_enhanced"] > 0: + console.print( + f"[dim] - Enhanced {enrichment_summary['acceptance_criteria_enhanced']} acceptance criteria[/dim]" + ) + if enrichment_summary["requirements_enhanced"] > 0: + console.print(f"[dim] - Enhanced {enrichment_summary['requirements_enhanced']} requirements[/dim]") + if enrichment_summary["tasks_enhanced"] > 0: + console.print(f"[dim] - Enhanced {enrichment_summary['tasks_enhanced']} tasks[/dim]") + if enrichment_summary["changes"]: + console.print("\n[bold]Changes made:[/bold]") + for change in enrichment_summary["changes"][:10]: # Show first 10 changes + console.print(f"[dim] - {change}[/dim]") + if len(enrichment_summary["changes"]) > 10: + console.print(f"[dim] ... and {len(enrichment_summary['changes']) - 10} more[/dim]") + else: + print_info("No enrichments needed - plan bundle is already well-specified") + + +@beartype +@require(lambda report: report is not None, "Report must not be None") +@require( + lambda findings_format: findings_format is None or isinstance(findings_format, str), + "Findings format must be None or str", +) +@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") +@ensure(lambda result: result is None, "Must return None") +def _output_findings( + report: Any, # AmbiguityReport (imported locally to avoid circular dependency) + findings_format: str | None, + is_non_interactive: bool, + output_path: Path | None = None, +) -> None: + """ + Output findings in structured format or table. + + Args: + report: Ambiguity report + findings_format: Output format (json, yaml, table) + is_non_interactive: Whether in non-interactive mode + output_path: Optional file path to save findings. If None, outputs to stdout. + """ + from rich.console import Console + from rich.table import Table + + from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus + + console = Console() + + # Determine output format + output_format_str = findings_format + if not output_format_str: + # Default: json for non-interactive, table for interactive + output_format_str = "json" if is_non_interactive else "table" + + output_format_str = output_format_str.lower() + + if output_format_str == "table": + # Interactive table output + findings_table = Table(title="Plan Review Findings", show_header=True, header_style="bold magenta") + findings_table.add_column("Category", style="cyan", no_wrap=True) + findings_table.add_column("Status", style="yellow") + findings_table.add_column("Description", style="white") + findings_table.add_column("Impact", justify="right", style="green") + findings_table.add_column("Uncertainty", justify="right", style="blue") + findings_table.add_column("Priority", justify="right", style="bold") + + findings_list = report.findings or [] + for finding in sorted(findings_list, key=lambda f: f.impact * f.uncertainty, reverse=True): + status_icon = ( + "✅" + if finding.status == AmbiguityStatus.CLEAR + else "⚠️" + if finding.status == AmbiguityStatus.PARTIAL + else "❌" + ) + priority = finding.impact * finding.uncertainty + findings_table.add_row( + finding.category.value, + f"{status_icon} {finding.status.value}", + finding.description[:80] + "..." if len(finding.description) > 80 else finding.description, + f"{finding.impact:.2f}", + f"{finding.uncertainty:.2f}", + f"{priority:.2f}", + ) + + console.print("\n") + console.print(findings_table) + + # Also show coverage summary + if report.coverage: + from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory + + console.print("\n[bold]Coverage Summary:[/bold]") + # Count findings per category by status + total_findings_by_category: dict[TaxonomyCategory, int] = {} + clear_findings_by_category: dict[TaxonomyCategory, int] = {} + partial_findings_by_category: dict[TaxonomyCategory, int] = {} + for finding in findings_list: + cat = finding.category + total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 + # Count by finding status + if finding.status == AmbiguityStatus.CLEAR: + clear_findings_by_category[cat] = clear_findings_by_category.get(cat, 0) + 1 + elif finding.status == AmbiguityStatus.PARTIAL: + partial_findings_by_category[cat] = partial_findings_by_category.get(cat, 0) + 1 + + for cat, status in report.coverage.items(): + status_icon = ( + "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" + ) + total = total_findings_by_category.get(cat, 0) + clear_count = clear_findings_by_category.get(cat, 0) + partial_count = partial_findings_by_category.get(cat, 0) + # Show format based on status: + # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total + # - Partial: Show partial_count/total (count of findings with PARTIAL status = unclear findings) + if status == AmbiguityStatus.CLEAR: + if total == 0: + # No findings - just show status without counts + console.print(f" {status_icon} {cat.value}: {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") + elif status == AmbiguityStatus.PARTIAL: + # Show count of partial (unclear) findings + # If all are unclear, just show the count without the fraction + if partial_count == total: + console.print(f" {status_icon} {cat.value}: {partial_count} {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {partial_count}/{total} {status.value}") + else: # MISSING + console.print(f" {status_icon} {cat.value}: {status.value}") + + elif output_format_str in ("json", "yaml"): + # Structured output (JSON or YAML) + findings_data = { + "findings": [ + { + "category": f.category.value, + "status": f.status.value, + "description": f.description, + "impact": f.impact, + "uncertainty": f.uncertainty, + "priority": f.impact * f.uncertainty, + "question": f.question, + "related_sections": f.related_sections or [], + } + for f in (report.findings or []) + ], + "coverage": {cat.value: status.value for cat, status in (report.coverage or {}).items()}, + "total_findings": len(report.findings or []), + "priority_score": report.priority_score, + } + + import sys + + if output_format_str == "json": + formatted_output = json.dumps(findings_data, indent=2) + "\n" + else: # yaml + from ruamel.yaml import YAML + + yaml = YAML() + yaml.default_flow_style = False + yaml.preserve_quotes = True + from io import StringIO + + output = StringIO() + yaml.dump(findings_data, output) + formatted_output = output.getvalue() + + if output_path: + # Save to file + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(formatted_output, encoding="utf-8") + from rich.console import Console + + console = Console() + console.print(f"[green]✓[/green] Findings saved to: {output_path}") + else: + # Output to stdout + sys.stdout.write(formatted_output) + sys.stdout.flush() + else: + print_error(f"Invalid findings format: {findings_format}. Must be 'json', 'yaml', or 'table'") + raise typer.Exit(1) + + +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda bundle: bundle is not None, "Bundle must not be None") +@ensure(lambda result: isinstance(result, int), "Must return int") +def _deduplicate_features(bundle: PlanBundle) -> int: + """ + Deduplicate features by normalized key (clean up duplicates from previous syncs). + + Uses prefix matching to handle abbreviated vs full names (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM). + + Args: + bundle: Plan bundle to deduplicate + + Returns: + Number of duplicates removed + """ + from specfact_cli.utils.feature_keys import normalize_feature_key + + seen_normalized_keys: set[str] = set() + deduplicated_features: list[Feature] = [] + + for existing_feature in bundle.features: + normalized_key = normalize_feature_key(existing_feature.key) + + # Check for exact match first + if normalized_key in seen_normalized_keys: + continue + + # Check for prefix match (abbreviated vs full names) + # e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM + # Only match if shorter is a PREFIX of longer with significant length difference + # AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin + # This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis) + matched = False + for seen_key in seen_normalized_keys: + shorter = min(normalized_key, seen_key, key=len) + longer = max(normalized_key, seen_key, key=len) + + # Check if at least one of the original keys has a numbered prefix (Spec-Kit format) + import re + + has_speckit_key = bool( + re.match(r"^\d{3}[_-]", existing_feature.key) + or any( + re.match(r"^\d{3}[_-]", f.key) + for f in deduplicated_features + if normalize_feature_key(f.key) == seen_key + ) + ) + + # More conservative matching: + # 1. At least one key must have numbered prefix (Spec-Kit origin) + # 2. Shorter must be at least 10 chars + # 3. Longer must start with shorter (prefix match) + # 4. Length difference must be at least 6 chars + # 5. Shorter must be < 75% of longer (to ensure significant difference) + length_diff = len(longer) - len(shorter) + length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0 + + if ( + has_speckit_key + and len(shorter) >= 10 + and longer.startswith(shorter) + and length_diff >= 6 + and length_ratio < 0.75 + ): + matched = True + # Prefer the longer (full) name - update the existing feature's key if needed + if len(normalized_key) > len(seen_key): + # Current feature has longer name - update the existing one + for dedup_feature in deduplicated_features: + if normalize_feature_key(dedup_feature.key) == seen_key: + dedup_feature.key = existing_feature.key + break + break + + if not matched: + seen_normalized_keys.add(normalized_key) + deduplicated_features.append(existing_feature) + + duplicates_removed = len(bundle.features) - len(deduplicated_features) + if duplicates_removed > 0: + bundle.features = deduplicated_features + + return duplicates_removed + + +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require( + lambda bundle_name: isinstance(bundle_name, str) and len(bundle_name) > 0, "Bundle name must be non-empty string" +) +@require(lambda project_hash: project_hash is None or isinstance(project_hash, str), "Project hash must be None or str") +@ensure( + lambda result: isinstance(result, tuple) and len(result) == 3, + "Must return (bool, SDDManifest | None, ValidationReport) tuple", +) +def _validate_sdd_for_bundle( + bundle: PlanBundle, bundle_name: str, require_sdd: bool = False, project_hash: str | None = None +) -> tuple[bool, SDDManifest | None, ValidationReport]: + """ + Validate SDD manifest for project bundle. + + Args: + bundle: Plan bundle to validate (converted from ProjectBundle) + bundle_name: Project bundle name + require_sdd: If True, return False if SDD is missing (for promotion gates) + project_hash: Optional hash computed from ProjectBundle BEFORE modifications (for consistency with plan harden) + + Returns: + Tuple of (is_valid, sdd_manifest, validation_report) + """ + from specfact_cli.models.deviation import Deviation, DeviationSeverity, ValidationReport + from specfact_cli.models.sdd import SDDManifest + + report = ValidationReport() + # Find SDD using discovery utility + from specfact_cli.utils.sdd_discovery import find_sdd_for_bundle + + base_path = Path.cwd() + sdd_path = find_sdd_for_bundle(bundle_name, base_path) + + # Check if SDD manifest exists + if sdd_path is None: + if require_sdd: + deviation = Deviation( + type=DeviationType.COVERAGE_THRESHOLD, + severity=DeviationSeverity.HIGH, + description="SDD manifest is required for plan promotion but not found", + location=str(sdd_path), + fix_hint=f"Run 'specfact plan harden {bundle_name}' to create SDD manifest", + ) + report.add_deviation(deviation) + return (False, None, report) + # SDD not required, just return None + return (True, None, report) + + # Load SDD manifest + try: + sdd_data = load_structured_file(sdd_path) + sdd_manifest = SDDManifest.model_validate(sdd_data) + except Exception as e: + deviation = Deviation( + type=DeviationType.COVERAGE_THRESHOLD, + severity=DeviationSeverity.HIGH, + description=f"Failed to load SDD manifest: {e}", + location=str(sdd_path), + fix_hint=f"Run 'specfact plan harden {bundle_name}' to recreate SDD manifest", + ) + report.add_deviation(deviation) + return (False, None, report) + + # Validate hash match + # IMPORTANT: Use project_hash if provided (computed from ProjectBundle BEFORE modifications) + # This ensures consistency with plan harden which computes hash from ProjectBundle. + # If not provided, fall back to computing from PlanBundle (for backward compatibility). + if project_hash: + bundle_hash = project_hash + else: + bundle.update_summary(include_hash=True) + bundle_hash = bundle.metadata.summary.content_hash if bundle.metadata and bundle.metadata.summary else None + + if bundle_hash and sdd_manifest.plan_bundle_hash != bundle_hash: + deviation = Deviation( + type=DeviationType.HASH_MISMATCH, + severity=DeviationSeverity.HIGH, + description=f"SDD bundle hash mismatch: expected {bundle_hash[:16]}..., got {sdd_manifest.plan_bundle_hash[:16]}...", + location=str(sdd_path), + fix_hint=f"Run 'specfact plan harden {bundle_name}' to update SDD manifest", + ) + report.add_deviation(deviation) + return (False, sdd_manifest, report) + + # Validate coverage thresholds + from specfact_cli.validators.contract_validator import calculate_contract_density, validate_contract_density + + metrics = calculate_contract_density(sdd_manifest, bundle) + density_deviations = validate_contract_density(sdd_manifest, bundle, metrics) + for deviation in density_deviations: + report.add_deviation(deviation) + + is_valid = report.total_deviations == 0 + return (is_valid, sdd_manifest, report) + + +def _validate_sdd_for_plan( + bundle: PlanBundle, plan_path: Path, require_sdd: bool = False +) -> tuple[bool, SDDManifest | None, ValidationReport]: + """ + Validate SDD manifest for plan bundle. + + Args: + bundle: Plan bundle to validate + plan_path: Path to plan bundle + require_sdd: If True, return False if SDD is missing (for promotion gates) + + Returns: + Tuple of (is_valid, sdd_manifest, validation_report) + """ + from specfact_cli.models.deviation import Deviation, DeviationSeverity, ValidationReport + from specfact_cli.models.sdd import SDDManifest + from specfact_cli.utils.structure import SpecFactStructure + + report = ValidationReport() + # Construct bundle-specific SDD path (Phase 8.5+) + base_path = Path.cwd() + if not plan_path.is_dir(): + print_error( + "Legacy monolithic plan detected. Please migrate to bundle directories via 'specfact migrate artifacts --repo .'." + ) + raise typer.Exit(1) + bundle_name = plan_path.name + from specfact_cli.utils.structured_io import StructuredFormat + + sdd_path = SpecFactStructure.get_bundle_sdd_path(bundle_name, base_path, StructuredFormat.YAML) + if not sdd_path.exists(): + sdd_path = SpecFactStructure.get_bundle_sdd_path(bundle_name, base_path, StructuredFormat.JSON) + + # Check if SDD manifest exists + if not sdd_path.exists(): + if require_sdd: + deviation = Deviation( + type=DeviationType.COVERAGE_THRESHOLD, + severity=DeviationSeverity.HIGH, + description="SDD manifest is required for plan promotion but not found", + location=".specfact/projects/<bundle>/sdd.yaml", + fix_hint="Run 'specfact plan harden' to create SDD manifest", + ) + report.add_deviation(deviation) + return (False, None, report) + # SDD not required, just return None + return (True, None, report) + + # Load SDD manifest + try: + sdd_data = load_structured_file(sdd_path) + sdd_manifest = SDDManifest.model_validate(sdd_data) + except Exception as e: + deviation = Deviation( + type=DeviationType.COVERAGE_THRESHOLD, + severity=DeviationSeverity.HIGH, + description=f"Failed to load SDD manifest: {e}", + location=str(sdd_path), + fix_hint="Run 'specfact plan harden' to regenerate SDD manifest", + ) + report.add_deviation(deviation) + return (False, None, report) + + # Validate hash match + bundle.update_summary(include_hash=True) + plan_hash = bundle.metadata.summary.content_hash if bundle.metadata and bundle.metadata.summary else None + + if not plan_hash: + deviation = Deviation( + type=DeviationType.COVERAGE_THRESHOLD, + severity=DeviationSeverity.HIGH, + description="Failed to compute plan bundle hash", + location=str(plan_path), + fix_hint="Plan bundle may be corrupted", + ) + report.add_deviation(deviation) + return (False, sdd_manifest, report) + + if sdd_manifest.plan_bundle_hash != plan_hash: + deviation = Deviation( + type=DeviationType.HASH_MISMATCH, + severity=DeviationSeverity.HIGH, + description=f"SDD plan bundle hash mismatch: expected {plan_hash[:16]}..., got {sdd_manifest.plan_bundle_hash[:16]}...", + location=".specfact/projects/<bundle>/sdd.yaml", + fix_hint="Run 'specfact plan harden' to update SDD manifest with current plan hash", + ) + report.add_deviation(deviation) + return (False, sdd_manifest, report) + + # Validate coverage thresholds using contract validator + from specfact_cli.validators.contract_validator import calculate_contract_density, validate_contract_density + + metrics = calculate_contract_density(sdd_manifest, bundle) + density_deviations = validate_contract_density(sdd_manifest, bundle, metrics) + + for deviation in density_deviations: + report.add_deviation(deviation) + + # Valid if no HIGH severity deviations + is_valid = report.high_count == 0 + return (is_valid, sdd_manifest, report) + + +@beartype +@require(lambda project_bundle: isinstance(project_bundle, ProjectBundle), "Project bundle must be ProjectBundle") +@require(lambda bundle_dir: isinstance(bundle_dir, Path), "Bundle dir must be Path") +@require(lambda bundle_name: isinstance(bundle_name, str), "Bundle name must be str") +@require(lambda auto_enrich: isinstance(auto_enrich, bool), "Auto enrich must be bool") +@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return tuple of PlanBundle and str") +def _prepare_review_bundle( + project_bundle: ProjectBundle, bundle_dir: Path, bundle_name: str, auto_enrich: bool +) -> tuple[PlanBundle, str]: + """ + Prepare plan bundle for review. + + Args: + project_bundle: Loaded project bundle + bundle_dir: Path to bundle directory + bundle_name: Bundle name + auto_enrich: Whether to auto-enrich the bundle + + Returns: + Tuple of (plan_bundle, current_stage) + """ + # Compute hash from ProjectBundle BEFORE any modifications (same as plan harden does) + # This ensures hash consistency with SDD manifest created by plan harden + project_summary = project_bundle.compute_summary(include_hash=True) + project_hash = project_summary.content_hash + if not project_hash: + print_warning("Failed to compute project bundle hash for SDD validation") + + # Convert to PlanBundle for compatibility with review functions + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Deduplicate features by normalized key (clean up duplicates from previous syncs) + duplicates_removed = _deduplicate_features(plan_bundle) + if duplicates_removed > 0: + # Convert back to ProjectBundle and save + # Update project bundle with deduplicated features + project_bundle.features = {f.key: f for f in plan_bundle.features} + _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) + print_success(f"✓ Removed {duplicates_removed} duplicate features from project bundle") + + # Check current stage (ProjectBundle doesn't have metadata.stage, use default) + current_stage = "draft" # TODO: Add promotion status to ProjectBundle manifest + + print_info(f"Current stage: {current_stage}") + + # Validate SDD manifest (warn if missing, validate thresholds if present) + # Pass project_hash computed BEFORE modifications to ensure consistency + print_info("Checking SDD manifest...") + sdd_valid, sdd_manifest, sdd_report = _validate_sdd_for_bundle( + plan_bundle, bundle_name, require_sdd=False, project_hash=project_hash + ) + + if sdd_manifest is None: + print_warning("SDD manifest not found. Consider running 'specfact plan harden' to create one.") + from rich.console import Console + + console = Console() + console.print("[dim]SDD manifest is recommended for plan review and promotion[/dim]") + elif not sdd_valid: + print_warning("SDD manifest validation failed:") + from rich.console import Console + + from specfact_cli.models.deviation import DeviationSeverity + + console = Console() + for deviation in sdd_report.deviations: + if deviation.severity == DeviationSeverity.HIGH: + console.print(f" [bold red]✗[/bold red] {deviation.description}") + elif deviation.severity == DeviationSeverity.MEDIUM: + console.print(f" [bold yellow]⚠[/bold yellow] {deviation.description}") + else: + console.print(f" [dim]ℹ[/dim] {deviation.description}") + console.print("\n[dim]Run 'specfact enforce sdd' for detailed validation report[/dim]") + else: + print_success("SDD manifest validated successfully") + + # Display contract density metrics + from rich.console import Console + + from specfact_cli.validators.contract_validator import calculate_contract_density + + console = Console() + metrics = calculate_contract_density(sdd_manifest, plan_bundle) + thresholds = sdd_manifest.coverage_thresholds + + console.print("\n[bold]Contract Density Metrics:[/bold]") + console.print( + f" Contracts/story: {metrics.contracts_per_story:.2f} (threshold: {thresholds.contracts_per_story})" + ) + console.print( + f" Invariants/feature: {metrics.invariants_per_feature:.2f} (threshold: {thresholds.invariants_per_feature})" + ) + console.print( + f" Architecture facets: {metrics.architecture_facets} (threshold: {thresholds.architecture_facets})" + ) + + if sdd_report.total_deviations > 0: + console.print(f"\n[dim]Found {sdd_report.total_deviations} coverage threshold warning(s)[/dim]") + console.print("[dim]Run 'specfact enforce sdd' for detailed report[/dim]") + + # Initialize clarifications if needed + from specfact_cli.models.plan import Clarifications + + if plan_bundle.clarifications is None: + plan_bundle.clarifications = Clarifications(sessions=[]) + + # Auto-enrich if requested (before scanning for ambiguities) + _handle_auto_enrichment(plan_bundle, bundle_dir, auto_enrich) + + return (plan_bundle, current_stage) + + +@beartype +@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle") +@require(lambda bundle_dir: isinstance(bundle_dir, Path), "Bundle dir must be Path") +@require(lambda category: category is None or isinstance(category, str), "Category must be None or str") +@require(lambda max_questions: max_questions > 0, "Max questions must be positive") +@ensure( + lambda result: isinstance(result, tuple) and len(result) == 3 and isinstance(result[0], list), + "Must return tuple of questions, report, scanner", +) +def _scan_and_prepare_questions( + plan_bundle: PlanBundle, bundle_dir: Path, category: str | None, max_questions: int +) -> tuple[list[tuple[Any, str]], Any, Any]: # Returns (questions_to_ask, report, scanner) + """ + Scan plan bundle and prepare questions for review. + + Args: + plan_bundle: Plan bundle to scan + bundle_dir: Bundle directory path (for finding repo path) + category: Optional category filter + max_questions: Maximum questions to prepare + + Returns: + Tuple of (questions_to_ask, report, scanner) + """ + from specfact_cli.analyzers.ambiguity_scanner import ( + AmbiguityScanner, + TaxonomyCategory, + ) + + # Scan for ambiguities + print_info("Scanning plan bundle for ambiguities...") + # Try to find repo path from bundle directory (go up to find .specfact parent, then repo root) + repo_path: Path | None = None + if bundle_dir.exists(): + # bundle_dir is typically .specfact/projects/<bundle-name> + # Go up to .specfact, then up to repo root + specfact_dir = bundle_dir.parent.parent if bundle_dir.parent.name == "projects" else bundle_dir.parent + if specfact_dir.name == ".specfact" and specfact_dir.parent.exists(): + repo_path = specfact_dir.parent + else: + # Fallback: try current directory + repo_path = Path(".") + else: + repo_path = Path(".") + + scanner = AmbiguityScanner(repo_path=repo_path) + report = scanner.scan(plan_bundle) + + # Filter by category if specified + if category: + try: + target_category = TaxonomyCategory(category) + if report.findings: + report.findings = [f for f in report.findings if f.category == target_category] + except ValueError: + print_warning(f"Unknown category: {category}, ignoring filter") + category = None + + # Prioritize questions by (Impact x Uncertainty) + findings_list = report.findings or [] + prioritized_findings = sorted( + findings_list, + key=lambda f: f.impact * f.uncertainty, + reverse=True, + ) + + # Filter out findings that already have clarifications + existing_question_ids = set() + if plan_bundle.clarifications: + for session in plan_bundle.clarifications.sessions: + for q in session.questions: + existing_question_ids.add(q.id) + + # Generate question IDs and filter + question_counter = 1 + candidate_questions: list[tuple[Any, str]] = [] + for finding in prioritized_findings: + if finding.question: + # Skip to next available question ID if current one is already used + while (question_id := f"Q{question_counter:03d}") in existing_question_ids: + question_counter += 1 + # Generate question ID and add if not already answered + candidate_questions.append((finding, question_id)) + question_counter += 1 + + # Limit to max_questions + questions_to_ask = candidate_questions[:max_questions] + + return (questions_to_ask, report, scanner) + + +@beartype +@require(lambda questions_to_ask: isinstance(questions_to_ask, list), "Questions must be list") +@require(lambda report: report is not None, "Report must not be None") +@ensure(lambda result: result is None, "Must return None") +def _handle_no_questions_case( + questions_to_ask: list[tuple[Any, str]], + report: Any, # AmbiguityReport +) -> None: + """ + Handle case when there are no questions to ask. + + Args: + questions_to_ask: List of questions (should be empty) + report: Ambiguity report + """ + from rich.console import Console + + from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus, TaxonomyCategory + + console = Console() + + # Check coverage status to determine if plan is truly ready for promotion + critical_categories = [ + TaxonomyCategory.FUNCTIONAL_SCOPE, + TaxonomyCategory.FEATURE_COMPLETENESS, + TaxonomyCategory.CONSTRAINTS, + ] + + missing_critical: list[TaxonomyCategory] = [] + if report.coverage: + for category, status in report.coverage.items(): + if category in critical_categories and status == AmbiguityStatus.MISSING: + missing_critical.append(category) + + # Count total findings per category (shared for both branches) + total_findings_by_category: dict[TaxonomyCategory, int] = {} + if report.findings: + for finding in report.findings: + cat = finding.category + total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 + + if missing_critical: + print_warning( + f"Plan has {len(missing_critical)} critical category(ies) marked as Missing, but no high-priority questions remain" + ) + console.print("[dim]Missing critical categories:[/dim]") + for cat in missing_critical: + console.print(f" - {cat.value}") + console.print("\n[bold]Coverage Summary:[/bold]") + if report.coverage: + for cat, status in report.coverage.items(): + status_icon = ( + "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" + ) + total = total_findings_by_category.get(cat, 0) + # Count findings by status + clear_count = sum( + 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.CLEAR + ) + partial_count = sum( + 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.PARTIAL + ) + # Show format based on status: + # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total + # - Partial: Show partial_count/total (count of findings with PARTIAL status) + if status == AmbiguityStatus.CLEAR: + if total == 0: + # No findings - just show status without counts + console.print(f" {status_icon} {cat.value}: {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") + elif status == AmbiguityStatus.PARTIAL: + console.print(f" {status_icon} {cat.value}: {partial_count}/{total} {status.value}") + else: # MISSING + console.print(f" {status_icon} {cat.value}: {status.value}") + console.print( + "\n[bold]⚠️ Warning:[/bold] Plan may not be ready for promotion due to missing critical categories" + ) + console.print("[dim]Consider addressing these categories before promoting[/dim]") + else: + print_success("No critical ambiguities detected. Plan is ready for promotion.") + console.print("\n[bold]Coverage Summary:[/bold]") + if report.coverage: + for cat, status in report.coverage.items(): + status_icon = ( + "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" + ) + total = total_findings_by_category.get(cat, 0) + # Count findings by status + clear_count = sum( + 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.CLEAR + ) + partial_count = sum( + 1 for f in (report.findings or []) if f.category == cat and f.status == AmbiguityStatus.PARTIAL + ) + # Show format based on status: + # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total + # - Partial: Show partial_count/total (count of findings with PARTIAL status) + if status == AmbiguityStatus.CLEAR: + if total == 0: + # No findings - just show status without counts + console.print(f" {status_icon} {cat.value}: {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") + elif status == AmbiguityStatus.PARTIAL: + console.print(f" {status_icon} {cat.value}: {partial_count}/{total} {status.value}") + else: # MISSING + console.print(f" {status_icon} {cat.value}: {status.value}") + + return + + +@beartype +@require(lambda questions_to_ask: isinstance(questions_to_ask, list), "Questions must be list") +@ensure(lambda result: result is None, "Must return None") +def _handle_list_questions_mode(questions_to_ask: list[tuple[Any, str]], output_path: Path | None = None) -> None: + """ + Handle --list-questions mode by outputting questions as JSON. + + Args: + questions_to_ask: List of (finding, question_id) tuples + output_path: Optional file path to save questions. If None, outputs to stdout. + """ + import json + import sys + + questions_json = [] + for finding, question_id in questions_to_ask: + questions_json.append( + { + "id": question_id, + "category": finding.category.value, + "question": finding.question, + "impact": finding.impact, + "uncertainty": finding.uncertainty, + "related_sections": finding.related_sections or [], + } + ) + + json_output = json.dumps({"questions": questions_json, "total": len(questions_json)}, indent=2) + + if output_path: + # Save to file + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json_output + "\n", encoding="utf-8") + from rich.console import Console + + console = Console() + console.print(f"[green]✓[/green] Questions saved to: {output_path}") + else: + # Output JSON to stdout (for Copilot mode parsing) + sys.stdout.write(json_output) + sys.stdout.write("\n") + sys.stdout.flush() + + return + + +@beartype +@require(lambda answers: isinstance(answers, str), "Answers must be string") +@ensure(lambda result: isinstance(result, dict), "Must return dict") +def _parse_answers_dict(answers: str) -> dict[str, str]: + """ + Parse --answers JSON string or file path. + + Args: + answers: JSON string or file path + + Returns: + Dictionary mapping question_id -> answer + """ + import json + + try: + # Try to parse as JSON string first + try: + answers_dict = json.loads(answers) + except json.JSONDecodeError: + # If JSON parsing fails, try as file path + answers_path = Path(answers) + if answers_path.exists() and answers_path.is_file(): + answers_dict = json.loads(answers_path.read_text()) + else: + raise ValueError(f"Invalid JSON string and file not found: {answers}") from None + + if not isinstance(answers_dict, dict): + print_error("--answers must be a JSON object with question_id -> answer mappings") + raise typer.Exit(1) + return answers_dict + except (json.JSONDecodeError, ValueError) as e: + print_error(f"Invalid JSON in --answers: {e}") + raise typer.Exit(1) from e + + +@beartype +@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle") +@require(lambda questions_to_ask: isinstance(questions_to_ask, list), "Questions must be list") +@require(lambda answers_dict: isinstance(answers_dict, dict), "Answers dict must be dict") +@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") +@require(lambda bundle_dir: isinstance(bundle_dir, Path), "Bundle dir must be Path") +@require(lambda project_bundle: isinstance(project_bundle, ProjectBundle), "Project bundle must be ProjectBundle") +@ensure(lambda result: isinstance(result, int), "Must return int") +def _ask_questions_interactive( + plan_bundle: PlanBundle, + questions_to_ask: list[tuple[Any, str]], + answers_dict: dict[str, str], + is_non_interactive: bool, + bundle_dir: Path, + project_bundle: ProjectBundle, +) -> int: + """ + Ask questions interactively and integrate answers. + + Args: + plan_bundle: Plan bundle to update + questions_to_ask: List of (finding, question_id) tuples + answers_dict: Pre-provided answers dict (may be empty) + is_non_interactive: Whether in non-interactive mode + bundle_dir: Bundle directory path + project_bundle: Project bundle to save + + Returns: + Number of questions asked + """ + from datetime import date, datetime + + from rich.console import Console + + from specfact_cli.models.plan import Clarification, ClarificationSession + + console = Console() + + # Create or get today's session + today = date.today().isoformat() + today_session: ClarificationSession | None = None + if plan_bundle.clarifications: + for session in plan_bundle.clarifications.sessions: + if session.date == today: + today_session = session + break + + if today_session is None: + today_session = ClarificationSession(date=today, questions=[]) + if plan_bundle.clarifications: + plan_bundle.clarifications.sessions.append(today_session) + + # Ask questions sequentially + questions_asked = 0 + for finding, question_id in questions_to_ask: + questions_asked += 1 + + # Get answer (interactive or from --answers) + if question_id in answers_dict: + # Non-interactive: use provided answer + answer = answers_dict[question_id] + if not isinstance(answer, str) or not answer.strip(): + print_error(f"Answer for {question_id} must be a non-empty string") + raise typer.Exit(1) + console.print(f"\n[bold cyan]Question {questions_asked}/{len(questions_to_ask)}[/bold cyan]") + console.print(f"[dim]Category: {finding.category.value}[/dim]") + console.print(f"[bold]Q: {finding.question}[/bold]") + console.print(f"[dim]Answer (from --answers): {answer}[/dim]") + default_value = None + else: + # Interactive: prompt user + if is_non_interactive: + # In non-interactive mode without --answers, skip this question + print_warning(f"Skipping {question_id}: no answer provided in non-interactive mode") + continue + + console.print(f"\n[bold cyan]Question {questions_asked}/{len(questions_to_ask)}[/bold cyan]") + console.print(f"[dim]Category: {finding.category.value}[/dim]") + console.print(f"[bold]Q: {finding.question}[/bold]") + + # Show current settings for related sections before asking and get default value + default_value = _show_current_settings_for_finding(plan_bundle, finding, console_instance=console) + + # Get answer from user with smart Yes/No handling (with default to confirm existing) + answer = _get_smart_answer(finding, plan_bundle, is_non_interactive, default_value=default_value) + + # Validate answer length (warn if too long, but only if user typed something new) + # Don't warn if user confirmed existing default value + # Check if answer matches default (normalize whitespace for comparison) + is_confirmed_default = False + if default_value: + # Normalize both for comparison (strip and compare) + answer_normalized = answer.strip() + default_normalized = default_value.strip() + # Check exact match or if answer is empty and we have default (Enter pressed) + is_confirmed_default = answer_normalized == default_normalized or ( + not answer_normalized and default_normalized + ) + if not is_confirmed_default and len(answer.split()) > 5: + print_warning("Answer is longer than 5 words. Consider a shorter, more focused answer.") + + # Integrate answer into plan bundle + integration_points = _integrate_clarification(plan_bundle, finding, answer) + + # Create clarification record + clarification = Clarification( + id=question_id, + category=finding.category.value, + question=finding.question or "", + answer=answer, + integrated_into=integration_points, + timestamp=datetime.now(UTC).isoformat(), + ) + + today_session.questions.append(clarification) + + # Answer integrated into bundle (will save at end for performance) + print_success("Answer recorded and integrated into plan bundle") + + # Ask if user wants to continue (only in interactive mode) + if ( + not is_non_interactive + and questions_asked < len(questions_to_ask) + and not prompt_confirm("Continue to next question?", default=True) + ): + break + + # Save project bundle once at the end (more efficient than saving after each question) + # Update existing project_bundle in memory (no need to reload - we already have it) + # Preserve manifest from original bundle + project_bundle.idea = plan_bundle.idea + project_bundle.business = plan_bundle.business + project_bundle.product = plan_bundle.product + project_bundle.features = {f.key: f for f in plan_bundle.features} + project_bundle.clarifications = plan_bundle.clarifications + _save_bundle_with_progress(project_bundle, bundle_dir, atomic=True) + print_success("Project bundle saved") + + return questions_asked + + +@beartype +@require(lambda plan_bundle: isinstance(plan_bundle, PlanBundle), "Plan bundle must be PlanBundle") +@require(lambda scanner: scanner is not None, "Scanner must not be None") +@require(lambda bundle: isinstance(bundle, str), "Bundle must be str") +@require(lambda questions_asked: questions_asked >= 0, "Questions asked must be non-negative") +@require(lambda report: report is not None, "Report must not be None") +@require(lambda current_stage: isinstance(current_stage, str), "Current stage must be str") +@require(lambda today_session: today_session is not None, "Today session must not be None") +@ensure(lambda result: result is None, "Must return None") +def _display_review_summary( + plan_bundle: PlanBundle, + scanner: Any, # AmbiguityScanner + bundle: str, + questions_asked: int, + report: Any, # AmbiguityReport + current_stage: str, + today_session: Any, # ClarificationSession +) -> None: + """ + Display final review summary and updated coverage. + + Args: + plan_bundle: Updated plan bundle + scanner: Ambiguity scanner instance + bundle: Bundle name + questions_asked: Number of questions asked + report: Original ambiguity report + current_stage: Current plan stage + today_session: Today's clarification session + """ + from rich.console import Console + + from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus + + console = Console() + + # Final validation + print_info("Validating updated plan bundle...") + validation_result = validate_plan_bundle(plan_bundle) + if isinstance(validation_result, ValidationReport): + if not validation_result.passed: + print_warning(f"Validation found {len(validation_result.deviations)} issue(s)") + else: + print_success("Validation passed") + else: + print_success("Validation passed") + + # Display summary + print_success(f"Review complete: {questions_asked} question(s) answered") + console.print(f"\n[bold]Project Bundle:[/bold] {bundle}") + console.print(f"[bold]Questions Asked:[/bold] {questions_asked}") + + if today_session.questions: + console.print("\n[bold]Sections Touched:[/bold]") + all_sections = set() + for q in today_session.questions: + all_sections.update(q.integrated_into) + for section in sorted(all_sections): + console.print(f" • {section}") + + # Re-scan plan bundle after questions to get updated coverage summary + print_info("Re-scanning plan bundle for updated coverage...") + updated_report = scanner.scan(plan_bundle) + + # Coverage summary (updated after questions) + console.print("\n[bold]Updated Coverage Summary:[/bold]") + if updated_report.coverage: + from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory + + # Count findings that can still generate questions (unclear findings) + # Use the same logic as _scan_and_prepare_questions to count unclear findings + existing_question_ids = set() + if plan_bundle.clarifications: + for session in plan_bundle.clarifications.sessions: + for q in session.questions: + existing_question_ids.add(q.id) + + # Prioritize findings by (Impact x Uncertainty) - same as _scan_and_prepare_questions + findings_list = updated_report.findings or [] + prioritized_findings = sorted( + findings_list, + key=lambda f: f.impact * f.uncertainty, + reverse=True, + ) + + # Count total findings and unclear findings per category + # A finding is unclear if it can still generate a question (same logic as _scan_and_prepare_questions) + total_findings_by_category: dict[TaxonomyCategory, int] = {} + unclear_findings_by_category: dict[TaxonomyCategory, int] = {} + clear_findings_by_category: dict[TaxonomyCategory, int] = {} + + question_counter = 1 + for finding in prioritized_findings: + cat = finding.category + total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 + + # Count by finding status + if finding.status == AmbiguityStatus.CLEAR: + clear_findings_by_category[cat] = clear_findings_by_category.get(cat, 0) + 1 + elif finding.status == AmbiguityStatus.PARTIAL: + # A finding is unclear if it can generate a question (same logic as _scan_and_prepare_questions) + if finding.question: + # Skip to next available question ID if current one is already used + while f"Q{question_counter:03d}" in existing_question_ids: + question_counter += 1 + # This finding can generate a question, so it's unclear + unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 + question_counter += 1 + else: + # Finding has no question, so it's unclear + unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 + + for cat, status in updated_report.coverage.items(): + status_icon = ( + "✅" if status == AmbiguityStatus.CLEAR else "⚠️" if status == AmbiguityStatus.PARTIAL else "❌" + ) + total = total_findings_by_category.get(cat, 0) + unclear = unclear_findings_by_category.get(cat, 0) + clear_count = clear_findings_by_category.get(cat, 0) + # Show format based on status: + # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total + # - Partial: Show unclear_count/total (how many findings are still unclear) + if status == AmbiguityStatus.CLEAR: + if total == 0: + # No findings - just show status without counts + console.print(f" {status_icon} {cat.value}: {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") + elif status == AmbiguityStatus.PARTIAL: + # Show how many findings are still unclear + # If all are unclear, just show the count without the fraction + if unclear == total: + console.print(f" {status_icon} {cat.value}: {unclear} {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {unclear}/{total} {status.value}") + else: # MISSING + console.print(f" {status_icon} {cat.value}: {status.value}") + + # Next steps + console.print("\n[bold]Next Steps:[/bold]") + if current_stage == "draft": + console.print(" • Review plan bundle for completeness") + console.print(" • Run: specfact plan promote --stage review") + elif current_stage == "review": + console.print(" • Plan is ready for approval") + console.print(" • Run: specfact plan promote --stage approved") + + return + + +@app.command("review") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda max_questions: max_questions > 0, "Max questions must be positive") +def review( + # Target/Input + bundle: str | None = typer.Argument( + None, + help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", + ), + category: str | None = typer.Option( + None, + "--category", + help="Focus on specific taxonomy category (optional). Default: None (all categories)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + # Output/Results + list_questions: bool = typer.Option( + False, + "--list-questions", + help="Output questions in JSON format without asking (for Copilot mode). Default: False", + ), + output_questions: Path | None = typer.Option( + None, + "--output-questions", + help="Save questions to file (JSON format). If --list-questions is also set, questions are saved to file instead of stdout. Default: None", + ), + list_findings: bool = typer.Option( + False, + "--list-findings", + help="Output all findings in structured format (JSON/YAML) or as table (interactive mode). Preferred for bulk updates via Copilot LLM enrichment. Default: False", + ), + findings_format: str | None = typer.Option( + None, + "--findings-format", + help="Output format for --list-findings: json, yaml, or table. Default: json for non-interactive, table for interactive", + case_sensitive=False, + hidden=True, # Hidden by default, shown with --help-advanced + ), + output_findings: Path | None = typer.Option( + None, + "--output-findings", + help="Save findings to file (JSON/YAML format). If --list-findings is also set, findings are saved to file instead of stdout. Default: None", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), + answers: str | None = typer.Option( + None, + "--answers", + help="JSON object with question_id -> answer mappings (for non-interactive mode). Can be JSON string or path to JSON file. Use with --output-questions to save questions, then edit and provide answers. Default: None", + hidden=True, # Hidden by default, shown with --help-advanced + ), + auto_enrich: bool = typer.Option( + False, + "--auto-enrich", + help="Automatically enrich vague acceptance criteria, incomplete requirements, and generic tasks using LLM-enhanced pattern matching. Default: False", + ), + # Advanced/Configuration + max_questions: int = typer.Option( + 5, + "--max-questions", + min=1, + max=10, + help="Maximum questions per session. Default: 5 (range: 1-10)", + hidden=True, # Hidden by default, shown with --help-advanced + ), +) -> None: + """ + Review project bundle to identify and resolve ambiguities. + + Analyzes the project bundle for missing information, unclear requirements, + and unknowns. Asks targeted questions to resolve ambiguities and make + the bundle ready for promotion. + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --category + - **Output/Results**: --list-questions, --list-findings, --findings-format + - **Behavior/Options**: --no-interactive, --answers, --auto-enrich + - **Advanced/Configuration**: --max-questions + + **Examples:** + specfact plan review legacy-api + specfact plan review auth-module --max-questions 3 --category "Functional Scope" + specfact plan review legacy-api --list-questions # Output questions as JSON + specfact plan review legacy-api --list-questions --output-questions /tmp/questions.json # Save questions to file + specfact plan review legacy-api --list-findings --findings-format json # Output all findings as JSON + specfact plan review legacy-api --list-findings --output-findings /tmp/findings.json # Save findings to file + specfact plan review legacy-api --answers '{"Q001": "answer1", "Q002": "answer2"}' # Non-interactive + """ + from rich.console import Console + + from specfact_cli.utils.structure import SpecFactStructure + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle is None: + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + from datetime import date + + from specfact_cli.analyzers.ambiguity_scanner import ( + AmbiguityStatus, + ) + from specfact_cli.models.plan import ClarificationSession + + # Detect operational mode + mode = detect_mode() + is_non_interactive = no_interactive or (answers is not None) or list_questions + + telemetry_metadata = { + "max_questions": max_questions, + "category": category, + "list_questions": list_questions, + "non_interactive": is_non_interactive, + "mode": mode.value, + } + + with telemetry.track_command("plan.review", telemetry_metadata) as record: + # Find bundle directory + bundle_dir = _find_bundle_dir(bundle) + if bundle_dir is None: + raise typer.Exit(1) + + print_section("SpecFact CLI - Plan Review") + + try: + # Load and prepare bundle + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + plan_bundle, current_stage = _prepare_review_bundle(project_bundle, bundle_dir, bundle, auto_enrich) + + if current_stage not in ("draft", "review"): + print_warning("Review is typically run on 'draft' or 'review' stage plans") + if not is_non_interactive and not prompt_confirm("Continue anyway?", default=False): + raise typer.Exit(0) + if is_non_interactive: + print_info("Continuing in non-interactive mode") + + # Scan and prepare questions + questions_to_ask, report, scanner = _scan_and_prepare_questions( + plan_bundle, bundle_dir, category, max_questions + ) + + # Handle --list-findings mode + if list_findings: + _output_findings(report, findings_format, is_non_interactive, output_findings) + raise typer.Exit(0) + + # Show initial coverage summary BEFORE questions (so user knows what's missing) + if questions_to_ask: + from specfact_cli.analyzers.ambiguity_scanner import AmbiguityStatus + + console.print("\n[bold]Initial Coverage Summary:[/bold]") + if report.coverage: + from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory + + # Count findings that can still generate questions (unclear findings) + # Use the same logic as _scan_and_prepare_questions to count unclear findings + existing_question_ids = set() + if plan_bundle.clarifications: + for session in plan_bundle.clarifications.sessions: + for q in session.questions: + existing_question_ids.add(q.id) + + # Prioritize findings by (Impact x Uncertainty) - same as _scan_and_prepare_questions + findings_list = report.findings or [] + prioritized_findings = sorted( + findings_list, + key=lambda f: f.impact * f.uncertainty, + reverse=True, + ) + + # Count total findings and unclear findings per category + # A finding is unclear if it can still generate a question (same logic as _scan_and_prepare_questions) + total_findings_by_category: dict[TaxonomyCategory, int] = {} + unclear_findings_by_category: dict[TaxonomyCategory, int] = {} + clear_findings_by_category: dict[TaxonomyCategory, int] = {} + + question_counter = 1 + for finding in prioritized_findings: + cat = finding.category + total_findings_by_category[cat] = total_findings_by_category.get(cat, 0) + 1 + + # Count by finding status + if finding.status == AmbiguityStatus.CLEAR: + clear_findings_by_category[cat] = clear_findings_by_category.get(cat, 0) + 1 + elif finding.status == AmbiguityStatus.PARTIAL: + # A finding is unclear if it can generate a question (same logic as _scan_and_prepare_questions) + if finding.question: + # Skip to next available question ID if current one is already used + while f"Q{question_counter:03d}" in existing_question_ids: + question_counter += 1 + # This finding can generate a question, so it's unclear + unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 + question_counter += 1 + else: + # Finding has no question, so it's unclear + unclear_findings_by_category[cat] = unclear_findings_by_category.get(cat, 0) + 1 + + for cat, status in report.coverage.items(): + status_icon = ( + "✅" + if status == AmbiguityStatus.CLEAR + else "⚠️" + if status == AmbiguityStatus.PARTIAL + else "❌" + ) + total = total_findings_by_category.get(cat, 0) + unclear = unclear_findings_by_category.get(cat, 0) + clear_count = clear_findings_by_category.get(cat, 0) + # Show format based on status: + # - Clear: If no findings (total=0), just show status. Otherwise show clear_count/total + # - Partial: Show unclear_count/total (how many findings are still unclear) + if status == AmbiguityStatus.CLEAR: + if total == 0: + # No findings - just show status without counts + console.print(f" {status_icon} {cat.value}: {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {clear_count}/{total} {status.value}") + elif status == AmbiguityStatus.PARTIAL: + # Show how many findings are still unclear + # If all are unclear, just show the count without the fraction + if unclear == total: + console.print(f" {status_icon} {cat.value}: {unclear} {status.value}") + else: + console.print(f" {status_icon} {cat.value}: {unclear}/{total} {status.value}") + else: # MISSING + console.print(f" {status_icon} {cat.value}: {status.value}") + console.print(f"\n[dim]Found {len(questions_to_ask)} question(s) to resolve[/dim]\n") + + # Handle --list-questions mode (must be before no-questions check) + if list_questions: + _handle_list_questions_mode(questions_to_ask, output_questions) + raise typer.Exit(0) + + if not questions_to_ask: + _handle_no_questions_case(questions_to_ask, report) + raise typer.Exit(0) + + # Parse answers if provided + answers_dict: dict[str, str] = {} + if answers: + answers_dict = _parse_answers_dict(answers) + + print_info(f"Found {len(questions_to_ask)} question(s) to resolve") + + # Ask questions interactively + questions_asked = _ask_questions_interactive( + plan_bundle, questions_to_ask, answers_dict, is_non_interactive, bundle_dir, project_bundle + ) + + # Get today's session for summary display + from datetime import date + + from specfact_cli.models.plan import ClarificationSession + + today = date.today().isoformat() + today_session: ClarificationSession | None = None + if plan_bundle.clarifications: + for session in plan_bundle.clarifications.sessions: + if session.date == today: + today_session = session + break + if today_session is None: + today_session = ClarificationSession(date=today, questions=[]) + + # Display final summary + _display_review_summary(plan_bundle, scanner, bundle, questions_asked, report, current_stage, today_session) + + record( + { + "questions_asked": questions_asked, + "findings_count": len(report.findings) if report.findings else 0, + "priority_score": report.priority_score, + } + ) + + except KeyboardInterrupt: + print_warning("Review interrupted by user") + raise typer.Exit(0) from None + except typer.Exit: + # Re-raise typer.Exit (used for --list-questions and other early exits) + raise + except Exception as e: + print_error(f"Failed to review plan: {e}") + raise typer.Exit(1) from e + + +def _convert_project_bundle_to_plan_bundle(project_bundle: ProjectBundle) -> PlanBundle: + """ + Convert ProjectBundle to PlanBundle for compatibility with existing extraction functions. + + Args: + project_bundle: ProjectBundle instance + + Returns: + PlanBundle instance + """ + return PlanBundle( + version="1.0", + idea=project_bundle.idea, + business=project_bundle.business, + product=project_bundle.product, + features=list(project_bundle.features.values()), + metadata=None, # ProjectBundle doesn't use Metadata, uses manifest instead + clarifications=project_bundle.clarifications, + ) + + +@beartype +def _convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: + """ + Convert PlanBundle to ProjectBundle (modular). + + Args: + plan_bundle: PlanBundle instance to convert + bundle_name: Project bundle name + + Returns: + ProjectBundle instance + """ + from specfact_cli.models.project import BundleManifest, BundleVersions + + # Create manifest + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + + # Convert features list to dict + features_dict: dict[str, Feature] = {f.key: f for f in plan_bundle.features} + + # Create and return ProjectBundle + return ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + idea=plan_bundle.idea, + business=plan_bundle.business, + product=plan_bundle.product, + features=features_dict, + clarifications=plan_bundle.clarifications, + ) + + +def _find_bundle_dir(bundle: str | None) -> Path | None: + """ + Find project bundle directory with improved validation and error messages. + + Args: + bundle: Bundle name or None + + Returns: + Bundle directory path or None if not found + """ + from specfact_cli.utils.structure import SpecFactStructure + + if bundle is None: + print_error("Bundle name is required. Use --bundle <name>") + print_info("Available bundles:") + projects_dir = Path(".") / SpecFactStructure.PROJECTS + if projects_dir.exists(): + bundles = [ + bundle_dir.name + for bundle_dir in projects_dir.iterdir() + if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists() + ] + if bundles: + for bundle_name in bundles: + print_info(f" - {bundle_name}") + else: + print_info(" (no bundles found)") + print_info("Create one with: specfact plan init <bundle-name>") + else: + print_info(" (projects directory not found)") + print_info("Create one with: specfact plan init <bundle-name>") + return None + + bundle_dir = SpecFactStructure.project_dir(bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle '{bundle}' not found: {bundle_dir}") + print_info(f"Create one with: specfact plan init {bundle}") + + # Suggest similar bundle names if available + projects_dir = Path(".") / SpecFactStructure.PROJECTS + if projects_dir.exists(): + available_bundles = [ + bundle_dir.name + for bundle_dir in projects_dir.iterdir() + if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists() + ] + if available_bundles: + print_info("Available bundles:") + for available_bundle in available_bundles: + print_info(f" - {available_bundle}") + return None + + return bundle_dir + + +@app.command("harden") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda sdd_path: sdd_path is None or isinstance(sdd_path, Path), "SDD path must be None or Path") +def harden( + # Target/Input + bundle: str | None = typer.Argument( + None, + help="Project bundle name (e.g., legacy-api, auth-module). Default: active plan from 'specfact plan select'", + ), + sdd_path: Path | None = typer.Option( + None, + "--sdd", + help="Output SDD manifest path. Default: bundle-specific .specfact/projects/<bundle-name>/sdd.<format> (Phase 8.5)", + ), + # Output/Results + output_format: StructuredFormat | None = typer.Option( + None, + "--output-format", + help="SDD manifest format (yaml or json). Default: global --output-format (yaml)", + case_sensitive=False, + ), + # Behavior/Options + interactive: bool = typer.Option( + True, + "--interactive/--no-interactive", + help="Interactive mode with prompts. Default: True (interactive, auto-detect)", + ), +) -> None: + """ + Create or update SDD manifest (hard spec) from project bundle. + + Generates a canonical SDD bundle that captures WHY (intent, constraints), + WHAT (capabilities, acceptance), and HOW (high-level architecture, invariants, + contracts) with promotion status. + + **Important**: SDD manifests are linked to specific project bundles via hash. + Each project bundle has its own SDD manifest in `.specfact/projects/<bundle-name>/sdd.yaml` (Phase 8.5). + + **Parameter Groups:** + - **Target/Input**: bundle (optional argument, defaults to active plan), --sdd + - **Output/Results**: --output-format + - **Behavior/Options**: --interactive/--no-interactive + + **Examples:** + specfact plan harden # Uses active plan (set via 'plan select') + specfact plan harden legacy-api # Interactive + specfact plan harden auth-module --no-interactive # CI/CD mode + specfact plan harden legacy-api --output-format json + """ + from specfact_cli.models.sdd import ( + SDDCoverageThresholds, + SDDEnforcementBudget, + SDDManifest, + ) + from specfact_cli.utils.structured_io import dump_structured_file + + effective_format = output_format or runtime.get_output_format() + is_non_interactive = not interactive + + from rich.console import Console + + from specfact_cli.utils.structure import SpecFactStructure + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle is None: + console.print("[bold red]✗[/bold red] Bundle name required") + console.print( + "[yellow]→[/yellow] Specify bundle name as argument or run 'specfact plan select' to set active plan" + ) + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + telemetry_metadata = { + "interactive": interactive, + "output_format": effective_format.value, + } + + with telemetry.track_command("plan.harden", telemetry_metadata) as record: + print_section("SpecFact CLI - SDD Manifest Creation") + + # Find bundle directory + bundle_dir = _find_bundle_dir(bundle) + if bundle_dir is None: + raise typer.Exit(1) + + try: + # Load project bundle with progress indicator + project_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Compute project bundle hash + summary = project_bundle.compute_summary(include_hash=True) + project_hash = summary.content_hash + if not project_hash: + print_error("Failed to compute project bundle hash") + raise typer.Exit(1) + + # Determine SDD output path (bundle-specific: .specfact/projects/<bundle-name>/sdd.yaml, Phase 8.5) + from specfact_cli.utils.sdd_discovery import get_default_sdd_path_for_bundle + + if sdd_path is None: + base_path = Path(".") + sdd_path = get_default_sdd_path_for_bundle(bundle, base_path, effective_format.value) + sdd_path.parent.mkdir(parents=True, exist_ok=True) + else: + # Ensure correct extension + if effective_format == StructuredFormat.YAML: + sdd_path = sdd_path.with_suffix(".yaml") + else: + sdd_path = sdd_path.with_suffix(".json") + + # Check if SDD already exists and reuse it if hash matches + existing_sdd: SDDManifest | None = None + # Convert to PlanBundle for extraction functions (temporary compatibility) + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + if sdd_path.exists(): + try: + from specfact_cli.utils.structured_io import load_structured_file + + existing_sdd_data = load_structured_file(sdd_path) + existing_sdd = SDDManifest.model_validate(existing_sdd_data) + if existing_sdd is not None and existing_sdd.plan_bundle_hash == project_hash: + # Hash matches - reuse existing SDD sections + print_info("SDD manifest exists with matching hash - reusing existing sections") + why = existing_sdd.why + what = existing_sdd.what + how = existing_sdd.how + elif existing_sdd is not None: + # Hash mismatch - warn and extract new, but reuse existing SDD as fallback + print_warning( + f"SDD manifest exists but is linked to a different bundle version.\n" + f" Existing bundle hash: {existing_sdd.plan_bundle_hash[:16]}...\n" + f" New bundle hash: {project_hash[:16]}...\n" + f" This will overwrite the existing SDD manifest.\n" + f" Note: SDD manifests are linked to specific bundle versions." + ) + if not is_non_interactive: + # In interactive mode, ask for confirmation + from rich.prompt import Confirm + + if not Confirm.ask("Overwrite existing SDD manifest?", default=False): + print_info("SDD manifest creation cancelled.") + raise typer.Exit(0) + # Extract from bundle, using existing SDD as fallback + if existing_sdd is None: + why = _extract_sdd_why(plan_bundle, is_non_interactive, None) + what = _extract_sdd_what(plan_bundle, is_non_interactive, None) + how = _extract_sdd_how(plan_bundle, is_non_interactive, None, project_bundle, bundle_dir) + else: + why = _extract_sdd_why(plan_bundle, is_non_interactive, existing_sdd.why) + what = _extract_sdd_what(plan_bundle, is_non_interactive, existing_sdd.what) + how = _extract_sdd_how( + plan_bundle, is_non_interactive, existing_sdd.how, project_bundle, bundle_dir + ) + else: + why = _extract_sdd_why(plan_bundle, is_non_interactive, None) + what = _extract_sdd_what(plan_bundle, is_non_interactive, None) + how = _extract_sdd_how(plan_bundle, is_non_interactive, None, project_bundle, bundle_dir) + except Exception: + # If we can't read/validate existing SDD, just proceed (might be corrupted) + existing_sdd = None + # Extract from bundle without fallback + why = _extract_sdd_why(plan_bundle, is_non_interactive, None) + what = _extract_sdd_what(plan_bundle, is_non_interactive, None) + how = _extract_sdd_how(plan_bundle, is_non_interactive, None, project_bundle, bundle_dir) + else: + # No existing SDD found, extract from bundle + why = _extract_sdd_why(plan_bundle, is_non_interactive, None) + what = _extract_sdd_what(plan_bundle, is_non_interactive, None) + how = _extract_sdd_how(plan_bundle, is_non_interactive, None, project_bundle, bundle_dir) + + # Type assertion: these variables are always set in valid code paths + # (typer.Exit exits the function, so those paths don't need these variables) + assert why is not None and what is not None and how is not None # type: ignore[unreachable] + + # Create SDD manifest + plan_bundle_id = project_hash[:16] # Use first 16 chars as ID + sdd_manifest = SDDManifest( + version="1.0.0", + plan_bundle_id=plan_bundle_id, + plan_bundle_hash=project_hash, + why=why, + what=what, + how=how, + coverage_thresholds=SDDCoverageThresholds( + contracts_per_story=1.0, + invariants_per_feature=1.0, + architecture_facets=3, + openapi_coverage_percent=80.0, + ), + enforcement_budget=SDDEnforcementBudget( + shadow_budget_seconds=300, + warn_budget_seconds=180, + block_budget_seconds=90, + ), + promotion_status="draft", # TODO: Add promotion status to ProjectBundle manifest + provenance={ + "source": "plan_harden", + "bundle_name": bundle, + "bundle_path": str(bundle_dir), + "created_by": "specfact_cli", + }, + ) + + # Save SDD manifest + sdd_path.parent.mkdir(parents=True, exist_ok=True) + sdd_data = sdd_manifest.model_dump(exclude_none=True) + dump_structured_file(sdd_data, sdd_path, effective_format) + + print_success(f"SDD manifest created: {sdd_path}") + + # Display summary + console.print("\n[bold]SDD Manifest Summary:[/bold]") + console.print(f"[bold]Project Bundle:[/bold] {bundle_dir}") + console.print(f"[bold]Bundle Hash:[/bold] {project_hash[:16]}...") + console.print(f"[bold]SDD Path:[/bold] {sdd_path}") + console.print("\n[bold]WHY (Intent):[/bold]") + console.print(f" {why.intent}") + if why.constraints: + console.print(f"[bold]Constraints:[/bold] {len(why.constraints)}") + console.print(f"\n[bold]WHAT (Capabilities):[/bold] {len(what.capabilities)}") + console.print("\n[bold]HOW (Architecture):[/bold]") + if how.architecture: + console.print(f" {how.architecture[:100]}...") + console.print(f"[bold]Invariants:[/bold] {len(how.invariants)}") + console.print(f"[bold]Contracts:[/bold] {len(how.contracts)}") + console.print(f"[bold]OpenAPI Contracts:[/bold] {len(how.openapi_contracts)}") + + record( + { + "bundle_name": bundle, + "bundle_path": str(bundle_dir), + "sdd_path": str(sdd_path), + "capabilities_count": len(what.capabilities), + "invariants_count": len(how.invariants), + } + ) + + except KeyboardInterrupt: + print_warning("SDD creation interrupted by user") + raise typer.Exit(0) from None + except Exception as e: + print_error(f"Failed to create SDD manifest: {e}") + raise typer.Exit(1) from e + + +@beartype +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") +def _extract_sdd_why(bundle: PlanBundle, is_non_interactive: bool, fallback: SDDWhy | None = None) -> SDDWhy: + """ + Extract WHY section from plan bundle. + + Args: + bundle: Plan bundle to extract from + is_non_interactive: Whether in non-interactive mode + + Returns: + SDDWhy instance + """ + from specfact_cli.models.sdd import SDDWhy + + intent = "" + constraints: list[str] = [] + target_users: str | None = None + value_hypothesis: str | None = None + + if bundle.idea: + intent = bundle.idea.narrative or bundle.idea.title or "" + constraints = bundle.idea.constraints or [] + if bundle.idea.target_users: + target_users = ", ".join(bundle.idea.target_users) + value_hypothesis = bundle.idea.value_hypothesis or None + + # Use fallback from existing SDD if available + if fallback: + if not intent: + intent = fallback.intent or "" + if not constraints: + constraints = fallback.constraints or [] + if not target_users: + target_users = fallback.target_users + if not value_hypothesis: + value_hypothesis = fallback.value_hypothesis + + # If intent is empty, prompt or use default + if not intent and not is_non_interactive: + intent = prompt_text("Primary intent/goal (WHY):", required=True) + elif not intent: + intent = "Extracted from plan bundle" + + return SDDWhy( + intent=intent, + constraints=constraints, + target_users=target_users, + value_hypothesis=value_hypothesis, + ) + + +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") +def _extract_sdd_what(bundle: PlanBundle, is_non_interactive: bool, fallback: SDDWhat | None = None) -> SDDWhat: + """ + Extract WHAT section from plan bundle. + + Args: + bundle: Plan bundle to extract from + is_non_interactive: Whether in non-interactive mode + + Returns: + SDDWhat instance + """ + from specfact_cli.models.sdd import SDDWhat + + capabilities: list[str] = [] + acceptance_criteria: list[str] = [] + out_of_scope: list[str] = [] + + # Extract capabilities from features + for feature in bundle.features: + if feature.title: + capabilities.append(feature.title) + # Collect acceptance criteria + acceptance_criteria.extend(feature.acceptance or []) + # Collect constraints that might indicate out-of-scope + for constraint in feature.constraints or []: + if "out of scope" in constraint.lower() or "not included" in constraint.lower(): + out_of_scope.append(constraint) + + # Use fallback from existing SDD if available + if fallback: + if not capabilities: + capabilities = fallback.capabilities or [] + if not acceptance_criteria: + acceptance_criteria = fallback.acceptance_criteria or [] + if not out_of_scope: + out_of_scope = fallback.out_of_scope or [] + + # If no capabilities, use default + if not capabilities: + if not is_non_interactive: + capabilities_input = prompt_text("Core capabilities (comma-separated):", required=True) + capabilities = [c.strip() for c in capabilities_input.split(",")] + else: + capabilities = ["Extracted from plan bundle"] + + return SDDWhat( + capabilities=capabilities, + acceptance_criteria=acceptance_criteria, + out_of_scope=out_of_scope, + ) + + +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") +def _extract_sdd_how( + bundle: PlanBundle, + is_non_interactive: bool, + fallback: SDDHow | None = None, + project_bundle: ProjectBundle | None = None, + bundle_dir: Path | None = None, +) -> SDDHow: + """ + Extract HOW section from plan bundle. + + Args: + bundle: Plan bundle to extract from + is_non_interactive: Whether in non-interactive mode + fallback: Optional fallback SDDHow to reuse values from + project_bundle: Optional ProjectBundle to extract OpenAPI contract references + bundle_dir: Optional bundle directory path for contract file validation + + Returns: + SDDHow instance + """ + from specfact_cli.models.contract import count_endpoints, load_openapi_contract, validate_openapi_schema + from specfact_cli.models.sdd import OpenAPIContractReference, SDDHow + + architecture: str | None = None + invariants: list[str] = [] + contracts: list[str] = [] + module_boundaries: list[str] = [] + + # Extract architecture from constraints + architecture_parts: list[str] = [] + for feature in bundle.features: + for constraint in feature.constraints or []: + if any(keyword in constraint.lower() for keyword in ["architecture", "design", "structure", "component"]): + architecture_parts.append(constraint) + + if architecture_parts: + architecture = " ".join(architecture_parts[:3]) # Limit to first 3 + + # Extract invariants from stories (acceptance criteria that are invariants) + for feature in bundle.features: + for story in feature.stories: + for acceptance in story.acceptance or []: + if any(keyword in acceptance.lower() for keyword in ["always", "never", "must", "invariant"]): + invariants.append(acceptance) + + # Extract contracts from story contracts + for feature in bundle.features: + for story in feature.stories: + if story.contracts: + contracts.append(f"{story.key}: {str(story.contracts)[:100]}") + + # Extract module boundaries from feature keys (as a simple heuristic) + module_boundaries = [f.key for f in bundle.features[:10]] # Limit to first 10 + + # Extract OpenAPI contract references from project bundle if available + openapi_contracts: list[OpenAPIContractReference] = [] + if project_bundle and bundle_dir: + for feature_index in project_bundle.manifest.features: + if feature_index.contract: + contract_path = bundle_dir / feature_index.contract + if contract_path.exists(): + try: + contract_data = load_openapi_contract(contract_path) + if validate_openapi_schema(contract_data): + endpoints_count = count_endpoints(contract_data) + openapi_contracts.append( + OpenAPIContractReference( + feature_key=feature_index.key, + contract_file=feature_index.contract, + endpoints_count=endpoints_count, + status="validated", + ) + ) + else: + # Contract exists but is invalid + openapi_contracts.append( + OpenAPIContractReference( + feature_key=feature_index.key, + contract_file=feature_index.contract, + endpoints_count=0, + status="draft", + ) + ) + except Exception: + # Contract file exists but couldn't be loaded + openapi_contracts.append( + OpenAPIContractReference( + feature_key=feature_index.key, + contract_file=feature_index.contract, + endpoints_count=0, + status="draft", + ) + ) + + # Use fallback from existing SDD if available + if fallback: + if not architecture: + architecture = fallback.architecture + if not invariants: + invariants = fallback.invariants or [] + if not contracts: + contracts = fallback.contracts or [] + if not module_boundaries: + module_boundaries = fallback.module_boundaries or [] + if not openapi_contracts: + openapi_contracts = fallback.openapi_contracts or [] + + # If no architecture, prompt or use default + if not architecture and not is_non_interactive: + # If we have a fallback, use it as default value in prompt + default_arch = fallback.architecture if fallback else None + if default_arch: + architecture = ( + prompt_text( + f"High-level architecture description (optional, current: {default_arch[:50]}...):", + required=False, + ) + or default_arch + ) + else: + architecture = prompt_text("High-level architecture description (optional):", required=False) or None + elif not architecture: + architecture = "Extracted from plan bundle constraints" + + return SDDHow( + architecture=architecture, + invariants=invariants[:10], # Limit to first 10 + contracts=contracts[:10], # Limit to first 10 + openapi_contracts=openapi_contracts, + module_boundaries=module_boundaries, + ) + + +@beartype +@require(lambda answer: isinstance(answer, str), "Answer must be string") +@ensure(lambda result: isinstance(result, list), "Must return list of criteria strings") +def _extract_specific_criteria_from_answer(answer: str) -> list[str]: + """ + Extract specific testable criteria from answer that contains replacement instructions. + + When answer contains "Replace generic 'works correctly' with testable criteria:", + extracts the specific criteria (items in single quotes) and returns them as a list. + + Args: + answer: Answer text that may contain replacement instructions + + Returns: + List of specific criteria strings, or empty list if no extraction possible + """ + import re + + # Check if answer contains replacement instructions + if "testable criteria:" not in answer.lower() and "replace generic" not in answer.lower(): + # Answer doesn't contain replacement format, return as single item + return [answer] if answer.strip() else [] + + # Find the position after "testable criteria:" to only extract criteria from that point + # This avoids extracting "works correctly" from the instruction text itself + testable_criteria_marker = "testable criteria:" + marker_pos = answer.lower().find(testable_criteria_marker) + + if marker_pos == -1: + # Fallback: try "with testable criteria:" + marker_pos = answer.lower().find("with testable criteria:") + if marker_pos != -1: + marker_pos += len("with testable criteria:") + + if marker_pos != -1: + # Only search for criteria after the marker + criteria_section = answer[marker_pos + len(testable_criteria_marker) :] + # Extract criteria (items in single quotes) + criteria_pattern = r"'([^']+)'" + matches = re.findall(criteria_pattern, criteria_section) + + if matches: + # Filter out "works correctly" if it appears (it's part of instruction, not a criterion) + filtered = [ + criterion.strip() + for criterion in matches + if criterion.strip() and criterion.strip().lower() not in ("works correctly", "works as expected") + ] + if filtered: + return filtered + + # Fallback: if no quoted criteria found, return original answer + return [answer] if answer.strip() else [] + + +@beartype +@require(lambda acceptance_list: isinstance(acceptance_list, list), "Acceptance list must be list") +@require(lambda finding: finding is not None, "Finding must not be None") +@ensure(lambda result: isinstance(result, list), "Must return list of acceptance strings") +def _identify_vague_criteria_to_remove( + acceptance_list: list[str], + finding: Any, # AmbiguityFinding +) -> list[str]: + """ + Identify vague acceptance criteria that should be removed when replacing with specific criteria. + + Args: + acceptance_list: Current list of acceptance criteria + finding: Ambiguity finding that triggered the question + + Returns: + List of vague criteria strings to remove + """ + from specfact_cli.utils.acceptance_criteria import ( + is_code_specific_criteria, + is_simplified_format_criteria, + ) + + vague_to_remove: list[str] = [] + + # Patterns that indicate vague criteria (from ambiguity scanner) + vague_patterns = [ + "is implemented", + "is functional", + "works", + "is done", + "is complete", + "is ready", + ] + + for acc in acceptance_list: + acc_lower = acc.lower() + + # Skip code-specific criteria (should not be removed) + if is_code_specific_criteria(acc): + continue + + # Skip simplified format criteria (valid format) + if is_simplified_format_criteria(acc): + continue + + # ALWAYS remove replacement instruction text (from previous answers) + # These are meta-instructions, not actual acceptance criteria + contains_replacement_instruction = ( + "replace generic" in acc_lower + or ("should be more specific" in acc_lower and "testable criteria:" in acc_lower) + or ("yes, these should be more specific" in acc_lower) + ) + + if contains_replacement_instruction: + vague_to_remove.append(acc) + continue + + # Check for vague patterns (but be more selective) + # Only flag as vague if it contains "works correctly" without "see contract examples" + # or other vague patterns in a standalone context + is_vague = False + if "works correctly" in acc_lower: + # Only remove if it doesn't have "see contract examples" (simplified format is valid) + if "see contract" not in acc_lower and "contract examples" not in acc_lower: + is_vague = True + else: + # Check other vague patterns + is_vague = any( + pattern in acc_lower and len(acc.split()) < 10 # Only flag short, vague statements + for pattern in vague_patterns + ) + + if is_vague: + vague_to_remove.append(acc) + + return vague_to_remove + + +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda answer: isinstance(answer, str) and bool(answer.strip()), "Answer must be non-empty string") +@ensure(lambda result: isinstance(result, list), "Must return list of integration points") +def _integrate_clarification( + bundle: PlanBundle, + finding: AmbiguityFinding, + answer: str, +) -> list[str]: + """ + Integrate clarification answer into plan bundle. + + Args: + bundle: Plan bundle to update + finding: Ambiguity finding with related sections + answer: User-provided answer + + Returns: + List of integration points (section paths) + """ + from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory + + integration_points: list[str] = [] + + category = finding.category + + # Functional Scope → idea.narrative, idea.target_users, features[].outcomes + if category == TaxonomyCategory.FUNCTIONAL_SCOPE: + related_sections = finding.related_sections or [] + if ( + "idea.narrative" in related_sections + and bundle.idea + and (not bundle.idea.narrative or len(bundle.idea.narrative) < 20) + ): + bundle.idea.narrative = answer + integration_points.append("idea.narrative") + elif "idea.target_users" in related_sections and bundle.idea: + if bundle.idea.target_users is None: + bundle.idea.target_users = [] + if answer not in bundle.idea.target_users: + bundle.idea.target_users.append(answer) + integration_points.append("idea.target_users") + else: + # Try to find feature by related section + for section in related_sections: + if section.startswith("features.") and ".outcomes" in section: + feature_key = section.split(".")[1] + for feature in bundle.features: + if feature.key == feature_key: + if answer not in feature.outcomes: + feature.outcomes.append(answer) + integration_points.append(section) + break + + # Data Model, Integration, Constraints → features[].constraints + elif category in ( + TaxonomyCategory.DATA_MODEL, + TaxonomyCategory.INTEGRATION, + TaxonomyCategory.CONSTRAINTS, + ): + related_sections = finding.related_sections or [] + for section in related_sections: + if section.startswith("features.") and ".constraints" in section: + feature_key = section.split(".")[1] + for feature in bundle.features: + if feature.key == feature_key: + if answer not in feature.constraints: + feature.constraints.append(answer) + integration_points.append(section) + break + elif section == "idea.constraints" and bundle.idea: + if bundle.idea.constraints is None: + bundle.idea.constraints = [] + if answer not in bundle.idea.constraints: + bundle.idea.constraints.append(answer) + integration_points.append(section) + + # Edge Cases, Completion Signals, Interaction & UX Flow → features[].acceptance, stories[].acceptance + elif category in ( + TaxonomyCategory.EDGE_CASES, + TaxonomyCategory.COMPLETION_SIGNALS, + TaxonomyCategory.INTERACTION_UX, + ): + related_sections = finding.related_sections or [] + for section in related_sections: + if section.startswith("features."): + parts = section.split(".") + if len(parts) >= 3: + feature_key = parts[1] + if parts[2] == "acceptance": + for feature in bundle.features: + if feature.key == feature_key: + # Extract specific criteria from answer + specific_criteria = _extract_specific_criteria_from_answer(answer) + # Identify and remove vague criteria + vague_to_remove = _identify_vague_criteria_to_remove(feature.acceptance, finding) + # Remove vague criteria + for vague in vague_to_remove: + if vague in feature.acceptance: + feature.acceptance.remove(vague) + # Add new specific criteria + for criterion in specific_criteria: + if criterion not in feature.acceptance: + feature.acceptance.append(criterion) + if specific_criteria: + integration_points.append(section) + break + elif parts[2] == "stories" and len(parts) >= 5: + story_key = parts[3] + if parts[4] == "acceptance": + for feature in bundle.features: + if feature.key == feature_key: + for story in feature.stories: + if story.key == story_key: + # Extract specific criteria from answer + specific_criteria = _extract_specific_criteria_from_answer(answer) + # Identify and remove vague criteria + vague_to_remove = _identify_vague_criteria_to_remove( + story.acceptance, finding + ) + # Remove vague criteria + for vague in vague_to_remove: + if vague in story.acceptance: + story.acceptance.remove(vague) + # Add new specific criteria + for criterion in specific_criteria: + if criterion not in story.acceptance: + story.acceptance.append(criterion) + if specific_criteria: + integration_points.append(section) + break + break + + # Feature Completeness → features[].stories, features[].acceptance + elif category == TaxonomyCategory.FEATURE_COMPLETENESS: + related_sections = finding.related_sections or [] + for section in related_sections: + if section.startswith("features."): + parts = section.split(".") + if len(parts) >= 3: + feature_key = parts[1] + if parts[2] == "stories": + # This would require creating a new story - skip for now + # (stories should be added via add-story command) + pass + elif parts[2] == "acceptance": + for feature in bundle.features: + if feature.key == feature_key: + if answer not in feature.acceptance: + feature.acceptance.append(answer) + integration_points.append(section) + break + + # Non-Functional → idea.constraints (with quantification) + elif ( + category == TaxonomyCategory.NON_FUNCTIONAL + and finding.related_sections + and "idea.constraints" in finding.related_sections + and bundle.idea + ): + if bundle.idea.constraints is None: + bundle.idea.constraints = [] + if answer not in bundle.idea.constraints: + # Try to quantify vague terms + quantified_answer = answer + bundle.idea.constraints.append(quantified_answer) + integration_points.append("idea.constraints") + + return integration_points + + +@beartype +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda finding: finding is not None, "Finding must not be None") +def _show_current_settings_for_finding( + bundle: PlanBundle, + finding: Any, # AmbiguityFinding (imported locally to avoid circular dependency) + console_instance: Any | None = None, # Console (imported locally, optional) +) -> str | None: + """ + Show current settings for related sections before asking a question. + + Displays current values for target_users, constraints, outcomes, acceptance criteria, + and narrative so users can confirm or modify them. + + Args: + bundle: Plan bundle to inspect + finding: Ambiguity finding with related sections + console_instance: Rich console instance (defaults to module console) + + Returns: + Default value string to use in prompt (or None if no current value) + """ + from rich.console import Console + + console = console_instance or Console() + + related_sections = finding.related_sections or [] + if not related_sections: + return None + + # Only show high-level plan attributes (idea-level), not individual features/stories + # Only show where there are findings to fix + current_values: dict[str, list[str] | str] = {} + default_value: str | None = None + + for section in related_sections: + # Only handle idea-level sections (high-level plan attributes) + if section == "idea.narrative" and bundle.idea and bundle.idea.narrative: + narrative_preview = ( + bundle.idea.narrative[:100] + "..." if len(bundle.idea.narrative) > 100 else bundle.idea.narrative + ) + current_values["Idea Narrative"] = narrative_preview + # Use full narrative as default (truncated for display only) + default_value = bundle.idea.narrative + + elif section == "idea.target_users" and bundle.idea and bundle.idea.target_users: + current_values["Target Users"] = bundle.idea.target_users + # Use comma-separated list as default + if not default_value: + default_value = ", ".join(bundle.idea.target_users) + + elif section == "idea.constraints" and bundle.idea and bundle.idea.constraints: + current_values["Idea Constraints"] = bundle.idea.constraints + # Use comma-separated list as default + if not default_value: + default_value = ", ".join(bundle.idea.constraints) + + # For Completion Signals questions, also extract story acceptance criteria + # (these are the specific values we're asking about) + elif section.startswith("features.") and ".stories." in section and ".acceptance" in section: + parts = section.split(".") + if len(parts) >= 5: + feature_key = parts[1] + story_key = parts[3] + feature = next((f for f in bundle.features if f.key == feature_key), None) + if feature: + story = next((s for s in feature.stories if s.key == story_key), None) + if story and story.acceptance: + # Show current acceptance criteria as default (for confirming or modifying) + acceptance_str = ", ".join(story.acceptance) + current_values[f"Story {story_key} Acceptance"] = story.acceptance + # Use first acceptance criteria as default (or all if short) + if not default_value: + default_value = acceptance_str if len(acceptance_str) <= 200 else story.acceptance[0] + + # Skip other feature/story-level sections - only show high-level plan attributes + # Other features and stories are handled through their specific questions + + # Display current values if any (only high-level attributes) + if current_values: + console.print("\n[dim]Current Plan Settings:[/dim]") + for key, value in current_values.items(): + if isinstance(value, list): + value_str = ", ".join(str(v) for v in value) if value else "(none)" + else: + value_str = str(value) + console.print(f" [cyan]{key}:[/cyan] {value_str}") + console.print("[dim]Press Enter to confirm current value, or type a new value[/dim]") + + return default_value + + +@beartype +@require(lambda finding: finding is not None, "Finding must not be None") +@require(lambda bundle: isinstance(bundle, PlanBundle), "Bundle must be PlanBundle") +@require(lambda is_non_interactive: isinstance(is_non_interactive, bool), "Is non-interactive must be bool") +@ensure(lambda result: isinstance(result, str) and bool(result.strip()), "Must return non-empty string") +def _get_smart_answer( + finding: Any, # AmbiguityFinding (imported locally) + bundle: PlanBundle, + is_non_interactive: bool, + default_value: str | None = None, +) -> str: + """ + Get answer from user with smart Yes/No handling. + + For Completion Signals questions asking "Should these be more specific?", + if user answers "Yes", prompts for the actual specific criteria. + If "No", marks as acceptable and returns appropriate response. + + Args: + finding: Ambiguity finding with question + bundle: Plan bundle (for context) + is_non_interactive: Whether in non-interactive mode + default_value: Default value to show in prompt (for confirming existing value) + + Returns: + User answer (processed if Yes/No detected) + """ + from rich.console import Console + + from specfact_cli.analyzers.ambiguity_scanner import TaxonomyCategory + + console = Console() + + # Build prompt message with default hint + if default_value: + # Truncate default for display if too long + default_display = default_value[:60] + "..." if len(default_value) > 60 else default_value + prompt_msg = f"Your answer (press Enter to confirm, or type new value/Yes/No): [{default_display}]" + else: + prompt_msg = "Your answer (<=5 words recommended, or Yes/No):" + + # Get initial answer (not required if default exists - user can press Enter) + # When default exists, allow empty answer (Enter) to confirm + answer = prompt_text(prompt_msg, default=default_value, required=not default_value) + + # If user pressed Enter with default, return the default value (confirm existing) + if not answer.strip() and default_value: + return default_value + + # Normalize Yes/No answers + answer_lower = answer.strip().lower() + is_yes = answer_lower in ("yes", "y", "true", "1") + is_no = answer_lower in ("no", "n", "false", "0") + + # Handle Completion Signals questions about specificity + if ( + finding.category == TaxonomyCategory.COMPLETION_SIGNALS + and "should these be more specific" in finding.question.lower() + ): + if is_yes: + # User wants to make it more specific - prompt for actual criteria + console.print("\n[yellow]Please provide the specific acceptance criteria:[/yellow]") + return prompt_text("Specific criteria:", required=True) + if is_no: + # User says no - mark as acceptable, return a note that it's acceptable as-is + return "Acceptable as-is (details in OpenAPI contracts)" + # Otherwise, return the original answer (might be a specific criteria already) + return answer + + # Handle other Yes/No questions intelligently + # For questions asking if something should be done/added + if (is_yes or is_no) and ("should" in finding.question.lower() or "need" in finding.question.lower()): + if is_yes: + # Prompt for what should be added + console.print("\n[yellow]What should be added?[/yellow]") + return prompt_text("Details:", required=True) + if is_no: + return "Not needed" + + # Return original answer if not a Yes/No or if Yes/No handling didn't apply + return answer diff --git a/src/specfact_cli/modules/project/src/__init__.py b/src/specfact_cli/modules/project/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/project/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/project/src/app.py b/src/specfact_cli/modules/project/src/app.py index b9b8977b..546d842f 100644 --- a/src/specfact_cli/modules/project/src/app.py +++ b/src/specfact_cli/modules/project/src/app.py @@ -1,6 +1,6 @@ -"""Project command: re-export from commands package.""" +"""project command entrypoint.""" -from specfact_cli.commands.project_cmd import app +from specfact_cli.modules.project.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/project/src/commands.py b/src/specfact_cli/modules/project/src/commands.py new file mode 100644 index 00000000..479396b7 --- /dev/null +++ b/src/specfact_cli/modules/project/src/commands.py @@ -0,0 +1,1836 @@ +""" +Project command - Persona workflows and bundle management. + +This module provides commands for persona-based editing, lock enforcement, +and merge conflict resolution for project bundles. +""" + +from __future__ import annotations + +import os +from contextlib import suppress +from datetime import UTC, datetime +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.table import Table + +from specfact_cli.models.project import ( + BundleManifest, + PersonaMapping, + ProjectBundle, + ProjectMetadata, + SectionLock, +) +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.utils import print_error, print_info, print_section, print_success, print_warning +from specfact_cli.utils.persona_ownership import ( + check_persona_ownership as shared_check_persona_ownership, + match_section_pattern as shared_match_section_pattern, +) +from specfact_cli.utils.progress import load_bundle_with_progress, save_bundle_with_progress +from specfact_cli.utils.structure import SpecFactStructure +from specfact_cli.versioning import ChangeAnalyzer, bump_version, validate_semver + + +app = typer.Typer(help="Manage project bundles with persona workflows") +version_app = typer.Typer(help="Manage project bundle versions") +app.add_typer(version_app, name="version") +console = Console() + + +# Use shared progress utilities for consistency (aliased to maintain existing function names) +def _load_bundle_with_progress(bundle_dir: Path, validate_hashes: bool = False) -> ProjectBundle: + """Load project bundle with unified progress display.""" + return load_bundle_with_progress(bundle_dir, validate_hashes=validate_hashes, console_instance=console) + + +def _save_bundle_with_progress(bundle: ProjectBundle, bundle_dir: Path, atomic: bool = True) -> None: + """Save project bundle with unified progress display.""" + save_bundle_with_progress(bundle, bundle_dir, atomic=atomic, console_instance=console) + + +# Default persona mappings +DEFAULT_PERSONAS: dict[str, PersonaMapping] = { + "product-owner": PersonaMapping( + owns=["idea", "business", "features.*.stories", "features.*.outcomes"], + exports_to="specs/*/spec.md", + ), + "architect": PersonaMapping( + owns=["features.*.constraints", "protocols", "contracts"], + exports_to="specs/*/plan.md", + ), + "developer": PersonaMapping( + owns=["features.*.acceptance", "features.*.implementation"], + exports_to="specs/*/tasks.md", + ), +} + +# Version bump severity ordering (for recommendations) +BUMP_SEVERITY = {"none": 0, "patch": 1, "minor": 2, "major": 3} + + +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: isinstance(result, tuple) and len(result) == 2, "Must return (bundle_name, bundle_dir)") +def _resolve_bundle(repo: Path, bundle: str | None) -> tuple[str, Path]: + """ + Resolve bundle name and directory, falling back to active bundle. + + Args: + repo: Repository path + bundle: Optional bundle name + + Returns: + Tuple of (bundle_name, bundle_dir) + """ + bundle_name = bundle or SpecFactStructure.get_active_bundle_name(repo) + if bundle_name is None: + print_error("Bundle not specified and no active bundle found. Use --bundle or set active bundle in config.") + raise typer.Exit(1) + + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle_name) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + return bundle_name, bundle_dir + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") +@require(lambda persona: isinstance(persona, str), "Persona must be str") +@require(lambda no_interactive: isinstance(no_interactive, bool), "No interactive must be bool") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def _initialize_persona_if_needed(bundle: ProjectBundle, persona: str, no_interactive: bool) -> bool: + """ + Initialize persona in bundle manifest if missing and available in defaults. + + Args: + bundle: Project bundle to update + persona: Persona name to initialize + no_interactive: If True, auto-initialize without prompting + + Returns: + True if persona was initialized, False otherwise + """ + # Check if persona already exists + if persona in bundle.manifest.personas: + return False + + # Check if persona is in default personas + if persona not in DEFAULT_PERSONAS: + return False + + # Initialize persona + if no_interactive: + # Auto-initialize in non-interactive mode + bundle.manifest.personas[persona] = DEFAULT_PERSONAS[persona] + print_success(f"Initialized persona '{persona}' in bundle manifest") + return True + # Interactive mode: ask user + from rich.prompt import Confirm + + print_info(f"Persona '{persona}' not found in bundle manifest.") + print_info(f"Would you like to initialize '{persona}' with default settings?") + if Confirm.ask("Initialize persona?", default=True): + bundle.manifest.personas[persona] = DEFAULT_PERSONAS[persona] + print_success(f"Initialized persona '{persona}' in bundle manifest") + return True + + return False + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") +@require(lambda no_interactive: isinstance(no_interactive, bool), "No interactive must be bool") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def _initialize_all_default_personas(bundle: ProjectBundle, no_interactive: bool) -> bool: + """ + Initialize all default personas in bundle manifest if missing. + + Args: + bundle: Project bundle to update + no_interactive: If True, auto-initialize without prompting + + Returns: + True if any personas were initialized, False otherwise + """ + # Find missing default personas + missing_personas = {k: v for k, v in DEFAULT_PERSONAS.items() if k not in bundle.manifest.personas} + + if not missing_personas: + return False + + if no_interactive: + # Auto-initialize all missing personas + bundle.manifest.personas.update(missing_personas) + print_success(f"Initialized {len(missing_personas)} default persona(s) in bundle manifest") + return True + # Interactive mode: ask user + from rich.prompt import Confirm + + console.print() # Empty line + print_info(f"Found {len(missing_personas)} default persona(s) not in bundle:") + for p_name in missing_personas: + print_info(f" - {p_name}") + console.print() # Empty line + if Confirm.ask("Initialize all default personas?", default=True): + bundle.manifest.personas.update(missing_personas) + print_success(f"Initialized {len(missing_personas)} default persona(s) in bundle manifest") + return True + + return False + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") +@require(lambda bundle_name: isinstance(bundle_name, str), "Bundle name must be str") +@ensure(lambda result: result is None, "Must return None") +def _list_available_personas(bundle: ProjectBundle, bundle_name: str) -> None: + """ + List all available personas (both in bundle and default personas). + + Args: + bundle: Project bundle to check + bundle_name: Name of the bundle (for display) + """ + console.print(f"\n[bold cyan]Available Personas for bundle '{bundle_name}'[/bold cyan]") + console.print("=" * 60) + + # Show personas in bundle + available_personas = list(bundle.manifest.personas.keys()) + if available_personas: + console.print("\n[bold green]Personas in bundle:[/bold green]") + for p in available_personas: + persona_mapping = bundle.manifest.personas[p] + owns_preview = ", ".join(persona_mapping.owns[:3]) + if len(persona_mapping.owns) > 3: + owns_preview += "..." + console.print(f" [green]✓[/green] {p}: owns {owns_preview}") + else: + console.print("\n[yellow]No personas defined in bundle manifest.[/yellow]") + + # Show default personas + console.print("\n[bold cyan]Default personas available:[/bold cyan]") + for p_name, p_mapping in DEFAULT_PERSONAS.items(): + status = "[green]✓[/green]" if p_name in bundle.manifest.personas else "[dim]○[/dim]" + owns_preview = ", ".join(p_mapping.owns[:3]) + if len(p_mapping.owns) > 3: + owns_preview += "..." + console.print(f" {status} {p_name}: owns {owns_preview}") + + console.print("\n[dim]To add personas, use:[/dim]") + console.print("[dim] specfact project init-personas --bundle <name>[/dim]") + console.print("[dim] specfact project init-personas --bundle <name> --persona <name>[/dim]") + console.print() + + +@beartype +@require(lambda section_pattern: isinstance(section_pattern, str), "Section pattern must be str") +@require(lambda path: isinstance(path, str), "Path must be str") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def match_section_pattern(section_pattern: str, path: str) -> bool: + """ + Check if a path matches a section pattern. + + Args: + section_pattern: Pattern (e.g., "idea", "features.*.stories", "contracts") + path: Path to check (e.g., "idea", "features/FEATURE-001/stories/STORY-001") + + Returns: + True if path matches pattern, False otherwise + + Examples: + >>> match_section_pattern("idea", "idea") + True + >>> match_section_pattern("features.*.stories", "features/FEATURE-001/stories/STORY-001") + True + >>> match_section_pattern("contracts", "contracts/FEATURE-001.openapi.yaml") + True + """ + return shared_match_section_pattern(section_pattern, path) + + +@beartype +@require(lambda persona: isinstance(persona, str), "Persona must be str") +@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") +@require(lambda section_path: isinstance(section_path, str), "Section path must be str") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def check_persona_ownership(persona: str, manifest: BundleManifest, section_path: str) -> bool: + """ + Check if persona owns a section. + + Args: + persona: Persona name (e.g., "product-owner", "architect") + manifest: Bundle manifest with persona mappings + section_path: Section path to check (e.g., "idea", "features/FEATURE-001/stories") + + Returns: + True if persona owns section, False otherwise + """ + return shared_check_persona_ownership(persona, manifest, section_path) + + +@beartype +@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") +@require(lambda section_path: isinstance(section_path, str), "Section path must be str") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def check_section_locked(manifest: BundleManifest, section_path: str) -> bool: + """ + Check if a section is locked. + + Args: + manifest: Bundle manifest with locks + section_path: Section path to check + + Returns: + True if section is locked, False otherwise + """ + return any(match_section_pattern(lock.section, section_path) for lock in manifest.locks) + + +@beartype +@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") +@require(lambda section_paths: isinstance(section_paths, list), "Section paths must be list") +@require(lambda persona: isinstance(persona, str), "Persona must be str") +@ensure(lambda result: isinstance(result, tuple), "Must return tuple") +def check_sections_locked_for_persona( + manifest: BundleManifest, section_paths: list[str], persona: str +) -> tuple[bool, list[str], str | None]: + """ + Check if any sections are locked and if persona can edit them. + + Args: + manifest: Bundle manifest with locks + section_paths: List of section paths to check + persona: Persona attempting to edit + + Returns: + Tuple of (is_locked, locked_sections, lock_owner) + - is_locked: True if any section is locked + - locked_sections: List of locked section paths + - lock_owner: Owner persona of the lock (if locked and not owned by persona) + """ + locked_sections: list[str] = [] + lock_owner: str | None = None + + for section_path in section_paths: + for lock in manifest.locks: + if match_section_pattern(lock.section, section_path): + locked_sections.append(section_path) + # If locked by a different persona, record the owner + if lock.owner != persona: + lock_owner = lock.owner + break + + return (len(locked_sections) > 0, locked_sections, lock_owner) + + +@app.command("export") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def export_persona( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + persona: str | None = typer.Option( + None, + "--persona", + help="Persona name (e.g., product-owner, architect). Use --list-personas to see available personas.", + ), + # Output/Results + output: Path | None = typer.Option( + None, + "--output", + "--out", + help="Output file path (default: docs/project-plans/<bundle>/<persona>.md or stdout with --stdout)", + ), + output_dir: Path | None = typer.Option( + None, + "--output-dir", + help="Output directory for Markdown file (default: docs/project-plans/<bundle>)", + ), + # Behavior/Options + stdout: bool = typer.Option( + False, + "--stdout", + help="Output to stdout instead of file (for piping/CI usage)", + ), + template: str | None = typer.Option( + None, + "--template", + help="Custom template name (default: uses persona-specific template)", + ), + list_personas: bool = typer.Option( + False, + "--list-personas", + help="List all available personas and exit", + ), + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Export persona-owned sections from project bundle to Markdown. + + Generates well-structured Markdown artifacts using templates, filtered by + persona ownership. Perfect for AI IDEs and manual editing workflows. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --persona + - **Output/Results**: --output, --output-dir, --stdout + - **Behavior/Options**: --template, --no-interactive + + **Examples:** + specfact project export --bundle legacy-api --persona product-owner + specfact project export --bundle legacy-api --persona architect --output-dir docs/plans + specfact project export --bundle legacy-api --persona developer --stdout + """ + if is_debug_mode(): + debug_log_operation( + "command", + "project export", + "started", + extra={"repo": str(repo), "bundle": bundle, "persona": persona}, + ) + debug_print("[dim]project export: started[/dim]") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + if is_debug_mode(): + debug_log_operation( + "command", + "project export", + "failed", + error="No project bundles found", + extra={"reason": "no_bundles"}, + ) + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + if is_debug_mode(): + debug_log_operation( + "command", + "project export", + "failed", + error=f"Project bundle not found: {bundle_dir}", + extra={"reason": "bundle_not_found", "bundle": bundle}, + ) + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Handle --list-personas flag or missing --persona + if list_personas or persona is None: + _list_available_personas(bundle_obj, bundle) + raise typer.Exit(0) + + # Check persona exists, try to initialize if missing + if persona not in bundle_obj.manifest.personas: + # Try to initialize the requested persona + persona_initialized = _initialize_persona_if_needed(bundle_obj, persona, no_interactive) + + if persona_initialized: + # Save bundle with new persona + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + else: + # Persona not available in defaults or user declined + print_error(f"Persona '{persona}' not found in bundle manifest") + console.print() # Empty line + + # Always show available personas in bundle + available_personas = list(bundle_obj.manifest.personas.keys()) + if available_personas: + print_info("Available personas in bundle:") + for p in available_personas: + print_info(f" - {p}") + else: + print_info("No personas defined in bundle manifest.") + + console.print() # Empty line + + # Always show default personas (even if some are already in bundle) + print_info("Default personas available:") + for p_name, p_mapping in DEFAULT_PERSONAS.items(): + status = "[green]✓[/green]" if p_name in bundle_obj.manifest.personas else "[dim]○[/dim]" + owns_preview = ", ".join(p_mapping.owns[:3]) + if len(p_mapping.owns) > 3: + owns_preview += "..." + print_info(f" {status} {p_name}: owns {owns_preview}") + + console.print() # Empty line + + # Offer to initialize all default personas if none are defined + if not available_personas and not no_interactive: + all_initialized = _initialize_all_default_personas(bundle_obj, no_interactive) + if all_initialized: + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + # Retry with the newly initialized persona + if persona in bundle_obj.manifest.personas: + persona_initialized = True + + if not persona_initialized: + print_info("To add personas, use:") + print_info(" specfact project init-personas --bundle <name>") + print_info(" specfact project init-personas --bundle <name> --persona <name>") + raise typer.Exit(1) + + # Get persona mapping + persona_mapping = bundle_obj.manifest.personas[persona] + + # Initialize exporter with template support + from specfact_cli.generators.persona_exporter import PersonaExporter + + # Check for project-specific templates + project_templates_dir = repo / ".specfact" / "templates" / "persona" + project_templates_dir = project_templates_dir if project_templates_dir.exists() else None + + exporter = PersonaExporter(project_templates_dir=project_templates_dir) + + # Determine output path + if stdout: + # Export to stdout + markdown_content = exporter.export_to_string(bundle_obj, persona_mapping, persona) + console.print(markdown_content) + else: + # Determine output file path + if output: + output_path = Path(output) + elif output_dir: + output_path = Path(output_dir) / f"{persona}.md" + else: + # Default: docs/project-plans/<bundle>/<persona>.md + default_dir = repo / "docs" / "project-plans" / bundle + output_path = default_dir / f"{persona}.md" + + # Export to file with progress + from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task(f"[cyan]Exporting persona '{persona}' to Markdown...", total=None) + try: + exporter.export_to_file(bundle_obj, persona_mapping, persona, output_path) + progress.update(task, description=f"[green]✓[/green] Exported to {output_path}") + except Exception as e: + progress.update(task, description="[red]✗[/red] Export failed") + print_error(f"Export failed: {e}") + raise typer.Exit(1) from e + + if is_debug_mode(): + debug_log_operation( + "command", + "project export", + "success", + extra={"bundle": bundle, "persona": persona, "output_path": str(output_path)}, + ) + debug_print("[dim]project export: success[/dim]") + print_success(f"Exported persona '{persona}' sections to {output_path}") + print_info(f"Template: {persona}.md.j2") + + +@app.command("import") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def import_persona( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + persona: str | None = typer.Option( + None, + "--persona", + help="Persona name (e.g., product-owner, architect). Use --list-personas to see available personas.", + ), + # Input + input_file: Path = typer.Option( + ..., + "--input", + "--file", + "-i", + help="Path to Markdown file to import", + exists=True, + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), + dry_run: bool = typer.Option( + False, + "--dry-run", + help="Validate import without applying changes", + ), +) -> None: + """ + Import persona-edited Markdown file back into project bundle. + + Validates Markdown structure against template schema, checks ownership, + and transforms Markdown content back to YAML bundle format. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --persona, --input + - **Behavior/Options**: --dry-run, --no-interactive + + **Examples:** + specfact project import --bundle legacy-api --persona product-owner --input product-owner.md + specfact project import --bundle legacy-api --persona architect --input architect.md --dry-run + """ + if is_debug_mode(): + debug_log_operation( + "command", + "project import", + "started", + extra={"repo": str(repo), "bundle": bundle, "persona": persona, "input_file": str(input_file)}, + ) + debug_print("[dim]project import: started[/dim]") + + from specfact_cli.models.persona_template import PersonaTemplate, SectionType, TemplateSection + from specfact_cli.parsers.persona_importer import PersonaImporter, PersonaImportError + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Handle --list-personas flag or missing --persona + if persona is None: + _list_available_personas(bundle_obj, bundle) + raise typer.Exit(0) + + # Check persona exists + if persona not in bundle_obj.manifest.personas: + print_error(f"Persona '{persona}' not found in bundle manifest") + _list_available_personas(bundle_obj, bundle) + raise typer.Exit(1) + + persona_mapping = bundle_obj.manifest.personas[persona] + + # Create template (simplified - in production would load from file) + # For now, create a basic template based on persona + template_sections = [ + TemplateSection( + name="idea_business_context", + heading="## Idea & Business Context", + type=SectionType.REQUIRED + if "idea" in " ".join(persona_mapping.owns) or "business" in " ".join(persona_mapping.owns) + else SectionType.OPTIONAL, + description="Problem statement, solution vision, and business context", + order=1, + validation=None, + placeholder=None, + condition=None, + ), + TemplateSection( + name="features", + heading="## Features & User Stories", + type=SectionType.REQUIRED if any("features" in o for o in persona_mapping.owns) else SectionType.OPTIONAL, + description="Features and user stories", + order=2, + validation=None, + placeholder=None, + condition=None, + ), + ] + template = PersonaTemplate( + persona_name=persona, + version="1.0.0", + description=f"Template for {persona} persona", + sections=template_sections, + ) + + # Initialize importer + # Disable agile validation in test mode to allow simpler test scenarios + validate_agile = os.environ.get("TEST_MODE") != "true" + importer = PersonaImporter(template, validate_agile=validate_agile) + + # Import with progress + from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task(f"[cyan]Validating and importing '{input_file.name}'...", total=None) + + try: + if dry_run: + # Just validate without importing + markdown_content = input_file.read_text(encoding="utf-8") + sections = importer.parse_markdown(markdown_content) + validation_errors = importer.validate_structure(sections) + + if validation_errors: + progress.update(task, description="[red]✗[/red] Validation failed") + print_error("Template validation failed:") + for error in validation_errors: + print_error(f" - {error}") + raise typer.Exit(1) + progress.update(task, description="[green]✓[/green] Validation passed") + print_success("Import validation passed (dry-run)") + else: + # Check locks before importing + # Determine which sections will be modified based on persona ownership + sections_to_modify = list(persona_mapping.owns) + + is_locked, locked_sections, lock_owner = check_sections_locked_for_persona( + bundle_obj.manifest, sections_to_modify, persona + ) + + # Only block if locked by a different persona + if is_locked and lock_owner is not None and lock_owner != persona: + progress.update(task, description="[red]✗[/red] Import blocked by locks") + print_error("Cannot import: Section(s) are locked") + for locked_section in locked_sections: + # Find the lock for this section + for lock in bundle_obj.manifest.locks: + if match_section_pattern(lock.section, locked_section): + # Only report if locked by different persona + if lock.owner != persona: + print_error( + f" - Section '{locked_section}' is locked by '{lock.owner}' " + f"(locked at {lock.locked_at})" + ) + break + print_info("Use 'specfact project unlock --section <section>' to unlock, or contact the lock owner") + raise typer.Exit(1) + + # Import and update bundle + updated_bundle = importer.import_from_file(input_file, bundle_obj, persona_mapping, persona) + progress.update(task, description="[green]✓[/green] Import complete") + + # Save updated bundle + _save_bundle_with_progress(updated_bundle, bundle_dir, atomic=True) + print_success(f"Imported persona '{persona}' edits from {input_file}") + + except PersonaImportError as e: + progress.update(task, description="[red]✗[/red] Import failed") + print_error(f"Import failed: {e}") + raise typer.Exit(1) from e + except Exception as e: + progress.update(task, description="[red]✗[/red] Import failed") + print_error(f"Unexpected error during import: {e}") + raise typer.Exit(1) from e + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "Bundle must be ProjectBundle") +@require(lambda persona_mapping: isinstance(persona_mapping, PersonaMapping), "Persona mapping must be PersonaMapping") +@ensure(lambda result: isinstance(result, dict), "Must return dict") +def _filter_bundle_by_persona(bundle: ProjectBundle, persona_mapping: PersonaMapping) -> dict[str, Any]: + """ + Filter bundle to include only persona-owned sections. + + Args: + bundle: Project bundle to filter + persona_mapping: Persona mapping with owned sections + + Returns: + Filtered bundle dictionary + """ + filtered: dict[str, Any] = { + "bundle_name": bundle.bundle_name, + "manifest": bundle.manifest.model_dump(), + } + + # Filter aspects by persona ownership + if bundle.idea and any(match_section_pattern(p, "idea") for p in persona_mapping.owns): + filtered["idea"] = bundle.idea.model_dump() + + if bundle.business and any(match_section_pattern(p, "business") for p in persona_mapping.owns): + filtered["business"] = bundle.business.model_dump() + + if any(match_section_pattern(p, "product") for p in persona_mapping.owns): + filtered["product"] = bundle.product.model_dump() + + # Filter features by persona ownership + filtered_features: dict[str, Any] = {} + for feature_key, feature in bundle.features.items(): + feature_dict = feature.model_dump() + filtered_feature: dict[str, Any] = {"key": feature.key, "title": feature.title} + + # Filter stories if persona owns stories + if any(match_section_pattern(p, "features.*.stories") for p in persona_mapping.owns): + filtered_feature["stories"] = feature_dict.get("stories", []) + + # Filter outcomes if persona owns outcomes + if any(match_section_pattern(p, "features.*.outcomes") for p in persona_mapping.owns): + filtered_feature["outcomes"] = feature_dict.get("outcomes", []) + + # Filter constraints if persona owns constraints + if any(match_section_pattern(p, "features.*.constraints") for p in persona_mapping.owns): + filtered_feature["constraints"] = feature_dict.get("constraints", []) + + # Filter acceptance if persona owns acceptance + if any(match_section_pattern(p, "features.*.acceptance") for p in persona_mapping.owns): + filtered_feature["acceptance"] = feature_dict.get("acceptance", []) + + if filtered_feature: + filtered_features[feature_key] = filtered_feature + + if filtered_features: + filtered["features"] = filtered_features + + return filtered + + +@beartype +@require(lambda bundle_data: isinstance(bundle_data, dict), "Bundle data must be dict") +@require(lambda output_path: isinstance(output_path, Path), "Output path must be Path") +@require(lambda format: isinstance(format, str), "Format must be str") +@ensure(lambda result: result is None, "Must return None") +def _export_bundle_to_file(bundle_data: dict[str, Any], output_path: Path, format: str) -> None: + """Export bundle data to file.""" + import json + + import yaml + + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", encoding="utf-8") as f: + if format.lower() == "json": + json.dump(bundle_data, f, indent=2, default=str) + else: + yaml.dump(bundle_data, f, default_flow_style=False, sort_keys=False) + + +@beartype +@require(lambda bundle_data: isinstance(bundle_data, dict), "Bundle data must be dict") +@require(lambda format: isinstance(format, str), "Format must be str") +@ensure(lambda result: result is None, "Must return None") +def _export_bundle_to_stdout(bundle_data: dict[str, Any], format: str) -> None: + """Export bundle data to stdout.""" + import json + + import yaml + + if format.lower() == "json": + console.print(json.dumps(bundle_data, indent=2, default=str)) + else: + console.print(yaml.dump(bundle_data, default_flow_style=False, sort_keys=False)) + + +@app.command("lock") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def lock_section( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + section: str = typer.Option(..., "--section", help="Section pattern (e.g., 'idea', 'features.*.stories')"), + persona: str = typer.Option(..., "--persona", help="Persona name (e.g., product-owner, architect)"), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Lock a section for a persona. + + Prevents other personas from editing the specified section until unlocked. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --section, --persona + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact project lock --bundle legacy-api --section idea --persona product-owner + specfact project lock --bundle legacy-api --section "features.*.stories" --persona product-owner + """ + if is_debug_mode(): + debug_log_operation( + "command", + "project lock", + "started", + extra={"repo": str(repo), "bundle": bundle, "section": section, "persona": persona}, + ) + debug_print("[dim]project lock: started[/dim]") + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Check persona exists, try to initialize if missing + if persona not in bundle_obj.manifest.personas: + # Try to initialize the requested persona + persona_initialized = _initialize_persona_if_needed(bundle_obj, persona, no_interactive) + + if persona_initialized: + # Save bundle with new persona + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + else: + # Persona not available in defaults or user declined + print_error(f"Persona '{persona}' not found in bundle manifest") + console.print() # Empty line + + # Always show available personas in bundle + available_personas = list(bundle_obj.manifest.personas.keys()) + if available_personas: + print_info("Available personas in bundle:") + for p in available_personas: + print_info(f" - {p}") + else: + print_info("No personas defined in bundle manifest.") + + console.print() # Empty line + + # Always show default personas (even if some are already in bundle) + print_info("Default personas available:") + for p_name, p_mapping in DEFAULT_PERSONAS.items(): + status = "[green]✓[/green]" if p_name in bundle_obj.manifest.personas else "[dim]○[/dim]" + owns_preview = ", ".join(p_mapping.owns[:3]) + if len(p_mapping.owns) > 3: + owns_preview += "..." + print_info(f" {status} {p_name}: owns {owns_preview}") + + console.print() # Empty line + + # Offer to initialize all default personas if none are defined + if not available_personas and not no_interactive: + all_initialized = _initialize_all_default_personas(bundle_obj, no_interactive) + if all_initialized: + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + # Retry with the newly initialized persona + if persona in bundle_obj.manifest.personas: + persona_initialized = True + + if not persona_initialized: + print_info("To add personas, use:") + print_info(" specfact project init-personas --bundle <name>") + print_info(" specfact project init-personas --bundle <name> --persona <name>") + raise typer.Exit(1) + + # Check persona owns section + if not check_persona_ownership(persona, bundle_obj.manifest, section): + print_error(f"Persona '{persona}' does not own section '{section}'") + raise typer.Exit(1) + + # Check if already locked + if check_section_locked(bundle_obj.manifest, section): + print_warning(f"Section '{section}' is already locked") + raise typer.Exit(1) + + # Create lock + lock = SectionLock( + section=section, + owner=persona, + locked_at=datetime.now(UTC).isoformat(), + locked_by=os.environ.get("USER", "unknown") + "@" + os.environ.get("HOSTNAME", "unknown"), + ) + + bundle_obj.manifest.locks.append(lock) + + # Save bundle + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + print_success(f"Locked section '{section}' for persona '{persona}'") + + +@app.command("unlock") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def unlock_section( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + section: str = typer.Option(..., "--section", help="Section pattern (e.g., 'idea', 'features.*.stories')"), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Unlock a section. + + Removes the lock on the specified section, allowing edits by any persona that owns it. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --section + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact project unlock --bundle legacy-api --section idea + specfact project unlock --bundle legacy-api --section "features.*.stories" + """ + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Find and remove lock + removed = False + for i, lock in enumerate(bundle_obj.manifest.locks): + if match_section_pattern(lock.section, section): + bundle_obj.manifest.locks.pop(i) + removed = True + break + + if not removed: + print_warning(f"Section '{section}' is not locked") + raise typer.Exit(1) + + # Save bundle + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + print_success(f"Unlocked section '{section}'") + + +@app.command("locks") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def list_locks( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + List all section locks. + + Shows all currently locked sections with their owners and lock timestamps. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact project locks --bundle legacy-api + """ + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Display locks + if not bundle_obj.manifest.locks: + print_info("No locks found") + return + + table = Table(title="Section Locks") + table.add_column("Section", style="cyan") + table.add_column("Owner", style="magenta") + table.add_column("Locked At", style="green") + table.add_column("Locked By", style="yellow") + + for lock in bundle_obj.manifest.locks: + table.add_row(lock.section, lock.owner, lock.locked_at, lock.locked_by) + + console.print(table) + + +@app.command("init-personas") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def init_personas( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + personas: list[str] = typer.Option( + [], + "--persona", + help="Specific persona(s) to initialize (e.g., --persona product-owner --persona architect). If not specified, initializes all default personas.", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Default: False (interactive mode)", + ), +) -> None: + """ + Initialize personas in project bundle manifest. + + Adds default persona mappings to the bundle manifest if they are missing. + Useful for migrating existing bundles to use persona workflows. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --persona + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact project init-personas --bundle legacy-api + specfact project init-personas --bundle legacy-api --persona product-owner --persona architect + specfact project init-personas --bundle legacy-api --no-interactive + """ + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + # Interactive selection + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + # Ensure bundle is not None + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Get bundle directory + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Determine which personas to initialize + personas_to_init: dict[str, PersonaMapping] = {} + + if personas: + # Initialize specific personas + for persona_name in personas: + if persona_name not in DEFAULT_PERSONAS: + print_error(f"Persona '{persona_name}' is not a default persona") + print_info("Available default personas:") + for p_name in DEFAULT_PERSONAS: + print_info(f" - {p_name}") + raise typer.Exit(1) + + if persona_name in bundle_obj.manifest.personas: + print_warning(f"Persona '{persona_name}' already exists in bundle manifest") + else: + personas_to_init[persona_name] = DEFAULT_PERSONAS[persona_name] + else: + # Initialize all missing default personas + personas_to_init = {k: v for k, v in DEFAULT_PERSONAS.items() if k not in bundle_obj.manifest.personas} + + if not personas_to_init: + print_info("All default personas are already initialized in bundle manifest") + return + + # Show what will be initialized + console.print() # Empty line + print_info(f"Will initialize {len(personas_to_init)} persona(s):") + for p_name, p_mapping in personas_to_init.items(): + owns_preview = ", ".join(p_mapping.owns[:3]) + if len(p_mapping.owns) > 3: + owns_preview += "..." + print_info(f" - {p_name}: owns {owns_preview}") + + # Confirm in interactive mode + if not no_interactive: + from rich.prompt import Confirm + + console.print() # Empty line + if not Confirm.ask("Initialize these personas?", default=True): + print_info("Persona initialization cancelled") + raise typer.Exit(0) + + # Initialize personas + bundle_obj.manifest.personas.update(personas_to_init) + + # Save bundle + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + print_success(f"Initialized {len(personas_to_init)} persona(s) in bundle manifest") + + +@app.command("merge") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def merge_bundles( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + base: str = typer.Option(..., "--base", help="Base branch/commit (common ancestor)"), + ours: str = typer.Option(..., "--ours", help="Our branch/commit (current branch)"), + theirs: str = typer.Option(..., "--theirs", help="Their branch/commit (incoming branch)"), + persona_ours: str = typer.Option(..., "--persona-ours", help="Persona who made our changes (e.g., product-owner)"), + persona_theirs: str = typer.Option( + ..., "--persona-theirs", help="Persona who made their changes (e.g., architect)" + ), + # Output/Results + output: Path | None = typer.Option( + None, + "--output", + "--out", + help="Output directory for merged bundle (default: current bundle directory)", + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", + ), + strategy: str = typer.Option( + "auto", + "--strategy", + help="Merge strategy: auto (persona-based), ours, theirs, base, manual", + ), +) -> None: + """ + Merge project bundles using three-way merge with persona-aware conflict resolution. + + Performs a three-way merge between base, ours, and theirs versions of a project bundle, + automatically resolving conflicts based on persona ownership rules. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --base, --ours, --theirs, --persona-ours, --persona-theirs + - **Output/Results**: --output + - **Behavior/Options**: --no-interactive, --strategy + + **Examples:** + specfact project merge --base main --ours po-branch --theirs arch-branch --persona-ours product-owner --persona-theirs architect + specfact project merge --bundle legacy-api --base main --ours feature-1 --theirs feature-2 --persona-ours developer --persona-theirs developer + """ + from specfact_cli.merge.resolver import MergeStrategy, PersonaMergeResolver + from specfact_cli.utils.git import GitOperations + + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + # Initialize Git operations + git_ops = GitOperations(repo) + if not git_ops._is_git_repo(): + print_error("Not a Git repository. Merge requires Git.") + raise typer.Exit(1) + + print_section(f"Merging project bundle '{bundle}'") + + # Load bundles from Git branches/commits + # For now, we'll load from current directory and assume bundles are checked out + # In a full implementation, we'd checkout branches and load bundles + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + + # Load base, ours, and theirs bundles from Git branches/commits + print_info("Loading bundles from Git branches/commits...") + + # Save current branch + current_branch = git_ops.get_current_branch() + + try: + # Load base bundle + print_info(f"Loading base bundle from {base}...") + git_ops.checkout(base) + base_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Load ours bundle + print_info(f"Loading ours bundle from {ours}...") + git_ops.checkout(ours) + ours_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Load theirs bundle + print_info(f"Loading theirs bundle from {theirs}...") + git_ops.checkout(theirs) + theirs_bundle = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + except Exception as e: + print_error(f"Failed to load bundles from Git: {e}") + # Restore original branch + with suppress(Exception): + git_ops.checkout(current_branch) + raise typer.Exit(1) from e + finally: + # Restore original branch + with suppress(Exception): + git_ops.checkout(current_branch) + print_info(f"Restored branch: {current_branch}") + + # Perform merge + resolver = PersonaMergeResolver() + resolution = resolver.resolve(base_bundle, ours_bundle, theirs_bundle, persona_ours, persona_theirs) + + # Display results + print_section("Merge Resolution Results") + print_info(f"Auto-resolved: {resolution.auto_resolved}") + print_info(f"Manual resolution required: {resolution.unresolved}") + + if resolution.conflicts: + from rich.table import Table + + conflicts_table = Table(title="Conflicts") + conflicts_table.add_column("Section", style="cyan") + conflicts_table.add_column("Field", style="magenta") + conflicts_table.add_column("Resolution", style="green") + conflicts_table.add_column("Status", style="yellow") + + for conflict in resolution.conflicts: + status = "✅ Auto-resolved" if conflict.resolution != MergeStrategy.MANUAL else "❌ Manual required" + conflicts_table.add_row( + conflict.section_path, + conflict.field_name, + conflict.resolution.value if conflict.resolution else "pending", + status, + ) + + console.print(conflicts_table) + + # Handle unresolved conflicts + if resolution.unresolved > 0: + print_warning(f"{resolution.unresolved} conflict(s) require manual resolution") + if not no_interactive: + from rich.prompt import Confirm + + if not Confirm.ask("Continue with manual resolution?", default=True): + print_info("Merge cancelled") + raise typer.Exit(0) + + # Interactive resolution for each conflict + for conflict in resolution.conflicts: + if conflict.resolution == MergeStrategy.MANUAL: + print_section(f"Resolving conflict: {conflict.field_name}") + print_info(f"Base: {conflict.base_value}") + print_info(f"Ours ({persona_ours}): {conflict.ours_value}") + print_info(f"Theirs ({persona_theirs}): {conflict.theirs_value}") + + from rich.prompt import Prompt + + choice = Prompt.ask( + "Choose resolution", + choices=["ours", "theirs", "base", "manual"], + default="ours", + ) + + if choice == "ours": + conflict.resolution = MergeStrategy.OURS + conflict.resolved_value = conflict.ours_value + elif choice == "theirs": + conflict.resolution = MergeStrategy.THEIRS + conflict.resolved_value = conflict.theirs_value + elif choice == "base": + conflict.resolution = MergeStrategy.BASE + conflict.resolved_value = conflict.base_value + else: + # Manual edit - prompt for value + manual_value = Prompt.ask("Enter manual value") + conflict.resolution = MergeStrategy.MANUAL + conflict.resolved_value = manual_value + + # Apply resolution + resolver._apply_resolution(resolution.merged_bundle, conflict.field_name, conflict.resolved_value) + + # Save merged bundle + output_dir = output if output else bundle_dir + output_dir.mkdir(parents=True, exist_ok=True) + + _save_bundle_with_progress(resolution.merged_bundle, output_dir, atomic=True) + print_success(f"Merged bundle saved to {output_dir}") + + +@app.command("resolve-conflict") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def resolve_conflict( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, attempts to auto-detect or prompt.", + ), + conflict_path: str = typer.Option(..., "--path", help="Conflict path (e.g., 'features.FEATURE-001.title')"), + resolution: str = typer.Option(..., "--resolution", help="Resolution: ours, theirs, base, or manual value"), + persona: str | None = typer.Option( + None, "--persona", help="Persona resolving the conflict (for ownership validation)" + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", + ), +) -> None: + """ + Resolve a specific conflict in a project bundle. + + Helper command for manually resolving individual conflicts after a merge operation. + + **Parameter Groups:** + - **Target/Input**: --repo, --bundle, --path, --resolution, --persona + - **Behavior/Options**: --no-interactive + + **Examples:** + specfact project resolve-conflict --path features.FEATURE-001.title --resolution ours + specfact project resolve-conflict --bundle legacy-api --path idea.intent --resolution theirs --persona product-owner + """ + # Get bundle name + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None and not no_interactive: + from rich.prompt import Prompt + + plans = SpecFactStructure.list_plans(repo) + if not plans: + print_error("No project bundles found") + raise typer.Exit(1) + bundle_names = [str(p["name"]) for p in plans if p.get("name")] + if not bundle_names: + print_error("No valid bundle names found") + raise typer.Exit(1) + bundle = Prompt.ask("Select bundle", choices=bundle_names) + elif bundle is None: + print_error("Bundle not specified and no active bundle found") + raise typer.Exit(1) + + if bundle is None: + print_error("Bundle not specified") + raise typer.Exit(1) + + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + # Parse resolution + from specfact_cli.merge.resolver import PersonaMergeResolver + + resolver = PersonaMergeResolver() + + # Determine value based on resolution strategy + if resolution.lower() in ("ours", "theirs", "base"): + print_warning("Resolution strategy 'ours', 'theirs', or 'base' requires merge context") + print_info("Use 'specfact project merge' for full merge resolution") + raise typer.Exit(1) + + # Manual value provided + resolved_value = resolution + + # Apply resolution + resolver._apply_resolution(bundle_obj, conflict_path, resolved_value) + + # Save bundle + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + print_success(f"Conflict resolved: {conflict_path} = {resolved_value}") + + +# ----------------------------- +# Version management subcommands +# ----------------------------- + + +@version_app.command("check") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def version_check( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, uses active bundle from config.", + ), +) -> None: + """ + Analyze bundle changes and recommend version bump (major/minor/patch/none). + """ + bundle_name, bundle_dir = _resolve_bundle(repo, bundle) + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + + analyzer = ChangeAnalyzer(repo_path=repo) + analysis = analyzer.analyze(bundle_dir, bundle=bundle_obj) + + print_section(f"Version analysis for bundle '{bundle_name}'") + print_info(f"Recommended bump: {analysis.recommended_bump}") + print_info(f"Change type: {analysis.change_type.value}") + + if analysis.changed_files: + table = Table(title="Bundle changes") + table.add_column("Path", style="cyan") + for path in sorted(set(analysis.changed_files)): + table.add_row(path) + console.print(table) + else: + print_info("No bundle file changes detected.") + + if analysis.reasons: + print_section("Reasons") + for reason in analysis.reasons: + console.print(f"- {reason}") + + if analysis.content_hash: + print_info(f"Current bundle hash: {analysis.content_hash[:8]}...") + + +@version_app.command("bump") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@require(lambda bump_type: bump_type in {"major", "minor", "patch"}, "Bump type must be major|minor|patch") +@ensure(lambda result: result is None, "Must return None") +def version_bump( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, uses active bundle from config.", + ), + bump_type: str = typer.Option( + ..., + "--type", + help="Version bump type: major | minor | patch", + case_sensitive=False, + ), +) -> None: + """ + Bump project version in bundle manifest (SemVer). + """ + bump_type = bump_type.lower() + bundle_name, bundle_dir = _resolve_bundle(repo, bundle) + + analyzer = ChangeAnalyzer(repo_path=repo) + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + analysis = analyzer.analyze(bundle_dir, bundle=bundle_obj) + current_version = bundle_obj.manifest.versions.project + new_version = bump_version(current_version, bump_type) + + # Warn if selected bump is lower than recommended + if BUMP_SEVERITY.get(analysis.recommended_bump, 0) > BUMP_SEVERITY.get(bump_type, 0): + print_warning( + f"Recommended bump is '{analysis.recommended_bump}' based on detected changes, " + f"but '{bump_type}' was requested." + ) + + project_metadata = bundle_obj.manifest.project_metadata or ProjectMetadata(stability="alpha") + project_metadata.version_history.append( + ChangeAnalyzer.create_history_entry(current_version, new_version, bump_type) + ) + bundle_obj.manifest.project_metadata = project_metadata + bundle_obj.manifest.versions.project = new_version + + # Record current content hash to support future comparisons + summary = bundle_obj.compute_summary(include_hash=True) + if summary.content_hash: + bundle_obj.manifest.bundle["content_hash"] = summary.content_hash + + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + print_success(f"Bumped project version to {new_version} for bundle '{bundle_name}'") + + +@version_app.command("set") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def version_set( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If not specified, uses active bundle from config.", + ), + version: str = typer.Option(..., "--version", help="Exact SemVer to set (e.g., 1.2.3)"), +) -> None: + """ + Set explicit project version in bundle manifest. + """ + bundle_name, bundle_dir = _resolve_bundle(repo, bundle) + bundle_obj = _load_bundle_with_progress(bundle_dir, validate_hashes=False) + current_version = bundle_obj.manifest.versions.project + + # Validate version before loading full bundle again for save + validate_semver(version) + + project_metadata = bundle_obj.manifest.project_metadata or ProjectMetadata(stability="alpha") + project_metadata.version_history.append(ChangeAnalyzer.create_history_entry(current_version, version, "set")) + bundle_obj.manifest.project_metadata = project_metadata + bundle_obj.manifest.versions.project = version + + summary = bundle_obj.compute_summary(include_hash=True) + if summary.content_hash: + bundle_obj.manifest.bundle["content_hash"] = summary.content_hash + + _save_bundle_with_progress(bundle_obj, bundle_dir, atomic=True) + print_success(f"Set project version to {version} for bundle '{bundle_name}'") diff --git a/src/specfact_cli/modules/repro/src/__init__.py b/src/specfact_cli/modules/repro/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/repro/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/repro/src/app.py b/src/specfact_cli/modules/repro/src/app.py index 3f8bd051..1f0151f8 100644 --- a/src/specfact_cli/modules/repro/src/app.py +++ b/src/specfact_cli/modules/repro/src/app.py @@ -1,6 +1,6 @@ -"""Repro command: re-export from commands package.""" +"""repro command entrypoint.""" -from specfact_cli.commands.repro import app +from specfact_cli.modules.repro.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/repro/src/commands.py b/src/specfact_cli/modules/repro/src/commands.py new file mode 100644 index 00000000..e8da6538 --- /dev/null +++ b/src/specfact_cli/modules/repro/src/commands.py @@ -0,0 +1,547 @@ +""" +Repro command - Run full validation suite for reproducibility. + +This module provides commands for running comprehensive validation +including linting, type checking, contract exploration, and tests. +""" + +from __future__ import annotations + +from pathlib import Path + +import typer +from beartype import beartype +from click import Context as ClickContext +from icontract import require +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.table import Table + +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils.env_manager import check_tool_in_env, detect_env_manager, detect_source_directories +from specfact_cli.utils.structure import SpecFactStructure +from specfact_cli.validators.repro_checker import ReproChecker + + +app = typer.Typer(help="Run validation suite for reproducibility") +console = Console() + + +def _update_pyproject_crosshair_config(pyproject_path: Path, config: dict[str, int | float]) -> bool: + """ + Update or create [tool.crosshair] section in pyproject.toml. + + Args: + pyproject_path: Path to pyproject.toml + config: Dictionary with CrossHair configuration values + + Returns: + True if config was updated/created, False otherwise + """ + try: + # Try tomlkit for style-preserving updates (recommended) + try: + import tomlkit + + # Read existing file to preserve style + if pyproject_path.exists(): + with pyproject_path.open("r", encoding="utf-8") as f: + doc = tomlkit.parse(f.read()) + else: + doc = tomlkit.document() + + # Update or create [tool.crosshair] section + if "tool" not in doc: + doc["tool"] = tomlkit.table() # type: ignore[assignment] + if "crosshair" not in doc["tool"]: # type: ignore[index] + doc["tool"]["crosshair"] = tomlkit.table() # type: ignore[index,assignment] + + for key, value in config.items(): + doc["tool"]["crosshair"][key] = value # type: ignore[index] + + # Write back + with pyproject_path.open("w", encoding="utf-8") as f: + f.write(tomlkit.dumps(doc)) # type: ignore[arg-type] + + return True + + except ImportError: + # Fallback: use tomllib/tomli to read, then append section manually + try: + import tomllib + except ImportError: + try: + import tomli as tomllib # noqa: F401 + except ImportError: + console.print("[red]Error:[/red] No TOML library available (need tomlkit, tomllib, or tomli)") + return False + + # Read existing content + existing_content = "" + if pyproject_path.exists(): + existing_content = pyproject_path.read_text(encoding="utf-8") + + # Check if [tool.crosshair] already exists + if "[tool.crosshair]" in existing_content: + # Update existing section (simple regex replacement) + import re + + pattern = r"\[tool\.crosshair\][^\[]*" + new_section = "[tool.crosshair]\n" + for key, value in config.items(): + new_section += f"{key} = {value}\n" + + existing_content = re.sub(pattern, new_section.rstrip(), existing_content, flags=re.DOTALL) + else: + # Append new section + if existing_content and not existing_content.endswith("\n"): + existing_content += "\n" + existing_content += "\n[tool.crosshair]\n" + for key, value in config.items(): + existing_content += f"{key} = {value}\n" + + pyproject_path.write_text(existing_content, encoding="utf-8") + return True + + except Exception as e: + console.print(f"[red]Error updating pyproject.toml:[/red] {e}") + return False + + +def _is_valid_repo_path(path: Path) -> bool: + """Check if path exists and is a directory.""" + return path.exists() and path.is_dir() + + +def _is_valid_output_path(path: Path | None) -> bool: + """Check if output path exists if provided.""" + return path is None or path.exists() + + +def _count_python_files(path: Path) -> int: + """Count Python files for anonymized telemetry reporting.""" + return sum(1 for _ in path.rglob("*.py")) + + +@app.callback(invoke_without_command=True, no_args_is_help=False) +# CrossHair: Skip analysis for Typer-decorated functions (signature analysis limitation) +# type: ignore[crosshair] +def main( + ctx: ClickContext, + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Output/Results + out: Path | None = typer.Option( + None, + "--out", + help="Output report path (default: bundle-specific .specfact/projects/<bundle-name>/reports/enforcement/report-<timestamp>.yaml if bundle context available, else global .specfact/reports/enforcement/, Phase 8.5)", + ), + # Behavior/Options + verbose: bool = typer.Option( + False, + "--verbose", + "-v", + help="Verbose output", + ), + fail_fast: bool = typer.Option( + False, + "--fail-fast", + help="Stop on first failure", + ), + fix: bool = typer.Option( + False, + "--fix", + help="Apply auto-fixes where available (Semgrep auto-fixes)", + ), + # Advanced/Configuration + budget: int = typer.Option( + 120, + "--budget", + help="Time budget in seconds (must be > 0)", + hidden=True, # Hidden by default, shown with --help-advanced + ), + sidecar: bool = typer.Option( + False, + "--sidecar", + help="Run sidecar validation for unannotated code (no-edit path)", + ), + sidecar_bundle: str | None = typer.Option( + None, + "--sidecar-bundle", + help="Bundle name for sidecar validation (required if --sidecar is used)", + ), +) -> None: + """ + Run full validation suite for reproducibility. + + Automatically detects the target repository's environment manager (hatch, poetry, uv, pip) + and adapts commands accordingly. All tools are optional and will be skipped with clear + messages if unavailable. + + Executes: + - Lint checks (ruff) - optional + - Async patterns (semgrep) - optional, only if config exists + - Type checking (basedpyright) - optional + - Contract exploration (CrossHair) - optional + - Property tests (pytest tests/contracts/) - optional, only if directory exists + - Smoke tests (pytest tests/smoke/) - optional, only if directory exists + - Sidecar validation (--sidecar) - optional, for unannotated code validation + + Works on external repositories without requiring SpecFact CLI adoption. + + Example: + specfact repro --verbose --budget 120 + specfact repro --repo /path/to/external/repo --verbose + specfact repro --fix --budget 120 + specfact repro --sidecar --sidecar-bundle legacy-api --repo /path/to/repo + """ + # If a subcommand was invoked, don't run the main validation + if ctx.invoked_subcommand is not None: + return + + if is_debug_mode(): + debug_log_operation( + "command", + "repro", + "started", + extra={"repo": str(repo), "budget": budget, "sidecar": sidecar, "sidecar_bundle": sidecar_bundle}, + ) + debug_print("[dim]repro: started[/dim]") + + # Type checking for parameters (after subcommand check) + if not _is_valid_repo_path(repo): + raise typer.BadParameter("Repo path must exist and be directory") + if budget <= 0: + raise typer.BadParameter("Budget must be positive") + if not _is_valid_output_path(out): + raise typer.BadParameter("Output path must exist if provided") + if sidecar and not sidecar_bundle: + raise typer.BadParameter("--sidecar-bundle is required when --sidecar is used") + + from specfact_cli.utils.yaml_utils import dump_yaml + + console.print("[bold cyan]Running validation suite...[/bold cyan]") + console.print(f"[dim]Repository: {repo}[/dim]") + console.print(f"[dim]Time budget: {budget}s[/dim]") + if fail_fast: + console.print("[dim]Fail-fast: enabled[/dim]") + if fix: + console.print("[dim]Auto-fix: enabled[/dim]") + console.print() + + # Ensure structure exists + SpecFactStructure.ensure_structure(repo) + + python_file_count = _count_python_files(repo) + + telemetry_metadata = { + "mode": "repro", + "files_analyzed": python_file_count, + } + + with telemetry.track_command("repro.run", telemetry_metadata) as record_event: + # Run all checks + checker = ReproChecker(repo_path=repo, budget=budget, fail_fast=fail_fast, fix=fix) + + # Detect and display environment manager before starting progress spinner + from specfact_cli.utils.env_manager import detect_env_manager + + env_info = detect_env_manager(repo) + if env_info.message: + console.print(f"[dim]{env_info.message}[/dim]") + + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + ) as progress: + progress.add_task("Running validation checks...", total=None) + + # This will show progress for each check internally + report = checker.run_all_checks() + + # Display results + console.print("\n[bold]Validation Results[/bold]\n") + + # Summary table + table = Table(title="Check Summary") + table.add_column("Check", style="cyan") + table.add_column("Tool", style="dim") + table.add_column("Status", style="bold") + table.add_column("Duration", style="dim") + + for check in report.checks: + if check.status.value == "passed": + status_icon = "[green]✓[/green] PASSED" + elif check.status.value == "failed": + status_icon = "[red]✗[/red] FAILED" + elif check.status.value == "timeout": + status_icon = "[yellow]⏱[/yellow] TIMEOUT" + elif check.status.value == "skipped": + status_icon = "[dim]⊘[/dim] SKIPPED" + else: + status_icon = "[dim]…[/dim] PENDING" + + duration_str = f"{check.duration:.2f}s" if check.duration else "N/A" + + table.add_row(check.name, check.tool, status_icon, duration_str) + + console.print(table) + + # Summary stats + console.print("\n[bold]Summary:[/bold]") + console.print(f" Total checks: {report.total_checks}") + console.print(f" [green]Passed: {report.passed_checks}[/green]") + if report.failed_checks > 0: + console.print(f" [red]Failed: {report.failed_checks}[/red]") + if report.timeout_checks > 0: + console.print(f" [yellow]Timeout: {report.timeout_checks}[/yellow]") + if report.skipped_checks > 0: + console.print(f" [dim]Skipped: {report.skipped_checks}[/dim]") + console.print(f" Total duration: {report.total_duration:.2f}s") + + if is_debug_mode(): + debug_log_operation( + "command", + "repro", + "success", + extra={ + "total_checks": report.total_checks, + "passed": report.passed_checks, + "failed": report.failed_checks, + }, + ) + debug_print("[dim]repro: success[/dim]") + record_event( + { + "checks_total": report.total_checks, + "checks_failed": report.failed_checks, + "violations_detected": report.failed_checks, + } + ) + + # Show errors if verbose + if verbose: + for check in report.checks: + if check.error: + console.print(f"\n[bold red]{check.name} Error:[/bold red]") + console.print(f"[dim]{check.error}[/dim]") + if check.output and check.status.value == "failed": + console.print(f"\n[bold red]{check.name} Output:[/bold red]") + console.print(f"[dim]{check.output[:500]}[/dim]") # Limit output + + # Write report if requested (Phase 8.5: try to use bundle-specific path) + if out is None: + # Try to detect bundle from active plan + bundle_name = SpecFactStructure.get_active_bundle_name(repo) + if bundle_name: + # Use bundle-specific enforcement report path (Phase 8.5) + out = SpecFactStructure.get_bundle_enforcement_report_path(bundle_name=bundle_name, base_path=repo) + else: + # Fallback to global path (backward compatibility during transition) + out = SpecFactStructure.get_timestamped_report_path("enforcement", repo, "yaml") + SpecFactStructure.ensure_structure(repo) + + out.parent.mkdir(parents=True, exist_ok=True) + dump_yaml(report.to_dict(), out) + console.print(f"\n[dim]Report written to: {out}[/dim]") + + # Run sidecar validation if requested (after main checks) + if sidecar and sidecar_bundle: + from specfact_cli.validators.sidecar.models import SidecarConfig + from specfact_cli.validators.sidecar.orchestrator import run_sidecar_validation + from specfact_cli.validators.sidecar.unannotated_detector import detect_unannotated_in_repo + + console.print("\n[bold cyan]Running sidecar validation for unannotated code...[/bold cyan]") + + # Detect unannotated code + unannotated = detect_unannotated_in_repo(repo) + if unannotated: + console.print(f"[dim]Found {len(unannotated)} unannotated functions[/dim]") + # Store unannotated functions info for harness generation + sidecar_config = SidecarConfig.create(sidecar_bundle, repo) + # Pass unannotated info to orchestrator (via results dict) + else: + console.print("[dim]No unannotated functions detected (all functions have contracts)[/dim]") + sidecar_config = SidecarConfig.create(sidecar_bundle, repo) + + # Run sidecar validation (harness will be generated for unannotated code) + sidecar_results = run_sidecar_validation(sidecar_config, console=console) + + # Display sidecar results + if sidecar_results.get("crosshair_summary"): + summary = sidecar_results["crosshair_summary"] + console.print( + f"[dim]Sidecar CrossHair: {summary.get('confirmed', 0)} confirmed, " + f"{summary.get('not_confirmed', 0)} not confirmed, " + f"{summary.get('violations', 0)} violations[/dim]" + ) + + # Exit with appropriate code + exit_code = report.get_exit_code() + if exit_code == 0: + crosshair_failed = any( + check.tool == "crosshair" and check.status.value == "failed" for check in report.checks + ) + if crosshair_failed: + console.print( + "\n[bold yellow]![/bold yellow] Required validations passed, but CrossHair failed (advisory)" + ) + console.print("[dim]Reproducibility verified with advisory failures[/dim]") + else: + console.print("\n[bold green]✓[/bold green] All validations passed!") + console.print("[dim]Reproducibility verified[/dim]") + elif exit_code == 1: + console.print("\n[bold red]✗[/bold red] Some validations failed") + raise typer.Exit(1) + else: + console.print("\n[yellow]⏱[/yellow] Budget exceeded") + raise typer.Exit(2) + + +@app.command("setup") +@beartype +@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") +def setup( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + install_crosshair: bool = typer.Option( + False, + "--install-crosshair", + help="Attempt to install crosshair-tool if not available", + ), +) -> None: + """ + Set up CrossHair configuration for contract exploration. + + + Automatically generates [tool.crosshair] configuration in pyproject.toml + to enable contract exploration with CrossHair during repro runs. + + This command: + - Detects source directories in the repository + - Creates/updates pyproject.toml with CrossHair configuration + - Optionally checks if crosshair-tool is installed + - Provides guidance on next steps + + Example: + specfact repro setup + specfact repro setup --repo /path/to/repo + specfact repro setup --install-crosshair + """ + console.print("[bold cyan]Setting up CrossHair configuration...[/bold cyan]") + console.print(f"[dim]Repository: {repo}[/dim]\n") + + # Detect environment manager + env_info = detect_env_manager(repo) + if env_info.message: + console.print(f"[dim]{env_info.message}[/dim]") + + # Detect source directories + source_dirs = detect_source_directories(repo) + if not source_dirs: + # Fallback to common patterns + if (repo / "src").exists(): + source_dirs = ["src/"] + elif (repo / "lib").exists(): + source_dirs = ["lib/"] + else: + source_dirs = ["."] + + console.print(f"[green]✓[/green] Detected source directories: {', '.join(source_dirs)}") + + # Check if crosshair-tool is available + crosshair_available, crosshair_message = check_tool_in_env(repo, "crosshair", env_info) + if crosshair_available: + console.print("[green]✓[/green] crosshair-tool is available") + else: + console.print(f"[yellow]⚠[/yellow] crosshair-tool not available: {crosshair_message}") + if install_crosshair: + console.print("[dim]Attempting to install crosshair-tool...[/dim]") + import subprocess + + # Build install command with environment manager + from specfact_cli.utils.env_manager import build_tool_command + + install_cmd = ["pip", "install", "crosshair-tool>=0.0.97"] + install_cmd = build_tool_command(env_info, install_cmd) + + try: + result = subprocess.run(install_cmd, capture_output=True, text=True, timeout=60, cwd=str(repo)) + if result.returncode == 0: + console.print("[green]✓[/green] crosshair-tool installed successfully") + crosshair_available = True + else: + console.print(f"[red]✗[/red] Failed to install crosshair-tool: {result.stderr}") + except subprocess.TimeoutExpired: + console.print("[red]✗[/red] Installation timed out") + except Exception as e: + console.print(f"[red]✗[/red] Installation error: {e}") + else: + console.print( + "[dim]Tip: Install with --install-crosshair flag, or manually: " + f"{'hatch run pip install' if env_info.manager == 'hatch' else 'pip install'} crosshair-tool[/dim]" + ) + + # Create/update pyproject.toml with CrossHair config + pyproject_path = repo / "pyproject.toml" + + # Default CrossHair configuration (matching our own pyproject.toml) + crosshair_config: dict[str, int | float] = { + "timeout": 60, + "per_condition_timeout": 10, + "per_path_timeout": 5, + "max_iterations": 1000, + } + + if _update_pyproject_crosshair_config(pyproject_path, crosshair_config): + if is_debug_mode(): + debug_log_operation("command", "repro setup", "success", extra={"pyproject_path": str(pyproject_path)}) + debug_print("[dim]repro setup: success[/dim]") + console.print(f"[green]✓[/green] Updated {pyproject_path.relative_to(repo)} with CrossHair configuration") + console.print("\n[bold]CrossHair Configuration:[/bold]") + for key, value in crosshair_config.items(): + console.print(f" {key} = {value}") + else: + if is_debug_mode(): + debug_log_operation( + "command", + "repro setup", + "failed", + error=f"Failed to update {pyproject_path}", + extra={"reason": "update_failed"}, + ) + console.print(f"[red]✗[/red] Failed to update {pyproject_path.relative_to(repo)}") + raise typer.Exit(1) + + # Summary + console.print("\n[bold green]✓[/bold green] Setup complete!") + console.print("\n[bold]Next steps:[/bold]") + console.print(" 1. Run [cyan]specfact repro[/cyan] to execute validation checks") + if not crosshair_available: + console.print(" 2. Install crosshair-tool to enable contract exploration:") + if env_info.manager == "hatch": + console.print(" [dim]hatch run pip install crosshair-tool[/dim]") + elif env_info.manager == "poetry": + console.print(" [dim]poetry add --dev crosshair-tool[/dim]") + elif env_info.manager == "uv": + console.print(" [dim]uv pip install crosshair-tool[/dim]") + else: + console.print(" [dim]pip install crosshair-tool[/dim]") + console.print(" 3. CrossHair will automatically explore contracts in your source code") + console.print(" 4. Results will appear in the validation report") diff --git a/src/specfact_cli/modules/sdd/src/__init__.py b/src/specfact_cli/modules/sdd/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/sdd/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/sdd/src/app.py b/src/specfact_cli/modules/sdd/src/app.py index 75d9e985..9b1233ac 100644 --- a/src/specfact_cli/modules/sdd/src/app.py +++ b/src/specfact_cli/modules/sdd/src/app.py @@ -1,6 +1,6 @@ -"""SDD command: re-export from commands package.""" +"""sdd command entrypoint.""" -from specfact_cli.commands.sdd import app +from specfact_cli.modules.sdd.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/sdd/src/commands.py b/src/specfact_cli/modules/sdd/src/commands.py new file mode 100644 index 00000000..90d6b102 --- /dev/null +++ b/src/specfact_cli/modules/sdd/src/commands.py @@ -0,0 +1,431 @@ +""" +SDD (Spec-Driven Development) manifest management commands. + +This module provides commands for managing SDD manifests, including listing +all SDD manifests in a repository, and constitution management for Spec-Kit compatibility. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.table import Table + +from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher +from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode +from specfact_cli.utils import print_error, print_info, print_success +from specfact_cli.utils.sdd_discovery import list_all_sdds +from specfact_cli.utils.structure import SpecFactStructure + + +app = typer.Typer( + name="sdd", + help="Manage SDD (Spec-Driven Development) manifests and constitutions", + rich_markup_mode="rich", +) + +console = get_configured_console() + +# Constitution subcommand group +constitution_app = typer.Typer( + help="Manage project constitutions (Spec-Kit format compatibility). Generates and validates constitutions at .specify/memory/constitution.md for Spec-Kit format compatibility." +) + +app.add_typer(constitution_app, name="constitution") + +# Constitution subcommand group +constitution_app = typer.Typer( + help="Manage project constitutions (Spec-Kit format compatibility). Generates and validates constitutions at .specify/memory/constitution.md for Spec-Kit format compatibility." +) + +app.add_typer(constitution_app, name="constitution") + + +@app.command("list") +@beartype +@require(lambda repo: isinstance(repo, Path), "Repo must be Path") +def sdd_list( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), +) -> None: + """ + List all SDD manifests in the repository. + + Shows all SDD manifests found in bundle-specific locations (.specfact/projects/<bundle-name>/sdd.yaml, Phase 8.5) + and legacy multi-SDD layout (.specfact/sdd/*.yaml) + and legacy single-SDD layout (.specfact/sdd.yaml). + + **Parameter Groups:** + - **Target/Input**: --repo + + **Examples:** + specfact sdd list + specfact sdd list --repo /path/to/repo + """ + if is_debug_mode(): + debug_log_operation("command", "sdd list", "started", extra={"repo": str(repo)}) + debug_print("[dim]sdd list: started[/dim]") + + console.print("\n[bold cyan]SpecFact CLI - SDD Manifest List[/bold cyan]") + console.print("=" * 60) + + base_path = repo.resolve() + all_sdds = list_all_sdds(base_path) + + if not all_sdds: + if is_debug_mode(): + debug_log_operation("command", "sdd list", "success", extra={"count": 0, "reason": "none_found"}) + debug_print("[dim]sdd list: success (none found)[/dim]") + console.print("[yellow]No SDD manifests found[/yellow]") + console.print(f"[dim]Searched in: {base_path / SpecFactStructure.PROJECTS}/*/sdd.yaml[/dim]") + console.print( + f"[dim]Legacy fallback: {base_path / SpecFactStructure.SDD}/* and {base_path / SpecFactStructure.ROOT / 'sdd.yaml'}[/dim]" + ) + console.print("\n[dim]Create SDD manifests with: specfact plan harden <bundle-name>[/dim]") + console.print("[dim]If you have legacy bundles, migrate with: specfact migrate artifacts --repo .[/dim]") + raise typer.Exit(0) + + # Create table + table = Table(title="SDD Manifests", show_header=True, header_style="bold cyan") + table.add_column("Path", style="cyan", no_wrap=False) + table.add_column("Bundle Hash", style="magenta") + table.add_column("Bundle ID", style="blue") + table.add_column("Status", style="green") + table.add_column("Coverage", style="yellow") + + for sdd_path, manifest in all_sdds: + # Determine if this is legacy or bundle-specific layout + # Bundle-specific (new format): .specfact/projects/<bundle-name>/sdd.yaml + # Legacy single-SDD: .specfact/sdd.yaml (root level) + # Legacy multi-SDD: .specfact/sdd/<bundle-name>.yaml + sdd_path_str = str(sdd_path) + is_bundle_specific = "/projects/" in sdd_path_str or "\\projects\\" in sdd_path_str + layout_type = "[green]bundle-specific[/green]" if is_bundle_specific else "[dim]legacy[/dim]" + + # Format path (relative to base_path) + try: + rel_path = sdd_path.relative_to(base_path) + except ValueError: + rel_path = sdd_path + + # Format hash (first 16 chars) + hash_short = ( + manifest.plan_bundle_hash[:16] + "..." if len(manifest.plan_bundle_hash) > 16 else manifest.plan_bundle_hash + ) + bundle_id_short = ( + manifest.plan_bundle_id[:16] + "..." if len(manifest.plan_bundle_id) > 16 else manifest.plan_bundle_id + ) + + # Format coverage thresholds + coverage_str = ( + f"Contracts/Story: {manifest.coverage_thresholds.contracts_per_story:.1f}, " + f"Invariants/Feature: {manifest.coverage_thresholds.invariants_per_feature:.1f}, " + f"Arch Facets: {manifest.coverage_thresholds.architecture_facets}" + ) + + # Format status + status = manifest.promotion_status + + table.add_row( + f"{rel_path} {layout_type}", + hash_short, + bundle_id_short, + status, + coverage_str, + ) + + console.print() + console.print(table) + console.print(f"\n[dim]Total SDD manifests: {len(all_sdds)}[/dim]") + if is_debug_mode(): + debug_log_operation("command", "sdd list", "success", extra={"count": len(all_sdds)}) + debug_print("[dim]sdd list: success[/dim]") + + # Show layout information + bundle_specific_count = sum(1 for path, _ in all_sdds if "/projects/" in str(path) or "\\projects\\" in str(path)) + legacy_count = len(all_sdds) - bundle_specific_count + + if bundle_specific_count > 0: + console.print(f"[green]✓ {bundle_specific_count} bundle-specific SDD manifest(s) found[/green]") + + if legacy_count > 0: + console.print(f"[yellow]⚠ {legacy_count} legacy SDD manifest(s) found[/yellow]") + console.print( + "[dim]Consider migrating to bundle-specific layout: .specfact/projects/<bundle-name>/sdd.yaml (Phase 8.5)[/dim]" + ) + + +@constitution_app.command("bootstrap") +@beartype +@require(lambda repo: repo.exists(), "Repository path must exist") +@require(lambda repo: repo.is_dir(), "Repository path must be a directory") +@ensure(lambda result: result is None, "Must return None") +def constitution_bootstrap( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Repository path. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Output/Results + out: Path | None = typer.Option( + None, + "--out", + help="Output path for constitution. Default: .specify/memory/constitution.md", + ), + # Behavior/Options + overwrite: bool = typer.Option( + False, + "--overwrite", + help="Overwrite existing constitution if it exists. Default: False", + ), +) -> None: + """ + Generate bootstrap constitution from repository analysis (Spec-Kit compatibility). + + This command generates a constitution in Spec-Kit format (`.specify/memory/constitution.md`) + for compatibility with Spec-Kit artifacts and sync operations. + + **Note**: SpecFact itself uses plan bundles (`.specfact/plans/*.bundle.<format>`) for internal + operations. Constitutions are only needed when syncing with Spec-Kit or working in Spec-Kit format. + + Analyzes the repository (README, pyproject.toml, .cursor/rules/, docs/rules/) + to extract project metadata, development principles, and quality standards, + then generates a bootstrap constitution template ready for review and adjustment. + + **Parameter Groups:** + - **Target/Input**: --repo + - **Output/Results**: --out + - **Behavior/Options**: --overwrite + + **Examples:** + specfact sdd constitution bootstrap --repo . + specfact sdd constitution bootstrap --repo . --out custom-constitution.md + specfact sdd constitution bootstrap --repo . --overwrite + """ + from specfact_cli.telemetry import telemetry + + with telemetry.track_command("sdd.constitution.bootstrap", {"repo": str(repo)}): + console.print(f"[bold cyan]Generating bootstrap constitution for:[/bold cyan] {repo}") + + # Determine output path + if out is None: + # Use Spec-Kit convention: .specify/memory/constitution.md + specify_dir = repo / ".specify" / "memory" + specify_dir.mkdir(parents=True, exist_ok=True) + out = specify_dir / "constitution.md" + else: + out.parent.mkdir(parents=True, exist_ok=True) + + # Check if constitution already exists + if out.exists() and not overwrite: + console.print(f"[yellow]⚠[/yellow] Constitution already exists: {out}") + console.print("[dim]Use --overwrite to replace it[/dim]") + raise typer.Exit(1) + + # Generate bootstrap constitution + print_info("Analyzing repository...") + enricher = ConstitutionEnricher() + enriched_content = enricher.bootstrap(repo, out) + + # Write constitution + out.write_text(enriched_content, encoding="utf-8") + print_success(f"✓ Bootstrap constitution generated: {out}") + + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Review the generated constitution") + console.print("2. Adjust principles and sections as needed") + console.print("3. Run 'specfact sdd constitution validate' to check completeness") + console.print("4. Run 'specfact sync bridge --adapter speckit' to sync with Spec-Kit artifacts") + + +@constitution_app.command("enrich") +@beartype +@require(lambda repo: repo.exists(), "Repository path must exist") +@require(lambda repo: repo.is_dir(), "Repository path must be a directory") +@ensure(lambda result: result is None, "Must return None") +def constitution_enrich( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Repository path (default: current directory)", + exists=True, + file_okay=False, + dir_okay=True, + ), + constitution: Path | None = typer.Option( + None, + "--constitution", + help="Path to constitution file (default: .specify/memory/constitution.md)", + ), +) -> None: + """ + Auto-enrich existing constitution with repository context (Spec-Kit compatibility). + + This command enriches a constitution in Spec-Kit format (`.specify/memory/constitution.md`) + for compatibility with Spec-Kit artifacts and sync operations. + + **Note**: SpecFact itself uses plan bundles (`.specfact/plans/*.bundle.<format>`) for internal + operations. Constitutions are only needed when syncing with Spec-Kit or working in Spec-Kit format. + + Analyzes the repository and enriches the existing constitution with + additional principles and details extracted from repository context. + + Example: + specfact sdd constitution enrich --repo . + """ + from specfact_cli.telemetry import telemetry + + with telemetry.track_command("sdd.constitution.enrich", {"repo": str(repo)}): + # Determine constitution path + if constitution is None: + constitution = repo / ".specify" / "memory" / "constitution.md" + + if not constitution.exists(): + console.print(f"[bold red]✗[/bold red] Constitution not found: {constitution}") + console.print("[dim]Run 'specfact sdd constitution bootstrap' first[/dim]") + raise typer.Exit(1) + + console.print(f"[bold cyan]Enriching constitution:[/bold cyan] {constitution}") + + # Analyze repository + print_info("Analyzing repository...") + enricher = ConstitutionEnricher() + analysis = enricher.analyze_repository(repo) + + # Suggest additional principles + principles = enricher.suggest_principles(analysis) + + console.print(f"[dim]Found {len(principles)} suggested principles[/dim]") + + # Read existing constitution + existing_content = constitution.read_text(encoding="utf-8") + + # Check if enrichment is needed (has placeholders) + import re + + placeholder_pattern = r"\[[A-Z_0-9]+\]" + placeholders = re.findall(placeholder_pattern, existing_content) + + if not placeholders: + console.print("[yellow]⚠[/yellow] Constitution appears complete (no placeholders found)") + console.print("[dim]No enrichment needed[/dim]") + return + + console.print(f"[dim]Found {len(placeholders)} placeholders to enrich[/dim]") + + # Enrich template + suggestions: dict[str, Any] = { + "project_name": analysis.get("project_name", "Project"), + "principles": principles, + "section2_name": "Development Workflow", + "section2_content": enricher._generate_workflow_section(analysis), + "section3_name": "Quality Standards", + "section3_content": enricher._generate_quality_standards_section(analysis), + "governance_rules": "Constitution supersedes all other practices. Amendments require documentation, team approval, and migration plan for breaking changes.", + } + + enriched_content = enricher.enrich_template(constitution, suggestions) + + # Write enriched constitution + constitution.write_text(enriched_content, encoding="utf-8") + print_success(f"✓ Constitution enriched: {constitution}") + + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Review the enriched constitution") + console.print("2. Adjust as needed") + console.print("3. Run 'specfact sdd constitution validate' to check completeness") + + +@constitution_app.command("validate") +@beartype +@require(lambda constitution: constitution.exists(), "Constitution path must exist") +@ensure(lambda result: result is None, "Must return None") +def constitution_validate( + constitution: Path = typer.Option( + Path(".specify/memory/constitution.md"), + "--constitution", + help="Path to constitution file", + exists=True, + ), +) -> None: + """ + Validate constitution completeness (Spec-Kit compatibility). + + This command validates a constitution in Spec-Kit format (`.specify/memory/constitution.md`) + for compatibility with Spec-Kit artifacts and sync operations. + + **Note**: SpecFact itself uses plan bundles (`.specfact/plans/*.bundle.<format>`) for internal + operations. Constitutions are only needed when syncing with Spec-Kit or working in Spec-Kit format. + + Checks if the constitution is complete (no placeholders, has principles, + has governance section, etc.). + + Example: + specfact sdd constitution validate + specfact sdd constitution validate --constitution custom-constitution.md + """ + from specfact_cli.telemetry import telemetry + + with telemetry.track_command("sdd.constitution.validate", {"constitution": str(constitution)}): + console.print(f"[bold cyan]Validating constitution:[/bold cyan] {constitution}") + + enricher = ConstitutionEnricher() + is_valid, issues = enricher.validate(constitution) + + if is_valid: + print_success("✓ Constitution is valid and complete") + else: + print_error("✗ Constitution validation failed") + console.print("\n[bold]Issues found:[/bold]") + for issue in issues: + console.print(f" - {issue}") + + console.print("\n[bold]Next Steps:[/bold]") + console.print("1. Run 'specfact sdd constitution bootstrap' to generate a complete constitution") + console.print("2. Or run 'specfact sdd constitution enrich' to enrich existing constitution") + raise typer.Exit(1) + + +def is_constitution_minimal(constitution_path: Path) -> bool: + """ + Check if constitution is minimal (essentially empty). + + Args: + constitution_path: Path to constitution file + + Returns: + True if constitution is minimal, False otherwise + """ + if not constitution_path.exists(): + return True + + try: + content = constitution_path.read_text(encoding="utf-8").strip() + # Check if it's just a header or very minimal + if not content or content == "# Constitution" or len(content) < 100: + return True + + # Check if it has mostly placeholders + import re + + placeholder_pattern = r"\[[A-Z_0-9]+\]" + placeholders = re.findall(placeholder_pattern, content) + lines = [line.strip() for line in content.split("\n") if line.strip()] + return bool(lines and len(placeholders) > len(lines) * 0.5) + except Exception: + return True diff --git a/src/specfact_cli/modules/spec/src/__init__.py b/src/specfact_cli/modules/spec/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/spec/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/spec/src/app.py b/src/specfact_cli/modules/spec/src/app.py index abf8ccbf..73d91869 100644 --- a/src/specfact_cli/modules/spec/src/app.py +++ b/src/specfact_cli/modules/spec/src/app.py @@ -1,6 +1,6 @@ -"""Spec command: re-export from commands package.""" +"""spec command entrypoint.""" -from specfact_cli.commands.spec import app +from specfact_cli.modules.spec.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/spec/src/commands.py b/src/specfact_cli/modules/spec/src/commands.py new file mode 100644 index 00000000..934e0a3b --- /dev/null +++ b/src/specfact_cli/modules/spec/src/commands.py @@ -0,0 +1,897 @@ +""" +Spec command - Specmatic integration for API contract testing. + +This module provides commands for validating OpenAPI/AsyncAPI specifications, +checking backward compatibility, generating test suites, and running mock servers +using Specmatic. +""" + +from __future__ import annotations + +import hashlib +import json +from contextlib import suppress +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.console import Console +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn +from rich.table import Table + +from specfact_cli.integrations.specmatic import ( + check_backward_compatibility, + check_specmatic_available, + create_mock_server, + generate_specmatic_tests, + validate_spec_with_specmatic, +) +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.utils import print_error, print_info, print_success, print_warning, prompt_text +from specfact_cli.utils.progress import load_bundle_with_progress +from specfact_cli.utils.structure import SpecFactStructure + + +app = typer.Typer( + help="Specmatic integration for API contract testing (OpenAPI/AsyncAPI validation, backward compatibility, mock servers)" +) +console = Console() + + +@app.command("validate") +@beartype +@require(lambda spec_path: spec_path is None or spec_path.exists(), "Spec file must exist if provided") +@ensure(lambda result: result is None, "Must return None") +def validate( + # Target/Input + spec_path: Path | None = typer.Argument( + None, + help="Path to OpenAPI/AsyncAPI specification file (optional if --bundle provided)", + exists=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If provided, validates all contracts in bundle. Default: active plan from 'specfact plan select'", + ), + # Advanced + previous_version: Path | None = typer.Option( + None, + "--previous", + help="Path to previous version for backward compatibility check", + exists=True, + hidden=True, # Hidden by default, shown with --help-advanced + ), + # Behavior/Options + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Disables interactive prompts.", + ), + force: bool = typer.Option( + False, + "--force", + help="Force validation even if cached result exists (bypass cache).", + ), +) -> None: + """ + Validate OpenAPI/AsyncAPI specification using Specmatic. + + Runs comprehensive validation including: + - Schema structure validation + - Example generation test + - Backward compatibility check (if previous version provided) + + Can validate a single contract file or all contracts in a project bundle. + Uses active plan (from 'specfact plan select') as default if --bundle not provided. + + **Caching:** + Validation results are cached in `.specfact/cache/specmatic-validation.json` based on + file content hashes. Unchanged contracts are automatically skipped on subsequent runs + to improve performance. Use --force to bypass cache and re-validate all contracts. + + **Parameter Groups:** + - **Target/Input**: spec_path (optional if --bundle provided), --bundle + - **Advanced**: --previous + - **Behavior/Options**: --no-interactive, --force + + **Examples:** + specfact spec validate api/openapi.yaml + specfact spec validate api/openapi.yaml --previous api/openapi.v1.yaml + specfact spec validate --bundle legacy-api + specfact spec validate # Interactive: select from active bundle contracts + specfact spec validate --bundle legacy-api --force # Bypass cache + """ + from specfact_cli.telemetry import telemetry + + if is_debug_mode(): + debug_log_operation( + "command", + "spec validate", + "started", + extra={"spec_path": str(spec_path) if spec_path else None, "bundle": bundle}, + ) + debug_print("[dim]spec validate: started[/dim]") + + repo_path = Path(".").resolve() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo_path) + if bundle: + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + # Determine which contracts to validate + spec_paths: list[Path] = [] + + if spec_path: + # Direct file path provided + spec_paths = [spec_path] + elif bundle: + # Load all contracts from bundle + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + if is_debug_mode(): + debug_log_operation( + "command", + "spec validate", + "failed", + error=f"Project bundle not found: {bundle_dir}", + extra={"reason": "bundle_not_found", "bundle": bundle}, + ) + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + for feature_key, feature in project_bundle.features.items(): + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + spec_paths.append(contract_path) + else: + print_warning(f"Contract file not found for {feature_key}: {feature.contract}") + + if not spec_paths: + print_error("No contract files found in bundle") + raise typer.Exit(1) + + # If multiple contracts and not in non-interactive mode, show selection + if len(spec_paths) > 1 and not no_interactive: + console.print(f"\n[bold]Found {len(spec_paths)} contracts in bundle '{bundle}':[/bold]\n") + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="bold yellow", justify="right", width=4) + table.add_column("Feature", style="bold", min_width=20) + table.add_column("Contract Path", style="dim") + + for i, contract_path in enumerate(spec_paths, 1): + # Find feature key for this contract + feature_key = "Unknown" + for fk, f in project_bundle.features.items(): + if f.contract and (bundle_dir / f.contract) == contract_path: + feature_key = fk + break + + table.add_row( + str(i), + feature_key, + str(contract_path.relative_to(repo_path)), + ) + + console.print(table) + console.print() + + selection = prompt_text( + f"Select contract(s) to validate (1-{len(spec_paths)}, 'all', or 'q' to quit): " + ).strip() + + if selection.lower() in ("q", "quit", ""): + print_info("Validation cancelled") + raise typer.Exit(0) + + if selection.lower() == "all": + # Validate all contracts + pass + else: + try: + indices = [int(x.strip()) for x in selection.split(",")] + if not all(1 <= idx <= len(spec_paths) for idx in indices): + print_error(f"Invalid selection. Must be between 1 and {len(spec_paths)}") + raise typer.Exit(1) + spec_paths = [spec_paths[idx - 1] for idx in indices] + except ValueError: + print_error(f"Invalid input: {selection}. Please enter numbers separated by commas.") + raise typer.Exit(1) from None + else: + # No spec_path and no bundle - show error + print_error("Either spec_path or --bundle must be provided") + console.print("\n[bold]Options:[/bold]") + console.print(" 1. Provide a spec file: specfact spec validate api/openapi.yaml") + console.print(" 2. Use --bundle option: specfact spec validate --bundle legacy-api") + console.print(" 3. Set active plan first: specfact plan select") + raise typer.Exit(1) + + if not spec_paths: + print_error("No contracts to validate") + raise typer.Exit(1) + + telemetry_metadata = { + "spec_path": str(spec_path) if spec_path else None, + "bundle": bundle, + "contracts_count": len(spec_paths), + } + + with telemetry.track_command("spec.validate", telemetry_metadata) as record: + # Check if Specmatic is available + is_available, error_msg = check_specmatic_available() + if not is_available: + if is_debug_mode(): + debug_log_operation( + "command", + "spec validate", + "failed", + error=error_msg or "Specmatic not available", + extra={"reason": "specmatic_unavailable"}, + ) + print_error(f"Specmatic not available: {error_msg}") + console.print("\n[bold]Installation:[/bold]") + console.print("Visit https://docs.specmatic.io/ for installation instructions") + raise typer.Exit(1) + + import asyncio + from datetime import UTC, datetime + from time import time + + # Load validation cache + cache_dir = repo_path / SpecFactStructure.CACHE + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / "specmatic-validation.json" + validation_cache: dict[str, dict[str, Any]] = {} + if cache_file.exists(): + try: + validation_cache = json.loads(cache_file.read_text()) + except Exception: + validation_cache = {} + + def compute_file_hash(file_path: Path) -> str: + """Compute SHA256 hash of file content.""" + try: + return hashlib.sha256(file_path.read_bytes()).hexdigest() + except Exception: + return "" + + validated_count = 0 + failed_count = 0 + skipped_count = 0 + total_count = len(spec_paths) + + for idx, contract_path in enumerate(spec_paths, 1): + contract_relative = contract_path.relative_to(repo_path) + contract_key = str(contract_relative) + file_hash = compute_file_hash(contract_path) if contract_path.exists() else "" + cache_entry = validation_cache.get(contract_key, {}) + + # Check cache (only if no previous_version specified, as backward compat check can't be cached) + use_cache = ( + not force + and not previous_version + and file_hash + and cache_entry + and cache_entry.get("hash") == file_hash + and cache_entry.get("status") == "success" + and cache_entry.get("is_valid") is True + ) + + if use_cache: + console.print( + f"\n[dim][{idx}/{total_count}][/dim] [bold cyan]Validating specification:[/bold cyan] {contract_relative}" + ) + console.print( + f"[dim]⏭️ Skipping (cache hit - unchanged since {cache_entry.get('timestamp', 'unknown')})[/dim]" + ) + validated_count += 1 + skipped_count += 1 + continue + + console.print( + f"\n[bold yellow][{idx}/{total_count}][/bold yellow] [bold cyan]Validating specification:[/bold cyan] {contract_relative}" + ) + + # Run validation with progress + start_time = time() + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + ) as progress: + task = progress.add_task("Running Specmatic validation...", total=None) + result = asyncio.run(validate_spec_with_specmatic(contract_path, previous_version)) + elapsed = time() - start_time + progress.update(task, description=f"✓ Validation complete ({elapsed:.2f}s)") + + # Display results + table = Table(title=f"Validation Results: {contract_path.name}") + table.add_column("Check", style="cyan") + table.add_column("Status", style="magenta") + table.add_column("Details", style="white") + + # Helper to format details with truncation + def format_details(items: list[str], max_length: int = 100) -> str: + """Format list of items, truncating if too long.""" + if not items: + return "" + if len(items) == 1: + detail = items[0] + return detail[:max_length] + ("..." if len(detail) > max_length else "") + # Multiple items: show first with count + first = items[0][: max_length - 20] + if len(first) < len(items[0]): + first += "..." + return f"{first} (+{len(items) - 1} more)" if len(items) > 1 else first + + # Get errors for each check type + schema_errors = [ + e + for e in result.errors + if "schema" in e.lower() or ("validate" in e.lower() and "example" not in e.lower()) + ] + example_errors = [e for e in result.errors if "example" in e.lower()] + compat_errors = [e for e in result.errors if "backward" in e.lower() or "compatibility" in e.lower()] + + # If we can't categorize, use all errors (fallback) + if not schema_errors and not example_errors and not compat_errors and result.errors: + # Distribute errors: first for schema, second for examples, rest for compat + if len(result.errors) >= 1: + schema_errors = [result.errors[0]] + if len(result.errors) >= 2: + example_errors = [result.errors[1]] + if len(result.errors) > 2: + compat_errors = result.errors[2:] + + table.add_row( + "Schema Validation", + "✓ PASS" if result.schema_valid else "✗ FAIL", + format_details(schema_errors) if not result.schema_valid else "", + ) + + table.add_row( + "Example Generation", + "✓ PASS" if result.examples_valid else "✗ FAIL", + format_details(example_errors) if not result.examples_valid else "", + ) + + if previous_version: + # For backward compatibility, show breaking changes if available, otherwise errors + compat_details = result.breaking_changes if result.breaking_changes else compat_errors + table.add_row( + "Backward Compatibility", + "✓ PASS" if result.backward_compatible else "✗ FAIL", + format_details(compat_details) if not result.backward_compatible else "", + ) + + console.print(table) + + # Show warnings if any + if result.warnings: + console.print("\n[bold yellow]Warnings:[/bold yellow]") + for warning in result.warnings: + console.print(f" ⚠ {warning}") + + # Show all errors in detail if validation failed + if not result.is_valid and result.errors: + console.print("\n[bold red]All Errors:[/bold red]") + for i, error in enumerate(result.errors, 1): + console.print(f" {i}. {error}") + + # Update cache (only if no previous_version, as backward compat can't be cached) + if not previous_version and file_hash: + validation_cache[contract_key] = { + "hash": file_hash, + "status": "success" if result.is_valid else "failure", + "is_valid": result.is_valid, + "schema_valid": result.schema_valid, + "examples_valid": result.examples_valid, + "timestamp": datetime.now(UTC).isoformat(), + } + # Save cache after each validation + with suppress(Exception): # Don't fail validation if cache write fails + cache_file.write_text(json.dumps(validation_cache, indent=2)) + + if result.is_valid: + print_success(f"✓ Specification is valid: {contract_path.name}") + validated_count += 1 + else: + print_error(f"✗ Specification validation failed: {contract_path.name}") + if result.errors: + console.print("\n[bold]Errors:[/bold]") + for error in result.errors: + console.print(f" - {error}") + failed_count += 1 + + if is_debug_mode(): + debug_log_operation( + "command", + "spec validate", + "success", + extra={"validated": validated_count, "skipped": skipped_count, "failed": failed_count}, + ) + debug_print("[dim]spec validate: success[/dim]") + + # Summary + if len(spec_paths) > 1: + console.print("\n[bold]Summary:[/bold]") + console.print(f" Validated: {validated_count}") + if skipped_count > 0: + console.print(f" Skipped (cache): {skipped_count}") + console.print(f" Failed: {failed_count}") + + record({"validated": validated_count, "skipped": skipped_count, "failed": failed_count}) + + if failed_count > 0: + raise typer.Exit(1) + + +@app.command("backward-compat") +@beartype +@require(lambda old_spec: old_spec.exists(), "Old spec file must exist") +@require(lambda new_spec: new_spec.exists(), "New spec file must exist") +@ensure(lambda result: result is None, "Must return None") +def backward_compat( + # Target/Input + old_spec: Path = typer.Argument(..., help="Path to old specification version", exists=True), + new_spec: Path = typer.Argument(..., help="Path to new specification version", exists=True), +) -> None: + """ + Check backward compatibility between two spec versions. + + Compares the new specification against the old version to detect + breaking changes that would affect existing consumers. + + **Parameter Groups:** + - **Target/Input**: old_spec, new_spec (both required) + + **Examples:** + specfact spec backward-compat api/openapi.v1.yaml api/openapi.v2.yaml + """ + import asyncio + + from specfact_cli.telemetry import telemetry + + with telemetry.track_command("spec.backward-compat", {"old_spec": str(old_spec), "new_spec": str(new_spec)}): + # Check if Specmatic is available + is_available, error_msg = check_specmatic_available() + if not is_available: + print_error(f"Specmatic not available: {error_msg}") + raise typer.Exit(1) + + console.print("[bold cyan]Checking backward compatibility...[/bold cyan]") + console.print(f" Old: {old_spec}") + console.print(f" New: {new_spec}") + + is_compatible, breaking_changes = asyncio.run(check_backward_compatibility(old_spec, new_spec)) + + if is_compatible: + print_success("✓ Specifications are backward compatible") + else: + print_error("✗ Backward compatibility check failed") + if breaking_changes: + console.print("\n[bold]Breaking Changes:[/bold]") + for change in breaking_changes: + console.print(f" - {change}") + raise typer.Exit(1) + + +@app.command("generate-tests") +@beartype +@require(lambda spec_path: spec_path.exists() if spec_path else True, "Spec file must exist if provided") +@ensure(lambda result: result is None, "Must return None") +def generate_tests( + # Target/Input + spec_path: Path | None = typer.Argument( + None, help="Path to OpenAPI/AsyncAPI specification (optional if --bundle provided)", exists=True + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If provided, generates tests for all contracts in bundle", + ), + # Output + output_dir: Path | None = typer.Option( + None, + "--output", + "--out", + help="Output directory for generated tests (default: .specfact/specmatic-tests/)", + ), + # Behavior/Options + force: bool = typer.Option( + False, + "--force", + help="Force test generation even if cached result exists (bypass cache).", + ), +) -> None: + """ + Generate Specmatic test suite from specification. + + Auto-generates contract tests from the OpenAPI/AsyncAPI specification + that can be run to validate API implementations. Can generate tests for + a single contract file or all contracts in a project bundle. + + **Caching:** + Test generation results are cached in `.specfact/cache/specmatic-tests.json` based on + file content hashes. Unchanged contracts are automatically skipped on subsequent runs + to improve performance. Use --force to bypass cache and re-generate all tests. + + **Parameter Groups:** + - **Target/Input**: spec_path (optional if --bundle provided), --bundle + - **Output**: --output + - **Behavior/Options**: --force + + **Examples:** + specfact spec generate-tests api/openapi.yaml + specfact spec generate-tests api/openapi.yaml --output tests/specmatic/ + specfact spec generate-tests --bundle legacy-api --output tests/contract/ + specfact spec generate-tests --bundle legacy-api --force # Bypass cache + """ + from rich.console import Console + + from specfact_cli.telemetry import telemetry + from specfact_cli.utils.progress import load_bundle_with_progress + from specfact_cli.utils.structure import SpecFactStructure + + console = Console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(Path(".")) + if bundle: + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + # Validate inputs + if not spec_path and not bundle: + print_error("Either spec_path or --bundle must be provided") + raise typer.Exit(1) + + repo_path = Path(".").resolve() + spec_paths: list[Path] = [] + + # If bundle provided, load all contracts from bundle + if bundle: + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + for feature_key, feature in project_bundle.features.items(): + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + spec_paths.append(contract_path) + else: + print_warning(f"Contract file not found for {feature_key}: {feature.contract}") + elif spec_path: + spec_paths = [spec_path] + + if not spec_paths: + print_error("No contract files found to generate tests from") + raise typer.Exit(1) + + telemetry_metadata = { + "spec_path": str(spec_path) if spec_path else None, + "bundle": bundle, + "contracts_count": len(spec_paths), + } + + with telemetry.track_command("spec.generate-tests", telemetry_metadata) as record: + # Check if Specmatic is available + is_available, error_msg = check_specmatic_available() + if not is_available: + print_error(f"Specmatic not available: {error_msg}") + raise typer.Exit(1) + + import asyncio + from datetime import UTC, datetime + + # Load test generation cache + cache_dir = repo_path / SpecFactStructure.CACHE + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = cache_dir / "specmatic-tests.json" + test_cache: dict[str, dict[str, Any]] = {} + if cache_file.exists(): + try: + test_cache = json.loads(cache_file.read_text()) + except Exception: + test_cache = {} + + def compute_file_hash(file_path: Path) -> str: + """Compute SHA256 hash of file content.""" + try: + return hashlib.sha256(file_path.read_bytes()).hexdigest() + except Exception: + return "" + + generated_count = 0 + failed_count = 0 + skipped_count = 0 + total_count = len(spec_paths) + + for idx, contract_path in enumerate(spec_paths, 1): + contract_relative = contract_path.relative_to(repo_path) + contract_key = str(contract_relative) + file_hash = compute_file_hash(contract_path) if contract_path.exists() else "" + cache_entry = test_cache.get(contract_key, {}) + + # Check cache + use_cache = ( + not force + and file_hash + and cache_entry + and cache_entry.get("hash") == file_hash + and cache_entry.get("status") == "success" + and cache_entry.get("output_dir") == str(output_dir or Path(".specfact/specmatic-tests")) + ) + + if use_cache: + console.print( + f"\n[dim][{idx}/{total_count}][/dim] [bold cyan]Generating test suite from:[/bold cyan] {contract_relative}" + ) + console.print( + f"[dim]⏭️ Skipping (cache hit - unchanged since {cache_entry.get('timestamp', 'unknown')})[/dim]" + ) + generated_count += 1 + skipped_count += 1 + continue + + console.print( + f"\n[bold yellow][{idx}/{total_count}][/bold yellow] [bold cyan]Generating test suite from:[/bold cyan] {contract_relative}" + ) + + try: + output = asyncio.run(generate_specmatic_tests(contract_path, output_dir)) + print_success(f"✓ Test suite generated: {output}") + + # Update cache + if file_hash: + test_cache[contract_key] = { + "hash": file_hash, + "status": "success", + "output_dir": str(output_dir or Path(".specfact/specmatic-tests")), + "timestamp": datetime.now(UTC).isoformat(), + } + # Save cache after each generation + with suppress(Exception): # Don't fail if cache write fails + cache_file.write_text(json.dumps(test_cache, indent=2)) + + generated_count += 1 + except Exception as e: + print_error(f"✗ Test generation failed for {contract_path.name}: {e!s}") + + # Update cache with failure (so we don't skip failed contracts) + if file_hash: + test_cache[contract_key] = { + "hash": file_hash, + "status": "failure", + "output_dir": str(output_dir or Path(".specfact/specmatic-tests")), + "timestamp": datetime.now(UTC).isoformat(), + } + with suppress(Exception): + cache_file.write_text(json.dumps(test_cache, indent=2)) + + failed_count += 1 + + # Summary + if generated_count > 0: + console.print(f"\n[bold green]✓[/bold green] Generated tests for {generated_count} contract(s)") + if skipped_count > 0: + console.print(f"[dim] Skipped (cache): {skipped_count}[/dim]") + console.print("[dim]Run the generated tests to validate your API implementation[/dim]") + + if failed_count > 0: + print_warning(f"Failed to generate tests for {failed_count} contract(s)") + if generated_count == 0: + raise typer.Exit(1) + + record({"generated": generated_count, "skipped": skipped_count, "failed": failed_count}) + + +@app.command("mock") +@beartype +@require(lambda spec_path: spec_path is None or spec_path.exists(), "Spec file must exist if provided") +@ensure(lambda result: result is None, "Must return None") +def mock( + # Target/Input + spec_path: Path | None = typer.Option( + None, + "--spec", + help="Path to OpenAPI/AsyncAPI specification (optional if --bundle provided)", + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name (e.g., legacy-api). If provided, selects contract from bundle. Default: active plan from 'specfact plan select'", + ), + # Behavior/Options + port: int = typer.Option(9000, "--port", help="Port number for mock server (default: 9000)"), + strict: bool = typer.Option( + True, + "--strict/--examples", + help="Use strict validation mode (default: strict)", + ), + no_interactive: bool = typer.Option( + False, + "--no-interactive", + help="Non-interactive mode (for CI/CD automation). Uses first contract if multiple available.", + ), +) -> None: + """ + Launch Specmatic mock server from specification. + + Starts a mock server that responds to API requests based on the + OpenAPI/AsyncAPI specification. Useful for frontend development + without a running backend. Can use a single spec file or select from bundle contracts. + + **Parameter Groups:** + - **Target/Input**: --spec (optional if --bundle provided), --bundle + - **Behavior/Options**: --port, --strict/--examples, --no-interactive + + **Examples:** + specfact spec mock --spec api/openapi.yaml + specfact spec mock --spec api/openapi.yaml --port 8080 + specfact spec mock --spec api/openapi.yaml --examples + specfact spec mock --bundle legacy-api # Interactive selection + specfact spec mock --bundle legacy-api --no-interactive # Uses first contract + """ + from specfact_cli.telemetry import telemetry + + repo_path = Path(".").resolve() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo_path) + if bundle: + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + # Determine which spec to use + selected_spec: Path | None = None + + if spec_path: + # Direct spec file provided + selected_spec = spec_path + elif bundle: + # Load contracts from bundle + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + if not bundle_dir.exists(): + print_error(f"Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + spec_paths: list[Path] = [] + feature_map: dict[str, str] = {} # contract_path -> feature_key + + for feature_key, feature in project_bundle.features.items(): + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + spec_paths.append(contract_path) + feature_map[str(contract_path)] = feature_key + + if not spec_paths: + print_error("No contract files found in bundle") + raise typer.Exit(1) + + if len(spec_paths) == 1: + # Only one contract, use it + selected_spec = spec_paths[0] + elif no_interactive: + # Non-interactive mode, use first contract + selected_spec = spec_paths[0] + console.print(f"[dim]Using first contract: {feature_map[str(selected_spec)]}[/dim]") + else: + # Interactive selection + console.print(f"\n[bold]Found {len(spec_paths)} contracts in bundle '{bundle}':[/bold]\n") + table = Table(show_header=True, header_style="bold cyan") + table.add_column("#", style="bold yellow", justify="right", width=4) + table.add_column("Feature", style="bold", min_width=20) + table.add_column("Contract Path", style="dim") + + for i, contract_path in enumerate(spec_paths, 1): + feature_key = feature_map.get(str(contract_path), "Unknown") + table.add_row( + str(i), + feature_key, + str(contract_path.relative_to(repo_path)), + ) + + console.print(table) + console.print() + + selection = prompt_text( + f"Select contract to use for mock server (1-{len(spec_paths)} or 'q' to quit): " + ).strip() + + if selection.lower() in ("q", "quit", ""): + print_info("Mock server cancelled") + raise typer.Exit(0) + + try: + idx = int(selection) + if not (1 <= idx <= len(spec_paths)): + print_error(f"Invalid selection. Must be between 1 and {len(spec_paths)}") + raise typer.Exit(1) + selected_spec = spec_paths[idx - 1] + except ValueError: + print_error(f"Invalid input: {selection}. Please enter a number.") + raise typer.Exit(1) from None + else: + # Auto-detect spec if not provided + common_names = [ + "openapi.yaml", + "openapi.yml", + "openapi.json", + "asyncapi.yaml", + "asyncapi.yml", + "asyncapi.json", + ] + for name in common_names: + candidate = Path(name) + if candidate.exists(): + selected_spec = candidate + break + + if selected_spec is None: + print_error("No specification file found. Please provide --spec or --bundle option.") + console.print("\n[bold]Options:[/bold]") + console.print(" 1. Provide a spec file: specfact spec mock --spec api/openapi.yaml") + console.print(" 2. Use --bundle option: specfact spec mock --bundle legacy-api") + console.print(" 3. Set active plan first: specfact plan select") + console.print("\n[bold]Common locations for auto-detection:[/bold]") + console.print(" - openapi.yaml") + console.print(" - api/openapi.yaml") + console.print(" - specs/openapi.yaml") + raise typer.Exit(1) + + telemetry_metadata = { + "spec_path": str(selected_spec) if selected_spec else None, + "bundle": bundle, + "port": port, + } + + with telemetry.track_command("spec.mock", telemetry_metadata): + # Check if Specmatic is available + is_available, error_msg = check_specmatic_available() + if not is_available: + print_error(f"Specmatic not available: {error_msg}") + raise typer.Exit(1) + + console.print("[bold cyan]Starting mock server...[/bold cyan]") + console.print(f" Spec: {selected_spec.relative_to(repo_path)}") + console.print(f" Port: {port}") + console.print(f" Mode: {'strict' if strict else 'examples'}") + + import asyncio + + try: + mock_server = asyncio.run(create_mock_server(selected_spec, port=port, strict_mode=strict)) + print_success(f"✓ Mock server started at http://localhost:{port}") + console.print("\n[bold]Available endpoints:[/bold]") + console.print(f" Try: curl http://localhost:{port}/actuator/health") + console.print("\n[yellow]Press Ctrl+C to stop the server[/yellow]") + + # Keep running until interrupted + try: + import time + + while mock_server.is_running(): + time.sleep(1) + except KeyboardInterrupt: + console.print("\n[yellow]Stopping mock server...[/yellow]") + mock_server.stop() + print_success("✓ Mock server stopped") + except Exception as e: + print_error(f"✗ Failed to start mock server: {e!s}") + raise typer.Exit(1) from e diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index d649496e..97a4f1a0 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -6,5 +6,7 @@ commands: command_help: sync: "Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.)" pip_dependencies: [] -module_dependencies: [] +module_dependencies: + - plan + - sdd tier: community diff --git a/src/specfact_cli/modules/sync/src/__init__.py b/src/specfact_cli/modules/sync/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/sync/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/sync/src/app.py b/src/specfact_cli/modules/sync/src/app.py index e84f78e4..a6acdf80 100644 --- a/src/specfact_cli/modules/sync/src/app.py +++ b/src/specfact_cli/modules/sync/src/app.py @@ -1,6 +1,6 @@ -"""Sync command: re-export from commands package.""" +"""sync command entrypoint.""" -from specfact_cli.commands.sync import app +from specfact_cli.modules.sync.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/sync/src/commands.py b/src/specfact_cli/modules/sync/src/commands.py new file mode 100644 index 00000000..651984a2 --- /dev/null +++ b/src/specfact_cli/modules/sync/src/commands.py @@ -0,0 +1,2438 @@ +""" +Sync command - Bidirectional synchronization for external tools and repositories. + +This module provides commands for synchronizing changes between external tool artifacts +(e.g., Spec-Kit, Linear, Jira), repository changes, and SpecFact plans using the +bridge architecture. +""" + +from __future__ import annotations + +import os +import re +import shutil +from pathlib import Path +from typing import Any + +import typer +from beartype import beartype +from icontract import ensure, require +from rich.progress import Progress, SpinnerColumn, TextColumn, TimeElapsedColumn + +from specfact_cli import runtime +from specfact_cli.adapters.registry import AdapterRegistry +from specfact_cli.models.bridge import AdapterType +from specfact_cli.models.plan import Feature, PlanBundle +from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode +from specfact_cli.telemetry import telemetry +from specfact_cli.utils.terminal import get_progress_config + + +app = typer.Typer( + help="Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, Linear, Jira, etc.). See 'specfact backlog refine' for template-driven backlog refinement." +) +console = get_configured_console() + + +@beartype +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def _is_test_mode() -> bool: + """Check if running in test mode.""" + # Check for TEST_MODE environment variable + if os.environ.get("TEST_MODE") == "true": + return True + # Check if running under pytest (common patterns) + import sys + + return any("pytest" in arg or "test" in arg.lower() for arg in sys.argv) or "pytest" in sys.modules + + +@beartype +@require(lambda selection: isinstance(selection, str), "Selection must be string") +@ensure(lambda result: isinstance(result, list), "Must return list") +def _parse_backlog_selection(selection: str) -> list[str]: + """Parse backlog selection string into a list of IDs/URLs.""" + if not selection: + return [] + parts = re.split(r"[,\n\r]+", selection) + return [part.strip() for part in parts if part.strip()] + + +@beartype +@require(lambda repo: isinstance(repo, Path), "Repo must be Path") +@ensure(lambda result: result is None or isinstance(result, str), "Must return None or string") +def _infer_bundle_name(repo: Path) -> str | None: + """Infer bundle name from active config or single bundle directory.""" + from specfact_cli.utils.structure import SpecFactStructure + + active_bundle = SpecFactStructure.get_active_bundle_name(repo) + if active_bundle: + return active_bundle + + projects_dir = repo / SpecFactStructure.PROJECTS + if projects_dir.exists(): + candidates = [ + bundle_dir.name + for bundle_dir in projects_dir.iterdir() + if bundle_dir.is_dir() and (bundle_dir / "bundle.manifest.yaml").exists() + ] + if len(candidates) == 1: + return candidates[0] + + return None + + +@beartype +@require(lambda plan: isinstance(plan, Path), "Plan must be Path") +@ensure(lambda result: result is None or isinstance(result, str), "Must return None or string") +def _extract_bundle_name_from_plan_path(plan: Path) -> str | None: + """Extract a modular bundle name from a plan path when possible.""" + plan_str = str(plan) + if "/projects/" in plan_str: + parts = plan_str.split("/projects/", 1) + if len(parts) > 1: + bundle_candidate = parts[1].split("/", 1)[0].strip() + if bundle_candidate: + return bundle_candidate + return None + + +@beartype +@require(lambda repo: isinstance(repo, Path), "Repo must be Path") +@require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool") +@require(lambda plan: plan is None or isinstance(plan, Path), "Plan must be None or Path") +@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") +@require(lambda watch: isinstance(watch, bool), "Watch must be bool") +@require(lambda interval: isinstance(interval, int) and interval >= 1, "Interval must be int >= 1") +@ensure(lambda result: result is None, "Must return None") +def sync_spec_kit( + repo: Path, + bidirectional: bool = False, + plan: Path | None = None, + overwrite: bool = False, + watch: bool = False, + interval: int = 5, +) -> None: + """ + Compatibility helper for callers that previously imported `sync_spec_kit`. + + Delegates to `sync bridge --adapter speckit` with concrete Python defaults, + avoiding direct invocation of Typer `OptionInfo` defaults. + """ + bundle = _extract_bundle_name_from_plan_path(plan) if plan is not None else None + if bundle is None: + bundle = _infer_bundle_name(repo) + + sync_bridge( + repo=repo, + bundle=bundle, + bidirectional=bidirectional, + mode=None, + overwrite=overwrite, + watch=watch, + ensure_compliance=False, + adapter="speckit", + repo_owner=None, + repo_name=None, + external_base_path=None, + github_token=None, + use_gh_cli=True, + ado_org=None, + ado_project=None, + ado_base_url=None, + ado_token=None, + ado_work_item_type=None, + sanitize=None, + target_repo=None, + interactive=False, + change_ids=None, + backlog_ids=None, + backlog_ids_file=None, + export_to_tmp=False, + import_from_tmp=False, + tmp_file=None, + update_existing=False, + track_code_changes=False, + add_progress_comment=False, + code_repo=None, + include_archived=False, + interval=interval, + ) + + +@beartype +@require(lambda repo: repo.exists(), "Repository path must exist") +@require(lambda repo: repo.is_dir(), "Repository path must be a directory") +@require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool") +@require(lambda bundle: bundle is None or isinstance(bundle, str), "Bundle must be None or str") +@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") +@require(lambda adapter_type: adapter_type is not None, "Adapter type must be set") +@ensure(lambda result: result is None, "Must return None") +def _perform_sync_operation( + repo: Path, + bidirectional: bool, + bundle: str | None, + overwrite: bool, + adapter_type: AdapterType, +) -> None: + """ + Perform sync operation without watch mode. + + This is extracted to avoid recursion when called from watch mode callback. + + Args: + repo: Path to repository + bidirectional: Enable bidirectional sync + bundle: Project bundle name + overwrite: Overwrite existing tool artifacts + adapter_type: Adapter type to use + """ + # Step 1: Detect tool repository (using bridge probe for auto-detection) + from specfact_cli.utils.structure import SpecFactStructure + from specfact_cli.validators.schema import validate_plan_bundle + + # Get adapter from registry (universal pattern - no hard-coded checks) + adapter_instance = AdapterRegistry.get_adapter(adapter_type.value) + if adapter_instance is None: + console.print(f"[bold red]✗[/bold red] Adapter '{adapter_type.value}' not found in registry") + console.print("[dim]Available adapters: " + ", ".join(AdapterRegistry.list_adapters()) + "[/dim]") + raise typer.Exit(1) + + # Use adapter's detect() method (no bridge_config needed for initial detection) + if not adapter_instance.detect(repo, None): + console.print(f"[bold red]✗[/bold red] Not a {adapter_type.value} repository") + console.print(f"[dim]Expected: {adapter_type.value} structure[/dim]") + console.print("[dim]Tip: Use 'specfact sync bridge probe' to auto-detect tool configuration[/dim]") + raise typer.Exit(1) + + console.print(f"[bold green]✓[/bold green] Detected {adapter_type.value} repository") + + # Generate bridge config using adapter + bridge_config = adapter_instance.generate_bridge_config(repo) + + # Step 1.5: Validate constitution exists and is not empty (Spec-Kit only) + # Note: Constitution is required for Spec-Kit but not for other adapters (e.g., OpenSpec) + capabilities = adapter_instance.get_capabilities(repo, bridge_config) + if adapter_type == AdapterType.SPECKIT: + has_constitution = capabilities.has_custom_hooks + if not has_constitution: + console.print("[bold red]✗[/bold red] Constitution required") + console.print("[red]Constitution file not found or is empty[/red]") + console.print("\n[bold yellow]Next Steps:[/bold yellow]") + console.print("1. Run 'specfact sdd constitution bootstrap --repo .' to auto-generate constitution") + console.print("2. Or run tool-specific constitution command in your AI assistant") + console.print("3. Then run 'specfact sync bridge --adapter <adapter>' again") + raise typer.Exit(1) + + # Check if constitution is minimal and suggest bootstrap (Spec-Kit only) + if adapter_type == AdapterType.SPECKIT: + constitution_path = repo / ".specify" / "memory" / "constitution.md" + if constitution_path.exists(): + from specfact_cli.modules.sdd.src.commands import is_constitution_minimal + + if is_constitution_minimal(constitution_path): + # Auto-generate in test mode, prompt in interactive mode + # Check for test environment (TEST_MODE or PYTEST_CURRENT_TEST) + is_test_env = os.environ.get("TEST_MODE") == "true" or os.environ.get("PYTEST_CURRENT_TEST") is not None + if is_test_env: + # Auto-generate bootstrap constitution in test mode + from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher + + enricher = ConstitutionEnricher() + enriched_content = enricher.bootstrap(repo, constitution_path) + constitution_path.write_text(enriched_content, encoding="utf-8") + else: + # Check if we're in an interactive environment + if runtime.is_interactive(): + console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)") + suggest_bootstrap = typer.confirm( + "Generate bootstrap constitution from repository analysis?", + default=True, + ) + if suggest_bootstrap: + from specfact_cli.enrichers.constitution_enricher import ConstitutionEnricher + + console.print("[dim]Generating bootstrap constitution...[/dim]") + enricher = ConstitutionEnricher() + enriched_content = enricher.bootstrap(repo, constitution_path) + constitution_path.write_text(enriched_content, encoding="utf-8") + console.print("[bold green]✓[/bold green] Bootstrap constitution generated") + console.print("[dim]Review and adjust as needed before syncing[/dim]") + else: + console.print( + "[dim]Skipping bootstrap. Run 'specfact sdd constitution bootstrap' manually if needed[/dim]" + ) + else: + # Non-interactive mode: skip prompt + console.print("[yellow]⚠[/yellow] Constitution is minimal (essentially empty)") + console.print( + "[dim]Run 'specfact sdd constitution bootstrap --repo .' to generate constitution[/dim]" + ) + else: + # Constitution exists and is not minimal + console.print("[bold green]✓[/bold green] Constitution found and validated") + + # Step 2: Detect SpecFact structure + specfact_exists = (repo / SpecFactStructure.ROOT).exists() + + if not specfact_exists: + console.print("[yellow]⚠[/yellow] SpecFact structure not found") + console.print(f"[dim]Initialize with: specfact plan init --scaffold --repo {repo}[/dim]") + # Create structure automatically + SpecFactStructure.ensure_structure(repo) + console.print("[bold green]✓[/bold green] Created SpecFact structure") + + if specfact_exists: + console.print("[bold green]✓[/bold green] Detected SpecFact structure") + + # Use BridgeSync for adapter-agnostic sync operations + from specfact_cli.sync.bridge_sync import BridgeSync + + bridge_sync = BridgeSync(repo, bridge_config=bridge_config) + + # Note: _sync_tool_to_specfact now uses adapter pattern, so converter/scanner are no longer needed + + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + # Step 3: Discover features using adapter (via bridge config) + task = progress.add_task(f"[cyan]Scanning {adapter_type.value} artifacts...[/cyan]", total=None) + progress.update(task, description=f"[cyan]Scanning {adapter_type.value} artifacts...[/cyan]") + + # Discover features using adapter or bridge_sync (adapter-agnostic) + features: list[dict[str, Any]] = [] + # Use adapter's discover_features method if available (e.g., Spec-Kit adapter) + if adapter_instance and hasattr(adapter_instance, "discover_features"): + features = adapter_instance.discover_features(repo, bridge_config) + else: + # For other adapters, use bridge_sync to discover features + feature_ids = bridge_sync._discover_feature_ids() + # Convert feature_ids to feature dicts (simplified for now) + features = [{"feature_key": fid} for fid in feature_ids] + + progress.update(task, description=f"[green]✓[/green] Found {len(features)} features") + + # Step 3.5: Validate tool artifacts for unidirectional sync + if not bidirectional and len(features) == 0: + console.print(f"[bold red]✗[/bold red] No {adapter_type.value} features found") + console.print( + f"[red]Unidirectional sync ({adapter_type.value} → SpecFact) requires at least one feature specification.[/red]" + ) + console.print("\n[bold yellow]Next Steps:[/bold yellow]") + console.print(f"1. Create feature specifications in your {adapter_type.value} project") + console.print(f"2. Then run 'specfact sync bridge --adapter {adapter_type.value}' again") + console.print( + f"\n[dim]Note: For bidirectional sync, {adapter_type.value} artifacts are optional if syncing from SpecFact → {adapter_type.value}[/dim]" + ) + raise typer.Exit(1) + + # Step 4: Sync based on mode + features_converted_speckit = 0 + conflicts: list[dict[str, Any]] = [] # Initialize conflicts for use in summary + + if bidirectional: + # Bidirectional sync: tool → SpecFact and SpecFact → tool + # Step 5.1: tool → SpecFact (unidirectional sync) + # Skip expensive conversion if no tool features found (optimization) + merged_bundle: PlanBundle | None = None + features_updated = 0 + features_added = 0 + + if len(features) == 0: + task = progress.add_task(f"[cyan]📝[/cyan] Converting {adapter_type.value} → SpecFact...", total=None) + progress.update( + task, + description=f"[green]✓[/green] Skipped (no {adapter_type.value} features found)", + ) + console.print(f"[dim] - Skipped {adapter_type.value} → SpecFact (no features found)[/dim]") + # Use existing plan bundle if available, otherwise create minimal empty one + from specfact_cli.utils.structure import SpecFactStructure + from specfact_cli.validators.schema import validate_plan_bundle + + # Use get_default_plan_path() to find the active plan (checks config or falls back to main.bundle.yaml) + plan_path = SpecFactStructure.get_default_plan_path(repo) + if plan_path and plan_path.exists(): + # Show progress while loading plan bundle + progress.update(task, description="[cyan]Parsing plan bundle YAML...[/cyan]") + # Check if path is a directory (modular bundle) - load it first + if plan_path.is_dir(): + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.progress import load_bundle_with_progress + + project_bundle = load_bundle_with_progress( + plan_path, + validate_hashes=False, + console_instance=progress.console if hasattr(progress, "console") else None, + ) + loaded_plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + is_valid = True + else: + # It's a file (legacy monolithic bundle) - validate directly + validation_result = validate_plan_bundle(plan_path) + if isinstance(validation_result, tuple): + is_valid, _error, loaded_plan_bundle = validation_result + else: + is_valid = False + loaded_plan_bundle = None + if is_valid and loaded_plan_bundle: + # Show progress during validation (Pydantic validation can be slow for large bundles) + progress.update( + task, + description=f"[cyan]Validating {len(loaded_plan_bundle.features)} features...[/cyan]", + ) + merged_bundle = loaded_plan_bundle + progress.update( + task, + description=f"[green]✓[/green] Loaded plan bundle ({len(loaded_plan_bundle.features)} features)", + ) + else: + # Fallback: create minimal bundle via adapter (but skip expensive parsing) + progress.update( + task, description=f"[cyan]Creating plan bundle from {adapter_type.value}...[/cyan]" + ) + merged_bundle = _sync_tool_to_specfact( + repo, adapter_instance, bridge_config, bridge_sync, progress, task + )[0] + else: + # No plan path found, create minimal bundle + progress.update(task, description=f"[cyan]Creating plan bundle from {adapter_type.value}...[/cyan]") + merged_bundle = _sync_tool_to_specfact( + repo, adapter_instance, bridge_config, bridge_sync, progress, task + )[0] + else: + task = progress.add_task(f"[cyan]Converting {adapter_type.value} → SpecFact...[/cyan]", total=None) + # Show current activity (spinner will show automatically) + progress.update(task, description=f"[cyan]Converting {adapter_type.value} → SpecFact...[/cyan]") + merged_bundle, features_updated, features_added = _sync_tool_to_specfact( + repo, adapter_instance, bridge_config, bridge_sync, progress + ) + + if merged_bundle: + if features_updated > 0 or features_added > 0: + progress.update( + task, + description=f"[green]✓[/green] Updated {features_updated}, Added {features_added} features", + ) + console.print(f"[dim] - Updated {features_updated} features[/dim]") + console.print(f"[dim] - Added {features_added} new features[/dim]") + else: + progress.update( + task, + description=f"[green]✓[/green] Created plan with {len(merged_bundle.features)} features", + ) + + # Step 5.2: SpecFact → tool (reverse conversion) + task = progress.add_task(f"[cyan]Converting SpecFact → {adapter_type.value}...[/cyan]", total=None) + # Show current activity (spinner will show automatically) + progress.update(task, description="[cyan]Detecting SpecFact changes...[/cyan]") + + # Detect SpecFact changes (for tracking/incremental sync, but don't block conversion) + # Uses adapter's change detection if available (adapter-agnostic) + + # Use the merged_bundle we already loaded, or load it if not available + # We convert even if no "changes" detected, as long as plan bundle exists and has features + plan_bundle_to_convert: PlanBundle | None = None + + # Prefer using merged_bundle if it has features (already loaded above) + if merged_bundle and len(merged_bundle.features) > 0: + plan_bundle_to_convert = merged_bundle + else: + # Fallback: load plan bundle from bundle name or default + plan_bundle_to_convert = None + if bundle: + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.progress import load_bundle_with_progress + + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if bundle_dir.exists(): + project_bundle = load_bundle_with_progress( + bundle_dir, validate_hashes=False, console_instance=console + ) + plan_bundle_to_convert = _convert_project_bundle_to_plan_bundle(project_bundle) + else: + # Use get_default_plan_path() to find the active plan (legacy compatibility) + plan_path: Path | None = None + if hasattr(SpecFactStructure, "get_default_plan_path"): + plan_path = SpecFactStructure.get_default_plan_path(repo) + if plan_path and plan_path.exists(): + progress.update(task, description="[cyan]Loading plan bundle...[/cyan]") + # Check if path is a directory (modular bundle) - load it first + if plan_path.is_dir(): + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.progress import load_bundle_with_progress + + project_bundle = load_bundle_with_progress( + plan_path, + validate_hashes=False, + console_instance=progress.console if hasattr(progress, "console") else None, + ) + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + is_valid = True + else: + # It's a file (legacy monolithic bundle) - validate directly + validation_result = validate_plan_bundle(plan_path) + if isinstance(validation_result, tuple): + is_valid, _error, plan_bundle = validation_result + else: + is_valid = False + plan_bundle = None + if is_valid and plan_bundle and len(plan_bundle.features) > 0: + plan_bundle_to_convert = plan_bundle + + # Convert if we have a plan bundle with features + if plan_bundle_to_convert and len(plan_bundle_to_convert.features) > 0: + # Handle overwrite mode + if overwrite: + progress.update(task, description="[cyan]Removing existing artifacts...[/cyan]") + # Delete existing tool artifacts before conversion + specs_dir = repo / "specs" + if specs_dir.exists(): + console.print( + f"[yellow]⚠[/yellow] Overwrite mode: Removing existing {adapter_type.value} artifacts..." + ) + shutil.rmtree(specs_dir) + specs_dir.mkdir(parents=True, exist_ok=True) + console.print("[green]✓[/green] Existing artifacts removed") + + # Convert SpecFact plan bundle to tool format + total_features = len(plan_bundle_to_convert.features) + progress.update( + task, + description=f"[cyan]Converting plan bundle to {adapter_type.value} format (0 of {total_features})...[/cyan]", + ) + + # Progress callback to update during conversion + def update_progress(current: int, total: int) -> None: + progress.update( + task, + description=f"[cyan]Converting plan bundle to {adapter_type.value} format ({current} of {total})...[/cyan]", + ) + + # Use adapter's export_bundle method (adapter-agnostic) + if adapter_instance and hasattr(adapter_instance, "export_bundle"): + features_converted_speckit = adapter_instance.export_bundle( + plan_bundle_to_convert, repo, update_progress, bridge_config + ) + else: + msg = "Bundle export not available for this adapter" + raise RuntimeError(msg) + progress.update( + task, + description=f"[green]✓[/green] Converted {features_converted_speckit} features to {adapter_type.value}", + ) + mode_text = "overwritten" if overwrite else "generated" + console.print( + f"[dim] - {mode_text.capitalize()} spec.md, plan.md, tasks.md for {features_converted_speckit} features[/dim]" + ) + # Warning about Constitution Check gates + console.print( + "[yellow]⚠[/yellow] [dim]Note: Constitution Check gates in plan.md are set to PENDING - review and check gates based on your project's actual state[/dim]" + ) + else: + progress.update(task, description=f"[green]✓[/green] No features to convert to {adapter_type.value}") + features_converted_speckit = 0 + + # Detect conflicts between both directions using adapter + if ( + adapter_instance + and hasattr(adapter_instance, "detect_changes") + and hasattr(adapter_instance, "detect_conflicts") + ): + # Detect changes in both directions + changes_result = adapter_instance.detect_changes(repo, direction="both", bridge_config=bridge_config) + speckit_changes = changes_result.get("speckit_changes", {}) + specfact_changes = changes_result.get("specfact_changes", {}) + # Detect conflicts + conflicts = adapter_instance.detect_conflicts(speckit_changes, specfact_changes) + else: + # Fallback: no conflict detection available + conflicts = [] + + if conflicts: + console.print(f"[yellow]⚠[/yellow] Found {len(conflicts)} conflicts") + console.print( + f"[dim]Conflicts resolved using priority rules (SpecFact > {adapter_type.value} for artifacts)[/dim]" + ) + else: + console.print("[bold green]✓[/bold green] No conflicts detected") + else: + # Unidirectional sync: tool → SpecFact + task = progress.add_task("[cyan]Converting to SpecFact format...[/cyan]", total=None) + # Show current activity (spinner will show automatically) + progress.update(task, description="[cyan]Converting to SpecFact format...[/cyan]") + + merged_bundle, features_updated, features_added = _sync_tool_to_specfact( + repo, adapter_instance, bridge_config, bridge_sync, progress + ) + + if features_updated > 0 or features_added > 0: + task = progress.add_task("[cyan]🔀[/cyan] Merging with existing plan...", total=None) + progress.update( + task, + description=f"[green]✓[/green] Updated {features_updated} features, Added {features_added} features", + ) + console.print(f"[dim] - Updated {features_updated} features[/dim]") + console.print(f"[dim] - Added {features_added} new features[/dim]") + else: + if merged_bundle: + progress.update( + task, description=f"[green]✓[/green] Created plan with {len(merged_bundle.features)} features" + ) + console.print(f"[dim]Created plan with {len(merged_bundle.features)} features[/dim]") + + # Report features synced + console.print() + if features: + console.print("[bold cyan]Features synced:[/bold cyan]") + for feature in features: + feature_key = feature.get("feature_key", "UNKNOWN") + feature_title = feature.get("title", "Unknown Feature") + console.print(f" - [cyan]{feature_key}[/cyan]: {feature_title}") + + # Step 8: Output Results + console.print() + if bidirectional: + console.print("[bold cyan]Sync Summary (Bidirectional):[/bold cyan]") + console.print( + f" - {adapter_type.value} → SpecFact: Updated {features_updated}, Added {features_added} features" + ) + # Always show conversion result (we convert if plan bundle exists, not just when changes detected) + if features_converted_speckit > 0: + console.print( + f" - SpecFact → {adapter_type.value}: {features_converted_speckit} features converted to {adapter_type.value} format" + ) + else: + console.print(f" - SpecFact → {adapter_type.value}: No features to convert") + if conflicts: + console.print(f" - Conflicts: {len(conflicts)} detected and resolved") + else: + console.print(" - Conflicts: None detected") + + # Post-sync validation suggestion + if features_converted_speckit > 0: + console.print() + console.print("[bold cyan]Next Steps:[/bold cyan]") + console.print(f" Validate {adapter_type.value} artifact consistency and quality") + console.print(" This will check for ambiguities, duplications, and constitution alignment") + else: + console.print("[bold cyan]Sync Summary (Unidirectional):[/bold cyan]") + if features: + console.print(f" - Features synced: {len(features)}") + if features_updated > 0 or features_added > 0: + console.print(f" - Updated: {features_updated} features") + console.print(f" - Added: {features_added} new features") + console.print(f" - Direction: {adapter_type.value} → SpecFact") + + # Post-sync validation suggestion + console.print() + console.print("[bold cyan]Next Steps:[/bold cyan]") + console.print(f" Validate {adapter_type.value} artifact consistency and quality") + console.print(" This will check for ambiguities, duplications, and constitution alignment") + + console.print() + console.print("[bold green]✓[/bold green] Sync complete!") + + # Auto-validate OpenAPI/AsyncAPI specs with Specmatic (if found) + import asyncio + + from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic + + spec_files = [] + for pattern in [ + "**/openapi.yaml", + "**/openapi.yml", + "**/openapi.json", + "**/asyncapi.yaml", + "**/asyncapi.yml", + "**/asyncapi.json", + ]: + spec_files.extend(repo.glob(pattern)) + + if spec_files: + console.print(f"\n[cyan]🔍 Found {len(spec_files)} API specification file(s)[/cyan]") + is_available, error_msg = check_specmatic_available() + if is_available: + for spec_file in spec_files[:3]: # Validate up to 3 specs + console.print(f"[dim]Validating {spec_file.relative_to(repo)} with Specmatic...[/dim]") + try: + result = asyncio.run(validate_spec_with_specmatic(spec_file)) + if result.is_valid: + console.print(f" [green]✓[/green] {spec_file.name} is valid") + else: + console.print(f" [yellow]⚠[/yellow] {spec_file.name} has validation issues") + if result.errors: + for error in result.errors[:2]: # Show first 2 errors + console.print(f" - {error}") + except Exception as e: + console.print(f" [yellow]⚠[/yellow] Validation error: {e!s}") + if len(spec_files) > 3: + console.print( + f"[dim]... and {len(spec_files) - 3} more spec file(s) (run 'specfact spec validate' to validate all)[/dim]" + ) + else: + console.print(f"[dim]💡 Tip: Install Specmatic to validate API specs: {error_msg}[/dim]") + + +@beartype +@require(lambda repo: repo.exists(), "Repository path must exist") +@require(lambda repo: repo.is_dir(), "Repository path must be a directory") +@require(lambda adapter_instance: adapter_instance is not None, "Adapter instance must not be None") +@require(lambda bridge_config: bridge_config is not None, "Bridge config must not be None") +@require(lambda bridge_sync: bridge_sync is not None, "Bridge sync must not be None") +@require(lambda progress: progress is not None, "Progress must not be None") +@require(lambda task: task is None or (isinstance(task, int) and task >= 0), "Task must be None or non-negative int") +@ensure(lambda result: isinstance(result, tuple) and len(result) == 3, "Must return tuple of 3 elements") +@ensure(lambda result: isinstance(result[0], PlanBundle), "First element must be PlanBundle") +@ensure(lambda result: isinstance(result[1], int) and result[1] >= 0, "Second element must be non-negative int") +@ensure(lambda result: isinstance(result[2], int) and result[2] >= 0, "Third element must be non-negative int") +def _sync_tool_to_specfact( + repo: Path, + adapter_instance: Any, + bridge_config: Any, + bridge_sync: Any, + progress: Any, + task: int | None = None, +) -> tuple[PlanBundle, int, int]: + """ + Sync tool artifacts to SpecFact format using adapter registry pattern. + + This is an adapter-agnostic replacement for _sync_speckit_to_specfact that uses + the adapter registry instead of hard-coded converter/scanner instances. + + Args: + repo: Repository path + adapter_instance: Adapter instance from registry + bridge_config: Bridge configuration + bridge_sync: BridgeSync instance + progress: Rich Progress instance + task: Optional progress task ID to update + + Returns: + Tuple of (merged_bundle, features_updated, features_added) + """ + from specfact_cli.generators.plan_generator import PlanGenerator + from specfact_cli.utils.structure import SpecFactStructure + from specfact_cli.validators.schema import validate_plan_bundle + + plan_path = SpecFactStructure.get_default_plan_path(repo) + existing_bundle: PlanBundle | None = None + # Check if plan_path is a modular bundle directory (even if it doesn't exist yet) + is_modular_bundle = (plan_path.exists() and plan_path.is_dir()) or ( + not plan_path.exists() and plan_path.parent.name == "projects" + ) + + if plan_path.exists(): + if task is not None: + progress.update(task, description="[cyan]Validating existing plan bundle...[/cyan]") + # Check if path is a directory (modular bundle) - load it first + if plan_path.is_dir(): + is_modular_bundle = True + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.progress import load_bundle_with_progress + + project_bundle = load_bundle_with_progress( + plan_path, + validate_hashes=False, + console_instance=progress.console if hasattr(progress, "console") else None, + ) + bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + is_valid = True + else: + # It's a file (legacy monolithic bundle) - validate directly + validation_result = validate_plan_bundle(plan_path) + if isinstance(validation_result, tuple): + is_valid, _error, bundle = validation_result + else: + is_valid = False + bundle = None + if is_valid and bundle: + existing_bundle = bundle + # Deduplicate existing features by normalized key (clean up duplicates from previous syncs) + from specfact_cli.utils.feature_keys import normalize_feature_key + + seen_normalized_keys: set[str] = set() + deduplicated_features: list[Feature] = [] + for existing_feature in existing_bundle.features: + normalized_key = normalize_feature_key(existing_feature.key) + if normalized_key not in seen_normalized_keys: + seen_normalized_keys.add(normalized_key) + deduplicated_features.append(existing_feature) + + duplicates_removed = len(existing_bundle.features) - len(deduplicated_features) + if duplicates_removed > 0: + existing_bundle.features = deduplicated_features + # Write back deduplicated bundle immediately to clean up the plan file + from specfact_cli.generators.plan_generator import PlanGenerator + + if task is not None: + progress.update( + task, + description=f"[cyan]Deduplicating {duplicates_removed} duplicate features and writing cleaned plan...[/cyan]", + ) + # Skip writing if plan_path is a modular bundle directory (already saved as ProjectBundle) + if not is_modular_bundle: + generator = PlanGenerator() + generator.generate(existing_bundle, plan_path) + if task is not None: + progress.update( + task, + description=f"[green]✓[/green] Removed {duplicates_removed} duplicates, cleaned plan saved", + ) + + # Convert tool artifacts to SpecFact using adapter pattern + if task is not None: + progress.update(task, description="[cyan]Converting tool artifacts to SpecFact format...[/cyan]") + + # Get default bundle name for ProjectBundle operations + from specfact_cli.utils.structure import SpecFactStructure + + bundle_name = SpecFactStructure.get_active_bundle_name(repo) or SpecFactStructure.DEFAULT_PLAN_NAME + bundle_dir = repo / SpecFactStructure.PROJECTS / bundle_name + + # Ensure bundle directory exists + bundle_dir.mkdir(parents=True, exist_ok=True) + + # Load or create ProjectBundle + from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle + from specfact_cli.utils.bundle_loader import load_project_bundle + + project_bundle: ProjectBundle | None = None + if bundle_dir.exists() and (bundle_dir / "bundle.manifest.yaml").exists(): + try: + project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) + except Exception: + # Bundle exists but failed to load - create new one + project_bundle = None + + if project_bundle is None: + # Create new ProjectBundle with latest schema version + from specfact_cli.migrations.plan_migrator import get_latest_schema_version + + manifest = BundleManifest( + versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + from specfact_cli.models.plan import Product + + project_bundle = ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + product=Product(themes=[], releases=[]), + features={}, + idea=None, + business=None, + clarifications=None, + ) + + # Discover features using adapter + discovered_features = [] + if hasattr(adapter_instance, "discover_features"): + discovered_features = adapter_instance.discover_features(repo, bridge_config) + else: + # Fallback: use bridge_sync to discover feature IDs + feature_ids = bridge_sync._discover_feature_ids() + discovered_features = [{"feature_key": fid} for fid in feature_ids] + + # Import each feature using adapter pattern + # Import artifacts in order: specification (required), then plan and tasks (if available) + artifact_order = ["specification", "plan", "tasks"] + for feature_data in discovered_features: + feature_id = feature_data.get("feature_key", "") + if not feature_id: + continue + + # Import artifacts in order (specification first, then plan/tasks if available) + for artifact_key in artifact_order: + # Check if artifact type is supported by bridge config + if artifact_key not in bridge_config.artifacts: + continue + + try: + result = bridge_sync.import_artifact(artifact_key, feature_id, bundle_name) + if not result.success and task is not None and artifact_key == "specification": + # Log error but continue with other artifacts/features + # Only show warning for specification (required), skip warnings for optional artifacts + progress.update( + task, + description=f"[yellow]⚠[/yellow] Failed to import {artifact_key} for {feature_id}: {result.errors[0] if result.errors else 'Unknown error'}", + ) + except Exception as e: + # Log error but continue + if task is not None and artifact_key == "specification": + progress.update( + task, description=f"[yellow]⚠[/yellow] Error importing {artifact_key} for {feature_id}: {e}" + ) + + # Save project bundle after all imports (BridgeSync.import_artifact saves automatically, but ensure it's saved) + from specfact_cli.utils.bundle_loader import save_project_bundle + + try: + project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) + save_project_bundle(project_bundle, bundle_dir, atomic=True) + except Exception: + # If loading fails, we'll create a new bundle below + project_bundle = None + + # Reload project bundle to get updated features (after all imports) + # BridgeSync.import_artifact saves automatically, so reload to get latest state + try: + project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) + except Exception: + # If loading fails after imports, something went wrong - create minimal bundle + if project_bundle is None: + from specfact_cli.migrations.plan_migrator import get_latest_schema_version + + manifest = BundleManifest( + versions=BundleVersions(schema=get_latest_schema_version(), project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + from specfact_cli.models.plan import Product + + project_bundle = ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + product=Product(themes=[], releases=[]), + features={}, + idea=None, + business=None, + clarifications=None, + ) + save_project_bundle(project_bundle, bundle_dir, atomic=True) + + # Convert ProjectBundle to PlanBundle for merging logic + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + + converted_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + # Merge with existing plan if it exists + features_updated = 0 + features_added = 0 + + if existing_bundle: + if task is not None: + progress.update(task, description="[cyan]Merging with existing plan bundle...[/cyan]") + # Use normalized keys for matching to handle different key formats (e.g., FEATURE-001 vs 001_FEATURE_NAME) + from specfact_cli.utils.feature_keys import normalize_feature_key + + # Build a map of normalized_key -> (index, original_key) for existing features + normalized_key_map: dict[str, tuple[int, str]] = {} + for idx, existing_feature in enumerate(existing_bundle.features): + normalized_key = normalize_feature_key(existing_feature.key) + # If multiple features have the same normalized key, keep the first one + if normalized_key not in normalized_key_map: + normalized_key_map[normalized_key] = (idx, existing_feature.key) + + for feature in converted_bundle.features: + normalized_key = normalize_feature_key(feature.key) + matched = False + + # Try exact match first + if normalized_key in normalized_key_map: + existing_idx, original_key = normalized_key_map[normalized_key] + # Preserve the original key format from existing bundle + feature.key = original_key + existing_bundle.features[existing_idx] = feature + features_updated += 1 + matched = True + else: + # Try prefix match for abbreviated vs full names + # (e.g., IDEINTEGRATION vs IDEINTEGRATIONSYSTEM) + # Only match if shorter is a PREFIX of longer with significant length difference + # AND at least one key has a numbered prefix (041_, 042-, etc.) indicating Spec-Kit origin + # This avoids false positives like SMARTCOVERAGE vs SMARTCOVERAGEMANAGER (both from code analysis) + for existing_norm_key, (existing_idx, original_key) in normalized_key_map.items(): + shorter = min(normalized_key, existing_norm_key, key=len) + longer = max(normalized_key, existing_norm_key, key=len) + + # Check if at least one key has a numbered prefix (tool format, e.g., Spec-Kit) + import re + + has_speckit_key = bool( + re.match(r"^\d{3}[_-]", feature.key) or re.match(r"^\d{3}[_-]", original_key) + ) + + # More conservative matching: + # 1. At least one key must have numbered prefix (tool origin, e.g., Spec-Kit) + # 2. Shorter must be at least 10 chars + # 3. Longer must start with shorter (prefix match) + # 4. Length difference must be at least 6 chars + # 5. Shorter must be < 75% of longer (to ensure significant difference) + length_diff = len(longer) - len(shorter) + length_ratio = len(shorter) / len(longer) if len(longer) > 0 else 1.0 + + if ( + has_speckit_key + and len(shorter) >= 10 + and longer.startswith(shorter) + and length_diff >= 6 + and length_ratio < 0.75 + ): + # Match found - use the existing key format (prefer full name if available) + if len(existing_norm_key) >= len(normalized_key): + # Existing key is longer (full name) - keep it + feature.key = original_key + else: + # New key is longer (full name) - use it but update existing + existing_bundle.features[existing_idx].key = feature.key + existing_bundle.features[existing_idx] = feature + features_updated += 1 + matched = True + break + + if not matched: + # New feature - add it + existing_bundle.features.append(feature) + features_added += 1 + + # Update product themes + themes_existing = set(existing_bundle.product.themes) + themes_new = set(converted_bundle.product.themes) + existing_bundle.product.themes = list(themes_existing | themes_new) + + # Write merged bundle (skip if modular bundle - already saved as ProjectBundle) + if not is_modular_bundle: + if task is not None: + progress.update(task, description="[cyan]Writing plan bundle to disk...[/cyan]") + generator = PlanGenerator() + generator.generate(existing_bundle, plan_path) + return existing_bundle, features_updated, features_added + # Write new bundle (skip if plan_path is a modular bundle directory) + if not is_modular_bundle: + # Legacy monolithic file - write it + generator = PlanGenerator() + generator.generate(converted_bundle, plan_path) + return converted_bundle, 0, len(converted_bundle.features) + + +@app.command("bridge") +@beartype +@require(lambda repo: repo.exists(), "Repository path must exist") +@require(lambda repo: repo.is_dir(), "Repository path must be a directory") +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle must be None or non-empty str", +) +@require(lambda bidirectional: isinstance(bidirectional, bool), "Bidirectional must be bool") +@require( + lambda mode: mode is None + or mode in ("read-only", "export-only", "import-annotation", "bidirectional", "unidirectional"), + "Mode must be valid sync mode", +) +@require(lambda overwrite: isinstance(overwrite, bool), "Overwrite must be bool") +@require( + lambda adapter: adapter is None or (isinstance(adapter, str) and len(adapter) > 0), + "Adapter must be None or non-empty str", +) +@ensure(lambda result: result is None, "Must return None") +def sync_bridge( + # Target/Input + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + bundle: str | None = typer.Option( + None, + "--bundle", + help="Project bundle name for SpecFact → tool conversion (default: auto-detect). Required for cross-adapter sync to preserve lossless content.", + ), + # Behavior/Options + bidirectional: bool = typer.Option( + False, + "--bidirectional", + help="Enable bidirectional sync (tool ↔ SpecFact)", + ), + mode: str | None = typer.Option( + None, + "--mode", + help="Sync mode: 'read-only' (OpenSpec → SpecFact), 'export-only' (SpecFact → DevOps), 'bidirectional' (tool ↔ SpecFact). Default: bidirectional if --bidirectional, else unidirectional. For backlog adapters (github/ado), use 'export-only' with --bundle for cross-adapter sync.", + ), + overwrite: bool = typer.Option( + False, + "--overwrite", + help="Overwrite existing tool artifacts (delete all existing before sync)", + ), + watch: bool = typer.Option( + False, + "--watch", + help="Watch mode for continuous sync", + ), + ensure_compliance: bool = typer.Option( + False, + "--ensure-compliance", + help="Validate and auto-enrich plan bundle for tool compliance before sync", + ), + # Advanced/Configuration + adapter: str = typer.Option( + "speckit", + "--adapter", + help="Adapter type: speckit, openspec, generic-markdown, github (available), ado (available), linear, jira, notion (future). Default: auto-detect. Use 'github' or 'ado' for backlog sync with cross-adapter capabilities (requires --bundle for lossless sync).", + hidden=True, # Hidden by default, shown with --help-advanced + ), + repo_owner: str | None = typer.Option( + None, + "--repo-owner", + help="GitHub repository owner (for GitHub adapter). Required for GitHub backlog sync.", + hidden=True, + ), + repo_name: str | None = typer.Option( + None, + "--repo-name", + help="GitHub repository name (for GitHub adapter). Required for GitHub backlog sync.", + hidden=True, + ), + external_base_path: Path | None = typer.Option( + None, + "--external-base-path", + help="Base path for external tool repository (for cross-repo integrations, e.g., OpenSpec in different repo)", + file_okay=False, + dir_okay=True, + ), + github_token: str | None = typer.Option( + None, + "--github-token", + help="GitHub API token (optional, uses GITHUB_TOKEN env var or gh CLI if not provided)", + hidden=True, + ), + use_gh_cli: bool = typer.Option( + True, + "--use-gh-cli/--no-gh-cli", + help="Use GitHub CLI (`gh auth token`) to get token automatically (default: True). Useful in enterprise environments where PAT creation is restricted.", + hidden=True, + ), + ado_org: str | None = typer.Option( + None, + "--ado-org", + help="Azure DevOps organization (for ADO adapter). Required for ADO backlog sync.", + hidden=True, + ), + ado_project: str | None = typer.Option( + None, + "--ado-project", + help="Azure DevOps project (for ADO adapter). Required for ADO backlog sync.", + hidden=True, + ), + ado_base_url: str | None = typer.Option( + None, + "--ado-base-url", + help="Azure DevOps base URL (for ADO adapter, defaults to https://dev.azure.com). Use for Azure DevOps Server (on-prem).", + hidden=True, + ), + ado_token: str | None = typer.Option( + None, + "--ado-token", + help="Azure DevOps PAT (optional, uses AZURE_DEVOPS_TOKEN env var if not provided). Requires Work Items (Read & Write) permissions.", + hidden=True, + ), + ado_work_item_type: str | None = typer.Option( + None, + "--ado-work-item-type", + help="Azure DevOps work item type (for ADO adapter, derived from process template if not provided). Examples: 'User Story', 'Product Backlog Item', 'Bug'.", + hidden=True, + ), + sanitize: bool | None = typer.Option( + None, + "--sanitize/--no-sanitize", + help="Sanitize proposal content for public issues (default: auto-detect based on repo setup). Removes competitive analysis, internal strategy, implementation details.", + hidden=True, + ), + target_repo: str | None = typer.Option( + None, + "--target-repo", + help="Target repository for issue creation (format: owner/repo). Default: same as code repository.", + hidden=True, + ), + interactive: bool = typer.Option( + False, + "--interactive", + help="Interactive mode for AI-assisted sanitization (requires slash command).", + hidden=True, + ), + change_ids: str | None = typer.Option( + None, + "--change-ids", + help="Comma-separated list of change proposal IDs to export (default: all active proposals). Use with --bundle for cross-adapter export. Example: 'add-feature-x,update-api'. Find change IDs in import output or bundle directory.", + ), + backlog_ids: str | None = typer.Option( + None, + "--backlog-ids", + help="Comma-separated list of backlog item IDs or URLs to import (GitHub/ADO). Use with --bundle to store lossless content for cross-adapter sync. Example: '123,456' or 'https://github.com/org/repo/issues/123'", + ), + backlog_ids_file: Path | None = typer.Option( + None, + "--backlog-ids-file", + help="Path to file containing backlog item IDs/URLs (one per line or comma-separated).", + exists=True, + file_okay=True, + dir_okay=False, + ), + export_to_tmp: bool = typer.Option( + False, + "--export-to-tmp", + help="Export proposal content to temporary file for LLM review (default: <system-temp>/specfact-proposal-<change-id>.md).", + hidden=True, + ), + import_from_tmp: bool = typer.Option( + False, + "--import-from-tmp", + help="Import sanitized content from temporary file after LLM review (default: <system-temp>/specfact-proposal-<change-id>-sanitized.md).", + hidden=True, + ), + tmp_file: Path | None = typer.Option( + None, + "--tmp-file", + help="Custom temporary file path (default: <system-temp>/specfact-proposal-<change-id>.md).", + hidden=True, + ), + update_existing: bool = typer.Option( + False, + "--update-existing/--no-update-existing", + help="Update existing issue bodies when proposal content changes (default: False for safety). Uses content hash to detect changes.", + hidden=True, + ), + track_code_changes: bool = typer.Option( + False, + "--track-code-changes/--no-track-code-changes", + help="Detect code changes (git commits, file modifications) and add progress comments to existing issues (default: False).", + hidden=True, + ), + add_progress_comment: bool = typer.Option( + False, + "--add-progress-comment/--no-add-progress-comment", + help="Add manual progress comment to existing issues without code change detection (default: False).", + hidden=True, + ), + code_repo: Path | None = typer.Option( + None, + "--code-repo", + help="Path to source code repository for code change detection (default: same as --repo). Required when OpenSpec repository differs from source code repository.", + hidden=True, + ), + include_archived: bool = typer.Option( + False, + "--include-archived/--no-include-archived", + help="Include archived change proposals in sync (default: False). Useful for updating existing issues with new comment logic or branch detection improvements.", + hidden=True, + ), + interval: int = typer.Option( + 5, + "--interval", + help="Watch interval in seconds (default: 5)", + min=1, + hidden=True, # Hidden by default, shown with --help-advanced + ), +) -> None: + """ + Sync changes between external tool artifacts and SpecFact using bridge architecture. + + Synchronizes artifacts from external tools (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.) with + SpecFact project bundles using configurable bridge mappings. + + **Related**: Use `specfact backlog refine` to standardize backlog items with template-driven refinement + before syncing to OpenSpec bundles. See backlog refinement guide for details. + + Supported adapters: + + - speckit: Spec-Kit projects (specs/, .specify/) - import & sync + - generic-markdown: Generic markdown-based specifications - import & sync + - openspec: OpenSpec integration (openspec/) - read-only sync (Phase 1) + - github: GitHub Issues - bidirectional sync (import issues as change proposals, export proposals as issues) + - ado: Azure DevOps Work Items - bidirectional sync (import work items as change proposals, export proposals as work items) + - linear: Linear Issues (future) - planned + - jira: Jira Issues (future) - planned + - notion: Notion pages (future) - planned + + **Sync Modes:** + + - read-only: OpenSpec → SpecFact (read specs, no writes) - OpenSpec adapter only + - bidirectional: Full two-way sync (tool ↔ SpecFact) - Spec-Kit, GitHub, and ADO adapters + - GitHub: Import issues as change proposals, export proposals as issues + - ADO: Import work items as change proposals, export proposals as work items + - Spec-Kit: Full bidirectional sync of specs and plans + - export-only: SpecFact → DevOps (create/update issues/work items, no import) - GitHub and ADO adapters + - import-annotation: DevOps → SpecFact (import issues, annotate with findings) - future + + **🚀 Cross-Adapter Sync (Advanced Feature):** + + Enable lossless round-trip synchronization between different backlog adapters (GitHub ↔ ADO): + - Use --bundle to preserve lossless content during cross-adapter syncs + - Import from one adapter (e.g., GitHub) into a bundle, then export to another (e.g., ADO) + - Content is preserved exactly as imported, enabling 100% fidelity migrations + - Example: Import GitHub issue → bundle → export to ADO (no content loss) + + **Parameter Groups:** + + - **Target/Input**: --repo, --bundle + - **Behavior/Options**: --bidirectional, --mode, --overwrite, --watch, --ensure-compliance + - **Advanced/Configuration**: --adapter, --interval, --repo-owner, --repo-name, --github-token + - **GitHub Options**: --repo-owner, --repo-name, --github-token, --use-gh-cli, --sanitize + - **ADO Options**: --ado-org, --ado-project, --ado-base-url, --ado-token, --ado-work-item-type + + **Basic Examples:** + + specfact sync bridge --adapter speckit --repo . --bidirectional + specfact sync bridge --adapter openspec --repo . --mode read-only # OpenSpec → SpecFact (read-only) + specfact sync bridge --adapter openspec --repo . --external-base-path ../other-repo # Cross-repo OpenSpec + specfact sync bridge --repo . --bidirectional # Auto-detect adapter + specfact sync bridge --repo . --watch --interval 10 + + **GitHub Examples:** + + specfact sync bridge --adapter github --bidirectional --repo-owner owner --repo-name repo # Bidirectional sync + specfact sync bridge --adapter github --mode export-only --repo-owner owner --repo-name repo # Export only + specfact sync bridge --adapter github --update-existing # Update existing issues when content changes + specfact sync bridge --adapter github --track-code-changes # Detect code changes and add progress comments + specfact sync bridge --adapter github --add-progress-comment # Add manual progress comment + + **Azure DevOps Examples:** + + specfact sync bridge --adapter ado --bidirectional --ado-org myorg --ado-project myproject # Bidirectional sync + specfact sync bridge --adapter ado --mode export-only --ado-org myorg --ado-project myproject # Export only + specfact sync bridge --adapter ado --mode export-only --ado-org myorg --ado-project myproject --bundle main # Bundle export + + **Cross-Adapter Sync Examples:** + + # GitHub → ADO Migration (lossless round-trip) + specfact sync bridge --adapter github --mode bidirectional --bundle migration --backlog-ids 123 + # Output shows: "✓ Imported GitHub issue #123 as change proposal: add-feature-x" + specfact sync bridge --adapter ado --mode export-only --bundle migration --change-ids add-feature-x + + # Multi-Tool Workflow (public GitHub + internal ADO) + specfact sync bridge --adapter github --mode export-only --sanitize # Export to public GitHub + specfact sync bridge --adapter github --mode bidirectional --bundle internal --backlog-ids 123 # Import to bundle + specfact sync bridge --adapter ado --mode export-only --bundle internal --change-ids <change-id> # Export to ADO + + **Finding Change IDs:** + + - Change IDs are shown in import output: "✓ Imported as change proposal: <change-id>" + - Or check bundle directory: ls .specfact/projects/<bundle>/change_tracking/proposals/ + - Or check OpenSpec directory: ls openspec/changes/ + + See docs/guides/devops-adapter-integration.md for complete documentation. + """ + if is_debug_mode(): + debug_log_operation( + "command", + "sync bridge", + "started", + extra={"repo": str(repo), "bundle": bundle, "adapter": adapter, "bidirectional": bidirectional}, + ) + debug_print("[dim]sync bridge: started[/dim]") + + # Auto-detect adapter if not specified + from specfact_cli.sync.bridge_probe import BridgeProbe + + if adapter == "speckit" or adapter == "auto": + probe = BridgeProbe(repo) + detected_capabilities = probe.detect() + # Use detected tool directly (e.g., "speckit", "openspec", "github") + # BridgeProbe already tries all registered adapters + if detected_capabilities.tool == "unknown": + console.print("[bold red]✗[/bold red] Could not auto-detect adapter") + console.print("[dim]No registered adapter detected this repository structure[/dim]") + registered = AdapterRegistry.list_adapters() + console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") + console.print("[dim]Tip: Specify adapter explicitly with --adapter <adapter>[/dim]") + raise typer.Exit(1) + adapter = detected_capabilities.tool + + # Validate adapter using registry (no hard-coded checks) + adapter_lower = adapter.lower() + if not AdapterRegistry.is_registered(adapter_lower): + console.print(f"[bold red]✗[/bold red] Unsupported adapter: {adapter}") + registered = AdapterRegistry.list_adapters() + console.print(f"[dim]Registered adapters: {', '.join(registered)}[/dim]") + raise typer.Exit(1) + + # Convert to AdapterType enum (for backward compatibility with existing code) + try: + adapter_type = AdapterType(adapter_lower) + except ValueError: + # Adapter is registered but not in enum (e.g., openspec might not be in enum yet) + # Use adapter string value directly + adapter_type = None + + # Determine adapter_value for use throughout function + adapter_value = adapter_type.value if adapter_type else adapter_lower + + # Determine sync mode using adapter capabilities (adapter-agnostic) + if mode is None: + # Get adapter to check capabilities + adapter_instance = AdapterRegistry.get_adapter(adapter_lower) + if adapter_instance: + # Get capabilities to determine supported sync modes + probe = BridgeProbe(repo) + capabilities = probe.detect() + bridge_config = probe.auto_generate_bridge(capabilities) if capabilities.tool != "unknown" else None + adapter_capabilities = adapter_instance.get_capabilities(repo, bridge_config) + + # Use adapter's supported sync modes if available + if adapter_capabilities.supported_sync_modes: + # Auto-select based on adapter capabilities and context + if "export-only" in adapter_capabilities.supported_sync_modes and (repo_owner or repo_name): + sync_mode = "export-only" + elif "read-only" in adapter_capabilities.supported_sync_modes: + sync_mode = "read-only" + elif "bidirectional" in adapter_capabilities.supported_sync_modes: + sync_mode = "bidirectional" if bidirectional else "unidirectional" + else: + sync_mode = "unidirectional" # Default fallback + else: + # Fallback: use bidirectional/unidirectional based on flag + sync_mode = "bidirectional" if bidirectional else "unidirectional" + else: + # Fallback if adapter not found + sync_mode = "bidirectional" if bidirectional else "unidirectional" + else: + sync_mode = mode.lower() + + # Validate mode for adapter type using adapter capabilities + adapter_instance = AdapterRegistry.get_adapter(adapter_lower) + adapter_capabilities = None + if adapter_instance: + probe = BridgeProbe(repo) + capabilities = probe.detect() + bridge_config = probe.auto_generate_bridge(capabilities) if capabilities.tool != "unknown" else None + adapter_capabilities = adapter_instance.get_capabilities(repo, bridge_config) + + if adapter_capabilities.supported_sync_modes and sync_mode not in adapter_capabilities.supported_sync_modes: + console.print(f"[bold red]✗[/bold red] Sync mode '{sync_mode}' not supported by adapter '{adapter_lower}'") + console.print(f"[dim]Supported modes: {', '.join(adapter_capabilities.supported_sync_modes)}[/dim]") + raise typer.Exit(1) + + # Validate temporary file workflow parameters + if export_to_tmp and import_from_tmp: + console.print("[bold red]✗[/bold red] --export-to-tmp and --import-from-tmp are mutually exclusive") + raise typer.Exit(1) + + # Parse change_ids if provided + change_ids_list: list[str] | None = None + if change_ids: + change_ids_list = [cid.strip() for cid in change_ids.split(",") if cid.strip()] + + backlog_items: list[str] = [] + if backlog_ids: + backlog_items.extend(_parse_backlog_selection(backlog_ids)) + if backlog_ids_file: + backlog_items.extend(_parse_backlog_selection(backlog_ids_file.read_text(encoding="utf-8"))) + if backlog_items: + backlog_items = list(dict.fromkeys(backlog_items)) + + telemetry_metadata = { + "adapter": adapter_value, + "mode": sync_mode, + "bidirectional": bidirectional, + "watch": watch, + "overwrite": overwrite, + "interval": interval, + } + + with telemetry.track_command("sync.bridge", telemetry_metadata) as record: + # Handle export-only mode (SpecFact → DevOps) + if sync_mode == "export-only": + from specfact_cli.sync.bridge_sync import BridgeSync + + console.print(f"[bold cyan]Exporting OpenSpec change proposals to {adapter_value}...[/bold cyan]") + + # Create bridge config using adapter registry + from specfact_cli.models.bridge import BridgeConfig + + adapter_instance = AdapterRegistry.get_adapter(adapter_value) + bridge_config = adapter_instance.generate_bridge_config(repo) + + # Create bridge sync instance + bridge_sync = BridgeSync(repo, bridge_config=bridge_config) + + # If bundle is provided for backlog adapters, export stored backlog items from bundle + if adapter_value in ("github", "ado") and bundle: + resolved_bundle = bundle or _infer_bundle_name(repo) + if not resolved_bundle: + console.print("[bold red]✗[/bold red] Bundle name required for backlog export") + console.print("[dim]Provide --bundle or set an active bundle in .specfact/config.yaml[/dim]") + raise typer.Exit(1) + + console.print( + f"[bold cyan]Exporting bundle backlog items to {adapter_value} ({resolved_bundle})...[/bold cyan]" + ) + if adapter_value == "github": + adapter_kwargs = { + "repo_owner": repo_owner, + "repo_name": repo_name, + "api_token": github_token, + "use_gh_cli": use_gh_cli, + } + else: + adapter_kwargs = { + "org": ado_org, + "project": ado_project, + "base_url": ado_base_url, + "api_token": ado_token, + "work_item_type": ado_work_item_type, + } + result = bridge_sync.export_backlog_from_bundle( + adapter_type=adapter_value, + bundle_name=resolved_bundle, + adapter_kwargs=adapter_kwargs, + update_existing=update_existing, + change_ids=change_ids_list, + ) + + if result.success: + console.print( + f"[bold green]✓[/bold green] Exported {len(result.operations)} backlog item(s) from bundle" + ) + for warning in result.warnings: + console.print(f"[yellow]⚠[/yellow] {warning}") + else: + console.print(f"[bold red]✗[/bold red] Export failed with {len(result.errors)} errors") + for error in result.errors: + console.print(f"[red] • {error}[/red]") + raise typer.Exit(1) + + return + + # Export change proposals + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + task = progress.add_task("[cyan]Syncing change proposals to DevOps...[/cyan]", total=None) + + # Resolve code_repo_path if provided, otherwise use repo (OpenSpec repo) + code_repo_path_for_export = Path(code_repo).resolve() if code_repo else repo.resolve() + + result = bridge_sync.export_change_proposals_to_devops( + include_archived=include_archived, + adapter_type=adapter_value, + repo_owner=repo_owner, + repo_name=repo_name, + api_token=github_token if adapter_value == "github" else ado_token, + use_gh_cli=use_gh_cli, + sanitize=sanitize, + target_repo=target_repo, + interactive=interactive, + change_ids=change_ids_list, + export_to_tmp=export_to_tmp, + import_from_tmp=import_from_tmp, + tmp_file=tmp_file, + update_existing=update_existing, + track_code_changes=track_code_changes, + add_progress_comment=add_progress_comment, + code_repo_path=code_repo_path_for_export, + ado_org=ado_org, + ado_project=ado_project, + ado_base_url=ado_base_url, + ado_work_item_type=ado_work_item_type, + ) + progress.update(task, description="[green]✓[/green] Sync complete") + + # Report results + if result.success: + console.print( + f"[bold green]✓[/bold green] Successfully synced {len(result.operations)} change proposals" + ) + if result.warnings: + for warning in result.warnings: + console.print(f"[yellow]⚠[/yellow] {warning}") + else: + console.print(f"[bold red]✗[/bold red] Sync failed with {len(result.errors)} errors") + for error in result.errors: + console.print(f"[red] • {error}[/red]") + raise typer.Exit(1) + + # Telemetry is automatically tracked via context manager + return + + # Handle read-only mode (OpenSpec → SpecFact) + if sync_mode == "read-only": + from specfact_cli.models.bridge import BridgeConfig + from specfact_cli.sync.bridge_sync import BridgeSync + + console.print(f"[bold cyan]Syncing OpenSpec artifacts (read-only) from:[/bold cyan] {repo}") + + # Create bridge config with external_base_path if provided + bridge_config = BridgeConfig.preset_openspec() + if external_base_path: + if not external_base_path.exists() or not external_base_path.is_dir(): + console.print( + f"[bold red]✗[/bold red] External base path does not exist or is not a directory: {external_base_path}" + ) + raise typer.Exit(1) + bridge_config.external_base_path = external_base_path.resolve() + + # Create bridge sync instance + bridge_sync = BridgeSync(repo, bridge_config=bridge_config) + + # Import OpenSpec artifacts + # In test mode, skip Progress to avoid stream closure issues with test framework + if _is_test_mode(): + # Test mode: simple console output without Progress + console.print("[cyan]Importing OpenSpec artifacts...[/cyan]") + + # Import project context + if bundle: + # Import specific artifacts for the bundle + # For now, import all OpenSpec specs + openspec_specs_dir = ( + bridge_config.external_base_path / "openspec" / "specs" + if bridge_config.external_base_path + else repo / "openspec" / "specs" + ) + if openspec_specs_dir.exists(): + for spec_dir in openspec_specs_dir.iterdir(): + if spec_dir.is_dir() and (spec_dir / "spec.md").exists(): + feature_id = spec_dir.name + result = bridge_sync.import_artifact("specification", feature_id, bundle) + if not result.success: + console.print( + f"[yellow]⚠[/yellow] Failed to import {feature_id}: {', '.join(result.errors)}" + ) + + console.print("[green]✓[/green] Import complete") + else: + # Normal mode: use Progress + progress_columns, progress_kwargs = get_progress_config() + with Progress( + *progress_columns, + console=console, + **progress_kwargs, + ) as progress: + task = progress.add_task("[cyan]Importing OpenSpec artifacts...[/cyan]", total=None) + + # Import project context + if bundle: + # Import specific artifacts for the bundle + # For now, import all OpenSpec specs + openspec_specs_dir = ( + bridge_config.external_base_path / "openspec" / "specs" + if bridge_config.external_base_path + else repo / "openspec" / "specs" + ) + if openspec_specs_dir.exists(): + for spec_dir in openspec_specs_dir.iterdir(): + if spec_dir.is_dir() and (spec_dir / "spec.md").exists(): + feature_id = spec_dir.name + result = bridge_sync.import_artifact("specification", feature_id, bundle) + if not result.success: + console.print( + f"[yellow]⚠[/yellow] Failed to import {feature_id}: {', '.join(result.errors)}" + ) + + progress.update(task, description="[green]✓[/green] Import complete") + # Ensure progress output is flushed before context exits + progress.refresh() + + # Generate alignment report + if bundle: + console.print("\n[bold]Generating alignment report...[/bold]") + bridge_sync.generate_alignment_report(bundle) + + console.print("[bold green]✓[/bold green] Read-only sync complete") + return + + console.print(f"[bold cyan]Syncing {adapter_value} artifacts from:[/bold cyan] {repo}") + + # Use adapter capabilities to check if bidirectional sync is supported + if adapter_capabilities and ( + adapter_capabilities.supported_sync_modes + and "bidirectional" not in adapter_capabilities.supported_sync_modes + ): + console.print(f"[yellow]⚠ Adapter '{adapter_value}' does not support bidirectional sync[/yellow]") + console.print(f"[dim]Supported modes: {', '.join(adapter_capabilities.supported_sync_modes)}[/dim]") + console.print("[dim]Use read-only mode for adapters that don't support bidirectional sync[/dim]") + raise typer.Exit(1) + + # Ensure tool compliance if requested + if ensure_compliance: + adapter_display = adapter_type.value if adapter_type else adapter_value + console.print(f"\n[cyan]🔍 Validating plan bundle for {adapter_display} compliance...[/cyan]") + from specfact_cli.utils.structure import SpecFactStructure + from specfact_cli.validators.schema import validate_plan_bundle + + # Use provided bundle name or default + plan_bundle = None + if bundle: + from specfact_cli.utils.progress import load_bundle_with_progress + + bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) + if bundle_dir.exists(): + project_bundle = load_bundle_with_progress( + bundle_dir, validate_hashes=False, console_instance=console + ) + # Convert to PlanBundle for validation (legacy compatibility) + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + else: + console.print(f"[yellow]⚠ Bundle '{bundle}' not found, skipping compliance check[/yellow]") + plan_bundle = None + else: + # Legacy: Try to find default plan path (for backward compatibility) + if hasattr(SpecFactStructure, "get_default_plan_path"): + plan_path = SpecFactStructure.get_default_plan_path(repo) + if plan_path and plan_path.exists(): + # Check if path is a directory (modular bundle) - load it first + if plan_path.is_dir(): + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.progress import load_bundle_with_progress + + project_bundle = load_bundle_with_progress( + plan_path, validate_hashes=False, console_instance=console + ) + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + else: + # It's a file (legacy monolithic bundle) - validate directly + validation_result = validate_plan_bundle(plan_path) + if isinstance(validation_result, tuple): + is_valid, _error, plan_bundle = validation_result + if not is_valid: + plan_bundle = None + else: + plan_bundle = None + + if plan_bundle: + # Check for technology stack in constraints + has_tech_stack = bool( + plan_bundle.idea + and plan_bundle.idea.constraints + and any( + "Python" in c or "framework" in c.lower() or "database" in c.lower() + for c in plan_bundle.idea.constraints + ) + ) + + if not has_tech_stack: + console.print("[yellow]⚠ Technology stack not found in constraints[/yellow]") + console.print("[dim]Technology stack will be extracted from constraints during sync[/dim]") + + # Check for testable acceptance criteria + features_with_non_testable = [] + for feature in plan_bundle.features: + for story in feature.stories: + testable_count = sum( + 1 + for acc in story.acceptance + if any( + keyword in acc.lower() for keyword in ["must", "should", "verify", "validate", "ensure"] + ) + ) + if testable_count < len(story.acceptance) and len(story.acceptance) > 0: + features_with_non_testable.append((feature.key, story.key)) + + if features_with_non_testable: + console.print( + f"[yellow]⚠ Found {len(features_with_non_testable)} stories with non-testable acceptance criteria[/yellow]" + ) + console.print("[dim]Acceptance criteria will be enhanced during sync[/dim]") + + console.print("[green]✓ Plan bundle validation complete[/green]") + else: + console.print("[yellow]⚠ Plan bundle not found, skipping compliance check[/yellow]") + + # Resolve repo path to ensure it's absolute and valid (do this once at the start) + resolved_repo = repo.resolve() + if not resolved_repo.exists(): + console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}") + raise typer.Exit(1) + if not resolved_repo.is_dir(): + console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}") + raise typer.Exit(1) + + if adapter_value in ("github", "ado") and sync_mode == "bidirectional": + from specfact_cli.sync.bridge_sync import BridgeSync + + resolved_bundle = bundle or _infer_bundle_name(resolved_repo) + if not resolved_bundle: + console.print("[bold red]✗[/bold red] Bundle name required for backlog sync") + console.print("[dim]Provide --bundle or set an active bundle in .specfact/config.yaml[/dim]") + raise typer.Exit(1) + + if not backlog_items and interactive and runtime.is_interactive(): + prompt = typer.prompt( + "Enter backlog item IDs/URLs to import (comma-separated, leave blank to skip)", + default="", + ) + backlog_items = _parse_backlog_selection(prompt) + backlog_items = list(dict.fromkeys(backlog_items)) + + if backlog_items: + console.print(f"[dim]Selected backlog items ({len(backlog_items)}): {', '.join(backlog_items)}[/dim]") + else: + console.print("[yellow]⚠[/yellow] No backlog items selected; import skipped") + + adapter_instance = AdapterRegistry.get_adapter(adapter_value) + bridge_config = adapter_instance.generate_bridge_config(resolved_repo) + bridge_sync = BridgeSync(resolved_repo, bridge_config=bridge_config) + + if backlog_items: + if adapter_value == "github": + adapter_kwargs = { + "repo_owner": repo_owner, + "repo_name": repo_name, + "api_token": github_token, + "use_gh_cli": use_gh_cli, + } + else: + adapter_kwargs = { + "org": ado_org, + "project": ado_project, + "base_url": ado_base_url, + "api_token": ado_token, + "work_item_type": ado_work_item_type, + } + + import_result = bridge_sync.import_backlog_items_to_bundle( + adapter_type=adapter_value, + bundle_name=resolved_bundle, + backlog_items=backlog_items, + adapter_kwargs=adapter_kwargs, + ) + if import_result.success: + console.print( + f"[bold green]✓[/bold green] Imported {len(import_result.operations)} backlog item(s)" + ) + for warning in import_result.warnings: + console.print(f"[yellow]⚠[/yellow] {warning}") + else: + console.print(f"[bold red]✗[/bold red] Import failed with {len(import_result.errors)} errors") + for error in import_result.errors: + console.print(f"[red] • {error}[/red]") + raise typer.Exit(1) + + if adapter_value == "github": + export_adapter_kwargs = { + "repo_owner": repo_owner, + "repo_name": repo_name, + "api_token": github_token, + "use_gh_cli": use_gh_cli, + } + else: + export_adapter_kwargs = { + "org": ado_org, + "project": ado_project, + "base_url": ado_base_url, + "api_token": ado_token, + "work_item_type": ado_work_item_type, + } + + export_result = bridge_sync.export_backlog_from_bundle( + adapter_type=adapter_value, + bundle_name=resolved_bundle, + adapter_kwargs=export_adapter_kwargs, + update_existing=update_existing, + change_ids=change_ids_list, + ) + + if export_result.success: + console.print(f"[bold green]✓[/bold green] Exported {len(export_result.operations)} backlog item(s)") + for warning in export_result.warnings: + console.print(f"[yellow]⚠[/yellow] {warning}") + else: + console.print(f"[bold red]✗[/bold red] Export failed with {len(export_result.errors)} errors") + for error in export_result.errors: + console.print(f"[red] • {error}[/red]") + raise typer.Exit(1) + + return + + # Watch mode implementation (using bridge-based watch) + if watch: + from specfact_cli.sync.bridge_watch import BridgeWatch + + console.print("[bold cyan]Watch mode enabled[/bold cyan]") + console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n") + + # Use bridge-based watch mode + bridge_watch = BridgeWatch( + repo_path=resolved_repo, + bundle_name=bundle, + interval=interval, + ) + + bridge_watch.watch() + return + + # Legacy watch mode (for backward compatibility during transition) + if False: # Disabled - use bridge watch above + from specfact_cli.sync.watcher import FileChange, SyncWatcher + + @beartype + @require(lambda changes: isinstance(changes, list), "Changes must be a list") + @require( + lambda changes: all(hasattr(c, "change_type") for c in changes), + "All changes must have change_type attribute", + ) + @ensure(lambda result: result is None, "Must return None") + def sync_callback(changes: list[FileChange]) -> None: + """Handle file changes and trigger sync.""" + tool_changes = [c for c in changes if c.change_type == "spec_kit"] + specfact_changes = [c for c in changes if c.change_type == "specfact"] + + if tool_changes or specfact_changes: + console.print(f"[cyan]Detected {len(changes)} change(s), syncing...[/cyan]") + # Perform one-time sync (bidirectional if enabled) + try: + # Re-validate resolved_repo before use (may have been cleaned up) + if not resolved_repo.exists(): + console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n") + return + if not resolved_repo.is_dir(): + console.print( + f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n" + ) + return + # Use resolved_repo from outer scope (already resolved and validated) + _perform_sync_operation( + repo=resolved_repo, + bidirectional=bidirectional, + bundle=bundle, + overwrite=overwrite, + adapter_type=adapter_type, + ) + console.print("[green]✓[/green] Sync complete\n") + except Exception as e: + console.print(f"[red]✗[/red] Sync failed: {e}\n") + + # Use resolved_repo for watcher (already resolved and validated) + watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval) + watcher.watch() + record({"watch_mode": True}) + return + + # Validate OpenAPI specs before sync (if bundle provided) + if bundle: + import asyncio + + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.progress import load_bundle_with_progress + from specfact_cli.utils.structure import SpecFactStructure + + bundle_dir = SpecFactStructure.project_dir(base_path=resolved_repo, bundle_name=bundle) + if bundle_dir.exists(): + console.print("\n[cyan]🔍 Validating OpenAPI contracts before sync...[/cyan]") + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + + from specfact_cli.integrations.specmatic import ( + check_specmatic_available, + validate_spec_with_specmatic, + ) + + is_available, error_msg = check_specmatic_available() + if is_available: + # Validate contracts referenced in bundle + contract_files = [] + for feature in plan_bundle.features: + if feature.contract: + contract_path = bundle_dir / feature.contract + if contract_path.exists(): + contract_files.append(contract_path) + + if contract_files: + console.print(f"[dim]Validating {len(contract_files)} contract(s)...[/dim]") + validation_failed = False + for contract_path in contract_files[:5]: # Validate up to 5 contracts + console.print(f"[dim]Validating {contract_path.relative_to(bundle_dir)}...[/dim]") + try: + result = asyncio.run(validate_spec_with_specmatic(contract_path)) + if not result.is_valid: + console.print( + f" [bold yellow]⚠[/bold yellow] {contract_path.name} has validation issues" + ) + if result.errors: + for error in result.errors[:2]: + console.print(f" - {error}") + validation_failed = True + else: + console.print(f" [bold green]✓[/bold green] {contract_path.name} is valid") + except Exception as e: + console.print(f" [bold yellow]⚠[/bold yellow] Validation error: {e!s}") + validation_failed = True + + if validation_failed: + console.print( + "[yellow]⚠[/yellow] Some contracts have validation issues. Sync will continue, but consider fixing them." + ) + else: + console.print("[green]✓[/green] All contracts validated successfully") + + # Check backward compatibility if previous version exists (for bidirectional sync) + if bidirectional and len(contract_files) > 0: + # TODO: Implement backward compatibility check by comparing with previous version + # This would require storing previous contract versions + console.print( + "[dim]Backward compatibility check skipped (previous versions not stored)[/dim]" + ) + else: + console.print("[dim]No contracts found in bundle[/dim]") + else: + console.print(f"[dim]💡 Tip: Install Specmatic to validate contracts: {error_msg}[/dim]") + + # Perform sync operation (extracted to avoid recursion in watch mode) + # Use resolved_repo (already resolved and validated above) + # Convert adapter_value to AdapterType for legacy _perform_sync_operation + # (This function will be refactored to use adapter registry in future) + if adapter_type is None: + # For adapters not in enum yet (like openspec), we can't use legacy sync + console.print(f"[yellow]⚠ Adapter '{adapter_value}' requires bridge-based sync (not legacy)[/yellow]") + console.print("[dim]Use read-only mode for OpenSpec adapter[/dim]") + raise typer.Exit(1) + + _perform_sync_operation( + repo=resolved_repo, + bidirectional=bidirectional, + bundle=bundle, + overwrite=overwrite, + adapter_type=adapter_type, + ) + if is_debug_mode(): + debug_log_operation("command", "sync bridge", "success", extra={"adapter": adapter, "bundle": bundle}) + debug_print("[dim]sync bridge: success[/dim]") + record({"sync_completed": True}) + + +@app.command("repository") +@beartype +@require(lambda repo: repo.exists(), "Repository path must exist") +@require(lambda repo: repo.is_dir(), "Repository path must be a directory") +@require( + lambda target: target is None or (isinstance(target, Path) and target.exists()), + "Target must be None or existing Path", +) +@require(lambda watch: isinstance(watch, bool), "Watch must be bool") +@require(lambda interval: isinstance(interval, int) and interval >= 1, "Interval must be int >= 1") +@require( + lambda confidence: isinstance(confidence, float) and 0.0 <= confidence <= 1.0, + "Confidence must be float in [0.0, 1.0]", +) +@ensure(lambda result: result is None, "Must return None") +def sync_repository( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository", + exists=True, + file_okay=False, + dir_okay=True, + ), + target: Path | None = typer.Option( + None, + "--target", + help="Target directory for artifacts (default: .specfact)", + ), + watch: bool = typer.Option( + False, + "--watch", + help="Watch mode for continuous sync", + ), + interval: int = typer.Option( + 5, + "--interval", + help="Watch interval in seconds (default: 5)", + min=1, + hidden=True, # Hidden by default, shown with --help-advanced + ), + confidence: float = typer.Option( + 0.5, + "--confidence", + help="Minimum confidence threshold for feature detection (default: 0.5)", + min=0.0, + max=1.0, + hidden=True, # Hidden by default, shown with --help-advanced + ), +) -> None: + """ + Sync code changes to SpecFact artifacts. + + Monitors repository code changes, updates plan artifacts based on detected + features/stories, and tracks deviations from manual plans. + + Example: + specfact sync repository --repo . --confidence 0.5 + """ + if is_debug_mode(): + debug_log_operation( + "command", + "sync repository", + "started", + extra={"repo": str(repo), "target": str(target) if target else None, "watch": watch}, + ) + debug_print("[dim]sync repository: started[/dim]") + + from specfact_cli.sync.repository_sync import RepositorySync + + telemetry_metadata = { + "watch": watch, + "interval": interval, + "confidence": confidence, + } + + with telemetry.track_command("sync.repository", telemetry_metadata) as record: + console.print(f"[bold cyan]Syncing repository changes from:[/bold cyan] {repo}") + + # Resolve repo path to ensure it's absolute and valid (do this once at the start) + resolved_repo = repo.resolve() + if not resolved_repo.exists(): + console.print(f"[red]Error:[/red] Repository path does not exist: {resolved_repo}") + raise typer.Exit(1) + if not resolved_repo.is_dir(): + console.print(f"[red]Error:[/red] Repository path is not a directory: {resolved_repo}") + raise typer.Exit(1) + + if target is None: + target = resolved_repo / ".specfact" + + sync = RepositorySync(resolved_repo, target, confidence_threshold=confidence) + + if watch: + from specfact_cli.sync.watcher import FileChange, SyncWatcher + + console.print("[bold cyan]Watch mode enabled[/bold cyan]") + console.print(f"[dim]Watching for changes every {interval} seconds[/dim]\n") + + @beartype + @require(lambda changes: isinstance(changes, list), "Changes must be a list") + @require( + lambda changes: all(hasattr(c, "change_type") for c in changes), + "All changes must have change_type attribute", + ) + @ensure(lambda result: result is None, "Must return None") + def sync_callback(changes: list[FileChange]) -> None: + """Handle file changes and trigger sync.""" + code_changes = [c for c in changes if c.change_type == "code"] + + if code_changes: + console.print(f"[cyan]Detected {len(code_changes)} code change(s), syncing...[/cyan]") + # Perform repository sync + try: + # Re-validate resolved_repo before use (may have been cleaned up) + if not resolved_repo.exists(): + console.print(f"[yellow]⚠[/yellow] Repository path no longer exists: {resolved_repo}\n") + return + if not resolved_repo.is_dir(): + console.print( + f"[yellow]⚠[/yellow] Repository path is no longer a directory: {resolved_repo}\n" + ) + return + # Use resolved_repo from outer scope (already resolved and validated) + result = sync.sync_repository_changes(resolved_repo) + if result.status == "success": + console.print("[green]✓[/green] Repository sync complete\n") + elif result.status == "deviation_detected": + console.print(f"[yellow]⚠[/yellow] Deviations detected: {len(result.deviations)}\n") + else: + console.print(f"[red]✗[/red] Sync failed: {result.status}\n") + except Exception as e: + console.print(f"[red]✗[/red] Sync failed: {e}\n") + + # Use resolved_repo for watcher (already resolved and validated) + watcher = SyncWatcher(resolved_repo, sync_callback, interval=interval) + watcher.watch() + record({"watch_mode": True}) + return + + # Use resolved_repo (already resolved and validated above) + # Disable Progress in test mode to avoid LiveError conflicts + if _is_test_mode(): + # In test mode, just run the sync without Progress + result = sync.sync_repository_changes(resolved_repo) + else: + with Progress( + SpinnerColumn(), + TextColumn("[progress.description]{task.description}"), + TimeElapsedColumn(), + console=console, + ) as progress: + # Step 1: Detect code changes + task = progress.add_task("Detecting code changes...", total=None) + result = sync.sync_repository_changes(resolved_repo) + progress.update(task, description=f"✓ Detected {len(result.code_changes)} code changes") + + # Step 2: Show plan updates + if result.plan_updates: + task = progress.add_task("Updating plan artifacts...", total=None) + total_features = sum(update.get("features", 0) for update in result.plan_updates) + progress.update(task, description=f"✓ Updated plan artifacts ({total_features} features)") + + # Step 3: Show deviations + if result.deviations: + task = progress.add_task("Tracking deviations...", total=None) + progress.update(task, description=f"✓ Found {len(result.deviations)} deviations") + + if is_debug_mode(): + debug_log_operation( + "command", + "sync repository", + "success", + extra={"code_changes": len(result.code_changes)}, + ) + debug_print("[dim]sync repository: success[/dim]") + # Record sync results + record( + { + "code_changes": len(result.code_changes), + "plan_updates": len(result.plan_updates) if result.plan_updates else 0, + "deviations": len(result.deviations) if result.deviations else 0, + } + ) + + # Report results + console.print(f"[bold cyan]Code Changes:[/bold cyan] {len(result.code_changes)}") + if result.plan_updates: + console.print(f"[bold cyan]Plan Updates:[/bold cyan] {len(result.plan_updates)}") + if result.deviations: + console.print(f"[yellow]⚠[/yellow] Found {len(result.deviations)} deviations from manual plan") + console.print("[dim]Run 'specfact plan compare' for detailed deviation report[/dim]") + else: + console.print("[bold green]✓[/bold green] No deviations detected") + console.print("[bold green]✓[/bold green] Repository sync complete!") + + # Auto-validate OpenAPI/AsyncAPI specs with Specmatic (if found) + import asyncio + + from specfact_cli.integrations.specmatic import check_specmatic_available, validate_spec_with_specmatic + + spec_files = [] + for pattern in [ + "**/openapi.yaml", + "**/openapi.yml", + "**/openapi.json", + "**/asyncapi.yaml", + "**/asyncapi.yml", + "**/asyncapi.json", + ]: + spec_files.extend(resolved_repo.glob(pattern)) + + if spec_files: + console.print(f"\n[cyan]🔍 Found {len(spec_files)} API specification file(s)[/cyan]") + is_available, error_msg = check_specmatic_available() + if is_available: + for spec_file in spec_files[:3]: # Validate up to 3 specs + console.print(f"[dim]Validating {spec_file.relative_to(resolved_repo)} with Specmatic...[/dim]") + try: + result = asyncio.run(validate_spec_with_specmatic(spec_file)) + if result.is_valid: + console.print(f" [green]✓[/green] {spec_file.name} is valid") + else: + console.print(f" [yellow]⚠[/yellow] {spec_file.name} has validation issues") + if result.errors: + for error in result.errors[:2]: # Show first 2 errors + console.print(f" - {error}") + except Exception as e: + console.print(f" [yellow]⚠[/yellow] Validation error: {e!s}") + if len(spec_files) > 3: + console.print( + f"[dim]... and {len(spec_files) - 3} more spec file(s) (run 'specfact spec validate' to validate all)[/dim]" + ) + else: + console.print(f"[dim]💡 Tip: Install Specmatic to validate API specs: {error_msg}[/dim]") + + +@app.command("intelligent") +@beartype +@require( + lambda bundle: bundle is None or (isinstance(bundle, str) and len(bundle) > 0), + "Bundle name must be None or non-empty string", +) +@require(lambda repo: isinstance(repo, Path), "Repository path must be Path") +@ensure(lambda result: result is None, "Must return None") +def sync_intelligent( + # Target/Input + bundle: str | None = typer.Argument( + None, help="Project bundle name (e.g., legacy-api). Default: active plan from 'specfact plan select'" + ), + repo: Path = typer.Option( + Path("."), + "--repo", + help="Path to repository. Default: current directory (.)", + exists=True, + file_okay=False, + dir_okay=True, + ), + # Behavior/Options + watch: bool = typer.Option( + False, + "--watch", + help="Watch mode for continuous sync. Default: False", + ), + code_to_spec: str = typer.Option( + "auto", + "--code-to-spec", + help="Code-to-spec sync mode: 'auto' (AST-based) or 'off'. Default: auto", + ), + spec_to_code: str = typer.Option( + "llm-prompt", + "--spec-to-code", + help="Spec-to-code sync mode: 'llm-prompt' (generate prompts) or 'off'. Default: llm-prompt", + ), + tests: str = typer.Option( + "specmatic", + "--tests", + help="Test generation mode: 'specmatic' (contract-based) or 'off'. Default: specmatic", + ), +) -> None: + """ + Continuous intelligent bidirectional sync with conflict resolution. + + Detects changes via hashing and syncs intelligently: + - Code→Spec: AST-based automatic sync (CLI can do) + - Spec→Code: LLM prompt generation (CLI orchestrates, LLM writes) + - Spec→Tests: Specmatic flows (contract-based, not LLM guessing) + + **Parameter Groups:** + - **Target/Input**: bundle (required argument), --repo + - **Behavior/Options**: --watch, --code-to-spec, --spec-to-code, --tests + + **Examples:** + specfact sync intelligent legacy-api --repo . + specfact sync intelligent my-bundle --repo . --watch + specfact sync intelligent my-bundle --repo . --code-to-spec auto --spec-to-code llm-prompt --tests specmatic + """ + if is_debug_mode(): + debug_log_operation( + "command", + "sync intelligent", + "started", + extra={"bundle": bundle, "repo": str(repo), "watch": watch}, + ) + debug_print("[dim]sync intelligent: started[/dim]") + + from specfact_cli.utils.structure import SpecFactStructure + + console = get_configured_console() + + # Use active plan as default if bundle not provided + if bundle is None: + bundle = SpecFactStructure.get_active_bundle_name(repo) + if bundle is None: + console.print("[bold red]✗[/bold red] Bundle name required") + console.print("[yellow]→[/yellow] Use --bundle option or run 'specfact plan select' to set active plan") + raise typer.Exit(1) + console.print(f"[dim]Using active plan: {bundle}[/dim]") + + from specfact_cli.sync.change_detector import ChangeDetector + from specfact_cli.sync.code_to_spec import CodeToSpecSync + from specfact_cli.sync.spec_to_code import SpecToCodeSync + from specfact_cli.sync.spec_to_tests import SpecToTestsSync + from specfact_cli.telemetry import telemetry + from specfact_cli.utils.progress import load_bundle_with_progress + from specfact_cli.utils.structure import SpecFactStructure + + repo_path = repo.resolve() + bundle_dir = SpecFactStructure.project_dir(base_path=repo_path, bundle_name=bundle) + + if not bundle_dir.exists(): + console.print(f"[bold red]✗[/bold red] Project bundle not found: {bundle_dir}") + raise typer.Exit(1) + + telemetry_metadata = { + "bundle": bundle, + "watch": watch, + "code_to_spec": code_to_spec, + "spec_to_code": spec_to_code, + "tests": tests, + } + + with telemetry.track_command("sync.intelligent", telemetry_metadata) as record: + console.print(f"[bold cyan]Intelligent Sync:[/bold cyan] {bundle}") + console.print(f"[dim]Repository:[/dim] {repo_path}") + + # Load project bundle with unified progress display + project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) + + # Initialize sync components + change_detector = ChangeDetector(bundle, repo_path) + code_to_spec_sync = CodeToSpecSync(repo_path) + spec_to_code_sync = SpecToCodeSync(repo_path) + spec_to_tests_sync = SpecToTestsSync(bundle, repo_path) + + def perform_sync() -> None: + """Perform one sync cycle.""" + console.print("\n[cyan]Detecting changes...[/cyan]") + + # Detect changes + changeset = change_detector.detect_changes(project_bundle.features) + + if not any([changeset.code_changes, changeset.spec_changes, changeset.test_changes]): + console.print("[dim]No changes detected[/dim]") + return + + # Report changes + if changeset.code_changes: + console.print(f"[cyan]Code changes:[/cyan] {len(changeset.code_changes)}") + if changeset.spec_changes: + console.print(f"[cyan]Spec changes:[/cyan] {len(changeset.spec_changes)}") + if changeset.test_changes: + console.print(f"[cyan]Test changes:[/cyan] {len(changeset.test_changes)}") + if changeset.conflicts: + console.print(f"[yellow]⚠ Conflicts:[/yellow] {len(changeset.conflicts)}") + + # Sync code→spec (AST-based, automatic) + if code_to_spec == "auto" and changeset.code_changes: + console.print("\n[cyan]Syncing code→spec (AST-based)...[/cyan]") + try: + code_to_spec_sync.sync(changeset.code_changes, bundle) + console.print("[green]✓[/green] Code→spec sync complete") + except Exception as e: + console.print(f"[red]✗[/red] Code→spec sync failed: {e}") + + # Sync spec→code (LLM prompt generation) + if spec_to_code == "llm-prompt" and changeset.spec_changes: + console.print("\n[cyan]Preparing LLM prompts for spec→code...[/cyan]") + try: + context = spec_to_code_sync.prepare_llm_context(changeset.spec_changes, repo_path) + prompt = spec_to_code_sync.generate_llm_prompt(context) + + # Save prompt to file + prompts_dir = repo_path / ".specfact" / "prompts" + prompts_dir.mkdir(parents=True, exist_ok=True) + prompt_file = prompts_dir / f"{bundle}-code-generation-{len(changeset.spec_changes)}.md" + prompt_file.write_text(prompt, encoding="utf-8") + + console.print(f"[green]✓[/green] LLM prompt generated: {prompt_file}") + console.print("[yellow]Execute this prompt with your LLM to generate code[/yellow]") + except Exception as e: + console.print(f"[red]✗[/red] LLM prompt generation failed: {e}") + + # Sync spec→tests (Specmatic) + if tests == "specmatic" and changeset.spec_changes: + console.print("\n[cyan]Generating tests via Specmatic...[/cyan]") + try: + spec_to_tests_sync.sync(changeset.spec_changes, bundle) + console.print("[green]✓[/green] Test generation complete") + except Exception as e: + console.print(f"[red]✗[/red] Test generation failed: {e}") + + if watch: + console.print("[bold cyan]Watch mode enabled[/bold cyan]") + console.print("[dim]Watching for changes...[/dim]") + console.print("[yellow]Press Ctrl+C to stop[/yellow]\n") + + from specfact_cli.sync.watcher import SyncWatcher + + def sync_callback(_changes: list) -> None: + """Handle file changes and trigger sync.""" + perform_sync() + + watcher = SyncWatcher(repo_path, sync_callback, interval=5) + try: + watcher.watch() + except KeyboardInterrupt: + console.print("\n[yellow]Stopping watch mode...[/yellow]") + else: + perform_sync() + + if is_debug_mode(): + debug_log_operation("command", "sync intelligent", "success", extra={"bundle": bundle}) + debug_print("[dim]sync intelligent: success[/dim]") + record({"sync_completed": True}) diff --git a/src/specfact_cli/modules/upgrade/src/__init__.py b/src/specfact_cli/modules/upgrade/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/upgrade/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/upgrade/src/app.py b/src/specfact_cli/modules/upgrade/src/app.py index 2d261e0f..0f0c1faa 100644 --- a/src/specfact_cli/modules/upgrade/src/app.py +++ b/src/specfact_cli/modules/upgrade/src/app.py @@ -1,6 +1,6 @@ -"""Upgrade command: re-export from commands package (update module).""" +"""upgrade command entrypoint.""" -from specfact_cli.commands.update import app +from specfact_cli.modules.upgrade.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/upgrade/src/commands.py b/src/specfact_cli/modules/upgrade/src/commands.py new file mode 100644 index 00000000..86ecef30 --- /dev/null +++ b/src/specfact_cli/modules/upgrade/src/commands.py @@ -0,0 +1,305 @@ +""" +Upgrade command for SpecFact CLI. + +This module provides the `specfact upgrade` command for checking and installing +CLI updates from PyPI. +""" + +from __future__ import annotations + +import subprocess +import sys +from datetime import UTC +from pathlib import Path +from typing import NamedTuple + +import typer +from beartype import beartype +from icontract import ensure +from rich.console import Console +from rich.panel import Panel +from rich.prompt import Confirm + +from specfact_cli import __version__ +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.utils.metadata import update_metadata +from specfact_cli.utils.startup_checks import check_pypi_version + + +app = typer.Typer( + help="Check for and install SpecFact CLI updates", + context_settings={"help_option_names": ["-h", "--help"]}, +) +console = Console() + + +class InstallationMethod(NamedTuple): + """Installation method information.""" + + method: str # "pip", "uvx", "pipx", or "unknown" + command: str # Command to run for update + location: str | None # Installation location if known + + +@beartype +@ensure(lambda result: isinstance(result, InstallationMethod), "Must return InstallationMethod") +def detect_installation_method() -> InstallationMethod: + """ + Detect how SpecFact CLI was installed. + + Returns: + InstallationMethod with detected method and update command + """ + # Check if running via uvx + if "uvx" in sys.argv[0] or "uvx" in str(Path(sys.executable)): + return InstallationMethod( + method="uvx", + command="uvx --from specfact-cli specfact --version", + location=None, + ) + + # Check if running via pipx + try: + result = subprocess.run( + ["pipx", "list"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if "specfact-cli" in result.stdout: + return InstallationMethod( + method="pipx", + command="pipx upgrade specfact-cli", + location=None, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Check if installed via pip (user or system) + try: + result = subprocess.run( + [sys.executable, "-m", "pip", "show", "specfact-cli"], + capture_output=True, + text=True, + timeout=5, + check=False, + ) + if result.returncode == 0: + # Parse location from output + location = None + for line in result.stdout.splitlines(): + if line.startswith("Location:"): + location = line.split(":", 1)[1].strip() + break + + return InstallationMethod( + method="pip", + command=f"{sys.executable} -m pip install --upgrade specfact-cli", + location=location, + ) + except (subprocess.TimeoutExpired, FileNotFoundError): + pass + + # Fallback: assume pip + return InstallationMethod( + method="pip", + command="pip install --upgrade specfact-cli", + location=None, + ) + + +@beartype +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def install_update(method: InstallationMethod, yes: bool = False) -> bool: + """ + Install update using the detected installation method. + + Args: + method: InstallationMethod with update command + yes: If True, skip confirmation prompt + + Returns: + True if update was successful, False otherwise + """ + if not yes: + console.print(f"[yellow]This will update SpecFact CLI using:[/yellow] [cyan]{method.command}[/cyan]") + if not Confirm.ask("Continue?", default=True): + console.print("[dim]Update cancelled[/dim]") + return False + + try: + console.print("[cyan]Updating SpecFact CLI...[/cyan]") + # Split command into parts for subprocess + if method.method == "pipx": + cmd = ["pipx", "upgrade", "specfact-cli"] + elif method.method == "pip": + # Handle both formats: "python -m pip" and "pip" + if " -m pip" in method.command: + parts = method.command.split() + cmd = [parts[0], "-m", "pip", "install", "--upgrade", "specfact-cli"] + else: + cmd = ["pip", "install", "--upgrade", "specfact-cli"] + else: + # uvx - just inform user + console.print( + "[yellow]uvx automatically uses the latest version.[/yellow]\n" + "[dim]No update needed. If you want to force a refresh, run:[/dim]\n" + "[cyan]uvx --from specfact-cli@latest specfact --version[/cyan]" + ) + return True + + result = subprocess.run( + cmd, + check=False, + timeout=300, # 5 minute timeout + ) + + if result.returncode == 0: + console.print("[green]✓ Update successful![/green]") + # Update metadata to reflect new version + from datetime import datetime + + update_metadata( + last_checked_version=__version__, + last_version_check_timestamp=datetime.now(UTC).isoformat(), + ) + return True + console.print(f"[red]✗ Update failed with exit code {result.returncode}[/red]") + return False + + except subprocess.TimeoutExpired: + console.print("[red]✗ Update timed out (exceeded 5 minutes)[/red]") + return False + except Exception as e: + console.print(f"[red]✗ Update failed: {e}[/red]") + return False + + +@app.callback(invoke_without_command=True) +@beartype +def upgrade( + check_only: bool = typer.Option( + False, + "--check-only", + help="Only check for updates, don't install", + ), + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip confirmation prompt and install immediately", + ), +) -> None: + """ + Check for and install SpecFact CLI updates. + + This command: + 1. Checks PyPI for the latest version + 2. Compares with current version + 3. Optionally installs the update using the detected installation method (pip, pipx, uvx) + + Examples: + # Check for updates only + specfact upgrade --check-only + + # Check and install (with confirmation) + specfact upgrade + + # Check and install without confirmation + specfact upgrade --yes + """ + if is_debug_mode(): + debug_log_operation( + "command", + "upgrade", + "started", + extra={"check_only": check_only, "yes": yes}, + ) + debug_print("[dim]upgrade: started[/dim]") + + # Check for updates + console.print("[cyan]Checking for updates...[/cyan]") + version_result = check_pypi_version() + + if version_result.error: + if is_debug_mode(): + debug_log_operation( + "command", + "upgrade", + "failed", + error=version_result.error or "Unknown error", + extra={"reason": "check_error"}, + ) + console.print(f"[red]Error checking for updates: {version_result.error}[/red]") + sys.exit(1) + + if not version_result.update_available: + if is_debug_mode(): + debug_log_operation( + "command", + "upgrade", + "success", + extra={"reason": "up_to_date", "version": version_result.current_version}, + ) + debug_print("[dim]upgrade: success (up to date)[/dim]") + console.print(f"[green]✓ You're up to date![/green] (version {version_result.current_version})") + # Update metadata even if no update available + from datetime import datetime + + update_metadata( + last_checked_version=__version__, + last_version_check_timestamp=datetime.now(UTC).isoformat(), + ) + return + + # Update available + if version_result.latest_version and version_result.update_type: + update_type_color = "red" if version_result.update_type == "major" else "yellow" + update_type_icon = "🔴" if version_result.update_type == "major" else "🟡" + + update_info = ( + f"[bold {update_type_color}]{update_type_icon} Update Available[/bold {update_type_color}]\n\n" + f"Current: [cyan]{version_result.current_version}[/cyan]\n" + f"Latest: [green]{version_result.latest_version}[/green]\n" + ) + + if version_result.update_type == "major": + update_info += ( + "\n[bold red]⚠ Breaking changes may be present![/bold red]\nReview release notes before upgrading.\n" + ) + + console.print() + console.print(Panel(update_info, border_style=update_type_color)) + + if check_only: + # Detect installation method for user info + method = detect_installation_method() + console.print(f"\n[yellow]To upgrade, run:[/yellow] [cyan]{method.command}[/cyan]") + console.print("[dim]Or run:[/dim] [cyan]specfact upgrade --yes[/cyan]") + return + + # Install update + method = detect_installation_method() + console.print(f"\n[cyan]Installation method detected:[/cyan] [bold]{method.method}[/bold]") + + success = install_update(method, yes=yes) + + if success: + if is_debug_mode(): + debug_log_operation("command", "upgrade", "success", extra={"reason": "installed"}) + debug_print("[dim]upgrade: success[/dim]") + console.print("\n[green]✓ Update complete![/green]") + console.print("[dim]Run 'specfact --version' to verify the new version.[/dim]") + else: + if is_debug_mode(): + debug_log_operation( + "command", + "upgrade", + "failed", + error="Update was not installed", + extra={"reason": "install_failed"}, + ) + console.print("\n[yellow]Update was not installed.[/yellow]") + console.print("[dim]You can manually update using the command shown above.[/dim]") + sys.exit(1) diff --git a/src/specfact_cli/modules/validate/src/__init__.py b/src/specfact_cli/modules/validate/src/__init__.py new file mode 100644 index 00000000..c29f9a9b --- /dev/null +++ b/src/specfact_cli/modules/validate/src/__init__.py @@ -0,0 +1 @@ +"""Module package source namespace.""" diff --git a/src/specfact_cli/modules/validate/src/app.py b/src/specfact_cli/modules/validate/src/app.py index d8785a6b..c19fb4ff 100644 --- a/src/specfact_cli/modules/validate/src/app.py +++ b/src/specfact_cli/modules/validate/src/app.py @@ -1,6 +1,6 @@ -"""Validate command: re-export from commands package.""" +"""validate command entrypoint.""" -from specfact_cli.commands.validate import app +from specfact_cli.modules.validate.src.commands import app __all__ = ["app"] diff --git a/src/specfact_cli/modules/validate/src/commands.py b/src/specfact_cli/modules/validate/src/commands.py new file mode 100644 index 00000000..8a27ac83 --- /dev/null +++ b/src/specfact_cli/modules/validate/src/commands.py @@ -0,0 +1,314 @@ +""" +Validate command group for SpecFact CLI. + +This module provides validation commands including sidecar validation. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import typer +from beartype import beartype +from icontract import require + +from specfact_cli.runtime import debug_log_operation, debug_print, get_configured_console, is_debug_mode +from specfact_cli.validators.sidecar.crosshair_summary import format_summary_line +from specfact_cli.validators.sidecar.models import SidecarConfig +from specfact_cli.validators.sidecar.orchestrator import initialize_sidecar_workspace, run_sidecar_validation + + +app = typer.Typer(name="validate", help="Validation commands", suggest_commands=False) +console = get_configured_console() + + +@beartype +def _format_crosshair_error(stderr: str, stdout: str) -> str: + """ + Format CrossHair error messages into user-friendly text. + + Filters out technical errors (like Rich markup errors) and provides + actionable error messages. + + Args: + stderr: CrossHair stderr output + stdout: CrossHair stdout output + + Returns: + User-friendly error message or empty string if no actionable error + """ + combined = (stderr + "\n" + stdout).strip() + if not combined: + return "" + + # Filter out Rich markup errors - these are internal errors, not user-facing + error_lower = combined.lower() + if "closing tag" in error_lower and "doesn't match any open tag" in error_lower: + # This is a Rich internal error - ignore it completely + return "" + + # Detect common error patterns and provide user-friendly messages + # Python shared library issue (venv Python can't load libraries) + if "error while loading shared libraries" in error_lower or "libpython" in error_lower: + return ( + "Python environment issue detected. CrossHair is using system Python instead. " + "This is usually harmless - validation will continue with system Python." + ) + + # CrossHair not found + if "not found" in error_lower and ("crosshair" in error_lower or "command" in error_lower): + return "CrossHair is not installed or not in PATH. Install it with: pip install crosshair-tool" + + # Timeout + if "timeout" in error_lower or "timed out" in error_lower: + return ( + "CrossHair analysis timed out. This is expected for complex applications with many routes. " + "Some routes were analyzed before timeout. Check the summary file for partial results. " + "To analyze more routes, increase --crosshair-timeout or --crosshair-per-path-timeout." + ) + + # Import errors + if "importerror" in error_lower or "module not found" in error_lower: + module_match = re.search(r"no module named ['\"]([^'\"]+)['\"]", error_lower) + if module_match: + module_name = module_match.group(1) + return ( + f"Missing Python module: {module_name}. " + "Ensure all dependencies are installed in the sidecar environment." + ) + return "Missing Python module. Ensure all dependencies are installed." + + # Syntax errors in harness + if "syntaxerror" in error_lower or "syntax error" in error_lower: + return ( + "Syntax error in generated harness. This may indicate an issue with contract generation. " + "Check the harness file for errors." + ) + + # Generic error - show a sanitized version (remove paths, technical details) + # Only show first line and remove technical details + lines = combined.split("\n") + first_line = lines[0].strip() if lines else "" + + # Remove common technical noise + first_line = re.sub(r"Error: closing tag.*", "", first_line, flags=re.IGNORECASE) + first_line = re.sub(r"at position \d+", "", first_line, flags=re.IGNORECASE) + first_line = re.sub(r"\.specfact/venv/bin/python.*", "", first_line) + first_line = re.sub(r"error while loading shared libraries.*", "", first_line, flags=re.IGNORECASE) + + # If we have a clean message, show it (limited length) + if first_line and len(first_line) > 10: + # Limit to reasonable length + if len(first_line) > 150: + first_line = first_line[:147] + "..." + return first_line + + # Fallback: generic message + return "CrossHair execution failed. Check logs for details." + + +# Create sidecar subcommand group +sidecar_app = typer.Typer(name="sidecar", help="Sidecar validation commands", suggest_commands=False) +app.add_typer(sidecar_app) + + +@sidecar_app.command() +@beartype +@require(lambda bundle_name: bundle_name and len(bundle_name.strip()) > 0, "Bundle name must be non-empty") +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +def init( + bundle_name: str = typer.Argument(..., help="Project bundle name (e.g., 'legacy-api')"), + repo_path: Path = typer.Argument(..., help="Path to repository root directory"), +) -> None: + """ + Initialize sidecar workspace for validation. + + Creates sidecar workspace directory structure and configuration for contract-based + validation of external codebases without modifying source code. + + **What it does:** + - Detects framework type (Django, FastAPI, DRF, pure-python) + - Creates sidecar workspace directory structure + - Generates configuration files + - Detects Python environment (venv, poetry, uv, pip) + - Sets up framework-specific configuration (e.g., DJANGO_SETTINGS_MODULE) + + **Example:** + ```bash + specfact validate sidecar init legacy-api /path/to/repo + ``` + + **Next steps:** + After initialization, run `specfact validate sidecar run` to execute validation. + """ + if is_debug_mode(): + debug_log_operation( + "command", + "validate sidecar init", + "started", + extra={"bundle_name": bundle_name, "repo_path": str(repo_path)}, + ) + debug_print("[dim]validate sidecar init: started[/dim]") + + config = SidecarConfig.create(bundle_name, repo_path) + + console.print(f"[bold]Initializing sidecar workspace for bundle: {bundle_name}[/bold]") + + if initialize_sidecar_workspace(config): + console.print("[green]✓[/green] Sidecar workspace initialized successfully") + console.print(f" Framework detected: {config.framework_type}") + if config.django_settings_module: + console.print(f" Django settings: {config.django_settings_module}") + else: + if is_debug_mode(): + debug_log_operation( + "command", + "validate sidecar init", + "failed", + error="Failed to initialize sidecar workspace", + extra={"reason": "init_failed", "bundle_name": bundle_name}, + ) + console.print("[red]✗[/red] Failed to initialize sidecar workspace") + raise typer.Exit(1) + + +@sidecar_app.command() +@beartype +@require(lambda bundle_name: bundle_name and len(bundle_name.strip()) > 0, "Bundle name must be non-empty") +@require(lambda repo_path: repo_path.exists(), "Repository path must exist") +def run( + bundle_name: str = typer.Argument(..., help="Project bundle name (e.g., 'legacy-api')"), + repo_path: Path = typer.Argument(..., help="Path to repository root directory"), + run_crosshair: bool = typer.Option( + True, "--run-crosshair/--no-run-crosshair", help="Run CrossHair symbolic execution analysis" + ), + run_specmatic: bool = typer.Option( + True, "--run-specmatic/--no-run-specmatic", help="Run Specmatic contract testing validation" + ), +) -> None: + """ + Run sidecar validation workflow. + + Executes complete sidecar validation workflow including framework detection, + route extraction, contract population, harness generation, and validation tools. + + **Workflow steps:** + 1. **Framework Detection**: Automatically detects Django, FastAPI, DRF, or pure-python + 2. **Route Extraction**: Extracts routes and schemas from framework-specific patterns + 3. **Contract Population**: Populates OpenAPI contracts with extracted routes/schemas + 4. **Harness Generation**: Generates CrossHair harness from populated contracts + 5. **CrossHair Analysis**: Runs symbolic execution on source code and harness (if enabled) + 6. **Specmatic Validation**: Runs contract testing against API endpoints (if enabled) + + **Example:** + ```bash + # Run full validation (CrossHair + Specmatic) + specfact validate sidecar run legacy-api /path/to/repo + + # Run only CrossHair analysis + specfact validate sidecar run legacy-api /path/to/repo --no-run-specmatic + + # Run only Specmatic validation + specfact validate sidecar run legacy-api /path/to/repo --no-run-crosshair + ``` + + **Output:** + + - Validation results displayed in console + - Reports saved to `.specfact/projects/<bundle>/reports/sidecar/` + - Progress indicators for long-running operations + """ + if is_debug_mode(): + debug_log_operation( + "command", + "validate sidecar run", + "started", + extra={ + "bundle_name": bundle_name, + "repo_path": str(repo_path), + "run_crosshair": run_crosshair, + "run_specmatic": run_specmatic, + }, + ) + debug_print("[dim]validate sidecar run: started[/dim]") + + config = SidecarConfig.create(bundle_name, repo_path) + config.tools.run_crosshair = run_crosshair + config.tools.run_specmatic = run_specmatic + + console.print(f"[bold]Running sidecar validation for bundle: {bundle_name}[/bold]") + + results = run_sidecar_validation(config, console=console) + + # Display results + console.print("\n[bold]Validation Results:[/bold]") + console.print(f" Framework: {results.get('framework_detected', 'unknown')}") + console.print(f" Routes extracted: {results.get('routes_extracted', 0)}") + console.print(f" Contracts populated: {results.get('contracts_populated', 0)}") + console.print(f" Harness generated: {results.get('harness_generated', False)}") + + if results.get("crosshair_results"): + console.print("\n[bold]CrossHair Results:[/bold]") + for key, value in results["crosshair_results"].items(): + success = value.get("success", False) + status = "[green]✓[/green]" if success else "[red]✗[/red]" + console.print(f" {status} {key}") + + # Display user-friendly error messages if CrossHair failed + if not success: + stderr = value.get("stderr", "") + stdout = value.get("stdout", "") + error_message = _format_crosshair_error(stderr, stdout) + if error_message: + # Use markup=False to prevent Rich from parsing brackets in error messages + # This prevents Rich markup errors when error messages contain brackets + try: + console.print(" [red]Error:[/red]", end=" ") + console.print(error_message, markup=False) + except Exception: + # If Rich itself fails (shouldn't happen with markup=False, but be safe) + # Fall back to plain print + print(f" Error: {error_message}") + + # Display summary if available + if results.get("crosshair_summary"): + summary = results["crosshair_summary"] + summary_line = format_summary_line(summary) + # Use try/except to catch Rich parsing errors + try: + console.print(f" {summary_line}") + except Exception: + # Fall back to plain print if Rich fails + print(f" {summary_line}") + + # Show summary file location if generated + if results.get("crosshair_summary_file"): + summary_file_path = results["crosshair_summary_file"] + # Use markup=False for paths to prevent Rich from parsing brackets + try: + console.print(" Summary file: ", end="") + console.print(str(summary_file_path), markup=False) + except Exception: + # Fall back to plain print if Rich fails + print(f" Summary file: {summary_file_path}") + + if results.get("specmatic_skipped"): + console.print( + f"\n[yellow]⚠ Specmatic skipped: {results.get('specmatic_skip_reason', 'Unknown reason')}[/yellow]" + ) + elif results.get("specmatic_results"): + console.print("\n[bold]Specmatic Results:[/bold]") + for key, value in results["specmatic_results"].items(): + success = value.get("success", False) + status = "[green]✓[/green]" if success else "[red]✗[/red]" + console.print(f" {status} {key}") + + if is_debug_mode(): + debug_log_operation( + "command", + "validate sidecar run", + "success", + extra={"bundle_name": bundle_name, "routes_extracted": results.get("routes_extracted", 0)}, + ) + debug_print("[dim]validate sidecar run: success[/dim]") diff --git a/src/specfact_cli/parsers/persona_importer.py b/src/specfact_cli/parsers/persona_importer.py index 455cc064..ba4b00c6 100644 --- a/src/specfact_cli/parsers/persona_importer.py +++ b/src/specfact_cli/parsers/persona_importer.py @@ -166,7 +166,7 @@ def extract_owned_sections(self, sections: dict[str, Any], persona_mapping: Pers Returns: Extracted sections dictionary for bundle update """ - from specfact_cli.commands.project_cmd import match_section_pattern + from specfact_cli.utils.persona_ownership import match_section_pattern extracted: dict[str, Any] = {} @@ -226,7 +226,7 @@ def _parse_business_section(self, content: str) -> dict[str, Any]: @ensure(lambda result: isinstance(result, dict), "Must return dict") def _parse_features_section(self, content: str, persona_mapping: PersonaMapping) -> dict[str, Any]: """Parse features section content.""" - from specfact_cli.commands.project_cmd import match_section_pattern + from specfact_cli.utils.persona_ownership import match_section_pattern features: dict[str, Any] = {} # Basic parsing - extract feature keys and titles diff --git a/src/specfact_cli/registry/registry.py b/src/specfact_cli/registry/registry.py index 4d2bde57..39efe29e 100644 --- a/src/specfact_cli/registry/registry.py +++ b/src/specfact_cli/registry/registry.py @@ -6,10 +6,10 @@ from __future__ import annotations -from typing import Any +from collections.abc import Callable +from typing import Any, TypeAlias from beartype import beartype -from beartype.typing import Callable from icontract import ensure, require from typing_extensions import TypedDict @@ -17,7 +17,7 @@ # Loader: callable that returns typer.Typer (invoked on first get_typer(name)) -Loader = Callable[[], Any] +Loader: TypeAlias = Callable[[], Any] # noqa: UP040 class _Entry(TypedDict, total=False): @@ -39,6 +39,16 @@ class CommandRegistry: _entries: list[_Entry] = [] _typer_cache: dict[str, Any] = {} + @classmethod + def _ensure_bootstrapped(cls) -> None: + """Re-register commands if registry was cleared during runtime/tests.""" + if cls._entries: + return + # Local import avoids circular import at module load time. + from specfact_cli.registry.bootstrap import register_builtin_commands + + register_builtin_commands() + @classmethod @beartype @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string") @@ -59,6 +69,7 @@ def register(cls, name: str, loader: Loader, metadata: CommandMetadata) -> None: @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string") def get_typer(cls, name: str) -> Any: """Return Typer app for name; invoke loader on first use and cache.""" + cls._ensure_bootstrapped() if name in cls._typer_cache: return cls._typer_cache[name] for e in cls._entries: @@ -77,6 +88,7 @@ def get_typer(cls, name: str) -> Any: @ensure(lambda result: isinstance(result, list), "Must return list") def list_commands(cls) -> list[str]: """Return all registered command names in registration order.""" + cls._ensure_bootstrapped() return [e.get("name", "") for e in cls._entries if e.get("name")] @classmethod @@ -84,11 +96,13 @@ def list_commands(cls) -> list[str]: @ensure(lambda result: isinstance(result, list), "Must return list") def list_commands_for_help(cls) -> list[tuple[str, CommandMetadata]]: """Return (name, metadata) for help display; does not invoke loaders.""" + cls._ensure_bootstrapped() return [(e.get("name", ""), e["metadata"]) for e in cls._entries if e.get("name") and "metadata" in e] @classmethod def get_metadata(cls, name: str) -> CommandMetadata | None: """Return metadata for name without invoking loader.""" + cls._ensure_bootstrapped() for e in cls._entries: if e.get("name") == name: return e.get("metadata") diff --git a/src/specfact_cli/utils/git.py b/src/specfact_cli/utils/git.py index 7337fa7e..e5f735e8 100644 --- a/src/specfact_cli/utils/git.py +++ b/src/specfact_cli/utils/git.py @@ -9,9 +9,9 @@ from pathlib import Path from typing import Any -import git from beartype import beartype -from git import Repo +from git.exc import InvalidGitRepositoryError +from git.repo import Repo from icontract import ensure, require @@ -43,7 +43,7 @@ def _is_git_repo(self) -> bool: try: _ = Repo(self.repo_path) return True - except git.exc.InvalidGitRepositoryError: + except InvalidGitRepositoryError: return False def init(self) -> None: diff --git a/src/specfact_cli/utils/persona_ownership.py b/src/specfact_cli/utils/persona_ownership.py new file mode 100644 index 00000000..952ab8c9 --- /dev/null +++ b/src/specfact_cli/utils/persona_ownership.py @@ -0,0 +1,34 @@ +"""Shared persona ownership helpers.""" + +from __future__ import annotations + +import fnmatch + +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.models.project import BundleManifest + + +@beartype +@require(lambda section_pattern: isinstance(section_pattern, str), "Section pattern must be str") +@require(lambda path: isinstance(path, str), "Path must be str") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def match_section_pattern(section_pattern: str, path: str) -> bool: + """Check if a path matches a section pattern.""" + pattern = section_pattern.replace(".*", "/*") + return fnmatch.fnmatch(path, pattern) or fnmatch.fnmatch(path, section_pattern) + + +@beartype +@require(lambda persona: isinstance(persona, str), "Persona must be str") +@require(lambda manifest: isinstance(manifest, BundleManifest), "Manifest must be BundleManifest") +@require(lambda section_path: isinstance(section_path, str), "Section path must be str") +@ensure(lambda result: isinstance(result, bool), "Must return bool") +def check_persona_ownership(persona: str, manifest: BundleManifest, section_path: str) -> bool: + """Check if persona owns a section.""" + if persona not in manifest.personas: + return False + + persona_mapping = manifest.personas[persona] + return any(match_section_pattern(pattern, section_path) for pattern in persona_mapping.owns) diff --git a/src/specfact_cli/utils/source_scanner.py b/src/specfact_cli/utils/source_scanner.py index 52585ca0..a46b2326 100644 --- a/src/specfact_cli/utils/source_scanner.py +++ b/src/specfact_cli/utils/source_scanner.py @@ -105,6 +105,9 @@ def _link_feature_to_specs( """ if feature.source_tracking is None: feature.source_tracking = SourceTracking() + source_tracking = feature.source_tracking + if source_tracking is None: + return # Initialize caches if not provided (for backward compatibility) if file_functions_cache is None: @@ -177,16 +180,16 @@ def _link_feature_to_specs( # Add matched implementation files to feature for rel_path in matched_impl_files: - if rel_path not in feature.source_tracking.implementation_files: - feature.source_tracking.implementation_files.append(rel_path) + if rel_path not in source_tracking.implementation_files: + source_tracking.implementation_files.append(rel_path) # Use cached hash if available (all hashes should be pre-computed) if rel_path in file_hashes_cache: - feature.source_tracking.file_hashes[rel_path] = file_hashes_cache[rel_path] + source_tracking.file_hashes[rel_path] = file_hashes_cache[rel_path] else: # Fallback: compute hash if not in cache (shouldn't happen, but safe fallback) file_path = repo_path / rel_path if file_path.exists(): - feature.source_tracking.update_hash(file_path) + source_tracking.update_hash(file_path) # Check if feature key matches any test file stem directly (O(1)) if feature_key_lower in test_files_by_stem: @@ -230,16 +233,16 @@ def _link_feature_to_specs( # Add matched test files to feature for rel_path in matched_test_files: - if rel_path not in feature.source_tracking.test_files: - feature.source_tracking.test_files.append(rel_path) + if rel_path not in source_tracking.test_files: + source_tracking.test_files.append(rel_path) # Use cached hash if available (all hashes should be pre-computed) if rel_path in file_hashes_cache: - feature.source_tracking.file_hashes[rel_path] = file_hashes_cache[rel_path] + source_tracking.file_hashes[rel_path] = file_hashes_cache[rel_path] else: # Fallback: compute hash if not in cache (shouldn't happen, but safe fallback) file_path = repo_path / rel_path if file_path.exists(): - feature.source_tracking.update_hash(file_path) + source_tracking.update_hash(file_path) # Extract function mappings for stories using cached results # Optimization: Use sets for O(1) lookups instead of O(n) list membership checks @@ -250,7 +253,7 @@ def _link_feature_to_specs( source_functions_set = set(story.source_functions) if story.source_functions else set() test_functions_set = set(story.test_functions) if story.test_functions else set() - for impl_file in feature.source_tracking.implementation_files: + for impl_file in source_tracking.implementation_files: # Use cached functions if available (all functions should be pre-computed) if impl_file in file_functions_cache: functions = file_functions_cache[impl_file] @@ -264,7 +267,7 @@ def _link_feature_to_specs( if func_mapping not in source_functions_set: source_functions_set.add(func_mapping) - for test_file in feature.source_tracking.test_files: + for test_file in source_tracking.test_files: # Use cached test functions if available (all test functions should be pre-computed) if test_file in file_test_functions_cache: test_functions = file_test_functions_cache[test_file] @@ -283,7 +286,7 @@ def _link_feature_to_specs( story.test_functions = list(test_functions_set) # Update sync timestamp - feature.source_tracking.update_sync_timestamp() + source_tracking.update_sync_timestamp() @beartype @require(lambda self, features: isinstance(features, list), "Features must be list") diff --git a/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py b/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py index b4cc62e5..9e219694 100644 --- a/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py +++ b/tests/e2e/backlog/test_backlog_refine_limit_and_cancel.py @@ -11,8 +11,8 @@ from beartype import beartype from specfact_cli.backlog.filters import BacklogFilters -from specfact_cli.commands.backlog_commands import _fetch_backlog_items from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.modules.backlog.src.commands import _fetch_backlog_items class TestBacklogRefineLimitAndCancel: @@ -35,7 +35,7 @@ def test_fetch_backlog_items_respects_limit(self) -> None: ] # Mock adapter to return all items - with patch("specfact_cli.commands.backlog_commands.AdapterRegistry") as mock_registry: + with patch("specfact_cli.modules.backlog.src.commands.AdapterRegistry") as mock_registry: from specfact_cli.backlog.adapters.base import BacklogAdapter mock_adapter = MagicMock(spec=BacklogAdapter) @@ -64,7 +64,7 @@ def test_fetch_backlog_items_no_limit_returns_all(self) -> None: for i in range(1, 11) # 10 items ] - with patch("specfact_cli.commands.backlog_commands.AdapterRegistry") as mock_registry: + with patch("specfact_cli.modules.backlog.src.commands.AdapterRegistry") as mock_registry: from specfact_cli.backlog.adapters.base import BacklogAdapter mock_adapter = MagicMock(spec=BacklogAdapter) diff --git a/tests/e2e/test_brownfield_speckit_compliance.py b/tests/e2e/test_brownfield_speckit_compliance.py index 1066ea91..d63eedbc 100644 --- a/tests/e2e/test_brownfield_speckit_compliance.py +++ b/tests/e2e/test_brownfield_speckit_compliance.py @@ -95,7 +95,7 @@ def test_complete_brownfield_to_speckit_workflow(self, brownfield_repo: Path) -> assert bundle_dir.exists() assert (bundle_dir / "bundle.manifest.yaml").exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -213,7 +213,7 @@ def test_brownfield_import_extracts_technology_stack(self, brownfield_repo: Path bundle_dir = brownfield_repo / ".specfact" / "projects" / bundle_name assert bundle_dir.exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -270,7 +270,7 @@ def test_enrich_for_speckit_ensures_compliance(self, brownfield_repo: Path) -> N bundle_dir = brownfield_repo / ".specfact" / "projects" / bundle_name assert bundle_dir.exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) diff --git a/tests/e2e/test_complete_workflow.py b/tests/e2e/test_complete_workflow.py index 942470c3..254ac142 100644 --- a/tests/e2e/test_complete_workflow.py +++ b/tests/e2e/test_complete_workflow.py @@ -1056,7 +1056,7 @@ def test_e2e_add_feature_and_story_workflow(self, workspace: Path, monkeypatch): print("✅ Story added via CLI") # Step 4: Verify plan structure (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = workspace / ".specfact" / "projects" / bundle_name @@ -1171,7 +1171,7 @@ def test_e2e_add_multiple_features_workflow(self, workspace: Path, monkeypatch): assert result3.exit_code == 0 # Verify all features exist (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = workspace / ".specfact" / "projects" / bundle_name @@ -2075,7 +2075,7 @@ def test_cli_analyze_code2spec_on_self(self): assert report_path.exists(), "Should create analysis report" # Verify bundle content (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) diff --git a/tests/e2e/test_directory_structure_workflow.py b/tests/e2e/test_directory_structure_workflow.py index b9b02068..d34b824e 100644 --- a/tests/e2e/test_directory_structure_workflow.py +++ b/tests/e2e/test_directory_structure_workflow.py @@ -132,7 +132,7 @@ def delete_user(self, user_id): assert bundle_dir.exists() assert (bundle_dir / "bundle.manifest.yaml").exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -157,8 +157,8 @@ def delete_user(self, user_id): ) # Save as modular bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.generators.plan_generator import PlanGenerator + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle manual_project_bundle = _convert_plan_bundle_to_project_bundle(manual_plan, bundle_name_manual) @@ -311,8 +311,8 @@ def delete_task(self, task_id): # Step 5: Create temporary PlanBundle files for comparison (plan compare expects file paths) # This is a workaround until plan compare is updated to support modular bundles directly - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle from specfact_cli.generators.plan_generator import PlanGenerator + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle plans_dir = tmp_path / ".specfact" / "plans" plans_dir.mkdir(parents=True, exist_ok=True) @@ -532,7 +532,7 @@ def test_migrate_from_old_structure(self, tmp_path): assert result.exit_code == 0 # Step 3: Migrate old plan to new structure (modular bundle) - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle # Load old plan @@ -580,8 +580,8 @@ def method(self): assert result.exit_code == 0 # Compare (create temporary PlanBundle files for comparison) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle from specfact_cli.generators.plan_generator import PlanGenerator + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle plans_dir = tmp_path / ".specfact" / "plans" plans_dir.mkdir(parents=True, exist_ok=True) @@ -707,8 +707,8 @@ def login(self, username, password): assert result.exit_code == 0 # Step 5: CI/CD: Compare with plan (create temporary PlanBundle files for comparison) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle from specfact_cli.generators.plan_generator import PlanGenerator + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle plans_dir = tmp_path / ".specfact" / "plans" plans_dir.mkdir(parents=True, exist_ok=True) @@ -821,8 +821,8 @@ def execute(self): assert (auto_bundle_dir / "bundle.manifest.yaml").exists() # Step 4: Developer B compares (create temporary PlanBundle files for comparison) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle from specfact_cli.generators.plan_generator import PlanGenerator + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle plans_dir = tmp_path / ".specfact" / "plans" diff --git a/tests/e2e/test_enforcement_workflow.py b/tests/e2e/test_enforcement_workflow.py index aff727ab..3e45a803 100644 --- a/tests/e2e/test_enforcement_workflow.py +++ b/tests/e2e/test_enforcement_workflow.py @@ -233,9 +233,9 @@ def generate_report(self): assert result.exit_code == 0 # Step 4: Compare plans without enforcement config (create temporary PlanBundle files) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle from specfact_cli.generators.plan_generator import PlanGenerator from specfact_cli.models.plan import PlanBundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle plans_dir = tmp_path / ".specfact" / "plans" diff --git a/tests/e2e/test_enrichment_workflow.py b/tests/e2e/test_enrichment_workflow.py index 9d5135d6..58fc2754 100644 --- a/tests/e2e/test_enrichment_workflow.py +++ b/tests/e2e/test_enrichment_workflow.py @@ -92,7 +92,7 @@ def test_dual_stack_enrichment_workflow(self, sample_repo: Path, tmp_path: Path) initial_bundle_dir = bundle_dir # Load and verify initial plan - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -308,7 +308,7 @@ def test_enrichment_preserves_plan_structure(self, sample_repo: Path, tmp_path: bundle_dir = specfact_dir / "projects" / bundle_name assert bundle_dir.exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle initial_project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index 9f194009..264aadef 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -198,7 +198,7 @@ def mock_get_locations(package_name: str) -> list: ) # Also mock in the init command module where it's imported monkeypatch.setattr( - "specfact_cli.commands.init.get_package_installation_locations", + "specfact_cli.modules.init.src.commands.get_package_installation_locations", mock_get_locations, ) @@ -212,7 +212,7 @@ def mock_find_resources(package_name: str, resource_subpath: str): ) # Also mock in the init command module where it's imported monkeypatch.setattr( - "specfact_cli.commands.init.find_package_resources_path", + "specfact_cli.modules.init.src.commands.find_package_resources_path", mock_find_resources, ) diff --git a/tests/e2e/test_phase1_features_e2e.py b/tests/e2e/test_phase1_features_e2e.py index 24a01fb0..445e2e7a 100644 --- a/tests/e2e/test_phase1_features_e2e.py +++ b/tests/e2e/test_phase1_features_e2e.py @@ -151,7 +151,7 @@ def test_step1_1_test_patterns_extraction(self, test_repo: Path) -> None: assert "Import complete" in result.stdout # Load plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = test_repo / ".specfact" / "projects" / bundle_name @@ -207,7 +207,7 @@ def test_step1_2_control_flow_scenarios(self, test_repo: Path) -> None: assert result.exit_code == 0 # Load plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = test_repo / ".specfact" / "projects" / bundle_name @@ -255,7 +255,7 @@ def test_step1_3_complete_requirements_and_nfrs(self, test_repo: Path) -> None: assert result.exit_code == 0 # Load plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = test_repo / ".specfact" / "projects" / bundle_name @@ -316,7 +316,7 @@ def test_step1_4_entry_point_scoping(self, test_repo: Path) -> None: assert result_full.exit_code == 0 # Load plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir_full = test_repo / ".specfact" / "projects" / bundle_name_full @@ -397,7 +397,7 @@ def test_phase1_complete_workflow(self, test_repo: Path) -> None: assert result.exit_code == 0 # Load plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = test_repo / ".specfact" / "projects" / bundle_name diff --git a/tests/e2e/test_phase2_contracts_e2e.py b/tests/e2e/test_phase2_contracts_e2e.py index 3b163d65..ca3870b4 100644 --- a/tests/e2e/test_phase2_contracts_e2e.py +++ b/tests/e2e/test_phase2_contracts_e2e.py @@ -64,7 +64,7 @@ def get_user(self, user_id: int) -> dict | None: assert result.exit_code == 0 # Check that plan bundle contains contracts (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = repo_path / ".specfact" / "projects" / bundle_name @@ -138,7 +138,7 @@ def process_payment(self, amount: float, currency: str = "USD") -> dict: assert result.exit_code == 0 # Verify contracts are in plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = repo_path / ".specfact" / "projects" / bundle_name @@ -227,7 +227,7 @@ def process(self, data: list[str]) -> dict: assert result.exit_code == 0 # Verify contracts exist in plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = repo_path / ".specfact" / "projects" / bundle_name @@ -317,7 +317,7 @@ def process(self, items: list[str], config: dict[str, int]) -> list[dict]: assert result.exit_code == 0 # Verify contracts with complex types are in plan bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle bundle_dir = repo_path / ".specfact" / "projects" / bundle_name diff --git a/tests/e2e/test_plan_review_batch_updates.py b/tests/e2e/test_plan_review_batch_updates.py index 6607c340..f8920581 100644 --- a/tests/e2e/test_plan_review_batch_updates.py +++ b/tests/e2e/test_plan_review_batch_updates.py @@ -108,7 +108,7 @@ def incomplete_plan(workspace: Path) -> Path: ) # Convert to modular bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle project_bundle = _convert_plan_bundle_to_project_bundle(bundle, bundle_name) @@ -344,7 +344,7 @@ def test_batch_update_features_from_file(self, workspace: Path, incomplete_plan: # Verify updates were applied # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -386,7 +386,7 @@ def test_batch_update_features_partial_updates(self, workspace: Path, incomplete # Read original plan # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle # Load original bundle (modular bundle) @@ -420,7 +420,7 @@ def test_batch_update_features_partial_updates(self, workspace: Path, incomplete # Verify partial updates # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -486,7 +486,7 @@ def test_batch_update_stories_from_file(self, workspace: Path, incomplete_plan: # Verify updates were applied # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -511,7 +511,7 @@ def test_batch_update_stories_multiple_features(self, workspace: Path, incomplet # Add a story to FEATURE-002 first # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle # Load bundle (modular bundle) @@ -536,7 +536,7 @@ def test_batch_update_stories_multiple_features(self, workspace: Path, incomplet ) # Save bundle (modular bundle) - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle project_bundle = _convert_plan_bundle_to_project_bundle( @@ -586,7 +586,7 @@ def test_batch_update_stories_multiple_features(self, workspace: Path, incomplet # Verify both stories were updated # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -617,8 +617,8 @@ def test_interactive_feature_update(self, workspace: Path, incomplete_plan: Path monkeypatch.chdir(workspace) with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, ): # Setup responses for interactive update mock_text.side_effect = [ @@ -661,8 +661,8 @@ def test_interactive_story_update(self, workspace: Path, incomplete_plan: Path, monkeypatch.chdir(workspace) with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, ): # Setup responses for interactive update mock_text.side_effect = [ @@ -779,7 +779,7 @@ def test_complete_batch_workflow(self, workspace: Path, incomplete_plan: Path, m # Step 4: Verify updates were applied # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -882,7 +882,7 @@ def test_copilot_llm_enrichment_workflow(self, workspace: Path, incomplete_plan: # Step 5: Verify all updates were applied # Load bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) diff --git a/tests/e2e/test_plan_review_non_interactive.py b/tests/e2e/test_plan_review_non_interactive.py index 744855c8..de2c3774 100644 --- a/tests/e2e/test_plan_review_non_interactive.py +++ b/tests/e2e/test_plan_review_non_interactive.py @@ -85,7 +85,7 @@ def incomplete_plan(workspace: Path) -> Path: ) # Convert to modular bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle project_bundle = _convert_plan_bundle_to_project_bundle(bundle, bundle_name) @@ -208,7 +208,7 @@ def test_list_questions_empty_when_no_ambiguities(self, workspace: Path, monkeyp ) # Convert to modular bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle project_bundle = _convert_plan_bundle_to_project_bundle(bundle, bundle_name) @@ -265,7 +265,7 @@ def test_answers_from_file(self, workspace: Path, incomplete_plan: Path, monkeyp assert "Review complete" in result.stdout or "question(s) answered" in result.stdout # Verify plan was updated (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle updated_project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -405,7 +405,7 @@ def test_answers_integration_into_plan(self, workspace: Path, incomplete_plan: P # Verify integration # Load updated bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle updated_project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) @@ -502,7 +502,7 @@ def test_copilot_workflow_simulation(self, workspace: Path, incomplete_plan: Pat # Verify all answers were integrated # Load updated bundle (modular bundle) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle updated_project_bundle = load_project_bundle(incomplete_plan, validate_hashes=False) diff --git a/tests/e2e/test_watch_mode_e2e.py b/tests/e2e/test_watch_mode_e2e.py index 42ec4bf3..615a1022 100644 --- a/tests/e2e/test_watch_mode_e2e.py +++ b/tests/e2e/test_watch_mode_e2e.py @@ -47,9 +47,9 @@ def test_watch_mode_detects_speckit_changes(self) -> None: bundle_dir.mkdir(parents=True) # Create minimal bundle manifest - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle from specfact_cli.models.project import Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -155,8 +155,8 @@ def test_watch_mode_detects_specfact_changes(self) -> None: bundle_dir.mkdir(parents=True) # Create minimal bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -200,11 +200,11 @@ def run_watch_mode() -> None: # Modify SpecFact bundle while watch mode is running # Load, modify, and save the bundle - from specfact_cli.commands.plan import ( + from specfact_cli.models.plan import Feature + from specfact_cli.modules.plan.src.commands import ( _convert_plan_bundle_to_project_bundle, _convert_project_bundle_to_plan_bundle, ) - from specfact_cli.models.plan import Feature from specfact_cli.utils.bundle_loader import load_project_bundle updated_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -262,8 +262,8 @@ def test_watch_mode_bidirectional_sync(self) -> None: bundle_dir.mkdir(parents=True) # Create minimal bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -330,11 +330,11 @@ def run_watch_mode() -> None: assert (bundle_dir / "bundle.manifest.yaml").exists(), "Bundle manifest should exist after sync" # Then modify SpecFact bundle - from specfact_cli.commands.plan import ( + from specfact_cli.models.plan import Feature + from specfact_cli.modules.plan.src.commands import ( _convert_plan_bundle_to_project_bundle, _convert_project_bundle_to_plan_bundle, ) - from specfact_cli.models.plan import Feature from specfact_cli.utils.bundle_loader import load_project_bundle updated_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -431,8 +431,8 @@ def test_watch_mode_detects_repository_changes(self) -> None: bundle_dir.mkdir(parents=True) # Create minimal bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -506,8 +506,8 @@ def test_watch_mode_handles_multiple_changes(self) -> None: bundle_dir.mkdir(parents=True) # Create minimal bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -587,8 +587,8 @@ def test_watch_mode_graceful_shutdown(self) -> None: bundle_dir.mkdir(parents=True) # Create minimal bundle - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( diff --git a/tests/integration/backlog/test_backlog_filtering_integration.py b/tests/integration/backlog/test_backlog_filtering_integration.py index af7edbdb..3a9fff5c 100644 --- a/tests/integration/backlog/test_backlog_filtering_integration.py +++ b/tests/integration/backlog/test_backlog_filtering_integration.py @@ -13,8 +13,8 @@ from beartype import beartype from specfact_cli.backlog.converter import convert_github_issue_to_backlog_item -from specfact_cli.commands.backlog_commands import _apply_filters from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.modules.backlog.src.commands import _apply_filters @pytest.fixture diff --git a/tests/integration/commands/test_auth_commands_integration.py b/tests/integration/commands/test_auth_commands_integration.py index efa8813a..05f70c98 100644 --- a/tests/integration/commands/test_auth_commands_integration.py +++ b/tests/integration/commands/test_auth_commands_integration.py @@ -13,7 +13,7 @@ from typer.testing import CliRunner from specfact_cli.cli import app -from specfact_cli.commands.auth import AZURE_DEVOPS_RESOURCE +from specfact_cli.modules.auth.src.commands import AZURE_DEVOPS_RESOURCE from specfact_cli.utils.auth_tokens import load_tokens diff --git a/tests/integration/commands/test_enrich_for_speckit.py b/tests/integration/commands/test_enrich_for_speckit.py index 8a10cee9..111d4a69 100644 --- a/tests/integration/commands/test_enrich_for_speckit.py +++ b/tests/integration/commands/test_enrich_for_speckit.py @@ -62,7 +62,7 @@ def create_user(self, name: str) -> bool: f"Project bundle not found. Exit code: {result.exit_code}, Output: {result.stdout}" ) - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -135,7 +135,7 @@ def login(self, username: str, password: str) -> bool: bundle_dir = repo_path / ".specfact" / "projects" / bundle_name assert bundle_dir.exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) @@ -216,7 +216,7 @@ class Service: # Verify technology stack was extracted (modular bundle) assert bundle_dir.exists() - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) diff --git a/tests/integration/commands/test_ensure_speckit_compliance.py b/tests/integration/commands/test_ensure_speckit_compliance.py index dc184a78..181ce46d 100644 --- a/tests/integration/commands/test_ensure_speckit_compliance.py +++ b/tests/integration/commands/test_ensure_speckit_compliance.py @@ -124,8 +124,8 @@ def test_ensure_speckit_compliance_warns_missing_tech_stack(self) -> None: (specify_dir / "constitution.md").write_text("# Constitution\n") # Create SpecFact structure with modular bundle without technology stack (new structure) - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import Feature, Idea, PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle from specfact_cli.utils.structure import SpecFactStructure @@ -206,8 +206,8 @@ def test_ensure_speckit_compliance_warns_non_testable_acceptance(self) -> None: (specify_dir / "constitution.md").write_text("# Constitution\n") # Create SpecFact structure with modular bundle with non-testable acceptance (new structure) - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import Feature, Idea, PlanBundle, Product, Story + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle from specfact_cli.utils.structure import SpecFactStructure diff --git a/tests/integration/commands/test_import_enrichment_contracts.py b/tests/integration/commands/test_import_enrichment_contracts.py index a283522d..1cc71103 100644 --- a/tests/integration/commands/test_import_enrichment_contracts.py +++ b/tests/integration/commands/test_import_enrichment_contracts.py @@ -222,7 +222,7 @@ def test_enrichment_with_new_features_gets_contracts_extracted(self, sample_repo assert bundle_dir.exists() # Load initial features - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.utils.bundle_loader import load_project_bundle initial_project_bundle = load_project_bundle(bundle_dir, validate_hashes=False) diff --git a/tests/integration/commands/test_repro_command.py b/tests/integration/commands/test_repro_command.py index 4bafbc18..f65fce4d 100644 --- a/tests/integration/commands/test_repro_command.py +++ b/tests/integration/commands/test_repro_command.py @@ -195,7 +195,7 @@ def test_setup_handles_no_source_directories(self, tmp_path: Path, monkeypatch): assert result.exit_code == 0 # Should still complete successfully, using "." as fallback - @patch("specfact_cli.commands.repro.check_tool_in_env") + @patch("specfact_cli.modules.repro.src.commands.check_tool_in_env") def test_setup_warns_when_crosshair_not_available(self, mock_check_tool, tmp_path: Path, monkeypatch): """Test setup warns when crosshair-tool is not available.""" monkeypatch.chdir(tmp_path) @@ -213,7 +213,7 @@ def test_setup_warns_when_crosshair_not_available(self, mock_check_tool, tmp_pat assert "crosshair-tool not available" in result.stdout assert "Tip:" in result.stdout - @patch("specfact_cli.commands.repro.check_tool_in_env") + @patch("specfact_cli.modules.repro.src.commands.check_tool_in_env") def test_setup_shows_crosshair_available(self, mock_check_tool, tmp_path: Path, monkeypatch): """Test setup shows success when crosshair-tool is available.""" monkeypatch.chdir(tmp_path) @@ -231,7 +231,7 @@ def test_setup_shows_crosshair_available(self, mock_check_tool, tmp_path: Path, assert "crosshair-tool is available" in result.stdout @patch("subprocess.run") - @patch("specfact_cli.commands.repro.check_tool_in_env") + @patch("specfact_cli.modules.repro.src.commands.check_tool_in_env") def test_setup_installs_crosshair_when_requested( self, mock_check_tool, mock_subprocess, tmp_path: Path, monkeypatch ): @@ -256,7 +256,7 @@ def test_setup_installs_crosshair_when_requested( mock_subprocess.assert_called_once() @patch("subprocess.run") - @patch("specfact_cli.commands.repro.check_tool_in_env") + @patch("specfact_cli.modules.repro.src.commands.check_tool_in_env") def test_setup_handles_installation_failure(self, mock_check_tool, mock_subprocess, tmp_path: Path, monkeypatch): """Test setup handles crosshair-tool installation failure gracefully.""" monkeypatch.chdir(tmp_path) @@ -296,7 +296,7 @@ def test_setup_provides_installation_guidance_for_hatch(self, tmp_path: Path, mo src_dir.mkdir(parents=True) (src_dir / "__init__.py").write_text("") - with patch("specfact_cli.commands.repro.check_tool_in_env") as mock_check: + with patch("specfact_cli.modules.repro.src.commands.check_tool_in_env") as mock_check: mock_check.return_value = (False, "Tool 'crosshair' not found") result = runner.invoke(app, ["repro", "setup", "--repo", str(tmp_path)]) diff --git a/tests/integration/commands/test_sdd_contract_integration.py b/tests/integration/commands/test_sdd_contract_integration.py index 079bfa88..4fd0acf1 100644 --- a/tests/integration/commands/test_sdd_contract_integration.py +++ b/tests/integration/commands/test_sdd_contract_integration.py @@ -225,7 +225,7 @@ def test_contract_coverage_metrics( sdd = SDDManifest.model_validate(sdd_data) # Calculate coverage - from specfact_cli.commands.plan import _convert_project_bundle_to_plan_bundle + from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle from specfact_cli.validators.contract_validator import calculate_contract_density # Convert ProjectBundle to PlanBundle for compatibility diff --git a/tests/integration/commands/test_spec_commands.py b/tests/integration/commands/test_spec_commands.py index 6fd3c5de..6b119d5f 100644 --- a/tests/integration/commands/test_spec_commands.py +++ b/tests/integration/commands/test_spec_commands.py @@ -14,8 +14,8 @@ class TestSpecValidateCommand: """Test suite for spec validate command.""" - @patch("specfact_cli.commands.spec.check_specmatic_available") - @patch("specfact_cli.commands.spec.validate_spec_with_specmatic") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.validate_spec_with_specmatic") def test_validate_command_success(self, mock_validate, mock_check, tmp_path): """Test successful validation command.""" mock_check.return_value = (True, None) @@ -51,7 +51,7 @@ async def mock_validate_coro(*args, **kwargs): assert "Validating specification" in result.stdout assert "✓ Specification is valid" in result.stdout - @patch("specfact_cli.commands.spec.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") def test_validate_command_specmatic_not_available(self, mock_check, tmp_path): """Test validation when Specmatic is not available.""" mock_check.return_value = (False, "Specmatic CLI not found") @@ -69,8 +69,8 @@ def test_validate_command_specmatic_not_available(self, mock_check, tmp_path): assert result.exit_code == 1 assert "Specmatic not available" in result.stdout - @patch("specfact_cli.commands.spec.check_specmatic_available") - @patch("specfact_cli.commands.spec.validate_spec_with_specmatic") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.validate_spec_with_specmatic") def test_validate_command_failure(self, mock_validate, mock_check, tmp_path): """Test validation command with validation failures.""" mock_check.return_value = (True, None) @@ -106,8 +106,8 @@ async def mock_validate_async(*args, **kwargs): class TestSpecBackwardCompatCommand: """Test suite for spec backward-compat command.""" - @patch("specfact_cli.commands.spec.check_specmatic_available") - @patch("specfact_cli.commands.spec.check_backward_compatibility") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.check_backward_compatibility") def test_backward_compat_command_success(self, mock_check_compat, mock_check, tmp_path): """Test successful backward compatibility check.""" mock_check.return_value = (True, None) @@ -134,8 +134,8 @@ async def mock_compat_async(*args, **kwargs): assert "Checking backward compatibility" in result.stdout assert "✓ Specifications are backward compatible" in result.stdout - @patch("specfact_cli.commands.spec.check_specmatic_available") - @patch("specfact_cli.commands.spec.check_backward_compatibility") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.check_backward_compatibility") def test_backward_compat_command_breaking_changes(self, mock_check_compat, mock_check, tmp_path): """Test backward compatibility check with breaking changes.""" mock_check.return_value = (True, None) @@ -166,8 +166,8 @@ async def mock_compat_async(*args, **kwargs): class TestSpecGenerateTestsCommand: """Test suite for spec generate-tests command.""" - @patch("specfact_cli.commands.spec.check_specmatic_available") - @patch("specfact_cli.commands.spec.generate_specmatic_tests") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.generate_specmatic_tests") def test_generate_tests_command_success(self, mock_generate, mock_check, tmp_path): """Test successful test generation.""" mock_check.return_value = (True, None) @@ -202,8 +202,8 @@ async def mock_generate_async(*args, **kwargs): class TestSpecMockCommand: """Test suite for spec mock command.""" - @patch("specfact_cli.commands.spec.check_specmatic_available") - @patch("specfact_cli.commands.spec.create_mock_server") + @patch("specfact_cli.modules.spec.src.commands.check_specmatic_available") + @patch("specfact_cli.modules.spec.src.commands.create_mock_server") def test_mock_command_success(self, mock_create, mock_check, tmp_path): """Test successful mock server creation.""" mock_check.return_value = (True, None) diff --git a/tests/integration/sync/test_sync_command.py b/tests/integration/sync/test_sync_command.py index 25b54668..c107b455 100644 --- a/tests/integration/sync/test_sync_command.py +++ b/tests/integration/sync/test_sync_command.py @@ -44,8 +44,8 @@ def test_sync_spec_kit_basic(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -84,8 +84,8 @@ def test_sync_spec_kit_with_bidirectional(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -154,8 +154,8 @@ def test_sync_spec_kit_with_changes(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -205,8 +205,8 @@ def test_sync_spec_kit_watch_mode_not_implemented(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -291,8 +291,8 @@ def test_sync_spec_kit_with_overwrite_flag(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -328,7 +328,7 @@ def test_sync_spec_kit_with_overwrite_flag(self) -> None: assert result.exit_code != 2, "Overwrite flag should be recognized" def test_plan_sync_shared_command(self) -> None: - """Test plan sync --shared command (convenience wrapper for bidirectional sync).""" + """Test plan sync --shared command delegates to sync bridge without import errors.""" with TemporaryDirectory() as tmpdir: repo_path = Path(tmpdir) @@ -344,8 +344,8 @@ def test_plan_sync_shared_command(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( @@ -360,18 +360,20 @@ def test_plan_sync_shared_command(self) -> None: project_bundle = _convert_plan_bundle_to_project_bundle(plan_bundle, bundle_name) save_project_bundle(project_bundle, bundle_dir, atomic=True) + plan_path = repo_path / ".specfact" / "plans" / "main.bundle.yaml" + plan_path.parent.mkdir(parents=True, exist_ok=True) + plan_path.write_text("version: 1.0\n") + result = runner.invoke( app, [ + "plan", "sync", - "bridge", - "--adapter", - "speckit", - "--bundle", - bundle_name, - "--bidirectional", + "--shared", "--repo", str(repo_path), + "--plan", + str(plan_path), ], ) @@ -412,8 +414,8 @@ def test_sync_spec_kit_watch_mode(self) -> None: bundle_dir = projects_dir / bundle_name bundle_dir.mkdir() - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import PlanBundle, Product + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle plan_bundle = PlanBundle( diff --git a/tests/integration/test_plan_command.py b/tests/integration/test_plan_command.py index 4cd2f58a..66f20557 100644 --- a/tests/integration/test_plan_command.py +++ b/tests/integration/test_plan_command.py @@ -5,14 +5,14 @@ from typer.testing import CliRunner from specfact_cli.cli import app +from specfact_cli.models.plan import Feature +from specfact_cli.models.project import ProjectBundle # Import conversion functions from plan command module -from specfact_cli.commands.plan import ( +from specfact_cli.modules.plan.src.commands import ( _convert_plan_bundle_to_project_bundle, _convert_project_bundle_to_plan_bundle, ) -from specfact_cli.models.plan import Feature -from specfact_cli.models.project import ProjectBundle from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle @@ -86,9 +86,9 @@ def test_plan_init_basic_idea_only(self, tmp_path, monkeypatch): # Mock all prompts for a minimal plan with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, - patch("specfact_cli.commands.plan.prompt_list") as mock_list, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_list") as mock_list, ): # Setup responses mock_text.side_effect = [ @@ -124,10 +124,10 @@ def test_plan_init_full_workflow(self, tmp_path, monkeypatch): bundle_name = "test-bundle" with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, - patch("specfact_cli.commands.plan.prompt_list") as mock_list, - patch("specfact_cli.commands.plan.prompt_dict") as mock_dict, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_list") as mock_list, + patch("specfact_cli.modules.plan.src.commands.prompt_dict") as mock_dict, ): # Setup complete workflow responses mock_text.side_effect = [ @@ -188,9 +188,9 @@ def test_plan_init_with_business_context(self, tmp_path, monkeypatch): bundle_name = "test-bundle" with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, - patch("specfact_cli.commands.plan.prompt_list") as mock_list, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_list") as mock_list, ): mock_text.side_effect = [ "Business Project", # idea title @@ -229,7 +229,7 @@ def test_plan_init_keyboard_interrupt(self, tmp_path, monkeypatch): monkeypatch.chdir(tmp_path) bundle_name = "test-bundle" - with patch("specfact_cli.commands.plan.prompt_text") as mock_text: + with patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text: mock_text.side_effect = KeyboardInterrupt() result = runner.invoke(app, ["plan", "init", bundle_name, "--interactive"]) @@ -252,9 +252,9 @@ def test_generated_plan_passes_json_schema_validation(self, tmp_path, monkeypatc bundle_name = "test-bundle" with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, - patch("specfact_cli.commands.plan.prompt_list") as mock_list, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_list") as mock_list, ): mock_text.side_effect = ["Schema Test", "Test for schema validation"] mock_confirm.side_effect = [False, False, False, False] @@ -295,10 +295,10 @@ def test_plan_init_with_metrics(self, tmp_path, monkeypatch): bundle_name = "test-bundle" with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, - patch("specfact_cli.commands.plan.prompt_list") as mock_list, - patch("specfact_cli.commands.plan.prompt_dict") as mock_dict, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_list") as mock_list, + patch("specfact_cli.modules.plan.src.commands.prompt_dict") as mock_dict, ): mock_text.side_effect = [ "Metrics Project", # idea title @@ -335,9 +335,9 @@ def test_plan_init_with_releases(self, tmp_path, monkeypatch): bundle_name = "test-bundle" with ( - patch("specfact_cli.commands.plan.prompt_text") as mock_text, - patch("specfact_cli.commands.plan.prompt_confirm") as mock_confirm, - patch("specfact_cli.commands.plan.prompt_list") as mock_list, + patch("specfact_cli.modules.plan.src.commands.prompt_text") as mock_text, + patch("specfact_cli.modules.plan.src.commands.prompt_confirm") as mock_confirm, + patch("specfact_cli.modules.plan.src.commands.prompt_list") as mock_list, ): mock_text.side_effect = [ "Release Project", # idea title diff --git a/tests/unit/commands/test_backlog_commands.py b/tests/unit/commands/test_backlog_commands.py index 0d99f9b7..51832a26 100644 --- a/tests/unit/commands/test_backlog_commands.py +++ b/tests/unit/commands/test_backlog_commands.py @@ -12,11 +12,11 @@ from specfact_cli.backlog.template_detector import TemplateDetector from specfact_cli.cli import app -from specfact_cli.commands.backlog_commands import ( +from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.modules.backlog.src.commands import ( _item_needs_refinement, _parse_refined_export_markdown, ) -from specfact_cli.models.backlog_item import BacklogItem from specfact_cli.templates.registry import BacklogTemplate, TemplateRegistry diff --git a/tests/unit/commands/test_backlog_config.py b/tests/unit/commands/test_backlog_config.py index d38d942e..c934ab4b 100644 --- a/tests/unit/commands/test_backlog_config.py +++ b/tests/unit/commands/test_backlog_config.py @@ -13,7 +13,7 @@ import pytest -from specfact_cli.commands.backlog_commands import ( +from specfact_cli.modules.backlog.src.commands import ( _build_adapter_kwargs, _infer_ado_context_from_cwd, _load_backlog_config, @@ -70,7 +70,7 @@ class TestBuildAdapterKwargsWithConfig: def test_github_uses_explicit_args_over_config(self) -> None: """When repo_owner/repo_name passed, they are used; config ignored for those.""" with patch( - "specfact_cli.commands.backlog_commands._load_backlog_config", + "specfact_cli.modules.backlog.src.commands._load_backlog_config", return_value={"github": {"repo_owner": "fromfile", "repo_name": "fromfile"}}, ): kwargs = _build_adapter_kwargs( @@ -84,7 +84,7 @@ def test_github_uses_explicit_args_over_config(self) -> None: def test_github_uses_config_when_args_none(self) -> None: """When repo_owner/repo_name not passed, values from config are used.""" with patch( - "specfact_cli.commands.backlog_commands._load_backlog_config", + "specfact_cli.modules.backlog.src.commands._load_backlog_config", return_value={"github": {"repo_owner": "myorg", "repo_name": "myrepo"}}, ): kwargs = _build_adapter_kwargs("github", repo_owner=None, repo_name=None) @@ -96,7 +96,7 @@ def test_github_env_overrides_config(self, monkeypatch: pytest.MonkeyPatch) -> N monkeypatch.setenv("SPECFACT_GITHUB_REPO_OWNER", "fromenv") monkeypatch.setenv("SPECFACT_GITHUB_REPO_NAME", "fromenv") with patch( - "specfact_cli.commands.backlog_commands._load_backlog_config", + "specfact_cli.modules.backlog.src.commands._load_backlog_config", return_value={"github": {"repo_owner": "fromfile", "repo_name": "fromfile"}}, ): kwargs = _build_adapter_kwargs("github", repo_owner=None, repo_name=None) @@ -106,7 +106,7 @@ def test_github_env_overrides_config(self, monkeypatch: pytest.MonkeyPatch) -> N def test_ado_uses_config_when_args_none(self) -> None: """When ado_org/ado_project not passed, values from config are used.""" with patch( - "specfact_cli.commands.backlog_commands._load_backlog_config", + "specfact_cli.modules.backlog.src.commands._load_backlog_config", return_value={ "ado": {"org": "myorg", "project": "MyProject", "team": "My Team"}, }, @@ -124,7 +124,7 @@ def test_ado_uses_config_when_args_none(self) -> None: def test_tokens_never_from_config(self) -> None: """Tokens (api_token) are only from explicit args; config is not used for tokens.""" with patch( - "specfact_cli.commands.backlog_commands._load_backlog_config", + "specfact_cli.modules.backlog.src.commands._load_backlog_config", return_value={ "github": {"repo_owner": "o", "repo_name": "r", "api_token": "never"}, }, @@ -145,7 +145,7 @@ class TestInferAdoContextFromCwd: def test_returns_org_project_from_https_url(self) -> None: """HTTPS dev.azure.com/org/project/_git/repo returns (org, project).""" with patch( - "specfact_cli.commands.backlog_commands.subprocess.run", + "specfact_cli.modules.backlog.src.commands.subprocess.run", return_value=MagicMock( returncode=0, stdout="https://dev.azure.com/myorg/MyProject/_git/myrepo\n", @@ -158,7 +158,7 @@ def test_returns_org_project_from_https_url(self) -> None: def test_returns_org_project_from_ssh_url(self) -> None: """SSH git@ssh.dev.azure.com:v3/org/project/repo returns (org, project).""" with patch( - "specfact_cli.commands.backlog_commands.subprocess.run", + "specfact_cli.modules.backlog.src.commands.subprocess.run", return_value=MagicMock( returncode=0, stdout="git@ssh.dev.azure.com:v3/myorg/MyProject/myrepo\n", @@ -171,7 +171,7 @@ def test_returns_org_project_from_ssh_url(self) -> None: def test_returns_org_project_from_ssh_url_with_user(self) -> None: """SSH user@ssh.dev.azure.com:v3/org/project/repo (as in .git/config) returns (org, project).""" with patch( - "specfact_cli.commands.backlog_commands.subprocess.run", + "specfact_cli.modules.backlog.src.commands.subprocess.run", return_value=MagicMock( returncode=0, stdout="user@ssh.dev.azure.com:v3/myorg/MyProject/myrepo\n", @@ -184,7 +184,7 @@ def test_returns_org_project_from_ssh_url_with_user(self) -> None: def test_returns_org_project_from_ssh_url_dev_azure_no_ssh_subdomain(self) -> None: """SSH user@dev.azure.com:v3/org/project/repo (no ssh. subdomain, as in some .git/config) returns (org, project).""" with patch( - "specfact_cli.commands.backlog_commands.subprocess.run", + "specfact_cli.modules.backlog.src.commands.subprocess.run", return_value=MagicMock( returncode=0, stdout="user@dev.azure.com:v3/myorg/MyProject/myrepo\n", @@ -197,7 +197,7 @@ def test_returns_org_project_from_ssh_url_dev_azure_no_ssh_subdomain(self) -> No def test_returns_none_when_not_ado_remote(self) -> None: """GitHub remote returns (None, None).""" with patch( - "specfact_cli.commands.backlog_commands.subprocess.run", + "specfact_cli.modules.backlog.src.commands.subprocess.run", return_value=MagicMock( returncode=0, stdout="https://github.com/owner/repo\n", @@ -211,11 +211,11 @@ def test_ado_uses_inferred_when_args_none(self) -> None: """When ado_org/ado_project not passed, inferred from git is used.""" with ( patch( - "specfact_cli.commands.backlog_commands._load_backlog_config", + "specfact_cli.modules.backlog.src.commands._load_backlog_config", return_value={}, ), patch( - "specfact_cli.commands.backlog_commands._infer_ado_context_from_cwd", + "specfact_cli.modules.backlog.src.commands._infer_ado_context_from_cwd", return_value=("inferred-org", "inferred-project"), ), ): diff --git a/tests/unit/commands/test_backlog_daily.py b/tests/unit/commands/test_backlog_daily.py index eb5cecdf..bdfe8816 100644 --- a/tests/unit/commands/test_backlog_daily.py +++ b/tests/unit/commands/test_backlog_daily.py @@ -29,7 +29,8 @@ from specfact_cli.backlog.adapters.base import BacklogAdapter from specfact_cli.cli import app -from specfact_cli.commands.backlog_commands import ( +from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.modules.backlog.src.commands import ( _apply_filters, _build_copilot_export_content, _build_standup_rows, @@ -39,7 +40,6 @@ _format_standup_comment, _post_standup_comment_supported, ) -from specfact_cli.models.backlog_item import BacklogItem runner = CliRunner() @@ -161,7 +161,7 @@ class TestPostStandupCommentViaAdapter: def test_post_standup_comment_calls_adapter_add_comment(self) -> None: """When user opts in and adapter supports comments, add_comment is called.""" - from specfact_cli.commands.backlog_commands import _post_standup_to_item + from specfact_cli.modules.backlog.src.commands import _post_standup_to_item mock = MagicMock(spec=BacklogAdapter) mock.add_comment.return_value = True @@ -173,7 +173,7 @@ def test_post_standup_comment_calls_adapter_add_comment(self) -> None: def test_post_standup_comment_failure_reported(self) -> None: """When add_comment returns False, success is False.""" - from specfact_cli.commands.backlog_commands import _post_standup_to_item + from specfact_cli.modules.backlog.src.commands import _post_standup_to_item mock = MagicMock(spec=BacklogAdapter) mock.add_comment.return_value = False @@ -217,7 +217,7 @@ class TestDefaultStandupScope: def test_resolve_standup_options_uses_defaults_when_none(self) -> None: """When state/limit/assignee not passed, effective state is open and limit is 20.""" - from specfact_cli.commands.backlog_commands import _resolve_standup_options + from specfact_cli.modules.backlog.src.commands import _resolve_standup_options state, limit, assignee = _resolve_standup_options(None, None, None, None) assert state == "open" @@ -226,7 +226,7 @@ def test_resolve_standup_options_uses_defaults_when_none(self) -> None: def test_resolve_standup_options_explicit_overrides_defaults(self) -> None: """Explicit --state and --limit override defaults.""" - from specfact_cli.commands.backlog_commands import _resolve_standup_options + from specfact_cli.modules.backlog.src.commands import _resolve_standup_options state, limit, assignee = _resolve_standup_options("closed", 10, None, None) assert state == "closed" @@ -282,7 +282,7 @@ class TestUnassignedItems: def test_split_assigned_vs_unassigned(self) -> None: """Standup view splits items into assigned and unassigned.""" - from specfact_cli.commands.backlog_commands import _split_assigned_unassigned + from specfact_cli.modules.backlog.src.commands import _split_assigned_unassigned items = [ _item("1", "Mine", assignees=["me"]), @@ -296,7 +296,7 @@ def test_split_assigned_vs_unassigned(self) -> None: def test_unassigned_only_filters_to_unassigned(self) -> None: """When unassigned_only, only unassigned items in scope.""" - from specfact_cli.commands.backlog_commands import _split_assigned_unassigned + from specfact_cli.modules.backlog.src.commands import _split_assigned_unassigned items = [ _item("1", "A", assignees=["me"]), @@ -314,7 +314,7 @@ def test_format_sprint_end_header(self) -> None: """When sprint end date provided, format as 'Sprint ends: YYYY-MM-DD (N days)'.""" from datetime import date - from specfact_cli.commands.backlog_commands import _format_sprint_end_header + from specfact_cli.modules.backlog.src.commands import _format_sprint_end_header end = date(2025, 2, 15) header = _format_sprint_end_header(end) @@ -327,7 +327,7 @@ class TestBlockersFirstAndOptionalPriority: def test_standup_rows_blockers_first(self) -> None: """When blockers-first, items with non-empty blockers appear first.""" - from specfact_cli.commands.backlog_commands import _build_standup_rows, _sort_standup_rows_blockers_first + from specfact_cli.modules.backlog.src.commands import _build_standup_rows, _sort_standup_rows_blockers_first body_no = "Description only." body_yes = "**Blockers:** Waiting on API." @@ -343,7 +343,7 @@ def test_standup_rows_blockers_first(self) -> None: def test_standup_rows_include_priority_when_enabled(self) -> None: """When config enables priority and BacklogItem has priority, row has priority.""" - from specfact_cli.commands.backlog_commands import _build_standup_rows + from specfact_cli.modules.backlog.src.commands import _build_standup_rows items = [_item("1", "P1 item", priority=1)] rows = _build_standup_rows(items, include_priority=True) diff --git a/tests/unit/commands/test_backlog_filtering.py b/tests/unit/commands/test_backlog_filtering.py index c332406e..773086c2 100644 --- a/tests/unit/commands/test_backlog_filtering.py +++ b/tests/unit/commands/test_backlog_filtering.py @@ -13,8 +13,8 @@ from beartype import beartype from specfact_cli.backlog.converter import convert_github_issue_to_backlog_item -from specfact_cli.commands.backlog_commands import _apply_filters from specfact_cli.models.backlog_item import BacklogItem +from specfact_cli.modules.backlog.src.commands import _apply_filters @pytest.fixture diff --git a/tests/unit/commands/test_import_feature_validation.py b/tests/unit/commands/test_import_feature_validation.py index 5ad5b3cd..10febd88 100644 --- a/tests/unit/commands/test_import_feature_validation.py +++ b/tests/unit/commands/test_import_feature_validation.py @@ -10,8 +10,8 @@ import pytest -from specfact_cli.commands.import_cmd import _validate_existing_features from specfact_cli.models.plan import Feature, PlanBundle, Product, SourceTracking, Story +from specfact_cli.modules.import_cmd.src.commands import _validate_existing_features @pytest.fixture diff --git a/tests/unit/commands/test_plan_add_commands.py b/tests/unit/commands/test_plan_add_commands.py index d14de177..6f7c3da9 100644 --- a/tests/unit/commands/test_plan_add_commands.py +++ b/tests/unit/commands/test_plan_add_commands.py @@ -7,8 +7,8 @@ from typer.testing import CliRunner from specfact_cli.cli import app -from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import Feature, PlanBundle, Product, Story +from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle diff --git a/tests/unit/commands/test_plan_telemetry.py b/tests/unit/commands/test_plan_telemetry.py index 787cb651..687454ed 100644 --- a/tests/unit/commands/test_plan_telemetry.py +++ b/tests/unit/commands/test_plan_telemetry.py @@ -15,7 +15,7 @@ class TestPlanCommandTelemetry: """Test that plan commands track telemetry correctly.""" - @patch("specfact_cli.commands.plan.telemetry") + @patch("specfact_cli.modules.plan.src.commands.telemetry") def test_plan_init_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path, monkeypatch): """Test that plan init command tracks telemetry.""" monkeypatch.chdir(tmp_path) @@ -35,7 +35,7 @@ def test_plan_init_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path, m assert "interactive" in call_args[0][1] assert "scaffold" in call_args[0][1] - @patch("specfact_cli.commands.plan.telemetry") + @patch("specfact_cli.modules.plan.src.commands.telemetry") def test_plan_add_feature_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path, monkeypatch): """Test that plan add-feature command tracks telemetry.""" monkeypatch.chdir(tmp_path) @@ -57,7 +57,7 @@ def test_plan_add_feature_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_ mock_telemetry.track_command.return_value.__exit__.return_value = None # Create modular bundle instead of single file - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle from specfact_cli.utils.structure import SpecFactStructure @@ -89,7 +89,7 @@ def test_plan_add_feature_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_ # Verify record was called with additional metadata mock_record.assert_called() - @patch("specfact_cli.commands.plan.telemetry") + @patch("specfact_cli.modules.plan.src.commands.telemetry") def test_plan_add_story_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path, monkeypatch): """Test that plan add-story command tracks telemetry.""" monkeypatch.chdir(tmp_path) @@ -116,7 +116,7 @@ def test_plan_add_story_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_pa clarifications=None, ) # Create modular bundle instead of single file - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle from specfact_cli.utils.structure import SpecFactStructure @@ -156,7 +156,7 @@ def test_plan_add_story_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_pa # Verify record was called with additional metadata mock_record.assert_called() - @patch("specfact_cli.commands.plan.telemetry") + @patch("specfact_cli.modules.plan.src.commands.telemetry") def test_plan_compare_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path): """Test that plan compare command tracks telemetry.""" from specfact_cli.generators.plan_generator import PlanGenerator @@ -249,7 +249,7 @@ def test_plan_compare_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path record_calls = [call[0][0] for call in mock_record.call_args_list] assert any("total_deviations" in call for call in record_calls if isinstance(call, dict)) - @patch("specfact_cli.commands.plan.telemetry") + @patch("specfact_cli.modules.plan.src.commands.telemetry") def test_plan_promote_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path, monkeypatch): """Test that plan promote command tracks telemetry.""" monkeypatch.chdir(tmp_path) @@ -268,7 +268,7 @@ def test_plan_promote_tracks_telemetry(self, mock_telemetry: MagicMock, tmp_path clarifications=None, ) # Create modular bundle instead of single file - from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle + from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import save_project_bundle from specfact_cli.utils.structure import SpecFactStructure diff --git a/tests/unit/commands/test_plan_update_commands.py b/tests/unit/commands/test_plan_update_commands.py index 03b1acb0..5d379c2e 100644 --- a/tests/unit/commands/test_plan_update_commands.py +++ b/tests/unit/commands/test_plan_update_commands.py @@ -7,8 +7,8 @@ from typer.testing import CliRunner from specfact_cli.cli import app -from specfact_cli.commands.plan import _convert_plan_bundle_to_project_bundle from specfact_cli.models.plan import Idea, PlanBundle, Product +from specfact_cli.modules.plan.src.commands import _convert_plan_bundle_to_project_bundle from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle diff --git a/tests/unit/commands/test_update.py b/tests/unit/commands/test_update.py index 7f85c19e..9d73db89 100644 --- a/tests/unit/commands/test_update.py +++ b/tests/unit/commands/test_update.py @@ -6,15 +6,15 @@ from unittest.mock import MagicMock, patch -from specfact_cli.commands.update import InstallationMethod, detect_installation_method, install_update +from specfact_cli.modules.upgrade.src.commands import InstallationMethod, detect_installation_method, install_update class TestInstallationMethodDetection: """Tests for installation method detection.""" - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.sys.executable", "/usr/bin/python3") - @patch("specfact_cli.commands.update.sys.argv", ["/usr/bin/python3", "-m", "specfact_cli"]) + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.sys.executable", "/usr/bin/python3") + @patch("specfact_cli.modules.upgrade.src.commands.sys.argv", ["/usr/bin/python3", "-m", "specfact_cli"]) def test_detect_pip_installation(self, mock_subprocess: MagicMock) -> None: """Test detecting pip installation.""" @@ -40,16 +40,16 @@ def side_effect(*args, **kwargs): assert method.method == "pip", f"Expected pip, got {method.method}" assert "pip" in method.command.lower() - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.sys.argv", ["uvx", "--from", "specfact-cli", "specfact"]) + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.sys.argv", ["uvx", "--from", "specfact-cli", "specfact"]) def test_detect_uvx_installation(self, mock_subprocess: MagicMock) -> None: """Test detecting uvx installation.""" method = detect_installation_method() assert method.method == "uvx" - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.sys.executable", "/usr/bin/python3") - @patch("specfact_cli.commands.update.sys.argv", ["/usr/bin/python3", "-m", "specfact_cli"]) + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.sys.executable", "/usr/bin/python3") + @patch("specfact_cli.modules.upgrade.src.commands.sys.argv", ["/usr/bin/python3", "-m", "specfact_cli"]) def test_detect_pipx_installation(self, mock_subprocess: MagicMock) -> None: """Test detecting pipx installation.""" @@ -74,9 +74,9 @@ def side_effect(*args, **kwargs): # Should detect pipx first (before checking pip) assert method.method == "pipx", f"Expected pipx, got {method.method}" - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.sys.executable", "/usr/bin/python3") - @patch("specfact_cli.commands.update.sys.argv", ["/usr/bin/python3", "-m", "specfact_cli"]) + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.sys.executable", "/usr/bin/python3") + @patch("specfact_cli.modules.upgrade.src.commands.sys.argv", ["/usr/bin/python3", "-m", "specfact_cli"]) def test_fallback_to_pip(self, mock_subprocess: MagicMock) -> None: """Test fallback to pip when detection fails.""" # All detection attempts fail @@ -89,9 +89,9 @@ def test_fallback_to_pip(self, mock_subprocess: MagicMock) -> None: class TestUpdateInstallation: """Tests for update installation.""" - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.Confirm.ask", return_value=True) - @patch("specfact_cli.commands.update.update_metadata") + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.Confirm.ask", return_value=True) + @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") def test_install_update_pip_success( self, mock_update_metadata: MagicMock, mock_confirm: MagicMock, mock_subprocess: MagicMock ) -> None: @@ -104,8 +104,8 @@ def test_install_update_pip_success( mock_subprocess.assert_called_once() mock_update_metadata.assert_called_once() - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.Confirm.ask", return_value=False) + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.Confirm.ask", return_value=False) def test_install_update_user_cancels(self, mock_confirm: MagicMock, mock_subprocess: MagicMock) -> None: """Test update installation when user cancels.""" method = InstallationMethod(method="pip", command="pip install --upgrade specfact-cli", location=None) @@ -114,8 +114,8 @@ def test_install_update_user_cancels(self, mock_confirm: MagicMock, mock_subproc assert result is False mock_subprocess.assert_not_called() - @patch("specfact_cli.commands.update.subprocess.run") - @patch("specfact_cli.commands.update.update_metadata") + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") def test_install_update_with_yes_flag(self, mock_update_metadata: MagicMock, mock_subprocess: MagicMock) -> None: """Test update installation with --yes flag (no confirmation).""" method = InstallationMethod(method="pip", command="pip install --upgrade specfact-cli", location=None) @@ -125,7 +125,7 @@ def test_install_update_with_yes_flag(self, mock_update_metadata: MagicMock, moc assert result is True mock_subprocess.assert_called_once() - @patch("specfact_cli.commands.update.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") def test_install_update_failure(self, mock_subprocess: MagicMock) -> None: """Test update installation failure.""" method = InstallationMethod(method="pip", command="pip install --upgrade specfact-cli", location=None) @@ -134,7 +134,7 @@ def test_install_update_failure(self, mock_subprocess: MagicMock) -> None: result = install_update(method, yes=True) assert result is False - @patch("specfact_cli.commands.update.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") def test_install_update_uvx_informs_user(self, mock_subprocess: MagicMock) -> None: """Test update installation for uvx (just informs user).""" method = InstallationMethod(method="uvx", command="uvx --from specfact-cli specfact", location=None) diff --git a/tests/unit/specfact_cli/test_module_boundary_imports.py b/tests/unit/specfact_cli/test_module_boundary_imports.py new file mode 100644 index 00000000..54706d58 --- /dev/null +++ b/tests/unit/specfact_cli/test_module_boundary_imports.py @@ -0,0 +1,34 @@ +"""Boundary tests for module package separation.""" + +from __future__ import annotations + +import re +from pathlib import Path + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +LEGACY_NON_APP_IMPORT_PATTERN = re.compile(r"from\s+specfact_cli\.commands\.[a-zA-Z0-9_]+\s+import\s+(?!app\b)") +LEGACY_SYMBOL_REF_PATTERN = re.compile(r"specfact_cli\.commands\.[a-zA-Z0-9_]+") + + +def test_no_legacy_non_app_command_imports_outside_compat_shims() -> None: + """Block new non-app command imports outside legacy compatibility shims.""" + violations: list[str] = [] + allowed_shim_dir = PROJECT_ROOT / "src" / "specfact_cli" / "commands" + + for root in (PROJECT_ROOT / "src", PROJECT_ROOT / "tests"): + for py_file in root.rglob("*.py"): + if "__pycache__" in py_file.parts: + continue + if py_file.is_relative_to(allowed_shim_dir): + continue + + text = py_file.read_text(encoding="utf-8") + if LEGACY_NON_APP_IMPORT_PATTERN.search(text) or LEGACY_SYMBOL_REF_PATTERN.search(text): + rel = py_file.relative_to(PROJECT_ROOT) + violations.append(str(rel)) + + assert not violations, ( + "Legacy command-module references found (use module-local paths or shared modules instead):\n" + + "\n".join(f"- {path}" for path in sorted(violations)) + ) diff --git a/tests/unit/specfact_cli/test_module_migration_compatibility.py b/tests/unit/specfact_cli/test_module_migration_compatibility.py new file mode 100644 index 00000000..508e8938 --- /dev/null +++ b/tests/unit/specfact_cli/test_module_migration_compatibility.py @@ -0,0 +1,151 @@ +"""Scenario-focused tests for module package separation migration.""" + +from __future__ import annotations + +import importlib +import re +from pathlib import Path + +from specfact_cli.registry.bootstrap import register_builtin_commands +from specfact_cli.registry.registry import CommandRegistry + + +PROJECT_ROOT = Path(__file__).resolve().parents[3] +MODULES_ROOT = PROJECT_ROOT / "src" / "specfact_cli" / "modules" + +# Legacy shim module name -> module package name. +LEGACY_SHIM_TO_MODULE: dict[str, str] = { + "analyze": "analyze", + "auth": "auth", + "backlog_commands": "backlog", + "contract_cmd": "contract", + "drift": "drift", + "enforce": "enforce", + "generate": "generate", + "import_cmd": "import_cmd", + "init": "init", + "migrate": "migrate", + "plan": "plan", + "project_cmd": "project", + "repro": "repro", + "sdd": "sdd", + "spec": "spec", + "sync": "sync", + "update": "upgrade", + "validate": "validate", +} + + +def _module_package_names() -> list[str]: + return sorted(path.name for path in MODULES_ROOT.iterdir() if path.is_dir() and (path / "src").exists()) + + +def test_module_app_entrypoints_import_module_local_commands() -> None: + """Each module app entrypoint imports app from module-local commands.""" + missing: list[str] = [] + wrong_import: list[str] = [] + + for module_name in _module_package_names(): + app_path = MODULES_ROOT / module_name / "src" / "app.py" + if not app_path.exists(): + missing.append(str(app_path.relative_to(PROJECT_ROOT))) + continue + + expected_import = f"from specfact_cli.modules.{module_name}.src.commands import app" + text = app_path.read_text(encoding="utf-8") + if expected_import not in text: + wrong_import.append(str(app_path.relative_to(PROJECT_ROOT))) + + assert not missing, "Missing module app entrypoint files:\n" + "\n".join(f"- {path}" for path in missing) + assert not wrong_import, "Module app entrypoints not wired to local commands:\n" + "\n".join( + f"- {path}" for path in wrong_import + ) + + +def test_legacy_command_shims_reexport_module_app() -> None: + """Legacy command import paths still expose same app object as module-local command implementation.""" + mismatches: list[str] = [] + + for legacy_mod, module_name in LEGACY_SHIM_TO_MODULE.items(): + legacy = importlib.import_module(f"specfact_cli.commands.{legacy_mod}") + target = importlib.import_module(f"specfact_cli.modules.{module_name}.src.commands") + if getattr(legacy, "app", None) is not getattr(target, "app", None): + mismatches.append(f"{legacy_mod} -> {module_name}") + + assert not mismatches, "Legacy command shims do not re-export module-local app:\n" + "\n".join( + f"- {entry}" for entry in mismatches + ) + + +def test_legacy_command_shims_reexport_public_symbols() -> None: + """Legacy shim modules expose only app plus symbols still required by legacy import usage.""" + pattern = re.compile(r"from\s+specfact_cli\.commands\.(?P<mod>[a-zA-Z0-9_]+)\s+import\s+(?P<names>.+)") + required: dict[str, set[str]] = {mod: set() for mod in LEGACY_SHIM_TO_MODULE} + + for root in (PROJECT_ROOT / "src", PROJECT_ROOT / "tests"): + for py_file in root.rglob("*.py"): + text = py_file.read_text(encoding="utf-8") + for match in pattern.finditer(text): + mod = match.group("mod") + if mod not in required: + continue + for raw in match.group("names").split(","): + name = raw.strip().split(" as ")[0].strip() + if name: + required[mod].add(name) + + issues: list[str] = [] + for legacy_mod, module_name in LEGACY_SHIM_TO_MODULE.items(): + legacy = importlib.import_module(f"specfact_cli.commands.{legacy_mod}") + target = importlib.import_module(f"specfact_cli.modules.{module_name}.src.commands") + + required_names = {"app"} | required[legacy_mod] + exported_names = set(getattr(legacy, "__all__", [])) + + # Require app compatibility and any still-referenced legacy symbols. + for name in sorted(required_names): + if not hasattr(legacy, name): + issues.append(f"{legacy_mod}.{name} missing") + continue + if not hasattr(target, name): + issues.append(f"{legacy_mod}.{name} not in module-local commands") + continue + if getattr(legacy, name) is not getattr(target, name): + issues.append(f"{legacy_mod}.{name} object mismatch") + + # Shim policy for this migration stage: do not export extra symbols by default. + extras = sorted(exported_names - required_names) + if extras: + issues.append(f"{legacy_mod} extra exports: {', '.join(extras)}") + + assert not issues, "Legacy shim exports do not match required compatibility surface:\n" + "\n".join( + f"- {item}" for item in issues + ) + + +def test_module_discovery_registers_commands_from_manifests() -> None: + """Command registry includes all commands declared by module-package manifests after bootstrap.""" + expected_commands: set[str] = set() + for module_name in _module_package_names(): + manifest = MODULES_ROOT / module_name / "module-package.yaml" + if not manifest.exists(): + continue + lines = manifest.read_text(encoding="utf-8").splitlines() + in_commands = False + for line in lines: + stripped = line.strip() + if stripped.startswith("commands:"): + in_commands = True + continue + if in_commands and stripped.startswith("- "): + expected_commands.add(stripped[2:].strip()) + continue + if in_commands and stripped and not stripped.startswith("#") and not stripped.startswith("- "): + in_commands = False + + CommandRegistry._clear_for_testing() + register_builtin_commands() + registered = set(CommandRegistry.list_commands()) + missing = sorted(expected_commands - registered) + + assert not missing, "Missing commands after registry bootstrap:\n" + "\n".join(f"- {cmd}" for cmd in missing) From 9d57566f7acbdd170080f645de68f9415670c0d7 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:52:05 +0100 Subject: [PATCH 02/11] feat: add module lifecycle management and split init ide setup --- CHANGELOG.md | 20 + README.md | 25 +- docs/README.md | 12 + docs/reference/architecture.md | 19 +- docs/reference/commands.md | 123 ++++-- docs/reference/directory-structure.md | 32 +- .../.openspec.yaml | 2 + .../CHANGE_VALIDATION.md | 120 +++++ .../design.md | 75 ++++ .../proposal.md | 39 ++ .../specs/module-lifecycle-management/spec.md | 183 ++++++++ .../tasks.md | 108 +++++ pyproject.toml | 2 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- src/specfact_cli/cli.py | 34 +- .../modules/analyze/module-package.yaml | 1 + .../modules/auth/module-package.yaml | 1 + .../modules/backlog/module-package.yaml | 1 + .../modules/contract/module-package.yaml | 1 + .../modules/drift/module-package.yaml | 1 + .../modules/enforce/module-package.yaml | 4 +- .../modules/enforce/src/commands.py | 4 +- .../modules/generate/module-package.yaml | 4 +- .../modules/generate/src/commands.py | 4 +- .../modules/import_cmd/module-package.yaml | 1 + .../modules/init/module-package.yaml | 1 + src/specfact_cli/modules/init/src/commands.py | 417 +++++++++++++++++- .../modules/migrate/module-package.yaml | 1 + .../modules/plan/module-package.yaml | 1 + src/specfact_cli/modules/plan/src/commands.py | 54 +-- .../modules/project/module-package.yaml | 1 + .../modules/repro/module-package.yaml | 1 + .../modules/sdd/module-package.yaml | 1 + src/specfact_cli/modules/sdd/src/commands.py | 29 +- .../modules/spec/module-package.yaml | 1 + .../modules/sync/module-package.yaml | 5 +- src/specfact_cli/modules/sync/src/commands.py | 34 +- .../modules/upgrade/module-package.yaml | 1 + .../modules/validate/module-package.yaml | 1 + src/specfact_cli/registry/module_packages.py | 154 ++++++- src/specfact_cli/registry/module_state.py | 32 ++ src/specfact_cli/runtime.py | 14 +- src/specfact_cli/utils/bundle_converters.py | 67 +++ tests/e2e/test_init_command.py | 36 +- .../registry/test_init_module_lifecycle_ux.py | 237 ++++++++++ .../registry/test_module_dependencies.py | 108 +++++ .../registry/test_version_constraints.py | 31 ++ .../test_module_boundary_imports.py | 35 ++ .../utils/test_bundle_converters.py | 59 +++ tests/unit/test_runtime.py | 59 +++ 52 files changed, 2004 insertions(+), 198 deletions(-) create mode 100644 openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml create mode 100644 openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md create mode 100644 openspec/changes/arch-03-module-lifecycle-management/design.md create mode 100644 openspec/changes/arch-03-module-lifecycle-management/proposal.md create mode 100644 openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md create mode 100644 openspec/changes/arch-03-module-lifecycle-management/tasks.md create mode 100644 src/specfact_cli/utils/bundle_converters.py create mode 100644 tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py create mode 100644 tests/unit/specfact_cli/registry/test_module_dependencies.py create mode 100644 tests/unit/specfact_cli/registry/test_version_constraints.py create mode 100644 tests/unit/specfact_cli/utils/test_bundle_converters.py diff --git a/CHANGELOG.md b/CHANGELOG.md index fc20488e..93af389a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ All notable changes to this project will be documented in this file. --- +## [0.29.0] - 2026-02-06 + +### Added (0.29.0) + +- **Module lifecycle management and dependency safety** (OpenSpec change `arch-03-module-lifecycle-management`, fixes [#203](https://github.com/nold-ai/specfact-cli/issues/203)) + - Added module manifest lifecycle validation for dependency integrity and CLI core compatibility (`core_compatibility`) during command registration. + - Added module lifecycle UX in `specfact init`: `--list-modules`, interactive arrow-key enable/disable selection in interactive mode, and explicit-id enforcement in non-interactive mode. + - Added force-mode dependency cascades: + - `--force` disable cascades to enabled dependents. + - `--force` enable cascades to required upstream dependencies. + - Added `specfact init ide` for dedicated IDE prompt/template setup, while keeping `specfact init` bootstrap/module-lifecycle focused. + +### Changed (0.29.0) + +- **Interaction default behavior**: Updated runtime prompt auto-detection to be interactive-by-default in interactive terminals, while remaining non-interactive in CI/non-interactive environments. +- **Docs**: Updated README and docs reference pages for the new init/init-ide split, module lifecycle behavior, and roadmap positioning for future granular module enhancements and planned third-party/community module installation. +- **Version**: Bumped to 0.29.0 (minor: new lifecycle features and UX improvements, backward compatible). + +--- + ## [0.28.0] - 2026-02-06 ### Added (0.28.0) diff --git a/README.md b/README.md index 97f38f5f..e6b0cc50 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,16 @@ uvx specfact-cli@latest pip install -U specfact-cli ``` -### Initialize IDE Integration (optional but recommended) +### Bootstrap and IDE Setup ```bash +# Bootstrap module registry and local config (~/.specfact) specfact init -specfact init --ide cursor -specfact init --ide vscode + +# Configure IDE prompts/templates (interactive selector by default) +specfact init ide +specfact init ide --ide cursor +specfact init ide --ide vscode ``` ### Run Your First Flow @@ -134,6 +138,21 @@ Most tools help **either** coders **or** agile teams. SpecFact does both: - **Backlogs**: GitHub Issues, Azure DevOps, Jira, Linear - **Contracts**: Specmatic, OpenAPI +### Module Lifecycle Baseline + +SpecFact now has a lifecycle-managed module system: + +- `specfact init` is bootstrap-first: initializes local CLI state, discovers installed modules, and reports prompt status. +- `specfact init ide` handles IDE prompt/template sync and IDE settings updates. +- `specfact init --list-modules` shows effective enabled/disabled state. +- `specfact init --enable-module` / `--disable-module` support: + - interactive selection in interactive terminals when no module id is provided + - explicit ids in non-interactive mode (for automation) + - dependency-aware safety checks with `--force` cascading enable/disable behavior +- Module manifests support dependency and core-version compatibility enforcement at registration time. + +This lifecycle model is the baseline for future granular module updates and enhancements. Module installation from third-party or open-source community providers is planned, but not implemented yet. + --- ## Developer Note: Command Layout diff --git a/docs/README.md b/docs/README.md index ae94e768..4236972c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,18 @@ Most tools help **either** coders **or** agile teams. SpecFact does both: - **Backlogs**: GitHub Issues, Azure DevOps, Jira, Linear - **Contracts**: Specmatic, OpenAPI +## Module Lifecycle System + +SpecFact CLI uses a lifecycle-managed module system: + +- `specfact init` bootstraps local state and manages module enable/disable lifecycle. +- `specfact init ide` handles IDE prompt/template installation and updates. +- `specfact init --list-modules` shows current enabled/disabled state. +- `--enable-module` and `--disable-module` support interactive selection in interactive terminals and explicit ids in non-interactive mode. +- Dependency and compatibility guards prevent invalid module states; `--force` enables dependency-aware cascades. + +This is the baseline for future granular module updates and enhancements. Third-party/community module installation is planned, but not available yet. + --- ## Documentation Sections diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 4e3917cc..20d9c1fd 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -90,7 +90,7 @@ specfact import from-code my-project --repo . specfact --mode copilot import from-code my-project --repo . # IDE integration (slash commands) -# First, initialize: specfact init --ide cursor +# First, initialize: specfact init ide --ide cursor # Then use in IDE chat: /specfact.01-import legacy-api --repo . --confidence 0.7 /specfact.02-plan init legacy-api @@ -541,7 +541,7 @@ class ChangeArchive(BaseModel): - **Manifest**: Each package has a `module-package.yaml` with: - `name`, `version`, `commands` (list of command names the package provides) - optional `command_help` (name → short help for root `specfact --help`) - - optional `pip_dependencies`, `module_dependencies`, `tier` (e.g. community/enterprise), `addon_id` + - optional `pip_dependencies`, `module_dependencies`, `core_compatibility`, `tier` (e.g. community/enterprise), `addon_id` - **Entry point**: Each package has `src/app.py` that exposes a Typer `app` by importing from module-local `src/commands.py`. ### Legacy shim policy and timeline @@ -557,7 +557,20 @@ class ChangeArchive(BaseModel): - **File**: `~/.specfact/registry/modules.json` (created when you run `specfact init`). - **Content**: List of `{ "id", "version", "enabled" }` per module. Only modules with `enabled: true` have their commands registered. -- **CLI**: `specfact init --enable-module <id>` and `--disable-module <id>` update this state and persist it to `modules.json`. +- **CLI**: + - `specfact init --list-modules` shows effective state. + - `specfact init --enable-module <id>` and `--disable-module <id>` update persisted state. + - In interactive terminals, `specfact init --enable-module` and `specfact init --disable-module` (without ids) open an interactive selector. + - In non-interactive mode, explicit module ids are required. + - Safe dependency guards block invalid enable/disable actions unless `--force` is used. + - With `--force`, enable cascades to required dependencies and disable cascades to enabled dependents. + +### Lifecycle notes and roadmap + +- `specfact init` is bootstrap/module-lifecycle focused. +- `specfact init ide` is responsible for IDE prompt/template setup. +- This lifecycle architecture is the baseline for future granular module updates and enhancements. +- Third-party/community module installation is planned as a next step, but not implemented yet. ### Registry package layout diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b58d7567..05e8f160 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,7 +69,7 @@ specfact auth status - `--input-format {yaml,json}` - Override default structured input detection for CLI commands (defaults to YAML) - `--output-format {yaml,json}` - Control how plan bundles and reports are written (JSON is ideal for CI/copilot automations) -- `--interactive/--no-interactive` - Force prompt behavior (overrides auto-detection from CI/CD vs Copilot environments) +- `--interactive/--no-interactive` - Force prompt behavior (default auto-detection from terminal + CI environment) ### Commands by Workflow @@ -176,7 +176,8 @@ specfact auth status **Setup & Maintenance:** -- `init` - Initialize IDE integration +- `init` - Bootstrap CLI local state and manage enabled/disabled modules +- `init ide` - Initialize IDE prompt/template integration - `upgrade` - Check for and install CLI updates **⚠️ Deprecated (v0.17.0):** @@ -540,7 +541,7 @@ When working with multiple projects in a single repository, external tool integr - Use `--entry-point` to analyze each project separately - Create separate project bundles for each project (`.specfact/projects/<bundle-name>/`) -- Run `specfact init` from the repository root to ensure IDE integration works correctly (templates are copied to root-level `.github/`, `.cursor/`, etc. directories) +- Run `specfact init ide` from the repository root to ensure IDE integration works correctly (templates are copied to root-level `.github/`, `.cursor/`, etc. directories) --- @@ -4717,65 +4718,99 @@ Replace `implement tasks` with the new AI IDE bridge workflow: --- -### `init` - Initialize IDE Integration +### `init` - Bootstrap and Module Lifecycle Management -Set up SpecFact CLI for IDE integration by copying prompt templates to IDE-specific locations. +Bootstrap SpecFact local state and manage enabled/disabled command modules. ```bash specfact init [OPTIONS] ``` -**Options:** +**Common options:** - `--repo PATH` - Repository path (default: current directory) -- `--force` - Overwrite existing files -- `--install-deps` - Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) via pip +- `--list-modules` - Show discovered modules with effective enabled/disabled state and exit +- `--enable-module TEXT` - Enable module by id (repeatable) +- `--disable-module TEXT` - Disable module by id (repeatable) +- `--force` - Override dependency guards; cascades dependency updates -**Advanced Options** (hidden by default, use `--help-advanced` or `-ha` to view): +**Interactive behavior:** + +- Default mode is auto-detected from terminal + CI environment. +- In interactive terminals, passing `--enable-module` or `--disable-module` without ids opens an arrow-key selector. +- In non-interactive mode, module ids are required (for example in CI/CD). -- `--ide TEXT` - IDE type (auto, cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q) (default: auto) +**Dependency-aware behavior:** + +- Safe disable blocks disabling a module that is required by other enabled modules. +- Safe enable blocks enabling a module when required dependencies are disabled. +- `--force` performs dependency-aware cascading: + - disable cascades to enabled dependents + - enable cascades to required dependencies **Examples:** ```bash -# Auto-detect IDE +# Bootstrap only (no IDE prompt/template copy) specfact init -# Specify IDE explicitly -specfact init --ide cursor -specfact init --ide vscode -specfact init --ide copilot +# List lifecycle state +specfact init --list-modules -# Force overwrite existing files -specfact init --ide cursor --force +# Interactive selection (TTY) +specfact init --enable-module +specfact init --disable-module -# Install required packages for contract enhancement -specfact init --install-deps +# Non-interactive explicit ids +specfact --no-interactive init --enable-module backlog +specfact --no-interactive init --disable-module upgrade -# Initialize IDE integration and install dependencies -specfact init --ide cursor --install-deps +# Force dependency cascade +specfact init --enable-module sync --force +specfact init --disable-module plan --force ``` **What it does:** -1. Detects your IDE (or uses `--ide` flag) -2. Copies prompt templates from `resources/prompts/` to IDE-specific location **at the repository root level** -3. Creates/updates VS Code settings.json if needed (for VS Code/Copilot) -4. Makes slash commands available in your IDE -5. **Copies default ADO field mapping templates** to `.specfact/templates/backlog/field_mappings/` for review and customization: - - `ado_default.yaml` - Default field mappings - - `ado_scrum.yaml` - Scrum process template mappings - - `ado_agile.yaml` - Agile process template mappings - - `ado_safe.yaml` - SAFe process template mappings - - `ado_kanban.yaml` - Kanban process template mappings - - Templates are only copied if they don't exist (use `--force` to overwrite) -6. Optionally installs required packages for contract enhancement (if `--install-deps` is provided): - - `beartype>=0.22.4` - Runtime type checking - - `icontract>=2.7.1` - Design-by-contract decorators - - `crosshair-tool>=0.0.97` - Contract exploration - - `pytest>=8.4.2` - Testing framework - -**Important:** Templates are always copied to the repository root level (where `.github/`, `.cursor/`, etc. directories must reside for IDE recognition). The `--repo` parameter specifies the repository root path. For multi-project codebases, run `specfact init` from the repository root to ensure IDE integration works correctly. +1. Initializes/updates user-level registry state under `~/.specfact/registry/`. +2. Discovers installed modules and applies enable/disable operations. +3. Enforces module dependency and compatibility constraints. +4. Reports IDE prompt status and points to `specfact init ide` for prompt/template setup. + +### `init ide` - IDE Prompt/Template Setup + +Install and update prompt templates and IDE settings. + +```bash +specfact init ide [OPTIONS] +``` + +**Options:** + +- `--repo PATH` - Repository path (default: current directory) +- `--ide TEXT` - IDE type (cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto) +- `--force` - Overwrite existing files +- `--install-deps` - Install contract-enhancement dependencies (`beartype`, `icontract`, `crosshair-tool`, `pytest`) + +**Behavior:** + +- In interactive terminals, `specfact init ide` without `--ide` opens an arrow-key IDE selector. +- In non-interactive mode, IDE auto-detection is used unless `--ide` is explicitly provided. +- Prompt templates are copied to IDE-specific root-level locations (`.github/prompts`, `.cursor/commands`, etc.). + +**Examples:** + +```bash +# Interactive IDE selection +specfact init ide + +# Explicit IDE +specfact init ide --ide cursor +specfact init ide --ide vscode --force + +# Optional dependency installation +specfact init ide --install-deps +``` **IDE-Specific Locations:** @@ -4868,16 +4903,16 @@ Slash commands provide an intuitive interface for IDE integration (VS Code, Curs ```bash # Initialize IDE integration (one-time setup) -specfact init --ide cursor +specfact init ide --ide cursor # Or auto-detect IDE -specfact init +specfact init ide # Initialize and install required packages for contract enhancement -specfact init --install-deps +specfact init ide --install-deps # Initialize for specific IDE and install dependencies -specfact init --ide cursor --install-deps +specfact init ide --ide cursor --install-deps ``` ### Usage @@ -4903,7 +4938,7 @@ After initialization, use slash commands directly in your IDE's AI chat: **How it works:** -Slash commands are **prompt templates** (markdown files) that are copied to IDE-specific locations by `specfact init`. The IDE automatically discovers and registers them as slash commands. +Slash commands are **prompt templates** (markdown files) that are copied to IDE-specific locations by `specfact init ide`. The IDE automatically discovers and registers them as slash commands. **See [IDE Integration Guide](../guides/ide-integration.md)** for detailed setup instructions and supported IDEs. diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index 3c66726b..d175edce 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -27,7 +27,11 @@ All SpecFact artifacts are stored under `.specfact/` in the repository root. Thi **User-level registry** (v0.27+): After you run `specfact init`, the CLI creates `~/.specfact/registry/` with: - `commands.json` – Command names and help text used for fast root `specfact --help` without loading every command module. -- `modules.json` – Per-module state (id, version, enabled) for optional module packages; `specfact init --enable-module <id>` / `--disable-module <id>` persist here. +- `modules.json` – Per-module state (id, version, enabled) for optional module packages. + - Managed by `specfact init --list-modules`, `specfact init --enable-module ...`, `specfact init --disable-module ...` + - Supports dependency-safe lifecycle operations with optional `--force` cascading behavior + +`specfact init` is bootstrap/module-lifecycle focused. IDE prompt/template setup is handled by `specfact init ide`. For how the CLI discovers and loads commands from module packages (registry, module-package.yaml, lazy loading), see [Architecture – Modules design](architecture.md#modules-design). @@ -442,19 +446,35 @@ specfact sync repository --repo . --watch --interval 5 ### `specfact init` +Bootstraps local module lifecycle state (without IDE template copy side effects): + +```bash +# Bootstrap and discover modules +specfact init + +# List enabled/disabled module state +specfact init --list-modules + +# Manage modules (interactive selector in interactive terminals) +specfact init --enable-module +specfact init --disable-module +``` + +### `specfact init ide` + Initializes IDE integration by copying prompt templates to IDE-specific locations: ```bash # Auto-detect IDE -specfact init +specfact init ide # Specify IDE explicitly -specfact init --ide cursor -specfact init --ide vscode -specfact init --ide copilot +specfact init ide --ide cursor +specfact init ide --ide vscode +specfact init ide --ide copilot ``` -**Creates IDE-specific directories:** +**Creates/updates IDE-specific directories:** - **Cursor**: `.cursor/commands/` (markdown files) - **VS Code / Copilot**: `.github/prompts/` (`.prompt.md` files) + `.vscode/settings.json` diff --git a/openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml b/openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml new file mode 100644 index 00000000..41094ca0 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-06 diff --git a/openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md b/openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md new file mode 100644 index 00000000..73fa07b4 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md @@ -0,0 +1,120 @@ +# Change Validation Report: arch-03-module-lifecycle-management + +**Validation Date**: 2026-02-06 18:35:22Z +**Change Proposal**: [proposal.md](./proposal.md) +**Validation Method**: Dry-run interface/dependency simulation in temporary workspace + +## Executive Summary + +- Breaking Changes: 1 potential behavior break detected, 0 unmitigated +- Dependent Files: 27 identified in impacted surfaces +- Impact Level: Medium +- Validation Result: Pass (safe to implement) +- User Decision: Proceed without scope extension; keep compatibility safeguards in implementation + +## Breaking Changes Detected + +### Interface/Behavior: `specfact init --disable-module <id>` + +- **Type**: Behavioral contract tightening +- **Old Behavior**: Disable operation persisted directly +- **New Behavior**: Disable blocked if enabled dependents require the module (unless `--force`) +- **Breaking**: Potentially yes for automation/scripts that currently rely on unconditional disable +- **Mitigation in scope**: + - Explicit `--force` override + - Clear error message listing dependent modules + - Documentation updates for new disable semantics + +### Interface/Behavior: Cross-module helper imports + +- **Type**: Internal import path migration (module code) +- **Old Behavior**: module-to-module imports from `specfact_cli.modules.<other>.src.commands` +- **New Behavior**: shared helpers in `specfact_cli.utils.bundle_converters` +- **Breaking**: Not if wrappers are retained in `plan/src/commands.py` and `sdd/src/commands.py` +- **Mitigation in scope**: + - Keep compatibility wrappers for `_convert_*` and `is_constitution_minimal` + - Update module imports in sync/generate/enforce + +## Dependencies Affected + +### Critical Updates Required + +- `src/specfact_cli/modules/init/src/commands.py`: add safe-disable gate and preserve clear CLI UX. +- `src/specfact_cli/registry/module_packages.py`: add compatibility/dependency checks without startup hard-fail. +- `src/specfact_cli/registry/module_state.py`: add reverse dependency helper used by safe-disable logic. +- `src/specfact_cli/modules/sync/src/commands.py`: replace cross-module helper imports with core utility imports. +- `src/specfact_cli/modules/generate/src/commands.py`: replace cross-module helper imports with core utility imports. +- `src/specfact_cli/modules/enforce/src/commands.py`: replace cross-module helper imports with core utility imports. +- `tests/unit/specfact_cli/test_module_boundary_imports.py`: extend guard for cross-module non-`app` imports. + +### Recommended Updates + +- `tests/unit/specfact_cli/registry/test_module_packages.py`: add assertions for `core_compatibility` parsing/validation behavior. +- `tests/unit/specfact_cli/registry/test_init_module_state.py`: add safe-disable behavior coverage. +- New tests planned in tasks (`test_module_dependencies.py`, `test_version_constraints.py`, `test_bundle_converters.py`). + +### Optional / No Immediate Action + +- Existing tests importing `_convert_*` from plan commands (18 files) are compatible if wrapper strategy is kept. + +## Impact Assessment + +- **Code Impact**: Registry lifecycle logic, init disable flow, cross-module helper import paths, new shared utility module. +- **Test Impact**: New contract-first tests required plus boundary guard extension; existing helper-import tests remain stable with wrappers. +- **Documentation Impact**: Update CLI/module lifecycle docs for `core_compatibility`, dependency enforcement, and `--force` behavior. +- **Release Impact**: Minor (`0.29.0`) remains appropriate; behavior change is intentional and mitigated. + +## User Decision + +**Decision**: Proceed + +**Rationale**: Detected breaking risk is bounded and explicitly mitigated (`--force` + compatibility wrappers). No additional proposal scope expansion is required at validation stage. + +**Next Steps**: + +1. Keep wrapper compatibility explicitly during implementation. +2. Add tests before code per tasks section 4. +3. Validate strict gates after implementation tasks. + +## Format Validation + +- **proposal.md Format**: Pass + - Title format: Correct (`# Change: ...`) + - Required sections: Present (`Why`, `What Changes`, `Capabilities`, `Impact`) + - `What Changes` format: Correct bullet list with NEW/EXTEND markers + - `Capabilities` section: Present + - `Impact` section: Present + - Source Tracking: Present and populated (issue #203) +- **tasks.md Format**: Pass + - Section headers: Hierarchical numbered groups present + - Task format: Checkbox format present + - Config compliance: + - TDD/SDD ordering block: Present + - Test tasks before implementation tasks: Present + - Quality gate tasks: Present + - Git workflow tasks: Present (branch first, PR last) + - GitHub issue task: Present + - **Note**: Placeholder `<issue-number>` remains in branch/PR task text; acceptable for proposal stage but should be resolved before apply. +- **specs Format**: Pass + - Given/When/Then scenarios present + - Requirement statements are clear and test-mappable +- **design.md Format**: Pass + - Architectural decisions, risks, and mitigations documented + - Sequence diagram not required for this non-multi-repo control-plane change +- **Format Issues Found**: 0 blocking, 1 advisory placeholder note +- **Config.yaml Compliance**: Pass + +## OpenSpec Validation + +- **Status**: Pass +- **Validation Command**: `openspec validate arch-03-module-lifecycle-management --strict` +- **Issues Found**: 0 +- **Issues Fixed**: 0 +- **Re-validated**: Yes + +## Validation Artifacts + +- Temporary workspace: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822` +- Interface scaffolds: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822/interface_scaffolds.md` +- Dependency graph: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822/dependency_graph.md` +- Dependency file list: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822/dependency_files.txt` diff --git a/openspec/changes/arch-03-module-lifecycle-management/design.md b/openspec/changes/arch-03-module-lifecycle-management/design.md new file mode 100644 index 00000000..667d8788 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/design.md @@ -0,0 +1,75 @@ +# Design: Module Lifecycle Management + +## Context + +`arch-02` moved commands into module packages and introduced manifest metadata, but the runtime registry still treats dependency metadata as advisory. This change adds enforcement for dependency and compatibility constraints without introducing startup fragility. + +## Goals + +- Enforce module dependency integrity during command registration. +- Enforce module-to-core compatibility through PEP 440 specifier evaluation. +- Prevent unsafe module disabling unless operator explicitly overrides. +- Remove cross-module private helper imports by moving shared utility logic to core `utils`. +- Preserve command startup resilience by skipping invalid modules with debug logging instead of hard-failing startup. + +## Non-Goals + +- Versioned inter-module dependency constraints (for example `sync>=0.29.0`). +- Runtime hot-reload of module enable/disable state. +- Plugin marketplace or remote module resolution. + +## Architecture + +### 1. Shared Helper Extraction + +Move reusable conversion and constitution helper functions into `src/specfact_cli/utils/bundle_converters.py` and redirect cross-module imports to this core utility. Keep compatibility wrappers in original module command files where needed. + +### 2. Manifest Schema Extension + +Add optional `core_compatibility` to `ModulePackageMetadata` and to all `module-package.yaml` manifests. Parsing remains tolerant: absent field means compatible with all core versions. + +### 3. Registration-Time Lifecycle Validation + +During `register_module_package_commands()`: + +1. Evaluate module enablement state. +2. Validate `core_compatibility` against CLI version. +3. Validate declared `module_dependencies` are discovered and enabled. +4. Register only valid modules; skip invalid modules with debug-level reason. + +This preserves startup continuity while enforcing lifecycle guarantees. + +### 4. Safe Disable Enforcement + +Before persisting disable operations in `init`: + +1. Compute effective enabled state. +2. Compute reverse dependencies for requested disables. +3. Block operation when enabled dependents exist. +4. Allow override with `--force` and explicit warning behavior. + +## Contracts and Testing Strategy + +- Add `@beartype` and `@icontract` usage for newly introduced public helper/validation APIs. +- Add focused tests for: + - dependency validation outcomes, + - compatibility specifier outcomes, + - safe-disable reverse dependency detection, + - extracted bundle conversion helpers, + - boundary guard against cross-module `src.commands` imports. +- Maintain contract-first validation gates and spec-to-test traceability. + +## Rollout + +- Implement in phases from helper extraction to registry checks to safe-disable and boundary tests. +- Verify with format, type-check, contract-test, and scenario-relevant test runs. +- Include documentation/version/changelog updates before PR. + +## Risks and Mitigations + +- **Risk**: Invalid compatibility specifier strings could block modules unexpectedly. + - **Mitigation**: Treat parse failures as non-blocking compatibility and log debug diagnostics. +- **Risk**: Users may be surprised when disabling modules is blocked. + - **Mitigation**: Provide explicit error with dependent module list and `--force` hint. +- **Risk**: Boundary guard may flag legitimate imports. + - **Mitigation**: Scope rule to non-`app` imports from other modules' `src.commands` only. diff --git a/openspec/changes/arch-03-module-lifecycle-management/proposal.md b/openspec/changes/arch-03-module-lifecycle-management/proposal.md new file mode 100644 index 00000000..5cfd360f --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/proposal.md @@ -0,0 +1,39 @@ +# Change: Module Lifecycle Management for Dependencies, Compatibility, and Safe Disable + +## Why + +`arch-02` completed module package separation, but module lifecycle constraints are still unenforced at runtime. `module_dependencies` is currently declarative-only, module manifests do not constrain CLI core compatibility, and `init --disable-module` can disable required modules without preflight protection. + +This creates avoidable runtime breakage and weakens contract-first guarantees for modular command loading. We need registry-time lifecycle validation and safe-disable protection while preserving backward compatibility. + +## What Changes + +- **NEW**: Add module lifecycle validation at registration time for dependency existence/enabled state and `core_compatibility` version constraints. +- **NEW**: Extend module manifests with `core_compatibility` (PEP 440 specifier string) across all module packages. +- **NEW**: Add safe-disable checks in `specfact init` so disabling a module fails when enabled dependents require it, with explicit `--force` override. +- **NEW**: Add `specfact init --list-modules` to show discovered installed modules with effective enabled/disabled status. +- **NEW**: Add interactive up/down module selection for enable/disable flows, with explicit-id enforcement in non-interactive mode. +- **NEW**: Split initialization behavior into bootstrap-first `specfact init` (no IDE template side effects) and `specfact init ide` for prompt/template setup. +- **NEW**: Extract shared bundle conversion and constitution helper utilities from cross-module command imports into `specfact_cli.utils.bundle_converters`. +- **NEW**: Add boundary guard coverage that blocks cross-module imports from `specfact_cli.modules.<other>.src.commands` for non-`app` symbols. +- **EXTEND**: Add contract-first tests for dependency validation, version compatibility behavior, extracted utility helpers, and safe-disable logic. +- **EXTEND**: Update user-facing documentation and changelog/version synchronization for lifecycle management behavior and module manifest schema. + +## Capabilities + +- **module-lifecycle-management**: Enforce module dependency integrity, version compatibility, safe-disable semantics, and module boundary hygiene. + +## Impact + +- **Affected specs**: New `openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md`. +- **Affected code**: `src/specfact_cli/registry/`, `src/specfact_cli/modules/*/module-package.yaml`, `src/specfact_cli/modules/init/src/commands.py`, and shared utility extraction under `src/specfact_cli/utils/`. +- **Affected tests**: Registry/unit test coverage, module boundary guard tests, and utility conversion tests. +- **Affected documentation** (<https://docs.specfact.io>): CLI/module docs and contributor guidance that describe module metadata, lifecycle constraints, and disable behavior. +- **Backward compatibility**: Preserved for command-level behavior; lifecycle checks prevent invalid module states earlier with actionable errors. + +## Source Tracking + +- **GitHub Issue**: #203 +- **Issue URL**: <https://github.com/nold-ai/specfact-cli/issues/203> +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: proposed diff --git a/openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md b/openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md new file mode 100644 index 00000000..37fa7326 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md @@ -0,0 +1,183 @@ +# Module Lifecycle Management + +## ADDED Requirements + +### Requirement: Shared helper extraction from cross-module command imports + +The system SHALL provide shared bundle conversion and constitution helper utilities under core `specfact_cli.utils` so modules do not import private non-`app` symbols from other modules' `src.commands`. + +#### Scenario: Cross-module helper imports use core utility module + +**Given** module command implementations that require shared conversion or constitution helper behavior + +**When** imports are updated for lifecycle management + +**Then** those modules import helpers from `specfact_cli.utils.bundle_converters` + +**And** cross-module imports from `specfact_cli.modules.<other>.src.commands` for non-`app` symbols are eliminated + +### Requirement: Module manifest core compatibility constraints + +The system SHALL support optional `core_compatibility` in each module package manifest using PEP 440 specifier syntax. + +#### Scenario: Compatibility field is parsed from module manifest + +**Given** a module `module-package.yaml` includes `core_compatibility: ">=0.28.0,<1.0.0"` + +**When** package metadata is discovered + +**Then** metadata includes the parsed `core_compatibility` string for compatibility evaluation + +**And** modules without the field remain valid and are treated as unconstrained + +### Requirement: Dependency and compatibility validation during registration + +The system SHALL validate dependency availability/enabled state and core compatibility before registering module commands. + +#### Scenario: Module with unmet dependency is skipped + +**Given** an enabled module declares `module_dependencies` containing a missing or disabled module + +**When** command registration runs + +**Then** the module is skipped from registration + +**And** the reason is emitted to debug logs + +#### Scenario: Module with incompatible core constraint is skipped + +**Given** an enabled module declares a `core_compatibility` range that does not include the current CLI version + +**When** command registration runs + +**Then** the module is skipped from registration + +**And** debug logs include the module id, required range, and current version + +### Requirement: Safe-disable enforcement in init workflow + +The system SHALL prevent disabling modules that are required by enabled dependent modules unless the user explicitly forces the action. + +#### Scenario: Unsafe disable is blocked without force + +**Given** module `A` is enabled and depends on module `B` + +**When** the user runs `specfact init --disable-module B` + +**Then** the command exits with an error + +**And** the error lists enabled dependents that require `B` + +**And** the output includes a hint to disable dependents first or use `--force` + +#### Scenario: Unsafe disable can be overridden with force + +**Given** module `A` is enabled and depends on module `B` + +**When** the user runs `specfact init --disable-module B --force` + +**Then** module `B` is disabled + +**And** enabled dependents of `B` are also disabled transitively + +**And** the command proceeds with force-override semantics + +#### Scenario: Force enable auto-enables upstream dependencies + +**Given** module `A` depends on module `B` + +**And** module `B` is currently disabled + +**When** the user runs `specfact init --enable-module A --force` + +**Then** module `A` is enabled + +**And** required upstream dependencies (including `B`) are enabled transitively + +### Requirement: Module state visibility and selection UX in init workflow + +The system SHALL provide module status visibility and interactive selection ergonomics for enable/disable operations, while preserving explicit module-id requirements in non-interactive mode. + +#### Scenario: Installed modules can be listed with enabled/disabled state + +**Given** module metadata is discoverable and module state may contain prior enable/disable overrides + +**When** the user runs `specfact init --list-modules` + +**Then** the command outputs each discovered module with its enabled or disabled status + +**And** output reflects the effective merged state from discovered manifests and persisted registry state + +#### Scenario: Interactive enable selection uses arrow-key menu + +**Given** the terminal is interactive + +**And** the user requests module enablement without explicit module ids + +**When** the command runs interactive module selection + +**Then** the user can pick a module using an up/down selection menu + +**And** the selected module is added to the enable list before state persistence + +#### Scenario: Interactive disable selection uses arrow-key menu + +**Given** the terminal is interactive + +**And** the user requests module disablement without explicit module ids + +**When** the command runs interactive module selection + +**Then** the user can pick a module using an up/down selection menu + +**And** the selected module is added to the disable list before safe-disable validation and state persistence + +#### Scenario: Non-interactive mode requires explicit module ids + +**Given** the command is running in non-interactive mode + +**When** the user requests module enablement or disablement without explicit module ids + +**Then** the command exits with an error + +**And** the error instructs the user to provide `--enable-module <id>` or `--disable-module <id>` + +### Requirement: Split bootstrap init from IDE template initialization + +The system SHALL separate bootstrap/module lifecycle initialization from IDE prompt/template side effects. + +#### Scenario: Top-level init is bootstrap-only + +**Given** the user runs `specfact init` + +**When** bootstrap and module-state checks complete + +**Then** the command does not copy or mutate IDE prompt/template files + +**And** it reports prompt installation health with guidance to run `specfact init ide` + +#### Scenario: IDE setup is handled by init ide + +**Given** the user runs `specfact init ide` + +**When** IDE prompt setup executes + +**Then** prompt/template files and IDE settings are created or updated for the selected IDE + +**And** in interactive mode without `--ide`, IDE selection is provided via up/down selection UI + +**And** in non-interactive mode, setup runs directly using explicit `--ide` or auto-detected IDE + +### Requirement: Boundary guard for cross-module command imports + +The test suite SHALL fail when any module imports non-`app` symbols from another module's `src.commands` package. + +#### Scenario: Boundary guard detects cross-module non-app command imports + +**Given** a module source file imports a non-`app` symbol from `specfact_cli.modules.<other>.src.commands` + +**When** boundary guard tests execute + +**Then** tests fail with a clear violation list + +**And** guidance points developers to shared utility modules for reusable helpers diff --git a/openspec/changes/arch-03-module-lifecycle-management/tasks.md b/openspec/changes/arch-03-module-lifecycle-management/tasks.md new file mode 100644 index 00000000..18035fbb --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/tasks.md @@ -0,0 +1,108 @@ +# Tasks: Module Lifecycle Management for Dependencies, Compatibility, and Safe Disable + +## TDD / SDD order (enforced) + +Per `openspec/config.yaml`, **tests before code** apply to any task that adds or changes behavior. + +1. **Spec deltas** define behavior (Given/When/Then) in `openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md`. +2. **Tests second**: Write unit/integration tests from those scenarios; run tests and **expect failure** (no implementation yet). +3. **Code last**: Implement until tests pass and behavior satisfies the spec. + +Do not implement production code for new behavior until the corresponding tests exist and have been run (expecting failure). + +--- + +## 1. Create git branch from dev + +- [x] 1.1 Ensure `dev` is checked out and up to date: `git checkout dev && git pull origin dev` +- [x] 1.2 Create branch linked to issue when available: `gh issue develop 203 --repo nold-ai/specfact-cli --name feature/arch-03-module-lifecycle-management --checkout` +- [x] 1.3 Fallback branch creation when no issue exists: `git checkout -b feature/arch-03-module-lifecycle-management` (not needed; issue-linked branch created) +- [x] 1.4 Verify current branch: `git branch --show-current` + +## 2. Create GitHub issue in target repository + +- [x] 2.1 Create sanitized issue in `nold-ai/specfact-cli` with labels `enhancement` and `change-proposal` +- [x] 2.2 Use title format `[Change] Module lifecycle management for dependency validation and safe disable` +- [x] 2.3 Update `proposal.md` Source Tracking with issue number, URL, repository, and status `proposed` + +## 3. Finalize spec delta (SDD) + +- [x] 3.1 Confirm `specs/module-lifecycle-management/spec.md` covers dependency validation, compatibility checks, safe-disable behavior, and boundary guard expectations +- [x] 3.2 Map each scenario to test tasks before implementation tasks + +## 4. Tests first for lifecycle behavior (TDD) + +- [x] 4.1 Add/update registry tests for dependency validation and safe-disable reverse dependency logic (expect failure before implementation) +- [x] 4.2 Add/update version compatibility tests for `core_compatibility` parsing and boundary ranges (expect failure before implementation) +- [x] 4.3 Add/update tests for extracted bundle converter helpers and constitution minimality utility (expect failure before implementation) +- [x] 4.4 Add/update boundary guard tests preventing cross-module non-`app` imports from `src.commands` (expect failure before implementation) + +## 5. Implement shared helper extraction + +- [x] 5.1 Create `src/specfact_cli/utils/bundle_converters.py` with conversion and constitution helpers plus contracts +- [x] 5.2 Redirect imports in sync/generate/enforce to core utility helpers +- [x] 5.3 Replace plan/sdd helper implementations with compatibility delegates where needed +- [x] 5.4 Re-run targeted tests for helper extraction changes + +## 6. Implement manifest and registry lifecycle validation + +- [x] 6.1 Extend module manifest model and parsing with optional `core_compatibility` +- [x] 6.2 Add `core_compatibility` to all module manifests and reconcile `module_dependencies` updates +- [x] 6.3 Add registry checks for compatibility and dependency validation before command registration +- [x] 6.4 Ensure skipped modules are debug-logged with clear reasons + +## 7. Implement safe-disable behavior in init + +- [x] 7.1 Add reverse dependency utility and disable validation helper +- [x] 7.2 Add init command guard that blocks unsafe disable with actionable message +- [x] 7.3 Add `--force` override support and verify behavior parity + +## 8. Quality gates + +- [x] 8.1 Run formatting and linting checks: `hatch run format` and `hatch run lint` +- [x] 8.2 Run strict type checks: `hatch run type-check` +- [x] 8.3 Run contract-first validation: `hatch run contract-test` +- [x] 8.4 Run scenario-relevant tests and full suite as required: `hatch run smart-test` and/or `hatch test --cover -v` +- [x] 8.5 Verify all module command helps resolve after changes + +## 9. Documentation research and review + +- [x] 9.1 Identify affected docs in `docs/`, `docs/index.md`, and `README.md` for module lifecycle behavior +- [x] 9.2 Update/add user-facing docs for manifest `core_compatibility`, dependency enforcement, and safe-disable semantics +- [x] 9.3 If pages are added/moved, update front-matter and `docs/_layouts/default.html` sidebar links (N/A: no pages added or moved) + +## 10. Version and changelog + +- [x] 10.1 Bump version according to semver impact and sync in `pyproject.toml`, `setup.py`, `src/__init__.py`, and `src/specfact_cli/__init__.py` +- [x] 10.2 Add `CHANGELOG.md` entry for lifecycle validation and safe-disable behavior + +## 11. Create Pull Request to dev (last) + +- [ ] 11.1 Commit with conventional commit message +- [ ] 11.2 Push branch: `git push origin feature/arch-03-module-lifecycle-management` +- [ ] 11.3 Create PR to `dev` using repository template and include `Fixes nold-ai/specfact-cli#<issue-number>` +- [ ] 11.4 Verify issue Development links include branch and PR + +## 12. Extend module lifecycle UX for listing and interactive selection + +- [x] 12.1 Add tests first for `specfact init --list-modules` effective enabled/disabled output +- [x] 12.2 Add tests first for interactive up/down selection flow for enable/disable when ids are not passed +- [x] 12.3 Add tests first for non-interactive validation requiring explicit module ids +- [x] 12.4 Implement `--list-modules` in init command with effective merged state output +- [x] 12.5 Implement interactive module selection using questionary for enable/disable requests without explicit ids +- [x] 12.6 Implement non-interactive guardrail error messaging for id-less enable/disable requests +- [x] 12.7 Run focused tests for new module UX behavior + +## 13. Split init bootstrap from IDE setup + +- [x] 13.1 Add `init ide` command for prompt/template copy and IDE settings update behavior +- [x] 13.2 Keep top-level `init` bootstrap/module-management only (no template copy side effects) +- [x] 13.3 Add prompt status audit messaging in bootstrap init with guidance to run `specfact init ide` +- [x] 13.4 Add interactive IDE selection (questionary up/down) when `init ide` runs without `--ide` +- [x] 13.5 Update tests to target `init ide` for template copy behavior and keep bootstrap-init regression coverage + +## 14. Force-mode dependency cascade + +- [x] 14.1 In `--force` disable flows, cascade-disable enabled dependents transitively +- [x] 14.2 In `--force` enable flows, cascade-enable required dependencies transitively +- [x] 14.3 Add regression tests for both force-mode cascades in registry/init lifecycle tests diff --git a/pyproject.toml b/pyproject.toml index 995ccdbb..419251e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.28.0" +version = "0.29.0" 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" diff --git a/setup.py b/setup.py index 6a7f79dd..a2e2187b 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.28.0", + version="0.29.0", 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 8a9a0e05..0b8990ac 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.28.0" +__version__ = "0.29.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index fb8f6639..d0f19675 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.28.0" +__version__ = "0.29.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 864090dc..4e1e9860 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -257,7 +257,7 @@ def main( bool | None, typer.Option( "--interactive/--no-interactive", - help="Force interaction mode (default auto based on CI/CD detection)", + help="Force interaction mode (default auto based on terminal/CI detection)", ), ] = None, ) -> None: @@ -274,6 +274,10 @@ def main( - Explicit --mode flag (highest priority) - Auto-detect from environment (CoPilot API, IDE integration) - Default to CI/CD mode + + Interaction Detection: + - Explicit --interactive/--no-interactive (highest priority) + - Auto-detect from terminal and CI environment """ global _show_banner # Set banner flag based on --banner option @@ -332,6 +336,33 @@ def __init__(self, cmd_name: str, help_str: str, name: str | None = None, help: def _make_delegate_command(self) -> click.Command: cmd_name = self._lazy_cmd_name + def _normalize_init_optional_module_flags(argv: list[str]) -> list[str]: + """ + Normalize bare init module flags to sentinel values. + + Typer/Click options declared with value types require an argument. To support + UX like `specfact init --enable-module` in interactive mode, rewrite bare flags + to include a sentinel token consumed by init command logic. + """ + if cmd_name != "init": + return argv + out: list[str] = [] + i = 0 + while i < len(argv): + token = argv[i] + if token in ("--enable-module", "--disable-module"): + out.append(token) + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + out.append(argv[i + 1]) + i += 2 + continue + out.append("__interactive_select__") + i += 1 + continue + out.append(token) + i += 1 + return out + def _invoke(args: tuple[str, ...]) -> None: from typer.main import get_command @@ -348,6 +379,7 @@ def _invoke(args: tuple[str, ...]) -> None: p = getattr(p, "parent", None) prog_name = " ".join(reversed(parts)) if parts else cmd_name args_list = list(args) + args_list = _normalize_init_optional_module_flags(args_list) # When the real app is a single command (e.g. drift has only "detect"), Typer # builds a TyperCommand, not a Group. Then args are ["detect", "bundle", "--repo", ...] # and the command expects ["bundle", "--repo", ...] (no leading "detect"). diff --git a/src/specfact_cli/modules/analyze/module-package.yaml b/src/specfact_cli/modules/analyze/module-package.yaml index 98d4d060..83377c55 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index f650a292..129e04c0 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index 7f1816aa..20eb700a 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index ed4ef14f..4b6b3767 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index c1e7aa08..aa069e32 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index 4e1dbe70..e2080ec1 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -6,6 +6,6 @@ commands: command_help: enforce: "Configure quality gates" pip_dependencies: [] -module_dependencies: - - plan +module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/enforce/src/commands.py b/src/specfact_cli/modules/enforce/src/commands.py index 00323f82..2e41c06b 100644 --- a/src/specfact_cli/modules/enforce/src/commands.py +++ b/src/specfact_cli/modules/enforce/src/commands.py @@ -296,9 +296,9 @@ def enforce_sdd( raise typer.Exit(1) # Convert to PlanBundle for compatibility with validation functions - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) # Create validation report report = ValidationReport() diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index 743c6d8d..c9bb44fe 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -6,6 +6,6 @@ commands: command_help: generate: "Generate artifacts from SDD and plans" pip_dependencies: [] -module_dependencies: - - plan +module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/generate/src/commands.py b/src/specfact_cli/modules/generate/src/commands.py index f2337d11..176e9c62 100644 --- a/src/specfact_cli/modules/generate/src/commands.py +++ b/src/specfact_cli/modules/generate/src/commands.py @@ -241,7 +241,7 @@ def generate_contracts( plan_hash = None if format_type == BundleFormat.MODULAR or bundle: # Load modular ProjectBundle and convert to PlanBundle for compatibility - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle project_bundle = load_bundle_with_progress(plan_path, validate_hashes=False, console_instance=console) @@ -250,7 +250,7 @@ def generate_contracts( plan_hash = summary.content_hash # Convert to PlanBundle for ContractGenerator compatibility - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) else: # Load monolithic PlanBundle plan_bundle = load_plan_bundle(plan_path) diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index 04a5a43e..c2090ffb 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 78d5636d..8aa99bac 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index d6cf9fd1..16a1fddb 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -10,22 +10,36 @@ import subprocess import sys from pathlib import Path +from typing import Any +import click import typer from beartype import beartype from icontract import ensure, require from rich.console import Console from rich.panel import Panel +from rich.rule import Rule +from rich.table import Table from specfact_cli import __version__ from specfact_cli.registry.help_cache import run_discovery_and_write_cache -from specfact_cli.registry.module_packages import get_discovered_modules_for_state -from specfact_cli.registry.module_state import write_modules_state -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.registry.module_packages import ( + discover_package_metadata, + expand_disable_with_dependents, + expand_enable_with_dependencies, + get_discovered_modules_for_state, + get_modules_root, + merge_module_state, + validate_disable_safe, + validate_enable_safe, +) +from specfact_cli.registry.module_state import read_modules_state, write_modules_state +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive from specfact_cli.telemetry import telemetry from specfact_cli.utils.env_manager import EnvManager, build_tool_command, detect_env_manager from specfact_cli.utils.ide_setup import ( IDE_CONFIG, + SPECFACT_COMMANDS, copy_templates_to_ide, detect_ide, find_package_resources_path, @@ -113,6 +127,200 @@ def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: app = typer.Typer(help="Initialize SpecFact for IDE integration") console = Console() +MODULE_SELECT_SENTINEL = "__interactive_select__" + + +def _questionary_style() -> Any: + """Return a shared questionary color theme for interactive selectors.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError: + return None + return questionary.Style( + [ + ("qmark", "fg:#00af87 bold"), + ("question", "bold"), + ("answer", "fg:#00af87 bold"), + ("pointer", "fg:#5f87ff bold"), + ("highlighted", "fg:#5f87ff bold"), + ("selected", "fg:#00af87 bold"), + ("instruction", "fg:#808080 italic"), + ("separator", "fg:#808080"), + ("text", ""), + ("disabled", "fg:#6c6c6c"), + ] + ) + + +def _render_modules_table(modules_list: list[dict[str, Any]]) -> None: + """Render discovered modules with effective enabled/disabled state.""" + table = Table(title="Installed Modules") + table.add_column("Module", style="cyan") + table.add_column("Version", style="magenta") + table.add_column("State", style="green") + for module in modules_list: + module_id = str(module.get("id", "")) + version = str(module.get("version", "")) + enabled = bool(module.get("enabled", True)) + state = "enabled" if enabled else "disabled" + table.add_row(module_id, version, state) + console.print(table) + + +def _select_module_ids_interactive(action: str, modules_list: list[dict[str, Any]]) -> list[str]: + """Select one or more module IDs interactively via arrow-key checkbox menu.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError as e: + console.print( + "[red]Interactive module selection requires 'questionary'. Install with: pip install questionary[/red]" + ) + raise typer.Exit(1) from e + + target_enabled = action == "disable" + candidates = [m for m in modules_list if bool(m.get("enabled", True)) is target_enabled] + if not candidates: + console.print(f"[yellow]No modules available to {action}.[/yellow]") + return [] + + action_title = "Enable" if action == "enable" else "Disable" + current_state = "disabled" if action == "enable" else "enabled" + console.print() + console.print( + Panel( + f"[bold cyan]{action_title} Modules[/bold cyan]\n" + f"Choose one or more currently [bold]{current_state}[/bold] modules.", + border_style="cyan", + ) + ) + console.print( + "[dim]Controls: ↑↓ navigate • Space toggle • Enter confirm • Type to search/filter • Ctrl+C cancel[/dim]" + ) + + action_title = "Enable" if action == "enable" else "Disable" + current_state = "disabled" if action == "enable" else "enabled" + display_to_id: dict[str, str] = {} + choices: list[str] = [] + for module in candidates: + module_id = str(module.get("id", "")) + version = str(module.get("version", "")) + state = "enabled" if bool(module.get("enabled", True)) else "disabled" + marker = "✗" if state == "disabled" else "✓" + display = f"{marker} {module_id:<14} [{state}] v{version}" + display_to_id[display] = module_id + choices.append(display) + + selected: list[str] | None = questionary.checkbox( + f"{action_title} module(s) from currently {current_state}:", + choices=choices, + instruction="(multi-select)", + style=_questionary_style(), + ).ask() + if not selected: + return [] + return [display_to_id[s] for s in selected if s in display_to_id] + + +def _resolve_templates_dir(repo_path: Path) -> Path | None: + """Resolve templates directory from repo checkout or installed package.""" + dev_templates_dir = (repo_path / "resources" / "prompts").resolve() + if dev_templates_dir.exists(): + return dev_templates_dir + try: + import importlib.resources + + resources_ref = importlib.resources.files("specfact_cli") + templates_ref = resources_ref / "resources" / "prompts" + package_templates_dir = Path(str(templates_ref)).resolve() + if package_templates_dir.exists(): + return package_templates_dir + except Exception: + pass + return find_package_resources_path("specfact_cli", "resources/prompts") + + +def _audit_prompt_installation(repo_path: Path) -> None: + """Report prompt installation health and next steps without mutating files.""" + detected_ide = detect_ide("auto") + config = IDE_CONFIG[detected_ide] + ide_dir = repo_path / str(config["folder"]) + format_type = str(config["format"]) + if format_type == "prompt.md": + expected_files = [f"{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] + elif format_type == "toml": + expected_files = [f"{cmd}.toml" for cmd in SPECFACT_COMMANDS] + else: + expected_files = [f"{cmd}.md" for cmd in SPECFACT_COMMANDS] + + if not ide_dir.exists(): + console.print( + f"[yellow]Prompt status:[/yellow] no prompts found for detected IDE ({detected_ide}). " + f"Run [bold]specfact init ide --ide {detected_ide}[/bold]." + ) + return + + missing = [name for name in expected_files if not (ide_dir / name).exists()] + templates_dir = _resolve_templates_dir(repo_path) + outdated = 0 + if templates_dir: + for cmd in SPECFACT_COMMANDS: + src = templates_dir / f"{cmd}.md" + if format_type == "prompt.md": + dest = ide_dir / f"{cmd}.prompt.md" + elif format_type == "toml": + dest = ide_dir / f"{cmd}.toml" + else: + dest = ide_dir / f"{cmd}.md" + if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime: + outdated += 1 + + if not missing and outdated == 0: + console.print(f"[green]Prompt status:[/green] {detected_ide} prompts are present and up to date.") + return + + console.print( + f"[yellow]Prompt status:[/yellow] missing={len(missing)}, outdated={outdated} for detected IDE ({detected_ide})." + ) + console.print(f"[dim]Run: specfact init ide --ide {detected_ide}{' --force' if outdated > 0 else ''}[/dim]") + + +def _select_ide_interactive(default_ide: str) -> str: + """Select IDE interactively with up/down controls.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError as e: + console.print( + "[red]Interactive IDE selection requires 'questionary'. Install with: pip install questionary[/red]" + ) + raise typer.Exit(1) from e + + choices: list[str] = [] + label_to_ide: dict[str, str] = {} + console.print() + console.print( + Panel( + "[bold cyan]IDE Prompt Setup[/bold cyan]\nSelect your editor/assistant integration target.", + border_style="cyan", + ) + ) + console.print("[dim]Controls: ↑↓ navigate • Enter select • Type to filter • Ctrl+C cancel[/dim]") + for ide_id, cfg in IDE_CONFIG.items(): + default_marker = "★" if ide_id == default_ide else " " + label = f"{default_marker} {cfg['name']:<24} ({ide_id})" + label_to_ide[label] = ide_id + choices.append(label) + + default_label = next((lbl for lbl, iid in label_to_ide.items() if iid == default_ide), choices[0]) + selected = questionary.select( + "Select IDE for prompt setup", + choices=choices, + default=default_label, + style=_questionary_style(), + ).ask() + if not selected: + raise typer.Exit(1) + console.print(Rule(style="dim")) + return label_to_ide[str(selected)] def _is_valid_repo_path(path: Path) -> bool: @@ -120,12 +328,105 @@ def _is_valid_repo_path(path: Path) -> bool: return path.exists() and path.is_dir() +@app.command("ide") +@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") +@ensure(lambda result: result is None, "Command should return None") +@beartype +def init_ide( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Repository path (default: current directory)", + exists=True, + file_okay=False, + dir_okay=True, + ), + force: bool = typer.Option(False, "--force", help="Overwrite existing files"), + install_deps: bool = typer.Option( + False, + "--install-deps", + help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest)", + ), + ide: str | None = typer.Option( + None, + "--ide", + help="IDE type (cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto)", + ), +) -> None: + """Initialize IDE prompt templates and settings.""" + repo_path = repo.resolve() + detected_default = detect_ide("auto") + if ide is not None: + selected_ide = detect_ide(ide) + elif is_non_interactive(): + selected_ide = detected_default + else: + selected_ide = _select_ide_interactive(detected_default) + + ide_config = IDE_CONFIG[selected_ide] + ide_name = str(ide_config["name"]) + + console.print() + console.print(Panel("[bold cyan]SpecFact IDE Setup[/bold cyan]", border_style="cyan")) + console.print(f"[cyan]Repository:[/cyan] {repo_path}") + console.print(f"[cyan]IDE:[/cyan] {ide_name} ({selected_ide})") + console.print() + + env_info = detect_env_manager(repo_path) + if env_info.manager == EnvManager.UNKNOWN: + console.print( + Panel( + "[bold yellow]⚠ No Compatible Environment Manager Detected[/bold yellow]", + border_style="yellow", + ) + ) + console.print("[dim]Supported tools: hatch, poetry, uv, pip[/dim]") + console.print() + + if install_deps: + required_packages = [ + "beartype>=0.22.4", + "icontract>=2.7.1", + "crosshair-tool>=0.0.97", + "pytest>=8.4.2", + ] + install_cmd = build_tool_command(env_info, ["pip", "install", "-U", *required_packages]) + result = subprocess.run( + install_cmd, + capture_output=True, + text=True, + check=False, + cwd=str(repo_path), + timeout=300, + ) + if result.returncode == 0: + console.print("[green]✓[/green] Dependencies installed") + else: + console.print("[yellow]⚠[/yellow] Dependency installation reported issues") + + templates_dir = _resolve_templates_dir(repo_path) + if not templates_dir or not templates_dir.exists(): + console.print("[red]Error:[/red] Templates directory not found.") + raise typer.Exit(1) + + console.print(f"[cyan]Templates:[/cyan] {templates_dir}") + copied_files, settings_path = copy_templates_to_ide(repo_path, selected_ide, templates_dir, force) + _copy_backlog_field_mapping_templates(repo_path, force, console) + + console.print() + console.print(Panel("[bold green]✓ IDE Initialization Complete[/bold green]", border_style="green")) + console.print(f"[green]Copied {len(copied_files)} template(s) to {ide_config['folder']}[/green]") + if settings_path: + console.print(f"[green]Updated VS Code settings:[/green] {settings_path}") + + @app.callback(invoke_without_command=True) @require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") @require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") @ensure(lambda result: result is None, "Command should return None") @beartype def init( + ctx: click.Context, # Target/Input repo: Path = typer.Option( Path("."), @@ -156,12 +457,23 @@ def init( enable_module: list[str] = typer.Option( [], "--enable-module", - help="Enable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", + help=( + "Enable module by id (repeatable); if provided without value in interactive mode, " + "opens selector. In non-interactive mode, a module id is required." + ), ), disable_module: list[str] = typer.Option( [], "--disable-module", - help="Disable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", + help=( + "Disable module by id (repeatable); if provided without value in interactive mode, " + "opens selector. In non-interactive mode, a module id is required." + ), + ), + list_modules: bool = typer.Option( + False, + "--list-modules", + help="List discovered installed modules with enabled/disabled status and exit.", ), ) -> None: """ @@ -185,13 +497,87 @@ def init( "ide": ide, "force": force, "install_deps": install_deps, + "list_modules": list_modules, } with telemetry.track_command("init", telemetry_metadata) as record: + if ctx.invoked_subcommand is not None: + return + module_management_requested = any( + [ + bool(enable_module), + bool(disable_module), + list_modules, + ] + ) + enable_ids = list(enable_module) + disable_ids = list(disable_module) + + requested_enable_interactive = MODULE_SELECT_SENTINEL in enable_ids + requested_disable_interactive = MODULE_SELECT_SENTINEL in disable_ids + enable_ids = [mid for mid in enable_ids if mid and mid != MODULE_SELECT_SENTINEL] + disable_ids = [mid for mid in disable_ids if mid and mid != MODULE_SELECT_SENTINEL] + + if is_non_interactive() and (requested_enable_interactive or requested_disable_interactive): + console.print( + "[red]Error:[/red] Non-interactive mode requires explicit module id values. " + "Use --enable-module <id> or --disable-module <id>." + ) + raise typer.Exit(1) + + if requested_enable_interactive: + discovered = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) + selected = _select_module_ids_interactive("enable", discovered) + enable_ids.extend(selected) + if selected: + module_management_requested = True + + if requested_disable_interactive: + discovered = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) + selected = _select_module_ids_interactive("disable", discovered) + disable_ids.extend(selected) + if selected: + module_management_requested = True + + packages = discover_package_metadata(get_modules_root()) + discovered_list = [(meta.name, meta.version) for _package_dir, meta in packages] + state = read_modules_state() + + if force and enable_ids: + enable_ids = expand_enable_with_dependencies(enable_ids, packages) + + enabled_map = merge_module_state(discovered_list, state, enable_ids, []) + + if enable_ids and not force: + blocked_enable = validate_enable_safe(enable_ids, packages, enabled_map) + if blocked_enable: + for module_id, missing in blocked_enable.items(): + console.print( + f"[red]Error:[/red] Cannot enable '{module_id}': missing required dependencies: " + f"{', '.join(missing)}" + ) + console.print( + "[dim]Hint: Enable dependencies first, or use --force to auto-enable required dependencies[/dim]" + ) + raise typer.Exit(1) + + if disable_ids: + if force: + disable_ids = expand_disable_with_dependents(disable_ids, packages, enabled_map) + blocked = validate_disable_safe(disable_ids, packages, enabled_map) + if blocked and not force: + for module_id, dependents in blocked.items(): + console.print( + f"[red]Error:[/red] Cannot disable '{module_id}': required by enabled modules: " + f"{', '.join(dependents)}" + ) + console.print("[dim]Hint: Disable dependent modules first, or use --force to override[/dim]") + raise typer.Exit(1) + # Update module state (enable/disable) and persist; then refresh help cache modules_list = get_discovered_modules_for_state( - enable_ids=enable_module, - disable_ids=disable_module, + enable_ids=enable_ids, + disable_ids=disable_ids, ) if modules_list: write_modules_state(modules_list) @@ -203,6 +589,23 @@ def init( "Re-enable with specfact init --enable-module <id>.[/dim]" ) run_discovery_and_write_cache(__version__) + if list_modules: + console.print() + _render_modules_table(modules_list) + return + if module_management_requested: + console.print("[green]✓[/green] Module state updated.") + return + repo_path = repo.resolve() + enabled_count = len([m for m in modules_list if bool(m.get("enabled", True))]) + disabled_count = len(modules_list) - enabled_count + console.print( + f"[green]✓[/green] Bootstrap complete. Modules discovered: {len(modules_list)} " + f"(enabled={enabled_count}, disabled={disabled_count})." + ) + _audit_prompt_installation(repo_path) + console.print("[dim]Use `specfact init ide` to install/update IDE prompts and settings.[/dim]") + return # Resolve repo path repo_path = repo.resolve() diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index 2317676e..7f0136be 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index e34eeec6..c82ddf48 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -9,3 +9,4 @@ pip_dependencies: [] module_dependencies: - sync tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/plan/src/commands.py b/src/specfact_cli/modules/plan/src/commands.py index d3777d96..de255d0c 100644 --- a/src/specfact_cli/modules/plan/src/commands.py +++ b/src/specfact_cli/modules/plan/src/commands.py @@ -4462,60 +4462,18 @@ def review( def _convert_project_bundle_to_plan_bundle(project_bundle: ProjectBundle) -> PlanBundle: - """ - Convert ProjectBundle to PlanBundle for compatibility with existing extraction functions. + """Convert ProjectBundle to PlanBundle via shared core helper.""" + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - Args: - project_bundle: ProjectBundle instance - - Returns: - PlanBundle instance - """ - return PlanBundle( - version="1.0", - idea=project_bundle.idea, - business=project_bundle.business, - product=project_bundle.product, - features=list(project_bundle.features.values()), - metadata=None, # ProjectBundle doesn't use Metadata, uses manifest instead - clarifications=project_bundle.clarifications, - ) + return convert_project_bundle_to_plan_bundle(project_bundle) @beartype def _convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: - """ - Convert PlanBundle to ProjectBundle (modular). - - Args: - plan_bundle: PlanBundle instance to convert - bundle_name: Project bundle name - - Returns: - ProjectBundle instance - """ - from specfact_cli.models.project import BundleManifest, BundleVersions + """Convert PlanBundle to ProjectBundle via shared core helper.""" + from specfact_cli.utils.bundle_converters import convert_plan_bundle_to_project_bundle - # Create manifest - manifest = BundleManifest( - versions=BundleVersions(schema="1.0", project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - - # Convert features list to dict - features_dict: dict[str, Feature] = {f.key: f for f in plan_bundle.features} - - # Create and return ProjectBundle - return ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - idea=plan_bundle.idea, - business=plan_bundle.business, - product=plan_bundle.product, - features=features_dict, - clarifications=plan_bundle.clarifications, - ) + return convert_plan_bundle_to_project_bundle(plan_bundle, bundle_name) def _find_bundle_dir(bundle: str | None) -> Path | None: diff --git a/src/specfact_cli/modules/project/module-package.yaml b/src/specfact_cli/modules/project/module-package.yaml index 89d30ed0..86f42288 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index 2e9cebc8..3094d0e6 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index 263284db..52c0f54c 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sdd/src/commands.py b/src/specfact_cli/modules/sdd/src/commands.py index 90d6b102..343e020f 100644 --- a/src/specfact_cli/modules/sdd/src/commands.py +++ b/src/specfact_cli/modules/sdd/src/commands.py @@ -402,30 +402,7 @@ def constitution_validate( def is_constitution_minimal(constitution_path: Path) -> bool: - """ - Check if constitution is minimal (essentially empty). - - Args: - constitution_path: Path to constitution file - - Returns: - True if constitution is minimal, False otherwise - """ - if not constitution_path.exists(): - return True + """Check constitution minimality via shared core helper.""" + from specfact_cli.utils.bundle_converters import is_constitution_minimal as _core_is_constitution_minimal - try: - content = constitution_path.read_text(encoding="utf-8").strip() - # Check if it's just a header or very minimal - if not content or content == "# Constitution" or len(content) < 100: - return True - - # Check if it has mostly placeholders - import re - - placeholder_pattern = r"\[[A-Z_0-9]+\]" - placeholders = re.findall(placeholder_pattern, content) - lines = [line.strip() for line in content.split("\n") if line.strip()] - return bool(lines and len(placeholders) > len(lines) * 0.5) - except Exception: - return True + return _core_is_constitution_minimal(constitution_path) diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index 382b69de..33e79331 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index 97a4f1a0..bd98ccd1 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -6,7 +6,6 @@ commands: command_help: sync: "Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.)" pip_dependencies: [] -module_dependencies: - - plan - - sdd +module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sync/src/commands.py b/src/specfact_cli/modules/sync/src/commands.py index 651984a2..24592520 100644 --- a/src/specfact_cli/modules/sync/src/commands.py +++ b/src/specfact_cli/modules/sync/src/commands.py @@ -228,7 +228,7 @@ def _perform_sync_operation( if adapter_type == AdapterType.SPECKIT: constitution_path = repo / ".specify" / "memory" / "constitution.md" if constitution_path.exists(): - from specfact_cli.modules.sdd.src.commands import is_constitution_minimal + from specfact_cli.utils.bundle_converters import is_constitution_minimal if is_constitution_minimal(constitution_path): # Auto-generate in test mode, prompt in interactive mode @@ -359,7 +359,7 @@ def _perform_sync_operation( progress.update(task, description="[cyan]Parsing plan bundle YAML...[/cyan]") # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( @@ -367,7 +367,7 @@ def _perform_sync_operation( validate_hashes=False, console_instance=progress.console if hasattr(progress, "console") else None, ) - loaded_plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + loaded_plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) is_valid = True else: # It's a file (legacy monolithic bundle) - validate directly @@ -443,7 +443,7 @@ def _perform_sync_operation( # Fallback: load plan bundle from bundle name or default plan_bundle_to_convert = None if bundle: - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) @@ -451,7 +451,7 @@ def _perform_sync_operation( project_bundle = load_bundle_with_progress( bundle_dir, validate_hashes=False, console_instance=console ) - plan_bundle_to_convert = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle_to_convert = convert_project_bundle_to_plan_bundle(project_bundle) else: # Use get_default_plan_path() to find the active plan (legacy compatibility) plan_path: Path | None = None @@ -461,7 +461,7 @@ def _perform_sync_operation( progress.update(task, description="[cyan]Loading plan bundle...[/cyan]") # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( @@ -469,7 +469,7 @@ def _perform_sync_operation( validate_hashes=False, console_instance=progress.console if hasattr(progress, "console") else None, ) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) is_valid = True else: # It's a file (legacy monolithic bundle) - validate directly @@ -730,7 +730,7 @@ def _sync_tool_to_specfact( # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): is_modular_bundle = True - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( @@ -738,7 +738,7 @@ def _sync_tool_to_specfact( validate_hashes=False, console_instance=progress.console if hasattr(progress, "console") else None, ) - bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + bundle = convert_project_bundle_to_plan_bundle(project_bundle) is_valid = True else: # It's a file (legacy monolithic bundle) - validate directly @@ -905,9 +905,9 @@ def _sync_tool_to_specfact( save_project_bundle(project_bundle, bundle_dir, atomic=True) # Convert ProjectBundle to PlanBundle for merging logic - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - converted_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + converted_bundle = convert_project_bundle_to_plan_bundle(project_bundle) # Merge with existing plan if it exists features_updated = 0 @@ -1670,9 +1670,9 @@ def sync_bridge( bundle_dir, validate_hashes=False, console_instance=console ) # Convert to PlanBundle for validation (legacy compatibility) - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) else: console.print(f"[yellow]⚠ Bundle '{bundle}' not found, skipping compliance check[/yellow]") plan_bundle = None @@ -1683,13 +1683,13 @@ def sync_bridge( if plan_path and plan_path.exists(): # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( plan_path, validate_hashes=False, console_instance=console ) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) else: # It's a file (legacy monolithic bundle) - validate directly validation_result = validate_plan_bundle(plan_path) @@ -1913,7 +1913,7 @@ def sync_callback(changes: list[FileChange]) -> None: if bundle: import asyncio - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress from specfact_cli.utils.structure import SpecFactStructure @@ -1921,7 +1921,7 @@ def sync_callback(changes: list[FileChange]) -> None: if bundle_dir.exists(): console.print("\n[cyan]🔍 Validating OpenAPI contracts before sync...[/cyan]") project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) from specfact_cli.integrations.specmatic import ( check_specmatic_available, diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index 9ff7c9b5..cdb19b66 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index 25a1a77e..b737bec9 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index 24bdba91..237c2b87 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -12,10 +12,14 @@ from typing import Any from beartype import beartype +from packaging.specifiers import SpecifierSet +from packaging.version import InvalidVersion, Version from pydantic import BaseModel, Field +from specfact_cli import __version__ as cli_version +from specfact_cli.common import get_bridge_logger from specfact_cli.registry.metadata import CommandMetadata -from specfact_cli.registry.module_state import read_modules_state +from specfact_cli.registry.module_state import find_dependents, read_modules_state from specfact_cli.registry.registry import CommandRegistry @@ -30,6 +34,10 @@ class ModulePackageMetadata(BaseModel): ) pip_dependencies: list[str] = Field(default_factory=list, description="Optional pip dependencies") module_dependencies: list[str] = Field(default_factory=list, description="Optional other package ids") + core_compatibility: str | None = Field( + default=None, + description="CLI core version compatibility (PEP 440 specifier, e.g. '>=0.28.0,<1.0.0')", + ) tier: str = Field(default="community", description="Tier: community or enterprise") addon_id: str | None = Field(default=None, description="Optional addon identifier") @@ -113,6 +121,7 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack command_help=command_help, pip_dependencies=[str(d) for d in raw.get("pip_dependencies", [])], module_dependencies=[str(d) for d in raw.get("module_dependencies", [])], + core_compatibility=str(raw["core_compatibility"]) if raw.get("core_compatibility") else None, tier=str(raw.get("tier", "community")), addon_id=str(raw["addon_id"]) if raw.get("addon_id") else None, ) @@ -122,6 +131,137 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack return result +@beartype +def _check_core_compatibility(meta: ModulePackageMetadata, current_cli_version: str) -> bool: + """Return True when module is compatible with the running CLI core version.""" + if not meta.core_compatibility: + return True + try: + specifier = SpecifierSet(meta.core_compatibility) + return Version(current_cli_version) in specifier + except (InvalidVersion, Exception): + # Keep malformed metadata non-blocking; emit details in debug logs at call site. + return True + + +@beartype +def _validate_module_dependencies( + meta: ModulePackageMetadata, + enabled_map: dict[str, bool], +) -> tuple[bool, list[str]]: + """Validate that declared dependencies exist and are enabled.""" + missing: list[str] = [] + for dep_id in meta.module_dependencies: + if dep_id not in enabled_map: + missing.append(f"{dep_id} (not found)") + elif not enabled_map[dep_id]: + missing.append(f"{dep_id} (disabled)") + return len(missing) == 0, missing + + +@beartype +def validate_disable_safe( + disable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], + enabled_map: dict[str, bool], +) -> dict[str, list[str]]: + """ + Return blocked disable requests mapped to enabled dependents. + + Empty dict means all disables are safe. + """ + effective_map = {**enabled_map} + for mid in disable_ids: + effective_map[mid] = False + + blocked: dict[str, list[str]] = {} + for mid in disable_ids: + dependents = find_dependents(mid, packages, effective_map) + if dependents: + blocked[mid] = dependents + return blocked + + +@beartype +def validate_enable_safe( + enable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], + enabled_map: dict[str, bool], +) -> dict[str, list[str]]: + """ + Return blocked enable requests mapped to unmet dependencies. + + Empty dict means all enables are dependency-safe in the effective map. + """ + meta_by_name: dict[str, ModulePackageMetadata] = {meta.name: meta for _package_dir, meta in packages} + blocked: dict[str, list[str]] = {} + for mid in enable_ids: + meta = meta_by_name.get(mid) + if meta is None: + blocked[mid] = ["module not found"] + continue + deps_ok, missing = _validate_module_dependencies(meta, enabled_map) + if not deps_ok: + blocked[mid] = missing + return blocked + + +@beartype +def expand_disable_with_dependents( + disable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], + enabled_map: dict[str, bool], +) -> list[str]: + """ + Expand disable set with transitive enabled dependents. + + Used by --force mode so disabling a dependency provider also disables + modules that depend on it. + """ + reverse_deps: dict[str, set[str]] = {} + for _package_dir, meta in packages: + name = meta.name + for dep in meta.module_dependencies: + reverse_deps.setdefault(dep, set()).add(name) + + expanded: set[str] = set(disable_ids) + queue = list(disable_ids) + while queue: + current = queue.pop(0) + for dependent in sorted(reverse_deps.get(current, set())): + if dependent in expanded: + continue + if not enabled_map.get(dependent, True): + continue + expanded.add(dependent) + queue.append(dependent) + return list(expanded) + + +@beartype +def expand_enable_with_dependencies( + enable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], +) -> list[str]: + """ + Expand enable set with transitive dependencies. + + Used by --force mode so enabling a module also enables required upstream + dependency providers. + """ + dep_map: dict[str, list[str]] = {meta.name: list(meta.module_dependencies) for _package_dir, meta in packages} + expanded: set[str] = set(enable_ids) + queue = list(enable_ids) + while queue: + current = queue.pop(0) + for dep in dep_map.get(current, []): + if dep in expanded: + continue + expanded.add(dep) + queue.append(dep) + return list(expanded) + + def _make_package_loader(package_dir: Path, package_name: str, _command_name: str) -> Any: """Return a callable that loads the package's app (from src/app.py or src/<name>/__init__.py).""" @@ -200,14 +340,26 @@ def register_module_package_commands( discovered_list: list[tuple[str, str]] = [(meta.name, meta.version) for _dir, meta in packages] state = read_modules_state() enabled_map = merge_module_state(discovered_list, state, enable_ids, disable_ids) + logger = get_bridge_logger(__name__) + skipped: list[tuple[str, str]] = [] for package_dir, meta in packages: if not enabled_map.get(meta.name, True): continue + compatible = _check_core_compatibility(meta, cli_version) + if not compatible: + skipped.append((meta.name, f"requires {meta.core_compatibility}, cli is {cli_version}")) + continue + deps_ok, missing = _validate_module_dependencies(meta, enabled_map) + if not deps_ok: + skipped.append((meta.name, f"missing dependencies: {', '.join(missing)}")) + continue for cmd_name in meta.commands: help_str = (meta.command_help or {}).get(cmd_name) or f"Module package: {meta.name}" loader = _make_package_loader(package_dir, meta.name, cmd_name) cmd_meta = CommandMetadata(name=cmd_name, help=help_str, tier=meta.tier, addon_id=meta.addon_id) CommandRegistry.register(cmd_name, loader, cmd_meta) + for module_id, reason in skipped: + logger.debug("Skipped module '%s': %s", module_id, reason) def get_discovered_modules_for_state( diff --git a/src/specfact_cli/registry/module_state.py b/src/specfact_cli/registry/module_state.py index 9a58ce70..1ad5a4d1 100644 --- a/src/specfact_cli/registry/module_state.py +++ b/src/specfact_cli/registry/module_state.py @@ -11,6 +11,7 @@ from typing import Any from beartype import beartype +from icontract import require from specfact_cli.registry.help_cache import get_registry_dir @@ -58,3 +59,34 @@ def write_modules_state(modules: list[dict[str, Any]]) -> None: get_registry_dir().mkdir(parents=True, exist_ok=True) path = get_modules_state_path() path.write_text(json.dumps({"modules": modules}, indent=2), encoding="utf-8") + + +@require(lambda module_id: isinstance(module_id, str) and len(module_id) > 0, "module_id must be non-empty") +@beartype +def find_dependents( + module_id: str, + packages: list[tuple[Path, Any]], + enabled_map: dict[str, bool], +) -> list[str]: + """ + Find enabled modules that depend on the given module ID. + + Args: + module_id: Candidate module to disable. + packages: Discovered package tuples (package_dir, metadata). + enabled_map: Effective enabled state map. + + Returns: + Sorted dependent module IDs currently enabled. + """ + dependents: list[str] = [] + for _package_dir, meta in packages: + name = str(getattr(meta, "name", "")) + if not name or name == module_id: + continue + if not enabled_map.get(name, True): + continue + module_dependencies = list(getattr(meta, "module_dependencies", [])) + if module_id in module_dependencies: + dependents.append(name) + return sorted(dependents) diff --git a/src/specfact_cli/runtime.py b/src/specfact_cli/runtime.py index d348e917..26cffa27 100644 --- a/src/specfact_cli/runtime.py +++ b/src/specfact_cli/runtime.py @@ -11,7 +11,6 @@ import json import logging import os -import sys from enum import StrEnum from logging.handlers import RotatingFileHandler from typing import Any @@ -102,19 +101,16 @@ def is_non_interactive() -> bool: Priority: 1. Explicit override - 2. CI/CD mode - 3. TTY detection + 2. Terminal/environment auto-detection (CI and TTY) """ if _non_interactive_override is not None: return _non_interactive_override - if _operational_mode == OperationalMode.CICD: - return True - try: - stdin_tty = bool(sys.stdin and sys.stdin.isatty()) - stdout_tty = bool(sys.stdout and sys.stdout.isatty()) - return not (stdin_tty and stdout_tty) + caps = detect_terminal_capabilities() + if caps.is_ci: + return True + return not caps.is_interactive except Exception: # pragma: no cover - defensive fallback return True diff --git a/src/specfact_cli/utils/bundle_converters.py b/src/specfact_cli/utils/bundle_converters.py new file mode 100644 index 00000000..f8f7eb9e --- /dev/null +++ b/src/specfact_cli/utils/bundle_converters.py @@ -0,0 +1,67 @@ +"""Shared bundle conversion helpers used across module commands.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.models.plan import PlanBundle +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle + + +@require(lambda project_bundle: project_bundle is not None, "project_bundle must not be None") +@ensure(lambda result: result is not None, "Must return PlanBundle") +@beartype +def convert_project_bundle_to_plan_bundle(project_bundle: ProjectBundle) -> PlanBundle: + """Convert ProjectBundle to PlanBundle for compatibility helpers.""" + return PlanBundle( + version="1.0", + idea=project_bundle.idea, + business=project_bundle.business, + product=project_bundle.product, + features=list(project_bundle.features.values()), + metadata=None, + clarifications=project_bundle.clarifications, + ) + + +@require(lambda plan_bundle: plan_bundle is not None, "plan_bundle must not be None") +@require(lambda bundle_name: isinstance(bundle_name, str) and len(bundle_name) > 0, "bundle_name must be non-empty") +@ensure(lambda result: result is not None, "Must return ProjectBundle") +@beartype +def convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: + """Convert PlanBundle to modular ProjectBundle format.""" + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + features_dict = {feature.key: feature for feature in plan_bundle.features} + return ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + idea=plan_bundle.idea, + business=plan_bundle.business, + product=plan_bundle.product, + features=features_dict, + clarifications=plan_bundle.clarifications, + ) + + +@beartype +def is_constitution_minimal(constitution_path: Path) -> bool: + """Return True when constitution content is missing or effectively placeholder-only.""" + if not constitution_path.exists(): + return True + try: + content = constitution_path.read_text(encoding="utf-8").strip() + if not content or content == "# Constitution" or len(content) < 100: + return True + placeholders = re.findall(r"\[[A-Z_0-9]+\]", content) + lines = [line.strip() for line in content.split("\n") if line.strip()] + return bool(lines and len(placeholders) > len(lines) * 0.5) + except Exception: + return True diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index 264aadef..70177f74 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -32,7 +32,7 @@ def test_init_auto_detect_cursor(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -56,7 +56,7 @@ def test_init_explicit_cursor(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -79,7 +79,7 @@ def test_init_explicit_vscode(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "vscode", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "vscode", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -106,7 +106,7 @@ def test_init_explicit_copilot(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "copilot", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "copilot", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -135,7 +135,7 @@ def test_init_skips_existing_files_without_force(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path)]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path)]) finally: os.chdir(old_cwd) @@ -164,7 +164,7 @@ def test_init_overwrites_with_force(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -220,7 +220,7 @@ def mock_find_resources(package_name: str, resource_subpath: str): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path)]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path)]) finally: os.chdir(old_cwd) @@ -254,7 +254,7 @@ def test_init_all_supported_ides(self, tmp_path): shutil.rmtree(ide_dir) - result = runner.invoke(app, ["init", "--ide", ide, "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", ide, "--repo", str(tmp_path), "--force"]) assert result.exit_code == 0, f"Failed for IDE: {ide}\n{result.stdout}\n{result.stderr}" assert "Initialization Complete" in result.stdout or "Copied" in result.stdout finally: @@ -279,7 +279,7 @@ def test_init_auto_detect_vscode(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -313,7 +313,7 @@ def test_init_auto_detect_claude(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -338,7 +338,7 @@ def test_init_warns_when_no_environment_manager(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -373,7 +373,7 @@ def test_init_no_warning_with_hatch_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -401,7 +401,7 @@ def test_init_copies_backlog_field_mapping_templates(self, tmp_path, monkeypatch old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -437,7 +437,7 @@ def test_init_skips_existing_backlog_templates(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path)]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path)]) finally: os.chdir(old_cwd) @@ -473,7 +473,7 @@ def test_init_force_overwrites_backlog_templates(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -505,7 +505,7 @@ def test_init_no_warning_with_poetry_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -527,7 +527,7 @@ def test_init_no_warning_with_pip_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -557,7 +557,7 @@ def test_init_no_warning_with_uv_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) diff --git a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py new file mode 100644 index 00000000..d72ba343 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py @@ -0,0 +1,237 @@ +"""Tests for init module lifecycle UX: listing and interactive/non-interactive selection.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from specfact_cli.cli import app +from specfact_cli.registry.module_packages import ModulePackageMetadata + + +runner = CliRunner() + + +def test_init_list_modules_shows_enabled_disabled(tmp_path: Path, monkeypatch) -> None: + """`specfact init --list-modules` prints discovered module statuses and exits.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + {"id": "generate", "version": "0.1.0", "enabled": False}, + ], + ) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--list-modules"]) + + assert result.exit_code == 0 + assert "sync" in result.stdout + assert "generate" in result.stdout + assert "enabled" in result.stdout.lower() + assert "disabled" in result.stdout.lower() + + +def test_init_non_interactive_bare_enable_module_requires_id(tmp_path: Path, monkeypatch) -> None: + """Non-interactive mode rejects bare --enable-module and requires explicit id.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands._select_module_ids_interactive", + lambda action, modules: (_ for _ in ()).throw(AssertionError("must not prompt in non-interactive mode")), + ) + + result = runner.invoke(app, ["--no-interactive", "init", "--repo", str(tmp_path), "--enable-module"]) + assert result.exit_code == 1 + assert "--enable-module <id>" in result.stdout or "--disable-module <id>" in result.stdout + + +def test_init_enable_module_bare_interactive_adds_selected_module(tmp_path: Path, monkeypatch) -> None: + """Bare --enable-module in interactive mode triggers selector and applies selected ids.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: False) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands._select_module_ids_interactive", + lambda action, modules: ["generate"], + ) + + observed_enable_ids: list[str] = [] + + def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): + nonlocal observed_enable_ids + observed_enable_ids = list(enable_ids or []) + return [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + {"id": "generate", "version": "0.1.0", "enabled": True}, + ] + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + _fake_get_discovered_modules_for_state, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module"]) + + assert result.exit_code == 0 + assert "generate" in observed_enable_ids + + +def test_init_disable_module_does_not_run_ide_setup(tmp_path: Path, monkeypatch) -> None: + """Module state updates should not trigger template copy or IDE setup side effects.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "upgrade", "version": "0.1.0", "enabled": False}, + ], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.validate_disable_safe", + lambda disable_ids, packages, enabled_map: {}, + ) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.discover_package_metadata", + lambda modules_root: [], + ) + + def _fail_copy(*args, **kwargs): + raise AssertionError("copy_templates_to_ide must not be called for module-state-only operations") + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "upgrade"]) + + assert result.exit_code == 0 + + +def test_init_bootstrap_only_does_not_run_ide_setup(tmp_path: Path, monkeypatch) -> None: + """Top-level init should not run template copy; it should stay bootstrap-only.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + ], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + def _fail_copy(*args, **kwargs): + raise AssertionError("copy_templates_to_ide must not be called by top-level init") + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path)]) + assert result.exit_code == 0 + assert "Use `specfact init ide`" in result.stdout + + +def test_init_force_disable_cascades_to_dependents(tmp_path: Path, monkeypatch) -> None: + """Force-disabling a dependency provider should cascade-disable dependents.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + packages = [ + ( + Path("/tmp/plan"), + ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), + ), + ( + Path("/tmp/sync"), + ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), + ), + ] + monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_package_metadata", lambda root: packages) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.read_modules_state", dict) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + observed_disable_ids: list[str] = [] + + def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): + nonlocal observed_disable_ids + observed_disable_ids = list(disable_ids or []) + return [ + {"id": "plan", "version": "0.1.0", "enabled": False}, + {"id": "sync", "version": "0.1.0", "enabled": False}, + ] + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + _fake_get_discovered_modules_for_state, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "sync", "--force"]) + assert result.exit_code == 0 + assert "sync" in observed_disable_ids + assert "plan" in observed_disable_ids + + +def test_init_force_enable_cascades_to_dependencies(tmp_path: Path, monkeypatch) -> None: + """Force-enabling a module should auto-enable transitive dependencies.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + packages = [ + ( + Path("/tmp/plan"), + ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), + ), + ( + Path("/tmp/sync"), + ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), + ), + ] + monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_package_metadata", lambda root: packages) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.read_modules_state", dict) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + observed_enable_ids: list[str] = [] + + def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): + nonlocal observed_enable_ids + observed_enable_ids = list(enable_ids or []) + return [ + {"id": "plan", "version": "0.1.0", "enabled": True}, + {"id": "sync", "version": "0.1.0", "enabled": True}, + ] + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + _fake_get_discovered_modules_for_state, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "plan", "--force"]) + assert result.exit_code == 0 + assert "plan" in observed_enable_ids + assert "sync" in observed_enable_ids + + +def test_init_enable_without_force_blocks_when_dependency_disabled(tmp_path: Path, monkeypatch) -> None: + """Enable should fail without force when required dependency is disabled.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + packages = [ + ( + Path("/tmp/plan"), + ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), + ), + ( + Path("/tmp/sync"), + ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), + ), + ] + monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_package_metadata", lambda root: packages) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.read_modules_state", lambda: {"sync": {"enabled": False}} + ) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "plan"]) + + assert result.exit_code == 1 + assert "Cannot enable 'plan'" in result.stdout + assert "--force" in result.stdout diff --git a/tests/unit/specfact_cli/registry/test_module_dependencies.py b/tests/unit/specfact_cli/registry/test_module_dependencies.py new file mode 100644 index 00000000..81f4eed9 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_module_dependencies.py @@ -0,0 +1,108 @@ +"""Contract-first tests for module dependency validation helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.registry.module_packages import ( + ModulePackageMetadata, + _validate_module_dependencies, + expand_disable_with_dependents, + expand_enable_with_dependencies, + validate_disable_safe, + validate_enable_safe, +) + + +def _pkg(name: str, deps: list[str] | None = None) -> tuple[Path, ModulePackageMetadata]: + return ( + Path(f"/tmp/{name}"), + ModulePackageMetadata( + name=name, + version="0.27.0", + commands=[name], + module_dependencies=deps or [], + ), + ) + + +def test_validate_module_dependencies_no_dependencies() -> None: + meta = ModulePackageMetadata(name="sync", version="0.27.0", commands=["sync"], module_dependencies=[]) + ok, missing = _validate_module_dependencies(meta, {"sync": True, "plan": True}) + assert ok is True + assert missing == [] + + +def test_validate_module_dependencies_detects_missing_and_disabled() -> None: + meta = ModulePackageMetadata( + name="sync", + version="0.27.0", + commands=["sync"], + module_dependencies=["plan", "sdd", "ghost"], + ) + ok, missing = _validate_module_dependencies(meta, {"plan": False, "sdd": True, "sync": True}) + assert ok is False + assert "plan (disabled)" in missing + assert "ghost (not found)" in missing + + +def test_validate_disable_safe_blocks_enabled_dependents() -> None: + packages = [ + _pkg("plan", ["sync"]), + _pkg("sync", []), + _pkg("sdd", []), + ] + enabled_map = {"plan": True, "sync": True, "sdd": True} + + blocked = validate_disable_safe(["sync"], packages, enabled_map) + + assert blocked == {"sync": ["plan"]} + + +def test_validate_disable_safe_allows_batch_disable_of_dependents() -> None: + packages = [ + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + enabled_map = {"plan": True, "sync": True} + + blocked = validate_disable_safe(["sync", "plan"], packages, enabled_map) + + assert blocked == {} + + +def test_expand_disable_with_dependents_transitive() -> None: + packages = [ + _pkg("project", ["plan"]), + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + enabled_map = {"project": True, "plan": True, "sync": True} + + expanded = set(expand_disable_with_dependents(["sync"], packages, enabled_map)) + + assert expanded == {"sync", "plan", "project"} + + +def test_expand_enable_with_dependencies_transitive() -> None: + packages = [ + _pkg("project", ["plan"]), + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + + expanded = set(expand_enable_with_dependencies(["project"], packages)) + + assert expanded == {"project", "plan", "sync"} + + +def test_validate_enable_safe_blocks_when_dependency_disabled() -> None: + packages = [ + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + enabled_map = {"plan": True, "sync": False} + + blocked = validate_enable_safe(["plan"], packages, enabled_map) + + assert blocked == {"plan": ["sync (disabled)"]} diff --git a/tests/unit/specfact_cli/registry/test_version_constraints.py b/tests/unit/specfact_cli/registry/test_version_constraints.py new file mode 100644 index 00000000..1f29c077 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_version_constraints.py @@ -0,0 +1,31 @@ +"""Contract-first tests for module core compatibility constraints.""" + +from __future__ import annotations + +from specfact_cli.registry.module_packages import ModulePackageMetadata, _check_core_compatibility + + +def _meta(core_compatibility: str | None) -> ModulePackageMetadata: + return ModulePackageMetadata( + name="sync", + version="0.27.0", + commands=["sync"], + module_dependencies=[], + core_compatibility=core_compatibility, + ) + + +def test_core_compatibility_none_is_compatible() -> None: + assert _check_core_compatibility(_meta(None), "0.27.0") is True + + +def test_core_compatibility_version_in_range() -> None: + assert _check_core_compatibility(_meta(">=0.28.0,<1.0.0"), "0.29.1") is True + + +def test_core_compatibility_version_out_of_range() -> None: + assert _check_core_compatibility(_meta(">=0.28.0,<1.0.0"), "1.2.0") is False + + +def test_core_compatibility_malformed_specifier_is_non_blocking() -> None: + assert _check_core_compatibility(_meta("not-a-valid-specifier"), "0.29.1") is True diff --git a/tests/unit/specfact_cli/test_module_boundary_imports.py b/tests/unit/specfact_cli/test_module_boundary_imports.py index 54706d58..32c9a657 100644 --- a/tests/unit/specfact_cli/test_module_boundary_imports.py +++ b/tests/unit/specfact_cli/test_module_boundary_imports.py @@ -9,6 +9,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[3] LEGACY_NON_APP_IMPORT_PATTERN = re.compile(r"from\s+specfact_cli\.commands\.[a-zA-Z0-9_]+\s+import\s+(?!app\b)") LEGACY_SYMBOL_REF_PATTERN = re.compile(r"specfact_cli\.commands\.[a-zA-Z0-9_]+") +CROSS_MODULE_COMMAND_IMPORT_PATTERN = re.compile( + r"from\s+specfact_cli\.modules\.([a-zA-Z0-9_]+)\.src\.commands\s+import\s+([^\n]+)" +) def test_no_legacy_non_app_command_imports_outside_compat_shims() -> None: @@ -32,3 +35,35 @@ def test_no_legacy_non_app_command_imports_outside_compat_shims() -> None: "Legacy command-module references found (use module-local paths or shared modules instead):\n" + "\n".join(f"- {path}" for path in sorted(violations)) ) + + +def test_no_cross_module_non_app_command_imports_in_module_sources() -> None: + """Block cross-module non-app imports from module command implementations.""" + violations: list[str] = [] + modules_root = PROJECT_ROOT / "src" / "specfact_cli" / "modules" + + for module_dir in sorted(modules_root.iterdir()): + if not module_dir.is_dir(): + continue + module_name = module_dir.name + for py_file in module_dir.rglob("*.py"): + if "__pycache__" in py_file.parts: + continue + text = py_file.read_text(encoding="utf-8") + for match in CROSS_MODULE_COMMAND_IMPORT_PATTERN.finditer(text): + imported_module = match.group(1) + imported_symbols = [sym.strip() for sym in match.group(2).split(",") if sym.strip()] + if imported_module == module_name: + continue + if all(sym == "app" for sym in imported_symbols): + continue + if imported_module == "sync" and module_name == "plan" and imported_symbols == ["sync_spec_kit"]: + continue + if imported_module != module_name: + rel = py_file.relative_to(PROJECT_ROOT) + violations.append(f"{rel}:{match.group(0)}") + + assert not violations, ( + "Cross-module src.commands imports found (use specfact_cli.utils for shared helpers):\n" + + "\n".join(f"- {v}" for v in sorted(violations)) + ) diff --git a/tests/unit/specfact_cli/utils/test_bundle_converters.py b/tests/unit/specfact_cli/utils/test_bundle_converters.py new file mode 100644 index 00000000..6ea0baaf --- /dev/null +++ b/tests/unit/specfact_cli/utils/test_bundle_converters.py @@ -0,0 +1,59 @@ +"""Contract-first tests for shared bundle converter utilities.""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.models.plan import Business, Feature, Idea, PlanBundle, Product +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.utils.bundle_converters import ( + convert_plan_bundle_to_project_bundle, + convert_project_bundle_to_plan_bundle, + is_constitution_minimal, +) + + +def _sample_feature() -> Feature: + return Feature(key="FEATURE-001", title="Feature", outcomes=[], acceptance=[], constraints=[], stories=[]) + + +def _sample_plan_bundle() -> PlanBundle: + return PlanBundle( + version="1.0", + idea=Idea(title="Idea", narrative="Narrative"), + business=Business(), + product=Product(), + features=[_sample_feature()], + metadata=None, + clarifications=None, + ) + + +def _sample_project_bundle() -> ProjectBundle: + return ProjectBundle( + manifest=BundleManifest(versions=BundleVersions(schema="1.0", project="0.1.0")), + bundle_name="demo", + idea=Idea(title="Idea", narrative="Narrative"), + business=Business(), + product=Product(), + features={"FEATURE-001": _sample_feature()}, + clarifications=None, + ) + + +def test_convert_project_bundle_to_plan_bundle_roundtrip() -> None: + plan = convert_project_bundle_to_plan_bundle(_sample_project_bundle()) + assert isinstance(plan, PlanBundle) + assert len(plan.features) == 1 + assert plan.features[0].key == "FEATURE-001" + + +def test_convert_plan_bundle_to_project_bundle_roundtrip() -> None: + project = convert_plan_bundle_to_project_bundle(_sample_plan_bundle(), "demo") + assert isinstance(project, ProjectBundle) + assert "FEATURE-001" in project.features + assert project.bundle_name == "demo" + + +def test_is_constitution_minimal_for_missing_file() -> None: + assert is_constitution_minimal(Path("/tmp/does-not-exist-constitution.md")) is True diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py index 4ebda4cd..28b96851 100644 --- a/tests/unit/test_runtime.py +++ b/tests/unit/test_runtime.py @@ -9,13 +9,17 @@ from pathlib import Path from unittest.mock import patch +from specfact_cli.modes import OperationalMode from specfact_cli.runtime import ( TerminalMode, debug_log_operation, debug_print, get_configured_console, get_terminal_mode, + is_non_interactive, is_debug_mode, + set_non_interactive_override, + set_operational_mode, set_debug_mode, ) from specfact_cli.utils.terminal import TerminalCapabilities @@ -91,6 +95,61 @@ def test_terminal_mode_graphical(self) -> None: assert mode == TerminalMode.GRAPHICAL +class TestInteractionMode: + """Test interactive/non-interactive runtime behavior.""" + + def test_explicit_override_true(self) -> None: + """Explicit override forces non-interactive.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=True, + is_interactive=True, + is_ci=False, + ) + set_non_interactive_override(True) + assert is_non_interactive() is True + set_non_interactive_override(None) + + def test_explicit_override_false(self) -> None: + """Explicit override forces interactive.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=True, + ) + set_non_interactive_override(False) + assert is_non_interactive() is False + set_non_interactive_override(None) + + def test_default_interactive_tty_even_in_cicd_mode(self) -> None: + """Interactive TTY defaults to interactive regardless of operational mode.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=True, + is_interactive=True, + is_ci=False, + ) + set_non_interactive_override(None) + set_operational_mode(OperationalMode.CICD) + assert is_non_interactive() is False + + def test_default_non_interactive_in_ci(self) -> None: + """CI defaults to non-interactive.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=True, + is_ci=True, + ) + set_non_interactive_override(None) + assert is_non_interactive() is True + + class TestGetConfiguredConsole: """Test configured console creation and caching.""" From 300af319eeb09e280050b4e84482bab54f3dea50 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:53:59 +0100 Subject: [PATCH 03/11] docs: update arch-03 tasks after pr creation --- .../changes/arch-03-module-lifecycle-management/tasks.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/openspec/changes/arch-03-module-lifecycle-management/tasks.md b/openspec/changes/arch-03-module-lifecycle-management/tasks.md index 18035fbb..5bd04ffd 100644 --- a/openspec/changes/arch-03-module-lifecycle-management/tasks.md +++ b/openspec/changes/arch-03-module-lifecycle-management/tasks.md @@ -78,9 +78,9 @@ Do not implement production code for new behavior until the corresponding tests ## 11. Create Pull Request to dev (last) -- [ ] 11.1 Commit with conventional commit message -- [ ] 11.2 Push branch: `git push origin feature/arch-03-module-lifecycle-management` -- [ ] 11.3 Create PR to `dev` using repository template and include `Fixes nold-ai/specfact-cli#<issue-number>` +- [x] 11.1 Commit with conventional commit message +- [x] 11.2 Push branch: `git push origin feature/arch-03-module-lifecycle-management` +- [x] 11.3 Create PR to `dev` using repository template and include `Fixes nold-ai/specfact-cli#<issue-number>` - [ ] 11.4 Verify issue Development links include branch and PR ## 12. Extend module lifecycle UX for listing and interactive selection From 49ca8a439813f43e68fa7f25851e32b10b9ed24d Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:59:11 +0100 Subject: [PATCH 04/11] docs: update init help text for module lifecycle and ide split --- .../modules/init/module-package.yaml | 2 +- src/specfact_cli/modules/init/src/commands.py | 41 +++++++++++-------- 2 files changed, 24 insertions(+), 19 deletions(-) diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 8aa99bac..0f5f8411 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -4,7 +4,7 @@ version: "0.27.0" commands: - init command_help: - init: "Initialize SpecFact for IDE integration" + init: "Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup)" pip_dependencies: [] module_dependencies: [] tier: community diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 16a1fddb..591e7ad0 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -1,8 +1,8 @@ """ -Init command - Initialize SpecFact for IDE integration. +Init commands for bootstrap, module lifecycle management, and IDE setup. -This module provides the `specfact init` command to copy prompt templates -to IDE-specific locations for slash command integration. +`specfact init` handles bootstrap and module enable/disable lifecycle state. +`specfact init ide` handles IDE prompt/template setup and optional dependency installation. """ from __future__ import annotations @@ -125,7 +125,7 @@ def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: console.print("[dim]Backlog field mapping templates already exist (use --force to overwrite)[/dim]") -app = typer.Typer(help="Initialize SpecFact for IDE integration") +app = typer.Typer(help="Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup)") console = Console() MODULE_SELECT_SENTINEL = "__interactive_select__" @@ -440,12 +440,18 @@ def init( force: bool = typer.Option( False, "--force", - help="Overwrite existing files", + help=( + "Override module dependency safety checks. In force mode, disable cascades to dependents " + "and enable cascades to required dependencies." + ), ), install_deps: bool = typer.Option( False, "--install-deps", - help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) using detected environment manager", + help=( + "Install required packages for contract enhancement. Prefer `specfact init ide --install-deps` " + "for IDE setup flow." + ), ), # Advanced/Configuration ide: str = typer.Option( @@ -477,21 +483,20 @@ def init( ), ) -> None: """ - Initialize SpecFact for IDE integration. - - Copies prompt templates to IDE-specific locations so slash commands work. - This command detects the IDE type (or uses --ide flag) and copies - SpecFact prompt templates to the appropriate directory. + Bootstrap SpecFact local state and manage module lifecycle. - Also copies backlog field mapping templates to `.specfact/templates/backlog/field_mappings/` - for custom ADO field mapping configuration. + This command initializes/updates user-level module registry state, discovers + installed modules, and manages enabled/disabled module lifecycle with dependency + safety checks. Use `specfact init ide` for IDE prompt/template setup. Examples: - specfact init # Auto-detect IDE - specfact init --ide cursor # Initialize for Cursor - specfact init --ide vscode --force # Overwrite existing files - specfact init --repo /path/to/repo --ide copilot - specfact init --install-deps # Install required packages for contract enhancement + specfact init # Bootstrap and discover modules + specfact init --list-modules # Show enabled/disabled modules + specfact init --enable-module # Interactive module selector (TTY) + specfact init --disable-module sync # Disable explicit module + specfact init --enable-module plan --force # Cascade-enable dependencies + specfact init ide --ide cursor # IDE prompt/template setup + specfact init ide --install-deps # Install contract enhancement dependencies """ telemetry_metadata = { "ide": ide, From 1270ef672aac8233e7963ddadc4e4506073a5282 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 21:59:48 +0100 Subject: [PATCH 05/11] Format missing --- tests/unit/test_runtime.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py index 28b96851..e0282daf 100644 --- a/tests/unit/test_runtime.py +++ b/tests/unit/test_runtime.py @@ -16,11 +16,11 @@ debug_print, get_configured_console, get_terminal_mode, - is_non_interactive, is_debug_mode, + is_non_interactive, + set_debug_mode, set_non_interactive_override, set_operational_mode, - set_debug_mode, ) from specfact_cli.utils.terminal import TerminalCapabilities From 255e07fcce0272f68e3631eb9f28984292a41483 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:07:05 +0100 Subject: [PATCH 06/11] fix: tighten ado assignee typing for basedpyright warning --- src/specfact_cli/adapters/ado.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/specfact_cli/adapters/ado.py b/src/specfact_cli/adapters/ado.py index 0c94c293..6a9c2bcf 100644 --- a/src/specfact_cli/adapters/ado.py +++ b/src/specfact_cli/adapters/ado.py @@ -14,7 +14,7 @@ import re from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, cast from urllib.parse import urlparse import requests @@ -549,7 +549,15 @@ def extract_change_proposal_data(self, item_data: dict[str, Any]) -> dict[str, A assigned_to = fields.get("System.AssignedTo") if assigned_to: if isinstance(assigned_to, dict): - assignee_name = assigned_to.get("displayName") or assigned_to.get("uniqueName", "") + assignee_dict = cast(dict[str, Any], assigned_to) + display_name = assignee_dict.get("displayName") + unique_name = assignee_dict.get("uniqueName") + if isinstance(display_name, str) and display_name.strip(): + assignee_name = display_name.strip() + elif isinstance(unique_name, str): + assignee_name = unique_name + else: + assignee_name = "" else: assignee_name = str(assigned_to) if assignee_name and not owner: From 4bed7a0135a4f40036602504e4aad043cdfccb44 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:09:29 +0100 Subject: [PATCH 07/11] fix: honor init install-deps and tighten ado typing --- src/specfact_cli/modules/init/src/commands.py | 50 +++++++++++-------- .../registry/test_init_module_lifecycle_ux.py | 45 +++++++++++++++++ 2 files changed, 74 insertions(+), 21 deletions(-) diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 591e7ad0..07f893da 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -36,7 +36,7 @@ from specfact_cli.registry.module_state import read_modules_state, write_modules_state from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive from specfact_cli.telemetry import telemetry -from specfact_cli.utils.env_manager import EnvManager, build_tool_command, detect_env_manager +from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager from specfact_cli.utils.ide_setup import ( IDE_CONFIG, SPECFACT_COMMANDS, @@ -130,6 +130,29 @@ def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: MODULE_SELECT_SENTINEL = "__interactive_select__" +def _install_contract_enhancement_dependencies(repo_path: Path, env_info: EnvManagerInfo) -> None: + """Install contract enhancement dependencies in the detected environment.""" + required_packages = [ + "beartype>=0.22.4", + "icontract>=2.7.1", + "crosshair-tool>=0.0.97", + "pytest>=8.4.2", + ] + install_cmd = build_tool_command(env_info, ["pip", "install", "-U", *required_packages]) + result = subprocess.run( + install_cmd, + capture_output=True, + text=True, + check=False, + cwd=str(repo_path), + timeout=300, + ) + if result.returncode == 0: + console.print("[green]✓[/green] Dependencies installed") + else: + console.print("[yellow]⚠[/yellow] Dependency installation reported issues") + + def _questionary_style() -> Any: """Return a shared questionary color theme for interactive selectors.""" try: @@ -384,25 +407,7 @@ def init_ide( console.print() if install_deps: - required_packages = [ - "beartype>=0.22.4", - "icontract>=2.7.1", - "crosshair-tool>=0.0.97", - "pytest>=8.4.2", - ] - install_cmd = build_tool_command(env_info, ["pip", "install", "-U", *required_packages]) - result = subprocess.run( - install_cmd, - capture_output=True, - text=True, - check=False, - cwd=str(repo_path), - timeout=300, - ) - if result.returncode == 0: - console.print("[green]✓[/green] Dependencies installed") - else: - console.print("[yellow]⚠[/yellow] Dependency installation reported issues") + _install_contract_enhancement_dependencies(repo_path, env_info) templates_dir = _resolve_templates_dir(repo_path) if not templates_dir or not templates_dir.exists(): @@ -508,6 +513,7 @@ def init( with telemetry.track_command("init", telemetry_metadata) as record: if ctx.invoked_subcommand is not None: return + repo_path = repo.resolve() module_management_requested = any( [ bool(enable_module), @@ -594,6 +600,9 @@ def init( "Re-enable with specfact init --enable-module <id>.[/dim]" ) run_discovery_and_write_cache(__version__) + if install_deps: + env_info = detect_env_manager(repo_path) + _install_contract_enhancement_dependencies(repo_path, env_info) if list_modules: console.print() _render_modules_table(modules_list) @@ -601,7 +610,6 @@ def init( if module_management_requested: console.print("[green]✓[/green] Module state updated.") return - repo_path = repo.resolve() enabled_count = len([m for m in modules_list if bool(m.get("enabled", True))]) disabled_count = len(modules_list) - enabled_count console.print( diff --git a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py index d72ba343..4f5bd4be 100644 --- a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py +++ b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py @@ -8,6 +8,7 @@ from specfact_cli.cli import app from specfact_cli.registry.module_packages import ModulePackageMetadata +from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo runner = CliRunner() @@ -131,6 +132,50 @@ def _fail_copy(*args, **kwargs): assert "Use `specfact init ide`" in result.stdout +def test_init_install_deps_runs_without_ide_template_copy(tmp_path: Path, monkeypatch) -> None: + """Top-level init --install-deps installs dependencies without invoking IDE template copy.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + ], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", + lambda repo_path: EnvManagerInfo( + manager=EnvManager.PIP, + available=True, + command_prefix=[], + message="pip", + ), + ) + + calls: list[list[str]] = [] + + class _Result: + returncode = 0 + + def _fake_run(cmd, capture_output, text, check, cwd, timeout): + calls.append(list(cmd)) + return _Result() + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.subprocess.run", _fake_run) + + def _fail_copy(*args, **kwargs): + raise AssertionError("copy_templates_to_ide must not be called by top-level init --install-deps") + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--install-deps"]) + + assert result.exit_code == 0 + assert calls, "Expected dependency installation command to run" + assert calls[0][:4] == ["pip", "install", "-U", "beartype>=0.22.4"] + + def test_init_force_disable_cascades_to_dependents(tmp_path: Path, monkeypatch) -> None: """Force-disabling a dependency provider should cascade-disable dependents.""" From d8745c341dbadb2dabd67368f2c96da363482f95 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:24:13 +0100 Subject: [PATCH 08/11] test: satisfy bundle converter constructor typing --- .../unit/specfact_cli/utils/test_bundle_converters.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/unit/specfact_cli/utils/test_bundle_converters.py b/tests/unit/specfact_cli/utils/test_bundle_converters.py index 6ea0baaf..533929af 100644 --- a/tests/unit/specfact_cli/utils/test_bundle_converters.py +++ b/tests/unit/specfact_cli/utils/test_bundle_converters.py @@ -20,7 +20,7 @@ def _sample_feature() -> Feature: def _sample_plan_bundle() -> PlanBundle: return PlanBundle( version="1.0", - idea=Idea(title="Idea", narrative="Narrative"), + idea=Idea(title="Idea", narrative="Narrative", metrics=None), business=Business(), product=Product(), features=[_sample_feature()], @@ -31,9 +31,13 @@ def _sample_plan_bundle() -> PlanBundle: def _sample_project_bundle() -> ProjectBundle: return ProjectBundle( - manifest=BundleManifest(versions=BundleVersions(schema="1.0", project="0.1.0")), + manifest=BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ), bundle_name="demo", - idea=Idea(title="Idea", narrative="Narrative"), + idea=Idea(title="Idea", narrative="Narrative", metrics=None), business=Business(), product=Product(), features={"FEATURE-001": _sample_feature()}, From ecf0bbb960452239ded41a57b719a9df362106c4 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:25:52 +0100 Subject: [PATCH 09/11] test: isolate module registry state in migration compatibility test --- .../specfact_cli/test_module_migration_compatibility.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/unit/specfact_cli/test_module_migration_compatibility.py b/tests/unit/specfact_cli/test_module_migration_compatibility.py index 508e8938..e7a3a0c0 100644 --- a/tests/unit/specfact_cli/test_module_migration_compatibility.py +++ b/tests/unit/specfact_cli/test_module_migration_compatibility.py @@ -6,6 +6,8 @@ import re from pathlib import Path +import pytest + from specfact_cli.registry.bootstrap import register_builtin_commands from specfact_cli.registry.registry import CommandRegistry @@ -123,8 +125,10 @@ def test_legacy_command_shims_reexport_public_symbols() -> None: ) -def test_module_discovery_registers_commands_from_manifests() -> None: +def test_module_discovery_registers_commands_from_manifests(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Command registry includes all commands declared by module-package manifests after bootstrap.""" + monkeypatch.setenv("SPECFACT_REGISTRY_DIR", str(tmp_path)) + expected_commands: set[str] = set() for module_name in _module_package_names(): manifest = MODULES_ROOT / module_name / "module-package.yaml" From d1535d24dae4258d02dc59a96f3e931cd34efab7 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:26:26 +0100 Subject: [PATCH 10/11] Update change --- .../proposal.md | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/openspec/changes/arch-03-module-lifecycle-management/proposal.md b/openspec/changes/arch-03-module-lifecycle-management/proposal.md index 5cfd360f..b032f0e6 100644 --- a/openspec/changes/arch-03-module-lifecycle-management/proposal.md +++ b/openspec/changes/arch-03-module-lifecycle-management/proposal.md @@ -2,12 +2,14 @@ ## Why + `arch-02` completed module package separation, but module lifecycle constraints are still unenforced at runtime. `module_dependencies` is currently declarative-only, module manifests do not constrain CLI core compatibility, and `init --disable-module` can disable required modules without preflight protection. This creates avoidable runtime breakage and weakens contract-first guarantees for modular command loading. We need registry-time lifecycle validation and safe-disable protection while preserving backward compatibility. ## What Changes + - **NEW**: Add module lifecycle validation at registration time for dependency existence/enabled state and `core_compatibility` version constraints. - **NEW**: Extend module manifests with `core_compatibility` (PEP 440 specifier string) across all module packages. - **NEW**: Add safe-disable checks in `specfact init` so disabling a module fails when enabled dependents require it, with explicit `--force` override. @@ -20,20 +22,14 @@ This creates avoidable runtime breakage and weakens contract-first guarantees fo - **EXTEND**: Update user-facing documentation and changelog/version synchronization for lifecycle management behavior and module manifest schema. ## Capabilities - - **module-lifecycle-management**: Enforce module dependency integrity, version compatibility, safe-disable semantics, and module boundary hygiene. -## Impact - -- **Affected specs**: New `openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md`. -- **Affected code**: `src/specfact_cli/registry/`, `src/specfact_cli/modules/*/module-package.yaml`, `src/specfact_cli/modules/init/src/commands.py`, and shared utility extraction under `src/specfact_cli/utils/`. -- **Affected tests**: Registry/unit test coverage, module boundary guard tests, and utility conversion tests. -- **Affected documentation** (<https://docs.specfact.io>): CLI/module docs and contributor guidance that describe module metadata, lifecycle constraints, and disable behavior. -- **Backward compatibility**: Preserved for command-level behavior; lifecycle checks prevent invalid module states earlier with actionable errors. +--- ## Source Tracking +<!-- source_repo: nold-ai/specfact-cli --> - **GitHub Issue**: #203 - **Issue URL**: <https://github.com/nold-ai/specfact-cli/issues/203> -- **Repository**: nold-ai/specfact-cli - **Last Synced Status**: proposed +<!-- content_hash: d31176ea7ec82ec0 --> From c110269ebf1e12982c5a36b4d2ac43106874c5e6 Mon Sep 17 00:00:00 2001 From: Dominikus Nold <djm81@users.noreply.github.com> Date: Fri, 6 Feb 2026 22:33:34 +0100 Subject: [PATCH 11/11] disable claude review due to high costs --- .github/workflows/claude-code-review.yml | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..441b944a 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,14 +1,12 @@ name: Claude Code Review on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + workflow_dispatch: + inputs: + pull_request_number: + description: "Pull request number to review" + required: true + type: string jobs: claude-review: @@ -38,7 +36,6 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ inputs.pull_request_number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options -