diff --git a/docs/_data/nav.yml b/docs/_data/nav.yml index 66f665c2..caa9704b 100644 --- a/docs/_data/nav.yml +++ b/docs/_data/nav.yml @@ -55,6 +55,11 @@ - title: Import & Migration url: /bundles/project/import-migration/ expertise: [intermediate, advanced] + - name: Requirements + items: + - title: Overview + url: /bundles/requirements/overview/ + expertise: [beginner, intermediate] - name: Codebase items: - title: Overview diff --git a/docs/bundles/requirements/overview.md b/docs/bundles/requirements/overview.md new file mode 100644 index 00000000..7a43e94f --- /dev/null +++ b/docs/bundles/requirements/overview.md @@ -0,0 +1,97 @@ +--- +layout: default +title: Requirements bundle overview +nav_order: 2 +permalink: /bundles/requirements/overview/ +keywords: [requirements, bundle, validation, evidence, coverage] +audience: [solo, team, enterprise] +expertise_level: [beginner, intermediate] +--- + +# Requirements bundle overview + +The **Requirements** bundle (`nold-ai/specfact-requirements`) imports existing +requirement context into SpecFact validation evidence. It normalizes upstream +records, stores them on project bundles as `requirements.inputs`, validates +evidence usefulness by profile, and reports coverage. It does not replace your +planning or product-management system. + +## Prerequisites + +- SpecFact CLI with core requirements context helpers +- Bundle installed: `specfact module install nold-ai/specfact-requirements` +- A project bundle directory, usually created by the [Project](/bundles/project/overview/) bundle +- Local JSON or YAML requirement records with source attribution + +## Command surface + +After installation, `specfact requirements --help` lists the runtime command +group. + +| Command | Purpose | +|--------|---------| +| `import` | Import local JSON/YAML requirement records into a project bundle | +| `validate` | Validate attached requirement context against a profile | +| `list` | List attached requirement records, optionally with coverage | +| `coverage` | Print coverage counts for downstream evidence links | + +## Input shape + +Import accepts either a list of records or a mapping with a `requirements` list. +Each record follows the core `RequirementInput` model and must include +`schema_version`, `requirement_id`, `title`, and at least one source reference. + +```json +{ + "requirements": [ + { + "schema_version": "1", + "requirement_id": "REQ-101", + "title": "Checkout requires fraud screening", + "sources": [ + { + "source_type": "issue", + "locator": "https://github.com/example/shop/issues/101" + } + ], + "evidence_links": [ + { + "link_type": "test", + "target": "tests/test_checkout_fraud.py" + } + ] + } + ] +} +``` + +## Quick examples + +```bash +specfact requirements import --from-file requirements.json --bundle .specfact/projects/shop --format json +specfact requirements list --bundle .specfact/projects/shop --show-coverage --format json +specfact requirements validate --bundle .specfact/projects/shop --profile enterprise --format json +specfact requirements coverage --bundle .specfact/projects/shop --format json +``` + +## Storage + +The command runtime rehydrates the core `requirements.inputs` extension before +delegating to validation helpers. Because current project bundle serialization +does not persist arbitrary extensions directly, the module also writes +`requirements.inputs.yaml` in the bundle root as the local persistence sidecar. + +## Scope boundaries + +The bundle is read-first and evidence-focused. + +- It imports and normalizes source-attributed requirement context. +- It reports validation findings and coverage gaps. +- It does not expose authoring templates. +- It does not perform bidirectional backlog sync or ceremony automation. + +## See also + +- [Project bundle overview](/bundles/project/overview/) +- [Backlog adapter patterns](/adapters/backlog-adapter-patterns/) +- [ProjectBundle schema](/reference/projectbundle-schema/) diff --git a/docs/index.md b/docs/index.md index 54fc43b2..45c85476 100644 --- a/docs/index.md +++ b/docs/index.md @@ -69,6 +69,7 @@ The modules site owns all bundle-specific deep guidance. Core CLI platform docs |--------|----------|------------| | Backlog | [Overview](bundles/backlog/overview/) | [Refinement](bundles/backlog/refinement/), [Delta](bundles/backlog/delta/), [Policy Engine](bundles/backlog/policy-engine/), [Dependency Analysis](bundles/backlog/dependency-analysis/) | | Project | [Overview](bundles/project/overview/) | [DevOps Flow](bundles/project/devops-flow/), [Import & Migration](bundles/project/import-migration/) | +| Requirements | [Overview](bundles/requirements/overview/) | Import, validate, list, coverage | | Codebase | [Overview](bundles/codebase/overview/) | [Sidecar Validation](bundles/codebase/sidecar-validation/), [Analyze](bundles/codebase/analyze/), [Drift](bundles/codebase/drift/), [Repro](bundles/codebase/repro/) | | Spec | [Overview](bundles/spec/overview/) | [Validate](bundles/spec/validate/), [Generate Tests](bundles/spec/generate-tests/), [Mock](bundles/spec/mock/) | | Govern | [Overview](bundles/govern/overview/) | [Enforce](bundles/govern/enforce/), [Patch](bundles/govern/patch/) | diff --git a/docs/reference/commands.generated.json b/docs/reference/commands.generated.json index c9e438de..21294e57 100644 --- a/docs/reference/commands.generated.json +++ b/docs/reference/commands.generated.json @@ -1695,6 +1695,99 @@ "source": "specfact_project.project.commands:app", "subcommands": [] }, + { + "arguments": [], + "bare_invocation": "requires-subcommand", + "command": "specfact requirements", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-requirements", + "options": [ + "--install-completion", + "--show-completion" + ], + "owner_package": "nold-ai/specfact-requirements", + "owner_repo": "nold-ai/specfact-cli-modules", + "short_help": "", + "source": "specfact_requirements.requirements.commands:app", + "subcommands": [ + "coverage", + "import", + "list", + "validate" + ] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact requirements coverage", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-requirements", + "options": [ + "--bundle", + "--format" + ], + "owner_package": "nold-ai/specfact-requirements", + "owner_repo": "nold-ai/specfact-cli-modules", + "short_help": "", + "source": "specfact_requirements.requirements.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact requirements import", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-requirements", + "options": [ + "--bundle", + "--format", + "--from-file" + ], + "owner_package": "nold-ai/specfact-requirements", + "owner_repo": "nold-ai/specfact-cli-modules", + "short_help": "", + "source": "specfact_requirements.requirements.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact requirements list", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-requirements", + "options": [ + "--bundle", + "--format", + "--show-coverage" + ], + "owner_package": "nold-ai/specfact-requirements", + "owner_repo": "nold-ai/specfact-cli-modules", + "short_help": "", + "source": "specfact_requirements.requirements.commands:app", + "subcommands": [] + }, + { + "arguments": [], + "bare_invocation": "executes", + "command": "specfact requirements validate", + "deprecated": false, + "hidden": false, + "install_prerequisite": "specfact module install nold-ai/specfact-requirements", + "options": [ + "--bundle", + "--format", + "--profile" + ], + "owner_package": "nold-ai/specfact-requirements", + "owner_repo": "nold-ai/specfact-cli-modules", + "short_help": "", + "source": "specfact_requirements.requirements.commands:app", + "subcommands": [] + }, { "arguments": [], "bare_invocation": "requires-subcommand", diff --git a/docs/reference/commands.generated.md b/docs/reference/commands.generated.md index a3991d43..315987f9 100644 --- a/docs/reference/commands.generated.md +++ b/docs/reference/commands.generated.md @@ -93,6 +93,11 @@ This file is generated from the current module command trees. Do not edit by han | `specfact project version bump` | nold-ai/specfact-project | `specfact module install nold-ai/specfact-project` | --bundle, --repo, --type; args: - | - | | | `specfact project version check` | nold-ai/specfact-project | `specfact module install nold-ai/specfact-project` | --bundle, --repo; args: - | - | | | `specfact project version set` | nold-ai/specfact-project | `specfact module install nold-ai/specfact-project` | --bundle, --repo, --version; args: - | - | | +| `specfact requirements` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --install-completion, --show-completion; args: - | coverage, import, list, validate | | +| `specfact requirements coverage` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format; args: - | - | | +| `specfact requirements import` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format, --from-file; args: - | - | | +| `specfact requirements list` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format, --show-coverage; args: - | - | | +| `specfact requirements validate` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format, --profile; args: - | - | | | `specfact spec` | nold-ai/specfact-spec | `specfact module install nold-ai/specfact-spec` | --install-completion, --show-completion; args: - | backward-compat, generate-tests, mock, validate | | | `specfact spec backward-compat` | nold-ai/specfact-spec | `specfact module install nold-ai/specfact-spec` | -; args: - | - | | | `specfact spec generate-tests` | nold-ai/specfact-spec | `specfact module install nold-ai/specfact-spec` | --bundle, --force, --out, --output; args: - | - | | diff --git a/llms.txt b/llms.txt index 9204718e..2cd332d2 100644 --- a/llms.txt +++ b/llms.txt @@ -97,6 +97,11 @@ This file is generated from the current module command trees. Do not edit by han | `specfact project version bump` | nold-ai/specfact-project | `specfact module install nold-ai/specfact-project` | --bundle, --repo, --type; args: - | - | | | `specfact project version check` | nold-ai/specfact-project | `specfact module install nold-ai/specfact-project` | --bundle, --repo; args: - | - | | | `specfact project version set` | nold-ai/specfact-project | `specfact module install nold-ai/specfact-project` | --bundle, --repo, --version; args: - | - | | +| `specfact requirements` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --install-completion, --show-completion; args: - | coverage, import, list, validate | | +| `specfact requirements coverage` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format; args: - | - | | +| `specfact requirements import` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format, --from-file; args: - | - | | +| `specfact requirements list` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format, --show-coverage; args: - | - | | +| `specfact requirements validate` | nold-ai/specfact-requirements | `specfact module install nold-ai/specfact-requirements` | --bundle, --format, --profile; args: - | - | | | `specfact spec` | nold-ai/specfact-spec | `specfact module install nold-ai/specfact-spec` | --install-completion, --show-completion; args: - | backward-compat, generate-tests, mock, validate | | | `specfact spec backward-compat` | nold-ai/specfact-spec | `specfact module install nold-ai/specfact-spec` | -; args: - | - | | | `specfact spec generate-tests` | nold-ai/specfact-spec | `specfact module install nold-ai/specfact-spec` | --bundle, --force, --out, --output; args: - | - | | diff --git a/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md b/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md index 4fb21c6d..9a7988bb 100644 --- a/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md +++ b/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md @@ -1,31 +1,64 @@ -# 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:32:32+02:00 +- **Workflow:** OpenSpec validate-change refresh and implementation final gate - **Strict command:** `openspec validate requirements-02-module-commands --strict` - **Result:** PASS ## Scope Summary -- **New capabilities:** requirements-module -- **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) +- **New capabilities:** requirements-validation-runtime +- **Modified capabilities:** module-io-contract, backlog-adapter +- **Declared dependencies:** core requirements input model and core + requirements context helpers from `nold-ai/specfact-cli#239` +- **Proposed affected code paths:** + - `packages/specfact-requirements/` + - `tests/unit/specfact_requirements/` + - `tests/integration/specfact_requirements/` + - `docs/bundles/requirements/` + - `docs/reference/commands.generated.*` + - `llms.txt` + - `scripts/check-bundle-imports.py` + - `scripts/generate-command-overview.py` -## Breaking-Change Analysis (Dry-Run) +## Breaking-Change Analysis -- 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 a new module bundle and grouped command surface. +- Existing bundles and registry entries remain backward compatible. +- ProjectBundle integration remains optional through the existing + `requirements.inputs` extension namespace. +- No existing runtime command signature is changed. ## 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. +- Core `requirements-01-data-model` is implemented and archived. +- Core `requirements-02-module-commands` (#239) is paired parallel work and + exposes the helpers this module consumes. +- GitHub issue #165 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 on + 2026-07-08T21:05:31+02:00. +- The connector does not expose GitHub project parent fields; the refreshed + local hierarchy cache remains the available structure evidence. ## 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. +- Required artifacts are present: `proposal.md`, `design.md`, `specs/**/*.md`, + `tasks.md`. +- Strict OpenSpec validation passed after implementation. +- Targeted failing-first test evidence was captured before the module package + existed, then passed after implementation. +- Final quality gates passed: + - `hatch run format` + - `hatch run type-check` + - `hatch run lint` + - `hatch run yaml-lint` + - `hatch run check-bundle-imports` + - `hatch run check-command-overview` + - `hatch run check-command-contract` + - `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump --public-key-file resources/keys/module-signing-public.pem` + - `hatch run contract-test` + - `hatch run smart-test` (`849 passed, 2 warnings`) + - `hatch run test` (`849 passed, 2 warnings`) + - `hatch run specfact code review run --enforcement changed --bug-hunt --json --out .specfact/code-review.json` +- SpecFact code review result: `PASS`, score `115`, `0` findings. 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..01216ed7 --- /dev/null +++ b/openspec/changes/requirements-02-module-commands/TDD_EVIDENCE.md @@ -0,0 +1,41 @@ +# TDD Evidence: requirements-02-module-commands + +## Failing-before + +- **Timestamp (Europe/Berlin):** 2026-07-08T21:28:00+02:00 +- **Command:** `hatch run pytest tests/unit/specfact_requirements/test_requirements_runtime.py tests/integration/specfact_requirements/test_command_apps.py -q` +- **Result:** FAIL, expected +- **Summary:** Pytest failed during collection because the new requirements + runtime module did not exist yet. +- **Key error:** `ModuleNotFoundError: No module named 'specfact_requirements'` + +## Passing-after + +- **Timestamp (Europe/Berlin):** 2026-07-08T21:43:00+02:00 +- **Command:** `hatch run pytest tests/unit/specfact_requirements/test_requirements_runtime.py tests/integration/specfact_requirements/test_command_apps.py -q` +- **Result:** PASS +- **Summary:** 6 targeted tests passed, covering file import, bounded + diagnostics, sidecar-backed bundle persistence, profile-aware validation, + JSON command output, coverage inspection, and absence of an authoring command. + +## Quality Gates + +- **Timestamp (Europe/Berlin):** 2026-07-08T21:32:32+02:00 +- **Result:** PASS +- **Commands:** + - `openspec validate requirements-02-module-commands --strict` + - `hatch run format` + - `hatch run type-check` + - `hatch run lint` + - `hatch run yaml-lint` + - `hatch run check-bundle-imports` + - `hatch run check-command-overview` + - `hatch run check-command-contract` + - `hatch run verify-modules-signature --payload-from-filesystem --enforce-version-bump --public-key-file resources/keys/module-signing-public.pem` + - `hatch run contract-test` + - `hatch run smart-test` + - `hatch run test` + - `hatch run specfact code review run --enforcement changed --bug-hunt --json --out .specfact/code-review.json` +- **Summary:** targeted and full gates passed after the requirements runtime + cleanup. `smart-test` and `test` both reported `849 passed, 2 warnings`. + SpecFact code review reported `PASS`, score `115`, and no findings. diff --git a/openspec/changes/requirements-02-module-commands/design.md b/openspec/changes/requirements-02-module-commands/design.md index f70d8055..906c3814 100644 --- a/openspec/changes/requirements-02-module-commands/design.md +++ b/openspec/changes/requirements-02-module-commands/design.md @@ -1,6 +1,10 @@ ## 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 module-owned runtime scope for +`requirements-02-module-commands` from the 2026-02-15 architecture-layer +integration plan. The paired core change `nold-ai/specfact-cli#239` supplies +shared requirement context helpers; this modules change supplies the grouped +runtime command surface. ## Goals / Non-Goals @@ -8,9 +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 `specfact requirements ...` command handlers that reuse core + normalization, bundle extension IO, validation report, and coverage summary + helpers. +- Emit JSON-friendly command output for validation evidence and AI handoff. **Non-Goals:** -- No production code implementation in this stage. +- No requirement authoring workflow. +- No bidirectional backlog sync or ceremony automation. - No schema-breaking changes outside declared capabilities. - No dependency expansion beyond the proposal and plan. @@ -20,21 +29,38 @@ 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`. +- Keep command output bounded to core diagnostics, validation reports, and + coverage summaries. +- Treat failed validation as a non-zero command result while keeping `warnings` + visible as successful advisory evidence. ## 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. +- [Core helper drift] -> Mitigation: validate against the paired + `specfact-cli-worktrees/feature/requirements-02-module-commands` checkout + until core #239 merges. +- [Command ownership confusion] -> Mitigation: module ships runtime command + handlers; core only provides helpers and missing-module diagnostics. ## Migration Plan -1. Implement this change only after listed dependencies are implemented. +1. Implement this change only after listed dependencies are implemented or + accepted as paired parallel work. 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, command overview artifacts, issue body, and 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 in core; core #239 is paired + parallel work and must remain API-compatible with this module runtime. +- The connector does not expose GitHub project fields; local hierarchy cache and + live issue state remain the available governance evidence. diff --git a/openspec/changes/requirements-02-module-commands/proposal.md b/openspec/changes/requirements-02-module-commands/proposal.md index 222a1425..7dba75f5 100644 --- a/openspec/changes/requirements-02-module-commands/proposal.md +++ b/openspec/changes/requirements-02-module-commands/proposal.md @@ -1,28 +1,36 @@ -# Change: Requirements Import and Validation Runtime +# Change: Requirements Runtime Commands ## Why -SpecFact needs module commands that import, normalize, validate, and inspect -upstream requirement context for evidence. It should not become the authoring -stack for requirements. +SpecFact needs a module-owned `requirements` command group that imports, +normalizes, validates, and inspects upstream requirement context for validation +evidence. It should not become the authoring stack for requirements, since +teams may already use Spec Kit, OpenSpec, Jira, GitHub Issues, Azure DevOps, +Linear, documents, or another planning source. ## Ownership Alignment (2026-06-06) -- Modules-owned scope retained here: grouped command runtime, adapters, and - validation behavior for normalized requirement inputs. -- Core-owned scope remains the shared requirements input model and evidence - contracts. +- Repository assignment: `split/rescope` +- Modules-owned scope retained here: grouped `specfact requirements ...` + runtime commands, module manifest wiring, docs, and adapter-facing command + behavior for normalized requirement inputs. +- Core-owned scope remains the shared requirements input model, adapter helper + APIs, evidence contracts, and missing-module root diagnostics. +- Runtime commands MUST consume the paired core helpers from + `nold-ai/specfact-cli#239`. - Requirement authoring templates are no longer critical-path scope. ## What Changes -- **NEW**: Import commands for backlog items, OpenSpec proposals, Spec Kit feature - folders, and local requirement records. -- **NEW**: Normalization into source-attributed records compatible with the core - requirements input model. -- **NEW**: Validation and coverage inspection for evidence usefulness by profile. -- **NEW**: Adapter hooks return bounded records instead of free-form planning - prose. +- **NEW**: `specfact requirements import` command for local requirement records + and adapter-produced source-attributed records. +- **NEW**: `specfact requirements validate` command that delegates to the core + profile-aware validation boundary. +- **NEW**: `specfact requirements list` and `specfact requirements coverage` + commands for machine-readable coverage inspection. +- **NEW**: Requirements module manifest and command overview wiring. +- **NEW**: Command runtime preserves bounded core diagnostics instead of + generating free-form planning prose. - **REMOVED FROM CRITICAL PATH**: Interactive requirement authoring and full requirement lifecycle management. @@ -30,16 +38,44 @@ stack for requirements. ### New Capabilities -- `requirements-validation-runtime`: Runtime commands for importing, +- `requirements-validation-runtime`: Module runtime commands for importing, normalizing, validating, and inspecting upstream requirement context. ### Modified Capabilities -- `module-io-contract`: Requirements implementation focuses on import and - validation hooks for evidence. +- `module-io-contract`: Requirements implementation focuses on import, + validation, and coverage hooks for evidence. - `backlog-adapter`: Backlog adapters can provide source-attributed requirement snippets. +## Impact + +- **Affected specs**: `requirements-module`, `module-io-contract`, + `backlog-adapter` +- **Affected code**: + - `packages/specfact-requirements/module-package.yaml` + - `packages/specfact-requirements/src/specfact_requirements/requirements/commands.py` + - `packages/specfact-requirements/src/specfact_requirements/requirements/runtime.py` + - `scripts/generate-command-overview.py` + - `scripts/check-bundle-imports.py` + - `tests/conftest.py` +- **Affected tests**: + - `tests/unit/specfact_requirements/test_requirements_runtime.py` + - `tests/integration/specfact_requirements/test_command_apps.py` +- **Affected docs**: + - `docs/bundles/requirements/overview.md` + - `docs/reference/commands.generated.json` + - `docs/reference/commands.generated.md` + - `docs/_data/nav.yml` + - `llms.txt` +- **Integration points**: Consumes the core helper APIs from + `nold-ai/specfact-cli#239` and the `requirements.inputs` model from + `requirements-01-data-model`. +- **Rollback plan**: remove the requirements bundle package, generated command + overview rows, docs/nav entries, tests, and module manifest. Existing + `requirements.inputs` bundle data remains compatible because the data model is + core-owned. + --- ## Source Tracking @@ -48,5 +84,6 @@ stack for requirements. - **GitHub Issue**: #165 - **Issue URL**: - **Core Counterpart**: nold-ai/specfact-cli#239 -- **Last Synced Status**: proposed +- **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 9a401ffa..dae70768 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 @@ -1,16 +1,20 @@ ## MODIFIED Requirements ### Requirement: Backlog Adapter -The system SHALL expose backlog acceptance-criteria content to requirements extraction workflows. -#### Scenario: Adapter returns acceptance criteria payload for extraction -- **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. +The system SHALL expose source-attributed backlog requirement snippets to +requirements import workflows. + +#### Scenario: Adapter returns acceptance criteria payload for import + +- **GIVEN** a backlog item selected for requirement context import +- **WHEN** requirements import receives adapter source fields +- **THEN** title, description, acceptance-criteria text, and item identity are available +- **AND** normalization proceeds without provider-specific parsing in command handlers. #### 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 +- **WHEN** normalization runs +- **THEN** the item is reported as incomplete input - **AND** command output includes 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 3a4e15ee..28f851e8 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 @@ -1,16 +1,20 @@ ## MODIFIED Requirements ### Requirement: Module Io Contract -The requirements module SHALL implement all `ModuleIOContract` operations. + +The requirements module 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 command +- **WHEN** the runtime 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** the runtime delegates to core requirements context validation +- **THEN** missing evidence links and weak context are reported +- **AND** validation severity respects the selected evidence strictness profile. 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 769f2813..dad621a7 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,22 +1,35 @@ ## ADDED Requirements -### Requirement: Requirements Module -The system SHALL provide requirements CLI commands for extract, author, validate, and list. - -#### Scenario: Extract command creates requirement artifacts -- **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. - -#### Scenario: Author command applies profile-aware template fields -- **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. - -#### Scenario: Validate and list expose completeness and trace coverage -- **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. +### Requirement: Requirements Runtime Commands + +The system SHALL provide module-owned `specfact requirements ...` commands for +importing, validating, listing, and inspecting upstream requirement context as +validation evidence. + +#### Scenario: Import local requirement records into bundle extensions + +- **GIVEN** a local JSON or YAML file containing source-attributed requirement records +- **WHEN** `specfact requirements import --from-file --bundle ` runs +- **THEN** valid records are stored under the bundle's `requirements.inputs` extension +- **AND** invalid records are returned as bounded diagnostics. + +#### Scenario: Validate requirement context by profile + +- **GIVEN** a project bundle with normalized requirement inputs +- **WHEN** `specfact requirements validate --bundle --profile enterprise` runs +- **THEN** validation delegates to the core profile-aware requirements context helper +- **AND** missing downstream evidence links are reported as failed validation. + +#### Scenario: List requirements with coverage summary + +- **GIVEN** requirement inputs are present on a project bundle +- **WHEN** `specfact requirements list --bundle --show-coverage --format json` runs +- **THEN** each requirement ID and title is returned +- **AND** the output includes a machine-readable coverage summary. + +#### Scenario: No authoring command is exposed + +- **GIVEN** the requirements module is installed +- **WHEN** the user inspects `specfact requirements --help` +- **THEN** import, validate, list, and coverage commands are visible +- **AND** requirement authoring commands are not exposed. diff --git a/openspec/changes/requirements-02-module-commands/tasks.md b/openspec/changes/requirements-02-module-commands/tasks.md index 592c1906..ab43561c 100644 --- a/openspec/changes/requirements-02-module-commands/tasks.md +++ b/openspec/changes/requirements-02-module-commands/tasks.md @@ -2,29 +2,35 @@ ## 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. +- [x] 1.2 Refresh GitHub hierarchy cache, verify issue #165 is not in progress, and confirm available label/structure metadata. +- [x] 1.3 Verify prerequisite changes are implemented or explicitly accepted as paired parallel work. +- [x] 1.4 Reconfirm scope against `openspec/CHANGE_ORDER.md`: keep this change as module runtime commands for import, normalization, validation, and coverage evidence. +- [x] 1.5 Update the public GitHub issue body to match the narrowed validation-evidence format. +- [x] 1.6 Update the wiki mirror and run the graph rebuild workflow if a mirror source exists. ## 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 `specfact-requirements` module package and grouped command app. +- [x] 3.2 Add/update contract decorators and type enforcement on public APIs. +- [x] 3.3 Update command overview, import-boundary wiring, module manifest, and test bootstrap source lists. +- [x] 3.4 Keep requirement authoring, backlog write-back, and lifecycle management outside this module runtime. ## 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 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 `openspec/CHANGE_ORDER.md` status/dependency notes if implementation sequencing changed. +- [x] 5.2 Open a PR from `feature/requirements-02-module-commands` to `dev` with spec/test/code/docs evidence: https://github.com/nold-ai/specfact-cli-modules/pull/326 diff --git a/packages/specfact-requirements/module-package.yaml b/packages/specfact-requirements/module-package.yaml new file mode 100644 index 00000000..e465cb26 --- /dev/null +++ b/packages/specfact-requirements/module-package.yaml @@ -0,0 +1,23 @@ +name: nold-ai/specfact-requirements +version: 0.1.5 +commands: + - requirements +tier: official +publisher: + name: nold-ai + email: hello@noldai.com +bundle_dependencies: + - nold-ai/specfact-project +pip_dependencies: + - beartype + - icontract + - pydantic + - pyyaml + - typer +core_compatibility: '>=0.50.0,<1.0.0' +description: Official SpecFact requirements evidence runtime bundle package. +category: project +bundle_group_command: requirements +integrity: + checksum: sha256:d200aaecff7760ee660dd984d70d417b56960879385ebd745ddf48f08cee1b0d + signature: 3cXSjTAnhQHUcvDsM4pYwp8OxJ/gAvNtZD3H0zCMqOyuHW5axSeCJcpGDqjno1YLAq+v4B1PL5HYuJkAUeZQAQ== diff --git a/packages/specfact-requirements/src/specfact_requirements/__init__.py b/packages/specfact-requirements/src/specfact_requirements/__init__.py new file mode 100644 index 00000000..0a0f0fb7 --- /dev/null +++ b/packages/specfact-requirements/src/specfact_requirements/__init__.py @@ -0,0 +1,5 @@ +"""SpecFact requirements runtime module.""" + +__all__ = ["__version__"] + +__version__ = "0.1.0" diff --git a/packages/specfact-requirements/src/specfact_requirements/requirements/__init__.py b/packages/specfact-requirements/src/specfact_requirements/requirements/__init__.py new file mode 100644 index 00000000..91e8d6d9 --- /dev/null +++ b/packages/specfact-requirements/src/specfact_requirements/requirements/__init__.py @@ -0,0 +1,6 @@ +"""Requirements command group.""" + +from specfact_requirements.requirements.commands import app + + +__all__ = ["app"] diff --git a/packages/specfact-requirements/src/specfact_requirements/requirements/app.py b/packages/specfact-requirements/src/specfact_requirements/requirements/app.py new file mode 100644 index 00000000..551a4b3d --- /dev/null +++ b/packages/specfact-requirements/src/specfact_requirements/requirements/app.py @@ -0,0 +1,6 @@ +"""requirements command entrypoint.""" + +from specfact_requirements.requirements.commands import app + + +__all__ = ["app"] diff --git a/packages/specfact-requirements/src/specfact_requirements/requirements/commands.py b/packages/specfact-requirements/src/specfact_requirements/requirements/commands.py new file mode 100644 index 00000000..2064958f --- /dev/null +++ b/packages/specfact-requirements/src/specfact_requirements/requirements/commands.py @@ -0,0 +1,134 @@ +"""Command handlers for requirement context evidence.""" + +from __future__ import annotations + +import json +from enum import StrEnum +from pathlib import Path +from typing import Annotated, Any + +import typer +from beartype import beartype +from icontract import ensure, require + +from specfact_requirements.requirements.runtime import ( + import_requirements_file_to_bundle, + inspect_requirements_bundle_coverage, + is_requirement_context_profile_supported, + list_requirements_with_coverage, + validate_requirements_bundle, +) + + +class OutputFormat(StrEnum): + """Supported command output formats.""" + + JSON = "json" + TEXT = "text" + + +app = typer.Typer( + help="Import, validate, and inspect requirement context evidence.", + no_args_is_help=True, + context_settings={"help_option_names": ["-h", "--help", "--help-advanced", "-ha"]}, +) + + +def _format_supported(output_format: OutputFormat) -> bool: + return output_format in {OutputFormat.JSON, OutputFormat.TEXT} + + +@beartype +@require(_format_supported, "output format must be supported") +@ensure(lambda result: result is None) +def _emit_payload(payload: dict[str, Any], output_format: OutputFormat) -> None: + if output_format == OutputFormat.JSON: + typer.echo(json.dumps(payload, indent=2, sort_keys=True)) + return + for key, value in payload.items(): + typer.echo(f"{key}: {value}") + + +@app.command("import", help="Import local requirement records into a project bundle.") +@beartype +@require(lambda from_file: from_file.is_file(), "from_file must exist") +@require(lambda bundle: bundle.is_dir(), "bundle must exist") +@require(_format_supported, "output format must be supported") +@ensure(lambda result: result is None) +def import_command( + from_file: Annotated[ + Path, + typer.Option( + "--from-file", exists=True, file_okay=True, dir_okay=False, readable=True, help="JSON/YAML records." + ), + ], + bundle: Annotated[ + Path, + typer.Option("--bundle", exists=True, file_okay=False, dir_okay=True, readable=True, writable=True), + ], + output_format: Annotated[OutputFormat, typer.Option("--format", help="Output format.")] = OutputFormat.TEXT, +) -> None: + """Import local requirement records into a project bundle.""" + result = import_requirements_file_to_bundle(from_file, bundle) + _emit_payload( + { + "imported": len(result.requirements), + "diagnostics": [diagnostic.model_dump(mode="json") for diagnostic in result.diagnostics], + }, + output_format, + ) + + +@app.command("validate", help="Validate requirement context evidence usefulness.") +@beartype +@require(lambda bundle: bundle.is_dir(), "bundle must exist") +@require(is_requirement_context_profile_supported, "profile must be a known requirement context profile") +@require(_format_supported, "output format must be supported") +@ensure(lambda result: result is None) +def validate_command( + bundle: Annotated[ + Path, + typer.Option("--bundle", exists=True, file_okay=False, dir_okay=True, readable=True), + ], + profile: Annotated[str, typer.Option("--profile", help="Validation profile.")] = "startup", + output_format: Annotated[OutputFormat, typer.Option("--format", help="Output format.")] = OutputFormat.TEXT, +) -> None: + """Validate requirement context evidence usefulness.""" + report = validate_requirements_bundle(bundle, profile=profile) + _emit_payload(report.model_dump(mode="json"), output_format) + if report.status == "failed": + raise typer.Exit(1) + + +@app.command("list", help="List normalized requirement inputs attached to a bundle.") +@beartype +@require(lambda bundle: bundle.is_dir(), "bundle must exist") +@require(_format_supported, "output format must be supported") +@ensure(lambda result: result is None) +def list_command( + bundle: Annotated[ + Path, + typer.Option("--bundle", exists=True, file_okay=False, dir_okay=True, readable=True), + ], + show_coverage: Annotated[bool, typer.Option("--show-coverage", help="Include coverage summary.")] = False, + output_format: Annotated[OutputFormat, typer.Option("--format", help="Output format.")] = OutputFormat.TEXT, +) -> None: + """List normalized requirement inputs attached to a bundle.""" + _emit_payload(list_requirements_with_coverage(bundle, show_coverage=show_coverage), output_format) + + +@app.command("coverage", help="Inspect requirement context coverage.") +@beartype +@require(lambda bundle: bundle.is_dir(), "bundle must exist") +@require(_format_supported, "output format must be supported") +@ensure(lambda result: result is None) +def coverage_command( + bundle: Annotated[ + Path, + typer.Option("--bundle", exists=True, file_okay=False, dir_okay=True, readable=True), + ], + output_format: Annotated[OutputFormat, typer.Option("--format", help="Output format.")] = OutputFormat.TEXT, +) -> None: + """Inspect requirement context coverage.""" + coverage = inspect_requirements_bundle_coverage(bundle) + _emit_payload(coverage.model_dump(mode="json"), output_format) diff --git a/packages/specfact-requirements/src/specfact_requirements/requirements/runtime.py b/packages/specfact-requirements/src/specfact_requirements/requirements/runtime.py new file mode 100644 index 00000000..0d140d55 --- /dev/null +++ b/packages/specfact-requirements/src/specfact_requirements/requirements/runtime.py @@ -0,0 +1,228 @@ +"""Runtime helpers for requirements context commands.""" + +from __future__ import annotations + +from collections.abc import Mapping, Sequence +from importlib import import_module +from pathlib import Path +from typing import Any, cast, get_args + +import yaml +from beartype import beartype +from icontract import ensure, require +from specfact_cli.models.project import ProjectBundle +from specfact_cli.models.validation import ValidationReport +from specfact_cli.utils.bundle_loader import load_project_bundle, save_project_bundle + + +_LEGACY_REQUIREMENTS_INPUTS_FILE = "requirements.inputs.yaml" +_REQUIREMENTS_INPUTS_FILE = "inputs.yaml" +_REQUIREMENTS_INPUTS_DIR = "requirements" +_REQUIREMENTS_MODELS_MODULE = "specfact_cli.models.requirements" +_REQUIREMENTS_CONTEXT_MODULE = "specfact_cli.requirements.context" + + +class RequirementsCoreUnavailableError(RuntimeError): + """Raised when the paired core requirements helpers are unavailable.""" + + +def _load_requirements_module(module_name: str, purpose: str) -> Any: + try: + return import_module(module_name) + except ImportError as exc: + msg = ( + f"specfact-requirements requires the paired specfact-cli {purpose} " + "from core change requirements-02-module-commands" + ) + raise RequirementsCoreUnavailableError(msg) from exc + + +def _records_are_supported(result: Sequence[Mapping[str, Any]]) -> bool: + return all(isinstance(record, Mapping) for record in result) + + +def _result_has_expected_keys(result: dict[str, Any]) -> bool: + return "requirements" in result + + +def _has_attributes(result: Any, *attributes: str) -> bool: + return all(hasattr(result, attribute) for attribute in attributes) + + +def _profile_aliases(profiles: frozenset[str]) -> frozenset[str]: + aliases = set(profiles) + aliases.update(profile.replace("_", "-") for profile in profiles) + aliases.update(profile.replace("-", "_") for profile in profiles) + return frozenset(aliases) + + +def _known_requirement_context_profiles() -> frozenset[str]: + context_helpers = _load_requirements_module(_REQUIREMENTS_CONTEXT_MODULE, "requirements context helpers") + known_profiles = getattr(context_helpers, "KNOWN_REQUIREMENT_CONTEXT_PROFILES", None) + if known_profiles is None: + profile_type = context_helpers.RequirementContextValidationProfile + known_profiles = get_args(profile_type) + return _profile_aliases(frozenset(str(profile) for profile in known_profiles)) + + +@beartype +@require(lambda profile: bool(profile.strip()), "profile must be non-empty") +@ensure(lambda result: bool(result.strip())) +def normalize_requirement_context_profile(profile: str) -> str: + """Return the core validator spelling for documented profile aliases.""" + return profile.replace("-", "_") + + +@beartype +@require(lambda profile: bool(profile.strip()), "profile must be non-empty") +@ensure(lambda result: isinstance(result, bool)) +def is_requirement_context_profile_supported(profile: str) -> bool: + """Return whether the paired core validator exposes this profile.""" + normalized = normalize_requirement_context_profile(profile) + return normalized in { + normalize_requirement_context_profile(candidate) for candidate in _known_requirement_context_profiles() + } + + +def _requirements_sidecar_path(bundle_dir: Path) -> Path: + return bundle_dir / "reports" / _REQUIREMENTS_INPUTS_DIR / _REQUIREMENTS_INPUTS_FILE + + +def _legacy_requirements_sidecar_path(bundle_dir: Path) -> Path: + return bundle_dir / _LEGACY_REQUIREMENTS_INPUTS_FILE + + +def _existing_requirements_sidecar_path(bundle_dir: Path) -> Path | None: + for candidate in (_requirements_sidecar_path(bundle_dir), _legacy_requirements_sidecar_path(bundle_dir)): + if candidate.exists(): + return candidate + return None + + +def _load_bundle_with_requirements(bundle_dir: Path) -> ProjectBundle: + bundle = load_project_bundle(bundle_dir) + sidecar = _existing_requirements_sidecar_path(bundle_dir) + if sidecar is None: + return bundle + payload = yaml.safe_load(sidecar.read_text(encoding="utf-8")) + if not isinstance(payload, dict): + msg = f"{sidecar} must contain a requirements.inputs mapping payload" + raise ValueError(msg) + model_helpers = _load_requirements_module(_REQUIREMENTS_MODELS_MODULE, "requirements models") + context_helpers = _load_requirements_module(_REQUIREMENTS_CONTEXT_MODULE, "requirements context helpers") + records = model_helpers.load_requirements_input_extension(cast(dict[str, Any], payload)) + context_helpers.attach_requirements_to_bundle(bundle, records) + return bundle + + +def _write_requirements_sidecar(bundle_dir: Path, records: Sequence[Any]) -> None: + model_helpers = _load_requirements_module(_REQUIREMENTS_MODELS_MODULE, "requirements models") + payload = model_helpers.requirements_input_extension_payload(list(records)) + sidecar = _requirements_sidecar_path(bundle_dir) + sidecar.parent.mkdir(parents=True, exist_ok=True) + temporary_sidecar = sidecar.with_name(f".{sidecar.name}.tmp") + try: + temporary_sidecar.write_text(yaml.safe_dump(payload, sort_keys=False), encoding="utf-8") + temporary_sidecar.replace(sidecar) + finally: + if temporary_sidecar.exists(): + temporary_sidecar.unlink() + + +@beartype +@require(lambda source_file: source_file.is_file(), "source_file must exist") +@ensure(_records_are_supported, "all loaded records must be RequirementInput or mapping values") +def load_requirement_records(source_file: Path) -> list[Mapping[str, Any]]: + """Load requirement records from a JSON or YAML source file.""" + loaded = yaml.safe_load(source_file.read_text(encoding="utf-8")) + if loaded is None: + return [] + if isinstance(loaded, list): + return cast(list[Mapping[str, Any]], loaded) + if isinstance(loaded, dict): + maybe_records = loaded.get("requirements") + if isinstance(maybe_records, list): + return cast(list[Mapping[str, Any]], maybe_records) + return [cast(Mapping[str, Any], loaded)] + msg = "requirements source must be a mapping, a list, or a mapping with a requirements list" + raise ValueError(msg) + + +@beartype +@require(lambda existing: all(hasattr(record, "requirement_id") for record in existing)) +@require(lambda imported: all(hasattr(record, "requirement_id") for record in imported)) +@ensure(lambda result: all(hasattr(record, "requirement_id") for record in result)) +def merge_requirement_inputs( + existing: Sequence[Any], + imported: Sequence[Any], +) -> list[Any]: + """Merge imported requirements by stable ID while preserving existing order.""" + by_id = {record.requirement_id: record for record in existing} + order = [record.requirement_id for record in existing] + for record in imported: + if record.requirement_id not in by_id: + order.append(record.requirement_id) + by_id[record.requirement_id] = record + return [by_id[requirement_id] for requirement_id in order] + + +@beartype +@require(lambda source_file: source_file.is_file(), "source_file must exist") +@require(lambda bundle_dir: bundle_dir.is_dir(), "bundle_dir must exist") +@ensure(lambda result: _has_attributes(result, "requirements", "diagnostics")) +def import_requirements_file_to_bundle(source_file: Path, bundle_dir: Path) -> Any: + """Normalize requirement records from a file and attach valid records to a ProjectBundle.""" + records = load_requirement_records(source_file) + context_helpers = _load_requirements_module(_REQUIREMENTS_CONTEXT_MODULE, "requirements context helpers") + result = context_helpers.normalize_requirement_records(records, source_locator=source_file.as_posix()) + bundle = _load_bundle_with_requirements(bundle_dir) + merged = merge_requirement_inputs(context_helpers.load_requirements_from_bundle(bundle), result.requirements) + context_helpers.attach_requirements_to_bundle(bundle, merged) + save_project_bundle(bundle, bundle_dir, atomic=True) + _write_requirements_sidecar(bundle_dir, merged) + return result + + +@beartype +@require(lambda bundle_dir: bundle_dir.is_dir(), "bundle_dir must exist") +@require(is_requirement_context_profile_supported, "profile must be a known requirement context profile") +@ensure(lambda result: isinstance(result, ValidationReport)) +def validate_requirements_bundle(bundle_dir: Path, *, profile: str = "startup") -> ValidationReport: + """Validate requirement context evidence usefulness for a bundle.""" + bundle = _load_bundle_with_requirements(bundle_dir) + context_helpers = _load_requirements_module(_REQUIREMENTS_CONTEXT_MODULE, "requirements context helpers") + return context_helpers.validate_requirement_context(bundle, profile=normalize_requirement_context_profile(profile)) + + +@beartype +@require(lambda bundle_dir: bundle_dir.is_dir(), "bundle_dir must exist") +@ensure(lambda result: _has_attributes(result, "total_requirements", "with_test_links")) +def inspect_requirements_bundle_coverage(bundle_dir: Path) -> Any: + """Inspect coverage for normalized requirement inputs attached to a bundle.""" + bundle = _load_bundle_with_requirements(bundle_dir) + context_helpers = _load_requirements_module(_REQUIREMENTS_CONTEXT_MODULE, "requirements context helpers") + return context_helpers.inspect_requirement_context_coverage(context_helpers.load_requirements_from_bundle(bundle)) + + +@beartype +@require(lambda bundle_dir: bundle_dir.is_dir(), "bundle_dir must exist") +@ensure(_result_has_expected_keys) +def list_requirements_with_coverage(bundle_dir: Path, *, show_coverage: bool = False) -> dict[str, Any]: + """Return serializable requirement rows and optional coverage summary.""" + bundle = _load_bundle_with_requirements(bundle_dir) + context_helpers = _load_requirements_module(_REQUIREMENTS_CONTEXT_MODULE, "requirements context helpers") + requirements = context_helpers.load_requirements_from_bundle(bundle) + payload: dict[str, Any] = { + "requirements": [ + { + "requirement_id": requirement.requirement_id, + "title": requirement.title, + "source_count": len(requirement.sources), + "evidence_link_count": len(requirement.evidence_links), + } + for requirement in requirements + ] + } + if show_coverage: + payload["coverage"] = context_helpers.inspect_requirement_context_coverage(requirements).model_dump(mode="json") + return payload diff --git a/pyproject.toml b/pyproject.toml index 4b87be4b..67514128 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -97,6 +97,7 @@ extraPaths = [ "packages/specfact-code-review/src", "packages/specfact-spec/src", "packages/specfact-govern/src", + "packages/specfact-requirements/src", ] reportMissingImports = true reportMissingTypeStubs = false @@ -119,6 +120,7 @@ executionEnvironments = [ { root = "packages/specfact-code-review/src", reportMissingImports = false, reportAttributeAccessIssue = false }, { root = "packages/specfact-spec/src", reportMissingImports = false, reportAttributeAccessIssue = false }, { root = "packages/specfact-govern/src", reportMissingImports = false, reportAttributeAccessIssue = false }, + { root = "packages/specfact-requirements/src", reportMissingImports = false, reportAttributeAccessIssue = false }, ] reportAttributeAccessIssue = false @@ -133,6 +135,7 @@ pythonpath = [ "packages/specfact-code-review/src", "packages/specfact-spec/src", "packages/specfact-govern/src", + "packages/specfact-requirements/src", ] testpaths = ["tests"] python_files = ["test_*.py"] @@ -282,6 +285,7 @@ known-first-party = [ "specfact_code_review", "specfact_spec", "specfact_govern", + "specfact_requirements", "specfact_cli_modules", ] diff --git a/registry/index.json b/registry/index.json index 6f65c42b..9844978d 100644 --- a/registry/index.json +++ b/registry/index.json @@ -91,6 +91,21 @@ "nold-ai/specfact-codebase" ], "description": "Official SpecFact code review bundle package." + }, + { + "id": "nold-ai/specfact-requirements", + "latest_version": "0.1.5", + "download_url": "modules/specfact-requirements-0.1.5.tar.gz", + "checksum_sha256": "fd3f684be7d8ba76c16296c00e44f7ab05dbddf00b90bb716d5b9882bbc6745c", + "tier": "official", + "publisher": { + "name": "nold-ai", + "email": "hello@noldai.com" + }, + "bundle_dependencies": [ + "nold-ai/specfact-project" + ], + "description": "Official SpecFact requirements evidence runtime bundle package." } ] } diff --git a/registry/modules/specfact-requirements-0.1.1.tar.gz b/registry/modules/specfact-requirements-0.1.1.tar.gz new file mode 100644 index 00000000..82b6df09 Binary files /dev/null and b/registry/modules/specfact-requirements-0.1.1.tar.gz differ diff --git a/registry/modules/specfact-requirements-0.1.1.tar.gz.sha256 b/registry/modules/specfact-requirements-0.1.1.tar.gz.sha256 new file mode 100644 index 00000000..a57f5f8e --- /dev/null +++ b/registry/modules/specfact-requirements-0.1.1.tar.gz.sha256 @@ -0,0 +1 @@ +67f0b4e94f78f7d826541229a201fb1add6c1716debac418b5635b30cfdd35de diff --git a/registry/modules/specfact-requirements-0.1.2.tar.gz b/registry/modules/specfact-requirements-0.1.2.tar.gz new file mode 100644 index 00000000..8f034c86 Binary files /dev/null and b/registry/modules/specfact-requirements-0.1.2.tar.gz differ diff --git a/registry/modules/specfact-requirements-0.1.2.tar.gz.sha256 b/registry/modules/specfact-requirements-0.1.2.tar.gz.sha256 new file mode 100644 index 00000000..fdfa5959 --- /dev/null +++ b/registry/modules/specfact-requirements-0.1.2.tar.gz.sha256 @@ -0,0 +1 @@ +5c390fa62078893b7b4d91684bdf83c359a30b5e0b074c61de20c5e088e6ceaf diff --git a/registry/modules/specfact-requirements-0.1.4.tar.gz b/registry/modules/specfact-requirements-0.1.4.tar.gz new file mode 100644 index 00000000..393e0345 Binary files /dev/null and b/registry/modules/specfact-requirements-0.1.4.tar.gz differ diff --git a/registry/modules/specfact-requirements-0.1.4.tar.gz.sha256 b/registry/modules/specfact-requirements-0.1.4.tar.gz.sha256 new file mode 100644 index 00000000..5f49f44a --- /dev/null +++ b/registry/modules/specfact-requirements-0.1.4.tar.gz.sha256 @@ -0,0 +1 @@ +a4e275705fce188e8c9075b59e455c87b941cf13524e91fa704c4944226d2d14 diff --git a/registry/modules/specfact-requirements-0.1.5.tar.gz b/registry/modules/specfact-requirements-0.1.5.tar.gz new file mode 100644 index 00000000..12bc5eeb Binary files /dev/null and b/registry/modules/specfact-requirements-0.1.5.tar.gz differ diff --git a/registry/modules/specfact-requirements-0.1.5.tar.gz.sha256 b/registry/modules/specfact-requirements-0.1.5.tar.gz.sha256 new file mode 100644 index 00000000..06ff6eb2 --- /dev/null +++ b/registry/modules/specfact-requirements-0.1.5.tar.gz.sha256 @@ -0,0 +1 @@ +fd3f684be7d8ba76c16296c00e44f7ab05dbddf00b90bb716d5b9882bbc6745c diff --git a/registry/signatures/specfact-requirements-0.1.1.tar.sig b/registry/signatures/specfact-requirements-0.1.1.tar.sig new file mode 100644 index 00000000..a9819cad --- /dev/null +++ b/registry/signatures/specfact-requirements-0.1.1.tar.sig @@ -0,0 +1 @@ +uKk5I0K4OvASQ4o/9tnYE3xztHrRgFYezh0g/HAVZNOKOGvuv6gbdFgncmHOcFBB8dZJsucvQ3MY0GIhaElwCg== diff --git a/registry/signatures/specfact-requirements-0.1.2.tar.sig b/registry/signatures/specfact-requirements-0.1.2.tar.sig new file mode 100644 index 00000000..5da3c433 --- /dev/null +++ b/registry/signatures/specfact-requirements-0.1.2.tar.sig @@ -0,0 +1 @@ +htRmMiXxy7lBVh7BfDVMkAv/ugzzAG7vAfPum4ZoGvHJT8nPUO+LR7FbnPqMkJUsp4qabP0S8XDWSvbqYLZzDA== diff --git a/registry/signatures/specfact-requirements-0.1.4.tar.sig b/registry/signatures/specfact-requirements-0.1.4.tar.sig new file mode 100644 index 00000000..d151abf6 --- /dev/null +++ b/registry/signatures/specfact-requirements-0.1.4.tar.sig @@ -0,0 +1 @@ +pBbt4tKSWfQfFZBQHK8Yuz206XVEGh1VF4nSt9LpWPCar/RV5deGbjpw2uyWJcX0dBikK58vo8jgVoU5I1tMAQ== diff --git a/registry/signatures/specfact-requirements-0.1.5.tar.sig b/registry/signatures/specfact-requirements-0.1.5.tar.sig new file mode 100644 index 00000000..1cddfd37 --- /dev/null +++ b/registry/signatures/specfact-requirements-0.1.5.tar.sig @@ -0,0 +1 @@ +3cXSjTAnhQHUcvDsM4pYwp8OxJ/gAvNtZD3H0zCMqOyuHW5axSeCJcpGDqjno1YLAq+v4B1PL5HYuJkAUeZQAQ== diff --git a/scripts/check-bundle-imports.py b/scripts/check-bundle-imports.py index 421d3ba1..a5078c56 100755 --- a/scripts/check-bundle-imports.py +++ b/scripts/check-bundle-imports.py @@ -9,6 +9,9 @@ from dataclasses import dataclass from pathlib import Path +from beartype import beartype +from icontract import ensure + ROOT = Path(__file__).resolve().parent.parent @@ -18,6 +21,7 @@ "specfact_codebase": ROOT / "packages/specfact-codebase/src/specfact_codebase", "specfact_spec": ROOT / "packages/specfact-spec/src/specfact_spec", "specfact_govern": ROOT / "packages/specfact-govern/src/specfact_govern", + "specfact_requirements": ROOT / "packages/specfact-requirements/src/specfact_requirements", } ALLOWED_SPECFACT_CLI_EXACT: set[str] = { @@ -32,6 +36,7 @@ "specfact_cli.contracts.module_interface", "specfact_cli.integrations.specmatic", "specfact_cli.models", + "specfact_cli.requirements", "specfact_cli.modes", "specfact_cli.modules", "specfact_cli.registry.registry", @@ -60,6 +65,7 @@ "specfact_codebase": {"specfact_project"}, "specfact_spec": {"specfact_project"}, "specfact_govern": {"specfact_project"}, + "specfact_requirements": set(), } @@ -111,6 +117,17 @@ def _visit(node: ast.AST) -> None: return imports +def _cross_bundle_violation(bundle_name: str, module_name: str) -> str | None: + for other_bundle in PACKAGE_DIRS: + if other_bundle == bundle_name: + continue + if module_name == other_bundle or module_name.startswith(other_bundle + "."): + if other_bundle not in ALLOWED_CROSS_BUNDLE_IMPORTS.get(bundle_name, set()): + return other_bundle + return None + return None + + def _scan_python_file(bundle_name: str, file_path: Path) -> list[Violation]: violations: list[Violation] = [] try: @@ -140,24 +157,22 @@ def _scan_python_file(bundle_name: str, file_path: Path) -> list[Violation]: ) continue - for other_bundle in PACKAGE_DIRS: - if other_bundle == bundle_name: - continue - if module_name == other_bundle or module_name.startswith(other_bundle + "."): - if other_bundle not in ALLOWED_CROSS_BUNDLE_IMPORTS.get(bundle_name, set()): - violations.append( - Violation( - file_path=file_path, - line=line, - import_name=module_name, - message=f"Forbidden cross-bundle import: {bundle_name} -> {other_bundle}", - ) - ) - break + other_bundle = _cross_bundle_violation(bundle_name, module_name) + if other_bundle is not None: + violations.append( + Violation( + file_path=file_path, + line=line, + import_name=module_name, + message=f"Forbidden cross-bundle import: {bundle_name} -> {other_bundle}", + ) + ) return violations +@beartype +@ensure(lambda result: result in {0, 1}) def main() -> int: violations: list[Violation] = [] @@ -168,13 +183,13 @@ def main() -> int: violations.extend(_scan_python_file(bundle_name, file_path)) if not violations: - print("Import boundary check passed.") + sys.stdout.write("Import boundary check passed.\n") return 0 - print("Import boundary violations found:") + sys.stdout.write("Import boundary violations found:\n") for violation in sorted(violations, key=lambda v: (str(v.file_path), v.line, v.import_name)): rel_path = violation.file_path.relative_to(ROOT) - print(f"- {rel_path}:{violation.line} [{violation.import_name}] {violation.message}") + sys.stdout.write(f"- {rel_path}:{violation.line} [{violation.import_name}] {violation.message}\n") return 1 diff --git a/scripts/check-command-contract.py b/scripts/check-command-contract.py index f31c0310..9d4faa22 100644 --- a/scripts/check-command-contract.py +++ b/scripts/check-command-contract.py @@ -13,6 +13,8 @@ from typing import Any, cast import typer +from beartype import beartype +from icontract import ensure from typer.testing import CliRunner @@ -24,6 +26,7 @@ ("specfact_code_review.review.commands", "app", ("specfact", "code", "review")), ("specfact_govern.govern.commands", "app", ("specfact", "govern")), ("specfact_project.project.commands", "app", ("specfact", "project")), + ("specfact_requirements.requirements.commands", "app", ("specfact", "requirements")), ("specfact_spec.spec.commands", "app", ("specfact", "spec")), ) MISSING_MARKERS = ( @@ -95,9 +98,7 @@ def _select_app(apps: dict[tuple[str, ...], object], command_parts: list[str]) - continue if best_prefix is None or len(prefix) > len(best_prefix): best_prefix = prefix - if best_prefix is None: - return None - return apps[best_prefix], command_parts[len(best_prefix) :] + return None if best_prefix is None else (apps[best_prefix], command_parts[len(best_prefix) :]) def _invoke( @@ -131,6 +132,20 @@ def _has_required_argument(record: dict[str, Any]) -> bool: return any(isinstance(argument, dict) and argument.get("required") for argument in arguments) +def _usage_lines(output: str) -> list[str]: + usage_lines: list[str] = [] + capture_usage = False + for line in output.splitlines(): + if "Usage:" in line: + capture_usage = True + if not capture_usage: + continue + if not line.strip(): + break + usage_lines.append(line.lower()) + return usage_lines + + def _check_help(runner: CliRunner, apps: dict[tuple[str, ...], object], record: dict[str, Any]) -> list[str]: command_parts = _command_parts(record) exit_code, output = _invoke(runner, apps, command_parts, ["--help"]) @@ -142,15 +157,7 @@ def _check_help(runner: CliRunner, apps: dict[tuple[str, ...], object], record: selected_args = selected[1] if selected is not None else [] if not _is_group(record) and selected_args: command_parts = _command_parts(record) - usage_lines = [] - capture_usage = False - for line in output.splitlines(): - if "Usage:" in line: - capture_usage = True - if capture_usage: - if not line.strip(): - break - usage_lines.append(line.lower()) + usage_lines = _usage_lines(output) if command_parts and command_parts[-1].lower() not in " ".join(usage_lines): return [f"{record.get('command')}: --help rendered parent usage instead of leaf usage\n{output}"] return [] @@ -204,6 +211,8 @@ def _check_missing_required_argument( return failures +@beartype +@ensure(lambda result: result in {0, 1}) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--limit", type=int, default=0, help="Validate only the first N generated commands") @@ -222,10 +231,11 @@ def main(argv: list[str] | None = None) -> int: failures.extend(_check_missing_required_argument(runner, apps, record)) if failures: - print("Generated module command contract validation failed:") - print("\n\n".join(failures)) + sys.stdout.write("Generated module command contract validation failed:\n") + sys.stdout.write("\n\n".join(failures)) + sys.stdout.write("\n") return 1 - print(f"check-command-contract: OK ({len(records)} generated module command path(s) validated)") + sys.stdout.write(f"check-command-contract: OK ({len(records)} generated module command path(s) validated)\n") return 0 diff --git a/scripts/check-docs-commands.py b/scripts/check-docs-commands.py index 73805c72..41140741 100755 --- a/scripts/check-docs-commands.py +++ b/scripts/check-docs-commands.py @@ -81,6 +81,7 @@ class CommandExample(NamedTuple): ("specfact_govern.govern.commands", "app", ("specfact", "govern")), ("specfact_govern.enforce.commands", "app", ("specfact", "govern", "enforce")), ("specfact_project.project.commands", "app", ("specfact", "project")), + ("specfact_requirements.requirements.commands", "app", ("specfact", "requirements")), ("specfact_spec.contract.commands", "app", ("specfact", "spec", "contract")), ("specfact_spec.spec.commands", "app", ("specfact", "spec")), ("specfact_spec.sdd.commands", "app", ("specfact", "spec")), diff --git a/scripts/check-prompt-commands.py b/scripts/check-prompt-commands.py index e4a7d43f..e4e3a6e6 100644 --- a/scripts/check-prompt-commands.py +++ b/scripts/check-prompt-commands.py @@ -40,6 +40,7 @@ ("specfact_govern.govern.commands", "app", ("specfact", "govern")), ("specfact_govern.enforce.commands", "app", ("specfact", "govern", "enforce")), ("specfact_project.project.commands", "app", ("specfact", "project")), + ("specfact_requirements.requirements.commands", "app", ("specfact", "requirements")), ("specfact_spec.contract.commands", "app", ("specfact", "spec", "contract")), ("specfact_spec.spec.commands", "app", ("specfact", "spec")), ("specfact_spec.sdd.commands", "app", ("specfact", "spec", "sdd")), diff --git a/scripts/generate-command-overview.py b/scripts/generate-command-overview.py index f1e5059a..30df6718 100644 --- a/scripts/generate-command-overview.py +++ b/scripts/generate-command-overview.py @@ -14,6 +14,8 @@ from typing import Any, cast import click +from beartype import beartype +from icontract import ensure from typer.main import get_command @@ -28,6 +30,12 @@ ("specfact_code_review.review.commands", "app", ("specfact", "code", "review"), "nold-ai/specfact-code-review"), ("specfact_govern.govern.commands", "app", ("specfact", "govern"), "nold-ai/specfact-govern"), ("specfact_project.project.commands", "app", ("specfact", "project"), "nold-ai/specfact-project"), + ( + "specfact_requirements.requirements.commands", + "app", + ("specfact", "requirements"), + "nold-ai/specfact-requirements", + ), ("specfact_spec.spec.commands", "app", ("specfact", "spec"), "nold-ai/specfact-spec"), ) @@ -152,6 +160,8 @@ def _walk(command: click.Command, path: tuple[str, ...], source: str, module_id: return records +@beartype +@ensure(lambda result: all("command" in record for record in result)) def build_records() -> list[dict[str, Any]]: _ensure_package_paths() records: list[dict[str, Any]] = [] @@ -200,7 +210,10 @@ def _render_llms(markdown: str) -> str: [ "# SpecFact Module Commands", "", - "Use this generated overview as the current module command contract before following older docs or prompts.", + ( + "Use this generated overview as the current module command contract " + "before following older docs or prompts." + ), "", markdown, ] @@ -223,7 +236,7 @@ def _check(outputs: dict[Path, str]) -> int: actual = path.read_text(encoding="utf-8") if path.exists() else "" if actual != expected: failures.append(path) - print( + sys.stdout.write( "\n".join( difflib.unified_diff( actual.splitlines(), @@ -233,13 +246,18 @@ def _check(outputs: dict[Path, str]) -> int: lineterm="", ) ) + + "\n" ) if failures: - print("Module command overview artifacts are stale. Run: python scripts/generate-command-overview.py --write") + sys.stdout.write( + "Module command overview artifacts are stale. Run: python scripts/generate-command-overview.py --write\n" + ) return 1 return 0 +@beartype +@ensure(lambda result: result in {0, 1}) def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--write", action="store_true", help="Write generated artifacts") diff --git a/tests/conftest.py b/tests/conftest.py index f3a13213..f3a9f4f3 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -27,6 +27,7 @@ "specfact_codebase", "specfact_spec", "specfact_govern", + "specfact_requirements", ) diff --git a/tests/integration/specfact_requirements/test_command_apps.py b/tests/integration/specfact_requirements/test_command_apps.py new file mode 100644 index 00000000..320e5928 --- /dev/null +++ b/tests/integration/specfact_requirements/test_command_apps.py @@ -0,0 +1,121 @@ +"""Integration tests for the requirements command app.""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +from pathlib import Path + +import pytest +from specfact_cli.common.bundle_factory import create_empty_project_bundle +from specfact_cli.utils.bundle_loader import save_project_bundle +from typer.testing import CliRunner + +from specfact_requirements.requirements.commands import app + + +runner = CliRunner() + + +def _bundle_dir(tmp_path: Path) -> Path: + bundle_dir = tmp_path / "bundle" + bundle = create_empty_project_bundle("bundle") + save_project_bundle(bundle, bundle_dir, atomic=False) + return bundle_dir + + +def _source_file(tmp_path: Path) -> Path: + source = tmp_path / "requirements.json" + source.write_text( + json.dumps( + [ + { + "schema_version": "1", + "requirement_id": "REQ-CLI", + "title": "CLI imports requirement context", + "sources": [ + { + "source_type": "issue", + "locator": "https://github.com/nold-ai/specfact-cli-modules/issues/165", + } + ], + } + ] + ), + encoding="utf-8", + ) + return source + + +@pytest.mark.integration +def test_command_module_exposes_typer_app() -> None: + assert app is not None + assert hasattr(app, "registered_commands") + + +@pytest.mark.integration +def test_command_module_import_does_not_require_core_requirements_context() -> None: + code = """ +import builtins + +real_import = builtins.__import__ + +def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.startswith("specfact_cli.requirements"): + raise ModuleNotFoundError("blocked specfact_cli.requirements import") + return real_import(name, globals, locals, fromlist, level) + +builtins.__import__ = guarded_import + +from specfact_requirements.requirements.commands import app + +assert app is not None +""" + source_path = Path(__file__).resolve().parents[3] / "packages" / "specfact-requirements" / "src" + env = os.environ | {"PYTHONPATH": os.pathsep.join([source_path.as_posix(), os.environ.get("PYTHONPATH", "")])} + + result = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False, text=True, env=env) + + assert result.returncode == 0, result.stderr + + +@pytest.mark.integration +def test_requirements_import_list_and_validate_commands_emit_json(tmp_path: Path) -> None: + bundle_dir = _bundle_dir(tmp_path) + source = _source_file(tmp_path) + + import_result = runner.invoke( + app, + ["import", "--from-file", str(source), "--bundle", str(bundle_dir), "--format", "json"], + ) + + assert import_result.exit_code == 0, import_result.output + assert json.loads(import_result.output)["imported"] == 1 + + list_result = runner.invoke(app, ["list", "--bundle", str(bundle_dir), "--show-coverage", "--format", "json"]) + + assert list_result.exit_code == 0, list_result.output + list_payload = json.loads(list_result.output) + assert list_payload["requirements"][0]["requirement_id"] == "REQ-CLI" + assert list_payload["coverage"]["missing_evidence_requirement_ids"] == ["REQ-CLI"] + + validate_result = runner.invoke( + app, + ["validate", "--bundle", str(bundle_dir), "--profile", "enterprise-full-stack", "--format", "json"], + ) + + assert validate_result.exit_code == 1 + assert json.loads(validate_result.output)["status"] == "failed" + + +@pytest.mark.integration +def test_requirements_help_exposes_no_author_command() -> None: + result = runner.invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "import" in result.output + assert "validate" in result.output + assert "coverage" in result.output + assert "author" not in result.output diff --git a/tests/unit/docs/test_bundle_overview_cli_examples.py b/tests/unit/docs/test_bundle_overview_cli_examples.py index a70e430c..aa3ce5c5 100644 --- a/tests/unit/docs/test_bundle_overview_cli_examples.py +++ b/tests/unit/docs/test_bundle_overview_cli_examples.py @@ -55,6 +55,26 @@ "--help", ], "specfact backlog daily github --state open --limit 20": ["backlog", "daily", "--help"], + "specfact requirements import --from-file requirements.json --bundle .specfact/projects/shop --format json": [ + "requirements", + "import", + "--help", + ], + "specfact requirements list --bundle .specfact/projects/shop --show-coverage --format json": [ + "requirements", + "list", + "--help", + ], + "specfact requirements validate --bundle .specfact/projects/shop --profile enterprise --format json": [ + "requirements", + "validate", + "--help", + ], + "specfact requirements coverage --bundle .specfact/projects/shop --format json": [ + "requirements", + "coverage", + "--help", + ], "specfact spec sdd list --repo .": ["spec", "sdd", "list", "--help"], } @@ -109,6 +129,7 @@ def _route_backlog(t: list[str]) -> tuple[Any, list[str]] | None: "plan": "specfact_project.plan.commands", "sync": "specfact_project.sync.commands", "migrate": "specfact_project.migrate.commands", + "requirements": "specfact_requirements.requirements.commands", } diff --git a/tests/unit/specfact_requirements/test_requirements_runtime.py b/tests/unit/specfact_requirements/test_requirements_runtime.py new file mode 100644 index 00000000..10592288 --- /dev/null +++ b/tests/unit/specfact_requirements/test_requirements_runtime.py @@ -0,0 +1,158 @@ +"""Tests for the requirements runtime module.""" + +from __future__ import annotations + +import json +from collections.abc import Callable +from importlib import import_module +from pathlib import Path +from types import ModuleType + +import pytest +from specfact_cli.common.bundle_factory import create_empty_project_bundle +from specfact_cli.utils.bundle_loader import save_project_bundle + +from specfact_requirements.requirements import runtime as requirements_runtime +from specfact_requirements.requirements.runtime import ( + RequirementsCoreUnavailableError, + import_requirements_file_to_bundle, + list_requirements_with_coverage, + validate_requirements_bundle, +) + + +def _requirement_record(requirement_id: str = "REQ-165", *, with_evidence: bool = False) -> dict[str, object]: + record: dict[str, object] = { + "schema_version": "1", + "requirement_id": requirement_id, + "title": "Requirement context is imported as validation evidence", + "sources": [ + { + "source_type": "issue", + "locator": "https://github.com/nold-ai/specfact-cli-modules/issues/165", + "title": "Requirements Input Runtime Commands Follow-Up", + } + ], + } + if with_evidence: + record["evidence_links"] = [{"link_type": "test", "target": "tests/unit/specfact_requirements"}] + return record + + +def _bundle_dir(tmp_path: Path) -> Path: + bundle_dir = tmp_path / "bundle" + bundle = create_empty_project_bundle("bundle") + save_project_bundle(bundle, bundle_dir, atomic=False) + return bundle_dir + + +def _block_runtime_import(module_name: str) -> Callable[[str], ModuleType]: + def blocked_import(name: str) -> ModuleType: + if name == module_name: + raise ImportError(f"blocked import: {name}") + return import_module(name) + + return blocked_import + + +def test_import_requirements_file_to_bundle_preserves_valid_records_and_diagnostics(tmp_path: Path) -> None: + source = tmp_path / "requirements.json" + source.write_text( + json.dumps( + { + "requirements": [ + _requirement_record(), + {"requirement_id": "REQ-BROKEN", "schema_version": "1", "title": "Missing source refs"}, + ] + } + ), + encoding="utf-8", + ) + bundle_dir = _bundle_dir(tmp_path) + + result = import_requirements_file_to_bundle(source, bundle_dir) + + assert [record.requirement_id for record in result.requirements] == ["REQ-165"] + assert [diagnostic.requirement_id for diagnostic in result.diagnostics] == ["REQ-BROKEN"] + listing = list_requirements_with_coverage(bundle_dir) + assert [record["requirement_id"] for record in listing["requirements"]] == ["REQ-165"] + + +@pytest.mark.parametrize("profile", ["enterprise", "enterprise-full-stack"]) +def test_validate_requirements_bundle_uses_profile_aware_core_validation(tmp_path: Path, profile: str) -> None: + source = tmp_path / "requirements.json" + source.write_text(json.dumps([_requirement_record()]), encoding="utf-8") + bundle_dir = _bundle_dir(tmp_path) + import_requirements_file_to_bundle(source, bundle_dir) + + report = validate_requirements_bundle(bundle_dir, profile=profile) + + assert report.status == "failed" + assert report.violations[0]["location"] == "requirements.inputs[REQ-165].evidence_links" + + +def test_imported_requirements_survive_later_atomic_bundle_save(tmp_path: Path) -> None: + source = tmp_path / "requirements.json" + source.write_text(json.dumps([_requirement_record("REQ-PRESERVE", with_evidence=True)]), encoding="utf-8") + bundle_dir = _bundle_dir(tmp_path) + import_requirements_file_to_bundle(source, bundle_dir) + + assert (bundle_dir / "reports" / "requirements" / "inputs.yaml").is_file() + assert not (bundle_dir / "requirements.inputs.yaml").exists() + + bundle = create_empty_project_bundle("bundle") + save_project_bundle(bundle, bundle_dir, atomic=True) + + listing = list_requirements_with_coverage(bundle_dir) + + assert [record["requirement_id"] for record in listing["requirements"]] == ["REQ-PRESERVE"] + + +def test_requirements_runtime_raises_clear_error_when_core_context_is_unavailable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + source = tmp_path / "requirements.json" + source.write_text(json.dumps([_requirement_record()]), encoding="utf-8") + bundle_dir = _bundle_dir(tmp_path) + monkeypatch.setattr( + requirements_runtime, + "import_module", + _block_runtime_import("specfact_cli.requirements.context"), + ) + + with pytest.raises(RequirementsCoreUnavailableError, match="requirements context helpers"): + import_requirements_file_to_bundle(source, bundle_dir) + + with pytest.raises(RequirementsCoreUnavailableError, match="requirements context helpers"): + validate_requirements_bundle(bundle_dir) + + +def test_requirements_runtime_raises_clear_error_when_core_models_are_unavailable( + monkeypatch: pytest.MonkeyPatch, tmp_path: Path +) -> None: + bundle_dir = _bundle_dir(tmp_path) + sidecar = bundle_dir / "reports" / "requirements" / "inputs.yaml" + sidecar.parent.mkdir(parents=True) + sidecar.write_text("requirements: []\n", encoding="utf-8") + monkeypatch.setattr( + requirements_runtime, + "import_module", + _block_runtime_import("specfact_cli.models.requirements"), + ) + + with pytest.raises(RequirementsCoreUnavailableError, match="requirements models"): + list_requirements_with_coverage(bundle_dir) + + +def test_list_requirements_with_coverage_is_machine_readable(tmp_path: Path) -> None: + source = tmp_path / "requirements.json" + source.write_text(json.dumps([_requirement_record("REQ-COVERED", with_evidence=True)]), encoding="utf-8") + bundle_dir = _bundle_dir(tmp_path) + import_requirements_file_to_bundle(source, bundle_dir) + + listing = list_requirements_with_coverage(bundle_dir, show_coverage=True) + + assert listing["requirements"][0]["requirement_id"] == "REQ-COVERED" + assert listing["requirements"][0]["title"] == "Requirement context is imported as validation evidence" + assert listing["coverage"]["total_requirements"] == 1 + assert listing["coverage"]["with_test_links"] == 1