diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f70ea1b..e8cf664c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,27 @@ All notable changes to this project will be documented in this file. --- +## [0.50.1] - 2026-07-08 + +### Fixed + +- **Release review follow-up**: persist bundle extension payloads through + directory save/load round trips so attached requirements evidence survives + reload, and make the requirements context adapter protocol stub type-check + cleanly. + +--- + +## [0.50.0] - 2026-07-08 + +### Added + +- **Requirements context adapter**: add core helpers for importing, + normalizing, validating, and inspecting source-attributed requirement context + as validation evidence for the future `requirements` runtime command group. + +--- + ## [0.49.1] - 2026-07-08 ### Fixed diff --git a/docs/_data/nav.yml b/docs/_data/nav.yml index 4b67c57d..94da4b02 100644 --- a/docs/_data/nav.yml +++ b/docs/_data/nav.yml @@ -174,6 +174,9 @@ - title: Requirements Evidence Input Model url: /reference/requirements-evidence-input-model/ expertise: [advanced] + - title: Requirements Context Adapter + url: /reference/requirements-context-adapter/ + expertise: [advanced] - title: Dependency Resolution url: /reference/dependency-resolution/ expertise: [advanced] diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md index f83476e1..26b1bb5a 100644 --- a/docs/getting-started/installation.md +++ b/docs/getting-started/installation.md @@ -11,7 +11,9 @@ expertise_level: [beginner] This guide will help you get started with SpecFact CLI in under 60 seconds — first with **no install** (uvx), then with a **persistent** install (pip) when you want IDE workflows and a stable `specfact` command. -> **Primary use case**: brownfield code modernization — reverse-engineering existing codebases into documented specs with runtime contract enforcement. See the [5-Minute Quickstart](quickstart.md) for a full walkthrough. +> **Primary use case**: validation evidence and AI-bloat defense for AI-assisted and brownfield Python delivery — +> deterministic review, drift detection, and spec/contract evidence before code reaches PR. +> See the [5-Minute Quickstart](quickstart.md) for a full walkthrough. ## Try it now — no install required diff --git a/docs/getting-started/tutorial-openspec-speckit.md b/docs/getting-started/tutorial-openspec-speckit.md index 1c0ad572..2951875f 100644 --- a/docs/getting-started/tutorial-openspec-speckit.md +++ b/docs/getting-started/tutorial-openspec-speckit.md @@ -7,7 +7,7 @@ permalink: /getting-started/tutorial-openspec-speckit/ # Tutorial: Using SpecFact with OpenSpec or Spec-Kit > **Complete step-by-step guide for new users** -> Learn how to use SpecFact CLI with OpenSpec or Spec-Kit for brownfield code modernization +> Learn how to use SpecFact CLI with OpenSpec or Spec-Kit to validate existing codebases and keep specs, contracts, and code in sync **Time**: 15-30 minutes | **Prerequisites**: Python 3.11+, basic command-line knowledge diff --git a/docs/guides/integrations-overview.md b/docs/guides/integrations-overview.md index b36d6084..8e30d98e 100644 --- a/docs/guides/integrations-overview.md +++ b/docs/guides/integrations-overview.md @@ -50,7 +50,8 @@ SpecFact CLI integrations fall into four main categories: - Learning and exploration of state machines and contracts - Single-developer projects and rapid prototyping -**Key difference**: Spec-Kit focuses on **new feature authoring**, while SpecFact CLI focuses on **brownfield code modernization**. +**Key difference**: Spec-Kit focuses on **new feature authoring**, while SpecFact CLI focuses on +**validating existing code and defending it against AI-bloat and drift**. **See also**: [Spec-Kit Journey Guide](./speckit-journey.md) diff --git a/docs/guides/speckit-journey.md b/docs/guides/speckit-journey.md index c8c0c5a4..fa0fbc9e 100644 --- a/docs/guides/speckit-journey.md +++ b/docs/guides/speckit-journey.md @@ -7,7 +7,7 @@ permalink: /guides/speckit-journey/ # The Journey: From Spec-Kit to SpecFact > **Spec-Kit and SpecFact are complementary, not competitive.** -> **Primary Use Case**: SpecFact CLI for brownfield code modernization +> **Primary Use Case**: SpecFact CLI for validation evidence and AI-bloat defense on existing codebases > **Secondary Use Case**: Add SpecFact enforcement to Spec-Kit's interactive authoring for new features --- diff --git a/docs/guides/use-cases.md b/docs/guides/use-cases.md index 13f32e2d..7289621d 100644 --- a/docs/guides/use-cases.md +++ b/docs/guides/use-cases.md @@ -10,7 +10,7 @@ redirect_from: Detailed use cases and examples for SpecFact CLI. -> **Primary Use Case**: Brownfield code modernization (Use Case 1) +> **Primary Use Case**: Validation evidence and AI-bloat defense on existing codebases (Use Case 1) > **Secondary Use Case**: Adding enforcement to Spec-Kit projects (Use Case 2) > **Alternative**: Greenfield spec-first development (Use Case 3) > **Validation Use Case**: Sidecar validation of external codebases (Use Case 4) 🆕 diff --git a/docs/index.md b/docs/index.md index d5302703..a4a3caf1 100644 --- a/docs/index.md +++ b/docs/index.md @@ -10,7 +10,7 @@ doc_owner: specfact-cli tracks: - src/specfact_cli/** - openspec/** -last_reviewed: 2026-05-21 +last_reviewed: 2026-07-08 exempt: false exempt_reason: "" --- @@ -157,6 +157,7 @@ bundle-specific adapters, and authoring guidance. - **[Module Categories](/reference/module-categories/)** - Category taxonomy - **[Module Contracts](/reference/module-contracts/)** - Contract interfaces - **[Requirements Evidence Input Model](/reference/requirements-evidence-input-model/)** - Requirement input records for validation evidence +- **[Requirements Context Adapter](/reference/requirements-context-adapter/)** - Import and coverage helpers for requirement context evidence - **[Operational Modes](/core-cli/modes/)** - CI/CD and CoPilot modes - **[Debug Logging](/core-cli/debug-logging/)** - Diagnostic logging diff --git a/docs/reference/README.md b/docs/reference/README.md index 821cd44a..eaffd95f 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -25,6 +25,7 @@ For bundle-specific deep command guides and runbooks, use the canonical modules - **[Module Security](module-security.md)** - Marketplace/module integrity and publisher metadata - **[Bridge Registry](bridge-registry.md)** - Registry-facing bridge converter declarations - **[Requirements Evidence Input Model](requirements-evidence-input-model.md)** - Requirement input records used by validation evidence +- **[Requirements Context Adapter](requirements-context-adapter.md)** - Import, validation, and coverage helpers for requirement context evidence - **[Directory Structure](directory-structure.md)** - Project structure and organization - **[Feature Keys](feature-keys.md)** - Key normalization and formats - **[Dependency Resolution](dependency-resolution.md)** - Module/pip dependency resolution behavior diff --git a/docs/reference/directory-structure.md b/docs/reference/directory-structure.md index 3b439630..aab6fedb 100644 --- a/docs/reference/directory-structure.md +++ b/docs/reference/directory-structure.md @@ -20,7 +20,10 @@ Use this document for repository-local artifact placement. Use [Module Development](../guides/module-development.md) and [Publishing modules](../guides/publishing-modules.md) for source/package layout. -> **Primary Use Case**: SpecFact CLI is designed for **brownfield code modernization** - reverse-engineering existing codebases into documented specs with runtime contract enforcement. The directory structure reflects this brownfield-first approach. +> **Primary Use Case**: SpecFact CLI produces **validation evidence and AI-bloat defense** for AI-assisted and +> brownfield Python delivery - reverse-engineering existing codebases into documented specs, then detecting drift, +> bloat, and weak contracts before merge. The directory structure reflects this evidence-first approach to +> existing code. **CLI-First Approach**: SpecFact works offline, requires no account, and integrates with your existing workflow. Works with VS Code, Cursor, GitHub Actions, pre-commit hooks, or any IDE. No platform to learn, no vendor lock-in. diff --git a/docs/reference/module-categories.md b/docs/reference/module-categories.md index d70486d3..de82cb2d 100644 --- a/docs/reference/module-categories.md +++ b/docs/reference/module-categories.md @@ -22,6 +22,7 @@ Category command groups: - `specfact code ...` - `specfact spec ...` - `specfact govern ...` +- `specfact requirements ...` ## Canonical Category Assignments @@ -47,6 +48,7 @@ Category command groups: | `generate` | `spec` | `specfact-spec` | `spec` | `generate` | | `enforce` | `govern` | `specfact-govern` | `govern` | `enforce` | | `patch_mode` | `govern` | `specfact-govern` | `govern` | `patch` | +| `requirements` | `requirements` | `specfact-requirements` | `requirements` | `requirements` | ## Bundle Contents by Category @@ -55,13 +57,14 @@ Category command groups: - `specfact-codebase`: `analyze`, `drift`, `validate`, `repro` - `specfact-spec`: `contract`, `api`, `sdd`, `generate` - `specfact-govern`: `enforce`, `patch` +- `specfact-requirements`: `requirements` ## Bundle Package Layout and Namespaces Official bundle packages are published from the dedicated modules repository: - Repository: `nold-ai/specfact-cli-modules` -- Package roots: `packages/specfact-project/`, `packages/specfact-backlog/`, `packages/specfact-codebase/`, `packages/specfact-spec/`, `packages/specfact-govern/` +- Package roots: `packages/specfact-project/`, `packages/specfact-backlog/`, `packages/specfact-codebase/`, `packages/specfact-spec/`, `packages/specfact-govern/`, `packages/specfact-requirements/` Namespace mapping: @@ -70,10 +73,11 @@ Namespace mapping: - `specfact-codebase` -> import namespace `specfact_codebase.*` - `specfact-spec` -> import namespace `specfact_spec.*` - `specfact-govern` -> import namespace `specfact_govern.*` +- `specfact-requirements` -> import namespace `specfact_requirements.*` Compatibility note: -- Flat top-level command shims were removed. Use category groups (`project`, `backlog`, `code`, `spec`, `govern`). +- Flat top-level command shims were removed. Use category groups (`project`, `backlog`, `code`, `spec`, `govern`, `requirements`). - `specfact backlog auth ...` is provided by the backlog bundle, not by the permanent core command surface. > Modules docs handoff: this page remains in the core docs set as release-line overview content. @@ -115,5 +119,5 @@ Before: After: -- Core top-level commands plus grouped workflow families (`project`, `backlog`, `code`, `spec`, `govern`). +- Core top-level commands plus grouped workflow families (`project`, `backlog`, `code`, `spec`, `govern`, `requirements`). - No backward-compatibility flat shims. diff --git a/docs/reference/requirements-context-adapter.md b/docs/reference/requirements-context-adapter.md new file mode 100644 index 00000000..e0e23495 --- /dev/null +++ b/docs/reference/requirements-context-adapter.md @@ -0,0 +1,79 @@ +--- +layout: default +title: Requirements Context Adapter +permalink: /reference/requirements-context-adapter/ +description: Core helpers for importing, normalizing, validating, and inspecting upstream requirement context. +keywords: [requirements, validation, evidence, adapter, projectbundle] +audience: [team, enterprise] +expertise_level: [advanced] +doc_owner: specfact-cli +tracks: + - src/specfact_cli/requirements/context.py + - tests/unit/requirements/test_context_adapter.py +last_reviewed: 2026-07-08 +exempt: false +exempt_reason: "" +--- + +# Requirements Context Adapter + +SpecFact core provides helpers that module runtimes can use to normalize +upstream requirement context into validation evidence. The helpers are designed +for import, validation, and inspection. They do not create a built-in +requirements authoring workflow. + +## Core Surface + +- `normalize_requirement_records(...)` converts source-attributed mappings or + `RequirementInput` objects into normalized records and bounded diagnostics. +- `attach_requirements_to_bundle(...)` stores normalized records under the + existing `requirements.inputs` `ProjectBundle` extension. +- `load_requirements_from_bundle(...)` reads that extension back into + `RequirementInput` instances. +- `validate_requirement_context(...)` emits a `ValidationReport` for evidence + usefulness by profile. +- `inspect_requirement_context_coverage(...)` returns machine-readable coverage + counts for downstream command handlers. + +```python +from specfact_cli.models.requirements import RequirementInput, RequirementSourceReference +from specfact_cli.requirements.context import ( + attach_requirements_to_bundle, + inspect_requirement_context_coverage, + normalize_requirement_records, +) + +result = normalize_requirement_records( + [ + { + "schema_version": "1", + "requirement_id": "REQ-239", + "title": "Imported context keeps source attribution", + "sources": [ + { + "source_type": "issue", + "locator": "https://github.com/nold-ai/specfact-cli/issues/239", + } + ], + } + ], + source_locator="requirements.yaml", +) +``` + +## Runtime Command Ownership + +The `requirements` command group is owned by the requirements module runtime. +Runtime commands should call the core helpers instead of parsing provider +payloads directly inside root CLI code. + +## Compatibility Notes + +- Requirement inputs must include `schema_version` and at least one source + reference. +- Invalid imported records produce bounded diagnostics; valid records remain + usable. +- Enterprise or strict validation treats missing downstream evidence links as + errors. Less strict profiles receive warnings. +- Backlog write-back and interactive requirement authoring remain outside this + core surface. diff --git a/docs/reference/requirements-evidence-input-model.md b/docs/reference/requirements-evidence-input-model.md index a68dec8a..6642e7ad 100644 --- a/docs/reference/requirements-evidence-input-model.md +++ b/docs/reference/requirements-evidence-input-model.md @@ -10,7 +10,7 @@ doc_owner: specfact-cli tracks: - src/specfact_cli/models/requirements.py - tests/unit/models/test_requirements.py -last_reviewed: 2026-07-07 +last_reviewed: 2026-07-08 exempt: false exempt_reason: "" --- @@ -76,3 +76,5 @@ bundle.set_extension( validation severity without blocking model construction by themselves. - No requirement authoring commands or backlog write-back behavior are provided by this model. +- Import, validation, and coverage helpers for these records live in + [Requirements Context Adapter](requirements-context-adapter.md). diff --git a/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md b/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md index 4fb21c6d..32ee9e85 100644 --- a/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md +++ b/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md @@ -1,31 +1,54 @@ -# Change Validation: requirements-02-module-commands +# Change Validation Report: requirements-02-module-commands -- **Validated on (UTC):** 2026-02-15T21:54:26Z -- **Workflow:** /wf-validate-change (proposal-stage dry-run validation) +- **Validation Date (Europe/Berlin):** 2026-07-08T21:00:03+02:00 +- **Workflow:** OpenSpec validate-change refresh before implementation - **Strict command:** `openspec validate requirements-02-module-commands --strict` - **Result:** PASS ## Scope Summary -- **New capabilities:** requirements-module -- **Modified capabilities:** module-io-contract,backlog-adapter +- **New capabilities:** requirements-context-adapter +- **Modified capabilities:** module-io-contract, backlog-adapter - **Declared dependencies:** requirements-01 (data model), arch-07 (#213, schema extensions for ProjectBundle) -- **Proposed affected code paths:** - `modules/requirements/` (new module);- `modules/backlog/src/adapters/` (extend adapters with AC text extraction interface) - `src/specfact_cli/contracts/module_interface.py` (no change — new implementation) +- **Proposed affected code paths:** + - `src/specfact_cli/requirements/context.py` + - `src/specfact_cli/requirements/__init__.py` + - `tests/unit/requirements/test_context_adapter.py` + - `docs/reference/requirements-context-adapter.md` ## Breaking-Change Analysis (Dry-Run) -- Interface changes are proposal-level only; no production code modifications were performed in this workflow stage. -- Proposed modified capabilities are additive/extension-oriented in the current spec deltas and do not require immediate breaking migrations at proposal time. -- Backward-compatibility risk is primarily sequencing-related (dependency ordering), not signature-level breakage at this stage. +- The change adds new helper APIs for module runtimes to reuse. +- ProjectBundle integration remains optional through the existing + `requirements.inputs` schema-extension namespace. +- No existing runtime command signature is changed. +- Existing bundles without `requirements.inputs` remain backward compatible. ## Dependency and Integration Review -- Dependency declarations align with the 2026-02-15 architecture layer integration plan sequencing. -- Cross-change integration points are explicitly represented in proposal/spec/task artifacts. -- No additional mandatory scope expansion was required to pass strict OpenSpec validation. +- `requirements-01-data-model` (#238) is implemented and archived on + 2026-07-07. +- `arch-07-schema-extension-system` (#213) is implemented and archived on + 2026-02-18. +- GitHub issue #239 was verified as open and not `in progress` through the + GitHub connector on 2026-07-08. +- The hierarchy cache refresh command succeeded with approved network access and + reported no cache changes. +- The roadmap places #239 under Requirements Layer / Epic #256; the connector + does not expose project parent fields, so the local roadmap remains the + available structure evidence. +- The public issue body and title are aligned to the narrowed + validation-evidence format. +- The internal wiki mirror `wiki/sources/requirements-02-module-commands.md` + must be updated when scope/status metadata changes and + `wiki_rebuild_graph.py` run from the internal repo root. ## Validation Outcome - Required artifacts are present: `proposal.md`, `design.md`, `specs/**/*.md`, `tasks.md`. - Strict OpenSpec validation passed. -- Change is ready for implementation-phase intake once prerequisites are satisfied. +- TDD implementation evidence is complete for the narrowed context-adapter + scope, but repository-wide total coverage remains below the 80% policy + threshold recorded by CI. +- Final release readiness is blocked until the required coverage gate is + satisfied or an explicit release-governance exception is recorded. diff --git a/openspec/changes/requirements-02-module-commands/TDD_EVIDENCE.md b/openspec/changes/requirements-02-module-commands/TDD_EVIDENCE.md new file mode 100644 index 00000000..4122e2ae --- /dev/null +++ b/openspec/changes/requirements-02-module-commands/TDD_EVIDENCE.md @@ -0,0 +1,95 @@ +# TDD Evidence: requirements-02-module-commands + +## Failing-before + +- **Timestamp (Europe/Berlin):** 2026-07-08T20:48:00+02:00 +- **Command:** `hatch run pytest tests/unit/requirements/test_context_adapter.py -q` +- **Result:** FAIL, expected +- **Summary:** Pytest failed during collection because the new core requirements + adapter package did not exist yet. +- **Key error:** `ModuleNotFoundError: No module named 'specfact_cli.requirements'` +- **Sandbox note:** A first unapproved Hatch run failed before pytest while + creating the worktree virtualenv; the approved rerun captured the behavioral + missing-package failure above. + +## Passing-after + +- **Timestamp (Europe/Berlin):** 2026-07-08T20:56:00+02:00 +- **Command:** `hatch run pytest tests/unit/requirements/test_context_adapter.py -q` +- **Result:** PASS +- **Summary:** 4 targeted tests passed, covering normalization diagnostics, + ProjectBundle extension IO, profile-aware validation severity, machine-readable + coverage summaries. + +## Quality Gates + +- **Final verification timestamp (Europe/Berlin):** + 2026-07-08T21:26:13+02:00 +- `openspec validate requirements-02-module-commands --strict`: PASS +- `hatch run format`: PASS, 637 files left unchanged after final edits. +- `hatch run pytest tests/unit/requirements/test_context_adapter.py -q`: PASS, + 4 passed. +- `hatch run type-check`: PASS, 0 errors; repository baseline warnings remain. +- `hatch run lint`: PASS, pylint 10.00/10. +- `hatch run yaml-lint`: PASS. +- `hatch run contract-test`: PASS, cached changed-file contract result. +- `hatch run smart-test-force`: PASS for test execution, 2,794 tests completed. + Total repository coverage reported 64.0%, below the 80% policy threshold; + this result is not recorded as a coverage-gate pass. + - Test log: + `logs/tests/test_run_20260708_211857.log` + - Coverage log: + `logs/tests/coverage_20260708_211857.log` + - Coverage disposition: existing repository-wide coverage gap remains; + changed adapter behavior is covered by targeted tests and code review, but + total coverage still requires follow-up before the 80% gate can be treated + as satisfied. + - Note: `hatch run smart-test` used the cached no-relevant-change shortcut + after staging and returned nonzero, so the forced full run is the + authoritative final suite evidence for this change. +- `hatch run check-version-sources`: PASS. +- `hatch run check-pypi-ahead`: PASS, local `0.50.0` ahead of PyPI `0.49.1`. +- `hatch run verify-modules-signature`: PASS, 4 manifests verified. +- `hatch run semgrep-sast --json --output /tmp/specfact-req02-semgrep.json`: + PASS, 0 findings. +- `hatch run semgrep-sast-gate --results /tmp/specfact-req02-semgrep.json --baseline tools/semgrep/sast-baseline.json`: + PASS. +- `hatch run bandit-scan`: PASS, no medium/high issues identified. +- `git diff --check`: PASS. +- `hatch run specfact code review run --json --out /tmp/req02-code-review-final.json --scope changed`: + PASS, no findings on the final code diff. +- `hatch run specfact code review run --json --out /tmp/req02-code-review-final.json --scope full --path src/specfact_cli/requirements/context.py`: + PASS, no findings after the final docs-only scope cleanup. + +## PR Review Fix Verification + +- **Timestamp (Europe/Berlin):** 2026-07-08T21:54:30+02:00 +- `openspec validate requirements-02-module-commands --strict`: PASS. +- `hatch run format`: PASS, 637 files left unchanged. +- `hatch run pytest tests/unit/requirements/test_context_adapter.py -q`: + PASS, 5 passed. +- `hatch run type-check`: PASS, 0 errors; repository baseline warnings remain. +- `hatch run lint`: PASS, pylint 10.00/10. +- `hatch run yaml-lint`: PASS. +- `hatch run python -m crosshair check src/specfact_cli/requirements/context.py --per_condition_timeout=5 --analysis_kind=icontract`: + PASS. +- `hatch run specfact code review run --json --out /tmp/req02-code-review-final.json --scope full --path src/specfact_cli/requirements/context.py`: + PASS, no findings. +- `git diff --check`: PASS. + +## CI Compatibility Fix Verification + +- **Timestamp (Europe/Berlin):** 2026-07-08T22:07:05+02:00 +- `hatch run pytest tests/unit/registry/test_module_grouping.py tests/unit/requirements/test_context_adapter.py -q`: + PASS, 14 passed. +- `hatch run python` discovery reproduction against fetched + `nold-ai/specfact-cli-modules` branch `feature/requirements-02-module-commands`: + PASS, discovered 7 packages and normalized `nold-ai/specfact-requirements` + to category/group `requirements`. +- `openspec validate requirements-02-module-commands --strict`: PASS. +- `hatch run type-check`: PASS, 0 errors; repository baseline warnings remain. +- `hatch run lint`: PASS, pylint 10.00/10. +- `hatch run yaml-lint`: PASS. +- `hatch run specfact code review run --json --out /tmp/req02-code-review-final.json --scope changed`: + PASS, no findings. +- `git diff --check`: PASS. diff --git a/openspec/changes/requirements-02-module-commands/design.md b/openspec/changes/requirements-02-module-commands/design.md index 5c0f2309..b4c0afce 100644 --- a/openspec/changes/requirements-02-module-commands/design.md +++ b/openspec/changes/requirements-02-module-commands/design.md @@ -1,6 +1,9 @@ ## Context -This change implements proposal scope for `requirements-02-module-commands` from the 2026-02-15 architecture-layer integration plan. It is proposal-stage only and defines implementation strategy without changing runtime code. +This change implements the core-owned proposal scope for +`requirements-02-module-commands` from the 2026-02-15 architecture-layer +integration plan. Runtime grouped commands remain paired module scope in +`nold-ai/specfact-cli-modules#165`. ## Goals / Non-Goals @@ -9,10 +12,14 @@ This change implements proposal scope for `requirements-02-module-commands` from - Define an implementation approach that stays within the proposal scope. - Keep compatibility with existing module registry, adapter bridge, and contract-first patterns. - Preserve offline-first behavior and deterministic CLI execution. +- Provide core helpers that module runtimes can reuse for import diagnostics, + ProjectBundle extension IO, validation reports, and coverage summaries. **Non-Goals:** -- No production code implementation in this stage. +- No requirement authoring workflow in this core repository. +- No runtime `specfact requirements ...` command handlers in this core + repository. - No schema-breaking changes outside declared capabilities. - No dependency expansion beyond the proposal and plan. @@ -22,21 +29,30 @@ This change implements proposal scope for `requirements-02-module-commands` from - Keep all public APIs contract-first with `@icontract` and `@beartype`. - Make all behavior extensions opt-in or backward-compatible by default. - Add/modify OpenSpec deltas first so tests can be derived before implementation. +- Store normalized requirement context through the existing + `requirements.inputs` extension from `requirements-01-data-model`. +- Treat missing downstream evidence links as profile-sensitive validation + findings: strict/enterprise profiles fail, lighter profiles warn. ## Risks / Trade-offs - [Dependency ordering drift] -> Mitigation: gate implementation tasks on declared prerequisites. - [Capability overlap with adjacent changes] -> Mitigation: keep this change scoped to listed capabilities only. - [Documentation drift] -> Mitigation: include explicit docs update tasks in apply phase. +- [Command ownership confusion] -> Mitigation: core ships reusable helpers only; + runtime command handlers stay in modules scope. ## Migration Plan 1. Implement this change only after listed dependencies are implemented. 2. Add tests from spec scenarios and capture failing-first evidence. 3. Implement minimal production changes needed for passing scenarios. -4. Run quality gates and then open PR to `dev`. +4. Update public docs, issue body, and internal wiki mirror metadata. +5. Run quality gates and then open PR to `dev`. ## Open Questions -- Dependency summary: Depends on requirements-01-data-model and arch-07-schema-extension-system. -- Whether additional cross-change sequencing constraints should be hard-blocked in `openspec/CHANGE_ORDER.md`. +- Dependency summary: requirements-01-data-model and + arch-07-schema-extension-system are implemented and archived. +- Runtime module command implementation remains tracked by paired modules issue + `nold-ai/specfact-cli-modules#165`. diff --git a/openspec/changes/requirements-02-module-commands/proposal.md b/openspec/changes/requirements-02-module-commands/proposal.md index 7708079e..365e2178 100644 --- a/openspec/changes/requirements-02-module-commands/proposal.md +++ b/openspec/changes/requirements-02-module-commands/proposal.md @@ -20,23 +20,35 @@ planning source. ## What Changes -- **NEW**: Import and normalization contract for upstream requirement-like - sources, including backlog items, OpenSpec proposals, Spec Kit feature - folders, and local markdown/YAML records. -- **NEW**: Validation command behavior that checks completeness, source - freshness, and evidence usefulness by profile. -- **NEW**: Coverage inspection over normalized inputs, architecture boundaries, - contracts, code, tests, and review findings. -- **NEW**: Adapter hooks that return bounded, source-attributed records rather - than free-form planning prose. +- **NEW**: Core import and normalization helpers for source-attributed + requirement records supplied by upstream adapters, including backlog-derived + snippets and OpenSpec-derived snippets that already conform to the normalized + requirement input contract. +- **NEW**: Profile-aware validation boundaries that check source attribution and + downstream evidence usefulness. +- **NEW**: Coverage inspection over normalized inputs, architecture, code, test, + and validation evidence links. +- **NEW**: Adapter protocol hooks that return bounded, source-attributed records + rather than free-form planning prose. - **REMOVED FROM CRITICAL PATH**: Interactive authoring templates and broad requirement lifecycle management. +## Out of Scope + +- Implementing the runtime `specfact requirements ...` command group in this + core repository. +- Implementing concrete ingestion adapters for Spec Kit feature folders, local + markdown files, or YAML requirement records. +- Interactive requirement authoring as a flagship workflow. +- Treating `.specfact/requirements/` as the system of record for product + management. +- Bidirectional backlog sync or ceremony automation. + ## Capabilities ### New Capabilities -- `requirements-validation-commands`: Commands for importing, normalizing, +- `requirements-context-adapter`: Core helpers for importing, normalizing, validating, and inspecting upstream requirement context as validation evidence. ### Modified Capabilities @@ -46,6 +58,37 @@ planning source. - `backlog-adapter`: Backlog adapters can provide source-attributed requirement snippets for validation. +## Impact + +- **Affected specs**: `requirements-context-adapter`, `module-io-contract`, + `backlog-adapter` +- **Affected code**: + - `src/specfact_cli/requirements/context.py` + - `src/specfact_cli/requirements/__init__.py` + - `src/specfact_cli/registry/module_grouping.py` +- **Affected tests**: + - `tests/unit/requirements/test_context_adapter.py` + - `tests/unit/registry/test_module_grouping.py` +- **Affected docs**: + - `docs/_data/nav.yml` + - `docs/index.md` + - `docs/reference/README.md` + - `docs/reference/module-categories.md` + - `docs/reference/requirements-context-adapter.md` + - `docs/reference/requirements-evidence-input-model.md` + - `CHANGELOG.md` +- **Integration points**: The paired modules runtime change + `nold-ai/specfact-cli-modules#165` consumes these helpers for grouped + commands. Core recognizes the `requirements` module category and group command + so the paired runtime can mount without shipping root CLI handlers here. This + core change does not ship backlog write-back or requirement authoring + commands. +- **Rollback plan**: remove the requirements adapter package, tests, reference + page, docs navigation/index updates, requirements evidence model cross-link, + OpenSpec change artifacts, and version/changelog entry. Existing + `requirements.inputs` bundle data remains compatible because it is still + defined by `requirements-01-data-model`. + --- ## Source Tracking @@ -55,5 +98,7 @@ planning source. - **Issue URL**: - **Paired Modules Runtime Issue**: nold-ai/specfact-cli-modules#165 - **Paired Modules Scope**: requirements runtime commands -- **Last Synced Status**: proposed +- **Repository**: nold-ai/specfact-cli +- **Last Synced Status**: in_progress - **Sanitized**: false + diff --git a/openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md b/openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md index f08b2213..65300dd7 100644 --- a/openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md +++ b/openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md @@ -2,18 +2,20 @@ ### Requirement: Backlog Adapter -The system SHALL expose backlog acceptance-criteria content to requirements extraction workflows. +The system SHALL define source-attributed backlog requirement snippets that +requirements runtime adapters can normalize without provider-specific parsing in +core command handlers. -#### Scenario: Adapter returns acceptance criteria payload for extraction +#### Scenario: Adapter returns source fields for requirement context import -- **GIVEN** a backlog item selected for extraction -- **WHEN** requirements extraction requests source fields -- **THEN** adapter returns title, description, acceptance-criteria text, and item identity -- **AND** extraction proceeds without provider-specific parsing in command handlers. +- **GIVEN** a backlog item selected for requirement context import +- **WHEN** the requirements adapter receives source fields +- **THEN** the adapter can return title, description, acceptance-criteria text, and item identity +- **AND** normalization proceeds without provider-specific parsing in core helpers. #### Scenario: Missing acceptance criteria is surfaced explicitly - **GIVEN** a backlog item with no acceptance criteria -- **WHEN** extraction runs -- **THEN** item is reported as incomplete input -- **AND** command output includes the backlog item identifier. +- **WHEN** requirements context normalization runs +- **THEN** the item is reported as incomplete input +- **AND** diagnostics include the backlog item identifier. diff --git a/openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md b/openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md index dbe03fb8..4e023758 100644 --- a/openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md +++ b/openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md @@ -2,18 +2,26 @@ ### Requirement: Module Io Contract -The requirements module SHALL implement all `ModuleIOContract` operations. +Requirements implementations SHALL consume core requirements context adapter +helpers through the existing `ModuleIOContract` boundary. #### Scenario: Import operation maps backlog items to requirements -- **GIVEN** backlog input for import -- **WHEN** `import_to_bundle` runs -- **THEN** requirements are added to the bundle with stable IDs -- **AND** parse diagnostics are included for partial failures. +- **GIVEN** source-attributed requirement records imported by a module +- **WHEN** `import_to_bundle` stores normalized requirements on a `ProjectBundle` +- **THEN** requirements are added under the `requirements.inputs` extension with stable IDs +- **AND** parse diagnostics remain available to the module runtime for partial failures. #### Scenario: Validate operation enforces profile schema -- **GIVEN** requirements bundle and active profile schema -- **WHEN** `validate_bundle` runs -- **THEN** missing required fields are reported -- **AND** validation severity respects active policy mode. +- **GIVEN** a requirements bundle and active validation profile +- **WHEN** `validate_bundle` delegates to core requirements context validation +- **THEN** missing evidence links and weak context are reported +- **AND** validation severity respects the selected evidence strictness profile. + +#### Scenario: Requirements runtime mounts through grouped module metadata + +- **GIVEN** a paired requirements runtime package with category `requirements` +- **WHEN** module metadata is discovered by core registry validation +- **THEN** the `requirements` category is accepted with group command `requirements` +- **AND** root CLI command handlers remain outside this core change. diff --git a/openspec/changes/requirements-02-module-commands/specs/requirements-module/spec.md b/openspec/changes/requirements-02-module-commands/specs/requirements-module/spec.md index 3cd5861a..37c0baf6 100644 --- a/openspec/changes/requirements-02-module-commands/specs/requirements-module/spec.md +++ b/openspec/changes/requirements-02-module-commands/specs/requirements-module/spec.md @@ -1,26 +1,28 @@ ## ADDED Requirements -### Requirement: Requirements Module +### Requirement: Requirements Context Adapter -The system SHALL provide requirements CLI commands for extract, author, validate, and list. +The system SHALL provide core requirements context adapter helpers for import, +normalization, validation, and coverage inspection of upstream requirement +context as validation evidence. -#### Scenario: Extract command creates requirement artifacts +#### Scenario: Import helpers normalize source-attributed records -- **GIVEN** `specfact requirements extract --from-backlog --output .specfact/requirements/` -- **WHEN** extraction succeeds -- **THEN** one or more `*.req.yaml` files are produced -- **AND** each file includes schema version and source backlog reference. +- **GIVEN** upstream requirement-like records with source references +- **WHEN** requirements context normalization runs +- **THEN** valid records are returned as `RequirementInput` instances +- **AND** each record keeps schema version and source attribution. -#### Scenario: Author command applies profile-aware template fields +#### Scenario: Invalid imported records produce bounded diagnostics -- **GIVEN** `specfact requirements author --template story` -- **WHEN** active profile is `solo` -- **THEN** authoring prompts require only solo-required fields -- **AND** optional advanced fields remain non-blocking. +- **GIVEN** one valid upstream record and one malformed upstream record +- **WHEN** requirements context normalization runs +- **THEN** valid records are preserved +- **AND** the malformed record is reported as a diagnostic without free-form planning prose. -#### Scenario: Validate and list expose completeness and trace coverage +#### Scenario: Validation and coverage expose evidence usefulness -- **GIVEN** requirement artifacts with trace references -- **WHEN** `specfact requirements validate` and `specfact requirements list --show-coverage` run -- **THEN** completeness and coverage are reported per requirement -- **AND** output is machine-readable when requested. +- **GIVEN** normalized requirement inputs on a `ProjectBundle` +- **WHEN** requirements context validation and coverage inspection run +- **THEN** bundle-level completeness and coverage counts are reported with missing-evidence requirement IDs +- **AND** the result is machine-readable for downstream module commands. diff --git a/openspec/changes/requirements-02-module-commands/tasks.md b/openspec/changes/requirements-02-module-commands/tasks.md index 592c1906..770b01d5 100644 --- a/openspec/changes/requirements-02-module-commands/tasks.md +++ b/openspec/changes/requirements-02-module-commands/tasks.md @@ -2,29 +2,37 @@ ## 1. Branch and dependency guardrails -- [ ] 1.1 Create dedicated worktree branch `feature/requirements-02-module-commands` from `dev` before implementation work: `scripts/worktree.sh create feature/requirements-02-module-commands`. -- [ ] 1.2 Verify prerequisite changes are implemented or explicitly accepted as parallel work. -- [ ] 1.3 Reconfirm scope against the 2026-02-15 architecture integration plan and this proposal. +- [x] 1.1 Create dedicated worktree branch `feature/requirements-02-module-commands` from `dev` before implementation work: `scripts/worktree.sh create feature/requirements-02-module-commands`. +- [x] 1.2 Refresh GitHub hierarchy cache, verify issue #239 is not in progress, and confirm available label/structure metadata. +- [x] 1.3 Verify prerequisite changes are implemented or explicitly accepted as parallel work. +- [x] 1.4 Reconfirm scope against `openspec/CHANGE_ORDER.md`: keep this change as import, normalization, validation, and coverage helpers for validation evidence. +- [x] 1.5 Update the public GitHub issue body to match the narrowed validation-evidence format. +- [x] 1.6 Update the internal wiki mirror and run `wiki_rebuild_graph.py` from the internal repo root. ## 2. Spec-first and test-first preparation -- [ ] 2.1 Finalize `specs/` deltas for all listed capabilities and cross-check scenario completeness. -- [ ] 2.2 Add/update tests mapped to new and modified scenarios. -- [ ] 2.3 Run targeted tests to capture failing-first behavior and record results in `TDD_EVIDENCE.md`. +- [x] 2.1 Finalize `specs/` deltas for all listed capabilities and cross-check scenario completeness. +- [x] 2.2 Add/update tests mapped to new and modified scenarios. +- [x] 2.3 Run targeted tests to capture failing-first behavior and record results in `TDD_EVIDENCE.md`. ## 3. Implementation -- [ ] 3.1 Implement minimal production code required to satisfy the new scenarios. -- [ ] 3.2 Add/update contract decorators and type enforcement on public APIs. -- [ ] 3.3 Update command wiring, adapters, and models required by this change scope only. +- [x] 3.1 Implement minimal production code required to satisfy the new scenarios. +- [x] 3.2 Add/update contract decorators and type enforcement on public APIs. +- [x] 3.3 Update adapter helpers and models required by this change scope only. +- [x] 3.4 Keep runtime `specfact requirements ...` command handlers in the paired modules-repo scope; core only exposes reusable helpers. +- [x] 3.5 Add core registry category compatibility for the paired `requirements` module group without adding root CLI handlers. ## 4. Validation and documentation -- [ ] 4.1 Re-run tests and quality gates until all changed scenarios pass. -- [ ] 4.2 Update user-facing docs and navigation for changed/added commands and workflows. -- [ ] 4.3 Run `openspec validate requirements-02-module-commands --strict` and resolve all issues. +- [x] 4.1 Re-run tests and quality gates until all changed scenarios pass. +- [x] 4.2 Update user-facing docs and navigation for changed/added commands and workflows. +- [x] 4.3 Run module-signature verification; if signed module assets changed, bump module versions and re-sign before PR. +- [x] 4.4 Run `openspec validate requirements-02-module-commands --strict` and resolve all issues. +- [x] 4.5 Run SpecFact code review JSON, independent static analysis, and clean-code gates; resolve all findings or document rare explicit exceptions. ## 5. Delivery -- [ ] 5.1 Update `openspec/CHANGE_ORDER.md` status/dependency notes if implementation sequencing changed. -- [ ] 5.2 Open a PR from `feature/requirements-02-module-commands` to `dev` with spec/test/code/docs evidence. +- [x] 5.1 Update version files and `CHANGELOG.md` with a minor feature release entry. +- [x] 5.2 Review `openspec/CHANGE_ORDER.md` status/dependency notes; update only if implementation sequencing changed. +- [x] 5.3 Open a PR from `feature/requirements-02-module-commands` to `dev` with spec/test/code/docs evidence. diff --git a/pyproject.toml b/pyproject.toml index 9abfbd92..fc5003e5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.49.1" +version = "0.50.1" description = "AI-bloat defense CLI for Python teams. Run deterministic code review, cleanup forecasts, and spec/contract evidence for AI-assisted and brownfield delivery." readme = "README.md" requires-python = ">=3.11" diff --git a/setup.py b/setup.py index 39069804..452e8986 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.49.1", + version="0.50.1", description=( "AI-bloat defense CLI for Python teams. Run deterministic code review, cleanup forecasts, " "and spec/contract evidence for AI-assisted and brownfield delivery." diff --git a/src/__init__.py b/src/__init__.py index 2d7b5205..a52ea986 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.49.1" +__version__ = "0.50.1" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 95ff063e..ac5607b6 100644 --- a/src/specfact_cli/__init__.py +++ b/src/specfact_cli/__init__.py @@ -76,6 +76,6 @@ def _install_progressive_disclosure() -> None: # keeps missing-command and missing-parameter UX consistent outside the root CLI too. _install_progressive_disclosure() -__version__ = "0.49.1" +__version__ = "0.50.1" __all__ = ["__version__"] diff --git a/src/specfact_cli/models/project.py b/src/specfact_cli/models/project.py index ce6dd072..19d92dbc 100644 --- a/src/specfact_cli/models/project.py +++ b/src/specfact_cli/models/project.py @@ -559,7 +559,13 @@ def load_from_directory( if progress_callback: progress_callback(current + 1, total_artifacts, "bundle.manifest.yaml") manifest_data = load_structured_file(manifest_path) - manifest = BundleManifest.model_validate(manifest_data) + if not isinstance(manifest_data, dict): + raise ValueError("Bundle manifest must be a mapping") + manifest_mapping = cast(dict[str, Any], manifest_data) + extensions_data = manifest_mapping.get("extensions", {}) + if not isinstance(extensions_data, dict): + raise ValueError("ProjectBundle extensions must be a mapping") + manifest = BundleManifest.model_validate(manifest_mapping) current += 1 slots = _BundleLoadSlots() @@ -585,6 +591,7 @@ def load_from_directory( features=slots.features, clarifications=slots.clarifications, change_tracking=change_tracking, + extensions=dict(extensions_data), ) @beartype @@ -663,6 +670,8 @@ def save_to_directory( progress_callback(total_artifacts, total_artifacts, "bundle.manifest.yaml") manifest_path = bundle_dir / "bundle.manifest.yaml" manifest_data = self.manifest.model_dump(mode="json") + if self.extensions: + manifest_data["extensions"] = self.extensions if num_features > 1000: import json diff --git a/src/specfact_cli/registry/module_grouping.py b/src/specfact_cli/registry/module_grouping.py index ccc38719..ce4829b1 100644 --- a/src/specfact_cli/registry/module_grouping.py +++ b/src/specfact_cli/registry/module_grouping.py @@ -8,17 +8,21 @@ from specfact_cli.models.module_package import ModulePackageMetadata -VALID_CATEGORIES = frozenset({"core", "project", "backlog", "codebase", "spec", "govern"}) +VALID_CATEGORIES = frozenset({"core", "project", "backlog", "codebase", "spec", "govern", "requirements"}) CATEGORY_TO_GROUP_COMMAND: dict[str, str] = { "project": "project", "backlog": "backlog", "codebase": "code", "spec": "spec", "govern": "govern", + "requirements": "requirements", } LEGACY_GROUP_COMMAND_ALIASES: dict[tuple[str, str], str] = { ("codebase", "codebase"): "code", } +LEGACY_CATEGORY_ALIASES: dict[tuple[str, str], str] = { + ("project", "requirements"): "requirements", +} class ModuleManifestError(Exception): @@ -71,6 +75,9 @@ def normalize_legacy_bundle_group_command(meta: ModulePackageMetadata) -> Module """Normalize known legacy bundle group values to canonical grouped commands.""" if meta.category is None or meta.bundle_group_command is None: return meta + normalized_category = LEGACY_CATEGORY_ALIASES.get((meta.category, meta.bundle_group_command)) + if normalized_category is not None: + meta.category = normalized_category normalized = LEGACY_GROUP_COMMAND_ALIASES.get((meta.category, meta.bundle_group_command)) if normalized is not None: meta.bundle_group_command = normalized diff --git a/src/specfact_cli/requirements/__init__.py b/src/specfact_cli/requirements/__init__.py new file mode 100644 index 00000000..f6ebe080 --- /dev/null +++ b/src/specfact_cli/requirements/__init__.py @@ -0,0 +1,28 @@ +"""Requirements context adapter helpers.""" + +from specfact_cli.requirements.context import ( + RequirementContextAdapter, + RequirementContextCoverageSummary, + RequirementContextDiagnostic, + RequirementContextDiagnosticSeverity, + RequirementContextImportResult, + attach_requirements_to_bundle, + inspect_requirement_context_coverage, + load_requirements_from_bundle, + normalize_requirement_records, + validate_requirement_context, +) + + +__all__ = [ + "RequirementContextAdapter", + "RequirementContextCoverageSummary", + "RequirementContextDiagnostic", + "RequirementContextDiagnosticSeverity", + "RequirementContextImportResult", + "attach_requirements_to_bundle", + "inspect_requirement_context_coverage", + "load_requirements_from_bundle", + "normalize_requirement_records", + "validate_requirement_context", +] diff --git a/src/specfact_cli/requirements/context.py b/src/specfact_cli/requirements/context.py new file mode 100644 index 00000000..8dca5644 --- /dev/null +++ b/src/specfact_cli/requirements/context.py @@ -0,0 +1,330 @@ +"""Core requirements context adapter contracts and helpers.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from enum import StrEnum +from pathlib import Path +from typing import Any, Literal, Protocol, cast, get_args + +from beartype import beartype +from icontract import ensure, require +from pydantic import BaseModel, ConfigDict, Field, ValidationError + +from specfact_cli.models.project import ProjectBundle +from specfact_cli.models.requirements import ( + RequirementEvidenceLinkType, + RequirementInput, + load_requirements_input_extension, + requirements_input_extension_payload, +) +from specfact_cli.models.validation import ValidationReport + + +RequirementContextValidationProfile = Literal[ + "solo", + "startup", + "team", + "enterprise", + "strict", + "solo_developer", + "api_first_team", + "enterprise_full_stack", +] +KNOWN_REQUIREMENT_CONTEXT_PROFILES: frozenset[str] = frozenset(get_args(RequirementContextValidationProfile)) +STRICT_REQUIREMENT_CONTEXT_PROFILES: frozenset[str] = frozenset({"enterprise", "strict", "enterprise_full_stack"}) + + +class RequirementContextDiagnosticSeverity(StrEnum): + """Severity for bounded requirements context diagnostics.""" + + INFO = "info" + WARNING = "warning" + ERROR = "error" + + +@beartype +class RequirementContextDiagnostic(BaseModel): + """Bounded diagnostic emitted while importing requirement context.""" + + model_config = ConfigDict(use_enum_values=True) + + severity: RequirementContextDiagnosticSeverity = Field(..., description="Diagnostic severity") + code: str = Field(..., min_length=1, description="Stable diagnostic code") + message: str = Field(..., min_length=1, description="Human-readable diagnostic message") + source_locator: str | None = Field(default=None, description="Optional upstream source locator") + requirement_id: str | None = Field(default=None, description="Best-effort requirement identifier") + record_index: int | None = Field( + default=None, + ge=0, + description="Zero-based source record index when available", + ) + + +@beartype +class RequirementContextImportResult(BaseModel): + """Normalized requirement import result plus bounded diagnostics.""" + + requirements: list[RequirementInput] = Field(default_factory=list, description="Normalized requirement records") + diagnostics: list[RequirementContextDiagnostic] = Field( + default_factory=list, + description="Import diagnostics for records that could not be normalized", + ) + + +@beartype +class RequirementContextCoverageSummary(BaseModel): + """Machine-readable coverage summary for normalized requirement inputs.""" + + total_requirements: int = Field(..., ge=0, description="Total normalized requirements") + with_business_rules: int = Field(..., ge=0, description="Requirements with business rules") + with_constraints: int = Field(..., ge=0, description="Requirements with constraints") + with_evidence_links: int = Field(..., ge=0, description="Requirements with evidence links") + with_architecture_links: int = Field(..., ge=0, description="Requirements linked to architecture evidence") + with_code_links: int = Field(..., ge=0, description="Requirements linked to code evidence") + with_test_links: int = Field(..., ge=0, description="Requirements linked to test evidence") + missing_evidence_requirement_ids: list[str] = Field( + default_factory=list, + description="Requirement IDs missing downstream evidence links", + ) + + +class RequirementContextAdapter(Protocol): + """Protocol for module adapters that import requirement-like sources.""" + + @beartype + @require(lambda source: isinstance(source, Path), "source must be a Path") + def import_requirements(self, source: Path, config: Mapping[str, Any]) -> RequirementContextImportResult: + """Import source-attributed requirement records from an upstream source.""" + raise NotImplementedError + + +def _all_records_supported(records: Sequence[RequirementInput | Mapping[str, Any]]) -> bool: + return all(isinstance(record, RequirementInput | Mapping) for record in records) + + +def _normalization_result_valid(result: RequirementContextImportResult) -> bool: + return all(isinstance(requirement, RequirementInput) for requirement in result.requirements) + + +def _record_requirement_id(record: RequirementInput | Mapping[str, Any]) -> str | None: + if isinstance(record, RequirementInput): + return _optional_text(record.requirement_id) + value = record.get("requirement_id") + return _optional_text(value) if isinstance(value, str) and value else None + + +def _validation_error_message(exc: ValidationError) -> str: + first_error = exc.errors()[0] if exc.errors() else {"msg": str(exc)} + location = ".".join(str(part) for part in first_error.get("loc", ())) + message = str(first_error.get("msg", "invalid requirement input")) or "invalid requirement input" + return f"{location}: {message}" if location else message + + +def _optional_text(value: str | None) -> str | None: + if not value: + return None + realize = getattr(value, "__ch_realize__", None) + if callable(realize): + realized = realize() + return realized if isinstance(realized, str) else str(realized) + return value + + +def _profile_nonempty(profile: str) -> bool: + return profile.strip() != "" + + +def _profile_supported(profile: str) -> bool: + return profile in KNOWN_REQUIREMENT_CONTEXT_PROFILES + + +def _source_locator_supported(source_locator: str | None) -> bool: + return source_locator is None or type(source_locator) is str + + +def _coverage_summary_consistent(result: RequirementContextCoverageSummary) -> bool: + return result.total_requirements >= len(result.missing_evidence_requirement_ids) + + +def _requirements_with_evidence_link_type( + requirements: Sequence[RequirementInput], + link_type: RequirementEvidenceLinkType, +) -> int: + count = 0 + for requirement in requirements: + if any(link.link_type == link_type for link in requirement.evidence_links): + count += 1 + return max(0, count) + + +def _missing_evidence_requirement_ids(requirements: Sequence[RequirementInput]) -> list[str]: + return [requirement.requirement_id for requirement in requirements if not requirement.evidence_links] + + +def _invalid_requirement_diagnostic( + exc: ValidationError, + record: RequirementInput | Mapping[str, Any], + source_locator: str | None, + record_index: int, +) -> RequirementContextDiagnostic: + return RequirementContextDiagnostic( + severity=RequirementContextDiagnosticSeverity.ERROR, + code="invalid_requirement_input", + message=_validation_error_message(exc), + source_locator=_optional_text(source_locator), + requirement_id=_record_requirement_id(record), + record_index=record_index, + ) + + +@beartype +@require(_all_records_supported, "records must be RequirementInput or mapping values") +@require(_source_locator_supported, "source_locator must be text when provided") +@ensure(_normalization_result_valid) +def normalize_requirement_records( + records: Sequence[RequirementInput | Mapping[str, Any]], + *, + source_locator: str | None = None, +) -> RequirementContextImportResult: + """Normalize source-attributed requirement records with bounded diagnostics.""" + normalized: list[RequirementInput] = [] + diagnostics: list[RequirementContextDiagnostic] = [] + + for record_index, record in enumerate(records): + if isinstance(record, RequirementInput): + normalized.append(record) + continue + try: + normalized.append(RequirementInput.model_validate(record)) + except ValidationError as exc: + diagnostics.append(_invalid_requirement_diagnostic(exc, record, source_locator, record_index)) + + return RequirementContextImportResult(requirements=normalized, diagnostics=diagnostics) + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "bundle must be a ProjectBundle") +@require(lambda requirements: all(isinstance(record, RequirementInput) for record in requirements)) +@ensure(lambda result: isinstance(result, ProjectBundle)) +def attach_requirements_to_bundle(bundle: ProjectBundle, requirements: Sequence[RequirementInput]) -> ProjectBundle: + """Store normalized requirements on the existing requirements.inputs extension.""" + bundle.set_extension("requirements", "inputs", requirements_input_extension_payload(list(requirements))) + return bundle + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "bundle must be a ProjectBundle") +@ensure(lambda result: all(isinstance(record, RequirementInput) for record in result)) +def load_requirements_from_bundle(bundle: ProjectBundle) -> list[RequirementInput]: + """Load normalized requirements from the requirements.inputs extension.""" + payload = bundle.get_extension("requirements", "inputs", default=None) + if payload is None: + return [] + if not isinstance(payload, dict): + raise ValueError("requirements.inputs extension must be a mapping payload") + return load_requirements_input_extension(cast(dict[str, Any], payload)) + + +@beartype +@require(lambda requirements: all(isinstance(record, RequirementInput) for record in requirements)) +@ensure(_coverage_summary_consistent) +def inspect_requirement_context_coverage( + requirements: Sequence[RequirementInput], +) -> RequirementContextCoverageSummary: + """Return machine-readable coverage counts for normalized requirement inputs.""" + requirement_list = list(requirements) + return RequirementContextCoverageSummary( + total_requirements=len(requirement_list), + with_business_rules=sum(bool(requirement.business_rules) for requirement in requirement_list), + with_constraints=sum(bool(requirement.constraints) for requirement in requirement_list), + with_evidence_links=sum(bool(requirement.evidence_links) for requirement in requirement_list), + with_architecture_links=_requirements_with_evidence_link_type( + requirement_list, + RequirementEvidenceLinkType.ARCHITECTURE, + ), + with_code_links=_requirements_with_evidence_link_type(requirement_list, RequirementEvidenceLinkType.CODE), + with_test_links=_requirements_with_evidence_link_type(requirement_list, RequirementEvidenceLinkType.TEST), + missing_evidence_requirement_ids=_missing_evidence_requirement_ids(requirement_list), + ) + + +def _missing_evidence_severity(profile: RequirementContextValidationProfile) -> str: + return "error" if profile in STRICT_REQUIREMENT_CONTEXT_PROFILES else "warning" + + +def _empty_requirements_report() -> ValidationReport: + return ValidationReport( + status="warnings", + violations=[ + { + "severity": "warning", + "message": "No requirements.inputs records are attached to the bundle.", + "location": "requirements.inputs", + } + ], + summary={"total_checks": 1, "passed": 0, "failed": 0, "warnings": 1}, + ) + + +def _missing_evidence_violations( + coverage: RequirementContextCoverageSummary, + *, + severity: str, +) -> list[dict[str, str]]: + return [ + { + "severity": severity, + "message": "Requirement input has no downstream evidence links.", + "location": f"requirements.inputs[{requirement_id}].evidence_links", + } + for requirement_id in coverage.missing_evidence_requirement_ids + ] + + +def _validation_status(failed: int, warnings: int) -> Literal["passed", "failed", "warnings"]: + if failed: + return "failed" + if warnings: + return "warnings" + return "passed" + + +def _validation_summary( + requirements: Sequence[RequirementInput], + coverage: RequirementContextCoverageSummary, + violations: Sequence[Mapping[str, str]], +) -> dict[str, int]: + failed = sum(violation["severity"] == "error" for violation in violations) + warnings = sum(violation["severity"] == "warning" for violation in violations) + return { + "total_checks": max(1, len(requirements) * 2), + "passed": len(requirements) + coverage.with_evidence_links, + "failed": failed, + "warnings": warnings, + } + + +@beartype +@require(lambda bundle: isinstance(bundle, ProjectBundle), "bundle must be a ProjectBundle") +@require(_profile_nonempty, "profile must not be empty") +@require(_profile_supported, "profile must be a supported requirements context validation profile") +@ensure(lambda result: isinstance(result, ValidationReport)) +def validate_requirement_context( + bundle: ProjectBundle, + *, + profile: RequirementContextValidationProfile = "startup", +) -> ValidationReport: + """Validate requirements context evidence usefulness for a ProjectBundle.""" + requirements = load_requirements_from_bundle(bundle) + if not requirements: + return _empty_requirements_report() + + coverage = inspect_requirement_context_coverage(requirements) + violations = _missing_evidence_violations(coverage, severity=_missing_evidence_severity(profile)) + summary = _validation_summary(requirements, coverage, violations) + return ValidationReport( + status=_validation_status(summary["failed"], summary["warnings"]), + violations=violations, + summary=summary, + ) diff --git a/tests/unit/registry/test_module_grouping.py b/tests/unit/registry/test_module_grouping.py index 0042046e..c64a7368 100644 --- a/tests/unit/registry/test_module_grouping.py +++ b/tests/unit/registry/test_module_grouping.py @@ -58,6 +58,40 @@ def test_module_package_yaml_with_category_codebase_passes_validation(tmp_path: assert meta.bundle_sub_command == "analyze" +def test_module_package_yaml_with_category_requirements_passes_validation(tmp_path: Path) -> None: + """requirements modules mount under the requirements group command.""" + _write_manifest( + tmp_path, + "requirements", + category="requirements", + bundle="specfact-requirements", + bundle_group_command="requirements", + bundle_sub_command="requirements", + ) + packages = discover_package_metadata(tmp_path, source="builtin") + assert len(packages) == 1 + meta = packages[0][1] + assert meta.category == "requirements" + assert meta.bundle_group_command == "requirements" + + +def test_prerelease_project_requirements_group_is_normalized(tmp_path: Path) -> None: + """Prerelease requirements manifests using project category normalize before validation.""" + _write_manifest( + tmp_path, + "requirements", + category="project", + bundle="specfact-requirements", + bundle_group_command="requirements", + bundle_sub_command="requirements", + ) + packages = discover_package_metadata(tmp_path, source="builtin") + assert len(packages) == 1 + meta = packages[0][1] + assert meta.category == "requirements" + assert meta.bundle_group_command == "requirements" + + def test_legacy_codebase_bundle_group_command_is_normalized(tmp_path: Path) -> None: """Legacy marketplace manifests using codebase as group command are normalized to code.""" _write_manifest( diff --git a/tests/unit/requirements/__init__.py b/tests/unit/requirements/__init__.py new file mode 100644 index 00000000..1e12e795 --- /dev/null +++ b/tests/unit/requirements/__init__.py @@ -0,0 +1 @@ +"""Requirements context tests.""" diff --git a/tests/unit/requirements/test_context_adapter.py b/tests/unit/requirements/test_context_adapter.py new file mode 100644 index 00000000..bb7c33df --- /dev/null +++ b/tests/unit/requirements/test_context_adapter.py @@ -0,0 +1,179 @@ +"""Tests for requirements context adapter helpers.""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any, cast + +import pytest +from beartype.roar import BeartypeCallHintParamViolation + +from specfact_cli.models.plan import Product +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.models.requirements import ( + RequirementEvidenceLink, + RequirementEvidenceLinkType, + RequirementInput, + RequirementSourceReference, + RequirementSourceType, +) +from specfact_cli.requirements.context import ( + attach_requirements_to_bundle, + inspect_requirement_context_coverage, + load_requirements_from_bundle, + normalize_requirement_records, + validate_requirement_context, +) + + +def _source() -> RequirementSourceReference: + return RequirementSourceReference( + source_type=RequirementSourceType.ISSUE, + locator="https://github.com/nold-ai/specfact-cli/issues/239", + title="Requirements Context Adapter Commands", + ) + + +def _requirement(requirement_id: str = "REQ-239") -> RequirementInput: + return RequirementInput( + requirement_id=requirement_id, + schema_version="1", + title="Requirement context is source attributed", + sources=[_source()], + evidence_links=[ + RequirementEvidenceLink( + link_type=RequirementEvidenceLinkType.TEST, + target="tests/unit/requirements/test_context_adapter.py", + ) + ], + ) + + +def _bundle() -> ProjectBundle: + manifest = BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ) + return ProjectBundle(manifest=manifest, bundle_name="test", product=Product(themes=[], releases=[])) + + +def test_normalize_requirement_records_preserves_valid_records_and_bounds_diagnostics() -> None: + """Normalization keeps valid records and reports malformed records deterministically.""" + result = normalize_requirement_records( + [ + _requirement().model_dump(mode="json"), + {"requirement_id": "REQ-BROKEN", "schema_version": "1", "title": "Missing sources"}, + {"schema_version": "1", "title": "Missing id and sources"}, + ], + source_locator="fixtures/requirements.yaml", + ) + + assert [record.requirement_id for record in result.requirements] == ["REQ-239"] + assert [diagnostic.code for diagnostic in result.diagnostics] == [ + "invalid_requirement_input", + "invalid_requirement_input", + ] + assert [diagnostic.requirement_id for diagnostic in result.diagnostics] == ["REQ-BROKEN", None] + assert [diagnostic.record_index for diagnostic in result.diagnostics] == [1, 2] + assert {diagnostic.source_locator for diagnostic in result.diagnostics} == {"fixtures/requirements.yaml"} + + +def test_normalize_requirement_records_preserves_diagnostics_when_crosshair_loaded( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Diagnostic identity fields must not depend on test-tool imports.""" + monkeypatch.setitem(sys.modules, "crosshair", object()) + + result = normalize_requirement_records( + [{"requirement_id": "REQ-BROKEN", "schema_version": "1", "title": "Missing sources"}], + source_locator="fixtures/requirements.yaml", + ) + + assert len(result.diagnostics) == 1 + assert result.diagnostics[0].requirement_id == "REQ-BROKEN" + assert result.diagnostics[0].source_locator == "fixtures/requirements.yaml" + + +def test_bundle_helpers_attach_and_load_requirements_input_extension() -> None: + """Normalized inputs are stored through the existing ProjectBundle extension namespace.""" + bundle = _bundle() + requirement = _requirement() + + returned = attach_requirements_to_bundle(bundle, [requirement]) + + assert returned is bundle + loaded = load_requirements_from_bundle(bundle) + assert [record.requirement_id for record in loaded] == ["REQ-239"] + + +def test_bundle_helpers_persist_requirements_input_extension(tmp_path: Path) -> None: + """Attached requirements survive ProjectBundle directory save/load.""" + bundle_dir = tmp_path / "test-bundle" + bundle = attach_requirements_to_bundle(_bundle(), [_requirement()]) + + bundle.save_to_directory(bundle_dir) + loaded = ProjectBundle.load_from_directory(bundle_dir) + + requirements = load_requirements_from_bundle(loaded) + assert [record.requirement_id for record in requirements] == ["REQ-239"] + + +def test_validate_requirement_context_reports_profile_aware_evidence_gaps() -> None: + """Enterprise validation treats missing evidence links as failed evidence usefulness.""" + requirement = RequirementInput( + requirement_id="REQ-NO-EVIDENCE", + schema_version="1", + title="Requirement lacks downstream evidence", + sources=[_source()], + ) + bundle = attach_requirements_to_bundle(_bundle(), [requirement]) + + report = validate_requirement_context(bundle, profile="enterprise") + + assert report.status == "failed" + assert report.summary == {"total_checks": 2, "passed": 1, "failed": 1, "warnings": 0} + assert report.violations[0]["severity"] == "error" + assert report.violations[0]["location"] == "requirements.inputs[REQ-NO-EVIDENCE].evidence_links" + + +def test_validate_requirement_context_rejects_unknown_profile() -> None: + """Profile selection uses a shared bounded vocabulary to catch typos.""" + bundle = attach_requirements_to_bundle(_bundle(), [_requirement()]) + + with pytest.raises(BeartypeCallHintParamViolation): + validate_requirement_context(bundle, profile=cast(Any, "enterprize")) + + +def test_inspect_requirement_context_coverage_is_machine_readable() -> None: + """Coverage inspection reports counts downstream command handlers can serialize.""" + requirement_with_repeated_test_links = _requirement("REQ-WITH-EVIDENCE") + requirement_with_repeated_test_links.evidence_links.append( + RequirementEvidenceLink( + link_type=RequirementEvidenceLinkType.TEST, + target="tests/unit/requirements/test_context_adapter.py::duplicate", + ) + ) + requirements = [ + requirement_with_repeated_test_links, + RequirementInput( + requirement_id="REQ-WITHOUT-EVIDENCE", + schema_version="1", + title="Requirement lacks downstream evidence", + sources=[_source()], + ), + ] + + coverage = inspect_requirement_context_coverage(requirements) + + assert coverage.model_dump() == { + "total_requirements": 2, + "with_business_rules": 0, + "with_constraints": 0, + "with_evidence_links": 1, + "with_architecture_links": 0, + "with_code_links": 0, + "with_test_links": 1, + "missing_evidence_requirement_ids": ["REQ-WITHOUT-EVIDENCE"], + }