From 53ffb1b3d73424c7dbd1da1880899a6c605b0228 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 1 Jun 2026 01:48:56 +0200 Subject: [PATCH 1/7] Harden CLI command reliability gates --- .../ISSUE_TEMPLATE/augmentation_metadata.md | 2 +- .github/ISSUE_TEMPLATE/bug_report.md | 2 +- .github/pull_request_template.md | 2 +- .github/workflows/docs-review.yml | 29 + .github/workflows/pr-orchestrator.yml | 24 +- .github/workflows/specfact.yml | 2 +- .markdownlint.json | 11 +- CHANGELOG.md | 29 + README.md | 5 + docs/core-cli/debug-logging.md | 2 +- docs/core-cli/init.md | 8 +- docs/core-cli/modes.md | 48 +- docs/examples/dogfooding-specfact-cli.md | 4 +- .../integration-showcases-quick-reference.md | 12 +- .../integration-showcases-testing-guide.md | 44 +- .../integration-showcases.md | 14 +- .../setup-integration-tests.sh | 3 +- docs/examples/quick-examples.md | 34 +- docs/getting-started/README.md | 2 +- docs/getting-started/installation.md | 4 +- docs/getting-started/quickstart.md | 2 +- .../tutorial-openspec-speckit.md | 4 +- docs/getting-started/where-to-start.md | 4 +- docs/guides/ai-ide-workflow.md | 8 +- docs/guides/command-chains.md | 6 +- docs/guides/copilot-mode.md | 10 +- docs/guides/ide-integration.md | 12 +- docs/guides/openspec-journey.md | 2 +- docs/guides/speckit-journey.md | 2 +- docs/guides/troubleshooting.md | 16 +- .../migration/migration-cli-reorganization.md | 6 +- docs/reference/commands.generated.json | 2202 +++++++++++++++++ docs/reference/commands.generated.md | 122 + docs/reference/commands.md | 2 +- docs/reference/directory-structure.md | 2 +- docs/technical/code2spec-analysis-logic.md | 2 +- llms.txt | 126 + openspec/CHANGE_ORDER.md | 1 + .../tester-cli-reliability/.openspec.yaml | 2 + .../tester-cli-reliability/TDD_EVIDENCE.md | 115 + .../tester-cli-reliability/proposal.md | 47 + .../specs/ci-integration/spec.md | 19 + .../specs/cli-error-guidance/spec.md | 39 + .../spec.md | 70 + .../specs/core-cli-reference/spec.md | 42 + .../specs/generated-command-overview/spec.md | 38 + .../specs/runtime-tool-probing/spec.md | 27 + .../changes/tester-cli-reliability/tasks.md | 39 + pyproject.toml | 7 +- resources/templates/pr-template.md.j2 | 3 +- scripts/check-command-contract.py | 243 ++ scripts/check-docs-commands.py | 175 +- scripts/generate-command-overview.py | 298 +++ scripts/pre-commit-quality-checks.sh | 61 +- scripts/runtime_discovery_smoke.py | 146 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 28 +- src/specfact_cli/cli.py | 203 +- .../modules/init/module-package.yaml | 5 +- src/specfact_cli/modules/init/src/commands.py | 9 +- .../modules/upgrade/module-package.yaml | 5 +- .../modules/upgrade/src/commands.py | 72 + src/specfact_cli/utils/bundle_loader.py | 2 +- src/specfact_cli/utils/env_manager.py | 22 +- src/specfact_cli/utils/github_annotations.py | 4 +- src/specfact_cli/utils/ide_setup.py | 161 +- .../utils/progressive_disclosure.py | 159 +- src/specfact_cli/utils/structure.py | 6 +- src/specfact_cli/utils/suggestions.py | 8 +- tests/conftest.py | 8 + .../scripts/test_runtime_discovery_smoke.py | 6 + tests/unit/cli/test_error_guidance.py | 113 + tests/unit/cli/test_lean_help_output.py | 27 +- tests/unit/commands/test_backlog_daily.py | 27 +- tests/unit/commands/test_update.py | 152 +- .../unit/docs/test_docs_validation_scripts.py | 48 + .../test_module_migration_07_cleanup.py | 1 + .../init/test_init_ide_prompt_selection.py | 56 + tests/unit/registry/test_category_groups.py | 9 +- tests/unit/registry/test_module_installer.py | 13 + .../registry/test_profile_presets.py | 31 + tests/unit/utils/test_env_manager.py | 24 + tests/unit/utils/test_ide_setup.py | 45 + .../test_trustworthy_green_checks.py | 16 +- 85 files changed, 5187 insertions(+), 258 deletions(-) create mode 100644 docs/reference/commands.generated.json create mode 100644 docs/reference/commands.generated.md create mode 100644 llms.txt create mode 100644 openspec/changes/tester-cli-reliability/.openspec.yaml create mode 100644 openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md create mode 100644 openspec/changes/tester-cli-reliability/proposal.md create mode 100644 openspec/changes/tester-cli-reliability/specs/ci-integration/spec.md create mode 100644 openspec/changes/tester-cli-reliability/specs/cli-error-guidance/spec.md create mode 100644 openspec/changes/tester-cli-reliability/specs/command-package-runtime-validation/spec.md create mode 100644 openspec/changes/tester-cli-reliability/specs/core-cli-reference/spec.md create mode 100644 openspec/changes/tester-cli-reliability/specs/generated-command-overview/spec.md create mode 100644 openspec/changes/tester-cli-reliability/specs/runtime-tool-probing/spec.md create mode 100644 openspec/changes/tester-cli-reliability/tasks.md create mode 100644 scripts/check-command-contract.py create mode 100644 scripts/generate-command-overview.py create mode 100644 tests/unit/cli/test_error_guidance.py diff --git a/.github/ISSUE_TEMPLATE/augmentation_metadata.md b/.github/ISSUE_TEMPLATE/augmentation_metadata.md index c27f965c..a8a6dd96 100644 --- a/.github/ISSUE_TEMPLATE/augmentation_metadata.md +++ b/.github/ISSUE_TEMPLATE/augmentation_metadata.md @@ -42,7 +42,7 @@ specfact --option value **Example:** ```bash -specfact import from-code ./legacy-project --exclude "tests/**" --confidence 0.7 +specfact code import --repo ./legacy-project legacy-project --exclude-tests --confidence 0.7 ``` ## Alternative Solutions diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index c1f0313d..4b58b8c0 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -23,7 +23,7 @@ specfact --options **Example:** ```bash -specfact import from-code ./my-legacy-project --confidence 0.8 +specfact code import --repo ./my-legacy-project legacy-project --confidence 0.8 ``` ## Expected Behavior diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index d9905ddb..1a598df8 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -36,7 +36,7 @@ Please check all that apply: - [ ] **Contract validation**: `hatch run contract-test-contracts` ✅ - [ ] **Contract exploration**: `hatch run contract-test-exploration` ✅ - [ ] **Scenario tests**: `hatch run contract-test-scenarios` ✅ -- [ ] **Full test suite**: `hatch run contract-test-full` ✅ +- [ ] **Full test suite**: `hatch run smart-test-full` ✅ ### Test Quality diff --git a/.github/workflows/docs-review.yml b/.github/workflows/docs-review.yml index 59a42c68..d878fad7 100644 --- a/.github/workflows/docs-review.yml +++ b/.github/workflows/docs-review.yml @@ -8,7 +8,10 @@ on: paths: - "**/*.md" - "**/*.mdc" + - ".github/**" - "docs/**" + - "resources/**" + - "src/specfact_cli/resources/**" - "docs/.doc-frontmatter-enforced" - "tests/unit/docs/**" - "tests/unit/scripts/test_doc_frontmatter/**" @@ -17,6 +20,10 @@ on: - "tests/helpers/doc_frontmatter_fixtures.py" - "tests/helpers/doc_frontmatter_types.py" - "scripts/check-docs-commands.py" + - "scripts/check-command-contract.py" + - "scripts/generate-command-overview.py" + - "docs/reference/commands.generated.*" + - "llms.txt" - "scripts/check-cross-site-links.py" - "scripts/check_doc_frontmatter.py" - "scripts/validate_agent_rule_applies_when.py" @@ -29,7 +36,10 @@ on: paths: - "**/*.md" - "**/*.mdc" + - ".github/**" - "docs/**" + - "resources/**" + - "src/specfact_cli/resources/**" - "docs/.doc-frontmatter-enforced" - "tests/unit/docs/**" - "tests/unit/scripts/test_doc_frontmatter/**" @@ -38,6 +48,10 @@ on: - "tests/helpers/doc_frontmatter_fixtures.py" - "tests/helpers/doc_frontmatter_types.py" - "scripts/check-docs-commands.py" + - "scripts/check-command-contract.py" + - "scripts/generate-command-overview.py" + - "docs/reference/commands.generated.*" + - "llms.txt" - "scripts/check-cross-site-links.py" - "scripts/check_doc_frontmatter.py" - "scripts/validate_agent_rule_applies_when.py" @@ -60,6 +74,15 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Checkout module command sources + uses: actions/checkout@v4 + with: + repository: nold-ai/specfact-cli-modules + path: specfact-cli-modules + ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + + - name: Export module command source path + run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.12 uses: actions/setup-python@v5 @@ -78,6 +101,12 @@ jobs: - name: Validate docs command examples run: hatch run check-docs-commands + - name: Validate generated command overview + run: hatch run check-command-overview + + - name: Validate generated command contract + run: hatch run check-command-contract + - name: Cross-site links (warn-only; live site may lag deploys) continue-on-error: true run: hatch run check-cross-site-links --warn-only diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index 62afa967..e3ba3dd2 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -242,7 +242,7 @@ jobs: if: needs.changes.outputs.skip_tests_dev_to_main != 'true' run: | python -m pip install --upgrade pip - pip install "hatch" "virtualenv<21" coverage "coverage[toml]" pytest pytest-cov pytest-mock pytest-asyncio pytest-xdist pytest-timeout + pip install "hatch" "virtualenv<21" pipx uv coverage "coverage[toml]" pytest pytest-cov pytest-mock pytest-asyncio pytest-xdist pytest-timeout pip install -e ".[dev]" - name: Verify version strings are synchronized @@ -310,7 +310,7 @@ jobs: if: needs.changes.outputs.skip_tests_dev_to_main != 'true' shell: bash run: | - python scripts/runtime_discovery_smoke.py --launcher direct --launcher pip-editable --launcher uvx + python scripts/runtime_discovery_smoke.py --launcher direct --launcher hatch-source --launcher pip-editable --launcher pipx --launcher uv-run --launcher uvx - name: Set run_unit_coverage (or skip for dev→main) id: detect-unit @@ -463,8 +463,11 @@ jobs: run: | echo "🔍 Validating runtime contracts..." REPRO_LOG="logs/repro/repro_$(date -u +%Y%m%d_%H%M%S).log" - echo "Running contract-first validation with required CrossHair... (log: $REPRO_LOG)" - hatch run contract-test 2>&1 | tee "$REPRO_LOG" + echo "Running scoped contract-first validation with required CrossHair... (log: $REPRO_LOG)" + { + hatch run contract-test-contracts + hatch run contract-test-exploration-fast + } 2>&1 | tee "$REPRO_LOG" exit "${PIPESTATUS[0]:-$?}" - name: Upload repro logs if: always() @@ -490,6 +493,14 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - name: Checkout module command sources + uses: actions/checkout@v4 + with: + repository: nold-ai/specfact-cli-modules + path: specfact-cli-modules + ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + - name: Export module command source path + run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.12 uses: actions/setup-python@v5 with: @@ -500,11 +511,16 @@ jobs: - name: Install CLI run: | echo "Installing SpecFact CLI..." + python -m pip install --upgrade pip + python -m pip install "hatch" "virtualenv<21" pip install -e . - name: Validate CLI commands run: | echo "🔍 Validating CLI commands..." specfact --help + hatch run check-command-overview + hatch run check-command-contract + hatch run check-docs-commands echo "✅ CLI validation complete" quality-gates: diff --git a/.github/workflows/specfact.yml b/.github/workflows/specfact.yml index f883e94b..bba0485f 100644 --- a/.github/workflows/specfact.yml +++ b/.github/workflows/specfact.yml @@ -83,7 +83,7 @@ jobs: id: repro continue-on-error: true run: | - specfact repro --verbose --crosshair-required --budget ${{ steps.validation.outputs.budget }} || true + specfact code repro --verbose --crosshair-required --budget ${{ steps.validation.outputs.budget }} || true echo "exit_code=$?" >> "$GITHUB_OUTPUT" - name: Find latest repro report diff --git a/.markdownlint.json b/.markdownlint.json index f079c815..cab9075e 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -1,8 +1,17 @@ { "default": true, + "MD024": { + "siblings_only": true + }, + "MD025": { + "front_matter_title": "" + }, + "MD029": false, "MD013": false, "MD033": false, + "MD036": false, + "MD040": false, + "MD051": false, "MD041": false, "MD060": false } - diff --git a/CHANGELOG.md b/CHANGELOG.md index 460a8ca3..bf597815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,35 @@ All notable changes to this project will be documented in this file. --- +## [0.47.0] - 2026-06-01 + +### Added + +- **Generated command overview**: add source-derived `llms.txt` and generated + command reference artifacts so agents, docs checks, and CI validate against + the same current CLI surface. +- **Runtime package-manager smoke gate**: add real-world launcher validation for + direct, Hatch, uv, pip, and pipx-style execution paths covering init, module, + upgrade, import, and export flows. + +### Changed + +- **Command validation gates**: make pre-commit and PR validation regenerate and + verify command artifacts before docs, prompts, and tests can pass. + +### Fixed + +- **CLI misuse guidance**: render contextual help plus the concrete missing + subcommand, parameter, option value, or unknown-command guidance across core + and module command groups. +- **Pipx upgrade launcher repair**: validate `specfact --version` after a + successful pipx upgrade and run `pipx reinstall specfact-cli` when the + console launcher still points at a stale or missing pipx venv. +- **Legacy flat command references**: remove stale shim expectations from docs, + tests, and templates in favor of the current namespaced command surface. + +--- + ## [0.46.28] - 2026-05-21 ### Changed diff --git a/README.md b/README.md index 8440cc83..b38c2a29 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,11 @@ +## Command Overview + +- [Generated command overview for humans](docs/reference/commands.generated.md) +- [AI-agent command overview](llms.txt) + ## Try it in 60 seconds ```bash diff --git a/docs/core-cli/debug-logging.md b/docs/core-cli/debug-logging.md index adfcc9c4..85f28123 100644 --- a/docs/core-cli/debug-logging.md +++ b/docs/core-cli/debug-logging.md @@ -38,7 +38,7 @@ Pass `--debug` before any command: ```bash specfact --debug init specfact --debug backlog refine --adapter ado --project my-project -specfact --debug plan select +specfact --debug project version check ``` Debug output appears in the terminal and is also appended to a log file. diff --git a/docs/core-cli/init.md b/docs/core-cli/init.md index c850e5d3..dae2141a 100644 --- a/docs/core-cli/init.md +++ b/docs/core-cli/init.md @@ -61,6 +61,10 @@ The `init ide` subcommand discovers prompt templates from **core** (bundled `spe ```bash # Initialize Cursor IDE integration (interactive: pick IDE, then prompt sources) specfact init ide --ide cursor +specfact init ide --ide vscode +specfact init ide --ide codex +specfact init ide --ide claude-skills +specfact init ide --ide vibe # Non-interactive: export all discovered sources (default) specfact init ide --ide cursor --repo . @@ -74,13 +78,13 @@ specfact init ide --ide cursor --prompts "core,nold-ai/specfact-backlog" specfact init ide --install-deps ``` -Exported IDE files are placed under **per-source subfolders** (for example `.cursor/commands/core/`, `.cursor/commands/nold-ai__specfact-backlog/`) so names collide deterministically and provenance stays visible. +Slash-command IDE targets export one file per prompt into their native command location, such as `.cursor/commands/specfact.02-plan.md` or `.github/prompts/specfact.02-plan.prompt.md`. Skill-based targets export one capability-oriented skill per source/module, such as `.codex/skills/specfact-project/SKILL.md`, `.claude/skills/specfact-backlog/SKILL.md`, or `.vibe/skills/specfact-spec/SKILL.md`. This creates or refreshes: - `.specfact/` directory structure - `.specfact/templates/backlog/field_mappings/` with default field mapping templates when available -- IDE-specific command files under the IDE export directory, namespaced by prompt source +- IDE-specific slash-command files or grouped `SKILL.md` files under the IDE export directory ## Dependency Installation diff --git a/docs/core-cli/modes.md b/docs/core-cli/modes.md index 963433ef..1320a2b7 100644 --- a/docs/core-cli/modes.md +++ b/docs/core-cli/modes.md @@ -42,16 +42,16 @@ This reference shows how to test mode detection and command routing in practice. ```bash # Test CI/CD mode explicitly -hatch run specfact --mode cicd hello +hatch run specfact --mode cicd module list # Test CoPilot mode explicitly -hatch run specfact --mode copilot hello +hatch run specfact --mode copilot module list # Test invalid mode (should fail) -hatch run specfact --mode invalid hello +hatch run specfact --mode invalid module list # Test short form -m flag -hatch run specfact -m cicd hello +hatch run specfact -m cicd module list ``` ### Quick Test Script @@ -73,15 +73,15 @@ This script tests all detection scenarios automatically. ```bash # Set environment variable and test export SPECFACT_MODE=copilot -specfact hello +specfact module list # Set to CI/CD mode export SPECFACT_MODE=cicd -specfact hello +specfact module list # Unset to test default unset SPECFACT_MODE -specfact hello # Should default to CI/CD +specfact module list # Should default to CI/CD ``` ### 3. Test Auto-Detection @@ -91,15 +91,15 @@ specfact hello # Should default to CI/CD ```bash # Simulate CoPilot API available export COPILOT_API_URL=https://api.copilot.com -specfact hello # Should detect CoPilot mode +specfact module list # Should detect CoPilot mode # Or with token export COPILOT_API_TOKEN=token123 -specfact hello # Should detect CoPilot mode +specfact module list # Should detect CoPilot mode # Or with GitHub Copilot token export GITHUB_COPILOT_TOKEN=token123 -specfact hello # Should detect CoPilot mode +specfact module list # Should detect CoPilot mode ``` #### Test IDE Detection @@ -108,17 +108,17 @@ specfact hello # Should detect CoPilot mode # Simulate VS Code environment export VSCODE_PID=12345 export COPILOT_ENABLED=true -specfact hello # Should detect CoPilot mode +specfact module list # Should detect CoPilot mode # Simulate Cursor environment export CURSOR_PID=12345 export CURSOR_COPILOT_ENABLED=true -specfact hello # Should detect CoPilot mode +specfact module list # Should detect CoPilot mode # Simulate VS Code via TERM_PROGRAM export TERM_PROGRAM=vscode export VSCODE_COPILOT_ENABLED=true -specfact hello # Should detect CoPilot mode +specfact module list # Should detect CoPilot mode ``` ### 4. Test Priority Order @@ -126,11 +126,11 @@ specfact hello # Should detect CoPilot mode ```bash # Test that explicit flag overrides environment export SPECFACT_MODE=copilot -specfact --mode cicd hello # Should use CI/CD mode (flag wins) +specfact --mode cicd module list # Should use CI/CD mode (flag wins) # Test that explicit flag overrides auto-detection export COPILOT_API_URL=https://api.copilot.com -specfact --mode cicd hello # Should use CI/CD mode (flag wins) +specfact --mode cicd module list # Should use CI/CD mode (flag wins) ``` ### 5. Test Default Behavior @@ -143,7 +143,7 @@ unset COPILOT_API_TOKEN unset GITHUB_COPILOT_TOKEN unset VSCODE_PID unset CURSOR_PID -specfact hello # Should default to CI/CD mode +specfact module list # Should default to CI/CD mode ``` ## Python Interactive Testing @@ -229,7 +229,7 @@ print(f'Execution mode: {result.execution_mode}') # In GitHub Actions or CI/CD # No environment variables set # Should auto-detect CI/CD mode (bundle name as positional argument) -hatch run specfact code import my-project --repo . --confidence 0.7 +hatch run specfact code import --repo . my-project --confidence 0.7 # Expected: Mode: CI/CD (direct execution) ``` @@ -240,7 +240,7 @@ hatch run specfact code import my-project --repo . --confidence 0.7 # Developer running in VS Code/Cursor with CoPilot enabled # IDE environment variables automatically set # Should auto-detect CoPilot mode (bundle name as positional argument) -hatch run specfact code import my-project --repo . --confidence 0.7 +hatch run specfact code import --repo . my-project --confidence 0.7 # Expected: Mode: CoPilot (agent routing) ``` @@ -266,31 +266,31 @@ echo "=== Testing Mode Detection ===" echo echo "1. Testing explicit CI/CD mode:" -specfact --mode cicd hello +specfact --mode cicd module list echo echo "2. Testing explicit CoPilot mode:" -specfact --mode copilot hello +specfact --mode copilot module list echo echo "3. Testing invalid mode (should fail):" -specfact --mode invalid hello 2>&1 || echo "✓ Failed as expected" +specfact --mode invalid module list 2>&1 || echo "✓ Failed as expected" echo echo "4. Testing SPECFACT_MODE environment variable:" export SPECFACT_MODE=copilot -specfact hello +specfact module list unset SPECFACT_MODE echo echo "5. Testing CoPilot API detection:" export COPILOT_API_URL=https://api.copilot.com -specfact hello +specfact module list unset COPILOT_API_URL echo echo "6. Testing default (no overrides):" -specfact hello +specfact module list echo echo "=== All Tests Complete ===" diff --git a/docs/examples/dogfooding-specfact-cli.md b/docs/examples/dogfooding-specfact-cli.md index 686ce414..aa04847d 100644 --- a/docs/examples/dogfooding-specfact-cli.md +++ b/docs/examples/dogfooding-specfact-cli.md @@ -31,7 +31,7 @@ We built SpecFact CLI and wanted to validate that it actually works in the real First, we analyzed the existing codebase to see what features it discovered: ```bash -specfact code import specfact-cli --repo . --confidence 0.5 +specfact code import --repo . specfact-cli --confidence 0.5 ``` **Output**: @@ -538,7 +538,7 @@ These are **actual questions** that need answers, not false positives! ```bash # 1. Analyze existing codebase (3 seconds) -specfact code import specfact-cli --repo . --confidence 0.5 +specfact code import --repo . specfact-cli --confidence 0.5 # ✅ Discovers 19 features, 49 stories # 2. Set quality gates (1 second) diff --git a/docs/examples/integration-showcases/integration-showcases-quick-reference.md b/docs/examples/integration-showcases/integration-showcases-quick-reference.md index 103ad013..9b8aeaaf 100644 --- a/docs/examples/integration-showcases/integration-showcases-quick-reference.md +++ b/docs/examples/integration-showcases/integration-showcases-quick-reference.md @@ -80,7 +80,7 @@ cd /tmp/specfact-integration-tests/example1_vscode specfact --no-banner code import from-code payment-processing --repo . --output-format yaml # Step 2: Run enforcement -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced # Expected: Contract violation about blocking I/O ``` @@ -98,11 +98,11 @@ cd /tmp/specfact-integration-tests/example2_cursor specfact --no-banner code import from-code data-pipeline --repo . --output-format yaml # Step 2: Test original (should pass) -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced # Step 3: Create broken version (remove None check) # Edit src/pipeline.py to remove None check, then: -specfact --no-banner plan compare src/pipeline.py src/pipeline_broken.py --fail-on HIGH +specfact --no-banner code drift detect auto-derived --repo . # Expected: Contract violation for missing None check ``` @@ -120,7 +120,7 @@ cd /tmp/specfact-integration-tests/example3_github_actions specfact --no-banner code import from-code user-api --repo . --output-format yaml # Step 2: Run enforcement -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced # Expected: Type mismatch violation (int vs dict) ``` @@ -160,7 +160,7 @@ cd /tmp/specfact-integration-tests/example5_agentic hatch run contract-test-exploration src/validator.py # Option 2: Contract enforcement (fallback) -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced # Expected: Division by zero edge case detected ``` @@ -221,7 +221,7 @@ for dir in example1_vscode example2_cursor example3_github_actions example4_prec cd /tmp/specfact-integration-tests/$dir bundle_name=$(echo "$dir" | sed 's/example[0-9]_//') specfact --no-banner code import from-code "$bundle_name" --repo . --output-format yaml 2>&1 - specfact --no-banner enforce stage --preset balanced 2>&1 + specfact --no-banner govern enforce stage --preset balanced 2>&1 echo "---" done ``` diff --git a/docs/examples/integration-showcases/integration-showcases-testing-guide.md b/docs/examples/integration-showcases/integration-showcases-testing-guide.md index 6c1a3deb..5bf17336 100644 --- a/docs/examples/integration-showcases/integration-showcases-testing-guide.md +++ b/docs/examples/integration-showcases/integration-showcases-testing-guide.md @@ -233,7 +233,7 @@ uvx specfact-cli@latest --no-banner code import from-code --repo . --output-form - **First-time setup**: Omit `--no-banner` to see the banner (verification, `specfact init`, `specfact --version`) - **Repeated runs**: Use `--no-banner` **before** the command to suppress banner output - **Important**: `--no-banner` is a global parameter and must come **before** the subcommand, not after - - ✅ Correct: `specfact --no-banner enforce stage --preset balanced` + - ✅ Correct: `specfact --no-banner govern enforce stage --preset balanced` - ✅ Correct: `uvx specfact-cli@latest --no-banner code import from-code --repo . --output-format yaml` - ❌ Wrong: `specfact govern enforce stage --preset balanced --no-banner` - ❌ Wrong: `uvx specfact-cli@latest code import from-code --repo . --output-format yaml --no-banner` @@ -309,7 +309,7 @@ Run plan review to identify missing stories, contracts, and other gaps: cd /tmp/specfact-integration-tests/example1_vscode # Run plan review with auto-enrichment to identify gaps (bundle name as positional argument) -specfact --no-banner plan review django-example \ +specfact --no-banner project health-check --bundle django-example \ --auto-enrich \ --no-interactive \ --list-findings \ @@ -328,7 +328,7 @@ If stories are missing, add them using `plan add-story`: ```bash # Add the async payment processing story (bundle name via --bundle option) -specfact --no-banner plan add-story \ +specfact --no-banner backlog add --type story \ --bundle django-example \ --feature FEATURE-PAYMENTVIEW \ --key STORY-PAYMENT-ASYNC \ @@ -338,7 +338,7 @@ specfact --no-banner plan add-story \ --value-points 10 # Add other stories as needed (Payment Status API, Cancel Payment, Create Payment) -specfact --no-banner plan add-story \ +specfact --no-banner backlog add --type story \ --bundle django-example \ --feature FEATURE-PAYMENTVIEW \ --key STORY-PAYMENT-STATUS \ @@ -356,7 +356,7 @@ After adding stories, verify the plan bundle is complete: ```bash # Re-run plan review to verify all critical items are resolved -specfact --no-banner plan review django-example \ +specfact --no-banner project health-check --bundle django-example \ --no-interactive \ --list-findings \ --findings-format json @@ -373,7 +373,7 @@ specfact --no-banner plan review django-example \ #### Step 3.4: Set Up Enforcement Configuration ```bash -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` **What to Look For**: @@ -405,7 +405,7 @@ mkdir -p src tests tools/semgrep **Run Validation**: ```bash -specfact --no-banner repro --repo . --budget 60 +specfact --no-banner code repro --repo . --budget 60 ``` **What to Look For**: @@ -415,7 +415,7 @@ specfact --no-banner repro --repo . --budget 60 - ✅ Detects blocking calls in async context (if violations exist) - ✅ Reports violations with severity levels - ⚠️ If Semgrep is not installed or config doesn't exist, this check will be skipped -- 💡 Use `--verbose` flag to see detailed Semgrep output: `specfact --no-banner repro --repo . --budget 60 --verbose` +- 💡 Use `--verbose` flag to see detailed Semgrep output: `specfact --no-banner code repro --repo . --budget 60 --verbose` **Expected Output Format** (summary table): @@ -456,14 +456,14 @@ Async patterns (semgrep) Error: - Detailed Semgrep output (scan status, findings) is only shown with `--verbose` flag - If Semgrep is not installed or config doesn't exist, the check will be skipped - The enforcement workflow still works via `plan compare`, which validates acceptance criteria in the plan bundle -- Use `--fix` flag to apply Semgrep auto-fixes: `specfact --no-banner repro --repo . --budget 60 --fix` +- Use `--fix` flag to apply Semgrep auto-fixes: `specfact --no-banner code repro --repo . --budget 60 --fix` #### Alternative: Use Plan Compare for Contract Validation You can also use `plan compare` to detect deviations between code and plan contracts: ```bash -specfact --no-banner plan compare --code-vs-plan +specfact --no-banner code drift detect auto-derived --repo . ``` This compares the current code state against the plan bundle contracts and reports any violations. @@ -475,7 +475,7 @@ Now let's test that enforcement actually works by comparing plans and detecting ```bash # Test plan comparison with enforcement (bundle directory paths) cd /tmp/specfact-integration-tests/example1_vscode -specfact --no-banner plan compare \ +specfact --no-banner code drift detect auto-derived \ --manual .specfact/projects/django-example \ --auto .specfact/projects/django-example-auto ``` @@ -712,7 +712,7 @@ Updates Applied: cd /tmp/specfact-integration-tests/example2_cursor # Review plan with auto-enrichment (bundle name as positional argument) -specfact --no-banner plan review data-processing-or-legacy-data-pipeline \ +specfact --no-banner project health-check --bundle data-processing-or-legacy-data-pipeline \ --auto-enrich \ --no-interactive \ --list-findings \ @@ -735,7 +735,7 @@ After plan review is complete and all critical issues are resolved, configure en ```bash cd /tmp/specfact-integration-tests/example2_cursor -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` **Expected Output**: @@ -770,7 +770,7 @@ Test that plan comparison works correctly by comparing the enriched plan against ```bash cd /tmp/specfact-integration-tests/example2_cursor -specfact --no-banner plan compare \ +specfact --no-banner code drift detect auto-derived \ --manual .specfact/projects/data-processing-or-legacy-data-pipeline \ --auto .specfact/projects/data-processing-or-legacy-data-pipeline-auto ``` @@ -880,7 +880,7 @@ mv src/pipeline_broken.py src/pipeline.py specfact --no-banner code import from-code pipeline-broken --repo . --output-format yaml # 4. Compare new plan (from broken code) against enriched plan -specfact --no-banner plan compare \ +specfact --no-banner code drift detect auto-derived \ --manual .specfact/projects/data-processing-or-legacy-data-pipeline \ --auto .specfact/projects/pipeline-broken @@ -986,7 +986,7 @@ specfact --no-banner code import from-code --repo . --output-format yaml ```bash cd /tmp/specfact-integration-tests/example3_github_actions -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` **What to Look For**: @@ -997,7 +997,7 @@ specfact --no-banner enforce stage --preset balanced ### Example 3 - Step 5: Run Validation Checks ```bash -specfact --no-banner repro --repo . --budget 90 +specfact --no-banner code repro --repo . --budget 90 ``` **Expected Output Format**: @@ -1156,10 +1156,10 @@ BUNDLE_NAME="example4_github_actions" PLAN_NAME=$(basename "$PLAN_FILE") # Set it as the active plan (this makes it the default for plan compare) -specfact --no-banner plan select "$BUNDLE_NAME" --no-interactive +specfact --no-banner project version check --bundle "$BUNDLE_NAME" --repo . # Verify it's set as active -specfact --no-banner plan select --current +specfact --no-banner project version check --repo . ``` **Note**: `plan compare --code-vs-plan` uses the active plan (set via `plan select`) or falls back to the default bundle if no active plan is set. Using `plan select` is the recommended approach as it's cleaner and doesn't require file copying. @@ -1207,7 +1207,7 @@ Before setting up the pre-commit hook, configure enforcement: ```bash cd /tmp/specfact-integration-tests/example4_precommit -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` **What to Look For**: @@ -1229,7 +1229,7 @@ Create `.git/hooks/pre-commit`: specfact --no-banner code import from-code --repo . --output-format yaml > /dev/null 2>&1 # Then compare: uses active plan (set via plan select) as manual, latest code-derived plan as auto -specfact --no-banner plan compare --code-vs-plan +specfact --no-banner code drift detect auto-derived --repo . ``` **What This Does**: @@ -1409,7 +1409,7 @@ uvx specfact-cli@latest --no-banner contract-test-exploration src/validator.py If CrossHair is not available, test with contract enforcement: ```bash -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` ### Example 5 - Step 4: Provide Output diff --git a/docs/examples/integration-showcases/integration-showcases.md b/docs/examples/integration-showcases/integration-showcases.md index cd90b06e..a6ffec00 100644 --- a/docs/examples/integration-showcases/integration-showcases.md +++ b/docs/examples/integration-showcases/integration-showcases.md @@ -57,7 +57,7 @@ def process_payment(request): ```bash # .git/hooks/pre-commit #!/bin/sh -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` **What This Does**: Runs SpecFact validation automatically before every commit. If it finds issues, the commit is blocked. @@ -218,7 +218,7 @@ jobs: - name: Install SpecFact CLI run: pip install specfact-cli - name: Configure Enforcement - run: specfact --no-banner enforce stage --preset balanced + run: specfact --no-banner govern enforce stage --preset balanced - name: Run SpecFact Validation run: specfact --no-banner repro --repo . --budget 90 ``` @@ -330,7 +330,7 @@ result = process_order(order_id="123") # ⚠️ Missing user_id **Setup** (one-time): -1. Configure enforcement: `specfact --no-banner enforce stage --preset balanced` +1. Configure enforcement: `specfact --no-banner govern enforce stage --preset balanced` 2. Add pre-commit hook: ```bash @@ -341,7 +341,7 @@ result = process_order(order_id="123") # ⚠️ Missing user_id specfact --no-banner code import from-code auto-derived --repo . --output-format yaml > /dev/null 2>&1 # Compare: uses active plan (set via plan select) as manual, latest auto-derived plan as auto -specfact --no-banner plan compare --code-vs-plan +specfact --no-banner code drift detect auto-derived --repo . ``` **What This Does**: Before you commit, SpecFact imports your current code to create a new plan, then compares it against the baseline plan. If it detects breaking changes with HIGH severity, the commit is blocked (based on enforcement configuration). @@ -448,7 +448,7 @@ def validate_and_calculate(data: dict) -> float: ```bash # .git/hooks/pre-commit #!/bin/sh -specfact --no-banner enforce stage --preset balanced +specfact --no-banner govern enforce stage --preset balanced ``` **Benefits**: @@ -472,7 +472,7 @@ specfact --no-banner enforce stage --preset balanced - name: Install SpecFact CLI run: pip install specfact-cli - name: Configure Enforcement - run: specfact --no-banner enforce stage --preset balanced + run: specfact --no-banner govern enforce stage --preset balanced - name: Run SpecFact Validation run: specfact --no-banner repro --repo . --budget 90 ``` @@ -494,7 +494,7 @@ specfact --no-banner enforce stage --preset balanced { "label": "SpecFact Validate", "type": "shell", - "command": "specfact --no-banner enforce stage --preset balanced" + "command": "specfact --no-banner govern enforce stage --preset balanced" } ``` diff --git a/docs/examples/integration-showcases/setup-integration-tests.sh b/docs/examples/integration-showcases/setup-integration-tests.sh index 02d5d570..793727fe 100755 --- a/docs/examples/integration-showcases/setup-integration-tests.sh +++ b/docs/examples/integration-showcases/setup-integration-tests.sh @@ -276,7 +276,7 @@ cat > .git/hooks/pre-commit << 'EOF' specfact --no-banner plan compare --code-vs-plan EOF chmod +x .git/hooks/pre-commit -echo "⚠️ Pre-commit hook created. Remember to run 'specfact enforce stage --preset balanced' before testing." +echo "⚠️ Pre-commit hook created. Remember to run 'specfact govern enforce stage --preset balanced' before testing." echo "✅ Example 4 setup complete (src/legacy.py, src/caller.py, pre-commit hook created)" cd .. @@ -360,4 +360,3 @@ echo " - Testing Guide: docs/examples/integration-showcases/integration-showca echo " - Quick Reference: docs/examples/integration-showcases/integration-showcases-quick-reference.md" echo " - Showcases: docs/examples/integration-showcases/integration-showcases.md" echo "" - diff --git a/docs/examples/quick-examples.md b/docs/examples/quick-examples.md index c3ec6c09..60809371 100644 --- a/docs/examples/quick-examples.md +++ b/docs/examples/quick-examples.md @@ -35,7 +35,7 @@ pip install specfact-cli specfact project snapshot --bundle my-project # Have existing code? -specfact code import my-project --repo . +specfact code import --repo . my-project # Using GitHub Spec-Kit? specfact code import from-bridge --adapter speckit --repo ./my-project --dry-run @@ -57,30 +57,30 @@ specfact code import from-bridge --adapter speckit --repo ./spec-kit-project --w ```bash # Basic import (bundle name as positional argument) -specfact code import my-project --repo . +specfact code import --repo . my-project # With confidence threshold -specfact code import my-project --repo . --confidence 0.7 +specfact code import --repo . my-project --confidence 0.7 # Shadow mode (observe only) -specfact code import my-project --repo . --shadow-only +specfact code import --repo . my-project --shadow-only # CoPilot mode (enhanced prompts) specfact --mode copilot code import from-code my-project --repo . --confidence 0.7 # Re-validate existing features (force re-analysis) -specfact code import my-project --repo . --revalidate-features +specfact code import --repo . my-project --revalidate-features # Resume interrupted import (features saved early as checkpoint) # If import is cancelled, just run the same command again -specfact code import my-project --repo . +specfact code import --repo . my-project # Partial analysis (analyze specific subdirectory only) -specfact code import my-project --repo . --entry-point src/core +specfact code import --repo . my-project --entry-point src/core # Large codebase with progress reporting # Progress bars show: feature analysis, source linking, contract extraction -specfact code import large-project --repo . --confidence 0.5 +specfact code import --repo . large-project --confidence 0.5 ``` @@ -197,7 +197,7 @@ specfact init ide --ide cursor --force ```bash # Auto-detect mode (default) -specfact code import my-project --repo . +specfact code import --repo . my-project # Force CI/CD mode specfact --mode cicd code import from-code my-project --repo . @@ -207,7 +207,7 @@ specfact --mode copilot code import from-code my-project --repo . # Set via environment variable export SPECFACT_MODE=copilot -specfact code import my-project --repo . +specfact code import --repo . my-project ``` ## Common Workflows @@ -232,7 +232,7 @@ specfact project regenerate ```bash # Step 1: Extract specs from legacy code -specfact code import my-project --repo . +specfact code import --repo . my-project # Step 2: Validate SDD and coverage thresholds specfact govern enforce sdd my-project @@ -273,7 +273,7 @@ specfact govern enforce stage --preset minimal ```bash # Step 1: Analyze code -specfact code import my-project --repo . --confidence 0.7 +specfact code import --repo . my-project --confidence 0.7 # Step 2: Review plan health specfact project health-check @@ -291,7 +291,7 @@ specfact project sync repository --repo . --watch --interval 5 ```bash # Bundle name is a positional argument (not --name option) -specfact code import my-project --repo . +specfact code import --repo . my-project ``` @@ -311,10 +311,10 @@ specfact project regenerate \ ```bash # Classname format (default for auto-derived) -specfact code import my-project --repo . --key-format classname +specfact code import --repo . my-project --key-format classname # Sequential format (for manual plans) -specfact code import my-project --repo . --key-format sequential +specfact code import --repo . my-project --key-format sequential ``` @@ -322,10 +322,10 @@ specfact code import my-project --repo . --key-format sequential ```bash # Lower threshold (more features, lower confidence) -specfact code import my-project --repo . --confidence 0.3 +specfact code import --repo . my-project --confidence 0.3 # Higher threshold (fewer features, higher confidence) -specfact code import my-project --repo . --confidence 0.8 +specfact code import --repo . my-project --confidence 0.8 ``` ## Integration Examples diff --git a/docs/getting-started/README.md b/docs/getting-started/README.md index 1b272e1c..ff84eacd 100644 --- a/docs/getting-started/README.md +++ b/docs/getting-started/README.md @@ -25,7 +25,7 @@ pip install specfact-cli specfact init --profile solo-developer # Analyze your codebase -specfact code import my-project --repo . +specfact code import --repo . my-project ``` See the **[5-Minute Quickstart](quickstart.md)** for a complete walkthrough. diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index 9c59d6ca..2aefe98c 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -69,11 +69,13 @@ Then set up IDE integration: specfact init ide specfact init ide --ide cursor specfact init ide --ide vscode +specfact init ide --ide codex +specfact init ide --ide claude-skills specfact init ide --install-deps specfact init ide --ide cursor --install-deps ``` -**Important**: SpecFact CLI does **not** ship with built-in AI. `specfact init ide` installs prompt templates for supported IDEs so your chosen AI copilot can call SpecFact commands in a guided workflow. +**Important**: SpecFact CLI does **not** ship with built-in AI. `specfact init ide` installs slash-command prompt templates or grouped `SKILL.md` files for supported IDEs so your chosen AI copilot can call SpecFact commands in a guided workflow. For VS Code / Copilot, the CLI **merges** prompt recommendations into `.vscode/settings.json` and keeps your other settings keys. If that file is **not parseable or mergeable by the CLI** (including a `chat` block the merge helper diff --git a/docs/getting-started/quickstart.md b/docs/getting-started/quickstart.md index d5e8dbe4..c4e70eca 100644 --- a/docs/getting-started/quickstart.md +++ b/docs/getting-started/quickstart.md @@ -74,7 +74,7 @@ This creates `.specfact/` directory structure and IDE-specific prompt templates. ## Step 5: Analyze Your Codebase and Check Health ```bash -specfact code import my-project --repo . +specfact code import --repo . my-project specfact project health-check ``` diff --git a/docs/getting-started/tutorial-openspec-speckit.md b/docs/getting-started/tutorial-openspec-speckit.md index a8713bb5..1c0ad572 100644 --- a/docs/getting-started/tutorial-openspec-speckit.md +++ b/docs/getting-started/tutorial-openspec-speckit.md @@ -128,7 +128,7 @@ openspec init ```bash # Analyze legacy codebase cd /path/to/your-openspec-project -specfact code import legacy-api --repo . +specfact code import --repo . legacy-api # Expected output: # 🔍 Analyzing codebase... @@ -150,7 +150,7 @@ specfact code import legacy-api --repo . ```bash cd /path/to/specfact-cli -hatch run specfact code import legacy-api --repo /path/to/your-openspec-project +hatch run specfact code import --repo /path/to/your-openspec-project legacy-api ``` ### Step 4: Create an OpenSpec Change Proposal diff --git a/docs/getting-started/where-to-start.md b/docs/getting-started/where-to-start.md index 27356a12..be0103b1 100644 --- a/docs/getting-started/where-to-start.md +++ b/docs/getting-started/where-to-start.md @@ -73,7 +73,7 @@ This is the most common entry point. 1. Install the CLI 2. Run `specfact init --profile solo-developer` -3. Run `specfact code import my-project --repo .` +3. Run `specfact code import --repo . my-project` 4. Check `specfact code repro --repo .` Start here: @@ -133,7 +133,7 @@ specfact module search specfact module list # Analyze the current repository -specfact code import my-project --repo . +specfact code import --repo . my-project # Check resulting state specfact code repro --repo . diff --git a/docs/guides/ai-ide-workflow.md b/docs/guides/ai-ide-workflow.md index 08919717..e5234e44 100644 --- a/docs/guides/ai-ide-workflow.md +++ b/docs/guides/ai-ide-workflow.md @@ -46,6 +46,8 @@ specfact init specfact init ide --ide cursor specfact init ide --ide vscode specfact init ide --ide copilot +specfact init ide --ide codex +specfact init ide --ide claude-skills # Install required packages for contract enhancement specfact init ide --ide cursor --install-deps @@ -56,7 +58,7 @@ specfact init ide --ide cursor --install-deps 1. Detects your IDE (or uses `--ide` flag) 2. Copies prompt templates from installed bundle modules (or an optional dev checkout under `resources/prompts/`) to the IDE-specific location 3. Creates/updates IDE settings if needed -4. Makes slash commands available in your IDE +4. Makes slash commands or grouped AI skills available in your IDE 5. Optionally installs required packages (`beartype`, `icontract`, `crosshair-tool`, `pytest`) **Related**: [IDE Integration Guide](ide-integration.md) - Complete setup instructions @@ -113,7 +115,7 @@ graph TD ```bash # Import from codebase -specfact code import my-project --repo . +specfact code import --repo . my-project # Run validation to find gaps specfact code repro --verbose @@ -196,7 +198,7 @@ The AI IDE workflow integrates with several command chains: ```bash # 1. Analyze codebase -specfact code import legacy-api --repo . +specfact code import --repo . legacy-api # 2. Find gaps specfact code repro --verbose diff --git a/docs/guides/command-chains.md b/docs/guides/command-chains.md index e3c80cd4..1b49dbcb 100644 --- a/docs/guides/command-chains.md +++ b/docs/guides/command-chains.md @@ -83,7 +83,7 @@ Start: What do you want to accomplish? ```bash # Step 1: Extract specifications from legacy code -specfact code import legacy-api --repo . +specfact code import --repo . legacy-api # Step 2: Review the extracted plan specfact project snapshot legacy-api @@ -209,7 +209,7 @@ graph TD ```bash # For Code/Spec Adapters (Spec-Kit, OpenSpec, generic-markdown): # Step 1: Import from external tool via bridge adapter -specfact code import from-bridge --repo . --adapter speckit --write +specfact code import --repo . from-bridge --adapter speckit --write # Step 2: Review the imported plan specfact project snapshot @@ -443,7 +443,7 @@ graph LR ```bash # Step 1: Import current code state -specfact code import current-state --repo . +specfact code import --repo . current-state # Step 2: Compare code against plan specfact project devops-flow --stage plan --action compare --bundle --code-vs-plan diff --git a/docs/guides/copilot-mode.md b/docs/guides/copilot-mode.md index ffc3c769..d14b0dea 100644 --- a/docs/guides/copilot-mode.md +++ b/docs/guides/copilot-mode.md @@ -33,7 +33,7 @@ Mode is auto-detected based on environment, or you can explicitly set it with `- specfact --mode copilot code import from-code legacy-api --repo . --confidence 0.7 # Mode is auto-detected based on environment (IDE integration, CoPilot API availability) -specfact code import legacy-api --repo . --confidence 0.7 # Auto-detects CoPilot if available +specfact code import --repo . legacy-api --confidence 0.7 # Auto-detects CoPilot if available ``` ### What You Get with CoPilot Mode @@ -120,10 +120,10 @@ specfact --mode copilot code import from-code --repo . --confidence 0.7 ```bash # CI/CD mode (minimal prompts) -specfact --mode cicd plan init --no-interactive +specfact --mode cicd project init-personas --no-interactive # CoPilot mode (enhanced interactive prompts) -specfact --mode copilot plan init --interactive +specfact --mode copilot project init-personas --no-interactive # Output: # Mode: CoPilot (agent routing) @@ -135,9 +135,7 @@ specfact --mode copilot plan init --interactive ```bash # CoPilot mode with enhanced deviation analysis (bundle directory paths) -specfact --mode copilot plan compare \ - --manual .specfact/projects/main \ - --auto .specfact/projects/my-project-auto +specfact --mode copilot code drift detect my-project --repo . # Output: # Mode: CoPilot (agent routing) diff --git a/docs/guides/ide-integration.md b/docs/guides/ide-integration.md index 87514a95..52bd430c 100644 --- a/docs/guides/ide-integration.md +++ b/docs/guides/ide-integration.md @@ -19,7 +19,7 @@ permalink: /guides/ide-integration/ ## Overview -SpecFact CLI supports IDE integration through **prompt templates** that work with various AI-assisted IDEs. These templates are copied to IDE-specific locations and automatically registered by the IDE as slash commands. The AI stays in your IDE; SpecFact remains the deterministic CLI the prompts call. +SpecFact CLI supports IDE integration through **prompt templates and grouped AI skills** that work with various AI-assisted IDEs. These files are copied to IDE-specific locations and automatically registered by the IDE as slash commands or skills. The AI stays in your IDE; SpecFact remains the deterministic CLI the prompts call. **See real examples**: [Integration Showcases](../examples/integration-showcases/) - 5 complete examples showing bugs fixed via IDE integrations @@ -28,6 +28,9 @@ SpecFact CLI supports IDE integration through **prompt templates** that work wit - ✅ **Cursor** - `.cursor/commands/` - ✅ **VS Code / GitHub Copilot** - `.github/prompts/` + `.vscode/settings.json` - ✅ **Claude Code** - `.claude/commands/` +- ✅ **Claude Code Skills** - `.claude/skills//SKILL.md` +- ✅ **Codex CLI** - `.codex/skills//SKILL.md` +- ✅ **Mistral Vibe** - `.vibe/skills//SKILL.md` - ✅ **Gemini CLI** - `.gemini/commands/` - ✅ **Qwen Code** - `.qwen/commands/` - ✅ **opencode** - `.opencode/command/` @@ -55,6 +58,7 @@ specfact init specfact init ide --ide cursor specfact init ide --ide vscode specfact init ide --ide copilot +specfact init ide --ide codex # Install required packages for contract enhancement specfact init --install-deps @@ -68,7 +72,7 @@ specfact init ide --ide cursor --install-deps 1. Detects your IDE (or uses `--ide` flag) 2. Discovers prompt templates from installed workflow modules first, then copies them to the IDE-specific location 3. Creates/updates VS Code settings if needed -4. Makes slash commands available in your IDE +4. Makes slash commands or grouped AI skills available in your IDE 5. 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 @@ -105,11 +109,11 @@ The IDE automatically recognizes these commands and exposes the corresponding pr ### Prompt Templates -Slash commands are **markdown prompt templates** (not executable CLI commands). They: +Slash-command targets use **markdown prompt templates** (not executable CLI commands). Skill-based targets use grouped `SKILL.md` files that collect several related workflows for one source/module. They: 1. **Are owned by installed workflow modules** - Bundle-specific prompts ship with their corresponding module packages 2. **Get copied to IDE locations** - `specfact init ide` discovers installed module resources and copies them to IDE-specific directories -3. **Registered automatically** - The IDE reads these files and makes them available as slash commands +3. **Registered automatically** - The IDE reads these files and makes them available as slash commands or skills 4. **Fall back safely during transition** - If no installed module prompt payloads are present yet, SpecFact can still use packaged core fallback resources 5. **Provide enhanced prompts** - Templates include detailed instructions for the AI assistant diff --git a/docs/guides/openspec-journey.md b/docs/guides/openspec-journey.md index cd24a79c..f8fff102 100644 --- a/docs/guides/openspec-journey.md +++ b/docs/guides/openspec-journey.md @@ -312,7 +312,7 @@ Here's how to use both tools together for legacy code modernization: ```bash # Step 1: Analyze legacy code with SpecFact -specfact code import legacy-api --repo ./legacy-app +specfact code import --repo ./legacy-app legacy-api # → Extracts features from existing code # → Creates SpecFact bundle: .specfact/projects/legacy-api/ diff --git a/docs/guides/speckit-journey.md b/docs/guides/speckit-journey.md index b6dc562b..c8c0c5a4 100644 --- a/docs/guides/speckit-journey.md +++ b/docs/guides/speckit-journey.md @@ -79,7 +79,7 @@ When modernizing legacy code, you can use **both tools together** for maximum va ```bash # Step 1: Use SpecFact to extract specs from legacy code -specfact code import customer-portal --repo ./legacy-app +specfact code import --repo ./legacy-app customer-portal # Output: Auto-generated project bundle from existing code # ✅ Analyzed 47 Python files diff --git a/docs/guides/troubleshooting.md b/docs/guides/troubleshooting.md index 3998b421..9342cc11 100644 --- a/docs/guides/troubleshooting.md +++ b/docs/guides/troubleshooting.md @@ -117,13 +117,13 @@ specfact project health-check 1. **Check repository path**: ```bash - specfact code import legacy-api --repo . --verbose + specfact code import --repo . legacy-api --verbose ``` 2. **Lower confidence threshold** (for legacy code with less structure): ```bash - specfact code import legacy-api --repo . --confidence 0.3 + specfact code import --repo . legacy-api --confidence 0.3 ``` 3. **Check file structure**: @@ -141,7 +141,7 @@ specfact project health-check 5. **For legacy codebases**, start with minimal confidence and review extracted features: ```bash - specfact code import legacy-api --repo . --confidence 0.2 + specfact code import --repo . legacy-api --confidence 0.2 ``` --- @@ -256,7 +256,7 @@ specfact project health-check 2. **Adjust confidence threshold**: ```bash - specfact code import legacy-api --repo . --confidence 0.7 + specfact code import --repo . legacy-api --confidence 0.7 ``` 3. **Check enforcement rules** (use CLI commands): @@ -352,7 +352,7 @@ specfact project health-check 3. **Generate auto-derived plan first**: ```bash - specfact code import legacy-api --repo . + specfact code import --repo . legacy-api ``` ### No Deviations Found (Expected Some) @@ -459,7 +459,7 @@ specfact project health-check ```bash export SPECFACT_MODE=copilot - specfact code import legacy-api --repo . + specfact code import --repo . legacy-api ``` 4. **See [Operational Modes](../core-cli/modes.md)** for details @@ -483,14 +483,14 @@ specfact project health-check 2. **Increase confidence threshold** (fewer features): ```bash - specfact code import legacy-api --repo . --confidence 0.8 + specfact code import --repo . legacy-api --confidence 0.8 ``` 3. **Exclude directories**: ```bash # Use .gitignore or exclude patterns - specfact code import legacy-api --repo . --exclude "tests/" + specfact code import --repo . legacy-api --exclude "tests/" ``` ### Watch Mode High CPU diff --git a/docs/migration/migration-cli-reorganization.md b/docs/migration/migration-cli-reorganization.md index 8b71a6c8..c34bf129 100644 --- a/docs/migration/migration-cli-reorganization.md +++ b/docs/migration/migration-cli-reorganization.md @@ -130,14 +130,14 @@ The new numbered commands follow natural workflow progression: **Before** (positional argument): ```bash -# Old: project plan init (removed) → use: specfact code import legacy-api --repo . +# Old: project plan init (removed) → use: specfact code import --repo . legacy-api # Old: project plan review (removed) → use: specfact project devops-flow --stage develop --bundle legacy-api ``` **After** (named parameter): ```bash -specfact code import legacy-api --repo . +specfact code import --repo . legacy-api specfact project devops-flow --stage develop --bundle legacy-api ``` @@ -205,7 +205,7 @@ Example: 'specfact constitution bootstrap' → 'specfact sdd constitution bootst ### Brownfield Import Workflow ```bash -specfact code import legacy-api --repo . +specfact code import --repo . legacy-api specfact sdd constitution bootstrap --repo . specfact project sync bridge --adapter speckit ``` diff --git a/docs/reference/commands.generated.json b/docs/reference/commands.generated.json new file mode 100644 index 00000000..f4c295fc --- /dev/null +++ b/docs/reference/commands.generated.json @@ -0,0 +1,2202 @@ +[ + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--banner", + "--debug", + "--input-format", + "--install-completion", + "--interactive", + "--mode", + "--no-interactive", + "--output-format", + "--show-completion", + "--skip-checks", + "--version" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.cli:app", + "subcommands": [ + "backlog", + "code", + "govern", + "init", + "module", + "project", + "spec", + "upgrade" + ] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact backlog", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [ + "add", + "analyze-deps", + "auth", + "ceremony", + "daily", + "delta", + "diff", + "init-config", + "map-fields", + "promote", + "refine", + "sync", + "verify-readiness" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog add", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--acceptance-criteria", + "--adapter", + "--body", + "--body-end-marker", + "--business-value", + "--check-dor", + "--custom-config", + "--description-format", + "--non-interactive", + "--parent", + "--priority", + "--project-id", + "--provider-field", + "--repo-path", + "--sprint", + "--story-points", + "--template", + "--title", + "--type" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog analyze-deps", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--custom-config", + "--json-export", + "--output", + "--project-id", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog auth", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [ + "azure-devops", + "clear", + "github", + "status" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog auth azure-devops", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--pat", + "--use-device-code" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog auth clear", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--provider" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog auth github", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--base-url", + "--client-id", + "--scopes" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog auth status", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact backlog ceremony", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [ + "flow", + "pi-summary", + "planning", + "refinement", + "standup" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog ceremony flow", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--mode" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog ceremony pi-summary", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--mode" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog ceremony planning", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--mode" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog ceremony refinement", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog ceremony standup", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--mode" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog daily", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--ado-org", + "--ado-project", + "--ado-team", + "--ado-token", + "--annotations", + "--assignee", + "--blockers", + "--blockers-first", + "--comments", + "--copilot-export", + "--first-comments", + "--first-issues", + "--github-token", + "--id", + "--interactive", + "--iteration", + "--labels", + "--last-comments", + "--last-issues", + "--limit", + "--mode", + "--no-show-unassigned", + "--patch", + "--post", + "--release", + "--repo-name", + "--repo-owner", + "--search", + "--show-unassigned", + "--sprint", + "--state", + "--suggest-next", + "--summarize", + "--summarize-to", + "--tags", + "--today", + "--unassigned-only", + "--yesterday" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact backlog delta", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [ + "cost-estimate", + "impact", + "rollback-analysis", + "status" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog delta cost-estimate", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--baseline-file", + "--project-id", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog delta impact", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--project-id", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog delta rollback-analysis", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--baseline-file", + "--project-id", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog delta status", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--baseline-file", + "--project-id", + "--repo-name", + "--repo-owner", + "--since", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog diff", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--baseline-file", + "--project-id", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog init-config", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--force" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog map-fields", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--ado-base-url", + "--ado-framework", + "--ado-org", + "--ado-project", + "--ado-token", + "--github-project-id", + "--github-project-v2-id", + "--github-type-field-id", + "--github-type-option", + "--non-interactive", + "--provider", + "--reset" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog promote", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--item-id", + "--project-id", + "--template", + "--to-status" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog refine", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--ado-org", + "--ado-project", + "--ado-team", + "--ado-token", + "--assignee", + "--auto-accept-high-confidence", + "--auto-bundle", + "--bundle", + "--check-dor", + "--custom-field-mapping", + "--export-to-tmp", + "--first-comments", + "--first-issues", + "--framework", + "--github-token", + "--id", + "--ignore-refined", + "--import-from-tmp", + "--iteration", + "--labels", + "--last-comments", + "--last-issues", + "--limit", + "--no-ignore-refined", + "--no-preview", + "--openspec-comment", + "--persona", + "--preview", + "--release", + "--repo-name", + "--repo-owner", + "--search", + "--sprint", + "--state", + "--tags", + "--template", + "--tmp-file", + "--write" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog sync", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--baseline-file", + "--force-baseline-overwrite", + "--output-format", + "--project-id", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact backlog verify-readiness", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-backlog", + "options": [ + "--adapter", + "--project-id", + "--target-items", + "--template" + ], + "owner_package": "nold-ai/specfact-backlog", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_backlog.backlog.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "analyze", + "drift", + "import", + "repro", + "validate" + ] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code analyze", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "contracts" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code analyze contracts", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--bundle", + "--repo" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code drift", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "detect" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code drift detect", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--format", + "--out", + "--repo" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code import", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--confidence", + "--enrich-for-speckit", + "--enrichment", + "--entry-point", + "--exclude-tests", + "--force", + "--include-tests", + "--key-format", + "--no-enrich-for-speckit", + "--no-revalidate-features", + "--repo", + "--report", + "--revalidate-features", + "--shadow-only" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "from-bridge", + "from-code" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code import from-bridge", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--adapter", + "--dry-run", + "--force", + "--out-branch", + "--repo", + "--report", + "--write" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code import from-code", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--confidence", + "--enrich-for-speckit", + "--enrichment", + "--entry-point", + "--exclude-tests", + "--force", + "--include-tests", + "--key-format", + "--no-enrich-for-speckit", + "--no-revalidate-features", + "--repo", + "--report", + "--revalidate-features", + "--shadow-only" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code repro", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--budget", + "--crosshair-per-path-timeout", + "--crosshair-required", + "--fail-fast", + "--fix", + "--out", + "--repo", + "--sidecar", + "--sidecar-bundle", + "--verbose" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "setup" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code repro setup", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--install-crosshair", + "--repo" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code review", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [ + "review" + ] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code review review", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [ + "ledger", + "rules", + "run" + ] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code review review ledger", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [ + "reset", + "status", + "update" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review ledger reset", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [ + "--confirm" + ], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review ledger status", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review ledger update", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [ + "--from" + ], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code review review rules", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [ + "init", + "show", + "update" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review rules init", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [ + "--ide" + ], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review rules show", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review rules update", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [ + "--ide" + ], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code review review run", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-code-review", + "options": [ + "--bug-hunt", + "--exclude-tests", + "--fix", + "--focus", + "--include-noise", + "--include-tests", + "--instructions", + "--interactive", + "--json", + "--level", + "--mode", + "--no-tests", + "--out", + "--path", + "--preview-fixes", + "--scope", + "--score-only", + "--suppress-noise", + "--with-mutation" + ], + "owner_package": "nold-ai/specfact-code-review", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_code_review.review.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code validate", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "sidecar" + ] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact code validate sidecar", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [ + "init", + "run" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code validate sidecar init", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact code validate sidecar run", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-codebase", + "options": [ + "--no-run-crosshair", + "--no-run-specmatic", + "--run-crosshair", + "--run-specmatic" + ], + "owner_package": "nold-ai/specfact-codebase", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_codebase.code.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact govern", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-govern", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-govern", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_govern.govern.commands:app", + "subcommands": [ + "enforce", + "patch" + ] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact govern enforce", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-govern", + "options": [], + "owner_package": "nold-ai/specfact-govern", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_govern.govern.commands:app", + "subcommands": [ + "sdd", + "stage" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact govern enforce sdd", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-govern", + "options": [ + "--no-interactive", + "--out", + "--output-format", + "--sdd" + ], + "owner_package": "nold-ai/specfact-govern", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_govern.govern.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact govern enforce stage", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-govern", + "options": [ + "--preset" + ], + "owner_package": "nold-ai/specfact-govern", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_govern.govern.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact govern patch", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-govern", + "options": [], + "owner_package": "nold-ai/specfact-govern", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_govern.govern.commands:app", + "subcommands": [ + "apply" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact govern patch apply", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-govern", + "options": [ + "--dry-run", + "--write", + "--yes" + ], + "owner_package": "nold-ai/specfact-govern", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_govern.govern.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact init", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--install", + "--install-completion", + "--install-deps", + "--profile", + "--repo", + "--show-completion" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.init.src.commands:app", + "subcommands": [ + "ide" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact init ide", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--env-manager", + "--force", + "--ide", + "--install-deps", + "--prompts", + "--repo" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.init.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact module", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [ + "add-registry", + "alias", + "disable", + "doctor", + "enable", + "init", + "install", + "list", + "list-registries", + "remove-registry", + "search", + "show", + "uninstall", + "upgrade" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module add-registry", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--id", + "--priority", + "--trust" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact module alias", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [ + "create", + "list", + "remove" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module alias create", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--force" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module alias list", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module alias remove", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module disable", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--force" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module doctor", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--repo" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module enable", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--force", + "--trust-non-official" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module init", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--repo", + "--scope", + "--trust-non-official" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module install", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--force", + "--reinstall", + "--repo", + "--scope", + "--skip-deps", + "--source", + "--trust-non-official", + "--version" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module list", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--available", + "--marketplace", + "--show-bundled-available", + "--show-origin", + "--source" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module list-registries", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module remove-registry", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module search", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module show", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module uninstall", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--repo", + "--scope" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact module upgrade", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--all", + "--yes" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.module_registry.src.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact project", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [ + "devops-flow", + "export", + "export-roadmap", + "health-check", + "import", + "init-personas", + "link-backlog", + "lock", + "locks", + "merge", + "regenerate", + "resolve-conflict", + "snapshot", + "sync", + "unlock", + "version" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project devops-flow", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--action", + "--bundle", + "--no-interactive", + "--project-name", + "--repo", + "--stage", + "--verbose" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project export", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--list-personas", + "--no-interactive", + "--out", + "--output", + "--output-dir", + "--persona", + "--repo", + "--stdout", + "--template" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project export-roadmap", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--output", + "--project-name", + "--repo" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project health-check", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--project-name", + "--repo", + "--verbose" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project import", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--dry-run", + "--file", + "--input", + "--no-interactive", + "--persona", + "--repo" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project init-personas", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--persona", + "--repo" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project link-backlog", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--adapter", + "--bundle", + "--no-interactive", + "--project-id", + "--project-name", + "--repo", + "--template" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project lock", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--persona", + "--repo", + "--section" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project locks", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--repo" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project merge", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--base", + "--bundle", + "--no-interactive", + "--ours", + "--out", + "--output", + "--persona-ours", + "--persona-theirs", + "--repo", + "--strategy", + "--theirs" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project regenerate", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--project-name", + "--repo", + "--strict", + "--verbose" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project resolve-conflict", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--path", + "--persona", + "--repo", + "--resolution" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project snapshot", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--output", + "--project-name", + "--repo" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact project sync", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [ + "bridge", + "intelligent", + "repository" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project sync bridge", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--adapter", + "--add-progress-comment", + "--ado-base-url", + "--ado-org", + "--ado-project", + "--ado-token", + "--ado-work-item-type", + "--all", + "--backlog-ids", + "--backlog-ids-file", + "--bidirectional", + "--bundle", + "--change-ids", + "--code-repo", + "--ensure-compliance", + "--export-to-tmp", + "--external-base-path", + "--feature", + "--github-token", + "--import-from-tmp", + "--include-archived", + "--interactive", + "--interval", + "--mode", + "--no-add-progress-comment", + "--no-gh-cli", + "--no-include-archived", + "--no-sanitize", + "--no-track-code-changes", + "--no-update-existing", + "--overwrite", + "--repo", + "--repo-name", + "--repo-owner", + "--sanitize", + "--target-repo", + "--tmp-file", + "--track-code-changes", + "--update-existing", + "--use-gh-cli", + "--watch" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project sync intelligent", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--code-to-spec", + "--repo", + "--spec-to-code", + "--tests", + "--watch" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project sync repository", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--confidence", + "--interval", + "--repo", + "--target", + "--watch" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project unlock", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--no-interactive", + "--repo", + "--section" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact project version", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [ + "bump", + "check", + "set" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project version bump", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--repo", + "--type" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project version check", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--repo" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact project version set", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-project", + "options": [ + "--bundle", + "--repo", + "--version" + ], + "owner_package": "nold-ai/specfact-project", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_project.project.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact spec", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-spec", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-spec", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_spec.spec.commands:app", + "subcommands": [ + "backward-compat", + "generate-tests", + "mock", + "validate" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact spec backward-compat", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-spec", + "options": [], + "owner_package": "nold-ai/specfact-spec", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_spec.spec.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact spec generate-tests", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-spec", + "options": [ + "--bundle", + "--force", + "--out", + "--output" + ], + "owner_package": "nold-ai/specfact-spec", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_spec.spec.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact spec mock", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-spec", + "options": [ + "--bundle", + "--examples", + "--no-interactive", + "--port", + "--spec", + "--strict" + ], + "owner_package": "nold-ai/specfact-spec", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_spec.spec.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact spec validate", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-spec", + "options": [ + "--bundle", + "--force", + "--no-interactive", + "--previous" + ], + "owner_package": "nold-ai/specfact-spec", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_spec.spec.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact upgrade", + "deprecated": false, + "hidden": false, + "install_prerequisite": "Install specfact-cli.", + "options": [ + "--check-only", + "--install-completion", + "--show-completion", + "--yes" + ], + "owner_package": "core", + "owner_repo": "nold-ai/specfact-cli", + "short_help": "", + "source": "specfact_cli.modules.upgrade.src.commands:app", + "subcommands": [] + } +] diff --git a/docs/reference/commands.generated.md b/docs/reference/commands.generated.md new file mode 100644 index 00000000..96c5c348 --- /dev/null +++ b/docs/reference/commands.generated.md @@ -0,0 +1,122 @@ +--- +layout: default +title: Generated SpecFact CLI Command Overview +permalink: /reference/generated-command-overview/ +exempt: true +exempt_reason: Generated command contract artifact. +--- + +# Generated SpecFact CLI Command Overview + +This file is generated from the current CLI command tree. Do not edit by hand. + +| Command | Owner | Options | Subcommands | Context | +| --- | --- | --- | --- | --- | +| `specfact` | core | --banner, --debug, --input-format, --install-completion, --interactive, --mode, --no-interactive, --output-format, --show-completion, --skip-checks, --version; args: - | backlog, code, govern, init, module, project, spec, upgrade | | +| `specfact backlog` | nold-ai/specfact-backlog | --install-completion, --show-completion; args: - | add, analyze-deps, auth, ceremony, daily, delta, diff, init-config, map-fields, promote, refine, sync, verify-readiness | | +| `specfact backlog add` | nold-ai/specfact-backlog | --acceptance-criteria, --adapter, --body, --body-end-marker, --business-value, --check-dor, --custom-config, --description-format, --non-interactive, --parent, --priority, --project-id, --provider-field, --repo-path, --sprint, --story-points, --template, --title, --type; args: - | - | | +| `specfact backlog analyze-deps` | nold-ai/specfact-backlog | --adapter, --custom-config, --json-export, --output, --project-id, --template; args: - | - | | +| `specfact backlog auth` | nold-ai/specfact-backlog | -; args: - | azure-devops, clear, github, status | | +| `specfact backlog auth azure-devops` | nold-ai/specfact-backlog | --pat, --use-device-code; args: - | - | | +| `specfact backlog auth clear` | nold-ai/specfact-backlog | --provider; args: - | - | | +| `specfact backlog auth github` | nold-ai/specfact-backlog | --base-url, --client-id, --scopes; args: - | - | | +| `specfact backlog auth status` | nold-ai/specfact-backlog | -; args: - | - | | +| `specfact backlog ceremony` | nold-ai/specfact-backlog | -; args: - | flow, pi-summary, planning, refinement, standup | | +| `specfact backlog ceremony flow` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog ceremony pi-summary` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog ceremony planning` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog ceremony refinement` | nold-ai/specfact-backlog | -; args: - | - | | +| `specfact backlog ceremony standup` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog daily` | nold-ai/specfact-backlog | --ado-org, --ado-project, --ado-team, --ado-token, --annotations, --assignee, --blockers, --blockers-first, --comments, --copilot-export, --first-comments, --first-issues, --github-token, --id, --interactive, --iteration, --labels, --last-comments, --last-issues, --limit, --mode, --no-show-unassigned, --patch, --post, --release, --repo-name, --repo-owner, --search, --show-unassigned, --sprint, --state, --suggest-next, --summarize, --summarize-to, --tags, --today, --unassigned-only, --yesterday; args: - | - | | +| `specfact backlog delta` | nold-ai/specfact-backlog | -; args: - | cost-estimate, impact, rollback-analysis, status | | +| `specfact backlog delta cost-estimate` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --template; args: - | - | | +| `specfact backlog delta impact` | nold-ai/specfact-backlog | --adapter, --project-id, --template; args: - | - | | +| `specfact backlog delta rollback-analysis` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --template; args: - | - | | +| `specfact backlog delta status` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --repo-name, --repo-owner, --since, --template; args: - | - | | +| `specfact backlog diff` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --template; args: - | - | | +| `specfact backlog init-config` | nold-ai/specfact-backlog | --force; args: - | - | | +| `specfact backlog map-fields` | nold-ai/specfact-backlog | --ado-base-url, --ado-framework, --ado-org, --ado-project, --ado-token, --github-project-id, --github-project-v2-id, --github-type-field-id, --github-type-option, --non-interactive, --provider, --reset; args: - | - | | +| `specfact backlog promote` | nold-ai/specfact-backlog | --adapter, --item-id, --project-id, --template, --to-status; args: - | - | | +| `specfact backlog refine` | nold-ai/specfact-backlog | --ado-org, --ado-project, --ado-team, --ado-token, --assignee, --auto-accept-high-confidence, --auto-bundle, --bundle, --check-dor, --custom-field-mapping, --export-to-tmp, --first-comments, --first-issues, --framework, --github-token, --id, --ignore-refined, --import-from-tmp, --iteration, --labels, --last-comments, --last-issues, --limit, --no-ignore-refined, --no-preview, --openspec-comment, --persona, --preview, --release, --repo-name, --repo-owner, --search, --sprint, --state, --tags, --template, --tmp-file, --write; args: - | - | | +| `specfact backlog sync` | nold-ai/specfact-backlog | --adapter, --baseline-file, --force-baseline-overwrite, --output-format, --project-id, --template; args: - | - | | +| `specfact backlog verify-readiness` | nold-ai/specfact-backlog | --adapter, --project-id, --target-items, --template; args: - | - | | +| `specfact code` | nold-ai/specfact-codebase | --install-completion, --show-completion; args: - | analyze, drift, import, repro, validate | | +| `specfact code analyze` | nold-ai/specfact-codebase | -; args: - | contracts | | +| `specfact code analyze contracts` | nold-ai/specfact-codebase | --bundle, --repo; args: - | - | | +| `specfact code drift` | nold-ai/specfact-codebase | -; args: - | detect | | +| `specfact code drift detect` | nold-ai/specfact-codebase | --format, --out, --repo; args: - | - | | +| `specfact code import` | nold-ai/specfact-codebase | --confidence, --enrich-for-speckit, --enrichment, --entry-point, --exclude-tests, --force, --include-tests, --key-format, --no-enrich-for-speckit, --no-revalidate-features, --repo, --report, --revalidate-features, --shadow-only; args: - | from-bridge, from-code | | +| `specfact code import from-bridge` | nold-ai/specfact-codebase | --adapter, --dry-run, --force, --out-branch, --repo, --report, --write; args: - | - | | +| `specfact code import from-code` | nold-ai/specfact-codebase | --confidence, --enrich-for-speckit, --enrichment, --entry-point, --exclude-tests, --force, --include-tests, --key-format, --no-enrich-for-speckit, --no-revalidate-features, --repo, --report, --revalidate-features, --shadow-only; args: - | - | | +| `specfact code repro` | nold-ai/specfact-codebase | --budget, --crosshair-per-path-timeout, --crosshair-required, --fail-fast, --fix, --out, --repo, --sidecar, --sidecar-bundle, --verbose; args: - | setup | | +| `specfact code repro setup` | nold-ai/specfact-codebase | --install-crosshair, --repo; args: - | - | | +| `specfact code review` | nold-ai/specfact-code-review | --install-completion, --show-completion; args: - | review | | +| `specfact code review review` | nold-ai/specfact-code-review | -; args: - | ledger, rules, run | | +| `specfact code review review ledger` | nold-ai/specfact-code-review | -; args: - | reset, status, update | | +| `specfact code review review ledger reset` | nold-ai/specfact-code-review | --confirm; args: - | - | | +| `specfact code review review ledger status` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review review ledger update` | nold-ai/specfact-code-review | --from; args: - | - | | +| `specfact code review review rules` | nold-ai/specfact-code-review | -; args: - | init, show, update | | +| `specfact code review review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review review rules show` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | +| `specfact code validate` | nold-ai/specfact-codebase | -; args: - | sidecar | | +| `specfact code validate sidecar` | nold-ai/specfact-codebase | -; args: - | init, run | | +| `specfact code validate sidecar init` | nold-ai/specfact-codebase | -; args: - | - | | +| `specfact code validate sidecar run` | nold-ai/specfact-codebase | --no-run-crosshair, --no-run-specmatic, --run-crosshair, --run-specmatic; args: - | - | | +| `specfact govern` | nold-ai/specfact-govern | --install-completion, --show-completion; args: - | enforce, patch | | +| `specfact govern enforce` | nold-ai/specfact-govern | -; args: - | sdd, stage | | +| `specfact govern enforce sdd` | nold-ai/specfact-govern | --no-interactive, --out, --output-format, --sdd; args: - | - | | +| `specfact govern enforce stage` | nold-ai/specfact-govern | --preset; args: - | - | | +| `specfact govern patch` | nold-ai/specfact-govern | -; args: - | apply | | +| `specfact govern patch apply` | nold-ai/specfact-govern | --dry-run, --write, --yes; args: - | - | | +| `specfact init` | core | --install, --install-completion, --install-deps, --profile, --repo, --show-completion; args: - | ide | | +| `specfact init ide` | core | --env-manager, --force, --ide, --install-deps, --prompts, --repo; args: - | - | | +| `specfact module` | core | --install-completion, --show-completion; args: - | add-registry, alias, disable, doctor, enable, init, install, list, list-registries, remove-registry, search, show, uninstall, upgrade | | +| `specfact module add-registry` | core | --id, --priority, --trust; args: - | - | | +| `specfact module alias` | core | -; args: - | create, list, remove | | +| `specfact module alias create` | core | --force; args: - | - | | +| `specfact module alias list` | core | -; args: - | - | | +| `specfact module alias remove` | core | -; args: - | - | | +| `specfact module disable` | core | --force; args: - | - | | +| `specfact module doctor` | core | --repo; args: - | - | | +| `specfact module enable` | core | --force, --trust-non-official; args: - | - | | +| `specfact module init` | core | --repo, --scope, --trust-non-official; args: - | - | | +| `specfact module install` | core | --force, --reinstall, --repo, --scope, --skip-deps, --source, --trust-non-official, --version; args: - | - | | +| `specfact module list` | core | --available, --marketplace, --show-bundled-available, --show-origin, --source; args: - | - | | +| `specfact module list-registries` | core | -; args: - | - | | +| `specfact module remove-registry` | core | -; args: - | - | | +| `specfact module search` | core | -; args: - | - | | +| `specfact module show` | core | -; args: - | - | | +| `specfact module uninstall` | core | --repo, --scope; args: - | - | | +| `specfact module upgrade` | core | --all, --yes; args: - | - | | +| `specfact project` | nold-ai/specfact-project | --install-completion, --show-completion; args: - | devops-flow, export, export-roadmap, health-check, import, init-personas, link-backlog, lock, locks, merge, regenerate, resolve-conflict, snapshot, sync, unlock, version | | +| `specfact project devops-flow` | nold-ai/specfact-project | --action, --bundle, --no-interactive, --project-name, --repo, --stage, --verbose; args: - | - | | +| `specfact project export` | nold-ai/specfact-project | --bundle, --list-personas, --no-interactive, --out, --output, --output-dir, --persona, --repo, --stdout, --template; args: - | - | | +| `specfact project export-roadmap` | nold-ai/specfact-project | --bundle, --no-interactive, --output, --project-name, --repo; args: - | - | | +| `specfact project health-check` | nold-ai/specfact-project | --bundle, --no-interactive, --project-name, --repo, --verbose; args: - | - | | +| `specfact project import` | nold-ai/specfact-project | --bundle, --dry-run, --file, --input, --no-interactive, --persona, --repo; args: - | - | | +| `specfact project init-personas` | nold-ai/specfact-project | --bundle, --no-interactive, --persona, --repo; args: - | - | | +| `specfact project link-backlog` | nold-ai/specfact-project | --adapter, --bundle, --no-interactive, --project-id, --project-name, --repo, --template; args: - | - | | +| `specfact project lock` | nold-ai/specfact-project | --bundle, --no-interactive, --persona, --repo, --section; args: - | - | | +| `specfact project locks` | nold-ai/specfact-project | --bundle, --no-interactive, --repo; args: - | - | | +| `specfact project merge` | nold-ai/specfact-project | --base, --bundle, --no-interactive, --ours, --out, --output, --persona-ours, --persona-theirs, --repo, --strategy, --theirs; args: - | - | | +| `specfact project regenerate` | nold-ai/specfact-project | --bundle, --no-interactive, --project-name, --repo, --strict, --verbose; args: - | - | | +| `specfact project resolve-conflict` | nold-ai/specfact-project | --bundle, --no-interactive, --path, --persona, --repo, --resolution; args: - | - | | +| `specfact project snapshot` | nold-ai/specfact-project | --bundle, --no-interactive, --output, --project-name, --repo; args: - | - | | +| `specfact project sync` | nold-ai/specfact-project | -; args: - | bridge, intelligent, repository | | +| `specfact project sync bridge` | nold-ai/specfact-project | --adapter, --add-progress-comment, --ado-base-url, --ado-org, --ado-project, --ado-token, --ado-work-item-type, --all, --backlog-ids, --backlog-ids-file, --bidirectional, --bundle, --change-ids, --code-repo, --ensure-compliance, --export-to-tmp, --external-base-path, --feature, --github-token, --import-from-tmp, --include-archived, --interactive, --interval, --mode, --no-add-progress-comment, --no-gh-cli, --no-include-archived, --no-sanitize, --no-track-code-changes, --no-update-existing, --overwrite, --repo, --repo-name, --repo-owner, --sanitize, --target-repo, --tmp-file, --track-code-changes, --update-existing, --use-gh-cli, --watch; args: - | - | | +| `specfact project sync intelligent` | nold-ai/specfact-project | --code-to-spec, --repo, --spec-to-code, --tests, --watch; args: - | - | | +| `specfact project sync repository` | nold-ai/specfact-project | --confidence, --interval, --repo, --target, --watch; args: - | - | | +| `specfact project unlock` | nold-ai/specfact-project | --bundle, --no-interactive, --repo, --section; args: - | - | | +| `specfact project version` | nold-ai/specfact-project | -; args: - | bump, check, set | | +| `specfact project version bump` | nold-ai/specfact-project | --bundle, --repo, --type; args: - | - | | +| `specfact project version check` | nold-ai/specfact-project | --bundle, --repo; args: - | - | | +| `specfact project version set` | nold-ai/specfact-project | --bundle, --repo, --version; args: - | - | | +| `specfact spec` | nold-ai/specfact-spec | --install-completion, --show-completion; args: - | backward-compat, generate-tests, mock, validate | | +| `specfact spec backward-compat` | nold-ai/specfact-spec | -; args: - | - | | +| `specfact spec generate-tests` | nold-ai/specfact-spec | --bundle, --force, --out, --output; args: - | - | | +| `specfact spec mock` | nold-ai/specfact-spec | --bundle, --examples, --no-interactive, --port, --spec, --strict; args: - | - | | +| `specfact spec validate` | nold-ai/specfact-spec | --bundle, --force, --no-interactive, --previous; args: - | - | | +| `specfact upgrade` | core | --check-only, --install-completion, --show-completion, --yes; args: - | - | | diff --git a/docs/reference/commands.md b/docs/reference/commands.md index 75cc4462..65f2141e 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -166,7 +166,7 @@ specfact init --profile solo-developer specfact module install nold-ai/specfact-backlog # Code + project flow -specfact code import legacy-api --repo . +specfact code import --repo . legacy-api specfact project snapshot --bundle legacy-api # Backlog flow diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index 01eb976e..3b439630 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -411,7 +411,7 @@ specfact code import --repo . [OPTIONS] ```bash # Analyze legacy codebase -specfact code import legacy-api --repo . --confidence 0.7 +specfact code import --repo . legacy-api --confidence 0.7 # Creates: # - .specfact/projects/legacy-api/bundle.manifest.yaml (versioned) diff --git a/docs/technical/code2spec-analysis-logic.md b/docs/technical/code2spec-analysis-logic.md index 36713bc3..1b480cd1 100644 --- a/docs/technical/code2spec-analysis-logic.md +++ b/docs/technical/code2spec-analysis-logic.md @@ -72,7 +72,7 @@ Uses **Python's AST + Semgrep pattern matching** for comprehensive structural an ```mermaid flowchart TD - A["code2spec Command
specfact code import my-project --repo . --confidence 0.5"] --> B{Operational Mode} + A["code2spec Command
specfact code import --repo . my-project --confidence 0.5"] --> B{Operational Mode} B -->|CoPilot Mode| C["AnalyzeAgent (AI-First)
• LLM semantic understanding
• Multi-language support
• Semantic extraction (priorities, constraints, unknowns)
• High-quality Spec-Kit artifacts"] diff --git a/llms.txt b/llms.txt new file mode 100644 index 00000000..af6509ac --- /dev/null +++ b/llms.txt @@ -0,0 +1,126 @@ +# SpecFact CLI Commands + +Use this generated overview as the current command contract before following older docs or prompts. + +--- +layout: default +title: Generated SpecFact CLI Command Overview +permalink: /reference/generated-command-overview/ +exempt: true +exempt_reason: Generated command contract artifact. +--- + +# Generated SpecFact CLI Command Overview + +This file is generated from the current CLI command tree. Do not edit by hand. + +| Command | Owner | Options | Subcommands | Context | +| --- | --- | --- | --- | --- | +| `specfact` | core | --banner, --debug, --input-format, --install-completion, --interactive, --mode, --no-interactive, --output-format, --show-completion, --skip-checks, --version; args: - | backlog, code, govern, init, module, project, spec, upgrade | | +| `specfact backlog` | nold-ai/specfact-backlog | --install-completion, --show-completion; args: - | add, analyze-deps, auth, ceremony, daily, delta, diff, init-config, map-fields, promote, refine, sync, verify-readiness | | +| `specfact backlog add` | nold-ai/specfact-backlog | --acceptance-criteria, --adapter, --body, --body-end-marker, --business-value, --check-dor, --custom-config, --description-format, --non-interactive, --parent, --priority, --project-id, --provider-field, --repo-path, --sprint, --story-points, --template, --title, --type; args: - | - | | +| `specfact backlog analyze-deps` | nold-ai/specfact-backlog | --adapter, --custom-config, --json-export, --output, --project-id, --template; args: - | - | | +| `specfact backlog auth` | nold-ai/specfact-backlog | -; args: - | azure-devops, clear, github, status | | +| `specfact backlog auth azure-devops` | nold-ai/specfact-backlog | --pat, --use-device-code; args: - | - | | +| `specfact backlog auth clear` | nold-ai/specfact-backlog | --provider; args: - | - | | +| `specfact backlog auth github` | nold-ai/specfact-backlog | --base-url, --client-id, --scopes; args: - | - | | +| `specfact backlog auth status` | nold-ai/specfact-backlog | -; args: - | - | | +| `specfact backlog ceremony` | nold-ai/specfact-backlog | -; args: - | flow, pi-summary, planning, refinement, standup | | +| `specfact backlog ceremony flow` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog ceremony pi-summary` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog ceremony planning` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog ceremony refinement` | nold-ai/specfact-backlog | -; args: - | - | | +| `specfact backlog ceremony standup` | nold-ai/specfact-backlog | --mode; args: - | - | | +| `specfact backlog daily` | nold-ai/specfact-backlog | --ado-org, --ado-project, --ado-team, --ado-token, --annotations, --assignee, --blockers, --blockers-first, --comments, --copilot-export, --first-comments, --first-issues, --github-token, --id, --interactive, --iteration, --labels, --last-comments, --last-issues, --limit, --mode, --no-show-unassigned, --patch, --post, --release, --repo-name, --repo-owner, --search, --show-unassigned, --sprint, --state, --suggest-next, --summarize, --summarize-to, --tags, --today, --unassigned-only, --yesterday; args: - | - | | +| `specfact backlog delta` | nold-ai/specfact-backlog | -; args: - | cost-estimate, impact, rollback-analysis, status | | +| `specfact backlog delta cost-estimate` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --template; args: - | - | | +| `specfact backlog delta impact` | nold-ai/specfact-backlog | --adapter, --project-id, --template; args: - | - | | +| `specfact backlog delta rollback-analysis` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --template; args: - | - | | +| `specfact backlog delta status` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --repo-name, --repo-owner, --since, --template; args: - | - | | +| `specfact backlog diff` | nold-ai/specfact-backlog | --adapter, --baseline-file, --project-id, --template; args: - | - | | +| `specfact backlog init-config` | nold-ai/specfact-backlog | --force; args: - | - | | +| `specfact backlog map-fields` | nold-ai/specfact-backlog | --ado-base-url, --ado-framework, --ado-org, --ado-project, --ado-token, --github-project-id, --github-project-v2-id, --github-type-field-id, --github-type-option, --non-interactive, --provider, --reset; args: - | - | | +| `specfact backlog promote` | nold-ai/specfact-backlog | --adapter, --item-id, --project-id, --template, --to-status; args: - | - | | +| `specfact backlog refine` | nold-ai/specfact-backlog | --ado-org, --ado-project, --ado-team, --ado-token, --assignee, --auto-accept-high-confidence, --auto-bundle, --bundle, --check-dor, --custom-field-mapping, --export-to-tmp, --first-comments, --first-issues, --framework, --github-token, --id, --ignore-refined, --import-from-tmp, --iteration, --labels, --last-comments, --last-issues, --limit, --no-ignore-refined, --no-preview, --openspec-comment, --persona, --preview, --release, --repo-name, --repo-owner, --search, --sprint, --state, --tags, --template, --tmp-file, --write; args: - | - | | +| `specfact backlog sync` | nold-ai/specfact-backlog | --adapter, --baseline-file, --force-baseline-overwrite, --output-format, --project-id, --template; args: - | - | | +| `specfact backlog verify-readiness` | nold-ai/specfact-backlog | --adapter, --project-id, --target-items, --template; args: - | - | | +| `specfact code` | nold-ai/specfact-codebase | --install-completion, --show-completion; args: - | analyze, drift, import, repro, validate | | +| `specfact code analyze` | nold-ai/specfact-codebase | -; args: - | contracts | | +| `specfact code analyze contracts` | nold-ai/specfact-codebase | --bundle, --repo; args: - | - | | +| `specfact code drift` | nold-ai/specfact-codebase | -; args: - | detect | | +| `specfact code drift detect` | nold-ai/specfact-codebase | --format, --out, --repo; args: - | - | | +| `specfact code import` | nold-ai/specfact-codebase | --confidence, --enrich-for-speckit, --enrichment, --entry-point, --exclude-tests, --force, --include-tests, --key-format, --no-enrich-for-speckit, --no-revalidate-features, --repo, --report, --revalidate-features, --shadow-only; args: - | from-bridge, from-code | | +| `specfact code import from-bridge` | nold-ai/specfact-codebase | --adapter, --dry-run, --force, --out-branch, --repo, --report, --write; args: - | - | | +| `specfact code import from-code` | nold-ai/specfact-codebase | --confidence, --enrich-for-speckit, --enrichment, --entry-point, --exclude-tests, --force, --include-tests, --key-format, --no-enrich-for-speckit, --no-revalidate-features, --repo, --report, --revalidate-features, --shadow-only; args: - | - | | +| `specfact code repro` | nold-ai/specfact-codebase | --budget, --crosshair-per-path-timeout, --crosshair-required, --fail-fast, --fix, --out, --repo, --sidecar, --sidecar-bundle, --verbose; args: - | setup | | +| `specfact code repro setup` | nold-ai/specfact-codebase | --install-crosshair, --repo; args: - | - | | +| `specfact code review` | nold-ai/specfact-code-review | --install-completion, --show-completion; args: - | review | | +| `specfact code review review` | nold-ai/specfact-code-review | -; args: - | ledger, rules, run | | +| `specfact code review review ledger` | nold-ai/specfact-code-review | -; args: - | reset, status, update | | +| `specfact code review review ledger reset` | nold-ai/specfact-code-review | --confirm; args: - | - | | +| `specfact code review review ledger status` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review review ledger update` | nold-ai/specfact-code-review | --from; args: - | - | | +| `specfact code review review rules` | nold-ai/specfact-code-review | -; args: - | init, show, update | | +| `specfact code review review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review review rules show` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | +| `specfact code validate` | nold-ai/specfact-codebase | -; args: - | sidecar | | +| `specfact code validate sidecar` | nold-ai/specfact-codebase | -; args: - | init, run | | +| `specfact code validate sidecar init` | nold-ai/specfact-codebase | -; args: - | - | | +| `specfact code validate sidecar run` | nold-ai/specfact-codebase | --no-run-crosshair, --no-run-specmatic, --run-crosshair, --run-specmatic; args: - | - | | +| `specfact govern` | nold-ai/specfact-govern | --install-completion, --show-completion; args: - | enforce, patch | | +| `specfact govern enforce` | nold-ai/specfact-govern | -; args: - | sdd, stage | | +| `specfact govern enforce sdd` | nold-ai/specfact-govern | --no-interactive, --out, --output-format, --sdd; args: - | - | | +| `specfact govern enforce stage` | nold-ai/specfact-govern | --preset; args: - | - | | +| `specfact govern patch` | nold-ai/specfact-govern | -; args: - | apply | | +| `specfact govern patch apply` | nold-ai/specfact-govern | --dry-run, --write, --yes; args: - | - | | +| `specfact init` | core | --install, --install-completion, --install-deps, --profile, --repo, --show-completion; args: - | ide | | +| `specfact init ide` | core | --env-manager, --force, --ide, --install-deps, --prompts, --repo; args: - | - | | +| `specfact module` | core | --install-completion, --show-completion; args: - | add-registry, alias, disable, doctor, enable, init, install, list, list-registries, remove-registry, search, show, uninstall, upgrade | | +| `specfact module add-registry` | core | --id, --priority, --trust; args: - | - | | +| `specfact module alias` | core | -; args: - | create, list, remove | | +| `specfact module alias create` | core | --force; args: - | - | | +| `specfact module alias list` | core | -; args: - | - | | +| `specfact module alias remove` | core | -; args: - | - | | +| `specfact module disable` | core | --force; args: - | - | | +| `specfact module doctor` | core | --repo; args: - | - | | +| `specfact module enable` | core | --force, --trust-non-official; args: - | - | | +| `specfact module init` | core | --repo, --scope, --trust-non-official; args: - | - | | +| `specfact module install` | core | --force, --reinstall, --repo, --scope, --skip-deps, --source, --trust-non-official, --version; args: - | - | | +| `specfact module list` | core | --available, --marketplace, --show-bundled-available, --show-origin, --source; args: - | - | | +| `specfact module list-registries` | core | -; args: - | - | | +| `specfact module remove-registry` | core | -; args: - | - | | +| `specfact module search` | core | -; args: - | - | | +| `specfact module show` | core | -; args: - | - | | +| `specfact module uninstall` | core | --repo, --scope; args: - | - | | +| `specfact module upgrade` | core | --all, --yes; args: - | - | | +| `specfact project` | nold-ai/specfact-project | --install-completion, --show-completion; args: - | devops-flow, export, export-roadmap, health-check, import, init-personas, link-backlog, lock, locks, merge, regenerate, resolve-conflict, snapshot, sync, unlock, version | | +| `specfact project devops-flow` | nold-ai/specfact-project | --action, --bundle, --no-interactive, --project-name, --repo, --stage, --verbose; args: - | - | | +| `specfact project export` | nold-ai/specfact-project | --bundle, --list-personas, --no-interactive, --out, --output, --output-dir, --persona, --repo, --stdout, --template; args: - | - | | +| `specfact project export-roadmap` | nold-ai/specfact-project | --bundle, --no-interactive, --output, --project-name, --repo; args: - | - | | +| `specfact project health-check` | nold-ai/specfact-project | --bundle, --no-interactive, --project-name, --repo, --verbose; args: - | - | | +| `specfact project import` | nold-ai/specfact-project | --bundle, --dry-run, --file, --input, --no-interactive, --persona, --repo; args: - | - | | +| `specfact project init-personas` | nold-ai/specfact-project | --bundle, --no-interactive, --persona, --repo; args: - | - | | +| `specfact project link-backlog` | nold-ai/specfact-project | --adapter, --bundle, --no-interactive, --project-id, --project-name, --repo, --template; args: - | - | | +| `specfact project lock` | nold-ai/specfact-project | --bundle, --no-interactive, --persona, --repo, --section; args: - | - | | +| `specfact project locks` | nold-ai/specfact-project | --bundle, --no-interactive, --repo; args: - | - | | +| `specfact project merge` | nold-ai/specfact-project | --base, --bundle, --no-interactive, --ours, --out, --output, --persona-ours, --persona-theirs, --repo, --strategy, --theirs; args: - | - | | +| `specfact project regenerate` | nold-ai/specfact-project | --bundle, --no-interactive, --project-name, --repo, --strict, --verbose; args: - | - | | +| `specfact project resolve-conflict` | nold-ai/specfact-project | --bundle, --no-interactive, --path, --persona, --repo, --resolution; args: - | - | | +| `specfact project snapshot` | nold-ai/specfact-project | --bundle, --no-interactive, --output, --project-name, --repo; args: - | - | | +| `specfact project sync` | nold-ai/specfact-project | -; args: - | bridge, intelligent, repository | | +| `specfact project sync bridge` | nold-ai/specfact-project | --adapter, --add-progress-comment, --ado-base-url, --ado-org, --ado-project, --ado-token, --ado-work-item-type, --all, --backlog-ids, --backlog-ids-file, --bidirectional, --bundle, --change-ids, --code-repo, --ensure-compliance, --export-to-tmp, --external-base-path, --feature, --github-token, --import-from-tmp, --include-archived, --interactive, --interval, --mode, --no-add-progress-comment, --no-gh-cli, --no-include-archived, --no-sanitize, --no-track-code-changes, --no-update-existing, --overwrite, --repo, --repo-name, --repo-owner, --sanitize, --target-repo, --tmp-file, --track-code-changes, --update-existing, --use-gh-cli, --watch; args: - | - | | +| `specfact project sync intelligent` | nold-ai/specfact-project | --code-to-spec, --repo, --spec-to-code, --tests, --watch; args: - | - | | +| `specfact project sync repository` | nold-ai/specfact-project | --confidence, --interval, --repo, --target, --watch; args: - | - | | +| `specfact project unlock` | nold-ai/specfact-project | --bundle, --no-interactive, --repo, --section; args: - | - | | +| `specfact project version` | nold-ai/specfact-project | -; args: - | bump, check, set | | +| `specfact project version bump` | nold-ai/specfact-project | --bundle, --repo, --type; args: - | - | | +| `specfact project version check` | nold-ai/specfact-project | --bundle, --repo; args: - | - | | +| `specfact project version set` | nold-ai/specfact-project | --bundle, --repo, --version; args: - | - | | +| `specfact spec` | nold-ai/specfact-spec | --install-completion, --show-completion; args: - | backward-compat, generate-tests, mock, validate | | +| `specfact spec backward-compat` | nold-ai/specfact-spec | -; args: - | - | | +| `specfact spec generate-tests` | nold-ai/specfact-spec | --bundle, --force, --out, --output; args: - | - | | +| `specfact spec mock` | nold-ai/specfact-spec | --bundle, --examples, --no-interactive, --port, --spec, --strict; args: - | - | | +| `specfact spec validate` | nold-ai/specfact-spec | --bundle, --force, --no-interactive, --previous; args: - | - | | +| `specfact upgrade` | core | --check-only, --install-completion, --show-completion, --yes; args: - | - | | diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 9c1ec55e..2283406b 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -78,6 +78,7 @@ User-facing CLI behavior assertions and acceptance-test surface. | Order | Change | Issue | Blocked by | |---|---|---|---| | 0 | `runtime-01-discovery-reliability` | [#552](https://github.com/nold-ai/specfact-cli/issues/552), [#553](https://github.com/nold-ai/specfact-cli/issues/553), [#554](https://github.com/nold-ai/specfact-cli/issues/554) | — | +| 0.5 | `tester-cli-reliability` | [#594](https://github.com/nold-ai/specfact-cli/issues/594); source bugs [#585](https://github.com/nold-ai/specfact-cli/issues/585), [#587](https://github.com/nold-ai/specfact-cli/issues/587), [#588](https://github.com/nold-ai/specfact-cli/issues/588), [#589](https://github.com/nold-ai/specfact-cli/issues/589), [#590](https://github.com/nold-ai/specfact-cli/issues/590) | paired modules `tester-module-cli-reliability` | | 1 | `cli-val-03-misuse-safety-proof` | [#281](https://github.com/nold-ai/specfact-cli/issues/281) | — | | 2 | `cli-val-04-acceptance-test-runner` | [#282](https://github.com/nold-ai/specfact-cli/issues/282) | cli-val-03 | diff --git a/openspec/changes/tester-cli-reliability/.openspec.yaml b/openspec/changes/tester-cli-reliability/.openspec.yaml new file mode 100644 index 00000000..927e3e8e --- /dev/null +++ b/openspec/changes/tester-cli-reliability/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-05-31 diff --git a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md new file mode 100644 index 00000000..0069959e --- /dev/null +++ b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md @@ -0,0 +1,115 @@ +# TDD Evidence: tester-cli-reliability + +## Readiness + +- Worktree: `/home/dom/git/nold-ai/specfact-cli-worktrees/feature/tester-command-reliability` +- Branch: `feature/tester-command-reliability` +- Core tracking story: nold-ai/specfact-cli#594 +- Paired modules story: nold-ai/specfact-cli-modules#306 + +## Source Ownership + +- `#585` stays core: root unknown-command guidance. +- `#586` is module-owned: `specfact project regenerate` runtime hardening. +- `#587` splits: core module discovery/missing-module guidance, modules canonical `project sync bridge` docs/help. +- `#588` splits: core docs/guidance validation, modules `code import` command contract. +- `#589` stays core: upgrade effective-runner detection. +- `#590` splits: core shared tool/env probing, modules codebase/code-review adoption. +- `#591` is module-owned under the shared CLI error contract. +- `#592` is module-owned backlog delta status contract. +- `#593` stays core: pipx upgrade must validate and repair stale/broken `specfact` launchers after reported success. + +## Failing Before + +- `hatch run pytest tests/unit/cli/test_error_guidance.py tests/unit/commands/test_update.py::TestInstallationMethodDetection::test_detect_uv_run_before_stale_pipx_inventory tests/unit/utils/test_env_manager.py::TestCheckToolInEnv::test_check_tool_probes_active_uv_environment tests/unit/docs/test_docs_validation_scripts.py::test_code_import_options_after_bundle_are_rejected tests/unit/docs/test_docs_validation_scripts.py::test_core_cli_modes_page_is_not_excluded_from_command_validation -q` -> 3 failed, 2 passed before production edits. + - `test_check_tool_probes_active_uv_environment` failed because `env_manager` did not actively invoke tools through the detected environment manager. + - `test_code_import_options_after_bundle_are_rejected` failed because invalid legacy option ordering was still accepted by docs validation. + - `test_core_cli_modes_page_is_not_excluded_from_command_validation` failed because `docs/core-cli/modes.md` was excluded from validation. +- `hatch run pytest tests/unit/cli/test_error_guidance.py -q` after adding global error-contract tests -> 2 failed, 2 passed before the shared renderer patch. + - Missing subcommand and nested unknown-command output only showed compact usage, not the command group's full contextual help. + +## OpenSpec Validation + +- `openspec validate tester-cli-reliability --strict` -> passed. + +## Passing After + +- `hatch run generate-command-overview` -> passed. +- `hatch run check-command-overview` -> passed. +- `hatch run check-command-contract` -> passed: `check-command-contract: OK (108 generated command path(s) validated)`. +- `hatch run check-docs-commands` -> passed: `check-docs-commands: OK (104 unique command prefix(es) checked)`. +- `hatch run pytest tests/unit/cli/test_error_guidance.py tests/unit/commands/test_update.py::TestInstallationMethodDetection::test_detect_uv_run_before_stale_pipx_inventory tests/unit/utils/test_env_manager.py::TestCheckToolInEnv::test_check_tool_probes_active_uv_environment tests/unit/docs/test_docs_validation_scripts.py::test_code_import_options_after_bundle_are_rejected tests/unit/docs/test_docs_validation_scripts.py::test_core_cli_modes_page_is_not_excluded_from_command_validation -q` -> 8 passed, 2 warnings. +- `hatch run pytest tests/unit/commands/test_update.py::test_successful_pipx_upgrade_repairs_stale_launcher tests/unit/commands/test_update.py::test_pipx_upgrade_fails_when_launcher_repair_fails -q` -> 2 failed before the pipx launcher validation patch because no launcher validation or reinstall repair path existed. +- `hatch run pytest tests/unit/commands/test_update.py::TestInstallationMethodDetection::test_detect_uv_run_before_stale_pipx_inventory tests/unit/commands/test_update.py::test_successful_pipx_upgrade_repairs_stale_launcher tests/unit/commands/test_update.py::test_pipx_upgrade_fails_when_launcher_repair_fails -q` -> 3 passed, 2 warnings after adding post-upgrade `specfact --version` validation and `pipx reinstall specfact-cli` repair. +- `hatch run pytest tests/unit/registry/test_module_installer.py::test_install_module_handles_macos_application_support_install_root tests/unit/specfact_cli/registry/test_profile_presets.py::test_install_bundles_for_init_preserves_application_support_root tests/unit/commands/test_update.py::test_install_update_pip_with_application_support_executable_uses_shlex -q` -> 3 passed, 2 warnings. These regressions construct `Library/Application Support` paths under `tmp_path` and verify install roots plus quoted update interpreters stay single path values. +- `hatch run pytest tests/unit/utils/test_ide_setup.py::TestCopyTemplatesToIDE::test_copy_templates_to_codex_creates_grouped_skill tests/unit/utils/test_ide_setup.py::test_expected_ide_prompt_export_paths_groups_skill_targets_by_source tests/unit/modules/init/test_init_ide_prompt_selection.py::test_copy_prompts_by_source_to_codex_exports_grouped_skills tests/unit/modules/init/test_init_ide_prompt_selection.py::test_copy_prompts_by_source_to_codex_prunes_stale_per_prompt_skill_exports tests/unit/docs/test_docs_validation_scripts.py::test_collect_specfact_commands_from_guidance_text_handles_inline_and_yaml tests/unit/docs/test_docs_validation_scripts.py::test_scan_guidance_templates_validates_resource_templates -q` -> initially found one parser gap for YAML/Jinja scalar command values, then passed after adding structured-value extraction. +- `hatch run pytest tests/unit/docs/test_docs_validation_scripts.py::test_scan_guidance_templates_validates_resource_templates -q` -> passed after fixing structured-value extraction. +- Manual and automated misuse matrix against `hatch run specfact ...` covered root typos, nested group typos, missing subcommands, missing required arguments, invalid options, dangling option values, lazy delegate groups, and module-owned direct apps: + - `specfact modul` + - `specfact module` + - `specfact module instal` + - `specfact module install` + - `specfact module install --scope` + - `specfact module show` + - `specfact module show --bad-option` + - `specfact module alias` + - `specfact module alias creat` + - `specfact module alias create` + - `specfact init ide --repo` + - `specfact upgrade --bad-option` + - `specfact code` + - `specfact code impor` + - `specfact code import --repo` + - `specfact backlog auth` + - `specfact backlog delta status` + - `specfact project sync brdge` + - `specfact project sync bridge --repo` +- The matrix found one additional lazy delegate gap: `specfact module install --scope` rendered wrapper usage instead of `specfact module install` help. +- `hatch run specfact module install --scope` after the fix -> renders `Usage: specfact module install [OPTIONS] MODULE_IDS...` and `Error: Option '--scope' requires an argument.` +- `hatch run pytest tests/unit/cli/test_error_guidance.py tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_missing_option_value_shows_leaf_help tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_forwards_bare_subcommand_without_options tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_bare_group_shows_full_help_and_missing_subcommand -q` -> 26 passed, 2 warnings. +- `hatch run runtime-discovery-smoke --launcher direct` -> passed. The smoke installed marketplace modules, verified `module list`, checked `upgrade --help`, `module upgrade --help`, `module upgrade --all --yes`, ran `init ide` with auto-detected and explicit `uv`, and asserted `code review run`, `code import from-code/from-bridge`, `project export`, and `project import` help surfaces. +- Release hygiene: + - Core package version bumped across all four canonical artifacts to `0.47.0`: `pyproject.toml`, `setup.py`, `src/__init__.py`, and `src/specfact_cli/__init__.py`. + - `CHANGELOG.md` gained the `0.47.0` entry for generated command artifacts, runtime package-manager smoke gates, CLI misuse guidance, and legacy command-reference cleanup. + - Built-in module manifests bumped for changed signed payloads: `init` `0.1.34`, `upgrade` `0.1.20`. + - `hatch run python scripts/sign-modules.py --allow-unsigned --payload-from-filesystem src/specfact_cli/modules/init/module-package.yaml src/specfact_cli/modules/upgrade/module-package.yaml` -> passed, refreshing payload checksums. No signing key variables were configured in this shell, so this was checksum-only local signing. + - `hatch run check-version-sources` -> passed. + - `hatch run verify-modules-signature-pr --version-check-base origin/dev` -> passed. + - `hatch run python scripts/verify-modules-signature.py --payload-from-filesystem --enforce-version-bump --version-check-base origin/dev` -> passed. + - `hatch run verify-modules-signature --version-check-base origin/dev` -> failed as expected for strict release signing because `init` and `upgrade` now have checksum-only integrity and no private signing key variables or `.specfact/sign-keys/module-signing-private.pem` key were available locally. Approval-time or release signing must add `integrity.signature` before a `main`-equivalent gate. + - `hatch run generate-command-overview` -> passed after the version bump. + - `hatch run check-command-overview` -> passed after regeneration. +- The generated command overview is now source-derived and expanded across paired modules when available: + - `docs/reference/commands.generated.json` + - `docs/reference/commands.generated.md` + - `llms.txt` +- `scripts/pre-commit-quality-checks.sh` regenerates and stages the generated command artifacts before checking overview freshness, command behavior, and docs command references. +- PR validation now checks command overview freshness, source-backed command behavior, docs command references, and real-world runtime discovery across the configured package-manager launchers. +- `openspec validate tester-cli-reliability --strict` -> passed after the misuse-matrix expansion. +- CI duplicate full-suite hardening: + - Core PR orchestrator now has one full-suite owner: `python tools/smart_test_coverage.py run --level full`. + - The contract-first PR job now runs only scoped checks: `hatch run contract-test-contracts` and `hatch run contract-test-exploration-fast`. + - Core pre-commit fallback now runs `hatch run contract-test-contracts` instead of the broad `hatch run contract-test` auto runner. + - PR template now points the full-suite checklist at `hatch run smart-test-full`, not `hatch run contract-test-full`. + - `hatch run pytest tests/unit/workflows/test_trustworthy_green_checks.py::test_pr_orchestrator_contract_first_job_uses_hatch_contract_test tests/unit/workflows/test_trustworthy_green_checks.py::test_pr_orchestrator_has_single_full_suite_owner tests/unit/specfact_cli/registry/test_signing_artifacts.py::test_pr_orchestrator_pins_virtualenv_below_21_for_hatch_jobs tests/unit/migration/test_module_migration_07_cleanup.py::test_no_flat_topology_command_expectations -q` -> 4 passed. + - The runtime smoke integration test now resolves the matching modules worktree before the stale sibling checkout when running from a core worktree. + - `hatch run pytest tests/integration/scripts/test_runtime_discovery_smoke.py::test_runtime_discovery_smoke_direct_launcher -q` -> 1 passed after the matching-worktree resolver fix. +- Package-manager smoke harness hardening: + - `hatch run runtime-discovery-smoke --launcher direct --launcher hatch-source --launcher pip-editable --launcher pipx --launcher uv-run --launcher uvx` initially failed in `hatch-source` because isolated `HOME` hid a user-installed Hatch package from the Hatch launcher. + - `scripts/runtime_discovery_smoke.py` now bootstraps a user-site Hatch launcher with its original Python package path, then resets `PYTHONPATH` before the child `specfact` process starts. + - `hatch run runtime-discovery-smoke --launcher pipx --launcher uv-run --launcher uvx` -> passed after forcing pipx to use the active Python 3.11+ interpreter. + - `hatch run runtime-discovery-smoke --launcher uv-run` -> passed after changing the uv launcher to `uv run --no-project --with ` with an isolated uv cache, so uv is exercised without creating or updating a project `uv.lock`. +- Quality gates after CI duplicate hardening: + - `hatch run format` -> passed. + - `hatch run type-check` -> passed with existing warnings and 0 errors. + - `hatch run lint` -> passed. + - `hatch run yaml-lint` -> passed. + - `openspec validate tester-cli-reliability --strict` -> passed. +- SpecFact code review bug-hunt: + - Initial `specfact code review run --scope changed --bug-hunt --include-tests --json --out .specfact/code-review.changed.json` found 3 blocking issues in this slice: runtime smoke launcher complexity, a 12-parameter test fixture helper, and a `Path | None` append in `scripts/check-command-contract.py`. + - Fixed the blocking findings by splitting runtime smoke assertions into smaller helpers, replacing the wide fixture signature with typed overrides, and narrowing the paired modules repo path before appending it. + - Rerun with paired modules source wired through `SPECFACT_MODULES_ROOTS`/`PYTHONPATH` -> `Review completed with 856 findings (0 blocking)`. The command still returned nonzero because advisory findings remain, but no blocking bug-hunt findings remain in core. + +## Deferred / Not Covered In This Slice + +- Full `smart-test` was attempted and failed on existing full-suite issues unrelated to the duplicate-run workflow patch: runtime smoke used stale sibling modules before resolver fix, migration fixture false positive, local project module discovery pollution, `ProjectBundle` timeout, and a stale virtualenv pin assertion. Focused regressions for the touched workflow/runtime areas now pass. diff --git a/openspec/changes/tester-cli-reliability/proposal.md b/openspec/changes/tester-cli-reliability/proposal.md new file mode 100644 index 00000000..b7b1eb12 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/proposal.md @@ -0,0 +1,47 @@ +## Why + +Primary tester reports `#585` through `#593` show that first real-use adoption is blocked by stale command guidance, ambiguous CLI errors, and package-manager runtime mismatches. The failures cut across core runtime behavior, generated documentation, package-manager detection, stale launcher repair, and module command discovery. + +Core owns the shared CLI contract, command inventory, documentation validation, upgrade/tool environment detection, and PR-blocking runtime simulation. Module-owned command implementations are tracked in paired modules issue `nold-ai/specfact-cli-modules#306`. + +## What Changes + +- Add a shared CLI error contract: unknown commands, missing subcommands, and missing required parameters show relevant help plus explicit actionable missing/invalid information. +- Generate deterministic command overview artifacts for core commands and installed official module command groups, including `llms.txt`, Markdown, and JSON forms. +- Validate docs, prompts, templates, and code guidance against the generated command contract instead of accepting prefix-only command matches. +- Streamline `init ide` so slash-command targets receive prompt files while skill-based targets such as Codex CLI, Claude Code Skills, and Mistral Vibe receive grouped capability-oriented `SKILL.md` files per source/module. +- Prefer the effective active runner/environment for upgrade and tool diagnostics so `uv run`, hatch, pip, and pipx do not misclassify each other. +- Validate and repair stale pipx console launchers after successful pipx upgrades so a reported successful upgrade cannot leave `specfact` broken. +- Extend PR validation with a package-manager runtime matrix covering uv, pip, pipx, and hatch execution paths. + +## Capabilities + +### New Capabilities + +- `cli-error-guidance` +- `generated-command-overview` +- `runtime-tool-probing` + +### Modified Capabilities + +- `core-cli-reference` +- `command-package-runtime-validation` +- `ci-integration` + +## Impact + +- Affected code: root CLI command resolution/error rendering, docs command validation, command audit/inventory generation, IDE prompt/skill export, upgrade install-method detection and pipx launcher validation, environment/tool probing helpers, CI workflow wiring. +- Affected docs: `README.md`, `llms.txt`, generated command references, core CLI pages, IDE setup docs, prompt/template guidance that mentions command paths. +- Affected tests: CLI error-contract tests, generated command artifact tests, IDE prompt/skill export tests, docs/prompt/template command validation tests, upgrade/env probing tests, runtime package-manager smoke tests. + +## Source Tracking + + +- **Parent Features**: [#375](https://github.com/nold-ai/specfact-cli/issues/375), [#356](https://github.com/nold-ai/specfact-cli/issues/356), [#353](https://github.com/nold-ai/specfact-cli/issues/353), [#355](https://github.com/nold-ai/specfact-cli/issues/355), [#404](https://github.com/nold-ai/specfact-cli/issues/404) +- **Change User Story**: [#594](https://github.com/nold-ai/specfact-cli/issues/594) +- **Source Bugs**: [#585](https://github.com/nold-ai/specfact-cli/issues/585), [#587](https://github.com/nold-ai/specfact-cli/issues/587), [#588](https://github.com/nold-ai/specfact-cli/issues/588), [#589](https://github.com/nold-ai/specfact-cli/issues/589), [#590](https://github.com/nold-ai/specfact-cli/issues/590), [#593](https://github.com/nold-ai/specfact-cli/issues/593) +- **Module-owned Bugs**: [#586](https://github.com/nold-ai/specfact-cli/issues/586), [#591](https://github.com/nold-ai/specfact-cli/issues/591), [#592](https://github.com/nold-ai/specfact-cli/issues/592) tracked by [nold-ai/specfact-cli-modules#306](https://github.com/nold-ai/specfact-cli-modules/issues/306) +- **Paired Modules Change**: `tester-module-cli-reliability` +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: GitHub story created; project/parent fields may need project-board field sync if CLI auth lacks project scope. +- **Sanitized**: false diff --git a/openspec/changes/tester-cli-reliability/specs/ci-integration/spec.md b/openspec/changes/tester-cli-reliability/specs/ci-integration/spec.md new file mode 100644 index 00000000..b143f507 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/specs/ci-integration/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: Package Manager Runtime Checks Block Pull Requests + +CI SHALL execute command contract smoke tests through the supported package-manager launch paths before pull requests can merge. + +#### Scenario: Pull request package-manager matrix + +- **GIVEN** a pull request changes CLI runtime, command docs, command validators, packaging, module discovery, or CI smoke scripts +- **WHEN** PR validation runs +- **THEN** CI executes a runtime matrix that covers hatch, pip wheel install, pipx install, uv run, and uv tool or uvx execution +- **AND** each matrix leg validates root help, unknown command guidance, module command discovery, generated command overview checks, and representative official module help paths. + +#### Scenario: Matrix failures identify package-manager context + +- **GIVEN** one matrix leg fails +- **WHEN** CI reports the failure +- **THEN** the failure output identifies the package-manager launcher, command path, exit code, and relevant stdout/stderr excerpt +- **AND** the PR is blocked until the mismatch is fixed or an explicit documented exception is accepted. diff --git a/openspec/changes/tester-cli-reliability/specs/cli-error-guidance/spec.md b/openspec/changes/tester-cli-reliability/specs/cli-error-guidance/spec.md new file mode 100644 index 00000000..16964ee3 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/specs/cli-error-guidance/spec.md @@ -0,0 +1,39 @@ +## ADDED Requirements + +### Requirement: CLI Errors Show Relevant Help And Missing Information + +The CLI SHALL render actionable help for unknown commands, missing subcommands, and missing required parameters across core and loaded module command groups. + +#### Scenario: Unknown root command suggests recovery + +- **GIVEN** a user invokes an unknown root command such as `specfact hello` +- **WHEN** command resolution fails +- **THEN** the output includes the root help context +- **AND** it states that `hello` is not a valid command +- **AND** it suggests nearby valid command groups or the command used to list commands +- **AND** the command exits with a usage-error status. + +#### Scenario: Missing subcommand on command group + +- **GIVEN** a command group has no default action +- **WHEN** the user invokes the group without a subcommand +- **THEN** the output includes that group's help +- **AND** it states that a subcommand is required +- **AND** it lists or points to the available subcommands +- **AND** the command exits with a usage-error status. + +#### Scenario: Missing required parameter on leaf command + +- **GIVEN** a leaf command requires one or more arguments or options +- **WHEN** the user invokes the command without those required parameters +- **THEN** the output includes that command's help +- **AND** it names each missing required parameter using its CLI spelling +- **AND** it shows the canonical invocation shape +- **AND** the command exits with a usage-error status. + +#### Scenario: Explicit help remains successful + +- **GIVEN** a user invokes any command or command group with `--help` +- **WHEN** help rendering succeeds +- **THEN** the command exits successfully +- **AND** no missing-parameter or missing-subcommand error is emitted. diff --git a/openspec/changes/tester-cli-reliability/specs/command-package-runtime-validation/spec.md b/openspec/changes/tester-cli-reliability/specs/command-package-runtime-validation/spec.md new file mode 100644 index 00000000..951306a9 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/specs/command-package-runtime-validation/spec.md @@ -0,0 +1,70 @@ +## MODIFIED Requirements + +### Requirement: Command Inventory Covers Core And Official Bundles + +The system SHALL derive a validation inventory that covers the released core commands and every official command package shipped from `specfact-cli-modules`. + +#### Scenario: Inventory includes core commands and official bundle roots + +- **GIVEN** the core module manifests under `src/specfact_cli/modules/` +- **AND** the official bundle manifests under `specfact-cli-modules/packages/` +- **WHEN** the runtime validation inventory is generated +- **THEN** it includes `specfact`, `init`, `module`, and `upgrade` +- **AND** it includes the official bundle roots `project`, `spec`, `code`, `backlog`, and `govern` +- **AND** every inventory entry records the owning package and command path. + +#### Scenario: Inventory expands nested subcommands from Typer apps + +- **GIVEN** a bundle root command with nested Typer groups or leaf commands +- **WHEN** the runtime validation inventory is generated +- **THEN** nested command paths are expanded from the Typer application tree +- **AND** the inventory includes grouped paths such as `backlog ceremony standup`, `project sync bridge`, `spec contract validate`, `code validate sidecar run`, and `govern patch apply` +- **AND** a missing nested command path fails validation instead of being silently skipped. + +#### Scenario: Inventory feeds AI-agent command overview artifacts + +- **GIVEN** the runtime validation inventory is generated +- **WHEN** command overview artifacts are written +- **THEN** the same inventory data is used for `llms.txt`, Markdown, JSON, docs validation, and runtime smoke selection +- **AND** validators do not use a separate manually maintained command allowlist for canonical commands. + +### Requirement: Validation Matrix Executes Commands In Logical Runtime Order + +The system SHALL execute the command inventory in a deterministic order that matches normal user setup and runtime dependencies. + +#### Scenario: Core setup executes before bundle commands + +- **GIVEN** a validation run starts in a clean workspace +- **WHEN** the command-package runtime audit runs +- **THEN** it executes root help and startup checks before any bundle commands +- **AND** it executes `specfact init`, `specfact module`, and `specfact upgrade` validation cases before bundle installation or bundle-root validation +- **AND** installation/bootstrap failures stop later phases from being reported as passed. + +#### Scenario: Bundle command phases follow installation order + +- **GIVEN** the official bundles are available from bundled artifacts or marketplace registry sources +- **WHEN** the audit installs and validates bundle commands +- **THEN** bundle roots are validated after install/bootstrap succeeds +- **AND** nested command families are executed under their owning root in a stable order +- **AND** the audit report shows which phase each command belonged to. + +### Requirement: Package Manager Runtime Matrix Blocks Command Mismatches + +The command validation surface SHALL run through representative hatch, pip, pipx, and uv launchers before PRs can merge. + +#### Scenario: Runtime matrix exercises installed CLI paths + +- **GIVEN** a pull request changes CLI runtime, module discovery, command docs, or packaging behavior +- **WHEN** CI runs the package-manager runtime matrix +- **THEN** it builds and executes the CLI through hatch source execution, pip wheel install, pipx install, uv run, and uv tool or uvx execution paths +- **AND** it validates representative core and official module command groups in each path +- **AND** any command path, module discovery, or install-method mismatch blocks the PR. + +#### Scenario: Pipx upgrade validates and repairs stale launcher + +- **GIVEN** `specfact upgrade` is running from a pipx-managed installation +- **AND** `pipx upgrade specfact-cli` exits successfully +- **WHEN** the installed `specfact --version` launcher fails because it still points at a stale or missing pipx venv path +- **THEN** the upgrader runs `pipx reinstall specfact-cli` +- **AND** it validates `specfact --version` again after the reinstall +- **AND** it reports failure if the launcher still cannot execute. diff --git a/openspec/changes/tester-cli-reliability/specs/core-cli-reference/spec.md b/openspec/changes/tester-cli-reliability/specs/core-cli-reference/spec.md new file mode 100644 index 00000000..10e42d5b --- /dev/null +++ b/openspec/changes/tester-cli-reliability/specs/core-cli-reference/spec.md @@ -0,0 +1,42 @@ +## MODIFIED Requirements + +### Requirement: Core CLI reference pages exist + +The system SHALL provide dedicated reference pages for core CLI commands. + +#### Scenario: Init reference page documents all subcommands and options + +- **GIVEN** the docs/core-cli/init.md page exists +- **WHEN** a user reads the page +- **THEN** it documents: specfact init, init --profile, init --install, init ide, init --install-deps +- **AND** all documented commands match the actual --help output. + +#### Scenario: Init ide exports match target integration model + +- **GIVEN** prompt sources are available from core or installed modules +- **WHEN** `specfact init ide` exports to a slash-command target such as Cursor, VS Code, or Claude commands +- **THEN** it writes one prompt file per workflow into the target command/prompt directory. +- **WHEN** `specfact init ide` exports to a skill-based target such as Codex CLI, Claude Code Skills, or Mistral Vibe +- **THEN** it writes grouped capability-oriented `/SKILL.md` files per selected source/module +- **AND** it does not create one `SKILL.md` folder per slash-command prompt. + +#### Scenario: Module reference page documents all subcommands + +- **GIVEN** the docs/core-cli/module.md page exists +- **WHEN** a user reads the page +- **THEN** it documents: module install, module uninstall, module list, module show, module search, module upgrade, module alias, module add-registry, module list-registries, module remove-registry, module enable, module disable +- **AND** all documented commands match the actual --help output. + +#### Scenario: Upgrade reference page documents the command + +- **GIVEN** the docs/core-cli/upgrade.md page exists +- **WHEN** a user reads the page +- **THEN** it documents the specfact upgrade command and its options. + +#### Scenario: Reference docs are checked against generated command overview + +- **GIVEN** a core CLI reference page, prompt, template, or guidance string contains a `specfact` command example +- **WHEN** docs command validation runs +- **THEN** the example is checked against the generated command overview +- **AND** stale flat shims and invalid option ordering fail validation +- **AND** no reference page is excluded from command validation unless it is explicitly marked as historical migration material. diff --git a/openspec/changes/tester-cli-reliability/specs/generated-command-overview/spec.md b/openspec/changes/tester-cli-reliability/specs/generated-command-overview/spec.md new file mode 100644 index 00000000..6a6a6e53 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/specs/generated-command-overview/spec.md @@ -0,0 +1,38 @@ +## ADDED Requirements + +### Requirement: Generated Command Overview Is The Authoritative Command Contract + +The repository SHALL generate deterministic command overview artifacts from the actual core and official module command tree. + +#### Scenario: Core command overview artifacts are generated + +- **GIVEN** the command overview generator runs in the core repository +- **WHEN** it writes artifacts +- **THEN** it produces `llms.txt`, `docs/reference/commands.generated.md`, and `docs/reference/commands.generated.json` +- **AND** every command record includes command path, owning repo, owning package or module, install prerequisite, short help, arguments/options, subcommands, source import path when known, and hidden/deprecated status +- **AND** generated output is stable for the same source tree. + +#### Scenario: Generated artifacts are freshness-checked + +- **GIVEN** CLI source, module manifests, docs, prompts, or command validation scripts change +- **WHEN** the command overview check runs in pre-commit or CI +- **THEN** it fails if generated artifacts are stale +- **AND** it tells the developer which generator command refreshes the artifacts. + +### Requirement: Docs And Guidance Validate Against Generated Command Contract + +Docs, prompt, template, and code guidance validation SHALL use the generated command contract instead of prefix-only help checks. + +#### Scenario: Legacy command references fail validation + +- **GIVEN** a Markdown file, prompt, Jinja2 template, YAML/JSON/text resource, or Python guidance string contains an obsolete command such as `specfact sync bridge` +- **WHEN** command guidance validation runs +- **THEN** the validator fails unless the reference is inside an explicitly marked migration/deprecation context +- **AND** the finding includes file path, line number, observed command, and canonical replacement when known. + +#### Scenario: Invalid option placement fails validation + +- **GIVEN** documentation contains `specfact code import --repo .` +- **WHEN** command guidance validation runs +- **THEN** the validator rejects the example because the generated command contract does not support that option placement +- **AND** it reports the canonical supported invocation. diff --git a/openspec/changes/tester-cli-reliability/specs/runtime-tool-probing/spec.md b/openspec/changes/tester-cli-reliability/specs/runtime-tool-probing/spec.md new file mode 100644 index 00000000..d1893199 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/specs/runtime-tool-probing/spec.md @@ -0,0 +1,27 @@ +## ADDED Requirements + +### Requirement: Runtime Tool Probing Uses The Active Execution Context + +Tool and install-method diagnostics SHALL prefer the active execution context over stale package-manager inventories. + +#### Scenario: Upgrade launched through uv run is not classified as pipx + +- **GIVEN** a machine has a pipx-installed SpecFact CLI entry in global inventory +- **AND** the current invocation is launched through `uv run specfact upgrade` +- **WHEN** install-method detection runs +- **THEN** the effective method is reported as uv-run or uv project execution +- **AND** pipx-specific spaced-home warnings are not emitted for that invocation. + +#### Scenario: Semgrep available through uv is detected + +- **GIVEN** a project where `uv run semgrep --version` succeeds +- **WHEN** code analysis diagnostics check semgrep availability +- **THEN** semgrep is reported as available +- **AND** the diagnostic does not tell the user to install semgrep with a pip-only command. + +#### Scenario: Tool probe failures name the active manager + +- **GIVEN** a required external tool is not available in the active hatch, uv, pip, or pipx context +- **WHEN** a diagnostic is emitted +- **THEN** the message names the active manager context +- **AND** the installation hint matches that manager when a manager-specific hint is known. diff --git a/openspec/changes/tester-cli-reliability/tasks.md b/openspec/changes/tester-cli-reliability/tasks.md new file mode 100644 index 00000000..736167e8 --- /dev/null +++ b/openspec/changes/tester-cli-reliability/tasks.md @@ -0,0 +1,39 @@ +# Tasks: tester-cli-reliability + +## 1. Readiness and source tracking + +- [x] 1.1 Confirm tester bugs `#585`-`#593` are mapped to core/modules ownership and record the decision in `TDD_EVIDENCE.md`. +- [x] 1.2 Confirm GitHub tracking issue `#594` and paired modules issue `nold-ai/specfact-cli-modules#306` exist with source links and labels. +- [x] 1.3 Validate the OpenSpec change with `openspec validate tester-cli-reliability --strict`. + +## 2. Spec-first and failing evidence + +- [x] 2.1 Add spec deltas for CLI error guidance, generated command overview, tool probing, command runtime validation, docs reference validation, and CI runtime matrix. +- [x] 2.2 Add failing tests for unknown commands, missing subcommands, missing required parameters, generated command overview freshness, docs/template stale-command detection, uv-run upgrade detection, and active tool probing. +- [x] 2.3 Run the targeted tests before production edits and record failing evidence in `TDD_EVIDENCE.md`. + +## 3. CLI guidance and command inventory + +- [x] 3.1 Implement shared error rendering for unknown commands, missing subcommands, and missing required parameters. +- [x] 3.2 Add deterministic command overview generation for core command groups. +- [x] 3.3 Commit generated `llms.txt`, Markdown, and JSON command overview artifacts and link them from `README.md`. + +## 4. Docs, prompts, and code guidance validation + +- [x] 4.1 Add generated-artifact freshness as a docs validation gate. +- [x] 4.2 Scan Markdown, `.github/prompts`, Jinja2/templates, and Python guidance strings for stale command paths and option ordering. +- [x] 4.3 Repair core docs and guidance that still mention obsolete flat shims or invalid `code import` ordering. +- [x] 4.4 Add grouped `SKILL.md` export support for skill-based AI IDEs while keeping slash-command prompt exports for command-based IDEs. + +## 5. Runtime environment and CI simulation + +- [x] 5.1 Fix upgrade install-method detection so effective `uv run` context wins over stale/global pipx inventory. +- [x] 5.2 Add active tool probing for uv/hatch/pip/pipx contexts. +- [x] 5.3 Extend runtime smoke/CI to execute representative commands through hatch, pip editable, pipx, uv run, and uvx paths. +- [x] 5.4 Fix pipx upgrade success handling so stale/broken `specfact` launchers are detected and repaired with `pipx reinstall specfact-cli`. + +## 6. Passing evidence and quality gates + +- [x] 6.1 Re-run targeted tests and record passing evidence in `TDD_EVIDENCE.md`. +- [ ] 6.2 Run required quality gates for touched scope: format, type-check, lint, YAML lint, contract-test, smart-test or targeted equivalent. +- [ ] 6.3 Run SpecFact code review and resolve findings or document explicit exceptions. diff --git a/pyproject.toml b/pyproject.toml index 89ea177f..d720f6ab 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.46.28" +version = "0.47.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" @@ -247,12 +247,15 @@ yaml-check-all = "bash scripts/yaml-tools.sh check-all {args}" # Docs validation (docs-12): command examples vs CLI; modules.specfact.io URLs in docs check-docs-commands = "python scripts/check-docs-commands.py" check-cross-site-links = "python scripts/check-cross-site-links.py" +generate-command-overview = "python scripts/generate-command-overview.py --write" +check-command-overview = "python scripts/generate-command-overview.py --check" +check-command-contract = "python scripts/check-command-contract.py" doc-frontmatter-check = "python scripts/check_doc_frontmatter.py" validate-agent-rule-signals = "python scripts/validate_agent_rule_applies_when.py" check-version-sources = "python scripts/check_version_sources.py" check-pypi-ahead = "python scripts/check_local_version_ahead_of_pypi.py" release = "python scripts/check_local_version_ahead_of_pypi.py && python scripts/check_version_sources.py" -docs-validate = "python scripts/check-docs-commands.py && python scripts/check-cross-site-links.py --warn-only && python scripts/check_doc_frontmatter.py && python scripts/validate_agent_rule_applies_when.py" +docs-validate = "python scripts/generate-command-overview.py --check && python scripts/check-command-contract.py && python scripts/check-docs-commands.py && python scripts/check-cross-site-links.py --warn-only && python scripts/check_doc_frontmatter.py && python scripts/validate_agent_rule_applies_when.py" # Legacy entry (kept for compatibility); prefer `workflows-lint` above lint-workflows = "bash scripts/run_actionlint.sh {args}" diff --git a/resources/templates/pr-template.md.j2 b/resources/templates/pr-template.md.j2 index 29fee4be..73dc2e46 100644 --- a/resources/templates/pr-template.md.j2 +++ b/resources/templates/pr-template.md.j2 @@ -20,7 +20,7 @@ - **Non-interactive / CI** must bootstrap workflow bundles before `specfact code …` or `specfact project …`: - `specfact init --profile solo-developer --repo .` (or another profile / `specfact init --install …`), **or** - `specfact module install nold-ai/specfact-codebase` (and other bundles as needed). -- Contract repro in CI uses **`specfact code repro`** (not `specfact repro`). Optional: `specfact code repro setup` for CrossHair config. +- Contract repro in CI uses **`specfact code repro`**. Optional: `specfact code repro setup` for CrossHair config. - Optional `specfact project version check` needs a project under `.specfact/projects//` and `--bundle `. **SpecFact CLI Validation Results:** @@ -64,4 +64,3 @@ ## 📝 Notes {{ notes | default("Add any additional notes here") }} - diff --git a/scripts/check-command-contract.py b/scripts/check-command-contract.py new file mode 100644 index 00000000..2ce1c3ae --- /dev/null +++ b/scripts/check-command-contract.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""Validate generated command overview paths against the live CLI behavior.""" + +from __future__ import annotations + +import argparse +import importlib +import json +import os +import sys +import tempfile +from pathlib import Path +from typing import Any, cast + +import typer +from typer.testing import CliRunner + + +REPO_ROOT = Path(__file__).resolve().parents[1] +COMMANDS_JSON = REPO_ROOT / "docs" / "reference" / "commands.generated.json" +HELP_FLAGS = ("--help", "-h") +APP_MOUNTS = ( + ("specfact_cli.modules.init.src.commands", "app", ("specfact", "init")), + ("specfact_cli.modules.module_registry.src.commands", "app", ("specfact", "module")), + ("specfact_cli.modules.upgrade.src.commands", "app", ("specfact", "upgrade")), + ("specfact_backlog.backlog.commands", "app", ("specfact", "backlog")), + ("specfact_codebase.code.commands", "app", ("specfact", "code")), + ("specfact_code_review.review.commands", "app", ("specfact", "code", "review")), + ("specfact_govern.govern.commands", "app", ("specfact", "govern")), + ("specfact_project.project.commands", "app", ("specfact", "project")), + ("specfact_spec.spec.commands", "app", ("specfact", "spec")), +) +MISSING_MARKERS = ( + "missing", + "requires an argument", + "no such option", + "no such command", + "not a valid command", +) +_TEMP_HOME: tempfile.TemporaryDirectory[str] | None = None + + +def _paired_worktree_repo(source_marker: str, target_marker: str) -> Path | None: + parts = REPO_ROOT.parts + if source_marker not in parts: + return None + marker_index = parts.index(source_marker) + base = Path(*parts[:marker_index]) + suffix = Path(*parts[marker_index + 1 :]) + return base / target_marker / suffix + + +def _ensure_imports() -> None: + os.environ.setdefault("TEST_MODE", "true") + global _TEMP_HOME + if os.environ.get("SPECFACT_COMMAND_CONTRACT_USE_REAL_HOME") != "1": + _TEMP_HOME = tempfile.TemporaryDirectory(prefix="specfact-command-contract-home-") + os.environ["HOME"] = _TEMP_HOME.name + src = str(REPO_ROOT / "src") + if src not in sys.path: + sys.path.insert(0, src) + modules_repo = os.environ.get("SPECFACT_MODULES_REPO", "").strip() + candidates = [Path(modules_repo).expanduser()] if modules_repo else [] + candidates.append(REPO_ROOT.parent / "specfact-cli-modules") + paired_modules_repo = _paired_worktree_repo("specfact-cli-worktrees", "specfact-cli-modules-worktrees") + if paired_modules_repo is not None: + candidates.append(paired_modules_repo) + for candidate in candidates: + if candidate is None: + continue + packages_dir = candidate / "packages" + if not packages_dir.is_dir(): + continue + os.environ.setdefault("SPECFACT_MODULES_REPO", str(candidate.resolve())) + for src_path in sorted(packages_dir.glob("*/src")): + package_src = str(src_path.resolve()) + if package_src not in sys.path: + sys.path.insert(0, package_src) + + +def _load_records() -> list[dict[str, Any]]: + raw = json.loads(COMMANDS_JSON.read_text(encoding="utf-8")) + if not isinstance(raw, list): + raise ValueError(f"{COMMANDS_JSON} must contain a JSON list") + return [entry for entry in raw if isinstance(entry, dict)] + + +def _command_args(record: dict[str, Any]) -> list[str]: + command = record.get("command") + if not isinstance(command, str): + return [] + parts = command.split() + if parts[:1] != ["specfact"]: + return [] + return parts[1:] + + +def _has_required_argument(record: dict[str, Any]) -> bool: + arguments = record.get("arguments") + if not isinstance(arguments, list): + return False + return any(isinstance(argument, dict) and argument.get("required") for argument in arguments) + + +def _is_group(record: dict[str, Any]) -> bool: + subcommands = record.get("subcommands") + return isinstance(subcommands, list) and len(subcommands) > 0 + + +def _load_apps() -> dict[tuple[str, ...], object]: + from specfact_cli.cli import app as root_app + + apps: dict[tuple[str, ...], object] = {("specfact",): root_app} + for module_path, attr_name, prefix in APP_MOUNTS: + module = importlib.import_module(module_path) + apps[prefix] = getattr(module, attr_name) + return apps + + +def _select_app(apps: dict[tuple[str, ...], object], command_parts: list[str]) -> tuple[object, list[str]]: + best_prefix: tuple[str, ...] = ("specfact",) + for prefix in apps: + if len(prefix) > len(best_prefix) and tuple(command_parts[: len(prefix)]) == prefix: + best_prefix = prefix + return apps[best_prefix], command_parts[len(best_prefix) :] + + +def _invoke( + runner: CliRunner, apps: dict[tuple[str, ...], object], command_parts: list[str], suffix: list[str] +) -> tuple[int, str]: + app, args = _select_app(apps, command_parts) + invoke_args = [*args, *suffix] + result = runner.invoke(cast(typer.Typer, app), invoke_args) + stdout = getattr(result, "stdout", "") or "" + try: + stderr = getattr(result, "stderr", "") or "" + except ValueError: + stderr = "" + return result.exit_code, f"{stdout}{stderr}" + + +def _check_help(runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any]) -> list[str]: + args = _command_args(record) + if not args and record.get("command") != "specfact": + return [f"{record.get('command')}: invalid command path in generated JSON"] + command_parts = ["specfact", *args] + exit_code, raw_output = _invoke(runner, apps, command_parts, ["--help"]) + output = raw_output.lower() + if exit_code != 0: + return [f"{record.get('command')}: --help exited {exit_code}\n{raw_output}"] + if "usage:" not in output: + return [f"{record.get('command')}: --help did not render usage\n{raw_output}"] + _selected_app, selected_args = _select_app(apps, command_parts) + if not _is_group(record) and selected_args: + usage_lines = [] + capture_usage = False + for line in raw_output.splitlines(): + if "Usage:" in line: + capture_usage = True + if capture_usage: + if not line.strip(): + break + usage_lines.append(line.lower()) + if args[-1].lower() not in " ".join(usage_lines): + return [f"{record.get('command')}: --help rendered parent usage instead of leaf usage\n{raw_output}"] + return [] + + +def _check_group_missing_subcommand( + runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any] +) -> list[str]: + if record.get("command") == "specfact" or not _is_group(record) or record.get("bare_invocation") == "executes": + return [] + args = _command_args(record) + command_parts = ["specfact", *args] + exit_code, raw_output = _invoke(runner, apps, command_parts, []) + output = raw_output.lower() + failures: list[str] = [] + if exit_code == 0: + failures.append(f"{record.get('command')}: bare group unexpectedly exited 0") + if "usage:" not in output: + failures.append(f"{record.get('command')}: bare group did not render usage") + if "missing subcommand" not in output: + failures.append(f"{record.get('command')}: bare group did not explain the missing subcommand") + if output.count("usage:") != 1: + failures.append(f"{record.get('command')}: expected exactly one usage block, saw {output.count('usage:')}") + if failures: + failures.append(raw_output) + return failures + + +def _check_missing_required_argument( + runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any] +) -> list[str]: + if _is_group(record) or not _has_required_argument(record): + return [] + args = _command_args(record) + command_parts = ["specfact", *args] + exit_code, raw_output = _invoke(runner, apps, command_parts, []) + output = raw_output.lower() + failures: list[str] = [] + if exit_code == 0: + failures.append(f"{record.get('command')}: missing required argument unexpectedly exited 0") + if "usage:" not in output: + failures.append(f"{record.get('command')}: missing required argument did not render usage") + if not any(marker in output for marker in MISSING_MARKERS): + failures.append(f"{record.get('command')}: missing required argument did not explain the failure") + if output.count("usage:") != 1: + failures.append(f"{record.get('command')}: expected exactly one usage block, saw {output.count('usage:')}") + if failures: + failures.append(raw_output) + return failures + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--limit", type=int, default=0, help="Validate only the first N generated commands") + args = parser.parse_args(argv) + + _ensure_imports() + apps = _load_apps() + records = _load_records() + records = sorted(records, key=lambda record: len(str(record.get("command", "")).split()), reverse=True) + if args.limit > 0: + records = records[: args.limit] + + runner = CliRunner() + failures: list[str] = [] + for record in records: + failures.extend(_check_help(runner, apps, record)) + failures.extend(_check_group_missing_subcommand(runner, apps, record)) + failures.extend(_check_missing_required_argument(runner, apps, record)) + + if failures: + print("Generated command contract validation failed:") + print("\n\n".join(failures)) + return 1 + print(f"check-command-contract: OK ({len(records)} generated command path(s) validated)") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-docs-commands.py b/scripts/check-docs-commands.py index 2c36c1bf..5cade892 100755 --- a/scripts/check-docs-commands.py +++ b/scripts/check-docs-commands.py @@ -3,6 +3,7 @@ from __future__ import annotations +import json import os import re import shlex @@ -23,11 +24,7 @@ _OUT = Console() # Historical / illustrative pages: command lines are not guaranteed to match the current CLI. -_EXCLUDED_DOC_PATHS: frozenset[str] = frozenset( - { - "docs/core-cli/modes.md", - } -) +_EXCLUDED_DOC_PATHS: frozenset[str] = frozenset() # Root ``@app.callback`` options on ``specfact`` (see ``cli.py``). Values must be skipped so # ``specfact --mode copilot import …`` yields ``import …`` for validation. @@ -38,6 +35,31 @@ "--output-format", } ) +_GENERATED_COMMANDS_PATH = _REPO_ROOT / "docs" / "reference" / "commands.generated.json" +_GENERATED_PREFIX_CACHE: set[tuple[str, ...]] | None = None +_GUIDANCE_TEXT_SUFFIXES: frozenset[str] = frozenset( + { + ".jinja", + ".j2", + ".jinja2", + ".json", + ".md", + ".mdc", + ".py", + ".rst", + ".toml", + ".txt", + ".yaml", + ".yml", + } +) +_ADDITIONAL_GUIDANCE_ROOTS: tuple[Path, ...] = ( + _REPO_ROOT / ".github", + _REPO_ROOT / "resources", + _REPO_ROOT / "src", + _REPO_ROOT / "src" / "specfact_cli" / "resources", +) +_GUIDANCE_INLINE_COMMAND_RE = re.compile(r"`(specfact\s+[^`\n]+)`") def _ensure_repo_path() -> None: @@ -123,7 +145,7 @@ def _sanitize_command_tokens(tokens: list[str]) -> list[str]: for token in tokens: if re.match(r"^<[^>]+>$", token): continue - if token in {"[OPTIONS]", "[ARGS]", "[COMMAND]", "[BUNDLE]"}: + if token in {"...", "COMMAND", "ARGS...", "[OPTIONS]", "[ARGS]", "[ARGS]...", "[COMMAND]", "[BUNDLE]"}: continue if token.startswith("[") and token.endswith("]"): continue @@ -145,6 +167,31 @@ def collect_specfact_commands_from_text(text: str) -> list[list[str]]: return commands +@beartype +@ensure(lambda result: isinstance(result, list), "must return a list") +def collect_specfact_commands_from_guidance_text(text: str) -> list[list[str]]: + """Collect command token lists from prose, templates, YAML, JSON, and Markdown guidance.""" + commands = collect_specfact_commands_from_text(text) + for line in text.splitlines(): + for match in _GUIDANCE_INLINE_COMMAND_RE.finditer(line): + tokens = _tokens_from_specfact_line(match.group(1)) + if tokens: + commands.append(tokens) + stripped = line.strip().lstrip("-").strip() + candidates = [stripped] + if ":" in stripped: + candidates.append(stripped.split(":", 1)[1].strip()) + for candidate in candidates: + candidate = candidate.strip().strip(",").strip("\"'") + if not candidate.startswith("specfact "): + continue + tokens = _tokens_from_specfact_line(candidate) + if tokens: + commands.append(tokens) + break + return commands + + def _cli_invoke_streams_text(result: object) -> str: """Stdout + stderr text for a CliRunner ``Result`` (stderr via bytes when split, else safe).""" out = (getattr(result, "stdout", None) or "").strip() @@ -180,6 +227,36 @@ def _eval_prefix_help(runner: CliRunner, prefix: list[str]) -> tuple[bool, str]: return False, last_err +def _generated_command_prefixes() -> set[tuple[str, ...]]: + global _GENERATED_PREFIX_CACHE + if _GENERATED_PREFIX_CACHE is not None: + return _GENERATED_PREFIX_CACHE + if not _GENERATED_COMMANDS_PATH.is_file(): + _GENERATED_PREFIX_CACHE = set() + return _GENERATED_PREFIX_CACHE + raw = json.loads(_GENERATED_COMMANDS_PATH.read_text(encoding="utf-8")) + prefixes: set[tuple[str, ...]] = set() + if isinstance(raw, list): + for entry in raw: + if not isinstance(entry, dict): + continue + command = entry.get("command") + if not isinstance(command, str): + continue + parts = tuple(command.split()) + if parts[:1] == ("specfact",) and len(parts) > 1: + prefixes.add(parts[1:]) + _GENERATED_PREFIX_CACHE = prefixes + return prefixes + + +def _generated_prefix_match(tokens: list[str]) -> bool: + generated = _generated_command_prefixes() + if not generated: + return False + return any(tuple(tokens[:size]) in generated for size in range(len(tokens), 0, -1)) + + @beartype @ensure( lambda result: ( @@ -192,6 +269,17 @@ def validate_command_tokens(tokens: list[str]) -> tuple[bool, str]: tokens = _sanitize_command_tokens(tokens) if not tokens: return True, "" + invalid_code_import = _invalid_code_import_order_message(tokens) + if invalid_code_import: + return False, invalid_code_import + if _generated_prefix_match(tokens): + return True, "" + if _generated_command_prefixes(): + return ( + False, + "Command path is not present in docs/reference/commands.generated.json. " + "Run `hatch run generate-command-overview` if the CLI changed; otherwise fix the docs.", + ) runner = CliRunner() last_err = "" @@ -205,6 +293,28 @@ def validate_command_tokens(tokens: list[str]) -> tuple[bool, str]: return False, last_err +@beartype +def _invalid_code_import_order_message(tokens: list[str]) -> str: + if len(tokens) < 4 or tokens[:2] != ["code", "import"]: + return "" + try: + first_flag_index = next(index for index, token in enumerate(tokens[2:], start=2) if token.startswith("-")) + except StopIteration: + return "" + positional_before_flag = [ + token + for token in tokens[2:first_flag_index] + if token not in {"from-code", "from-bridge"} and not re.match(r"^<[^>]+>$", token) + ] + if not positional_before_flag: + return "" + return ( + "Invalid specfact code import option order: options such as --repo must appear before the bundle " + "argument, for example `specfact code import --repo . legacy-api`, or use the explicit " + "`specfact code import from-code legacy-api --repo .` form." + ) + + @beartype def _should_skip_markdown_path(rel: Path, rel_posix: str) -> bool: if "_site" in rel.parts or "vendor" in rel.parts: @@ -214,6 +324,13 @@ def _should_skip_markdown_path(rel: Path, rel_posix: str) -> bool: return rel_posix.startswith("docs/migration/") or rel_posix in _EXCLUDED_DOC_PATHS +@beartype +def _should_skip_guidance_path(rel: Path) -> bool: + if any(part in {"_site", "vendor", ".venv", "__pycache__"} for part in rel.parts): + return True + return rel.as_posix() in _EXCLUDED_DOC_PATHS + + @beartype def _scan_docs_for_command_validation(docs_root: Path) -> tuple[set[tuple[str, ...]], list[str]]: seen: set[tuple[str, ...]] = set() @@ -239,6 +356,49 @@ def _scan_docs_for_command_validation(docs_root: Path) -> tuple[set[tuple[str, . return seen, failures +@beartype +def _iter_additional_guidance_paths(docs_root: Path) -> list[Path]: + """Return non-doc guidance/template files whose command examples must stay current.""" + roots = [docs_root, *_ADDITIONAL_GUIDANCE_ROOTS] + paths: set[Path] = set() + for root in roots: + if not root.exists(): + continue + for path in root.rglob("*"): + if not path.is_file() or path.suffix.lower() not in _GUIDANCE_TEXT_SUFFIXES: + continue + rel = path.relative_to(_REPO_ROOT) + if _should_skip_guidance_path(rel): + continue + if rel.parts[0] == "docs" and path.suffix.lower() == ".md": + continue + paths.add(path) + return sorted(paths) + + +@beartype +def _scan_guidance_templates_for_command_validation(docs_root: Path) -> tuple[set[tuple[str, ...]], list[str]]: + """Validate command examples in non-Markdown docs, prompts, Jinja2 templates, YAML, JSON, and text assets.""" + seen: set[tuple[str, ...]] = set() + failures: list[str] = [] + for path in _iter_additional_guidance_paths(docs_root): + rel = path.relative_to(_REPO_ROOT) + try: + text = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + failures.append(f"{rel}: cannot decode file as UTF-8 ({exc})") + continue + for tokens in collect_specfact_commands_from_guidance_text(text): + key = tuple(tokens) + if key in seen: + continue + seen.add(key) + ok, msg = validate_command_tokens(tokens) + if not ok: + failures.append(f"{rel}: specfact {' '.join(tokens)} — {msg}") + return seen, failures + + @beartype def _extract_front_matter(text: str) -> dict[str, str]: if not text.startswith("---\n"): @@ -352,6 +512,9 @@ def main() -> int: return 1 seen, failures = _scan_docs_for_command_validation(docs_root) + guidance_seen, guidance_failures = _scan_guidance_templates_for_command_validation(docs_root) + seen.update(guidance_seen) + failures.extend(guidance_failures) failures.extend(_validate_nav_targets(docs_root)) if failures: diff --git a/scripts/generate-command-overview.py b/scripts/generate-command-overview.py new file mode 100644 index 00000000..ab14260e --- /dev/null +++ b/scripts/generate-command-overview.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +"""Generate deterministic command overview artifacts for humans and AI agents.""" + +from __future__ import annotations + +import argparse +import difflib +import importlib +import json +import os +import sys +from pathlib import Path +from typing import Any + +from typer.main import get_command + + +REPO_ROOT = Path(__file__).resolve().parents[1] +JSON_PATH = REPO_ROOT / "docs" / "reference" / "commands.generated.json" +MARKDOWN_PATH = REPO_ROOT / "docs" / "reference" / "commands.generated.md" +LLMS_PATH = REPO_ROOT / "llms.txt" +CORE_APP_MOUNTS = ( + ("specfact_cli.modules.init.src.commands", "app", ("specfact", "init"), "core"), + ("specfact_cli.modules.module_registry.src.commands", "app", ("specfact", "module"), "core"), + ("specfact_cli.modules.upgrade.src.commands", "app", ("specfact", "upgrade"), "core"), +) +MODULE_APP_MOUNTS = ( + ("specfact_backlog.backlog.commands", "app", ("specfact", "backlog"), "nold-ai/specfact-backlog"), + ("specfact_codebase.code.commands", "app", ("specfact", "code"), "nold-ai/specfact-codebase"), + ("specfact_code_review.review.commands", "app", ("specfact", "code", "review"), "nold-ai/specfact-code-review"), + ("specfact_govern.govern.commands", "app", ("specfact", "govern"), "nold-ai/specfact-govern"), + ("specfact_project.project.commands", "app", ("specfact", "project"), "nold-ai/specfact-project"), + ("specfact_spec.spec.commands", "app", ("specfact", "spec"), "nold-ai/specfact-spec"), +) + + +def _paired_worktree_repo(source_marker: str, target_marker: str) -> Path | None: + parts = REPO_ROOT.parts + if source_marker not in parts: + return None + marker_index = parts.index(source_marker) + base = Path(*parts[:marker_index]) + suffix = Path(*parts[marker_index + 1 :]) + return base / target_marker / suffix + + +def _ensure_imports() -> None: + os.environ.setdefault("TEST_MODE", "true") + modules_repo = os.environ.get("SPECFACT_MODULES_REPO", "").strip() + module_repo_candidates = [ + Path(modules_repo).expanduser() if modules_repo else None, + REPO_ROOT.parent / "specfact-cli-modules", + _paired_worktree_repo("specfact-cli-worktrees", "specfact-cli-modules-worktrees"), + ] + for candidate in module_repo_candidates: + if candidate is None: + continue + packages_dir = candidate / "packages" + if not packages_dir.is_dir(): + continue + os.environ.setdefault("SPECFACT_MODULES_REPO", str(candidate.resolve())) + for src_path in sorted(packages_dir.glob("*/src")): + src = str(src_path.resolve()) + if src not in sys.path: + sys.path.insert(0, src) + src = str(REPO_ROOT / "src") + if src not in sys.path: + sys.path.insert(0, src) + + +def _import_typer(module_path: str, attr_name: str) -> Any: + module = importlib.import_module(module_path) + return getattr(module, attr_name) + + +def _command_options(command: Any) -> list[str]: + options: set[str] = set() + for param in command.params: + if hasattr(param, "opts"): + options.update(opt for opt in [*param.opts, *param.secondary_opts] if opt.startswith("--")) + return sorted(options) + + +def _command_arguments(command: Any) -> list[dict[str, Any]]: + arguments: list[dict[str, Any]] = [] + for param in command.params: + if not hasattr(param, "opts") and hasattr(param, "human_readable_name"): + arguments.append( + { + "name": param.human_readable_name, + "required": bool(param.required), + "nargs": param.nargs, + } + ) + return arguments + + +def _command_children(command: Any) -> dict[str, Any]: + command = _materialized_command(command) + if not (hasattr(command, "list_commands") and hasattr(command, "get_command")): + return {} + context_cls = command.context_class + with context_cls(command, info_name=command.name) as ctx: + children: dict[str, Any] = {} + for name in command.list_commands(ctx): + if name == "__delegate__": + continue + child = command.get_command(ctx, name) + if child is not None: + children[name] = child + return children + + +def _materialized_command(command: Any) -> Any: + real_group_loader = getattr(command, "_get_real_click_group", None) + if callable(real_group_loader): + real_group = real_group_loader() + if hasattr(real_group, "params"): + return real_group + return command + + +def _bare_invocation(command: Any) -> str: + command = _materialized_command(command) + is_group = hasattr(command, "list_commands") and hasattr(command, "get_command") + if is_group and bool(getattr(command, "invoke_without_command", False)): + return "executes" + if is_group: + return "requires-subcommand" + return "executes" + + +def _walk( + command: Any, + path: tuple[str, ...], + source: str, + owner_package: str, + install_prerequisite: str, +) -> list[dict[str, Any]]: + command = _materialized_command(command) + children = _command_children(command) + record = { + "command": " ".join(path), + "owner_repo": "nold-ai/specfact-cli", + "owner_package": owner_package, + "install_prerequisite": install_prerequisite, + "short_help": (command.short_help or "").strip(), + "arguments": _command_arguments(command), + "bare_invocation": _bare_invocation(command), + "options": _command_options(command), + "subcommands": sorted(children), + "source": source, + "hidden": bool(getattr(command, "hidden", False)), + "deprecated": bool(getattr(command, "deprecated", False)), + } + records = [record] + for name, child in sorted(children.items()): + records.extend(_walk(child, (*path, name), source, owner_package, install_prerequisite)) + return records + + +def _root_record(root_subcommands: list[str]) -> dict[str, Any]: + _ensure_imports() + from specfact_cli.cli import app + + root_command = get_command(app) + return { + "command": "specfact", + "owner_repo": "nold-ai/specfact-cli", + "owner_package": "core", + "install_prerequisite": "Install specfact-cli.", + "short_help": (root_command.short_help or "").strip(), + "arguments": _command_arguments(root_command), + "bare_invocation": "executes", + "options": _command_options(root_command), + "subcommands": root_subcommands, + "source": "specfact_cli.cli:app", + "hidden": bool(getattr(root_command, "hidden", False)), + "deprecated": bool(getattr(root_command, "deprecated", False)), + } + + +def build_records() -> list[dict[str, Any]]: + _ensure_imports() + records: list[dict[str, Any]] = [] + for module_path, attr_name, prefix, owner_package in (*CORE_APP_MOUNTS, *MODULE_APP_MOUNTS): + app = _import_typer(module_path, attr_name) + install_prerequisite = ( + "Install specfact-cli." if owner_package == "core" else f"specfact module install {owner_package}" + ) + records.extend( + _walk( + get_command(app), + prefix, + f"{module_path}:{attr_name}", + owner_package, + install_prerequisite, + ) + ) + root_subcommands = sorted( + {str(record["command"]).split()[1] for record in records if len(str(record["command"]).split()) > 1} + ) + return [_root_record(root_subcommands), *sorted(records, key=lambda record: record["command"])] + + +def _render_markdown(records: list[dict[str, Any]]) -> str: + lines = [ + "---", + "layout: default", + "title: Generated SpecFact CLI Command Overview", + "permalink: /reference/generated-command-overview/", + "exempt: true", + "exempt_reason: Generated command contract artifact.", + "---", + "", + "# Generated SpecFact CLI Command Overview", + "", + "This file is generated from the current CLI command tree. Do not edit by hand.", + "", + "| Command | Owner | Options | Subcommands | Context |", + "| --- | --- | --- | --- | --- |", + ] + for record in records: + arguments = ", ".join( + f"{arg['name']}{' (required)' if arg.get('required') else ''}" for arg in record["arguments"] + ) + options = ", ".join(record["options"]) or "-" + subcommands = ", ".join(record["subcommands"]) or "-" + help_text = str(record["short_help"]).replace("\n", " ") + lines.append( + f"| `{record['command']}` | {record['owner_package']} | {options}; args: {arguments or '-'} | " + f"{subcommands} | {help_text} |" + ) + lines.append("") + return "\n".join(lines) + + +def _render_llms(markdown: str) -> str: + return "\n".join( + [ + "# SpecFact CLI Commands", + "", + "Use this generated overview as the current command contract before following older docs or prompts.", + "", + markdown, + ] + ) + + +def _desired_outputs() -> dict[Path, str]: + records = build_records() + json_text = json.dumps(records, indent=2, sort_keys=True) + "\n" + markdown = _render_markdown(records) + return { + JSON_PATH: json_text, + MARKDOWN_PATH: markdown, + LLMS_PATH: _render_llms(markdown), + } + + +def _check(outputs: dict[Path, str]) -> int: + failures = [] + for path, expected in outputs.items(): + actual = path.read_text(encoding="utf-8") if path.exists() else "" + if actual != expected: + failures.append(path) + diff = "\n".join( + difflib.unified_diff( + actual.splitlines(), + expected.splitlines(), + fromfile=str(path), + tofile=f"{path} (generated)", + lineterm="", + ) + ) + print(diff) + if failures: + print("Command overview artifacts are stale. Run: python scripts/generate-command-overview.py --write") + return 1 + return 0 + + +def main(argv: list[str] | None = None) -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--write", action="store_true", help="Write generated artifacts") + parser.add_argument("--check", action="store_true", help="Check generated artifacts are current") + args = parser.parse_args(argv) + outputs = _desired_outputs() + if args.write: + for path, text in outputs.items(): + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + return 0 + return _check(outputs) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/pre-commit-quality-checks.sh b/scripts/pre-commit-quality-checks.sh index 1e79a0d7..1e603978 100755 --- a/scripts/pre-commit-quality-checks.sh +++ b/scripts/pre-commit-quality-checks.sh @@ -38,7 +38,7 @@ print_block2_overview() { echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 echo " specfact-cli pre-commit — Block 2: code review + contract tests" >&2 echo " 1/2 code review gate (staged Python under src/, scripts/, tools/, tests/, openspec/changes/)" >&2 - echo " 2/2 contract-first tests (contract-test-status → hatch run contract-test)" >&2 + echo " 2/2 contract-first tests (contract-test-status → hatch run contract-test-contracts)" >&2 echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" >&2 echo "" >&2 } @@ -400,20 +400,69 @@ run_code_review_gate() { fi } +run_command_overview_validation_gate() { + local hit=0 + local file + while IFS= read -r file || [[ -n "${file}" ]]; do + [[ -z "${file}" ]] && continue + case "${file}" in + src/*|docs/*|.github/*|resources/*|scripts/check-docs-commands.py|scripts/check-command-contract.py|scripts/generate-command-overview.py|README.md|llms.txt|docs/reference/commands.generated.*) + hit=1 + break + ;; + esac + done < <(staged_files) + if [[ "${hit}" -eq 0 ]]; then + return 0 + fi + info "📦 Block 2 — command overview — regenerating current AI command artifacts" + if hatch run generate-command-overview; then + git add -- llms.txt docs/reference/commands.generated.json docs/reference/commands.generated.md + success "✅ Command overview artifacts regenerated and staged" + else + error "❌ Command overview generation failed" + exit 1 + fi + info "📦 Block 2 — command overview — running \`hatch run check-command-overview\`" + if hatch run check-command-overview; then + success "✅ Command overview validation passed" + else + error "❌ Command overview validation failed" + warn "💡 Run: hatch run generate-command-overview" + exit 1 + fi + info "📦 Block 2 — command contract — running \`hatch run check-command-contract\`" + if hatch run check-command-contract; then + success "✅ Generated command contract validation passed" + else + error "❌ Generated command contract validation failed" + warn "💡 Run: hatch run check-command-contract" + exit 1 + fi + info "📦 Block 2 — docs commands — running \`hatch run check-docs-commands\`" + if hatch run check-docs-commands; then + success "✅ Docs command validation passed" + else + error "❌ Docs command validation failed" + warn "💡 Fix stale command examples or regenerate the command overview if the CLI changed" + exit 1 + fi +} + run_contract_tests_visible() { info "📦 Block 2 — contract tests — running \`hatch run contract-test-status\`" # Discard status-check output: transient failures (missing optional deps, environment noise) should - # not alarm the user; we fall through to the full `hatch run contract-test` which surfaces real failures. + # not alarm the user; we fall through to scoped contract checks that surface real failures quickly. if hatch run contract-test-status >/dev/null 2>&1; then success "✅ Block 2 — contract tests — skipped (contract-test-status: no input changes)" else - info "📦 Block 2 — contract tests — running \`hatch run contract-test\`" - if hatch run contract-test; then + info "📦 Block 2 — contract tests — running \`hatch run contract-test-contracts\`" + if hatch run contract-test-contracts; then success "✅ Block 2 — contract-first tests passed" warn "💡 CI may still run the full quality matrix" else error "❌ Block 2 — contract-first tests failed" - warn "💡 Run: hatch run contract-test-status" + warn "💡 Run: hatch run contract-test-contracts" exit 1 fi fi @@ -459,6 +508,7 @@ run_block1_lint() { run_block2() { warn "🔍 specfact-cli pre-commit — Block 2 — hook: review + contract tests" + run_command_overview_validation_gate if check_safe_change; then success "✅ Safe change detected — skipping Block 2 (code review + contract tests)" info "💡 Only docs (incl. *.mdc), workflow, version files, or allowlisted infra changed" @@ -482,6 +532,7 @@ run_all() { run_workflow_lint_if_needed run_lint_if_staged_python success "✅ Block 1 complete (all stages passed or skipped as expected)" + run_command_overview_validation_gate if check_safe_change; then success "✅ Safe change detected — skipping Block 2 (code review + contract tests)" info "💡 Only docs (incl. *.mdc), workflow, version files, or allowlisted infra changed" diff --git a/scripts/runtime_discovery_smoke.py b/scripts/runtime_discovery_smoke.py index 3d298735..48f19874 100644 --- a/scripts/runtime_discovery_smoke.py +++ b/scripts/runtime_discovery_smoke.py @@ -50,6 +50,51 @@ def _run(command: list[str], *, cwd: Path, env: dict[str, str], timeout: int = 1 return result +def _normalized_output(output: str) -> str: + return " ".join(output.split()) + + +_HATCH_SOURCE_RUNNER = ( + "import os; " + "os.environ['PYTHONPATH'] = os.environ.get('SPECFACT_CHILD_PYTHONPATH', ''); " + "from hatch.cli import main; " + "raise SystemExit(main())" +) + + +def _hatch_launcher_python_and_site() -> tuple[str, str] | None: + hatch_executable = shutil.which("hatch") + if hatch_executable is None: + return None + try: + first_line = Path(hatch_executable).read_text(encoding="utf-8").splitlines()[0] + except (IndexError, OSError, UnicodeDecodeError): + return None + if not first_line.startswith("#!") or "python" not in first_line.lower(): + return None + python_executable = first_line[2:].strip().split()[0] + if not python_executable: + return None + result = subprocess.run( + [ + python_executable, + "-c", + "import hatch, pathlib; print(pathlib.Path(hatch.__file__).resolve().parents[1])", + ], + cwd=REPO_ROOT, + env=os.environ.copy(), + text=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + timeout=10, + check=False, + ) + if result.returncode != 0: + return None + site_path = result.stdout.strip() + return (python_executable, site_path) if site_path else None + + def _write(path: Path, content: str) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(content, encoding="utf-8") @@ -113,6 +158,12 @@ def _resolve_modules_repo(configured: str | None) -> Path: Path.cwd().parent / "specfact-cli-modules", ] ) + parts = REPO_ROOT.parts + if "specfact-cli-worktrees" in parts: + marker_index = parts.index("specfact-cli-worktrees") + candidates.append( + Path(*parts[:marker_index]) / "specfact-cli-modules-worktrees" / Path(*parts[marker_index + 1 :]) + ) if len(REPO_ROOT.parents) > 2: candidates.append(REPO_ROOT.parents[2] / "specfact-cli-modules") for candidate in candidates: @@ -197,8 +248,29 @@ def _create_pip_editable_launcher(workspace: Path) -> list[str]: def _launcher_command(name: str, workspace: Path) -> list[str]: if name == "direct": return [sys.executable, "-m", "specfact_cli.cli"] + if name == "hatch-source": + hatch_launcher = _hatch_launcher_python_and_site() + if hatch_launcher is not None: + python_executable, _site_path = hatch_launcher + return [python_executable, "-c", _HATCH_SOURCE_RUNNER, "run", "specfact"] + return ["hatch", "run", "specfact"] if name == "pip-editable": return _create_pip_editable_launcher(workspace) + if name == "pipx": + return ["pipx", "run", "--python", sys.executable, "--spec", str(REPO_ROOT), "specfact"] + if name == "uv-run": + return [ + "uv", + "--cache-dir", + str(workspace / "uv-cache"), + "run", + "--no-project", + "--python", + sys.executable, + "--with", + str(REPO_ROOT), + "specfact", + ] if name == "console": return ["specfact"] if name == "uvx": @@ -230,10 +302,7 @@ def _install_marketplace_modules(cli: list[str], demo: Path, env: dict[str, str] ) -def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, modules_repo: Path) -> None: - home = workspace / f"home-{name}" - home.mkdir(parents=True) - cli = _launcher_command(name, workspace / f"launcher-{name}") +def _build_launcher_env(name: str, home: Path, index_path: Path, modules_repo: Path) -> dict[str, str]: env = os.environ.copy() python_path = str(SRC_ROOT) if env.get("PYTHONPATH"): @@ -242,6 +311,7 @@ def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, mo { "HOME": str(home), "PYTHONPATH": python_path, + "SPECFACT_CHILD_PYTHONPATH": python_path, "SPECFACT_MODULES_REPO": str(modules_repo), "SPECFACT_REGISTRY_INDEX_URL": str(index_path), "SPECFACT_ALLOW_UNSIGNED": "1", @@ -249,15 +319,67 @@ def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, mo "PYTHONUNBUFFERED": "1", } ) + hatch_launcher = _hatch_launcher_python_and_site() + if name == "hatch-source" and hatch_launcher is not None: + _python_executable, hatch_site_path = hatch_launcher + env["PYTHONPATH"] = hatch_site_path + return env - _install_marketplace_modules(cli, demo, env) - list_result = _run([*cli, "module", "list"], cwd=demo, env=env) + +def _assert_module_list_contains(result: subprocess.CompletedProcess[str]) -> None: for module_id in MODULE_IDS: - if module_id not in list_result.stdout: + if module_id not in result.stdout: raise AssertionError(f"{module_id} missing from module list output") + +def _assert_tokens_present(output: str, command: str, tokens: tuple[str, ...]) -> None: + for token in tokens: + if token not in output: + raise AssertionError(f"`{command}` did not include {token!r}") + + +def _assert_code_help(cli: list[str], demo: Path, env: dict[str, str]) -> None: + help_result = _run([*cli, "code", "--help"], cwd=demo, env=env) + _assert_tokens_present( + help_result.stdout, "specfact code --help", ("review", "import", "analyze", "drift", "validate", "repro") + ) + _run([*cli, "code", "review", "run", "--help"], cwd=demo, env=env) + import_help = _run([*cli, "code", "import", "--help"], cwd=demo, env=env) + _assert_tokens_present(import_help.stdout, "specfact code import --help", ("from-code", "from-bridge")) + from_code_help = _run([*cli, "code", "import", "from-code", "--help"], cwd=demo, env=env) + if "from-code [OPTIONS]" not in _normalized_output(from_code_help.stdout): + raise AssertionError("`specfact code import from-code --help` did not render the explicit subcommand") + from_bridge_help = _run([*cli, "code", "import", "from-bridge", "--help"], cwd=demo, env=env) + if "from-bridge [OPTIONS]" not in _normalized_output(from_bridge_help.stdout): + raise AssertionError("`specfact code import from-bridge --help` did not render the explicit subcommand") + + +def _assert_project_help(cli: list[str], demo: Path, env: dict[str, str]) -> None: + export_help = _run([*cli, "project", "export", "--help"], cwd=demo, env=env) + if "project export" not in export_help.stdout: + raise AssertionError("`specfact project export --help` did not render the export command") + import_project_help = _run([*cli, "project", "import", "--help"], cwd=demo, env=env) + if "project import" not in import_project_help.stdout: + raise AssertionError("`specfact project import --help` did not render the import command") + sync_bridge_help = _run([*cli, "project", "sync", "bridge", "--help"], cwd=demo, env=env) + if "specfact project sync bridge" not in sync_bridge_help.stdout: + raise AssertionError("`specfact project sync bridge --help` did not include the canonical command path") + if "specfact sync bridge" in sync_bridge_help.stdout: + raise AssertionError("`specfact project sync bridge --help` still includes removed flat command examples") + + +def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, modules_repo: Path) -> None: + home = workspace / f"home-{name}" + home.mkdir(parents=True) + cli = _launcher_command(name, workspace / f"launcher-{name}") + env = _build_launcher_env(name, home, index_path, modules_repo) + + _install_marketplace_modules(cli, demo, env) + _assert_module_list_contains(_run([*cli, "module", "list"], cwd=demo, env=env)) + _run([*cli, "upgrade", "--help"], cwd=demo, env=env) _run([*cli, "module", "upgrade", "--help"], cwd=demo, env=env) + _run([*cli, "module", "upgrade", "--all", "--yes"], cwd=demo, env=env) init_result = _run([*cli, "init", "ide", "--ide", "cursor", "--repo", str(demo), "--force"], cwd=demo, env=env) _assert_no_env_warning(init_result) @@ -268,12 +390,8 @@ def _smoke_launcher(name: str, workspace: Path, demo: Path, index_path: Path, mo ) _assert_no_env_warning(explicit_result) - help_result = _run([*cli, "code", "--help"], cwd=demo, env=env) - for token in ("review", "import", "analyze", "drift", "validate", "repro"): - if token not in help_result.stdout: - raise AssertionError(f"`specfact code --help` did not include {token!r}") - _run([*cli, "code", "review", "run", "--help"], cwd=demo, env=env) - _run([*cli, "code", "import", "--help"], cwd=demo, env=env) + _assert_code_help(cli, demo, env) + _assert_project_help(cli, demo, env) LOGGER.info("runtime-discovery smoke passed for launcher=%s", name) @@ -289,7 +407,7 @@ def main() -> int: parser.add_argument( "--launcher", action="append", - choices=("direct", "pip-editable", "console", "uvx"), + choices=("direct", "hatch-source", "pip-editable", "pipx", "uv-run", "console", "uvx"), help="Launcher to test. Repeatable. Defaults to direct.", ) parser.add_argument("--keep-workspace", action="store_true", help="Keep the temporary workspace for debugging") diff --git a/setup.py b/setup.py index b9e0ab37..fa9736c8 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.46.28", + version="0.47.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 72ccbb7d..81221203 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.46.28" +__version__ = "0.47.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index be2c0e24..3e9bab8f 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -13,6 +13,7 @@ from __future__ import annotations +import importlib.util import os import sys from pathlib import Path @@ -25,6 +26,12 @@ def _candidate_modules_repo_roots() -> list[Path]: roots.append(Path(configured).expanduser()) this_file = Path(__file__).resolve() + parts = this_file.parts + if "specfact-cli-worktrees" in parts: + marker_index = parts.index("specfact-cli-worktrees") + base = Path(*parts[:marker_index]) + suffix = Path(*parts[marker_index + 1 : -3]) + roots.append(base / "specfact-cli-modules-worktrees" / suffix) for base in (this_file.parent.parent.parent, *this_file.parents): roots.append(base / "specfact-cli-modules") roots.append(base.parent / "specfact-cli-modules") @@ -45,6 +52,25 @@ def _bootstrap_bundle_paths() -> None: _bootstrap_bundle_paths() -__version__ = "0.46.28" + +def _install_progressive_disclosure() -> None: + module_name = "_specfact_progressive_disclosure_bootstrap" + if module_name in sys.modules: + return + module_path = Path(__file__).resolve().parent / "utils" / "progressive_disclosure.py" + spec = importlib.util.spec_from_file_location(module_name, module_path) + if spec is None or spec.loader is None: + return + module = importlib.util.module_from_spec(spec) + sys.modules[module_name] = module + spec.loader.exec_module(module) + + +# Install the shared Click/Typer usage-error contract as soon as core is imported. +# Module packages import specfact_cli before constructing direct Typer apps, so this +# keeps missing-command and missing-parameter UX consistent outside the root CLI too. +_install_progressive_disclosure() + +__version__ = "0.47.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 7264ec22..5f69d345 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -72,6 +72,12 @@ def _normalized_detect_shell(pid: int | None = None, max_depth: int = 10) -> tup from specfact_cli.utils.structured_io import StructuredFormat +try: + from typer._click.exceptions import UsageError as TyperUsageError +except ImportError: # pragma: no cover - older Typer delegates directly to Click + TyperUsageError = click.UsageError # type: ignore[assignment,misc] + + # Names of commands that come from installable bundles; when not registered, show actionable error. KNOWN_BUNDLE_GROUP_OR_SHIM_NAMES: frozenset[str] = frozenset( { @@ -165,9 +171,7 @@ class _RootCLIGroup(ProgressiveDisclosureGroup): """Root group that shows actionable error when an unknown command is a known bundle group/shim.""" @ensure(lambda result: isinstance(result, tuple) and len(result) == 3, "result must be a 3-tuple") - def resolve_command( - self, ctx: click.Context, args: list[str] - ) -> tuple[str | None, click.Command | None, list[str]]: + def resolve_command(self, ctx: Any, args: list[str]) -> Any: if not args: return super().resolve_command(ctx, args) invoked = args[0] @@ -567,6 +571,10 @@ def _raise_lazy_delegate_click_exception(exc: Exception) -> NoReturn: raise click.ClickException(str(exc)) from exc +def _is_group_like(command: object) -> bool: + return hasattr(command, "list_commands") and hasattr(command, "get_command") + + def _load_lazy_delegate_typer(cmd_name: str) -> typer.Typer: resolved_name = resolve_command(cmd_name) try: @@ -583,7 +591,7 @@ def _build_lazy_delegate_click_command(cmd_name: str, args: tuple[str, ...], rea from typer.main import get_command try: - return get_command(real_typer) + return cast(click.Command, get_command(real_typer)) except (RuntimeError, ValueError) as exc: if _args_request_help(args): _print_lazy_help_fallback(cmd_name, args) @@ -593,6 +601,15 @@ def _build_lazy_delegate_click_command(cmd_name: str, args: tuple[str, ...], rea def _lazy_delegate_prog_name(ctx: click.Context, cmd_name: str) -> str: + original_prog_name = ctx.meta.get("original_prog_name") + if not isinstance(original_prog_name, str) and ctx.parent is not None: + original_prog_name = ctx.parent.meta.get("original_prog_name") + if isinstance(original_prog_name, str) and original_prog_name: + return original_prog_name + if isinstance(ctx.info_name, str) and " " in ctx.info_name: + return ctx.info_name + if isinstance(ctx.command_path, str) and " " in ctx.command_path: + return ctx.command_path parts: list[str] = [] parent = ctx.parent while parent and getattr(parent, "command", None): @@ -601,20 +618,46 @@ def _lazy_delegate_prog_name(ctx: click.Context, cmd_name: str) -> str: parts.append(name) parent = getattr(parent, "parent", None) if parts: - return " ".join(reversed(parts)) - original_prog_name = ctx.meta.get("original_prog_name") - if isinstance(original_prog_name, str) and original_prog_name: - return original_prog_name + prog_name = " ".join(reversed(parts)) + if prog_name == cmd_name: + return f"specfact {cmd_name}" + return prog_name return cmd_name def _strip_redundant_single_command_arg(click_cmd: click.Command, args: tuple[str, ...]) -> list[str]: args_list = list(args) - if not isinstance(click_cmd, click.Group) and args_list and args_list[0] == getattr(click_cmd, "name", None): + if not _is_group_like(click_cmd) and args_list and args_list[0] == getattr(click_cmd, "name", None): return args_list[1:] return args_list +def _help_args_before_first_option(args: list[str]) -> list[str]: + help_args: list[str] = [] + for arg in args: + if arg.startswith("-"): + break + help_args.append(arg) + return help_args + + +def _lazy_usage_error_message(exc: Exception, command: click.Command, ctx: click.Context | None) -> str: + message = str(getattr(exc, "format_message", lambda: str(exc))()) + if not message.strip().lower().startswith("missing command"): + return message + names: list[str] = [] + group = ctx.command if ctx is not None else command + if _is_group_like(group): + try: + command_ctx = ctx or click.Context(cast(click.Command, group)) + names = [str(name) for name in group.list_commands(command_ctx)] + except Exception: + names = [] + if names: + return f"Missing subcommand. Choose one of: {', '.join(names)}." + return "Missing subcommand." + + def _lazy_delegate_remaining_args(ctx: click.Context) -> list[str]: ctx_state = vars(ctx) protected_args = ctx_state.get("_protected_args") or ctx_state.get("protected_args") or () @@ -632,7 +675,11 @@ def __init__(self, cmd_name: str, help_str: str, name: str | None = None, help: super().__init__( name=name or cmd_name, help=help or help_str, - context_settings={"ignore_unknown_options": True}, + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + "allow_interspersed_args": False, + }, invoke_without_command=True, no_args_is_help=False, ) @@ -641,37 +688,73 @@ def __init__(self, cmd_name: str, help_str: str, name: str | None = None, help: self._delegate_cmd = self._make_delegate_command() def _make_delegate_command(self) -> click.Command: - cmd_name = self._lazy_cmd_name - def _invoke(args: tuple[str, ...]) -> None: - ctx = click.get_current_context() - real_typer = _load_lazy_delegate_typer(cmd_name) - click_cmd = _build_lazy_delegate_click_command(cmd_name, args, real_typer) - # Build full prog name from root (e.g. "specfact sync") so usage shows "specfact sync bridge", not "sync sync bridge" - prog_name = _lazy_delegate_prog_name(ctx, cmd_name) - # 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"). - args_list = _strip_redundant_single_command_arg(click_cmd, args) - exit_code = click_cmd.main(args=args_list, prog_name=prog_name, standalone_mode=False) - if exit_code and exit_code != 0: - raise SystemExit(exit_code) + self._invoke_real_command(click.get_current_context(), args) return click.Command( "__delegate__", callback=_invoke, params=[click.Argument(["args"], nargs=-1, type=click.UNPROCESSED)], - context_settings={"ignore_unknown_options": True}, + context_settings={ + "ignore_unknown_options": True, + "allow_extra_args": True, + "allow_interspersed_args": False, + }, add_help_option=False, # Pass --help through to real Typer so "specfact backlog daily ado --help" shows correct usage ) + def _invoke_real_command(self, ctx: click.Context, args: tuple[str, ...] | list[str]) -> None: + cmd_name = self._lazy_cmd_name + real_typer = _load_lazy_delegate_typer(cmd_name) + args_tuple = tuple(args) + click_cmd = _build_lazy_delegate_click_command(cmd_name, args_tuple, real_typer) + prog_name = _lazy_delegate_prog_name(ctx, cmd_name) + args_list = _strip_redundant_single_command_arg(click_cmd, args_tuple) + try: + exit_code = click_cmd.main(args=args_list, prog_name=prog_name, standalone_mode=False) + except (click.UsageError, TyperUsageError) as exc: + if exc.ctx is None: + help_args = _help_args_before_first_option(args_list) + try: + click_cmd.main(args=[*help_args, "--help"], prog_name=prog_name, standalone_mode=False) + except BaseException as help_exit: + if not help_exit.__class__.__name__.endswith("Exit"): + raise + click.echo(f"Error: {_lazy_usage_error_message(exc, click_cmd, None)}", file=sys.stderr) + else: + try: + click.echo(exc.ctx.get_help(), file=sys.stderr) + click.echo("", file=sys.stderr) + except Exception: + pass + click.echo(f"Error: {_lazy_usage_error_message(exc, click_cmd, exc.ctx)}", file=sys.stderr) + raise SystemExit(exc.exit_code) from None + except BaseException as exc: + if exc.__class__.__name__.endswith("Exit"): + raise SystemExit(getattr(exc, "exit_code", 0)) from None + raise + if exit_code and exit_code != 0: + raise SystemExit(exit_code) + + def parse_args(self, ctx: click.Context, args: list[str]) -> list[str]: + if _args_request_help(args): + try: + return super().parse_args(ctx, args) + except click.exceptions.Exit as exc: + raise SystemExit(exc.exit_code) from None + ctx.args = list(args) + return [] + @require(lambda ctx: ctx is not None, "ctx must not be None") @ensure(lambda result: result is None or isinstance(result, int), "result must be None or an exit code") def invoke(self, ctx: click.Context) -> Any: if ctx.invoked_subcommand is None: args = _lazy_delegate_remaining_args(ctx) - ctx.meta["original_prog_name"] = ctx.command_path - return self._delegate_cmd.main(args=args, prog_name=ctx.command_path, standalone_mode=False) + command_path = ctx.command_path + if command_path == self._lazy_cmd_name: + command_path = f"specfact {self._lazy_cmd_name}" + ctx.meta["original_prog_name"] = command_path + return self._invoke_real_command(ctx, args) return super().invoke(ctx) @require(_lazy_delegate_cmd_name_ready, "lazy command name must be set") @@ -712,8 +795,8 @@ def _get_real_click_group(self) -> click.Group | None: click_cmd = get_command(real_typer) except (RuntimeError, ValueError): return None - if isinstance(click_cmd, click.Group): - return click_cmd + if _is_group_like(click_cmd): + return cast(click.Group, click_cmd) return None @require(_lazy_delegate_cmd_name_ready, "lazy command name must be set before formatting help") @@ -736,6 +819,10 @@ def format_help(self, ctx: click.Context, formatter: click.HelpFormatter) -> Non click_cmd.main(args=["-h"], prog_name=prog_name, standalone_mode=False) except SystemExit: raise # Re-raise so process exits (help was already printed with Rich) + except BaseException as exc: + if exc.__class__.__name__.endswith("Exit"): + raise SystemExit(getattr(exc, "exit_code", 0)) from None + raise # main() returned without exiting; Rich help was already printed, skip default formatter return @@ -748,7 +835,7 @@ def _build_lazy_delegate_group(cmd_name: str, help_str: str) -> click.Group: def _flatten_specfact_nested_subgroup(result: click.Group, flatten_name: str) -> None: """Merge a nested subgroup named `flatten_name` into its parent and re-sort command order.""" redundant = result.commands.pop(flatten_name) - if isinstance(redundant, click.Group): + if _is_group_like(redundant): for cmd_name, cmd in redundant.commands.items(): result.add_command(cmd, name=cmd_name) if result.commands: @@ -814,6 +901,7 @@ def _get_group_from_info_wrapper( _typer_get_group_from_info_original: Callable[..., click.Group] | None = None _typer_get_command_original: Callable[[typer.Typer], click.Command] | None = None _typer_get_params_original: Callable[..., Any] | None = None +_typer_get_params_convertors_original: Callable[..., Any] | None = None def _specfact_get_params_from_function(func: Callable[..., Any]) -> Any: @@ -838,6 +926,55 @@ def _specfact_get_params_from_function(func: Callable[..., Any]) -> Any: return orig(func) +def _is_context_annotation(annotation: object) -> bool: + if annotation in (click.Context, typer.Context): + return True + return getattr(annotation, "__name__", "") == "Context" and "click" in getattr(annotation, "__module__", "") + + +def _specfact_get_params_convertors_ctx_param_name_from_function(callback: Callable[..., Any] | None) -> Any: + """Treat both Click context classes as Typer context parameters across Typer releases.""" + assert _typer_get_params_convertors_original is not None + original_error: RuntimeError | None = None + try: + return _typer_get_params_convertors_original(callback) + except RuntimeError as exc: + if callback is None or "click.core.Context" not in str(exc): + raise + original_error = exc + import typer.utils as typer_utils + + params = _specfact_get_params_from_function(callback) + ctx_param_name: str | None = None + filtered_params: dict[str, Any] = {} + for name, param in params.items(): + if ctx_param_name is None and _is_context_annotation(getattr(param, "annotation", None)): + ctx_param_name = name + continue + filtered_params[name] = param + + if ctx_param_name is None: + raise RuntimeError("Unable to identify Click context parameter") from original_error + + typer_main = cast(Any, importlib.import_module("typer.main")) + previous_main_get_params = typer_main.get_params_from_function + previous_utils_get_params = typer_utils.get_params_from_function + + def _filtered_get_params(func: Callable[..., Any]) -> Any: + if func is callback: + return filtered_params + return previous_main_get_params(func) + + try: + typer_main.get_params_from_function = _filtered_get_params + typer_utils.get_params_from_function = _filtered_get_params + click_params, convertors, _ignored_ctx = _typer_get_params_convertors_original(callback) + finally: + typer_main.get_params_from_function = previous_main_get_params + typer_utils.get_params_from_function = previous_utils_get_params + return click_params, convertors, ctx_param_name + + # Patch so root app build uses our delegate group for lazy typers (built via get_group_from_info). def _patch_typer_build() -> None: import typer.utils as typer_utils @@ -845,6 +982,7 @@ def _patch_typer_build() -> None: typer_main = cast(Any, importlib.import_module("typer.main")) global _typer_get_group_from_info_original, _typer_get_command_original, _typer_get_params_original + global _typer_get_params_convertors_original # Save originals only on first patch; avoid overwriting with our wrapper when cli is re-imported (e.g. by plan module). if _typer_get_group_from_info_original is None: _typer_get_group_from_info_original = typer_main.get_group_from_info @@ -852,9 +990,14 @@ def _patch_typer_build() -> None: _typer_get_command_original = typer_main.get_command if _typer_get_params_original is None: _typer_get_params_original = typer_utils.get_params_from_function + if _typer_get_params_convertors_original is None: + _typer_get_params_convertors_original = typer_main.get_params_convertors_ctx_param_name_from_function typer_utils.get_params_from_function = _specfact_get_params_from_function # typer.main may have bound get_params_from_function at import time; keep in sync. typer_main.get_params_from_function = _specfact_get_params_from_function + typer_main.get_params_convertors_ctx_param_name_from_function = ( + _specfact_get_params_convertors_ctx_param_name_from_function + ) typer_main.get_command = _get_command typer_main.get_group_from_info = _get_group_from_info_wrapper diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index ee1afd69..59c5a5c1 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.33 +version: 0.1.34 commands: - init category: core @@ -17,5 +17,4 @@ publisher: description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:0086a4fa3e1f8744deee21640e73e6e2f24daf2b4a680b9157feed5487afc208 - signature: L7DtVqvg+zqc/CgJu/mOkvxYpfH9HuddbLS3EK5Uvnf6K/j/BofqopamZDdHVlevOGAb+dbsk06kskNOIJfkDQ== + checksum: sha256:61f8486ee635a8c4de3ae856a58f5343edc0d30852c7b1ebd962e944743a8f5c diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index ca849db9..dde213b2 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -7,7 +7,6 @@ from pathlib import Path from typing import Any, cast -import click import typer from beartype import beartype from icontract import ensure, require @@ -614,7 +613,10 @@ def init_ide( 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)", + help=( + "IDE/agent target (cursor, vscode, copilot, claude, claude-skills, codex, mistral, vibe, " + "gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto)" + ), ), env_manager: EnvManager = typer.Option( EnvManager.AUTO, @@ -712,9 +714,8 @@ def init_ide( @app.callback(invoke_without_command=True) @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, + ctx: typer.Context, repo: Path = typer.Option( Path("."), "--repo", diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index 9f661c30..f9135821 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -1,5 +1,5 @@ name: upgrade -version: 0.1.19 +version: 0.1.20 commands: - upgrade category: core @@ -17,5 +17,4 @@ publisher: description: Check and apply SpecFact CLI version upgrades. license: Apache-2.0 integrity: - checksum: sha256:fda269f874f7b61ad5ec8217ba1550e49b1e2e17b23aafd08b39df1a98d66ee1 - signature: yx89WfwC8DQUkAw2WD1EdHp08Ug0dFw3gNbIXbwgcdoxUBa9qA/0TaHHNBbyhVIxgL6wb+H4XCbYjdwb6XU1Bg== + checksum: sha256:812c41fd674958172579af1f6cf7f1ade389db9e8102ac94ca9b872ecfe43376 diff --git a/src/specfact_cli/modules/upgrade/src/commands.py b/src/specfact_cli/modules/upgrade/src/commands.py index 7f8234d5..9ad791ad 100644 --- a/src/specfact_cli/modules/upgrade/src/commands.py +++ b/src/specfact_cli/modules/upgrade/src/commands.py @@ -11,6 +11,7 @@ import os import shlex +import shutil import subprocess import sys from datetime import UTC @@ -67,6 +68,10 @@ def detect_installation_method() -> InstallationMethod: if uv_method: return uv_method + uv_method = _detect_uv_run_installation(executable_path) + if uv_method: + return uv_method + pipx_method = _detect_pipx_installation() if pipx_method: return pipx_method @@ -125,6 +130,18 @@ def _detect_uv_project_installation(executable_path: str) -> InstallationMethod return None +def _detect_uv_run_installation(executable_path: str) -> InstallationMethod | None: + uv_context_keys = ("UV_RUN_RECURSION", "UV") + if not any(os.environ.get(key, "").strip() for key in uv_context_keys): + return None + executable_text = str(Path(executable_path)) + return InstallationMethod( + method="uv", + command=f"uv pip install --python {shlex.quote(executable_text)} --upgrade specfact-cli", + location=executable_text, + ) + + def _detect_uv_tool_installation() -> InstallationMethod | None: try: result = subprocess.run( @@ -245,6 +262,8 @@ def _execute_upgrade_command(command: list[str]) -> bool: stderr = _filter_pipx_spaced_home_warning(stderr) _replay_upgrade_output(stdout) _replay_upgrade_output(stderr) + if _is_pipx_upgrade_command(command) and not _ensure_pipx_launcher_healthy(): + return False console.print("[green]✓ Update successful![/green]") from datetime import datetime @@ -269,6 +288,59 @@ def _is_pipx_upgrade_command(command: list[str]) -> bool: return len(command) >= 3 and command[:3] == ["pipx", "upgrade", "specfact-cli"] +def _ensure_pipx_launcher_healthy() -> bool: + """Validate the public specfact launcher after pipx upgrade and repair stale shims.""" + launcher = shutil.which("specfact") + if not launcher: + console.print( + "[yellow]⚠ Could not find `specfact` on PATH after pipx upgrade; skipping launcher validation.[/yellow]" + ) + return True + + first_check = _run_launcher_version_check(launcher) + if first_check.returncode == 0: + _replay_upgrade_output(_coerce_subprocess_output(first_check.stdout)) + _replay_upgrade_output(_coerce_subprocess_output(first_check.stderr)) + return True + + console.print("[yellow]⚠ pipx launcher is stale or broken; running `pipx reinstall specfact-cli`.[/yellow]") + _replay_upgrade_output(_coerce_subprocess_output(first_check.stdout)) + _replay_upgrade_output(_coerce_subprocess_output(first_check.stderr)) + reinstall = _run_pipx_reinstall() + _replay_upgrade_output(_coerce_subprocess_output(reinstall.stdout)) + _replay_upgrade_output(_coerce_subprocess_output(reinstall.stderr)) + if reinstall.returncode != 0: + console.print(f"[red]✗ pipx reinstall specfact-cli failed with exit code {reinstall.returncode}[/red]") + return False + + second_check = _run_launcher_version_check(launcher) + _replay_upgrade_output(_coerce_subprocess_output(second_check.stdout)) + _replay_upgrade_output(_coerce_subprocess_output(second_check.stderr)) + if second_check.returncode != 0: + console.print("[red]✗ pipx launcher still fails after reinstall[/red]") + return False + console.print("[green]✓ pipx launcher repaired and validated[/green]") + return True + + +def _run_launcher_version_check(launcher: str) -> subprocess.CompletedProcess[bytes]: + """Run the installed launcher version check without invoking a shell.""" + try: + return subprocess.run([launcher, "--version"], check=False, timeout=30, capture_output=True) + except (OSError, subprocess.TimeoutExpired) as exc: + return subprocess.CompletedProcess([launcher, "--version"], 1, stdout=b"", stderr=str(exc).encode()) + + +def _run_pipx_reinstall() -> subprocess.CompletedProcess[bytes]: + """Repair a stale pipx launcher by reinstalling the package.""" + try: + return subprocess.run(["pipx", "reinstall", "specfact-cli"], check=False, timeout=300, capture_output=True) + except (OSError, subprocess.TimeoutExpired) as exc: + return subprocess.CompletedProcess( + ["pipx", "reinstall", "specfact-cli"], 1, stdout=b"", stderr=str(exc).encode() + ) + + def _replay_upgrade_output(output: str) -> None: """Replay captured child-process output without Rich markup parsing.""" if output: diff --git a/src/specfact_cli/utils/bundle_loader.py b/src/specfact_cli/utils/bundle_loader.py index c7635399..efc20340 100644 --- a/src/specfact_cli/utils/bundle_loader.py +++ b/src/specfact_cli/utils/bundle_loader.py @@ -133,7 +133,7 @@ def validate_bundle_format(path: Path) -> BundleFormat: error_msg += "\n - Monolithic: Single file with 'idea', 'product', 'features' keys" error_msg += "\n - Modular: Directory with 'bundle.manifest.yaml' file" error_msg += "\n\nTo migrate from monolithic to modular format, run:" - error_msg += "\n specfact migrate bundle " + error_msg += "\n specfact project migrate artifacts --repo ." raise BundleFormatError(error_msg) return format_type diff --git a/src/specfact_cli/utils/env_manager.py b/src/specfact_cli/utils/env_manager.py index fc08d83c..671a1d31 100644 --- a/src/specfact_cli/utils/env_manager.py +++ b/src/specfact_cli/utils/env_manager.py @@ -9,6 +9,7 @@ from __future__ import annotations import shutil +import subprocess from dataclasses import dataclass from enum import StrEnum from pathlib import Path @@ -361,11 +362,24 @@ def check_tool_in_env( if shutil.which(tool_name) is not None: return True, None - # If environment manager is available, check if tool might be in that environment if env_info.available and env_info.command_prefix: - # We can't easily check if tool is in the environment without running it - # So we'll return True with a message that it might be available - return True, f"Tool '{tool_name}' not in PATH, but may be available in {env_info.manager.value} environment" + probe_command = build_tool_command(env_info, [tool_name, "--version"]) + try: + result = subprocess.run( + probe_command, + cwd=repo_path, + capture_output=True, + text=True, + timeout=10, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as exc: + return False, f"Tool '{tool_name}' not available in {env_info.manager.value} environment: {exc}" + if result.returncode == 0: + return True, None + detail = (result.stderr or result.stdout or "").strip() + suffix = f": {detail}" if detail else "" + return False, f"Tool '{tool_name}' not available in {env_info.manager.value} environment{suffix}" # Tool not found return False, f"Tool '{tool_name}' not found. Install with: pip install {tool_name} or use your environment manager" diff --git a/src/specfact_cli/utils/github_annotations.py b/src/specfact_cli/utils/github_annotations.py index 9570c11b..fb253473 100644 --- a/src/specfact_cli/utils/github_annotations.py +++ b/src/specfact_cli/utils/github_annotations.py @@ -45,7 +45,7 @@ def _append_pr_failed_check_details(lines: list[str], check: dict[str, Any]) -> lines.append("\n\n") if tool == "semgrep": lines.append( - "💡 **Auto-fix available**: Run `specfact repro --fix` to apply automatic fixes for violations with fix capabilities.\n\n" + "💡 **Auto-fix available**: Run `specfact code repro --fix` to apply automatic fixes for violations with fix capabilities.\n\n" ) @@ -294,7 +294,7 @@ def _append_pr_comment_detail_sections( lines.append("### 💡 Suggestions\n\n") lines.append("1. Review the failed checks above") lines.append("2. Fix the issues in your code") - lines.append("3. Re-run validation: `specfact repro --budget 90`\n\n") + lines.append("3. Re-run validation: `specfact code repro --budget 90`\n\n") lines.append("To run in warn mode (non-blocking), set `mode: warn` in your workflow configuration.\n\n") diff --git a/src/specfact_cli/utils/ide_setup.py b/src/specfact_cli/utils/ide_setup.py index 702b18b5..ccc045f4 100644 --- a/src/specfact_cli/utils/ide_setup.py +++ b/src/specfact_cli/utils/ide_setup.py @@ -7,6 +7,7 @@ from __future__ import annotations +import contextlib import os import re import shutil @@ -44,6 +45,18 @@ "format": "md", "settings_file": None, }, + "claude-skills": { + "name": "Claude Code Skills", + "folder": ".claude/skills/", + "format": "skill.md", + "settings_file": None, + }, + "codex": { + "name": "Codex CLI", + "folder": ".codex/skills/", + "format": "skill.md", + "settings_file": None, + }, "copilot": { "name": "GitHub Copilot", "folder": ".github/prompts/", @@ -62,6 +75,18 @@ "format": "md", "settings_file": None, }, + "mistral": { + "name": "Mistral Vibe", + "folder": ".vibe/skills/", + "format": "skill.md", + "settings_file": None, + }, + "vibe": { + "name": "Mistral Vibe", + "folder": ".vibe/skills/", + "format": "skill.md", + "settings_file": None, + }, "gemini": { "name": "Gemini CLI", "folder": ".gemini/commands/", @@ -402,6 +427,20 @@ def _output_filename_for_template(template_path: Path, format_type: str) -> str: return template_path.name +def _source_id_to_skill_name(source_id: str) -> str: + """Map a prompt source id to a capability-oriented skill directory name.""" + if source_id == PROMPT_SOURCE_CORE: + return "specfact-cli" + raw_name = source_id.strip().split("/")[-1] or source_id.strip() + normalized = re.sub(r"[^A-Za-z0-9]+", "-", raw_name).strip("-").lower() + return normalized or "specfact-cli" + + +def _skill_output_name_for_source(source_id: str) -> str: + """Return the relative SKILL.md path for a prompt source.""" + return f"{_source_id_to_skill_name(source_id)}/SKILL.md" + + def _merge_prompt_export_outputs_by_basename( prompts_by_source: dict[str, list[Path]], format_type: str, @@ -446,6 +485,8 @@ def _flat_export_glob_pattern_for_prune(format_type: str) -> str: return "specfact*.prompt.md" if format_type == "toml": return "specfact*.toml" + if format_type == "skill.md": + return "specfact*/SKILL.md" return "specfact*.md" @@ -464,11 +505,16 @@ def _prune_flat_specfact_exports_not_in_expected( for p in base.glob(pattern): if not p.is_file(): continue - if p.name in expected_output_names: + rel_name = p.relative_to(base).as_posix() + if p.name in expected_output_names or rel_name in expected_output_names: continue try: p.unlink() console.print(f"[dim]Removed stale prompt export:[/dim] {p}") + parent = p.parent + if format_type == "skill.md" and parent != base: + with contextlib.suppress(OSError): + parent.rmdir() except OSError as exc: console.print(f"[yellow]Could not remove stale export {p}:[/yellow] {exc}") @@ -510,6 +556,7 @@ def _copy_template_files_to_ide( template_data = read_template(template_path) processed_content = process_template(template_data["content"], template_data["description"], format_type) # type: ignore[arg-type] output_path = ide_dir / _output_filename_for_template(template_path, format_type) + output_path.parent.mkdir(parents=True, exist_ok=True) if output_path.exists() and not force: console.print(f"[yellow]Skipping:[/yellow] {output_path} (already exists, use --force to overwrite)") @@ -529,6 +576,99 @@ def _copy_template_files_to_ide( return copied_files, settings_path +def _ordered_prompt_source_items(prompts_by_source: dict[str, list[Path]]) -> list[tuple[str, list[Path]]]: + """Return source prompt groups in deterministic core-first order.""" + return sorted( + prompts_by_source.items(), + key=lambda item: (item[0] != PROMPT_SOURCE_CORE, item[0]), + ) + + +def _skill_title_for_source(source_id: str) -> str: + """Human-readable title for a grouped SpecFact skill.""" + if source_id == PROMPT_SOURCE_CORE: + return "SpecFact CLI" + return _source_id_to_skill_name(source_id).replace("-", " ").title() + + +def _skill_description_for_source(source_id: str, template_files: list[Path]) -> str: + """Short agent-facing description for a grouped SpecFact skill.""" + stems = ", ".join(path.stem for path in template_files[:4]) + suffix = f" Workflows include {stems}." if stems else "" + if source_id == PROMPT_SOURCE_CORE: + return f"Use SpecFact CLI core workflows and verify current command syntax with CLI help.{suffix}" + return f"Use SpecFact {source_id.split('/')[-1]} module workflows and verify current command syntax with CLI help.{suffix}" + + +def _yaml_single_quoted(value: str) -> str: + """Render a scalar string for simple YAML frontmatter.""" + return "'" + value.replace("'", "''") + "'" + + +def _render_skill_body_for_source(source_id: str, template_files: list[Path]) -> str: + """Render one capability-oriented SKILL.md containing all prompts from a source/module.""" + skill_name = _source_id_to_skill_name(source_id) + description = _skill_description_for_source(source_id, template_files) + description_yaml = _yaml_single_quoted(description) + title = _skill_title_for_source(source_id) + sections: list[str] = [ + "---", + f"name: {skill_name}", + f"description: {description_yaml}", + "---", + "", + f"# {title}", + "", + ( + "Use this skill for SpecFact workflows from this source. Treat the embedded workflows as " + "operating guidance, not as the source of truth. Before running a command, verify current syntax " + "with the nearest `specfact ... --help` output or the generated `llms.txt` command overview." + ), + "", + ] + for template_path in sorted(template_files, key=lambda path: path.name): + template_data = read_template(template_path) + description_line = template_data["description"].strip() + sections.append(f"## {template_path.stem}") + if description_line: + sections.append("") + sections.append(description_line) + sections.append("") + sections.append(template_data["content"].strip()) + sections.append("") + return "\n".join(sections).rstrip() + "\n" + + +def _copy_skill_bundles_to_ide( + repo_path: Path, + ide: str, + prompts_by_source: dict[str, list[Path]], + force: bool = False, +) -> tuple[list[Path], None]: + """Copy source/module prompt groups to skill-based IDE targets.""" + config = IDE_CONFIG[ide] + ide_dir = repo_path / str(config["folder"]) + ide_dir.mkdir(parents=True, exist_ok=True) + + expected = {_skill_output_name_for_source(source_id) for source_id in prompts_by_source} + _prune_flat_specfact_exports_not_in_expected(repo_path, ide, expected) + + copied_files: list[Path] = [] + for source_id, template_files in _ordered_prompt_source_items(prompts_by_source): + if not template_files: + continue + output_path = ide_dir / _skill_output_name_for_source(source_id) + output_path.parent.mkdir(parents=True, exist_ok=True) + if output_path.exists() and not force: + console.print(f"[yellow]Skipping:[/yellow] {output_path} (already exists, use --force to overwrite)") + continue + output_path.write_text(_render_skill_body_for_source(source_id, template_files), encoding="utf-8") + copied_files.append(output_path) + console.print(f"[green]Copied:[/green] {output_path}") + + return copied_files, None + + @beartype @require(repo_path_exists, "Repo path must exist") @require(repo_path_is_dir, "Repo path must be a directory") @@ -558,6 +698,8 @@ def expected_ide_prompt_export_paths( catalog = discover_prompt_sources_catalog(repo_path) if prompt_source_ids is not None: catalog = {k: v for k, v in catalog.items() if k in prompt_source_ids} + if format_type == "skill.md": + return [base / _skill_output_name_for_source(source_id) for source_id in sorted(catalog)] merged = _merge_prompt_export_outputs_by_basename(catalog, format_type) return [base / name for name in sorted(merged.keys())] @@ -587,6 +729,16 @@ def count_outdated_ide_prompt_exports( catalog = discover_prompt_sources_catalog(repo_path) if prompt_source_ids is not None: catalog = {k: v for k, v in catalog.items() if k in prompt_source_ids} + if format_type == "skill.md": + outdated = 0 + for source_id, template_files in catalog.items(): + dest = base / _skill_output_name_for_source(source_id) + if not dest.exists(): + continue + newest_source_mtime = max((src.stat().st_mtime for src in template_files if src.exists()), default=0) + if newest_source_mtime and dest.stat().st_mtime < newest_source_mtime: + outdated += 1 + return outdated merged = _merge_prompt_export_outputs_by_basename(catalog, format_type) outdated = 0 for dest_name, src in merged.items(): @@ -620,6 +772,8 @@ def copy_prompts_by_source_to_ide( config = IDE_CONFIG[ide] format_type = str(config["format"]) _cleanup_legacy_multisource_segment_dirs(repo_path, ide) + if format_type == "skill.md": + return _copy_skill_bundles_to_ide(repo_path, ide, prompts_by_source, force) merged = _merge_prompt_export_outputs_by_basename(prompts_by_source, format_type) _prune_flat_specfact_exports_not_in_expected(repo_path, ide, set(merged.keys())) template_list = list(merged.values()) @@ -793,7 +947,10 @@ def copy_templates_to_ide( >>> len(copied) > 0 True """ - return _copy_template_files_to_ide(repo_path, ide, _iter_prompt_template_files(templates_dir), force) + template_files = _iter_prompt_template_files(templates_dir) + if str(IDE_CONFIG[ide]["format"]) == "skill.md": + return _copy_skill_bundles_to_ide(repo_path, ide, {PROMPT_SOURCE_CORE: template_files}, force) + return _copy_template_files_to_ide(repo_path, ide, template_files, force) def _vscode_prompt_recommendation_paths_from_sources(prompts_by_source: dict[str, list[Path]]) -> list[str]: diff --git a/src/specfact_cli/utils/progressive_disclosure.py b/src/specfact_cli/utils/progressive_disclosure.py index a7633733..28e98d92 100644 --- a/src/specfact_cli/utils/progressive_disclosure.py +++ b/src/specfact_cli/utils/progressive_disclosure.py @@ -9,15 +9,31 @@ import os import sys -from typing import Any +from typing import Any, cast +import click from beartype import beartype -from click.core import Command, Context as ClickContext +from click.core import Command +from click.exceptions import UsageError from icontract import ensure from rich.console import Console from typer.core import TyperCommand, TyperGroup +try: + from typer._click import core as _typer_click_core + from typer._click.exceptions import ClickException as TyperClickException, UsageError as TyperUsageError +except ImportError: # pragma: no cover - only older Typer layouts lack this namespace + _typer_click_core = None # type: ignore[assignment] + TyperClickException = click.ClickException # type: ignore[assignment,misc] + TyperUsageError = UsageError # type: ignore[assignment,misc] + +try: + import typer.rich_utils as _typer_rich_utils +except ImportError: # pragma: no cover - Typer is a hard dependency in normal runtime + _typer_rich_utils = None # type: ignore[assignment] + + console = Console() # Global flag to track if advanced help is requested @@ -26,6 +42,19 @@ # Store original methods (must be done before we define helper functions) _original_get_params = Command.get_params _original_make_context = Command.make_context +_original_usage_error_show = UsageError.show +_original_group_parse_args = click.Group.parse_args +_original_typer_get_params = ( + _typer_click_core.Command.get_params if _typer_click_core is not None else _original_get_params +) +_original_typer_make_context = ( + _typer_click_core.Command.make_context if _typer_click_core is not None else _original_make_context +) +_original_typer_usage_error_show = TyperUsageError.show +_typer_group_class = getattr(_typer_click_core, "Group", TyperGroup) if _typer_click_core is not None else TyperGroup +_original_typer_group_parse_args = getattr(_typer_group_class, "parse_args", _original_group_parse_args) +_original_rich_format_error = _typer_rich_utils.rich_format_error if _typer_rich_utils is not None else None +_USAGE_ERROR_TYPES = (UsageError, TyperUsageError) @beartype @@ -83,7 +112,7 @@ def intercept_help_advanced() -> None: @beartype -def _is_advanced_help_context(ctx: ClickContext | None) -> bool: +def _is_advanced_help_context(ctx: Any | None) -> bool: """Check if this context is for showing advanced help.""" # Check sys.argv directly first if "--help-advanced" in sys.argv or "-ha" in sys.argv: @@ -101,7 +130,7 @@ class ProgressiveDisclosureGroup(TyperGroup): @beartype @ensure(lambda result: isinstance(result, list), "returns param list") - def get_params(self, ctx: ClickContext) -> list[Any]: + def get_params(self, ctx: Any) -> list[Any]: """ Override get_params to include hidden options when advanced help is requested. @@ -166,7 +195,7 @@ def _set_help_text(self, text: str) -> None: @beartype @ensure(lambda result: result is None, "formatter returns None") - def format_help(self, ctx: ClickContext, formatter: Any) -> None: + def format_help(self, ctx: Any, formatter: Any) -> None: """ Override format_help to conditionally show advanced options in docstring. @@ -188,7 +217,7 @@ def format_help(self, ctx: ClickContext, formatter: Any) -> None: @beartype @ensure(lambda result: isinstance(result, list), "returns param list") - def get_params(self, ctx: ClickContext) -> list[Any]: + def get_params(self, ctx: Any) -> list[Any]: """ Override get_params to include hidden options when advanced help is requested. @@ -245,7 +274,7 @@ def get_hidden_value() -> bool: @beartype -def _patched_get_params(self: Command, ctx: ClickContext) -> list[Any]: +def _patched_get_params(self: Any, ctx: Any) -> list[Any]: """ Patched get_params that includes hidden options when advanced help is requested. @@ -274,11 +303,16 @@ def _patched_get_params(self: Command, ctx: ClickContext) -> list[Any]: return all_params # Otherwise, use original behavior (filter out hidden params) - return _original_get_params(self, ctx) + original_get_params = ( + _original_typer_get_params + if _typer_click_core is not None and isinstance(self, _typer_click_core.Command) + else _original_get_params + ) + return cast(Any, original_get_params)(self, ctx) @beartype -def _ensure_help_advanced_in_context_settings(self: Command) -> None: +def _ensure_help_advanced_in_context_settings(self: Any) -> None: """Ensure --help-advanced and --help are in context_settings.help_option_names.""" # Get or create context settings if self.context_settings is None: @@ -303,17 +337,99 @@ def _ensure_help_advanced_in_context_settings(self: Command) -> None: self.context_settings["help_option_names"] = help_option_names -# Remove parse_args patch - we handle it in intercept_help_advanced instead +# Shared usage-error rendering helpers. +def _error_output_stream(ctx: Any | None, file: Any | None) -> Any: + if file is not None: + return file + return sys.stderr + + +def _available_subcommands_text(command: Any, ctx: Any) -> str: + if not (hasattr(command, "list_commands") and hasattr(command, "get_command")): + return "" + names = command.list_commands(ctx) + if not names: + return "" + return ", ".join(names) + + +def _missing_subcommand_message(command: Any, ctx: Any) -> str: + available = _available_subcommands_text(command, ctx) + if available: + return f"Error: Missing subcommand. Choose one of: {available}." + return "Error: Missing subcommand." + + +def _show_context_help_for_usage_error(error: Any, file: Any | None) -> None: + ctx = error.ctx + if ctx is None: + return + output = _error_output_stream(ctx, file) + try: + click.echo(ctx.get_help(), file=output) + click.echo("", file=output) + except Exception: + return + + +def _should_add_missing_subcommand_hint(error: Any) -> bool: + return str(error).strip().lower().startswith("missing command") + + +def _patched_usage_error_show(self: Any, file: Any | None = None) -> None: + _show_context_help_for_usage_error(self, file) + original_show = ( + _original_typer_usage_error_show if isinstance(self, TyperUsageError) else _original_usage_error_show + ) + cast(Any, original_show)(self, file=file) + if self.ctx is not None and _should_add_missing_subcommand_hint(self): + click.echo(_missing_subcommand_message(self.ctx.command, self.ctx), file=_error_output_stream(self.ctx, file)) + + +def _patched_group_parse_args(self: Any, ctx: Any, args: list[str]) -> list[str]: + if not args and self.no_args_is_help and self.commands and not ctx.resilient_parsing: + output = _error_output_stream(ctx, None) + click.echo(ctx.get_help(), file=output) + click.echo("", file=output) + click.echo(_missing_subcommand_message(self, ctx), file=output) + ctx.exit(2) + original_parse_args = ( + _original_typer_group_parse_args + if isinstance(self, (TyperGroup, _typer_group_class)) + else _original_group_parse_args + ) + return original_parse_args(self, ctx, args) + + +def _patched_rich_format_error(error: Any) -> None: + if isinstance(error, _USAGE_ERROR_TYPES): + _show_context_help_for_usage_error(error, None) + if error.ctx is not None and _should_add_missing_subcommand_hint(error): + click.echo( + _missing_subcommand_message(error.ctx.command, error.ctx), file=_error_output_stream(error.ctx, None) + ) + return + click.echo(f"Error: {error.format_message()}", file=_error_output_stream(error.ctx, None)) + if error.ctx is not None and cast(Any, error.ctx.command).get_help_option(error.ctx) is not None: + help_option = "--help" if "--help" in error.ctx.help_option_names else error.ctx.help_option_names[0] + click.echo( + f"Try '{error.ctx.command_path} {help_option}' for help.", file=_error_output_stream(error.ctx, None) + ) + return + if _original_rich_format_error is not None: + _original_rich_format_error(error) + else: + error.show() @beartype def _patched_make_context( - self: Command, + self: Any, info_name: str | None = None, args: list[str] | None = None, - parent: ClickContext | None = None, + parent: Any | None = None, **extra: Any, -) -> ClickContext: +) -> Any: """ Patched make_context that ensures --help-advanced is always in help_option_names. @@ -327,10 +443,25 @@ def _patched_make_context( if args is None: args = [] - return _original_make_context(self, info_name, args, parent, **extra) + original_make_context = ( + _original_typer_make_context + if _typer_click_core is not None and isinstance(self, _typer_click_core.Command) + else _original_make_context + ) + return cast(Any, original_make_context)(self, info_name, args, parent, **extra) # Monkey-patch Click's Command class to use our patched methods # This must happen after all helper functions are defined Command.get_params = _patched_get_params # type: ignore[assignment] Command.make_context = _patched_make_context # type: ignore[assignment] +UsageError.show = _patched_usage_error_show # type: ignore[assignment] +click.Group.parse_args = _patched_group_parse_args # type: ignore[assignment] +if _typer_click_core is not None: + _typer_click_core.Command.get_params = _patched_get_params + _typer_click_core.Command.make_context = _patched_make_context + TyperUsageError.show = _patched_usage_error_show # type: ignore[assignment] + _typer_group_class.parse_args = _patched_group_parse_args +TyperGroup.parse_args = _patched_group_parse_args # type: ignore[method-assign] +if _typer_rich_utils is not None: + _typer_rich_utils.rich_format_error = _patched_rich_format_error diff --git a/src/specfact_cli/utils/structure.py b/src/specfact_cli/utils/structure.py index 98a7941a..043dc480 100644 --- a/src/specfact_cli/utils/structure.py +++ b/src/specfact_cli/utils/structure.py @@ -261,7 +261,7 @@ def get_default_plan_path( if legacy_config_path.exists(): raise FileNotFoundError( "Legacy plan configuration detected at .specfact/plans/config.yaml. " - "Please migrate to the new bundle structure using 'specfact migrate artifacts --repo .'." + "Please migrate to the new bundle structure using 'specfact project migrate artifacts --repo .'." ) # No active bundle found - return default bundle directory path (may not exist) @@ -977,13 +977,13 @@ def create_readme(cls, base_path: Path | None = None) -> None: ```bash # Create a new plan -specfact plan init --interactive +specfact project plan init --interactive # Analyze existing code specfact code import --repo . # Compare plans - specfact plan compare --manual .specfact/plans/main.bundle.yaml --auto .specfact/plans/auto-derived-.bundle.yaml + specfact project plan compare --manual .specfact/plans/main.bundle.yaml --auto .specfact/plans/auto-derived-.bundle.yaml ``` """ readme_path.write_text(readme_content) diff --git a/src/specfact_cli/utils/suggestions.py b/src/specfact_cli/utils/suggestions.py index d224ad9b..b1860d9b 100644 --- a/src/specfact_cli/utils/suggestions.py +++ b/src/specfact_cli/utils/suggestions.py @@ -61,11 +61,11 @@ def suggest_next_steps(repo_path: Path, context: ProjectContext | None = None) - # Enforcement suggestions if context.has_plan and not context.last_enforcement: - suggestions.append("specfact enforce sdd --bundle # Enforce quality gates") + suggestions.append("specfact govern enforce sdd # Enforce quality gates") # Sync suggestions if context.has_plan: - suggestions.append("specfact sync intelligent --bundle # Sync code and specs") + suggestions.append("specfact project sync intelligent # Sync code and specs") return suggestions @@ -90,7 +90,7 @@ def suggest_fixes(error_message: str, context: ProjectContext | None = None) -> # Bundle not found if "bundle" in error_lower and ("not found" in error_lower or "does not exist" in error_lower): - suggestions.append("specfact plan select # Select an active plan bundle") + suggestions.append("specfact project plan select # Select an active plan bundle") suggestions.append("specfact code import --repo . # Create a new bundle from code") # Contract validation errors @@ -139,7 +139,7 @@ def suggest_improvements(context: ProjectContext) -> list[str]: # Outdated enforcement if context.last_enforcement: - suggestions.append("specfact enforce sdd --bundle # Re-run quality gates") + suggestions.append("specfact govern enforce sdd # Re-run quality gates") return suggestions diff --git a/tests/conftest.py b/tests/conftest.py index 20434f96..0196cfdc 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -30,6 +30,14 @@ def _resolve_modules_repo_root() -> Path: configured = os.environ.get("SPECFACT_MODULES_REPO") if configured: return Path(configured).expanduser().resolve() + root_parts = project_root.parts + if "specfact-cli-worktrees" in root_parts: + marker_index = root_parts.index("specfact-cli-worktrees") + paired = ( + Path(*root_parts[:marker_index]) / "specfact-cli-modules-worktrees" / Path(*root_parts[marker_index + 1 :]) + ) + if paired.exists(): + return paired.resolve() for candidate_base in (project_root, *project_root.parents): sibling_repo = candidate_base / "specfact-cli-modules" if sibling_repo.exists(): diff --git a/tests/integration/scripts/test_runtime_discovery_smoke.py b/tests/integration/scripts/test_runtime_discovery_smoke.py index 11c16d09..b86e9d53 100644 --- a/tests/integration/scripts/test_runtime_discovery_smoke.py +++ b/tests/integration/scripts/test_runtime_discovery_smoke.py @@ -18,6 +18,12 @@ def _resolve_modules_repo() -> Path | None: if configured: candidates.append(Path(configured).expanduser()) candidates.append(REPO_ROOT.parent / "specfact-cli-modules") + parts = REPO_ROOT.parts + if "specfact-cli-worktrees" in parts: + marker_index = parts.index("specfact-cli-worktrees") + candidates.append( + Path(*parts[:marker_index]) / "specfact-cli-modules-worktrees" / Path(*parts[marker_index + 1 :]) + ) if len(REPO_ROOT.parents) > 2: candidates.append(REPO_ROOT.parents[2] / "specfact-cli-modules") for candidate in candidates: diff --git a/tests/unit/cli/test_error_guidance.py b/tests/unit/cli/test_error_guidance.py new file mode 100644 index 00000000..baf682be --- /dev/null +++ b/tests/unit/cli/test_error_guidance.py @@ -0,0 +1,113 @@ +from __future__ import annotations + +from typing import Any, cast + +import pytest +import typer +from click.testing import CliRunner +from typer.main import get_command +from typer.testing import CliRunner as TyperCliRunner + +from specfact_cli.cli import app + + +def test_unknown_root_command_shows_help_and_recovery_guidance() -> None: + runner = CliRunner() + result = runner.invoke(cast(Any, get_command(app)), ["hello"]) + + assert result.exit_code != 0 + output = result.output.lower() + assert "usage: specfact" in output + assert "hello" in output + assert "not a valid command" in output or "no such command" in output + assert "try" in output + assert "specfact --help" in output or "specfact -h" in output + + +def _sample_app() -> typer.Typer: + sample = typer.Typer(name="sample") + widgets = typer.Typer(help="Manage widgets.") + + @widgets.command("list") + def list_widgets() -> None: + typer.echo("listed") + + @widgets.command("deploy") + def deploy_widget(target: str) -> None: + typer.echo(target) + + sample.add_typer(widgets, name="widgets") + return sample + + +def test_global_group_without_subcommand_shows_help_and_missing_subcommand() -> None: + result = CliRunner().invoke(cast(Any, get_command(_sample_app())), ["widgets"]) + + assert result.exit_code == 2 + output = result.output.lower() + assert "usage:" in output + assert "manage widgets" in output + assert "list" in output + assert "deploy" in output + assert "missing subcommand" in output + + +def test_global_leaf_missing_argument_shows_help_and_missing_parameter() -> None: + result = CliRunner().invoke(cast(Any, get_command(_sample_app())), ["widgets", "deploy"]) + + assert result.exit_code == 2 + output = result.output.lower() + assert "usage:" in output + assert "deploy" in output + assert "target" in output + assert "missing" in output + + +def test_global_nested_unknown_command_shows_group_help_and_invalid_command() -> None: + result = CliRunner().invoke(cast(Any, get_command(_sample_app())), ["widgets", "remove"]) + + assert result.exit_code == 2 + output = result.output.lower() + assert "usage:" in output + assert "manage widgets" in output + assert "remove" in output + assert "not a valid command" in output or "no such command" in output + + +@pytest.mark.parametrize( + ("name", "args"), + [ + ("root typo", ["modul"]), + ("bare module", ["module"]), + ("module typo", ["module", "instal"]), + ("module missing arg", ["module", "install"]), + ("module missing option value", ["module", "install", "--scope"]), + ("module leaf missing arg", ["module", "show"]), + ("module bad option", ["module", "show", "--bad-option"]), + ("module subgroup missing subcommand", ["module", "alias"]), + ("module subgroup typo", ["module", "alias", "creat"]), + ("module subgroup leaf missing arg", ["module", "alias", "create"]), + ("init subgroup missing option value", ["init", "ide", "--repo"]), + ("upgrade bad option", ["upgrade", "--bad-option"]), + ("code missing subcommand", ["code"]), + ("code typo", ["code", "impor"]), + ("code import missing option value", ["code", "import", "--repo"]), + ("backlog auth missing subcommand", ["backlog", "auth"]), + ("backlog delta status missing context", ["backlog", "delta", "status"]), + ("project sync typo", ["project", "sync", "brdge"]), + ("project sync bridge missing option value", ["project", "sync", "bridge", "--repo"]), + ], +) +def test_cli_misuse_matrix_shows_contextual_help_once(name: str, args: list[str]) -> None: + result = TyperCliRunner().invoke(app, args) + output = result.output.lower() + + assert result.exit_code != 0, name + assert "usage:" in output, name + assert any( + token in output + for token in ["error:", "missing", "no such command", "no such option", "requires an argument", "did you mean"] + ), name + assert output.count("usage:") == 1, name + if args[:1] == ["module"]: + assert "usage: module " not in output, name diff --git a/tests/unit/cli/test_lean_help_output.py b/tests/unit/cli/test_lean_help_output.py index 225d811f..40e33cdd 100644 --- a/tests/unit/cli/test_lean_help_output.py +++ b/tests/unit/cli/test_lean_help_output.py @@ -2,6 +2,8 @@ from __future__ import annotations +from typing import Any, cast + import click import pytest import typer @@ -78,7 +80,7 @@ def test_specfact_help_contains_init_hint() -> None: def test_root_group_unknown_bundle_command_shows_install_guidance(capsys: pytest.CaptureFixture[str]) -> None: """Unknown bundle commands should show install guidance instead of raw Click errors.""" group = _RootCLIGroup(name="specfact") - ctx = click.Context(group) + ctx = click.Context(cast(Any, group)) with pytest.raises(SystemExit) as exc_info: group.resolve_command(ctx, ["backlog", "--help"]) @@ -94,7 +96,7 @@ def test_root_group_unknown_bundle_command_shows_install_guidance(capsys: pytest def test_root_group_unknown_code_shows_specfact_codebase_module(capsys: pytest.CaptureFixture[str]) -> None: """Missing `code` group should name nold-ai/specfact-codebase (not the VS Code `code` CLI).""" group = _RootCLIGroup(name="specfact") - ctx = click.Context(group) + ctx = click.Context(cast(Any, group)) with pytest.raises(SystemExit) as exc_info: group.resolve_command(ctx, ["code", "--help"]) @@ -146,6 +148,27 @@ def alias_command() -> None: assert "listed" in result.output +def test_lazy_delegate_bare_group_shows_full_help_and_missing_subcommand() -> None: + """Bare lazy groups should show real help, explicit missing-subcommand guidance, and full command path.""" + result = runner.invoke(app, ["module"], catch_exceptions=False) + + assert result.exit_code == 2 + assert "Usage: specfact module" in result.output + assert "Manage marketplace modules" in result.output + assert "Missing subcommand" in result.output + assert "list" in result.output + + +def test_lazy_delegate_missing_option_value_shows_leaf_help() -> None: + """Dangling option values must render the delegated leaf help, not the wrapper usage.""" + result = runner.invoke(app, ["module", "install", "--scope"], catch_exceptions=False) + + assert result.exit_code == 2 + assert "Usage: specfact module install" in result.output + assert "Option '--scope' requires an argument" in result.output + assert "MODULE_IDS" in result.output + + def test_lazy_delegate_help_falls_back_when_typer_command_build_fails(monkeypatch: pytest.MonkeyPatch) -> None: """Help-only delegation should not fail when Typer cannot materialize a loaded app.""" CommandRegistry._clear_for_testing() diff --git a/tests/unit/commands/test_backlog_daily.py b/tests/unit/commands/test_backlog_daily.py index 88efbaac..746b67bc 100644 --- a/tests/unit/commands/test_backlog_daily.py +++ b/tests/unit/commands/test_backlog_daily.py @@ -22,6 +22,7 @@ import re from datetime import UTC, datetime +from typing import Any, cast from unittest.mock import MagicMock import click @@ -83,7 +84,7 @@ def _strip_ansi(text: str) -> str: def _get_daily_command_option_names() -> set[str]: """Return all option names registered on `specfact backlog daily` (from CLI help or command tree).""" root_cmd = typer.main.get_command(app) - root_ctx = click.Context(root_cmd) + root_ctx = click.Context(cast(Any, root_cmd)) backlog_cmd = root_cmd.get_command(root_ctx, "backlog") assert backlog_cmd is not None, "root should have 'backlog' command" backlog_ctx = click.Context(backlog_cmd) @@ -118,30 +119,24 @@ def _item( title: str = "Item", state: str = "open", updated_at: datetime | None = None, - assignees: list[str] | None = None, - body_markdown: str = "", - iteration: str | None = None, - sprint: str | None = None, - priority: int | None = None, - business_value: int | None = None, - story_points: int | None = None, - acceptance_criteria: str | None = None, + **overrides: Any, ) -> BacklogItem: + assignees = cast(list[str] | None, overrides.get("assignees")) return BacklogItem( id=id_, provider="github", url=f"https://github.com/o/r/issues/{id_}", title=title, - body_markdown=body_markdown, + body_markdown=cast(str, overrides.get("body_markdown", "")), state=state, assignees=assignees or [], updated_at=updated_at or datetime.now(UTC), - iteration=iteration, - sprint=sprint, - priority=priority, - business_value=business_value, - story_points=story_points, - acceptance_criteria=acceptance_criteria, + iteration=cast(str | None, overrides.get("iteration")), + sprint=cast(str | None, overrides.get("sprint")), + priority=cast(int | None, overrides.get("priority")), + business_value=cast(int | None, overrides.get("business_value")), + story_points=cast(int | None, overrides.get("story_points")), + acceptance_criteria=cast(str | None, overrides.get("acceptance_criteria")), ) diff --git a/tests/unit/commands/test_update.py b/tests/unit/commands/test_update.py index 5659fa37..e2ebd97a 100644 --- a/tests/unit/commands/test_update.py +++ b/tests/unit/commands/test_update.py @@ -144,6 +144,36 @@ def side_effect(*args, **kwargs): assert method.method == "pipx", f"Expected pipx, got {method.method}" assert method.command == "pipx upgrade specfact-cli" + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") + @patch("specfact_cli.modules.upgrade.src.commands.sys.executable", "/workspace/app/.venv/bin/python") + @patch("specfact_cli.modules.upgrade.src.commands.sys.argv", ["/workspace/app/.venv/bin/specfact", "upgrade"]) + @patch.dict( + "specfact_cli.modules.upgrade.src.commands.os.environ", + {"UV_PROJECT_ENVIRONMENT": "/workspace/app/.venv", "UV": "1"}, + clear=False, + ) + def test_detect_uv_run_before_stale_pipx_inventory(self, mock_subprocess: MagicMock) -> None: + """The active uv-run/project context must win over stale global pipx inventory.""" + + def side_effect(*args, **kwargs): + result = MagicMock() + cmd = args[0] if args else [] + cmd_str = " ".join(cmd) if isinstance(cmd, list) else str(cmd) + if "pipx list" in cmd_str: + result.returncode = 0 + result.stdout = "package specfact-cli 1.0.0" + else: + result.returncode = 1 + result.stdout = "" + return result + + mock_subprocess.side_effect = side_effect + + method = detect_installation_method() + + assert method.method == "uv" + assert "uv pip install" in method.command + @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"]) @@ -282,6 +312,38 @@ def test_install_update_pip_with_spaced_executable_uses_shlex( ) +@patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") +@patch("specfact_cli.modules.upgrade.src.commands.update_metadata") +def test_install_update_pip_with_application_support_executable_uses_shlex( + mock_update_metadata: MagicMock, mock_subprocess: MagicMock, tmp_path: Path +) -> None: + """macOS Application Support interpreter paths must stay a single argv element.""" + python_path = tmp_path / "Library" / "Application Support" / "SpecFact" / "python" + method = InstallationMethod( + method="pip", + command=f"'{python_path}' -m pip install --upgrade specfact-cli", + location=None, + ) + mock_subprocess.return_value.returncode = 0 + + result = install_update(method, yes=True) + + assert result is True + mock_subprocess.assert_called_once_with( + [ + str(python_path), + "-m", + "pip", + "install", + "--upgrade", + "specfact-cli", + ], + check=False, + timeout=300, + capture_output=True, + ) + + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") def test_install_update_uv_pip_targets_detected_interpreter( @@ -335,9 +397,10 @@ def test_check_only_uvx_does_not_print_upgrade_command(mock_detect: MagicMock, m @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") +@patch("specfact_cli.modules.upgrade.src.commands.shutil.which", return_value=None) @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") def test_successful_pipx_upgrade_suppresses_spaced_home_warning( - mock_run: MagicMock, mock_update_metadata: MagicMock + mock_run: MagicMock, mock_which: MagicMock, mock_update_metadata: MagicMock ) -> None: """Successful pipx upgrades must not leak pipx's benign spaced-home warning.""" mock_run.return_value.returncode = 0 @@ -358,12 +421,99 @@ def test_successful_pipx_upgrade_suppresses_spaced_home_warning( result = install_update(InstallationMethod("pipx", "pipx upgrade specfact-cli", None), yes=True) assert result is True + mock_which.assert_called_once_with("specfact") rendered = output.getvalue() assert "Found a space in the pipx home path" not in rendered assert "PIPX_HOME" not in rendered assert "upgraded package specfact-cli from 0.46.19 to 0.46.25" in rendered +@patch("specfact_cli.modules.upgrade.src.commands.update_metadata") +@patch("specfact_cli.modules.upgrade.src.commands.shutil.which", return_value="/usr/local/bin/specfact") +@patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") +def test_successful_pipx_upgrade_repairs_stale_launcher( + mock_run: MagicMock, + mock_which: MagicMock, + mock_update_metadata: MagicMock, +) -> None: + """A successful pipx upgrade must repair a stale/broken launcher before reporting success.""" + mock_run.side_effect = [ + subprocess.CompletedProcess(["pipx", "upgrade", "specfact-cli"], 0, stdout=b"already latest\n", stderr=b""), + subprocess.CompletedProcess( + ["/usr/local/bin/specfact", "--version"], + 127, + stdout=b"", + stderr=b"/usr/local/bin/specfact: No such file or directory\n", + ), + subprocess.CompletedProcess(["pipx", "reinstall", "specfact-cli"], 0, stdout=b"reinstalled\n", stderr=b""), + subprocess.CompletedProcess( + ["/usr/local/bin/specfact", "--version"], 0, stdout=b"SpecFact CLI - v0.47.0\n", stderr=b"" + ), + ] + output = StringIO() + + with patch( + "specfact_cli.modules.upgrade.src.commands.console", + Console(file=output, force_terminal=False, width=120), + ): + result = _execute_upgrade_command(["pipx", "upgrade", "specfact-cli"]) + + assert result is True + mock_which.assert_called_once_with("specfact") + assert [call.args[0] for call in mock_run.call_args_list] == [ + ["pipx", "upgrade", "specfact-cli"], + ["/usr/local/bin/specfact", "--version"], + ["pipx", "reinstall", "specfact-cli"], + ["/usr/local/bin/specfact", "--version"], + ] + rendered = output.getvalue() + assert "pipx launcher is stale or broken" in rendered + assert "reinstalled" in rendered + assert "SpecFact CLI - v0.47.0" in rendered + mock_update_metadata.assert_called_once() + + +@patch("specfact_cli.modules.upgrade.src.commands.update_metadata") +@patch("specfact_cli.modules.upgrade.src.commands.shutil.which", return_value="/usr/local/bin/specfact") +@patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") +def test_pipx_upgrade_fails_when_launcher_repair_fails( + mock_run: MagicMock, + mock_which: MagicMock, + mock_update_metadata: MagicMock, +) -> None: + """A failed pipx launcher repair keeps the overall upgrade result failed.""" + mock_run.side_effect = [ + subprocess.CompletedProcess(["pipx", "upgrade", "specfact-cli"], 0, stdout=b"already latest\n", stderr=b""), + subprocess.CompletedProcess( + ["/usr/local/bin/specfact", "--version"], + 127, + stdout=b"", + stderr=b"/usr/local/bin/specfact: No such file or directory\n", + ), + subprocess.CompletedProcess(["pipx", "reinstall", "specfact-cli"], 1, stdout=b"", stderr=b"reinstall failed\n"), + ] + output = StringIO() + + with patch( + "specfact_cli.modules.upgrade.src.commands.console", + Console(file=output, force_terminal=False, width=120), + ): + result = _execute_upgrade_command(["pipx", "upgrade", "specfact-cli"]) + + assert result is False + mock_which.assert_called_once_with("specfact") + assert [call.args[0] for call in mock_run.call_args_list] == [ + ["pipx", "upgrade", "specfact-cli"], + ["/usr/local/bin/specfact", "--version"], + ["pipx", "reinstall", "specfact-cli"], + ] + rendered = output.getvalue() + assert "pipx launcher is stale or broken" in rendered + assert "reinstall failed" in rendered + assert "pipx reinstall specfact-cli failed" in rendered + mock_update_metadata.assert_not_called() + + @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") def test_failed_pipx_upgrade_preserves_child_diagnostics(mock_run: MagicMock) -> None: """Failed pipx upgrades must replay child stdout and stderr without filtering.""" diff --git a/tests/unit/docs/test_docs_validation_scripts.py b/tests/unit/docs/test_docs_validation_scripts.py index 64c48159..9a982096 100644 --- a/tests/unit/docs/test_docs_validation_scripts.py +++ b/tests/unit/docs/test_docs_validation_scripts.py @@ -4,6 +4,8 @@ import textwrap from pathlib import Path +import pytest + REPO_ROOT = Path(__file__).resolve().parents[3] @@ -60,6 +62,22 @@ def test_tokens_from_line_stops_at_flags() -> None: assert ["backlog", "analyze-deps"] in cmds +def test_code_import_options_after_bundle_are_rejected() -> None: + mod = _load_check_docs_commands() + + ok, message = mod.validate_command_tokens(["code", "import", "legacy-api", "--repo"]) + + assert ok is False + assert "code import" in message + assert "--repo" in message + + +def test_core_cli_modes_page_is_not_excluded_from_command_validation() -> None: + mod = _load_check_docs_commands() + + assert "docs/core-cli/modes.md" not in mod._EXCLUDED_DOC_PATHS + + def test_tokens_skip_leading_global_options_before_subcommand() -> None: mod = _load_check_docs_commands() text = """ @@ -71,6 +89,36 @@ def test_tokens_skip_leading_global_options_before_subcommand() -> None: assert ["import", "from-code", "legacy-api"] in cmds +def test_collect_specfact_commands_from_guidance_text_handles_inline_and_yaml() -> None: + mod = _load_check_docs_commands() + text = """ +guidance: "Run `specfact module list --show-origin` before editing." +steps: + - specfact project sync bridge --help +""" + cmds = mod.collect_specfact_commands_from_guidance_text(text) + assert ["module", "list"] in cmds + assert ["project", "sync", "bridge"] in cmds + + +def test_scan_guidance_templates_validates_resource_templates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + mod = _load_check_docs_commands() + monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) + monkeypatch.setattr(mod, "_ADDITIONAL_GUIDANCE_ROOTS", (tmp_path / "resources",)) + monkeypatch.setattr(mod, "validate_command_tokens", lambda tokens: (tokens != ["sync", "bridge"], "stale")) + + docs_root = tmp_path / "docs" + docs_root.mkdir() + template = tmp_path / "resources" / "templates" / "protocol.yaml.j2" + template.parent.mkdir(parents=True) + template.write_text('command: "specfact sync bridge --help"\n', encoding="utf-8") + + seen, failures = mod._scan_guidance_templates_for_command_validation(docs_root) + + assert ("sync", "bridge") in seen + assert failures == ["resources/templates/protocol.yaml.j2: specfact sync bridge — stale"] + + def test_cross_site_url_stops_at_markdown_delimiters() -> None: mod = _load_check_cross_site_links() line = "| `https://modules.specfact.io/foo/bar/` |" diff --git a/tests/unit/migration/test_module_migration_07_cleanup.py b/tests/unit/migration/test_module_migration_07_cleanup.py index 5b6f88c2..3bf55fd3 100644 --- a/tests/unit/migration/test_module_migration_07_cleanup.py +++ b/tests/unit/migration/test_module_migration_07_cleanup.py @@ -44,6 +44,7 @@ def test_no_flat_topology_command_expectations() -> None: allowed_files = { root / "tests" / "unit" / "migration" / "test_module_migration_07_cleanup.py", root / "tests" / "integration" / "test_core_slimming.py", + root / "tests" / "unit" / "docs" / "test_docs_validation_scripts.py", } offenders: list[str] = [] for test_file in sorted((root / "tests").rglob("test_*.py")): diff --git a/tests/unit/modules/init/test_init_ide_prompt_selection.py b/tests/unit/modules/init/test_init_ide_prompt_selection.py index 9968bfb8..b8779dab 100644 --- a/tests/unit/modules/init/test_init_ide_prompt_selection.py +++ b/tests/unit/modules/init/test_init_ide_prompt_selection.py @@ -135,6 +135,62 @@ def test_copy_prompts_by_source_to_ide_removes_unselected_module_exports_from_fl assert not (cmd_dir / "specfact.backlog-add.md").exists() +def test_copy_prompts_by_source_to_codex_exports_grouped_skills(tmp_path: Path) -> None: + """Codex receives capability-oriented skills grouped by source/module.""" + prompts = tmp_path / "resources" / "prompts" + prompts.mkdir(parents=True) + f1 = prompts / "specfact.01-import.md" + f1.write_text("---\ndescription: A\n---\n# A\n", encoding="utf-8") + f2 = prompts / "specfact.validate.md" + f2.write_text("---\ndescription: B\n---\n# B\n", encoding="utf-8") + + mod_dir = tmp_path / "mod" / "resources" / "prompts" + mod_dir.mkdir(parents=True) + f3 = mod_dir / "specfact.backlog-add.md" + f3.write_text("---\ndescription: C\n---\n# C\n", encoding="utf-8") + + copied, _settings = copy_prompts_by_source_to_ide( + tmp_path, + "codex", + {PROMPT_SOURCE_CORE: [f1, f2], "nold-ai/specfact-backlog": [f3]}, + force=True, + ) + + skills_dir = tmp_path / ".codex" / "skills" + assert copied == [ + skills_dir / "specfact-cli" / "SKILL.md", + skills_dir / "specfact-backlog" / "SKILL.md", + ] + assert not (skills_dir / "specfact.01-import").exists() + core_skill = (skills_dir / "specfact-cli" / "SKILL.md").read_text(encoding="utf-8") + module_skill = (skills_dir / "specfact-backlog" / "SKILL.md").read_text(encoding="utf-8") + assert "## specfact.01-import" in core_skill + assert "## specfact.validate" in core_skill + assert "## specfact.backlog-add" in module_skill + + +def test_copy_prompts_by_source_to_codex_prunes_stale_per_prompt_skill_exports(tmp_path: Path) -> None: + """A grouped skill export removes stale per-prompt skill folders from earlier previews.""" + prompts = tmp_path / "resources" / "prompts" + prompts.mkdir(parents=True) + f1 = prompts / "specfact.01-import.md" + f1.write_text("---\ndescription: A\n---\n# A\n", encoding="utf-8") + + stale = tmp_path / ".codex" / "skills" / "specfact.01-import" / "SKILL.md" + stale.parent.mkdir(parents=True) + stale.write_text("stale\n", encoding="utf-8") + owned = tmp_path / ".codex" / "skills" / "openspec-workflows" / "SKILL.md" + owned.parent.mkdir(parents=True) + owned.write_text("owned\n", encoding="utf-8") + + copy_prompts_by_source_to_ide(tmp_path, "codex", {PROMPT_SOURCE_CORE: [f1]}, force=True) + + assert not stale.exists() + assert not stale.parent.exists() + assert owned.exists() + assert (tmp_path / ".codex" / "skills" / "specfact-cli" / "SKILL.md").exists() + + def test_parse_prompts_option_all_expands_to_full_catalog() -> None: fake_catalog = { PROMPT_SOURCE_CORE: [], diff --git a/tests/unit/registry/test_category_groups.py b/tests/unit/registry/test_category_groups.py index 71a3064c..27d48c41 100644 --- a/tests/unit/registry/test_category_groups.py +++ b/tests/unit/registry/test_category_groups.py @@ -5,6 +5,7 @@ import os from collections.abc import Generator from pathlib import Path +from typing import Any, cast from unittest.mock import patch import pytest @@ -115,7 +116,7 @@ def test_govern_help_when_not_installed_suggests_install( runner = CliRunner() root_cmd = get_command(app) - result = runner.invoke(root_cmd, ["govern", "--help"]) + result = runner.invoke(cast(Any, root_cmd), ["govern", "--help"]) assert ( result.exit_code == 0 or "install" in (result.output or "").lower() or "govern" in (result.output or "").lower() ) @@ -139,7 +140,7 @@ def test_flat_validate_is_not_found_in_copilot_mode( runner = CliRunner() root_cmd = get_command(app) - result = runner.invoke(root_cmd, ["validate", "--help"]) + result = runner.invoke(cast(Any, root_cmd), ["validate", "--help"]) assert result.exit_code != 0 assert "not installed" in (result.output or "").lower() or "no such command" in (result.output or "").lower() @@ -160,7 +161,7 @@ def test_flat_validate_is_not_found_in_cicd_mode(tmp_path: Path) -> None: runner = CliRunner() root_cmd = get_command(app) - result = runner.invoke(root_cmd, ["validate", "--help"]) + result = runner.invoke(cast(Any, root_cmd), ["validate", "--help"]) assert result.exit_code != 0 assert "not installed" in (result.output or "").lower() or "no such command" in (result.output or "").lower() @@ -181,6 +182,6 @@ def test_spec_api_validate_routes_correctly(tmp_path: Path) -> None: if "spec" not in root_commands: return runner = CliRunner() - result = runner.invoke(root_cmd, ["spec", "validate", "--help"]) + result = runner.invoke(cast(Any, root_cmd), ["spec", "validate", "--help"]) assert result.exit_code == 0, f"spec validate --help failed: {result.output}" assert "validate" in (result.output or "").lower() or "Specmatic" in (result.output or "") diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py index c7ea764d..7cc6ccbb 100644 --- a/tests/unit/registry/test_module_installer.py +++ b/tests/unit/registry/test_module_installer.py @@ -82,6 +82,19 @@ def test_install_module_to_default_marketplace_path(monkeypatch, tmp_path: Path) assert installed == install_root / "drift" +def test_install_module_handles_macos_application_support_install_root(monkeypatch, tmp_path: Path) -> None: + """Marketplace install must treat macOS Application Support paths as paths, not shell words.""" + tarball = _create_module_tarball(tmp_path, "drift") + monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) + + install_root = tmp_path / "Library" / "Application Support" / "SpecFact" / "modules" + installed = install_module("specfact/drift", InstallModuleOptions(install_root=install_root)) + + assert installed == install_root / "drift" + assert (install_root / "drift" / "module-package.yaml").is_file() + assert (install_root / "drift" / module_installer.REGISTRY_ID_FILE).read_text(encoding="utf-8") == "specfact/drift" + + def test_install_module_already_installed_returns_existing(monkeypatch, tmp_path: Path) -> None: tarball = _create_module_tarball(tmp_path, "sync") monkeypatch.setattr("specfact_cli.registry.module_installer.download_module", lambda *_args, **_kwargs: tarball) diff --git a/tests/unit/specfact_cli/registry/test_profile_presets.py b/tests/unit/specfact_cli/registry/test_profile_presets.py index 47c4dce7..464a2bae 100644 --- a/tests/unit/specfact_cli/registry/test_profile_presets.py +++ b/tests/unit/specfact_cli/registry/test_profile_presets.py @@ -17,6 +17,7 @@ install_bundles_for_init, resolve_profile_bundles, ) +from specfact_cli.registry.module_installer import InstallModuleOptions # ── Scenario: Profile canonical bundle mapping is machine-verifiable ────────── @@ -136,3 +137,33 @@ def _fake_marketplace(module_id: str, options: object | None = None, **_kwargs: assert "nold-ai/specfact-codebase" in installed_marketplace_ids assert "nold-ai/specfact-code-review" in installed_marketplace_ids + + +def test_install_bundles_for_init_preserves_application_support_root(tmp_path: Path) -> None: + """Init bundle installation must pass macOS Application Support roots as a single Path value.""" + observed_roots: list[Path] = [] + install_root = tmp_path / "Library" / "Application Support" / "SpecFact" / "modules" + + def _fake_marketplace(module_id: str, options: object | None = None, **_kwargs: object) -> Path: + assert isinstance(options, InstallModuleOptions) + assert isinstance(options.install_root, Path) + observed_roots.append(options.install_root) + return options.install_root / module_id.split("/", 1)[1] + + with ( + patch( + "specfact_cli.registry.module_installer.install_bundled_module", + return_value=False, + ), + patch( + "specfact_cli.registry.module_installer.install_module", + side_effect=_fake_marketplace, + ), + ): + install_bundles_for_init( + ["specfact-codebase"], + install_root=install_root, + non_interactive=True, + ) + + assert observed_roots == [install_root] diff --git a/tests/unit/utils/test_env_manager.py b/tests/unit/utils/test_env_manager.py index 79a79d5a..3ec37127 100644 --- a/tests/unit/utils/test_env_manager.py +++ b/tests/unit/utils/test_env_manager.py @@ -6,6 +6,7 @@ from __future__ import annotations from pathlib import Path +from subprocess import CompletedProcess from unittest.mock import patch from specfact_cli.utils.env_manager import ( @@ -345,6 +346,29 @@ def test_check_tool_with_env_info(self, tmp_path: Path): assert available is True assert message is None + def test_check_tool_probes_active_uv_environment(self, tmp_path: Path): + """A uv-managed tool should be checked with `uv run --version`.""" + env_info = EnvManagerInfo( + manager=EnvManager.UV, + available=True, + command_prefix=["uv", "run"], + message="Test", + ) + + with ( + patch("shutil.which", return_value=None), + patch( + "specfact_cli.utils.env_manager.subprocess.run", + return_value=CompletedProcess(["uv", "run", "semgrep", "--version"], 0, stdout="1.0\n", stderr=""), + ) as run_mock, + ): + available, message = check_tool_in_env(tmp_path, "semgrep", env_info) + + assert available is True + assert message is None + run_mock.assert_called_once() + assert run_mock.call_args.args[0] == ["uv", "run", "semgrep", "--version"] + class TestDetectSourceDirectories: """Test source directory detection.""" diff --git a/tests/unit/utils/test_ide_setup.py b/tests/unit/utils/test_ide_setup.py index e4e701d3..98c60583 100644 --- a/tests/unit/utils/test_ide_setup.py +++ b/tests/unit/utils/test_ide_setup.py @@ -32,6 +32,9 @@ def test_detect_ide_explicit(self) -> None: assert detect_ide("cursor") == "cursor" assert detect_ide("vscode") == "vscode" assert detect_ide("copilot") == "copilot" + assert detect_ide("codex") == "codex" + assert detect_ide("claude-skills") == "claude-skills" + assert detect_ide("mistral") == "mistral" def test_detect_ide_cursor_from_env(self, monkeypatch: pytest.MonkeyPatch) -> None: """Test Cursor detection from environment variables.""" @@ -194,6 +197,25 @@ def test_copy_templates_to_vscode(self, tmp_path: Path) -> None: assert (prompts_dir / "specfact.01-import.prompt.md").exists() assert (tmp_path / ".vscode" / "settings.json").exists() + def test_copy_templates_to_codex_creates_grouped_skill(self, tmp_path: Path) -> None: + """Skill-based targets export one capability skill, not one slash-command folder per prompt.""" + templates_dir = tmp_path / "resources" / "prompts" + templates_dir.mkdir(parents=True) + (templates_dir / "specfact.01-import.md").write_text("---\ndescription: Analyze\n---\n# Analyze\n$ARGUMENTS") + (templates_dir / "specfact.validate.md").write_text("---\ndescription: Validate\n---\n# Validate\n$ARGUMENTS") + + copied_files, settings_path = copy_templates_to_ide(tmp_path, "codex", templates_dir, force=True) + + assert copied_files == [tmp_path / ".codex" / "skills" / "specfact-cli" / "SKILL.md"] + assert settings_path is None + assert not (tmp_path / ".codex" / "skills" / "specfact.01-import").exists() + skill = copied_files[0].read_text(encoding="utf-8") + assert "name: specfact-cli" in skill + assert "## specfact.01-import" in skill + assert "## specfact.validate" in skill + assert "# Analyze" in skill + assert "# Validate" in skill + def test_copy_templates_skips_existing_without_force(self, tmp_path: Path) -> None: """Test copying templates skips existing files without force.""" templates_dir = tmp_path / "resources" / "prompts" @@ -322,6 +344,7 @@ def test_flat_export_glob_pattern_for_prune_matches_output_formats() -> None: assert _flat_export_glob_pattern_for_prune("prompt.md") == "specfact*.prompt.md" assert _flat_export_glob_pattern_for_prune("toml") == "specfact*.toml" assert _flat_export_glob_pattern_for_prune("md") == "specfact*.md" + assert _flat_export_glob_pattern_for_prune("skill.md") == "specfact*/SKILL.md" def test_is_specfact_github_prompt_path_only_specfact_named_prompts() -> None: @@ -413,3 +436,25 @@ def test_expected_ide_prompt_export_paths_respects_prompt_source_subset( assert len(subset_paths) == 1 assert subset_paths[0].name == "c.md" assert "core" not in subset_paths[0].parts + + +def test_expected_ide_prompt_export_paths_groups_skill_targets_by_source( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + """Skill audits expect one SKILL.md per source/module.""" + p_core = tmp_path / "c.md" + p_mod = tmp_path / "m.md" + monkeypatch.setattr( + "specfact_cli.utils.ide_setup.discover_prompt_sources_catalog", + lambda _rp, include_package_fallback=True: { + PROMPT_SOURCE_CORE: [p_core], + "nold-ai/specfact-project": [p_mod], + }, + ) + + paths = expected_ide_prompt_export_paths(tmp_path, "codex") + + assert paths == [ + tmp_path / ".codex" / "skills" / "specfact-cli" / "SKILL.md", + tmp_path / ".codex" / "skills" / "specfact-project" / "SKILL.md", + ] diff --git a/tests/unit/workflows/test_trustworthy_green_checks.py b/tests/unit/workflows/test_trustworthy_green_checks.py index 8dfa3a36..464393d3 100644 --- a/tests/unit/workflows/test_trustworthy_green_checks.py +++ b/tests/unit/workflows/test_trustworthy_green_checks.py @@ -4,6 +4,7 @@ from __future__ import annotations +import re from pathlib import Path from typing import Any, cast @@ -202,12 +203,23 @@ def test_pr_orchestrator_advisory_jobs_are_named_as_advisory() -> None: def test_pr_orchestrator_contract_first_job_uses_hatch_contract_test() -> None: - """Contract-first CI should use the hatch contract-test script (no CLI bundle dependency).""" + """Contract-first CI should run scoped contract checks, leaving the full suite to smart-test-full.""" raw = PR_ORCHESTRATOR.read_text(encoding="utf-8") - assert "hatch run contract-test" in raw + assert "hatch run contract-test-contracts" in raw + assert "hatch run contract-test-exploration-fast" in raw + assert "hatch run contract-test 2>&1" not in raw assert "hatch run specfact repro --verbose --crosshair-required --budget 120" not in raw +def test_pr_orchestrator_has_single_full_suite_owner() -> None: + """PR validation must not run equivalent full pytest suites through multiple aliases.""" + raw = PR_ORCHESTRATOR.read_text(encoding="utf-8") + full_suite_runs = re.findall( + r"(?:python tools/smart_test_coverage\.py run --level full|hatch run test|hatch run contract-test(?!-))", raw + ) + assert full_suite_runs == ["python tools/smart_test_coverage.py run --level full"] + + def test_module_signature_check_name_is_canonical_across_workflows() -> None: """Orchestrator and dedicated signature workflows should emit the same required check name.""" orchestrator_jobs = _load_jobs() From 5297ea511199afcbea6b0541d6ed3dff997880d9 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 1 Jun 2026 01:57:37 +0200 Subject: [PATCH 2/7] Fix paired modules branch in core CI --- .github/workflows/docs-review.yml | 15 ++++- .github/workflows/pr-orchestrator.yml | 61 +++++++++++++++++-- .../test_trustworthy_green_checks.py | 21 +++++++ 3 files changed, 92 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docs-review.yml b/.github/workflows/docs-review.yml index d878fad7..f6c819f7 100644 --- a/.github/workflows/docs-review.yml +++ b/.github/workflows/docs-review.yml @@ -74,12 +74,25 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 + - name: Resolve module command sources ref + id: modules-ref + shell: bash + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + candidate="${CANDIDATE_REF}" + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git "$candidate" >/dev/null 2>&1; then + echo "ref=$candidate" >> "$GITHUB_OUTPUT" + else + echo "ref=dev" >> "$GITHUB_OUTPUT" + fi + - name: Checkout module command sources uses: actions/checkout@v4 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules - ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + ref: ${{ steps.modules-ref.outputs.ref }} - name: Export module command source path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index e3ba3dd2..a0b9d083 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -217,13 +217,27 @@ jobs: with: fetch-depth: 0 + - name: Resolve module bundles ref + id: modules-ref + if: needs.changes.outputs.skip_tests_dev_to_main != 'true' + shell: bash + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + candidate="${CANDIDATE_REF}" + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git "$candidate" >/dev/null 2>&1; then + echo "ref=$candidate" >> "$GITHUB_OUTPUT" + else + echo "ref=dev" >> "$GITHUB_OUTPUT" + fi + - name: Checkout module bundles repo if: needs.changes.outputs.skip_tests_dev_to_main != 'true' uses: actions/checkout@v4 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules - ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + ref: ${{ steps.modules-ref.outputs.ref }} - name: Export module bundles path if: needs.changes.outputs.skip_tests_dev_to_main != 'true' @@ -373,12 +387,25 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - name: Resolve module bundles ref + id: modules-ref + shell: bash + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + candidate="${CANDIDATE_REF}" + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git "$candidate" >/dev/null 2>&1; then + echo "ref=$candidate" >> "$GITHUB_OUTPUT" + else + echo "ref=dev" >> "$GITHUB_OUTPUT" + fi + - name: Checkout module bundles repo uses: actions/checkout@v4 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules - ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + ref: ${{ steps.modules-ref.outputs.ref }} - name: Export module bundles path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.11 @@ -428,12 +455,25 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - name: Resolve module bundles ref + id: modules-ref + shell: bash + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + candidate="${CANDIDATE_REF}" + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git "$candidate" >/dev/null 2>&1; then + echo "ref=$candidate" >> "$GITHUB_OUTPUT" + else + echo "ref=dev" >> "$GITHUB_OUTPUT" + fi + - name: Checkout module bundles repo uses: actions/checkout@v4 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules - ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + ref: ${{ steps.modules-ref.outputs.ref }} - name: Export module bundles path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.12 @@ -493,12 +533,25 @@ jobs: contents: read steps: - uses: actions/checkout@v4 + - name: Resolve module command sources ref + id: modules-ref + shell: bash + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + candidate="${CANDIDATE_REF}" + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git "$candidate" >/dev/null 2>&1; then + echo "ref=$candidate" >> "$GITHUB_OUTPUT" + else + echo "ref=dev" >> "$GITHUB_OUTPUT" + fi + - name: Checkout module command sources uses: actions/checkout@v4 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules - ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }} + ref: ${{ steps.modules-ref.outputs.ref }} - name: Export module command source path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.12 diff --git a/tests/unit/workflows/test_trustworthy_green_checks.py b/tests/unit/workflows/test_trustworthy_green_checks.py index 464393d3..31ae593b 100644 --- a/tests/unit/workflows/test_trustworthy_green_checks.py +++ b/tests/unit/workflows/test_trustworthy_green_checks.py @@ -13,6 +13,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3] PR_ORCHESTRATOR = REPO_ROOT / ".github" / "workflows" / "pr-orchestrator.yml" +DOCS_REVIEW = REPO_ROOT / ".github" / "workflows" / "docs-review.yml" SIGN_MODULES = REPO_ROOT / ".github" / "workflows" / "sign-modules.yml" PUBLISH_MODULES = REPO_ROOT / ".github" / "workflows" / "publish-modules.yml" PRE_COMMIT_CONFIG = REPO_ROOT / ".pre-commit-config.yaml" @@ -220,6 +221,26 @@ def test_pr_orchestrator_has_single_full_suite_owner() -> None: assert full_suite_runs == ["python tools/smart_test_coverage.py run --level full"] +def test_core_ci_checks_out_matching_modules_branch_when_available() -> None: + """Core PR CI must validate against the paired modules branch before falling back to dev.""" + raw = PR_ORCHESTRATOR.read_text(encoding="utf-8") + assert raw.count("id: modules-ref") == 4 + assert raw.count("git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git") == 4 + assert raw.count('echo "ref=dev" >> "$GITHUB_OUTPUT"') == 4 + assert raw.count("ref: ${{ steps.modules-ref.outputs.ref }}") == 4 + assert "ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }}" not in raw + + +def test_docs_review_checks_out_matching_modules_branch_when_available() -> None: + """Docs command validation must use the same paired modules branch logic as PR CI.""" + raw = DOCS_REVIEW.read_text(encoding="utf-8") + assert raw.count("id: modules-ref") == 1 + assert "git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git" in raw + assert 'echo "ref=dev" >> "$GITHUB_OUTPUT"' in raw + assert "ref: ${{ steps.modules-ref.outputs.ref }}" in raw + assert "ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }}" not in raw + + def test_module_signature_check_name_is_canonical_across_workflows() -> None: """Orchestrator and dedicated signature workflows should emit the same required check name.""" orchestrator_jobs = _load_jobs() From 55746a3d0215d2210f94772bf23c35d2cb078a84 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 1 Jun 2026 22:54:37 +0200 Subject: [PATCH 3/7] fix: address core reliability review threads --- .github/workflows/docs-review.yml | 3 +- .github/workflows/pr-orchestrator.yml | 12 ++-- .github/workflows/specfact.yml | 13 +++- .markdownlint.json | 2 +- CHANGELOG.md | 10 +++ .../integration-showcases-testing-guide.md | 26 +++---- .../migration/migration-cli-reorganization.md | 6 +- docs/reference/commands.generated.json | 38 +++------- docs/reference/commands.generated.md | 23 +++--- llms.txt | 33 ++++----- openspec/CHANGE_ORDER.md | 2 +- .../tester-cli-reliability/TDD_EVIDENCE.md | 37 ++++++++++ .../changes/tester-cli-reliability/tasks.md | 4 +- pyproject.toml | 2 +- scripts/check-command-contract.py | 27 ++++--- scripts/check-docs-commands.py | 11 ++- scripts/generate-command-overview.py | 41 ++++++++++- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 8 ++- src/specfact_cli/cli.py | 5 +- .../modules/init/module-package.yaml | 2 +- src/specfact_cli/modules/init/src/commands.py | 4 +- .../modules/upgrade/module-package.yaml | 2 +- .../modules/upgrade/src/commands.py | 15 +++- src/specfact_cli/utils/structure.py | 2 +- src/specfact_cli/utils/suggestions.py | 12 ++-- tests/unit/cli/test_error_guidance.py | 25 +++---- tests/unit/cli/test_lean_help_output.py | 23 ++++-- tests/unit/commands/test_update.py | 71 +++++++++++++++---- .../unit/docs/test_docs_validation_scripts.py | 36 +++++++--- tests/unit/utils/test_suggestions.py | 2 +- 32 files changed, 334 insertions(+), 167 deletions(-) diff --git a/.github/workflows/docs-review.yml b/.github/workflows/docs-review.yml index f6c819f7..a47445af 100644 --- a/.github/workflows/docs-review.yml +++ b/.github/workflows/docs-review.yml @@ -88,11 +88,12 @@ jobs: fi - name: Checkout module command sources - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules ref: ${{ steps.modules-ref.outputs.ref }} + persist-credentials: false - name: Export module command source path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" diff --git a/.github/workflows/pr-orchestrator.yml b/.github/workflows/pr-orchestrator.yml index a0b9d083..b117193a 100644 --- a/.github/workflows/pr-orchestrator.yml +++ b/.github/workflows/pr-orchestrator.yml @@ -233,11 +233,12 @@ jobs: - name: Checkout module bundles repo if: needs.changes.outputs.skip_tests_dev_to_main != 'true' - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules ref: ${{ steps.modules-ref.outputs.ref }} + persist-credentials: false - name: Export module bundles path if: needs.changes.outputs.skip_tests_dev_to_main != 'true' @@ -401,11 +402,12 @@ jobs: fi - name: Checkout module bundles repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules ref: ${{ steps.modules-ref.outputs.ref }} + persist-credentials: false - name: Export module bundles path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.11 @@ -469,11 +471,12 @@ jobs: fi - name: Checkout module bundles repo - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules ref: ${{ steps.modules-ref.outputs.ref }} + persist-credentials: false - name: Export module bundles path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.12 @@ -547,11 +550,12 @@ jobs: fi - name: Checkout module command sources - uses: actions/checkout@v4 + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 with: repository: nold-ai/specfact-cli-modules path: specfact-cli-modules ref: ${{ steps.modules-ref.outputs.ref }} + persist-credentials: false - name: Export module command source path run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" - name: Set up Python 3.12 diff --git a/.github/workflows/specfact.yml b/.github/workflows/specfact.yml index bba0485f..8daea5bf 100644 --- a/.github/workflows/specfact.yml +++ b/.github/workflows/specfact.yml @@ -83,8 +83,17 @@ jobs: id: repro continue-on-error: true run: | - specfact code repro --verbose --crosshair-required --budget ${{ steps.validation.outputs.budget }} || true - echo "exit_code=$?" >> "$GITHUB_OUTPUT" + budget='${{ steps.validation.outputs.budget }}' + if ! [[ "$budget" =~ ^[0-9]+$ ]]; then + echo "Invalid budget: $budget" >&2 + echo "exit_code=2" >> "$GITHUB_OUTPUT" + exit 0 + fi + set +e + specfact code repro --verbose --crosshair-required --budget "$budget" + rc=$? + set -e + echo "exit_code=$rc" >> "$GITHUB_OUTPUT" - name: Find latest repro report id: report diff --git a/.markdownlint.json b/.markdownlint.json index cab9075e..5e81810a 100644 --- a/.markdownlint.json +++ b/.markdownlint.json @@ -10,7 +10,7 @@ "MD013": false, "MD033": false, "MD036": false, - "MD040": false, + "MD040": true, "MD051": false, "MD041": false, "MD060": false diff --git a/CHANGELOG.md b/CHANGELOG.md index bf597815..f3c1602a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,16 @@ All notable changes to this project will be documented in this file. --- +## [0.47.1] - 2026-06-01 + +### Fixed + +- **Tester command reliability follow-ups**: address PR review findings for + command overview generation, command contract validation, launcher repair, + workflow hardening, and CLI error propagation. + +--- + ## [0.47.0] - 2026-06-01 ### Added diff --git a/docs/examples/integration-showcases/integration-showcases-testing-guide.md b/docs/examples/integration-showcases/integration-showcases-testing-guide.md index 5bf17336..9f37ad80 100644 --- a/docs/examples/integration-showcases/integration-showcases-testing-guide.md +++ b/docs/examples/integration-showcases/integration-showcases-testing-guide.md @@ -1147,7 +1147,7 @@ result = process_order(order_id="123") specfact --no-banner code import from-code --repo . --output-format yaml ``` -**Important**: After creating the initial plan, we need to make it the default plan so `plan compare --code-vs-plan` can find it. Use `plan select` to set it as the active plan: +**Important**: After creating the initial plan, keep the bundle name explicit for later drift comparison steps: ```bash # Find the created plan bundle @@ -1155,14 +1155,14 @@ specfact --no-banner code import from-code --repo . --output-format yaml BUNDLE_NAME="example4_github_actions" PLAN_NAME=$(basename "$PLAN_FILE") -# Set it as the active plan (this makes it the default for plan compare) -specfact --no-banner project version check --bundle "$BUNDLE_NAME" --repo . +# Verify the bundle that later comparison steps will use explicitly +specfact --no-banner project health-check --project-name "$BUNDLE_NAME" --repo . -# Verify it's set as active -specfact --no-banner project version check --repo . +# Verify the project command surface remains available in this repo +specfact --no-banner project health-check --project-name "$BUNDLE_NAME" --repo . ``` -**Note**: `plan compare --code-vs-plan` uses the active plan (set via `plan select`) or falls back to the default bundle if no active plan is set. Using `plan select` is the recommended approach as it's cleaner and doesn't require file copying. +**Note**: Later comparison steps pass `"$BUNDLE_NAME"` explicitly instead of relying on legacy active-plan selection. Then commit: @@ -1228,7 +1228,7 @@ Create `.git/hooks/pre-commit`: # Use default name "auto-derived" so plan compare --code-vs-plan can find it specfact --no-banner code import from-code --repo . --output-format yaml > /dev/null 2>&1 -# Then compare: uses active plan (set via plan select) as manual, latest code-derived plan as auto +# Then compare against the explicitly named auto-derived bundle specfact --no-banner code drift detect auto-derived --repo . ``` @@ -1237,13 +1237,13 @@ specfact --no-banner code drift detect auto-derived --repo . - Imports current code to create a new plan (auto-derived from modified code) - **Important**: Uses default name "auto-derived" (or omit `--name`) so `plan compare --code-vs-plan` can find it - `plan compare --code-vs-plan` looks for plans named `auto-derived.*.bundle.*` -- Compares the new plan (auto) against the active plan (manual/baseline - set via `plan select` in Step 2) +- Compares the new plan against the explicitly named generated bundle - Uses enforcement configuration to determine if deviations should block the commit - Blocks commit if HIGH severity deviations are found (based on enforcement preset) **Note**: The `--code-vs-plan` flag automatically uses: -- **Manual plan**: The active plan (set via `plan select`) or `main.bundle.yaml` as fallback +- **Manual plan**: The baseline bundle named by the workflow or `main.bundle.yaml` as fallback - **Auto plan**: The latest `auto-derived` project bundle (from `code import from-code auto-derived` or default bundle name) Make it executable: @@ -1629,7 +1629,7 @@ rm -rf specfact-integration-tests **What's Validated**: - ✅ Plan bundle creation (`code import from-code`) -- ✅ Plan selection (`plan select` sets active plan) +- ✅ Bundle verification (`project health-check` confirms the named bundle) - ✅ Enforcement configuration (`enforce stage` with BALANCED preset) - ✅ Pre-commit hook setup (imports code, then compares) - ✅ Plan comparison (`plan compare --code-vs-plan` finds both plans correctly) @@ -1638,9 +1638,9 @@ rm -rf specfact-integration-tests **Test Results**: - Plan creation: ✅ `code import from-code ` creates project bundle at `.specfact/projects//` (modular structure) -- Plan selection: ✅ `plan select` sets active plan correctly +- Bundle verification: ✅ `project health-check` confirms the named bundle - Plan comparison: ✅ `plan compare --code-vs-plan` finds: - - Manual plan: Active plan (set via `plan select`) + - Manual plan: Explicit baseline bundle - Auto plan: Latest `auto-derived` project bundle (`.specfact/projects/auto-derived/`) - Deviation detection: ✅ Detects deviations (1 HIGH, 2 LOW in test case) - Enforcement: ✅ Blocks commit when HIGH severity deviations found @@ -1649,7 +1649,7 @@ rm -rf specfact-integration-tests **Key Findings**: - ✅ `code import from-code` should use bundle name "auto-derived" so `plan compare --code-vs-plan` can find it -- ✅ `plan select` is the recommended way to set the baseline plan (cleaner than copying to `main.bundle.yaml`) +- ✅ Passing bundle names explicitly is the recommended way to set the baseline - ✅ Pre-commit hook workflow: `code import from-code` → `plan compare --code-vs-plan` works correctly - ✅ Enforcement configuration is respected (HIGH → BLOCK based on preset) diff --git a/docs/migration/migration-cli-reorganization.md b/docs/migration/migration-cli-reorganization.md index c34bf129..e885c08a 100644 --- a/docs/migration/migration-cli-reorganization.md +++ b/docs/migration/migration-cli-reorganization.md @@ -130,14 +130,14 @@ The new numbered commands follow natural workflow progression: **Before** (positional argument): ```bash -# Old: project plan init (removed) → use: specfact code import --repo . legacy-api +# Old: project plan init (removed) → use: specfact code import from-code --repo . legacy-api # Old: project plan review (removed) → use: specfact project devops-flow --stage develop --bundle legacy-api ``` **After** (named parameter): ```bash -specfact code import --repo . legacy-api +specfact code import from-code --repo . legacy-api specfact project devops-flow --stage develop --bundle legacy-api ``` @@ -205,7 +205,7 @@ Example: 'specfact constitution bootstrap' → 'specfact sdd constitution bootst ### Brownfield Import Workflow ```bash -specfact code import --repo . legacy-api +specfact code import from-code --repo . legacy-api specfact sdd constitution bootstrap --repo . specfact project sync bridge --adapter speckit ``` diff --git a/docs/reference/commands.generated.json b/docs/reference/commands.generated.json index f4c295fc..2b6991ef 100644 --- a/docs/reference/commands.generated.json +++ b/docs/reference/commands.generated.json @@ -647,6 +647,7 @@ "drift", "import", "repro", + "review", "validate" ] }, @@ -851,25 +852,6 @@ "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", - "options": [ - "--install-completion", - "--show-completion" - ], - "owner_package": "nold-ai/specfact-code-review", - "owner_repo": "nold-ai/specfact-cli", - "short_help": "", - "source": "specfact_code_review.review.commands:app", - "subcommands": [ - "review" - ] - }, - { - "arguments": [], - "bare_invocation": "requires-subcommand", - "command": "specfact code review review", - "deprecated": false, - "hidden": false, - "install_prerequisite": "specfact module install nold-ai/specfact-code-review", "options": [], "owner_package": "nold-ai/specfact-code-review", "owner_repo": "nold-ai/specfact-cli", @@ -884,7 +866,7 @@ { "arguments": [], "bare_invocation": "requires-subcommand", - "command": "specfact code review review ledger", + "command": "specfact code review ledger", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -902,7 +884,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review ledger reset", + "command": "specfact code review ledger reset", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -918,7 +900,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review ledger status", + "command": "specfact code review ledger status", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -932,7 +914,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review ledger update", + "command": "specfact code review ledger update", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -948,7 +930,7 @@ { "arguments": [], "bare_invocation": "requires-subcommand", - "command": "specfact code review review rules", + "command": "specfact code review rules", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -966,7 +948,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review rules init", + "command": "specfact code review rules init", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -982,7 +964,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review rules show", + "command": "specfact code review rules show", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -996,7 +978,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review rules update", + "command": "specfact code review rules update", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", @@ -1012,7 +994,7 @@ { "arguments": [], "bare_invocation": "executes", - "command": "specfact code review review run", + "command": "specfact code review run", "deprecated": false, "hidden": false, "install_prerequisite": "specfact module install nold-ai/specfact-code-review", diff --git a/docs/reference/commands.generated.md b/docs/reference/commands.generated.md index 96c5c348..667627a3 100644 --- a/docs/reference/commands.generated.md +++ b/docs/reference/commands.generated.md @@ -40,7 +40,7 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact backlog refine` | nold-ai/specfact-backlog | --ado-org, --ado-project, --ado-team, --ado-token, --assignee, --auto-accept-high-confidence, --auto-bundle, --bundle, --check-dor, --custom-field-mapping, --export-to-tmp, --first-comments, --first-issues, --framework, --github-token, --id, --ignore-refined, --import-from-tmp, --iteration, --labels, --last-comments, --last-issues, --limit, --no-ignore-refined, --no-preview, --openspec-comment, --persona, --preview, --release, --repo-name, --repo-owner, --search, --sprint, --state, --tags, --template, --tmp-file, --write; args: - | - | | | `specfact backlog sync` | nold-ai/specfact-backlog | --adapter, --baseline-file, --force-baseline-overwrite, --output-format, --project-id, --template; args: - | - | | | `specfact backlog verify-readiness` | nold-ai/specfact-backlog | --adapter, --project-id, --target-items, --template; args: - | - | | -| `specfact code` | nold-ai/specfact-codebase | --install-completion, --show-completion; args: - | analyze, drift, import, repro, validate | | +| `specfact code` | nold-ai/specfact-codebase | --install-completion, --show-completion; args: - | analyze, drift, import, repro, review, validate | | | `specfact code analyze` | nold-ai/specfact-codebase | -; args: - | contracts | | | `specfact code analyze contracts` | nold-ai/specfact-codebase | --bundle, --repo; args: - | - | | | `specfact code drift` | nold-ai/specfact-codebase | -; args: - | detect | | @@ -50,17 +50,16 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact code import from-code` | nold-ai/specfact-codebase | --confidence, --enrich-for-speckit, --enrichment, --entry-point, --exclude-tests, --force, --include-tests, --key-format, --no-enrich-for-speckit, --no-revalidate-features, --repo, --report, --revalidate-features, --shadow-only; args: - | - | | | `specfact code repro` | nold-ai/specfact-codebase | --budget, --crosshair-per-path-timeout, --crosshair-required, --fail-fast, --fix, --out, --repo, --sidecar, --sidecar-bundle, --verbose; args: - | setup | | | `specfact code repro setup` | nold-ai/specfact-codebase | --install-crosshair, --repo; args: - | - | | -| `specfact code review` | nold-ai/specfact-code-review | --install-completion, --show-completion; args: - | review | | -| `specfact code review review` | nold-ai/specfact-code-review | -; args: - | ledger, rules, run | | -| `specfact code review review ledger` | nold-ai/specfact-code-review | -; args: - | reset, status, update | | -| `specfact code review review ledger reset` | nold-ai/specfact-code-review | --confirm; args: - | - | | -| `specfact code review review ledger status` | nold-ai/specfact-code-review | -; args: - | - | | -| `specfact code review review ledger update` | nold-ai/specfact-code-review | --from; args: - | - | | -| `specfact code review review rules` | nold-ai/specfact-code-review | -; args: - | init, show, update | | -| `specfact code review review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | -| `specfact code review review rules show` | nold-ai/specfact-code-review | -; args: - | - | | -| `specfact code review review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | -| `specfact code review review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | +| `specfact code review` | nold-ai/specfact-code-review | -; args: - | ledger, rules, run | | +| `specfact code review ledger` | nold-ai/specfact-code-review | -; args: - | reset, status, update | | +| `specfact code review ledger reset` | nold-ai/specfact-code-review | --confirm; args: - | - | | +| `specfact code review ledger status` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review ledger update` | nold-ai/specfact-code-review | --from; args: - | - | | +| `specfact code review rules` | nold-ai/specfact-code-review | -; args: - | init, show, update | | +| `specfact code review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review rules show` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | | `specfact code validate` | nold-ai/specfact-codebase | -; args: - | sidecar | | | `specfact code validate sidecar` | nold-ai/specfact-codebase | -; args: - | init, run | | | `specfact code validate sidecar init` | nold-ai/specfact-codebase | -; args: - | - | | diff --git a/llms.txt b/llms.txt index af6509ac..5c8a2703 100644 --- a/llms.txt +++ b/llms.txt @@ -1,7 +1,3 @@ -# SpecFact CLI Commands - -Use this generated overview as the current command contract before following older docs or prompts. - --- layout: default title: Generated SpecFact CLI Command Overview @@ -10,7 +6,9 @@ exempt: true exempt_reason: Generated command contract artifact. --- -# Generated SpecFact CLI Command Overview +# SpecFact CLI Commands + +Use this generated overview as the current command contract before following older docs or prompts. This file is generated from the current CLI command tree. Do not edit by hand. @@ -44,7 +42,7 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact backlog refine` | nold-ai/specfact-backlog | --ado-org, --ado-project, --ado-team, --ado-token, --assignee, --auto-accept-high-confidence, --auto-bundle, --bundle, --check-dor, --custom-field-mapping, --export-to-tmp, --first-comments, --first-issues, --framework, --github-token, --id, --ignore-refined, --import-from-tmp, --iteration, --labels, --last-comments, --last-issues, --limit, --no-ignore-refined, --no-preview, --openspec-comment, --persona, --preview, --release, --repo-name, --repo-owner, --search, --sprint, --state, --tags, --template, --tmp-file, --write; args: - | - | | | `specfact backlog sync` | nold-ai/specfact-backlog | --adapter, --baseline-file, --force-baseline-overwrite, --output-format, --project-id, --template; args: - | - | | | `specfact backlog verify-readiness` | nold-ai/specfact-backlog | --adapter, --project-id, --target-items, --template; args: - | - | | -| `specfact code` | nold-ai/specfact-codebase | --install-completion, --show-completion; args: - | analyze, drift, import, repro, validate | | +| `specfact code` | nold-ai/specfact-codebase | --install-completion, --show-completion; args: - | analyze, drift, import, repro, review, validate | | | `specfact code analyze` | nold-ai/specfact-codebase | -; args: - | contracts | | | `specfact code analyze contracts` | nold-ai/specfact-codebase | --bundle, --repo; args: - | - | | | `specfact code drift` | nold-ai/specfact-codebase | -; args: - | detect | | @@ -54,17 +52,16 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact code import from-code` | nold-ai/specfact-codebase | --confidence, --enrich-for-speckit, --enrichment, --entry-point, --exclude-tests, --force, --include-tests, --key-format, --no-enrich-for-speckit, --no-revalidate-features, --repo, --report, --revalidate-features, --shadow-only; args: - | - | | | `specfact code repro` | nold-ai/specfact-codebase | --budget, --crosshair-per-path-timeout, --crosshair-required, --fail-fast, --fix, --out, --repo, --sidecar, --sidecar-bundle, --verbose; args: - | setup | | | `specfact code repro setup` | nold-ai/specfact-codebase | --install-crosshair, --repo; args: - | - | | -| `specfact code review` | nold-ai/specfact-code-review | --install-completion, --show-completion; args: - | review | | -| `specfact code review review` | nold-ai/specfact-code-review | -; args: - | ledger, rules, run | | -| `specfact code review review ledger` | nold-ai/specfact-code-review | -; args: - | reset, status, update | | -| `specfact code review review ledger reset` | nold-ai/specfact-code-review | --confirm; args: - | - | | -| `specfact code review review ledger status` | nold-ai/specfact-code-review | -; args: - | - | | -| `specfact code review review ledger update` | nold-ai/specfact-code-review | --from; args: - | - | | -| `specfact code review review rules` | nold-ai/specfact-code-review | -; args: - | init, show, update | | -| `specfact code review review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | -| `specfact code review review rules show` | nold-ai/specfact-code-review | -; args: - | - | | -| `specfact code review review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | -| `specfact code review review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | +| `specfact code review` | nold-ai/specfact-code-review | -; args: - | ledger, rules, run | | +| `specfact code review ledger` | nold-ai/specfact-code-review | -; args: - | reset, status, update | | +| `specfact code review ledger reset` | nold-ai/specfact-code-review | --confirm; args: - | - | | +| `specfact code review ledger status` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review ledger update` | nold-ai/specfact-code-review | --from; args: - | - | | +| `specfact code review rules` | nold-ai/specfact-code-review | -; args: - | init, show, update | | +| `specfact code review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review rules show` | nold-ai/specfact-code-review | -; args: - | - | | +| `specfact code review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | +| `specfact code review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | | `specfact code validate` | nold-ai/specfact-codebase | -; args: - | sidecar | | | `specfact code validate sidecar` | nold-ai/specfact-codebase | -; args: - | init, run | | | `specfact code validate sidecar init` | nold-ai/specfact-codebase | -; args: - | - | | @@ -123,4 +120,4 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact spec generate-tests` | nold-ai/specfact-spec | --bundle, --force, --out, --output; args: - | - | | | `specfact spec mock` | nold-ai/specfact-spec | --bundle, --examples, --no-interactive, --port, --spec, --strict; args: - | - | | | `specfact spec validate` | nold-ai/specfact-spec | --bundle, --force, --no-interactive, --previous; args: - | - | | -| `specfact upgrade` | core | --check-only, --install-completion, --show-completion, --yes; args: - | - | | +| `specfact upgrade` | core | --check-only, --install-completion, --show-completion, --yes; args: - | - | | \ No newline at end of file diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 2283406b..d98158d2 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -78,7 +78,7 @@ User-facing CLI behavior assertions and acceptance-test surface. | Order | Change | Issue | Blocked by | |---|---|---|---| | 0 | `runtime-01-discovery-reliability` | [#552](https://github.com/nold-ai/specfact-cli/issues/552), [#553](https://github.com/nold-ai/specfact-cli/issues/553), [#554](https://github.com/nold-ai/specfact-cli/issues/554) | — | -| 0.5 | `tester-cli-reliability` | [#594](https://github.com/nold-ai/specfact-cli/issues/594); source bugs [#585](https://github.com/nold-ai/specfact-cli/issues/585), [#587](https://github.com/nold-ai/specfact-cli/issues/587), [#588](https://github.com/nold-ai/specfact-cli/issues/588), [#589](https://github.com/nold-ai/specfact-cli/issues/589), [#590](https://github.com/nold-ai/specfact-cli/issues/590) | paired modules `tester-module-cli-reliability` | +| 0.5 | `tester-cli-reliability` | [#594](https://github.com/nold-ai/specfact-cli/issues/594); source bugs [#585](https://github.com/nold-ai/specfact-cli/issues/585), [#587](https://github.com/nold-ai/specfact-cli/issues/587), [#588](https://github.com/nold-ai/specfact-cli/issues/588), [#589](https://github.com/nold-ai/specfact-cli/issues/589), [#590](https://github.com/nold-ai/specfact-cli/issues/590), [#593](https://github.com/nold-ai/specfact-cli/issues/593) | paired modules `tester-module-cli-reliability` | | 1 | `cli-val-03-misuse-safety-proof` | [#281](https://github.com/nold-ai/specfact-cli/issues/281) | — | | 2 | `cli-val-04-acceptance-test-runner` | [#282](https://github.com/nold-ai/specfact-cli/issues/282) | cli-val-03 | diff --git a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md index 0069959e..c3e50825 100644 --- a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md +++ b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md @@ -113,3 +113,40 @@ ## Deferred / Not Covered In This Slice - Full `smart-test` was attempted and failed on existing full-suite issues unrelated to the duplicate-run workflow patch: runtime smoke used stale sibling modules before resolver fix, migration fixture false positive, local project module discovery pollution, `ProjectBundle` timeout, and a stale virtualenv pin assertion. Focused regressions for the touched workflow/runtime areas now pass. + +## Follow-up Review Fixes + +- Addressed PR review findings after commit `5297ea51`: + - `llms.txt` generation now keeps Jekyll front matter at the top and emits a single H1. + - `scripts/check-docs-commands.py` preserves flags in parsed command examples so legacy `specfact code import --repo ...` ordering is actually rejected. + - Placeholder examples such as `specfact --help` are ignored as examples, while explicit subcommands such as `specfact code import from-code legacy-api --repo .` remain valid. + - Core CLI misuse tests strip ANSI locally and cover only core-owned command paths to avoid module-installation flakiness. + - `src/specfact_cli/utils/structure.py` now emits canonical `specfact code import --repo . ` guidance. + - `tasks.md` quality/review checklist now matches the recorded evidence. +- Follow-up verification: + - `hatch run generate-command-overview && hatch run check-command-overview` -> passed. + - `hatch run pytest tests/unit/docs/test_docs_validation_scripts.py tests/unit/cli/test_error_guidance.py tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_bare_group_shows_full_help_and_missing_subcommand tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_missing_option_value_shows_leaf_help -q` -> 31 passed, 2 warnings. + - `hatch run python scripts/check-docs-commands.py` -> passed: `check-docs-commands: OK (385 unique command prefix(es) checked)`. + - `openspec validate tester-cli-reliability --strict` -> passed. + - `hatch run format && hatch run lint` -> passed. + +## Follow-up PR Thread Fixes + +- Validated live PR #595 review threads and CI annotations after the previous follow-up. +- Addressed remaining actionable findings: + - Generated command overview now flattens the mounted code-review app, so the public contract exposes `specfact code review run` instead of `specfact code review review run`; parent subcommand inventories are backfilled from generated child records. + - Command-contract validation now maps the public `specfact code review` prefix to the code-review app's internal `review` root when invoking mounted help paths. + - Secondary `specfact-cli-modules` checkouts in touched workflows now pin `actions/checkout` to `34e114876b0b11c390a56381ad16ebd13914f8d5` and disable credential persistence. + - `specfact.yml` contract validation preserves the real repro exit status and validates/quotes the budget input before invoking the command. + - Progressive-disclosure bootstrap removes a partially-loaded module from `sys.modules` if import execution fails. + - Lazy delegated plain `SystemExit` codes are preserved instead of being normalized to success. + - Public `init` callback now has `@beartype`; the injected Typer vendored Click context is annotated with the runtime context type so Typer error rendering remains intact. + - Pipx upgrade validation treats a missing launcher as repairable via `pipx reinstall specfact-cli`, then re-checks the launcher before reporting success. + - Suggestions and docs were updated away from removed `project plan select` and legacy `code import --repo . ` forms. + - `.markdownlint.json` re-enables MD040 fenced-code-language enforcement. + - OpenSpec change-order tracking includes source bug `#593`. +- Follow-up verification: + - `hatch run generate-command-overview` -> passed. + - `hatch run pytest tests/unit/commands/test_update.py tests/unit/utils/test_suggestions.py tests/unit/docs/test_docs_validation_scripts.py tests/unit/cli/test_error_guidance.py tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_bare_group_shows_full_help_and_missing_subcommand tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_missing_option_value_shows_leaf_help tests/unit/workflows/test_trustworthy_green_checks.py -q` -> 89 passed, 2 warnings. + - `hatch run check-command-contract && hatch run check-command-overview && hatch run python scripts/check-docs-commands.py && hatch run lint-workflows && openspec validate tester-cli-reliability --strict` -> passed. + - `hatch run format && hatch run lint` -> passed. diff --git a/openspec/changes/tester-cli-reliability/tasks.md b/openspec/changes/tester-cli-reliability/tasks.md index 736167e8..d5fd3c28 100644 --- a/openspec/changes/tester-cli-reliability/tasks.md +++ b/openspec/changes/tester-cli-reliability/tasks.md @@ -35,5 +35,5 @@ ## 6. Passing evidence and quality gates - [x] 6.1 Re-run targeted tests and record passing evidence in `TDD_EVIDENCE.md`. -- [ ] 6.2 Run required quality gates for touched scope: format, type-check, lint, YAML lint, contract-test, smart-test or targeted equivalent. -- [ ] 6.3 Run SpecFact code review and resolve findings or document explicit exceptions. +- [x] 6.2 Run required quality gates for touched scope: format, type-check, lint, YAML lint, contract-test, smart-test or targeted equivalent. +- [x] 6.3 Run SpecFact code review and resolve findings or document explicit exceptions. diff --git a/pyproject.toml b/pyproject.toml index d720f6ab..4b76c652 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.47.0" +version = "0.47.1" 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/scripts/check-command-contract.py b/scripts/check-command-contract.py index 2ce1c3ae..0e1d0d18 100644 --- a/scripts/check-command-contract.py +++ b/scripts/check-command-contract.py @@ -38,6 +38,7 @@ "not a valid command", ) _TEMP_HOME: tempfile.TemporaryDirectory[str] | None = None +MountedApps = dict[tuple[str, ...], tuple[object, tuple[str, ...]]] def _paired_worktree_repo(source_marker: str, target_marker: str) -> Path | None: @@ -107,27 +108,27 @@ def _is_group(record: dict[str, Any]) -> bool: return isinstance(subcommands, list) and len(subcommands) > 0 -def _load_apps() -> dict[tuple[str, ...], object]: +def _load_apps() -> MountedApps: from specfact_cli.cli import app as root_app - apps: dict[tuple[str, ...], object] = {("specfact",): root_app} + apps: MountedApps = {("specfact",): (root_app, ())} for module_path, attr_name, prefix in APP_MOUNTS: module = importlib.import_module(module_path) - apps[prefix] = getattr(module, attr_name) + internal_prefix = ("review",) if prefix == ("specfact", "code", "review") else () + apps[prefix] = (getattr(module, attr_name), internal_prefix) return apps -def _select_app(apps: dict[tuple[str, ...], object], command_parts: list[str]) -> tuple[object, list[str]]: +def _select_app(apps: MountedApps, command_parts: list[str]) -> tuple[object, list[str]]: best_prefix: tuple[str, ...] = ("specfact",) for prefix in apps: if len(prefix) > len(best_prefix) and tuple(command_parts[: len(prefix)]) == prefix: best_prefix = prefix - return apps[best_prefix], command_parts[len(best_prefix) :] + app, internal_prefix = apps[best_prefix] + return app, [*internal_prefix, *command_parts[len(best_prefix) :]] -def _invoke( - runner: CliRunner, apps: dict[tuple[str, ...], object], command_parts: list[str], suffix: list[str] -) -> tuple[int, str]: +def _invoke(runner: CliRunner, apps: MountedApps, command_parts: list[str], suffix: list[str]) -> tuple[int, str]: app, args = _select_app(apps, command_parts) invoke_args = [*args, *suffix] result = runner.invoke(cast(typer.Typer, app), invoke_args) @@ -139,7 +140,7 @@ def _invoke( return result.exit_code, f"{stdout}{stderr}" -def _check_help(runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any]) -> list[str]: +def _check_help(runner: CliRunner, apps: MountedApps, record: dict[str, Any]) -> list[str]: args = _command_args(record) if not args and record.get("command") != "specfact": return [f"{record.get('command')}: invalid command path in generated JSON"] @@ -166,9 +167,7 @@ def _check_help(runner: CliRunner, apps: dict[tuple[str, ...], object], record: return [] -def _check_group_missing_subcommand( - runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any] -) -> list[str]: +def _check_group_missing_subcommand(runner: CliRunner, apps: MountedApps, record: dict[str, Any]) -> list[str]: if record.get("command") == "specfact" or not _is_group(record) or record.get("bare_invocation") == "executes": return [] args = _command_args(record) @@ -189,9 +188,7 @@ def _check_group_missing_subcommand( return failures -def _check_missing_required_argument( - runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any] -) -> list[str]: +def _check_missing_required_argument(runner: CliRunner, apps: MountedApps, record: dict[str, Any]) -> list[str]: if _is_group(record) or not _has_required_argument(record): return [] args = _command_args(record) diff --git a/scripts/check-docs-commands.py b/scripts/check-docs-commands.py index 5cade892..9c73a041 100755 --- a/scripts/check-docs-commands.py +++ b/scripts/check-docs-commands.py @@ -130,12 +130,7 @@ def _tokens_from_specfact_line(line: str) -> list[str] | None: parts = _strip_leading_global_options(parts) if not parts: return None - out: list[str] = [] - for part in parts: - if part.startswith("-"): - break - out.append(part) - return out if out else None + return parts @beartype @@ -267,7 +262,7 @@ def _generated_prefix_match(tokens: list[str]) -> bool: def validate_command_tokens(tokens: list[str]) -> tuple[bool, str]: """True if some prefix of *tokens* is a valid CLI path (``… --help`` exits 0).""" tokens = _sanitize_command_tokens(tokens) - if not tokens: + if not tokens or tokens[0].startswith("-"): return True, "" invalid_code_import = _invalid_code_import_order_message(tokens) if invalid_code_import: @@ -297,6 +292,8 @@ def validate_command_tokens(tokens: list[str]) -> tuple[bool, str]: def _invalid_code_import_order_message(tokens: list[str]) -> str: if len(tokens) < 4 or tokens[:2] != ["code", "import"]: return "" + if tokens[2] in {"from-code", "from-bridge"}: + return "" try: first_flag_index = next(index for index, token in enumerate(tokens[2:], start=2) if token.startswith("-")) except StopIteration: diff --git a/scripts/generate-command-overview.py b/scripts/generate-command-overview.py index ab14260e..cef254bc 100644 --- a/scripts/generate-command-overview.py +++ b/scripts/generate-command-overview.py @@ -159,6 +159,15 @@ def _walk( return records +def _mounted_command(command: Any, path: tuple[str, ...]) -> Any: + """Flatten mounted Typer apps whose root command repeats the mount segment.""" + children = _command_children(command) + repeated_root = children.get(path[-1]) + if repeated_root is not None and len(children) == 1: + return repeated_root + return command + + def _root_record(root_subcommands: list[str]) -> dict[str, Any]: _ensure_imports() from specfact_cli.cli import app @@ -190,19 +199,35 @@ def build_records() -> list[dict[str, Any]]: ) records.extend( _walk( - get_command(app), + _mounted_command(get_command(app), prefix), prefix, f"{module_path}:{attr_name}", owner_package, install_prerequisite, ) ) + _populate_parent_subcommands(records) root_subcommands = sorted( {str(record["command"]).split()[1] for record in records if len(str(record["command"]).split()) > 1} ) return [_root_record(root_subcommands), *sorted(records, key=lambda record: record["command"])] +def _populate_parent_subcommands(records: list[dict[str, Any]]) -> None: + by_command = {str(record["command"]): record for record in records} + for command in sorted(by_command): + parts = command.split() + if len(parts) <= 1: + continue + parent_command = " ".join(parts[:-1]) + parent = by_command.get(parent_command) + if parent is None: + continue + subcommands = set(parent.get("subcommands", [])) + subcommands.add(parts[-1]) + parent["subcommands"] = sorted(subcommands) + + def _render_markdown(records: list[dict[str, Any]]) -> str: lines = [ "---", @@ -236,13 +261,25 @@ def _render_markdown(records: list[dict[str, Any]]) -> str: def _render_llms(markdown: str) -> str: + lines = markdown.splitlines() + closing_index = lines.index("---", 1) + front_matter = lines[: closing_index + 1] + body_lines = lines[closing_index + 1 :] + while body_lines and not body_lines[0]: + body_lines.pop(0) + if body_lines[:1] == ["# Generated SpecFact CLI Command Overview"]: + body_lines.pop(0) + while body_lines and not body_lines[0]: + body_lines.pop(0) return "\n".join( [ + *front_matter, + "", "# SpecFact CLI Commands", "", "Use this generated overview as the current command contract before following older docs or prompts.", "", - markdown, + *body_lines, ] ) diff --git a/setup.py b/setup.py index fa9736c8..da4401f2 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.47.0", + version="0.47.1", 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 81221203..5853735e 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.47.0" +__version__ = "0.47.1" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 3e9bab8f..0d4c3825 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -63,7 +63,11 @@ def _install_progressive_disclosure() -> None: return module = importlib.util.module_from_spec(spec) sys.modules[module_name] = module - spec.loader.exec_module(module) + try: + spec.loader.exec_module(module) + except Exception: + sys.modules.pop(module_name, None) + raise # Install the shared Click/Typer usage-error contract as soon as core is imported. @@ -71,6 +75,6 @@ def _install_progressive_disclosure() -> None: # keeps missing-command and missing-parameter UX consistent outside the root CLI too. _install_progressive_disclosure() -__version__ = "0.47.0" +__version__ = "0.47.1" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 5f69d345..dd844368 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -729,9 +729,12 @@ def _invoke_real_command(self, ctx: click.Context, args: tuple[str, ...] | list[ pass click.echo(f"Error: {_lazy_usage_error_message(exc, click_cmd, exc.ctx)}", file=sys.stderr) raise SystemExit(exc.exit_code) from None + except SystemExit as exc: + code = exc.code if isinstance(exc.code, int) else 1 if exc.code else 0 + raise SystemExit(code) from None except BaseException as exc: if exc.__class__.__name__.endswith("Exit"): - raise SystemExit(getattr(exc, "exit_code", 0)) from None + raise SystemExit(getattr(exc, "exit_code", getattr(exc, "code", 0))) from None raise if exit_code and exit_code != 0: raise SystemExit(exit_code) diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 59c5a5c1..643b0eb0 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.34 +version: 0.1.35 commands: - init category: core diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index dde213b2..4afba1ac 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -13,6 +13,7 @@ from rich.console import Console from rich.panel import Panel from rich.rule import Rule +from typer._click.core import Context as TyperClickContext from specfact_cli import __version__ from specfact_cli.contracts.module_interface import ModuleIOContract @@ -714,8 +715,9 @@ def init_ide( @app.callback(invoke_without_command=True) @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: typer.Context, + ctx: TyperClickContext, repo: Path = typer.Option( Path("."), "--repo", diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index f9135821..428c437c 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -1,5 +1,5 @@ name: upgrade -version: 0.1.20 +version: 0.1.21 commands: - upgrade category: core diff --git a/src/specfact_cli/modules/upgrade/src/commands.py b/src/specfact_cli/modules/upgrade/src/commands.py index 9ad791ad..fe56a56e 100644 --- a/src/specfact_cli/modules/upgrade/src/commands.py +++ b/src/specfact_cli/modules/upgrade/src/commands.py @@ -293,9 +293,10 @@ def _ensure_pipx_launcher_healthy() -> bool: launcher = shutil.which("specfact") if not launcher: console.print( - "[yellow]⚠ Could not find `specfact` on PATH after pipx upgrade; skipping launcher validation.[/yellow]" + "[yellow]⚠ Could not find `specfact` on PATH after pipx upgrade; " + "running `pipx reinstall specfact-cli`.[/yellow]" ) - return True + return _repair_pipx_launcher(None) first_check = _run_launcher_version_check(launcher) if first_check.returncode == 0: @@ -306,6 +307,11 @@ def _ensure_pipx_launcher_healthy() -> bool: console.print("[yellow]⚠ pipx launcher is stale or broken; running `pipx reinstall specfact-cli`.[/yellow]") _replay_upgrade_output(_coerce_subprocess_output(first_check.stdout)) _replay_upgrade_output(_coerce_subprocess_output(first_check.stderr)) + return _repair_pipx_launcher(launcher) + + +def _repair_pipx_launcher(previous_launcher: str | None) -> bool: + """Reinstall via pipx and validate the resulting public launcher.""" reinstall = _run_pipx_reinstall() _replay_upgrade_output(_coerce_subprocess_output(reinstall.stdout)) _replay_upgrade_output(_coerce_subprocess_output(reinstall.stderr)) @@ -313,6 +319,11 @@ def _ensure_pipx_launcher_healthy() -> bool: console.print(f"[red]✗ pipx reinstall specfact-cli failed with exit code {reinstall.returncode}[/red]") return False + launcher = shutil.which("specfact") or previous_launcher + if not launcher: + console.print("[red]✗ `specfact` is still missing on PATH after reinstall[/red]") + return False + second_check = _run_launcher_version_check(launcher) _replay_upgrade_output(_coerce_subprocess_output(second_check.stdout)) _replay_upgrade_output(_coerce_subprocess_output(second_check.stderr)) diff --git a/src/specfact_cli/utils/structure.py b/src/specfact_cli/utils/structure.py index 043dc480..e35fd5f2 100644 --- a/src/specfact_cli/utils/structure.py +++ b/src/specfact_cli/utils/structure.py @@ -980,7 +980,7 @@ def create_readme(cls, base_path: Path | None = None) -> None: specfact project plan init --interactive # Analyze existing code -specfact code import --repo . +specfact code import --repo . # Compare plans specfact project plan compare --manual .specfact/plans/main.bundle.yaml --auto .specfact/plans/auto-derived-.bundle.yaml diff --git a/src/specfact_cli/utils/suggestions.py b/src/specfact_cli/utils/suggestions.py index b1860d9b..abbbdbc3 100644 --- a/src/specfact_cli/utils/suggestions.py +++ b/src/specfact_cli/utils/suggestions.py @@ -46,14 +46,14 @@ def suggest_next_steps(repo_path: Path, context: ProjectContext | None = None) - # First-time setup suggestions if not context.has_plan and not context.has_config: - suggestions.append("specfact code import --repo . # Import your codebase") + suggestions.append("specfact code import --repo . # Import your codebase") suggestions.append("specfact init # Initialize SpecFact configuration") return suggestions # Analysis suggestions if context.has_plan and context.contract_coverage < 0.5: suggestions.append("specfact analyze --bundle # Analyze contract coverage") - suggestions.append("specfact code import --repo . # Update the project bundle from code") + suggestions.append("specfact code import --repo . # Update the project bundle from code") # Specmatic integration suggestions if context.has_specmatic_config and not context.openapi_specs: @@ -90,8 +90,8 @@ def suggest_fixes(error_message: str, context: ProjectContext | None = None) -> # Bundle not found if "bundle" in error_lower and ("not found" in error_lower or "does not exist" in error_lower): - suggestions.append("specfact project plan select # Select an active plan bundle") - suggestions.append("specfact code import --repo . # Create a new bundle from code") + suggestions.append("specfact project --help # Inspect available project bundle commands") + suggestions.append("specfact code import --repo . # Create a new bundle from code") # Contract validation errors if "contract" in error_lower and ("violation" in error_lower or "invalid" in error_lower): @@ -105,7 +105,7 @@ def suggest_fixes(error_message: str, context: ProjectContext | None = None) -> # Import errors if "import" in error_lower and "failed" in error_lower: - suggestions.append("specfact code import --repo . # Retry import") + suggestions.append("specfact code import --repo . # Retry import") return suggestions @@ -127,7 +127,7 @@ def suggest_improvements(context: ProjectContext) -> list[str]: # Low contract coverage if context.contract_coverage < 0.3: suggestions.append("specfact analyze --bundle # Identify missing contracts") - suggestions.append("specfact code import --repo . # Extract contracts from code") + suggestions.append("specfact code import --repo . # Extract contracts from code") # Missing OpenAPI specs if context.has_plan and not context.openapi_specs: diff --git a/tests/unit/cli/test_error_guidance.py b/tests/unit/cli/test_error_guidance.py index baf682be..cef8da85 100644 --- a/tests/unit/cli/test_error_guidance.py +++ b/tests/unit/cli/test_error_guidance.py @@ -1,5 +1,6 @@ from __future__ import annotations +import re from typing import Any, cast import pytest @@ -11,12 +12,19 @@ from specfact_cli.cli import app +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + + def test_unknown_root_command_shows_help_and_recovery_guidance() -> None: runner = CliRunner() result = runner.invoke(cast(Any, get_command(app)), ["hello"]) assert result.exit_code != 0 - output = result.output.lower() + output = _strip_ansi(result.output).lower() assert "usage: specfact" in output assert "hello" in output assert "not a valid command" in output or "no such command" in output @@ -44,7 +52,7 @@ def test_global_group_without_subcommand_shows_help_and_missing_subcommand() -> result = CliRunner().invoke(cast(Any, get_command(_sample_app())), ["widgets"]) assert result.exit_code == 2 - output = result.output.lower() + output = _strip_ansi(result.output).lower() assert "usage:" in output assert "manage widgets" in output assert "list" in output @@ -56,7 +64,7 @@ def test_global_leaf_missing_argument_shows_help_and_missing_parameter() -> None result = CliRunner().invoke(cast(Any, get_command(_sample_app())), ["widgets", "deploy"]) assert result.exit_code == 2 - output = result.output.lower() + output = _strip_ansi(result.output).lower() assert "usage:" in output assert "deploy" in output assert "target" in output @@ -67,7 +75,7 @@ def test_global_nested_unknown_command_shows_group_help_and_invalid_command() -> result = CliRunner().invoke(cast(Any, get_command(_sample_app())), ["widgets", "remove"]) assert result.exit_code == 2 - output = result.output.lower() + output = _strip_ansi(result.output).lower() assert "usage:" in output assert "manage widgets" in output assert "remove" in output @@ -89,18 +97,11 @@ def test_global_nested_unknown_command_shows_group_help_and_invalid_command() -> ("module subgroup leaf missing arg", ["module", "alias", "create"]), ("init subgroup missing option value", ["init", "ide", "--repo"]), ("upgrade bad option", ["upgrade", "--bad-option"]), - ("code missing subcommand", ["code"]), - ("code typo", ["code", "impor"]), - ("code import missing option value", ["code", "import", "--repo"]), - ("backlog auth missing subcommand", ["backlog", "auth"]), - ("backlog delta status missing context", ["backlog", "delta", "status"]), - ("project sync typo", ["project", "sync", "brdge"]), - ("project sync bridge missing option value", ["project", "sync", "bridge", "--repo"]), ], ) def test_cli_misuse_matrix_shows_contextual_help_once(name: str, args: list[str]) -> None: result = TyperCliRunner().invoke(app, args) - output = result.output.lower() + output = _strip_ansi(result.output).lower() assert result.exit_code != 0, name assert "usage:" in output, name diff --git a/tests/unit/cli/test_lean_help_output.py b/tests/unit/cli/test_lean_help_output.py index 40e33cdd..4072bb68 100644 --- a/tests/unit/cli/test_lean_help_output.py +++ b/tests/unit/cli/test_lean_help_output.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from typing import Any, cast import click @@ -16,6 +17,12 @@ runner = CliRunner() +_ANSI_RE = re.compile(r"\x1b\[[0-9;]*m") + + +def _strip_ansi(text: str) -> str: + return _ANSI_RE.sub("", text) + CORE_THREE = {"init", "module", "upgrade"} EXTRACTED_ANY = [ @@ -151,22 +158,24 @@ def alias_command() -> None: def test_lazy_delegate_bare_group_shows_full_help_and_missing_subcommand() -> None: """Bare lazy groups should show real help, explicit missing-subcommand guidance, and full command path.""" result = runner.invoke(app, ["module"], catch_exceptions=False) + output = _strip_ansi(result.output) assert result.exit_code == 2 - assert "Usage: specfact module" in result.output - assert "Manage marketplace modules" in result.output - assert "Missing subcommand" in result.output - assert "list" in result.output + assert "Usage: specfact module" in output + assert "Manage marketplace modules" in output + assert "Missing subcommand" in output + assert "list" in output def test_lazy_delegate_missing_option_value_shows_leaf_help() -> None: """Dangling option values must render the delegated leaf help, not the wrapper usage.""" result = runner.invoke(app, ["module", "install", "--scope"], catch_exceptions=False) + output = _strip_ansi(result.output) assert result.exit_code == 2 - assert "Usage: specfact module install" in result.output - assert "Option '--scope' requires an argument" in result.output - assert "MODULE_IDS" in result.output + assert "Usage: specfact module install" in output + assert "Option '--scope' requires an argument" in output + assert "MODULE_IDS" in output def test_lazy_delegate_help_falls_back_when_typer_command_build_fails(monkeypatch: pytest.MonkeyPatch) -> None: diff --git a/tests/unit/commands/test_update.py b/tests/unit/commands/test_update.py index e2ebd97a..a21f161e 100644 --- a/tests/unit/commands/test_update.py +++ b/tests/unit/commands/test_update.py @@ -8,7 +8,7 @@ import subprocess from io import StringIO from pathlib import Path -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, call, patch from rich.console import Console @@ -397,21 +397,28 @@ def test_check_only_uvx_does_not_print_upgrade_command(mock_detect: MagicMock, m @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") -@patch("specfact_cli.modules.upgrade.src.commands.shutil.which", return_value=None) +@patch("specfact_cli.modules.upgrade.src.commands.shutil.which") @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") def test_successful_pipx_upgrade_suppresses_spaced_home_warning( mock_run: MagicMock, mock_which: MagicMock, mock_update_metadata: MagicMock ) -> None: """Successful pipx upgrades must not leak pipx's benign spaced-home warning.""" - mock_run.return_value.returncode = 0 - mock_run.return_value.stdout = ( - "Found a space in the pipx home path. We heavily discourage this, due to multiple\n" - " incompatibilities. Please check our docs for more information on this, as well as\n" - " some pointers on how to migrate to a different home path.\n" - "To see your PIPX_HOME dir: pipx environment --value PIPX_HOME\n" - "Most likely fix on macOS: mv ~/Library/Application\\ Support/pipx ~/.local/\n" - "upgraded package specfact-cli from 0.46.19 to 0.46.25\n" + mock_which.side_effect = [None, "/usr/local/bin/specfact"] + spaced_home_output = ( + b"Found a space in the pipx home path. We heavily discourage this, due to multiple\n" + b" incompatibilities. Please check our docs for more information on this, as well as\n" + b" some pointers on how to migrate to a different home path.\n" + b"To see your PIPX_HOME dir: pipx environment --value PIPX_HOME\n" + b"Most likely fix on macOS: mv ~/Library/Application\\ Support/pipx ~/.local/\n" + b"upgraded package specfact-cli from 0.46.19 to 0.46.25\n" ) + mock_run.side_effect = [ + subprocess.CompletedProcess(["pipx", "upgrade", "specfact-cli"], 0, stdout=spaced_home_output, stderr=b""), + subprocess.CompletedProcess(["pipx", "reinstall", "specfact-cli"], 0, stdout=b"reinstalled\n", stderr=b""), + subprocess.CompletedProcess( + ["/usr/local/bin/specfact", "--version"], 0, stdout=b"SpecFact CLI - v0.47.0\n", stderr=b"" + ), + ] output = StringIO() with patch( @@ -421,11 +428,12 @@ def test_successful_pipx_upgrade_suppresses_spaced_home_warning( result = install_update(InstallationMethod("pipx", "pipx upgrade specfact-cli", None), yes=True) assert result is True - mock_which.assert_called_once_with("specfact") + assert mock_which.call_args_list == [call("specfact"), call("specfact")] rendered = output.getvalue() assert "Found a space in the pipx home path" not in rendered assert "PIPX_HOME" not in rendered assert "upgraded package specfact-cli from 0.46.19 to 0.46.25" in rendered + assert "reinstalled" in rendered @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") @@ -459,7 +467,7 @@ def test_successful_pipx_upgrade_repairs_stale_launcher( result = _execute_upgrade_command(["pipx", "upgrade", "specfact-cli"]) assert result is True - mock_which.assert_called_once_with("specfact") + assert mock_which.call_args_list == [call("specfact"), call("specfact")] assert [call.args[0] for call in mock_run.call_args_list] == [ ["pipx", "upgrade", "specfact-cli"], ["/usr/local/bin/specfact", "--version"], @@ -473,6 +481,45 @@ def test_successful_pipx_upgrade_repairs_stale_launcher( mock_update_metadata.assert_called_once() +@patch("specfact_cli.modules.upgrade.src.commands.update_metadata") +@patch("specfact_cli.modules.upgrade.src.commands.shutil.which") +@patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") +def test_successful_pipx_upgrade_repairs_missing_launcher( + mock_run: MagicMock, + mock_which: MagicMock, + mock_update_metadata: MagicMock, +) -> None: + """A missing launcher after pipx upgrade is repairable and must be validated.""" + mock_which.side_effect = [None, "/usr/local/bin/specfact"] + mock_run.side_effect = [ + subprocess.CompletedProcess(["pipx", "upgrade", "specfact-cli"], 0, stdout=b"already latest\n", stderr=b""), + subprocess.CompletedProcess(["pipx", "reinstall", "specfact-cli"], 0, stdout=b"reinstalled\n", stderr=b""), + subprocess.CompletedProcess( + ["/usr/local/bin/specfact", "--version"], 0, stdout=b"SpecFact CLI - v0.47.0\n", stderr=b"" + ), + ] + output = StringIO() + + with patch( + "specfact_cli.modules.upgrade.src.commands.console", + Console(file=output, force_terminal=False, width=120), + ): + result = _execute_upgrade_command(["pipx", "upgrade", "specfact-cli"]) + + assert result is True + assert mock_which.call_args_list == [call("specfact"), call("specfact")] + assert [call.args[0] for call in mock_run.call_args_list] == [ + ["pipx", "upgrade", "specfact-cli"], + ["pipx", "reinstall", "specfact-cli"], + ["/usr/local/bin/specfact", "--version"], + ] + rendered = output.getvalue() + assert "Could not find `specfact` on PATH after pipx upgrade" in rendered + assert "reinstalled" in rendered + assert "SpecFact CLI - v0.47.0" in rendered + mock_update_metadata.assert_called_once() + + @patch("specfact_cli.modules.upgrade.src.commands.update_metadata") @patch("specfact_cli.modules.upgrade.src.commands.shutil.which", return_value="/usr/local/bin/specfact") @patch("specfact_cli.modules.upgrade.src.commands.subprocess.run") diff --git a/tests/unit/docs/test_docs_validation_scripts.py b/tests/unit/docs/test_docs_validation_scripts.py index 9a982096..9893833e 100644 --- a/tests/unit/docs/test_docs_validation_scripts.py +++ b/tests/unit/docs/test_docs_validation_scripts.py @@ -51,7 +51,7 @@ def test_collect_specfact_commands_chained_with_and() -> None: assert ["module", "list"] in cmds -def test_tokens_from_line_stops_at_flags() -> None: +def test_tokens_from_line_preserves_flags_for_order_validation() -> None: mod = _load_check_docs_commands() text = """ ```bash @@ -59,7 +59,7 @@ def test_tokens_from_line_stops_at_flags() -> None: ``` """ cmds = mod.collect_specfact_commands_from_text(text) - assert ["backlog", "analyze-deps"] in cmds + assert ["backlog", "analyze-deps", "--json"] in cmds def test_code_import_options_after_bundle_are_rejected() -> None: @@ -72,6 +72,24 @@ def test_code_import_options_after_bundle_are_rejected() -> None: assert "--repo" in message +def test_code_import_explicit_subcommand_keeps_legacy_bundle_position() -> None: + mod = _load_check_docs_commands() + + ok, message = mod.validate_command_tokens(["code", "import", "from-code", "legacy-api", "--repo", "."]) + + assert ok is True + assert message == "" + + +def test_placeholder_command_examples_are_ignored() -> None: + mod = _load_check_docs_commands() + + ok, message = mod.validate_command_tokens(["", "--help"]) + + assert ok is True + assert message == "" + + def test_core_cli_modes_page_is_not_excluded_from_command_validation() -> None: mod = _load_check_docs_commands() @@ -86,7 +104,7 @@ def test_tokens_skip_leading_global_options_before_subcommand() -> None: ``` """ cmds = mod.collect_specfact_commands_from_text(text) - assert ["import", "from-code", "legacy-api"] in cmds + assert ["import", "from-code", "legacy-api", "--repo", ".", "--confidence", "0.7"] in cmds def test_collect_specfact_commands_from_guidance_text_handles_inline_and_yaml() -> None: @@ -97,15 +115,17 @@ def test_collect_specfact_commands_from_guidance_text_handles_inline_and_yaml() - specfact project sync bridge --help """ cmds = mod.collect_specfact_commands_from_guidance_text(text) - assert ["module", "list"] in cmds - assert ["project", "sync", "bridge"] in cmds + assert ["module", "list", "--show-origin"] in cmds + assert ["project", "sync", "bridge", "--help"] in cmds def test_scan_guidance_templates_validates_resource_templates(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: mod = _load_check_docs_commands() monkeypatch.setattr(mod, "_REPO_ROOT", tmp_path) monkeypatch.setattr(mod, "_ADDITIONAL_GUIDANCE_ROOTS", (tmp_path / "resources",)) - monkeypatch.setattr(mod, "validate_command_tokens", lambda tokens: (tokens != ["sync", "bridge"], "stale")) + monkeypatch.setattr( + mod, "validate_command_tokens", lambda tokens: (tokens != ["sync", "bridge", "--help"], "stale") + ) docs_root = tmp_path / "docs" docs_root.mkdir() @@ -115,8 +135,8 @@ def test_scan_guidance_templates_validates_resource_templates(tmp_path: Path, mo seen, failures = mod._scan_guidance_templates_for_command_validation(docs_root) - assert ("sync", "bridge") in seen - assert failures == ["resources/templates/protocol.yaml.j2: specfact sync bridge — stale"] + assert ("sync", "bridge", "--help") in seen + assert failures == ["resources/templates/protocol.yaml.j2: specfact sync bridge --help — stale"] def test_cross_site_url_stops_at_markdown_delimiters() -> None: diff --git a/tests/unit/utils/test_suggestions.py b/tests/unit/utils/test_suggestions.py index a59838f6..4728620c 100644 --- a/tests/unit/utils/test_suggestions.py +++ b/tests/unit/utils/test_suggestions.py @@ -54,7 +54,7 @@ def test_suggest_bundle_not_found(self) -> None: error = "Bundle 'test' not found" suggestions = suggest_fixes(error) assert len(suggestions) > 0 - assert any("plan select" in s.lower() for s in suggestions) + assert any("project --help" in s.lower() for s in suggestions) def test_suggest_contract_validation_error(self) -> None: """Test suggestions for contract validation error.""" From a07cd54e05a468ce4135bb0bae04381de098b39a Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Mon, 1 Jun 2026 23:45:52 +0200 Subject: [PATCH 4/7] fix: address core PR CI failures --- .github/workflows/specfact.yml | 24 +++++++++++++++++++ CHANGELOG.md | 11 +++++++++ docs/reference/commands.generated.json | 1 + docs/reference/commands.generated.md | 2 +- llms.txt | 2 +- .../tester-cli-reliability/TDD_EVIDENCE.md | 22 ++++++++++++++++- pyproject.toml | 2 +- setup.py | 2 +- src/__init__.py | 2 +- src/specfact_cli/__init__.py | 2 +- .../modules/init/module-package.yaml | 2 +- src/specfact_cli/modules/init/src/commands.py | 4 +--- .../modules/init/test_first_run_selection.py | 6 +++++ .../test_trustworthy_green_checks.py | 11 +++++++++ 14 files changed, 82 insertions(+), 11 deletions(-) diff --git a/.github/workflows/specfact.yml b/.github/workflows/specfact.yml index 8daea5bf..bda7b019 100644 --- a/.github/workflows/specfact.yml +++ b/.github/workflows/specfact.yml @@ -47,6 +47,30 @@ jobs: - name: Checkout uses: actions/checkout@v4 + - name: Resolve module bundles ref + id: modules-ref + shell: bash + env: + CANDIDATE_REF: ${{ github.head_ref || github.ref_name }} + run: | + candidate="${CANDIDATE_REF}" + if [ -n "$candidate" ] && git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git "$candidate" >/dev/null 2>&1; then + echo "ref=$candidate" >> "$GITHUB_OUTPUT" + else + echo "ref=dev" >> "$GITHUB_OUTPUT" + fi + + - name: Checkout module bundles repo + uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 + with: + repository: nold-ai/specfact-cli-modules + path: specfact-cli-modules + ref: ${{ steps.modules-ref.outputs.ref }} + persist-credentials: false + + - name: Export module bundles path + run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" + - name: Set up Python uses: actions/setup-python@v5 with: diff --git a/CHANGELOG.md b/CHANGELOG.md index f3c1602a..3714c4e9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,17 @@ All notable changes to this project will be documented in this file. --- +## [0.47.2] - 2026-06-01 + +### Fixed + +- **Core PR CI follow-up**: remove a private Typer context import from the + `init` callback, run standalone contract validation against the paired + modules branch, and refresh generated command artifacts for the new code + review enforcement option. + +--- + ## [0.47.1] - 2026-06-01 ### Fixed diff --git a/docs/reference/commands.generated.json b/docs/reference/commands.generated.json index 2b6991ef..a444d190 100644 --- a/docs/reference/commands.generated.json +++ b/docs/reference/commands.generated.json @@ -1000,6 +1000,7 @@ "install_prerequisite": "specfact module install nold-ai/specfact-code-review", "options": [ "--bug-hunt", + "--enforcement", "--exclude-tests", "--fix", "--focus", diff --git a/docs/reference/commands.generated.md b/docs/reference/commands.generated.md index 667627a3..3199fa1f 100644 --- a/docs/reference/commands.generated.md +++ b/docs/reference/commands.generated.md @@ -59,7 +59,7 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact code review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | | `specfact code review rules show` | nold-ai/specfact-code-review | -; args: - | - | | | `specfact code review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | -| `specfact code review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | +| `specfact code review run` | nold-ai/specfact-code-review | --bug-hunt, --enforcement, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | | `specfact code validate` | nold-ai/specfact-codebase | -; args: - | sidecar | | | `specfact code validate sidecar` | nold-ai/specfact-codebase | -; args: - | init, run | | | `specfact code validate sidecar init` | nold-ai/specfact-codebase | -; args: - | - | | diff --git a/llms.txt b/llms.txt index 5c8a2703..5a023302 100644 --- a/llms.txt +++ b/llms.txt @@ -61,7 +61,7 @@ This file is generated from the current CLI command tree. Do not edit by hand. | `specfact code review rules init` | nold-ai/specfact-code-review | --ide; args: - | - | | | `specfact code review rules show` | nold-ai/specfact-code-review | -; args: - | - | | | `specfact code review rules update` | nold-ai/specfact-code-review | --ide; args: - | - | | -| `specfact code review run` | nold-ai/specfact-code-review | --bug-hunt, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | +| `specfact code review run` | nold-ai/specfact-code-review | --bug-hunt, --enforcement, --exclude-tests, --fix, --focus, --include-noise, --include-tests, --instructions, --interactive, --json, --level, --mode, --no-tests, --out, --path, --preview-fixes, --scope, --score-only, --suppress-noise, --with-mutation; args: - | - | | | `specfact code validate` | nold-ai/specfact-codebase | -; args: - | sidecar | | | `specfact code validate sidecar` | nold-ai/specfact-codebase | -; args: - | init, run | | | `specfact code validate sidecar init` | nold-ai/specfact-codebase | -; args: - | - | | diff --git a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md index c3e50825..6fbc318b 100644 --- a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md +++ b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md @@ -140,7 +140,7 @@ - `specfact.yml` contract validation preserves the real repro exit status and validates/quotes the budget input before invoking the command. - Progressive-disclosure bootstrap removes a partially-loaded module from `sys.modules` if import execution fails. - Lazy delegated plain `SystemExit` codes are preserved instead of being normalized to success. - - Public `init` callback now has `@beartype`; the injected Typer vendored Click context is annotated with the runtime context type so Typer error rendering remains intact. + - Public `init` callback was initially wrapped with `@beartype` using Typer's vendored Click context type; this was later replaced after CI proved that private Typer namespace is not available under the declared dependency range. - Pipx upgrade validation treats a missing launcher as repairable via `pipx reinstall specfact-cli`, then re-checks the launcher before reporting success. - Suggestions and docs were updated away from removed `project plan select` and legacy `code import --repo . ` forms. - `.markdownlint.json` re-enables MD040 fenced-code-language enforcement. @@ -150,3 +150,23 @@ - `hatch run pytest tests/unit/commands/test_update.py tests/unit/utils/test_suggestions.py tests/unit/docs/test_docs_validation_scripts.py tests/unit/cli/test_error_guidance.py tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_bare_group_shows_full_help_and_missing_subcommand tests/unit/cli/test_lean_help_output.py::test_lazy_delegate_missing_option_value_shows_leaf_help tests/unit/workflows/test_trustworthy_green_checks.py -q` -> 89 passed, 2 warnings. - `hatch run check-command-contract && hatch run check-command-overview && hatch run python scripts/check-docs-commands.py && hatch run lint-workflows && openspec validate tester-cli-reliability --strict` -> passed. - `hatch run format && hatch run lint` -> passed. + +## Second Follow-up PR CI Fixes + +- Re-checked PR #595 after pushing the paired modules fixes. Fresh CI failures were valid: + - Docs Review failed in `hatch run check-command-overview` with `ModuleNotFoundError: No module named 'typer._click'` while importing `src/specfact_cli/modules/init/src/commands.py`. + - Contract Validation failed because `.github/workflows/specfact.yml` ran `specfact code repro` without checking out the paired `specfact-cli-modules` branch, so the installable `code` command group was unavailable. +- Fixed the Typer compatibility regression by removing the private `typer._click` import from the public `init` callback path and using the public `typer.Context` annotation without beartype on that existing framework callback. +- Added `test_init_commands_avoid_private_typer_click_import` to keep the init command source off Typer private namespaces. +- Updated `.github/workflows/specfact.yml` to resolve the matching modules branch, fall back to `dev`, check out `nold-ai/specfact-cli-modules` with pinned `actions/checkout`, and export `SPECFACT_MODULES_REPO` before contract validation. +- Added `test_specfact_contract_workflow_checks_out_matching_modules_branch_when_available`. +- Refreshed core generated command artifacts after the paired modules branch introduced `specfact code review run --enforcement`. +- Follow-up verification: + - `hatch run pytest tests/unit/modules/init/test_first_run_selection.py tests/unit/docs/test_docs_validation_scripts.py tests/unit/workflows/test_trustworthy_green_checks.py -q` -> 52 passed, 2 warnings. + - `hatch run check-command-overview` -> passed. + - `hatch run check-command-contract` -> passed: `check-command-contract: OK (107 generated command path(s) validated)`. + - `hatch run check-docs-commands` -> passed: `check-docs-commands: OK (380 unique command prefix(es) checked)`. + - `hatch run lint-workflows` -> passed. + - `hatch run yaml-lint` -> passed. + - `hatch run lint` -> passed. + - `openspec validate tester-cli-reliability --strict` -> passed. diff --git a/pyproject.toml b/pyproject.toml index 4b76c652..7822d6a2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.47.1" +version = "0.47.2" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index da4401f2..6d8b4b8f 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.47.1", + version="0.47.2", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 5853735e..25758c1d 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.47.1" +__version__ = "0.47.2" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 0d4c3825..1c1ff1c5 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -75,6 +75,6 @@ def _install_progressive_disclosure() -> None: # keeps missing-command and missing-parameter UX consistent outside the root CLI too. _install_progressive_disclosure() -__version__ = "0.47.1" +__version__ = "0.47.2" __all__ = ["__version__"] diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 643b0eb0..c119a68d 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,5 +1,5 @@ name: init -version: 0.1.35 +version: 0.1.36 commands: - init category: core diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index 4afba1ac..dde213b2 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -13,7 +13,6 @@ from rich.console import Console from rich.panel import Panel from rich.rule import Rule -from typer._click.core import Context as TyperClickContext from specfact_cli import __version__ from specfact_cli.contracts.module_interface import ModuleIOContract @@ -715,9 +714,8 @@ def init_ide( @app.callback(invoke_without_command=True) @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: TyperClickContext, + ctx: typer.Context, repo: Path = typer.Option( Path("."), "--repo", diff --git a/tests/unit/modules/init/test_first_run_selection.py b/tests/unit/modules/init/test_first_run_selection.py index 51d9b542..ef1442f4 100644 --- a/tests/unit/modules/init/test_first_run_selection.py +++ b/tests/unit/modules/init/test_first_run_selection.py @@ -15,6 +15,12 @@ runner = CliRunner() +def test_init_commands_avoid_private_typer_click_import() -> None: + source = Path("src/specfact_cli/modules/init/src/commands.py").read_text(encoding="utf-8") + + assert "typer._click" not in source + + def _telemetry_track_context(): return patch( "specfact_cli.modules.init.src.commands.telemetry", diff --git a/tests/unit/workflows/test_trustworthy_green_checks.py b/tests/unit/workflows/test_trustworthy_green_checks.py index 31ae593b..633d2501 100644 --- a/tests/unit/workflows/test_trustworthy_green_checks.py +++ b/tests/unit/workflows/test_trustworthy_green_checks.py @@ -14,6 +14,7 @@ REPO_ROOT = Path(__file__).resolve().parents[3] PR_ORCHESTRATOR = REPO_ROOT / ".github" / "workflows" / "pr-orchestrator.yml" DOCS_REVIEW = REPO_ROOT / ".github" / "workflows" / "docs-review.yml" +SPECFACT_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "specfact.yml" SIGN_MODULES = REPO_ROOT / ".github" / "workflows" / "sign-modules.yml" PUBLISH_MODULES = REPO_ROOT / ".github" / "workflows" / "publish-modules.yml" PRE_COMMIT_CONFIG = REPO_ROOT / ".pre-commit-config.yaml" @@ -238,6 +239,16 @@ def test_docs_review_checks_out_matching_modules_branch_when_available() -> None assert "git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git" in raw assert 'echo "ref=dev" >> "$GITHUB_OUTPUT"' in raw assert "ref: ${{ steps.modules-ref.outputs.ref }}" in raw + + +def test_specfact_contract_workflow_checks_out_matching_modules_branch_when_available() -> None: + """Standalone contract validation must resolve installable module commands from paired branches.""" + raw = SPECFACT_WORKFLOW.read_text(encoding="utf-8") + assert raw.count("id: modules-ref") == 1 + assert "git ls-remote --exit-code --heads https://github.com/nold-ai/specfact-cli-modules.git" in raw + assert 'echo "ref=dev" >> "$GITHUB_OUTPUT"' in raw + assert "ref: ${{ steps.modules-ref.outputs.ref }}" in raw + assert "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" in raw assert "ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }}" not in raw From da5b7b7289ed25e75c1c3f93ab459f6ec177a7ba Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 2 Jun 2026 00:07:15 +0200 Subject: [PATCH 5/7] fix: expose paired module roots in contract validation --- .github/workflows/specfact.yml | 4 +++- openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md | 2 ++ tests/unit/modules/init/test_first_run_selection.py | 9 ++++++--- tests/unit/workflows/test_trustworthy_green_checks.py | 1 + 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/.github/workflows/specfact.yml b/.github/workflows/specfact.yml index bda7b019..bd2b20e0 100644 --- a/.github/workflows/specfact.yml +++ b/.github/workflows/specfact.yml @@ -69,7 +69,9 @@ jobs: persist-credentials: false - name: Export module bundles path - run: echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" + run: | + echo "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" >> "$GITHUB_ENV" + echo "SPECFACT_MODULES_ROOTS=${GITHUB_WORKSPACE}/specfact-cli-modules/packages" >> "$GITHUB_ENV" - name: Set up Python uses: actions/setup-python@v5 diff --git a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md index 6fbc318b..43bc4664 100644 --- a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md +++ b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md @@ -158,7 +158,9 @@ - Contract Validation failed because `.github/workflows/specfact.yml` ran `specfact code repro` without checking out the paired `specfact-cli-modules` branch, so the installable `code` command group was unavailable. - Fixed the Typer compatibility regression by removing the private `typer._click` import from the public `init` callback path and using the public `typer.Context` annotation without beartype on that existing framework callback. - Added `test_init_commands_avoid_private_typer_click_import` to keep the init command source off Typer private namespaces. +- Updated `test_init_commands_avoid_private_typer_click_import` to read the imported commands module source path instead of assuming pytest runs from the repository root. - Updated `.github/workflows/specfact.yml` to resolve the matching modules branch, fall back to `dev`, check out `nold-ai/specfact-cli-modules` with pinned `actions/checkout`, and export `SPECFACT_MODULES_REPO` before contract validation. +- After rerunning Contract Validation, added `SPECFACT_MODULES_ROOTS=${GITHUB_WORKSPACE}/specfact-cli-modules/packages` because runtime module discovery uses module roots, while `SPECFACT_MODULES_REPO` alone is only repository/path context. - Added `test_specfact_contract_workflow_checks_out_matching_modules_branch_when_available`. - Refreshed core generated command artifacts after the paired modules branch introduced `specfact code review run --enforcement`. - Follow-up verification: diff --git a/tests/unit/modules/init/test_first_run_selection.py b/tests/unit/modules/init/test_first_run_selection.py index ef1442f4..c7758960 100644 --- a/tests/unit/modules/init/test_first_run_selection.py +++ b/tests/unit/modules/init/test_first_run_selection.py @@ -2,21 +2,24 @@ from __future__ import annotations +import inspect from pathlib import Path from unittest.mock import MagicMock, patch import pytest from typer.testing import CliRunner -from specfact_cli.modules.init.src import first_run_selection as frs -from specfact_cli.modules.init.src.commands import app +from specfact_cli.modules.init.src import commands as init_commands, first_run_selection as frs runner = CliRunner() +app = init_commands.app def test_init_commands_avoid_private_typer_click_import() -> None: - source = Path("src/specfact_cli/modules/init/src/commands.py").read_text(encoding="utf-8") + source_path = inspect.getsourcefile(init_commands) + assert source_path is not None + source = Path(source_path).read_text(encoding="utf-8") assert "typer._click" not in source diff --git a/tests/unit/workflows/test_trustworthy_green_checks.py b/tests/unit/workflows/test_trustworthy_green_checks.py index 633d2501..26872e57 100644 --- a/tests/unit/workflows/test_trustworthy_green_checks.py +++ b/tests/unit/workflows/test_trustworthy_green_checks.py @@ -249,6 +249,7 @@ def test_specfact_contract_workflow_checks_out_matching_modules_branch_when_avai assert 'echo "ref=dev" >> "$GITHUB_OUTPUT"' in raw assert "ref: ${{ steps.modules-ref.outputs.ref }}" in raw assert "SPECFACT_MODULES_REPO=${GITHUB_WORKSPACE}/specfact-cli-modules" in raw + assert "SPECFACT_MODULES_ROOTS=${GITHUB_WORKSPACE}/specfact-cli-modules/packages" in raw assert "ref: ${{ (github.ref == 'refs/heads/main' || github.head_ref == 'main') && 'main' || 'dev' }}" not in raw From 5ef09065cbb16983ae1b3815e35055a1ce388739 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 2 Jun 2026 00:09:30 +0200 Subject: [PATCH 6/7] docs: fix integration baseline example --- .../integration-showcases-testing-guide.md | 12 ++++-------- .../changes/tester-cli-reliability/TDD_EVIDENCE.md | 1 + 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/docs/examples/integration-showcases/integration-showcases-testing-guide.md b/docs/examples/integration-showcases/integration-showcases-testing-guide.md index 9f37ad80..a0157cee 100644 --- a/docs/examples/integration-showcases/integration-showcases-testing-guide.md +++ b/docs/examples/integration-showcases/integration-showcases-testing-guide.md @@ -1150,15 +1150,11 @@ specfact --no-banner code import from-code --repo . --output-format yaml **Important**: After creating the initial plan, keep the bundle name explicit for later drift comparison steps: ```bash -# Find the created plan bundle -# Use bundle name directly (no need to find file) -BUNDLE_NAME="example4_github_actions" -PLAN_NAME=$(basename "$PLAN_FILE") +# Use the exact bundle name created in Step 2. +# Keep this value consistent in later drift commands. +BUNDLE_NAME="example4_precommit" -# Verify the bundle that later comparison steps will use explicitly -specfact --no-banner project health-check --project-name "$BUNDLE_NAME" --repo . - -# Verify the project command surface remains available in this repo +# Verify the baseline bundle exists before later comparison steps. specfact --no-banner project health-check --project-name "$BUNDLE_NAME" --repo . ``` diff --git a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md index 43bc4664..8c80cd87 100644 --- a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md +++ b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md @@ -161,6 +161,7 @@ - Updated `test_init_commands_avoid_private_typer_click_import` to read the imported commands module source path instead of assuming pytest runs from the repository root. - Updated `.github/workflows/specfact.yml` to resolve the matching modules branch, fall back to `dev`, check out `nold-ai/specfact-cli-modules` with pinned `actions/checkout`, and export `SPECFACT_MODULES_REPO` before contract validation. - After rerunning Contract Validation, added `SPECFACT_MODULES_ROOTS=${GITHUB_WORKSPACE}/specfact-cli-modules/packages` because runtime module discovery uses module roots, while `SPECFACT_MODULES_REPO` alone is only repository/path context. +- Corrected the Example 4 integration-showcase docs snippet to use the `example4_precommit` bundle name and remove the undefined `PLAN_FILE`/duplicate health-check lines. - Added `test_specfact_contract_workflow_checks_out_matching_modules_branch_when_available`. - Refreshed core generated command artifacts after the paired modules branch introduced `specfact code review run --enforcement`. - Follow-up verification: From 664580e1c18ec1cf40da5c3b1ed80cf034873b22 Mon Sep 17 00:00:00 2001 From: Dominikus Nold Date: Tue, 2 Jun 2026 00:15:39 +0200 Subject: [PATCH 7/7] docs: use explicit drift baseline in integration guide --- .../integration-showcases-testing-guide.md | 67 +++++-------------- .../tester-cli-reliability/TDD_EVIDENCE.md | 1 + 2 files changed, 18 insertions(+), 50 deletions(-) diff --git a/docs/examples/integration-showcases/integration-showcases-testing-guide.md b/docs/examples/integration-showcases/integration-showcases-testing-guide.md index a0157cee..e95bb735 100644 --- a/docs/examples/integration-showcases/integration-showcases-testing-guide.md +++ b/docs/examples/integration-showcases/integration-showcases-testing-guide.md @@ -1220,27 +1220,23 @@ Create `.git/hooks/pre-commit`: ```bash #!/bin/sh -# First, import current code to create a new plan for comparison -# Use default name "auto-derived" so plan compare --code-vs-plan can find it -specfact --no-banner code import from-code --repo . --output-format yaml > /dev/null 2>&1 +BUNDLE_NAME="example4_precommit" -# Then compare against the explicitly named auto-derived bundle -specfact --no-banner code drift detect auto-derived --repo . +# Compare the staged repository against the explicit baseline bundle +specfact --no-banner code drift detect "$BUNDLE_NAME" --repo . ``` **What This Does**: -- Imports current code to create a new plan (auto-derived from modified code) - - **Important**: Uses default name "auto-derived" (or omit `--name`) so `plan compare --code-vs-plan` can find it - - `plan compare --code-vs-plan` looks for plans named `auto-derived.*.bundle.*` -- Compares the new plan against the explicitly named generated bundle +- Compares the current repository against the explicitly named baseline bundle +- Avoids implicit active-plan selection when multiple bundles exist - Uses enforcement configuration to determine if deviations should block the commit - Blocks commit if HIGH severity deviations are found (based on enforcement preset) -**Note**: The `--code-vs-plan` flag automatically uses: +**Baseline resolution**: -- **Manual plan**: The baseline bundle named by the workflow or `main.bundle.yaml` as fallback -- **Auto plan**: The latest `auto-derived` project bundle (from `code import from-code auto-derived` or default bundle name) +- This hook passes `"$BUNDLE_NAME"` explicitly so the baseline is `example4_precommit`. +- If you use a `plan compare --code-vs-plan` workflow elsewhere, pass the manual baseline explicitly; otherwise the legacy fallback is `main.bundle.yaml`, and the auto plan is the latest `auto-derived` bundle. Make it executable: @@ -1262,56 +1258,27 @@ git commit -m "Breaking change test" - ✅ Commit blocked - ✅ Error message about signature change -**Expected Output Format**: +**Expected Output Shape**: ```bash ============================================================ -Code vs Plan Drift Detection -============================================================ - -Comparing intended design (manual plan) vs actual implementation (code-derived plan) - -ℹ️ Using default manual plan: .specfact/projects/django-example/ -ℹ️ Using latest code-derived plan: .specfact/projects/auto-derived/ - +Drift Detection: example4_precommit ============================================================ -Comparison Results -============================================================ - -Total Deviations: 3 - -Deviation Summary: - 🔴 HIGH: 1 - 🟡 MEDIUM: 0 - 🔵 LOW: 2 - Deviations by Type and Severity -┏━━━━━━━━━━┳━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━┓ -┃ Severity ┃ Type ┃ Description ┃ Location ┃ -┡━━━━━━━━━━╇━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━┩ -│ 🔴 HIGH │ Missing Feature │ Feature 'FEATURE-*' │ features[FEATURE-*] │ -│ │ │ in manual plan but not │ │ -│ │ │ implemented in code │ │ -└──────────┴─────────────────┴────────────────────────┴────────────────────────┘ +Repository: /tmp/specfact-integration-tests/example4_precommit -============================================================ -Enforcement Rules -============================================================ - -🚫 [HIGH] missing_feature: BLOCK -❌ Enforcement BLOCKED: 1 deviation(s) violate quality gates -Fix the blocking deviations or adjust enforcement config -❌ Comparison failed: 1 +... HIGH severity drift ... +... Enforcement BLOCKED ... ``` **What This Shows**: -- ✅ Plan comparison successfully finds both plans (active plan as manual, latest auto-derived as auto) -- ✅ Detects deviations (missing features, mismatches) +- ✅ Drift detection uses the explicit `example4_precommit` baseline bundle +- ✅ Detects deviations between the baseline bundle and current code - ✅ Enforcement blocks the commit (HIGH → BLOCK based on balanced preset) - ✅ Pre-commit hook exits with code 1, blocking the commit -**Note**: The comparison may show deviations like "Missing Feature" when comparing an enriched plan (with AI-added features) against an AST-only plan (which may have 0 features). This is expected behavior - the enriched plan represents the intended design, while the AST-only plan represents what's actually in the code. For breaking change detection, you would compare two code-derived plans (before and after code changes). +**Note**: The comparison may show deviations like missing or changed features when the current code no longer matches the baseline bundle. This is expected: the baseline bundle represents the intended design, while the repository represents the proposed change. ### Example 4 - Step 6: Verify Results @@ -1321,7 +1288,7 @@ Fix the blocking deviations or adjust enforcement config 2. ✅ Committed the original plan (baseline) 3. ✅ Modified code to introduce breaking change (added required `user_id` parameter) 4. ✅ Configured enforcement (balanced preset with HIGH → BLOCK) -5. ✅ Set up pre-commit hook (`plan compare --code-vs-plan`) +5. ✅ Set up pre-commit hook (`code drift detect "$BUNDLE_NAME" --repo .`) 6. ✅ Tested pre-commit hook (commit blocked due to HIGH severity deviation) **Plan Bundle Status**: diff --git a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md index 8c80cd87..bbaa09cb 100644 --- a/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md +++ b/openspec/changes/tester-cli-reliability/TDD_EVIDENCE.md @@ -162,6 +162,7 @@ - Updated `.github/workflows/specfact.yml` to resolve the matching modules branch, fall back to `dev`, check out `nold-ai/specfact-cli-modules` with pinned `actions/checkout`, and export `SPECFACT_MODULES_REPO` before contract validation. - After rerunning Contract Validation, added `SPECFACT_MODULES_ROOTS=${GITHUB_WORKSPACE}/specfact-cli-modules/packages` because runtime module discovery uses module roots, while `SPECFACT_MODULES_REPO` alone is only repository/path context. - Corrected the Example 4 integration-showcase docs snippet to use the `example4_precommit` bundle name and remove the undefined `PLAN_FILE`/duplicate health-check lines. +- Updated the Example 4 pre-commit hook walkthrough to pass the explicit `example4_precommit` baseline bundle to `code drift detect` instead of relying on `auto-derived`/legacy `--code-vs-plan` selection. - Added `test_specfact_contract_workflow_checks_out_matching_modules_branch_when_available`. - Refreshed core generated command artifacts after the paired modules branch introduced `specfact code review run --enforcement`. - Follow-up verification: