diff --git a/.github/workflows/claude-code-review.yml b/.github/workflows/claude-code-review.yml index b5e8cfd4..441b944a 100644 --- a/.github/workflows/claude-code-review.yml +++ b/.github/workflows/claude-code-review.yml @@ -1,14 +1,12 @@ name: Claude Code Review on: - pull_request: - types: [opened, synchronize, ready_for_review, reopened] - # Optional: Only run on specific file changes - # paths: - # - "src/**/*.ts" - # - "src/**/*.tsx" - # - "src/**/*.js" - # - "src/**/*.jsx" + workflow_dispatch: + inputs: + pull_request_number: + description: "Pull request number to review" + required: true + type: string jobs: claude-review: @@ -38,7 +36,6 @@ jobs: claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }} plugin_marketplaces: 'https://github.com/anthropics/claude-code.git' plugins: 'code-review@claude-code-plugins' - prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}' + prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ inputs.pull_request_number }}' # See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md # or https://code.claude.com/docs/en/cli-reference for available options - diff --git a/CHANGELOG.md b/CHANGELOG.md index fc20488e..93af389a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ All notable changes to this project will be documented in this file. --- +## [0.29.0] - 2026-02-06 + +### Added (0.29.0) + +- **Module lifecycle management and dependency safety** (OpenSpec change `arch-03-module-lifecycle-management`, fixes [#203](https://github.com/nold-ai/specfact-cli/issues/203)) + - Added module manifest lifecycle validation for dependency integrity and CLI core compatibility (`core_compatibility`) during command registration. + - Added module lifecycle UX in `specfact init`: `--list-modules`, interactive arrow-key enable/disable selection in interactive mode, and explicit-id enforcement in non-interactive mode. + - Added force-mode dependency cascades: + - `--force` disable cascades to enabled dependents. + - `--force` enable cascades to required upstream dependencies. + - Added `specfact init ide` for dedicated IDE prompt/template setup, while keeping `specfact init` bootstrap/module-lifecycle focused. + +### Changed (0.29.0) + +- **Interaction default behavior**: Updated runtime prompt auto-detection to be interactive-by-default in interactive terminals, while remaining non-interactive in CI/non-interactive environments. +- **Docs**: Updated README and docs reference pages for the new init/init-ide split, module lifecycle behavior, and roadmap positioning for future granular module enhancements and planned third-party/community module installation. +- **Version**: Bumped to 0.29.0 (minor: new lifecycle features and UX improvements, backward compatible). + +--- + ## [0.28.0] - 2026-02-06 ### Added (0.28.0) diff --git a/README.md b/README.md index 97f38f5f..87d3a453 100644 --- a/README.md +++ b/README.md @@ -31,12 +31,16 @@ uvx specfact-cli@latest pip install -U specfact-cli ``` -### Initialize IDE Integration (optional but recommended) +### Bootstrap and IDE Setup ```bash +# Bootstrap module registry and local config (~/.specfact) specfact init -specfact init --ide cursor -specfact init --ide vscode + +# Configure IDE prompts/templates (interactive selector by default) +specfact init ide +specfact init ide --ide cursor +specfact init ide --ide vscode ``` ### Run Your First Flow @@ -134,6 +138,34 @@ Most tools help **either** coders **or** agile teams. SpecFact does both: - **Backlogs**: GitHub Issues, Azure DevOps, Jira, Linear - **Contracts**: Specmatic, OpenAPI +### Module Lifecycle Baseline + +SpecFact now has a lifecycle-managed module system: + +- `specfact init` is bootstrap-first: initializes local CLI state, discovers installed modules, and reports prompt status. +- `specfact init ide` handles IDE prompt/template sync and IDE settings updates. +- `specfact init --list-modules` shows effective enabled/disabled state. +- `specfact init --enable-module` / `--disable-module` support: + - interactive selection in interactive terminals when no module id is provided + - explicit ids in non-interactive mode (for automation) + - dependency-aware safety checks with `--force` cascading enable/disable behavior +- Module manifests support dependency and core-version compatibility enforcement at registration time. + +This lifecycle model is the baseline for future granular module updates and enhancements. Module installation from third-party or open-source community providers is planned, but not implemented yet. + +--- + +## Developer Note: Command Layout + +- Primary command implementations live in `src/specfact_cli/modules//src/commands.py`. +- Legacy imports from `src/specfact_cli/commands/*.py` are compatibility shims and only guarantee `app` re-exports. +- Preferred imports for module code: + - `from specfact_cli.modules..src.commands import app` + - `from specfact_cli.modules..src.commands import ` +- Shim deprecation timeline: + - Legacy shim usage is deprecated for non-`app` symbols now. + - Shim removal is planned no earlier than `v0.30` (or the next major migration window). + --- ## Developer Note: Command Layout diff --git a/docs/README.md b/docs/README.md index ae94e768..4236972c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -58,6 +58,18 @@ Most tools help **either** coders **or** agile teams. SpecFact does both: - **Backlogs**: GitHub Issues, Azure DevOps, Jira, Linear - **Contracts**: Specmatic, OpenAPI +## Module Lifecycle System + +SpecFact CLI uses a lifecycle-managed module system: + +- `specfact init` bootstraps local state and manages module enable/disable lifecycle. +- `specfact init ide` handles IDE prompt/template installation and updates. +- `specfact init --list-modules` shows current enabled/disabled state. +- `--enable-module` and `--disable-module` support interactive selection in interactive terminals and explicit ids in non-interactive mode. +- Dependency and compatibility guards prevent invalid module states; `--force` enables dependency-aware cascades. + +This is the baseline for future granular module updates and enhancements. Third-party/community module installation is planned, but not available yet. + --- ## Documentation Sections diff --git a/docs/reference/architecture.md b/docs/reference/architecture.md index 4e3917cc..20d9c1fd 100644 --- a/docs/reference/architecture.md +++ b/docs/reference/architecture.md @@ -90,7 +90,7 @@ specfact import from-code my-project --repo . specfact --mode copilot import from-code my-project --repo . # IDE integration (slash commands) -# First, initialize: specfact init --ide cursor +# First, initialize: specfact init ide --ide cursor # Then use in IDE chat: /specfact.01-import legacy-api --repo . --confidence 0.7 /specfact.02-plan init legacy-api @@ -541,7 +541,7 @@ class ChangeArchive(BaseModel): - **Manifest**: Each package has a `module-package.yaml` with: - `name`, `version`, `commands` (list of command names the package provides) - optional `command_help` (name → short help for root `specfact --help`) - - optional `pip_dependencies`, `module_dependencies`, `tier` (e.g. community/enterprise), `addon_id` + - optional `pip_dependencies`, `module_dependencies`, `core_compatibility`, `tier` (e.g. community/enterprise), `addon_id` - **Entry point**: Each package has `src/app.py` that exposes a Typer `app` by importing from module-local `src/commands.py`. ### Legacy shim policy and timeline @@ -557,7 +557,20 @@ class ChangeArchive(BaseModel): - **File**: `~/.specfact/registry/modules.json` (created when you run `specfact init`). - **Content**: List of `{ "id", "version", "enabled" }` per module. Only modules with `enabled: true` have their commands registered. -- **CLI**: `specfact init --enable-module ` and `--disable-module ` update this state and persist it to `modules.json`. +- **CLI**: + - `specfact init --list-modules` shows effective state. + - `specfact init --enable-module ` and `--disable-module ` update persisted state. + - In interactive terminals, `specfact init --enable-module` and `specfact init --disable-module` (without ids) open an interactive selector. + - In non-interactive mode, explicit module ids are required. + - Safe dependency guards block invalid enable/disable actions unless `--force` is used. + - With `--force`, enable cascades to required dependencies and disable cascades to enabled dependents. + +### Lifecycle notes and roadmap + +- `specfact init` is bootstrap/module-lifecycle focused. +- `specfact init ide` is responsible for IDE prompt/template setup. +- This lifecycle architecture is the baseline for future granular module updates and enhancements. +- Third-party/community module installation is planned as a next step, but not implemented yet. ### Registry package layout diff --git a/docs/reference/commands.md b/docs/reference/commands.md index b58d7567..05e8f160 100644 --- a/docs/reference/commands.md +++ b/docs/reference/commands.md @@ -69,7 +69,7 @@ specfact auth status - `--input-format {yaml,json}` - Override default structured input detection for CLI commands (defaults to YAML) - `--output-format {yaml,json}` - Control how plan bundles and reports are written (JSON is ideal for CI/copilot automations) -- `--interactive/--no-interactive` - Force prompt behavior (overrides auto-detection from CI/CD vs Copilot environments) +- `--interactive/--no-interactive` - Force prompt behavior (default auto-detection from terminal + CI environment) ### Commands by Workflow @@ -176,7 +176,8 @@ specfact auth status **Setup & Maintenance:** -- `init` - Initialize IDE integration +- `init` - Bootstrap CLI local state and manage enabled/disabled modules +- `init ide` - Initialize IDE prompt/template integration - `upgrade` - Check for and install CLI updates **⚠️ Deprecated (v0.17.0):** @@ -540,7 +541,7 @@ When working with multiple projects in a single repository, external tool integr - Use `--entry-point` to analyze each project separately - Create separate project bundles for each project (`.specfact/projects//`) -- Run `specfact init` from the repository root to ensure IDE integration works correctly (templates are copied to root-level `.github/`, `.cursor/`, etc. directories) +- Run `specfact init ide` from the repository root to ensure IDE integration works correctly (templates are copied to root-level `.github/`, `.cursor/`, etc. directories) --- @@ -4717,65 +4718,99 @@ Replace `implement tasks` with the new AI IDE bridge workflow: --- -### `init` - Initialize IDE Integration +### `init` - Bootstrap and Module Lifecycle Management -Set up SpecFact CLI for IDE integration by copying prompt templates to IDE-specific locations. +Bootstrap SpecFact local state and manage enabled/disabled command modules. ```bash specfact init [OPTIONS] ``` -**Options:** +**Common options:** - `--repo PATH` - Repository path (default: current directory) -- `--force` - Overwrite existing files -- `--install-deps` - Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) via pip +- `--list-modules` - Show discovered modules with effective enabled/disabled state and exit +- `--enable-module TEXT` - Enable module by id (repeatable) +- `--disable-module TEXT` - Disable module by id (repeatable) +- `--force` - Override dependency guards; cascades dependency updates -**Advanced Options** (hidden by default, use `--help-advanced` or `-ha` to view): +**Interactive behavior:** + +- Default mode is auto-detected from terminal + CI environment. +- In interactive terminals, passing `--enable-module` or `--disable-module` without ids opens an arrow-key selector. +- In non-interactive mode, module ids are required (for example in CI/CD). -- `--ide TEXT` - IDE type (auto, cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q) (default: auto) +**Dependency-aware behavior:** + +- Safe disable blocks disabling a module that is required by other enabled modules. +- Safe enable blocks enabling a module when required dependencies are disabled. +- `--force` performs dependency-aware cascading: + - disable cascades to enabled dependents + - enable cascades to required dependencies **Examples:** ```bash -# Auto-detect IDE +# Bootstrap only (no IDE prompt/template copy) specfact init -# Specify IDE explicitly -specfact init --ide cursor -specfact init --ide vscode -specfact init --ide copilot +# List lifecycle state +specfact init --list-modules -# Force overwrite existing files -specfact init --ide cursor --force +# Interactive selection (TTY) +specfact init --enable-module +specfact init --disable-module -# Install required packages for contract enhancement -specfact init --install-deps +# Non-interactive explicit ids +specfact --no-interactive init --enable-module backlog +specfact --no-interactive init --disable-module upgrade -# Initialize IDE integration and install dependencies -specfact init --ide cursor --install-deps +# Force dependency cascade +specfact init --enable-module sync --force +specfact init --disable-module plan --force ``` **What it does:** -1. Detects your IDE (or uses `--ide` flag) -2. Copies prompt templates from `resources/prompts/` to IDE-specific location **at the repository root level** -3. Creates/updates VS Code settings.json if needed (for VS Code/Copilot) -4. Makes slash commands available in your IDE -5. **Copies default ADO field mapping templates** to `.specfact/templates/backlog/field_mappings/` for review and customization: - - `ado_default.yaml` - Default field mappings - - `ado_scrum.yaml` - Scrum process template mappings - - `ado_agile.yaml` - Agile process template mappings - - `ado_safe.yaml` - SAFe process template mappings - - `ado_kanban.yaml` - Kanban process template mappings - - Templates are only copied if they don't exist (use `--force` to overwrite) -6. Optionally installs required packages for contract enhancement (if `--install-deps` is provided): - - `beartype>=0.22.4` - Runtime type checking - - `icontract>=2.7.1` - Design-by-contract decorators - - `crosshair-tool>=0.0.97` - Contract exploration - - `pytest>=8.4.2` - Testing framework - -**Important:** Templates are always copied to the repository root level (where `.github/`, `.cursor/`, etc. directories must reside for IDE recognition). The `--repo` parameter specifies the repository root path. For multi-project codebases, run `specfact init` from the repository root to ensure IDE integration works correctly. +1. Initializes/updates user-level registry state under `~/.specfact/registry/`. +2. Discovers installed modules and applies enable/disable operations. +3. Enforces module dependency and compatibility constraints. +4. Reports IDE prompt status and points to `specfact init ide` for prompt/template setup. + +### `init ide` - IDE Prompt/Template Setup + +Install and update prompt templates and IDE settings. + +```bash +specfact init ide [OPTIONS] +``` + +**Options:** + +- `--repo PATH` - Repository path (default: current directory) +- `--ide TEXT` - IDE type (cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto) +- `--force` - Overwrite existing files +- `--install-deps` - Install contract-enhancement dependencies (`beartype`, `icontract`, `crosshair-tool`, `pytest`) + +**Behavior:** + +- In interactive terminals, `specfact init ide` without `--ide` opens an arrow-key IDE selector. +- In non-interactive mode, IDE auto-detection is used unless `--ide` is explicitly provided. +- Prompt templates are copied to IDE-specific root-level locations (`.github/prompts`, `.cursor/commands`, etc.). + +**Examples:** + +```bash +# Interactive IDE selection +specfact init ide + +# Explicit IDE +specfact init ide --ide cursor +specfact init ide --ide vscode --force + +# Optional dependency installation +specfact init ide --install-deps +``` **IDE-Specific Locations:** @@ -4868,16 +4903,16 @@ Slash commands provide an intuitive interface for IDE integration (VS Code, Curs ```bash # Initialize IDE integration (one-time setup) -specfact init --ide cursor +specfact init ide --ide cursor # Or auto-detect IDE -specfact init +specfact init ide # Initialize and install required packages for contract enhancement -specfact init --install-deps +specfact init ide --install-deps # Initialize for specific IDE and install dependencies -specfact init --ide cursor --install-deps +specfact init ide --ide cursor --install-deps ``` ### Usage @@ -4903,7 +4938,7 @@ After initialization, use slash commands directly in your IDE's AI chat: **How it works:** -Slash commands are **prompt templates** (markdown files) that are copied to IDE-specific locations by `specfact init`. The IDE automatically discovers and registers them as slash commands. +Slash commands are **prompt templates** (markdown files) that are copied to IDE-specific locations by `specfact init ide`. The IDE automatically discovers and registers them as slash commands. **See [IDE Integration Guide](../guides/ide-integration.md)** for detailed setup instructions and supported IDEs. diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index 3c66726b..d175edce 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -27,7 +27,11 @@ All SpecFact artifacts are stored under `.specfact/` in the repository root. Thi **User-level registry** (v0.27+): After you run `specfact init`, the CLI creates `~/.specfact/registry/` with: - `commands.json` – Command names and help text used for fast root `specfact --help` without loading every command module. -- `modules.json` – Per-module state (id, version, enabled) for optional module packages; `specfact init --enable-module ` / `--disable-module ` persist here. +- `modules.json` – Per-module state (id, version, enabled) for optional module packages. + - Managed by `specfact init --list-modules`, `specfact init --enable-module ...`, `specfact init --disable-module ...` + - Supports dependency-safe lifecycle operations with optional `--force` cascading behavior + +`specfact init` is bootstrap/module-lifecycle focused. IDE prompt/template setup is handled by `specfact init ide`. For how the CLI discovers and loads commands from module packages (registry, module-package.yaml, lazy loading), see [Architecture – Modules design](architecture.md#modules-design). @@ -442,19 +446,35 @@ specfact sync repository --repo . --watch --interval 5 ### `specfact init` +Bootstraps local module lifecycle state (without IDE template copy side effects): + +```bash +# Bootstrap and discover modules +specfact init + +# List enabled/disabled module state +specfact init --list-modules + +# Manage modules (interactive selector in interactive terminals) +specfact init --enable-module +specfact init --disable-module +``` + +### `specfact init ide` + Initializes IDE integration by copying prompt templates to IDE-specific locations: ```bash # Auto-detect IDE -specfact init +specfact init ide # Specify IDE explicitly -specfact init --ide cursor -specfact init --ide vscode -specfact init --ide copilot +specfact init ide --ide cursor +specfact init ide --ide vscode +specfact init ide --ide copilot ``` -**Creates IDE-specific directories:** +**Creates/updates IDE-specific directories:** - **Cursor**: `.cursor/commands/` (markdown files) - **VS Code / Copilot**: `.github/prompts/` (`.prompt.md` files) + `.vscode/settings.json` diff --git a/openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml b/openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml new file mode 100644 index 00000000..41094ca0 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-02-06 diff --git a/openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md b/openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md new file mode 100644 index 00000000..73fa07b4 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/CHANGE_VALIDATION.md @@ -0,0 +1,120 @@ +# Change Validation Report: arch-03-module-lifecycle-management + +**Validation Date**: 2026-02-06 18:35:22Z +**Change Proposal**: [proposal.md](./proposal.md) +**Validation Method**: Dry-run interface/dependency simulation in temporary workspace + +## Executive Summary + +- Breaking Changes: 1 potential behavior break detected, 0 unmitigated +- Dependent Files: 27 identified in impacted surfaces +- Impact Level: Medium +- Validation Result: Pass (safe to implement) +- User Decision: Proceed without scope extension; keep compatibility safeguards in implementation + +## Breaking Changes Detected + +### Interface/Behavior: `specfact init --disable-module ` + +- **Type**: Behavioral contract tightening +- **Old Behavior**: Disable operation persisted directly +- **New Behavior**: Disable blocked if enabled dependents require the module (unless `--force`) +- **Breaking**: Potentially yes for automation/scripts that currently rely on unconditional disable +- **Mitigation in scope**: + - Explicit `--force` override + - Clear error message listing dependent modules + - Documentation updates for new disable semantics + +### Interface/Behavior: Cross-module helper imports + +- **Type**: Internal import path migration (module code) +- **Old Behavior**: module-to-module imports from `specfact_cli.modules..src.commands` +- **New Behavior**: shared helpers in `specfact_cli.utils.bundle_converters` +- **Breaking**: Not if wrappers are retained in `plan/src/commands.py` and `sdd/src/commands.py` +- **Mitigation in scope**: + - Keep compatibility wrappers for `_convert_*` and `is_constitution_minimal` + - Update module imports in sync/generate/enforce + +## Dependencies Affected + +### Critical Updates Required + +- `src/specfact_cli/modules/init/src/commands.py`: add safe-disable gate and preserve clear CLI UX. +- `src/specfact_cli/registry/module_packages.py`: add compatibility/dependency checks without startup hard-fail. +- `src/specfact_cli/registry/module_state.py`: add reverse dependency helper used by safe-disable logic. +- `src/specfact_cli/modules/sync/src/commands.py`: replace cross-module helper imports with core utility imports. +- `src/specfact_cli/modules/generate/src/commands.py`: replace cross-module helper imports with core utility imports. +- `src/specfact_cli/modules/enforce/src/commands.py`: replace cross-module helper imports with core utility imports. +- `tests/unit/specfact_cli/test_module_boundary_imports.py`: extend guard for cross-module non-`app` imports. + +### Recommended Updates + +- `tests/unit/specfact_cli/registry/test_module_packages.py`: add assertions for `core_compatibility` parsing/validation behavior. +- `tests/unit/specfact_cli/registry/test_init_module_state.py`: add safe-disable behavior coverage. +- New tests planned in tasks (`test_module_dependencies.py`, `test_version_constraints.py`, `test_bundle_converters.py`). + +### Optional / No Immediate Action + +- Existing tests importing `_convert_*` from plan commands (18 files) are compatible if wrapper strategy is kept. + +## Impact Assessment + +- **Code Impact**: Registry lifecycle logic, init disable flow, cross-module helper import paths, new shared utility module. +- **Test Impact**: New contract-first tests required plus boundary guard extension; existing helper-import tests remain stable with wrappers. +- **Documentation Impact**: Update CLI/module lifecycle docs for `core_compatibility`, dependency enforcement, and `--force` behavior. +- **Release Impact**: Minor (`0.29.0`) remains appropriate; behavior change is intentional and mitigated. + +## User Decision + +**Decision**: Proceed + +**Rationale**: Detected breaking risk is bounded and explicitly mitigated (`--force` + compatibility wrappers). No additional proposal scope expansion is required at validation stage. + +**Next Steps**: + +1. Keep wrapper compatibility explicitly during implementation. +2. Add tests before code per tasks section 4. +3. Validate strict gates after implementation tasks. + +## Format Validation + +- **proposal.md Format**: Pass + - Title format: Correct (`# Change: ...`) + - Required sections: Present (`Why`, `What Changes`, `Capabilities`, `Impact`) + - `What Changes` format: Correct bullet list with NEW/EXTEND markers + - `Capabilities` section: Present + - `Impact` section: Present + - Source Tracking: Present and populated (issue #203) +- **tasks.md Format**: Pass + - Section headers: Hierarchical numbered groups present + - Task format: Checkbox format present + - Config compliance: + - TDD/SDD ordering block: Present + - Test tasks before implementation tasks: Present + - Quality gate tasks: Present + - Git workflow tasks: Present (branch first, PR last) + - GitHub issue task: Present + - **Note**: Placeholder `` remains in branch/PR task text; acceptable for proposal stage but should be resolved before apply. +- **specs Format**: Pass + - Given/When/Then scenarios present + - Requirement statements are clear and test-mappable +- **design.md Format**: Pass + - Architectural decisions, risks, and mitigations documented + - Sequence diagram not required for this non-multi-repo control-plane change +- **Format Issues Found**: 0 blocking, 1 advisory placeholder note +- **Config.yaml Compliance**: Pass + +## OpenSpec Validation + +- **Status**: Pass +- **Validation Command**: `openspec validate arch-03-module-lifecycle-management --strict` +- **Issues Found**: 0 +- **Issues Fixed**: 0 +- **Re-validated**: Yes + +## Validation Artifacts + +- Temporary workspace: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822` +- Interface scaffolds: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822/interface_scaffolds.md` +- Dependency graph: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822/dependency_graph.md` +- Dependency file list: `/tmp/specfact-validation-arch-03-module-lifecycle-management-1770402822/dependency_files.txt` diff --git a/openspec/changes/arch-03-module-lifecycle-management/design.md b/openspec/changes/arch-03-module-lifecycle-management/design.md new file mode 100644 index 00000000..667d8788 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/design.md @@ -0,0 +1,75 @@ +# Design: Module Lifecycle Management + +## Context + +`arch-02` moved commands into module packages and introduced manifest metadata, but the runtime registry still treats dependency metadata as advisory. This change adds enforcement for dependency and compatibility constraints without introducing startup fragility. + +## Goals + +- Enforce module dependency integrity during command registration. +- Enforce module-to-core compatibility through PEP 440 specifier evaluation. +- Prevent unsafe module disabling unless operator explicitly overrides. +- Remove cross-module private helper imports by moving shared utility logic to core `utils`. +- Preserve command startup resilience by skipping invalid modules with debug logging instead of hard-failing startup. + +## Non-Goals + +- Versioned inter-module dependency constraints (for example `sync>=0.29.0`). +- Runtime hot-reload of module enable/disable state. +- Plugin marketplace or remote module resolution. + +## Architecture + +### 1. Shared Helper Extraction + +Move reusable conversion and constitution helper functions into `src/specfact_cli/utils/bundle_converters.py` and redirect cross-module imports to this core utility. Keep compatibility wrappers in original module command files where needed. + +### 2. Manifest Schema Extension + +Add optional `core_compatibility` to `ModulePackageMetadata` and to all `module-package.yaml` manifests. Parsing remains tolerant: absent field means compatible with all core versions. + +### 3. Registration-Time Lifecycle Validation + +During `register_module_package_commands()`: + +1. Evaluate module enablement state. +2. Validate `core_compatibility` against CLI version. +3. Validate declared `module_dependencies` are discovered and enabled. +4. Register only valid modules; skip invalid modules with debug-level reason. + +This preserves startup continuity while enforcing lifecycle guarantees. + +### 4. Safe Disable Enforcement + +Before persisting disable operations in `init`: + +1. Compute effective enabled state. +2. Compute reverse dependencies for requested disables. +3. Block operation when enabled dependents exist. +4. Allow override with `--force` and explicit warning behavior. + +## Contracts and Testing Strategy + +- Add `@beartype` and `@icontract` usage for newly introduced public helper/validation APIs. +- Add focused tests for: + - dependency validation outcomes, + - compatibility specifier outcomes, + - safe-disable reverse dependency detection, + - extracted bundle conversion helpers, + - boundary guard against cross-module `src.commands` imports. +- Maintain contract-first validation gates and spec-to-test traceability. + +## Rollout + +- Implement in phases from helper extraction to registry checks to safe-disable and boundary tests. +- Verify with format, type-check, contract-test, and scenario-relevant test runs. +- Include documentation/version/changelog updates before PR. + +## Risks and Mitigations + +- **Risk**: Invalid compatibility specifier strings could block modules unexpectedly. + - **Mitigation**: Treat parse failures as non-blocking compatibility and log debug diagnostics. +- **Risk**: Users may be surprised when disabling modules is blocked. + - **Mitigation**: Provide explicit error with dependent module list and `--force` hint. +- **Risk**: Boundary guard may flag legitimate imports. + - **Mitigation**: Scope rule to non-`app` imports from other modules' `src.commands` only. diff --git a/openspec/changes/arch-03-module-lifecycle-management/proposal.md b/openspec/changes/arch-03-module-lifecycle-management/proposal.md new file mode 100644 index 00000000..b032f0e6 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/proposal.md @@ -0,0 +1,35 @@ +# Change: Module Lifecycle Management for Dependencies, Compatibility, and Safe Disable + +## Why + + +`arch-02` completed module package separation, but module lifecycle constraints are still unenforced at runtime. `module_dependencies` is currently declarative-only, module manifests do not constrain CLI core compatibility, and `init --disable-module` can disable required modules without preflight protection. + +This creates avoidable runtime breakage and weakens contract-first guarantees for modular command loading. We need registry-time lifecycle validation and safe-disable protection while preserving backward compatibility. + +## What Changes + + +- **NEW**: Add module lifecycle validation at registration time for dependency existence/enabled state and `core_compatibility` version constraints. +- **NEW**: Extend module manifests with `core_compatibility` (PEP 440 specifier string) across all module packages. +- **NEW**: Add safe-disable checks in `specfact init` so disabling a module fails when enabled dependents require it, with explicit `--force` override. +- **NEW**: Add `specfact init --list-modules` to show discovered installed modules with effective enabled/disabled status. +- **NEW**: Add interactive up/down module selection for enable/disable flows, with explicit-id enforcement in non-interactive mode. +- **NEW**: Split initialization behavior into bootstrap-first `specfact init` (no IDE template side effects) and `specfact init ide` for prompt/template setup. +- **NEW**: Extract shared bundle conversion and constitution helper utilities from cross-module command imports into `specfact_cli.utils.bundle_converters`. +- **NEW**: Add boundary guard coverage that blocks cross-module imports from `specfact_cli.modules..src.commands` for non-`app` symbols. +- **EXTEND**: Add contract-first tests for dependency validation, version compatibility behavior, extracted utility helpers, and safe-disable logic. +- **EXTEND**: Update user-facing documentation and changelog/version synchronization for lifecycle management behavior and module manifest schema. + +## Capabilities +- **module-lifecycle-management**: Enforce module dependency integrity, version compatibility, safe-disable semantics, and module boundary hygiene. + +--- + +## Source Tracking + + +- **GitHub Issue**: #203 +- **Issue URL**: +- **Last Synced Status**: proposed + diff --git a/openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md b/openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md new file mode 100644 index 00000000..37fa7326 --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md @@ -0,0 +1,183 @@ +# Module Lifecycle Management + +## ADDED Requirements + +### Requirement: Shared helper extraction from cross-module command imports + +The system SHALL provide shared bundle conversion and constitution helper utilities under core `specfact_cli.utils` so modules do not import private non-`app` symbols from other modules' `src.commands`. + +#### Scenario: Cross-module helper imports use core utility module + +**Given** module command implementations that require shared conversion or constitution helper behavior + +**When** imports are updated for lifecycle management + +**Then** those modules import helpers from `specfact_cli.utils.bundle_converters` + +**And** cross-module imports from `specfact_cli.modules..src.commands` for non-`app` symbols are eliminated + +### Requirement: Module manifest core compatibility constraints + +The system SHALL support optional `core_compatibility` in each module package manifest using PEP 440 specifier syntax. + +#### Scenario: Compatibility field is parsed from module manifest + +**Given** a module `module-package.yaml` includes `core_compatibility: ">=0.28.0,<1.0.0"` + +**When** package metadata is discovered + +**Then** metadata includes the parsed `core_compatibility` string for compatibility evaluation + +**And** modules without the field remain valid and are treated as unconstrained + +### Requirement: Dependency and compatibility validation during registration + +The system SHALL validate dependency availability/enabled state and core compatibility before registering module commands. + +#### Scenario: Module with unmet dependency is skipped + +**Given** an enabled module declares `module_dependencies` containing a missing or disabled module + +**When** command registration runs + +**Then** the module is skipped from registration + +**And** the reason is emitted to debug logs + +#### Scenario: Module with incompatible core constraint is skipped + +**Given** an enabled module declares a `core_compatibility` range that does not include the current CLI version + +**When** command registration runs + +**Then** the module is skipped from registration + +**And** debug logs include the module id, required range, and current version + +### Requirement: Safe-disable enforcement in init workflow + +The system SHALL prevent disabling modules that are required by enabled dependent modules unless the user explicitly forces the action. + +#### Scenario: Unsafe disable is blocked without force + +**Given** module `A` is enabled and depends on module `B` + +**When** the user runs `specfact init --disable-module B` + +**Then** the command exits with an error + +**And** the error lists enabled dependents that require `B` + +**And** the output includes a hint to disable dependents first or use `--force` + +#### Scenario: Unsafe disable can be overridden with force + +**Given** module `A` is enabled and depends on module `B` + +**When** the user runs `specfact init --disable-module B --force` + +**Then** module `B` is disabled + +**And** enabled dependents of `B` are also disabled transitively + +**And** the command proceeds with force-override semantics + +#### Scenario: Force enable auto-enables upstream dependencies + +**Given** module `A` depends on module `B` + +**And** module `B` is currently disabled + +**When** the user runs `specfact init --enable-module A --force` + +**Then** module `A` is enabled + +**And** required upstream dependencies (including `B`) are enabled transitively + +### Requirement: Module state visibility and selection UX in init workflow + +The system SHALL provide module status visibility and interactive selection ergonomics for enable/disable operations, while preserving explicit module-id requirements in non-interactive mode. + +#### Scenario: Installed modules can be listed with enabled/disabled state + +**Given** module metadata is discoverable and module state may contain prior enable/disable overrides + +**When** the user runs `specfact init --list-modules` + +**Then** the command outputs each discovered module with its enabled or disabled status + +**And** output reflects the effective merged state from discovered manifests and persisted registry state + +#### Scenario: Interactive enable selection uses arrow-key menu + +**Given** the terminal is interactive + +**And** the user requests module enablement without explicit module ids + +**When** the command runs interactive module selection + +**Then** the user can pick a module using an up/down selection menu + +**And** the selected module is added to the enable list before state persistence + +#### Scenario: Interactive disable selection uses arrow-key menu + +**Given** the terminal is interactive + +**And** the user requests module disablement without explicit module ids + +**When** the command runs interactive module selection + +**Then** the user can pick a module using an up/down selection menu + +**And** the selected module is added to the disable list before safe-disable validation and state persistence + +#### Scenario: Non-interactive mode requires explicit module ids + +**Given** the command is running in non-interactive mode + +**When** the user requests module enablement or disablement without explicit module ids + +**Then** the command exits with an error + +**And** the error instructs the user to provide `--enable-module ` or `--disable-module ` + +### Requirement: Split bootstrap init from IDE template initialization + +The system SHALL separate bootstrap/module lifecycle initialization from IDE prompt/template side effects. + +#### Scenario: Top-level init is bootstrap-only + +**Given** the user runs `specfact init` + +**When** bootstrap and module-state checks complete + +**Then** the command does not copy or mutate IDE prompt/template files + +**And** it reports prompt installation health with guidance to run `specfact init ide` + +#### Scenario: IDE setup is handled by init ide + +**Given** the user runs `specfact init ide` + +**When** IDE prompt setup executes + +**Then** prompt/template files and IDE settings are created or updated for the selected IDE + +**And** in interactive mode without `--ide`, IDE selection is provided via up/down selection UI + +**And** in non-interactive mode, setup runs directly using explicit `--ide` or auto-detected IDE + +### Requirement: Boundary guard for cross-module command imports + +The test suite SHALL fail when any module imports non-`app` symbols from another module's `src.commands` package. + +#### Scenario: Boundary guard detects cross-module non-app command imports + +**Given** a module source file imports a non-`app` symbol from `specfact_cli.modules..src.commands` + +**When** boundary guard tests execute + +**Then** tests fail with a clear violation list + +**And** guidance points developers to shared utility modules for reusable helpers diff --git a/openspec/changes/arch-03-module-lifecycle-management/tasks.md b/openspec/changes/arch-03-module-lifecycle-management/tasks.md new file mode 100644 index 00000000..5bd04ffd --- /dev/null +++ b/openspec/changes/arch-03-module-lifecycle-management/tasks.md @@ -0,0 +1,108 @@ +# Tasks: Module Lifecycle Management for Dependencies, Compatibility, and Safe Disable + +## TDD / SDD order (enforced) + +Per `openspec/config.yaml`, **tests before code** apply to any task that adds or changes behavior. + +1. **Spec deltas** define behavior (Given/When/Then) in `openspec/changes/arch-03-module-lifecycle-management/specs/module-lifecycle-management/spec.md`. +2. **Tests second**: Write unit/integration tests from those scenarios; run tests and **expect failure** (no implementation yet). +3. **Code last**: Implement until tests pass and behavior satisfies the spec. + +Do not implement production code for new behavior until the corresponding tests exist and have been run (expecting failure). + +--- + +## 1. Create git branch from dev + +- [x] 1.1 Ensure `dev` is checked out and up to date: `git checkout dev && git pull origin dev` +- [x] 1.2 Create branch linked to issue when available: `gh issue develop 203 --repo nold-ai/specfact-cli --name feature/arch-03-module-lifecycle-management --checkout` +- [x] 1.3 Fallback branch creation when no issue exists: `git checkout -b feature/arch-03-module-lifecycle-management` (not needed; issue-linked branch created) +- [x] 1.4 Verify current branch: `git branch --show-current` + +## 2. Create GitHub issue in target repository + +- [x] 2.1 Create sanitized issue in `nold-ai/specfact-cli` with labels `enhancement` and `change-proposal` +- [x] 2.2 Use title format `[Change] Module lifecycle management for dependency validation and safe disable` +- [x] 2.3 Update `proposal.md` Source Tracking with issue number, URL, repository, and status `proposed` + +## 3. Finalize spec delta (SDD) + +- [x] 3.1 Confirm `specs/module-lifecycle-management/spec.md` covers dependency validation, compatibility checks, safe-disable behavior, and boundary guard expectations +- [x] 3.2 Map each scenario to test tasks before implementation tasks + +## 4. Tests first for lifecycle behavior (TDD) + +- [x] 4.1 Add/update registry tests for dependency validation and safe-disable reverse dependency logic (expect failure before implementation) +- [x] 4.2 Add/update version compatibility tests for `core_compatibility` parsing and boundary ranges (expect failure before implementation) +- [x] 4.3 Add/update tests for extracted bundle converter helpers and constitution minimality utility (expect failure before implementation) +- [x] 4.4 Add/update boundary guard tests preventing cross-module non-`app` imports from `src.commands` (expect failure before implementation) + +## 5. Implement shared helper extraction + +- [x] 5.1 Create `src/specfact_cli/utils/bundle_converters.py` with conversion and constitution helpers plus contracts +- [x] 5.2 Redirect imports in sync/generate/enforce to core utility helpers +- [x] 5.3 Replace plan/sdd helper implementations with compatibility delegates where needed +- [x] 5.4 Re-run targeted tests for helper extraction changes + +## 6. Implement manifest and registry lifecycle validation + +- [x] 6.1 Extend module manifest model and parsing with optional `core_compatibility` +- [x] 6.2 Add `core_compatibility` to all module manifests and reconcile `module_dependencies` updates +- [x] 6.3 Add registry checks for compatibility and dependency validation before command registration +- [x] 6.4 Ensure skipped modules are debug-logged with clear reasons + +## 7. Implement safe-disable behavior in init + +- [x] 7.1 Add reverse dependency utility and disable validation helper +- [x] 7.2 Add init command guard that blocks unsafe disable with actionable message +- [x] 7.3 Add `--force` override support and verify behavior parity + +## 8. Quality gates + +- [x] 8.1 Run formatting and linting checks: `hatch run format` and `hatch run lint` +- [x] 8.2 Run strict type checks: `hatch run type-check` +- [x] 8.3 Run contract-first validation: `hatch run contract-test` +- [x] 8.4 Run scenario-relevant tests and full suite as required: `hatch run smart-test` and/or `hatch test --cover -v` +- [x] 8.5 Verify all module command helps resolve after changes + +## 9. Documentation research and review + +- [x] 9.1 Identify affected docs in `docs/`, `docs/index.md`, and `README.md` for module lifecycle behavior +- [x] 9.2 Update/add user-facing docs for manifest `core_compatibility`, dependency enforcement, and safe-disable semantics +- [x] 9.3 If pages are added/moved, update front-matter and `docs/_layouts/default.html` sidebar links (N/A: no pages added or moved) + +## 10. Version and changelog + +- [x] 10.1 Bump version according to semver impact and sync in `pyproject.toml`, `setup.py`, `src/__init__.py`, and `src/specfact_cli/__init__.py` +- [x] 10.2 Add `CHANGELOG.md` entry for lifecycle validation and safe-disable behavior + +## 11. Create Pull Request to dev (last) + +- [x] 11.1 Commit with conventional commit message +- [x] 11.2 Push branch: `git push origin feature/arch-03-module-lifecycle-management` +- [x] 11.3 Create PR to `dev` using repository template and include `Fixes nold-ai/specfact-cli#` +- [ ] 11.4 Verify issue Development links include branch and PR + +## 12. Extend module lifecycle UX for listing and interactive selection + +- [x] 12.1 Add tests first for `specfact init --list-modules` effective enabled/disabled output +- [x] 12.2 Add tests first for interactive up/down selection flow for enable/disable when ids are not passed +- [x] 12.3 Add tests first for non-interactive validation requiring explicit module ids +- [x] 12.4 Implement `--list-modules` in init command with effective merged state output +- [x] 12.5 Implement interactive module selection using questionary for enable/disable requests without explicit ids +- [x] 12.6 Implement non-interactive guardrail error messaging for id-less enable/disable requests +- [x] 12.7 Run focused tests for new module UX behavior + +## 13. Split init bootstrap from IDE setup + +- [x] 13.1 Add `init ide` command for prompt/template copy and IDE settings update behavior +- [x] 13.2 Keep top-level `init` bootstrap/module-management only (no template copy side effects) +- [x] 13.3 Add prompt status audit messaging in bootstrap init with guidance to run `specfact init ide` +- [x] 13.4 Add interactive IDE selection (questionary up/down) when `init ide` runs without `--ide` +- [x] 13.5 Update tests to target `init ide` for template copy behavior and keep bootstrap-init regression coverage + +## 14. Force-mode dependency cascade + +- [x] 14.1 In `--force` disable flows, cascade-disable enabled dependents transitively +- [x] 14.2 In `--force` enable flows, cascade-enable required dependencies transitively +- [x] 14.3 Add regression tests for both force-mode cascades in registry/init lifecycle tests diff --git a/pyproject.toml b/pyproject.toml index 995ccdbb..419251e1 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.28.0" +version = "0.29.0" description = "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with validation and contract enforcement for new projects and long-lived codebases." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 6a7f79dd..a2e2187b 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.28.0", + version="0.29.0", description=( "The swiss knife CLI for agile DevOps teams. Keep backlog, specs, tests, and code in sync with " "validation and contract enforcement for new projects and long-lived codebases." diff --git a/src/__init__.py b/src/__init__.py index 8a9a0e05..0b8990ac 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -3,4 +3,4 @@ """ # Package version: keep in sync with pyproject.toml, setup.py, src/specfact_cli/__init__.py -__version__ = "0.28.0" +__version__ = "0.29.0" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index fb8f6639..d0f19675 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -8,6 +8,6 @@ - Supporting agile ceremonies and team workflows """ -__version__ = "0.28.0" +__version__ = "0.29.0" __all__ = ["__version__"] diff --git a/src/specfact_cli/adapters/ado.py b/src/specfact_cli/adapters/ado.py index 0c94c293..6a9c2bcf 100644 --- a/src/specfact_cli/adapters/ado.py +++ b/src/specfact_cli/adapters/ado.py @@ -14,7 +14,7 @@ import re from datetime import UTC, datetime from pathlib import Path -from typing import Any +from typing import Any, cast from urllib.parse import urlparse import requests @@ -549,7 +549,15 @@ def extract_change_proposal_data(self, item_data: dict[str, Any]) -> dict[str, A assigned_to = fields.get("System.AssignedTo") if assigned_to: if isinstance(assigned_to, dict): - assignee_name = assigned_to.get("displayName") or assigned_to.get("uniqueName", "") + assignee_dict = cast(dict[str, Any], assigned_to) + display_name = assignee_dict.get("displayName") + unique_name = assignee_dict.get("uniqueName") + if isinstance(display_name, str) and display_name.strip(): + assignee_name = display_name.strip() + elif isinstance(unique_name, str): + assignee_name = unique_name + else: + assignee_name = "" else: assignee_name = str(assigned_to) if assignee_name and not owner: diff --git a/src/specfact_cli/cli.py b/src/specfact_cli/cli.py index 864090dc..4e1e9860 100644 --- a/src/specfact_cli/cli.py +++ b/src/specfact_cli/cli.py @@ -257,7 +257,7 @@ def main( bool | None, typer.Option( "--interactive/--no-interactive", - help="Force interaction mode (default auto based on CI/CD detection)", + help="Force interaction mode (default auto based on terminal/CI detection)", ), ] = None, ) -> None: @@ -274,6 +274,10 @@ def main( - Explicit --mode flag (highest priority) - Auto-detect from environment (CoPilot API, IDE integration) - Default to CI/CD mode + + Interaction Detection: + - Explicit --interactive/--no-interactive (highest priority) + - Auto-detect from terminal and CI environment """ global _show_banner # Set banner flag based on --banner option @@ -332,6 +336,33 @@ def __init__(self, cmd_name: str, help_str: str, name: str | None = None, help: def _make_delegate_command(self) -> click.Command: cmd_name = self._lazy_cmd_name + def _normalize_init_optional_module_flags(argv: list[str]) -> list[str]: + """ + Normalize bare init module flags to sentinel values. + + Typer/Click options declared with value types require an argument. To support + UX like `specfact init --enable-module` in interactive mode, rewrite bare flags + to include a sentinel token consumed by init command logic. + """ + if cmd_name != "init": + return argv + out: list[str] = [] + i = 0 + while i < len(argv): + token = argv[i] + if token in ("--enable-module", "--disable-module"): + out.append(token) + if i + 1 < len(argv) and not argv[i + 1].startswith("-"): + out.append(argv[i + 1]) + i += 2 + continue + out.append("__interactive_select__") + i += 1 + continue + out.append(token) + i += 1 + return out + def _invoke(args: tuple[str, ...]) -> None: from typer.main import get_command @@ -348,6 +379,7 @@ def _invoke(args: tuple[str, ...]) -> None: p = getattr(p, "parent", None) prog_name = " ".join(reversed(parts)) if parts else cmd_name args_list = list(args) + args_list = _normalize_init_optional_module_flags(args_list) # When the real app is a single command (e.g. drift has only "detect"), Typer # builds a TyperCommand, not a Group. Then args are ["detect", "bundle", "--repo", ...] # and the command expects ["bundle", "--repo", ...] (no leading "detect"). diff --git a/src/specfact_cli/modules/analyze/module-package.yaml b/src/specfact_cli/modules/analyze/module-package.yaml index 98d4d060..83377c55 100644 --- a/src/specfact_cli/modules/analyze/module-package.yaml +++ b/src/specfact_cli/modules/analyze/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/auth/module-package.yaml b/src/specfact_cli/modules/auth/module-package.yaml index f650a292..129e04c0 100644 --- a/src/specfact_cli/modules/auth/module-package.yaml +++ b/src/specfact_cli/modules/auth/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/backlog/module-package.yaml b/src/specfact_cli/modules/backlog/module-package.yaml index 7f1816aa..20eb700a 100644 --- a/src/specfact_cli/modules/backlog/module-package.yaml +++ b/src/specfact_cli/modules/backlog/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/contract/module-package.yaml b/src/specfact_cli/modules/contract/module-package.yaml index ed4ef14f..4b6b3767 100644 --- a/src/specfact_cli/modules/contract/module-package.yaml +++ b/src/specfact_cli/modules/contract/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/drift/module-package.yaml b/src/specfact_cli/modules/drift/module-package.yaml index c1e7aa08..aa069e32 100644 --- a/src/specfact_cli/modules/drift/module-package.yaml +++ b/src/specfact_cli/modules/drift/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/enforce/module-package.yaml b/src/specfact_cli/modules/enforce/module-package.yaml index 4e1dbe70..e6c47214 100644 --- a/src/specfact_cli/modules/enforce/module-package.yaml +++ b/src/specfact_cli/modules/enforce/module-package.yaml @@ -9,3 +9,4 @@ pip_dependencies: [] module_dependencies: - plan tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/enforce/src/commands.py b/src/specfact_cli/modules/enforce/src/commands.py index 00323f82..2e41c06b 100644 --- a/src/specfact_cli/modules/enforce/src/commands.py +++ b/src/specfact_cli/modules/enforce/src/commands.py @@ -296,9 +296,9 @@ def enforce_sdd( raise typer.Exit(1) # Convert to PlanBundle for compatibility with validation functions - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) # Create validation report report = ValidationReport() diff --git a/src/specfact_cli/modules/generate/module-package.yaml b/src/specfact_cli/modules/generate/module-package.yaml index 743c6d8d..9b798791 100644 --- a/src/specfact_cli/modules/generate/module-package.yaml +++ b/src/specfact_cli/modules/generate/module-package.yaml @@ -9,3 +9,4 @@ pip_dependencies: [] module_dependencies: - plan tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/generate/src/commands.py b/src/specfact_cli/modules/generate/src/commands.py index f2337d11..176e9c62 100644 --- a/src/specfact_cli/modules/generate/src/commands.py +++ b/src/specfact_cli/modules/generate/src/commands.py @@ -241,7 +241,7 @@ def generate_contracts( plan_hash = None if format_type == BundleFormat.MODULAR or bundle: # Load modular ProjectBundle and convert to PlanBundle for compatibility - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle project_bundle = load_bundle_with_progress(plan_path, validate_hashes=False, console_instance=console) @@ -250,7 +250,7 @@ def generate_contracts( plan_hash = summary.content_hash # Convert to PlanBundle for ContractGenerator compatibility - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) else: # Load monolithic PlanBundle plan_bundle = load_plan_bundle(plan_path) diff --git a/src/specfact_cli/modules/import_cmd/module-package.yaml b/src/specfact_cli/modules/import_cmd/module-package.yaml index 04a5a43e..c2090ffb 100644 --- a/src/specfact_cli/modules/import_cmd/module-package.yaml +++ b/src/specfact_cli/modules/import_cmd/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/init/module-package.yaml b/src/specfact_cli/modules/init/module-package.yaml index 78d5636d..0f5f8411 100644 --- a/src/specfact_cli/modules/init/module-package.yaml +++ b/src/specfact_cli/modules/init/module-package.yaml @@ -4,7 +4,8 @@ version: "0.27.0" commands: - init command_help: - init: "Initialize SpecFact for IDE integration" + init: "Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup)" pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/init/src/commands.py b/src/specfact_cli/modules/init/src/commands.py index d6cf9fd1..07f893da 100644 --- a/src/specfact_cli/modules/init/src/commands.py +++ b/src/specfact_cli/modules/init/src/commands.py @@ -1,8 +1,8 @@ """ -Init command - Initialize SpecFact for IDE integration. +Init commands for bootstrap, module lifecycle management, and IDE setup. -This module provides the `specfact init` command to copy prompt templates -to IDE-specific locations for slash command integration. +`specfact init` handles bootstrap and module enable/disable lifecycle state. +`specfact init ide` handles IDE prompt/template setup and optional dependency installation. """ from __future__ import annotations @@ -10,22 +10,36 @@ import subprocess import sys from pathlib import Path +from typing import Any +import click import typer from beartype import beartype from icontract import ensure, require from rich.console import Console from rich.panel import Panel +from rich.rule import Rule +from rich.table import Table from specfact_cli import __version__ from specfact_cli.registry.help_cache import run_discovery_and_write_cache -from specfact_cli.registry.module_packages import get_discovered_modules_for_state -from specfact_cli.registry.module_state import write_modules_state -from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode +from specfact_cli.registry.module_packages import ( + discover_package_metadata, + expand_disable_with_dependents, + expand_enable_with_dependencies, + get_discovered_modules_for_state, + get_modules_root, + merge_module_state, + validate_disable_safe, + validate_enable_safe, +) +from specfact_cli.registry.module_state import read_modules_state, write_modules_state +from specfact_cli.runtime import debug_log_operation, debug_print, is_debug_mode, is_non_interactive from specfact_cli.telemetry import telemetry -from specfact_cli.utils.env_manager import EnvManager, build_tool_command, detect_env_manager +from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo, build_tool_command, detect_env_manager from specfact_cli.utils.ide_setup import ( IDE_CONFIG, + SPECFACT_COMMANDS, copy_templates_to_ide, detect_ide, find_package_resources_path, @@ -111,8 +125,225 @@ def _copy_backlog_field_mapping_templates(repo_path: Path, force: bool, console: console.print("[dim]Backlog field mapping templates already exist (use --force to overwrite)[/dim]") -app = typer.Typer(help="Initialize SpecFact for IDE integration") +app = typer.Typer(help="Bootstrap SpecFact and manage module lifecycle (use `init ide` for IDE setup)") console = Console() +MODULE_SELECT_SENTINEL = "__interactive_select__" + + +def _install_contract_enhancement_dependencies(repo_path: Path, env_info: EnvManagerInfo) -> None: + """Install contract enhancement dependencies in the detected environment.""" + required_packages = [ + "beartype>=0.22.4", + "icontract>=2.7.1", + "crosshair-tool>=0.0.97", + "pytest>=8.4.2", + ] + install_cmd = build_tool_command(env_info, ["pip", "install", "-U", *required_packages]) + result = subprocess.run( + install_cmd, + capture_output=True, + text=True, + check=False, + cwd=str(repo_path), + timeout=300, + ) + if result.returncode == 0: + console.print("[green]✓[/green] Dependencies installed") + else: + console.print("[yellow]⚠[/yellow] Dependency installation reported issues") + + +def _questionary_style() -> Any: + """Return a shared questionary color theme for interactive selectors.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError: + return None + return questionary.Style( + [ + ("qmark", "fg:#00af87 bold"), + ("question", "bold"), + ("answer", "fg:#00af87 bold"), + ("pointer", "fg:#5f87ff bold"), + ("highlighted", "fg:#5f87ff bold"), + ("selected", "fg:#00af87 bold"), + ("instruction", "fg:#808080 italic"), + ("separator", "fg:#808080"), + ("text", ""), + ("disabled", "fg:#6c6c6c"), + ] + ) + + +def _render_modules_table(modules_list: list[dict[str, Any]]) -> None: + """Render discovered modules with effective enabled/disabled state.""" + table = Table(title="Installed Modules") + table.add_column("Module", style="cyan") + table.add_column("Version", style="magenta") + table.add_column("State", style="green") + for module in modules_list: + module_id = str(module.get("id", "")) + version = str(module.get("version", "")) + enabled = bool(module.get("enabled", True)) + state = "enabled" if enabled else "disabled" + table.add_row(module_id, version, state) + console.print(table) + + +def _select_module_ids_interactive(action: str, modules_list: list[dict[str, Any]]) -> list[str]: + """Select one or more module IDs interactively via arrow-key checkbox menu.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError as e: + console.print( + "[red]Interactive module selection requires 'questionary'. Install with: pip install questionary[/red]" + ) + raise typer.Exit(1) from e + + target_enabled = action == "disable" + candidates = [m for m in modules_list if bool(m.get("enabled", True)) is target_enabled] + if not candidates: + console.print(f"[yellow]No modules available to {action}.[/yellow]") + return [] + + action_title = "Enable" if action == "enable" else "Disable" + current_state = "disabled" if action == "enable" else "enabled" + console.print() + console.print( + Panel( + f"[bold cyan]{action_title} Modules[/bold cyan]\n" + f"Choose one or more currently [bold]{current_state}[/bold] modules.", + border_style="cyan", + ) + ) + console.print( + "[dim]Controls: ↑↓ navigate • Space toggle • Enter confirm • Type to search/filter • Ctrl+C cancel[/dim]" + ) + + action_title = "Enable" if action == "enable" else "Disable" + current_state = "disabled" if action == "enable" else "enabled" + display_to_id: dict[str, str] = {} + choices: list[str] = [] + for module in candidates: + module_id = str(module.get("id", "")) + version = str(module.get("version", "")) + state = "enabled" if bool(module.get("enabled", True)) else "disabled" + marker = "✗" if state == "disabled" else "✓" + display = f"{marker} {module_id:<14} [{state}] v{version}" + display_to_id[display] = module_id + choices.append(display) + + selected: list[str] | None = questionary.checkbox( + f"{action_title} module(s) from currently {current_state}:", + choices=choices, + instruction="(multi-select)", + style=_questionary_style(), + ).ask() + if not selected: + return [] + return [display_to_id[s] for s in selected if s in display_to_id] + + +def _resolve_templates_dir(repo_path: Path) -> Path | None: + """Resolve templates directory from repo checkout or installed package.""" + dev_templates_dir = (repo_path / "resources" / "prompts").resolve() + if dev_templates_dir.exists(): + return dev_templates_dir + try: + import importlib.resources + + resources_ref = importlib.resources.files("specfact_cli") + templates_ref = resources_ref / "resources" / "prompts" + package_templates_dir = Path(str(templates_ref)).resolve() + if package_templates_dir.exists(): + return package_templates_dir + except Exception: + pass + return find_package_resources_path("specfact_cli", "resources/prompts") + + +def _audit_prompt_installation(repo_path: Path) -> None: + """Report prompt installation health and next steps without mutating files.""" + detected_ide = detect_ide("auto") + config = IDE_CONFIG[detected_ide] + ide_dir = repo_path / str(config["folder"]) + format_type = str(config["format"]) + if format_type == "prompt.md": + expected_files = [f"{cmd}.prompt.md" for cmd in SPECFACT_COMMANDS] + elif format_type == "toml": + expected_files = [f"{cmd}.toml" for cmd in SPECFACT_COMMANDS] + else: + expected_files = [f"{cmd}.md" for cmd in SPECFACT_COMMANDS] + + if not ide_dir.exists(): + console.print( + f"[yellow]Prompt status:[/yellow] no prompts found for detected IDE ({detected_ide}). " + f"Run [bold]specfact init ide --ide {detected_ide}[/bold]." + ) + return + + missing = [name for name in expected_files if not (ide_dir / name).exists()] + templates_dir = _resolve_templates_dir(repo_path) + outdated = 0 + if templates_dir: + for cmd in SPECFACT_COMMANDS: + src = templates_dir / f"{cmd}.md" + if format_type == "prompt.md": + dest = ide_dir / f"{cmd}.prompt.md" + elif format_type == "toml": + dest = ide_dir / f"{cmd}.toml" + else: + dest = ide_dir / f"{cmd}.md" + if src.exists() and dest.exists() and dest.stat().st_mtime < src.stat().st_mtime: + outdated += 1 + + if not missing and outdated == 0: + console.print(f"[green]Prompt status:[/green] {detected_ide} prompts are present and up to date.") + return + + console.print( + f"[yellow]Prompt status:[/yellow] missing={len(missing)}, outdated={outdated} for detected IDE ({detected_ide})." + ) + console.print(f"[dim]Run: specfact init ide --ide {detected_ide}{' --force' if outdated > 0 else ''}[/dim]") + + +def _select_ide_interactive(default_ide: str) -> str: + """Select IDE interactively with up/down controls.""" + try: + import questionary # type: ignore[reportMissingImports] + except ImportError as e: + console.print( + "[red]Interactive IDE selection requires 'questionary'. Install with: pip install questionary[/red]" + ) + raise typer.Exit(1) from e + + choices: list[str] = [] + label_to_ide: dict[str, str] = {} + console.print() + console.print( + Panel( + "[bold cyan]IDE Prompt Setup[/bold cyan]\nSelect your editor/assistant integration target.", + border_style="cyan", + ) + ) + console.print("[dim]Controls: ↑↓ navigate • Enter select • Type to filter • Ctrl+C cancel[/dim]") + for ide_id, cfg in IDE_CONFIG.items(): + default_marker = "★" if ide_id == default_ide else " " + label = f"{default_marker} {cfg['name']:<24} ({ide_id})" + label_to_ide[label] = ide_id + choices.append(label) + + default_label = next((lbl for lbl, iid in label_to_ide.items() if iid == default_ide), choices[0]) + selected = questionary.select( + "Select IDE for prompt setup", + choices=choices, + default=default_label, + style=_questionary_style(), + ).ask() + if not selected: + raise typer.Exit(1) + console.print(Rule(style="dim")) + return label_to_ide[str(selected)] def _is_valid_repo_path(path: Path) -> bool: @@ -120,12 +351,87 @@ def _is_valid_repo_path(path: Path) -> bool: return path.exists() and path.is_dir() +@app.command("ide") +@require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") +@ensure(lambda result: result is None, "Command should return None") +@beartype +def init_ide( + repo: Path = typer.Option( + Path("."), + "--repo", + help="Repository path (default: current directory)", + exists=True, + file_okay=False, + dir_okay=True, + ), + force: bool = typer.Option(False, "--force", help="Overwrite existing files"), + install_deps: bool = typer.Option( + False, + "--install-deps", + help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest)", + ), + ide: str | None = typer.Option( + None, + "--ide", + help="IDE type (cursor, vscode, copilot, claude, gemini, qwen, opencode, windsurf, kilocode, auggie, roo, codebuddy, amp, q, auto)", + ), +) -> None: + """Initialize IDE prompt templates and settings.""" + repo_path = repo.resolve() + detected_default = detect_ide("auto") + if ide is not None: + selected_ide = detect_ide(ide) + elif is_non_interactive(): + selected_ide = detected_default + else: + selected_ide = _select_ide_interactive(detected_default) + + ide_config = IDE_CONFIG[selected_ide] + ide_name = str(ide_config["name"]) + + console.print() + console.print(Panel("[bold cyan]SpecFact IDE Setup[/bold cyan]", border_style="cyan")) + console.print(f"[cyan]Repository:[/cyan] {repo_path}") + console.print(f"[cyan]IDE:[/cyan] {ide_name} ({selected_ide})") + console.print() + + env_info = detect_env_manager(repo_path) + if env_info.manager == EnvManager.UNKNOWN: + console.print( + Panel( + "[bold yellow]⚠ No Compatible Environment Manager Detected[/bold yellow]", + border_style="yellow", + ) + ) + console.print("[dim]Supported tools: hatch, poetry, uv, pip[/dim]") + console.print() + + if install_deps: + _install_contract_enhancement_dependencies(repo_path, env_info) + + templates_dir = _resolve_templates_dir(repo_path) + if not templates_dir or not templates_dir.exists(): + console.print("[red]Error:[/red] Templates directory not found.") + raise typer.Exit(1) + + console.print(f"[cyan]Templates:[/cyan] {templates_dir}") + copied_files, settings_path = copy_templates_to_ide(repo_path, selected_ide, templates_dir, force) + _copy_backlog_field_mapping_templates(repo_path, force, console) + + console.print() + console.print(Panel("[bold green]✓ IDE Initialization Complete[/bold green]", border_style="green")) + console.print(f"[green]Copied {len(copied_files)} template(s) to {ide_config['folder']}[/green]") + if settings_path: + console.print(f"[green]Updated VS Code settings:[/green] {settings_path}") + + @app.callback(invoke_without_command=True) @require(lambda ide: ide in IDE_CONFIG or ide == "auto", "IDE must be valid or 'auto'") @require(lambda repo: _is_valid_repo_path(repo), "Repo path must exist and be directory") @ensure(lambda result: result is None, "Command should return None") @beartype def init( + ctx: click.Context, # Target/Input repo: Path = typer.Option( Path("."), @@ -139,12 +445,18 @@ def init( force: bool = typer.Option( False, "--force", - help="Overwrite existing files", + help=( + "Override module dependency safety checks. In force mode, disable cascades to dependents " + "and enable cascades to required dependencies." + ), ), install_deps: bool = typer.Option( False, "--install-deps", - help="Install required packages for contract enhancement (beartype, icontract, crosshair-tool, pytest) using detected environment manager", + help=( + "Install required packages for contract enhancement. Prefer `specfact init ide --install-deps` " + "for IDE setup flow." + ), ), # Advanced/Configuration ide: str = typer.Option( @@ -156,42 +468,127 @@ def init( enable_module: list[str] = typer.Option( [], "--enable-module", - help="Enable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", + help=( + "Enable module by id (repeatable); if provided without value in interactive mode, " + "opens selector. In non-interactive mode, a module id is required." + ), ), disable_module: list[str] = typer.Option( [], "--disable-module", - help="Disable module by id (repeatable); persisted in ~/.specfact/registry/modules.json", + help=( + "Disable module by id (repeatable); if provided without value in interactive mode, " + "opens selector. In non-interactive mode, a module id is required." + ), + ), + list_modules: bool = typer.Option( + False, + "--list-modules", + help="List discovered installed modules with enabled/disabled status and exit.", ), ) -> None: """ - Initialize SpecFact for IDE integration. + Bootstrap SpecFact local state and manage module lifecycle. - Copies prompt templates to IDE-specific locations so slash commands work. - This command detects the IDE type (or uses --ide flag) and copies - SpecFact prompt templates to the appropriate directory. - - Also copies backlog field mapping templates to `.specfact/templates/backlog/field_mappings/` - for custom ADO field mapping configuration. + This command initializes/updates user-level module registry state, discovers + installed modules, and manages enabled/disabled module lifecycle with dependency + safety checks. Use `specfact init ide` for IDE prompt/template setup. Examples: - specfact init # Auto-detect IDE - specfact init --ide cursor # Initialize for Cursor - specfact init --ide vscode --force # Overwrite existing files - specfact init --repo /path/to/repo --ide copilot - specfact init --install-deps # Install required packages for contract enhancement + specfact init # Bootstrap and discover modules + specfact init --list-modules # Show enabled/disabled modules + specfact init --enable-module # Interactive module selector (TTY) + specfact init --disable-module sync # Disable explicit module + specfact init --enable-module plan --force # Cascade-enable dependencies + specfact init ide --ide cursor # IDE prompt/template setup + specfact init ide --install-deps # Install contract enhancement dependencies """ telemetry_metadata = { "ide": ide, "force": force, "install_deps": install_deps, + "list_modules": list_modules, } with telemetry.track_command("init", telemetry_metadata) as record: + if ctx.invoked_subcommand is not None: + return + repo_path = repo.resolve() + module_management_requested = any( + [ + bool(enable_module), + bool(disable_module), + list_modules, + ] + ) + enable_ids = list(enable_module) + disable_ids = list(disable_module) + + requested_enable_interactive = MODULE_SELECT_SENTINEL in enable_ids + requested_disable_interactive = MODULE_SELECT_SENTINEL in disable_ids + enable_ids = [mid for mid in enable_ids if mid and mid != MODULE_SELECT_SENTINEL] + disable_ids = [mid for mid in disable_ids if mid and mid != MODULE_SELECT_SENTINEL] + + if is_non_interactive() and (requested_enable_interactive or requested_disable_interactive): + console.print( + "[red]Error:[/red] Non-interactive mode requires explicit module id values. " + "Use --enable-module or --disable-module ." + ) + raise typer.Exit(1) + + if requested_enable_interactive: + discovered = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) + selected = _select_module_ids_interactive("enable", discovered) + enable_ids.extend(selected) + if selected: + module_management_requested = True + + if requested_disable_interactive: + discovered = get_discovered_modules_for_state(enable_ids=[], disable_ids=[]) + selected = _select_module_ids_interactive("disable", discovered) + disable_ids.extend(selected) + if selected: + module_management_requested = True + + packages = discover_package_metadata(get_modules_root()) + discovered_list = [(meta.name, meta.version) for _package_dir, meta in packages] + state = read_modules_state() + + if force and enable_ids: + enable_ids = expand_enable_with_dependencies(enable_ids, packages) + + enabled_map = merge_module_state(discovered_list, state, enable_ids, []) + + if enable_ids and not force: + blocked_enable = validate_enable_safe(enable_ids, packages, enabled_map) + if blocked_enable: + for module_id, missing in blocked_enable.items(): + console.print( + f"[red]Error:[/red] Cannot enable '{module_id}': missing required dependencies: " + f"{', '.join(missing)}" + ) + console.print( + "[dim]Hint: Enable dependencies first, or use --force to auto-enable required dependencies[/dim]" + ) + raise typer.Exit(1) + + if disable_ids: + if force: + disable_ids = expand_disable_with_dependents(disable_ids, packages, enabled_map) + blocked = validate_disable_safe(disable_ids, packages, enabled_map) + if blocked and not force: + for module_id, dependents in blocked.items(): + console.print( + f"[red]Error:[/red] Cannot disable '{module_id}': required by enabled modules: " + f"{', '.join(dependents)}" + ) + console.print("[dim]Hint: Disable dependent modules first, or use --force to override[/dim]") + raise typer.Exit(1) + # Update module state (enable/disable) and persist; then refresh help cache modules_list = get_discovered_modules_for_state( - enable_ids=enable_module, - disable_ids=disable_module, + enable_ids=enable_ids, + disable_ids=disable_ids, ) if modules_list: write_modules_state(modules_list) @@ -203,6 +600,25 @@ def init( "Re-enable with specfact init --enable-module .[/dim]" ) run_discovery_and_write_cache(__version__) + if install_deps: + env_info = detect_env_manager(repo_path) + _install_contract_enhancement_dependencies(repo_path, env_info) + if list_modules: + console.print() + _render_modules_table(modules_list) + return + if module_management_requested: + console.print("[green]✓[/green] Module state updated.") + return + enabled_count = len([m for m in modules_list if bool(m.get("enabled", True))]) + disabled_count = len(modules_list) - enabled_count + console.print( + f"[green]✓[/green] Bootstrap complete. Modules discovered: {len(modules_list)} " + f"(enabled={enabled_count}, disabled={disabled_count})." + ) + _audit_prompt_installation(repo_path) + console.print("[dim]Use `specfact init ide` to install/update IDE prompts and settings.[/dim]") + return # Resolve repo path repo_path = repo.resolve() diff --git a/src/specfact_cli/modules/migrate/module-package.yaml b/src/specfact_cli/modules/migrate/module-package.yaml index 2317676e..7f0136be 100644 --- a/src/specfact_cli/modules/migrate/module-package.yaml +++ b/src/specfact_cli/modules/migrate/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/plan/module-package.yaml b/src/specfact_cli/modules/plan/module-package.yaml index e34eeec6..c82ddf48 100644 --- a/src/specfact_cli/modules/plan/module-package.yaml +++ b/src/specfact_cli/modules/plan/module-package.yaml @@ -9,3 +9,4 @@ pip_dependencies: [] module_dependencies: - sync tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/plan/src/commands.py b/src/specfact_cli/modules/plan/src/commands.py index d3777d96..de255d0c 100644 --- a/src/specfact_cli/modules/plan/src/commands.py +++ b/src/specfact_cli/modules/plan/src/commands.py @@ -4462,60 +4462,18 @@ def review( def _convert_project_bundle_to_plan_bundle(project_bundle: ProjectBundle) -> PlanBundle: - """ - Convert ProjectBundle to PlanBundle for compatibility with existing extraction functions. + """Convert ProjectBundle to PlanBundle via shared core helper.""" + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - Args: - project_bundle: ProjectBundle instance - - Returns: - PlanBundle instance - """ - return PlanBundle( - version="1.0", - idea=project_bundle.idea, - business=project_bundle.business, - product=project_bundle.product, - features=list(project_bundle.features.values()), - metadata=None, # ProjectBundle doesn't use Metadata, uses manifest instead - clarifications=project_bundle.clarifications, - ) + return convert_project_bundle_to_plan_bundle(project_bundle) @beartype def _convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: - """ - Convert PlanBundle to ProjectBundle (modular). - - Args: - plan_bundle: PlanBundle instance to convert - bundle_name: Project bundle name - - Returns: - ProjectBundle instance - """ - from specfact_cli.models.project import BundleManifest, BundleVersions + """Convert PlanBundle to ProjectBundle via shared core helper.""" + from specfact_cli.utils.bundle_converters import convert_plan_bundle_to_project_bundle - # Create manifest - manifest = BundleManifest( - versions=BundleVersions(schema="1.0", project="0.1.0"), - schema_metadata=None, - project_metadata=None, - ) - - # Convert features list to dict - features_dict: dict[str, Feature] = {f.key: f for f in plan_bundle.features} - - # Create and return ProjectBundle - return ProjectBundle( - manifest=manifest, - bundle_name=bundle_name, - idea=plan_bundle.idea, - business=plan_bundle.business, - product=plan_bundle.product, - features=features_dict, - clarifications=plan_bundle.clarifications, - ) + return convert_plan_bundle_to_project_bundle(plan_bundle, bundle_name) def _find_bundle_dir(bundle: str | None) -> Path | None: diff --git a/src/specfact_cli/modules/project/module-package.yaml b/src/specfact_cli/modules/project/module-package.yaml index 89d30ed0..86f42288 100644 --- a/src/specfact_cli/modules/project/module-package.yaml +++ b/src/specfact_cli/modules/project/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/repro/module-package.yaml b/src/specfact_cli/modules/repro/module-package.yaml index 2e9cebc8..3094d0e6 100644 --- a/src/specfact_cli/modules/repro/module-package.yaml +++ b/src/specfact_cli/modules/repro/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sdd/module-package.yaml b/src/specfact_cli/modules/sdd/module-package.yaml index 263284db..52c0f54c 100644 --- a/src/specfact_cli/modules/sdd/module-package.yaml +++ b/src/specfact_cli/modules/sdd/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sdd/src/commands.py b/src/specfact_cli/modules/sdd/src/commands.py index 90d6b102..343e020f 100644 --- a/src/specfact_cli/modules/sdd/src/commands.py +++ b/src/specfact_cli/modules/sdd/src/commands.py @@ -402,30 +402,7 @@ def constitution_validate( def is_constitution_minimal(constitution_path: Path) -> bool: - """ - Check if constitution is minimal (essentially empty). - - Args: - constitution_path: Path to constitution file - - Returns: - True if constitution is minimal, False otherwise - """ - if not constitution_path.exists(): - return True + """Check constitution minimality via shared core helper.""" + from specfact_cli.utils.bundle_converters import is_constitution_minimal as _core_is_constitution_minimal - try: - content = constitution_path.read_text(encoding="utf-8").strip() - # Check if it's just a header or very minimal - if not content or content == "# Constitution" or len(content) < 100: - return True - - # Check if it has mostly placeholders - import re - - placeholder_pattern = r"\[[A-Z_0-9]+\]" - placeholders = re.findall(placeholder_pattern, content) - lines = [line.strip() for line in content.split("\n") if line.strip()] - return bool(lines and len(placeholders) > len(lines) * 0.5) - except Exception: - return True + return _core_is_constitution_minimal(constitution_path) diff --git a/src/specfact_cli/modules/spec/module-package.yaml b/src/specfact_cli/modules/spec/module-package.yaml index 382b69de..33e79331 100644 --- a/src/specfact_cli/modules/spec/module-package.yaml +++ b/src/specfact_cli/modules/spec/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sync/module-package.yaml b/src/specfact_cli/modules/sync/module-package.yaml index 97a4f1a0..96970309 100644 --- a/src/specfact_cli/modules/sync/module-package.yaml +++ b/src/specfact_cli/modules/sync/module-package.yaml @@ -10,3 +10,4 @@ module_dependencies: - plan - sdd tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/sync/src/commands.py b/src/specfact_cli/modules/sync/src/commands.py index 651984a2..24592520 100644 --- a/src/specfact_cli/modules/sync/src/commands.py +++ b/src/specfact_cli/modules/sync/src/commands.py @@ -228,7 +228,7 @@ def _perform_sync_operation( if adapter_type == AdapterType.SPECKIT: constitution_path = repo / ".specify" / "memory" / "constitution.md" if constitution_path.exists(): - from specfact_cli.modules.sdd.src.commands import is_constitution_minimal + from specfact_cli.utils.bundle_converters import is_constitution_minimal if is_constitution_minimal(constitution_path): # Auto-generate in test mode, prompt in interactive mode @@ -359,7 +359,7 @@ def _perform_sync_operation( progress.update(task, description="[cyan]Parsing plan bundle YAML...[/cyan]") # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( @@ -367,7 +367,7 @@ def _perform_sync_operation( validate_hashes=False, console_instance=progress.console if hasattr(progress, "console") else None, ) - loaded_plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + loaded_plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) is_valid = True else: # It's a file (legacy monolithic bundle) - validate directly @@ -443,7 +443,7 @@ def _perform_sync_operation( # Fallback: load plan bundle from bundle name or default plan_bundle_to_convert = None if bundle: - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress bundle_dir = SpecFactStructure.project_dir(base_path=repo, bundle_name=bundle) @@ -451,7 +451,7 @@ def _perform_sync_operation( project_bundle = load_bundle_with_progress( bundle_dir, validate_hashes=False, console_instance=console ) - plan_bundle_to_convert = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle_to_convert = convert_project_bundle_to_plan_bundle(project_bundle) else: # Use get_default_plan_path() to find the active plan (legacy compatibility) plan_path: Path | None = None @@ -461,7 +461,7 @@ def _perform_sync_operation( progress.update(task, description="[cyan]Loading plan bundle...[/cyan]") # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( @@ -469,7 +469,7 @@ def _perform_sync_operation( validate_hashes=False, console_instance=progress.console if hasattr(progress, "console") else None, ) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) is_valid = True else: # It's a file (legacy monolithic bundle) - validate directly @@ -730,7 +730,7 @@ def _sync_tool_to_specfact( # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): is_modular_bundle = True - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( @@ -738,7 +738,7 @@ def _sync_tool_to_specfact( validate_hashes=False, console_instance=progress.console if hasattr(progress, "console") else None, ) - bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + bundle = convert_project_bundle_to_plan_bundle(project_bundle) is_valid = True else: # It's a file (legacy monolithic bundle) - validate directly @@ -905,9 +905,9 @@ def _sync_tool_to_specfact( save_project_bundle(project_bundle, bundle_dir, atomic=True) # Convert ProjectBundle to PlanBundle for merging logic - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - converted_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + converted_bundle = convert_project_bundle_to_plan_bundle(project_bundle) # Merge with existing plan if it exists features_updated = 0 @@ -1670,9 +1670,9 @@ def sync_bridge( bundle_dir, validate_hashes=False, console_instance=console ) # Convert to PlanBundle for validation (legacy compatibility) - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) else: console.print(f"[yellow]⚠ Bundle '{bundle}' not found, skipping compliance check[/yellow]") plan_bundle = None @@ -1683,13 +1683,13 @@ def sync_bridge( if plan_path and plan_path.exists(): # Check if path is a directory (modular bundle) - load it first if plan_path.is_dir(): - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress project_bundle = load_bundle_with_progress( plan_path, validate_hashes=False, console_instance=console ) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) else: # It's a file (legacy monolithic bundle) - validate directly validation_result = validate_plan_bundle(plan_path) @@ -1913,7 +1913,7 @@ def sync_callback(changes: list[FileChange]) -> None: if bundle: import asyncio - from specfact_cli.modules.plan.src.commands import _convert_project_bundle_to_plan_bundle + from specfact_cli.utils.bundle_converters import convert_project_bundle_to_plan_bundle from specfact_cli.utils.progress import load_bundle_with_progress from specfact_cli.utils.structure import SpecFactStructure @@ -1921,7 +1921,7 @@ def sync_callback(changes: list[FileChange]) -> None: if bundle_dir.exists(): console.print("\n[cyan]🔍 Validating OpenAPI contracts before sync...[/cyan]") project_bundle = load_bundle_with_progress(bundle_dir, validate_hashes=False, console_instance=console) - plan_bundle = _convert_project_bundle_to_plan_bundle(project_bundle) + plan_bundle = convert_project_bundle_to_plan_bundle(project_bundle) from specfact_cli.integrations.specmatic import ( check_specmatic_available, diff --git a/src/specfact_cli/modules/upgrade/module-package.yaml b/src/specfact_cli/modules/upgrade/module-package.yaml index 9ff7c9b5..cdb19b66 100644 --- a/src/specfact_cli/modules/upgrade/module-package.yaml +++ b/src/specfact_cli/modules/upgrade/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/modules/validate/module-package.yaml b/src/specfact_cli/modules/validate/module-package.yaml index 25a1a77e..b737bec9 100644 --- a/src/specfact_cli/modules/validate/module-package.yaml +++ b/src/specfact_cli/modules/validate/module-package.yaml @@ -8,3 +8,4 @@ command_help: pip_dependencies: [] module_dependencies: [] tier: community +core_compatibility: ">=0.28.0,<1.0.0" diff --git a/src/specfact_cli/registry/module_packages.py b/src/specfact_cli/registry/module_packages.py index 24bdba91..237c2b87 100644 --- a/src/specfact_cli/registry/module_packages.py +++ b/src/specfact_cli/registry/module_packages.py @@ -12,10 +12,14 @@ from typing import Any from beartype import beartype +from packaging.specifiers import SpecifierSet +from packaging.version import InvalidVersion, Version from pydantic import BaseModel, Field +from specfact_cli import __version__ as cli_version +from specfact_cli.common import get_bridge_logger from specfact_cli.registry.metadata import CommandMetadata -from specfact_cli.registry.module_state import read_modules_state +from specfact_cli.registry.module_state import find_dependents, read_modules_state from specfact_cli.registry.registry import CommandRegistry @@ -30,6 +34,10 @@ class ModulePackageMetadata(BaseModel): ) pip_dependencies: list[str] = Field(default_factory=list, description="Optional pip dependencies") module_dependencies: list[str] = Field(default_factory=list, description="Optional other package ids") + core_compatibility: str | None = Field( + default=None, + description="CLI core version compatibility (PEP 440 specifier, e.g. '>=0.28.0,<1.0.0')", + ) tier: str = Field(default="community", description="Tier: community or enterprise") addon_id: str | None = Field(default=None, description="Optional addon identifier") @@ -113,6 +121,7 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack command_help=command_help, pip_dependencies=[str(d) for d in raw.get("pip_dependencies", [])], module_dependencies=[str(d) for d in raw.get("module_dependencies", [])], + core_compatibility=str(raw["core_compatibility"]) if raw.get("core_compatibility") else None, tier=str(raw.get("tier", "community")), addon_id=str(raw["addon_id"]) if raw.get("addon_id") else None, ) @@ -122,6 +131,137 @@ def discover_package_metadata(modules_root: Path) -> list[tuple[Path, ModulePack return result +@beartype +def _check_core_compatibility(meta: ModulePackageMetadata, current_cli_version: str) -> bool: + """Return True when module is compatible with the running CLI core version.""" + if not meta.core_compatibility: + return True + try: + specifier = SpecifierSet(meta.core_compatibility) + return Version(current_cli_version) in specifier + except (InvalidVersion, Exception): + # Keep malformed metadata non-blocking; emit details in debug logs at call site. + return True + + +@beartype +def _validate_module_dependencies( + meta: ModulePackageMetadata, + enabled_map: dict[str, bool], +) -> tuple[bool, list[str]]: + """Validate that declared dependencies exist and are enabled.""" + missing: list[str] = [] + for dep_id in meta.module_dependencies: + if dep_id not in enabled_map: + missing.append(f"{dep_id} (not found)") + elif not enabled_map[dep_id]: + missing.append(f"{dep_id} (disabled)") + return len(missing) == 0, missing + + +@beartype +def validate_disable_safe( + disable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], + enabled_map: dict[str, bool], +) -> dict[str, list[str]]: + """ + Return blocked disable requests mapped to enabled dependents. + + Empty dict means all disables are safe. + """ + effective_map = {**enabled_map} + for mid in disable_ids: + effective_map[mid] = False + + blocked: dict[str, list[str]] = {} + for mid in disable_ids: + dependents = find_dependents(mid, packages, effective_map) + if dependents: + blocked[mid] = dependents + return blocked + + +@beartype +def validate_enable_safe( + enable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], + enabled_map: dict[str, bool], +) -> dict[str, list[str]]: + """ + Return blocked enable requests mapped to unmet dependencies. + + Empty dict means all enables are dependency-safe in the effective map. + """ + meta_by_name: dict[str, ModulePackageMetadata] = {meta.name: meta for _package_dir, meta in packages} + blocked: dict[str, list[str]] = {} + for mid in enable_ids: + meta = meta_by_name.get(mid) + if meta is None: + blocked[mid] = ["module not found"] + continue + deps_ok, missing = _validate_module_dependencies(meta, enabled_map) + if not deps_ok: + blocked[mid] = missing + return blocked + + +@beartype +def expand_disable_with_dependents( + disable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], + enabled_map: dict[str, bool], +) -> list[str]: + """ + Expand disable set with transitive enabled dependents. + + Used by --force mode so disabling a dependency provider also disables + modules that depend on it. + """ + reverse_deps: dict[str, set[str]] = {} + for _package_dir, meta in packages: + name = meta.name + for dep in meta.module_dependencies: + reverse_deps.setdefault(dep, set()).add(name) + + expanded: set[str] = set(disable_ids) + queue = list(disable_ids) + while queue: + current = queue.pop(0) + for dependent in sorted(reverse_deps.get(current, set())): + if dependent in expanded: + continue + if not enabled_map.get(dependent, True): + continue + expanded.add(dependent) + queue.append(dependent) + return list(expanded) + + +@beartype +def expand_enable_with_dependencies( + enable_ids: list[str], + packages: list[tuple[Path, ModulePackageMetadata]], +) -> list[str]: + """ + Expand enable set with transitive dependencies. + + Used by --force mode so enabling a module also enables required upstream + dependency providers. + """ + dep_map: dict[str, list[str]] = {meta.name: list(meta.module_dependencies) for _package_dir, meta in packages} + expanded: set[str] = set(enable_ids) + queue = list(enable_ids) + while queue: + current = queue.pop(0) + for dep in dep_map.get(current, []): + if dep in expanded: + continue + expanded.add(dep) + queue.append(dep) + return list(expanded) + + def _make_package_loader(package_dir: Path, package_name: str, _command_name: str) -> Any: """Return a callable that loads the package's app (from src/app.py or src//__init__.py).""" @@ -200,14 +340,26 @@ def register_module_package_commands( discovered_list: list[tuple[str, str]] = [(meta.name, meta.version) for _dir, meta in packages] state = read_modules_state() enabled_map = merge_module_state(discovered_list, state, enable_ids, disable_ids) + logger = get_bridge_logger(__name__) + skipped: list[tuple[str, str]] = [] for package_dir, meta in packages: if not enabled_map.get(meta.name, True): continue + compatible = _check_core_compatibility(meta, cli_version) + if not compatible: + skipped.append((meta.name, f"requires {meta.core_compatibility}, cli is {cli_version}")) + continue + deps_ok, missing = _validate_module_dependencies(meta, enabled_map) + if not deps_ok: + skipped.append((meta.name, f"missing dependencies: {', '.join(missing)}")) + continue for cmd_name in meta.commands: help_str = (meta.command_help or {}).get(cmd_name) or f"Module package: {meta.name}" loader = _make_package_loader(package_dir, meta.name, cmd_name) cmd_meta = CommandMetadata(name=cmd_name, help=help_str, tier=meta.tier, addon_id=meta.addon_id) CommandRegistry.register(cmd_name, loader, cmd_meta) + for module_id, reason in skipped: + logger.debug("Skipped module '%s': %s", module_id, reason) def get_discovered_modules_for_state( diff --git a/src/specfact_cli/registry/module_state.py b/src/specfact_cli/registry/module_state.py index 9a58ce70..1ad5a4d1 100644 --- a/src/specfact_cli/registry/module_state.py +++ b/src/specfact_cli/registry/module_state.py @@ -11,6 +11,7 @@ from typing import Any from beartype import beartype +from icontract import require from specfact_cli.registry.help_cache import get_registry_dir @@ -58,3 +59,34 @@ def write_modules_state(modules: list[dict[str, Any]]) -> None: get_registry_dir().mkdir(parents=True, exist_ok=True) path = get_modules_state_path() path.write_text(json.dumps({"modules": modules}, indent=2), encoding="utf-8") + + +@require(lambda module_id: isinstance(module_id, str) and len(module_id) > 0, "module_id must be non-empty") +@beartype +def find_dependents( + module_id: str, + packages: list[tuple[Path, Any]], + enabled_map: dict[str, bool], +) -> list[str]: + """ + Find enabled modules that depend on the given module ID. + + Args: + module_id: Candidate module to disable. + packages: Discovered package tuples (package_dir, metadata). + enabled_map: Effective enabled state map. + + Returns: + Sorted dependent module IDs currently enabled. + """ + dependents: list[str] = [] + for _package_dir, meta in packages: + name = str(getattr(meta, "name", "")) + if not name or name == module_id: + continue + if not enabled_map.get(name, True): + continue + module_dependencies = list(getattr(meta, "module_dependencies", [])) + if module_id in module_dependencies: + dependents.append(name) + return sorted(dependents) diff --git a/src/specfact_cli/runtime.py b/src/specfact_cli/runtime.py index d348e917..26cffa27 100644 --- a/src/specfact_cli/runtime.py +++ b/src/specfact_cli/runtime.py @@ -11,7 +11,6 @@ import json import logging import os -import sys from enum import StrEnum from logging.handlers import RotatingFileHandler from typing import Any @@ -102,19 +101,16 @@ def is_non_interactive() -> bool: Priority: 1. Explicit override - 2. CI/CD mode - 3. TTY detection + 2. Terminal/environment auto-detection (CI and TTY) """ if _non_interactive_override is not None: return _non_interactive_override - if _operational_mode == OperationalMode.CICD: - return True - try: - stdin_tty = bool(sys.stdin and sys.stdin.isatty()) - stdout_tty = bool(sys.stdout and sys.stdout.isatty()) - return not (stdin_tty and stdout_tty) + caps = detect_terminal_capabilities() + if caps.is_ci: + return True + return not caps.is_interactive except Exception: # pragma: no cover - defensive fallback return True diff --git a/src/specfact_cli/utils/bundle_converters.py b/src/specfact_cli/utils/bundle_converters.py new file mode 100644 index 00000000..f8f7eb9e --- /dev/null +++ b/src/specfact_cli/utils/bundle_converters.py @@ -0,0 +1,67 @@ +"""Shared bundle conversion helpers used across module commands.""" + +from __future__ import annotations + +import re +from pathlib import Path + +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.models.plan import PlanBundle +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle + + +@require(lambda project_bundle: project_bundle is not None, "project_bundle must not be None") +@ensure(lambda result: result is not None, "Must return PlanBundle") +@beartype +def convert_project_bundle_to_plan_bundle(project_bundle: ProjectBundle) -> PlanBundle: + """Convert ProjectBundle to PlanBundle for compatibility helpers.""" + return PlanBundle( + version="1.0", + idea=project_bundle.idea, + business=project_bundle.business, + product=project_bundle.product, + features=list(project_bundle.features.values()), + metadata=None, + clarifications=project_bundle.clarifications, + ) + + +@require(lambda plan_bundle: plan_bundle is not None, "plan_bundle must not be None") +@require(lambda bundle_name: isinstance(bundle_name, str) and len(bundle_name) > 0, "bundle_name must be non-empty") +@ensure(lambda result: result is not None, "Must return ProjectBundle") +@beartype +def convert_plan_bundle_to_project_bundle(plan_bundle: PlanBundle, bundle_name: str) -> ProjectBundle: + """Convert PlanBundle to modular ProjectBundle format.""" + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + features_dict = {feature.key: feature for feature in plan_bundle.features} + return ProjectBundle( + manifest=manifest, + bundle_name=bundle_name, + idea=plan_bundle.idea, + business=plan_bundle.business, + product=plan_bundle.product, + features=features_dict, + clarifications=plan_bundle.clarifications, + ) + + +@beartype +def is_constitution_minimal(constitution_path: Path) -> bool: + """Return True when constitution content is missing or effectively placeholder-only.""" + if not constitution_path.exists(): + return True + try: + content = constitution_path.read_text(encoding="utf-8").strip() + if not content or content == "# Constitution" or len(content) < 100: + return True + placeholders = re.findall(r"\[[A-Z_0-9]+\]", content) + lines = [line.strip() for line in content.split("\n") if line.strip()] + return bool(lines and len(placeholders) > len(lines) * 0.5) + except Exception: + return True diff --git a/tests/e2e/test_init_command.py b/tests/e2e/test_init_command.py index 264aadef..70177f74 100644 --- a/tests/e2e/test_init_command.py +++ b/tests/e2e/test_init_command.py @@ -32,7 +32,7 @@ def test_init_auto_detect_cursor(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -56,7 +56,7 @@ def test_init_explicit_cursor(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -79,7 +79,7 @@ def test_init_explicit_vscode(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "vscode", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "vscode", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -106,7 +106,7 @@ def test_init_explicit_copilot(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "copilot", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "copilot", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -135,7 +135,7 @@ def test_init_skips_existing_files_without_force(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path)]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path)]) finally: os.chdir(old_cwd) @@ -164,7 +164,7 @@ def test_init_overwrites_with_force(self, tmp_path): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -220,7 +220,7 @@ def mock_find_resources(package_name: str, resource_subpath: str): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path)]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path)]) finally: os.chdir(old_cwd) @@ -254,7 +254,7 @@ def test_init_all_supported_ides(self, tmp_path): shutil.rmtree(ide_dir) - result = runner.invoke(app, ["init", "--ide", ide, "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", ide, "--repo", str(tmp_path), "--force"]) assert result.exit_code == 0, f"Failed for IDE: {ide}\n{result.stdout}\n{result.stderr}" assert "Initialization Complete" in result.stdout or "Copied" in result.stdout finally: @@ -279,7 +279,7 @@ def test_init_auto_detect_vscode(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -313,7 +313,7 @@ def test_init_auto_detect_claude(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -338,7 +338,7 @@ def test_init_warns_when_no_environment_manager(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -373,7 +373,7 @@ def test_init_no_warning_with_hatch_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -401,7 +401,7 @@ def test_init_copies_backlog_field_mapping_templates(self, tmp_path, monkeypatch old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -437,7 +437,7 @@ def test_init_skips_existing_backlog_templates(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path)]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path)]) finally: os.chdir(old_cwd) @@ -473,7 +473,7 @@ def test_init_force_overwrites_backlog_templates(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -505,7 +505,7 @@ def test_init_no_warning_with_poetry_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -527,7 +527,7 @@ def test_init_no_warning_with_pip_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) @@ -557,7 +557,7 @@ def test_init_no_warning_with_uv_project(self, tmp_path, monkeypatch): old_cwd = os.getcwd() try: os.chdir(tmp_path) - result = runner.invoke(app, ["init", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) + result = runner.invoke(app, ["init", "ide", "--ide", "cursor", "--repo", str(tmp_path), "--force"]) finally: os.chdir(old_cwd) diff --git a/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py new file mode 100644 index 00000000..4f5bd4be --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_init_module_lifecycle_ux.py @@ -0,0 +1,282 @@ +"""Tests for init module lifecycle UX: listing and interactive/non-interactive selection.""" + +from __future__ import annotations + +from pathlib import Path + +from typer.testing import CliRunner + +from specfact_cli.cli import app +from specfact_cli.registry.module_packages import ModulePackageMetadata +from specfact_cli.utils.env_manager import EnvManager, EnvManagerInfo + + +runner = CliRunner() + + +def test_init_list_modules_shows_enabled_disabled(tmp_path: Path, monkeypatch) -> None: + """`specfact init --list-modules` prints discovered module statuses and exits.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + {"id": "generate", "version": "0.1.0", "enabled": False}, + ], + ) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--list-modules"]) + + assert result.exit_code == 0 + assert "sync" in result.stdout + assert "generate" in result.stdout + assert "enabled" in result.stdout.lower() + assert "disabled" in result.stdout.lower() + + +def test_init_non_interactive_bare_enable_module_requires_id(tmp_path: Path, monkeypatch) -> None: + """Non-interactive mode rejects bare --enable-module and requires explicit id.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands._select_module_ids_interactive", + lambda action, modules: (_ for _ in ()).throw(AssertionError("must not prompt in non-interactive mode")), + ) + + result = runner.invoke(app, ["--no-interactive", "init", "--repo", str(tmp_path), "--enable-module"]) + assert result.exit_code == 1 + assert "--enable-module " in result.stdout or "--disable-module " in result.stdout + + +def test_init_enable_module_bare_interactive_adds_selected_module(tmp_path: Path, monkeypatch) -> None: + """Bare --enable-module in interactive mode triggers selector and applies selected ids.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: False) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands._select_module_ids_interactive", + lambda action, modules: ["generate"], + ) + + observed_enable_ids: list[str] = [] + + def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): + nonlocal observed_enable_ids + observed_enable_ids = list(enable_ids or []) + return [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + {"id": "generate", "version": "0.1.0", "enabled": True}, + ] + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + _fake_get_discovered_modules_for_state, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module"]) + + assert result.exit_code == 0 + assert "generate" in observed_enable_ids + + +def test_init_disable_module_does_not_run_ide_setup(tmp_path: Path, monkeypatch) -> None: + """Module state updates should not trigger template copy or IDE setup side effects.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "upgrade", "version": "0.1.0", "enabled": False}, + ], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.validate_disable_safe", + lambda disable_ids, packages, enabled_map: {}, + ) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.discover_package_metadata", + lambda modules_root: [], + ) + + def _fail_copy(*args, **kwargs): + raise AssertionError("copy_templates_to_ide must not be called for module-state-only operations") + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "upgrade"]) + + assert result.exit_code == 0 + + +def test_init_bootstrap_only_does_not_run_ide_setup(tmp_path: Path, monkeypatch) -> None: + """Top-level init should not run template copy; it should stay bootstrap-only.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + ], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + def _fail_copy(*args, **kwargs): + raise AssertionError("copy_templates_to_ide must not be called by top-level init") + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path)]) + assert result.exit_code == 0 + assert "Use `specfact init ide`" in result.stdout + + +def test_init_install_deps_runs_without_ide_template_copy(tmp_path: Path, monkeypatch) -> None: + """Top-level init --install-deps installs dependencies without invoking IDE template copy.""" + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + lambda enable_ids=None, disable_ids=None: [ + {"id": "sync", "version": "0.1.0", "enabled": True}, + ], + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.detect_env_manager", + lambda repo_path: EnvManagerInfo( + manager=EnvManager.PIP, + available=True, + command_prefix=[], + message="pip", + ), + ) + + calls: list[list[str]] = [] + + class _Result: + returncode = 0 + + def _fake_run(cmd, capture_output, text, check, cwd, timeout): + calls.append(list(cmd)) + return _Result() + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.subprocess.run", _fake_run) + + def _fail_copy(*args, **kwargs): + raise AssertionError("copy_templates_to_ide must not be called by top-level init --install-deps") + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.copy_templates_to_ide", _fail_copy) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--install-deps"]) + + assert result.exit_code == 0 + assert calls, "Expected dependency installation command to run" + assert calls[0][:4] == ["pip", "install", "-U", "beartype>=0.22.4"] + + +def test_init_force_disable_cascades_to_dependents(tmp_path: Path, monkeypatch) -> None: + """Force-disabling a dependency provider should cascade-disable dependents.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + packages = [ + ( + Path("/tmp/plan"), + ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), + ), + ( + Path("/tmp/sync"), + ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), + ), + ] + monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_package_metadata", lambda root: packages) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.read_modules_state", dict) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + observed_disable_ids: list[str] = [] + + def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): + nonlocal observed_disable_ids + observed_disable_ids = list(disable_ids or []) + return [ + {"id": "plan", "version": "0.1.0", "enabled": False}, + {"id": "sync", "version": "0.1.0", "enabled": False}, + ] + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + _fake_get_discovered_modules_for_state, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--disable-module", "sync", "--force"]) + assert result.exit_code == 0 + assert "sync" in observed_disable_ids + assert "plan" in observed_disable_ids + + +def test_init_force_enable_cascades_to_dependencies(tmp_path: Path, monkeypatch) -> None: + """Force-enabling a module should auto-enable transitive dependencies.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + packages = [ + ( + Path("/tmp/plan"), + ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), + ), + ( + Path("/tmp/sync"), + ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), + ), + ] + monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_package_metadata", lambda root: packages) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.read_modules_state", dict) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.run_discovery_and_write_cache", lambda version: None) + + observed_enable_ids: list[str] = [] + + def _fake_get_discovered_modules_for_state(enable_ids=None, disable_ids=None): + nonlocal observed_enable_ids + observed_enable_ids = list(enable_ids or []) + return [ + {"id": "plan", "version": "0.1.0", "enabled": True}, + {"id": "sync", "version": "0.1.0", "enabled": True}, + ] + + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.get_discovered_modules_for_state", + _fake_get_discovered_modules_for_state, + ) + monkeypatch.setattr("specfact_cli.modules.init.src.commands.write_modules_state", lambda modules: None) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "plan", "--force"]) + assert result.exit_code == 0 + assert "plan" in observed_enable_ids + assert "sync" in observed_enable_ids + + +def test_init_enable_without_force_blocks_when_dependency_disabled(tmp_path: Path, monkeypatch) -> None: + """Enable should fail without force when required dependency is disabled.""" + + monkeypatch.setattr("specfact_cli.modules.init.src.commands.is_non_interactive", lambda: True) + packages = [ + ( + Path("/tmp/plan"), + ModulePackageMetadata(name="plan", version="0.1.0", commands=["plan"], module_dependencies=["sync"]), + ), + ( + Path("/tmp/sync"), + ModulePackageMetadata(name="sync", version="0.1.0", commands=["sync"], module_dependencies=[]), + ), + ] + monkeypatch.setattr("specfact_cli.modules.init.src.commands.discover_package_metadata", lambda root: packages) + monkeypatch.setattr( + "specfact_cli.modules.init.src.commands.read_modules_state", lambda: {"sync": {"enabled": False}} + ) + + result = runner.invoke(app, ["init", "--repo", str(tmp_path), "--enable-module", "plan"]) + + assert result.exit_code == 1 + assert "Cannot enable 'plan'" in result.stdout + assert "--force" in result.stdout diff --git a/tests/unit/specfact_cli/registry/test_module_dependencies.py b/tests/unit/specfact_cli/registry/test_module_dependencies.py new file mode 100644 index 00000000..81f4eed9 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_module_dependencies.py @@ -0,0 +1,108 @@ +"""Contract-first tests for module dependency validation helpers.""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.registry.module_packages import ( + ModulePackageMetadata, + _validate_module_dependencies, + expand_disable_with_dependents, + expand_enable_with_dependencies, + validate_disable_safe, + validate_enable_safe, +) + + +def _pkg(name: str, deps: list[str] | None = None) -> tuple[Path, ModulePackageMetadata]: + return ( + Path(f"/tmp/{name}"), + ModulePackageMetadata( + name=name, + version="0.27.0", + commands=[name], + module_dependencies=deps or [], + ), + ) + + +def test_validate_module_dependencies_no_dependencies() -> None: + meta = ModulePackageMetadata(name="sync", version="0.27.0", commands=["sync"], module_dependencies=[]) + ok, missing = _validate_module_dependencies(meta, {"sync": True, "plan": True}) + assert ok is True + assert missing == [] + + +def test_validate_module_dependencies_detects_missing_and_disabled() -> None: + meta = ModulePackageMetadata( + name="sync", + version="0.27.0", + commands=["sync"], + module_dependencies=["plan", "sdd", "ghost"], + ) + ok, missing = _validate_module_dependencies(meta, {"plan": False, "sdd": True, "sync": True}) + assert ok is False + assert "plan (disabled)" in missing + assert "ghost (not found)" in missing + + +def test_validate_disable_safe_blocks_enabled_dependents() -> None: + packages = [ + _pkg("plan", ["sync"]), + _pkg("sync", []), + _pkg("sdd", []), + ] + enabled_map = {"plan": True, "sync": True, "sdd": True} + + blocked = validate_disable_safe(["sync"], packages, enabled_map) + + assert blocked == {"sync": ["plan"]} + + +def test_validate_disable_safe_allows_batch_disable_of_dependents() -> None: + packages = [ + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + enabled_map = {"plan": True, "sync": True} + + blocked = validate_disable_safe(["sync", "plan"], packages, enabled_map) + + assert blocked == {} + + +def test_expand_disable_with_dependents_transitive() -> None: + packages = [ + _pkg("project", ["plan"]), + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + enabled_map = {"project": True, "plan": True, "sync": True} + + expanded = set(expand_disable_with_dependents(["sync"], packages, enabled_map)) + + assert expanded == {"sync", "plan", "project"} + + +def test_expand_enable_with_dependencies_transitive() -> None: + packages = [ + _pkg("project", ["plan"]), + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + + expanded = set(expand_enable_with_dependencies(["project"], packages)) + + assert expanded == {"project", "plan", "sync"} + + +def test_validate_enable_safe_blocks_when_dependency_disabled() -> None: + packages = [ + _pkg("plan", ["sync"]), + _pkg("sync", []), + ] + enabled_map = {"plan": True, "sync": False} + + blocked = validate_enable_safe(["plan"], packages, enabled_map) + + assert blocked == {"plan": ["sync (disabled)"]} diff --git a/tests/unit/specfact_cli/registry/test_version_constraints.py b/tests/unit/specfact_cli/registry/test_version_constraints.py new file mode 100644 index 00000000..1f29c077 --- /dev/null +++ b/tests/unit/specfact_cli/registry/test_version_constraints.py @@ -0,0 +1,31 @@ +"""Contract-first tests for module core compatibility constraints.""" + +from __future__ import annotations + +from specfact_cli.registry.module_packages import ModulePackageMetadata, _check_core_compatibility + + +def _meta(core_compatibility: str | None) -> ModulePackageMetadata: + return ModulePackageMetadata( + name="sync", + version="0.27.0", + commands=["sync"], + module_dependencies=[], + core_compatibility=core_compatibility, + ) + + +def test_core_compatibility_none_is_compatible() -> None: + assert _check_core_compatibility(_meta(None), "0.27.0") is True + + +def test_core_compatibility_version_in_range() -> None: + assert _check_core_compatibility(_meta(">=0.28.0,<1.0.0"), "0.29.1") is True + + +def test_core_compatibility_version_out_of_range() -> None: + assert _check_core_compatibility(_meta(">=0.28.0,<1.0.0"), "1.2.0") is False + + +def test_core_compatibility_malformed_specifier_is_non_blocking() -> None: + assert _check_core_compatibility(_meta("not-a-valid-specifier"), "0.29.1") is True diff --git a/tests/unit/specfact_cli/test_module_boundary_imports.py b/tests/unit/specfact_cli/test_module_boundary_imports.py index 54706d58..32c9a657 100644 --- a/tests/unit/specfact_cli/test_module_boundary_imports.py +++ b/tests/unit/specfact_cli/test_module_boundary_imports.py @@ -9,6 +9,9 @@ PROJECT_ROOT = Path(__file__).resolve().parents[3] LEGACY_NON_APP_IMPORT_PATTERN = re.compile(r"from\s+specfact_cli\.commands\.[a-zA-Z0-9_]+\s+import\s+(?!app\b)") LEGACY_SYMBOL_REF_PATTERN = re.compile(r"specfact_cli\.commands\.[a-zA-Z0-9_]+") +CROSS_MODULE_COMMAND_IMPORT_PATTERN = re.compile( + r"from\s+specfact_cli\.modules\.([a-zA-Z0-9_]+)\.src\.commands\s+import\s+([^\n]+)" +) def test_no_legacy_non_app_command_imports_outside_compat_shims() -> None: @@ -32,3 +35,35 @@ def test_no_legacy_non_app_command_imports_outside_compat_shims() -> None: "Legacy command-module references found (use module-local paths or shared modules instead):\n" + "\n".join(f"- {path}" for path in sorted(violations)) ) + + +def test_no_cross_module_non_app_command_imports_in_module_sources() -> None: + """Block cross-module non-app imports from module command implementations.""" + violations: list[str] = [] + modules_root = PROJECT_ROOT / "src" / "specfact_cli" / "modules" + + for module_dir in sorted(modules_root.iterdir()): + if not module_dir.is_dir(): + continue + module_name = module_dir.name + for py_file in module_dir.rglob("*.py"): + if "__pycache__" in py_file.parts: + continue + text = py_file.read_text(encoding="utf-8") + for match in CROSS_MODULE_COMMAND_IMPORT_PATTERN.finditer(text): + imported_module = match.group(1) + imported_symbols = [sym.strip() for sym in match.group(2).split(",") if sym.strip()] + if imported_module == module_name: + continue + if all(sym == "app" for sym in imported_symbols): + continue + if imported_module == "sync" and module_name == "plan" and imported_symbols == ["sync_spec_kit"]: + continue + if imported_module != module_name: + rel = py_file.relative_to(PROJECT_ROOT) + violations.append(f"{rel}:{match.group(0)}") + + assert not violations, ( + "Cross-module src.commands imports found (use specfact_cli.utils for shared helpers):\n" + + "\n".join(f"- {v}" for v in sorted(violations)) + ) diff --git a/tests/unit/specfact_cli/test_module_migration_compatibility.py b/tests/unit/specfact_cli/test_module_migration_compatibility.py index 508e8938..e7a3a0c0 100644 --- a/tests/unit/specfact_cli/test_module_migration_compatibility.py +++ b/tests/unit/specfact_cli/test_module_migration_compatibility.py @@ -6,6 +6,8 @@ import re from pathlib import Path +import pytest + from specfact_cli.registry.bootstrap import register_builtin_commands from specfact_cli.registry.registry import CommandRegistry @@ -123,8 +125,10 @@ def test_legacy_command_shims_reexport_public_symbols() -> None: ) -def test_module_discovery_registers_commands_from_manifests() -> None: +def test_module_discovery_registers_commands_from_manifests(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: """Command registry includes all commands declared by module-package manifests after bootstrap.""" + monkeypatch.setenv("SPECFACT_REGISTRY_DIR", str(tmp_path)) + expected_commands: set[str] = set() for module_name in _module_package_names(): manifest = MODULES_ROOT / module_name / "module-package.yaml" diff --git a/tests/unit/specfact_cli/utils/test_bundle_converters.py b/tests/unit/specfact_cli/utils/test_bundle_converters.py new file mode 100644 index 00000000..533929af --- /dev/null +++ b/tests/unit/specfact_cli/utils/test_bundle_converters.py @@ -0,0 +1,63 @@ +"""Contract-first tests for shared bundle converter utilities.""" + +from __future__ import annotations + +from pathlib import Path + +from specfact_cli.models.plan import Business, Feature, Idea, PlanBundle, Product +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.utils.bundle_converters import ( + convert_plan_bundle_to_project_bundle, + convert_project_bundle_to_plan_bundle, + is_constitution_minimal, +) + + +def _sample_feature() -> Feature: + return Feature(key="FEATURE-001", title="Feature", outcomes=[], acceptance=[], constraints=[], stories=[]) + + +def _sample_plan_bundle() -> PlanBundle: + return PlanBundle( + version="1.0", + idea=Idea(title="Idea", narrative="Narrative", metrics=None), + business=Business(), + product=Product(), + features=[_sample_feature()], + metadata=None, + clarifications=None, + ) + + +def _sample_project_bundle() -> ProjectBundle: + return ProjectBundle( + manifest=BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ), + bundle_name="demo", + idea=Idea(title="Idea", narrative="Narrative", metrics=None), + business=Business(), + product=Product(), + features={"FEATURE-001": _sample_feature()}, + clarifications=None, + ) + + +def test_convert_project_bundle_to_plan_bundle_roundtrip() -> None: + plan = convert_project_bundle_to_plan_bundle(_sample_project_bundle()) + assert isinstance(plan, PlanBundle) + assert len(plan.features) == 1 + assert plan.features[0].key == "FEATURE-001" + + +def test_convert_plan_bundle_to_project_bundle_roundtrip() -> None: + project = convert_plan_bundle_to_project_bundle(_sample_plan_bundle(), "demo") + assert isinstance(project, ProjectBundle) + assert "FEATURE-001" in project.features + assert project.bundle_name == "demo" + + +def test_is_constitution_minimal_for_missing_file() -> None: + assert is_constitution_minimal(Path("/tmp/does-not-exist-constitution.md")) is True diff --git a/tests/unit/test_runtime.py b/tests/unit/test_runtime.py index 4ebda4cd..e0282daf 100644 --- a/tests/unit/test_runtime.py +++ b/tests/unit/test_runtime.py @@ -9,6 +9,7 @@ from pathlib import Path from unittest.mock import patch +from specfact_cli.modes import OperationalMode from specfact_cli.runtime import ( TerminalMode, debug_log_operation, @@ -16,7 +17,10 @@ get_configured_console, get_terminal_mode, is_debug_mode, + is_non_interactive, set_debug_mode, + set_non_interactive_override, + set_operational_mode, ) from specfact_cli.utils.terminal import TerminalCapabilities @@ -91,6 +95,61 @@ def test_terminal_mode_graphical(self) -> None: assert mode == TerminalMode.GRAPHICAL +class TestInteractionMode: + """Test interactive/non-interactive runtime behavior.""" + + def test_explicit_override_true(self) -> None: + """Explicit override forces non-interactive.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=True, + is_interactive=True, + is_ci=False, + ) + set_non_interactive_override(True) + assert is_non_interactive() is True + set_non_interactive_override(None) + + def test_explicit_override_false(self) -> None: + """Explicit override forces interactive.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=False, + is_ci=True, + ) + set_non_interactive_override(False) + assert is_non_interactive() is False + set_non_interactive_override(None) + + def test_default_interactive_tty_even_in_cicd_mode(self) -> None: + """Interactive TTY defaults to interactive regardless of operational mode.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=True, + is_interactive=True, + is_ci=False, + ) + set_non_interactive_override(None) + set_operational_mode(OperationalMode.CICD) + assert is_non_interactive() is False + + def test_default_non_interactive_in_ci(self) -> None: + """CI defaults to non-interactive.""" + with patch("specfact_cli.runtime.detect_terminal_capabilities") as mock_detect: + mock_detect.return_value = TerminalCapabilities( + supports_color=True, + supports_animations=False, + is_interactive=True, + is_ci=True, + ) + set_non_interactive_override(None) + assert is_non_interactive() is True + + class TestGetConfiguredConsole: """Test configured console creation and caching."""