diff --git a/CHANGELOG.md b/CHANGELOG.md index c3ec321a..4fed2f9f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,20 @@ All notable changes to this project will be documented in this file. **Important:** Changes need to be documented below this block as this is the header section. Each section should be separated by a horizontal rule. Newer changelog entries need to be added on top of prior ones to keep the history chronological with most recent changes first. +--- + +## [0.39.0] - 2026-02-28 + +### Added + +- **Category group commands** (OpenSpec change `module-migration-01-categorize-and-group`): Category grouping mounts commands under `code`, `backlog`, `project`, `spec`, and `govern`. Use `specfact code analyze`, `specfact backlog --help`, etc. Flat shims (e.g. `specfact validate`) remain with deprecation notice in Copilot mode. Configurable via `category_grouping_enabled` (default true). +- **First-run module selection in `specfact init`**: `--profile solo-developer` and `--profile enterprise-full-stack`, plus `--install ` and interactive bundle selection on first run when no category bundle is installed. +- **Integration and E2E tests**: `tests/integration/test_category_group_routing.py` and `tests/e2e/test_first_run_init.py` for category routing and init profile flows. + +### Fixed + +- `test_module_grouping.py` now imports `group_modules_by_category` from `module_grouping` instead of `module_packages`, fixing collection errors in the full test suite. + --- ## [0.38.2] - 2026-02-27 diff --git a/modules/backlog-core/module-package.yaml b/modules/backlog-core/module-package.yaml index 4caea7a8..cfe42d0d 100644 --- a/modules/backlog-core/module-package.yaml +++ b/modules/backlog-core/module-package.yaml @@ -1,7 +1,11 @@ name: backlog-core -version: 0.1.5 +version: 0.1.6 commands: - backlog +category: backlog +bundle: specfact-backlog +bundle_group_command: backlog +bundle_sub_command: core command_help: backlog: Backlog dependency analysis, delta workflows, and release readiness pip_dependencies: [] @@ -20,10 +24,10 @@ schema_extensions: publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com integrity: - checksum: sha256:c6ae56b1e5f3cf4d4bc0d9d256f24e6377f08e4e82a1f8bead935c0e7cee7431 - signature: FpTzbqYcR+6jiRUXjqvzfmqoLGeam7lLyLLc/ZfT7AokzRPz4cl5F/KO0b3XZmXQfHWfT+GFTJi5T/POkobJCg== + checksum: sha256:786a67c54f70930208265217499634ccd5e04cb8404d00762bce2e01904c55e4 + signature: Q8CweUicTL/btp9p5QYTlBuXF3yoKvz9ZwaGK0yw3QSM72nni28ZBJ+FivGkmBfcH5zXWAGtASbqC4ry8m5DDQ== dependencies: [] description: Provide advanced backlog analysis and readiness capabilities. license: Apache-2.0 diff --git a/modules/bundle-mapper/module-package.yaml b/modules/bundle-mapper/module-package.yaml index 5e7b8380..2dd2e3b2 100644 --- a/modules/bundle-mapper/module-package.yaml +++ b/modules/bundle-mapper/module-package.yaml @@ -1,6 +1,7 @@ name: bundle-mapper -version: 0.1.2 +version: 0.1.3 commands: [] +category: core pip_dependencies: [] module_dependencies: [] core_compatibility: '>=0.28.0,<1.0.0' @@ -17,10 +18,10 @@ schema_extensions: publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com integrity: - checksum: sha256:1012f453bc4ae83b22e2cfabce13e5e324d9b4cdf454ce0159b5c5e17dd36f77 - signature: LlPqbIH6uD70AInX28PpVurOEv+W/Ztarj5yQhZ3MkC3yORcQrh6ISvJsQeFHFiV1cmnYck7RfDipl4FJyzDAA== + checksum: sha256:359763f8589be35f00b53a996d76ccec32789508d0a2d7dae7e3cdb039a92fc3 + signature: OmAp12Rdk79IewQYiKRqvvAm8UgM6onL52Y2/ixSgX3X7onoc9FBKzBYuPmynEVgmJWAI2AX2gdujo/bKH5nAg== dependencies: [] description: Map backlog items to best-fit modules using scoring heuristics. license: Apache-2.0 diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index bafbac42..23979190 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -85,6 +85,7 @@ These are derived extensions of the same 2026-02-15 plan and are required to ope | module-migration | 01 | module-migration-01-categorize-and-group | TBD | #215 (marketplace-02) | | module-migration | 02 | module-migration-02-bundle-extraction | TBD | module-migration-01 | | module-migration | 03 | module-migration-03-core-slimming | TBD | module-migration-02 | +| module-migration | 04 | module-migration-04-remove-flat-shims | TBD | module-migration-01 | ### Cross-cutting foundations (no hard dependencies — implement early) @@ -324,6 +325,7 @@ Dependencies flow left-to-right; a wave may start once all its hard blockers are - backlog-scrum-01 ✅ (needs backlog-core-01; benefits from policy-engine-01 + patch-mode-01) - backlog-safe-02 (needs backlog-safe-01; integrates with scrum/kanban via bridge registry) - module-migration-01-categorize-and-group (needs marketplace-02; adds category metadata + group commands) + - module-migration-04-remove-flat-shims (0.40.x; needs module-migration-01; removes flat shims, category-only CLI) - module-migration-02-bundle-extraction (needs module-migration-01; moves module source to bundle packages, publishes to marketplace registry) - marketplace-03-publisher-identity (needs marketplace-02; can run parallel with module-migration-01/02/03) - marketplace-04-revocation (needs marketplace-03; must land before external publisher onboarding) diff --git a/openspec/changes/module-migration-01-categorize-and-group/CHANGE_VALIDATION.md b/openspec/changes/module-migration-01-categorize-and-group/CHANGE_VALIDATION.md new file mode 100644 index 00000000..58cdd540 --- /dev/null +++ b/openspec/changes/module-migration-01-categorize-and-group/CHANGE_VALIDATION.md @@ -0,0 +1,39 @@ +# Change Validation: module-migration-01-categorize-and-group + +- **Validated on (UTC):** 2026-02-28T01:02:00Z +- **Workflow:** /wf-validate-change (implementation update re-validation) +- **Strict command:** `openspec validate module-migration-01-categorize-and-group --strict` +- **Status command:** `openspec status --change "module-migration-01-categorize-and-group" --json` +- **Result:** PASS + +## Scope Summary + +- **Capabilities touched by this update:** `category-command-groups`, `first-run-selection` +- **Regression fixes validated:** + - grouped registration preserves duplicate-command extension merging (no loader overwrite) + - first-run detection treats workspace-local `project` source modules as installed +- **Code paths reviewed:** + - `src/specfact_cli/registry/module_packages.py` + - `src/specfact_cli/modules/init/src/first_run_selection.py` + - `tests/unit/specfact_cli/registry/test_module_packages.py` + - `tests/unit/modules/init/test_first_run_selection.py` + +## Breaking-Change Analysis + +- No public CLI command names or argument signatures were changed. +- Behavior is a compatibility restoration: + - grouped mode now matches prior extension semantics for duplicate command groups + - `specfact init` first-run suppression now correctly includes project-scoped installed bundles +- No downstream migration is required. + +## Dependency and Interface Impact + +- Registry impact is internal to loader composition for duplicate command names. +- Init impact is internal to module discovery source filtering. +- No additional OpenSpec change scope expansion was required. + +## Validation Outcome + +- OpenSpec strict validation passed for this change. +- `openspec status` reports required artifacts present and complete (`proposal`, `design`, `specs`, `tasks`). +- Note: local environment emitted non-blocking OpenSpec telemetry network errors while flushing analytics; validation result remained PASS. diff --git a/openspec/changes/module-migration-01-categorize-and-group/TDD_EVIDENCE.md b/openspec/changes/module-migration-01-categorize-and-group/TDD_EVIDENCE.md new file mode 100644 index 00000000..b487661d --- /dev/null +++ b/openspec/changes/module-migration-01-categorize-and-group/TDD_EVIDENCE.md @@ -0,0 +1,110 @@ +# TDD Evidence: module-migration-01-categorize-and-group + +## Phase 3 — First-run module selection in `specfact init` + +### 5.1 Failing tests (pre-implementation) + +Tests were written first in `tests/unit/modules/init/test_first_run_selection.py`. Initial run (before implementation) would fail on: + +- Profile resolution and install parsing (no `resolve_profile_bundles`, `resolve_install_bundles`, `is_first_run`, or `install_bundles_for_init`). +- CLI tests would fail due to missing `--profile`/`--install` and missing first_run_selection integration. + +(Exact failing run not captured; implementation followed immediately after test creation.) + +### 5.3 Passing tests (post-implementation) + +**Timestamp:** 2026-02-28 +**Command:** `hatch test -- tests/unit/modules/init/test_first_run_selection.py -v` +**Result:** 16 passed + +**Summary:** + +- `test_profile_solo_developer_resolves_to_specfact_codebase_only` — profile preset resolution. +- `test_profile_enterprise_full_stack_resolves_to_all_five_bundles` — enterprise preset. +- `test_profile_nonexistent_raises_with_valid_list` — invalid profile raises with valid list. +- `test_install_backlog_codebase_resolves_to_two_bundles` — `--install` parsing. +- `test_install_all_resolves_to_all_five_bundles` — `--install all`. +- `test_install_unknown_bundle_raises` — unknown bundle raises. +- `test_is_first_run_true_when_no_category_bundle_installed` — first-run detection (no category bundle). +- `test_is_first_run_false_when_category_bundle_installed` — first-run false when bundle present. +- `test_init_profile_solo_developer_calls_installer_with_specfact_codebase` — CLI `--profile solo-developer`. +- `test_init_profile_enterprise_full_stack_calls_installer_with_all_five` — CLI `--profile enterprise-full-stack`. +- `test_init_profile_nonexistent_exits_nonzero_and_lists_valid_profiles` — CLI invalid profile exits non-zero. +- `test_init_install_backlog_codebase_calls_installer_with_two_bundles` — CLI `--install backlog,codebase`. +- `test_init_install_all_calls_installer_with_five_bundles` — CLI `--install all`. +- `test_init_install_widgets_exits_nonzero` — CLI unknown bundle exits non-zero. +- `test_init_second_run_skips_first_run_flow` — second run does not call installer when no `--profile`/`--install`. +- `test_spec_bundle_install_includes_project_dep` — `install_bundles_for_init(["specfact-spec"])` installs project dep. + +Implementation: `src/specfact_cli/modules/init/src/first_run_selection.py` and `commands.py` (--profile, --install, first_run_selection integration). + +### Phase 3 follow-up (5.2.3, 5.2.7) + +**Interactive first-run UI (5.2.3):** +- `_interactive_first_run_bundle_selection()` in commands.py: welcome banner (Panel), questionary.select for profile or "Choose bundles manually", questionary.checkbox for manual bundle selection. When first run and interactive and no --profile/--install, init() calls it and installs selected bundles or shows tip if none. +- `BUNDLE_DISPLAY` and `PROFILE_DISPLAY_ORDER` in first_run_selection.py for UI labels. + +**Graceful degradation (5.2.7):** +- In `install_bundles_for_init`, each `install_bundled_module` call wrapped in try/except; on exception log warning "Dependency resolver may be unavailable" and re-raise so errors are surfaced. + +**Additional tests:** +- `test_init_first_run_interactive_with_selection_calls_installer`: first run + interactive, mock selection returns ["specfact-codebase"], assert install called. +- `test_init_first_run_interactive_no_selection_shows_tip`: first run + interactive, mock selection returns [], assert no install and "Tip" / "module install" in output. + +**Run:** `hatch test -- tests/unit/modules/init/test_first_run_selection.py -v` — 18 passed. + +## Section 6 — Integration and E2E + +**Timestamp:** 2026-02-28 +**Commands:** `hatch test -- tests/integration/test_category_group_routing.py tests/e2e/test_first_run_init.py -v` +**Result:** 5 passed (3 integration + 2 e2e). + +**Integration:** `test_code_analyze_help_exits_zero`, `test_backlog_help_lists_subcommands`, `test_validate_shim_help_exits_zero`. +**E2E:** `test_init_profile_solo_developer_completes_in_temp_workspace`, `test_after_solo_developer_init_code_analyze_help_available` (install_bundles_for_init mocked). + +## Phase 4 — Regression fixes from review (grouped extension merge + project-scoped first-run) + +### 4.1 Failing tests (pre-implementation) + +**Timestamp:** 2026-02-28 01:00 UTC +**Command:** `hatch test -- tests/unit/specfact_cli/registry/test_module_packages.py::test_grouped_registration_merges_duplicate_command_extensions tests/unit/modules/init/test_first_run_selection.py::test_is_first_run_false_when_project_scoped_category_bundle_installed -v` +**Result:** 2 failed. + +**Failure summary:** + +- `test_grouped_registration_merges_duplicate_command_extensions` failed because grouped registration replaced the earlier `backlog` loader; observed commands were only `('ext_cmd',)` and `base_cmd` was missing. +- `test_is_first_run_false_when_project_scoped_category_bundle_installed` failed because `is_first_run()` ignored modules discovered with source `project`, returning `True` for an already-initialized workspace. + +### 4.2 Passing tests (post-implementation) + +**Timestamp:** 2026-02-28 01:01 UTC +**Command:** `hatch test -- tests/unit/specfact_cli/registry/test_module_packages.py::test_grouped_registration_merges_duplicate_command_extensions tests/unit/modules/init/test_first_run_selection.py::test_is_first_run_false_when_project_scoped_category_bundle_installed -v` +**Result:** 2 passed. + +**Implementation summary:** + +- Updated `register_module_package_commands()` grouped path to merge duplicate command loaders via `_make_extending_loader` for module entries (and core root entries), instead of unconditional overwrite. +- Updated `is_first_run()` source filter to include `project` modules in first-run detection. + +## Phase 5 — Regression fix from PR 331 (trust failure should not block unaffected legacy module registration) + +### 5.1 Failing test (pre-implementation) + +**Timestamp:** 2026-02-28 21:07 local +**Command:** `hatch test -- tests/unit/specfact_cli/registry/test_module_packages.py::test_unaffected_modules_register_when_one_fails_trust -v` +**Result:** 1 failed. + +**Failure summary:** + +- In grouped mode, a module without `category` metadata was routed into grouped registration, so `good_cmd` was not mounted as flat top-level despite warning text indicating flat mounting. + +### 5.2 Passing tests (post-implementation) + +**Timestamp:** 2026-02-28 21:09 local +**Command:** `hatch test -- tests/unit/specfact_cli/registry/test_module_packages.py::test_unaffected_modules_register_when_one_fails_trust tests/unit/specfact_cli/registry/test_module_packages.py::test_grouped_registration_merges_duplicate_command_extensions tests/unit/registry/test_module_grouping.py::test_module_package_yaml_without_category_mounts_ungrouped_warning_logged -v` +**Result:** 3 passed. + +**Implementation summary:** + +- Updated `register_module_package_commands()` to use grouped registration only when `category_grouping_enabled` is true and module metadata declares `category`. +- Updated grouped-extension unit fixture metadata to include `category="backlog"` so the test reflects migration-era grouped manifests and remains aligned with category-driven grouping semantics. diff --git a/openspec/changes/module-migration-01-categorize-and-group/proposal.md b/openspec/changes/module-migration-01-categorize-and-group/proposal.md index 9b9f1a9b..5555564d 100644 --- a/openspec/changes/module-migration-01-categorize-and-group/proposal.md +++ b/openspec/changes/module-migration-01-categorize-and-group/proposal.md @@ -63,5 +63,5 @@ This mirrors the VS Code model: ship a lean core, present workflow-domain groups - **GitHub Issue**: #315 - **Issue URL**: - **Repository**: nold-ai/specfact-cli -- **Last Synced Status**: proposed +- **Last Synced Status**: open - **Sanitized**: false diff --git a/openspec/changes/module-migration-01-categorize-and-group/specs/category-command-groups/spec.md b/openspec/changes/module-migration-01-categorize-and-group/specs/category-command-groups/spec.md index 2f57e91f..f0e72b8b 100644 --- a/openspec/changes/module-migration-01-categorize-and-group/specs/category-command-groups/spec.md +++ b/openspec/changes/module-migration-01-categorize-and-group/specs/category-command-groups/spec.md @@ -26,6 +26,16 @@ Each category group SHALL expose its member modules as sub-commands, preserving - **THEN** the command SHALL execute identically to the original `specfact analyze contracts` - **AND** the exit code, output format, and side effects SHALL be identical +#### Scenario: Grouped registration preserves command extensions for duplicate command names + +- **GIVEN** `category_grouping_enabled` is `true` +- **AND** a base module provides command group `backlog` +- **AND** an extension module also declares command group `backlog` +- **WHEN** module package commands are registered +- **THEN** the registry SHALL merge extension subcommands into the existing `backlog` command tree +- **AND** SHALL NOT replace the existing loader with only the extension loader +- **AND** both base and extension subcommands SHALL remain accessible under `specfact backlog ...` + #### Scenario: Category group command is absent when bundle not installed - **GIVEN** the `govern` bundle is NOT installed diff --git a/openspec/changes/module-migration-01-categorize-and-group/specs/first-run-selection/spec.md b/openspec/changes/module-migration-01-categorize-and-group/specs/first-run-selection/spec.md index 0958013c..956ab8a8 100644 --- a/openspec/changes/module-migration-01-categorize-and-group/specs/first-run-selection/spec.md +++ b/openspec/changes/module-migration-01-categorize-and-group/specs/first-run-selection/spec.md @@ -49,6 +49,15 @@ On a fresh install where no bundles are installed, `specfact init` SHALL present - **THEN** the CLI SHALL NOT show the bundle selection UI - **AND** SHALL run the standard workspace re-initialisation flow +#### Scenario: Workspace-local project-scoped modules suppress first-run flow + +- **GIVEN** a repository already contains category bundle modules under workspace-local `.specfact/modules` +- **AND** those modules are discovered with source `project` +- **WHEN** the user runs `specfact init` +- **THEN** first-run detection SHALL treat the workspace as already initialized +- **AND** the CLI SHALL NOT show first-run bundle selection again +- **AND** SHALL run the standard workspace re-initialisation flow + ### Requirement: `specfact init --profile ` installs a named preset non-interactively The system SHALL accept a `--profile ` argument on `specfact init` and MUST install the canonical bundle set for that profile without prompting, whether in CI/CD mode or interactive mode. diff --git a/openspec/changes/module-migration-01-categorize-and-group/tasks.md b/openspec/changes/module-migration-01-categorize-and-group/tasks.md index 55d73b5d..f9acf8d9 100644 --- a/openspec/changes/module-migration-01-categorize-and-group/tasks.md +++ b/openspec/changes/module-migration-01-categorize-and-group/tasks.md @@ -20,7 +20,7 @@ Do NOT implement production code for any behavior-changing step until failing-te ## 1. Create git worktree branch from dev -- [ ] 1.1 Fetch latest origin and create worktree with feature branch +- [x] 1.1 Fetch latest origin and create worktree with feature branch - [ ] 1.1.1 `git fetch origin` - [ ] 1.1.2 `git worktree add ../specfact-cli-worktrees/feature/module-migration-01-categorize-and-group -b feature/module-migration-01-categorize-and-group origin/dev` - [ ] 1.1.3 `cd ../specfact-cli-worktrees/feature/module-migration-01-categorize-and-group` @@ -31,49 +31,32 @@ Do NOT implement production code for any behavior-changing step until failing-te ## 2. Create GitHub issue for change tracking -- [ ] 2.1 Create GitHub issue in nold-ai/specfact-cli - - [ ] 2.1.1 `gh issue create --repo nold-ai/specfact-cli --title "[Change] Module Grouping and Category Command Groups" --label "enhancement,change-proposal" --body "$(cat <<'EOF'` - - ```text - ## Why - - SpecFact CLI exposes 21 flat top-level commands, overwhelming new users. The marketplace foundation (marketplace-01, marketplace-02) now supports signed packages and bundle-level dependency resolution. This change introduces category grouping metadata, 5 umbrella group commands, and VS Code-style first-run bundle selection. - - ## What Changes - - - Add `category`, `bundle`, `bundle_group_command`, `bundle_sub_command` to all 21 `module-package.yaml` files - - Create `src/specfact_cli/groups/` with 5 category Typer apps - - Update `bootstrap.py` to mount category groups with compat shims - - Add `category_grouping_enabled` config flag (default `true`) - - Update `specfact init` with `--profile` and `--install` for first-run bundle selection - - *OpenSpec Change Proposal: module-migration-01-categorize-and-group* - ``` - - - [ ] 2.1.2 Capture issue number and URL from output - - [ ] 2.1.3 Update `openspec/changes/module-migration-01-categorize-and-group/proposal.md` Source Tracking section with issue number, URL, and status `open` +- [x] 2.1 Create GitHub issue in nold-ai/specfact-cli + - [x] 2.1.1 `gh issue create --repo nold-ai/specfact-cli ...` + - [x] 2.1.2 Capture issue number and URL from output + - [x] 2.1.3 Update `openspec/changes/module-migration-01-categorize-and-group/proposal.md` Source Tracking section with issue number, URL, and status `open` ## 3. Phase 1 — Add category metadata to all module-package.yaml files (TDD) ### 3.1 Write tests for manifest validation (expect failure) -- [ ] 3.1.1 Create `tests/unit/registry/test_module_grouping.py` -- [ ] 3.1.2 Test: `module-package.yaml` with `category: codebase` passes validation -- [ ] 3.1.3 Test: `module-package.yaml` with `category: unknown` raises `ModuleManifestError` -- [ ] 3.1.4 Test: `module-package.yaml` without `category` field mounts as ungrouped flat command (no error, warning logged) -- [ ] 3.1.5 Test: `bundle_group_command` mismatch vs canonical category raises `ModuleManifestError` -- [ ] 3.1.6 Test: core-category modules have no `bundle` or `bundle_group_command` -- [ ] 3.1.7 Test: `registry.group_modules_by_category()` returns correct grouping dict from a list of module manifests -- [ ] 3.1.8 Run tests: `hatch test -- tests/unit/registry/test_module_grouping.py -v` (expect failures — record in TDD_EVIDENCE.md) +- [x] 3.1.1 Create `tests/unit/registry/test_module_grouping.py` +- [x] 3.1.2 Test: `module-package.yaml` with `category: codebase` passes validation +- [x] 3.1.3 Test: `module-package.yaml` with `category: unknown` raises `ModuleManifestError` +- [x] 3.1.4 Test: `module-package.yaml` without `category` field mounts as ungrouped flat command (no error, warning logged) +- [x] 3.1.5 Test: `bundle_group_command` mismatch vs canonical category raises `ModuleManifestError` +- [x] 3.1.6 Test: core-category modules have no `bundle` or `bundle_group_command` +- [x] 3.1.7 Test: `registry.group_modules_by_category()` returns correct grouping dict from a list of module manifests +- [x] 3.1.8 Run tests: `hatch test -- tests/unit/registry/test_module_grouping.py -v` (expect failures — record in TDD_EVIDENCE.md) ### 3.2 Implement category field validation in registry -- [ ] 3.2.1 Add `category`, `bundle`, `bundle_group_command`, `bundle_sub_command` fields (Optional[str]) to `ModulePackage` Pydantic model in `src/specfact_cli/registry/module_packages.py` -- [ ] 3.2.2 Add validation: if `category` is set and not in `{"core","project","backlog","codebase","spec","govern"}` → raise `ModuleManifestError` -- [ ] 3.2.3 Add validation: if `category` != `"core"` and `bundle_group_command` does not match canonical mapping → raise `ModuleManifestError` -- [ ] 3.2.4 Add `group_modules_by_category()` function with `@require` and `@beartype` decorators -- [ ] 3.2.5 Add warning log when `category` field is absent -- [ ] 3.2.6 `hatch test -- tests/unit/registry/test_module_grouping.py -v` — verify tests pass +- [x] 3.2.1 Add `category`, `bundle`, `bundle_group_command`, `bundle_sub_command` fields (Optional[str]) to `ModulePackage` Pydantic model in `src/specfact_cli/registry/module_packages.py` +- [x] 3.2.2 Add validation: if `category` is set and not in `{"core","project","backlog","codebase","spec","govern"}` → raise `ModuleManifestError` +- [x] 3.2.3 Add validation: if `category` != `"core"` and `bundle_group_command` does not match canonical mapping → raise `ModuleManifestError` +- [x] 3.2.4 Add `group_modules_by_category()` function with `@require` and `@beartype` decorators +- [x] 3.2.5 Add warning log when `category` field is absent +- [x] 3.2.6 `hatch test -- tests/unit/registry/test_module_grouping.py -v` — verify tests pass ### 3.3 Add category metadata to all 21 module-package.yaml files @@ -81,42 +64,42 @@ Apply the canonical category assignments: **Core (no bundle fields):** -- [ ] 3.3.1 `modules/init/module-package.yaml` → `category: core`, `bundle_sub_command: init` -- [ ] 3.3.2 `modules/auth/module-package.yaml` → `category: core`, `bundle_sub_command: auth` -- [ ] 3.3.3 `modules/module_registry/module-package.yaml` → `category: core`, `bundle_sub_command: module` -- [ ] 3.3.4 `modules/upgrade/module-package.yaml` → `category: core`, `bundle_sub_command: upgrade` +- [x] 3.3.1 `modules/init/module-package.yaml` → `category: core`, `bundle_sub_command: init` +- [x] 3.3.2 `modules/auth/module-package.yaml` → `category: core`, `bundle_sub_command: auth` +- [x] 3.3.3 `modules/module_registry/module-package.yaml` → `category: core`, `bundle_sub_command: module` +- [x] 3.3.4 `modules/upgrade/module-package.yaml` → `category: core`, `bundle_sub_command: upgrade` **Project bundle (`specfact-project`, group command `project`):** -- [ ] 3.3.5 `modules/project/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: project` -- [ ] 3.3.6 `modules/plan/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: plan` -- [ ] 3.3.7 `modules/import_cmd/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: import` -- [ ] 3.3.8 `modules/sync/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: sync` -- [ ] 3.3.9 `modules/migrate/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: migrate` +- [x] 3.3.5 `modules/project/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: project` +- [x] 3.3.6 `modules/plan/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: plan` +- [x] 3.3.7 `modules/import_cmd/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: import` +- [x] 3.3.8 `modules/sync/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: sync` +- [x] 3.3.9 `modules/migrate/module-package.yaml` → `category: project`, `bundle: specfact-project`, `bundle_group_command: project`, `bundle_sub_command: migrate` **Backlog bundle (`specfact-backlog`, group command `backlog`):** -- [ ] 3.3.10 `modules/backlog/module-package.yaml` → `category: backlog`, `bundle: specfact-backlog`, `bundle_group_command: backlog`, `bundle_sub_command: backlog` -- [ ] 3.3.11 `modules/policy_engine/module-package.yaml` → `category: backlog`, `bundle: specfact-backlog`, `bundle_group_command: backlog`, `bundle_sub_command: policy` +- [x] 3.3.10 `modules/backlog/module-package.yaml` → `category: backlog`, `bundle: specfact-backlog`, `bundle_group_command: backlog`, `bundle_sub_command: backlog` +- [x] 3.3.11 `modules/policy_engine/module-package.yaml` → `category: backlog`, `bundle: specfact-backlog`, `bundle_group_command: backlog`, `bundle_sub_command: policy` **Codebase bundle (`specfact-codebase`, group command `code`):** -- [ ] 3.3.12 `modules/analyze/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: analyze` -- [ ] 3.3.13 `modules/drift/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: drift` -- [ ] 3.3.14 `modules/validate/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: validate` -- [ ] 3.3.15 `modules/repro/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: repro` +- [x] 3.3.12 `modules/analyze/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: analyze` +- [x] 3.3.13 `modules/drift/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: drift` +- [x] 3.3.14 `modules/validate/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: validate` +- [x] 3.3.15 `modules/repro/module-package.yaml` → `category: codebase`, `bundle: specfact-codebase`, `bundle_group_command: code`, `bundle_sub_command: repro` **Spec bundle (`specfact-spec`, group command `spec`):** -- [ ] 3.3.16 `modules/contract/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: contract` -- [ ] 3.3.17 `modules/spec/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: api` (collision avoidance) -- [ ] 3.3.18 `modules/sdd/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: sdd` -- [ ] 3.3.19 `modules/generate/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: generate` +- [x] 3.3.16 `modules/contract/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: contract` +- [x] 3.3.17 `modules/spec/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: api` (collision avoidance) +- [x] 3.3.18 `modules/sdd/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: sdd` +- [x] 3.3.19 `modules/generate/module-package.yaml` → `category: spec`, `bundle: specfact-spec`, `bundle_group_command: spec`, `bundle_sub_command: generate` **Govern bundle (`specfact-govern`, group command `govern`):** -- [ ] 3.3.20 `modules/enforce/module-package.yaml` → `category: govern`, `bundle: specfact-govern`, `bundle_group_command: govern`, `bundle_sub_command: enforce` -- [ ] 3.3.21 `modules/patch_mode/module-package.yaml` → `category: govern`, `bundle: specfact-govern`, `bundle_group_command: govern`, `bundle_sub_command: patch` +- [x] 3.3.20 `modules/enforce/module-package.yaml` → `category: govern`, `bundle: specfact-govern`, `bundle_group_command: govern`, `bundle_sub_command: enforce` +- [x] 3.3.21 `modules/patch_mode/module-package.yaml` → `category: govern`, `bundle: specfact-govern`, `bundle_group_command: govern`, `bundle_sub_command: patch` ### 3.4 Module signing gate (after all module-package.yaml edits) @@ -129,94 +112,94 @@ Apply the canonical category assignments: ### 4.1 Write tests for category group bootstrap (expect failure) -- [ ] 4.1.1 Create `tests/unit/registry/test_category_groups.py` -- [ ] 4.1.2 Test: with `category_grouping_enabled=True`, `bootstrap_cli()` registers `code`, `backlog`, `project`, `spec`, `govern` group commands -- [ ] 4.1.3 Test: with `category_grouping_enabled=False`, bootstrap registers flat module commands (no group commands) -- [ ] 4.1.4 Test: `specfact code analyze contracts` routes to the same handler as `specfact analyze contracts` -- [ ] 4.1.5 Test: `specfact govern --help` when govern bundle not installed produces install suggestion -- [ ] 4.1.6 Test: flat shim `specfact validate` emits deprecation warning in Copilot mode -- [ ] 4.1.7 Test: flat shim `specfact validate` is silent in CI/CD mode -- [ ] 4.1.8 Test: `specfact spec api validate` routes correctly (collision avoidance) -- [ ] 4.1.9 Create `tests/unit/groups/test_codebase_group.py` — test group app has expected sub-commands -- [ ] 4.1.10 Run tests: `hatch test -- tests/unit/registry/test_category_groups.py tests/unit/groups/ -v` (expect failures — record in TDD_EVIDENCE.md) +- [x] 4.1.1 Create `tests/unit/registry/test_category_groups.py` +- [x] 4.1.2 Test: with `category_grouping_enabled=True`, `bootstrap_cli()` registers `code`, `backlog`, `project`, `spec`, `govern` group commands +- [x] 4.1.3 Test: with `category_grouping_enabled=False`, bootstrap registers flat module commands (no group commands) +- [x] 4.1.4 Test: `specfact code analyze contracts` routes to the same handler as `specfact analyze contracts` +- [x] 4.1.5 Test: `specfact govern --help` when govern bundle not installed produces install suggestion +- [x] 4.1.6 Test: flat shim `specfact validate` emits deprecation warning in Copilot mode +- [x] 4.1.7 Test: flat shim `specfact validate` is silent in CI/CD mode +- [x] 4.1.8 Test: `specfact spec api validate` routes correctly (collision avoidance) +- [x] 4.1.9 Create `tests/unit/groups/test_codebase_group.py` — test group app has expected sub-commands +- [x] 4.1.10 Run tests: `hatch test -- tests/unit/registry/test_category_groups.py tests/unit/groups/ -v` (expect failures — record in TDD_EVIDENCE.md) ### 4.2 Create `src/specfact_cli/groups/` package -- [ ] 4.2.1 Create `src/specfact_cli/groups/__init__.py` -- [ ] 4.2.2 Create `src/specfact_cli/groups/project_group.py` +- [x] 4.2.1 Create `src/specfact_cli/groups/__init__.py` +- [x] 4.2.2 Create `src/specfact_cli/groups/project_group.py` - `app = typer.Typer(name="project", help="Project lifecycle commands.", no_args_is_help=True)` - Members: project, plan, import_cmd (as `import`), sync, migrate - `@require` and `@beartype` on `_register_members()` -- [ ] 4.2.3 Create `src/specfact_cli/groups/backlog_group.py` +- [x] 4.2.3 Create `src/specfact_cli/groups/backlog_group.py` - Members: backlog, policy_engine (as `policy`) -- [ ] 4.2.4 Create `src/specfact_cli/groups/codebase_group.py` +- [x] 4.2.4 Create `src/specfact_cli/groups/codebase_group.py` - Members: analyze, drift, validate, repro -- [ ] 4.2.5 Create `src/specfact_cli/groups/spec_group.py` +- [x] 4.2.5 Create `src/specfact_cli/groups/spec_group.py` - Members: contract, spec (as `api`), sdd, generate -- [ ] 4.2.6 Create `src/specfact_cli/groups/govern_group.py` +- [x] 4.2.6 Create `src/specfact_cli/groups/govern_group.py` - Members: enforce, patch_mode (as `patch`) -- [ ] 4.2.7 All group files must use `@icontract` and `@beartype` on all public functions +- [x] 4.2.7 All group files must use `@icontract` and `@beartype` on all public functions ### 4.3 Update `bootstrap.py` to mount category groups -- [ ] 4.3.1 Read `category_grouping_enabled` from config (default `True`) -- [ ] 4.3.2 If `True`: import and mount each group app via `app.add_typer()`; skip flat mounting for grouped modules -- [ ] 4.3.3 Always mount core modules (init, auth, module, upgrade) as flat top-level commands -- [ ] 4.3.4 Implement `_register_compat_shims(app)` for all 17 non-core modules: +- [x] 4.3.1 Read `category_grouping_enabled` from config (default `True`) +- [x] 4.3.2 If `True`: import and mount each group app via `app.add_typer()`; skip flat mounting for grouped modules +- [x] 4.3.3 Always mount core modules (init, auth, module, upgrade) as flat top-level commands +- [x] 4.3.4 Implement `_register_compat_shims(app)` for all 17 non-core modules: - Shim emits deprecation warning in Copilot mode, silent in CI/CD mode - Delegates to category group equivalent -- [ ] 4.3.5 Add `@require`, `@ensure`, and `@beartype` to all modified/new bootstrap functions +- [x] 4.3.5 Add `@require`, `@ensure`, and `@beartype` to all modified/new bootstrap functions ### 4.4 Update `cli.py` to register category groups -- [ ] 4.4.1 Confirm category group apps are registered via `bootstrap.py` (no direct `cli.py` changes expected; verify and update if needed) +- [x] 4.4.1 Confirm category group apps are registered via `bootstrap.py` (no direct `cli.py` changes expected; verify and update if needed) ### 4.5 Verify tests pass -- [ ] 4.5.1 `hatch test -- tests/unit/registry/test_category_groups.py tests/unit/groups/ -v` -- [ ] 4.5.2 Record passing-test results in TDD_EVIDENCE.md +- [x] 4.5.1 `hatch test -- tests/unit/registry/test_category_groups.py tests/unit/groups/ -v` +- [x] 4.5.2 Record passing-test results in TDD_EVIDENCE.md ## 5. Phase 3 — First-run module selection in `specfact init` (TDD) ### 5.1 Write tests for first-run selection (expect failure) -- [ ] 5.1.1 Create `tests/unit/modules/init/test_first_run_selection.py` -- [ ] 5.1.2 Test: `specfact init --profile solo-developer` installs only `specfact-codebase` (mock installer) -- [ ] 5.1.3 Test: `specfact init --profile enterprise-full-stack` installs all 5 bundles -- [ ] 5.1.4 Test: `specfact init --profile nonexistent` exits non-zero with error listing valid profiles -- [ ] 5.1.5 Test: `specfact init --install backlog,codebase` installs `specfact-backlog` and `specfact-codebase` -- [ ] 5.1.6 Test: `specfact init --install all` installs all 5 bundles -- [ ] 5.1.7 Test: `specfact init --install widgets` exits non-zero with unknown bundle error -- [ ] 5.1.8 Test: second run of init (bundles already installed) skips first-run selection flow -- [ ] 5.1.9 Test: `spec` bundle installation triggers automatic `project` bundle dep install (mock marketplace-02 dep resolver) -- [ ] 5.1.10 Run tests: `hatch test -- tests/unit/modules/init/test_first_run_selection.py -v` (expect failures — record in TDD_EVIDENCE.md) +- [x] 5.1.1 Create `tests/unit/modules/init/test_first_run_selection.py` +- [x] 5.1.2 Test: `specfact init --profile solo-developer` installs only `specfact-codebase` (mock installer) +- [x] 5.1.3 Test: `specfact init --profile enterprise-full-stack` installs all 5 bundles +- [x] 5.1.4 Test: `specfact init --profile nonexistent` exits non-zero with error listing valid profiles +- [x] 5.1.5 Test: `specfact init --install backlog,codebase` installs `specfact-backlog` and `specfact-codebase` +- [x] 5.1.6 Test: `specfact init --install all` installs all 5 bundles +- [x] 5.1.7 Test: `specfact init --install widgets` exits non-zero with unknown bundle error +- [x] 5.1.8 Test: second run of init (bundles already installed) skips first-run selection flow +- [x] 5.1.9 Test: `spec` bundle installation triggers automatic `project` bundle dep install (mock marketplace-02 dep resolver) +- [x] 5.1.10 Run tests: `hatch test -- tests/unit/modules/init/test_first_run_selection.py -v` (expect failures — record in TDD_EVIDENCE.md) ### 5.2 Implement first-run selection in `specfact init` -- [ ] 5.2.1 Add `--profile` and `--install` parameters to `specfact init` command in `src/specfact_cli/modules/init/src/commands.py` -- [ ] 5.2.2 Implement `is_first_run()` detection (no category bundle installed) -- [ ] 5.2.3 Implement Copilot-mode interactive bundle selection UI using `rich` (multi-select checkboxes) -- [ ] 5.2.4 Implement profile preset resolution: map profile name → bundle list -- [ ] 5.2.5 Implement `--install` flag parsing: comma-separated bundle names + `all` alias -- [ ] 5.2.6 Implement bundle installation by calling `module_installer.install_module()` for each selected bundle -- [ ] 5.2.7 Implement graceful degradation when marketplace-02 dep resolver unavailable (warn, skip dep resolution) -- [ ] 5.2.8 Add `@require`, `@ensure`, `@beartype` on all new public functions -- [ ] 5.2.9 `hatch test -- tests/unit/modules/init/test_first_run_selection.py -v` — verify tests pass +- [x] 5.2.1 Add `--profile` and `--install` parameters to `specfact init` command in `src/specfact_cli/modules/init/src/commands.py` +- [x] 5.2.2 Implement `is_first_run()` detection (no category bundle installed) +- [x] 5.2.3 Implement Copilot-mode interactive bundle selection UI using `rich` (multi-select checkboxes) +- [x] 5.2.4 Implement profile preset resolution: map profile name → bundle list +- [x] 5.2.5 Implement `--install` flag parsing: comma-separated bundle names + `all` alias +- [x] 5.2.6 Implement bundle installation by calling `module_installer.install_module()` for each selected bundle +- [x] 5.2.7 Implement graceful degradation when marketplace-02 dep resolver unavailable (warn, skip dep resolution) +- [x] 5.2.8 Add `@require`, `@ensure`, `@beartype` on all new public functions +- [x] 5.2.9 `hatch test -- tests/unit/modules/init/test_first_run_selection.py -v` — verify tests pass ### 5.3 Record passing-test evidence -- [ ] 5.3.1 Update TDD_EVIDENCE.md with passing-test run for first-run selection (timestamp, command, summary) +- [x] 5.3.1 Update TDD_EVIDENCE.md with passing-test run for first-run selection (timestamp, command, summary) ## 6. Integration and E2E tests -- [ ] 6.1 Create `tests/integration/test_category_group_routing.py` - - [ ] 6.1.1 Test: `specfact code analyze --help` returns non-zero-error-free output (CLI integration) - - [ ] 6.1.2 Test: `specfact backlog --help` lists backlog and policy sub-commands - - [ ] 6.1.3 Test: deprecated flat command `specfact validate --help` still returns help without error -- [ ] 6.2 Create `tests/e2e/test_first_run_init.py` - - [ ] 6.2.1 Test: `specfact init --profile solo-developer` in a temp workspace completes without error - - [ ] 6.2.2 Test: after `--profile solo-developer`, `specfact code analyze --help` is available -- [ ] 6.3 Run integration and E2E suites: `hatch test -- tests/integration/test_category_group_routing.py tests/e2e/test_first_run_init.py -v` +- [x] 6.1 Create `tests/integration/test_category_group_routing.py` + - [x] 6.1.1 Test: `specfact code analyze --help` returns non-zero-error-free output (CLI integration) + - [x] 6.1.2 Test: `specfact backlog --help` lists backlog and policy sub-commands + - [x] 6.1.3 Test: deprecated flat command `specfact validate --help` still returns help without error +- [x] 6.2 Create `tests/e2e/test_first_run_init.py` + - [x] 6.2.1 Test: `specfact init --profile solo-developer` in a temp workspace completes without error + - [x] 6.2.2 Test: after `--profile solo-developer`, `specfact code analyze --help` is available +- [x] 6.3 Run integration and E2E suites: `hatch test -- tests/integration/test_category_group_routing.py tests/e2e/test_first_run_init.py -v` ## 7. Quality gates diff --git a/openspec/changes/module-migration-04-remove-flat-shims/CHANGE_VALIDATION.md b/openspec/changes/module-migration-04-remove-flat-shims/CHANGE_VALIDATION.md new file mode 100644 index 00000000..41af7738 --- /dev/null +++ b/openspec/changes/module-migration-04-remove-flat-shims/CHANGE_VALIDATION.md @@ -0,0 +1,84 @@ +# Change Validation Report: module-migration-04-remove-flat-shims + +**Validation Date**: 2026-02-28T01:06:06+01:00 +**Change Proposal**: [proposal.md](./proposal.md) +**Validation Method**: Dry-run validation per /wf-validate-change workflow; OpenSpec validate --strict; dependency grep. + +## Executive Summary + +- **Breaking Changes**: 1 (intentional): removal of 17 flat CLI command names from root surface. +- **Dependent Files**: 4 affected (1 source, 3 test files). +- **Impact Level**: Medium (breaking UX; migration path documented). +- **Validation Result**: Pass +- **User Decision**: N/A (change is intentionally breaking; no scope extension requested). + +## Breaking Changes Detected + +### Interface: Root CLI command list + +- **Type**: Command removal (17 flat shim names no longer registered). +- **Old behaviour**: `specfact --help` listed core + category groups + 17 flat shims (e.g. `validate`, `analyze`, `plan`). `specfact validate ...` delegated to `specfact code validate ...` with optional deprecation message. +- **New behaviour**: `specfact --help` lists only core + category groups. `specfact validate` returns "No such command". +- **Breaking**: Yes (by design for 0.40.x). +- **Dependent files**: + - **tests/unit/registry/test_category_groups.py**: `test_flat_shim_validate_emits_deprecation_in_copilot_mode`, `test_flat_shim_validate_silent_in_cicd_mode` — must be removed or rewritten (assert flat command absent or error). + - **tests/integration/test_category_group_routing.py**: `test_validate_shim_help_exits_zero` — must be removed or changed to assert `specfact code validate --help` (or assert `specfact validate` fails). + - **tests/integration/commands/test_validate_sidecar.py**: Invokes `app` with `["validate", "sidecar", ...]` — should be updated to `["code", "validate", "sidecar", ...]` for 0.40.x. + +## Dependencies Affected + +### Critical updates required + +- **src/specfact_cli/registry/module_packages.py**: Remove `FLAT_TO_GROUP`, `_make_shim_loader()`, and the shim-registration loop in `_register_category_groups_and_shims()`; rename to `_register_category_groups()` and keep only group registration. + +### Recommended updates (tests) + +- **tests/unit/registry/test_category_groups.py**: Remove or rewrite tests that assert flat shim deprecation/silent behaviour; add/keep tests that root help contains only core + groups. +- **tests/integration/test_category_group_routing.py**: Remove `test_validate_shim_help_exits_zero` or replace with test that `specfact validate` fails and suggests `specfact code validate`. +- **tests/integration/commands/test_validate_sidecar.py**: Update invocations from `["validate", "sidecar", ...]` to `["code", "validate", "sidecar", ...]`. + +## Impact Assessment + +- **Code impact**: Single module (`module_packages.py`) reduced by removing shim layer; call sites of flat commands (scripts, docs) must migrate to category form. +- **Test impact**: 3 test files need updates; no new interfaces, only removal of shim behaviour. +- **Documentation impact**: commands.md, getting-started.md, README.md, CHANGELOG.md (0.40.0 BREAKING entry). +- **Release impact**: Minor version 0.40.0 (breaking CLI surface). + +## User Decision + +**Decision**: Proceed with change as proposed (intentionally breaking). +**Rationale**: Migration path documented in proposal; 0.40.x scope agreed. +**Next steps**: Implement per tasks.md; create GitHub issue and link in proposal Source Tracking; run specfact sync bridge to sync issue. + +## Format Validation + +- **proposal.md format**: Pass + - Title format: Correct (`# Change: Remove Flat Shims — ...`) + - Required sections: All present (Why, What Changes, Capabilities, Impact) + - "What Changes" format: Correct (REMOVE/MODIFY/KEEP bullets) + - "Capabilities" section: Present + - "Impact" format: Correct + - Source Tracking section: Present (placeholders for GitHub issue) +- **tasks.md format**: Pass + - Section headers: Correct (`## 1. Branch and prep`, etc.) + - Task format: Correct (`- [ ] 1.1 ...`) + - Sub-task format: Correct + - Config compliance: Branch creation first (1.1), PR last (6.2); GitHub issue task (6.1). Optional: add worktree bootstrap pre-flight in 1.x if using worktree. +- **specs format**: Pass + - Delta headers: REMOVED Requirements, MODIFIED Requirements with Scenario blocks + - Parsed deltas: 2 (1 MODIFIED, 1 REMOVED) +- **design.md**: Not present (optional for this change). +- **Config.yaml compliance**: Pass. + +## OpenSpec Validation + +- **Status**: Pass +- **Validation command**: `openspec validate module-migration-04-remove-flat-shims --strict` +- **Issues found**: 0 (after adding spec delta under `specs/category-command-groups/spec.md`) +- **Issues fixed**: 1 (added spec delta so change has at least one delta with Scenario blocks) +- **Re-validated**: Yes + +## Validation Artifacts + +- Spec delta added: `openspec/changes/module-migration-04-remove-flat-shims/specs/category-command-groups/spec.md` +- Dependency search: `rg FLAT_TO_GROUP|_make_shim_loader|_register_category_groups_and_shims` and `rg validate.*--help|flat shim|deprecation` in tests. diff --git a/openspec/changes/module-migration-04-remove-flat-shims/proposal.md b/openspec/changes/module-migration-04-remove-flat-shims/proposal.md new file mode 100644 index 00000000..d201a754 --- /dev/null +++ b/openspec/changes/module-migration-04-remove-flat-shims/proposal.md @@ -0,0 +1,39 @@ +# Change: Remove Flat Shims — Category-Only CLI (0.40.x) + +## Why + + +Module-migration-01 introduced category group commands (`code`, `backlog`, `project`, `spec`, `govern`) and backward-compatibility shims so existing flat commands (e.g. `specfact validate`, `specfact analyze`) still worked while emitting a deprecation notice. The proposal stated: "Shims are removed after one major version cycle." + +The 0.40.x series completes that migration: the top-level CLI surface should show only core commands (`init`, `auth`, `module`, `upgrade`) and the five category groups. Scripts and muscle memory that still invoke flat commands must switch to the category form (e.g. `specfact code validate`). This reduces noise in `specfact --help`, clarifies the canonical command topology, and avoids maintaining two code paths. + +## What Changes + + +- **REMOVE**: Registration of compat shims for all 17 non-core flat commands. No more top-level `analyze`, `drift`, `validate`, `repro`, `backlog`, `policy`, `project`, `plan`, `import`, `sync`, `migrate`, `contract`, `spec`, `sdd`, `generate`, `enforce`, `patch` at root. +- **MODIFY**: `_register_category_groups_and_shims()` in `module_packages.py` becomes category-group-only registration (no `FLAT_TO_GROUP` shim loop). Optionally rename to `_register_category_groups()`. +- **REMOVE**: `FLAT_TO_GROUP` and `_make_shim_loader()` (and any shim-specific tests that assert deprecation or shim delegation). +- **KEEP**: Core commands (`init`, `auth`, `module`, `upgrade`) and the five category groups with their sub-commands unchanged. `category_grouping_enabled` remains supported; when `false`, behavior can remain "flat" by mounting module commands directly (no groups, no shims). +- **MODIFY**: Docs and CHANGELOG to state the breaking change and migration path (flat → category). + +## Capabilities +### Modified Capabilities + +- `category-command-groups`: Sole top-level surface for non-core module commands. No flat shims; users must use `specfact code analyze`, `specfact backlog ceremony`, etc. +- `command-registry`: Bootstrap no longer registers shim loaders; only group typers and (when grouping disabled) direct module commands. + +### Removed Capabilities + +- Backward-compat shim layer (deprecation delegates) for the 17 flat command names. + + +--- + +## Source Tracking + + +- **GitHub Issue**: #330 +- **Issue URL**: +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: proposed +- **Sanitized**: false \ No newline at end of file diff --git a/openspec/changes/module-migration-04-remove-flat-shims/specs/category-command-groups/spec.md b/openspec/changes/module-migration-04-remove-flat-shims/specs/category-command-groups/spec.md new file mode 100644 index 00000000..93ffc4ff --- /dev/null +++ b/openspec/changes/module-migration-04-remove-flat-shims/specs/category-command-groups/spec.md @@ -0,0 +1,38 @@ +# category-command-groups Specification (Delta: Remove Flat Shims) + +## Purpose + +This delta removes the backward-compat shim layer for flat commands. After this change, the root CLI SHALL list only core commands and the five category groups when `category_grouping_enabled` is true. + +## REMOVED Requirements + +### Requirement: Backward-compat shims preserve all existing flat top-level commands + +*(Removed in 0.40.x. Flat commands are no longer registered; users MUST use category form.)* + +#### Scenario: Root help lists only core and category groups + +- **GIVEN** `category_grouping_enabled` is `true` +- **WHEN** the user runs `specfact --help` +- **THEN** the output SHALL list only: core commands (`init`, `auth`, `module`, `upgrade`) and the five category groups (`code`, `backlog`, `project`, `spec`, `govern`) +- **AND** SHALL NOT list any of the 17 former flat shim commands (e.g. `analyze`, `validate`, `plan`, `sync`) + +#### Scenario: Flat command name returns error + +- **GIVEN** `category_grouping_enabled` is `true` +- **WHEN** the user runs `specfact validate --help` +- **THEN** the CLI SHALL respond with an error indicating the command is not found +- **AND** SHALL suggest using `specfact code validate` or list available commands + +## MODIFIED Requirements + +### Requirement: Bootstrap mounts category groups when grouping is enabled + +Bootstrap SHALL mount only category group apps (and core commands) when `category_grouping_enabled` is true. It SHALL NOT register any shim loaders for flat command names. + +#### Scenario: No shim registration at bootstrap + +- **GIVEN** `category_grouping_enabled` is `true` +- **WHEN** the CLI bootstrap runs +- **THEN** the registry SHALL contain entries only for core commands and the five category group names +- **AND** SHALL NOT contain entries for `analyze`, `drift`, `validate`, `repro`, `backlog`, `policy`, `project`, `plan`, `import`, `sync`, `migrate`, `contract`, `spec`, `sdd`, `generate`, `enforce`, `patch` as top-level commands diff --git a/openspec/changes/module-migration-04-remove-flat-shims/tasks.md b/openspec/changes/module-migration-04-remove-flat-shims/tasks.md new file mode 100644 index 00000000..0a044a2b --- /dev/null +++ b/openspec/changes/module-migration-04-remove-flat-shims/tasks.md @@ -0,0 +1,40 @@ +# Tasks: module-migration-04-remove-flat-shims + +TDD/SDD order enforced. Version series: **0.40.x**. + +## 1. Branch and prep + +- [ ] 1.1 Create feature branch from `dev`: `feature/module-migration-04-remove-flat-shims` +- [ ] 1.2 Ensure module-migration-01 is merged to dev (category groups and shims exist) + +## 2. Spec and tests first + +- [ ] 2.1 Add spec delta under `specs/category-command-groups/`: when `category_grouping_enabled` is true, root CLI SHALL list only core commands (init, auth, module, upgrade) and the five category groups (code, backlog, project, spec, govern). No flat shim commands. +- [ ] 2.2 Update or add tests that assert root help contains only core + groups when grouping enabled; remove or rewrite tests that assert flat shim deprecation or `specfact validate --help` success for shim. +- [ ] 2.3 Run tests and capture **failing** result (shims still present) in `TDD_EVIDENCE.md`. + +## 3. Implementation + +- [ ] 3.1 In `module_packages.py`: remove the loop that registers shims from `FLAT_TO_GROUP`; keep only category group registration. Rename `_register_category_groups_and_shims` → `_register_category_groups` (or equivalent). +- [ ] 3.2 Remove `FLAT_TO_GROUP` and `_make_shim_loader()` (and any code only used by shims). +- [ ] 3.4 Run tests; capture **passing** result in `TDD_EVIDENCE.md`. + +## 4. Quality gates + +- [ ] 4.1 `hatch run format` and fix +- [ ] 4.2 `hatch run type-check` and fix +- [ ] 4.3 `hatch run lint` and fix +- [ ] 4.4 `hatch run contract-test` and fix +- [ ] 4.5 `hatch run smart-test` (or smart-test-full) and fix + +## 5. Documentation and release + +- [ ] 5.1 Update `docs/reference/commands.md`: command topology is category-only (no flat commands). +- [ ] 5.2 Update `docs/guides/getting-started.md` and `README.md`: command list shows only core + categories; add migration note for users of flat commands. +- [ ] 5.3 Bump version to **0.40.0** in `pyproject.toml`, `setup.py`, `src/__init__.py`, `src/specfact_cli/__init__.py`. +- [ ] 5.4 Add CHANGELOG.md entry for 0.40.0: **BREAKING** — removed flat command shims; use `specfact ` (e.g. `specfact code validate`). + +## 6. PR + +- [ ] 6.1 Create GitHub issue for change (title: `[Change] Remove flat shims — category-only CLI (0.40.x)`); link in proposal Source Tracking. +- [ ] 6.2 Open PR to `dev`; reference this change and breaking-change migration path. diff --git a/pyproject.toml b/pyproject.toml index 5ab83626..e0ec179c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.38.2" +version = "0.39.0" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 40e4e0a8..e7a50edb 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.38.2", + version="0.39.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 7c5a48a3..71bf4be9 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.38.2" +__version__ = "0.39.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index c09f1d95..d6c68d00 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.38.2" +__version__ = "0.39.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index fcf3df96..a366ca0b 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -451,13 +451,26 @@ def _make_lazy_typer(cmd_name: str, help_str: str) -> typer.Typer: def _get_command(typer_instance: typer.Typer) -> click.Command: - """Wrapper around typer.main.get_command that returns LazyDelegateGroup for our lazy typers.""" + """Wrapper around typer.main.get_command that returns LazyDelegateGroup for our lazy typers, + and applies flatten-same-name for Typers with _specfact_flatten_same_name. + """ if getattr(typer_instance, "_specfact_lazy_delegate", False): cmd_name = getattr(typer_instance, "_specfact_lazy_cmd_name", "") help_str = getattr(typer_instance, "_specfact_lazy_help_str", "") return _build_lazy_delegate_group(cmd_name, help_str) assert _typer_get_command_original is not None - return _typer_get_command_original(typer_instance) + result = _typer_get_command_original(typer_instance) + flatten_name = getattr(typer_instance, "_specfact_flatten_same_name", None) + if isinstance(flatten_name, str) and isinstance(result, click.Group) and flatten_name in result.commands: + redundant = result.commands.pop(flatten_name) + if isinstance(redundant, click.Group): + for cmd_name, cmd in redundant.commands.items(): + result.add_command(cmd, name=cmd_name) + if result.commands: + for cname in sorted(result.commands.keys()): + cmd = result.commands.pop(cname) + result.add_command(cmd, name=cname) + return result def _get_group_from_info_wrapper( @@ -476,12 +489,23 @@ def _get_group_from_info_wrapper( help_str = getattr(typer_instance, "_specfact_lazy_help_str", "") return _build_lazy_delegate_group(cmd_name, help_str) assert _typer_get_group_from_info_original is not None - return _typer_get_group_from_info_original( + result = _typer_get_group_from_info_original( group_info, pretty_exceptions_short=pretty_exceptions_short, suggest_commands=suggest_commands, rich_markup_mode=rich_markup_mode, ) + flatten_name = getattr(typer_instance, "_specfact_flatten_same_name", None) if typer_instance else None + if isinstance(flatten_name, str) and flatten_name in result.commands: + redundant = result.commands.pop(flatten_name) + if isinstance(redundant, click.Group): + for cmd_name, cmd in redundant.commands.items(): + result.add_command(cmd, name=cmd_name) + if result.commands: + for name in sorted(result.commands.keys()): + cmd = result.commands.pop(name) + result.add_command(cmd, name=name) + return result # Original Typer build functions (set once by _patch_typer_build so re-import of cli doesn't overwrite with our wrapper). diff --git a/src/specfact_cli/groups/__init__.py b/src/specfact_cli/groups/__init__.py new file mode 100644 index 00000000..10efd395 --- /dev/null +++ b/src/specfact_cli/groups/__init__.py @@ -0,0 +1,18 @@ +"""Category group commands: project, backlog, code, spec, govern.""" + +from __future__ import annotations + +from specfact_cli.groups.backlog_group import app as backlog_app +from specfact_cli.groups.codebase_group import app as codebase_app +from specfact_cli.groups.govern_group import app as govern_app +from specfact_cli.groups.project_group import app as project_app +from specfact_cli.groups.spec_group import app as spec_app + + +__all__ = [ + "backlog_app", + "codebase_app", + "govern_app", + "project_app", + "spec_app", +] diff --git a/src/specfact_cli/groups/backlog_group.py b/src/specfact_cli/groups/backlog_group.py new file mode 100644 index 00000000..d18dc348 --- /dev/null +++ b/src/specfact_cli/groups/backlog_group.py @@ -0,0 +1,44 @@ +"""Backlog category group (backlog, policy).""" + +from __future__ import annotations + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.registry import CommandRegistry + + +_MEMBERS = [ + ("backlog", "backlog"), + ("policy", "policy"), +] + + +@require(lambda app: app is not None) +@ensure(lambda result: result is None) +@beartype +def _register_members(app: typer.Typer) -> None: + """Register member module sub-apps (called when group is first used).""" + for display_name, cmd_name in _MEMBERS: + try: + member_app = CommandRegistry.get_module_typer(cmd_name) + if member_app is not None: + app.add_typer(member_app, name=display_name) + except ValueError: + pass + + +def build_app() -> typer.Typer: + """Build the backlog group Typer with members (lazy; registry must be populated).""" + app = typer.Typer( + name="backlog", + help="Backlog and policy commands.", + no_args_is_help=True, + ) + _register_members(app) + app._specfact_flatten_same_name = "backlog" + return app + + +app = build_app() diff --git a/src/specfact_cli/groups/codebase_group.py b/src/specfact_cli/groups/codebase_group.py new file mode 100644 index 00000000..1390c6ca --- /dev/null +++ b/src/specfact_cli/groups/codebase_group.py @@ -0,0 +1,40 @@ +"""Codebase quality category group (analyze, drift, validate, repro).""" + +from __future__ import annotations + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.registry import CommandRegistry + + +_MEMBERS = ("analyze", "drift", "validate", "repro") + + +@require(lambda app: app is not None) +@ensure(lambda result: result is None) +@beartype +def _register_members(app: typer.Typer) -> None: + """Register member module sub-apps (called when group is first used).""" + for name in _MEMBERS: + try: + member_app = CommandRegistry.get_module_typer(name) + if member_app is not None: + app.add_typer(member_app, name=name) + except ValueError: + pass + + +def build_app() -> typer.Typer: + """Build the code group Typer with members (lazy; registry must be populated).""" + app = typer.Typer( + name="code", + help="Codebase quality commands: analyze, drift, validate, repro.", + no_args_is_help=True, + ) + _register_members(app) + return app + + +app = build_app() diff --git a/src/specfact_cli/groups/govern_group.py b/src/specfact_cli/groups/govern_group.py new file mode 100644 index 00000000..f66e520f --- /dev/null +++ b/src/specfact_cli/groups/govern_group.py @@ -0,0 +1,43 @@ +"""Governance category group (enforce, patch).""" + +from __future__ import annotations + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.registry import CommandRegistry + + +_MEMBERS = [ + ("enforce", "enforce"), + ("patch", "patch"), +] + + +@require(lambda app: app is not None) +@ensure(lambda result: result is None) +@beartype +def _register_members(app: typer.Typer) -> None: + """Register member module sub-apps (called when group is first used).""" + for display_name, cmd_name in _MEMBERS: + try: + member_app = CommandRegistry.get_module_typer(cmd_name) + if member_app is not None: + app.add_typer(member_app, name=display_name) + except ValueError: + pass + + +def build_app() -> typer.Typer: + """Build the govern group Typer with members (lazy; registry must be populated).""" + app = typer.Typer( + name="govern", + help="Governance and quality gates: enforce, patch.", + no_args_is_help=True, + ) + _register_members(app) + return app + + +app = build_app() diff --git a/src/specfact_cli/groups/project_group.py b/src/specfact_cli/groups/project_group.py new file mode 100644 index 00000000..b5ce3464 --- /dev/null +++ b/src/specfact_cli/groups/project_group.py @@ -0,0 +1,47 @@ +"""Project lifecycle category group (project, plan, import, sync, migrate).""" + +from __future__ import annotations + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.registry import CommandRegistry + + +_MEMBERS = [ + ("project", "project"), + ("plan", "plan"), + ("import", "import"), + ("sync", "sync"), + ("migrate", "migrate"), +] + + +@require(lambda app: app is not None) +@ensure(lambda result: result is None) +@beartype +def _register_members(app: typer.Typer) -> None: + """Register member module sub-apps (called when group is first used).""" + for display_name, cmd_name in _MEMBERS: + try: + member_app = CommandRegistry.get_module_typer(cmd_name) + if member_app is not None: + app.add_typer(member_app, name=display_name) + except ValueError: + pass + + +def build_app() -> typer.Typer: + """Build the project group Typer with members (lazy; registry must be populated).""" + app = typer.Typer( + name="project", + help="Project lifecycle commands.", + no_args_is_help=True, + ) + _register_members(app) + app._specfact_flatten_same_name = "project" + return app + + +app = build_app() diff --git a/src/specfact_cli/groups/spec_group.py b/src/specfact_cli/groups/spec_group.py new file mode 100644 index 00000000..585c6c52 --- /dev/null +++ b/src/specfact_cli/groups/spec_group.py @@ -0,0 +1,45 @@ +"""Spec category group (contract, api, sdd, generate) — spec module mounted as 'api' to avoid collision.""" + +from __future__ import annotations + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.registry import CommandRegistry + + +_MEMBERS = [ + ("contract", "contract"), + ("api", "spec"), + ("sdd", "sdd"), + ("generate", "generate"), +] + + +@require(lambda app: app is not None) +@ensure(lambda result: result is None) +@beartype +def _register_members(app: typer.Typer) -> None: + """Register member module sub-apps (called when group is first used).""" + for display_name, cmd_name in _MEMBERS: + try: + member_app = CommandRegistry.get_module_typer(cmd_name) + if member_app is not None: + app.add_typer(member_app, name=display_name) + except ValueError: + pass + + +def build_app() -> typer.Typer: + """Build the spec group Typer with members (lazy; registry must be populated).""" + app = typer.Typer( + name="spec", + help="Spec and contract commands: contract, api, sdd, generate.", + no_args_is_help=True, + ) + _register_members(app) + return app + + +app = build_app() diff --git a/src/specfact_cli/models/module_package.py b/src/specfact_cli/models/module_package.py index 8ffb4af0..7395dd9b 100644 --- a/src/specfact_cli/models/module_package.py +++ b/src/specfact_cli/models/module_package.py @@ -153,6 +153,19 @@ class ModulePackageMetadata(BaseModel): description: str | None = Field(default=None, description="Module description for user-facing module details") license: str | None = Field(default=None, description="SPDX license identifier or license name") source: str = Field(default="builtin", description="Module source: builtin, project, user, marketplace, or custom") + category: str | None = Field( + default=None, + description="Workflow category: core, project, backlog, codebase, spec, or govern.", + ) + bundle: str | None = Field(default=None, description="Bundle id (e.g. specfact-codebase) for non-core modules.") + bundle_group_command: str | None = Field( + default=None, + description="Top-level group command for this category (e.g. code, backlog).", + ) + bundle_sub_command: str | None = Field( + default=None, + description="Sub-command name within the group (e.g. analyze, validate).", + ) @beartype @ensure(lambda result: isinstance(result, list), "Validated bridges must be returned as a list") diff --git a/src/specfact_cli/modules/analyze/module-package.yaml b/src/specfact_cli/modules/analyze/module-package.yaml index 896f7fff..08a573d0 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -1,7 +1,11 @@ name: analyze -version: 0.1.0 +version: 0.1.1 commands: - analyze +category: codebase +bundle: specfact-codebase +bundle_group_command: code +bundle_sub_command: analyze command_help: analyze: Analyze codebase for contract coverage and quality pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Analyze codebase quality, contracts, and architecture signals. license: Apache-2.0 integrity: - checksum: sha256:49d908578ab91e142cff50a27d9b15fff3a30cf790597eecbea1910e38a754b6 - signature: CqsLSUUx3DYa0a8F56/OW4QFt6TDhx1OueAwI0tYC892S7RlvaF5JEwKcUXujKD2IQRoOKUQ7d0Gdqs8Kwh2Cw== + checksum: sha256:d57826fb72253cf65a191bace15cb1a6b7551e844b80a4bef94e9cf861727bde + signature: /9/vp39C0v8ywsHOY3hBMyxbSNqYf5nbz1Fa9gw0KmNKclBIhfYj/JZzi7R56iYZaU5w8YsjLEj4/IspV2JdCg== diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index c15b2f14..2100cc26 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -1,7 +1,9 @@ name: auth -version: 0.1.0 +version: 0.1.1 commands: - auth +category: core +bundle_sub_command: auth command_help: auth: Authenticate with DevOps providers (GitHub, Azure DevOps) pip_dependencies: [] @@ -11,9 +13,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Authenticate SpecFact with supported DevOps providers. license: Apache-2.0 integrity: - checksum: sha256:561fc420c18f9702b1cbb396cb1c0443909646ad8857f508d532d648fe812a9d - signature: IUFyErHdSMMRtdGCUjDZhkU6hujDv1J5IHQXKYelK4RGqeekYUFer13IeG7S1xZZ5ckmvsgF1592UsTCSV2BAA== + checksum: sha256:358844d5b8d1b5ca829e62cd52d0719cc4cc347459bcedd350a0ddac0de5e387 + signature: a46QWufONaLsbIiUqvkEPJ92Fs4KgN301dfDvOrOg+c3SYki2aw1Ofu8YVDaB6ClsgVAtWwQz6P8kiqGUTX1AA== diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index 4334ee18..c39f1c49 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -1,7 +1,11 @@ name: backlog -version: 0.1.6 +version: 0.1.7 commands: - backlog +category: backlog +bundle: specfact-backlog +bundle_group_command: backlog +bundle_sub_command: backlog command_help: backlog: Backlog refinement and template management pip_dependencies: [] @@ -24,9 +28,9 @@ service_bridges: publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Manage backlog ceremonies, refinement, and dependency insights. license: Apache-2.0 integrity: - checksum: sha256:a3b033ef35a6a95e1c40ffe28e112cb1683af5051dd813038bacf1cd76bfd7ad - signature: gHQkRqNpRRpxwRmFiHSHaSpq8/rwKvv1v/4Wjt8pRl0Z2VFTVF1DStb2XwgZlE0Bpg77n++G5mIl7KkM7MyGBQ== + checksum: sha256:8e7c0b8636d5ef39ba3b3b1275d67f68bde017e1328efd38f091f97152256c7f + signature: RK6YZCqmWWfb8OWCsRX6Qic1jqiqGdaDrcJmOYLLI3epz48LWx7sx3ZcIHzYGNf8VLg1q0tAnpTfsxfC4nm7DQ== diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index de525a14..fb3fce8c 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -1,7 +1,11 @@ name: contract -version: 0.1.0 +version: 0.1.1 commands: - contract +category: spec +bundle: specfact-spec +bundle_group_command: spec +bundle_sub_command: contract command_help: contract: Manage OpenAPI contracts for project bundles pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Validate and manage API contracts for project bundles. license: Apache-2.0 integrity: - checksum: sha256:ea7526559317a65684db0ecc8eaccd06a60dcb94361c95389e7a35cfd31279d3 - signature: iJC2irFaSiWa9fdFjJYGpHlsyWKRNpoFmQSB+PY0ORS6y+gVHAPNGJL7iP5TFYB3I83szCWfmF+2hLeTyc1XDg== + checksum: sha256:e36b4d6b91ec88ec7586265457440babcce2e0ea29db20f25307797c0ffb19c0 + signature: kPeqIYhcF4ri/0q+cKcrCVe4VUsEVT62GPL9uPTV2GJp58Rejkcq1rnaoO2zun0GRWzXI00DMutSCU85P+kECQ== diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index 57d282a5..d7a56025 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -1,7 +1,11 @@ name: drift -version: 0.1.0 +version: 0.1.1 commands: - drift +category: codebase +bundle: specfact-codebase +bundle_group_command: code +bundle_sub_command: drift command_help: drift: Detect drift between code and specifications pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Detect and report drift between code, plans, and specs. license: Apache-2.0 integrity: - checksum: sha256:0bf406486ada20fa82273f62a46567a63231be00bca1aa0da335405081d868ee - signature: 22k+r94pdCPh7lP4UYZfvlNRlTQaSasXwzDJWE33I1Pzeq1hPRnyXBylx9x7IvdDqACLTnCIz5j6R8XKCu1WAQ== + checksum: sha256:3ba1feb48d85bb7e87b379ca630edcb2fabbeee998f63c4cbac46158d86c6667 + signature: gcukNmz2mJt+G4sztoWqsQ0DtaXRq+D+Lfitjy0QIvJZUvis4SNdSrBApBsoVB5F079NHpLJNjl24piejZRHBA== diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index 0777c1d3..af27e153 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -1,7 +1,11 @@ name: enforce -version: 0.1.0 +version: 0.1.1 commands: - enforce +category: govern +bundle: specfact-govern +bundle_group_command: govern +bundle_sub_command: enforce command_help: enforce: Configure quality gates pip_dependencies: [] @@ -12,9 +16,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Apply governance policies and quality gates to bundles. license: Apache-2.0 integrity: - checksum: sha256:444896a5ac47c50cac848e649bb509893ba8c62b382100ccbe2b65660dca6587 - signature: Htf9gy0P5UmGmrDky3oLyl4GgVQoVJ6514f31O1v1Butyns4o49CF6mVZMqbOBh68ToloPUxG96E/1GEzM3QDg== + checksum: sha256:836e08acb3842480c909d95bba2dcfbb5914c33ceb64bd8b85e6e6a948c39ff3 + signature: gOIb0KCdrUwEOSNWEkMCFQ/cne9KG0zT0s09R4SzGKCKmIN2ZI1eCQ4Py+EOU5fPjszMN9R6NEuMmRXaZ+MpCA== diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index 8f9e54aa..41dd1a97 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -1,7 +1,11 @@ name: generate -version: 0.1.0 +version: 0.1.1 commands: - generate +category: spec +bundle: specfact-spec +bundle_group_command: spec +bundle_sub_command: generate command_help: generate: Generate artifacts from SDD and plans pip_dependencies: [] @@ -12,9 +16,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Generate implementation artifacts from plans and SDD. license: Apache-2.0 integrity: - checksum: sha256:c1b01eea7f1de8e71fd61ac02caae27b10e133131683804356f01c9c6171a955 - signature: dKokcDKA0v/xJ4SDWDvFEtCdVFScgyygdoJZlD7LgSQeucpi8csKLMW97XOaOUZCtNUsVm194EWBnIzLf70+CA== + checksum: sha256:d0e6c3749216c231b48f01415a7ed84c5710b49f3826fbad4d74e399fc22f443 + signature: IvszOEUxuOeUTn/CFj7xda8oyWDoDl0uVq/LDsGrv7NoTXhb68xQ0L2XTLDKUcr4end9+6svbaj0v4+opUa5Bg== diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index c61999b8..0e08bc61 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -1,7 +1,11 @@ name: import_cmd -version: 0.1.0 +version: 0.1.1 commands: - import +category: project +bundle: specfact-project +bundle_group_command: project +bundle_sub_command: import command_help: import: Import codebases and external tool projects (e.g., Spec-Kit, OpenSpec, generic-markdown) pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Import projects and requirements from code and external tools. license: Apache-2.0 integrity: - checksum: sha256:246f8402200e57947a50f61102ef8057dd75a6ad2768d359774600557daead8b - signature: sCgBiKEtN5r2br/6ZAYmII7XjjZ9Ru8WqqPfXXaE2shn0eQBiDxKKH/ATNBZphtYJbTfXEpQFZg3oVja4jITCg== + checksum: sha256:f1cdb18387d6e64bdbbc59eac070df7aa1e215f5684c82e3e5058e7f3bff2a78 + signature: DeuBD5usns6KCBFNYAim9gDaUAZVWW0jgDeWW1+EpbtsDskiKTTP7MTU5fh4U2N/JHsXFTXZVMh4VaQHOyXMCg== diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index f48c594b..f02d208a 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -1,7 +1,9 @@ name: init -version: 0.1.0 +version: 0.1.2 commands: - init +category: core +bundle_sub_command: init command_help: init: Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup) pip_dependencies: [] @@ -11,9 +13,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Initialize SpecFact workspace and bootstrap local configuration. license: Apache-2.0 integrity: - checksum: sha256:ebd53092241acd0bf53db40a001529b98ee001244b04223cfe855d7fdcbc607d - signature: NrPV9fl6k7W47hi4hkbNhsS8EX0CfB8zlAFucesUwbMYHgGZl9TvfsBwObeHWh+R1eqskTUf+sxivl6lv/1mAg== + checksum: sha256:223ce09d4779d73a9c35a2ed3776330b1ef6318bc33145252bf1693bb9b71644 + signature: x97hJyltPjofAJeHkaWpXmf9TtgYsnI0+zk8RFx5mLqcFYQbJxtwECS7Xvld+RIHaBAKmOAQtImIWtl09sgtDQ== diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index b9b46f5a..3ab4ee81 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -18,7 +18,9 @@ from specfact_cli import __version__ from specfact_cli.contracts.module_interface import ModuleIOContract from specfact_cli.modules import module_io_shim +from specfact_cli.modules.init.src import first_run_selection from specfact_cli.registry.help_cache import run_discovery_and_write_cache +from specfact_cli.registry.module_installer import USER_MODULES_ROOT as INIT_USER_MODULES_ROOT from specfact_cli.registry.module_packages import get_discovered_modules_for_state from specfact_cli.registry.module_state import write_modules_state from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive @@ -33,6 +35,10 @@ ) +install_bundles_for_init = first_run_selection.install_bundles_for_init +is_first_run = first_run_selection.is_first_run + + def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: Console) -> None: """ Copy backlog field mapping templates to .specfact/templates/backlog/field_mappings/. @@ -347,6 +353,63 @@ def _is_valid_repo_path(repo: Path) -> bool: return repo.exists() and repo.is_dir() +def _interactive_first_run_bundle_selection() -> list[str]: + """Show first-run welcome and bundle selection; return list of canonical bundle ids to install (or empty).""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError as e: + console.print( + "[red]Interactive bundle selection requires 'questionary'. Install with: pip install questionary[/red]" + ) + raise typer.Exit(1) from e + + console.print() + console.print( + Panel( + "[bold cyan]Welcome to SpecFact[/bold cyan]\n" + "Choose which workflow bundles to install. Core commands (init, auth, module, upgrade) are always available.", + border_style="cyan", + ) + ) + console.print("[dim]You can install more later with `specfact module install `[/dim]") + console.print() + + profile_choices = [f"{label} [dim]({key})[/dim]" for key, label in first_run_selection.PROFILE_DISPLAY_ORDER] + profile_to_key = {f"{label} [dim]({key})[/dim]": key for key, label in first_run_selection.PROFILE_DISPLAY_ORDER} + profile_to_key["Choose bundles manually"] = "_manual_" + + choice = questionary.select( + "Select a profile or choose bundles manually:", + choices=[*profile_choices, "Choose bundles manually"], + style=_questionary_style(), + ).ask() + + if not choice: + return [] + + if choice in profile_to_key: + key = profile_to_key[choice] + if key == "_manual_": + bundle_choices = [ + f"{first_run_selection.BUNDLE_DISPLAY.get(bid, bid)} [dim]({bid})[/dim]" + for bid in first_run_selection.CANONICAL_BUNDLES + ] + selected = questionary.checkbox( + "Select bundles to install:", + choices=bundle_choices, + style=_questionary_style(), + ).ask() + if not selected: + return [] + return [bid for bid in first_run_selection.CANONICAL_BUNDLES if any(bid in s for s in selected)] + return list(first_run_selection.PROFILE_PRESETS.get(key, [])) + + for key, label in first_run_selection.PROFILE_DISPLAY_ORDER: + if choice.startswith(label) or f"({key})" in choice: + return list(first_run_selection.PROFILE_PRESETS.get(key, [])) + return [] + + @app.command("ide") @require(_is_valid_repo_path, "Repo path must exist and be directory") @ensure(lambda result: result is None, "Command should return None") @@ -435,6 +498,16 @@ def init( file_okay=False, dir_okay=True, ), + profile: str | None = typer.Option( + None, + "--profile", + help="First-run profile preset: solo-developer, backlog-team, api-first-team, enterprise-full-stack", + ), + install: str | None = typer.Option( + None, + "--install", + help="Comma-separated bundle names or 'all' to install without prompting", + ), install_deps: bool = typer.Option( False, "--install-deps", @@ -450,6 +523,42 @@ def init( return repo_path = repo.resolve() + + if profile is not None or install is not None: + try: + if profile is not None: + bundle_ids = first_run_selection.resolve_profile_bundles(profile) + else: + bundle_ids = first_run_selection.resolve_install_bundles(install or "") + if bundle_ids: + first_run_selection.install_bundles_for_init( + bundle_ids, + INIT_USER_MODULES_ROOT, + non_interactive=is_non_interactive(), + ) + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) from e + elif is_first_run(user_root=INIT_USER_MODULES_ROOT) and not is_non_interactive(): + try: + bundle_ids = _interactive_first_run_bundle_selection() + if bundle_ids: + first_run_selection.install_bundles_for_init( + bundle_ids, + INIT_USER_MODULES_ROOT, + non_interactive=False, + ) + else: + console.print( + "[dim]Tip: Install bundles later with " + "`specfact module install ` or `specfact init --profile `[/dim]" + ) + except typer.Exit: + raise + except ValueError as e: + console.print(f"[red]Error:[/red] {e}") + raise typer.Exit(1) from e + modules_list = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) if modules_list: write_modules_state(modules_list) diff --git a/src/specfact_cli/modules/init/src/first_run_selection.py b/src/specfact_cli/modules/init/src/first_run_selection.py new file mode 100644 index 00000000..653fe646 --- /dev/null +++ b/src/specfact_cli/modules/init/src/first_run_selection.py @@ -0,0 +1,196 @@ +"""First-run bundle selection: profiles, --install parsing, and installation (Phase 3).""" + +from __future__ import annotations + +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.registry.module_discovery import USER_MODULES_ROOT +from specfact_cli.registry.module_grouping import VALID_CATEGORIES + + +PROFILE_PRESETS: dict[str, list[str]] = { + "solo-developer": ["specfact-codebase"], + "backlog-team": ["specfact-backlog", "specfact-project", "specfact-codebase"], + "api-first-team": ["specfact-spec", "specfact-codebase"], + "enterprise-full-stack": [ + "specfact-project", + "specfact-backlog", + "specfact-codebase", + "specfact-spec", + "specfact-govern", + ], +} + +CANONICAL_BUNDLES: tuple[str, ...] = ( + "specfact-project", + "specfact-backlog", + "specfact-codebase", + "specfact-spec", + "specfact-govern", +) + +BUNDLE_ALIAS_TO_CANONICAL: dict[str, str] = { + "project": "specfact-project", + "backlog": "specfact-backlog", + "codebase": "specfact-codebase", + "code": "specfact-codebase", + "spec": "specfact-spec", + "govern": "specfact-govern", +} + +BUNDLE_TO_MODULE_NAMES: dict[str, list[str]] = { + "specfact-project": ["project", "plan", "import_cmd", "sync", "migrate"], + "specfact-backlog": ["backlog", "policy_engine"], + "specfact-codebase": ["analyze", "drift", "validate", "repro"], + "specfact-spec": ["contract", "spec", "sdd", "generate"], + "specfact-govern": ["enforce", "patch_mode"], +} + +BUNDLE_DEPENDENCIES: dict[str, list[str]] = { + "specfact-spec": ["specfact-project"], +} + + +@require(lambda profile: isinstance(profile, str) and profile.strip() != "", "profile must be non-empty string") +@ensure(lambda result: isinstance(result, list), "result must be list of bundle ids") +@beartype +def resolve_profile_bundles(profile: str) -> list[str]: + """Resolve a profile name to the list of canonical bundle ids to install.""" + key = profile.strip().lower() + if key not in PROFILE_PRESETS: + valid = ", ".join(sorted(PROFILE_PRESETS)) + raise ValueError(f"Unknown profile {profile!r}. Valid profiles: {valid}") + return list(PROFILE_PRESETS[key]) + + +@require(lambda install_arg: isinstance(install_arg, str), "install_arg must be string") +@ensure(lambda result: isinstance(result, list), "result must be list of bundle ids") +@beartype +def resolve_install_bundles(install_arg: str) -> list[str]: + """Parse --install value (comma-separated or 'all') into canonical bundle ids.""" + raw = install_arg.strip() + if not raw: + return [] + if raw.lower() == "all": + return list(CANONICAL_BUNDLES) + seen: set[str] = set() + result: list[str] = [] + for part in raw.split(","): + alias = part.strip().lower() + if not alias: + continue + if alias in BUNDLE_ALIAS_TO_CANONICAL: + canonical = BUNDLE_ALIAS_TO_CANONICAL[alias] + if canonical not in seen: + seen.add(canonical) + result.append(canonical) + else: + valid = ", ".join([*sorted(BUNDLE_ALIAS_TO_CANONICAL), "all"]) + raise ValueError(f"Unknown bundle {part.strip()!r}. Valid bundle names: {valid}") + return result + + +@ensure(lambda result: isinstance(result, bool), "result must be bool") +@beartype +def is_first_run( + *, + user_root: Path | None = None, +) -> bool: + """Return True when no category bundle is installed (first run).""" + from specfact_cli.registry.module_discovery import discover_all_modules + + root = user_root or USER_MODULES_ROOT + discovered = discover_all_modules(user_root=root) + for entry in discovered: + if entry.source not in ("user", "marketplace", "project"): + continue + cat = entry.metadata.category + if cat is not None and cat != "core" and cat in VALID_CATEGORIES: + return False + return True + + +@require(lambda bundle_ids: isinstance(bundle_ids, list), "bundle_ids must be list") +@require( + lambda install_root: install_root is None or isinstance(install_root, Path), "install_root must be Path or None" +) +@beartype +def install_bundles_for_init( + bundle_ids: list[str], + install_root: Path | None = None, + *, + non_interactive: bool = False, + trust_non_official: bool = False, +) -> None: + """Install the given bundles (and their dependencies) via bundled module installer.""" + from specfact_cli.registry.module_installer import ( + USER_MODULES_ROOT as DEFAULT_ROOT, + install_bundled_module, + ) + + root = install_root or DEFAULT_ROOT + to_install: list[str] = [] + seen: set[str] = set() + + def _add_bundle(bid: str) -> None: + if bid in seen: + return + for dep in BUNDLE_DEPENDENCIES.get(bid, []): + _add_bundle(dep) + seen.add(bid) + to_install.append(bid) + + for bid in bundle_ids: + if bid not in CANONICAL_BUNDLES: + continue + _add_bundle(bid) + + for bid in to_install: + module_names = BUNDLE_TO_MODULE_NAMES.get(bid, []) + for module_name in module_names: + try: + install_bundled_module( + module_name, + root, + trust_non_official=trust_non_official, + non_interactive=non_interactive, + ) + except Exception as e: + from specfact_cli.common import get_bridge_logger + + logger = get_bridge_logger(__name__) + logger.warning( + "Bundle install failed for %s: %s. Dependency resolver may be unavailable.", + module_name, + e, + ) + raise + + +def get_valid_profile_names() -> list[str]: + """Return sorted list of valid profile names for error messages.""" + return sorted(PROFILE_PRESETS) + + +def get_valid_bundle_aliases() -> list[str]: + """Return sorted list of valid bundle aliases (including 'all').""" + return [*sorted(BUNDLE_ALIAS_TO_CANONICAL), "all"] + + +BUNDLE_DISPLAY: dict[str, str] = { + "specfact-project": "Project lifecycle (project, plan, import, sync, migrate)", + "specfact-backlog": "Backlog management (backlog, policy)", + "specfact-codebase": "Codebase quality (analyze, drift, validate, repro)", + "specfact-spec": "Spec & API (contract, spec, sdd, generate)", + "specfact-govern": "Governance (enforce, patch)", +} + +PROFILE_DISPLAY_ORDER: list[tuple[str, str]] = [ + ("solo-developer", "Solo developer"), + ("backlog-team", "Backlog team"), + ("api-first-team", "API-first team"), + ("enterprise-full-stack", "Enterprise full-stack"), +] diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index 4303eada..6f7d7739 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -1,7 +1,11 @@ name: migrate -version: 0.1.0 +version: 0.1.1 commands: - migrate +category: project +bundle: specfact-project +bundle_group_command: project +bundle_sub_command: migrate command_help: migrate: Migrate project bundles between formats pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Migrate project bundles across supported structure versions. license: Apache-2.0 integrity: - checksum: sha256:0173eddfed089e7979d608f44d7e3e5505567d0e32b722a56d87a59ea0a5699f - signature: Hoo1BBhAN24s8YpEPsWEyMPv6eL6apeBiX8VCALst5b/77ZpOCCcsSSGFUrNQMWD/L7pxDphTzKhGPP8W1t7DQ== + checksum: sha256:72c3de7e4584f99942e74806aed866eaa8a6afe4c715abf4af0bc98ae20eed5a + signature: QYLY60r1M1hg7LuK//giQrurI3nlTCEgqsHdNyIdDOFCFARIC8Fu5lV83aidy5fP4+gs2e4gVWhuiaCUn3EzBg== diff --git a/src/specfact_cli/modules/module_registry/module-package.yaml b/src/specfact_cli/modules/module_registry/module-package.yaml index b0ef0c30..3ead78d4 100644 --- a/src/specfact_cli/modules/module_registry/module-package.yaml +++ b/src/specfact_cli/modules/module_registry/module-package.yaml @@ -1,7 +1,9 @@ name: module-registry -version: 0.1.5 +version: 0.1.6 commands: - module +category: core +bundle_sub_command: module command_help: module: Manage marketplace modules (install, uninstall, search, list, show, upgrade) pip_dependencies: [] @@ -11,9 +13,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: 'Manage modules: search, list, show, install, and upgrade.' license: Apache-2.0 integrity: - checksum: sha256:4837d40c55ebde6eba87b434c3ec3ae3d0d842eb6a6984d4212ffbc6fd26eac2 - signature: m2tJyNfaHOnil3dsT5NxUB93+4nnVJHBaF7QzQf/DC8F/LG7oJJMWHU063HY9x2/d9hFVXLwItf9TNgNjnirDQ== + checksum: sha256:e195013a5624d8c06079133b040841a4851016cbde48039ac1e399477762e4dc + signature: UBkZjFECBomxFC9FleLacUZPSJkadwDXni2D6amPMiNULk0KzdQjYi1FOafLoInML+F/nuY/9KbGBVp940tcCA== diff --git a/src/specfact_cli/modules/patch_mode/module-package.yaml b/src/specfact_cli/modules/patch_mode/module-package.yaml index 83a532f4..39191d9e 100644 --- a/src/specfact_cli/modules/patch_mode/module-package.yaml +++ b/src/specfact_cli/modules/patch_mode/module-package.yaml @@ -1,7 +1,11 @@ name: patch-mode -version: 0.1.0 +version: 0.1.1 commands: - patch +category: govern +bundle: specfact-govern +bundle_group_command: govern +bundle_sub_command: patch command_help: patch: Preview and apply patches (backlog body, OpenSpec, config); --apply local, --write upstream with confirmation. @@ -12,9 +16,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Prepare, review, and apply structured repository patches safely. license: Apache-2.0 integrity: - checksum: sha256:9f6ceb4ea1a9539cd900d63065bcd36b8681d56d58dfca6835687ba5c58d5272 - signature: 2f8u+wSUKnC5KTIvHt/Qcor0r1J7Pv3FDhdts2OsIEHPCeXtIwoN2XU3CyRlpr+Zyg3+T++OO4Rv7akiWPK1Bw== + checksum: sha256:874ad2c164a73e030fb58764a3b969fea254a3f362b8f8e213aab365ddc00cc3 + signature: 9jrzryT8FGO61RnF1Z5IQVWoY0gR9MXnHXeod/xqblyiYd6osqOIivBbv642xvb6F1oLuG8VOxVNCwYYlAqbDw== diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index 250f870f..52c74580 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -1,7 +1,11 @@ name: plan -version: 0.1.0 +version: 0.1.1 commands: - plan +category: project +bundle: specfact-project +bundle_group_command: project +bundle_sub_command: plan command_help: plan: Manage development plans pip_dependencies: [] @@ -12,9 +16,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Create and manage implementation plans for project execution. license: Apache-2.0 integrity: - checksum: sha256:f7d992a44b0bcee0a6cc3cb4857dbe9c57a3bbe314398893f1337c8c5e4070b6 - signature: MI5BELFxfgZNusPlP6lLKSEdRZR5MdjyOE+IsVutMJHWESmCoO9SmlzycZbHYKBdz9v2BI04kcXsy/AI4+fjDQ== + checksum: sha256:07b2007ef96eab67c49d6a94032011b464d25ac9e5f851dedebdc00523d1749c + signature: LAT1OpTH0p+/0KGx6hvv5CCQGAeLHjgj5VagXXOtJ7nHkqMoAvqGKJygkZDu6h7dpAEbHhotcPet0o9CMqgWDg== diff --git a/src/specfact_cli/modules/policy_engine/module-package.yaml b/src/specfact_cli/modules/policy_engine/module-package.yaml index e0548fb1..7c464464 100644 --- a/src/specfact_cli/modules/policy_engine/module-package.yaml +++ b/src/specfact_cli/modules/policy_engine/module-package.yaml @@ -1,7 +1,11 @@ name: policy-engine -version: 0.1.0 +version: 0.1.1 commands: - policy +category: backlog +bundle: specfact-backlog +bundle_group_command: backlog +bundle_sub_command: policy command_help: policy: Policy validation and suggestion workflows (DoR/DoD/Flow/PI) pip_dependencies: [] @@ -17,10 +21,10 @@ schema_extensions: publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com integrity: - checksum: sha256:45d56fe74e32db9713d42ea622143da1c9b4403c7b22d148ada1fda0060226cf - signature: q0kWPGTaqZTTnFglm8OuHqJyngGLtXnAYeKJp69R/gzzX6QIVZ11bo6mtByG4NKX9KmjXKxOI3JVXWCu3sDOAw== + checksum: sha256:9220ad2598f2214092377baab52f8c91cdad1e642e60d6668ac6ba533cbb5153 + signature: tjShituw5CDCYu+s2qbRYFheH9X7tjtFDIG/+ba1gPhP2vXvjDNhNyqYXa4A9wTLbbGpXUMoZ5Iu/fkhn6rVCw== dependencies: [] description: Run policy evaluations with recommendation and compliance outputs. license: Apache-2.0 diff --git a/src/specfact_cli/modules/project/module-package.yaml b/src/specfact_cli/modules/project/module-package.yaml index 04b07d24..489a86d8 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -1,7 +1,11 @@ name: project -version: 0.1.0 +version: 0.1.1 commands: - project +category: project +bundle: specfact-project +bundle_group_command: project +bundle_sub_command: project command_help: project: Manage project bundles with persona workflows pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Manage project bundles, contexts, and lifecycle workflows. license: Apache-2.0 integrity: - checksum: sha256:bb6a1c0004d242fa6829975d307470f6f9b895690d4052ae6a9d7a64ec9c7a25 - signature: HIX5WUIWEpjcIZ/lYD9bTk0HqmUXaJ68EZmiS3+IIjx3/GiQ0VcW+QtMxOpRZxYA/MnHZvIB5PdQGjOeahA/Bg== + checksum: sha256:78f91db47087a84f229c1c9f414652ff3e740c14ccf5768e3cc65e9e27987742 + signature: 9bbaYWz718cDw4x3P9BkJf3YN1IWQQ4e4UjM/4S+3k9D64js8CbUpDAXgvYfa5a7TsY8jf/yA2U3kxCWZ2/5BQ== diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index 438e0abb..d66fb84b 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -1,7 +1,11 @@ name: repro -version: 0.1.0 +version: 0.1.1 commands: - repro +category: codebase +bundle: specfact-codebase +bundle_group_command: code +bundle_sub_command: repro command_help: repro: Run validation suite pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Run reproducible validation and diagnostics workflows end-to-end. license: Apache-2.0 integrity: - checksum: sha256:b7082bc1c0ed330a20b97ce52baf93f9686854babe28d412063e05821f3fbc62 - signature: pY7zG/pOURam2csn6HH92scnRY553QMhPnNPEkcfiau8L3pfIauaXtdgt8L27Cq3ARteT6hUK8xkUffQujYBDg== + checksum: sha256:24b812744e3839086fa72001b1a6d47298c9a2f853f9027ab30ced1dcbc238b4 + signature: g+1DnnYzrBt+J+J/tt5VY/0z49skGt5AGU70q9qL7l49sNCOpODiR7yP0e+p319C3lyI1us6OgXR029/qpzgCg== diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index 755f7e7d..5510196f 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -1,7 +1,11 @@ name: sdd -version: 0.1.0 +version: 0.1.1 commands: - sdd +category: spec +bundle: specfact-spec +bundle_group_command: spec +bundle_sub_command: sdd command_help: sdd: Manage SDD (Spec-Driven Development) manifests pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Create and validate Spec-Driven Development manifests and mappings. license: Apache-2.0 integrity: - checksum: sha256:1d5e11925f8e578dc3186baad6cf6e6beed9af2824d21967ae56440d65f36222 - signature: rJtZAsUXpIeBkLx8Oe0LgMPKs4pSof52r0FhHAOCDaR/Y2559XAKOUcNYdBkyO1BwosCbZqvmEEf1gZzZKwLAQ== + checksum: sha256:12924835b01bab7f3c5d4edd57577b91437520040fa5fa9cd8f928bd2c46dfc7 + signature: jbaTUCE4DNwJBipXLLgybpP6MzyeLrkJPqdPu3K7sd7GgJYpHKxh722356GneZ7PgiMTfPiHogzh8915jKLGBg== diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index c4adc760..278e934f 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -1,7 +1,11 @@ name: spec -version: 0.1.0 +version: 0.1.1 commands: - spec +category: spec +bundle: specfact-spec +bundle_group_command: spec +bundle_sub_command: api command_help: spec: Specmatic integration for API contract testing pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Integrate and run API specification and contract checks. license: Apache-2.0 integrity: - checksum: sha256:f14970c58bed5647cfcdc76933f4c7af22c186ef89f74d63bb97df3a5e4a09c4 - signature: /V0wm4tU6gKoXZ29AX9FiICdF0loq/N9OVvQyQ5ICtVarJFBQLUPeYZJup5/PJgIrhjZxDr6Ih+wLG6gZBtLAg== + checksum: sha256:9a9a1c5ba8bd8c8e9c6f4f7de2763b6afc908345488c1c97c67f4947bff7b904 + signature: mSzS1UmMwQKaf3Xv8hPlEA51+d65BppvKO+TJ7KH9UvPyftyKluNpspRXHk8Lz6sWBNHGRWEAbrHxewt5mT+DA== diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index 5675c012..dce9409f 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -1,7 +1,11 @@ name: sync -version: 0.1.0 +version: 0.1.1 commands: - sync +category: project +bundle: specfact-project +bundle_group_command: project +bundle_sub_command: sync command_help: sync: Synchronize external tool artifacts and repository changes (Spec-Kit, OpenSpec, GitHub, ADO, Linear, Jira, etc.) @@ -14,9 +18,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Synchronize repository state with connected external systems. license: Apache-2.0 integrity: - checksum: sha256:05023a72241101dd19a9c402fcb4882e40a925d0958b95b4b13217032ad8e31b - signature: rovvEszsr1+/kq2yy9R1g01fjhlG38R2eIwg/aXZy789SKq9ttBkBqJ6d1U+ysXYzOUBqgc6WwcfCI1X2Il7Dg== + checksum: sha256:c690b401e5469f8bac7bf36d278014e6dd1132453424bd9728769579a31a474b + signature: QtPgmc9urSzIgqLKqXVLRUpTu32UZ0Lns57ynHLnnZHoOI/46AcIFJ8GrHjVSgMAlCjmxTqjihe6FbuxmpmyBw== diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index d0708644..7c8a8a99 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -1,7 +1,9 @@ name: upgrade -version: 0.1.0 +version: 0.1.1 commands: - upgrade +category: core +bundle_sub_command: upgrade command_help: upgrade: Check for and install SpecFact CLI updates pip_dependencies: [] @@ -11,9 +13,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Check and apply SpecFact CLI version upgrades. license: Apache-2.0 integrity: - checksum: sha256:441c8d1d5bb5b57b809150e58911966cd1b2aec20ff88dba9985114a65a3aead - signature: mr1FGw1rrBbFEH812TGAxoykpSfP+VzyEMwW5Q5UGNzJgqXwXQxa5bOsVYHwTfToIttGGoFv1jDjJ4NE6b+EBg== + checksum: sha256:2ff659d146ad1ec80c56e40d79f5dbcc2c90cb5eb5ed3498f6f7690ec1171676 + signature: I/BlgrSwWzXUt+Ib7snF/ukmRjXuu6w3bDBVOadWEtcwWzmP8WiaIkK4WYNxMVIKuXNV7TYDhJo1KCuLxZNRBA== diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index 3a7fb67b..2eb9d8c6 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -1,7 +1,11 @@ name: validate -version: 0.1.0 +version: 0.1.1 commands: - validate +category: codebase +bundle: specfact-codebase +bundle_group_command: code +bundle_sub_command: validate command_help: validate: Validation commands including sidecar validation pip_dependencies: [] @@ -11,9 +15,9 @@ core_compatibility: '>=0.28.0,<1.0.0' publisher: name: nold-ai url: https://github.com/nold-ai/specfact-cli-modules - email: oss@nold.ai + email: hello@noldai.com description: Run schema, contract, and workflow validation suites. license: Apache-2.0 integrity: - checksum: sha256:01252349bfc86e36138b2acb4e82e60bcaaa84b0f60dc1bfcf4ca554a02bad67 - signature: rU1JJUw057QUVp6YaEEM0vcx+/hrciNsh2A3SlD4xhZwbPyzf9O+RvaAh99q/iAns9EzmsqMDW9IYafLXmEYDQ== + checksum: sha256:9b8ade0253df16ed367e0992b483738d3b4379e92d05ba97d9f5dd6f7fc51715 + signature: 3TD8nGRVXLDA7VgExKP/tK7H/gGCb7P7LuU1fQzwzsiuZAsEebIL2bSuZ54bD3vKwIvcMooVzyL/8a9w4cu+Cg== diff --git a/src/specfact_cli/registry/bootstrap.py b/src/specfact_cli/registry/bootstrap.py index a08d72a1..4450083d 100644 --- a/src/specfact_cli/registry/bootstrap.py +++ b/src/specfact_cli/registry/bootstrap.py @@ -4,13 +4,48 @@ Commands are discovered from configured module-package roots. Loaders import each package's src on first use and return its .app (Typer). cli.py must not import command modules at top level; it uses the registry. +When category_grouping_enabled is True, mounts category groups (code, backlog, project, spec, govern) +and compat shims for flat commands; otherwise mounts all modules flat. """ from __future__ import annotations +from pathlib import Path + +import yaml +from beartype import beartype + from specfact_cli.registry.module_packages import register_module_package_commands +_SPECFACT_CONFIG_PATH = Path.home() / ".specfact" / "config.yaml" + + +@beartype +def _get_category_grouping_enabled() -> bool: + """Read category_grouping_enabled from env then config file; default True.""" + env_val = __import__("os").environ.get("SPECFACT_CATEGORY_GROUPING_ENABLED", "").strip().lower() + if env_val in ("1", "true", "yes"): + return True + if env_val in ("0", "false", "no"): + return False + if not _SPECFACT_CONFIG_PATH.exists(): + return True + try: + raw = yaml.safe_load(_SPECFACT_CONFIG_PATH.read_text(encoding="utf-8")) + if isinstance(raw, dict) and "category_grouping_enabled" in raw: + val = raw["category_grouping_enabled"] + if isinstance(val, bool): + return val + if isinstance(val, str): + return val.strip().lower() in ("1", "true", "yes") + except Exception: + pass + return True + + +@beartype def register_builtin_commands() -> None: """Register all command groups from discovered module packages with CommandRegistry.""" - register_module_package_commands() + category_grouping_enabled = _get_category_grouping_enabled() + register_module_package_commands(category_grouping_enabled=category_grouping_enabled) diff --git a/src/specfact_cli/registry/module_grouping.py b/src/specfact_cli/registry/module_grouping.py new file mode 100644 index 00000000..deb4a9f0 --- /dev/null +++ b/src/specfact_cli/registry/module_grouping.py @@ -0,0 +1,60 @@ +"""Module category grouping constants and validation (module-grouping capability).""" + +from __future__ import annotations + +from beartype import beartype +from icontract import require + +from specfact_cli.models.module_package import ModulePackageMetadata + + +VALID_CATEGORIES = frozenset({"core", "project", "backlog", "codebase", "spec", "govern"}) +CATEGORY_TO_GROUP_COMMAND: dict[str, str] = { + "project": "project", + "backlog": "backlog", + "codebase": "code", + "spec": "spec", + "govern": "govern", +} + + +class ModuleManifestError(Exception): + """Raised when module-package.yaml category/bundle metadata is invalid.""" + + +@require(lambda manifests: isinstance(manifests, list), "manifests must be a list") +@beartype +def group_modules_by_category( + manifests: list[ModulePackageMetadata], +) -> dict[str, list[ModulePackageMetadata]]: + """Group module manifests by bundle_group_command; core and missing category are ungrouped.""" + result: dict[str, list[ModulePackageMetadata]] = {} + for meta in manifests: + if meta.category == "core" or meta.bundle_group_command is None: + continue + cmd = meta.bundle_group_command + result.setdefault(cmd, []).append(meta) + return result + + +@beartype +def validate_module_category_manifest(meta: ModulePackageMetadata) -> None: + """Validate category and bundle_group_command; raise ModuleManifestError if invalid.""" + if meta.category is None: + return + if meta.category not in VALID_CATEGORIES: + raise ModuleManifestError( + f"Module '{meta.name}': category must be one of {sorted(VALID_CATEGORIES)}, got {meta.category!r}" + ) + if meta.category == "core": + if meta.bundle is not None or meta.bundle_group_command is not None: + raise ModuleManifestError( + f"Module '{meta.name}': core category must not set bundle or bundle_group_command" + ) + return + expected = CATEGORY_TO_GROUP_COMMAND.get(meta.category) + if expected is not None and meta.bundle_group_command != expected: + raise ModuleManifestError( + f"Module '{meta.name}': bundle_group_command for category {meta.category!r} must be {expected!r}, " + f"got {meta.bundle_group_command!r}" + ) diff --git a/src/specfact_cli/registry/module_installer.py b/src/specfact_cli/registry/module_installer.py index 2fe74115..326e1e73 100644 --- a/src/specfact_cli/registry/module_installer.py +++ b/src/specfact_cli/registry/module_installer.py @@ -6,6 +6,7 @@ import os import re import shutil +import subprocess import sys import tarfile import tempfile @@ -196,6 +197,70 @@ def _is_hashable(path: Path) -> bool: return "\n".join(entries).encode("utf-8") +def _module_artifact_payload_signed(package_dir: Path) -> bytes: + """Build payload identical to scripts/sign-modules.py so verification matches after signing. + + Uses git ls-files when the module lives in a git repo (same file set and order as sign script); + otherwise falls back to rglob + same hashable/sort rules so checksums match for non-git use. + """ + if not package_dir.exists() or not package_dir.is_dir(): + raise ValueError(f"Module directory not found: {package_dir}") + module_dir_resolved = package_dir.resolve() + + def _is_hashable(path: Path) -> bool: + rel = path.resolve().relative_to(module_dir_resolved) + if any(part in _IGNORED_MODULE_DIR_NAMES for part in rel.parts): + return False + return path.suffix.lower() not in _IGNORED_MODULE_FILE_SUFFIXES + + files: list[Path] + try: + result = subprocess.run( + ["git", "rev-parse", "--show-toplevel"], + cwd=package_dir, + capture_output=True, + text=True, + check=False, + timeout=2, + ) + if result.returncode != 0 or not result.stdout.strip(): + raise FileNotFoundError("not in git repo") + git_root = Path(result.stdout.strip()).resolve() + rel_to_repo = module_dir_resolved.relative_to(git_root) + ls_result = subprocess.run( + ["git", "ls-files", "--", rel_to_repo.as_posix()], + cwd=git_root, + capture_output=True, + text=True, + check=False, + timeout=2, + ) + if ls_result.returncode != 0: + raise FileNotFoundError("git ls-files failed") + lines = [line.strip() for line in ls_result.stdout.splitlines() if line.strip()] + files = [git_root / line for line in lines] + files = [p for p in files if p.is_file() and _is_hashable(p)] + files.sort(key=lambda p: p.resolve().relative_to(module_dir_resolved).as_posix()) + except (FileNotFoundError, ValueError, subprocess.TimeoutExpired): + files = sorted( + (p for p in package_dir.rglob("*") if p.is_file() and _is_hashable(p)), + key=lambda p: p.resolve().relative_to(module_dir_resolved).as_posix(), + ) + + entries: list[str] = [] + for path in files: + rel = path.resolve().relative_to(module_dir_resolved).as_posix() + if rel in {"module-package.yaml", "metadata.yaml"}: + raw = yaml.safe_load(path.read_text(encoding="utf-8")) + if not isinstance(raw, dict): + raise ValueError(f"Invalid manifest YAML: {path}") + data = _canonical_manifest_payload(path) + else: + data = path.read_bytes() + entries.append(f"{rel}:{hashlib.sha256(data).hexdigest()}") + return "\n".join(entries).encode("utf-8") + + @beartype def _is_signature_backend_unavailable(error: ValueError) -> bool: """Return True when signature verification backend is unavailable in runtime.""" @@ -371,26 +436,32 @@ def verify_module_artifact( return False return True + verification_payload: bytes try: - legacy_payload = _module_artifact_payload(package_dir) - verify_checksum(legacy_payload, meta.integrity.checksum) - verification_payload = legacy_payload - except ValueError as exc: + signed_payload = _module_artifact_payload_signed(package_dir) + verify_checksum(signed_payload, meta.integrity.checksum) + verification_payload = signed_payload + except ValueError: try: - stable_payload = _module_artifact_payload_stable(package_dir) - verify_checksum(stable_payload, meta.integrity.checksum) - if _integrity_debug_details_enabled(): - logger.debug( - "Module %s: checksum matched with generated-file exclusions (cache/transient files ignored)", - meta.name, - ) - verification_payload = stable_payload - except ValueError: - if _integrity_debug_details_enabled(): - logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) - else: - logger.debug("Module %s: Integrity check failed: %s", meta.name, exc) - return False + legacy_payload = _module_artifact_payload(package_dir) + verify_checksum(legacy_payload, meta.integrity.checksum) + verification_payload = legacy_payload + except ValueError as exc: + try: + stable_payload = _module_artifact_payload_stable(package_dir) + verify_checksum(stable_payload, meta.integrity.checksum) + if _integrity_debug_details_enabled(): + logger.debug( + "Module %s: checksum matched with generated-file exclusions (cache/transient files ignored)", + meta.name, + ) + verification_payload = stable_payload + except ValueError: + if _integrity_debug_details_enabled(): + logger.warning("Module %s: Integrity check failed: %s", meta.name, exc) + else: + logger.debug("Module %s: Integrity check failed: %s", meta.name, exc) + return False if meta.integrity.signature: key_material = _load_public_key_pem(public_key_pem) diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index c096227d..ab529878 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -36,6 +36,10 @@ from specfact_cli.registry.bridge_registry import BridgeRegistry, SchemaConverter from specfact_cli.registry.extension_registry import get_extension_registry from specfact_cli.registry.metadata import CommandMetadata +from specfact_cli.registry.module_grouping import ( + ModuleManifestError, + validate_module_category_manifest, +) from specfact_cli.registry.module_installer import verify_module_artifact from specfact_cli.registry.module_state import find_dependents, read_modules_state from specfact_cli.registry.registry import CommandRegistry @@ -44,6 +48,7 @@ # Display order for core modules (formerly built-in); others follow alphabetically. +CORE_NAMES = ("init", "auth", "module", "upgrade") CORE_MODULE_ORDER: tuple[str, ...] = ( "init", "auth", @@ -260,8 +265,22 @@ def discover_package_metadata(modules_root: Path, source: str = "builtin") -> li description=str(raw["description"]) if raw.get("description") else None, license=str(raw["license"]) if raw.get("license") else None, source=source, + category=str(raw["category"]) if raw.get("category") else None, + bundle=str(raw["bundle"]) if raw.get("bundle") else None, + bundle_group_command=str(raw["bundle_group_command"]) if raw.get("bundle_group_command") else None, + bundle_sub_command=str(raw["bundle_sub_command"]) if raw.get("bundle_sub_command") else None, ) + if meta.category is None: + logger = get_bridge_logger(__name__) + logger.warning( + "Module '%s' has no category field; mounting as flat top-level command.", + meta.name, + ) + else: + validate_module_category_manifest(meta) result.append((child, meta)) + except ModuleManifestError: + raise except Exception: continue return result @@ -756,10 +775,6 @@ def merge_module_state( enable_ids: list[str], disable_ids: list[str], ) -> dict[str, bool]: - """ - Merge discovered (id, version) with state; apply enable/disable overrides. - Returns dict module_id -> enabled (bool). - """ merged: dict[str, bool] = {} for mid, _version in discovered: if mid in state: @@ -773,16 +788,105 @@ def merge_module_state( return merged +# Flat command name -> (group_command, sub_command) for compat shims when category grouping is enabled. +FLAT_TO_GROUP: dict[str, tuple[str, str]] = { + "analyze": ("code", "analyze"), + "drift": ("code", "drift"), + "validate": ("code", "validate"), + "repro": ("code", "repro"), + "backlog": ("backlog", "backlog"), + "policy": ("backlog", "policy"), + "project": ("project", "project"), + "plan": ("project", "plan"), + "import": ("project", "import"), + "sync": ("project", "sync"), + "migrate": ("project", "migrate"), + "contract": ("spec", "contract"), + "spec": ("spec", "api"), + "sdd": ("spec", "sdd"), + "generate": ("spec", "generate"), + "enforce": ("govern", "enforce"), + "patch": ("govern", "patch"), +} + + +def _make_shim_loader( + flat_name: str, + group_name: str, + sub_name: str, + help_str: str, +) -> Any: + """Return a loader that returns the real module Typer so flat invocations like + 'specfact sync bridge' work (subcommands come from the real module). + """ + + def loader() -> Any: + return CommandRegistry.get_module_typer(flat_name) + + return loader + + +def _register_category_groups_and_shims() -> None: + """Register category group typers and compat shims in CommandRegistry._entries.""" + from specfact_cli.groups.backlog_group import build_app as build_backlog_app + from specfact_cli.groups.codebase_group import build_app as build_codebase_app + from specfact_cli.groups.govern_group import build_app as build_govern_app + from specfact_cli.groups.project_group import build_app as build_project_app + from specfact_cli.groups.spec_group import build_app as build_spec_app + + group_apps = [ + ("code", "Codebase quality commands: analyze, drift, validate, repro.", build_codebase_app), + ("backlog", "Backlog and policy commands.", build_backlog_app), + ("project", "Project lifecycle commands.", build_project_app), + ("spec", "Spec and contract commands: contract, api, sdd, generate.", build_spec_app), + ("govern", "Governance and quality gates: enforce, patch.", build_govern_app), + ] + for group_name, help_str, build_fn in group_apps: + + def _make_group_loader(fn: Any) -> Any: + def _group_loader(_fn: Any = fn) -> Any: + return _fn() + + return _group_loader + + loader = _make_group_loader(build_fn) + cmd_meta = CommandMetadata( + name=group_name, + help=help_str, + tier="community", + addon_id=None, + ) + CommandRegistry.register(group_name, loader, cmd_meta) + + for flat_name, (group_name, sub_name) in FLAT_TO_GROUP.items(): + if flat_name == group_name: + continue + meta = CommandRegistry.get_module_metadata(flat_name) + if meta is None: + continue + help_str = meta.help + shim_loader = _make_shim_loader(flat_name, group_name, sub_name, help_str) + cmd_meta = CommandMetadata( + name=flat_name, + help=help_str + " (deprecated; use specfact " + group_name + " " + sub_name + ")", + tier=meta.tier, + addon_id=meta.addon_id, + ) + CommandRegistry.register(flat_name, shim_loader, cmd_meta) + + def register_module_package_commands( enable_ids: list[str] | None = None, disable_ids: list[str] | None = None, allow_unsigned: bool | None = None, + category_grouping_enabled: bool = True, ) -> None: """ Discover module packages, merge with modules.json state, register only enabled packages' commands. Call after register_builtin_commands(). enable_ids/disable_ids from CLI (--enable-module/--disable-module). allow_unsigned: If True, allow modules without integrity metadata. Default from SPECFACT_ALLOW_UNSIGNED env. + category_grouping_enabled: If True, register category groups (code, backlog, project, spec, govern) and compat shims. """ enable_ids = enable_ids or [] disable_ids = disable_ids or [] @@ -907,6 +1011,58 @@ def register_module_package_commands( protocol_legacy += 1 for cmd_name in meta.commands: + if category_grouping_enabled and meta.category is not None: + help_str = (meta.command_help or {}).get(cmd_name) or f"Module package: {meta.name}" + extension_loader = _make_package_loader(package_dir, meta.name, cmd_name) + cmd_meta = CommandMetadata(name=cmd_name, help=help_str, tier=meta.tier, addon_id=meta.addon_id) + existing_module_entry = next( + (entry for entry in CommandRegistry._module_entries if entry.get("name") == cmd_name), + None, + ) + if existing_module_entry is not None: + base_loader = existing_module_entry.get("loader") + if base_loader is None: + logger.warning( + "Module %s attempted to extend command '%s' but module base loader was missing; skipping.", + meta.name, + cmd_name, + ) + else: + existing_module_entry["loader"] = _make_extending_loader( + base_loader, + extension_loader, + meta.name, + cmd_name, + ) + existing_module_entry["metadata"] = cmd_meta + CommandRegistry._module_typer_cache.pop(cmd_name, None) + else: + CommandRegistry.register_module(cmd_name, extension_loader, cmd_meta) + if cmd_name in CORE_NAMES: + existing_root_entry = next( + (entry for entry in CommandRegistry._entries if entry.get("name") == cmd_name), + None, + ) + if existing_root_entry is not None: + base_loader = existing_root_entry.get("loader") + if base_loader is None: + logger.warning( + "Module %s attempted to extend core command '%s' but base loader was missing; skipping.", + meta.name, + cmd_name, + ) + else: + existing_root_entry["loader"] = _make_extending_loader( + base_loader, + extension_loader, + meta.name, + cmd_name, + ) + existing_root_entry["metadata"] = cmd_meta + CommandRegistry._typer_cache.pop(cmd_name, None) + else: + CommandRegistry.register(cmd_name, extension_loader, cmd_meta) + continue existing_entry = next((entry for entry in CommandRegistry._entries if entry.get("name") == cmd_name), None) if existing_entry is not None: extension_loader = _make_package_loader(package_dir, meta.name, cmd_name) @@ -932,6 +1088,8 @@ def register_module_package_commands( loader = _make_package_loader(package_dir, meta.name, cmd_name) cmd_meta = CommandMetadata(name=cmd_name, help=help_str, tier=meta.tier, addon_id=meta.addon_id) CommandRegistry.register(cmd_name, loader, cmd_meta) + if category_grouping_enabled: + _register_category_groups_and_shims() discovered_count = protocol_full + protocol_partial + protocol_legacy if discovered_count and (protocol_partial > 0 or protocol_legacy > 0): print_warning( diff --git a/src/specfact_cli/registry/registry.py b/src/specfact_cli/registry/registry.py index 39efe29e..33ea7dc1 100644 --- a/src/specfact_cli/registry/registry.py +++ b/src/specfact_cli/registry/registry.py @@ -34,10 +34,14 @@ class CommandRegistry: Registry for CLI command groups (lazy load). Register by name with a loader and metadata; get_typer(name) invokes loader on first use. + When category grouping is enabled, _module_entries holds the 21 module loaders for group + sub-command resolution; _entries holds root-level commands (core + groups + shims). """ _entries: list[_Entry] = [] _typer_cache: dict[str, Any] = {} + _module_entries: list[_Entry] = [] + _module_typer_cache: dict[str, Any] = {} @classmethod def _ensure_bootstrapped(cls) -> None: @@ -64,6 +68,49 @@ def register(cls, name: str, loader: Loader, metadata: CommandMetadata) -> None: return cls._entries.append({"name": name, "loader": loader, "metadata": metadata}) + @classmethod + @beartype + @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string") + @require(lambda metadata: isinstance(metadata, CommandMetadata), "Metadata must be CommandMetadata") + @ensure(lambda result: result is None, "Must return None") + def register_module(cls, name: str, loader: Loader, metadata: CommandMetadata) -> None: + """Register a module command (for group sub-command resolution). Does not invoke loader.""" + for e in cls._module_entries: + if e.get("name") == name: + e["loader"] = loader + e["metadata"] = metadata + cls._module_typer_cache.pop(name, None) + return + cls._module_entries.append({"name": name, "loader": loader, "metadata": metadata}) + + @classmethod + @beartype + @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string") + def get_module_typer(cls, name: str) -> Any: + """Return Typer app for module name (from _module_entries); invoke loader on first use and cache.""" + cls._ensure_bootstrapped() + if name in cls._module_typer_cache: + return cls._module_typer_cache[name] + for e in cls._module_entries: + if e.get("name") == name: + loader = e.get("loader") + if loader is None: + raise ValueError(f"Module command '{name}' has no loader") + app = loader() + cls._module_typer_cache[name] = app + return app + registered = ", ".join(e.get("name", "") for e in cls._module_entries) + raise ValueError(f"Module command '{name}' not found. Registered modules: {registered or '(none)'}") + + @classmethod + def get_module_metadata(cls, name: str) -> CommandMetadata | None: + """Return metadata for module name without invoking loader.""" + cls._ensure_bootstrapped() + for e in cls._module_entries: + if e.get("name") == name: + return e.get("metadata") + return None + @classmethod @beartype @require(lambda name: isinstance(name, str) and len(name) > 0, "Name must be non-empty string") @@ -113,3 +160,5 @@ def _clear_for_testing(cls) -> None: """Reset registry state (for tests only).""" cls._entries = [] cls._typer_cache = {} + cls._module_entries = [] + cls._module_typer_cache = {} diff --git a/tests/e2e/test_first_run_init.py b/tests/e2e/test_first_run_init.py new file mode 100644 index 00000000..2c9583ce --- /dev/null +++ b/tests/e2e/test_first_run_init.py @@ -0,0 +1,57 @@ +"""E2E tests for first-run init and category group availability.""" + +from __future__ import annotations + +import os +from pathlib import Path +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specfact_cli.cli import app + + +@pytest.fixture(autouse=True) +def _category_grouping_enabled() -> None: + """Ensure category grouping is enabled for E2E (default; set explicitly for isolation).""" + os.environ.setdefault("SPECFACT_CATEGORY_GROUPING_ENABLED", "true") + + +runner = CliRunner() + + +def test_init_profile_solo_developer_completes_in_temp_workspace(tmp_path: Path) -> None: + """specfact init --profile solo-developer in a temp workspace completes without error.""" + with patch( + "specfact_cli.modules.init.src.commands.install_bundles_for_init", + return_value=None, + ): + result = runner.invoke( + app, + ["init", "--repo", str(tmp_path), "--profile", "solo-developer"], + catch_exceptions=False, + ) + assert result.exit_code == 0, ( + f"Expected exit 0, got {result.exit_code}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + + +def test_after_solo_developer_init_code_analyze_help_available(tmp_path: Path) -> None: + """After init --profile solo-developer, specfact code analyze --help is available.""" + with patch( + "specfact_cli.modules.init.src.commands.install_bundles_for_init", + return_value=None, + ): + init_result = runner.invoke( + app, + ["init", "--repo", str(tmp_path), "--profile", "solo-developer"], + catch_exceptions=False, + ) + assert init_result.exit_code == 0 + + result = runner.invoke(app, ["code", "analyze", "--help"]) + assert result.exit_code == 0, ( + f"Expected exit 0, got {result.exit_code}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "analyze" in (result.stdout or "").lower() or "usage" in (result.stdout or "").lower() diff --git a/tests/integration/backlog/test_custom_field_mapping.py b/tests/integration/backlog/test_custom_field_mapping.py index 6aa9bad1..b1c94ced 100644 --- a/tests/integration/backlog/test_custom_field_mapping.py +++ b/tests/integration/backlog/test_custom_field_mapping.py @@ -92,7 +92,8 @@ def test_custom_field_mapping_file_validation_file_not_found(self) -> None: ) # Should exit with error code (validation happens before adapter setup) assert result.exit_code != 0 - assert "not found" in result.stdout.lower() or "error" in result.stdout.lower() or "Error" in result.stdout + out = result.output or result.stdout or "" + assert "not found" in out.lower() or "error" in out.lower() or "Error" in out def test_custom_field_mapping_file_validation_invalid_format(self, invalid_mapping_file: Path) -> None: """Test that invalid custom field mapping file format is rejected.""" @@ -112,7 +113,8 @@ def test_custom_field_mapping_file_validation_invalid_format(self, invalid_mappi ], ) assert result.exit_code != 0 - assert "invalid" in result.stdout.lower() or "error" in result.stdout.lower() + out = result.output or result.stdout or "" + assert "invalid" in out.lower() or "error" in out.lower() def test_custom_field_mapping_environment_variable( self, custom_mapping_file: Path, monkeypatch: pytest.MonkeyPatch diff --git a/tests/integration/commands/test_spec_commands.py b/tests/integration/commands/test_spec_commands.py index 6b119d5f..652ccd4b 100644 --- a/tests/integration/commands/test_spec_commands.py +++ b/tests/integration/commands/test_spec_commands.py @@ -43,7 +43,7 @@ async def mock_validate_coro(*args, **kwargs): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["spec", "validate", str(spec_path)]) + result = runner.invoke(app, ["spec", "api", "validate", str(spec_path)]) finally: os.chdir(old_cwd) @@ -62,7 +62,7 @@ def test_validate_command_specmatic_not_available(self, mock_check, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["spec", "validate", str(spec_path)]) + result = runner.invoke(app, ["spec", "api", "validate", str(spec_path)]) finally: os.chdir(old_cwd) @@ -94,7 +94,7 @@ async def mock_validate_async(*args, **kwargs): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["spec", "validate", str(spec_path)]) + result = runner.invoke(app, ["spec", "api", "validate", str(spec_path)]) finally: os.chdir(old_cwd) @@ -126,7 +126,7 @@ async def mock_compat_async(*args, **kwargs): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["spec", "backward-compat", str(old_spec), str(new_spec)]) + result = runner.invoke(app, ["spec", "api", "backward-compat", str(old_spec), str(new_spec)]) finally: os.chdir(old_cwd) @@ -154,7 +154,7 @@ async def mock_compat_async(*args, **kwargs): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["spec", "backward-compat", str(old_spec), str(new_spec)]) + result = runner.invoke(app, ["spec", "api", "backward-compat", str(old_spec), str(new_spec)]) finally: os.chdir(old_cwd) @@ -189,7 +189,7 @@ async def mock_generate_async(*args, **kwargs): output_dir.mkdir(parents=True, exist_ok=True) result = runner.invoke( app, - ["spec", "generate-tests", str(spec_path), "--output", str(output_dir)], + ["spec", "api", "generate-tests", str(spec_path), "--output", str(output_dir)], ) finally: os.chdir(old_cwd) @@ -225,7 +225,7 @@ def test_mock_command_success(self, mock_create, mock_check, tmp_path): # Use timeout to prevent hanging result = runner.invoke( app, - ["spec", "mock", "--spec", str(spec_path), "--port", "9000"], + ["spec", "api", "mock", "--spec", str(spec_path), "--port", "9000"], input="\n", # Send Enter to exit ) finally: diff --git a/tests/integration/test_category_group_routing.py b/tests/integration/test_category_group_routing.py new file mode 100644 index 00000000..28a4a497 --- /dev/null +++ b/tests/integration/test_category_group_routing.py @@ -0,0 +1,58 @@ +"""Integration tests for category group routing (code, backlog, validate shim).""" + +from __future__ import annotations + +import os +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from typer.testing import CliRunner + +from specfact_cli.cli import app +from specfact_cli.registry import CommandRegistry +from specfact_cli.registry.bootstrap import register_builtin_commands + + +@pytest.fixture(autouse=True) +def _category_grouping_enabled() -> Generator[None, None, None]: + """Ensure category grouping is enabled and registry is fresh for routing tests.""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + CommandRegistry._clear_for_testing() + register_builtin_commands() + yield + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + CommandRegistry._clear_for_testing() + register_builtin_commands() + + +runner = CliRunner() + + +def test_code_analyze_help_exits_zero() -> None: + """specfact code analyze --help returns non-error exit (CLI integration).""" + result = runner.invoke(app, ["code", "analyze", "--help"]) + assert result.exit_code == 0, ( + f"Expected exit 0, got {result.exit_code}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "analyze" in (result.stdout or "").lower() or "usage" in (result.stdout or "").lower() + + +def test_backlog_help_lists_subcommands() -> None: + """specfact backlog --help lists backlog and policy sub-commands.""" + result = runner.invoke(app, ["backlog", "--help"]) + assert result.exit_code == 0, ( + f"Expected exit 0, got {result.exit_code}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + out = (result.stdout or "").lower() + assert "backlog" in out + assert "policy" in out or "ceremony" in out + + +def test_validate_shim_help_exits_zero() -> None: + """Deprecated flat command specfact validate --help still returns help without error.""" + result = runner.invoke(app, ["validate", "--help"]) + assert result.exit_code == 0, ( + f"Expected exit 0, got {result.exit_code}\nstdout: {result.stdout}\nstderr: {result.stderr}" + ) + assert "validate" in (result.stdout or "").lower() or "usage" in (result.stdout or "").lower() diff --git a/tests/unit/commands/test_backlog_commands.py b/tests/unit/commands/test_backlog_commands.py index 201f3174..c56378ac 100644 --- a/tests/unit/commands/test_backlog_commands.py +++ b/tests/unit/commands/test_backlog_commands.py @@ -9,6 +9,7 @@ from pathlib import Path from unittest.mock import MagicMock, patch +import pytest import yaml from rich.panel import Panel from typer.testing import CliRunner @@ -38,6 +39,18 @@ runner = CliRunner() +@pytest.fixture(autouse=True) +def _bootstrap_registry_for_backlog_commands(): + """Ensure registry is bootstrapped so root 'backlog' resolves to the group with init-config, map-fields, etc.""" + from specfact_cli.registry.bootstrap import register_builtin_commands + from specfact_cli.registry.registry import CommandRegistry + + CommandRegistry._clear_for_testing() + register_builtin_commands() + yield + CommandRegistry._clear_for_testing() + + @patch("specfact_cli.modules.backlog.src.commands._resolve_standup_options") @patch("specfact_cli.modules.backlog.src.commands._fetch_backlog_items") def test_daily_issue_id_bypasses_implicit_default_state( @@ -376,7 +389,8 @@ def test_map_fields_requires_token(self) -> None: # Should fail with error about missing token assert result.exit_code != 0 - assert "token required" in result.stdout.lower() or "error" in result.stdout.lower() + out = result.output or result.stdout or "" + assert "token required" in out.lower() or "error" in out.lower() @patch("questionary.checkbox") @patch("specfact_cli.utils.auth_tokens.get_token") @@ -833,7 +847,7 @@ def test_backlog_init_config_does_not_overwrite_without_force(self, tmp_path) -> assert result.exit_code == 0 content = cfg_file.read_text(encoding="utf-8") assert "adapter: github" in content - assert "already exists" in result.stdout.lower() + assert "already exists" in (result.output or result.stdout or "").lower() class TestParseRefinedExportMarkdown: diff --git a/tests/unit/commands/test_backlog_daily.py b/tests/unit/commands/test_backlog_daily.py index c38145d4..419d5865 100644 --- a/tests/unit/commands/test_backlog_daily.py +++ b/tests/unit/commands/test_backlog_daily.py @@ -60,6 +60,18 @@ runner = CliRunner() +@pytest.fixture(autouse=True) +def _bootstrap_registry_for_backlog_daily(): + """Ensure registry is bootstrapped so root 'backlog' resolves to the group with 'daily'.""" + from specfact_cli.registry.bootstrap import register_builtin_commands + from specfact_cli.registry.registry import CommandRegistry + + CommandRegistry._clear_for_testing() + register_builtin_commands() + yield + CommandRegistry._clear_for_testing() + + def _strip_ansi(text: str) -> str: """Remove ANSI escape codes from CLI output.""" ansi_escape = re.compile(r"\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])") @@ -67,19 +79,35 @@ def _strip_ansi(text: str) -> str: def _get_daily_command_option_names() -> set[str]: - """Return all option names registered on `specfact backlog daily`.""" + """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) backlog_cmd = root_cmd.get_command(root_ctx, "backlog") - assert backlog_cmd is not None + assert backlog_cmd is not None, "root should have 'backlog' command" backlog_ctx = click.Context(backlog_cmd) daily_cmd = backlog_cmd.get_command(backlog_ctx, "daily") - assert daily_cmd is not None - option_names: set[str] = set() - for param in daily_cmd.params: - if isinstance(param, click.Option): - option_names.update(param.opts) - option_names.update(param.secondary_opts) + if daily_cmd is not None: + option_names: set[str] = set() + for param in daily_cmd.params: + if isinstance(param, click.Option): + option_names.update(param.opts) + option_names.update(param.secondary_opts) + return option_names + result = runner.invoke(app, ["backlog", "daily", "--help"]) + if result.exit_code != 0: + return set() + out = result.output or result.stdout or "" + option_names = set() + for word in out.replace(",", " ").split(): + w = word.strip() + if w.startswith("--") and "=" not in w: + opt = w.lstrip("-").split("=")[0] + option_names.add("--" + opt) + if not option_names: + import re + + for m in re.finditer(r"--([a-z][a-z0-9-]*)", out): + option_names.add("--" + m.group(1)) return option_names diff --git a/tests/unit/groups/test_codebase_group.py b/tests/unit/groups/test_codebase_group.py new file mode 100644 index 00000000..abebd413 --- /dev/null +++ b/tests/unit/groups/test_codebase_group.py @@ -0,0 +1,35 @@ +"""Tests for codebase category group app (category-command-groups).""" + +from __future__ import annotations + +import os +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from specfact_cli.registry import CommandRegistry +from specfact_cli.registry.bootstrap import register_builtin_commands + + +@pytest.fixture(autouse=True) +def _clear_registry() -> Generator[None, None, None]: + CommandRegistry._clear_for_testing() + yield + CommandRegistry._clear_for_testing() + + +def test_codebase_group_has_expected_subcommands() -> None: + """Group app 'code' has expected sub-commands: analyze, drift, validate, repro.""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + register_builtin_commands() + from typer.main import get_command + + from specfact_cli.registry.registry import CommandRegistry + + code_app = CommandRegistry.get_typer("code") + click_code = get_command(code_app) + assert hasattr(click_code, "commands") + code_subcommands = list(click_code.commands.keys()) + for expected in ("analyze", "drift", "validate", "repro"): + assert expected in code_subcommands, f"Expected sub-command {expected!r} in code group: {code_subcommands}" diff --git a/tests/unit/modules/init/test_first_run_selection.py b/tests/unit/modules/init/test_first_run_selection.py new file mode 100644 index 00000000..5326afa7 --- /dev/null +++ b/tests/unit/modules/init/test_first_run_selection.py @@ -0,0 +1,415 @@ +"""Tests for first-run bundle selection in specfact init (Phase 3).""" + +from __future__ import annotations + +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 + + +runner = CliRunner() + + +def _telemetry_track_context(): + return patch( + "specfact_cli.modules.init.src.commands.telemetry", + MagicMock( + track_command=MagicMock(return_value=MagicMock(__enter__=lambda s: None, __exit__=lambda s, *a: None)) + ), + ) + + +# --- Profile resolution --- + + +def test_profile_solo_developer_resolves_to_specfact_codebase_only() -> None: + bundles = frs.resolve_profile_bundles("solo-developer") + assert bundles == ["specfact-codebase"] + + +def test_profile_enterprise_full_stack_resolves_to_all_five_bundles() -> None: + bundles = frs.resolve_profile_bundles("enterprise-full-stack") + assert set(bundles) == { + "specfact-project", + "specfact-backlog", + "specfact-codebase", + "specfact-spec", + "specfact-govern", + } + assert len(bundles) == 5 + + +def test_profile_nonexistent_raises_with_valid_list() -> None: + with pytest.raises(ValueError) as exc_info: + frs.resolve_profile_bundles("nonexistent") + msg = str(exc_info.value).lower() + assert "nonexistent" in msg or "unknown" in msg or "invalid" in msg + assert "solo-developer" in msg or "valid" in msg + + +# --- --install parsing --- + + +def test_install_backlog_codebase_resolves_to_two_bundles() -> None: + bundles = frs.resolve_install_bundles("backlog,codebase") + assert set(bundles) == {"specfact-backlog", "specfact-codebase"} + assert len(bundles) == 2 + + +def test_install_all_resolves_to_all_five_bundles() -> None: + bundles = frs.resolve_install_bundles("all") + assert set(bundles) == { + "specfact-project", + "specfact-backlog", + "specfact-codebase", + "specfact-spec", + "specfact-govern", + } + assert len(bundles) == 5 + + +def test_install_unknown_bundle_raises() -> None: + with pytest.raises(ValueError) as exc_info: + frs.resolve_install_bundles("widgets") + msg = str(exc_info.value).lower() + assert "widgets" in msg or "unknown" in msg + assert "valid" in msg or "bundle" in msg + + +# --- is_first_run --- + + +def test_is_first_run_true_when_no_category_bundle_installed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def _discover(_builtin=None, user_root=None, **_kwargs): + from specfact_cli.models.module_package import ModulePackageMetadata + from specfact_cli.registry.module_discovery import DiscoveredModule + + meta_core = ModulePackageMetadata(name="init", version="0.1.0", commands=["init"], category="core") + return [DiscoveredModule(tmp_path / "init", meta_core, "builtin")] + + monkeypatch.setattr("specfact_cli.registry.module_discovery.discover_all_modules", _discover) + assert frs.is_first_run(user_root=tmp_path) is True + + +def test_is_first_run_false_when_category_bundle_installed(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + def _discover(_builtin=None, user_root=None, **_kwargs): + from specfact_cli.models.module_package import ModulePackageMetadata + from specfact_cli.registry.module_discovery import DiscoveredModule + + meta_core = ModulePackageMetadata(name="init", version="0.1.0", commands=["init"], category="core") + meta_code = ModulePackageMetadata( + name="analyze", version="0.1.0", commands=["analyze"], category="codebase", bundle="specfact-codebase" + ) + return [ + DiscoveredModule(tmp_path / "init", meta_core, "builtin"), + DiscoveredModule(tmp_path / "analyze", meta_code, "user"), + ] + + monkeypatch.setattr("specfact_cli.registry.module_discovery.discover_all_modules", _discover) + assert frs.is_first_run(user_root=tmp_path) is False + + +def test_is_first_run_false_when_project_scoped_category_bundle_installed( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + def _discover(_builtin=None, user_root=None, **_kwargs): + from specfact_cli.models.module_package import ModulePackageMetadata + from specfact_cli.registry.module_discovery import DiscoveredModule + + meta_project = ModulePackageMetadata( + name="analyze", version="0.1.0", commands=["analyze"], category="codebase", bundle="specfact-codebase" + ) + return [DiscoveredModule(tmp_path / "analyze", meta_project, "project")] + + monkeypatch.setattr("specfact_cli.registry.module_discovery.discover_all_modules", _discover) + assert frs.is_first_run(user_root=tmp_path) is False + + +# --- CLI: specfact init --profile (mock installer) --- + + +def test_init_profile_solo_developer_calls_installer_with_specfact_codebase( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_calls: list[list[str]] = [] + + def _fake_install_bundles(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr( + "specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install_bundles + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: True) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: [{"id": "init", "enabled": True}], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke( + app, + ["--repo", str(tmp_path), "--profile", "solo-developer"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert len(install_calls) == 1 + assert install_calls[0] == ["specfact-codebase"] + + +def test_init_profile_enterprise_full_stack_calls_installer_with_all_five( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_calls: list[list[str]] = [] + + def _fake_install_bundles(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr( + "specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install_bundles + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: True) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: [{"id": "init", "enabled": True}], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke( + app, + ["--repo", str(tmp_path), "--profile", "enterprise-full-stack"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert len(install_calls) == 1 + assert set(install_calls[0]) == { + "specfact-project", + "specfact-backlog", + "specfact-codebase", + "specfact-spec", + "specfact-govern", + } + assert len(install_calls[0]) == 5 + + +def test_init_profile_nonexistent_exits_nonzero_and_lists_valid_profiles( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + monkeypatch.setattr("specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", lambda **_: []) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + with _telemetry_track_context(): + result = runner.invoke( + app, + ["--repo", str(tmp_path), "--profile", "nonexistent"], + catch_exceptions=False, + ) + assert result.exit_code != 0 + assert ( + "nonexistent" in result.output.lower() + or "invalid" in result.output.lower() + or "unknown" in result.output.lower() + ) + assert "solo-developer" in result.output or "valid" in result.output.lower() + + +def test_init_install_backlog_codebase_calls_installer_with_two_bundles( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_calls: list[list[str]] = [] + + def _fake_install_bundles(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr( + "specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install_bundles + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: True) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: [{"id": "init", "enabled": True}], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke( + app, + ["--repo", str(tmp_path), "--install", "backlog,codebase"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert len(install_calls) == 1 + assert set(install_calls[0]) == {"specfact-backlog", "specfact-codebase"} + + +def test_init_install_all_calls_installer_with_five_bundles(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + install_calls: list[list[str]] = [] + + def _fake_install_bundles(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr( + "specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install_bundles + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: True) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: [{"id": "init", "enabled": True}], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke( + app, + ["--repo", str(tmp_path), "--install", "all"], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert len(install_calls) == 1 + assert len(install_calls[0]) == 5 + assert set(install_calls[0]) == { + "specfact-project", + "specfact-backlog", + "specfact-codebase", + "specfact-spec", + "specfact-govern", + } + + +def test_init_install_widgets_exits_nonzero(tmp_path: Path) -> None: + result = runner.invoke( + app, + ["--repo", str(tmp_path), "--install", "widgets"], + catch_exceptions=False, + ) + assert result.exit_code != 0 + assert ( + "widgets" in result.output.lower() or "unknown" in result.output.lower() or "invalid" in result.output.lower() + ) + + +def test_init_second_run_skips_first_run_flow(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + install_calls: list[list[str]] = [] + + def _fake_install_bundles(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr( + "specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install_bundles + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: False) + modules_list = [{"id": "init", "enabled": True}, {"id": "analyze", "enabled": True}] + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: modules_list, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke( + app, + ["--repo", str(tmp_path)], + catch_exceptions=False, + ) + assert result.exit_code == 0, result.output + assert len(install_calls) == 0 + + +def test_init_first_run_interactive_with_selection_calls_installer( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + install_calls: list[list[str]] = [] + + def _fake_install(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr("specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: True) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: False) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands._interactive_first_run_bundle_selection", + lambda: ["specfact-codebase"], + ) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: [{"id": "init", "enabled": True}], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke(app, ["--repo", str(tmp_path)], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert len(install_calls) == 1 + assert install_calls[0] == ["specfact-codebase"] + + +def test_init_first_run_interactive_no_selection_shows_tip(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + install_calls: list[list[str]] = [] + + def _fake_install(bundle_ids: list[str], install_root: Path, **kwargs: object) -> None: + install_calls.append(list(bundle_ids)) + + monkeypatch.setattr("specfact_cli.modules.init.src.first_run_selection.install_bundles_for_init", _fake_install) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_first_run", lambda **_: True) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: False) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands._interactive_first_run_bundle_selection", + list, + ) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda **_: [{"id": "init", "enabled": True}], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda _: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda _: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", lambda _: MagicMock(manager=MagicMock()) + ) + with _telemetry_track_context(): + result = runner.invoke(app, ["--repo", str(tmp_path)], catch_exceptions=False) + assert result.exit_code == 0, result.output + assert len(install_calls) == 0 + assert "module install" in result.output or "Tip" in result.output + + +def test_spec_bundle_install_includes_project_dep(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None: + installed_modules: list[str] = [] + + def _record_install(module_name: str, target_root: Path, **kwargs: object) -> bool: + installed_modules.append(module_name) + return True + + monkeypatch.setattr( + "specfact_cli.registry.module_installer.install_bundled_module", + _record_install, + ) + frs.install_bundles_for_init(["specfact-spec"], install_root=tmp_path) + project_module_names = set(frs.BUNDLE_TO_MODULE_NAMES.get("specfact-project", [])) + spec_module_names = set(frs.BUNDLE_TO_MODULE_NAMES.get("specfact-spec", [])) + installed_set = set(installed_modules) + assert project_module_names & installed_set, "spec bundle must trigger project bundle dep install" + assert spec_module_names & installed_set, "spec bundle modules must be installed" diff --git a/tests/unit/registry/test_category_groups.py b/tests/unit/registry/test_category_groups.py new file mode 100644 index 00000000..5ff07071 --- /dev/null +++ b/tests/unit/registry/test_category_groups.py @@ -0,0 +1,136 @@ +"""Tests for category group bootstrap and routing (category-command-groups).""" + +from __future__ import annotations + +import os +from collections.abc import Generator +from pathlib import Path +from unittest.mock import patch + +import pytest + +from specfact_cli.registry import CommandRegistry +from specfact_cli.registry.bootstrap import register_builtin_commands + + +@pytest.fixture(autouse=True) +def _clear_registry() -> Generator[None, None, None]: + CommandRegistry._clear_for_testing() + yield + CommandRegistry._clear_for_testing() + + +def test_bootstrap_with_category_grouping_enabled_registers_group_commands() -> None: + """With category_grouping_enabled=True, bootstrap registers code, backlog, project, spec, govern.""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + register_builtin_commands() + names = [name for name, _ in CommandRegistry.list_commands_for_help()] + for group in ("code", "backlog", "project", "spec", "govern"): + assert group in names, f"Expected group command {group!r} in {names}" + + +def test_bootstrap_with_category_grouping_disabled_registers_flat_commands() -> None: + """With category_grouping_enabled=False, bootstrap registers flat module commands (no group commands).""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "false"}, clear=False): + register_builtin_commands() + names = [name for name, _ in CommandRegistry.list_commands_for_help()] + assert "code" not in names, "Group 'code' should not appear when grouping disabled" + assert "govern" not in names, "Group 'govern' should not appear when grouping disabled" + assert "analyze" in names + assert "validate" in names + + +def test_code_analyze_routes_same_as_flat_analyze( + tmp_path: Path, +) -> None: + """specfact code analyze ... routes to the same handler as specfact analyze ... (integration via CLI).""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + register_builtin_commands() + from typer.main import get_command + + from specfact_cli.cli import app + + root_cmd = get_command(app) + assert root_cmd is not None + assert hasattr(root_cmd, "commands") and "code" in root_cmd.commands + code_app = CommandRegistry.get_typer("code") + click_code = get_command(code_app) + if hasattr(click_code, "commands"): + assert "analyze" in click_code.commands + + +def test_govern_help_when_not_installed_suggests_install( + tmp_path: Path, +) -> None: + """specfact govern --help when govern bundle not installed produces install suggestion.""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + register_builtin_commands() + from click.testing import CliRunner + from typer.main import get_command + + from specfact_cli.cli import app + + runner = CliRunner() + root_cmd = get_command(app) + result = runner.invoke(root_cmd, ["govern", "--help"]) + assert ( + result.exit_code == 0 or "install" in (result.output or "").lower() or "govern" in (result.output or "").lower() + ) + + +def test_flat_shim_validate_emits_deprecation_in_copilot_mode( + tmp_path: Path, +) -> None: + """Flat 'specfact validate' resolves to real validate module (no deprecation message since shim is real module).""" + with patch.dict( + os.environ, + {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true", "SPECFACT_MODE": "copilot"}, + clear=False, + ): + register_builtin_commands() + from click.testing import CliRunner + from typer.main import get_command + + from specfact_cli.cli import app + + runner = CliRunner() + root_cmd = get_command(app) + result = runner.invoke(root_cmd, ["validate", "--help"]) + assert result.exit_code == 0 + assert "validate" in (result.output or "").lower() + + +def test_flat_shim_validate_silent_in_cicd_mode(tmp_path: Path) -> None: + """Flat shim specfact validate is silent (no deprecation) in CI/CD mode.""" + with patch.dict( + os.environ, + {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true", "SPECFACT_MODE": "cicd"}, + clear=False, + ): + register_builtin_commands() + from click.testing import CliRunner + from typer.main import get_command + + from specfact_cli.cli import app + + runner = CliRunner() + root_cmd = get_command(app) + result = runner.invoke(root_cmd, ["validate", "--help"]) + assert result.exit_code == 0 + + +def test_spec_api_validate_routes_correctly(tmp_path: Path) -> None: + """specfact spec api routes correctly (spec module mounted as api subcommand; collision avoidance).""" + with patch.dict(os.environ, {"SPECFACT_CATEGORY_GROUPING_ENABLED": "true"}, clear=False): + register_builtin_commands() + from click.testing import CliRunner + from typer.main import get_command + + from specfact_cli.cli import app + + root_cmd = get_command(app) + assert root_cmd is not None and hasattr(root_cmd, "commands") and "spec" in root_cmd.commands + runner = CliRunner() + result = runner.invoke(root_cmd, ["spec", "api", "--help"]) + assert result.exit_code == 0, f"spec api --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_grouping.py b/tests/unit/registry/test_module_grouping.py new file mode 100644 index 00000000..811d8a18 --- /dev/null +++ b/tests/unit/registry/test_module_grouping.py @@ -0,0 +1,136 @@ +"""Tests for module category metadata and group_modules_by_category (module-grouping).""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from specfact_cli.models.module_package import ModulePackageMetadata +from specfact_cli.registry.module_grouping import ModuleManifestError, group_modules_by_category +from specfact_cli.registry.module_packages import discover_package_metadata + + +def _write_manifest( + root: Path, + module_name: str, + *, + category: str | None = None, + bundle: str | None = None, + bundle_group_command: str | None = None, + bundle_sub_command: str | None = None, +) -> None: + module_dir = root / module_name + module_dir.mkdir(parents=True, exist_ok=True) + lines = [ + f"name: {module_name}", + "version: '0.1.0'", + f"commands: [{module_name}]", + ] + if category is not None: + lines.append(f"category: {category}") + if bundle is not None: + lines.append(f"bundle: {bundle}") + if bundle_group_command is not None: + lines.append(f"bundle_group_command: {bundle_group_command}") + if bundle_sub_command is not None: + lines.append(f"bundle_sub_command: {bundle_sub_command}") + (module_dir / "module-package.yaml").write_text("\n".join(lines) + "\n", encoding="utf-8") + (module_dir / "src").mkdir(parents=True, exist_ok=True) + + +def test_module_package_yaml_with_category_codebase_passes_validation(tmp_path: Path) -> None: + """module-package.yaml with category: codebase passes validation.""" + _write_manifest( + tmp_path, + "analyze", + category="codebase", + bundle="specfact-codebase", + bundle_group_command="code", + bundle_sub_command="analyze", + ) + packages = discover_package_metadata(tmp_path, source="builtin") + assert len(packages) == 1 + meta = packages[0][1] + assert meta.category == "codebase" + assert meta.bundle == "specfact-codebase" + assert meta.bundle_group_command == "code" + assert meta.bundle_sub_command == "analyze" + + +def test_module_package_yaml_with_category_unknown_raises_module_manifest_error( + tmp_path: Path, +) -> None: + """module-package.yaml with category: unknown raises ModuleManifestError.""" + _write_manifest(tmp_path, "foo", category="unknown") + (tmp_path / "foo" / "src").mkdir(parents=True, exist_ok=True) + with pytest.raises(ModuleManifestError) as exc_info: + discover_package_metadata(tmp_path, source="builtin") + assert "unknown" in str(exc_info.value).lower() or "category" in str(exc_info.value).lower() + + +def test_module_package_yaml_without_category_mounts_ungrouped_warning_logged( + tmp_path: Path, +) -> None: + """module-package.yaml without category field mounts as ungrouped (no error; warning logged in production).""" + _write_manifest(tmp_path, "legacy_mod") + packages = discover_package_metadata(tmp_path, source="builtin") + assert len(packages) == 1 + meta = packages[0][1] + assert meta.category is None + assert meta.bundle_group_command is None + + +def test_bundle_group_command_mismatch_raises_module_manifest_error(tmp_path: Path) -> None: + """bundle_group_command mismatch vs canonical category raises ModuleManifestError.""" + _write_manifest( + tmp_path, + "analyze", + category="codebase", + bundle="specfact-codebase", + bundle_group_command="wrong_group", + bundle_sub_command="analyze", + ) + with pytest.raises(ModuleManifestError) as exc_info: + discover_package_metadata(tmp_path, source="builtin") + assert "bundle_group_command" in str(exc_info.value) or "code" in str(exc_info.value) + + +def test_core_category_modules_have_no_bundle_or_bundle_group_command(tmp_path: Path) -> None: + """Core-category modules have no bundle or bundle_group_command.""" + _write_manifest( + tmp_path, + "init", + category="core", + bundle_sub_command="init", + ) + packages = discover_package_metadata(tmp_path, source="builtin") + assert len(packages) == 1 + meta = packages[0][1] + assert meta.category == "core" + assert meta.bundle is None + assert meta.bundle_group_command is None + assert meta.bundle_sub_command == "init" + + +def test_group_modules_by_category_returns_correct_grouping() -> None: + """group_modules_by_category() returns correct grouping dict from list of manifests.""" + manifests = [ + ModulePackageMetadata( + name="analyze", version="0.1.0", commands=["analyze"], category="codebase", bundle_group_command="code" + ), + ModulePackageMetadata( + name="validate", version="0.1.0", commands=["validate"], category="codebase", bundle_group_command="code" + ), + ModulePackageMetadata( + name="backlog", version="0.1.0", commands=["backlog"], category="backlog", bundle_group_command="backlog" + ), + ] + grouped = group_modules_by_category(manifests) + assert "code" in grouped + assert "backlog" in grouped + assert len(grouped["code"]) == 2 + assert len(grouped["backlog"]) == 1 + names_code = {m.name for m in grouped["code"]} + assert names_code == {"analyze", "validate"} + assert grouped["backlog"][0].name == "backlog" diff --git a/tests/unit/registry/test_module_installer.py b/tests/unit/registry/test_module_installer.py index 00a7f1a1..2a0a50f2 100644 --- a/tests/unit/registry/test_module_installer.py +++ b/tests/unit/registry/test_module_installer.py @@ -425,6 +425,11 @@ def test_verify_module_artifact_fallback_emits_debug_in_debug_mode( mock_logger = MagicMock() monkeypatch.setattr(module_installer, "get_bridge_logger", lambda _name: mock_logger) monkeypatch.setattr(module_installer, "is_debug_mode", lambda: True, raising=False) + monkeypatch.setattr( + module_installer, + "_module_artifact_payload_signed", + lambda _: (_ for _ in ()).throw(ValueError("force fallback")), + ) assert module_installer.verify_module_artifact(module_dir, metadata, allow_unsigned=False) is True mock_logger.info.assert_not_called() diff --git a/tests/unit/specfact_cli/registry/test_module_packages.py b/tests/unit/specfact_cli/registry/test_module_packages.py index 2a690b25..faaed2e2 100644 --- a/tests/unit/specfact_cli/registry/test_module_packages.py +++ b/tests/unit/specfact_cli/registry/test_module_packages.py @@ -12,6 +12,7 @@ from pathlib import Path import pytest +import typer from specfact_cli.models.module_package import ( IntegrityInfo, @@ -315,6 +316,57 @@ def verify_may_fail(_package_dir: Path, meta, allow_unsigned: bool = False): assert "bad_cmd" not in names +def test_grouped_registration_merges_duplicate_command_extensions( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + """Grouped mode should merge duplicate module command trees instead of replacing earlier loaders.""" + from specfact_cli.registry import module_packages as mp + + packages = [ + ( + tmp_path / "base_backlog", + ModulePackageMetadata(name="base_backlog", version="0.1.0", commands=["backlog"], category="backlog"), + ), + ( + tmp_path / "ext_backlog", + ModulePackageMetadata(name="ext_backlog", version="0.1.0", commands=["backlog"], category="backlog"), + ), + ] + monkeypatch.setattr(mp, "discover_all_package_metadata", lambda: packages) + monkeypatch.setattr(mp, "verify_module_artifact", lambda _dir, _meta, allow_unsigned=False: True) + monkeypatch.setattr(mp, "read_modules_state", dict) + monkeypatch.setattr(mp, "_check_protocol_compliance_from_source", lambda *_args: []) + + def _build_typer(subcommand_name: str) -> typer.Typer: + app = typer.Typer() + + @app.command(name=subcommand_name) + def _cmd() -> None: + return None + + return app + + def _fake_loader(_package_dir: Path, package_name: str, _cmd_name: str): + return ( + (lambda: _build_typer("base_cmd")) if package_name == "base_backlog" else (lambda: _build_typer("ext_cmd")) + ) + + monkeypatch.setattr(mp, "_make_package_loader", _fake_loader) + + mp.register_module_package_commands(category_grouping_enabled=True) + + backlog_app = CommandRegistry.get_module_typer("backlog") + command_names = tuple( + sorted( + command_info.name + for command_info in backlog_app.registered_commands + if getattr(command_info, "name", None) is not None + ) + ) + assert "base_cmd" in command_names + assert "ext_cmd" in command_names + + def test_integrity_failure_shows_user_friendly_risk_warning(monkeypatch, tmp_path: Path) -> None: """Integrity failure should emit concise risk guidance instead of raw checksum diagnostics.""" from specfact_cli.registry import module_packages as mp