diff --git a/CHANGELOG.md b/CHANGELOG.md index 12dd5572..f720dfaf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,28 @@ All notable changes to this project will be documented in this file. --- +## [0.51.1] - 2026-07-10 + +### Fixed + +- **Requirements traceability drift**: do not classify linked requirements as + stale when callers have not supplied the target universe; stale-link drift + remains enabled when `known_targets` is provided. + +--- + +## [0.51.0] - 2026-07-09 + +### Added + +- **Core evidence and traceability contracts**: add a typed evidence envelope + with deterministic CI verdict derivation plus a generic artifact index, + stable links/fingerprints, incremental rebuild facts, and deterministic + orphan/drift/ambiguity/contradiction classification. Requirements is the + first integrated adapter; runtime persistence and commands remain deferred. + +--- + ## [0.50.2] - 2026-07-08 ### Fixed diff --git a/docs/reference/README.md b/docs/reference/README.md index eaffd95f..c587b8d9 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -26,6 +26,7 @@ For bundle-specific deep command guides and runbooks, use the canonical modules - **[Bridge Registry](bridge-registry.md)** - Registry-facing bridge converter declarations - **[Requirements Evidence Input Model](requirements-evidence-input-model.md)** - Requirement input records used by validation evidence - **[Requirements Context Adapter](requirements-context-adapter.md)** - Import, validation, and coverage helpers for requirement context evidence +- **[Validation Evidence Contracts](validation-evidence-contracts.md)** - Core envelope and traceability result contracts - **[Directory Structure](directory-structure.md)** - Project structure and organization - **[Feature Keys](feature-keys.md)** - Key normalization and formats - **[Dependency Resolution](dependency-resolution.md)** - Module/pip dependency resolution behavior diff --git a/docs/reference/requirements-context-adapter.md b/docs/reference/requirements-context-adapter.md index e0e23495..14713396 100644 --- a/docs/reference/requirements-context-adapter.md +++ b/docs/reference/requirements-context-adapter.md @@ -34,6 +34,9 @@ requirements authoring workflow. usefulness by profile. - `inspect_requirement_context_coverage(...)` returns machine-readable coverage counts for downstream command handlers. +- `analyze_requirement_traceability(...)` reads `requirements.inputs`; when + callers supply `known_targets`, it also returns deterministic stale-link drift + findings for evidence consumers. ```python from specfact_cli.models.requirements import RequirementInput, RequirementSourceReference @@ -73,7 +76,9 @@ payloads directly inside root CLI code. reference. - Invalid imported records produce bounded diagnostics; valid records remain usable. -- Enterprise or strict validation treats missing downstream evidence links as - errors. Less strict profiles receive warnings. +- Enterprise, strict, and enterprise_full_stack validation treat missing + downstream evidence links as errors. Less strict profiles receive warnings. - Backlog write-back and interactive requirement authoring remain outside this core surface. +- Evidence files, CI flags, terminal rendering, and query commands are owned by + paired module runtimes rather than core. diff --git a/docs/reference/validation-evidence-contracts.md b/docs/reference/validation-evidence-contracts.md new file mode 100644 index 00000000..0791be9f --- /dev/null +++ b/docs/reference/validation-evidence-contracts.md @@ -0,0 +1,38 @@ +--- +layout: default +title: Validation Evidence Contracts +permalink: /reference/validation-evidence-contracts/ +description: Core contracts for deterministic validation evidence and requirements-first traceability. +keywords: [validation, evidence, traceability, requirements] +audience: [team, enterprise] +expertise_level: [advanced] +doc_owner: specfact-cli +tracks: + - src/specfact_cli/evidence.py + - src/specfact_cli/traceability.py +last_reviewed: 2026-07-09 +exempt: false +exempt_reason: "" +--- + +# Validation Evidence Contracts + +Core exposes typed contracts only. Module runtimes own command flags, evidence +file persistence, and rendering. + +- `EvidenceEnvelope` derives `PASS`, `PASS_WITH_ADVISORY`, or `FAIL` and its CI + exit code from typed result summaries. +- `ArtifactRecord`, `ArtifactLink`, and `build_artifact_index(...)` provide a + generic, deterministic, JSON-serializable index for normalized inputs from + requirements, architecture, specifications, code, tests, contracts, and other + adapters. +- The index classifies unlinked artifacts, dangling links, duplicate identities, + and self-referential contradictions; rebuild results report changed and + removed identities. +- `requirements_to_artifact_records(...)` is the first integrated adapter. + Architecture and other inputs are optional; their absence does not create a + finding. + +Use these contracts as inputs to validation and governance modules; they do not +introduce a `specfact trace` command, index-file persistence, or requirements +authoring workflow. diff --git a/openspec/CHANGE_ORDER.md b/openspec/CHANGE_ORDER.md index b9226d1d..2906cf38 100644 --- a/openspec/CHANGE_ORDER.md +++ b/openspec/CHANGE_ORDER.md @@ -51,12 +51,13 @@ into auditable validation evidence and cleanup feedback. | 1 | `profile-01-config-layering` | [#237](https://github.com/nold-ai/specfact-cli/issues/237) | Rollout modes for validation severity and evidence strictness | - | | 2 | `governance-01-evidence-output` | [#247](https://github.com/nold-ai/specfact-cli/issues/247) | Evidence JSON, CI verdicts, remediation packet attachment points | modules `policy-02` | | 3 | `governance-02-exception-management` | [#248](https://github.com/nold-ai/specfact-cli/issues/248) | Time-bound validation exceptions and waiver evidence | governance-01; modules `policy-02` | -| 4 | `traceability-01-index-and-orphans` | [#242](https://github.com/nold-ai/specfact-cli/issues/242) | Artifact drift and orphan detection across inputs | requirements/architecture adapter contracts where present | +| 4 | `traceability-01-index-and-orphans` | [#242](https://github.com/nold-ai/specfact-cli/issues/242) | Generic artifact index and orphan/drift classification across normalized inputs | requirements input contracts; other adapters optional when present | | 5 | `validation-02-full-chain-engine` | [#241](https://github.com/nold-ai/specfact-cli/issues/241) | Validation evidence graph over existing inputs, not a product lifecycle engine | governance-01, traceability-01 | | 6 | `dogfooding-01-full-chain-e2e-proof` | [#255](https://github.com/nold-ai/specfact-cli/issues/255) | AI-bloat defense and validation proof on real PRs | governance-01, validation-02, traceability-01 | -**Critical path**: profile-01 plus governance-01 -> traceability-01 -> -validation-02 -> dogfooding proof. +**Critical path**: profile-01, governance-01, and traceability-01 converge at +validation-02 -> dogfooding proof. Traceability requires requirements inputs; +governance and traceability do not block each other. ### Track B - AI IDE Validation Distribution diff --git a/openspec/changes/requirements-02-module-commands/.openspec.yaml b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/.openspec.yaml similarity index 100% rename from openspec/changes/requirements-02-module-commands/.openspec.yaml rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/.openspec.yaml diff --git a/openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/CHANGE_VALIDATION.md similarity index 100% rename from openspec/changes/requirements-02-module-commands/CHANGE_VALIDATION.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/CHANGE_VALIDATION.md diff --git a/openspec/changes/requirements-02-module-commands/TDD_EVIDENCE.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/TDD_EVIDENCE.md similarity index 100% rename from openspec/changes/requirements-02-module-commands/TDD_EVIDENCE.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/TDD_EVIDENCE.md diff --git a/openspec/changes/requirements-02-module-commands/design.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/design.md similarity index 100% rename from openspec/changes/requirements-02-module-commands/design.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/design.md diff --git a/openspec/changes/requirements-02-module-commands/proposal.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/proposal.md similarity index 100% rename from openspec/changes/requirements-02-module-commands/proposal.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/proposal.md diff --git a/openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/backlog-adapter/spec.md similarity index 90% rename from openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/backlog-adapter/spec.md index 65300dd7..7aacf1e4 100644 --- a/openspec/changes/requirements-02-module-commands/specs/backlog-adapter/spec.md +++ b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/backlog-adapter/spec.md @@ -1,6 +1,6 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: Backlog Adapter +### Requirement: Source-Attributed Backlog Requirement Snippets The system SHALL define source-attributed backlog requirement snippets that requirements runtime adapters can normalize without provider-specific parsing in diff --git a/openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/module-io-contract/spec.md similarity index 96% rename from openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/module-io-contract/spec.md index 44c8bf64..f7922aad 100644 --- a/openspec/changes/requirements-02-module-commands/specs/module-io-contract/spec.md +++ b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/module-io-contract/spec.md @@ -1,6 +1,6 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: Module Io Contract +### Requirement: Requirements Module IO Contract Requirements implementations SHALL consume core requirements context adapter helpers through the existing `ModuleIOContract` boundary. diff --git a/openspec/changes/requirements-02-module-commands/specs/requirements-module/spec.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/requirements-module/spec.md similarity index 100% rename from openspec/changes/requirements-02-module-commands/specs/requirements-module/spec.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/specs/requirements-module/spec.md diff --git a/openspec/changes/requirements-02-module-commands/tasks.md b/openspec/changes/archive/2026-07-09-requirements-02-module-commands/tasks.md similarity index 100% rename from openspec/changes/requirements-02-module-commands/tasks.md rename to openspec/changes/archive/2026-07-09-requirements-02-module-commands/tasks.md diff --git a/openspec/changes/governance-01-evidence-output/proposal.md b/openspec/changes/governance-01-evidence-output/proposal.md index 9880cc0b..d5bfe846 100644 --- a/openspec/changes/governance-01-evidence-output/proposal.md +++ b/openspec/changes/governance-01-evidence-output/proposal.md @@ -17,9 +17,19 @@ validation results consumable by any deterministic gate or agent handoff. - Target modules-repo follow-up issue: [#169](https://github.com/nold-ai/specfact-cli-modules/issues/169) in `nold-ai/specfact-cli-modules` - Downstream changes may extend the envelope, but they MUST NOT redefine the schema or imply core ownership of bundle runtime behavior. +## Core Slice (2026-07-09) + +- This change delivers the typed envelope, result semantics, and deterministic CI + verdict derivation only. +- Runtime flags, file persistence, terminal rendering, and emitters remain + deferred to modules issue #169. +- `validation-02` is a downstream producer of envelope sections, not a + prerequisite for this core contract. + ## What Changes -- **NEW**: Evidence writer producing standardized JSON artifacts: +- **NEW**: Typed evidence-envelope schema and field semantics for the + standardized JSON shape consumed by runtime emitters: ```json { @@ -51,26 +61,23 @@ validation results consumable by any deterministic gate or agent handoff. } ``` -- **NEW**: `--evidence-dir .specfact/evidence/` flag on validation and code-review runs to persist evidence artifacts per run -- **NEW**: `--ci-mode` flag that sets exit codes based on profile enforcement mode: advisory=always 0, mixed=1 for hard-fail rules only, hard=1 for any failure -- **NEW**: Evidence artifact naming: `{timestamp}_{run_id}_evidence.json` for audit trail -- **NEW**: Evidence summary on terminal: human-readable table alongside JSON output -- **EXTEND**: Validation evidence graph (validation-02) extended to produce evidence artifacts -- **EXTEND**: Validation evidence can append `code_quality` as a parallel section when the run includes review-based clean-code checks -- **EXTEND**: Policy engine results formatted as evidence-compatible structures +- **NEW**: Deterministic core CI verdict derivation from typed result summaries. +- **DEFERRED TO MODULES #169**: `--evidence-dir`, `--ci-mode`, file + persistence, artifact naming, terminal rendering, and command emitters. +- **DOWNSTREAM**: validation-02 and policy owners populate compatible envelope + sections; they do not change the core schema ownership. - **NEW**: Ownership authority — this change is authoritative for evidence JSON envelope/schema; sibling governance changes may add fields only through this envelope contract. ## Capabilities ### New Capabilities -- `governance-evidence-output`: Machine-readable JSON evidence artifacts for CI/CD gates and audit systems, with per-run persistence, CI exit code modes, coverage percentages, exception status, and profile-aware verdicts. +- `governance-evidence-output`: Typed machine-readable evidence envelope and + deterministic CI verdict contract for CI/CD gates and audit consumers. ### Modified Capabilities -- `validation-evidence-graph`: Extended with evidence artifact generation via `--evidence-dir` and `--ci-mode` flags -- `policy-engine`: Results formatted as evidence-compatible structures with run_id and timestamps -- `governance-evidence-output`: Extended with a `code_quality` section that remains parallel to `validation_results` rather than introducing a new traceability layer +(none; runtime delivery and downstream producers remain separately owned) --- diff --git a/openspec/changes/governance-01-evidence-output/tasks.md b/openspec/changes/governance-01-evidence-output/tasks.md index 410c5372..7365379b 100644 --- a/openspec/changes/governance-01-evidence-output/tasks.md +++ b/openspec/changes/governance-01-evidence-output/tasks.md @@ -17,7 +17,7 @@ - [ ] 3.1 Implement minimal production code required to satisfy the new scenarios. - [ ] 3.2 Add/update contract decorators and type enforcement on public APIs. -- [ ] 3.3 Update command wiring, adapters, and models required by this change scope only. +- [ ] 3.3 Add the core envelope models and deterministic verdict derivation; do not add command wiring, emitters, or persistence. - [ ] 3.4 Keep clean-code evidence as a sibling `code_quality` section in the envelope rather than adding a new validation layer. ## 4. Validation and documentation diff --git a/openspec/changes/profile-01-config-layering/proposal.md b/openspec/changes/profile-01-config-layering/proposal.md index c39dd11a..bb8947a1 100644 --- a/openspec/changes/profile-01-config-layering/proposal.md +++ b/openspec/changes/profile-01-config-layering/proposal.md @@ -62,6 +62,6 @@ layering around validation. - **GitHub Issue**: #237 - **Issue URL**: -- **Last Synced Status**: implementation-ready / PR candidate on `feature/profile-01-config-layering-baseline` +- **Last Synced Status**: implemented in PR [#624](https://github.com/nold-ai/specfact-cli/pull/624); GitHub issue #237 closed on 2026-07-08. - **Sanitized**: false diff --git a/openspec/changes/profile-01-config-layering/tasks.md b/openspec/changes/profile-01-config-layering/tasks.md index ee8bdad4..f714573b 100644 --- a/openspec/changes/profile-01-config-layering/tasks.md +++ b/openspec/changes/profile-01-config-layering/tasks.md @@ -29,4 +29,4 @@ ## 5. Delivery - [x] 5.1 Confirm `openspec/CHANGE_ORDER.md` did not need status/dependency updates; implementation sequencing stayed unchanged. -- [ ] 5.2 Open a PR from `feature/profile-01-config-layering-baseline` to `dev` with spec/test/code/docs evidence. +- [x] 5.2 Merge PR [#624](https://github.com/nold-ai/specfact-cli/pull/624) to `dev` with spec/test/code/docs evidence; promotion to `main` remains pending release PR #642. diff --git a/openspec/changes/traceability-01-index-and-orphans/CHANGE_VALIDATION.md b/openspec/changes/traceability-01-index-and-orphans/CHANGE_VALIDATION.md index 578bb2f4..3dbf2eff 100644 --- a/openspec/changes/traceability-01-index-and-orphans/CHANGE_VALIDATION.md +++ b/openspec/changes/traceability-01-index-and-orphans/CHANGE_VALIDATION.md @@ -1,31 +1,78 @@ -# Change Validation: traceability-01-index-and-orphans +# Change Validation Report: traceability-01-index-and-orphans -- **Validated on (UTC):** 2026-02-15T21:54:26Z -- **Workflow:** /wf-validate-change (proposal-stage dry-run validation) -- **Strict command:** `openspec validate traceability-01-index-and-orphans --strict` -- **Result:** PASS +**Validation Date**: 2026-07-09 (Europe/Berlin) +**Change Proposal**: [proposal.md](./proposal.md) +**Validation Method**: Dry-run interface and dependency analysis in +`/tmp/specfact-validation-traceability-01-index-and-orphans.R4HtLY` -## Scope Summary +## Executive Summary -- **New capabilities:** traceability-index -- **Modified capabilities:** (none) -- **Declared dependencies:** requirements-02 (requirements module), architecture-01 (architecture module) -- **Proposed affected code paths:** - `modules/trace/` (new module);- `.specfact/trace/index.json` (new generated artifact) +- Breaking changes: 0 detected / 0 unresolved +- Dependent files: 3 affected (`traceability.py` and its two unit-test modules) +- Impact level: Low +- Validation result: Pass +- User decision: Extend the change to complete generic core issue #242; keep + persistence and runtime UX in modules #170. -## Breaking-Change Analysis (Dry-Run) +## Interface and Dependency Analysis -- Interface changes are proposal-level only; no production code modifications were performed in this workflow stage. -- Proposed modified capabilities are additive/extension-oriented in the current spec deltas and do not require immediate breaking migrations at proposal time. -- Backward-compatibility risk is primarily sequencing-related (dependency ordering), not signature-level breakage at this stage. +The new public contract adds `ArtifactRecord`, `ArtifactLink`, +`ArtifactEvidenceIndex`, and `build_artifact_index(...)`. It does not change an +existing public function signature. The existing +`analyze_requirement_traceability(...)` helper remains available and delegates +to the generic index. -## Dependency and Integration Review +`TraceabilityResult` remains an alias for the returned model, and the legacy +`TraceabilityFinding.requirement_id` read surface remains available as a +compatibility property. Repository search found no production callers outside +`src/specfact_cli/traceability.py`; the two existing requirement-traceability +tests were updated for the generalized finding names. -- Dependency declarations align with the 2026-02-15 architecture layer integration plan sequencing. -- Cross-change integration points are explicitly represented in proposal/spec/task artifacts. -- No additional mandatory scope expansion was required to pass strict OpenSpec validation. +No downstream runtime command or persistence dependency is introduced. The +requirements adapter is the sole required integrated input. Missing +architecture records cannot produce a finding because classification considers +only supplied normalized records. -## Validation Outcome +## Required Updates -- Required artifacts are present: `proposal.md`, `design.md`, `specs/**/*.md`, `tasks.md`. -- Strict OpenSpec validation passed. -- Change is ready for implementation-phase intake once prerequisites are satisfied. +### Critical Updates + +None. + +### Completed Updates + +- Added generic index tests for canonical ordering, all four classifications, + rebuild deltas, JSON serialization, and requirements mapping. +- Updated the traceability contract, proposal, design, task list, change order, + and validation-evidence reference documentation. +- Updated the internal wiki source and rebuilt its dependency graph. + +## Impact Assessment + +- **Code impact**: One core traceability module gains a generic, in-memory + index contract and compatibility adapter. +- **Test impact**: Existing requirements-only tests are retained; new unit + tests cover the generalized contract. +- **Documentation impact**: Public evidence-contract documentation now + distinguishes core index ownership from modules runtime delivery. +- **Release impact**: Minor (new public core contract; no removals). + +## Format Validation + +- **proposal.md format**: Pass +- **tasks.md format**: Pass; worktree, TDD, documentation, validation, and PR + tasks are explicit. +- **specs format**: Pass; each requirement uses Given/When/Then scenarios. +- **Config compliance**: Pass, subject to final repository quality gates. + +## OpenSpec Validation + +- **Status**: Pass +- **Command**: `openspec validate traceability-01-index-and-orphans --strict` +- **Issues found/fixed**: 0 / 0 + +## Scope Resolution + +Core issue #242 is complete when this reusable artifact index is merged. +Modules issue #170 remains a separate delivery follow-up for persistence, +commands, flags, rendering, and query UX; it is not a core-issue blocker. diff --git a/openspec/changes/traceability-01-index-and-orphans/TDD_EVIDENCE.md b/openspec/changes/traceability-01-index-and-orphans/TDD_EVIDENCE.md new file mode 100644 index 00000000..c95ddc90 --- /dev/null +++ b/openspec/changes/traceability-01-index-and-orphans/TDD_EVIDENCE.md @@ -0,0 +1,22 @@ +# TDD Evidence: traceability-01-index-and-orphans + +- Failing-before: `hatch run pytest tests/unit/traceability/test_evidence_traceability.py -q` — 2026-07-09 Europe/Berlin; failed during collection because `specfact_cli.traceability` did not exist. +- Passing-after: `hatch run pytest tests/unit/traceability/test_evidence_traceability.py -q` — 2026-07-09 Europe/Berlin; 3 passed. +- Failing-before generic-index expansion: `hatch run pytest tests/unit/traceability/test_artifact_index.py -q` — 2026-07-09 Europe/Berlin; failed during collection because the generic index contracts did not exist. +- Passing-after generic-index expansion: `hatch run pytest tests/unit/traceability -q` — 2026-07-09 Europe/Berlin; 7 passed after aligning the compatibility assertion to the generic finding taxonomy. + +## Quality Gate Evidence + +Verified 2026-07-10T00:41:51+0200 Europe/Berlin after Codex and CodeRabbit +remediation: + +- `hatch run pytest tests/unit/traceability tests/unit/requirements/test_context_adapter.py -q` — PASS, 18 passed. +- `hatch run type-check` — PASS, 0 errors; 1,627 pre-existing baseline warnings. +- `hatch run docs-validate` and `hatch run yaml-lint` — PASS. +- `hatch run contract-test` — PASS. +- `hatch run bandit-scan`, `hatch run semgrep-sast --json`, and + `hatch run semgrep-sast-gate` — PASS, no blocking findings. +- `hatch run specfact code review run --json --out .specfact/code-review.json --scope changed` — PASS, no findings. +- `openspec validate traceability-01-index-and-orphans --strict`, + `openspec validate governance-01-evidence-output --strict`, and + `openspec validate profile-01-config-layering --strict` — PASS. diff --git a/openspec/changes/traceability-01-index-and-orphans/design.md b/openspec/changes/traceability-01-index-and-orphans/design.md index fb4877d8..34667374 100644 --- a/openspec/changes/traceability-01-index-and-orphans/design.md +++ b/openspec/changes/traceability-01-index-and-orphans/design.md @@ -1,42 +1,73 @@ ## Context -This change implements proposal scope for `traceability-01-index-and-orphans` from the 2026-02-15 architecture-layer integration plan. It is proposal-stage only and defines implementation strategy without changing runtime code. +This change completes core issue #242 after the product focus moved from +planning and architecture authoring toward validation evidence. Core therefore +provides a reusable, deterministic artifact-evidence index; it does not +collect every domain itself or expose a runtime command surface. ## Goals / Non-Goals **Goals:** -- Define an implementation approach that stays within the proposal scope. -- Keep compatibility with existing module registry, adapter bridge, and contract-first patterns. -- Preserve offline-first behavior and deterministic CLI execution. +- Define stable, generic records, identities, links, fingerprints, and finding + classifications that downstream validators can consume. +- Integrate `requirements.inputs` as the first producer without making + architecture input mandatory. +- Preserve offline-first, deterministic behavior and public contract-first APIs. **Non-Goals:** -- No production code implementation in this stage. -- No schema-breaking changes outside declared capabilities. -- No dependency expansion beyond the proposal and plan. +- No core collector/parser for every artifact domain. +- No `.specfact` persistence, flags, grouped commands, rendering, or query UX. +- No policy-pack behavior; profile configuration only determines finding + severity defaults. ## Decisions -- Use module-oriented integration and registry lazy-loading patterns already used in SpecFact CLI. -- Keep all public APIs contract-first with `@icontract` and `@beartype`. -- Make all behavior extensions opt-in or backward-compatible by default. -- Add/modify OpenSpec deltas first so tests can be derived before implementation. +- Model every input as an `ArtifactRecord` with a stable identity, artifact + kind, location, fingerprint, and typed links. Domain owners may create these + normalized records without coupling core to their parser or storage format. +- Canonically sort records and findings before returning them. Emit a + JSON-serializable `ArtifactEvidenceIndex` so downstream governance and + validation changes share a contract rather than a private representation. +- Classify unlinked artifacts as orphans, unknown link targets as drift, + duplicate identities as ambiguity, and self-links as contradictions. +- Accept the prior in-memory index for rebuild comparison and report changed + and removed identities. Persistence belongs to modules #170. +- Map `requirements.inputs` into `ArtifactRecord` values as the first adapter. + Architecture and all other domains are optional: they participate only when + an owning change supplies normalized records. +- Keep public APIs contract-first with `@icontract` and runtime type + enforcement. Preserve the legacy requirements helper as a compatibility + adapter while routing it through the generic index. ## Risks / Trade-offs -- [Dependency ordering drift] -> Mitigation: gate implementation tasks on declared prerequisites. -- [Capability overlap with adjacent changes] -> Mitigation: keep this change scoped to listed capabilities only. -- [Documentation drift] -> Mitigation: include explicit docs update tasks in apply phase. +- [Unstable identifiers create noisy rebuilds] -> Mitigation: require stable + identities and use canonical serialized fingerprints for comparisons. +- [Optional adapters become false orphan sources] -> Mitigation: only classify + supplied records; absence of architecture records creates no architecture + finding. +- [Core/runtime ownership blurs again] -> Mitigation: specify modules #170 as + the owner of persistence and command UX in proposal, spec, docs, and tasks. ## Migration Plan -1. Implement this change only after listed dependencies are implemented. +1. Revalidate existing change artifacts and GitHub issue #242 against current + ownership; record the product decision to complete generic core scope. 2. Add tests from spec scenarios and capture failing-first evidence. -3. Implement minimal production changes needed for passing scenarios. -4. Run quality gates and then open PR to `dev`. +3. Implement the smallest generic core index and requirements adapter needed + for passing scenarios. +4. Update contracts and navigation docs, then run quality gates and open a PR + to `dev` that closes #242 on merge. -## Open Questions +## Dependency Resolution -- Dependency summary: Depends on requirements-02-module-commands and architecture-01-solution-layer. -- Whether additional cross-change sequencing constraints should be hard-blocked in `openspec/CHANGE_ORDER.md`. +- `requirements-02-module-commands` is the only required integrated input and + is shipped. +- Architecture records are optional and have no bearing on a requirements-only + result. +- `governance-01-evidence-output` and `validation-02-full-chain-engine` are + downstream consumers/producers of this contract, not prerequisites. +- Modules #170 is a paired runtime-delivery follow-up and does not block + closing core issue #242. diff --git a/openspec/changes/traceability-01-index-and-orphans/proposal.md b/openspec/changes/traceability-01-index-and-orphans/proposal.md index a10b0efb..1ab2357a 100644 --- a/openspec/changes/traceability-01-index-and-orphans/proposal.md +++ b/openspec/changes/traceability-01-index-and-orphans/proposal.md @@ -23,17 +23,30 @@ dashboard, or planning-authoring feature. ## What Changes -- **NEW**: Artifact evidence index stored as generated state under `.specfact/`. +- **NEW**: Deterministic in-memory and serializable artifact evidence index. - **NEW**: Link model for upstream inputs and downstream implementation evidence: Spec Kit, OpenSpec, backlog, ADRs, contracts, specs, code, tests, policy results, and code-review findings. - **NEW**: Orphan and drift categories for artifacts with missing, stale, ambiguous, or contradicted evidence. -- **NEW**: Incremental rebuild contract so validation runs can update affected - entries without reindexing an entire repository when possible. +- **NEW**: Incremental rebuild contract that reports changed and removed artifact + identities without owning filesystem persistence. - **NEW**: JSON export shape consumed by `validation-02-full-chain-engine` and `governance-01-evidence-output`. +## Core Boundary (2026-07-09) + +- Core owns generic artifact records, stable identities, links, fingerprints, + deterministic index/rebuild semantics, and orphan/drift/ambiguity/ + contradiction classification. +- Core consumes normalized records from adapters. `requirements.inputs` is the + first integrated adapter; OpenSpec, Spec Kit, backlog, ADR, architecture, + contract, code, test, policy, and review adapters supply records only when + their owning changes make them available. +- Modules issue #170 owns file persistence, grouped commands, flags, rendering, + and query UX. Core SHALL NOT write `.specfact/trace/index.json` or revive a + flat `specfact trace` command. + ## Capabilities ### New Capabilities @@ -54,5 +67,6 @@ dashboard, or planning-authoring feature. - **Issue URL**: - **Paired Modules Runtime Issue**: nold-ai/specfact-cli-modules#170 - **Paired Modules Scope**: traceability runtime queries and orphan detection -- **Last Synced Status**: proposed +- **Last Synced Status**: merged into `dev` by PR #641; promotion to `main` + is pending release PR #642 as of 2026-07-10. - **Sanitized**: false diff --git a/openspec/changes/traceability-01-index-and-orphans/specs/traceability-index/spec.md b/openspec/changes/traceability-01-index-and-orphans/specs/traceability-index/spec.md index 603615fa..9165f38a 100644 --- a/openspec/changes/traceability-01-index-and-orphans/specs/traceability-index/spec.md +++ b/openspec/changes/traceability-01-index-and-orphans/specs/traceability-index/spec.md @@ -1,26 +1,28 @@ ## ADDED Requirements -### Requirement: Traceability Index +### Requirement: Generic Artifact Evidence Index -The system SHALL maintain a queryable index of upstream and downstream links across requirements, architecture, specs, code, and tests. +The system SHALL build a deterministic, serializable index of normalized +artifact records and links. Core SHALL not own collection, filesystem +persistence, or command rendering. -#### Scenario: Rebuild index captures all artifact layers +#### Scenario: Requirements-only input does not require architecture -- **GIVEN** `specfact trace index --rebuild` -- **WHEN** index generation completes -- **THEN** `.specfact/trace/index.json` contains entries for requirement, architecture, spec, code, and test artifacts -- **AND** each entry contains both upstream and downstream references. +- **GIVEN** normalized requirements with downstream evidence links +- **WHEN** they are mapped into the generic index without architecture records +- **THEN** no architecture finding is emitted solely because architecture is absent. -#### Scenario: Orphan command reports missing linkage by type +#### Scenario: Index classifies deterministic evidence findings -- **GIVEN** at least one artifact has missing required references -- **WHEN** `specfact trace orphans` runs -- **THEN** output groups orphan findings by artifact type -- **AND** each finding includes artifact identifier and missing link category. +- **GIVEN** normalized records containing an unlinked artifact, a dangling link, + a duplicate identity, or a self-contradicting link +- **WHEN** the index is built +- **THEN** it emits bounded orphan, drift, ambiguity, and contradiction findings + in stable order. -#### Scenario: Matrix export supports machine and human formats +#### Scenario: Rebuild reports changed and removed identities -- **GIVEN** a built index -- **WHEN** `specfact trace matrix --format markdown|csv|json` runs -- **THEN** matrix output includes requirement-centered chain rows -- **AND** exported content is deterministic for CI diffs. +- **GIVEN** a prior index and an updated record set +- **WHEN** the core index rebuilds +- **THEN** its result identifies changed and removed artifact identities without + writing generated state. diff --git a/openspec/changes/traceability-01-index-and-orphans/tasks.md b/openspec/changes/traceability-01-index-and-orphans/tasks.md index 44add5f0..60718565 100644 --- a/openspec/changes/traceability-01-index-and-orphans/tasks.md +++ b/openspec/changes/traceability-01-index-and-orphans/tasks.md @@ -2,29 +2,29 @@ ## 1. Branch and dependency guardrails -- [ ] 1.1 Create dedicated worktree branch `feature/traceability-01-index-and-orphans` from `dev` before implementation work: `scripts/worktree.sh create feature/traceability-01-index-and-orphans`. -- [ ] 1.2 Verify prerequisite changes are implemented or explicitly accepted as parallel work. -- [ ] 1.3 Reconfirm scope against the 2026-02-15 architecture integration plan and this proposal. +- [x] 1.1 Created dedicated branch `feature/traceability-evidence-spine` from the reconciled `dev` foundation before implementation work. +- [x] 1.2 Verified requirements input contracts are implemented; optional adapters are consumed only when they supply normalized records. +- [x] 1.3 Revalidated scope on 2026-07-09: core consumes normalized artifact records; modules #170 owns persistence and runtime UX. ## 2. Spec-first and test-first preparation -- [ ] 2.1 Finalize `specs/` deltas for all listed capabilities and cross-check scenario completeness. -- [ ] 2.2 Add/update tests mapped to new and modified scenarios. -- [ ] 2.3 Run targeted tests to capture failing-first behavior and record results in `TDD_EVIDENCE.md`. +- [x] 2.1 Define generic artifact, link, index, rebuild, and finding contracts plus requirements adapter mapping. +- [x] 2.2 Add tests for stable identities, deterministic ordering, incremental rebuild, dangling links, unlinked artifacts, duplicate identities, self-contradictions, and requirements mapping. +- [x] 2.3 Run targeted tests to capture failing-first behavior and record results in `TDD_EVIDENCE.md`. ## 3. Implementation -- [ ] 3.1 Implement minimal production code required to satisfy the new scenarios. -- [ ] 3.2 Add/update contract decorators and type enforcement on public APIs. -- [ ] 3.3 Update command wiring, adapters, and models required by this change scope only. +- [x] 3.1 Implement the generic artifact evidence index and deterministic rebuild output. +- [x] 3.2 Add/update contract decorators and type enforcement on public APIs. +- [x] 3.3 Implement the complete generic core index engine and integrate `requirements.inputs`; architecture remains optional and modules #170 owns persistence/query runtime. ## 4. Validation and documentation -- [ ] 4.1 Re-run tests and quality gates until all changed scenarios pass. -- [ ] 4.2 Update user-facing docs and navigation for changed/added commands and workflows. -- [ ] 4.3 Run `openspec validate traceability-01-index-and-orphans --strict` and resolve all issues. +- [x] 4.1 Re-ran targeted tests, type, clean-code, docs, contract, and security gates; all changed scenarios pass. +- [x] 4.2 Updated the validation-evidence reference and existing reference navigation; no core command or workflow was added. +- [x] 4.3 Ran `openspec validate traceability-01-index-and-orphans --strict` successfully. ## 5. Delivery -- [ ] 5.1 Update `openspec/CHANGE_ORDER.md` status/dependency notes if implementation sequencing changed. -- [ ] 5.2 Open a PR from `feature/traceability-01-index-and-orphans` to `dev` with spec/test/code/docs evidence. +- [x] 5.1 Updated `openspec/CHANGE_ORDER.md` to record requirements as the first required adapter and all other adapters as optional. +- [x] 5.2 Updated PR [#641](https://github.com/nold-ai/specfact-cli/pull/641) from `feature/traceability-evidence-spine` to `dev` with spec/test/code/docs evidence and `Closes #242`. diff --git a/openspec/specs/backlog-adapter/spec.md b/openspec/specs/backlog-adapter/spec.md index 79c78f53..d3eb6bb0 100644 --- a/openspec/specs/backlog-adapter/spec.md +++ b/openspec/specs/backlog-adapter/spec.md @@ -91,3 +91,23 @@ The system SHALL allow custom bridge field mappings for backlog converter workfl - **WHEN** custom mapping configuration is malformed - **THEN** converter execution SHALL continue with default mapping behavior - **AND** SHALL emit warning/debug context for troubleshooting. + +### Requirement: Source-Attributed Backlog Requirement Snippets + +The system SHALL define source-attributed backlog requirement snippets that +requirements runtime adapters can normalize without provider-specific parsing in +core command handlers. + +#### Scenario: Adapter returns source fields for requirement context import + +- **GIVEN** a backlog item selected for requirement context import +- **WHEN** the requirements adapter receives source fields +- **THEN** the adapter can return title, description, acceptance-criteria text, and item identity +- **AND** normalization proceeds without provider-specific parsing in core helpers. + +#### Scenario: Missing acceptance criteria is surfaced explicitly + +- **GIVEN** a backlog item with no acceptance criteria +- **WHEN** requirements context normalization runs +- **THEN** the item is reported as incomplete input +- **AND** diagnostics include the backlog item identifier. diff --git a/openspec/specs/module-io-contract/spec.md b/openspec/specs/module-io-contract/spec.md index 0f07c161..44ad2386 100644 --- a/openspec/specs/module-io-contract/spec.md +++ b/openspec/specs/module-io-contract/spec.md @@ -131,3 +131,37 @@ The system SHALL provide a `ValidationReport` Pydantic model for structured vali - **WHEN** validation completes - **THEN** report MUST have `summary` field with counts: total_checks, passed, failed, warnings + +### Requirement: Requirements Module IO Contract + +Requirements implementations SHALL consume core requirements context adapter +helpers through the existing `ModuleIOContract` boundary. + +#### Scenario: Import operation maps backlog items to requirements + +- **GIVEN** source-attributed requirement records imported by a module +- **WHEN** `import_to_bundle` stores normalized requirements on a `ProjectBundle` +- **THEN** requirements are added under the `requirements.inputs` extension with stable IDs +- **AND** parse diagnostics remain available to the module runtime for partial failures. + +#### Scenario: Validate operation enforces profile schema + +- **GIVEN** a requirements bundle and active validation profile +- **WHEN** `validate_bundle` delegates to core requirements context validation +- **THEN** missing evidence links and weak context are reported +- **AND** validation severity respects the selected evidence strictness profile. + +#### Scenario: Requirements runtime mounts through grouped module metadata + +- **GIVEN** a paired requirements runtime package with category `requirements` +- **WHEN** module metadata is discovered by core registry validation +- **THEN** the `requirements` category is accepted with group command `requirements` +- **AND** root CLI command handlers remain owned by the paired requirements runtime. + +#### Scenario: Installed requirements runtime is discoverable through grouped metadata + +- **GIVEN** the paired requirements runtime package is installed and enabled +- **AND** the package exposes a native `requirements` command app +- **WHEN** core registry validation processes the discovered metadata +- **THEN** the runtime remains available to the module command host +- **AND** root CLI handlers and recovery UX remain owned by the paired requirements runtime. diff --git a/openspec/specs/requirements-module/spec.md b/openspec/specs/requirements-module/spec.md new file mode 100644 index 00000000..3129fcec --- /dev/null +++ b/openspec/specs/requirements-module/spec.md @@ -0,0 +1,35 @@ +# requirements-module Specification + +## Purpose + +The requirements-module specification defines the core requirements-context +adapter helpers and the module-facing validation and coverage contract. + +## 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. + +#### 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. diff --git a/pyproject.toml b/pyproject.toml index 37507f09..6daceee3 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "specfact-cli" -version = "0.50.2" +version = "0.51.1" description = "AI-bloat defense CLI for Python teams. Run deterministic code review, cleanup forecasts, and spec/contract evidence for AI-assisted and brownfield delivery." readme = "README.md" requires-python = ">=3.11" diff --git a/scripts/pre-commit-quality-checks.sh b/scripts/pre-commit-quality-checks.sh index 3ae574f3..57085c12 100755 --- a/scripts/pre-commit-quality-checks.sh +++ b/scripts/pre-commit-quality-checks.sh @@ -302,7 +302,9 @@ run_markdown_autofix_if_needed() { fail_if_markdown_has_unstaged_hunks if command -v markdownlint >/dev/null 2>&1; then if markdownlint --fix --config .markdownlint.json "${md_files[@]}"; then - git add -- "${md_files[@]}" + if ! git diff --quiet -- "${md_files[@]}"; then + git add -- "${md_files[@]}" + fi success "✅ Block 1 — Markdown auto-fix applied" else error "❌ Block 1 — Markdown auto-fix failed" @@ -310,7 +312,9 @@ run_markdown_autofix_if_needed() { fi else if npx --yes markdownlint-cli --fix --config .markdownlint.json "${md_files[@]}"; then - git add -- "${md_files[@]}" + if ! git diff --quiet -- "${md_files[@]}"; then + git add -- "${md_files[@]}" + fi success "✅ Block 1 — Markdown auto-fix applied (npx)" else error "❌ Block 1 — Markdown auto-fix failed (npx)" diff --git a/setup.py b/setup.py index 149f1a75..1bb0b8f2 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ if __name__ == "__main__": _setup = setup( name="specfact-cli", - version="0.50.2", + version="0.51.1", description=( "AI-bloat defense CLI for Python teams. Run deterministic code review, cleanup forecasts, " "and spec/contract evidence for AI-assisted and brownfield delivery." diff --git a/src/__init__.py b/src/__init__.py index eeace6c3..53dbf682 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.50.2" +__version__ = "0.51.1" diff --git a/src/specfact_cli/__init__.py b/src/specfact_cli/__init__.py index 2788b824..bcb72fdd 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.50.2" +__version__ = "0.51.1" __all__ = ["__version__"] diff --git a/src/specfact_cli/evidence.py b/src/specfact_cli/evidence.py new file mode 100644 index 00000000..e2265385 --- /dev/null +++ b/src/specfact_cli/evidence.py @@ -0,0 +1,74 @@ +"""Core machine-readable evidence envelope contracts.""" + +from __future__ import annotations + +from collections.abc import Mapping +from types import MappingProxyType +from typing import Literal + +from beartype import beartype +from icontract import ensure +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, computed_field, field_serializer, field_validator + + +@beartype +class EvidenceResultSummary(BaseModel): + """Pass, failure, and advisory totals for one evidence category.""" + + model_config = ConfigDict(frozen=True, populate_by_name=True, serialize_by_alias=True) + + pass_count: int = Field( + default=0, ge=0, validation_alias=AliasChoices("pass", "pass_count"), serialization_alias="pass" + ) + fail_count: int = Field( + default=0, ge=0, validation_alias=AliasChoices("fail", "fail_count"), serialization_alias="fail" + ) + advisory_count: int = Field( + default=0, + ge=0, + validation_alias=AliasChoices("advisory", "advisory_count"), + serialization_alias="advisory", + ) + + +@beartype +class EvidenceEnvelope(BaseModel): + """Core-only envelope; runtime emitters belong to the modules repository.""" + + model_config = ConfigDict(frozen=True) + + schema_version: Literal["1.0"] = "1.0" + profile: str = Field(..., min_length=1) + validation_results: Mapping[str, EvidenceResultSummary] = Field(default_factory=dict) + + @field_validator("validation_results", mode="after") + @classmethod + def _freeze_validation_results( + cls, results: Mapping[str, EvidenceResultSummary] + ) -> Mapping[str, EvidenceResultSummary]: + """Copy result summaries into a read-only mapping for stable verdicts.""" + return MappingProxyType({name: summary.model_copy(deep=True) for name, summary in results.items()}) + + @field_serializer("validation_results") + def _serialize_validation_results(self, results: Mapping[str, EvidenceResultSummary]) -> dict[str, dict[str, int]]: + """Emit immutable results using the standardized JSON summary keys.""" + return {name: summary.model_dump(by_alias=True) for name, summary in results.items()} + + @computed_field + @property + @ensure(lambda result: result in {"PASS", "PASS_WITH_ADVISORY", "FAIL"}) + def overall_verdict(self) -> Literal["PASS", "PASS_WITH_ADVISORY", "FAIL"]: + """Derive the immutable CI verdict from validation result summaries.""" + summaries = self.validation_results.values() + if any(summary.fail_count for summary in summaries): + return "FAIL" + if any(summary.advisory_count for summary in summaries): + return "PASS_WITH_ADVISORY" + return "PASS" + + @computed_field + @property + @ensure(lambda result: result in {0, 1}) + def ci_exit_code(self) -> int: + """Return the CI exit code derived from the immutable verdict.""" + return 1 if self.overall_verdict == "FAIL" else 0 diff --git a/src/specfact_cli/traceability.py b/src/specfact_cli/traceability.py new file mode 100644 index 00000000..d783a6fd --- /dev/null +++ b/src/specfact_cli/traceability.py @@ -0,0 +1,284 @@ +"""Generic core artifact evidence index contracts and requirements adapter.""" + +from __future__ import annotations + +import json +from collections import Counter +from collections.abc import Sequence, Set +from enum import StrEnum +from typing import Literal + +from beartype import beartype +from icontract import ensure, require +from pydantic import BaseModel, Field + +from specfact_cli.models.project import ProjectBundle +from specfact_cli.models.requirements import RequirementInput +from specfact_cli.requirements.context import ( + KNOWN_REQUIREMENT_CONTEXT_PROFILES, + STRICT_REQUIREMENT_CONTEXT_PROFILES, + RequirementContextValidationProfile, + load_requirements_from_bundle, +) + + +class ArtifactKind(StrEnum): + """Normalized kinds that adapters may contribute to the evidence index.""" + + REQUIREMENT = "requirement" + ARCHITECTURE = "architecture" + SPECIFICATION = "specification" + CODE = "code" + TEST = "test" + CONTRACT = "contract" + OPENSPEC = "openspec" + SPECKIT = "speckit" + BACKLOG = "backlog" + ADR = "adr" + POLICY = "policy" + REVIEW = "review" + VALIDATION = "validation" + + +@beartype +class ArtifactLink(BaseModel): + """Directed relationship between stable artifact identities.""" + + target: str = Field(..., min_length=1) + relation: str = Field(default="references", min_length=1) + + +@beartype +class ArtifactRecord(BaseModel): + """Adapter-neutral evidence artifact with stable identity and fingerprint.""" + + identity: str = Field(..., min_length=1) + kind: ArtifactKind + locator: str = Field(..., min_length=1) + fingerprint: str = Field(..., min_length=1) + links: list[ArtifactLink] = Field(default_factory=list) + + +@beartype +class ArtifactEvidenceIndex(BaseModel): + """Deterministic core index; callers own persistence and rendering.""" + + schema_version: Literal["1"] = "1" + artifacts: list[ArtifactRecord] = Field(default_factory=list) + + @ensure(lambda result: isinstance(result, str), "result must be JSON text") + def to_json(self) -> str: + """Return canonical JSON suitable for a modules-owned persistence layer.""" + return json.dumps(self.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + + +@beartype +class TraceabilityFinding(BaseModel): + """Bounded index finding.""" + + code: str = Field(..., min_length=1) + identity: str = Field(..., min_length=1) + severity: Literal["warning", "error"] + location: str = Field(..., min_length=1) + + @property + @ensure(lambda result: isinstance(result, str), "result must be text") + def requirement_id(self) -> str: + """Compatibility identifier for requirements-first callers.""" + return self.identity.removeprefix("requirement:") + + +@beartype +class ArtifactIndexBuildResult(BaseModel): + """Index plus deterministic classifications and incremental rebuild facts.""" + + index: ArtifactEvidenceIndex + orphans: list[TraceabilityFinding] = Field(default_factory=list) + drift: list[TraceabilityFinding] = Field(default_factory=list) + ambiguities: list[TraceabilityFinding] = Field(default_factory=list) + contradictions: list[TraceabilityFinding] = Field(default_factory=list) + changed_identities: list[str] = Field(default_factory=list) + removed_identities: list[str] = Field(default_factory=list) + + +TraceabilityResult = ArtifactIndexBuildResult + + +def _severity(profile: str) -> Literal["warning", "error"]: + return "error" if profile in STRICT_REQUIREMENT_CONTEXT_PROFILES else "warning" + + +def _finding(code: str, identity: str, severity: Literal["warning", "error"]) -> TraceabilityFinding: + return TraceabilityFinding(code=code, identity=identity, severity=severity, location=f"artifacts[{identity}]") + + +def _canonical_records(records: Sequence[ArtifactRecord]) -> tuple[list[ArtifactRecord], list[str]]: + grouped: dict[str, list[ArtifactRecord]] = {} + for record in records: + grouped.setdefault(record.identity, []).append( + record.model_copy(update={"links": sorted(record.links, key=lambda link: (link.target, link.relation))}) + ) + duplicates = sorted(identity for identity, values in grouped.items() if len(values) > 1) + canonical = [sorted(values, key=_record_signature)[0] for _, values in sorted(grouped.items())] + return canonical, duplicates + + +def _record_signature(record: ArtifactRecord) -> str: + return json.dumps(record.model_dump(mode="json"), sort_keys=True, separators=(",", ":")) + + +def _incoming_links(records: Sequence[ArtifactRecord], identities: set[str]) -> Counter[str]: + return Counter( + link.target + for record in records + for link in record.links + if link.target in identities and link.target != record.identity + ) + + +def _orphan_findings( + records: Sequence[ArtifactRecord], incoming: Counter[str], severity: Literal["warning", "error"] +) -> list[TraceabilityFinding]: + return [ + _finding("unlinked_artifact", record.identity, severity) + for record in records + if not record.links and not incoming[record.identity] + ] + + +def _classify_record_links( + record: ArtifactRecord, identities: set[str], severity: Literal["warning", "error"] +) -> list[TraceabilityFinding]: + findings: list[TraceabilityFinding] = [] + for link in record.links: + if link.target == record.identity: + findings.append(_finding("self_referential_link", record.identity, severity)) + elif link.target not in identities: + findings.append(_finding("dangling_link", record.identity, severity)) + return findings + + +def _link_findings( + records: Sequence[ArtifactRecord], identities: set[str], severity: Literal["warning", "error"] +) -> tuple[list[TraceabilityFinding], list[TraceabilityFinding]]: + findings = [finding for record in records for finding in _classify_record_links(record, identities, severity)] + return ( + [finding for finding in findings if finding.code == "dangling_link"], + [finding for finding in findings if finding.code == "self_referential_link"], + ) + + +def _rebuild_delta( + records: Sequence[ArtifactRecord], previous_index: ArtifactEvidenceIndex | None +) -> tuple[list[str], list[str]]: + prior = {record.identity: record for record in (previous_index.artifacts if previous_index else [])} + current = {record.identity: record for record in records} + changed = sorted( + identity + for identity, record in current.items() + if identity not in prior or _record_signature(record) != _record_signature(prior[identity]) + ) + return changed, sorted(set(prior) - set(current)) + + +@require( + lambda records: all(isinstance(record, ArtifactRecord) for record in records), + "records must contain ArtifactRecord values", +) +@ensure(lambda result: isinstance(result, ArtifactIndexBuildResult), "result must be an ArtifactIndexBuildResult") +@beartype +def build_artifact_index( + records: Sequence[ArtifactRecord], + *, + previous_index: ArtifactEvidenceIndex | None = None, + profile: RequirementContextValidationProfile = "startup", +) -> ArtifactIndexBuildResult: + """Build a generic deterministic index without collecting or persisting artifacts.""" + severity = _severity(profile) + canonical, duplicates = _canonical_records(records) + identities = {record.identity for record in canonical} + incoming = _incoming_links(canonical, identities) + drift, contradictions = _link_findings(canonical, identities, severity) + changed, removed = _rebuild_delta(canonical, previous_index) + return ArtifactIndexBuildResult( + index=ArtifactEvidenceIndex(artifacts=canonical), + orphans=_orphan_findings(canonical, incoming, severity), + drift=drift, + ambiguities=[_finding("duplicate_identity", identity, severity) for identity in duplicates], + contradictions=contradictions, + changed_identities=changed, + removed_identities=removed, + ) + + +@require( + lambda requirements: all(isinstance(requirement, RequirementInput) for requirement in requirements), + "requirements must contain RequirementInput values", +) +@beartype +def requirements_to_artifact_records(requirements: Sequence[RequirementInput]) -> list[ArtifactRecord]: + """Map normalized requirements into generic core artifacts without parsing sources.""" + return [ + ArtifactRecord( + identity=f"requirement:{requirement.requirement_id}", + kind=ArtifactKind.REQUIREMENT, + locator=requirement.requirement_id, + fingerprint=json.dumps(requirement.model_dump(mode="json"), sort_keys=True, separators=(",", ":")), + links=[ + ArtifactLink(target=f"{link.link_type}:{link.target}", relation=link.relation) + for link in requirement.evidence_links + ], + ) + for requirement in requirements + ] + + +def _known_target_records(requirements: Sequence[RequirementInput], known_targets: Set[str]) -> list[ArtifactRecord]: + kinds_by_target: dict[str, set[ArtifactKind]] = {} + link_kind_map = { + "architecture": ArtifactKind.ARCHITECTURE, + "spec": ArtifactKind.SPECIFICATION, + "code": ArtifactKind.CODE, + "test": ArtifactKind.TEST, + "validation": ArtifactKind.VALIDATION, + "requirement": ArtifactKind.REQUIREMENT, + } + for requirement in requirements: + for link in requirement.evidence_links: + if link.target in known_targets: + kinds_by_target.setdefault(link.target, set()).add(link_kind_map[str(link.link_type)]) + return [ + ArtifactRecord( + identity=f"{kind_prefix}:{target}", + kind=kind, + locator=target, + fingerprint=target, + ) + for target in sorted(known_targets) + for kind in sorted(kinds_by_target.get(target, {ArtifactKind.CODE}), key=lambda item: item.value) + for kind_prefix in [next(prefix for prefix, value in link_kind_map.items() if value is kind)] + ] + + +@require(lambda bundle: isinstance(bundle, ProjectBundle), "bundle must be a ProjectBundle") +@require(lambda profile: isinstance(profile, str) and bool(profile.strip()), "profile must not be empty") +@require( + lambda profile: profile in KNOWN_REQUIREMENT_CONTEXT_PROFILES, "profile must be a supported validation profile" +) +@ensure(lambda result: isinstance(result, TraceabilityResult), "result must be a TraceabilityResult") +@beartype +def analyze_requirement_traceability( + bundle: ProjectBundle, + *, + profile: RequirementContextValidationProfile, + known_targets: Set[str] | None = None, +) -> TraceabilityResult: + """Analyze requirements; link drift requires a caller-provided target universe.""" + requirements = load_requirements_from_bundle(bundle) + records = requirements_to_artifact_records(requirements) + if known_targets is not None: + records.extend(_known_target_records(requirements, known_targets)) + result = build_artifact_index(records, profile=profile) + if known_targets is None: + return result.model_copy(update={"drift": []}) + return result diff --git a/tests/unit/traceability/test_artifact_index.py b/tests/unit/traceability/test_artifact_index.py new file mode 100644 index 00000000..b179661c --- /dev/null +++ b/tests/unit/traceability/test_artifact_index.py @@ -0,0 +1,95 @@ +"""Tests for the generic core artifact evidence index.""" + +import json + +from specfact_cli.models.requirements import ( + RequirementEvidenceLink, + RequirementEvidenceLinkType, + RequirementInput, + RequirementSourceReference, + RequirementSourceType, +) +from specfact_cli.traceability import ( + ArtifactKind, + ArtifactLink, + ArtifactRecord, + build_artifact_index, + requirements_to_artifact_records, +) + + +def _record(identity: str, *, links: list[ArtifactLink] | None = None, fingerprint: str = "v1") -> ArtifactRecord: + return ArtifactRecord( + identity=identity, kind=ArtifactKind.CODE, locator=identity, fingerprint=fingerprint, links=links or [] + ) + + +def test_index_is_deterministic_and_classifies_orphans_and_drift() -> None: + result = build_artifact_index( + [ + _record("code:b", links=[ArtifactLink(target="code:a")]), + _record("code:a"), + _record("test:orphan"), + _record("code:dangling", links=[ArtifactLink(target="code:missing")]), + ] + ) + + assert [record.identity for record in result.index.artifacts] == [ + "code:a", + "code:b", + "code:dangling", + "test:orphan", + ] + assert [finding.identity for finding in result.orphans] == ["test:orphan"] + assert [finding.identity for finding in result.drift] == ["code:dangling"] + + +def test_index_canonicalizes_link_order() -> None: + links = [ArtifactLink(target="code:z"), ArtifactLink(target="code:a")] + + result = build_artifact_index([_record("code:links", links=list(reversed(links)))]) + + assert [link.target for link in result.index.artifacts[0].links] == ["code:a", "code:z"] + + +def test_index_classifies_duplicate_and_self_links() -> None: + result = build_artifact_index( + [ + _record("code:duplicate"), + _record("code:duplicate", fingerprint="v2"), + _record("code:self", links=[ArtifactLink(target="code:self")]), + ] + ) + + assert [finding.identity for finding in result.ambiguities] == ["code:duplicate"] + assert [finding.identity for finding in result.contradictions] == ["code:self"] + + +def test_rebuild_reports_changed_and_removed_identities() -> None: + before = build_artifact_index([_record("code:stable"), _record("code:removed")]).index + + result = build_artifact_index( + [_record("code:stable", fingerprint="v2"), _record("code:added")], previous_index=before + ) + + assert result.changed_identities == ["code:added", "code:stable"] + assert result.removed_identities == ["code:removed"] + assert [artifact["identity"] for artifact in json.loads(result.index.to_json())["artifacts"]] == [ + "code:added", + "code:stable", + ] + + +def test_requirements_map_to_stable_artifacts_and_links() -> None: + requirement = RequirementInput( + schema_version="1", + requirement_id="REQ-1", + title="Trace me", + sources=[RequirementSourceReference(source_type=RequirementSourceType.ISSUE, locator="https://example.test/1")], + evidence_links=[RequirementEvidenceLink(link_type=RequirementEvidenceLinkType.CODE, target="src/app.py")], + ) + + records = requirements_to_artifact_records([requirement]) + + assert records[0].identity == "requirement:REQ-1" + assert records[0].links[0].target == "code:src/app.py" diff --git a/tests/unit/traceability/test_evidence_traceability.py b/tests/unit/traceability/test_evidence_traceability.py new file mode 100644 index 00000000..a49dc3eb --- /dev/null +++ b/tests/unit/traceability/test_evidence_traceability.py @@ -0,0 +1,132 @@ +"""Contract tests for core evidence and traceability helpers.""" + +from typing import Any, cast + +import pytest +from icontract.errors import ViolationError +from pydantic import ValidationError + +from specfact_cli.evidence import EvidenceEnvelope, EvidenceResultSummary +from specfact_cli.models.plan import Product +from specfact_cli.models.project import BundleManifest, BundleVersions, ProjectBundle +from specfact_cli.models.requirements import ( + RequirementEvidenceLink, + RequirementEvidenceLinkType, + RequirementInput, + RequirementSourceReference, + RequirementSourceType, +) +from specfact_cli.requirements.context import attach_requirements_to_bundle +from specfact_cli.traceability import analyze_requirement_traceability + + +def _requirement(*, links: list[RequirementEvidenceLink]) -> RequirementInput: + return RequirementInput( + schema_version="1", + requirement_id="REQ-1", + title="Trace me", + sources=[RequirementSourceReference(source_type=RequirementSourceType.ISSUE, locator="https://example.test/1")], + evidence_links=links, + ) + + +def _bundle() -> ProjectBundle: + 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 test_requirements_only_trace_does_not_require_architecture() -> None: + bundle = attach_requirements_to_bundle( + _bundle(), + [ + _requirement( + links=[RequirementEvidenceLink(link_type=RequirementEvidenceLinkType.CODE, target="src/app.py")] + ) + ], + ) + + result = analyze_requirement_traceability(bundle, profile="startup", known_targets={"src/app.py"}) + + assert result.orphans == [] + assert result.drift == [] + + +def test_traceability_without_targets_does_not_report_link_drift() -> None: + bundle = attach_requirements_to_bundle( + _bundle(), + [ + _requirement( + links=[RequirementEvidenceLink(link_type=RequirementEvidenceLinkType.CODE, target="src/app.py")] + ) + ], + ) + + result = analyze_requirement_traceability(bundle, profile="startup") + + assert result.orphans == [] + assert result.drift == [] + + +def test_known_targets_preserve_requirement_link_kind() -> None: + bundle = attach_requirements_to_bundle( + _bundle(), + [ + _requirement( + links=[RequirementEvidenceLink(link_type=RequirementEvidenceLinkType.TEST, target="tests/test_app.py")] + ) + ], + ) + + result = analyze_requirement_traceability(bundle, profile="startup", known_targets={"tests/test_app.py"}) + + assert result.drift == [] + assert result.index.artifacts[-1].identity == "test:tests/test_app.py" + + +def test_traceability_reports_missing_and_stale_links_with_profile_severity() -> None: + first = _requirement(links=[]) + second = first.model_copy( + update={ + "requirement_id": "REQ-2", + "evidence_links": [ + RequirementEvidenceLink(link_type=RequirementEvidenceLinkType.TEST, target="tests/missing.py") + ], + } + ) + bundle = attach_requirements_to_bundle(_bundle(), [first, second]) + + result = analyze_requirement_traceability(bundle, profile="enterprise", known_targets=set()) + + assert [finding.code for finding in result.orphans] == ["unlinked_artifact"] + assert result.orphans[0].severity == "error" + assert [finding.code for finding in result.drift] == ["dangling_link"] + + +def test_traceability_rejects_unknown_profile() -> None: + with pytest.raises(ViolationError): + analyze_requirement_traceability(_bundle(), profile=cast(Any, "enterprize")) + + +def test_evidence_envelope_derives_ci_verdict_without_runtime_flags() -> None: + source_results = {"orphans": EvidenceResultSummary(pass_count=0, fail_count=1, advisory_count=0)} + envelope = EvidenceEnvelope( + profile="enterprise", + validation_results=source_results, + ) + + assert envelope.overall_verdict == "FAIL" + assert envelope.ci_exit_code == 1 + assert envelope.model_copy(update={"overall_verdict": "PASS"}).overall_verdict == "FAIL" + with pytest.raises(ValidationError): + envelope.overall_verdict = "PASS" # type: ignore[misc] + source_results["late"] = EvidenceResultSummary(fail_count=0, advisory_count=1) + with pytest.raises(TypeError): + envelope.validation_results["late"] = EvidenceResultSummary(fail_count=1) # type: ignore[index] + with pytest.raises(ValidationError): + envelope.validation_results["orphans"].fail_count = 0 + assert envelope.model_dump()["validation_results"]["orphans"] == {"pass": 0, "fail": 1, "advisory": 0}