diff --git a/CHANGELOG.md b/CHANGELOG.md index 79acd34c..35337e66 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,48 @@ All notable changes to this project will be documented in this file. --- +## [0.52.3] - 2026-07-14 + +### Fixed + +- **Upstream evidence review remediation**: preserve wrapped OpenSpec scenario + clauses, report malformed Spec Kit requirement entries, and tolerate invalid + UTF-8 in optional profile configuration. + +--- + +## [0.52.2] - 2026-07-13 + +### Fixed + +- **OpenSpec schema import resilience**: reject invalid UTF-8 configuration + with the standard fail-closed source-schema diagnostic instead of crashing. + +--- + +## [0.52.1] - 2026-07-13 + +### Fixed + +- **Import evidence review hardening**: reject malformed OpenSpec schema + declarations, preserve unique deterministic requirement identities, resolve + relative source locators from the project root, and tolerate unreadable + optional profile configuration. + +--- + +## [0.52.0] - 2026-07-13 + +### Added + +- **Upstream requirements evidence import**: normalize native OpenSpec and + Spec Kit requirement sources into deterministic, auditable records, with + source hashes, import gates, layered profile mappings, and required-field + advisories. Unsupported or customized source schemas now fail closed without + emitting partial records. + +--- + ## [0.51.2] - 2026-07-10 ### Fixed diff --git a/docs/reference/requirements-context-adapter.md b/docs/reference/requirements-context-adapter.md index 14713396..470a51a8 100644 --- a/docs/reference/requirements-context-adapter.md +++ b/docs/reference/requirements-context-adapter.md @@ -9,8 +9,10 @@ expertise_level: [advanced] doc_owner: specfact-cli tracks: - src/specfact_cli/requirements/context.py + - src/specfact_cli/requirements/importers.py - tests/unit/requirements/test_context_adapter.py -last_reviewed: 2026-07-08 + - tests/unit/requirements/test_upstream_evidence_imports.py +last_reviewed: 2026-07-13 exempt: false exempt_reason: "" --- @@ -26,6 +28,10 @@ requirements authoring workflow. - `normalize_requirement_records(...)` converts source-attributed mappings or `RequirementInput` objects into normalized records and bounded diagnostics. +- `import_openspec_change(...)` reads a native OpenSpec change folder and + derives deterministic, source-attributed requirement records. +- `import_speckit_feature(...)` reads a native Spec Kit feature folder through + the existing scanner and derives deterministic requirement records. - `attach_requirements_to_bundle(...)` stores normalized records under the existing `requirements.inputs` `ProjectBundle` extension. - `load_requirements_from_bundle(...)` reads that extension back into @@ -38,6 +44,49 @@ requirements authoring workflow. callers supply `known_targets`, it also returns deterministic stale-link drift findings for evidence consumers. +Both native import helpers are read-only. They derive stable IDs, preserve +Given/When/Then scenarios as business rules, and record the parsed artifact's +`sha256:` revision. Re-running unchanged input produces the same records. + +## Source Compatibility Boundary + +Imports are deliberately fail-closed. The core supports only fixture-backed +default Markdown profiles: OpenSpec `spec-driven` (or no declared schema) with +native `Requirement` and `Scenario` headings, and the default Spec Kit +`Feature Specification`/`FR-` template. A custom OpenSpec schema, a Spec Kit +template override/preset/extension root, or unknown required heading produces +an error `unsupported-source-schema` diagnostic and no records from that +source. + +The adapter does not infer an upstream CLI version from Markdown or fetch a +live schema during import: native artifacts do not consistently carry a tool +version, and network-dependent parsing would make CI non-reproducible. To add +an upstream format, add a pinned representative fixture, extend the core +profile, and pass its compatibility test before release. + +## Import Validation Gates + +For OpenSpec and Spec Kit records, `validate_requirement_context(...)` emits +machine-readable findings for: + +- `scenario-unverified`: a derived scenario has no test or validation link. +- `stale-import`: an artifact's current bytes no longer match its imported + `sha256:` revision. +- `source-missing`: an imported artifact is no longer available. +- `ambiguous-mapping`: different imported sources claim the same derived ID. + +An error-severity finding makes the paired `requirements validate` command +exit non-zero; the module runtime owns terminal formatting and command flags. +The core helper also preserves `missing-evidence` as a stable, machine-readable +finding code. + +When no profile is passed, validation resolves the layered profile from the +organization, repository, and developer-local configuration. The adapter maps +only evidence-backed required fields: `id`, `title`, `acceptance`, and +`trace_links`. Other profile fields are surfaced as +`unsupported-profile-field` advisories rather than requiring OpenSpec or Spec +Kit authors to add SpecFact metadata. + ```python from specfact_cli.models.requirements import RequirementInput, RequirementSourceReference from specfact_cli.requirements.context import ( @@ -70,6 +119,17 @@ 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. +After this core change ships, the paired module change will add +`requirements import --from-openspec` and `--from-speckit`; those flags will +delegate entirely to these core helpers. +`requirements import --from-file` remains the generic fallback for records +outside the supported native profiles. + +The currently released requirements module does not expose those native-import +flags yet. It remains compatible because it uses only `--from-file`; the paired +module release for #168 will declare a `0.52.0` core compatibility floor before +exposing `--from-openspec` or `--from-speckit`. + ## Compatibility Notes - Requirement inputs must include `schema_version` and at least one source diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index 90dc013d..56d77284 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -82,9 +82,9 @@ planning workflows. |---:|---|---|---|---| | 1 | `requirements-01-data-model` | [#238](https://github.com/nold-ai/specfact-cli/issues/238) | Normalized requirements-input records for evidence | arch-07 | | 2 | `requirements-02-module-commands` | [#239](https://github.com/nold-ai/specfact-cli/issues/239) | Import and normalize existing requirement context | requirements-01 | -| 3 | `requirements-03-backlog-sync` | [#244](https://github.com/nold-ai/specfact-cli/issues/244) | Read-first drift evidence; no write-back critical path | requirements-02; modules `sync-01` | +| 3 | `openspec-01-intent-trace` | [#350](https://github.com/nold-ai/specfact-cli/issues/350) | Import-first OpenSpec and Spec Kit requirement evidence with pass/fail gates (rescoped 2026-07-13) | requirements-01/02 | | 4 | `architecture-01-solution-layer` | [#240](https://github.com/nold-ai/specfact-cli/issues/240) | Architecture-boundary records and drift validation | requirements input contracts | -| 5 | `openspec-01-intent-trace` | [#350](https://github.com/nold-ai/specfact-cli/issues/350) | Optional OpenSpec and Spec Kit evidence adapter | requirements-01/02 | +| Parked | `requirements-03-backlog-sync` | [#244](https://github.com/nold-ai/specfact-cli/issues/244) | Read-first drift evidence; no write-back critical path. Deprioritized 2026-07-13 behind openspec-01 | requirements-02; modules `sync-01` | | Gated | `architecture-02-well-architected-review` | [#524](https://github.com/nold-ai/specfact-cli/issues/524) | Architecture-boundary review findings | architecture-01 shipped plus one usage cycle | | Gated | `telemetry-01-opentelemetry-default-on` | [#518](https://github.com/nold-ai/specfact-cli/issues/518) | Opt-in validation outcome telemetry only | governance-01 evidence fields | @@ -111,7 +111,7 @@ Update each proposal first, then run strict OpenSpec validation. | `requirements-02-module-commands` | Drop requirement authoring as a flagship path. Keep import, normalization, validation, and coverage inspection. | | `requirements-03-backlog-sync` | Make drift detection/read-first import the product value. Keep write-back preview out of the critical path. | | `architecture-01-solution-layer` | Reduce to architecture-boundary validation and drift evidence. Do not generate architecture. | -| `openspec-01-intent-trace` | Rewrite as an optional adapter that consumes OpenSpec and Spec Kit artifacts. | +| `openspec-01-intent-trace` | Done 2026-07-13: rescoped to import-first adapter consuming native OpenSpec and Spec Kit artifacts with deterministic pass/fail gates. | | `dogfooding-01-full-chain-e2e-proof` | Rewrite proof around real PR review, JSON evidence, AI-bloat findings, remediation packets, rerun proof. | | `ai-integration-01-agent-skill` | Teach agents to run SpecFact validation and interpret evidence. | | `ai-integration-03-instruction-files` | Generate lightweight validation aliases only. | @@ -155,9 +155,12 @@ Update each proposal first, then run strict OpenSpec validation. ### Wave 5 - Optional adapters and later extensions -- `requirements-01/02/03` only as validation input adapters. +- `requirements-01/02` only as validation input adapters (shipped); + `requirements-03` parked 2026-07-13. - `architecture-01` only as architecture-boundary validation input. -- `openspec-01` as optional OpenSpec and Spec Kit evidence adapter. +- `openspec-01` as import-first OpenSpec and Spec Kit requirement evidence + adapter with deterministic gates; pulled forward to Track C order 3 on + 2026-07-13 (rescoped, no longer positioned as optional-only). - `architecture-02`, `telemetry-01`, and `ai-integration-02` only after pull from the validation loop exists. diff --git a/openspec/changes/openspec-01-intent-trace/CHANGE_VALIDATION.md b/openspec/changes/openspec-01-intent-trace/CHANGE_VALIDATION.md index e505e2ef..8b65ddbc 100644 --- a/openspec/changes/openspec-01-intent-trace/CHANGE_VALIDATION.md +++ b/openspec/changes/openspec-01-intent-trace/CHANGE_VALIDATION.md @@ -1,112 +1,110 @@ # Change Validation Report: openspec-01-intent-trace -**Validation Date**: 2026-03-05 +**Validation Date**: 2026-07-13 (Europe/Berlin) **Change Proposal**: [proposal.md](./proposal.md) -**Validation Method**: Dry-run simulation — codebase interface analysis + temporary workspace -**Source Plan**: `specfact-cli-internal/docs/internal/implementation/2026-03-05-CLAUDE-RESEARCH-INTENT-DRIVEN-DEVELOPMENT.md` +**Validation Method**: Dry-run interface and dependency analysis against the current core CLI worktree. ## Executive Summary -- Breaking Changes: 0 detected -- Dependent Files: 3 files affected (additive, non-breaking) -- Impact Level: Low-Medium (extends existing adapter with optional new capability) -- Validation Result: **Pass** -- User Decision: N/A -- Implementation Constraint Identified: 1 (beartype type-annotation — implementation note, not a blocker) - -## Breaking Changes Detected - -None. All interface extensions are additive and optional: - -- `parse_change_proposal()` returns `dict[str, Any]` — adding optional `"intent_trace"` key is non-breaking -- `--import-intent` CLI flag has no default effect (opt-in) -- New files (`intent_trace_validator.py`, `intent-trace.schema.json`) have no existing callers - -## Implementation Constraint (Non-Breaking) - -**Constraint**: `_parse_proposal_content()` at `openspec_parser.py:335` has type annotation `dict[str, str]` (return type). If intent trace data (a nested dict) were added here, `@beartype` would raise a type error. - -**Required implementation approach**: Intent trace extraction MUST be done in `parse_change_proposal()` (returns `dict[str, Any]`) by: - -1. Calling `_parse_proposal_content(content)` as usual → returns `dict[str, str]` -2. Separately extracting the YAML fenced block under `## Intent Trace` from `content` -3. Parsing with `yaml.safe_load()` and assigning to `result["intent_trace"]` -4. `parse_change_proposal()` return dict is `dict[str, Any]` — no type violation - -The task at step 5.2 ("Add `## Intent Trace` section parser") should be executed in `parse_change_proposal()`, not in `_parse_proposal_content()`. This is an implementation detail — recommend adding a note to `tasks.md`. - -## Dependencies Affected - -### Critical (hard blockers — must land before `--import-intent` write path) - -| Dependency | Issue | Status | -|---|---|---| -| `requirements-01-data-model` | [#238](https://github.com/nold-ai/specfact-cli/issues/238) | PENDING (Wave 5) | -| `requirements-02-module-commands` | [#239](https://github.com/nold-ai/specfact-cli/issues/239) | PENDING (Wave 5/6) | - -Note: The validation/parsing components (JSON Schema, `validate_intent_trace()`, parser extension) do NOT require requirements-01/02. Only the `--import-intent` write path (creating `.req.yaml` files) requires them. This means parsing and validation can be implemented ahead of Wave 5. - -### Recommended Updates (affected, not breaking) - -| File | Reason | Update Type | -|---|---|---| -| `src/specfact_cli/adapters/openspec_parser.py` | Extend `parse_change_proposal()` with intent trace extraction | Additive | -| `src/specfact_cli/adapters/openspec.py` | Extend `_import_change_proposal()` to pass intent trace to bundle; add `--import-intent` path | Additive | -| `src/specfact_cli/validators/change_proposal_integration.py` | May need to call `validate_intent_trace()` when strict mode enabled | Additive | - -## Impact Assessment - -- **Code Impact**: Low — 2 existing files extended (additive only), 2 new files created -- **Test Impact**: Low — new test files only; no existing test modifications required -- **Documentation Impact**: Medium — `docs/adapters/openspec.md` and `docs/guides/openspec-journey.md` need updates -- **Release Impact**: Minor version bump (new features, no breaking changes) +- Breaking Changes: 0 detected / 0 unresolved +- Dependent Files: 3 direct core surfaces, plus the paired module runtime issue +- Impact Level: Medium (new validation behavior; no removed public interface) +- Validation Result: Pass +- User Decision: Apply the evidence-compatible required-field mapping and the + fail-closed source compatibility boundary approved on 2026-07-13. + +The rescoped change preserves the existing `RequirementInput` schema, +`--from-file` flow, and source types while adding core-owned OpenSpec/Spec Kit +normalization and deterministic validation gates. The approved +evidence-compatible required-field mapping defines their semantics against +that schema without widening it. +The `specfact-requirements` command wiring remains explicitly paired with +`nold-ai/specfact-cli-modules#168`; it does not block the core implementation. + +The approved compatibility extension adds no public parameter or model change. +It accepts only fixture-backed default OpenSpec and Spec Kit structural +profiles, returning `unsupported-source-schema` and no partial records for +custom or unrecognized sources. This is a non-breaking, additive diagnostic +contract; #168 will surface it without reimplementing compatibility logic. + +## Resolved Required-Field Contract + +`id`, `title`, `acceptance`, and `trace_links` map to `requirement_id`, +`title`, `business_rules`, and `evidence_links`, respectively. Other profile +fields produce a machine-readable `unsupported-profile-field` advisory and do +not make imported records incomplete. Owner, risk, and exception metadata stay +outside this import-first schema until a separately scoped enrichment change +defines an accountable source for them. + +## Interface and Dependency Analysis + +### New Core Interfaces + +- `src/specfact_cli/requirements/`: OpenSpec and Spec Kit import normalizers + will consume existing parser outputs and return `RequirementInput` records. +- `src/specfact_cli/requirements/context.py`: validation gains four additive + finding categories and layered-profile resolution when no explicit profile + is supplied. + +No parameter is removed, no required parameter is added to an existing public +API, and no existing model field changes type. The pending decision concerns +the profile-default validation semantics, not interface compatibility. + +### Dependent Files Affected + +#### Critical Updates Required + +- None before core implementation. + +#### Recommended Updates + +- `src/specfact_cli/requirements/__init__.py`: export the new core helpers. +- `tests/unit/requirements/test_upstream_evidence_imports.py`: cover import + normalization, gate categories, profile resolution, idempotency, and + source-directory immutability. +- `nold-ai/specfact-cli-modules#168`: add module command flags and persistence + wiring after the core helpers are available. + +## Risk Assessment + +- Upstream-format drift is contained by a fail-closed compatibility preflight: + unsupported schemas or template profiles return + `unsupported-source-schema` and no requirement records. Adding a newer + profile requires a pinned fixture and explicit core update; import never + fetches or guesses a live upstream version. +- Content hashes intentionally treat any byte change as stale; re-import is the + rollback path and remains idempotent. +- The new default profile source can change validation severity for callers + that omit `--profile`; tests must prove explicit profiles still override it. ## Format Validation -- **proposal.md Format**: Pass - - `# Change:` title ✓ - - `## Why`, `## What Changes`, `## Capabilities`, `## Impact` sections ✓ - - NEW/EXTEND markers ✓ - - Capabilities linked to spec folders ✓ - - Source Tracking section ✓ -- **tasks.md Format**: Pass - - Hierarchical `## 1.`…`## 10.` structure ✓ - - Task 1 = git worktree creation ✓ - - Task 10 = PR creation (last) ✓ - - Post-merge cleanup section ✓ - - TDD / SDD order section at top ✓ - - Tests before implementation (Tasks 2-3 tests before Tasks 4-5 implementation) ✓ - - `TDD_EVIDENCE.md` recording tasks ✓ - - Quality gate tasks ✓ - - Documentation task included ✓ - - Version and changelog task ✓ - - GitHub issue creation task ✓ - - Module signing verification task: not included — this change has no module-package.yaml changes; acceptable -- **specs Format**: Pass - - `####` for scenario headers ✓ - - `## ADDED Requirements` / `## MODIFIED Requirements` delta format ✓ - - G/W/T format with THEN/AND ✓ - - Every requirement has ≥1 scenario ✓ -- **Config.yaml Compliance**: Pass +- **proposal.md Format**: Pass — required sections, capability mapping, source + tracking, ownership split, impact, and rollback constraints are present. +- **tasks.md Format**: Pass — TDD order is explicit; the required worktree was + created first; GitHub synchronization now precedes PR creation; post-merge + cleanup is recorded. +- **specs Format**: Pass — requirements use Given/When/Then scenarios and map + to the declared capabilities. +- **Config.yaml Compliance**: Pass — offline/read-only constraints, contracts, + documentation, test-first evidence, and the required-field mapping are + accounted for. ## OpenSpec Validation - **Status**: Pass - **Command**: `openspec validate openspec-01-intent-trace --strict` -- **Output**: `Change 'openspec-01-intent-trace' is valid` -- **Issues Found/Fixed**: 0 - -## Dependency Phasing Note - -Unlike ai-integration-04, this change has **two separable implementation phases**: - -1. **Phase A (can land with Wave 5/6)**: JSON Schema definition, `validate_intent_trace()`, parser extension to extract the intent trace block, `openspec validate --strict` hook. No dependency on requirements-01/02. -2. **Phase B (requires Wave 5 complete)**: `--import-intent` flag and `.req.yaml` artifact writer. Requires `BusinessOutcome`/`BusinessRule` Pydantic models. - -Tasks 2-4 can proceed as soon as the worktree is created. Task 5.3 (`--import-intent` flag) should wait for requirements-01 (#238). - -## Ownership Authority - -- **Intent trace schema** (`openspec/schemas/intent-trace.schema.json`): owned by this change; aligns with `requirements-01-data-model` field definitions (no conflict — requirements-01 owns the Pydantic model, this change owns the JSON Schema) -- **`parse_change_proposal()` return dict** (`openspec_parser.py`): existing authority is `openspec_parser.py` itself; this change adds the `"intent_trace"` key — no conflict -- **`--import-intent` write path** in `openspec.py`: this change is authoritative; no other pending change touches this code path +- **Issues Found/Fixed**: 2 — task ordering/cleanup and required-field mapping. +- **Failing-before artifact**: + `hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py::test_import_openspec_change_preserves_wrapped_scenario_clauses tests/unit/requirements/test_upstream_evidence_imports.py::test_import_speckit_feature_reports_malformed_requirement_entries tests/unit/requirements/test_upstream_evidence_imports.py::test_validation_ignores_invalid_utf8_optional_config -q` + reported 3 failures before the review remediation. +- **Passing-after artifact**: + `hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q` + reported 29 passing tests after remediation. + +## Validation Artifacts + +- Worktree: `feature/openspec-01-intent-trace` (host path redacted) +- GitHub readiness: issue #350 is open and Todo, with parent #371, required + labels/project assignment, closed blockers #238 and #239, and no items it + blocks. diff --git a/openspec/changes/openspec-01-intent-trace/TDD_EVIDENCE.md b/openspec/changes/openspec-01-intent-trace/TDD_EVIDENCE.md new file mode 100644 index 00000000..f5101b10 --- /dev/null +++ b/openspec/changes/openspec-01-intent-trace/TDD_EVIDENCE.md @@ -0,0 +1,150 @@ +# TDD Evidence: openspec-01-intent-trace + +**Date**: 2026-07-13 (Europe/Berlin) + +## Failing Before Production Code + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py -q +ImportError: cannot import name 'import_openspec_change' from 'specfact_cli.requirements' +``` + +The test was added after the OpenSpec delta defined native import, content-hash, +read-only, gate, and profile-mapping behavior. It failed during collection +because the core normalizers did not yet exist. + +## Iteration Finding + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q +1 failed, 11 passed +KeyError: 'code' +``` + +The expanded deterministic-gate test exposed that the pre-existing +missing-evidence finding lacked a machine-readable category. The implementation +now emits `missing-evidence` without changing its severity, message, or +location contract. + +## Passing After Production Code + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q +12 passed in 0.47s +``` + +The passing run covers OpenSpec and Spec Kit normalization, deterministic IDs, +GIVEN/WHEN/THEN preservation, `sha256:` revisions, read-only source behavior, +all four import gates, explicit-profile precedence, layered-profile resolution, +and the evidence-compatible required-field mapping. + +## Compatibility Boundary: Failing Before Preflight + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py -q +3 failed, 5 passed +``` + +The new tests proved that custom OpenSpec schemas, Spec Kit template +customization roots, and unrecognized Markdown markers were previously +accepted or silently returned no records without an explanatory diagnostic. + +## Compatibility Boundary: Passing After Preflight + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q +18 passed in 0.98s +``` + +The compatibility tests cover default profile acceptance, project- and +change-local OpenSpec schema rejection, all supported Spec Kit customization +root rejections, unknown Markdown markers, and the no-partial-records +contract for `unsupported-source-schema`. + +## PR #646 Review Remediation Evidence (2026-07-13) + +### Failing-first + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py -q +7 failed, 11 passed +``` + +The failures covered non-string OpenSpec schema declarations, duplicate derived +OpenSpec/Spec Kit identities, project-root-relative source locators, and +malformed optional configuration. + +### Passing-after + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q +25 passed +``` + +The explicit-profile regression assertion also remains green: configured +profile-derived schema fields cannot override an explicitly passed profile. + +### Invalid UTF-8 Schema Remediation (2026-07-13) + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py::test_import_openspec_change_rejects_invalid_utf8_schema_without_partial_records -q +1 failed (UnicodeDecodeError escaped from config.yaml decoding) + +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q +26 passed +``` + +Invalid UTF-8 in an OpenSpec schema configuration now produces the same +`unsupported-source-schema` fail-closed result as malformed YAML or a +non-string schema declaration. + +## PR #647 Review Remediation Evidence (2026-07-14) + +### Failing-first + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py::test_import_openspec_change_preserves_wrapped_scenario_clauses tests/unit/requirements/test_upstream_evidence_imports.py::test_import_speckit_feature_reports_malformed_requirement_entries tests/unit/requirements/test_upstream_evidence_imports.py::test_validation_ignores_invalid_utf8_optional_config -q +3 failed +``` + +The failures proved that wrapped OpenSpec clauses were truncated, malformed +Spec Kit requirement entries were silently dropped, and binary optional profile +configuration raised `UnicodeDecodeError`. + +### Passing-after + +```text +$ hatch run pytest tests/unit/requirements/test_upstream_evidence_imports.py tests/unit/requirements/test_context_adapter.py -q +29 passed +``` + +The review remediation preserves Markdown clause continuations, emits bounded +warnings for malformed Spec Kit entries, and tolerates invalid UTF-8 in +optional profile configuration. + +## Final Gate Evidence + +```text +$ hatch run smart-test +2847 passed, 10 skipped; 64.0% coverage (local `fail_under = 50` threshold) + +This local result is not evidence that the PR's 80% CI quality-gate threshold +has passed. At the time recorded, the PR Tests job also failed first on the +unrelated `test_contracts_include_parameters` integration regression, so its +dependent Quality Gates job did not run. Coverage remediation is repository-wide +work outside this import-adapter change; this change adds targeted tests for all +new adapter paths. + +$ hatch run contract-test +Runtime contracts: PASS; contract exploration: PASS; scenario tests: 21 passed + +$ hatch run specfact code review run --json --out .specfact/code-review.json +Review completed with no findings. + +$ hatch run semgrep-sast --json --output logs/static-analysis/semgrep.json +$ hatch run semgrep-sast-gate --results logs/static-analysis/semgrep.json --baseline tools/semgrep/sast-baseline.json +0 current findings; no new findings outside baseline + +$ hatch run bandit-scan +No medium or high issues identified. +``` diff --git a/openspec/changes/openspec-01-intent-trace/design.md b/openspec/changes/openspec-01-intent-trace/design.md index d7347e6c..670bdff9 100644 --- a/openspec/changes/openspec-01-intent-trace/design.md +++ b/openspec/changes/openspec-01-intent-trace/design.md @@ -1,86 +1,169 @@ -# Design: OpenSpec Intent Trace — Bridge Adapter Integration +# Design: OpenSpec and Spec Kit Import-First Requirement Evidence ## Context -SpecFact's OpenSpec bridge adapter (`specfact sync bridge --adapter openspec`) reads proposal and task files from an OpenSpec change directory and imports them into SpecFact's project bundle. Currently it reads: title, description, tasks list, and spec references. It does not read any business intent context. This change adds an optional `## Intent Trace` YAML block to the OpenSpec proposal format and teaches the bridge adapter to import it into the `.specfact/requirements/` storage hierarchy used by requirements-01-data-model. +`requirements-01-data-model` and `requirements-02-module-commands` shipped a +normalized requirement evidence schema (`src/specfact_cli/models/requirements.py`) +and a `specfact requirements` command group (module `specfact-requirements`). +The import path is manual: `requirements import --from-file `. -The principle is: **"OpenSpec owns the intent. SpecFact owns the evidence."** OpenSpec authors write intent in a human-readable YAML block; SpecFact validates conformance and generates evidence. This separation keeps the intent format tool-agnostic. +The repository already contains deterministic parsers for both upstream +formats: `src/specfact_cli/adapters/openspec_parser.py` (OpenSpec change +folders) and `src/specfact_cli/importers/speckit_scanner.py` / +`speckit_converter.py` (Spec Kit feature folders). This change connects those +parsers to the requirements evidence schema and adds deterministic gates. + +The principle is: **"OpenSpec and Spec Kit own authoring. SpecFact owns +verification evidence."** No upstream tool adopts a SpecFact schema; SpecFact +reads what they already produce. ## Goals / Non-Goals **Goals:** -- Define the `## Intent Trace` section YAML schema and JSON Schema validator -- Extend the OpenSpec bridge adapter to parse and import intent artifacts when the section is present -- Keep the `## Intent Trace` section strictly optional — existing proposals without it are unaffected -- Validate the section against the JSON Schema during `openspec validate --strict` -- Produce `.specfact/requirements/{id}.req.yaml` artifacts from imported intent data +- Import native OpenSpec change folders and Spec Kit feature folders into + `RequirementInput` records with full source attribution. +- Map spec scenarios (GIVEN/WHEN/THEN) onto `BusinessRule` records so evidence + is testable, not prose. +- Record a content hash in `RequirementSourceReference.revision` at import + time so staleness is mechanically detectable. +- Extend requirement context validation with deterministic pass/fail gate + categories usable in CI via exit codes and JSON evidence. +- Keep the whole path read-only toward upstream artifacts. +- Fail closed when an upstream source declares or signals an untested schema or + template profile; do not silently emit zero or partial requirement records. **Non-Goals:** -- Forcing all existing OpenSpec proposals to add an `## Intent Trace` section -- Building a new proposal authoring tool — the section is hand-authored YAML in Markdown -- Replacing requirements-01/02 commands — the bridge adapter imports intent; the requirements module validates and traces it +- Defining any authoring schema, YAML block, or metadata convention that + upstream authors must write (retired `## Intent Trace` scope). +- Removing or deprecating SpecFact plan-authoring commands (separate change if + ever needed; this change only repositions documentation). +- Backlog import or drift (`requirements-03-backlog-sync`, deprioritized). +- LLM-assisted mapping or enrichment in the gate path. ## Decisions -### D1: YAML fenced block vs structured Markdown headings for Intent Trace - -**Decision**: YAML fenced block under `## Intent Trace` heading - -```yaml -intent_trace: - business_outcomes: - - id: "BO-001" - description: "..." - persona: "..." - business_rules: - - id: "BR-001" - outcome_ref: "BO-001" - given: "..." - when: "..." - then: "..." -``` - -**Rationale**: YAML is machine-parseable with a single `yaml.safe_load()` call and maps directly to Pydantic models. Structured Markdown headings require custom parsing logic that is brittle and hard to validate with JSON Schema. YAML fenced blocks are already used in GitHub Actions, Docker Compose, and Kubernetes manifests — authors are familiar with the pattern. -**Alternative rejected**: Structured `### Business Outcomes / ### Business Rules` Markdown sub-sections — readable but not JSON Schema validatable. - -### D2: JSON Schema stored in SpecFact vs in OpenSpec format repo - -**Decision**: JSON Schema at `openspec/schemas/intent-trace.schema.json` within SpecFact's own repo -**Rationale**: SpecFact is the authority for validation. The schema living in SpecFact's repo means the bridge adapter always validates against the version it was built with. When OpenSpec is an external tool (not this repo), the schema reference is still resolvable locally. -**Alternative rejected**: Hosting schema at `openspec.dev/schemas/intent-trace` — external HTTP dependency violates offline-first constraint. - -### D3: `--import-intent` flag vs automatic intent import - -**Decision**: `specfact sync bridge --adapter openspec --import-intent` — opt-in flag -**Rationale**: Not every team using the OpenSpec bridge wants `.specfact/requirements/` files created from every proposal import. The opt-in flag gives teams control. When `## Intent Trace` is present but `--import-intent` is not passed, the bridge still validates the section on `openspec validate --strict` but does not write requirements artifacts. -**Alternative rejected**: Automatic import when section is present — surprising side effect; could overwrite existing requirement files. - -### D4: `requirement_refs` in tasks.md — free-form vs validated IDs - -**Decision**: Free-form string list (`["BR-001", "AC-002"]`) with advisory validation -**Rationale**: Task-level requirement refs are metadata for traceability, not for enforcement. Advisory-mode validation warns if a ref ID does not match any known `BusinessRule` or `ArchitecturalConstraint` in `.specfact/requirements/` but does not block import. Hard enforcement would break workflows where requirements are not yet captured. - -### D5: `evidence` field in archived changes - -**Decision**: Optional string field in change archive metadata: `evidence: ".specfact/evidence/{timestamp}_{run_id}_evidence.json"` -**Rationale**: Minimal — just a file path reference. The evidence file itself is owned by governance-01-evidence-output. The archive metadata is a pointer, not a copy. This keeps the archive lightweight while enabling audit trail navigation. +### D1: Reuse `RequirementInput` instead of a new intent model + +**Decision**: No new top-level model. OpenSpec requirements and Spec Kit specs +normalize into existing `RequirementInput`; scenarios normalize into +`BusinessRule` (given/when/then); sources use existing +`RequirementSourceType.OPENSPEC_CHANGE` and `SPECKIT_SPEC`. +**Rationale**: The requirements-01 schema was designed for exactly this +(source types already exist). A parallel intent model would duplicate the +sidecar, validation, and coverage machinery (DRY violation). +**Alternative rejected**: Separate `IntentRecord` model and sidecar — two +overlapping evidence stores with divergent gates. + +### D2: Content hash in `revision` field vs new schema field + +**Decision**: Importers populate `RequirementSourceReference.revision` with +`sha256:` of the parsed artifact content. No schema version bump. +**Rationale**: `revision` is documented as "revision, commit, or version"; a +content hash is a revision. Avoids a schema migration for existing sidecars. +**Trade-off**: Hash convention must be documented and validated by prefix +(`sha256:`); non-prefixed revisions are treated as opaque and exempt from the +staleness gate. + +### D3: Deterministic requirement identity + +**Decision**: Requirement IDs are derived, stable, and collision-checked: +`openspec:::` and +`speckit::`. Re-import with an unchanged +artifact is idempotent; re-import with changed content updates the record in +place (merge by `requirement_id`, existing behavior of +`merge_requirement_inputs`). +**Rationale**: Idempotent re-import is what makes "no manual stuff" safe to +run repeatedly (in CI or a pre-commit hook). +**Alternative rejected**: UUIDs per import run — breaks merge and produces +duplicate evidence. + +### D4: Gate categories as validation findings, not a new command + +**Decision**: Gates extend the existing `requirements validate` report with +finding categories `scenario-unverified`, `stale-import`, `source-missing`, +and `ambiguous-mapping`. Profiles decide severity (error fails the run, +warning does not). No new top-level command. +**Rationale**: `requirements validate` already returns a `ValidationReport` +with profile support and a non-zero exit on failure; CI integration exists. +**Alternative rejected**: New `specfact validate intent` command — surface +sprawl for the same evidence. + +Gate severity MUST align with `profile-01-config-layering` (shipped): when the +`--profile` flag is omitted, the effective profile resolves from the layered +config (`resolve_profile_config`: profile defaults -> org baseline -> repo +overlay -> developer local) instead of a hardcoded `startup` default, and the +profile's `requirements_schema.required_fields` participates in completeness +findings. The explicit flag always wins. + +The requirements evidence adapter evaluates only the following explicit +aliases: `id` → `requirement_id`, `title` → `title`, `acceptance` → +`business_rules`, and `trace_links` → `evidence_links`. A configured field +outside that set is returned as a machine-readable +`unsupported-profile-field` advisory, not as missing record metadata. Native +OpenSpec and Spec Kit artifacts do not consistently supply owner, risk, or +exception metadata; adding those fields here would violate the import-first, +read-only boundary. A future enrichment change may define a source and schema +for that metadata. + +### D5: Import runtime lives in the `specfact-requirements` module + +**Decision**: Core owns parsers, normalization, hashing, and gate evaluation +as importable helpers. The `specfact-requirements` module adds +`requirements import --from-openspec [PATH]` and `--from-speckit [PATH]` +flags with auto-detection (`openspec/changes/` and Spec Kit `specs/` layouts) +when the path is omitted. +**Rationale**: Matches the requirements-02 split (core contracts, module +runtime) and keeps the module thin. + +### D6: Structural compatibility profiles, not inferred tool versions + +**Decision**: Core preflight accepts only two explicitly tested profiles: + +- OpenSpec's default `spec-driven` schema (or no schema declaration) with + native `### Requirement:` and `#### Scenario:` Markdown structure. +- Spec Kit's default Markdown template with `# Feature Specification:` and + functional-requirement (`FR-`) entries, provided no project template + override, preset, or extension template root is active. + +An OpenSpec custom schema, a Spec Kit customization root, malformed profile +marker, or an unrecognized required Markdown structure returns an error +diagnostic with code `unsupported-source-schema` and **zero records** for that +source. The adapter does not attempt fallback parsing or partial emission. + +**Rationale**: Neither source artifact format provides a dependable universal +tool-version field. Inferring a CLI version from Markdown would be false +precision. Structural profiles make the supported contract deterministic and +allow upstream changes to fail visibly until a pinned fixture and an explicit +profile update are added. + +**Alternative rejected**: Fetching a current upstream schema during import. +That makes CI non-reproducible, introduces network authority into a read-only +normalizer, and could change results mid-run. ## Risks / Trade-offs -- **[Risk] YAML indentation errors in proposals** — Authors writing `## Intent Trace` blocks manually may introduce YAML syntax errors. Mitigation: `openspec validate --strict` catches YAML parse errors before import; error message shows the line number and suggests a fix. -- **[Risk] ID collision between imported BusinessOutcome IDs and existing requirements** — If a team runs `--import-intent` twice with the same proposal, duplicate `.req.yaml` files may result. Mitigation: bridge adapter checks for existing file with same ID before writing; uses `--overwrite` flag to allow update. -- **[Trade-off] Schema evolution** — As requirements-01-data-model evolves (new fields), the `intent-trace.schema.json` must stay in sync. Mitigation: schema versioning (`schema_version: "1.0"` in the YAML block); bridge adapter rejects unknown schema versions with a clear error. +- **[Risk] Upstream format drift** — OpenSpec/Spec Kit layouts evolve. + Mitigation: profile preflight is fail-closed; unsupported sources emit + `unsupported-source-schema` and no records until a core fixture-backed + profile is deliberately added. +- **[Risk] Hash churn on whitespace-only edits** — staleness gate fires on any + byte change. Accepted: deterministic beats clever; re-import is one command + and idempotent. +- **[Trade-off] Scenario-to-test linking starts manual** — `evidence_links` + to tests still come from records or later tooling; the gate reports + unverified scenarios rather than inventing links. Follow-up changes + (`traceability-01`, `validation-02`) can populate links mechanically. ## Migration Plan -1. Land requirements-01-data-model (#238) — `BusinessOutcome` and `BusinessRule` Pydantic models must exist. -2. Define `intent-trace.schema.json` using the Pydantic model fields from requirements-01 as source of truth. -3. Extend bridge adapter parser to detect `## Intent Trace` section and extract the YAML block. -4. Implement `--import-intent` flag and requirements artifact writer. -5. Extend `openspec validate --strict` to call JSON Schema validator on intent trace section. -6. Update existing SpecFact dogfood proposals (this repo's `openspec/changes/`) with `## Intent Trace` sections as the team adopts the format. +1. No data migration: existing sidecars validate unchanged. +2. Docs reposition import-first as the flagship path; `--from-file` stays. +3. `requirements-03-backlog-sync` ordering note updated in `CHANGE_ORDER.md`. +4. Adding support for a newer upstream format requires a pinned representative + fixture, explicit profile change, and a passing compatibility test before + release; it is not discovered dynamically during import. ## Open Questions diff --git a/openspec/changes/openspec-01-intent-trace/proposal.md b/openspec/changes/openspec-01-intent-trace/proposal.md index 05f5190e..67ee777c 100644 --- a/openspec/changes/openspec-01-intent-trace/proposal.md +++ b/openspec/changes/openspec-01-intent-trace/proposal.md @@ -1,59 +1,122 @@ -# Change: OpenSpec and Spec Kit Evidence Adapter +# Change: OpenSpec and Spec Kit Import-First Requirement Evidence ## Why -OpenSpec and Spec Kit already own large parts of upstream specification and -planning. SpecFact should consume their artifacts as validation inputs rather -than define a mandatory upstream intent schema that those projects must adopt. - -This change reframes intent trace as an optional adapter convention: when -structured context exists, SpecFact imports it; when it does not, SpecFact still -validates code, contracts, tests, and artifact drift with source-attributed -evidence. - -## Ownership Alignment (2026-06-06) +OpenSpec and Spec Kit own upstream specification and planning authoring. SpecFact +must not compete with them as an authoring surface. The product value is +downstream: import their native artifacts as accountable source-of-truth items, +normalize them into the requirements evidence schema, enrich them with +verification links, and emit deterministic pass/fail gates that CI and reviewers +can trust. + +Today the `specfact requirements` command group works but is manual: users +hand-author JSON/YAML records and pass `--from-file`. This change makes upstream +OpenSpec change folders and Spec Kit feature folders first-class import sources, +so requirement evidence exists without manual authoring and every record carries +source attribution back to the accountable upstream artifact. + +## Rescope (2026-07-13) + +This change was previously scoped as an optional `## Intent Trace` YAML block +that upstream authors would hand-write into proposals. That scope is retired: +it made SpecFact define an authoring schema for other tools, which contradicts +the validation-evidence thesis. The new scope is **import-first**: + +- SpecFact parses **native** OpenSpec and Spec Kit artifacts as they exist + today. Upstream tools change nothing. +- Imported records normalize into the existing requirements evidence schema + (`requirements-01-data-model`): scenarios become given/when/then business + rules, sources carry locator plus content-hash revision. +- Validation gates are deterministic and mechanical: unverified scenarios, + stale imports, missing sources, and ambiguous mappings fail or warn by + profile. No LLM speculation in the gate path. +- SpecFact bundle records for specs and intent are **derived, read-only + imports**. SpecFact never writes into `openspec/` or Spec Kit directories. + +`requirements-03-backlog-sync` remains proposed but is deprioritized behind +this change (see `openspec/CHANGE_ORDER.md`). + +## Ownership Alignment (2026-07-13) - Repository assignment: `split/rescope` -- Core-owned scope retained here: optional adapter schema, source attribution, - task/reference parsing, and validation behavior when context is present. -- Bundle-owned follow-up required: runtime import behavior belongs to the - canonical project-bundle sync/import owner. +- Core-owned scope retained here: import contracts for OpenSpec and Spec Kit + artifacts, normalization and source-attribution semantics, content-hash + staleness contract, gate categories, and validation behavior. +- Bundle-owned follow-up required: import runtime flags and command wiring in + the `specfact-requirements` module. - Target modules-repo follow-up issue: [#168](https://github.com/nold-ai/specfact-cli-modules/issues/168) - Implementation MUST NOT require OpenSpec or Spec Kit to change their native - authoring model. + authoring model, and MUST NOT create or mutate upstream artifacts. ## What Changes -- **NEW**: Optional adapter metadata convention for OpenSpec proposals and Spec - Kit feature folders. -- **NEW**: Source-attributed import of outcomes, requirement references, tasks, - spec deltas, acceptance checks, and evidence links when present. -- **NEW**: Strict validation of adapter metadata only when the optional metadata - is present. -- **NEW**: Evidence pointers on archived changes MAY reference validation JSON - produced during implementation. -- **EXTEND**: Bridge/import flows can map upstream artifacts into the - requirements input model and evidence graph without creating duplicate - planning artifacts. +- **NEW**: Deterministic import of OpenSpec change folders (`proposal.md`, + `specs/*/spec.md` requirements and scenarios, `tasks.md`) into normalized + `RequirementInput` records with `openspec_change` source references. +- **NEW**: Deterministic import of Spec Kit feature folders (`spec.md`, + `plan.md`, `tasks.md`) into normalized `RequirementInput` records with + `speckit_spec` source references. +- **NEW**: Source references from these importers MUST populate `revision` + with a content hash of the parsed artifact, enabling staleness detection. +- **NEW**: Import preflight recognizes only the tested default OpenSpec + `spec-driven` Markdown profile and the tested default Spec Kit Markdown + profile. Custom OpenSpec schemas and Spec Kit template override/preset or + extension roots are rejected with a blocking `unsupported-source-schema` + diagnostic before any requirement record is emitted; the importer never + guesses or returns a partial import. +- **NEW**: Requirement context validation gains deterministic gate categories: + `scenario-unverified` (business rule without test/validation evidence link), + `stale-import` (source content hash no longer matches artifact on disk), + `source-missing` (referenced artifact absent), and `ambiguous-mapping` + (duplicate or conflicting requirement identity across sources). Severity is + profile-driven; failed gates exit non-zero with JSON evidence. +- **CHANGED**: OpenSpec and Spec Kit are documented as the accountable + authoring systems of record. SpecFact plan-authoring commands remain + available but are repositioned as secondary; import-first is the documented + flagship path. Command deprecation mechanics are out of scope here. +- **CHANGED (shipped-gap fix, folded in 2026-07-13)**: the core + `validate_requirement_context` helper resolves the effective profile from the layered configuration + shipped by `profile-01-config-layering` when no explicit profile is passed + (an explicit flag always wins), and honors the profile's + `requirements_schema.required_fields` through an explicit evidence-field + mapping: `id` → `requirement_id`, `title` → `title`, `acceptance` → + `business_rules`, and `trace_links` → `evidence_links`. Unsupported profile + fields are emitted as machine-readable `unsupported-profile-field` + advisories; they never make a native imported record incomplete. This change + does not add owner, risk, or exception metadata to the import-first schema. + The requirements CLI still hardcodes the `startup` default; the paired + module follow-up (#168) will adopt the core helper rather than duplicating + profile resolution. +- **REMOVED (from this change's previous scope)**: the `## Intent Trace` YAML + authoring block, `intent-trace.schema.json`, and `--import-intent` bridge + flag. Never implemented; retired before implementation. ## Capabilities ### New Capabilities -- `openspec-speckit-evidence-adapter`: Optional source-attributed adapter for - OpenSpec and Spec Kit artifacts consumed by SpecFact validation. +- `openspec-speckit-evidence-adapter`: Deterministic, read-only, + source-attributed import of native OpenSpec and Spec Kit artifacts into the + requirements evidence schema. ### Modified Capabilities -- `openspec-bridge-adapter`: Extended to parse optional metadata and evidence - links without requiring them. +- `requirements-module`: Requirement context import gains OpenSpec and Spec + Kit sources; validation gains deterministic pass/fail gate categories with + profile-driven severity. ## Impact -- OpenSpec and Spec Kit are documented as upstream inputs. -- Existing OpenSpec proposals remain valid when no adapter metadata exists. -- Depends on `requirements-01-data-model` and `requirements-02-module-commands` - only for normalized evidence import. +- `specfact requirements import` becomes useful without hand-authored files; + `--from-file` remains for generic records. +- `specfact requirements validate` becomes a CI-usable pass/fail gate over + imported upstream context. +- No breaking changes: existing bundles, sidecars, and `--from-file` flows are + unchanged. Depends on `requirements-01-data-model` and + `requirements-02-module-commands` (both shipped). +- This intentionally pins compatibility to source *format profiles*, not an + inferred upstream CLI version: native artifacts do not reliably carry one. + Future upstream profiles require explicit fixtures and a core adapter update. --- diff --git a/openspec/changes/openspec-01-intent-trace/specs/openspec-bridge-adapter/spec.md b/openspec/changes/openspec-01-intent-trace/specs/openspec-bridge-adapter/spec.md deleted file mode 100644 index e2dbb00e..00000000 --- a/openspec/changes/openspec-01-intent-trace/specs/openspec-bridge-adapter/spec.md +++ /dev/null @@ -1,26 +0,0 @@ -## MODIFIED Requirements - -### Requirement: OpenSpec Bridge Adapter Import - -The system SHALL import OpenSpec change proposals into SpecFact's project bundle with full backwards compatibility when the `## Intent Trace` section is absent, and include intent context when the section is present. - -#### Scenario: Proposal import without Intent Trace section is unchanged - -- **GIVEN** an OpenSpec proposal that has no `## Intent Trace` section -- **WHEN** `specfact sync bridge --adapter openspec` is run -- **THEN** the import behaviour is identical to the pre-change behaviour -- **AND** no error, warning, or advisory is emitted related to missing intent trace - -#### Scenario: Proposal import includes intent context when section is present - -- **GIVEN** an OpenSpec proposal with a valid `## Intent Trace` section -- **WHEN** `specfact sync bridge --adapter openspec` is run (without `--import-intent`) -- **THEN** the proposal's intent trace metadata is attached to the project bundle as read-only context -- **AND** `specfact project health-check` can report that intent context is available for the change - -#### Scenario: `openspec validate --strict` validates intent trace when present - -- **GIVEN** an OpenSpec change with a proposal containing a `## Intent Trace` section -- **WHEN** `openspec validate --strict` is run -- **THEN** the validator checks the YAML block against `intent-trace.schema.json` -- **AND** any schema violations cause a non-zero exit code with descriptive error messages diff --git a/openspec/changes/openspec-01-intent-trace/specs/openspec-bridge-intent-import/spec.md b/openspec/changes/openspec-01-intent-trace/specs/openspec-bridge-intent-import/spec.md deleted file mode 100644 index 0492a350..00000000 --- a/openspec/changes/openspec-01-intent-trace/specs/openspec-bridge-intent-import/spec.md +++ /dev/null @@ -1,58 +0,0 @@ -## ADDED Requirements - -### Requirement: Bridge Adapter Intent Import - -The system SHALL extend `specfact sync bridge --adapter openspec` with an `--import-intent` flag that reads the `## Intent Trace` YAML block from imported proposals and creates corresponding `.specfact/requirements/{id}.req.yaml` artifacts. - -#### Scenario: Intent import creates BusinessOutcome artifacts - -- **GIVEN** an OpenSpec proposal with a `## Intent Trace` section containing at least one `business_outcomes` entry -- **WHEN** `specfact sync bridge --adapter openspec --import-intent` is run -- **THEN** a `.specfact/requirements/{id}.req.yaml` file is created for each `BusinessOutcome` in the intent trace -- **AND** each artifact validates against the `BusinessOutcome` Pydantic schema without errors - -#### Scenario: Intent import creates BusinessRule artifacts - -- **GIVEN** an OpenSpec proposal with `business_rules` entries in the `## Intent Trace` section -- **WHEN** `specfact sync bridge --adapter openspec --import-intent` is run -- **THEN** each `BusinessRule` (id, outcome_ref, given, when, then) is stored in the corresponding `.req.yaml` artifact under its parent `BusinessOutcome` -- **AND** the `outcome_ref` is resolved to a valid `BusinessOutcome` ID in the imported requirements - -#### Scenario: Intent import skips existing artifacts without --overwrite - -- **GIVEN** a `.specfact/requirements/BO-001.req.yaml` file already exists -- **WHEN** `specfact sync bridge --adapter openspec --import-intent` is run without `--overwrite` -- **THEN** the existing file is not modified -- **AND** the CLI output notes the skipped artifact with its ID - -#### Scenario: Intent import overwrites with --overwrite flag - -- **GIVEN** a `.specfact/requirements/BO-001.req.yaml` file already exists -- **WHEN** `specfact sync bridge --adapter openspec --import-intent --overwrite` is run -- **THEN** the existing file is updated with the content from the proposal's intent trace section -- **AND** the CLI output confirms the overwritten artifact ID - -#### Scenario: Import without --import-intent ignores intent trace section - -- **GIVEN** an OpenSpec proposal with a `## Intent Trace` section -- **WHEN** `specfact sync bridge --adapter openspec` is run without `--import-intent` -- **THEN** no `.specfact/requirements/` artifacts are created -- **AND** the section is validated but not imported - -### Requirement: Task-Level Requirement References - -The system SHALL support an optional `requirement_refs` list field on individual tasks in OpenSpec `tasks.md` files, linking tasks to specific `BusinessRule` or `ArchitecturalConstraint` IDs. - -#### Scenario: Bridge adapter parses requirement_refs in tasks - -- **GIVEN** a `tasks.md` file with a task containing `requirement_refs: ["BR-001", "AC-002"]` -- **WHEN** the bridge adapter imports the proposal -- **THEN** the imported task record includes the requirement ref IDs -- **AND** they are included in the project bundle's task metadata - -#### Scenario: Advisory validation warns on unresolved requirement refs - -- **GIVEN** a task with `requirement_refs: ["BR-999"]` where BR-999 does not exist in `.specfact/requirements/` -- **WHEN** `specfact sync bridge --adapter openspec` is run -- **THEN** the CLI emits an advisory warning: `[ADVISORY] Task X: requirement_refs contains unknown ID BR-999` -- **AND** the import proceeds without failing diff --git a/openspec/changes/openspec-01-intent-trace/specs/openspec-intent-trace-schema/spec.md b/openspec/changes/openspec-01-intent-trace/specs/openspec-intent-trace-schema/spec.md deleted file mode 100644 index f0620149..00000000 --- a/openspec/changes/openspec-01-intent-trace/specs/openspec-intent-trace-schema/spec.md +++ /dev/null @@ -1,50 +0,0 @@ -## ADDED Requirements - -### Requirement: Intent Trace Section Schema - -The system SHALL define a JSON Schema at `openspec/schemas/intent-trace.schema.json` that validates the `## Intent Trace` YAML block in OpenSpec proposal files. - -#### Scenario: Valid intent trace section passes schema validation - -- **GIVEN** an OpenSpec proposal with a correctly structured `## Intent Trace` YAML block -- **WHEN** `openspec validate --strict` is run -- **THEN** the intent trace section validates without errors -- **AND** the validation output confirms intent trace section is present and valid - -#### Scenario: Invalid intent trace section fails schema validation - -- **GIVEN** an OpenSpec proposal with a `## Intent Trace` YAML block missing a required field (e.g., `id` on a `BusinessOutcome`) -- **WHEN** `openspec validate --strict` is run -- **THEN** the validation exits with a non-zero code -- **AND** the error message identifies the specific field violation and the line context - -#### Scenario: Missing intent trace section is valid (section is optional) - -- **GIVEN** an OpenSpec proposal without any `## Intent Trace` section -- **WHEN** `openspec validate --strict` is run -- **THEN** the validation passes without intent-trace errors -- **AND** no warning about missing intent trace is emitted in normal mode - -#### Scenario: Intent trace schema includes schema version field - -- **GIVEN** an intent trace YAML block with `schema_version: "1.0"` -- **WHEN** the bridge adapter reads the block -- **THEN** it accepts the artifact and records the schema version -- **AND** if the schema version is unknown the adapter emits a clear error with supported versions - -### Requirement: Intent Trace Evidence Field in Archive - -The system SHALL support an optional `evidence` field in change archive metadata pointing to the evidence JSON envelope file produced during implementation. - -#### Scenario: Archive metadata includes evidence reference - -- **GIVEN** an archived change that generated a governance evidence artifact -- **WHEN** the archive metadata is read -- **THEN** the `evidence` field contains a relative path to the `.specfact/evidence/` JSON file -- **AND** the path resolves to a readable file on disk - -#### Scenario: Archive without evidence field is valid - -- **GIVEN** an archived change that did not produce governance evidence -- **WHEN** the archive metadata is validated -- **THEN** validation passes without errors related to the missing evidence field diff --git a/openspec/changes/openspec-01-intent-trace/specs/openspec-speckit-evidence-adapter/spec.md b/openspec/changes/openspec-01-intent-trace/specs/openspec-speckit-evidence-adapter/spec.md new file mode 100644 index 00000000..3bf2ed95 --- /dev/null +++ b/openspec/changes/openspec-01-intent-trace/specs/openspec-speckit-evidence-adapter/spec.md @@ -0,0 +1,144 @@ +## ADDED Requirements + +### Requirement: OpenSpec Change Folder Import + +The system SHALL deterministically import native OpenSpec change folders +(`proposal.md`, `specs/*/spec.md`, `tasks.md`) into normalized +`RequirementInput` records without requiring any SpecFact-specific metadata in +the upstream artifacts. + +#### Scenario: OpenSpec spec delta requirements become requirement inputs + +- **GIVEN** an OpenSpec change folder whose `specs//spec.md` + contains requirements with GIVEN/WHEN/THEN scenarios +- **WHEN** the OpenSpec import runs against that change folder +- **THEN** each spec requirement produces a `RequirementInput` with a stable + derived `requirement_id` of the form `openspec:::` +- **AND** repeated titles that derive the same slug receive a deterministic + ordinal suffix so every record in one import has a unique identity +- **AND** each scenario is normalized into a `BusinessRule` with its given, + when, and then clauses preserved, including Markdown continuation lines +- **AND** each record carries a `RequirementSourceReference` with + `source_type` `openspec_change` and a locator pointing at the parsed artifact + +#### Scenario: Import requires no upstream metadata + +- **GIVEN** an OpenSpec change folder with no SpecFact-specific sections or + metadata of any kind +- **WHEN** the OpenSpec import runs +- **THEN** the import succeeds using only the native OpenSpec artifact + structure +- **AND** no warning or advisory about missing SpecFact metadata is emitted + +#### Scenario: Re-import of unchanged artifacts is idempotent + +- **GIVEN** a bundle that already contains records imported from an OpenSpec + change folder +- **WHEN** the same unchanged change folder is imported again +- **THEN** the resulting requirement records are identical to the prior import +- **AND** no duplicate records are created + +### Requirement: Spec Kit Feature Folder Import + +The system SHALL deterministically import native Spec Kit feature folders +(`spec.md`, `plan.md`, `tasks.md`) into normalized `RequirementInput` records +without requiring any SpecFact-specific metadata in the upstream artifacts. + +#### Scenario: Spec Kit spec requirements become requirement inputs + +- **GIVEN** a Spec Kit feature folder containing a `spec.md` with functional + requirements and acceptance scenarios +- **WHEN** the Spec Kit import runs against that feature folder +- **THEN** each requirement produces a `RequirementInput` with a stable derived + `requirement_id` of the form `speckit::` +- **AND** repeated requirement text that derives the same slug receives a + deterministic ordinal suffix so every record in one import has a unique identity +- **AND** acceptance scenarios are normalized into `BusinessRule` records +- **AND** each record carries a `RequirementSourceReference` with + `source_type` `speckit_spec` + +#### Scenario: Malformed Spec Kit requirement entries remain auditable + +- **GIVEN** a supported Spec Kit feature whose parsed requirements contain an + entry that is not a mapping or has no requirement text +- **WHEN** the Spec Kit import runs +- **THEN** valid entries are still normalized into `RequirementInput` records +- **AND** each malformed entry produces one bounded `source-missing` warning + that identifies the source artifact and entry index + +### Requirement: Content-Hash Source Attribution + +Importers for OpenSpec and Spec Kit artifacts SHALL populate each +`RequirementSourceReference.revision` with a content hash of the parsed +artifact using the `sha256:` convention, so staleness is mechanically +detectable. + +#### Scenario: Imported source references carry a content hash + +- **GIVEN** an OpenSpec or Spec Kit artifact being imported +- **WHEN** the import produces a `RequirementSourceReference` +- **THEN** the reference `revision` is set to `sha256:` followed by the SHA-256 + hex digest of the artifact content at import time + +#### Scenario: Non-hash revisions remain opaque + +- **GIVEN** a requirement record whose source `revision` does not start with + `sha256:` +- **WHEN** staleness evaluation runs +- **THEN** the record is exempt from content-hash staleness checks +- **AND** no finding is produced solely because the revision is not a hash + +### Requirement: Read-Only Upstream Guarantee + +The import path SHALL never create, modify, or delete files inside upstream +OpenSpec or Spec Kit directories. + +#### Scenario: Import leaves upstream artifacts untouched + +- **GIVEN** an OpenSpec change folder and a Spec Kit feature folder used as + import sources +- **WHEN** import, validation, and coverage inspection run to completion +- **THEN** the byte content and file listing of the upstream directories are + unchanged + +### Requirement: Fail-Closed Source Compatibility + +The system SHALL import only explicitly tested native source-format profiles +and SHALL reject unrecognized or customized source schemas before emitting any +requirement records. Compatibility SHALL be structural because upstream +artifacts do not provide a dependable universal tool-version field. + +#### Scenario: Default OpenSpec schema is accepted + +- **GIVEN** an OpenSpec change using the default `spec-driven` schema (or no + explicit schema) and native requirement/scenario Markdown headings +- **WHEN** the OpenSpec import runs +- **THEN** the source is accepted by compatibility preflight +- **AND** normalization proceeds using the default OpenSpec profile. + +#### Scenario: Custom OpenSpec schema is rejected without partial records + +- **GIVEN** an OpenSpec change whose project or change configuration declares + a schema other than the tested default, or declares a non-string schema value +- **WHEN** the OpenSpec import runs +- **THEN** the result contains an error diagnostic with code + `unsupported-source-schema` +- **AND** the result contains no requirement records from that source. + +#### Scenario: Customized Spec Kit templates are rejected without partial records + +- **GIVEN** a Spec Kit feature under a project with template overrides, + presets, or extension template roots +- **WHEN** the Spec Kit import runs +- **THEN** the result contains an error diagnostic with code + `unsupported-source-schema` +- **AND** the result contains no requirement records from that source. + +#### Scenario: Unrecognized default-format markers are rejected + +- **GIVEN** an OpenSpec or Spec Kit source that does not contain the required + headings of its tested default Markdown profile +- **WHEN** the importer preflights the source +- **THEN** it emits `unsupported-source-schema` +- **AND** it does not guess a mapping, fetch upstream definitions, or emit a + partial import. diff --git a/openspec/changes/openspec-01-intent-trace/specs/requirements-module/spec.md b/openspec/changes/openspec-01-intent-trace/specs/requirements-module/spec.md new file mode 100644 index 00000000..3cd3fb02 --- /dev/null +++ b/openspec/changes/openspec-01-intent-trace/specs/requirements-module/spec.md @@ -0,0 +1,106 @@ +## MODIFIED Requirements + +### Requirement: Requirements Context Adapter + +The system SHALL provide core requirements context adapter helpers for import, +normalization, validation, and coverage inspection of upstream requirement +context as validation evidence. Import SHALL support native OpenSpec change +folders and Spec Kit feature folders as first-class sources in addition to +generic record files, and validation SHALL evaluate deterministic pass/fail +gate categories with profile-driven severity. + +#### Scenario: Import helpers normalize source-attributed records + +- **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: Invalid imported records produce bounded diagnostics + +- **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: Validation and coverage expose evidence usefulness + +- **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. + +#### Pending paired-module follow-up + +The `specfact requirements import --from-openspec` and `--from-speckit` +command flags, auto-detection, persistence, and diagnostic rendering remain +unimplemented in the requirements module. They are owned by +`nold-ai/specfact-cli-modules#168`; this core change supplies only the helpers +that the future module runtime will call. Until that module release declares a +core compatibility floor of `0.52.0`, `--from-file` is the only shipped module +import path. + +#### Scenario: Unverified scenarios gate validation + +- **GIVEN** an imported requirement whose business rules have no `test` or `validation` evidence link +- **WHEN** requirements context validation runs under a profile that treats `scenario-unverified` as an error +- **THEN** the validation report contains a `scenario-unverified` finding naming the requirement and rule IDs +- **AND** the command exits non-zero. + +#### Scenario: Stale imports gate validation + +- **GIVEN** an imported requirement whose source `revision` content hash no longer matches the artifact on disk +- **WHEN** requirements context validation runs +- **THEN** the validation report contains a `stale-import` finding with the source locator +- **AND** profile severity decides whether the run fails. + +#### Scenario: Missing sources gate validation + +- **GIVEN** an imported requirement whose source locator no longer resolves to an existing artifact +- **WHEN** requirements context validation runs +- **THEN** the validation report contains a `source-missing` finding with the unresolved locator. + +#### Scenario: Relative source locators resolve from the project root + +- **GIVEN** an imported requirement stores a relative source locator +- **WHEN** validation runs with its project root from a different process directory +- **THEN** the locator is resolved relative to that project root for missing-source and staleness checks. + +#### Scenario: Omitted profile resolves from layered configuration + +- **GIVEN** a project whose layered configuration (profile defaults, org baseline, repo overlay, developer local) resolves to a validation profile +- **WHEN** requirements context validation runs without an explicit profile argument +- **THEN** the resolved configured profile decides gate severity instead of a hardcoded default +- **AND** an explicitly passed profile argument overrides the configured profile. +- **AND** profile-derived `requirements_schema` values from configured layers + do not override the explicitly selected profile defaults. + +#### Scenario: Profile required fields use evidence-compatible aliases + +- **GIVEN** a resolved profile whose `requirements_schema.required_fields` + contains `id`, `title`, `acceptance`, `trace_links`, and a field unsupported + by `RequirementInput` +- **WHEN** requirements context validation runs +- **THEN** `id`, `title`, `acceptance`, and `trace_links` evaluate + `requirement_id`, `title`, `business_rules`, and `evidence_links`, + respectively, for completeness findings +- **AND** the unsupported field produces a machine-readable + `unsupported-profile-field` advisory +- **AND** native imported records are not marked incomplete solely because the + upstream artifact has no owner, risk, or exception metadata. + +#### Scenario: Ambiguous mappings gate validation + +- **GIVEN** two imported requirements that claim the same derived requirement identity from different sources +- **WHEN** requirements context validation runs +- **THEN** the validation report contains an `ambiguous-mapping` finding naming both source locators +- **AND** no record is silently dropped or overwritten. + +#### Scenario: Unsupported source format blocks module import + +- **GIVEN** a module import request whose OpenSpec schema or Spec Kit template + profile is not supported by the core evidence adapter +- **WHEN** the module delegates import to core +- **THEN** it surfaces the core `unsupported-source-schema` diagnostic without + creating or persisting partial requirement records +- **AND** it does not implement provider-version detection or fallback parsing. diff --git a/openspec/changes/openspec-01-intent-trace/tasks.md b/openspec/changes/openspec-01-intent-trace/tasks.md index 949b54e0..a2aedc99 100644 --- a/openspec/changes/openspec-01-intent-trace/tasks.md +++ b/openspec/changes/openspec-01-intent-trace/tasks.md @@ -1,156 +1,65 @@ -# Tasks: OpenSpec Intent Trace — Bridge Adapter Integration +# Tasks: OpenSpec and Spec Kit Import-First Requirement Evidence ## TDD / SDD order (enforced) -Per `openspec/config.yaml`, tests MUST precede production code for any behavior-changing task. - -Order: - -1. Spec deltas (already in `specs/`) -2. Tests derived from spec scenarios — run and expect failure -3. Production code — implement until tests pass - -Do not implement production code until tests exist and have been run (expecting failure). +Per `openspec/config.yaml`, tests MUST precede production code for any +behavior-changing task: spec deltas first, failing tests second, production +code third. Record evidence in `TDD_EVIDENCE.md`. --- -## 1. Create git worktree for this change - -- [ ] 1.1 Fetch latest and create a worktree with a new branch from `origin/dev`. - - [ ] 1.1.1 `git fetch origin` - - [ ] 1.1.2 `git worktree add ../specfact-cli-worktrees/feature/openspec-01-intent-trace -b feature/openspec-01-intent-trace origin/dev` - - [ ] 1.1.3 `cd ../specfact-cli-worktrees/feature/openspec-01-intent-trace` - - [ ] 1.1.4 `python -m venv .venv && source .venv/bin/activate && pip install -e ".[dev]"` - - [ ] 1.1.5 `git branch --show-current` (verify `feature/openspec-01-intent-trace`) - -## 2. Define Intent Trace JSON Schema - -- [ ] 2.1 Create `openspec/schemas/intent-trace.schema.json`: - - [ ] 2.1.1 Root object with `schema_version` (required string), `intent_trace` object - - [ ] 2.1.2 `business_outcomes` array: each item requires `id` (string), `description` (string), `persona` (string) - - [ ] 2.1.3 `business_rules` array: each item requires `id`, `outcome_ref`, `given`, `when`, `then` - - [ ] 2.1.4 `architectural_constraints` array: each item requires `id`, `outcome_ref`, `constraint` - - [ ] 2.1.5 `requirement_refs` optional array of strings - - [ ] 2.1.6 `additionalProperties: false` on all objects for strict validation -- [ ] 2.2 Write schema unit tests in `tests/unit/specfact_cli/test_intent_trace_schema.py`: - - [ ] 2.2.1 Test valid complete intent trace block passes validation - - [ ] 2.2.2 Test missing required `id` field on BusinessOutcome fails - - [ ] 2.2.3 Test missing intent trace section (None) passes (optional) - - [ ] 2.2.4 Test unknown `schema_version` raises descriptive error -- [ ] 2.3 Run schema tests — expect failure: `hatch test -- tests/unit/specfact_cli/test_intent_trace_schema.py -v` -- [ ] 2.4 Record failing test evidence in `TDD_EVIDENCE.md` - -## 3. Write bridge adapter tests (TDD — expect failure) - -- [ ] 3.1 Review existing OpenSpec bridge adapter tests in `tests/` -- [ ] 3.2 Add `tests/unit/specfact_cli/test_openspec_bridge_intent.py`: - - [ ] 3.2.1 Test bridge import of proposal with `## Intent Trace` creates `.req.yaml` files (with `--import-intent`) - - [ ] 3.2.2 Test bridge import without `## Intent Trace` section is unchanged (backwards compatible) - - [ ] 3.2.3 Test `--import-intent` without `--overwrite` skips existing artifacts - - [ ] 3.2.4 Test `--import-intent --overwrite` updates existing artifacts - - [ ] 3.2.5 Test advisory warning on unresolved `requirement_refs` - - [ ] 3.2.6 Test `requirement_refs` parsed into imported task metadata -- [ ] 3.3 Add `tests/integration/test_openspec_intent_trace_e2e.py`: - - [ ] 3.3.1 End-to-end test: proposal with intent trace → bridge import → `.req.yaml` exists and validates -- [ ] 3.4 Run bridge tests — expect failure: `hatch test -- tests/unit/specfact_cli/test_openspec_bridge_intent.py -v` -- [ ] 3.5 Record failing test evidence in `TDD_EVIDENCE.md` - -## 4. Implement Intent Trace schema validator - -- [ ] 4.1 Add `src/specfact_cli/validators/intent_trace_validator.py`: - - [ ] 4.1.1 `validate_intent_trace(yaml_block: dict | None) -> ValidationResult` with `@require` and `@beartype` - - [ ] 4.1.2 Load `openspec/schemas/intent-trace.schema.json` (bundled resource) - - [ ] 4.1.3 Use `jsonschema.validate()` for schema check - - [ ] 4.1.4 Return structured errors with field path, message, and suggestion -- [ ] 4.2 Register schema file in `pyproject.toml` `[tool.hatch.build.targets.wheel]` force-include - -## 5. Extend OpenSpec bridge adapter with intent import - -> **Implementation constraint (from CHANGE_VALIDATION.md)**: `_parse_proposal_content()` in `openspec_parser.py` has return type `dict[str, str]`. Intent trace extraction MUST be done in `parse_change_proposal()` (returns `dict[str, Any]`) — NOT in `_parse_proposal_content()` — to avoid a `@beartype` type violation. - -- [ ] 5.1 Locate OpenSpec bridge adapter in `src/specfact_cli/adapters/` -- [ ] 5.2 Add `## Intent Trace` section parser: - - [ ] 5.2.1 Extract YAML fenced block under `## Intent Trace` heading from proposal Markdown - - [ ] 5.2.2 Parse YAML with `yaml.safe_load()` - - [ ] 5.2.3 Run `validate_intent_trace()` on the parsed block -- [ ] 5.3 Add `--import-intent` flag to `specfact sync bridge --adapter openspec` command: - - [ ] 5.3.1 `@require`: intent import requires requirements-01 module is installed (advisory check) - - [ ] 5.3.2 Write `.specfact/requirements/{id}.req.yaml` for each `BusinessOutcome` - - [ ] 5.3.3 Embed `BusinessRule` entries in parent `.req.yaml` files - - [ ] 5.3.4 Respect `--overwrite` flag; skip existing files otherwise -- [ ] 5.4 Add `requirement_refs` parsing from `tasks.md` task entries - - [ ] 5.4.1 Parse optional `requirement_refs:` YAML field on task lines - - [ ] 5.4.2 Include in imported task metadata in project bundle - - [ ] 5.4.3 Advisory warning for unresolved IDs -- [ ] 5.5 Extend `openspec validate --strict` hook to call `validate_intent_trace()` when section present -- [ ] 5.6 Add `@require`, `@ensure`, `@beartype` decorators to all new public API functions - -## 6. Passing tests and quality gates - -- [ ] 6.1 Run all new tests — expect passing: `hatch test -- tests/unit/specfact_cli/test_intent_trace*.py tests/unit/specfact_cli/test_openspec_bridge*.py tests/integration/test_openspec_intent_trace*.py -v` -- [ ] 6.2 Record passing test evidence in `TDD_EVIDENCE.md` -- [ ] 6.3 `hatch run format` -- [ ] 6.4 `hatch run type-check` -- [ ] 6.5 `hatch run lint` -- [ ] 6.6 `hatch run yaml-lint` -- [ ] 6.7 `hatch run contract-test` -- [ ] 6.8 `hatch run smart-test` - -## 7. Documentation - -- [ ] 7.1 Update `docs/adapters/openspec.md` (or equivalent): - - [ ] 7.1.1 Document `## Intent Trace` section format with full YAML example - - [ ] 7.1.2 Document `--import-intent` and `--overwrite` flags - - [ ] 7.1.3 Document `requirement_refs` field on tasks -- [ ] 7.2 Update `docs/guides/openspec-journey.md` — add Intent Trace section -- [ ] 7.3 Ensure front-matter on all updated/new doc pages is valid (layout, title, permalink, description) -- [ ] 7.4 Update `docs/_layouts/default.html` sidebar navigation if new pages are added - -## 8. Version and changelog - -- [ ] 8.1 Bump minor version in `pyproject.toml`, `setup.py`, `src/__init__.py`, `src/specfact_cli/__init__.py` -- [ ] 8.2 Add CHANGELOG.md entry under new `[X.Y.Z] - 2026-XX-XX` with Added/Changed sections - -## 9. GitHub issue creation - -- [ ] 9.1 Create GitHub issue: - - ```bash - gh issue create \ - --repo nold-ai/specfact-cli \ - --title "[Change] OpenSpec Intent Trace — Bridge Adapter Integration" \ - --body-file /tmp/github-issue-openspec-01.md \ - --label "enhancement" \ - --label "change-proposal" - ``` - -- [ ] 9.2 Link issue to project: `gh project item-add 1 --owner nold-ai --url ` -- [ ] 9.3 Update `proposal.md` Source Tracking section with issue number and URL -- [ ] 9.4 Link branch to issue: `gh issue develop --repo nold-ai/specfact-cli --name feature/openspec-01-intent-trace` - -## 10. Pull request - -- [ ] 10.1 `git add` all changed files; commit with `feat: add OpenSpec Intent Trace section and bridge adapter import` -- [ ] 10.2 `git push -u origin feature/openspec-01-intent-trace` -- [ ] 10.3 Create PR: - - ```bash - gh pr create \ - --repo nold-ai/specfact-cli \ - --base dev \ - --head feature/openspec-01-intent-trace \ - --title "feat: OpenSpec Intent Trace bridge adapter integration" \ - --body-file /tmp/pr-body-openspec-01.md - ``` - -- [ ] 10.4 Link PR to project: `gh project item-add 1 --owner nold-ai --url ` -- [ ] 10.5 Set project status to "In Progress" - -## Post-merge cleanup (after PR is merged) - -- [ ] Return to primary checkout: `cd .../specfact-cli` -- [ ] `git fetch origin` -- [ ] `git worktree remove ../specfact-cli-worktrees/feature/openspec-01-intent-trace` -- [ ] `git branch -d feature/openspec-01-intent-trace` -- [ ] `git worktree prune` -- [ ] (Optional) `git push origin --delete feature/openspec-01-intent-trace` +## 1. Branch and dependency guardrails + +- [x] 1.1 Create dedicated worktree branch `feature/openspec-01-intent-trace` from `dev`: `scripts/worktree.sh create feature/openspec-01-intent-trace`. +- [x] 1.2 Verify `requirements-01-data-model` and `requirements-02-module-commands` are archived (shipped) in `openspec/changes/archive/`. +- [x] 1.3 Re-run change validation for the rescoped proposal and refresh `CHANGE_VALIDATION.md` (previous validation covers the retired Intent Trace scope). + +## 2. Spec-first and test-first preparation + +- [x] 2.1 Finalize `specs/` deltas (`openspec-speckit-evidence-adapter` NEW, `requirements-module` MODIFIED) and cross-check scenario completeness. +- [x] 2.2 Add unit tests for OpenSpec import normalization (change folder fixture → `RequirementInput` records with `openspec_change` sources, scenario → `BusinessRule` mapping, `sha256:` revision). +- [x] 2.3 Add unit tests for Spec Kit import normalization (feature folder fixture → `RequirementInput` records with `speckit_spec` sources). +- [x] 2.4 Add unit tests for gate categories: `scenario-unverified`, `stale-import`, `source-missing`, `ambiguous-mapping`, including profile severity mapping and idempotent re-import. +- [x] 2.4b Add unit tests for layered-config profile resolution: omitted profile resolves via `resolve_profile_config` (explicit flag wins); `id`, `title`, `acceptance`, and `trace_links` map to evidence fields; unsupported fields emit an advisory without making imported records incomplete. +- [x] 2.5 Run targeted tests, capture failing-first output in `TDD_EVIDENCE.md`. +- [x] 2.6 Add failing-first compatibility tests for default OpenSpec/Spec Kit + profiles, custom OpenSpec schemas, Spec Kit template customization roots, + and unknown Markdown markers; prove unsupported sources emit no partial + records. +- [x] 2.7 Add review-regression tests for malformed schema declarations, + duplicate derived identities, explicit-profile isolation, resilient config + loading, and project-root-relative source locators; capture failing-first + evidence. + +## 3. Implementation (core) + +- [x] 3.1 Add import normalizers in `src/specfact_cli/requirements/` that consume `adapters/openspec_parser.py` and `importers/speckit_scanner.py`/`speckit_converter.py` output and emit `RequirementInput` records with deterministic IDs and content-hash revisions. +- [x] 3.2 Extend `requirements/context.py` validation with the four gate categories and profile-driven severity; keep existing findings intact. +- [x] 3.2b Wire layered-config profile resolution: when no explicit profile is passed, resolve the effective profile via `resolve_profile_config` (profile defaults -> org baseline -> repo overlay -> developer local); apply the four supported required-field aliases to completeness findings and emit `unsupported-profile-field` advisories for all others. +- [x] 3.3 Guarantee read-only behavior toward upstream artifact directories (contract tests: no writes under source roots). +- [x] 3.4 Contract decorators (`@beartype`, `@require`, `@ensure`) on all new public APIs. +- [x] 3.5 Add fail-closed compatibility preflight in the core import helpers: + support only fixture-backed default profiles and return + `unsupported-source-schema` for unknown/custom sources without emitting + partial records. +- [x] 3.6 Remediate accepted PR review findings without widening the paired + module #168 runtime scope. + +## 4. Implementation (modules repo, paired) + +- [ ] 4.1 Track runtime wiring in nold-ai/specfact-cli-modules#168: `requirements import --from-openspec [PATH] --from-speckit [PATH]` with auto-detection, plus validate/list/coverage surfacing of new gate findings including `unsupported-source-schema`. +- [ ] 4.2 Keep module runtime thin: parsing, hashing, and gate logic stay core-owned. + +## 5. Validation and documentation + +- [x] 5.1 Re-run tests and quality gates (`hatch run format`, `type-check`, `lint`, `contract-test`, `smart-test`) until green; record passing evidence in `TDD_EVIDENCE.md`. +- [x] 5.2 Update docs: requirements guide repositioned import-first (OpenSpec/Spec Kit as accountable authoring sources; `--from-file` as generic fallback); document gate categories and CI exit-code usage. +- [x] 5.3 Run `openspec validate openspec-01-intent-trace --strict` and resolve all issues. + +## 6. Delivery + +- [x] 6.1 Update `openspec/CHANGE_ORDER.md` if sequencing changed during implementation. +- [x] 6.2 Mirror material scope updates to `../specfact-cli-internal/wiki/sources/openspec-01-intent-trace.md` and rebuild the wiki graph. +- [x] 6.3 Sync GitHub issue #350 (and modules #168) title/body with the rescoped proposal. +- [ ] 6.4 Open PR from `feature/openspec-01-intent-trace` to `dev` with spec/test/code/docs evidence. +- [ ] 6.5 After the PR merges, remove the feature worktree with `scripts/worktree.sh remove feature/openspec-01-intent-trace`. diff --git a/pyproject.toml b/pyproject.toml index 1d2b6661..284eff85 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.51.2" +version = "0.52.3" 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 0460711c..7557ab37 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.51.2", + version="0.52.3", 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 f5a09a31..c50dba19 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.51.2" +__version__ = "0.52.3" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index d1fb49d4..20e3a57d 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.51.2" +__version__ = "0.52.3" __all__ = ["__version__"] diff --git a/src/specfact_cli/requirements/__init__.py b/src/specfact_cli/requirements/__init__.py index f6ebe080..cca77ecd 100644 --- a/src/specfact_cli/requirements/__init__.py +++ b/src/specfact_cli/requirements/__init__.py @@ -12,6 +12,7 @@ normalize_requirement_records, validate_requirement_context, ) +from specfact_cli.requirements.importers import import_openspec_change, import_speckit_feature __all__ = [ @@ -21,6 +22,8 @@ "RequirementContextDiagnosticSeverity", "RequirementContextImportResult", "attach_requirements_to_bundle", + "import_openspec_change", + "import_speckit_feature", "inspect_requirement_context_coverage", "load_requirements_from_bundle", "normalize_requirement_records", diff --git a/src/specfact_cli/requirements/context.py b/src/specfact_cli/requirements/context.py index 8dca5644..c6fc714e 100644 --- a/src/specfact_cli/requirements/context.py +++ b/src/specfact_cli/requirements/context.py @@ -2,11 +2,13 @@ from __future__ import annotations +import hashlib from collections.abc import Mapping, Sequence from enum import StrEnum from pathlib import Path from typing import Any, Literal, Protocol, cast, get_args +import yaml from beartype import beartype from icontract import ensure, require from pydantic import BaseModel, ConfigDict, Field, ValidationError @@ -15,10 +17,12 @@ from specfact_cli.models.requirements import ( RequirementEvidenceLinkType, RequirementInput, + RequirementSourceType, load_requirements_input_extension, requirements_input_extension_payload, ) from specfact_cli.models.validation import ValidationReport +from specfact_cli.modules.init.src.first_run_selection import resolve_profile_config RequirementContextValidationProfile = Literal[ @@ -26,6 +30,7 @@ "startup", "team", "enterprise", + "mid_size", "strict", "solo_developer", "api_first_team", @@ -33,6 +38,19 @@ ] KNOWN_REQUIREMENT_CONTEXT_PROFILES: frozenset[str] = frozenset(get_args(RequirementContextValidationProfile)) STRICT_REQUIREMENT_CONTEXT_PROFILES: frozenset[str] = frozenset({"enterprise", "strict", "enterprise_full_stack"}) +_PROFILE_RESOLUTION_ALIASES: dict[str, str] = { + "api_first_team": "mid_size", + "enterprise_full_stack": "enterprise", + "solo_developer": "solo", + "strict": "enterprise", + "team": "mid_size", +} +_REQUIRED_FIELD_ALIASES: dict[str, str] = { + "id": "requirement_id", + "title": "title", + "acceptance": "business_rules", + "trace_links": "evidence_links", +} class RequirementContextDiagnosticSeverity(StrEnum): @@ -131,16 +149,77 @@ def _optional_text(value: str | None) -> str | None: return value -def _profile_nonempty(profile: str) -> bool: - return profile.strip() != "" +def _source_locator_supported(source_locator: str | None) -> bool: + return source_locator is None or type(source_locator) is str -def _profile_supported(profile: str) -> bool: - return profile in KNOWN_REQUIREMENT_CONTEXT_PROFILES +def _optional_profile_supported(profile: str | None) -> bool: + return profile is None or (profile.strip() != "" and 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 _project_root_supported(project_root: Path | None) -> bool: + return project_root is None or isinstance(project_root, Path) + + +def _read_config_mapping(path: Path) -> dict[str, Any]: + try: + if not path.is_file(): + return {} + raw = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return {} + return raw if isinstance(raw, dict) else {} + + +def _without_profile_derived_values(layer: Mapping[str, Any]) -> dict[str, Any]: + return {key: value for key, value in layer.items() if key not in {"profile", "requirements_schema"}} + + +def _configured_profile(*layers: Mapping[str, object]) -> str: + for layer in layers: + profile = layer.get("profile") + if isinstance(profile, str) and profile in KNOWN_REQUIREMENT_CONTEXT_PROFILES: + return profile + return "startup" + + +def _requirements_schema_fields(values: Mapping[str, Any]) -> list[str]: + schema = values.get("requirements_schema") + if not isinstance(schema, Mapping): + return [] + fields = cast(Mapping[str, object], schema).get("required_fields") + return [field for field in fields if isinstance(field, str)] if isinstance(fields, list) else [] + + +def _resolve_requirement_profile( + profile: RequirementContextValidationProfile | None, + project_root: Path | None, +) -> tuple[str, dict[str, Any]]: + root = project_root or Path.cwd() + org_baseline = _read_config_mapping(Path.home() / ".specfact" / "config.yaml") + repo_overlay = _read_config_mapping(root / ".specfact" / "config.yaml") + developer_local = _read_config_mapping(root / ".specfact" / "config.local.yaml") + configured = _configured_profile(developer_local, repo_overlay, org_baseline) + effective_profile = profile or configured + resolver_profile = _PROFILE_RESOLUTION_ALIASES.get(effective_profile, effective_profile) + if profile is not None: + org_baseline = _without_profile_derived_values(org_baseline) + repo_overlay = _without_profile_derived_values(repo_overlay) + developer_local = _without_profile_derived_values(developer_local) + resolved = resolve_profile_config( + resolver_profile, + org_baseline=org_baseline, + repo_overlay=repo_overlay, + developer_local=developer_local, + ) + return effective_profile, resolved.values + + +def _is_imported_requirement(requirement: RequirementInput) -> bool: + return any( + source.source_type in {RequirementSourceType.OPENSPEC_CHANGE, RequirementSourceType.SPECKIT_SPEC} + for source in requirement.sources + ) def _coverage_summary_consistent(result: RequirementContextCoverageSummary) -> bool: @@ -274,6 +353,7 @@ def _missing_evidence_violations( ) -> list[dict[str, str]]: return [ { + "code": "missing-evidence", "severity": severity, "message": "Requirement input has no downstream evidence links.", "location": f"requirements.inputs[{requirement_id}].evidence_links", @@ -282,6 +362,136 @@ def _missing_evidence_violations( ] +def _scenario_unverified_violation(requirement: RequirementInput, *, severity: str) -> dict[str, str] | None: + if not requirement.business_rules or any( + link.link_type in {RequirementEvidenceLinkType.TEST, RequirementEvidenceLinkType.VALIDATION} + for link in requirement.evidence_links + ): + return None + rule_ids = ", ".join(rule.rule_id for rule in requirement.business_rules) + return { + "code": "scenario-unverified", + "severity": severity, + "message": f"Imported scenarios have no test or validation evidence links: {rule_ids}.", + "location": f"requirements.inputs[{requirement.requirement_id}].business_rules", + } + + +def _source_integrity_violations( + requirement: RequirementInput, + *, + severity: str, + project_root: Path, +) -> list[dict[str, str]]: + violations: list[dict[str, str]] = [] + for source in requirement.sources: + if source.source_type not in {RequirementSourceType.OPENSPEC_CHANGE, RequirementSourceType.SPECKIT_SPEC}: + continue + source_path = Path(source.locator) + if not source_path.is_absolute(): + source_path = project_root / source_path + if not source_path.is_file(): + violations.append( + { + "code": "source-missing", + "severity": severity, + "message": "Imported source artifact no longer exists.", + "location": source.locator, + } + ) + continue + if source.revision and source.revision.startswith("sha256:"): + current_revision = f"sha256:{hashlib.sha256(source_path.read_bytes()).hexdigest()}" + if current_revision != source.revision: + violations.append( + { + "code": "stale-import", + "severity": severity, + "message": "Imported source artifact content changed after import.", + "location": source.locator, + } + ) + return violations + + +def _ambiguous_mapping_violations(identities: Mapping[str, set[str]], *, severity: str) -> list[dict[str, str]]: + return [ + { + "code": "ambiguous-mapping", + "severity": severity, + "message": "Multiple imported sources claim the same requirement identity.", + "location": f"requirements.inputs[{requirement_id}]", + } + for requirement_id, locators in identities.items() + if len(locators) > 1 + ] + + +def _import_gate_violations( + requirements: Sequence[RequirementInput], + *, + severity: str, + project_root: Path, +) -> list[dict[str, str]]: + violations: list[dict[str, str]] = [] + identities: dict[str, set[str]] = {} + for requirement in requirements: + if not _is_imported_requirement(requirement): + continue + identities.setdefault(requirement.requirement_id, set()).update( + source.locator for source in requirement.sources + ) + scenario_violation = _scenario_unverified_violation(requirement, severity=severity) + if scenario_violation: + violations.append(scenario_violation) + violations.extend( + _source_integrity_violations( + requirement, + severity=severity, + project_root=project_root, + ) + ) + violations.extend(_ambiguous_mapping_violations(identities, severity=severity)) + return violations + + +def _required_field_violations( + requirements: Sequence[RequirementInput], + required_fields: Sequence[str], + *, + severity: str, +) -> list[dict[str, str]]: + imported_requirements = [requirement for requirement in requirements if _is_imported_requirement(requirement)] + if not imported_requirements: + return [] + + violations: list[dict[str, str]] = [] + for field in required_fields: + attribute = _REQUIRED_FIELD_ALIASES.get(field) + if attribute is None: + violations.append( + { + "code": "unsupported-profile-field", + "severity": "info", + "message": "Profile field is not represented by the import-first requirements evidence schema.", + "location": f"requirements_schema.required_fields[{field}]", + } + ) + continue + for requirement in imported_requirements: + value = getattr(requirement, attribute) + if not value: + violations.append( + { + "code": "required-field-missing", + "severity": severity, + "message": f"Required evidence field '{field}' is missing.", + "location": f"requirements.inputs[{requirement.requirement_id}].{attribute}", + } + ) + return violations + + def _validation_status(failed: int, warnings: int) -> Literal["passed", "failed", "warnings"]: if failed: return "failed" @@ -307,21 +517,40 @@ def _validation_summary( @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") +@require( + _optional_profile_supported, "profile must be a supported requirements context validation profile when provided" +) +@require(_project_root_supported, "project_root must be a Path when provided") @ensure(lambda result: isinstance(result, ValidationReport)) def validate_requirement_context( bundle: ProjectBundle, *, - profile: RequirementContextValidationProfile = "startup", + profile: RequirementContextValidationProfile | None = None, + project_root: Path | None = None, ) -> ValidationReport: """Validate requirements context evidence usefulness for a ProjectBundle.""" requirements = load_requirements_from_bundle(bundle) if not requirements: return _empty_requirements_report() + effective_profile, resolved_config = _resolve_requirement_profile(profile, project_root) + severity = _missing_evidence_severity(cast(RequirementContextValidationProfile, effective_profile)) coverage = inspect_requirement_context_coverage(requirements) - violations = _missing_evidence_violations(coverage, severity=_missing_evidence_severity(profile)) + violations = _missing_evidence_violations(coverage, severity=severity) + violations.extend( + _import_gate_violations( + requirements, + severity=severity, + project_root=project_root or Path.cwd(), + ) + ) + violations.extend( + _required_field_violations( + requirements, + _requirements_schema_fields(resolved_config), + severity=severity, + ) + ) summary = _validation_summary(requirements, coverage, violations) return ValidationReport( status=_validation_status(summary["failed"], summary["warnings"]), diff --git a/src/specfact_cli/requirements/importers.py b/src/specfact_cli/requirements/importers.py new file mode 100644 index 00000000..6c46ede5 --- /dev/null +++ b/src/specfact_cli/requirements/importers.py @@ -0,0 +1,339 @@ +"""Read-only normalizers for native OpenSpec and Spec Kit requirement evidence.""" + +from __future__ import annotations + +import hashlib +import re +from pathlib import Path +from typing import cast + +import yaml +from beartype import beartype +from icontract import ensure, require + +from specfact_cli.adapters.openspec_parser import OpenSpecParser +from specfact_cli.importers.speckit_scanner import SpecKitScanner +from specfact_cli.models.requirements import ( + BusinessRule, + RequirementInput, + RequirementSourceReference, + RequirementSourceType, +) +from specfact_cli.requirements.context import ( + RequirementContextDiagnostic, + RequirementContextDiagnosticSeverity, + RequirementContextImportResult, +) + + +_DEFAULT_OPENSPEC_SCHEMA = "spec-driven" +_OPENSPEC_REQUIREMENT_PATTERN = re.compile(r"^### Requirement:\s+.+$", re.MULTILINE) +_OPENSPEC_SCENARIO_PATTERN = re.compile(r"^#### Scenario:\s+.+$", re.MULTILINE) +_SPECKIT_TITLE_PATTERN = re.compile(r"^# Feature Specification:\s+.+$", re.MULTILINE) +_SPECKIT_REQUIREMENT_PATTERN = re.compile(r"^\s*-?\s*\*\*FR-\d+\*\*:\s*System MUST\s+.+$", re.MULTILINE) +_SPECKIT_CUSTOMIZATION_ROOTS = ( + Path(".specify/templates/overrides"), + Path(".specify/presets"), + Path(".specify/extensions"), +) + + +def _slug(value: str) -> str: + return re.sub(r"-+", "-", re.sub(r"[^a-z0-9]+", "-", value.lower())).strip("-") or "requirement" + + +def _unique_requirement_id(base_id: str, seen_ids: set[str]) -> str: + candidate = base_id + ordinal = 2 + while candidate in seen_ids: + candidate = f"{base_id}-{ordinal}" + ordinal += 1 + seen_ids.add(candidate) + return candidate + + +def _source_revision(path: Path) -> str: + return f"sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}" + + +def _scenario_rules(requirement_id: str, content: str) -> list[BusinessRule]: + rules: list[BusinessRule] = [] + scenario_pattern = re.compile( + r"^#### Scenario:\s*(.+?)\n(.*?)(?=^#### Scenario:|^### Requirement:|\Z)", + re.MULTILINE | re.DOTALL, + ) + for match in scenario_pattern.finditer(content): + name = match.group(1).strip() + body = match.group(2) + clauses: dict[str, str] = {} + for clause in ("given", "when", "then"): + clause_match = re.search( + rf"^- \*\*{clause.upper()}\*\*\s+([^\n]+(?:\n[ \t]+[^\n]+)*)", + body, + re.MULTILINE | re.IGNORECASE, + ) + if clause_match: + clauses[clause] = " ".join(clause_match.group(1).split()) + if len(clauses) == 3: + rules.append( + BusinessRule( + rule_id=f"{requirement_id}:{_slug(name)}", + name=name, + given=clauses["given"], + when=clauses["when"], + then=clauses["then"], + ) + ) + return rules + + +def _speckit_rules(requirement_id: str, content: str) -> list[BusinessRule]: + rules: list[BusinessRule] = [] + pattern = re.compile( + r"\*\*Given\*\*\s+(.+?),\s*\*\*When\*\*\s+(.+?),\s*\*\*Then\*\*\s+(.+?)(?=\n|$)", + re.IGNORECASE, + ) + for index, match in enumerate(pattern.finditer(content), start=1): + rules.append( + BusinessRule( + rule_id=f"{requirement_id}:scenario-{index}", + name=f"Acceptance scenario {index}", + given=match.group(1).strip(), + when=match.group(2).strip(), + then=match.group(3).strip(), + ) + ) + return rules + + +def _missing_source_result(source: Path, source_type: RequirementSourceType) -> RequirementContextImportResult: + return RequirementContextImportResult( + diagnostics=[ + RequirementContextDiagnostic( + severity=RequirementContextDiagnosticSeverity.ERROR, + code="source-missing", + message=f"Required {source_type.value} source does not exist.", + source_locator=str(source), + ) + ] + ) + + +def _unsupported_source_result( + source: Path, + source_type: RequirementSourceType, + message: str, +) -> RequirementContextImportResult: + return RequirementContextImportResult( + diagnostics=[ + RequirementContextDiagnostic( + severity=RequirementContextDiagnosticSeverity.ERROR, + code="unsupported-source-schema", + message=message, + source_locator=str(source), + ) + ] + ) + + +def _load_schema_name(path: Path) -> str | None: + try: + if not path.is_file(): + return None + data = yaml.safe_load(path.read_text(encoding="utf-8")) or {} + except (OSError, UnicodeDecodeError, yaml.YAMLError): + return "" + if not isinstance(data, dict): + return "" + schema = cast(dict[str, object], data).get("schema") + return "" if "schema" in data and not isinstance(schema, str) else cast(str | None, schema) + + +def _openspec_compatibility_error(change_dir: Path, spec_paths: list[Path]) -> RequirementContextImportResult | None: + config_paths = (change_dir.parent.parent / "config.yaml", change_dir / ".openspec.yaml") + for config_path in config_paths: + schema = _load_schema_name(config_path) + if schema is not None and schema != _DEFAULT_OPENSPEC_SCHEMA: + return _unsupported_source_result( + config_path, + RequirementSourceType.OPENSPEC_CHANGE, + "OpenSpec schema is not supported by the default evidence import profile.", + ) + if not spec_paths: + return _unsupported_source_result( + change_dir, + RequirementSourceType.OPENSPEC_CHANGE, + "OpenSpec change has no default-profile spec.md artifact to import.", + ) + for spec_path in spec_paths: + content = spec_path.read_text(encoding="utf-8") + if not (_OPENSPEC_REQUIREMENT_PATTERN.search(content) and _OPENSPEC_SCENARIO_PATTERN.search(content)): + return _unsupported_source_result( + spec_path, + RequirementSourceType.OPENSPEC_CHANGE, + "OpenSpec artifact does not match the supported default requirement/scenario Markdown profile.", + ) + return None + + +def _speckit_project_root(feature_dir: Path) -> Path | None: + for directory in (feature_dir.parent, *feature_dir.parents): + if (directory / ".specify").is_dir(): + return directory + return None + + +def _speckit_compatibility_error( + feature_dir: Path, spec_path: Path, content: str +) -> RequirementContextImportResult | None: + project_root = _speckit_project_root(feature_dir) + if project_root: + for customization_root in _SPECKIT_CUSTOMIZATION_ROOTS: + path = project_root / customization_root + if path.exists(): + return _unsupported_source_result( + path, + RequirementSourceType.SPECKIT_SPEC, + "Spec Kit template customization is not supported by the default evidence import profile.", + ) + if not (_SPECKIT_TITLE_PATTERN.search(content) and _SPECKIT_REQUIREMENT_PATTERN.search(content)): + return _unsupported_source_result( + spec_path, + RequirementSourceType.SPECKIT_SPEC, + "Spec Kit artifact does not match the supported default Markdown template profile.", + ) + return None + + +def _import_result_has_requirements(result: RequirementContextImportResult) -> bool: + return all(isinstance(record, RequirementInput) for record in result.requirements) + + +@require(lambda change_dir: isinstance(change_dir, Path), "change_dir must be a Path") +@ensure(_import_result_has_requirements) +@beartype +def import_openspec_change(change_dir: Path) -> RequirementContextImportResult: + """Normalize one native OpenSpec change directory without modifying it.""" + if not change_dir.is_dir(): + return _missing_source_result(change_dir, RequirementSourceType.OPENSPEC_CHANGE) + + spec_paths = sorted((change_dir / "specs").glob("*/spec.md")) + compatibility_error = _openspec_compatibility_error(change_dir, spec_paths) + if compatibility_error: + return compatibility_error + + parser = OpenSpecParser() + requirements: list[RequirementInput] = [] + diagnostics: list[RequirementContextDiagnostic] = [] + seen_requirement_ids: set[str] = set() + for spec_path in spec_paths: + parsed = parser.parse_change_spec_delta(spec_path) + if parsed is None: + diagnostics.append( + RequirementContextDiagnostic( + severity=RequirementContextDiagnosticSeverity.ERROR, + code="source-missing", + message="OpenSpec delta specification could not be read.", + source_locator=str(spec_path), + ) + ) + continue + content = str(parsed.get("raw_content", "")) + capability = spec_path.parent.name + source = RequirementSourceReference( + source_type=RequirementSourceType.OPENSPEC_CHANGE, + locator=str(spec_path), + title=capability, + revision=_source_revision(spec_path), + ) + for match in re.finditer( + r"^### Requirement:\s*(.+?)\n(.*?)(?=^### Requirement:|\Z)", content, re.MULTILINE | re.DOTALL + ): + title = match.group(1).strip() + requirement_id = _unique_requirement_id( + f"openspec:{change_dir.name}:{capability}:{_slug(title)}", + seen_requirement_ids, + ) + body = match.group(2).strip() + summary = body.split("#### Scenario:", maxsplit=1)[0].strip() or None + requirements.append( + RequirementInput( + schema_version="1", + requirement_id=requirement_id, + title=title, + summary=summary, + sources=[source], + business_rules=_scenario_rules(requirement_id, body), + ) + ) + return RequirementContextImportResult(requirements=requirements, diagnostics=diagnostics) + + +@require(lambda feature_dir: isinstance(feature_dir, Path), "feature_dir must be a Path") +@ensure(_import_result_has_requirements) +@beartype +def import_speckit_feature(feature_dir: Path) -> RequirementContextImportResult: + """Normalize one native Spec Kit feature directory without modifying it.""" + spec_path = feature_dir / "spec.md" + if not feature_dir.is_dir() or not spec_path.is_file(): + return _missing_source_result(feature_dir, RequirementSourceType.SPECKIT_SPEC) + + content = spec_path.read_text(encoding="utf-8") + compatibility_error = _speckit_compatibility_error(feature_dir, spec_path, content) + if compatibility_error: + return compatibility_error + + parsed = SpecKitScanner(feature_dir.parent).parse_spec_markdown(spec_path) + if parsed is None: + return _missing_source_result(spec_path, RequirementSourceType.SPECKIT_SPEC) + + source = RequirementSourceReference( + source_type=RequirementSourceType.SPECKIT_SPEC, + locator=str(spec_path), + title=str(parsed.get("feature_title") or feature_dir.name), + revision=_source_revision(spec_path), + ) + requirements: list[RequirementInput] = [] + seen_requirement_ids: set[str] = set() + raw_requirements = cast(list[object], parsed.get("requirements", [])) + diagnostics: list[RequirementContextDiagnostic] = [] + for index, raw_requirement in enumerate(raw_requirements): + if not isinstance(raw_requirement, dict): + diagnostics.append( + RequirementContextDiagnostic( + severity=RequirementContextDiagnosticSeverity.WARNING, + code="source-missing", + message="Spec Kit requirement entry is not a mapping.", + source_locator=str(spec_path), + record_index=index, + ) + ) + continue + requirement_values = cast(dict[str, object], raw_requirement) + summary = str(requirement_values.get("text", "")).strip() + if not summary: + diagnostics.append( + RequirementContextDiagnostic( + severity=RequirementContextDiagnosticSeverity.WARNING, + code="source-missing", + message="Spec Kit requirement entry has no text.", + source_locator=str(spec_path), + record_index=index, + ) + ) + continue + requirement_id = _unique_requirement_id( + f"speckit:{feature_dir.name}:{_slug(summary)}", + seen_requirement_ids, + ) + requirements.append( + RequirementInput( + schema_version="1", + requirement_id=requirement_id, + title=summary[:1].upper() + summary[1:], + summary=summary, + sources=[source], + business_rules=_speckit_rules(requirement_id, content), + ) + ) + return RequirementContextImportResult(requirements=requirements, diagnostics=diagnostics) diff --git a/tests/unit/requirements/test_upstream_evidence_imports.py b/tests/unit/requirements/test_upstream_evidence_imports.py new file mode 100644 index 00000000..76641e90 --- /dev/null +++ b/tests/unit/requirements/test_upstream_evidence_imports.py @@ -0,0 +1,493 @@ +"""Tests for native OpenSpec and Spec Kit requirement evidence imports.""" + +from __future__ import annotations + +import hashlib +from pathlib import Path + +import pytest + +from specfact_cli.importers.speckit_scanner import SpecKitScanner +from specfact_cli.models.plan import Product +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.models.requirements import ( + BusinessRule, + RequirementEvidenceLink, + RequirementEvidenceLinkType, + RequirementInput, + RequirementSourceReference, + RequirementSourceType, +) +from specfact_cli.requirements import ( + attach_requirements_to_bundle, + import_openspec_change, + import_speckit_feature, + validate_requirement_context, +) + + +def _bundle() -> ProjectBundle: + """Return a minimal bundle that can carry requirements evidence.""" + return ProjectBundle( + manifest=BundleManifest( + versions=BundleVersions(schema="1.0", project="0.1.0"), + schema_metadata=None, + project_metadata=None, + ), + bundle_name="test", + product=Product(themes=[], releases=[]), + ) + + +def _write_openspec_change(change_dir: Path) -> Path: + """Create one native OpenSpec change fixture.""" + spec_file = change_dir / "specs" / "widgets" / "spec.md" + spec_file.parent.mkdir(parents=True) + (change_dir / "proposal.md").write_text("# Change: Widget evidence\n", encoding="utf-8") + (change_dir / "tasks.md").write_text("# Tasks: Widget evidence\n", encoding="utf-8") + spec_file.write_text( + """## ADDED Requirements + +### Requirement: Widget rendering + +The system SHALL render a widget. + +#### Scenario: Render a valid widget + +- **GIVEN** a valid widget request +- **WHEN** rendering runs +- **THEN** the widget is returned +""", + encoding="utf-8", + ) + return spec_file + + +def _write_speckit_feature(feature_dir: Path) -> Path: + """Create one native Spec Kit feature fixture.""" + spec_file = feature_dir / "spec.md" + feature_dir.mkdir(parents=True) + spec_file.write_text( + """# Feature Specification: Widget rendering + +## User Scenarios & Testing + +### User Story 1 - Render widgets (Priority: P1) + +As a user, I want widgets rendered so that I can see them. + +**Acceptance Scenarios**: + +1. **Given** a valid widget request, **When** rendering runs, **Then** the widget is returned + +## Requirements + +- **FR-001**: System MUST render a widget +""", + encoding="utf-8", + ) + return spec_file + + +def test_import_openspec_change_normalizes_scenarios_hashes_and_preserves_source(tmp_path: Path) -> None: + """OpenSpec imports derive stable requirements without changing source artifacts.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + spec_file = _write_openspec_change(change_dir) + before = spec_file.read_bytes() + + result = import_openspec_change(change_dir) + + assert result.diagnostics == [] + assert [record.requirement_id for record in result.requirements] == [ + "openspec:widget-evidence:widgets:widget-rendering" + ] + requirement = result.requirements[0] + assert requirement.sources[0].source_type == RequirementSourceType.OPENSPEC_CHANGE + assert requirement.sources[0].locator == str(spec_file) + assert requirement.sources[0].revision is not None + assert requirement.sources[0].revision.startswith("sha256:") + assert requirement.business_rules[0].model_dump() == { + "rule_id": "openspec:widget-evidence:widgets:widget-rendering:render-a-valid-widget", + "name": "Render a valid widget", + "given": "a valid widget request", + "when": "rendering runs", + "then": "the widget is returned", + "priority": None, + } + assert import_openspec_change(change_dir).model_dump() == result.model_dump() + assert spec_file.read_bytes() == before + + +def test_import_openspec_change_preserves_wrapped_scenario_clauses(tmp_path: Path) -> None: + """Indented Markdown continuation lines remain part of the scenario clause.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + spec_file = _write_openspec_change(change_dir) + spec_file.write_text( + spec_file.read_text(encoding="utf-8").replace( + "a valid widget request", "a valid widget request\n with an active account" + ), + encoding="utf-8", + ) + + result = import_openspec_change(change_dir) + + assert result.requirements[0].business_rules[0].given == "a valid widget request with an active account" + + +def test_import_speckit_feature_normalizes_requirement_and_scenario(tmp_path: Path) -> None: + """Spec Kit imports use the existing scanner while preserving G/W/T evidence.""" + feature_dir = tmp_path / "specs" / "001-widget-rendering" + spec_file = _write_speckit_feature(feature_dir) + + result = import_speckit_feature(feature_dir) + + assert result.diagnostics == [] + assert [record.requirement_id for record in result.requirements] == ["speckit:001-widget-rendering:render-a-widget"] + requirement = result.requirements[0] + assert requirement.sources[0].source_type == RequirementSourceType.SPECKIT_SPEC + assert requirement.sources[0].locator == str(spec_file) + assert requirement.sources[0].revision is not None + assert requirement.sources[0].revision.startswith("sha256:") + assert requirement.business_rules[0].given == "a valid widget request" + assert requirement.business_rules[0].when == "rendering runs" + assert requirement.business_rules[0].then == "the widget is returned" + + +def test_import_speckit_feature_reports_malformed_requirement_entries( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Malformed scanner entries remain auditable without dropping valid requirements.""" + feature_dir = tmp_path / "specs" / "001-widget-rendering" + spec_file = _write_speckit_feature(feature_dir) + monkeypatch.setattr( + SpecKitScanner, + "parse_spec_markdown", + lambda _self, _path: { + "feature_title": "Widget rendering", + "requirements": ["not-a-mapping", {"text": ""}, {"text": "System MUST render a widget"}], + }, + ) + + result = import_speckit_feature(feature_dir) + + assert [requirement.requirement_id for requirement in result.requirements] == [ + "speckit:001-widget-rendering:system-must-render-a-widget" + ] + assert [(diagnostic.code, diagnostic.severity, diagnostic.record_index) for diagnostic in result.diagnostics] == [ + ("source-missing", "warning", 0), + ("source-missing", "warning", 1), + ] + assert {diagnostic.source_locator for diagnostic in result.diagnostics} == {str(spec_file)} + + +def test_import_openspec_change_rejects_custom_schema_without_partial_records(tmp_path: Path) -> None: + """Custom OpenSpec schemas fail closed until a core profile explicitly supports them.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + _write_openspec_change(change_dir) + (change_dir.parent.parent / "config.yaml").write_text("schema: company-custom\n", encoding="utf-8") + + result = import_openspec_change(change_dir) + + assert result.requirements == [] + assert [(diagnostic.code, diagnostic.severity) for diagnostic in result.diagnostics] == [ + ("unsupported-source-schema", "error") + ] + + +def test_import_openspec_change_rejects_change_local_custom_schema_without_partial_records(tmp_path: Path) -> None: + """A change-local OpenSpec schema declaration is as authoritative as project configuration.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + _write_openspec_change(change_dir) + (change_dir / ".openspec.yaml").write_text("schema: company-custom\n", encoding="utf-8") + + result = import_openspec_change(change_dir) + + assert result.requirements == [] + assert result.diagnostics[0].code == "unsupported-source-schema" + + +@pytest.mark.parametrize("schema_value", ["123", "true", "null", "{profile: default}"]) +def test_import_openspec_change_rejects_non_string_schema_without_partial_records( + tmp_path: Path, + schema_value: str, +) -> None: + """Malformed schema declarations cannot fall through to the default profile.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + _write_openspec_change(change_dir) + (change_dir.parent.parent / "config.yaml").write_text(f"schema: {schema_value}\n", encoding="utf-8") + + result = import_openspec_change(change_dir) + + assert result.requirements == [] + assert result.diagnostics[0].code == "unsupported-source-schema" + + +def test_import_openspec_change_rejects_invalid_utf8_schema_without_partial_records(tmp_path: Path) -> None: + """Unreadable schema configuration fails closed instead of crashing the import.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + _write_openspec_change(change_dir) + (change_dir.parent.parent / "config.yaml").write_bytes(b"schema: \xff\n") + + result = import_openspec_change(change_dir) + + assert result.requirements == [] + assert result.diagnostics[0].code == "unsupported-source-schema" + + +@pytest.mark.parametrize( + "customization_root", + [ + Path(".specify/templates/overrides"), + Path(".specify/presets"), + Path(".specify/extensions"), + ], +) +def test_import_speckit_feature_rejects_template_customization_without_partial_records( + tmp_path: Path, + customization_root: Path, +) -> None: + """Spec Kit override roots fail closed rather than being parsed with default assumptions.""" + feature_dir = tmp_path / "specs" / "001-widget-rendering" + _write_speckit_feature(feature_dir) + (tmp_path / customization_root).mkdir(parents=True) + + result = import_speckit_feature(feature_dir) + + assert result.requirements == [] + assert [(diagnostic.code, diagnostic.severity) for diagnostic in result.diagnostics] == [ + ("unsupported-source-schema", "error") + ] + + +def test_imports_reject_unknown_default_profile_markers_without_partial_records(tmp_path: Path) -> None: + """Unrecognized Markdown markers cannot silently produce an empty or partial import.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + open_spec_file = _write_openspec_change(change_dir) + open_spec_file.write_text("## ADDED Functional Requirements\n", encoding="utf-8") + feature_dir = tmp_path / "specs" / "001-widget-rendering" + speckit_file = _write_speckit_feature(feature_dir) + speckit_file.write_text("# Product Requirements\n\n- Render a widget\n", encoding="utf-8") + + openspec_result = import_openspec_change(change_dir) + speckit_result = import_speckit_feature(feature_dir) + + assert openspec_result.requirements == [] + assert speckit_result.requirements == [] + assert openspec_result.diagnostics[0].code == "unsupported-source-schema" + assert speckit_result.diagnostics[0].code == "unsupported-source-schema" + + +def test_importers_disambiguate_duplicate_derived_requirement_ids(tmp_path: Path) -> None: + """Repeated upstream titles and summaries retain distinct deterministic identities.""" + change_dir = tmp_path / "openspec" / "changes" / "widget-evidence" + openspec_spec = _write_openspec_change(change_dir) + openspec_spec.write_text( + openspec_spec.read_text(encoding="utf-8") + + """ + +### Requirement: Widget rendering + +The system SHALL render a fallback widget. + +#### Scenario: Render a fallback widget + +- **GIVEN** a fallback widget request +- **WHEN** rendering runs +- **THEN** the fallback widget is returned +""", + encoding="utf-8", + ) + feature_dir = tmp_path / "specs" / "001-widget-rendering" + speckit_spec = _write_speckit_feature(feature_dir) + speckit_spec.write_text( + speckit_spec.read_text(encoding="utf-8") + "\n- **FR-002**: System MUST render a widget\n", + encoding="utf-8", + ) + + openspec_result = import_openspec_change(change_dir) + speckit_result = import_speckit_feature(feature_dir) + + assert [record.requirement_id for record in openspec_result.requirements] == [ + "openspec:widget-evidence:widgets:widget-rendering", + "openspec:widget-evidence:widgets:widget-rendering-2", + ] + assert [record.requirement_id for record in speckit_result.requirements] == [ + "speckit:001-widget-rendering:render-a-widget", + "speckit:001-widget-rendering:render-a-widget-2", + ] + + +def test_validation_reports_import_gates_and_profile_required_field_advisories(tmp_path: Path) -> None: + """Validation distinguishes import failures from unsupported profile metadata.""" + source_file = tmp_path / "missing-after-import.md" + requirement = _imported_requirement( + source_file, + revision="sha256:0123456789abcdef", + with_test_link=True, + ) + bundle = attach_requirements_to_bundle(_bundle(), [requirement]) + + report = validate_requirement_context(bundle, profile="enterprise", project_root=tmp_path) + + assert report.status == "failed" + assert {violation["code"] for violation in report.violations} >= { + "source-missing", + "unsupported-profile-field", + } + assert all( + violation["code"] != "required-field-missing" + or violation["location"] != f"requirements.inputs[{requirement.requirement_id}].owner" + for violation in report.violations + ) + + +def _imported_requirement(source_file: Path, *, revision: str, with_test_link: bool) -> RequirementInput: + """Return an imported requirement fixture with one G/W/T scenario.""" + evidence_links = ( + [ + RequirementEvidenceLink( + link_type=RequirementEvidenceLinkType.TEST, + target="tests/unit/requirements/test_upstream_evidence_imports.py", + ) + ] + if with_test_link + else [] + ) + return RequirementInput( + schema_version="1", + requirement_id="openspec:widget-evidence:widgets:widget-rendering", + title="Widget rendering", + sources=[ + RequirementSourceReference( + source_type=RequirementSourceType.OPENSPEC_CHANGE, + locator=str(source_file), + revision=revision, + ) + ], + business_rules=[ + BusinessRule( + rule_id="rule-1", + name="Render a valid widget", + given="a valid widget request", + when="rendering runs", + then="the widget is returned", + ) + ], + evidence_links=evidence_links, + ) + + +def test_validation_reports_scenario_stale_and_ambiguous_import_gates(tmp_path: Path) -> None: + """Every deterministic import gate is surfaced without heuristic matching.""" + source_a = tmp_path / "source-a.md" + source_b = tmp_path / "source-b.md" + source_a.write_text("source A", encoding="utf-8") + source_b.write_text("source B", encoding="utf-8") + requirements = [ + _imported_requirement(source_a, revision="sha256:outdated", with_test_link=False), + _imported_requirement(source_b, revision="sha256:outdated", with_test_link=True), + ] + bundle = attach_requirements_to_bundle(_bundle(), requirements) + + report = validate_requirement_context(bundle, profile="solo", project_root=tmp_path) + + assert {violation["code"] for violation in report.violations} >= { + "scenario-unverified", + "stale-import", + "ambiguous-mapping", + } + + +def test_validation_resolves_relative_source_locator_from_project_root(tmp_path: Path) -> None: + """Validation uses project_root rather than the process working directory for relative locators.""" + source_file = tmp_path / "source.md" + source_file.write_text("source", encoding="utf-8") + requirement = _imported_requirement( + source_file, + revision=f"sha256:{hashlib.sha256(source_file.read_bytes()).hexdigest()}", + with_test_link=True, + ).model_copy( + update={ + "sources": [ + RequirementSourceReference( + source_type=RequirementSourceType.OPENSPEC_CHANGE, + locator="source.md", + revision=f"sha256:{hashlib.sha256(source_file.read_bytes()).hexdigest()}", + ) + ] + } + ) + + report = validate_requirement_context( + attach_requirements_to_bundle(_bundle(), [requirement]), + profile="solo", + project_root=tmp_path, + ) + + assert report.status == "passed" + + +def test_validation_ignores_malformed_optional_config(tmp_path: Path) -> None: + """Unreadable optional configuration cannot prevent requirements validation.""" + source_file = tmp_path / "source.md" + source_file.write_text("source", encoding="utf-8") + config_dir = tmp_path / ".specfact" + config_dir.mkdir() + (config_dir / "config.yaml").write_text("profile: [\n", encoding="utf-8") + requirement = _imported_requirement( + source_file, + revision=f"sha256:{hashlib.sha256(source_file.read_bytes()).hexdigest()}", + with_test_link=True, + ) + + report = validate_requirement_context( + attach_requirements_to_bundle(_bundle(), [requirement]), + profile="solo", + project_root=tmp_path, + ) + + assert report.status == "passed" + + +def test_validation_ignores_invalid_utf8_optional_config(tmp_path: Path) -> None: + """Binary optional configuration cannot prevent requirements validation.""" + source_file = tmp_path / "source.md" + source_file.write_text("source", encoding="utf-8") + config_dir = tmp_path / ".specfact" + config_dir.mkdir() + (config_dir / "config.yaml").write_bytes(b"profile: \xff\n") + requirement = _imported_requirement( + source_file, + revision=f"sha256:{hashlib.sha256(source_file.read_bytes()).hexdigest()}", + with_test_link=True, + ) + + report = validate_requirement_context( + attach_requirements_to_bundle(_bundle(), [requirement]), project_root=tmp_path + ) + + assert report.status == "passed" + + +def test_omitted_profile_uses_layered_configuration_and_explicit_profile_wins(tmp_path: Path) -> None: + """Profile resolution changes severity only when callers do not specify one.""" + source_file = tmp_path / "source.md" + source_file.write_text("source", encoding="utf-8") + config_dir = tmp_path / ".specfact" + config_dir.mkdir() + (config_dir / "config.yaml").write_text( + "profile: enterprise\nrequirements_schema:\n required_fields: [owner]\n", + encoding="utf-8", + ) + bundle = attach_requirements_to_bundle( + _bundle(), + [_imported_requirement(source_file, revision="sha256:outdated", with_test_link=False)], + ) + + configured_report = validate_requirement_context(bundle, project_root=tmp_path) + explicit_report = validate_requirement_context(bundle, profile="solo", project_root=tmp_path) + + assert configured_report.status == "failed" + assert explicit_report.status == "warnings" + assert not any(violation["code"] == "required-field-missing" for violation in explicit_report.violations)