|
| 1 | +# Design: Module Grouping and Category Command Groups |
| 2 | + |
| 3 | +## Context |
| 4 | + |
| 5 | +SpecFact CLI ships 21 modules as flat top-level commands in `src/specfact_cli/modules/`. The marketplace foundation (marketplace-01 archived, marketplace-02 in progress) enables signed, versioned, installable module packages. This change introduces the grouping layer that organises those 21 modules into 5 workflow-domain categories, exposes them through category umbrella commands, and replaces the first-run experience with VS Code-style bundle selection. |
| 6 | + |
| 7 | +**Current State:** |
| 8 | + |
| 9 | +- 21 flat top-level commands registered in bootstrap.py |
| 10 | +- No category concept in module-package.yaml |
| 11 | +- `specfact init` performs workspace setup with no bundle selection |
| 12 | +- `specfact --help` is overwhelming for new users |
| 13 | + |
| 14 | +**Constraints:** |
| 15 | + |
| 16 | +- Must not break existing `specfact <module>` invocations during migration window |
| 17 | +- Must work offline (no cloud dependency for grouping logic) |
| 18 | +- Must remain backward-compatible with CI/CD pipelines that call flat commands |
| 19 | +- Must depend on marketplace-02 dependency resolver for bundle-level dep graph (spec → project, govern → project) |
| 20 | +- All new public APIs must carry `@icontract` and `@beartype` decorators |
| 21 | + |
| 22 | +## Goals / Non-Goals |
| 23 | + |
| 24 | +**Goals:** |
| 25 | + |
| 26 | +- Add category metadata to all 21 module-package.yaml files (Phase 1) |
| 27 | +- Implement 5 category group Typer apps in `src/specfact_cli/groups/` (Phase 2) |
| 28 | +- Update bootstrap.py to mount groups with compat shims for flat commands (Phase 2) |
| 29 | +- Add `category_grouping_enabled` config flag (default `true`) |
| 30 | +- Add first-run bundle selection to `specfact init` with `--profile` and `--install` flags |
| 31 | +- Preserve all existing command paths via deprecation shims |
| 32 | + |
| 33 | +**Non-Goals:** |
| 34 | + |
| 35 | +- Extracting module source code to separate packages (module-migration-02) |
| 36 | +- Removing bundled modules from pyproject.toml (module-migration-03) |
| 37 | +- Publishing bundles to the marketplace registry (module-migration-02) |
| 38 | +- Removing backward-compat shims (module-migration-03) |
| 39 | + |
| 40 | +## Decisions |
| 41 | + |
| 42 | +### Decision 1: Category group architecture — separate `groups/` layer |
| 43 | + |
| 44 | +**Options:** |
| 45 | + |
| 46 | +- **A**: Embed grouping logic directly in bootstrap.py |
| 47 | +- **B**: New `src/specfact_cli/groups/` package with one file per category |
| 48 | + |
| 49 | +**Choice: B (dedicated `groups/` layer)** |
| 50 | + |
| 51 | +**Rationale:** |
| 52 | + |
| 53 | +- Clean separation of concerns — bootstrap orchestrates, groups define aggregation |
| 54 | +- Each group file is independently testable |
| 55 | +- Mirrors the `modules/` structure, making the pattern predictable |
| 56 | +- Easier to remove in module-migration-03 (delete groups/ directory) |
| 57 | + |
| 58 | +**Structure:** |
| 59 | + |
| 60 | +```text |
| 61 | +src/specfact_cli/groups/ |
| 62 | + __init__.py |
| 63 | + project_group.py # aggregates: project, plan, import_cmd, sync, migrate |
| 64 | + backlog_group.py # aggregates: backlog, policy_engine |
| 65 | + codebase_group.py # aggregates: analyze, drift, validate, repro |
| 66 | + spec_group.py # aggregates: contract, spec (as 'api'), sdd, generate |
| 67 | + govern_group.py # aggregates: enforce, patch_mode |
| 68 | +``` |
| 69 | + |
| 70 | +**Group file pattern:** |
| 71 | + |
| 72 | +```python |
| 73 | +import typer |
| 74 | +from beartype import beartype |
| 75 | +from icontract import require |
| 76 | + |
| 77 | +app = typer.Typer(name="code", help="Codebase quality commands.") |
| 78 | + |
| 79 | +@require(lambda: True) # placeholder; real contracts on member loaders |
| 80 | +@beartype |
| 81 | +def _register_members() -> None: |
| 82 | + """Lazy-register member module sub-apps.""" |
| 83 | + ... |
| 84 | +``` |
| 85 | + |
| 86 | +### Decision 2: Backward-compat shim strategy |
| 87 | + |
| 88 | +**Options:** |
| 89 | + |
| 90 | +- **A**: Register both flat and grouped commands permanently |
| 91 | +- **B**: Register flat commands as shims that delegate to grouped commands |
| 92 | +- **C**: Remove flat commands immediately (breaking) |
| 93 | + |
| 94 | +#### Choice: B (shim delegation) |
| 95 | + |
| 96 | +**Rationale:** |
| 97 | + |
| 98 | +- Zero breaking changes for existing scripts |
| 99 | +- Deprecation warning in Copilot mode trains users to migrate |
| 100 | +- Silent in CI/CD mode (detected from environment) |
| 101 | +- Clean removal path: delete shim registrations in module-migration-03 |
| 102 | + |
| 103 | +**Implementation:** |
| 104 | + |
| 105 | +```python |
| 106 | +# In bootstrap.py, after mounting category groups: |
| 107 | +def _register_compat_shims(app: typer.Typer) -> None: |
| 108 | + """Register flat-command shims that delegate to category group equivalents.""" |
| 109 | + from specfact_cli.common.modes import is_cicd_mode |
| 110 | + ... |
| 111 | +``` |
| 112 | + |
| 113 | +### Decision 3: Spec module name collision resolution |
| 114 | + |
| 115 | +The `spec` module's command name (`specfact spec`) collides with the `spec` category group command. |
| 116 | + |
| 117 | +**Choice: Mount the `spec` module as `api` sub-command within the `spec` group** |
| 118 | + |
| 119 | +**Rationale:** |
| 120 | + |
| 121 | +- `specfact spec api validate` is semantically clear (API spec validation) |
| 122 | +- The flat shim `specfact spec validate` continues to work during migration window |
| 123 | +- Avoids any namespace recursion (spec inside spec group) |
| 124 | + |
| 125 | +**Manifest change in `modules/spec/module-package.yaml`:** |
| 126 | + |
| 127 | +```yaml |
| 128 | +bundle_sub_command: api |
| 129 | +``` |
| 130 | +
|
| 131 | +### Decision 4: `category_grouping_enabled` config flag storage |
| 132 | + |
| 133 | +**Choice: Stored in `~/.specfact/config.yaml` under key `category_grouping_enabled: true`** |
| 134 | + |
| 135 | +**Rationale:** |
| 136 | + |
| 137 | +- Consistent with existing specfact config conventions |
| 138 | +- Easy to disable per-machine during rollout |
| 139 | +- Read once at CLI startup, passed down to bootstrap |
| 140 | + |
| 141 | +### Decision 5: First-run detection mechanism |
| 142 | + |
| 143 | +#### Choice: Check whether any category bundle is installed; if none, treat as first-run |
| 144 | + |
| 145 | +**Rationale:** |
| 146 | + |
| 147 | +- Simple and reliable — no additional state file needed |
| 148 | +- Idempotent: re-running init after bundles are installed skips selection |
| 149 | +- Compatible with `--install all` legacy flag (bypasses first-run UI) |
| 150 | + |
| 151 | +### Decision 6: Bundle-level dependency resolution at init time |
| 152 | + |
| 153 | +#### Choice: Delegate to marketplace-02 dependency resolver |
| 154 | + |
| 155 | +**Rationale:** |
| 156 | + |
| 157 | +- marketplace-02 owns the dependency resolution contract |
| 158 | +- Avoids duplicating resolution logic in init |
| 159 | +- At init time, spec and govern bundles automatically pull project bundle as dep |
| 160 | +- If marketplace-02 is not yet complete, init can warn and skip dep resolution (graceful degradation) |
| 161 | + |
| 162 | +## Architecture |
| 163 | + |
| 164 | +### Data flow: CLI startup with grouping enabled |
| 165 | + |
| 166 | +```text |
| 167 | +specfact <command> <args> |
| 168 | + │ |
| 169 | + ├─ cli.py: cli_main() |
| 170 | + │ └─ loads root typer.Typer app |
| 171 | + │ |
| 172 | + ├─ registry/bootstrap.py: bootstrap_cli(app) |
| 173 | + │ ├─ reads category_grouping_enabled from config |
| 174 | + │ ├─ if enabled: |
| 175 | + │ │ ├─ groups/codebase_group.app → app.add_typer(name="code") |
| 176 | + │ │ ├─ groups/backlog_group.app → app.add_typer(name="backlog") |
| 177 | + │ │ ├─ groups/project_group.app → app.add_typer(name="project") |
| 178 | + │ │ ├─ groups/spec_group.app → app.add_typer(name="spec") |
| 179 | + │ │ ├─ groups/govern_group.app → app.add_typer(name="govern") |
| 180 | + │ │ └─ _register_compat_shims(app) ← flat shims |
| 181 | + │ └─ core modules always mounted flat: init, auth, module, upgrade |
| 182 | + │ |
| 183 | + └─ registry/registry.py: lazy-load member module on invocation |
| 184 | +``` |
| 185 | + |
| 186 | +### Category group file structure (example: codebase_group.py) |
| 187 | + |
| 188 | +```python |
| 189 | +"""Codebase quality category group.""" |
| 190 | +import typer |
| 191 | +from beartype import beartype |
| 192 | +from icontract import ensure, require |
| 193 | +
|
| 194 | +from specfact_cli.registry.registry import get_module_app |
| 195 | +
|
| 196 | +app = typer.Typer( |
| 197 | + name="code", |
| 198 | + help="Codebase quality commands: analyze, drift, validate, repro.", |
| 199 | + no_args_is_help=True, |
| 200 | +) |
| 201 | +
|
| 202 | +_MEMBERS = ("analyze", "drift", "validate", "repro") |
| 203 | +
|
| 204 | +@require(lambda: True) |
| 205 | +@ensure(lambda result: result is None) |
| 206 | +@beartype |
| 207 | +def _register_members() -> None: |
| 208 | + for name in _MEMBERS: |
| 209 | + member_app = get_module_app(name) |
| 210 | + if member_app is not None: |
| 211 | + app.add_typer(member_app, name=name) |
| 212 | +
|
| 213 | +_register_members() |
| 214 | +``` |
| 215 | + |
| 216 | +### module-package.yaml additions |
| 217 | + |
| 218 | +```yaml |
| 219 | +# Example: modules/validate/module-package.yaml (additions only) |
| 220 | +category: codebase |
| 221 | +bundle: specfact-codebase |
| 222 | +bundle_group_command: code |
| 223 | +bundle_sub_command: validate |
| 224 | +``` |
| 225 | + |
| 226 | +### Compat shim pattern (bootstrap.py) |
| 227 | + |
| 228 | +```python |
| 229 | +def _make_shim(category_group_cmd: str, sub_cmd: str) -> Callable[..., Any]: |
| 230 | + """Return a callback that delegates to the category group sub-command.""" |
| 231 | + from specfact_cli.common.modes import is_cicd_mode |
| 232 | + from specfact_cli.common import get_bridge_logger |
| 233 | +
|
| 234 | + logger = get_bridge_logger(__name__) |
| 235 | +
|
| 236 | + def shim(*args: Any, **kwargs: Any) -> None: |
| 237 | + if not is_cicd_mode(): |
| 238 | + console.print( |
| 239 | + f"[yellow]Note: `specfact {sub_cmd}` is deprecated. " |
| 240 | + f"Use `specfact {category_group_cmd} {sub_cmd}` instead.[/yellow]" |
| 241 | + ) |
| 242 | + # delegate to the group sub-command |
| 243 | + ... |
| 244 | + |
| 245 | + return shim |
| 246 | +``` |
| 247 | + |
| 248 | +### First-run init flow |
| 249 | + |
| 250 | +```text |
| 251 | +specfact init |
| 252 | + │ |
| 253 | + ├─ detect first-run: any category bundle installed? → No |
| 254 | + │ |
| 255 | + ├─ Copilot mode? |
| 256 | + │ ├─ Yes → show interactive multi-select UI (rich prompt) |
| 257 | + │ │ user picks bundles or profile preset |
| 258 | + │ │ confirm → install selected bundles via module_installer |
| 259 | + │ └─ No → skip selection (core-only install) |
| 260 | + │ |
| 261 | + └─ --profile / --install flags? |
| 262 | + ├─ --profile <name> → resolve canonical bundle list → install |
| 263 | + └─ --install <list> → parse comma-separated bundle names → install |
| 264 | +``` |
| 265 | + |
| 266 | +## Risks / Trade-offs |
| 267 | + |
| 268 | +### Risk 1: Spec module name collision causes routing confusion |
| 269 | + |
| 270 | +**Mitigation**: Mount spec module as `api` sub-command within spec group; flat shim `specfact spec <sub>` delegates correctly. Covered by explicit spec in `category-command-groups`. |
| 271 | + |
| 272 | +### Risk 2: CLI startup time regression from group loading |
| 273 | + |
| 274 | +**Mitigation**: Groups use the same lazy-loading pattern as the existing registry — member sub-apps are imported only on first invocation of a sub-command, not at startup. |
| 275 | + |
| 276 | +### Risk 3: `sync` ↔ `plan` circular-ish dependency across group files |
| 277 | + |
| 278 | +**Mitigation**: Both `sync` and `plan` are in the `project` category group — no cross-group dependency. The circular import is intra-group and resolved by Typer's deferred registration. |
| 279 | + |
| 280 | +### Risk 4: Compat shims add noise to `specfact --help` |
| 281 | + |
| 282 | +**Mitigation**: Shim entries are marked `deprecated=True` in Typer; displayed with visual annotation. Users who want a clean help can set `category_grouping_enabled: true` (already default) and accept the new group layout. |
| 283 | + |
| 284 | +### Risk 5: marketplace-02 not yet complete when this change is implemented |
| 285 | + |
| 286 | +**Mitigation**: Phase 1 (metadata only) has no dependency on marketplace-02. Phase 2 (group commands) can be implemented and merged without marketplace-02 bundle dep resolution as long as the bundle dependency installation at init time gracefully degrades to a warning. |
| 287 | + |
| 288 | +## Migration Plan |
| 289 | + |
| 290 | +### Phase 1 — Metadata only (no code movement, no behavior change) |
| 291 | + |
| 292 | +1. Add `category`, `bundle`, `bundle_group_command`, `bundle_sub_command` to all 21 module-package.yaml files |
| 293 | +2. Add manifest validation in registry/module_packages.py for new fields |
| 294 | +3. Run module signing gate: `hatch run ./scripts/verify-modules-signature.py --require-signature` |
| 295 | +4. Re-sign all 21 manifests |
| 296 | + |
| 297 | +### Phase 2 — Category group commands |
| 298 | + |
| 299 | +1. Create `src/specfact_cli/groups/` with 5 group files + `__init__.py` |
| 300 | +2. Update `bootstrap.py` to mount groups when `category_grouping_enabled` is `true` |
| 301 | +3. Add `_register_compat_shims()` for all 17 non-core flat commands |
| 302 | +4. Update `cli.py` to register category groups |
| 303 | + |
| 304 | +### Phase 3 — First-run init enhancement |
| 305 | + |
| 306 | +1. Add `--profile` and `--install` parameters to `specfact init` |
| 307 | +2. Implement first-run detection and interactive bundle selection UI |
| 308 | +3. Wire bundle installation through existing `module_installer` |
| 309 | + |
| 310 | +### Rollback |
| 311 | + |
| 312 | +1. Set `category_grouping_enabled: false` in `~/.specfact/config.yaml` (immediate, no code change) |
| 313 | +2. If code rollback needed: revert `bootstrap.py` (remove group mounting) and delete `groups/` directory |
| 314 | + |
| 315 | +## Open Questions |
| 316 | + |
| 317 | +**Q1: Should the migration window be one major version or time-boxed?** |
| 318 | + |
| 319 | +- Recommendation: One major version (e.g., v0.x → v1.0 removes shims). Easier to communicate in changelog. |
| 320 | + |
| 321 | +**Q2: Should `specfact --help` show shim commands alongside group commands by default?** |
| 322 | + |
| 323 | +- Recommendation: Yes for the migration window, to avoid breaking muscle memory. Add deprecation annotation to shim entries. |
| 324 | + |
| 325 | +**Q3: Should first-run selection be skippable with a `--no-interactive` flag?** |
| 326 | + |
| 327 | +- Recommendation: CI/CD mode auto-detection handles this. Add `--no-interactive` as an explicit opt-out for edge cases. |
0 commit comments